做电影订票及评论网站这个项目前,我其实犹豫了很久。市面上的课程项目要么是单表增删改查的后台管理系统,要么是各种云服务拼凑出来的 Demo,真正把“用户认证、影票库存、订单事务、评论互动”串在一起的完整案例不多。这个系统之所以值得拿出来写一篇长文,是因为它覆盖了后端开发最典型的几个难点:并发扣库存、订单与座位的一致性、JWT 鉴权、前后端分离下的跨域联调,以及前端的复杂交互状态管理。你把它跑通了,等于把 Java 生态里最常见的那套东西亲手过了一遍。
下面我就按自己实际搭这套 SpringBoot + Vue3 + MyBatis + MySQL 项目的顺序,把数据库设计、后端接口、前端页面、联调部署完整拆开讲一遍。
1. 需求边界:这个电影订票网站要解决哪些核心业务问题
很多人在拿到类似系统源码时,第一反应是“先跑起来再说”。这没错,但如果你想真正复用这套代码,甚至能在简历上清晰地描述它,第一步应该把业务边界想清楚。需求边界不是功能清单那么简单,它决定了你会建几张表、写几个接口、代码怎么分层。
1.1 用户角色与核心业务闭环
电影订票及评论网站至少有三类角色:游客、注册用户、管理员。游客只能浏览电影列表、查看电影详情和场次;注册用户可以选座下单、查看自己的订单、对看过的电影发表评论;管理员负责维护电影信息、排片场次,以及处理一些异常订单。这个划分不是拍脑袋想出来的,它直接对应权限控制的实现范围。
有了角色之后,核心的业务闭环是这样的:
浏览电影 → 查看场次 → 选择座位 → 创建订单 → 模拟支付成功 → 观影后发表评论
把这个闭环走通,你的系统就已经具备了一个真实电商网站的最小骨架。注意这里面“订票”和“评论”是两个独立但互相牵连的模块,评论通常要求用户买过票才能发,这就引出了数据关联的问题:评论表需要和订单表产生关系,而不是随便哪个人都能在评论区灌水。
1.2 功能模块的边界划分
我把项目分成五个模块:用户模块、电影模块、场次与座位模块、订单模块、评论模块。
用户模块负责注册、登录、个人信息维护;电影模块负责电影列表分页查询、详情展示;场次与座位模块是整个系统的核心,它管理某部电影在某个影厅的播放时间、票价、座位状态;订单模块负责下单流程,包括座位锁定、库存扣减、订单状态流转;评论模块负责发表评论、评论列表展示、电影评分的聚合计算。
这里有一个新手容易犯的错误:分不清“场次”和“电影”的关系。曾经见过有人把电影表里直接放一个“播放时间”字段,这会在实际业务里炸得很惨。一部电影多天多场次播放,这是最典型的一对多关系,必须单独建场次表。座位也要单独建或者结合订单来管理,不能拍脑袋把所有座位堆在电影表里。
需求边界想清楚了,表结构其实已经呼之欲出。
2. 技术选型实战:SpringBoot + Vue3 + MyBatis + MySQL 的搭档逻辑
写这套项目选型时,我参考了两个原则:第一,主流公司真实在用的技术栈是什么;第二,每个组件在项目里具体承担什么职责,不是因为它火才用。这四个技术选型放到一起,本质上是在回答“后端怎么快速提供 API、前端怎么高效渲染、数据层怎么把 SQL 掌控在自己手里、数据存到哪里”这四个问题。
2.1 后端框架:SpringBoot 的版本选择有讲究
SpringBoot 的优势大家都能背出来:自动配置、内嵌 Tomcat、起步依赖简化 Maven 配置。但实际开发中版本选择非常关键。SpringBoot 3.x 强制要求 JDK 17 或更高,如果你的本机环境还是 JDK 8,贸然用 3.x 就会在启动阶段直接报错。很多同学下载源码后启动失败,十有八九是版本不匹配。
我在这套项目里用的是 SpringBoot 2.7.x,配 JDK 8。别觉得版本旧,生产环境里 2.7 仍然是一大批存量项目的真实选择,稳定、资料多、各种组件兼容性最好。而在 SpringBoot 2.6.0 之后,默认的路径匹配策略从 AntPathMatcher 切换成了 PathPatternParser,直接导致 springfox swagger 依赖启动时疯狂报错。这个问题在热搜词“springboot版本太高”里频繁出现,建议你固定用 2.7 系列,不要追新版本给自己挖坑。
2.2 前端框架:Vue3 适合这个系统的现实原因
Vue3 和 Vue2 最大的区别是组合式 API 和更灵活的响应式系统。在这个电影订票项目里,选座页面里有大量局部状态,比如二维数组的座位图、选中座位集合、剩余票数计算,组合式 API 可以非常自然地把这些状态封装成一个 composable 函数,而 Vue2 的 Options API 写起来会显得很散。
另外搭配 Vite 构建工具,开发环境启动快到让人感动。Element Plus 组件库在这个项目里并不是必须的,但如果你不想手写弹窗、表单、日期选择器,引入它能把开发速度提升一大截。我在这个项目里使用 Element Plus,但它不影响你对 Vue3 核心逻辑的理解。
2.3 MyBatis 和 MySQL:把 SQL 主动权留在自己手里
选 MyBatis 而不是 JPA 的核心原因,是订票系统里有一些复杂查询和高并发更新操作,需要精确控制 SQL。JPA 的自动化程度高,但一旦遇到多表关联或者需要手动加锁的更新语句,你反而要花更多功夫去绕开它的自动行为。MyBatis 的做法很简单:基础增删改查可以靠通用 Mapper 或 MyBatis-Plus 解决,关键 SQL 自己写在 XML 里,每一行都能掌控。
MySQL 则承担数据持久化。这个项目里 MySQL 版本用 8.x 完全可以,注意 8.x 的默认认证插件是 caching_sha2_password,连接驱动要用 mysql-connector-java 8.0 以上。下载安装配置这一步,网上教程很详细,这里就不展开了。
3. 数据库设计:核心业务表与关键索引规划
数据库设计是整个项目的基石。我见过太多人一上来就写代码,写到订单接口发现字段不够、表关系不对,再回头改表,浪费时间还容易把数据弄脏。先花半小时把表设计好,后面的日子会好过很多。
3.1 核心表结构拆解
这个系统一共需要五张核心表,外加一张角色权限表。我逐个说一下结构和设计理由。
用户表:
CREATE TABLE `user` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `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 '头像地址', `phone` varchar(20) DEFAULT NULL, `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;密码字段用 varchar(100),是因为 BCrypt 加密后的字符串固定是 60 位,留足空间避免以后升级算法时长度不够。用户名一定要建唯一索引,这是防重复注册最便宜的方案。
电影表:
CREATE TABLE `movie` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `title` varchar(200) NOT NULL, `poster` varchar(255) DEFAULT NULL COMMENT '海报URL', `description` text COMMENT '电影简介', `duration` int(11) DEFAULT NULL COMMENT '片长,单位分钟', `release_date` date DEFAULT NULL, `director` varchar(100) DEFAULT NULL, `actors` varchar(500) DEFAULT NULL, `genre` varchar(100) DEFAULT NULL COMMENT '类型', `rating` decimal(3,1) DEFAULT '0.0' COMMENT '评分', `status` tinyint(4) DEFAULT '1' COMMENT '1上映中 2已下架', PRIMARY KEY (`id`), KEY `idx_status` (`status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;场次表:
CREATE TABLE `session` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `movie_id` bigint(20) NOT NULL, `cinema_name` varchar(100) DEFAULT NULL COMMENT '影厅名称', `hall` varchar(50) DEFAULT NULL COMMENT '如1号厅', `start_time` datetime NOT NULL, `end_time` datetime DEFAULT NULL, `price` decimal(10,2) NOT NULL, `total_seats` int(11) DEFAULT '100', `remaining_seats` int(11) DEFAULT '100' COMMENT '剩余座位数', PRIMARY KEY (`id`), KEY `idx_movie_start` (`movie_id`, `start_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;在这里特别提醒:session 在 MySQL 里有特殊含义,虽然能用反引号包起来建表,但建议表名改叫 showtime 或者 movie_session,省得后面写 SQL 时到处加反引号。
订单表:
CREATE TABLE `orders` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `order_no` varchar(64) NOT NULL COMMENT '订单编号', `user_id` bigint(20) NOT NULL, `session_id` bigint(20) NOT NULL, `seat_ids` varchar(255) DEFAULT NULL COMMENT '座位ID拼接,如1,2,3', `amount` decimal(10,2) NOT NULL, `status` tinyint(4) DEFAULT '0' COMMENT '0待支付 1已支付 2已取消', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;评论表:
CREATE TABLE `comment` ( `id` bigint(20) NOT NULL AUTO_INCREMENT, `movie_id` bigint(20) NOT NULL, `user_id` bigint(20) NOT NULL, `order_id` bigint(20) DEFAULT NULL COMMENT '关联订单,防止无票评论', `content` varchar(500) NOT NULL, `rating` int(11) DEFAULT '5' COMMENT '评分1-5', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_movie` (`movie_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;3.2 表关系设计与索引规划的深层考虑
这五张表的关系并不复杂:一个用户对应多个订单,一部电影对应多个场次,一个订单包含多个座位,一个用户对一部电影可以有多条评论。
关键点在于,为什么要单独存一个 seat_ids 字段而不是建订单_座位关联表?这个项目的实际场景里,一个订单的座位数量通常不超过 5 个,用逗号拼接字符串存是一个性价比非常高的方案。如果要做成大型票务平台,当然可以拆一张 order_seat 关联表,但在这个项目里,字符串方案查询和插入都更简单,也不需要额外的关联表维护。这就是一个典型的“根据真实需求决定表设计”的案例。
索引规划方面,最需要注意的是 MySQL 索引不是越多越好。每个索引都会拖慢插入和更新速度。这个项目里最核心的索引就是 user 表的 username 唯一索引、movie 表的 status 索引、session 表的 movie_id + start_time 联合索引、orders 表的 user_id 索引。覆盖日常查询就够了。
评分冗余也是一个设计细节:movie 表里保留一个 rating 字段,评论表里每次新增评论时同步更新 movie.rating。如果不冗余,每次查询电影详情都要实时聚合评论表算平均分,场次多了、评论多了,查询压力会明显上升。这种小规模的冗余在业务系统里很正常,不要一说“冗余”就觉得不规范,实际收益大于成本。
4. 后端实现:从 JWT 登录到订票并发控制的完整链路
后端是整个系统的中枢。我搭建项目时严格按照 controller → service → mapper 三层结构来写,entity 和 dto 分开。Controller 只负责参数接收和结果返回,Service 层承载业务逻辑,Mapper 层处理数据库交互。这个分层不是教条,它的意义在于:当你的订票逻辑需要同时操作订单表、更新场次剩余座位数、校验用户身份时,代码的可维护性会好很多。
4.1 项目结构与依赖准备
后端工程结构:
src/main/java/com/example/cinema/ ├── controller/ # 接口层 ├── service/ # 业务层 ├── mapper/ # MyBatis Mapper接口 ├── entity/ # 数据库实体 ├── dto/ # 接口传输对象 ├── config/ # 配置类,跨域、拦截器 ├── common/ # 统一返回结果、工具类 └── CinemaApplication.javapom.xml 里的核心依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>io.jsonwebtoken</groupId> <artifactId>jjwt-api</artifactId> <version>0.11.5</version> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency>application.yml 中尤其需要注意 MyBatis 的配置:
spring: datasource: url: jdbc:mysql://localhost:3306/cinema?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false username: root password: yourpassword jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.cinema.entity configuration: map-underscore-to-camel-case: true cache-enabled: false log-impl: org.apache.ibatis.logging.stdout.StdOutImplmap-underscore-to-camel-case 设置为 true,数据库里的 create_time 就能自动映射到实体里的 createTime,这是必须开启的。cache-enabled 我在这里设置成 false,是因为 MyBatis 二级缓存默认不开启,显式关掉能避免一些缓存数据不一致的坑。日志配置成 StdOutImpl 后,控制台会直接打印 SQL,排查问题非常方便。
4.2 JWT 用户认证:拦截器方案
用户认证没有引入 Spring Security,而是自己实现 JWT 拦截器,原因是这个项目的认证需求足够简单:接口白名单之外的请求都要校验 token。用 Spring Security 当然可以,但配置复杂度高,对新手不友好。手写一个拦截器,十几行代码就能搞定,也更容易理解 token 认证的本质。
登录接口核心逻辑:
@PostMapping("/api/auth/login") public Result login(@RequestBody LoginDTO dto) { User user = userService.findByUsername(dto.getUsername()); if (user == null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) { return Result.error("用户名或密码错误"); } String token = JwtUtil.generateToken(user.getId(), user.getUsername()); return Result.success(Collections.singletonMap("token", token)); }这里用了 BCrypt 而不是 MD5,是因为 MD5 加盐虽然也能用,但 BCrypt 是专门为密码哈希设计的算法,自带随机盐,同样明文每次生成的密文都不同,安全性高一个量级。Spring Security 框架里的 crypto 模块提供了 BCrypt 工具类,直接引进来用,不需要引入整个框架。
拦截器层面:
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || !token.startsWith("Bearer ")) { throw new BusinessException(401, "未登录"); } token = token.substring(7); Claims claims = JwtUtil.parseToken(token); // 把用户ID放到request属性里,后续逻辑直接取 request.setAttribute("userId", claims.get("userId")); return true; } }配置类里注册拦截器并设置白名单:
@Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(jwtInterceptor) .addPathPatterns("/api/**") .excludePathPatterns("/api/auth/**", "/api/movies/**", "/api/sessions/**"); }把这个逻辑想成电影院门口的检票员:观众想看任何“需要购票的区域”,必须先出示票据(token),白名单区域可以自由进入。
4.3 MyBatis 的 SQL 编写与分页查询
电影列表接口用了 PageHelper 分页插件,这是国内 MyBatis 生态最常用的做法。在 Service 层写业务逻辑时,通过 PageHelper.startPage(pageNum, pageSize) 就能在下一条 SQL 执行时自动拼接 limit,非常方便。
Mapper 接口:
public interface MovieMapper { List<Movie> selectMovieList(@Param("keyword") String keyword, @Param("status") Integer status); }对应的 XML:
<select id="selectMovieList" resultType="com.example.cinema.entity.Movie"> SELECT * FROM movie <where> <if test="status != null"> status = #{status} </if> <if test="keyword != null and keyword != ''"> AND title LIKE CONCAT('%', #{keyword}, '%') </if> </where> ORDER BY release_date DESC </select>动态 SQL 是 MyBatis 最实用的能力。用 if 标签拼条件,比 Java 代码里拼接 SQL 字符串不知道高到哪里去了。这里的 where 标签会自动处理第一个条件前的 AND,不需要自己写那一堆 1=1 的尴尬代码。
评论列表查询涉及用户表和评论表的关联:
<select id="selectCommentsByMovieId" resultType="com.example.cinema.vo.CommentVO"> SELECT c.id, c.content, c.rating, c.create_time, u.nickname, u.avatar FROM comment c LEFT JOIN user u ON c.user_id = u.id WHERE c.movie_id = #{movieId} ORDER BY c.create_time DESC </select>评论查询是典型的多表查询场景,用 VO(value object)接收,避免每个字段都要 map。
4.4 订票核心流程:并发控制的现实解法
订票接口是整个项目中最容易出问题的地方。如果只是简单的先查剩余座位、再插入订单、再更新剩余座位,在高并发情况下一定会出现超卖。
我采用的做法是:在创建订单前,先用带条件更新的方式扣减场次剩余座位数。
@Transactional(rollbackFor = Exception.class) public Order createOrder(OrderCreateDTO dto) { // 1. 锁定场次,更新剩余座位 int updated = sessionMapper.decreaseRemainingSeats(dto.getSessionId(), dto.getSeatIds().size()); if (updated == 0) { throw new BusinessException("座位不足,购买失败"); } // 2. 创建订单 Order order = new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(dto.getUserId()); order.setSessionId(dto.getSessionId()); order.setSeatIds(String.join(",", dto.getSeatIds())); order.setAmount(...); order.setStatus(0); orderMapper.insert(order); return order; }对应的 SQL:
<update id="decreaseRemainingSeats"> UPDATE session SET remaining_seats = remaining_seats - #{count} WHERE id = #{sessionId} AND remaining_seats >= #{count} </update>这条 SQL 的精髓在于 WHERE 条件里带上了 remaining_seats >= count。如果剩余座位数量不够,MySQL 会返回影响行数为 0,程序就直接抛异常回滚。单个 UPDATE 语句是原子操作,在高并发下天然不会出现超卖。
事务注解 @Transactional 放在 Service 层方法上,意味着如果创建订单失败,前面扣减座位数的操作也会一起回滚。这一步很关键:扣减成功但订单插入失败,如果事务没回滚,座位就白白扣掉了。这属于“要么都成功,要么都失败”的典型场景。
订单号生成,我用的方案是时间戳 + 随机数 + 用户ID后缀。不需要引入分布式 ID 框架,业务量级不到那个程度。如果实在担心订单号重复,给 order_no 加唯一索引,插入报错时重新生成一次即可。
4.5 评论接口与评论后评分更新
发表评论前校验用户是否已购买该电影,是这里的业务规则。实现思路:在 comment 表里放 order_id 字段,插入前先去订单表检查是否存在该用户针对该电影的有效订单。评论成功后更新电影评分:
@Transactional(rollbackFor = Exception.class) public void addComment(CommentDTO dto, Long userId) { // 校验订单,省略... commentMapper.insert(comment); // 重新计算平均分 BigDecimal avg = commentMapper.selectAvgRatingByMovieId(dto.getMovieId()); movieMapper.updateRating(dto.getMovieId(), avg); }这个做法简单直接。评分数据量大了以后可以改成异步更新,但把这个项目跑清楚,同步更新足够。
5. 前端实现:Vue3 页面搭建与选座交互的完整逻辑
前端我用 Vite 创建项目,命令是 npm create vite@latest cinema-front -- --template vue。Vue3 的安装和环境配置,最需要注意的是 Node.js 版本要在 16 以上,低于这个版本 Vite 跑不起来。
5.1 前端项目结构与核心依赖
前端目录结构:
src/ ├── api/ # 接口请求封装 ├── assets/ # 静态资源 ├── components/ # 通用组件 ├── router/ # 路由配置 ├── store/ # Pinia 状态管理 ├── views/ # 页面组件 ├── App.vue └── main.js依赖安装:
npm install axios vue-router@4 pinia element-plus5.2 路由守卫与登录状态管理
电影列表页和详情页应该对游客开放,但订票页和订单页必须登录。用路由守卫统一处理:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next({ path: '/login', query: { redirect: to.fullPath } }) } else { next() } })登录状态用 Pinia 管理:
export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: JSON.parse(localStorage.getItem('userInfo') || 'null') }), actions: { setLogin(data) { this.token = data.token this.userInfo = data.userInfo localStorage.setItem('token', data.token) localStorage.setItem('userInfo', JSON.stringify(data.userInfo)) }, logout() { this.token = '' this.userInfo = null localStorage.removeItem('token') localStorage.removeItem('userInfo') } } })axios 封装时,请求拦截器统一携带 token:
axios.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config })响应拦截器统一处理 401,前端遇到登录过期直接跳转登录页。这个步骤看起来简单,但很多项目就是没做,导致用户遇到接口报错时一脸懵。
5.3 电影列表与详情页:数据请求和接口对接
电影列表页是用户进入系统看到的第一屏。我的做法是调用 GET /api/movies?page=1&size=12 获取分页数据,卡片式展示海报、片名、类型、评分。
电影详情页从路由参数拿 id,并行请求两个接口:电影详情和评论列表。Vue3 里可以这样写:
const [movieRes, commentRes] = await Promise.all([ getMovieDetail(id), getComments(id) ])Promise.all 并行请求能明显减少页面等待时间,而不是串行地先拿详情再拿评论。
选座页面是这个项目前端交互最复杂的部分。我的思路:后端返回该场次的座位总数和已售座位 ID 集合,前端生成一个二维数组,每个元素代表一个座位的状态。
// 假设影厅10排10列 const seats = ref([]) for (let row = 1; row <= 10; row++) { const rowArr = [] for (let col = 1; col <= 10; col++) { rowArr.push({ id: `row-${row}-col-${col}`, row, col, status: soldSeats.includes(`row-${row}-col-${col}`) ? 'sold' : 'available', selected: false }) } seats.value.push(rowArr) }点击选座、再次点击取消,同时维护选中数组,并在底部实时显示张数和总价。提交订单时把选中的座位 ID 数组传给后端。这整个过程用组合式 API 里的 ref 和 computed 很容易控制,体验也很流畅。
订单列表页相对简单,遍历订单数据,展示订单号、场次信息、金额、状态。状态字段用不同的标签颜色区分,待支付显示黄色、已支付显示绿色、已取消显示灰色。
6. 前后端联调、部署与性能优化里的真实经验
前后端分离项目,联调阶段遇到的问题是真实的开发日常,也是很多只做过单体项目的人初次遇到时最容易卡壳的地方。我把这一部分单独拎出来讲,因为这些坑几乎每个人都会踩一遍。
6.1 跨域问题:不要让 CORS 浪费你半天时间
前端运行在 http://localhost:5173,后端运行在 http://localhost:8080,端口不同,浏览器会拦截跨域请求。解决方式有两个:后端配置跨域过滤器,或者前端用 Vite 代理。
后端的方案最通用,因为上线后前后端分离部署依然需要它:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }如果发现自己配了跨域仍然报跨域错误,检查一下是不是自定义拦截器把 OPTIONS 预检请求拦截了。预检请求不带 token,拦截器里遇到 OPTIONS 请求应该直接放行。这个坑非常隐蔽。
前端 Vite 代理的方案更适合开发环境:
// vite.config.js server: { proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } }两种方式二选一。我自己开发时用 Vite 代理,部署时靠后端 CORS 或者 Nginx 反向代理。
6.2 联调中的经典问题:Long 类型精度丢失
这个项目的数据库 ID 用的是 bigint,Java 实体里用 Long,正常情况下没问题。但当前端页面上拿到订单号或者用户 ID 超过 JavaScript 的 Number.MAX_SAFE_INTEGER 时,精度会丢失,最后几位变成 0000。这会导致一些非常诡异的问题,比如删除某条数据时,后端收到的 ID 是错的。
解决办法是在后端的 JSON 序列化配置里,把 Long 类型转成字符串:
spring: jackson: generator: write-numbers-as-strings: true或者给实体字段加注解:
@JsonSerialize(using = ToStringSerializer.class) private Long id;这个细节很少有人提前注意到,但几乎每个前后端分离项目都会遇到。
6.3 部署:从 jar 包到 Nginx 反向代理
后端部署最简单的方式是把项目打包成 jar:
mvn clean package java -jar cinema-backend.jar --spring.profiles.active=prod前端构建:
npm run build构建完成后 dist 目录下就是纯静态文件,放到 Nginx 的 html 目录,再配一个反向代理把 /api 转发到后端服务:
server { listen 80; server_name yourdomain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }这里 try_files 很关键,Vue Router 使用 history 模式时,刷新 /movie/123 这个路径,Nginx 必须把请求重定向回 index.html,由前端路由接管,否则会 404。
6.4 性能优化:索引、缓存和连接池
实际运行这个项目,在数据量不大的情况下性能不会有什么压力。但有几个习惯建议提前养成:MySQL 的连接池参数在配置里显式设置,而不是全靠默认值;MyBatis 的 SQL 日志在开发环境打开、生产环境关掉;评论表的关联查询一定要走索引,否则数据量上来以后页面会越来越慢。
想再进一步,可以在 SpringBoot 里加一层 Redis 缓存电影列表和电影详情,减少数据库的查询压力。这套系统如果加上 Redis 缓存,性能表现会更好,也是一个很好的延伸点。
最后分享两个实际的踩坑经验
这套项目我前后维护过两个版本,说两个最有代表性的问题。
第一个是表名order。第一次跑的时候,插入订单数据的 SQL 一直报错,排查了很久才发现 order 是 MySQL 的保留字,要么改成 orders,要么每条 SQL 都加反引号。后来我把所有表名都统一成了复数形式,user、movie、comment 这些也改成了 users、movies、comments,彻底规避了保留字问题。
第二个是事务回滚只对 RuntimeException 生效。在订票方法里如果抛出的是 Exception 而不是 RuntimeException,@Transactional 默认不会回滚。很多同学写代码时会 throw new Exception("座位不足"),结果订单没创建成功,座位却扣掉了。建议把自定义异常都继承 RuntimeException,或者在 @Transactional 注解里显式加上 rollbackFor = Exception.class。
这个系统的价值不在于技术栈多新多炫,而是它把所有基础组件串成了真实业务。把用户认证、分页查询、事务控制、跨域联调这些高频场景都过了一遍之后,再去看公司里的业务代码,你会发现很多模块的套路是相通的。把它跑通、读透、改成自己的项目,比单纯看十篇教程都有用。