
做毕业设计接了一个电影院订票选座的小程序项目花了三周从零到交付调试阶段踩了不少坑。网上关于这个题目的资料大多是零散的代码片段或者只讲某一块的博客真正能把“选座并发”“支付回调”“小程序端状态同步”串成一条完整链路的很少。这篇文章就把我这套系统的完整设计思路、核心代码逻辑和调试经验整理出来源码、数据库脚本、接口文档都齐了只要你照着搭一遍就能跑起来更适合拿来当毕设或者练手的参考。1. 在动手写代码之前这套系统到底要管理哪些业务状态先别急着打开IDE写接口。电影院订票选座系统和普通的商品下单有一个本质区别——它同时涉及“库存”和“座位状态”两个维度的实时变化。一张电影票卖出去不仅是一个SKU库存减一还意味着某个影厅里某个座位的占用状态要立刻变更而且要保证同一时间只有一个人能锁定同一个座位。我从需求分析里拆出来的核心状态有这么几个场次Session一部电影在某个影厅、某个时间段的放映计划。一张票必然归属于某个场次。座位Seat影院影厅的物理座位有排号、列号、区域普通区/情侣区/VIP区属性。一个座位在某一场次下只有三种状态可售、已锁定、已售出。订单Order用户下单的记录关联场次、座位集合、支付状态。订单状态机的设计是整个系统的地基。排片Schedule管理端录入的电影放映计划。这个模块决定了用户能看到哪些场次。我画的业务流程图是用户选择电影 → 看到排片列表 → 进入选座页 → 点选座位 → 生成待支付订单座位锁定 → 支付成功座位永久占用 → 生成电子票 → 到店扫码入场。整个链路里最容易被忽略、也最容易在后期出问题的是订单状态和座位状态的同步。如果用户下单后一直不支付座位要不要一直锁着锁多久释放如果用户支付成功了但服务端没收到回调座位是变成已售还是回滚成可售这些问题在写代码之前必须想清楚否则后面每一个都是Bug来源。2. 技术选型为什么是小程序原生 Spring Boot MySQL Redis这个题目看名字就知道客户端锁定微信小程序。服务端的选择我对比过几个方案最后敲定的是Spring Boot 2.7 MyBatis-Plus MySQL 8.0 Redis理由很简单Spring BootJava生态里开发效率最高的脚手架社区资料最多遇到问题随手一搜就有答案最适合毕设和中小型项目。不用SSM那套老古董配置注解搞定一切。MyBatis-Plus单表CRUD基本不用写SQL自带分页插件代码量能少三分之一。复杂一点的多表关联查询自己写XML映射灵活度也够。MySQL 8.0存储业务数据。之所以不用5.7是因为8.0支持窗口函数后面做“场次热度排行”这类统计会方便很多而且8.0默认字符集就是utf8mb4存电影名称里的emoji不会乱码。Redis干两件事——一是座位锁定的临时状态存储二是接口幂等性凭证的存储。选座并发控制靠Redis Lua脚本实现原子操作这是这套系统的技术核心后面的章节我会专门展开讲。微信小程序端我用的是原生框架没有上uniapp或Taro。原因很简单这个项目只需要微信一个端原生框架的组件和API支持最直接调试工具链最稳。如果用跨端框架等于平白多了一层编译转换出了问题排查链路更长。当然如果你后续想同时发布支付宝小程序再考虑迁移框架也不迟。小程序端的页面结构按照功能拆成四层首页电影列表、影院/场次选择页、选座页、订单确认页和订单列表页。这里有一个设计细节值得注意一台手机就是一个用户端但一个用户可以有多张订单订单和座位是多对多的关系所以数据表设计里必须有“中间表”来关联订单和座位不能把座位直接写死在订单表的一个字段里。3. 数据库设计六张核心表和一个关键的时间字段我把数据库表拆成了六张电影表movie、影院表cinema、影厅表hall、场次表session、座位表seat、订单表orders订单与座位的关联用一张子表 order_seat 记录。这里重点说说几个核心表的结构设计这些字段直接影响后续的查询效率和扩展性。场次表 session是核心中的核心我给它加了三个索引字段维度hall_id哪个影厅、movie_id放什么电影、start_time开演时间。这三个字段联合查就是“某部电影在某个影厅的所有排片”是用户选场次页面最核心的查询。我顺手加了一个冗余字段status用来标记场次是“售票中”还是“已开场/已结束”避免每次查询都要拿当前时间跟start_time比大小。座位表 seat除了常规的 prase_id 和 hall_id我加了一个很实用的字段叫seat_type。因为电影院有普通座、情侣座、VIP沙发座的区别不同区域的价格逻辑也不同。选座页前端渲染的时候就是根据这个字段决定座位的颜色和可交互状态。还有一个所有表里都必须有的字段叫deleted逻辑删除标志而不是物理删除。原因很简单电影院的数据一旦录入就有历史价值比如统计某部电影在某个厅的上座率时如果之前把下线的场次物理删了历史统计就废了。MyBatis-Plus 自带TableLogic注解逻辑删除用起来非常无感。订单表 orders我单独放了一节因为它设计得好不好直接影响支付链路。除了常规的 user_id、session_id、total_amount 之外关键字段是这几个字段用途说明order_no订单编号用“yyyyMMddHHmmss 随机6位”生成保证并发情况下不重复status订单状态0待支付 1已支付 2已取消 3已退款expire_time锁定过期时间这是整套系统最核心的字段通俗点说就是“座位的临时保质期”payment_method支付方式微信支付/模拟支付毕设里这个字段很实用订单的表结构还有一个容易踩的坑不要把座位号直接存成“3排5座”这种字符串。表面上看着挺直观但一旦要统计“哪个座位最抢手”“情侣区的上座率”你就得写一堆字符串解析逻辑。正确做法是通过 order_seat 关联表存 seat_id查询的时候再join座位表取排行列号数据规范扩展性好。4. 选座的核心难点座位锁定、防超卖与会话保持的完整链路选座功能是整个系统的门面也是最考验并发处理的地方。用户看一个座位空着点了付款结果被另一个用户抢先占了这种情况必须避免——好的选座系统应该做到“谁先发起锁定谁就暂时拥有这个座位”。我用的方案是Redis缓存 Lua脚本原子操作整体流程是这样用户点选座位时小程序端把选中的count个座位id发送到后端。后端先在Redis里检查这些座位的状态如果全部是可售没有锁也没有卖就执行Lua脚本批量把座位写入Redis键格式是seat:locked:{sessionId}:{seatId}同时设置过期时间默认是5分钟倒计时。同时生成待支付订单写入MySQL订单状态是0待支付订单的expire_time就是当前时间加5分钟。前端进入支付页面开始5分钟的倒计时。支付成功回调后把Redis里对应座位的lock状态升级为sold同时更新MySQL订单状态为1座位表也更新为已出售。这里用Lua脚本的原因很直接Redis单线程执行Lua能保证多座位检查写入这两个操作是原子的。如果不用Lua而用“先查再写”在高并发下就会出现两个用户同时查到空位然后都写入成功——这就是超卖。我实际写的Lua脚本核心逻辑简化后是这样的local session_id KEYS[1] local seat_ids ARGV local expired_time tonumber(ARGV[#ARGV]) -- 最后一个参数是过期时间戳 -- 先检查所有座位是否可售 for i 1, #seat_ids - 1 do local key seat:locked: .. session_id .. : .. seat_ids[i] local exists redis.call(EXISTS, key) if exists 1 then return -1 -- 有座位已经被占直接失败 end end -- 全部可售才写入锁定 for i 1, #seat_ids - 1 do local key seat:locked: .. session_id .. : .. seat_ids[i] redis.call(SET, key, locked, PX, expired_time) end return 1这段脚本看起来简单但实际调试的时候有几个细节要处理干净过期时间不是随意拍的。用户在选座页停留的时间、支付流程的耗时、网络波动的时间都要算进去。我设了5分钟后端在写Redis的时候还会额外加30秒的缓冲这样即使前端倒计时归零、用户刚好点了支付也不会因为缓存过期导致座位回滚。锁定期内的轮询问题。用户锁定座位以后如果迟迟不支付5分钟一到Redis键自动过期座位会变回可售状态。这种“自动释放”的特性是选择Redis做锁的天然优势如果用MySQL存锁状态你还得写一个定时任务去扫表麻烦很多。说完锁定再说超卖防护的另一个层面——MySQL层的兜底。Redis处理的是高并发瞬间的请求但它毕竟是个缓存层万一Redis里的数据没有正确同步到MySQL比如缓存被清理、数据丢失订单还是会出问题。所以我在MySQL的order表插入前做了一个“座位状态二次校验”-- 在生成订单前执行确保座位真的没有被占用 SELECT COUNT(*) FROM order_seat o INNER JOIN orders oo ON o.order_id oo.id WHERE o.seat_id IN (#{seatIds}) AND oo.status IN (0, 1) -- 待支付和已支付都算占用这步操作能挡住“Redis缓存已经过期但订单还没过期”的极端情况。两个服务的校验结合起来才算是一道真正的双保险。5. 支付环节的处理微信支付对接的完整链路与毕业设计里的替代方案支付是整个项目里最容易被老师追问、也最需要讲清楚的部分。微信支付的完整流程是小程序端拿到用户的操作凭证调用wx.requestPayment拉起微信支付弹窗微信支付完成后微信服务器携带支付结果向我们的后端服务器发送异步通知回调后端在回调里更新订单状态。这里的顺序和同步/异步关系是面试和答辩的高频考点。现实项目中支付回调必须做幂等处理。什么意思就是同一个支付成功的通知可能会被微信发送多次如果后端收到两次成功回调就把订单状态从0改成1执行两遍虽然最终结果可能没错但如果中途有新建票券、调用其他系统等副作用操作就会产生脏数据。所以我给回调处理加了一个检查如果订单状态已经是1直接返回成功不作任何处理。但毕设里完整对接微信支付有实实在在的阻碍——微信支付商户号申请需要企业资质个人开发者拿不到。我的做法是在系统中同时保留两条支付链路完整模式如果你有商户号走标准的wx.requestPayment 回调通知。演示模式给毕设演示和本地调试用前端直接调用一个“模拟支付成功”的接口后端把这个接口当作支付成功的回调来处理同一套逻辑。这个设计的好处是代码层面的订单状态流转完全一致切换起来只差一个开关配置。答辩的时候你可以自如地展示完整的选座、锁座、支付、出票流程不用因为资质问题而卡在演示环节。模拟支付接口的实现也很简单前端在支付确认页直接把金额展示出来用户点一个“确认支付演示环境”的按钮后端就会执行和真实回调一样的方法PostMapping(/api/pay/simulate) public Result simulatePay(RequestBody SimulatePayRequest request) { Order order orderService.getByOrderNo(request.getOrderNo()); // 校验订单确实是待支付状态 if (order.getStatus() ! 0) { return Result.error(订单状态异常请刷新); } // 调用与真实回调相同的处理逻辑 return payService.handlePaymentSuccess(order.getOrderNo()); }这里有一个细节我在调试时踩过坑生成订单后前端的倒计时可能已经到了最后一秒用户刚好在这一刻点击支付。如果Redis的座位锁已经过期但订单还处于待支付状态这时候支付成功处理逻辑应该怎么走我的答案是在支付成功处理的方法里不要只依赖Redis状态而是以MySQL订单为准——只要订单状态是0就允许支付成功然后在支付成功后再同步Redis状态。这样既保证了用户体验也避免了一些边界产生的死锁问题。6. 小程序前端组件化渲染座位矩阵的三种状态与页面交互逻辑小程序端的选座页是整个前端最核心的页面。一个影厅的座位渲染本质上就是二维数组的套循环把每一排的座位横向排列然后根据座位状态来决定组件怎么渲染。我用的数据结构是这样{ hallSeatMap: [ { row: 1, count: 20, seats: [ { id: 1001, row: 1, col: 1, type: normal }, ... ] }, { row: 2, count: 20, seats: [...] } ] }WXML里就是两层wx:for外层遍历排内层遍历该排的座位。每个座位的状态用一个三元值控制0表示可售1表示已售出不可点2表示已被别人暂锁定不可点3表示当前用户已选中高亮。渲染的时候根据状态切换css类名实现灰色、红色、绿色的视觉区分。选座的交互逻辑有个前端层面的防抖需求用户连续点了两个座位前端要收集所有选中的座位id选完以后点击“确认选座”一次性提交给后端。这里不能做成“点一个就往后端请求一次”因为网络请求有延迟用户手速快就会造成状态不一致。正确做法是前端先把所选座位的id存到本地Data里确认后才统一提交。还有一个小程序的原生坑setData的数据量限制。一个影厅如果有12排×20列那就是240个座位每个座位还要带id、行号、列号、类型、状态。一次性放进setData在某些低端安卓机上会出现渲染卡顿。我的优化方案是只setData当前屏需要渲染的数据或者把座位的状态数据拆开——座位静态属性行号、列号第一次渲染后不再改动每次交互只更新状态数组这样setData的payload能小一半以上。页面跳转的参数传递也值得注意选座页需要知道场次id从排片列表页跳转过来的时候我用了wx.navigateTo的URL参数透传页面接收后在onLoad里处理并请求座位数据。如果你用事件通道EventChannel传递也能实现但代码会分裂成两个地方不如URL参数直观。订单确认页的数据组装也很讲究选座页选完座位 → 跳转订单确认页时要把哪些信息带过去我的做法是前端只带seatIds数组和sessionId订单金额和影片信息统一在订单确认页通过接口从后端取。这样能防止用户篡改前端上传的金额是支付类系统的基本安全要求。7. 调试与联调是最磨人的阶段我遇到过的四个经典问题这部分可能是对大家最有用的——我实际调试过程中遇到并解决的四个问题每个都值得拿出来说说排查思路。问题一真机预览时接口请求全部失败但开发工具里一切正常这个坑几乎人人都会遇到。根本原因是小程序的网络请求必须走HTTPS而且必须在微信公众平台配置request合法域名。我在开发工具阶段勾选了“不校验合法域名”所以一切正常但手机预览时微信强制校验导致所有wx.request直接失败。排查时我看了下Network面板请求都是pending状态没有任何返回。最终解决分两步在公众平台的后台把服务器的HTTPS域名加入request合法域名白名单同时在开发调试阶段如果后端是本地服务可以用“真机调试”功能它允许开发者工具连上真机后绕过域名校验。但注意这个模式只能用于开发调试正式发布的小程序还是必须配置合法域名。问题二往MySQL写入订单时出现“Data too long for column”这个报错直接卡了我半天。原因很隐蔽订单号我定义成了VARCHAR(32)但生成的order_no格式是“yyyyMMddHHmmss”加6位随机数实际长度是32个字符看起来刚好卡在边界。但MySQL的VARCHAR(32)在utf8mb4字符集下最多能存32个字符而不是32个字节中文字符占3个字节英文数字占1个字节。我订单号里全是数字字母本来应该没问题但订单表另一个字段存了用户昵称昵称里有中文而我把昵称字段长度设短了。排查方法很简单看异常堆栈里指的哪个字段再去数据库看该字段的定义。解决就是把昵称之类的用户信息字段全部改成VARCHAR(128)订单号改成了BIGINT类型为主键从根上杜绝长度问题。问题三两个用户同时点同一个座位都显示“选座成功”这个问题最能体现并发控制的必要性。我当时在本地起了一个Java后端开两个浏览器窗口模拟两个用户同时操作。第一版代码只用了“先查询MySQL座位状态再写入锁定”的逻辑结果因为两个请求交替执行查询和写入后一个请求覆盖了前一个的锁定。这就是典型的“读改写”竞争条件。我的修复方案就是前面说的Redis Lua脚本把检查写入合并成原子操作。如果Redis连接不可用Lua脚本执行失败后端会捕获异常并向前端返回“系统繁忙请重试”不会让用户觉得自己选座成功了。问题四订单状态在“待支付”和“已取消”之间反复横跳这个其实是定时任务的锅。我写了一个扫描器每30秒扫一次所有待支付订单如果超过过期时间就自动取消。逻辑本身没错但问题是这个扫描器没有把“刚刚支付成功但回调还没处理”的订单排除在外——用户在倒计时最后两秒支付成功回调还没结束扫描器先跑了把订单改成已取消后面回调回来就发现订单不存在了。解决方式是给订单取消任务加了一个“宽限期”扫描器只取消expire_time 60秒还没支付的订单给支付回调留足处理时间。这也是我在答辩时一定会讲到的业务边界考虑比单纯写完功能更体现工程思维。8. 部署与交付从本地跑通到服务器上线的完整操作流程如果你的毕设或者项目需要部署到服务器我这里给一套完整的操作命令照着执行就能跑起来。后端打jar包mvn clean package -DskipTests打包完会在target目录生成jar文件。推荐直接用nohup方式启动nohup java -jar cinema-booking.jar --spring.profiles.activeprod app.log 21 注意生产环境的application-prod.yml里要把数据库地址、Redis地址都换成服务器上的地址还要在小程序后台把request合法域名改成服务器的域名。如果用的是云服务器记得在安全组里放行8080端口或你配置的端口。小程序端上传微信开发者工具点“上传”按钮填版本号和备注然后去微信公众平台提交审核。注意提交审核需要完成小程序类目认证个人主体的小程序不能开通支付功能这也是我一直强调要在系统里保留模拟支付模式的原因。部署完成以后我建议做一轮完整的回归测试覆盖这些业务链路用户浏览电影列表、查看排片 → 正常展示用户选座、锁定座位 → 座位状态变绿倒计时出现5分钟内不支付 → 订单自动取消座位恢复可售用户支付成功 → 订单状态变已支付座位变成已售并发测试两用户点同一座位 → 只有一方成功后台管理录入电影、排片、大厅管理 → 前端同步出现新数据我当时用Apache JMeter做了一个简单的并发测试50个线程同时请求同一个场次的座位锁定接口结果只有1个请求成功其余49个全部返回失败Redis的防超卖机制确实稳。9. 这套系统还可以怎么扩展如果你不满足于交完毕设就结束这套系统的可扩展方向其实相当可观座位价格按区域差异化目前座位表里已经有了seat_type字段价格可以拆成单独的票价规则表支持不同场次不同区域动态定价。优惠券与会员积分订单表加两个关联字段下单前校验优惠券状态支付完成后按规则返积分。选座页面的座位偏好推荐比如单人用户优先推荐靠过道的座位情侣用户优先推荐情侣座这个用简单的规则引擎就能实现。管理后端我目前的排片和影院信息都是直接在数据库录入的如果做成一个小管理后台用VueElementUI就能实现可视化排片体验会好很多。我个人的建议是先把主线业务跑通、把并发调试的经验吃透再考虑扩展功能。尤其要理解好“锁座位”这件事的底层逻辑——因为所有订票类系统机票、火车票、演出的核心逻辑其实都一样换个界面就能复用。把这一个项目做深、讲透比做五个半途而废的功能强得多。