ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

SpringBoot+Vue+MyBatis电影评论网站管理系统开发实战

SpringBoot+Vue+MyBatis电影评论网站管理系统开发实战 先把结论放在前面这套基于SpringBootVueMyBatisMySQL的电影评论网站管理系统我从建表到上线用了差不多三周中间踩得最多的坑不是写业务代码而是MySQL的排序、MyBatis动态SQL的映射、Vue路由和前后端联调这些细节。如果你正准备用这套技术栈做毕业设计、简历项目或者单纯想搞一个带评论功能的完整系统这篇内容可以直接给你一个可复用的落地思路。项目本身是前后端分离结构后端用SpringBoot提供REST接口MyBatis管数据库操作MySQL存数据前端用Vue做页面和路由源码里的核心模块包括用户登录、电影信息管理、评论发布与审核、评分统计、后台管理这些。下面我把几个最容易出现问题的地方展开说不贴完整源码但结构和关键代码都会给到。1. 这个电影评论网站项目到底解决了什么问题很多朋友看到“电影评论网站”第一反应是“这不就是一张表几个增删改查接口吗”。真上手做就会发现电影、用户、评论这三类数据一旦开始关联复杂度马上上来。这个项目不是为了造一个豆瓣的替代品而是把“用户看完一部电影之后发表评分和评论管理员审核内容其他用户按热度或评分浏览电影”这条完整链路跑通。它的核心价值在于一个普通人能够注册登录到电影详情页看到剧情简介和预告片发表自己的评分和评论然后管理端可以对评论进行审核和统计。这里最见功夫的是评论的树形结构设计、评分与电影列表的聚合逻辑、以及按热度排序的算法取舍。1.1 从需求到功能拆解我最初列的功能清单比较杂真正落地时按照用户端和管理端两条线重新整理了一遍这样后端接口和前端页面才不会臃肿。整个系统的功能模块大致如下模块用户端功能管理端功能用户模块注册、登录、查看个人评论记录用户禁用/启用、角色管理电影模块电影列表、搜索、类型筛选、详情页电影新增、修改、上架下架评论模块发表评论、回复评论、点赞评论审核、置顶、删除统计模块电影平均分、评论数、热度榜评论数量趋势、高分电影排行这个拆法最大的好处是前后端接口可以按“功能域”划分比如/api/movie/**、/api/comment/**而不会出现一个controller里塞了几百行的情况。实际开发时我习惯先把表结构定义好再去写接口文档最后动前端页面顺序乱了很容易返工。1.2 为什么技术栈选SpringBootVue这套组合这个组合现在已经是JavaWeb项目里的国民配置了。SpringBoot的优势在于自动配置确实省心内嵌Tomcat后打成一个jar包就能跑不需要单独部署容器。MyBatis则让我在写多表关联和动态条件查询时心里有底因为SQL都掌握在自己手里而不是被ORM框架隐式生成的查询绑架。MySQL作为存储层对中小规模项目来说足够稳定部署和备份都很简单网上资料也多遇到问题基本搜得到答案。Vue这边我用的是Vue3配合Vue Router和Pinia构建工具用的Vite开发体验比Vue2时代的webpack明显轻快。选这套还有一个现实原因招聘市场上这类职位最多拿它做简历项目和面试官聊技术点的时候不容易冷场。2. MySQL表结构设计与MyBatis映射中的关键决策这个系统的大部分难点其实都在表结构设计阶段而不是后端业务代码。我的经验是先花半天时间把表关系理清楚后面写代码的速度会快很多。电影评论网站最核心的就是用户表、电影表、评论表这三张表再加上一些辅助表比如电影类型表、点赞记录表。我们逐一来看。2.1 电影、用户、评论三张核心表怎么设计用户表没什么特别但要注意密码不要明文存我用的是Spring Security自带的BCryptPasswordEncoder加密数据库里存的是哈希串。电影表是我一开始设计得最草率的地方最开始想当然地把评分、评论数直接做成查询时现算结果列表页一多就卡后来才改成冗余字段。建议电影表至少包含这些字段CREATE TABLE movie ( id bigint NOT NULL AUTO_INCREMENT, title varchar(128) NOT NULL COMMENT 电影名称, director varchar(64) DEFAULT COMMENT 导演, actors varchar(255) DEFAULT COMMENT 主演, type varchar(64) DEFAULT COMMENT 类型多个用逗号分隔, release_year int DEFAULT NULL COMMENT 上映年份, poster varchar(255) DEFAULT COMMENT 海报地址, video_url varchar(255) DEFAULT COMMENT 预告片地址, score decimal(3,1) NOT NULL DEFAULT 0.0 COMMENT 冗余平均分, comment_count int NOT NULL DEFAULT 0 COMMENT 冗余评论数, create_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;评论表要支持楼中楼回复所以必须有parent_id字段。状态字段status我用来表示待审核、已通过、已拒绝三种情况前台只展示status1的评论。另外需要一个rating字段保存用户给电影打的分数范围0到10。内容字段content一定要用utf8mb4不然用户评论里带个emoji表情插入数据库就会报错或变成问号。CREATE TABLE comment ( id bigint NOT NULL AUTO_INCREMENT, user_id bigint NOT NULL, movie_id bigint NOT NULL, parent_id bigint DEFAULT NULL COMMENT 父评论id0为顶级评论, content text NOT NULL, rating decimal(2,1) DEFAULT NULL COMMENT 评分0-10, status tinyint NOT NULL DEFAULT 0 COMMENT 0待审核 1通过 2拒绝, create_time datetime DEFAULT NULL, PRIMARY KEY (id), KEY idx_movie_status (movie_id, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;2.2 用MyBatis的XML写动态SQL避免堆砌Java判断电影列表页通常会有很多筛选条件比如关键字、类型、上映年份、最低评分。如果我用注解SQL一旦条件变多就只能在Java代码里一个个拼接字符串很容易出错。MyBatis的XML动态SQL就是为这个场景准备的。比如电影列表这个查询我写在Mapper XML里大概是这样select idlistMovies resultTypecom.example.entity.Movie SELECT * FROM movie where if testkeyword ! null and keyword ! AND (title LIKE CONCAT(%, #{keyword}, %) OR director LIKE CONCAT(%, #{keyword}, %)) /if if testtype ! null and type ! AND FIND_IN_SET(#{type}, type) /if if testminScore ! null AND score gt; #{minScore} /if /where ORDER BY release_year DESC, score DESC /select用where标签的好处是它会自动处理第一个条件前面的AND你不需要在Java代码里判断是不是第一个条件也就彻底摆脱了“拼接SQL时多一个WHERE或者多一个AND”的噩梦。这里要注意type字段我用的是逗号分隔的多类型字符串比如“动作,科幻”筛选某个类型时用FIND_IN_SET匹配这在数据量不大时非常方便。如果数据量上来了更正规的做法是拆一张电影-类型关联表但对这个项目的体量来说字符串存储完全够用。2.3 一个容易被忽略的字段类型与TypeHandler问题如果说这个项目里有什么地方让我排查了很久那一定是日期和类型的映射。早期我用MySQL的datetime字段配合Java的java.util.Date后来统一改成LocalDateTimeMyBatis在3.5以上版本其实已经支持了但如果你用的还是老版本或者某些老驱动运行时可能会报No EnumTypeHandler found之类的错误。还有一种常见情况是数据库字段是JSON字符串Java实体里想直接映射成List这时候就需要自定义TypeHandler。我简单的做法是这样MappedTypes(List.class) public class StringListTypeHandler extends BaseTypeHandlerListString { Override public void setNonNullParameter(PreparedStatement ps, int i, ListString parameter, JdbcType jdbcType) throws SQLException { ps.setString(i, String.join(,, parameter)); } Override public ListString getNullableResult(ResultSet rs, String columnName) throws SQLException { String value rs.getString(columnName); return value null ? new ArrayList() : Arrays.asList(value.split(,)); } // 省略getNullableResult的其他重载方法 }然后在Mapper XML的结果映射里指定typeHandlercom.example.handler.StringListTypeHandler即可。这个设计让我在处理电影类型、评论标签时非常舒服虽然没有复杂到值得吹嘘但对于新手来说遇到“字段类型对不上”的报错时至少知道有这条路可以走。3. SpringBoot后端接口从登录鉴权到评论审核的完整链路后端这部分我只挑三条核心链路说登录与接口权限控制、电影列表与评分的聚合查询、评论发布与审核状态机。这三处覆盖了一个业务系统最典型的“认证-查询-状态流转”问题。3.1 用户登录与接口权限控制很多小项目用Session保存登录状态但前后端分离后我更倾向于用JWT。原因很简单前端部署在一台服务器后端接口可能在另一台Session在跨域和负载均衡场景下很麻烦而JWT是无状态的后端不需要存会话记录。我的实现核心是一个拦截器public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (request.getMethod().equals(OPTIONS)) { return true; } String token request.getHeader(Authorization); if (!StringUtils.hasText(token) || !JwtUtil.validate(token)) { response.setStatus(HttpStatus.UNAUTHORIZED.value()); return false; } return true; } }在WebMvcConfigurer里我通过addInterceptor注册这个拦截器并排除登录、注册、电影列表这些无需鉴权的接口。注意我并没有引入Spring Security因为对这个项目来说一个拦截器已经够用Spring Security虽然功能强大但配置门槛和概念学习成本反而会拖慢进度。如果你想在此基础上扩展权限角色比如区分普通用户和管理员可以在JWT里加入role字段拦截器里再判断一下即可。3.2 电影列表与评分的聚合查询这里有一条血泪教训最开始我把平均分和评论数都在SQL里即时聚合类似这样SELECT m.*, IFNULL(ROUND(AVG(c.rating), 1), 0) AS avg_score, COUNT(c.id) AS comment_count FROM movie m LEFT JOIN comment c ON c.movie_id m.id AND c.status 1 GROUP BY m.id看起来没问题但一旦电影多、评论多列表页每条记录都要扫一遍评论表性能会很差。而且GROUP BY m.*这种写法在MySQL 5.7以上默认开启ONLY_FULL_GROUP_BY时还会直接报错。后来我改成在movie表上冗余了score和comment_count字段写一个Service方法专门负责在评论审核通过、删除时重新计算评分并更新电影表。这样列表页的查询就变成单表SELECT * FROM movie ORDER BY score DESC性能提升非常明显。这也是我在项目里最想劝你早点做的一个决定冗余字段不是设计缺陷而是一种常见性能优化手段。3.3 评论发布与审核状态机评论业务要注意“状态机”这个概念。用户提交评论后状态是待审核管理员可以选择通过或拒绝。如果在“待审核”状态下用户还想修改评论我不会让他改因为改动会导致审核内容不一致所以状态流转只能是“待审核-已通过”或“待审核-已拒绝”通过后允许用户删除但不允许编辑。这个业务规则在代码里写清楚就不容易出逻辑漏洞。审核通过时我调用一个带Transactional的方法Transactional(rollbackFor Exception.class) public void approveComment(Long commentId) { // 1. 更新评论状态 commentMapper.updateStatus(commentId, 1); // 2. 重新计算该电影的平均分和评论数 movieMapper.refreshScore(commentId); }在refreshScore里执行一条更新子查询把该电影的平均分和评论数重新算出来。这里必须加事务因为评论状态和电影统计字段的更新必须要么都成功要么都失败。还有个小细节前端在用户点击“发布评论”后我会让按钮进入loading状态并且15秒内不可重复点击避免用户手抖发两条相同评论。后端则提供一个最近评论时间判断比如同一用户对同一电影60秒内只能评论一次。4. Vue前端从零搭起来路由、状态管理与接口联调后端接口写完后前端部分我用了大概一周时间。Vue3的组件化开发对这类管理系统非常友好我先把通用布局搭好然后一款一款填充页面。这个阶段最核心的是三个点路由配置、Axios封装和视频播放兼容。4.1 Vite创建Vue3项目并配置路由创建项目我用的是Vite官方脚手架npm create vitelatest movie-web -- --template vue cd movie-web npm install npm install vue-router4 pinia axiosVite比Vue CLI快很多启动项目后热更新几乎秒级。路由配置上我采用了经典的页面结构import { createRouter, createWebHistory } from vue-router const routes [ { path: /, name: Home, component: () import(../views/Home.vue) }, { path: /movie/:id, name: MovieDetail, component: () import(../views/MovieDetail.vue) }, { path: /login, name: Login, component: () import(../views/Login.vue) }, { path: /admin, name: Admin, component: () import(../views/Admin.vue), meta: { requiresAuth: true } } ] const router createRouter({ history: createWebHistory(), routes }) router.beforeEach((to) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { return /login } }) export default router电影详情页的参数通过route.params.id拿到然后用它去调用后端/api/movie/detail?idxx接口。使用createWebHistory会得到更干净的URL但后面部署到Nginx时需要额外配置否则刷新页面会404这一点我放到最后一部分单独说。4.2 用Axios统一处理请求和登录态如果每个页面单独写fetch或axios登录过期、接口报错这些逻辑会在每个页面重复一遍。我在src/utils/request.js里封装了一个axios实例拦截器做统一处理import axios from axios import router from ../router const http axios.create({ baseURL: /api, timeout: 10000 }) http.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization token } return config }) http.interceptors.response.use(response response.data, error { if (error.response?.status 401) { localStorage.removeItem(token) router.push(/login) } return Promise.reject(error) }) export default http这样所有页面只要import http然后调用http.get(/movie/list)即可不需要关心token携带和401跳转的问题。状态管理我用Pinia存用户基本信息比如头像、用户名、是否为管理员。用户登录成功后把token存localStorage用户信息存Pinia刷新页面时再从后端拉一次用户信息保证状态不丢失。4.3 前端播放电影预告片m3u8的踩坑记录电影网站基本都要放预告片。我手里的预告片资源不少是m3u8格式这种流媒体格式在Chrome里不能直接video播放必须借助hls.js库。最开始我直接用video srcxxx.m3u8结果只有Safari能放其他浏览器全部黑屏。后来加了一个封装import Hls from hls.js export function playM3u8(videoElement, url) { if (Hls.isSupported()) { const hls new Hls() hls.loadSource(url) hls.attachMedia(videoElement) return hls } else if (videoElement.canPlayType(application/vnd.apple.mpegurl)) { videoElement.src url } }这个封装解决了跨浏览器兼容问题。需要注意的一点是m3u8文件分片如果后端接口地址需要带token鉴权hls.js默认的加载方式是直接请求遇到需要带Header的场景还要重写hls.config.loader。不过我这个项目的预告片是公开静态地址所以没有做复杂鉴权。5. 排序、评分计算与简单推荐看似简单实则容易翻车前面功能都能跑通排序和推荐才是最考验设计能力的地方。尤其是“热门电影榜”这个功能如果直接按平均分倒序排列会出现一个很搞笑的结果某部冷门片只有一个人打分给了10分它反而排到所有大片前面。这不是算法高级不高级的问题而是基础排序逻辑就没想清楚。5.1 今日热榜的SQL排序为什么不能直接ORDER BY score按平均分排序是用户最常见的直观需求但对评论数很少的电影不公平。我用了一种非常经典的贝叶斯平均思想来平滑评分。公式长这样平滑评分 (电影平均分 * 电影评论数 全局平均分 * C) / (电影评论数 C)其中C是一个常数可以理解为“每个电影至少要有的评论数权重”我设置的C是5。全局平均分可以是所有电影的平均评分。这样一部只有1个10分的电影平滑后的分成就会被拉到接近全局平均水平不会虚高。SQL写起来稍微有点绕SELECT *, ((score * comment_count (SELECT AVG(score) FROM movie) * 5) / (comment_count 5)) AS smoothed_score FROM movie ORDER BY smoothed_score DESC这个方案在数据量不大的情况下运行得不错。当然如果做真正的推荐系统用离线计算把平滑后的分数提前算好存字段会更合理别在SQL里实时算否则索引也用不上。5.2 平均评分用MySQL的AVG还是用Java算很多新人会纠结统计平均分到底用SQL的AVG还是查出来在Java里算。我的建议是对于单条电影详情页完全可以用SQL的AVG因为数据量小。对于列表页不要用AVG实时聚合而是用我之前说的冗余字段。具体执行时我在MovieMapper里写了一个更新方法UPDATE movie m SET score ( SELECT ROUND(AVG(c.rating), 1) FROM comment c WHERE c.movie_id m.id AND c.status 1 ), comment_count ( SELECT COUNT(*) FROM comment c WHERE c.movie_id m.id AND c.status 1 ) WHERE m.id #{movieId}这个方法在评论审核通过后被调用注意status1这个条件不能丢不然未通过审核的评论也会把评分拉进去用户看到的结果就乱了。用这种方法列表页查询永远只查movie表不需要关联评论表性能上有指数级的提升。5.3 基于评论标签做粗粒度推荐的实现思路完整的协同过滤推荐在这个项目里属于超纲但我用了一个“评论标签”的思路做了粗粒度推荐用户在发表评论时可以打标签比如“剧情紧凑”“特效炸裂”“演技在线”这些标签以逗号分隔存在评论表里。用户看完一部电影后该用户的历史标签就形成了一个偏好向量。推荐时做三步查询当前用户最近10条已通过评论里的标签统计出现次数最高的前3个标签把这些标签映射到电影类型上比如“剧情紧凑”偏向剧情片“视听震撼”偏向科幻/动作片搜索这些类型下评分最高的、且用户还没评论过的电影返回给前端“猜你喜欢”栏目。这里用到的SQL依然很简单比如根据标签前缀匹配电影类型SELECT * FROM movie WHERE type LIKE CONCAT(%, #{preferredType}, %) AND id NOT IN (SELECT movie_id FROM comment WHERE user_id #{userId}) ORDER BY score DESC LIMIT 10推荐效果当然不能和搜索引擎比但作为个人项目展示思路和代码实现已经够了。面试聊到这个功能时你至少能说清楚“我用了基于标签的朴素推荐如果要做更细粒度可以改成协同过滤”这样的演进思路。6. 部署到服务器过程中我遇到的实际问题所有代码在本机跑通不代表部署顺利。这个项目在部署阶段我遇到三个比较典型的问题SpringBoot版本和JDK版本不匹配、MySQL连接串配置错误、Vue的history路由在Nginx下需要额外处理。每一个都花了我不少时间排查。6.1 SpringBoot版本过高导致的Jackson兼容问题我在本机用的是SpringBoot 3.2版本配套JDK17运行很顺畅。但到了服务器上一看服务器只装了JDK8SpringBoot 3.x根本起不来。SpringBoot 3.0开始强制要求JDK17并且包名从javax变成了jakarta很多老插件和依赖都要跟着改。如果服务器环境不能用JDK17最稳妥的做法是回退到SpringBoot 2.7.x。我当时在这个问题上反复编译报错最后把项目整体回退到2.7.18才消停。除了版本Jackson序列化LocalDateTime的问题也很常见——默认输出格式是一长串数组前端解析特别别扭我在application.yml里加了如下配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai这样前端拿到的日期就是正常人能看懂的时间字符串。6.2 MySQL的SSL连接错误和时区问题把后端jar包部署好后接口一报错就是SSL connection error或者Public Key Retrieval is not allowed。这通常是因为MySQL 8默认启用SSL和caching_sha2_password认证而应用的数据库连接串没有显式关闭SSL。我的jdbc连接串最终是这样的jdbc:mysql://localhost:3306/movie_db?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrueuseSSLfalse必须要有allowPublicKeyRetrievaltrue是解决RSA公钥检索问题serverTimezoneAsia/Shanghai则是为了防止时间差8小时。这里顺带提醒一句MySQL 8的驱动类名是com.mysql.cj.jdbc.Driver不再是老的com.mysql.jdbc.Driver如果配置里写错了会直接报ClassNotFound。6.3 前端打包后放进SpringBoot静态资源的坑如果你像我一样一开始不想装Nginx可以把Vue构建出来的dist目录拷贝到SpringBoot的src/main/resources/static下后端启动后直接访问根路径。但这会遇到一个大坑Vue Router如果用了createWebHistory模式访问根路径没问题但只要在二级路由里刷新页面比如直接访问/movie/12后端会返回404因为后端没有这个路径对应的Controller。解决办法有两个一个是在SpringBoot里加一个Controller把非/api开头的路径都转发到index.htmlController public class PageForwardController { RequestMapping(value /{path:[^\\.]*}) public String forward() { return forward:/index.html; } }另一个是改用createWebHashHistoryURL会变成/#/movie/12刷新时不会请求后端。但URL美观度差一些。我最终选择了独立部署Nginx因为Nginx还能顺便做反向代理。核心配置简化如下server { listen 80; server_name your-domain.com; location / { root /opt/movie-web/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080/api/; } }try_files $uri $uri/ /index.html这行就是解决history路由刷新404的关键。前端请求/api时通过Nginx转发到后端也顺带解决了跨域。如果不用Nginx开发环境则需要在Vite的vite.config.js里配置/api代理到http://localhost:8080否则前后端联调时浏览器会因为跨域拦截。整个项目做下来我最大的体会是评论系统绝不是一张表加几个增删改查接口那么简单。数据冗余、状态流转、排序算法、路由刷新这些细节才是决定一个项目能不能真正跑起来的地方。如果你也在做同类系统我建议先把数据库表设计打到最稳再写后端最后才碰前端每走一步都确认数据是通的这样能省下大把联调时间。最后分享一个小技巧所有建表SQL和初始化数据脚本一定要放进项目源码的sql目录里因为你在换电脑、换服务器、给同学演示时所遇到的第一个问题通常都是“库表忘了跑脚本”。把这步做好别人拿过源码就能一键跑起来体验会好很多。
返回列表