说实话,看到"Java SpringBoot+Vue3+MyBatis 电影评论网站系统源码"这个标题,我就知道十有八九是冲着毕业设计或简历项目来的。这个组合现在几乎成了全栈入门项目的标配——后端用SpringBoot撑起业务逻辑,前端用Vue3做交互界面,ORM层交给MyBatis操作MySQL,最后配一个前后端分离的架构,面子上过得去,里子上也基本把主流技术栈的常见问题都踩了一遍。
我去年帮人从头到尾捋过一套类似的项目,从数据库建表到功能实现再一直到部署上线,前前后后改了六七版,踩了不少坑。这篇文章就按实际动手的顺序,把这个电影评论网站系统从设计到落地的全过程拆开揉碎讲清楚。适合正在做毕设、想写在简历上的Java后端开发者,也适合刚学完SpringBoot和Vue3、想做个完整项目练手的朋友,看完基本能照着把项目搭起来。
1. 项目整体设计与技术选型思路
1.1 前后端分离到底在分离什么
先说架构。很多人以为前后端分离就是把代码分成两个文件夹,前端一个、后端一个,这就是天大的误会。前后端分离的核心是职责边界和交互协议的分离,代码文件放哪只是表象。
在这个电影评论系统里,后端只负责两件事:处理业务逻辑、提供JSON接口。前端也只负责两件事:渲染页面、调用接口。整个系统通过HTTP接口通信,前端不碰数据库,后端不碰页面渲染。
具体到项目目录,后端是一个标准的SpringBoot工程,包结构按controller、service、mapper、entity四个层级划分;前端是一个Vue3工程,按views、components、api、router四个目录组织。前后端通过统一的前缀约定(比如后端所有接口都以/api开头)配合跨域配置进行通信。
之所以坚持前后端分离而不是用Thymeleaf那套服务端渲染,原因有两个。第一,前后端可以并行开发,后端定义好接口文档后,前端直接用Mock数据推进,不用等后端写完才启动,项目周期能压缩不少。第二,部署时可以前端静态资源放Nginx、后端打jar包独立跑,只要接口不跨域或者反向代理配好,后期扩展和运维都灵活得多。
1.2 技术栈选型的取舍逻辑
这套技术栈单拆开看每个都不算新,但组合在一起恰好覆盖了Java生态最常见的开发链路:
- SpringBoot: 2.7.x版本是当前兼容性最稳的,自带Tomcat、自动配置、starter机制,把过去SpringMVC那种繁琐的XML配置全部干掉,几分钟就能跑起一个Web服务。
- Vue3: 用Composition API配合
script setup语法,组件逻辑复用比Vue2的Options API舒服太多。电影列表、评论区、用户中心这些页面拆成组件后,状态管理清晰,数据响应式处理也顺手。 - MyBatis: 比JPA更贴近SQL本身,尤其是电影评论这种需要多表联查、动态条件筛选(按类型筛选、按评分排序、分页)的业务场景,手写SQL的可控性远高于自动生成的SQL。
- MySQL: 8.0以上版本在事务、性能、JSON支持上都够用,而且也是大多数公司的生产环境数据库,学这个不亏。
这套组合最舒服的地方在于踩坑资料多。不管是MyBatis的#{}和${}区别、Vue3的响应式陷阱,还是MySQL的连接配置问题,网上随便一搜就有大量现成解决方案,对于新手项目来说,能快速找到答案比什么都重要。
1.3 功能模块拆解与需求梳理
一个能拿得出手的电影评论网站,光有增删改查是不够的。我在设计功能清单时,会刻意覆盖一些常见业务场景,这样写起简历来也更有话可说。最终敲定的核心功能模块如下:
- 用户模块:注册、登录、个人信息查看与修改。登录使用JWT实现无状态认证,用户密码经过BCrypt加密存储,不保存明文。
- 电影模块:电影列表展示、按类型/关键字搜索、电影详情页。列表分页,支持按上映时间、评分高低排序。
- 评论模块:用户对电影发表评论、查看评论列表、删除自己发布的评论。评论支持分页加载。
- 评分模块:用户给电影打1到5分,系统自动计算并更新电影的平均评分和评分人数。
除此之外,还有几个容易被忽略但实际必做的环节:异常统一处理、参数合法性校验、跨域配置、数据库连接池配置。这些杂活看着不起眼,但恰恰是项目能不能稳定跑起来的关键。
2. 数据库设计与核心表结构搭建
2.1 表结构设计:三张表解决问题
电影评论系统的数据模型不复杂,三张核心表就能覆盖全部业务:用户表、电影表、评论表。但真正的细节都在字段设计上,这是新手最容易忽略的地方。
用户表(t_user)的大致结构:
CREATE TABLE `t_user` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `username` varchar(50) NOT NULL COMMENT '用户名', `password` varchar(100) NOT NULL COMMENT '密码(BCrypt加密)', `nickname` varchar(50) DEFAULT NULL COMMENT '昵称', `avatar` varchar(255) DEFAULT NULL COMMENT '头像URL', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';电影表(t_movie)的字段要稍微多几个:
CREATE TABLE `t_movie` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `title` varchar(100) NOT NULL COMMENT '电影标题', `poster` varchar(255) DEFAULT NULL COMMENT '海报URL', `director` varchar(50) DEFAULT NULL COMMENT '导演', `actors` varchar(255) DEFAULT NULL COMMENT '主演', `genre` varchar(50) DEFAULT NULL COMMENT '类型(动作/科幻/喜剧等)', `release_date` date DEFAULT NULL COMMENT '上映日期', `duration` int(11) DEFAULT NULL COMMENT '片长(分钟)', `rating` decimal(3,1) DEFAULT '0.0' COMMENT '平均评分', `rating_count` int(11) DEFAULT '0' COMMENT '评分人数', `description` text COMMENT '剧情简介', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='电影表';评论表(t_comment)是业务逻辑的核心,先看定义:
CREATE TABLE `t_comment` ( `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '主键ID', `movie_id` int(11) NOT NULL COMMENT '电影ID', `user_id` int(11) NOT NULL COMMENT '用户ID', `content` text NOT NULL COMMENT '评论内容', `score` int(11) DEFAULT NULL COMMENT '评分(1-5分)', `like_count` int(11) DEFAULT '0' COMMENT '点赞数', `create_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '发布时间', PRIMARY KEY (`id`), KEY `idx_movie_id` (`movie_id`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='评论表';在设计评论表时有个容易纠结的点:把评分字段放在评论表里,还是单独建一张评分表?我的方案是把评分直接放在评论表里,用户每发一条带评分的评论就算做一次打分。这样做的逻辑很简单:一个用户对一部电影只会有一个有效评分,想改评分就更新自己的评论,不需要再单独维护评分关系。这样查询某个电影的评分,直接对评论表的score字段做聚合就行,一步到位。
2.2 字符集与存储引擎的坑
这个坑我踩过不止一次,必须单独拿出来讲。
MySQL建表时如果你不指定字符集,会继承数据库级别的默认字符集。很多低版本MySQL默认的latin1不支持中文,到时候插入数据直接报错或者乱码。所以建表语句里务必显式写上CHARSET=utf8mb4。
utf8mb4不是utf8,它多支持了emoji和生僻字。电影简介里万一有特殊符号,用utf8就会插入失败。我这个项目所有表统一用utf8mb4,字段类型能选text就选text而不是varchar(评论内容这种不限制长度的字段用text更合适),能省掉很多不必要的commons。
存储引擎选择InnoDB基本没有悬念。它支持事务、行级锁、外键约束和崩溃恢复,对于需要保证用户评论不丢失的场景,这是唯一靠谱的选择。
2.3 初始化数据从哪里来
做毕设或演示项目,没有电影数据就是空壳子。我有两个建议渠道:
第一,从豆瓣或其他电影网站抓取一批经典电影数据,整理成SQL脚本直接导入。注意别一次性导入太多,50到100部足够撑起页面展示效果。数据字段至少要覆盖标题、海报、导演、主演、类型、上映日期、简介,不然电影详情页会很寒酸。
第二,如果不想手动整理,写一个简单的DataInitializer,在SpringBoot启动时自动向数据库植入种子数据。这种方式的好处是项目拉下来一跑就有数据,不用手动执行SQL脚本。
我实际项目中两种方式都用了:开发环境跑种子数据,生产环境用固定SQL脚本。种子数据类在项目里要加@Profile("dev")限制,防止每次启动都重复插入。
3. 后端核心模块实现与接口设计
3.1 SpringBoot分层结构与启动类
后端工程我习惯按这种包结构组织:
com.example.movie ├── MovieApplication.java # 启动类 ├── common # 通用返回结果、异常处理 │ ├── Result.java │ └── GlobalExceptionHandler.java ├── config # 跨域、拦截器、JWT配置 │ ├── CorsConfig.java │ └── JwtInterceptor.java ├── controller # 接口层 │ ├── UserController.java │ ├── MovieController.java │ └── CommentController.java ├── entity # 数据库实体 │ ├── User.java │ ├── Movie.java │ └── Comment.java ├── mapper # MyBatis数据访问层 │ ├── UserMapper.java │ ├── MovieMapper.java │ └── CommentMapper.java ├── service # 业务逻辑层 │ ├── UserService.java │ ├── MovieService.java │ └── CommentService.java └── vo # 前端展示对象 └── CommentVO.javaMovieApplication.java就是标准的三件套:@SpringBootApplication注解、main方法、SpringApplication.run()。很多教程会在启动类上扫描@MapperScan,我习惯把@MapperScan("com.example.movie.mapper")直接写在启动类上,比在每个Mapper接口上加@Mapper注解省事,也更整洁。
一个好的实践是Controller只做参数接收和结果返回,把业务逻辑全部下沉到Service层。这样Controller代码行数会缩到很短,真正的逻辑都在Service里,方便测试和复用。
3.2 MyBatis实战配置与SQL映射
集成MyBatis最省力的方式是引入mybatis-spring-boot-starter,版本一定要选对。SpringBoot 2.7.x对应2.3.x版本的starter,版本跨代太大会出现各种奇怪的兼容性问题。
在application.yml里,MyBatis相关的核心配置有四项:
mybatis: # mapper XML文件位置 mapper-locations: classpath:mapper/*.xml # 实体类别名包路径,省去每次写全限定类名 type-aliases-package: com.example.movie.entity configuration: # 下划线转驼峰:数据库字段create_time自动映射为createTime map-underscore-to-camel-case: true # 控制台打印SQL日志,开发排错神器 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl其中map-underscore-to-camel-case务必设为true,否则你的实体类要么字段名全改成下划线风格(丑和别扭),要么在XML里每个字段写resultMap映射(累赘)。开启后,数据库的create_time自动映射到实体的createTime字段,省了一大半工作量。
再说SQL映射文件。评论列表是这项目最复杂的查询,因为要联表拿用户名和用户头像。对应的Mapper接口方法定义是:
public interface CommentMapper { List<CommentVO> selectCommentsByMovieId(@Param("movieId") Integer movieId, @Param("offset") Integer offset, @Param("limit") Integer limit); int insertComment(Comment comment); int deleteComment(@Param("id") Integer id, @Param("userId") Integer userId); }CommentVO对象里可以直接多定义两个字段:username、userAvatar,它们不在评论表但SQL能查出来。XML映射:
<select id="selectCommentsByMovieId" resultType="com.example.movie.vo.CommentVO"> SELECT c.id, c.content, c.score, c.create_time, c.like_count, u.username, u.avatar AS userAvatar FROM t_comment c LEFT JOIN t_user u ON c.user_id = u.id WHERE c.movie_id = #{movieId} ORDER BY c.create_time DESC LIMIT #{offset}, #{limit} </select>这个SQL有几个细节值得说。第一,LEFT JOIN比INNER JOIN稳妥,因为理论上评论的用户一定存在,但数据库FK如果没建,可能出现孤儿数据,LEFT JOIN至少能保证评论不丢。第二,用#{offset}, #{limit}做物理分页,虽然数据量大时性能不算最优,但这个项目是学习用,足够实用和直观。
特别注意:MyBatis里#{}和${}完全是两回事。#{}走的是预编译,会生成?占位符,能防SQL注入;${}是纯字符串拼接,能实现动态表名、动态排序字段,但有注入风险。除非万不得已,一律用#{}。
3.3 JWT登录认证与拦截器设计
登录认证是全栈项目避不开的模块。这个项目选JWT而不是Session,核心原因是前后端分离下,后端服务可能是多实例部署的,Session复制方案太复杂。JWT把用户信息加密放在token本身,服务端无状态,后端拿着密钥验签就行。
依赖方面,我用jjwt库,版本选0.9.1,这是目前资料最多、用法最稳定的版本。生成token的核心逻辑:
public String generateToken(User user) { Date now = new Date(); Date expireDate = new Date(now.getTime() + 24 * 60 * 60 * 1000); // 24小时过期 return Jwts.builder() .setSubject(user.getUsername()) .claim("userId", user.getId()) .claim("nickname", user.getNickname()) .setIssuedAt(now) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }注意secretKey不能太短,至少要32个字符,不然HS256会报弱密钥异常。这是jjwt 0.9.1版本的硬性规定,不少人都栽在这。
前端拿到token后一般存localStorage,然后每次请求在Authorization头带上。后端拦截器从请求头取token并解析,解析失败则返回401。拦截器注册时要配置放行路径,注册、登录、电影列表查询这些接口不需要携带token就能访问,要是拦错了前端会一直报401死循环。
顺带的密码安全问题:用户注册时密码用BCryptPasswordEncoder加密后存库,登录时用matches方法比对。这是SpringSecurity自带的实用工具类,不用引入整个SpringSecurity就能用。绝不允许存明文密码,这种项目将来即使只是作为简历展示,也要体现安全意识。
3.4 统一返回格式与全局异常处理
很多新手项目接口返回格式五花八门,有的返回String,有的直接裸返回Map,前端对接时全靠猜。我强烈建议统一用一个Result<T>包装所有接口返回。
public class Result<T> { private Integer code; // 200成功 400业务错误 401未登录 500系统异常 private String message; private T data; // 静态方法:success(data)、error(message)... }配合一个GlobalExceptionHandler,让所有业务的RuntimeException自动转换成Result.error()返回。这样Controller里的逻辑就干净了,不用到处写try-catch。
这个模块看起来简单,但能让代码整洁度上一个档次,也让前端拿到所有响应时都能用一个统一的解析逻辑处理。
4. 前端Vue3页面实现与数据交互
4.1 Vue3工程搭建与目录规划
前端工程用Vite搭建,命令就三行:
npm create vite@latest movie-web -- --template vue cd movie-web npm install前端目录规划,我清理掉脚手架自带的杂乱结构后,最终是这样的:
movie-web ├── src │ ├── api # 接口请求封装 │ │ ├── request.js │ │ ├── auth.js │ │ ├── movie.js │ │ └── comment.js │ ├── assets │ ├── components # 公共组件 │ │ ├── MovieCard.vue │ │ └── CommentList.vue │ ├── router # 路由配置 │ │ └── index.js │ ├── stores # Pinia状态管理 │ │ └── userStore.js │ ├── views # 页面组件 │ │ ├── Home.vue │ │ ├── MovieDetail.vue │ │ ├── Login.vue │ │ └── Profile.vue │ ├── App.vue │ └── main.js4.2 Axios封装与认证拦截
Axios请求封装是前端能顺畅开发的基础。我的做法是创建一个request.js,统一配置baseURL并加请求拦截器和响应拦截器。
import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) // 请求拦截器:自动携带token request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = token } return config }) // 响应拦截器:统一处理后端返回 request.interceptors.response.use( response => { const res = response.data if (res.code === 200) { return res.data } else if (res.code === 401) { // 登录过期,跳回登录页 localStorage.removeItem('token') window.location.href = '/login' return Promise.reject(new Error('未登录')) } else { ElMessage.error(res.message) return Promise.reject(new Error(res.message)) } }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } ) export default request这里有个值得注意的细节:baseURL设为/api,如果前端是Vite开发环境,需要在vite.config.js里配置代理,把请求转发到后端的http://localhost:8080。如果前端最终部署到Nginx,也要用location /api反向代理到后端服务。这个跨域问题不解决,前端会一直报CORS错误。
4.3 电影详情页和评论提交交互
电影详情页是整个系统交互最丰富的页面,包含电影信息展示、平均评分展示、历史评论列表、当前用户评分评论框四个核心区块。
评论提交的表单我用的Element Plus组件,评分组件el-rate绑定评论分数,评论内容用el-input多行输入。提交前做两层校验:前端Button的disabled属性控制用户没登录或分数为空时不能提交;后端Controller再用@NotBlank等注解兜底校验。双保险在前后端分离项目里几乎是必须的——前端校验只为了用户体验,后端校验才真正保护数据安全。
评论列表用CommentList.vue组件抽出来,接收movieId属性,负责分页加载评论数据。这里我用了Vue3的Composition API的ref和onMounted来管理加载逻辑。对于第一次写这种交互的人来说,可能会遇到一个Vue3特有的坑:reactive对象深拷贝时响应式丢失。用ref包数组基本能绕开这个坑,简单数据一律ref没毛病。
4.4 路由守卫与用户状态管理
前端路由分为公开页面和需要登录才能访问的页面。我的处理方式是:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })/movie/:id详情页是所有用户都能访问的,/profile个人中心需要登录。requiresAuth直接写在路由meta里即可,清晰直观。
用户状态管理我用Pinia,计数器式的store就够:维护一个userInfo对象,初始从localStorage恢复,登录成功时更新并持久化。Vue3的官方推荐状态库就是Pinia,相比Vuex写起来少了一半样板代码。
4.5 后端单元测试怎么让接口验证更顺手
接口开发完,验证逻辑对不对。我通常会做两种验证:
第一,用浏览器的Network面板手动调接口确认返回。这在开发阶段最常用,配合后端控制台打印SQL,能快速定位是SQL问题还是参数问题。
第二,为Service层写单元测试,用spring-boot-starter-test加上H2内存数据库跑用例。测试覆盖评论发布、评分更新这类核心业务。这个动作虽然会多花点时间,但能在简历上大方写"项目包含单元测试",这在同龄候选人里算加分项。
5. 完整部署与上线操作流程
5.1 从零到一的全流程演示
下面是最省心的操作顺序,建议按这个步骤一步步来:
- 在MySQL里执行建库建表脚本,确认表结构和字段正确。
- 修改后端
application.yml的数据库账号密码,确认能连上库。 - 启动后端
MovieApplication,观察控制台日志没有报错。 - 用
curl http://localhost:8080/api/movie/list?page=1验证接口通不通。 - 启动前端
npm run dev,浏览器打开http://localhost:5173。 - 注册一个新用户,登录后尝试发表评论。
- 检查数据库
t_comment表是否新增了记录,评分是否更新到t_movie表。 - 本地验证通过后,构建部署包:
mvn clean package -DskipTests打包后端jar,npm run build打包前端静态文件。 - 部署到服务器:jar用
java -jar启动,前端静态文件传Nginx的html目录并配置反向代理。
5.2 部署过程中必备的Nginx配置参考
如果你的服务器上装了Nginx,下面这个配置片段可以直接参考:
server { listen 80; server_name your-domain.com; root /var/www/movie-web/dist; index index.html; # 前端路由history模式回退 location / { try_files $uri $uri/ /index.html; } # 后端接口反向代理 location /api/ { proxy_pass http://127.0.0.1:8080; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }两个关键点:try_files是前端history路由的标配,刷新页面时不会404;proxy_pass结尾的/务必保留,它表示把/api前缀掰掉后再转发给后端。如果漏了/,后端接口路径就会变成/api/list而不是/list,直接404。
5.3 Maven与前端构建的常见坑
后端打包,强烈建议加-DskipTests跳过测试阶段,否则本地如果有不确定的测试用例会直接卡住构建。
前端构建最大的坑是Node版本和依赖版本不匹配,Vite 5要求Node 18以上,老版本Node会直接报错。构建产物在dist目录,里面是纯静态文件,扔给任意Web服务器就能跑。
6. 项目扩展与性能优化进阶建议
6.1 部署一台Nginx的缓存配置参考
基础版本跑通后,有几个低成本高收益的优化思路很值得做。
缓存优化:电影列表和详情这类读多写少的数据,后端可以用Spring Cache配合Redis做缓存,设置合理的过期时间。请求到达时先查缓存,缓存没有才走数据库。评论新增时主动淘汰对应电影的缓存,保证数据一致性。
索引优化:评论表已经建了movie_id索引,但如果用户量多大后经常按用户查他的评论记录,user_id的索引也建议建上。对于大表的排序分页查询,可以用覆盖索引减少回表。
全文搜索:当前的LIKE '%关键字%'写法在数据量大时必定慢,MySQL的全文索引或引入Elasticsearch都可以解决。前者实现简单,后者更专业。这个优化点写到简历的"项目亮点"是很扎实的一条。
6.2 从单机到微服务不必过度设计
诚实说,这个体量的项目上微服务纯属炫技。业务边界只有一个聚合根,服务拆了反而增加通信成本和运维复杂度。但如果是为了面试聊架构演进,你可以把潜在拆分点列出来:用户认证服务、电影信息服务、评论服务。说明清楚什么规模该拆、拆之后面临的分布式事务问题怎么处理,这比真拆一个微服务工程能聊的东西更多。
6.3 给毕业设计交作业的加分细节
最后补充几个能让你在答辩或面试时多聊几句的细节:
- 在
README里写清楚环境要求、启动步骤、接口文档和项目结构说明,这是负责任的项目该有的样子。 - 后端写几个关键接口的单元测试,面试官问"你这项目怎么保证质量"的时候,你就有具体内容可以讲。
- 统一定义
Result全局返回格式,这比Map<String, Object>看起来专业很多。 - 密码加密存储 + 统一鉴权 + 接口参数校验,这三点能直接体现工程素养,答辩或面试的时候有得聊。
我个人的实际体会是:这类项目做出来不难,难的是每个细节都能说出"为什么这么做"以及"换一种方案的差别是什么"。照着本文把项目搭起来只是第一步,真正有价值的是在调试过程中把SpringBoot的启动流程、MyBatis的代理机制、Vue3的响应式原理这些底层概念弄明白。这样面试官深挖技术栈的时候,你才不会只停留在"会用"的层面。
最后再分享一个小技巧:所有代码写完后,把项目丢到GitHub上,并开启GitHub Actions自动构建,每次push都能自动跑测试和打包。这一个小小的习惯,会让你的项目完整度直接上一个台阶,面试官看到你懂CI/CD,兴致会明显不一样。