news 2026/9/19 13:16:35

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
多平台电影院票务系统设计与实现:从架构到并发选座全解析

想起个事儿,我最近被问了好几次“电影院票务系统怎么做”,问的人里有做毕设的学生,也有准备接小影院外包项目的朋友。仔细聊下来发现,大家纠结的点其实很一致:不是不知道怎么写代码,而是不知道怎么把“小程序、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 支付回调处理的幂等设计

支付环节用的是微信支付,核心是回调通知的处理。这里的核心原则是:回调可能重复、乱序、延迟,处理逻辑必须做到幂等。

我的回调处理逻辑分四步:

  1. 验签:确认回调来自微信支付服务器
  2. 查单:通过微信支付 API 查询订单在微信侧的最新状态,以查单结果为准而不是以回调参数为准
  3. 幂等处理:根据订单号查本地订单,如果已经是 PAID 状态则直接返回成功
  4. 事务更新:本地订单状态改为 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 原子操作 + 数据库状态乐观锁双保险
小程序端请求报 401token 过期或未携带,code 被重复使用检查请求头,token 刷新使用 promise 队列串行化
Web 端开发环境接口跨域未配置 Vite proxy 或后端未开启 CORS配置 Vite proxy,生产环境 Nginx 统一反代
管理端展示的座位图和实际不一致座位模板修改后未同步到已有日程模板变更时提醒管理员重新生成场次或选择“应用到未来场次”
退款后座位没有释放退款流程中座位释放不在同一事务座位释放和退款申请放在同一事务,失败则整体回滚

写在最后

这套系统我从需求分析到最终交付前后花了大约三周,如果只做核心功能(注册 + 排片 + 选座 + 支付 + 订单),一周半就能跑通主流程。真正耗时的是那些边界情况:并发选座、支付回调、退款、改签、微信登录、小程序审核,每一项单看都不复杂,但叠在一起就很考验整体设计。

我个人在设计上的一个深刻体会是:不要把“多平台”想成三个项目,它本质上还是一套业务系统,小程序、Web 都只是触达用户的渠道。你只要坚持“业务规则放后端、前端只管展示交互”这个原则,平台再多也不怕。最后分享一个小技巧:在开发阶段,给后端接口写一个简单的在线文档(Swagger 或者国内更常用的 Apifox),前端对接时效率能提升一倍,省下来的时间足够你把上面的坑全部踩一遍再填平。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/19 13:07:03

java开发中常见锁的使用场景和代码示例

文章快速指引一、java中锁的作用二、synchronized三、ReentrantLock三、ReadWriteLock四、Condition五、StampedLock六、LockSupport七、CountDownLatch一、java中锁的作用 在Java中,锁(Locks)是一种同步机制,主要用于控制多线程…

作者头像 李华
网站建设 2026/9/19 13:04:02

Hadoop与Hive构建足球数据仓库:从事件表到预测分析

简介:这份《hadoop大数据课件-足球大数据案例》面向大数据初学者、足球数据分析爱好者及体育科技从业者,以足球赛事场景演示Hadoop在体育数据挖掘中的典型应用。课件围绕“足球的大数据7种武器”展开,覆盖比赛统计、热点图与轨迹图、球员统计…

作者头像 李华