先别急着把源码拖进 IDE 就跑。这套企业级 web 电影院购票系统源码,技术栈写着 SpringBoot+Vue+MyBatis+MySQL,乍看之下都是常见货,但真正有分量的不是注册登录和增删改查,而是选座、锁座、订单超时、排片冲突这一串业务设计。我在拿到完整版源码之后,从建库脚本到前后端联调,再到用 JMeter 模拟并发抢座,差不多花了一个完整的周末才把整个链路吃透。这篇文章把项目拆解和落地过程完整梳理一遍,适合正在做 Java 课程设计、想系统学习前后端分离项目的人,也适合刚接触企业级管理系统、想搞明白一套业务系统完整链路的新手。
1. 这套系统的业务全貌:从用户下单到后台排片的完整闭环
1.1 用户端的一条购票主线
先画一下用户端的业务流:注册登录、浏览电影、查看影片详情、选择场次、选座、下单、支付、查订单。前四项都是常规 CRUD,但整套系统真正容易出问题的环节集中在"选座到下单"这一段:用户打开座位图时,座位状态是可选,用户点击座位后系统要立刻帮他把座位锁住,避免另一个人同时选中同一个位置;如果用户锁了座位却迟迟不支付,过了一定时间还要把座位释放回座位池。
所以,用户端的代码虽然在顺序上是从控制器到 Service 再到 Mapper,但设计核心始终围着"座位状态"转。首页展示热映和即将上映的电影,点击电影详情可以看到海报、简介、影片时长、评分和排片场次;点进场次后进入影厅座位图,座位图按“排 x 列”渲染成网格,可选座位可以点选,已售或已被别人锁定的座位显示为灰色不可点;选完点击提交,后端校验座位状态,创建订单进入支付流程(Demo 系统通常用模拟支付),支付成功后订单变为已支付,用户在个人中心能看到电影票和取票码。
这套完整版源码里,用户端页面数量不多,但每个页面的数据来源和状态流转一定要理清。特别是选座页面,它同时依赖三个后端接口:查场次信息、查该场次座位状态、提交选座创建订单。这三个接口一个都不能少,少一个,前端流程就走不通。
1.2 管理端真正“管”的事情
管理端才是这套系统里能体现“企业级”三个字的部分。后台至少应该包含五个模块:电影管理、影厅管理、场次管理、订单管理、用户管理。
- 电影管理:新增电影、上传海报、设置上映时间和状态(未上映、热映中、已下架),这里要注意电影下架后已经排好的场次怎么处理,一般要禁止再售票,已售出的订单不受影响。
- 影厅管理:维护影厅名称、类型(IMAX、普通厅、VIP 厅)、排数、列数,系统根据排数列数自动生成座位模板。
- 场次管理:选择一个电影、一个影厅、一个开始时间,系统根据影厅的排数和列数自动生成该场次的座位快照,同时校验排片时间是否与同影厅已有场次冲突。
- 订单管理:查询所有订单,手动取消异常订单,退款处理。这部分往往还配一个简单的报表统计,比如今日票房、出票数。
- 用户管理:用户列表、禁用/启用账号。
场次管理里最容易被忽略的细节是排片冲突校验:1 号厅 18:00 到 20:00 在放 A 影片,就不能再排一个 19:00 到 21:00 的 B 影片。基础的冲突判断逻辑是:新场次的开始时间要不早于旧场次的结束时间,或者新场次的结束时间要不晚于旧场次的开始时间。写成 SQL 或 Java 判断都很简单,但漏掉这个逻辑,后台就会出现同一个影厅同一时间放两部电影的笑话。
1.3 系统边界:完整版不等于商业产品
面对任何一套“完整版源码”,第一件事其实不是跑代码,而是搞清楚它的业务边界。这套电影院系统做了排片、选座、订单、支付模拟、后台管理,但真实影院里的会员储值、优惠券、学生票、特殊场次加价、取票机对接、影城卡这些功能基本都没做。
刻意省略这些功能不是偷懒,而是为了把核心链路做扎实。一个购票系统最怕的是座位卖重、订单金额对不上、券用了没法核销这类数据一致性问题。所以这套源码的价值集中在“排片、座位、订单”这三张核心关系上,你把这三块吃透,之后往里面加优惠券、会员等级都是顺手的事。带着这个认知去看代码,你就不会抱怨“为什么没有某某功能”了。
2. 技术选型拆解:为什么这套组合能撑起影院场景
2.1 SpringBoot 解决的是工程化问题
如果项目只有几千行代码,用不用 SpringBoot 无所谓;但一套包含用户端、管理端、订单状态机、定时任务、拦截器校验的系统,如果没有一个像样的工程框架,项目结构很快就会失控。SpringBoot 在这里的核心价值是自动配置和生态整合:MyBatis 的 starter、参数校验、事务管理、日志、内置 Tomcat,全部通过统一配置生效,省掉大量重复的 XML 配置。
对影院购票系统来说,Spring 的声明式事务是刚需。座位锁定、订单创建、库存扣减必须在一个事务里完成,而 Spring 的 @Transactional 注解是行业默认答案。另外,SpringBoot 的 profile 机制可以把开发、测试、生产环境配置分开,同一套代码在不同环境切换只需改一个参数,这对后面部署上线非常关键。
2.2 Vue 前后端分离:为什么不是 JSP 或 Thymeleaf
电影院系统有典型的两个端:C 端用户购票页和 B 端后台管理页。用 Vue 做前端分离最大的好处是组件化和接口联调解耦。后端只负责提供 RESTful 接口,前端按页面维度去组织组件——座位图是一个独立组件、电影卡片是一个独立组件、订单列表是一个独立组件。每一块都可以单独维护和复用,后端接口字段变了,前端只需改对应的数据绑定层。
如果换成 JSP 或 Thymeleaf 这类服务端渲染方案,选座这种强交互页面做起来会非常痛苦。每一个座位的点击都要刷新页面或通过 AJAX 拼接 DOM,代码的可维护性会随着交互复杂度的提升越来越差。Vue 的双向绑定和响应式数据模型在这里省掉大量手工 DOM 操作,这也是前后端分离在管理类系统中成为主流的原因。
2.3 MyBatis 在复杂查询场景的取舍逻辑
谈到持久层,很多文章会吹 JPA 更“优雅”,但我在影院这类业务里选 MyBatis 的理由非常务实:SQL 完全可控,复杂查询的优化空间大。查一个场次的剩余座位列表,要关联场次表、影厅表、座位快照表,这种查询用 SQL 写出来清晰直接;批量更新座位状态(一个订单买了 5 个座位),一条 UPDATE 带 IN 参数就能完成。这些场景如果靠 ORM 自动推导 SQL,要么会生成效率很低的语句,要么需要学习大量 API 才能写出符合预期的查询。
MyBatis 的 XML Mapper 看起来很原始,但恰恰是这种“把 SQL 摆在你面前”的方式,让人一眼就能看清每条语句的性能瓶颈。再加上 MyBatis 的缓存机制,能在本地缓存一级缓存(SqlSession 级别)和二级缓存(Mapper 级别)层面做简单的读写优化。配合 PageHelper 分页插件处理后台列表的分页,效率很高。面试里常问的 MyBatis 缓存、#{} 与 ${} 的区别、动态 SQL 标签,也都能在这套项目里找到实际案例。
2.4 MySQL 在影院系统里的容量评估
影院购票系统的业务量到底有多大?一个中型影城一天几千到几万订单是常态,绝对不是互联网海量并发产品的量级。MySQL 单机配置合理的情况下完全扛得住,不需要一开始就上分布式数据库或分库分表。
选 MySQL 做存储还有一层原因是生态成熟。运维、备份、监控的现成方案非常多,网上遇到问题时能搜到的资料也远多于其他数据库。对这套系统和多数读者来说,重点要关注的是 MySQL 的配置调优而非架构改造。比如连接池大小要和 Tomcat 线程数匹配,事务隔离级别用默认的 REPEATABLE READ 就够了,关键的查询字段要建索引。做到这些,一个单机 MySQL 支撑十几家影院的业务量也没有压力。
3. 数据库设计核心:五张主表之外的细节才是分水岭
3.1 核心表结构与字段设计思路
这套系统的数据库表可以归纳为七张核心表:用户表 user、电影表 film、影厅表 hall、场次表 session、场次座位快照表 session_seat、订单表 orders、订单明细表 order_item。如果做了支付模拟,还会有一张支付记录表 payment。
除了表数量,字段设计里有很多值得抄的细节:
- 密码字段不能明文存,要用 BCrypt 哈希后的字符串,长度留到 100 以上。
- 状态字段一律用 tinyint,0/1/2 表示离散状态,不要用字符串。字符串状态看着直观,但查询效率和后续扩展都不如整数。
- 金额字段必须用 decimal(10,2),不能用 float 或 double,否则累计票房、退款的金额迟早算出小数误差。
- create_time、update_time 用 datetime,由数据库的默认值或应用层统一维护。
- 订单状态机字段推荐用:0 待支付、1 已支付、2 已取消、3 已退款、4 已完成(已观影)。
订单表里还有两个字段容易被初学者忽略:一个是支付超时时间 expire_time,用于实现“超时未支付自动取消订单并释放座位”;另一个是冗余的场次信息冗余字段,比如影厅名称、影片名称、场次时间,方便订单列表页直接展示,不用每次回表关联查询太多张表。这种冗余在管理系统中是合理的,只要在代码注释里写清楚字段来源即可。
3.2 座位状态与锁定的数据模型
这是整套系统数据库设计里最核心的部分。影厅是静态配置(几排几列),但座位状态是跟场次绑定的动态数据。所以项目里采用了“影厅模板 + 场次座位快照”的设计:
- 影厅表 hall 记录排数和列数,比如 10 排 16 列。
- 每次创建场次时,根据影厅参数自动生成这个场次的 session_seat 记录,每条记录代表这个场次里的一个具体座位,字段包括 row、col、status。
- status 用 0 表示可选、1 表示锁定、2 表示已售。
这样设计的好处是不同场次的同一个物理座位状态互不影响:周三 18:00 的场次某个座位被占,周四 18:00 的同一座位依然是可选状态。而且座位锁定和售出的状态只集中在 session_seat 一张表里,查询和更新逻辑非常聚焦。
session_seat 表上必须建立唯一索引 (session_id, row, col),保证同一个场次同一个座位只有一条记录。这个唯一约束是后面并发控制的基础。如果索引建得不对,数据库层面就会允许同一个座位出现两条记录,后面的锁座位逻辑再严谨都会出大问题。
3.3 索引设计:哪些查询必须走索引
电影院系统的查询压力集中在几个位置,每个位置都有明确的索引策略:
- 首页电影列表:按 status 过滤,按上映时间排序,建 (status, release_date) 联合索引。
- 场次列表页:按电影 ID 查场次,建 session.film_id 索引;按开始时间排序,可以考虑 (film_id, start_time) 联合索引。
- 用户订单列表:按用户 ID 查订单,建 (user_id, create_time) 联合索引,避免一次把某个用户所有历史订单都捞出来。
- 订单号查询:order_no 建唯一索引,用于支付回调时的幂等校验。
- 座位查询和更新:session_seat 建 (session_id, row, col) 唯一索引,前面已经说过。
索引不要建太多,否则写入性能会下降。影院场景的核心是读多写少,优先保证读路径的索引覆盖。如果一个 SQL 的执行计划里出现了 Using filesort 或 Using temporary,就要检查排序字段是否有索引;如果某个过滤字段的区分度太低(比如 status 只有 0/1/2),单独建索引帮助不大,应该联合其他字段一起建。
3.4 为什么订单号不能直接用自增 ID
订单表的主键可以继续用自增 ID,但对外展示和支付回调用的订单号建议单独生成一个字段。原因有三点:第一,自增 ID 会暴露业务量,一天卖多少单从 ID 就能算出来;第二,对接支付渠道、对账、防止重复回调时,需要一个业务维度全局唯一的订单号;第三,订单号本身要便于人工识别和回溯,纯数字递增看不出任何业务信息。
常用的订单号生成规则是:日期时间 + 随机数 + 用户标识的一部分,比如“20250101120000 + 后 6 位时间戳 + 随机 4 位”,拼出来大约 20 位左右。生成时注意加唯一索引,极端情况下重复了要能捕获异常重试。网上还有用雪花算法生成 ID 的做法,但在单库单表的影院场景里杀鸡用牛刀了,简单的日期加随机序列就够。
4. 后端核心实现:座位锁定、订单事务与 MyBatis 分页实战
4.1 选座与下单的并发控制方案
选座是这套系统最容易翻车的地方,我在本地用 JMeter 模拟 50 个用户同时抢同一个座位来验证并发控制是否有效。先说结论,正确的实现方式是:在创建订单的事务里,先执行一条带条件的 UPDATE 语句。
UPDATE session_seat SET status = 1 WHERE session_id = ? AND row = ? AND col = ? AND status = 0影响行数为 1,说明座位抢锁成功;影响行数为 0,说明座位已经被别人锁定或购买,直接返回“座位已被选走”。这个方案的本质是数据库的行锁加条件更新,把并发检查放在一条 SQL 里完成,原子性由数据库保证。
这里有一个关键的教训:不要先 SELECT 判断座位状态再 UPDATE,因为两个操作之间会有别的请求插进来,形成超卖。要么用上面这种带条件的 UPDATE 一步到位,要么用 SELECT ... FOR UPDATE 先把目标行锁住再判断再更新。前者代码更简单,推荐直接用。
还需要注意事务隔离级别。MySQL 默认的 REPEATABLE READ 配合行锁在低并发场景下已经够用。除非你的压测发现死锁频繁,否则不建议动全局隔离级别。
4.2 事务边界的正确划分
购票流程涉及三个写操作:锁定座位、创建订单、更新场次的剩余座位数。这三个逻辑必须放在同一个事务里,保证要么全部成功,要么全部失败。而“支付成功回调”更新订单状态,必须在另一个事务里,因为支付回调是外部异步触发的,如果和下单逻辑放一个事务,事务时间会拉得非常长,数据库连接容易被占满。
事务边界还有两个容易被忽视的坑。第一个是 Spring 事务默认只对 RuntimeException 回滚,如果你抛了一个继承自 Exception 的自定义异常,默认不会回滚。正确写法是 @Transactional(rollbackFor = Exception.class)。第二个是自调用问题:同一个类里 this.xxx() 调用带 @Transactional 的方法,事务不会生效,因为 Spring 事务是基于 AOP 代理实现的,代理对象才能拦截调用。如果发现事务不生效,第一反应应该是查代码里有没有自调用。
4.3 PageHelper 分页插件的正确用法与常见坑
后台管理列表(订单列表、用户列表、场次列表)都要分页,MyBatis 里最常用的是 PageHelper。用法看起来很简单:查询前调用 PageHelper.startPage(pageNum, pageSize),紧随其后的第一条 MyBatis 查询会自动带上 limit。但有几个坑必须说明:
- startPage 和查询之间不能插入其他 MyBatis 操作,否则分页会作用到无关查询上。
- 多表 JOIN 查询时,如果 SQL 里包含 GROUP BY 等聚合逻辑,PageHelper 自动生成的 count 语句可能不准,这时需要自己定义 count 查询。
- 如果 Mapper 查询里用了嵌套子查询(比如 collection 标签加载子集合),分页只会对主查询生效,子查询的集合会被错误截断。这是项目运行一段时间后才会暴露的问题。
- PageHelper 只对紧跟的查询生效,用完即止,不要连续调用两次 startPage。
如果你遇到 MyBatis 的 @Update 执行很慢,不要立刻怀疑 PageHelper,先去看是不是在 for 循环里逐条 UPDATE。每一条都单独提交事务,几十条数据就能把连接池拖垮。解决方案是改用批量执行器 ExecutorType.BATCH,或者用 MyBatis 的 foreach 标签拼接一条批量 UPDATE。批量操作时还要注意 MySQL 对单条 SQL 的最大包大小限制,必要时分片执行。
4.4 接口层设计:统一返回体、异常处理和 Token 校验
接口层的设计直接决定前后端联调是顺滑还是反复扯皮。这套源码里有几个值得参考的规范:
- 统一返回体 Result :包含 code、message、data 三个字段,前端根据 code 判断业务是否成功,而不是靠 HTTP 状态码。HTTP 状态码只表示传输层是否正常,业务失败用 200 + 业务 code 是前后端分离项目的常见做法。
- 全局异常处理器 @RestControllerAdvice:把业务异常、参数校验异常、未知异常分开处理,返回可读的错误信息,避免把堆栈直接抛给前端。
- 登录态用 JWT:后端登录接口发放 token,前端请求头加 Authorization,后端拦截器统一校验。管理端和用户端可以设置不同的拦截路径规则,比如 /admin/** 必须校验管理员角色。
- 分页查询参数统一封装成 PageQuery 对象,返回统一用 PageResult,避免每个接口各写各的分页逻辑。
登录模块还要考虑密码加密:数据库里存的是 BCrypt 哈希,登录时用 BCrypt 校验。这种做法的好处是同一密码每次生成的哈希串不同,即使数据库泄露,也无法通过彩虹表反推出明文密码。
5. Vue 前端实现细节:路由、状态管理与组件复用的实战记录
5.1 前端路由组织:用户端与管理端怎么切
Vue Router 项目里最忌讳把所有路由写在一个平铺列表里。我的做法是按业务拆成 user 模块和 admin 模块,配合嵌套路由和路由懒加载。用户端路由包括首页、电影详情 /film/:id、选座购票 /buy/:sessionId、我的订单 /orders;管理端路由包括登录、数据概览、电影管理、影厅管理、场次管理。
管理端必须有路由守卫:进入 /admin/** 之前检查当前用户角色是不是管理员,不是就重定向到登录页。用户端的登录状态用同样的拦截器,未登录用户不能进入订单页和选座页。Vue Router 的动态路由也是个好用的特性,可以根据用户的角色动态添加可用路由,避免在初始化时把所有管理端页面都暴露给普通用户。
路由参数这块有一个常见需求:从电影详情页跳转到选座页时,需要带上场次 ID,用 this.$route.params.sessionId 或 useRoute() 拿到。但要注意刷新页面后参数还在不在,因为 params 里的参数在刷新后可能丢失,更稳妥的做法是把关键参数拼接在 URL 的 query 上,或者进入选座页后再通过接口拉取场次信息。
5.2 选座组件的核心交互逻辑
选座是整个前端最核心的组件。实现思路是:先在 created 或 onMounted 生命周期里请求后端接口,拿到该场次的座位列表,然后根据 row 和 col 初始化一个二维数组,每个座位对象包含 row、col、status 三个字段。
初始渲染时,status 为 2 的座位显示为灰色不可点,status 为 0 的可以点击。点击座位后把它加入本地的 selected 集合,组件内部把这个座位高亮显示;再次点击则从集合移除。提交订单时,把 selected 集合里所有座位的 row 和 col 传给后端,后端再去做状态校验。
前端有一个细节很容易踩坑:提交按钮必须有一个“正在提交”的禁用状态,防止用户手快点了多次提交导致重复下单。提交成功后,要把本地的 selected 集合清空,然后携带订单号跳转到支付页或订单详情页。还有一种体验优化是,在选座组件里做一个“提交中”的遮罩层,视觉上阻止用户继续点击其他座位。
如果你要给电影详情页加预告片播放功能,通常的做法是用 video.js 或者 video.js 的 hls.js 插件播放 m3u8 格式的视频流,后端提供视频流地址或 CDN 地址。这个功能在影院系统里属于增强项,但实现起来并不复杂:在组件里动态创建 video 标签,初始化播放器,把 m3u8 地址传进去就行。
5.3 Axios 封装与登录状态管理
前端不要在每个页面里直接写 axios.get,否则接口请求一多就会非常混乱。建议封装一个 request.js,统一处理几件事:baseURL 指向后端地址、请求拦截器里带上 JWT token、响应拦截器里统一处理 code(比如 code 为 401 时跳转登录页并清除本地 token)、业务异常用 Element 的 Message 组件统一弹出后端返回的错误信息。
登录状态管理可以用 Vuex 或 Pinia,取决于项目用的是 Vue 2 还是 Vue 3。用户登录后把用户信息存到 store,刷新页面时从 localStorage 读取。注意一点:如果源码是 Vue2,组件库用 Element UI;如果是 Vue3,组件库用 Element Plus,这两个组合别混着用,混了会出现很多组件兼容性 bug。
跨域问题是前后端分离联调时最容易卡住的环节。开发环境可以在 vue.config.js 里配置 devServer 的 proxy,把 /api 前缀的请求代理到后端地址;正式环境用 Nginx 反向代理,把 /api 的 location 转发到后端服务。前端请求地址统一写成相对路径 /api/xxx,不要写死 http://localhost:8080,这样开发环境和生产环境都能正确转发。
5.4 前后端联调时最容易出的问题
我在跑这套系统时卡得最久的就是跨域和时间格式。跨域前面已经说了,另外一个经典问题就是日期时间字段。Java 后端默认返回的 LocalDateTime 是类似 "2025-01-01T12:00:00" 的 ISO 格式,前端如果不处理直接显示很丑。解决方案有两个:后端在 Jackson 配置里统一格式化成 "yyyy-MM-dd HH:mm:ss",或者前端在展示层写一个格式化函数。我建议优先用后端统一格式化,因为同一套数据可能不止一个前端在用,统一格式能避免重复劳动。
接口联调时还要注意字段命名规范。后端 Java 习惯用驼峰命名 userId,前端 JS 也用 userId,这是最理想的情况。如果后端某个字段返回的是 user_id,前端就要统一做一次转换。这套项目如果用了 MyBatis 的 mapUnderscoreToCamelCase 配置,则返回字段会自动转驼峰,联调会顺利很多。
6. 把项目跑起来:环境搭建与本地部署的完整过程
6.1 环境版本:避免“我这版本怎么跑不起来”
先看源码里 pom.xml 的 spring-boot-starter-parent 版本,再决定本地 JDK 和 Node 版本,这是最稳的路线。Spring Boot 2.x 对应 JDK 1.8 或 11,Spring Boot 3.x 必须 JDK 17 以上。Vue2 项目用 Node 14/16,Vue3 项目建议 Node 16/18。MySQL 5.7 和 8.0 都行,注意驱动依赖要对应版本。
数据库连接配置里有个容易踩的坑:MySQL 8.0 和 5.7 的驱动类名不同,5.7 用 com.mysql.jdbc.Driver,8.0 用 com.mysql.cj.jdbc.Driver。同时 8.0 的连接串里要带 serverTimezone 参数,否则会报时区错误。推荐直接用 8.0,因为 5.7 已经停止新特性更新,新项目没必要再选旧版本。
如果你需要在 Linux 服务器上安装 MySQL,最省事的方式是用系统包管理器:CentOS 用 yum,Ubuntu 用 apt。安装完成后记得修改 root 密码、创建业务数据库和专用账号,不要用 root 直接跑应用。MySQL 的官网下载页面可以提供不同版本的安装包,但在 Ubuntu 上用 apt 安装其实是最不容易出错的路径。
6.2 后端启动步骤与常见错误
后端启动步骤:导入 Maven 项目、修改 application.yml 里的数据库连接(地址、端口、账号、密码)、执行项目里的 sql 脚本建库建表、再执行种子数据脚本、启动 Application 主类。
启动时最常见的报错是 Failed to configure a DataSource,十有八九是数据库没连上或配置文件里的库名写错。如果看到 Access denied for user,检查 MySQL 账号密码和远程连接权限。如果所有配置都对但启动还是慢,考虑是不是本机没有配置 Maven 阿里云镜像,依赖下载太慢导致启动等待时间长。
还有一个小技巧:启动前先在 MySQL 客户端里执行项目提供的 sql 脚本,确认建表语句有没有报错。有些源码提供的脚本可能是从生产库导出的,里面带着多余的历史数据或外键约束,直接执行可能报错。如果出现这类问题,把建表语句单独抽出来执行,比在应用启动脚本里逐步排查高效得多。
6.3 前端启动步骤与跨域处理
前端启动步骤:npm install 安装依赖、把 src/api 目录下请求的 baseURL 改成后端地址或配置代理、npm run serve 启动。
npm install 如果很慢,建议临时切换淘宝镜像源:npm config set registry https://registry.npmmirror.com。装完如果缺依赖或版本冲突,删掉 package-lock.json 重新 install 通常能解决。启动后如果页面能打开但接口全部报 500 或网络错误,先看浏览器 Network 面板,再对比后端日志,重点排查跨域和路径拼接问题。
开发调试强烈建议装 Vue devtools 插件,它可以直接查看 Vue 组件树、store 状态和路由信息,对排查“为什么这个数据没渲染出来”这类问题极其有用。后端写 Mapper XML 建议装 MyBatisX 插件,XML 里的 SQL 有高亮提示,还能从 Mapper 接口方法直接跳转到 XML,再也不用来回翻文件找 SQL。
6.4 初始化数据:没有这些数据,前端页面一片空白
影院系统不是空表就能玩的,一定要有种子数据:至少 3 部电影、2 个影厅、每个影厅 2-3 个场次、每个场次对应的座位快照。很多同学把项目跑起来后发现首页空白,原因就是没执行种子数据脚本,或者脚本没生成场次座位。
要怎么检查场次座位生成逻辑?新建一个场次,然后查 session_seat 表,看记录数是否等于该影厅排数乘以列数。比如建了一个 10 排 16 列的场次,10 乘以 16 等于 160,session_seat 里就该有 160 条记录。如果少于这个数,说明生成逻辑有 bug,而不是前端渲染问题。
有个细节值得注意:种子数据里的电影海报 URL 最好是本地静态资源路径或可访问的图片链接,否则前端页面会出现一堆裂图,影响调试心情。
7. 从“能跑”到“能上线”:这套系统还需要补什么
7.1 会话管理升级:JWT 无状态化与 Redis 共享
这套系统如果只是本地跑通,用简单 JWT 就够了。但真要部署到多台服务器,JWT 无状态化的优势就体现出来了:每个请求自包含用户身份,后端无需查会话表。它的代价是登出和踢人不好做,token 在过期前无法主动撤销。如果要做更精细的用户控制和单点登录,可以把 token 存到 Redis,拦截器校验的时候先查一下 Redis,就能实现主动失效。
单机部署时,JWT 加 Redis 存储属于锦上添花;多实例部署时,这就是必须项。不过要注意引入 Redis 后,原来的本地缓存策略要重新梳理,否则会出现一个实例缓存了用户信息、另一个实例却查不到的尴尬情况。
7.2 热点缓存策略:电影列表与剩余座位数
影院系统里的热点数据主要是首页电影列表和某场次的剩余座位数。电影列表变更频率低,可以缓存到 Redis,设置 5 分钟左右的 TTL,数据库压力能降一大截。剩余座位数的计算可以维护一个计数器,下单时扣减,退款时回补。
但有一点必须拎清楚:缓存可以延迟几秒,座位锁定和订单状态必须实时准确。座位状态这种强一致的数据不要走 Redis 缓存,直接查库最稳。缓存是扛读压力的,不是解决数据一致性的,把这两件事搞混,业务数据早晚会出错。
7.3 订单超时释放座位:定时扫描到延迟队列的演进
订单锁定座位但超过 20 分钟未支付,座位要释放给下一个用户。实现方案有三种递增级别:
- 定时任务扫表:最简单,每 1 分钟扫一次订单表,把超过 expire_time 且 status 为 0 的订单置为取消,对应的座位重置为可选。缺点是释放有延迟,极端情况下用户等了一个多小时才看到座位被释放。
- 延迟队列:下单时把 order_no 放入 Redis 的 ZSet 或 RabbitMQ 延迟队列,到期后消费消息处理取消订单。实时性和准确性更高,实现复杂度也可接受。
- 消息队列延迟消息:比如 RocketMQ 的定时消息,原理类似,但引入了额外中间件,运维成本更高。
如果是课程设计或小规模项目,定时任务扫表完全够用。如果要写进简历,把延迟队列方案写上去会更有亮点,也更容易在面试里聊出深度。
7.4 安全加固与运营能力扩展
安全这块,虽然这类系统的用户量不大,但该补的项一个都不能省。用户输入的昵称、评论等字段要用全局 XSS 过滤器清洗;所有 SQL 走 MyBatis 的 #{} 参数绑定,防止 SQL 注入;管理端接口做 IP 白名单或接口限流;上传文件时不能只信 Content-Type,要用工具检测文件的真实类型。特别是上传海报、PDF 这类文件,如果攻击者把一个包含恶意脚本的 HTML 伪装成 PDF 上传,再通过浏览器打开,就可能形成存储型 XSS。用 Apache Tika 检测真实文件类型,不是目标类型就直接拒绝。
运营侧如果需要给管理层看票房、上座率报表,可以用现成的报表服务器整合方案,把按日、按月的票房数据导出成 Excel 或 PDF,比自己在页面上堆图表更省力。如果后续要接入退款审批流程,可以集成 Flowable 工作流引擎,把“用户申请退款 - 后台审批 - 退款处理”串成一个流程。这些扩展虽然不在原始源码里,但都是在跑通核心链路之后自然延伸的方向。
这套系统跑通之后,我个人最深的体会是:影院购票系统的核心价值不在用多新的技术,而在于把“座位不超卖、订单状态不错乱、超时不占座”这几件事做扎实。我在本地跑项目的时候,最触动我的就是选座并发那一行 UPDATE——看起来只有一句 SQL,但想清楚为什么这一句能解决问题,比多写一百行业务代码更有收获。如果你正拿这套源码做课程设计或者练手,建议不要只跑通就收工,自己动手把订单超时释放、排片冲突校验、并发抢座这几个逻辑重新实现一遍。跑通一遍和亲手写通一遍,完全是两种体验。