尧图精选

多平台电影院票务系统设计与实现:从架构到并发选座全解析

🕒 发布时间:2026/9/19 13:17:04 📁 来源:尧图网络
想起个事儿我最近被问了好几次“电影院票务系统怎么做”问的人里有做毕设的学生也有准备接小影院外包项目的朋友。仔细聊下来发现大家纠结的点其实很一致不是不知道怎么写代码而是不知道怎么把“小程序、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看团队熟悉度用户端 WebVue 3 Vite Element Plus管理端 WebVue 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、statushall影厅表id、cinema_id、name、seat_template_id、screen_typeseat_template座席模板表id、hall_id、row_count、col_count、seat_dataseat_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 状态才能进入 REFUNDINGCREATED 状态超时后只能 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 和自定义登录态 tokentoken 有效期 7 天存储在服务端 Rediskey 为“login:token:” tokenvalue 为 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 原子操作 数据库状态乐观锁双保险小程序端请求报 401token 过期或未携带code 被重复使用检查请求头token 刷新使用 promise 队列串行化Web 端开发环境接口跨域未配置 Vite proxy 或后端未开启 CORS配置 Vite proxy生产环境 Nginx 统一反代管理端展示的座位图和实际不一致座位模板修改后未同步到已有日程模板变更时提醒管理员重新生成场次或选择“应用到未来场次”退款后座位没有释放退款流程中座位释放不在同一事务座位释放和退款申请放在同一事务失败则整体回滚写在最后这套系统我从需求分析到最终交付前后花了大约三周如果只做核心功能注册 排片 选座 支付 订单一周半就能跑通主流程。真正耗时的是那些边界情况并发选座、支付回调、退款、改签、微信登录、小程序审核每一项单看都不复杂但叠在一起就很考验整体设计。我个人在设计上的一个深刻体会是不要把“多平台”想成三个项目它本质上还是一套业务系统小程序、Web 都只是触达用户的渠道。你只要坚持“业务规则放后端、前端只管展示交互”这个原则平台再多也不怕。最后分享一个小技巧在开发阶段给后端接口写一个简单的在线文档Swagger 或者国内更常用的 Apifox前端对接时效率能提升一倍省下来的时间足够你把上面的坑全部踩一遍再填平。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →