简介:这是一套基于SpringBoot与Vue前后端分离架构的电影售票及影院管理系统源码,面向正在学习JavaWeb开发或以课程设计、毕业设计为目标的学生,也适合需要参考完整业务闭环的初级全栈开发者,可直接导入开发工具运行调试。压缩包共314个文件,大小约16.14MB,文件类型以81个Java后端源码、41个Vue单文件组件、15个XML配置、15个JS脚本、11个PNG及102个JPG静态资源为主,另有SQL数据库脚本和项目说明文档,前后端目录边界清晰,具备较好的工程完整性。目前已有71人参与学习,覆盖用户注册登录、电影信息维护、影院与排片管理、在线选座、订单生成、支付对接及后台权限管理等核心模块。通过研读这套代码,能够掌握SpringBoot自动配置、RESTful接口设计、SpringSecurity鉴权等后端实践,同时熟悉Vue路由、Vuex状态管理、Axios请求封装等前端用法,是一份可直接复用和二次开发的实战参考资料。
1. 一个真正能跑通全链路的 SpringBoot+Vue 项目:电影售票及影院管理系统,拆之前先理清这几点
动手拆这个基于SpringBoot + Vue的电影售票及影院管理系统之前,先说结论:它不是一个只有增删改查的练习,而是一套完整的前后端分离业务系统。后端用 SpringBoot 提供 RESTful API,前端用 Vue 管理后台和用户购票端,MySQL 存数据,核心链路覆盖电影上架、影厅排片、在线选座、订单支付状态流转。这个包真正值得看的不是界面,而是选座锁座和订单状态怎么对齐——很多类似项目就死在这个环节。适合准备 SpringBoot 毕设的人、正在练全栈实战的人,或者想找一套能直接跑起来当骨架改的开发者。
2. 先看架构再看表:系统分层和六张核心表设计
读代码之前先建立全局视图,后面联调时找文件会快很多,遇到问题也能判断问题处在哪一层。这个项目的整体结构不复杂,但把结构关系看懂再动手,效率会差好几倍。
2.1 前后端分离的分层结构:SpringBoot 三层与 Vue 的 views、router、api 一一对应
后端是标准三层:controller 接收请求、service 写业务逻辑、mapper 做数据库操作。前端则是 views 放页面、router 管路由、api 封装请求。拆源码时抓住“后端一个 controller 对应前端一个 api 模块和一个页面”这条线,整套代码很快就能读顺。
后端 controller 大致包含这些:AuthController(登录注册)、MovieController(电影列表、上架下架)、SessionController(排片场次)、SeatController(锁座释放)、OrderController(创建订单与状态更新)、AdminController(后台统计)。每个 controller 只做参数校验和结果返回,业务逻辑全部下沉到 service 层,数据库操作在 mapper 层通过 MyBatis-Plus 完成——单表 CRUD 直接用 BaseMapper 内置方法,多表关联才写 XML。
前端目录结构一般长这样:src/api/ 按后端 controller 拆分请求模块,axios 实例统一创建,baseURL 写 /api,登录后从 localStorage 取 token 挂到请求头;src/router/ 分 admin 和 user 两套布局,登录态用路由守卫控制;src/views/ 里 admin 下是电影管理、影厅管理、场次管理、订单管理,user 下是电影列表、电影详情、选座页、订单确认、我的订单。
联调时最常见的现象是“接口通了但页面数据不对”,九成是前端使用的字段名和后端返回的 JSON 字段对不上。所以拿到源码后我的习惯是:先找后端返回的 JSON 结构,再比对前端页面的 data 绑定,确认字段没拼写差异,再来跑页面。这个习惯比打开页面直接点点点节省大量时间。
2.2 六张核心表:用户、电影、影厅、场次、座位、订单的关系设计
这个项目没有单独的库存表,库存是通过“场次 + 座位”组合隐式表达的。一部电影可以排到多个影厅的多个场次,每个场次创建时生成一批独立的座位记录,用户买票就是在某场次的座位记录里改状态。这个设计在中小规模完全够用,也直接决定了锁座逻辑该怎么写。
核心表结构整理如下,不同版本的源码字段可能略有增减,但主干基本一致:
| 表名 | 核心字段 | 作用 |
|---|---|---|
| sys_user | id、username、password、phone、role | 用户与管理员,role 区分 0/1 |
| movie_info | id、title、poster、duration、director、type、release_date、status | 电影元数据与上架状态 |
| cinema_hall | id、name、seat_rows、seat_cols | 影厅名称和座位规模 |
| movie_session | id、movie_id、hall_id、start_time、end_time、price、status | 场次排片,关联电影和影厅 |
| seat | id、session_id、row_no、col_no、status | 某场次下的座位,状态 0/1/2 |
| order_info | id、order_no、session_id、user_id、seat_ids、total_price、status | 订单主表,seat_ids 存座位坐标串 |
关系上的核心是 movie_session。它把 movie_info 和 cinema_hall 关联起来,seat 挂在 movie_session 下面,生成多少座位由 cinema_hall 的 seat_rows 和 seat_cols 决定。order_info 记录 session_id 和 seat_ids,查询某个用户的订单时,按 user_id 过滤就能拿到该用户买了哪些场次哪些座位。
设计上有一个值得留意的点:seat 表不是全局座位,而是“场次下的座位实例”。同一个影厅、不同场次会有各自的 seat 记录,互不影响。这个拆法牺牲了一点存储空间,但换来了锁座的简单可靠——每个场次的座位状态独立,不需要在锁座时去判断“这个座位在另一个场次是否被占用”。与之相对的是把座位绑在影厅上的做法,那种设计在排片交叉时会非常麻烦,查状态要额外关联场次,并发控制也更绕。
另外记住 seat 表的 status 字段:0 表示可用,1 表示已被锁定未支付,2 表示已售出。0 到 1 是用户选座锁单,1 到 2 是支付成功,1 回到 0 是超时释放。后面章节的锁座、并发控制、定时任务,全部围绕这个字段展开。
有些版本还会在 order_info 里加 expire_time 或 pay_time,用支付截止时间做前端倒计时。如果源码里没有这个字段,建议自己加一个,后面做超时释放时能把 SQL 写得更精确。
3. 把系统在本地跑起来:数据库初始化、后端配置与前端联调
资源包通常自带 SQL 脚本和前后端两套工程。第一次跑通完整流程需要三个动作:建库导数据、启动后端、启动前端。每个环节都有固定配置点,改完一次就能稳定联调。
3.1 后端启动:建库导数据与 application.yml 里的数据源配置
先确认资源包里 sql 文件的位置,通常在 sql/ 或 db/ 目录下,文件名类似 cinema_db.sql。建议用命令行导入,避免图形化工具在编码上出意外:
mysql -uroot -p --default-character-set=utf8mb4 -e "CREATE DATABASE cinema_db DEFAULT CHARACTER SET utf8mb4;" mysql -uroot -p cinema_db --default-character-set=utf8mb4 < cinema_db.sql第一条命令建库,指定默认字符集 utf8mb4;第二条导入数据。如果 .sql 文件里已经写了 CREATE DATABASE,建库这步可以跳过,直接导入即可。导入后先用一条 SELECT 验证表数量,比如 SHOW TABLES,确认六张核心表都在,别急着启动后端。
然后改后端的 application.yml:
server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/cinema_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: mapper-locations: classpath:/mapper/*.xml configuration: map-underscore-to-camel-case: true这段配置有三个关键点。url 里的 serverTimezone=Asia/Shanghai 必须保留,否则 MySQL 8 的 LocalDateTime 读写会出现 8 小时偏差;driver-class-name 用 com.mysql.cj.jdbc.Driver 对应 mysql-connector-java 8.x,如果项目里写的是旧的 com.mysql.jdbc.Driver,说明依赖版本偏老,建议统一升级;map-underscore-to-camel-case 必须为 true,否则表里的 seat_rows 映射不到实体类的 seatRows 字段,查出来的结果全是 null。
如果项目用了 MyBatis-Plus 的逻辑删除配置,比如全局配置了 logic-delete-field 而表里没有 deleted 字段,启动后所有列表查询都会自动追加 AND deleted=0,结果是什么都查不到。遇到“数据明明在库里但页面空白”的情况,优先检查这个开关。
启动命令一般是 mvn spring-boot:run 或直接用 IDE 跑 main 方法。注意端口冲突:8080 被占用时后端起不来,改成 8081 就要同步改前端代理的 target。
3.2 前端起步:依赖安装、devServer 代理与 axios 请求封装
前端目录通常在 vue/ 或 frontend/ 下。先装依赖,node 版本较高时经常撞上 peer 依赖问题:
cd frontend npm install # 如果报 ERESOLVE 错误,加 --legacy-peer-deps 重装 npm install --legacy-peer-deps npm run serve项目基于 Vue CLI 的代理配置在 vue.config.js,基于 Vite 的在 vite.config.js 的 server.proxy 里。以 Vue CLI 为例:
module.exports = { devServer: { port: 3000, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } }配了代理后,前端 axios 请求直接写相对路径 /api/login,开发服务器会把请求转发到 8080,浏览器层面不出现跨域。如果前端代码里把 baseURL 写死成 http://localhost:8080,代理就不生效,跨域问题会原样暴露。
axios 封装一般是统一在 src/api/request.js 里创建实例、设置拦截器:
import axios from 'axios' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.request.use(config => { const token = localStorage.getItem('token') if (token) { config.headers.Authorization = `Bearer ${token}` } return config }) request.interceptors.response.use( res => { if (res.data.code === 200) return res.data.data return Promise.reject(new Error(res.data.msg)) }, err => Promise.reject(err) ) export default request这里注意 Authorization 头要不要带 Bearer 前缀,取决于后端 JWT 拦截器的写法。拿到源码后搜一遍后端的 token 解析逻辑,确认头部格式,否则登录成功后所有接口还是报认证失败。项目中如果后端返回结构不是 { code, msg, data },拦截器里对应的取值也要同步改。
3.3 双端入口:管理员和普通用户两套角色的登录与路由守卫
项目通常有一个登录入口,提交用户名密码后后端返回 token 和用户信息,前端根据角色字段决定跳转方向。数据库里一般预置了管理员账号和测试用户账号,先用预置账号登录跑通流程,别急着改密码。
路由守卫写在 src/router/index.js:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.meta.requiresAuth && !token) { next('/login') return } if (to.path.startsWith('/admin') && token) { const userInfo = JSON.parse(localStorage.getItem('userInfo')) if (userInfo.role !== 1) { next('/') return } } next() })守卫的逻辑很简单:未登录不能进受保护页面,普通用户不能进 admin 前缀路由。这里最容易翻车的是 localStorage 里存 userInfo 时格式不统一——存的时候是字符串,取的时候按对象 parse,或者反过来,都会导致页面直接报错。统一写法:
localStorage.setItem('token', res.data.token) localStorage.setItem('userInfo', JSON.stringify(res.data.userInfo))再确认后端返回的角色字段名。前端对比的是 role,后端返回的却是 roles,或者字段名是 userType,都会导致权限判断永远不通过。搜后端登录接口的返回值就能看到真实字段名,前端保持一致。
4. 核心业务实现:排片建座、原子锁座与前端选座组件
这一章是系统含金量最高的部分,也是面试或答辩最常被追问的模块。先看后端怎么建座位,再看锁座如何防超卖,最后看前端怎么渲染座位图和提交选择。
4.1 排片与建座:一个场次生成独立座位记录的过程
新建场次时,前端传入 movie_id、hall_id、start_time、price。后端先向 movie_session 插入一条场次记录,然后按影厅的 seat_rows 和 seat_cols 批量生成座位。行号和列号从 1 开始,不要从 0 开始,否则前端展示时容易错位。
// SessionService.java @Transactional(rollbackFor = Exception.class) public Long createSession(SessionDTO dto) { MovieSession session = new MovieSession(); session.setMovieId(dto.getMovieId()); session.setHallId(dto.getHallId()); session.setStartTime(dto.getStartTime()); session.setEndTime(dto.getEndTime().plusMinutes( movieService.getById(dto.getMovieId()).getDuration())); session.setPrice(dto.getPrice()); session.setStatus(1); sessionMapper.insert(session); CinemaHall hall = hallMapper.selectById(dto.getHallId()); for (int row = 1; row <= hall.getSeatRows(); row++) { for (int col = 1; col <= hall.getSeatCols(); col++) { Seat seat = new Seat(); seat.setSessionId(session.getId()); seat.setRowNo(row); seat.setColNo(col); seat.setStatus(0); seatMapper.insert(seat); } } return session.getId(); }逻辑说明:@Transactional 保证场次和座位一起成功或一起回滚,不会出现场次建好但座位缺一半的脏数据。end_time 不依赖前端传,用电影时长推算,减少人为录入错误。建座用双重 for 循环,座位数量完全由影厅的 seat_rows 和 seat_cols 决定。
如果影厅座位数很大,循环逐条 insert 会慢,可以改成拼接多值 INSERT 一次插入几百行。几百个座位的场景循环写法完全够用,报错定位也更直观,没必要提前优化。注意 createSession 的入参里如果没有 end_time 字段,后端根据 startTime + duration 计算是正确做法,有的项目让前端传 end_time,人工填错会导致排片检查逻辑出错。
4.2 锁座接口:用一条 UPDATE 语句防并发超卖
锁座是整个项目最需要防并发的地方。两个用户同时点同一个座位,如果代码是先 SELECT 再 UPDATE,两个请求都会看到 status=0,结果超卖。正确做法是把座位条件和状态变更放到同一条 UPDATE 语句里,靠受影响行数判断锁定结果。
// SeatService.java public boolean lockSeat(Long sessionId, int rowNo, int colNo) { int affected = seatMapper.lockSeat(sessionId, rowNo, colNo); if (affected == 0) { return false; } // 锁座成功后生成待支付订单 orderService.createPendingOrder(sessionId, rowNo, colNo); return true; }对应的 SeatMapper XML:
<!-- SeatMapper.xml --> <update id="lockSeat"> UPDATE seat SET status = 1 WHERE session_id = #{sessionId} AND row_no = #{rowNo} AND col_no = #{colNo} AND status = 0 </update>这条 SQL 依赖的是数据库行锁:同一时间只有一个事务能把这一行的 status 从 0 改成 1,另一个事务执行时匹配不到 status=0 的行,affected 为 0,自然被判定为抢座失败。比在应用层加 synchronized 或引入 Redis 分布式锁都简单,也是这类单体项目最实用的方案。MyBatis-Plus 的 updateById 达不到这个效果,必须用自定义 SQL。
seat 表需要额外加一个 lock_time 字段,锁座时同时写入锁定时间,后续超时释放依赖它。没有这个字段,用户选座后不支付,座位会永远卡在 status=1,后台只能手动清数据。
orderService.createPendingOrder 内部要注意幂等:同一个 sessionId 加同一组座位,不应该生成两条待支付订单。常见做法是在 order_info 表对 (session_id, seat_ids) 加唯一索引,或者创建前先查是否已有待支付订单。前端用户手抖连点两下提交按钮,后端必须兜住这层。
4.3 前端选座组件:把座位记录映射成二维座位图
选座页从场次列表进入,拿到 sessionId 后请求后端的座位列表接口,返回的是当前场次的全部座位记录。前端把记录按 rowNo 分组,渲染成排。
<template> <div class="seat-map"> <div v-for="(row, rIdx) in seatRows" :key="rIdx" class="seat-row"> <span class="row-label">{{ rIdx + 1 }}排</span> <div v-for="(seat, cIdx) in row" :key="cIdx" class="seat" :class="seatClass(seat)" @click="chooseSeat(seat)" > {{ cIdx + 1 }} </div> </div> </div> </template>// 状态 0 可用、1 锁定、2 已售、3 本地选中 const selectedSeats = ref([]) function seatClass(seat) { return { available: seat.status === 0, locked: seat.status === 1, sold: seat.status === 2, picking: seat.status === 3 } } function chooseSeat(seat) { if (seat.status === 1 || seat.status === 2) return if (seat.status === 0) { seat.status = 3 selectedSeats.value.push({ row: seat.rowNo, col: seat.colNo }) } else if (seat.status === 3) { seat.status = 0 selectedSeats.value = selectedSeats.value.filter( s => !(s.row === seat.rowNo && s.col === seat.colNo) ) } }最关键的细节:前端选中的状态用 3 表示,不能直接把后端返回的 status 改成 1。因为锁定是后端动作,前端把 status 改成 3 只是界面高亮,点击提交时把选中坐标列表交给后端锁座接口,后端成功后刷新座位列表验证状态。如果前端直接改 status=1,页面刷新后座位真实状态重新拉取,会发现界面和实际不符。
提交前可以加一层前端校验:用户再次点击已选座位时,如果座位实际状态变成 1 或 2,说明被别人抢先锁了,这时前端要提示座位已不可用并刷新数据,而不是让请求带着旧数据打到后端被驳回。这个小校验能省很多无谓的接口调用,用户的体感也会更好。
5. 运行时最常踩的五个坑:现象、原因、解决方案逐个说
这一章把本地运行里最容易翻车的场景列一遍,每条都按“现象 → 原因 → 解决”整理,这些都是拆类似项目时反复遇到的问题。
5.1 现象:前端请求后端报 CORS 跨域或直接 404
现象:浏览器控制台出现 CORS policy 报错,或者请求发出去了但返回 404。
原因:前端 devServer 端口(常见 3000)和后端端口(8080)不同,前端 axios 又写了绝对地址,浏览器做了跨域拦截;如果路径带了 /api 但代理没配上,请求会被转发到后端不存在的接口,返回 404。
解决:前端 baseURL 改成相对路径 /api,并在 vue.config.js 配好 proxy。后端若想直接解决跨域,可以加 CorsFilter 配置类:
@Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOrigins("http://localhost:3000") .allowedMethods("GET", "POST", "PUT", "DELETE"); } }注意前端代理和后端 CORS 二选一即可。两者同时开时,某些浏览器会对预检响应做二次校验,反而报错,表现问题为“有时候通有时候不通”,很容易误判成偶发 bug。选择代理方案更干净,因为部署到生产时前端和后端往往同源,代理逻辑可以直接淘汰。
5.2 现象:场次时间显示跟数据库差 8 小时
现象:数据库 start_time 是 19:00,前端页面显示 11:00,或者反过来差 8 小时。
原因:JDBC 连接时未指定 serverTimezone,或 Spring Boot 序列化 LocalDateTime 用的时区不是东八区。两处时区不一致,数据库时间和接口返回时间就会错位。
解决:数据源 url 加 serverTimezone=Asia/Shanghai,同时 application.yml 配置 Jackson 时区:
spring: jackson: time-zone: GMT+8 date-format: yyyy-MM-dd HH:mm:ss改完重启后端,看接口返回 JSON 里的时间字段是否还带 T。不带 T 且对应东八区是正常状态;如果带 T 或差了整小时,再检查数据库连接参数。这个坑在 MySQL 8 里尤其高发,因为它的默认时区设置和旧版本差别很大。
5.3 现象:两个用户同时抢同一个座位,两条订单都建成功
现象:浏览器开两个窗口用不同账号同时选同一个座位,两边都提示下单成功,座位图里却只有一个显示锁定。
原因:锁座逻辑写成了先查再改。并发时两个请求同时读到 status=0,先后都执行了更新,第二个请求没有得到数据库的约束。SELECT 到 UPDATE 之间的时间窗口就是超卖漏洞。
解决:换成带条件 UPDATE,把 status=0 条件和座位坐标一起写进 WHERE,受影响行数非 0 才算锁座成功:
<update id="lockSeat"> UPDATE seat SET status = 1 WHERE session_id = #{sessionId} AND row_no = #{rowNo} AND col_no = #{colNo} AND status = 0 </update>同时给创建订单方法加 @Transactional,锁座和订单插入在同一事务,任何一步失败整体回滚。更保险的一层是给 order_info 加唯一索引防重订单,三层做下来基本没漏洞。
5.4 现象:前端打包后放进 SpringBoot 的 static 目录,刷新页面 404
现象:npm run build 后的 dist 文件拷贝到 src/main/resources/static 下,重新打包启动,首页访问正常,点击路由跳转也正常,但直接刷新 /movie/3 或 /order 页面,出现 SpringBoot 的 Whitelabel Error Page。
原因:Vue 用了 history 模式,前端路由路径在浏览器地址栏是真实 URL,刷新时浏览器直接请求该路径,后端没有匹配的 controller,返回 404。hash 模式 URL 带 #,刷新不会打到后端,但地址栏不美观,很多项目还是会开 history。
解决:后端加一个转发规则,非 /api 开头且不带文件后缀的请求,统一转发到 index.html,交给前端路由接管:
@Controller public class IndexController { @GetMapping(value = {"/{path:[^\\.]*}", "/{path:^(?!api$).*}/**/{path:[^\\.]*}"}) public String forward() { return "forward:/index.html"; } }这里的正则特意排除了带点号的路径,js、css、图片等静态资源不会被误转发。如果上传后仍然 404,确认前端 dist 里的 index.html 真的被放进了 classpath,而不是被构建工具清掉或覆盖。这个问题在把前端打包放进 SpringBoot 时几乎必踩一遍,记住这个 ForwardController 就能绕过去。
5.5 现象:数据库导入后中文乱码,电影名全部变成问号
现象:导入 .sql 文件后,查询 movie_info 表,title 字段显示 ??? 或一堆乱码符号。
原因:SQL 文件本身的编码不是 UTF-8,或 MySQL 连接字符集不对。Windows 环境下很多 .sql 文件默认 GBK 编码,直接导入,表里落了一堆乱码,后面查出来自然也是错的。
解决:先在 MySQL 侧执行 SET NAMES utf8mb4;,再用支持编码转换的方式导入。用文本编辑器打开 .sql 查看右下角编码,如果是 ANSI 就另存为 UTF-8 再导入。导入后检查表的字符集:
ALTER TABLE movie_info CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;然后重新导数据,用 SELECT title FROM movie_info LIMIT 5 验证。乱码问题一旦在底层出现,不只是导数据的问题,字符集不对会导致后续所有中文写入都出问题,必须从建库字符集开始统一。
6. 进阶实操:给这套系统补上缓存和超时释放,并验证完整链路
系统能跑以后,往生产方向推一步还需要两件事:热点数据进 Redis、锁座超时自动释放。这两件事在这个业务场景里价值最直接,改动量也不大。
6.1 缓存热门电影列表:五分钟级缓存加手动失效
电影列表和场次列表是典型的读多写少,适合加缓存。用 StringRedisTemplate 存 JSON 是成本最低的方式,不需要引入额外的缓存框架。
@Service public class MovieService { @Autowired private StringRedisTemplate redisTemplate; public List<MovieInfo> getHotMovies() { String key = "hot:movies"; String cached = redisTemplate.opsForValue().get(key); if (cached != null) { return JSON.parseArray(cached, MovieInfo.class); } List<MovieInfo> list = movieInfoMapper.selectList( new LambdaQueryWrapper<MovieInfo>() .eq(MovieInfo::getStatus, 1) .last("LIMIT 10") ); redisTemplate.opsForValue().set( key, JSON.toJSONString(list), 10, TimeUnit.MINUTES); return list; } }Redis key 命名加业务前缀方便管理,比如 hot:movies、session:1:seats,不同业务的数据不会互相撞 key。缓存时间设 10 分钟是均衡值,电影列表不需要秒级最新。如果管理员后台上架新电影想立即生效,在上架接口里手动调用 redisTemplate.delete(key) 删缓存,而不是等自然过期。StringRedisTemplate 序列化方式就是字符串,JSON 存取不会出现类型转换问题,比 RedisTemplate 默认的 JDK 序列化省心很多。
6.2 验证完整链路:从建场次到锁座、下单、超时释放
最后用命令把核心链路验证一遍。这个方法比页面点击可靠,能直接看到每个接口的返回状态,也方便定位是后端问题还是前端渲染问题:
# 管理员登录获取 token curl -X POST http://localhost:8080/api/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":"123456"}' # 登录响应里取到 token 后,查询场次 curl -X GET http://localhost:8080/api/session/list \ -H "Authorization: Bearer <token>" # 对返回的第一场次锁座 3排5座 curl -X POST http://localhost:8080/api/seat/lock \ -H "Authorization: Bearer <token>" \ -H "Content-Type: application/json" \ -d '{"sessionId":1,"rowNo":3,"colNo":5}'锁座成功后查询订单表,确认订单是待支付状态;再查 seat 表,确认这一行 status 变成 1。超时释放用定时任务或手动执行 SQL 验证:
UPDATE seat SET status = 0 WHERE status = 1 AND lock_time < NOW() - INTERVAL 15 MINUTE执行后重新查刚才的座位,status 应该回到 0,前端刷新后座位恢复可用。释放逻辑要注意一个前提:释放前确认对应订单仍是待支付状态,不能把已支付订单的座位释放掉,SQL 里加一个 NOT EXISTS 子查询,排除掉已支付订单关联的 seat_id。
这套验证流程我每次接手类似项目都强制走一遍:先接口层确认后端行为没问题,再打开页面看渲染结果。直接点页面遇到问题时会分不清是后端接口返回了脏数据,还是前端渲染写错,排查成本高很多;先 curl 跑通核心链路,前端页面再出问题就只可能是渲染层的事,定位范围缩小一大半。从那以后我拿到任何 SpringBoot+Vue 源码,第一件事永远是先把锁座、下单、释放三个关键接口用 curl 验证一遍,再碰页面代码,这套习惯让我拆项目又快又稳,希望帮到你。
本文还有配套的精品资源,点击获取