简介:这份毕业设计资源面向计算机相关专业学生与Java全栈初学者,提供一套基于微信小程序+SSM+MySQL的电影院订票选座系统完整方案,解决传统APP安装成本高、用户留存难的问题,将订票、选座、支付等核心流程迁移到小程序端。资源包共1138个文件,约45.81MB,包含103个Java后端源码、114个Vue前端组件、160个JS脚本、75个WXSS与73个WXML小程序页面文件,以及2个SQL数据库脚本、开题报告、毕业论文和mp4视频演示,另附png、svg、jpg等界面素材与bat启动脚本,覆盖从环境搭建到功能演示的完整链路。系统实现管理员对影院、电影、资讯及多状态订单的统一管理,用户可查看、收藏、评论影院与电影,完成选座支付与在线充值。目前已有160人学习,适合需要完整赛题方案、可运行源码与论文参考的读者,便于快速理解SSM与小程序前后端协作方式并二次开发。
1. 电影院订票选座小程序:从选座锁座到订单落库,一套能跑通的毕业设计全链路
影院选座和普通电商下单最大的区别在于:座位是有限库存,而且必须“先锁后付”。你点开一场《流浪地球3》,看到 8 排 6 座是灰的,那不是样式问题,是有人已经占了或者正在付款。这个项目要解决的核心就是:微信小程序端把座位图渲染出来,用户点选后服务端原子性地锁座,支付成功后落订单,超时未付自动释放。技术栈是微信小程序 + SSM(Spring + SpringMVC + MyBatis)+ MySQL,属于计算机毕业设计里最典型也最容易翻车的一类——看着简单,真做起来锁座并发、座位图坐标、订单状态机全是坑。适合正在做基于 Java 的毕业设计选题、想找一个有真实业务复杂度的同学,也适合已经写完 CRUD 想补一块“能讲清楚”的亮点模块的人。
2. 选座业务的数据模型:座位、场次、订单三张表怎么设计才不返工
很多人一上来就写代码,表结构拍脑袋定,做到锁座那一步发现没法表达“这个座位在这场次被谁锁了多久”,只能推倒重来。这一章把数据模型讲透,后面所有逻辑都从这里长出来。
2.1 影院座位图的本质:物理座位 + 场次座位状态
先分清两个概念。物理座位是影厅里固定的椅子,1 号厅有 10 排每排 12 座,这是不变的。场次座位状态是“某一场次里某个座位当前是否可售”,这是随每场电影变化的。如果你只建一张 seat 表,把状态直接写在座位上,那同一影厅放两场电影就冲突了。
常见做法是拆成三张核心表:
| 表名 | 作用 | 关键字段 |
|---|---|---|
hall | 影厅 | id, name, row_count, col_count |
seat | 物理座位 | id, hall_id, row_num, col_num, seat_type |
schedule | 场次 | id, movie_id, hall_id, start_time, price |
schedule_seat | 场次座位状态 | id, schedule_id, seat_id, status, lock_user, lock_time, order_id |
order | 订单 | id, order_no, user_id, schedule_id, total_price, status, create_time |
schedule_seat是整张图的核心。每排一场电影,就根据seat表批量生成这个场次的所有座位记录,初始status = 0(可售)。用户选座后把对应记录改成status = 1(锁定)并写入lock_user和lock_time,支付成功改成status = 2(已售),超时释放回0。
提示:
schedule_seat的数据量是 场次 × 座位数。一个影厅 120 座,一天排 20 场,一个月就是 7 万多条,毕业设计规模完全扛得住,但别忘了给(schedule_id, seat_id)建唯一索引,这是后面防重复锁座的底牌。
2.2 建表 SQL 与唯一索引
直接给可执行的建表语句,MySQL 5.7 和 8.0 都能跑:
CREATE TABLE `schedule_seat` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `schedule_id` BIGINT NOT NULL COMMENT '场次id', `seat_id` BIGINT NOT NULL COMMENT '物理座位id', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0可售 1锁定 2已售', `lock_user` BIGINT DEFAULT NULL COMMENT '锁定用户id', `lock_time` DATETIME DEFAULT NULL COMMENT '锁定时间', `order_id` BIGINT DEFAULT NULL COMMENT '关联订单', PRIMARY KEY (`id`), UNIQUE KEY `uk_schedule_seat` (`schedule_id`,`seat_id`), KEY `idx_status_time` (`status`,`lock_time`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;逻辑说明:唯一索引uk_schedule_seat保证同一场次同一座位只有一条记录,从数据库层面杜绝重复插入。idx_status_time是给超时释放任务用的——定时扫描status=1 且 lock_time 超过 15 分钟的记录,没有这个索引,场次一多扫描就慢。
参数说明:status用 TINYINT 而不是字符串,省空间且比较快;lock_time用 DATETIME,释放任务里用DATE_SUB(NOW(), INTERVAL 15 MINUTE)做条件。锁定时长 15 分钟是行业常见值,太短用户来不及付,太长座位被占死。
2.3 场次座位批量初始化
每新增一个场次,要把它对应的影厅座位全部复制一份到schedule_seat。用一条 INSERT ... SELECT 就能搞定,不用在 Java 里循环:
INSERT INTO schedule_seat (schedule_id, seat_id, status) SELECT #{scheduleId}, id, 0 FROM seat WHERE hall_id = #{hallId};逻辑说明:#{scheduleId}是刚插入场次返回的自增 id,#{hallId}是该场次所属影厅。这条语句把该影厅所有物理座位一次性铺成场次座位,初始全部可售。
参数说明:MyBatis 里用@Insert或 XML 都行,注意useGeneratedKeys="true" keyProperty="id"拿到场次 id 后再执行这条。如果影厅座位是 200 个,这条语句插入 200 行,毫秒级完成。
3. 微信小程序选座页:座位图渲染与点击交互
后端数据铺好了,前端要把座位图画出来。这一章讲小程序端怎么把二维座位数组渲染成可点击的座位图,以及点击时怎么和状态联动。
3.1 从接口拿到座位数据并转成二维数组
后端返回的是一维列表,前端要按排分组才能画成影院那种一行一行的效果。接口返回结构大致是:
{ "scheduleId": 1001, "rows": 10, "cols": 12, "seats": [ {"seatId": 1, "row": 1, "col": 1, "status": 0}, {"seatId": 2, "row": 1, "col": 2, "status": 2} ] }小程序页面里处理成二维数组:
// pages/selectSeat/selectSeat.js Page({ data: { seatMap: [], selected: [] }, onLoad(options) { const scheduleId = options.scheduleId; wx.request({ url: `https://your-domain/api/schedule/${scheduleId}/seats`, success: (res) => { const { rows, cols, seats } = res.data; // 初始化 rows x cols 的二维数组 const map = Array.from({ length: rows }, () => Array.from({ length: cols }, () => ({ status: -1 })) ); seats.forEach(s => { map[s.row - 1][s.col - 1] = { seatId: s.seatId, status: s.status // 0可售 1锁定 2已售 }; }); this.setData({ seatMap: map }); } }); } });逻辑说明:Array.from先铺一个 rows×cols 的空矩阵,status: -1表示过道或空位(影院座位图不是矩形,边角可能有空缺)。遍历接口返回的座位填充进去,行列出后端给的是 1 开始,数组下标 0 开始,所以减 1。
参数说明:status三个值前端要区分样式——0 白色可点,1 黄色锁定不可点,2 灰色已售不可点,-1 透明占位。selected数组存用户当前选中的 seatId,用于底部结算栏显示。
3.2 座位点击与选中态切换
点击座位要判断状态,可售才能选,已选再点取消:
onSeatTap(e) { const { row, col } = e.currentTarget.dataset; const seat = this.data.seatMap[row][col]; if (seat.status !== 0) return; // 锁定或已售直接忽略 const key = `${row}-${col}`; let selected = this.data.selected.slice(); const idx = selected.indexOf(key); if (idx > -1) { selected.splice(idx, 1); // 取消选中 } else { if (selected.length >= 4) { wx.showToast({ title: '最多选4个座位', icon: 'none' }); return; } selected.push(key); } // 更新该座位在 map 里的选中标记 const map = this.data.seatMap; map[row][col].selected = idx === -1; this.setData({ selected, seatMap: map }); }逻辑说明:用row-col拼成 key 存进 selected,避免存对象引用导致 setData 比较失效。最多选 4 个是影院常见限制,防止恶意占座。map[row][col].selected是前端临时标记,和数据库 status 无关,只控制样式。
参数说明:e.currentTarget.dataset里的 row/col 来自 wxml 的>// app.js 或页面 onLoad 里 const sysInfo = wx.getSystemInfoSync(); const statusBarHeight = sysInfo.statusBarHeight; // 状态栏高度 const menuButton = wx.getMenuButtonBoundingClientRect(); // 胶囊按钮位置 const navBarHeight = (menuButton.top - statusBarHeight) * 2 + menuButton.height; this.setData({ statusBarHeight, navBarHeight });
逻辑说明:微信小程序顶部导航栏高度不是固定值,不同机型不一样。用胶囊按钮的 top 减去状态栏高度得到导航栏内容区高度,乘 2 加上胶囊高度就是完整导航栏高度。这是社区里验证过的通用算法。
参数说明:statusBarHeight用于占位,navBarHeight用于自定义导航栏容器高度。座位图容器设padding-top: {{statusBarHeight + navBarHeight}}px就不会被盖。
4. 锁座并发:为什么你的选座会出现两个人抢同一个座位
这是整个项目最容易翻车的地方,也是答辩时老师最爱问的点。两个人同时点同一个座位,如果代码写得随意,两张订单都会创建成功,到了影院发现座位重了。这一章讲清楚怎么用数据库事务和乐观锁把这个问题摁死。
4.1 锁座的正确姿势:UPDATE 带状态条件
错误做法是先 SELECT 查状态,Java 里判断可售,再 UPDATE。这两步之间有时间窗口,并发下必然出问题。正确做法是把判断和更新合并成一条 SQL:
UPDATE schedule_seat SET status = 1, lock_user = #{userId}, lock_time = NOW() WHERE schedule_id = #{scheduleId} AND seat_id = #{seatId} AND status = 0;逻辑说明:WHERE status = 0是关键。数据库执行 UPDATE 时会加行锁,两个并发请求只有一个能匹配到status = 0并更新成功,另一个匹配 0 行。Java 里判断affectedRows:
@Transactional public boolean lockSeat(Long scheduleId, Long seatId, Long userId) { int affected = scheduleSeatMapper.lockSeat(scheduleId, seatId, userId); if (affected == 0) { throw new BizException("座位已被选走"); } return true; }参数说明:@Transactional保证锁座和后续写订单在同一事务,失败一起回滚。affected == 0说明座位已被别人锁走,直接抛业务异常,前端提示“座位已被选走,请重新选择”。
注意:多个座位要一起锁时,必须按 seat_id 排序后再依次 UPDATE,否则两个请求锁座顺序相反会死锁。这是血泪经验,答辩被问到能加分。
4.2 超时未支付自动释放:定时任务怎么写
用户锁了座不付款,座位不能一直占着。常见做法是定时任务扫描超时锁定记录释放:
@Scheduled(cron = "0 */1 * * * ?") // 每分钟执行 public void releaseExpiredSeats() { // 释放超过15分钟仍未支付的锁定座位 int released = scheduleSeatMapper.releaseExpired(15); log.info("释放超时座位 {} 个", released); }对应 SQL:
UPDATE schedule_seat SET status = 0, lock_user = NULL, lock_time = NULL WHERE status = 1 AND lock_time < DATE_SUB(NOW(), INTERVAL #{minutes} MINUTE);逻辑说明:cron = "0 */1 * * * ?"表示每分钟第 0 秒执行。DATE_SUB(NOW(), INTERVAL 15 MINUTE)算出 15 分钟前的时间点,早于它的锁定记录全部释放。
参数说明:释放时长 15 分钟要和前端提示一致。如果项目用了 Redis,更优雅的做法是下单时写一个 15 分钟过期的 key,过期回调释放,但毕业设计用定时任务足够,别过度设计。
4.3 订单状态机:从待支付到已完成
订单状态不能乱改,要有明确流转:
| 状态值 | 含义 | 可流转到 |
|---|---|---|
| 0 | 待支付 | 1 已支付 / 2 已取消 |
| 1 | 已支付 | 3 已完成 / 4 退款中 |
| 2 | 已取消 | 无 |
| 3 | 已完成 | 无 |
支付回调里更新订单状态并同步座位状态:
@Transactional public void paySuccess(String orderNo) { Order order = orderMapper.selectByNo(orderNo); if (order.getStatus() != 0) return; // 幂等,防止重复回调 orderMapper.updateStatus(orderNo, 1); // 座位从锁定变已售 scheduleSeatMapper.soldByOrder(order.getId()); }逻辑说明:if (order.getStatus() != 0) return;是幂等保护,微信支付回调可能重复推送,不加这句会重复处理。soldByOrder把该订单关联的座位 status 从 1 改成 2。
参数说明:订单号order_no建议用时间戳 + 用户 id 后几位生成,保证唯一且可读。状态值用数字存,前端映射成文案。
5. 避坑与排查:那些让毕业设计卡三天的具体问题
这一章全是实操里真会遇到的坑,每条按现象、原因、解决写,照着排查能省大量时间。
5.1 座位图渲染出来全是灰的
现象:小程序选座页打开,所有座位都是不可点的灰色。
原因:接口返回的 status 字段类型不对,或者前端判断条件写反了。常见是后端返回status为字符串"0",前端用=== 0严格比较永远 false。
解决:后端统一返回数字类型,前端判断前先Number(seat.status)。另外检查seatMap初始化时status: -1是否被误判成不可售,-1 应该单独处理成占位不渲染。
5.2 锁座成功但订单没生成
现象:用户选座提示成功,但订单列表里没有记录。
原因:锁座和创建订单不在同一事务里,锁座提交了,创建订单抛异常回滚了,座位却被锁死。
解决:把锁座和创建订单放进同一个@Transactional方法,任何一步失败整体回滚。检查 Spring 事务是否生效——同类内部方法调用不会走代理,事务会失效,这是经典翻车点。
5.3 超时释放把已支付订单的座位也释放了
现象:用户付完款,过一会座位又变回可售,被别人买了。
原因:释放任务的 SQL 只判断了status = 1和超时,没排除已经生成订单的情况。支付成功后如果座位状态没及时从 1 改成 2,就会被误释放。
解决:释放 SQL 加条件AND order_id IS NULL,或者支付回调里确保座位状态同步更新。更稳妥的是释放前先查关联订单状态,已支付的不释放。
5.4 小程序请求后端报域名不合法
现象:开发者工具里接口正常,真机预览报“不在以下 request 合法域名列表中”。
原因:微信小程序正式环境要求所有请求域名在后台配置且必须是 HTTPS。
解决:开发阶段在开发者工具“详情 - 本地设置”勾选“不校验合法域名”。上线前在微信公众平台配置 request 合法域名。毕业设计答辩演示用开发者工具即可,但要知道这个限制。
5.5 MySQL 8.0 连接报时区错误
现象:Spring 连 MySQL 8.0 报The server time zone value 'xxx' is unrecognized。
原因:MySQL 8.0 默认时区配置和 JDBC 驱动不匹配。
解决:JDBC URL 加参数?serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true。这是 MySQL 8.0 安装配置后最常见的连接问题,加上就好。
6. 让答辩加分的一个技巧:把锁座过程做成可演示的并发验证
毕业设计答辩最怕老师说“你这个没难度”。锁座这块如果只是讲,说服力有限。我一般会准备一个能现场演示的并发验证:用两个浏览器窗口或者 Postman 同时发锁座请求,展示只有一个成功、另一个返回“座位已被选走”。这个演示比讲十分钟原理都管用。
具体做法是写一个简单的压测脚本,用 Java 的 CountDownLatch 模拟同时发起:
public void concurrentLockTest() throws InterruptedException { int threads = 10; CountDownLatch latch = new CountDownLatch(threads); ExecutorService pool = Executors.newFixedThreadPool(threads); AtomicInteger success = new AtomicInteger(0); for (int i = 0; i < threads; i++) { final long userId = i + 1; pool.submit(() -> { try { latch.await(); // 所有线程在此等待,同时放行 boolean ok = seatService.lockSeat(1001L, 5L, userId); if (ok) success.incrementAndGet(); } catch (Exception e) { // 锁座失败,正常 } }); latch.countDown(); } pool.shutdown(); pool.awaitTermination(10, TimeUnit.SECONDS); System.out.println("成功锁座数:" + success.get()); // 期望输出 1 }逻辑说明:CountDownLatch让 10 个线程在latch.await()处阻塞,主线程countDown到 0 时同时放行,制造真实并发。AtomicInteger统计成功次数,因为数据库行锁和status = 0条件,最终只有 1 个线程能成功。
参数说明:threads = 10模拟 10 人抢同一座位,scheduleId=1001, seatId=5是测试数据。期望输出“成功锁座数:1”,如果输出大于 1,说明锁座逻辑有并发漏洞,回去检查 UPDATE 语句的 WHERE 条件。
这个测试跑通,答辩时直接展示控制台输出,再配合数据库里那条status = 2的记录,老师基本不会再追问并发问题。我自己的习惯是每个涉及库存的项目都留这么一个测试类,改完锁座逻辑就跑一遍,比事后拍脑袋靠谱。希望帮到你。
本文还有配套的精品资源,点击获取