想起个事儿,我最近被问了好几次“电影院票务系统怎么做”,问的人里有做毕设的学生,也有准备接小影院外包项目的朋友。仔细聊下来发现,大家纠结的点其实很一致:不是不知道怎么写代码,而是不知道怎么把“小程序、Web、多平台”这几个词落地成一个能跑通、能交付的系统。
正好我手里有一套刚做完的多平台电影院票务系统,小程序、Web 管理端、后端服务全都齐活。这篇就把整个设计思路、核心模块、关键代码和踩坑记录整理出来,给准备做类似项目的朋友一个可以直接抄的作业。内容比较长,但从需求分析到数据建模再到并发处理都会覆盖到,拿去做毕业设计、课程设计或者实际商用项目都有参考价值。
1. 项目整体设计与方案选型
1.1 “多平台”到底该怎么理解
很多人在做“多平台”系统时有个误区,以为就是要同时开发小程序、App、Web 三个前端项目。实际上,多平台的核心不是前端的数量,而是业务模型能不能复用、服务端能不能以统一的方式对外提供能力。
我这套系统最终落地为三个端:用户端微信小程序、用户端 Web(浏览器访问)、后台管理 Web。小程序和 Web 用户端功能几乎一致——注册登录、浏览影片、选择场次、选座下单、支付出票、查看订单、退票改签;管理端则负责影院管理、影厅管理、排片管理、订单管理、数据统计。
设计上,所有业务规则全部收敛在后端服务层,小程序和 Web 用户端只做界面展示和用户交互。换句话说,用户端是两个壳,里面的业务逻辑全部通过同一套 API 驱动。这样做的直接好处是:后续要加 App 或者 H5,前端工作量只是适配界面,后端一行不用改。
1.2 技术栈选型与理由
这套系统的技术栈如下:
- 后端:Spring Boot 2.7 + MyBatis-Plus + MySQL 8.0 + Redis + WebSocket
- 用户端小程序:原生微信小程序(也可以用 uni-app,看团队熟悉度)
- 用户端 Web:Vue 3 + Vite + Element Plus
- 管理端 Web:Vue 3 + Vite + Element Plus
- 部署:Nginx 反代后端接口,前端打包后静态部署
后端选择 Spring Boot 是因为生态成熟、招人好招、出问题社区方案多,对毕设项目来说答辩时也更好讲清楚。MyBatis-Plus 省去大量单表 CRUD 代码,MyBatis 本身对复杂 SQL 和联表查询又保留完整的控制力。Redis 在这里不是点缀,座位锁定时长、分布式锁、用户登录态缓存全都依赖它。
小程序用原生框架主要考虑轻量、不需要额外编译链。如果你熟悉 Vue,选 uni-app 也可以,但需要注意 uni-app 在部分原生组件(比如自定义导航栏、canvas 绘制座位图)上的兼容性坑不少。管理端和 Web 用户端同用一套 Vue 3 + Element Plus,团队成员不用维护两套技术栈,这是很实际的效率考量。
1.3 系统架构分层设计
系统整体采用经典的四层结构:
- 接入层:Nginx 统一入口,处理 HTTPS 证书、反向代理、静态资源托管
- 接口层:RESTful API,按业务域拆分为用户、影片、场次、座位、订单、支付、管理端等模块
- 服务层:核心业务逻辑所在,座位锁定、订单状态流转、支付回调处理、票品生成全部在这里完成
- 数据层:MySQL 负责持久化,Redis 负责缓存和临时状态(锁座、验证码、登录态)
在设计接口时我唯一坚持的原则是:面向场景设计,而不是面向数据表设计。举个例子,用户选座这个场景,前端需要的不是一个“查询座位”接口加一个“创建订单”接口,而是一个有业务语义的“锁定座位并创建待支付订单”接口。如果接口设计成前端自己调两次,就会产生座位锁了但订单没建、或者订单建了但座位没锁的死角。后面讲并发处理时你就能看到这么设计的好处。
2. 核心模块设计与数据建模
2.1 用户与登录模块
用户体系分为小程序用户和 Web 用户。小程序端通过 wx.login 获取临时 code,后端用 code 换取 openid,这是微信生态的标准做法。Web 端因为要兼容普通浏览器用户,提供手机号+密码和短信验证码两种登录方式。
为了统一两端用户,我建了一张 user 表,核心字段包括:id、nickname、avatar、phone、password、openid、unionid、status、create_time。小程序用户登录成功后,如果 openid 不存在就自动注册,存在则直接返回登录态。Web 用户注册时绑定手机号,如果该手机号已经绑定过 openid,就做用户合并关联。
这里踩过一个坑:小程序 code 换 openid 时,code 是一次性的,而且有效期只有五分钟。有次我写的逻辑是“如果换取失败就自动重试”,结果微信那边返回了错误码,重试时拿着已使用的 code 再请求,必然失败。后来改成换到就缓存 userInfo,绝不自动重试。
2.2 影院、影厅与座位模型
座位模型是整个系统的地基,这一块设计不好后面全崩。我这边抽象为四张表:
- cinema(影院表):id、name、address、phone、status
- hall(影厅表):id、cinema_id、name、seat_template_id、screen_type
- seat_template(座席模板表):id、hall_id、row_count、col_count、seat_data
- seat_layout(单场座位布局表):id、schedule_id、seat_row、seat_col、seat_no、status
一个比较容易忽略的点:影厅的座位布局是相对稳定的,但同一影厅在不同场次里座位状态完全不同。所以我做了模板和实例分离,seat_data 用 JSON 存整个影厅的座位静态信息(比如哪些位置是过道、哪些是情侣座、哪些是残疾人位),schedule 创建时从模板复制一份生成 seat_layout 实例,之后这场座位的锁定、售出、释放都在实例上操作。
2.3 影片、场次与排片管理
电影相关的表主要是 movie(影片)、schedule(场次)、hall(关联)。排片管理放在管理端,管理员选择影院、影厅、影片、放映时间后,系统自动生成场次,同时根据影厅座位模板初始化该场次的全部座位。
场次设计最需要注意的是时间冲突检测。同一个影厅,上一场没散场下一场就开始,这在真实影院是完全不允许的。我的处理方式是:影院先维护一个“整备时间”参数(默认 30 分钟),创建场次时校验“上一场结束时间 + 整备时间 >= 下一场开始时间”这个条件。同时数据库里加唯一约束(hall_id, start_time),从根上堵住同一影厅同一时间重复排片。
2.4 订单与票品
订单和票品我拆成了两张表,order 和 ticket。订单表负责记录整体状态和支付信息,ticket 表记录每一张票的具体座位信息。一个订单可以包含多张票,比如用户一次选三个座位下单,生成一张订单、三张票。拆分的好处是后续做退票时,可以支持“整单退”和“部分退”两种模式。
订单状态我用状态机管理,常量定义如下:
- CREATED(待支付)
- PAID(已支付)
- REFUNDING(退款中)
- REFUNDED(已退款)
- CLOSED(已关闭)
- USED(已使用)
每个状态的流转都有严格校验,比如只有 PAID 状态才能进入 REFUNDING,CREATED 状态超时后只能 CLOSED。我给订单表加了 version 字段,更新时用乐观锁,防止并发操作把订单状态覆盖掉。
3. 关键业务逻辑与实操实现
3.1 选座锁座:不能超卖的核心逻辑
电影院票务系统并发压力最大的场景不在支付,而在选座。假设某场次只剩最后一张票,三个人同时在看这个座位,三人都点了“确认选座”,系统必须保证只有一个人能锁定成功。
最直接的方案是数据库行锁:更新座位状态前先 SELECT ... FOR UPDATE 锁住这行。但在高并发下这个方案有两个问题,一是连接池会被长时间占用(用户停留在选座页面的时间可能长达几分钟),二是锁粒度太大,一场 200 个座位就有 200 个热点行,极端情况性能很差。
我采用的方案是 Redis 预占 + 数据库确认。用户提交选座时,先通过 Redis 的原子操作尝试锁定座位:
// 伪代码:Redis 锁座逻辑 public boolean tryLockSeat(Long scheduleId, String seatKey, String userId, long expireSeconds) { String redisKey = "seat:lock:" + scheduleId + ":" + seatKey; Boolean result = redisTemplate.opsForValue() .setIfAbsent(redisKey, userId, Duration.ofMinutes(expireSeconds)); return Boolean.TRUE.equals(result); }setIfAbsent 是原子的,多个用户同时请求时 Redis 会串行执行,天然避免了超卖。锁定时长默认 10 分钟,即用户锁座后必须在 10 分钟内完成支付,否则锁自动释放。前端界面会显示倒计时,给用户明确的紧迫感,这个交互设计对支付转化率有明显帮助。
Redis 锁座成功后,后端才创建订单并返回要支付的订单号。数据库座位状态此时并不立即改为“已售”,而是保持“锁定中”状态,Redis 的 key 是最终判断依据。支付成功后再通过回调确认更新数据库状态为“已售”。
3.2 支付回调处理的幂等设计
支付环节用的是微信支付,核心是回调通知的处理。这里的核心原则是:回调可能重复、乱序、延迟,处理逻辑必须做到幂等。
我的回调处理逻辑分四步:
- 验签:确认回调来自微信支付服务器
- 查单:通过微信支付 API 查询订单在微信侧的最新状态,以查单结果为准而不是以回调参数为准
- 幂等处理:根据订单号查本地订单,如果已经是 PAID 状态则直接返回成功
- 事务更新:本地订单状态改为 PAID,座位状态改为 SOLD,生成正式票品
关键在第三步。因为回调是异步的,极端情况下同一个支付成功通知会送达多次。如果不做幂等,就可能出现同一笔订单被处理两次、生成两张重复票的严重事故。我加了一个分布式锁,key 为“pay:callback:” + 订单号,保证同一笔订单的回调处理在同一时刻只有一个线程在执行。
// 伪代码:支付回调幂等处理 String lockKey = "pay:callback:" + orderNo; boolean locked = tryLock(lockKey, 30); if (!locked) { return "处理中,请稍后重试"; } try { Order order = getByOrderNo(orderNo); if (order.getStatus() == OrderStatus.PAID) { return SUCCESS; // 已处理过,直接返回 } // 校验金额、更新订单状态、生成票品 doTransactionalUpdate(order); return SUCCESS; } finally { unlock(lockKey); }3.3 座位图渲染与状态同步
小程序端和 Web 端都要渲染座位图。我最初在小程序里用 canvas 画,结果发现不同机型、不同微信版本对 canvas 的支持差异很大,改 bug 改到怀疑人生。后来改用 view 组件拼接,每个座位是一个 wxml 里的 view 标签,通过 flex 布局 + 动态 class 控制样式。
每个座位的状态有四种:可选、已售、锁定中、当前选中。数据来自 schedule 创建时生成的 seat_layout 表,前端一次性拿到所有座位数据后本地渲染。
关于选座后的状态同步,这里有个设计细节:当用户 A 锁定了座位 B,用户 C 正在浏览这个场次,C 的页面上座位 B 应该实时变成“锁定中”。实现方式是用 WebSocket 推送。后端在锁座成功、支付成功、锁超时释放三个时机,向对应场次的频道广播一条消息,前端收到消息后局部更新座位状态。
WebSocket 的连接管理我用了一个非常轻量的方案:用户在进入选座页时建立连接,携带场次 ID 订阅频道。离开页面时关闭连接。服务端用 ConcurrentHashMap 维护 session 和场次的映射关系,广播时遍历发送。
3.4 退票与改签的细节处理
退票功能看似简单,其实有不少坑。我实现的是整单退和部分退都支持。核心逻辑是:更新订单状态为 REFUNDED(如果是部分退则拆分订单),释放对应座位,调用微信支付退款接口。
这里有两个细节值得注意。第一,座位释放后用户可能立刻重新锁定同一个座位,所以释放操作必须和退款操作在同一个事务里,否则可能出现“钱退了但座位还是锁定状态”或者“座释放了但钱没退”中间态。第二,微信支付退款接口是异步的,退款申请提交后最终结果通过回调通知,所以 REFUNDING 到 REFUNDED 的流转依赖回调触发,不能本地立刻置为 REFUNDED。
改签我做了简化处理:先退旧票,再创建新订单选新场次。用户感知上是“改签”,底层其实是两笔操作。这个方案实现简单,不容易出错。真正做复杂改签(比如差价自动计算、原座位保留)成本很高,对小规模系统不值得。
4. 小程序与 Web 端的落地与适配细节
4.1 微信小程序登录态管理
小程序端登录我采用 token 机制。wx.login 拿到 code 后传给后端换取 openid 和自定义登录态 token,token 有效期 7 天,存储在服务端 Redis,key 为“login:token:” + token,value 为 userId。小程序端把 token 存到 wx.storage,每次请求带上 Authorization 请求头。
这里有个比较隐蔽的坑:小程序的网络请求在弱网环境下可能超时重试。如果前端在 token 即将过期时同时发出多个请求,可能导致多个请求同时刷新 token,后返回的 token 会覆盖先返回的,造成其他请求携带旧 token 报 401。我的解决方案是用一个 promise 队列,token 刷新期间挂起所有业务请求,刷新完成后再统一放行。
首页的电影列表、即将上映、影院列表这些静态数据不需要用户登录,所以我把接口分为公开接口和需要鉴权的接口,公开接口直接放行,鉴权接口统一走拦截器校验 token。这样既保证体验(弱网下也能浏览),又保证核心操作安全。
4.2 Web 端适配与跨域处理
Web 用户端我用 Vue 3 开发,整体视觉风格和设计语言与小程序尽量保持一致,但交互细节根据设备差异做调整。比如小程序有天然的返回手势和底部 tab,网页则需要考虑浏览器前进后退按钮和更宽泛的布局尺寸。
开发环境中,前端 dev server 监听 5173 端口,后端接口在 8080 端口,必然遇到跨域问题。我的处理方式分两层:
- 开发环境:Vite 配置 proxy,直接把 /api 前缀的请求代理到后端
- 生产环境:Nginx 统一反代,前端静态资源和后端 API 共用同一个域名,从根源上避免跨域
生产环境 Nginx 的关键配置如下:
server { listen 443 ssl; server_name ticket.example.com; # Web 前端静态资源 location / { root /usr/share/nginx/html/web; index index.html; try_files $uri $uri/ /index.html; } # 小程序管理端 location /admin/ { alias /usr/share/nginx/html/admin/; index index.html; try_files $uri $uri/ /admin/index.html; } # 后端 API location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } # WebSocket 支持 location /ws/ { proxy_pass http://127.0.0.1:8080/ws/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; } }4.3 小程序备案与上线注意点
小程序项目在选择了一本道很有用——没有备案的小程序很多类目是过不了审核的。2023 年后微信对小程序备案的要求收紧,个人主体做涉及支付和票务的小程序几乎不可能通过审核,所以如果你想做商用项目,建议优先注册企业主体,提前把备案材料准备好。
备案信息填写时有个小坑:小程序名称和简介里不要出现“全国”“平台”这类极限词,会影响审核。类目选择“文娱 > 电影/演出/体育赛事票务”比选“工具”更容易过审。支付能力也要提前在小程序后台申请开通,个人主体无法开通微信支付,必须企业主体。
5. 常见问题与排查技巧实录
5.1 并发选座导致的超卖问题
有次压测时我用 JMeter 模拟 200 个并发用户抢最后 10 个座位,结果 Redis 锁座逻辑正常工作,但有少量订单在数据库层面出现了异常的座位状态。排查后发现原因:Redis 锁座成功只是第一步,后续创建订单的事务里更新座位状态时,我没有重新校验数据库座位状态,导致极端情况下 Redis 锁被释放(10 分钟超时)但数据库事务还没提交。
这个问题最终的解法是双保险:Redis 锁座是“快速失败”的前置拦截,数据库更新时 WHERE 条件里加上 seat_status = 'AVAILABLE',如果影响行数为 0 则抛出异常回滚事务。这样即使 Redis 这层被绕过,数据库层面依然不会超卖。
5.2 支付回调丢失导致订单卡死
微信支付回调偶尔会“丢失”——虽然微信官方有重试机制,但极少数情况下重试间隔较长(最长可达一天),用户支付成功但系统一直显示“待支付”,体验极差。
我的解决方案是引入主动对账。订单创建后启动一个定时任务,每 5 分钟扫描一次“已创建但未支付且未超时”的订单,调用微信支付查单接口确认支付状态。如果发现已支付但本地状态未更新,则主动触发支付成功处理逻辑。这个方案能保证订单状态在最多 5 分钟内收敛。
5.3 小程序 canvas 渲染座位图兼容性
开始我用 canvas 绘制座位图,在 iOS 上表现还行,但 Android 低端机上频繁出现点击坐标偏移、触摸事件不响应的问题。后来我全部改成 view 组件渲染,每个座位是一个 6px * 6px 的 view,加上 flex 布局。
性能上最开始担心座位多时渲染卡顿,实测一个 200 座的影厅渲染只有一百多个节点,毫无压力。如果影厅超过 500 座,建议用虚拟滚动只渲染可视区域内的座位,但大多数影院场景用不到。
5.4 Web 端 Session 与 Token 的选择
Web 用户端和管理端我统一用了 Token 机制,没有用 Session。原因有两点:一是前后端分离项目中 Token 状态保存在服务端 Redis,天然支持多实例部署和水平扩展;二是和移动端保持同一套鉴权体系,逻辑统一以后维护成本低。
注意 Token 的存储位置。我强烈不建议把 Token 放 localStorage,因为 localStorage 可以被 XSS 攻击读取。实际项目中我放在 HttpOnly Cookie 里,虽然会有 CSRF 风险,但配合 SameSite 属性和请求头校验可以降到可控范围。
5.5 管理端与用户端的关系梳理
管理端和用户端共用同一个后端服务,但接口权限完全隔离。用户端接口一律走用户身份鉴权,管理端接口走管理员角色鉴权。我先定义了两套拦截器,用户接口用 @UserAuth 注解标记,管理端接口用 @AdminAuth 注解标记,拦截器根据注解判断是否需要校验管理员权限。
这里有一点非常重要:千万不要在接口方法内部再自己判断角色。我曾经图省事,在个别管理接口里写 if (isAdmin(userId)) 这样的代码,后来出过一次越权问题:用户端跑通了一个管理接口,原因是那个接口没有被任何拦截器覆盖,内部的角色判断又写错了。统一用注解 + 拦截器后就没有这个问题了。
5.6 常见问题速查表
我把开发过程中遇到的典型问题整理成表,方便直接排查:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 用户支付成功但订单仍是待支付 | 支付回调未到达,或回调处理抛异常 | 启动对账定时任务,主动查单确认 |
| 同一个座位被两个用户同时选中 | Redis 锁未生效,或锁已过期但事务未提交 | Redis 原子操作 + 数据库状态乐观锁双保险 |
| 小程序端请求报 401 | token 过期或未携带,code 被重复使用 | 检查请求头,token 刷新使用 promise 队列串行化 |
| Web 端开发环境接口跨域 | 未配置 Vite proxy 或后端未开启 CORS | 配置 Vite proxy,生产环境 Nginx 统一反代 |
| 管理端展示的座位图和实际不一致 | 座位模板修改后未同步到已有日程 | 模板变更时提醒管理员重新生成场次或选择“应用到未来场次” |
| 退款后座位没有释放 | 退款流程中座位释放不在同一事务 | 座位释放和退款申请放在同一事务,失败则整体回滚 |
写在最后
这套系统我从需求分析到最终交付前后花了大约三周,如果只做核心功能(注册 + 排片 + 选座 + 支付 + 订单),一周半就能跑通主流程。真正耗时的是那些边界情况:并发选座、支付回调、退款、改签、微信登录、小程序审核,每一项单看都不复杂,但叠在一起就很考验整体设计。
我个人在设计上的一个深刻体会是:不要把“多平台”想成三个项目,它本质上还是一套业务系统,小程序、Web 都只是触达用户的渠道。你只要坚持“业务规则放后端、前端只管展示交互”这个原则,平台再多也不怕。最后分享一个小技巧:在开发阶段,给后端接口写一个简单的在线文档(Swagger 或者国内更常用的 Apifox),前端对接时效率能提升一倍,省下来的时间足够你把上面的坑全部踩一遍再填平。