做了好几套前后端分离的项目之后,我越来越觉得影院订票系统是Java全栈开发里被低估的练手题材。它业务规模不大,但覆盖了权限校验、库存并发、订单状态机、支付回调这些真正值钱的技术点;它看起来只是选个座、付个款,但能把表结构设计、接口鉴权、前端状态管理这些基本功全部串起来。这篇文章就从我搭这套SpringBoot+Vue3+MyBatis前后端分离影院订票系统的完整过程说起,把核心模块的设计思路、关键代码、踩坑记录都摊开讲清楚,给准备用这个项目练手或者做课程设计的同学一个能直接参考的路线图。
1. 为什么选影院订票这个业务来串SpringBoot+Vue3全栈
1.1 业务复杂度刚好落在舒适区之外
影院订票系统的核心链路是:用户浏览电影和场次,选择座位,生成订单,支付,出票。这条链路看起来直白,但每一步都藏着全栈开发必须面对的真实问题。比如说选座,它不是简单地往数据库里插一条记录,而是要在同一时刻面对大量用户竞争同一个座位,这就要求座位状态不能靠简单的查询再更新去处理,必须考虑并发下的数据一致性。再比如说订单,生成了订单不代表交易完成,订单可能被支付、被取消,也可能超时未支付自动释放座位,这背后是一个典型的订单状态流转问题。
对比常见的图书管理系统或者博客系统,影院订票的独特之处在于它有时效性、有库存竞争、有状态流转。图书管理系统改个库存,错了还能手工调回来,影院订票在热门场次的选座环节,一秒钟的并发处理失误就会导致同一个座位被两个人同时锁定的严重事故。所以这个项目做下来,SpringBoot、Vue3、MyBatis这些技术本身反而不是最大的收获,最大的收获是你被迫认真思考数据一致性和接口边界问题,这正是从CRUD程序员往前走一步的关键门槛。
1.2 技术栈选择的实际考量
这套项目用的是SpringBoot 2.7.x + Vue3 + MyBatis + MySQL 8.0,没有引入过于花哨的组件。有人问为什么不用MyBatis-Plus,为什么不用Spring Cloud,为什么前端不上一套重型后台框架。我的想法很简单:这个阶段的项目核心是搞懂业务闭环和技术原理,MyBatis手写SQL能让你对每一条查询、每一次更新都有精确的控制力,尤其在座位锁定这种需要写条件更新的场景,手写SQL反而比MyBatis-Plus的自动填充更直观。
前端选Vue3而不选Vue2也是同样的逻辑。Vue3的组合式API在处理选座这种带二维状态、实时反馈的交互场景时,明显比选项式API更顺手。useSeatStore这样一个组合函数,把座位列表、已选座位、锁定状态封装在一起,组件里只需要调用方法、渲染状态,逻辑复用性强很多。同时Vue3配合Vite的开发体验也确实比Webpack那一套快不少,冷启动基本在几百毫秒级别,改代码热更新也跟手。
数据库选MySQL 8.0,主要是因为8.0的窗口函数和更好的JSON支持在一些报表和复杂查询场景里有优势,虽然影院订票系统用不太上,但学习成本不高,后续扩展也方便。
2. 数据模型设计:影院系统的地基怎么打
2.1 核心表结构和关联关系
影院订票系统的数据模型围绕两条主线展开:一条是内容线,也就是电影和场次;一条是交易线,也就是座位、订单和支付。
内容线方面,电影表保存影片标题、简介、海报、时长、上映日期等基础信息,场次表关联电影和影厅,记录放映时间、语言版本、票价。这里有一个容易忽略的设计点:票价应该挂在场次上而不是电影上。同一个电影在不同时间段、不同影厅可能定价不同,比如黄金时段和上午场的价格就有差异,如果把票价放在电影表里,后面每次调整都是一个数据库更新,设计上就僵化了。
交易线方面,座位表是这套系统的核心。影厅表和座位表一般做成父子关系,影厅记录名称、影厅类型、座位排数、每排座位数,座位表则记录具体的排号、列号、座位编码。订单表保存用户、场次、总价、状态、创建时间等信息,订单和座位的关联用一张中间表来实现,因为一个订单可以包含多个座位。
比较关键的一张表是座位状态表。我做项目的时候一开始把座位状态放在座位表里,后来发现行不通:同一个座位在不同场次的状态是独立的,7点场次的座位被选了,9点场次还可以买。所以座位状态必须和场次绑定,也就是说需要一张"场次座位表",记录某个场次下每个座位的状态。这个表的核心字段就是场次ID、座位ID、状态,状态可以设计成0空闲、1已锁定、2已售出。
2.2 座位建模:为什么不能图省事存字符串
很多人做影院项目喜欢偷懒,把座位信息存成一个字符串,比如"3排5座,3排6座",一个订单就是一行记录。这个做法在小规模Demo里能跑,但稍微一想就全是坑。首先,你无法在数据库层面做座位的唯一性约束,同一个座位的重复售卖只能靠应用层代码去判断;其次,选座的时候要统计哪些座位被选了,你得把字符串拆开、解析、对比,用不上任何索引;第三,一旦需要释放某个座位,字符串处理就成了灾难。
正确的做法是给座位一个全局唯一的编码,比如A厅3排5座的编码可以设计为A-03-05。在场次座位表里把场次ID和座位ID建成联合唯一索引,从数据库层面保证同一个座位的状态记录不会出现重复。这样后端在做选座的时候,执行一条条件更新SQL,就能原子地判断"这个座位是否仍然是空闲状态",如果不是就不更新,非常干净。
还有一个小细节,座位的排号、列号最好分别用整数保存,不要直接存"3排5座"这种带单位的字符串。排号列号拆开之后,前端生成选座界面时能直接通过两层循环渲染座位矩阵,排序也方便,按排号升序、列号升序一查就能输出带位置的座位序列。
| 表名 | 关键字段 | 设计要点 |
|---|---|---|
| film | id, title, duration, release_date | 价格不放在这里 |
| hall | id, name, rows, cols | 影厅行数列数驱动前端选座渲染 |
| schedule | id, film_id, hall_id, start_time, price | 价格挂在场次上 |
| seat | id, hall_id, row_no, col_no, seat_code | 全局唯一seat_code |
| schedule_seat | id, schedule_id, seat_id, status | 场次ID+座位ID联合唯一索引 |
| orders | id, user_id, schedule_id, total_price, status | 状态机字段 |
2.3 订单状态和超时取消的数据库设计
订单状态我设计了四个值:0待支付、1已支付、2已取消、3已退款。待支付是最需要考虑的状态,因为待支付订单如果没有后续动作,座位会被一直占用。常见的做法是在订单表里加一个expire_time字段,记录订单的过期时间,通常设成创建时间后15分钟。后台跑一个定时任务,每隔一段时间扫描一次待支付且已过期的订单,把订单置为取消,同时把关联的场次座位状态改回空闲。
这个设计看起来很常规,但有两个关键细节。第一,释放座位必须把"订单取消"和"座位释放"放在同一个事务里,否则会出现订单取消成功但座位没释放,导致座位永久占用的情况。第二,扫描任务要注意分批处理,一次不要查太多数据,可以按limit 100的方式一批批处理,避免一次大事务锁表影响正常业务。我在生产模拟环境里遇到过一个问题:定时任务每30秒扫描一次,每次处理几百条过期订单,其中有一条订单的座位释放SQL执行时间特别长,导致整个任务超时堆积。后来把扫描任务改成按过期时间排序、每次只取前50条,问题就消失了。
3. 后端SpringBoot落地:骨架规范和核心业务闭环
3.1 项目结构与分层规范
后端项目我按照标准的单体分层来组织:controller层接收参数、返回统一响应;service层写业务逻辑;mapper层对应MyBatis的数据库操作。包名结构大致是controller、service、mapper、entity、dto、common,common里放统一返回结果类、异常处理、工具类。
这里我想多说一句统一响应结构。前后端分离项目最怕的就是各写各的,后端返回一半是JSON一半是字符串,前端的响应拦截器就没法写。我的统一响应体设计很简单:
{ "code": 200, "message": "success", "data": {} }code为200表示成功,非200表示业务异常,比如401未登录、403无权限、500系统错误。前端axios拦截器里只判断code,拦截到401就跳登录页,干净利落。这个设计虽然简单,但能让前后端联调少吵很多架。
3.2 登录鉴权的JWT实践
影院订票系统有用户和管理员两种角色。用户端需要登录之后才能下单、查看订单,管理员端要管理电影、场次、座位数据。项目使用JWT做无状态鉴权,登录成功之后后端签发Token,前端存在localStorage里,每次请求通过Authorization头携带。
JWT的实现不复杂,但有几个坑要特别注意。首先是Token过期时间,很多教程喜欢把过期时间设置成两小时甚至一天,这在后台管理系统里勉强能用,但在影院订票场景下不合适。用户选座、下单、支付一套流程走下来可能超过十分钟,如果Token在支付的时候过期,体验就很差。我的做法是AccessToken设两小时过期,同时前端在axios响应拦截器里捕获401,用refreshToken换新Token,对用户无感续期。refreshToken过期时间设七天,存在localStorage里。
其次是JWT密钥的保管。密钥绝对不能写在代码里提交到仓库,我习惯放在application.yml里,通过环境变量注入。密钥至少32位,线上环境要定期更换,如果怀疑泄露要能立即作废。JWT的最大弱点是一旦签发,服务端无法主动让它失效,所以如果做用户封禁功能,JWT方案就不太够用,需要引入服务端黑名单机制。这个小系统暂时用不上,但你要知道自己在用什么方案,以及它的边界在哪。
3.3 选座锁座的核心并发逻辑
选座是整个系统含金量最高的部分。两个用户同时点同一个座位,系统必须保证只有一个人能锁座成功。实现这个功能靠的是MyBatis的条件更新SQL,而不是先查询再更新的Java代码。
先看错误示范。很多初学者写的是:先查询座位状态,如果空闲就执行更新。这个操作在并发场景下必出问题。两个请求同时查到座位是空闲的,然后都执行更新,最后的结果就是两个人都锁座成功。加上事务也没用,事务解决的是要么全部成功要么全部失败,解决不了"基于旧状态做判断"的并发覆盖问题。
正确的做法是直接把状态判断写进更新语句里:
UPDATE schedule_seat SET status = 1, lock_time = NOW(), user_id = #{userId} WHERE schedule_id = #{scheduleId} AND seat_id = #{seatId} AND status = 0这条SQL的意思是:只有当前座位状态是0空闲的时候,才把它改成1锁定,并且把锁定人和锁定时间记录下来。MySQL的UPDATE语句默认加行锁,两条并发请求同时执行这条SQL的时候,只有第一条能成功,第二条因为状态已经不是0了,影响行数为0,直接被判定为锁座失败。整个判断过程在数据库层面完成,不需要任何分布式锁,性能开销极小。
锁座成功之后,紧接着创建待支付订单。这里要处理的是锁座和建订单的一致性。我的做法是:在一个事务里完成"更新座位状态"和"插入订单记录"两步,两条SQL用同一个事务提交。事务提交成功后,座位锁了、订单也有了,不会出现座位锁了但订单没建成、或者订单建了但座位没锁上的脏数据。
还有一个细节是座位状态的清理。用户锁座之后没有支付,座位就一直锁着。除了依赖订单超时取消的后台任务之外,我还做了一层兜底:后端在锁座的时候记录lock_time,定时任务扫描状态为1锁定但已超过15分钟的座位,把状态重置为空闲。这层兜底主要是为了防止用户选完座、订单还没生成就关了浏览器,结果订单表和座位状态都对不上号的异常情况。
3.4 订单防重复提交和状态校验
订单环节有两个常见问题:用户重复点击下单按钮,以及用户把请求重放一遍。
重复点击的问题,前端按钮loading可以挡住大部分,但前端永远不能作为唯一的防线。后端我加了一个幂等处理:创建订单的接口要求传入一个前端生成的请求唯一标识,业务执行前先查一下这个标识是否处理过,处理过就直接返回上次的结果。实现方式可以查订单表里的request_id字段,也可以借助Redis的SETNX命令做一个短时锁,我用的是数据库唯一索引的方案,简单直接,不依赖Redis。
订单支付回调的验签和状态校验也要注意。支付的异步通知是可以重复发送的,后端处理通知幂等的关键是:收到通知后先查订单当前状态,如果已经是已支付,直接返回成功,不再做任何处理。我刚开始做的时候没注意这个问题,测试中连续两次收到同一笔订单的通知,第二次把订单状态又更新了一遍,金额没变还好,但逻辑上是错的。正确的流程是:判断订单状态 -> 校验金额 -> 更新订单状态 -> 更新座位状态为已售出 -> 返回通知成功。
4. 前端Vue3的开发节奏:页面组织和关键交互
4.1 Vue3的项目结构和路由设计
前端用的是Vite + Vue3 + Pinia + Vue Router + Axios的经典组合。页面结构分成用户端和管理端两块,用户端有电影列表页、电影详情页、选座页、订单确认页、订单列表页、个人中心页;管理端有电影管理、场次管理、影厅管理、订单管理。路由设计上,用户端和管理端的路由分开嵌套,使用不同的布局组件,管理端通过路由守卫做登录权限校验。
这里要提一个选座页路由设计的细节。选座页需要带场次ID参数,路由路径设计成/schedule/:scheduleId/seat,组件内通过useRoute取参数,再调用接口加载电影、影厅和座位数据。不要用query参数传,因为用户刷新选座页时query参数可能丢失,而路径参数在刷新之后依然保留,用户可以放心刷新。
4.2 选座组件的双向联动状态设计
选座交互是前端最复杂的部分。加载场次座位数据之后,前端拿到的是一个座位列表,包含每个座位的行号、列号、状态。渲染方式很简单:按行号分组,两层循环生成座位矩阵。但交互上要处理三类座位:已售出的不能点,已锁定的不能点(别人的座位),自己选中的座位要继续保持高亮。如果已经被自己选中但状态已经变为锁定,说明被别人抢了,前端要弹出提示并刷新座位状态。
这里的状态管理用Pinia来封装一个useSeatStore。Store内部维护三个核心状态:座位列表seatList、已选座位的列表selectedSeats、当前场次ID。选座的操作是点击一个空闲座位,把它加入selectedSeats,再次点击就移除。提交订单时把selectedSeats传给后端,调用锁座接口。这套状态用组合式API的ref和computed实现很流畅:
const seatList = ref([]) const selectedSeats = ref([]) const selectedSeatCount = computed(() => selectedSeats.value.length) const selectedTotalPrice = computed(() => selectedSeats.value.length * schedulePrice.value) function toggleSeat(seat) { if (seat.status === 2 || seat.status === 1) return const index = selectedSeats.value.findIndex(s => s.id === seat.id) if (index === -1) { selectedSeats.value.push(seat) } else { selectedSeats.value.splice(index, 1) } }注意一个交互细节:座位状态为1锁定不代表不能点,如果锁定人恰好是当前用户自己,界面上应该显示为"已选",高亮显示,点了可以取消。所以选座交互不能只看后端返回的状态值,前端还需要区分"别人锁的"和"自己锁的"两种锁定状态,后者在本地表现为已选座位。实现方式是进入页面时把当前用户的已选座位初始化到selectedSeats里,同时把这些座位的展示状态改成已选样式。
4.3 Axios封装和与后端的联调约定
Axios我在项目里做了一层简单封装,核心是三个拦截器。请求拦截器统一往请求头里塞Token;响应拦截器统一处理code非200的情况,401跳登录页,其他错误统一弹提示。这层封装的意义是让业务代码里不需要重复写错误处理逻辑,组件里只需要关心成功分支。
service.interceptors.request.use(config => { const token = localStorage.getItem('accessToken') if (token) { config.headers['Authorization'] = `Bearer ${token}` } return config }) service.interceptors.response.use( response => { const res = response.data if (res.code !== 200) { if (res.code === 401) { router.push('/login') } else { ElMessage.error(res.message) } return Promise.reject(new Error(res.message)) } return res.data }, error => { ElMessage.error('网络异常,请稍后重试') return Promise.reject(error) } )5. 前后端联调的十个高频问题和排查思路
5.1 跨域和Cookie的纠缠
前后端分离项目联调第一关就是跨域。后端在SpringBoot里我直接使用CorsRegistry配置跨域规则,允许的前端地址写进配置文件。这里有一个容易被绕晕的问题:如果请求里需要携带Cookie,前端axios必须配置withCredentials为true,后端CORS配置里allowCredentials也要是true,而且allowedOrigins不能写成*,必须写具体域名。我第一次联调的时候前端报跨域,后端报CORS错误,最后定位到就是因为有一个配置用了allowedOrigins("*"),和allowCredentials(true)冲突。
现在的项目我用JWT做鉴权,Token放在Authorization头里而不是Cookie里,所以不需要withCredentials,跨域配置就宽松很多。这也是我推荐Token方案的原因之一,少很多配置上的麻烦。
5.2 日期时间格式化的统一处理
影院系统的场次时间、订单时间到处都是LocalDateTime,前后端联调时最容易出现的问题就是时间格式不统一。前端要求的是"2024-05-20 19:30:00"这样的格式,后端返回的却是"2024-05-20T19:30:00"这种带T的ISO格式,前端如果不处理就只能展示一个T在中间。
我的做法是在后端统一配置Jackson的日期序列化格式,全局生效,不用每个字段加注解:
spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8另外一个更容易踩的坑是时区问题。数据库存的是UTC时间,前端展示的是北京时间,如果不统一时区,场次时间会差8个小时。我在配置文件里显式指定了jdbc连接串的serverTimezone=Asia/Shanghai,同时在MySQL连接参数里设置useTimeZone=true,确保应用读取时区一致。
5.3 联调阶段最常见的逻辑错位
除了跨域和时间格式,联调阶段还会遇到一些业务逻辑上的错位。举例来说,后端的接口要求前端传一个整形的场次ID,前端传过来一个字符串,如果没有统一类型校验,数据库查询就会报错或者查不到数据。我会在后端service层统一加参数校验,使用Spring Validation对DTO字段做类型和范围校验,不符合直接返回参数错误,避免错误数据走到数据库层。
还有一种是状态不同步问题,比如用户下单之后,选座页显示座位还是空闲状态,因为没有刷新座位状态。这个问题的解决办法是用户从订单列表页或支付成功页返回重新进入选座页时,重新请求座位数据,而不是用页面缓存。有些人不理解为什么选座页每次进入都要重新请求,其实是故意的,座位状态是强时效数据,缓存了才是错的。
| 问题类型 | 典型表现 | 排查方向 |
|---|---|---|
| 跨域 | 浏览器报CORS | 检查allowedOrigins和allowCredentials |
| 时间格式 | 返回带T的时间 | 配置Jackson全局格式和时区 |
| Token过期 | 接口返回401 | 检查Token过期时间和refresh机制 |
| 状态不同步 | 座位显示异常 | 确认进入页面的请求时机 |
| 参数类型不匹配 | 后端报参数错误 | 统一DTO校验和前端参数格式 |
6. 项目部署前的性能优化和收尾建议
6.1 数据库索引的几个关键设计
影院订票系统的数据量在Demo阶段不大,但你不能因为这个就不考虑索引设计。我的数据库里有三组索引很关键:orders表里user_id和status的联合索引,用于用户查询自己的订单列表;schedule_seat表里schedule_id联合seat_id的唯一索引,这个索引同时保障了座位状态的唯一性和选座更新的查询性能;orders表里的request_id唯一索引,用于订单幂等。
索引设计需要遵循的一个原则是区分度优先。比如orders表的status字段,只有0、1、2、3三个值,区分度很低,单独给status建索引基本没用,必须和user_id组合使用。同理schedule_seat表里schedule_id的区分度很高,单独建后缀索引就够了。
6.2 部署方案和Nginx配置要点
本地开发完了,部署到服务器我用的是经典组合:前端打包成静态文件扔给Nginx托管,后端打包成jar包用systemd管理,MySQL单独部署。Nginx配置里核心是两部分:静态文件托管和API反向代理。前端访问/api开头的请求,Nginx转发到后端服务的8080端口:
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; }这个配置也有一个小细节。proxy_pass后面的URL如果带了路径,比如proxy_pass http://127.0.0.1:8080/,转发时会去掉location匹配的/api前缀;如果不带路径,转发时会保留/api。这取决于后端接口设计有没有带/api统一前缀,我这边是带了的,所以proxy_pass就写成了不带路径的写法。很多人在这一步栽跟头,转发之后发现后端404,其实不是Java代码的问题,就是Nginx路径拼接的规则没搞清楚。
6.3 从项目到简历的技能提炼
最后说一个个人经验。很多同学把项目做完就结束了,代码一扔,简历上写"实现了影院订票系统",面试官一问并发选座怎么做,答不上来。这个项目最值钱的不是CRUD写得多熟练,而是你能否把自己的设计决策讲清楚。为什么座位表要拆成影厅表和座位表、为什么选座用条件更新而不是更新前查询、为什么订单要加expire_time字段、为什么前端刷新页面之后要重新拉取座位数据。这些都是能体现技术深度的点。
我个人建议在每个核心模块动工之前,先花半小时把设计方案写成文档,把为什么这么做的理由罗列出来。这不仅是给面试官看的,更重要的是你在写代码的时候有一个明确的技术依据,不会写着写着就把方案改乱。我见过太多人做项目是从网上抄一段代码改个字段名就开始跑,项目是"跑起来了",但技术上的收获基本为零。这个项目做完能留在你脑子里的,不应该是哪句代码写得漂亮,而是你踩过的坑、做出的取舍、以及你对整个业务链路从数据到交互的理解。这些东西才能陪伴你走到下一个项目。