
做电影订票及评论网站这个项目前我其实犹豫了很久。市面上的课程项目要么是单表增删改查的后台管理系统要么是各种云服务拼凑出来的 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;密码字段用 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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;场次表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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;在这里特别提醒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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;评论表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) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version2.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependencyapplication.yml 中尤其需要注意 MyBatis 的配置spring: datasource: url: jdbc:mysql://localhost:3306/cinema?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: yourpassword jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 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 { ListMovie selectMovieList(Param(keyword) String keyword, Param(status) Integer status); }对应的 XMLselect idselectMovieList resultTypecom.example.cinema.entity.Movie SELECT * FROM movie where if teststatus ! null status #{status} /if if testkeyword ! null and keyword ! AND title LIKE CONCAT(%, #{keyword}, %) /if /where ORDER BY release_date DESC /select动态 SQL 是 MyBatis 最实用的能力。用 if 标签拼条件比 Java 代码里拼接 SQL 字符串不知道高到哪里去了。这里的 where 标签会自动处理第一个条件前的 AND不需要自己写那一堆 11 的尴尬代码。评论列表查询涉及用户表和评论表的关联select idselectCommentsByMovieId resultTypecom.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评论查询是典型的多表查询场景用 VOvalue 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; }对应的 SQLupdate iddecreaseRemainingSeats 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 vitelatest 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-router4 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 封装时请求拦截器统一携带 tokenaxios.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config })响应拦截器统一处理 401前端遇到登录过期直接跳转登录页。这个步骤看起来简单但很多项目就是没做导致用户遇到接口报错时一脸懵。5.3 电影列表与详情页数据请求和接口对接电影列表页是用户进入系统看到的第一屏。我的做法是调用 GET /api/movies?page1size12 获取分页数据卡片式展示海报、片名、类型、评分。电影详情页从路由参数拿 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 用的是 bigintJava 实体里用 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 反向代理后端部署最简单的方式是把项目打包成 jarmvn clean package java -jar cinema-backend.jar --spring.profiles.activeprod前端构建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 而不是 RuntimeExceptionTransactional 默认不会回滚。很多同学写代码时会 throw new Exception(座位不足)结果订单没创建成功座位却扣掉了。建议把自定义异常都继承 RuntimeException或者在 Transactional 注解里显式加上 rollbackFor Exception.class。这个系统的价值不在于技术栈多新多炫而是它把所有基础组件串成了真实业务。把用户认证、分页查询、事务控制、跨域联调这些高频场景都过了一遍之后再去看公司里的业务代码你会发现很多模块的套路是相通的。把它跑通、读透、改成自己的项目比单纯看十篇教程都有用。