☰
SpringBoot+Vue电影售票系统实战:并发锁、支付回调与影城管理
2026/10/21 0:52:46 网站建设 项目流程

简介:面向全栈开发学习者的完整实战项目源码,以SpringBoot构建后端服务、Vue.js搭建前端交互界面,实现电影查询、在线选座、购票支付、影院排片及后台管理等功能闭环。项目覆盖用户、电影信息、影院、排片、座位、订单、管理员等核心模块,包含Spring Security权限控制、Redis缓存、Axios接口通信、vue-router路由与Vuex状态管理等关键技术点的落地实践。

压缩包为zip格式,共314个文件,整体约16.14MB。其中含81个java后端源码文件、41个vue前端组件文件、15个xml配置及1个sql数据库初始化脚本,另有102个jpg海报素材、15个js脚本与4个css样式等前端资源,目录结构按前后端分离规范组织,便于对照学习。已有71人学习浏览。

对于希望快速理解SpringBoot与Vue实战整合流程的开发者而言,这份资源提供了可运行的项目框架、完整的接口交互示例和工程化目录规划,是进阶全栈开发、构建个人作品集的实用参考。

1. 从"卖票"到"经营":一套系统背后的真实业务拆解

每次看到"电影售票及影院管理系统"这类标题,我第一反应不是代码仓库,而是一个现场画面:某影城经理对着三个割裂的Excel表——票房日报、场次排期、会员储值——打电话催运营手动核对数据。你接手一套SpringBoot加Vue的售票系统,本质上是在替他把这三张表拼成一张能联动的经营视图。这类项目在外包市场和毕设市场都极常见,但真正能上线扛住周末晚场流量、同时让前台、财务、运营三个角色都觉得"比原来的流程强",远比看一遍Demo代码难。

这个标题的核心词值得拆成三层:SpringBoot决定的是后端稳定性——座位锁定的并发控制、订单超时释放、支付回调幂等这些硬骨头都在这一层;Vue决定的是前台操作手感——选座组件的状态管理、场次切换时的票价联动、以及最后下单时的倒计时焦虑;影院管理决定的是业务边界——除了卖票,排片、会员、退改签、卖品套餐、报表统计才是让经理拍板买单的功能。这篇文章我会从一张核心数据模型讲起,按"建库 → 做接口 → 联前台 → 上线排查"的顺序,把每一步的参数、逻辑、踩坑点都说透。适合两类人:刚入职想用完整项目练手的前端或后端开发者,以及要把这套系统落地到实际影城的全栈工程师。

2. 数据模型先行:影城业务的ER图与库存本质

2.1 排片表才是整个系统的牵一发动全身

做电影售票系统之前,先想清楚一个核心问题:你卖的不是电影,是座位在某一个时间片上的使用权。这个认知决定了核心表怎么设计。我见过不少新手上来就建一张t_order表,把电影名、影厅名、座位号、场次时间全塞进去,结果一到"改场次时间"或"取消排片"就到处改冗余字段。正确做法是严格区分排片(screening)和订单,排片表是独立于订单的核心库存表。

常见的排片表结构我会这么设计:

CREATE TABLE screening ( id BIGINT PRIMARY KEY AUTO_INCREMENT, movie_id BIGINT NOT NULL COMMENT '关联电影表', hall_id BIGINT NOT NULL COMMENT '关联影厅表', start_time DATETIME NOT NULL COMMENT '开场时间', end_time DATETIME NOT NULL COMMENT '预计散场时间', base_price DECIMAL(10,2) NOT NULL COMMENT '基础票价', hall_capacity INT NOT NULL COMMENT '影厅总座位数,冗余自影厅表', status TINYINT NOT NULL DEFAULT 1 COMMENT '1待开场 2已开场 3已散场 4已取消', UNIQUE KEY uk_hall_time (hall_id, start_time) ) ENGINE=InnoDB COMMENT='排片表';

这段建表逻辑里有两个关键点。第一个是uk_hall_time唯一索引,它在数据库层挡住了"同一影厅同一时间被排两场电影"的脏数据——这类问题在排片管理界面看起来只是个小弹窗提示,但如果没有数据库约束,并发排片时就会悄悄插入违例数据。第二个是hall_capacity冗余字段,我当时没有每次去join影厅表算座位数,因为选座页面前端要频繁拿到"剩余座位数"做展示,冗余这个字段换一次join开销,对这套系统来说是划算的。

影厅表本身建议单独维护一个座位分布。最常见的做法是seat表和hall表分开,seat记录每个影厅的第几排第几列、是否靠过道,这样前端渲染选座界面时能直接拿到二维矩阵,而不是每次从订单表反查哪些座位被占了。综合来看,表结构设计的核心口诀是:订单表尽量瘦,排片表和座位表尽量宽。

2.2 订单表与座位锁定:为什么不能下单就写死座位

订单表的设计直接决定你在高峰期会不会崩。很多入门项目把订单做成"提交即占座,支付才改状态",这是最常见的翻车点。用户选好座位如果迟迟不支付,座位会一直锁住,其它用户看到的可选座位越来越少,咨询客服的投诉量会暴涨。我一般会用两套方案里的第二套。

CREATE TABLE seat_lock ( id BIGINT PRIMARY KEY AUTO_INCREMENT, screening_id BIGINT NOT NULL, seat_row INT NOT NULL, seat_col INT NOT NULL, order_id BIGINT DEFAULT NULL COMMENT '关联订单,未支付前可为空', lock_token VARCHAR(32) NOT NULL COMMENT '前端操作令牌', expire_at DATETIME NOT NULL COMMENT '锁定的到期时间', KEY idx_screening_expire (screening_id, expire_at) ) ENGINE=InnoDB COMMENT='座位锁定表';

下单流程变成三态:选座时落seat_lock(带expire_at,默认5分钟),暂不生成订单;确认支付时创建订单并把座位从锁定表移进订单明细表;订单过期或用户取消时删掉锁定记录即可。expire_at字段是核心,后台要跑一个定时任务去看哪些锁到期了还没生成订单,批量释放。

这里有一个关键参数要调:锁定时长。我做过一个影院项目,经理要求锁定时长从默认的5分钟调到10分钟,理由是"用户要选卖品套餐,5分钟太赶"。结果周六晚上后台释放座位的定时任务一跑,一屏全是超时锁,用户怨声载道。后来我们改成前端展示倒计时、后端锁15分钟但到第10分钟时前端自动alert提示"即将超时",投诉率才降下来。锁定时间不是一个拍脑袋的值,要按影城的平均支付时长去统计后设置。

3. SpringBoot后端:并发扣库存与支付回调的工程细节

3.1 座位锁定的原子性:数据库行锁还是Redis分布式锁

选座接口是最核心的并发点。同一场次、同一时刻可能有几十个用户在选同一个座位。如果直接在service层写"先查有没有锁,再插锁",就会出现并发穿透:两个请求同时查到"未锁定",然后同时插入成功,一个座位被两个人买到。这个问题的解决方式要分部署情况看。

单体应用(绝大多数毕设和中小影城项目)不需要上Redis分布式锁,直接用数据库的悲观锁把"查询+插入"做成一个原子操作就能扛住。我常用的写法是:

@Transactional public boolean lockSeat(Long screeningId, Integer row, Integer col, Integer expireMinutes) { // 对排片记录加行锁,避免并发穿透 Screening screening = screeningMapper.selectByIdForUpdate(screeningId); if (screening == null || screening.getStatus() == 4) { throw new BizException("场次不存在或已取消"); } // 检查是否已被锁定或已售出 int lockCount = seatLockMapper.countLocked(screeningId, row, col); if (lockCount > 0) { return false; } SeatLock lock = new SeatLock(); lock.setScreeningId(screeningId); lock.setSeatRow(row); lock.setSeatCol(col); lock.setExpireAt(Date.from(LocalDateTime.now().plusMinutes(expireMinutes).toInstant(ZoneId.systemDefault()))); return seatLockMapper.insert(lock) > 0; }

代码逻辑不难看:先通过selectByIdForUpdate锁住排片记录这一行,后面的核对和插入都在事务里串行执行。这里两个参数值得说:expireMinutes是业务参数,@Transactional则是工程质量参数。如果漏了事务注解,行锁会在第一条SQL执行完就释放,后续的检查和插入还是可能被穿插,检查的意义就废了。

那什么时候用Redis分布式锁?当你的后端服务准备横向扩展成多实例(哪怕只是两台服务器挂在Nginx后面),上述这种数据库行锁就只在单库范围内有效,两个实例同时操作同一行时数据库层面还能兜住,但跨库跨连接池的场景就会出问题。我的建议是:如果短期内没有多实例部署的硬需求,先用数据库行锁,别把架构复杂化;等真的要多实例了,再把lock的插入动作包进Redis SETNX加一个过期时间。不要一上来就为了秀技术加Redis依赖,维护成本和网络开销都是实际的。

3.2 支付回调幂等:不是"回调里改状态"那么简单

订单支付的常规做法是用户在前端跳转支付平台,支付成功之后支付平台异步通知你的后端接口。这里最大的坑是重复回调。支付平台为了保证送达,会以15秒、30秒、1分钟、3分钟的间隔重试,如果你的回调接口直接把订单状态从"待支付"改成"已支付",第二次回调就会把状态从"已支付"改成"已支付"——看起来没坏,但其实它已经触发了一次重复的"出票"操作。

我会在回调处理的第一步做幂等检查:

@PostMapping("/api/pay/notify") public String payNotify(@RequestBody String notifyBody) { // 1. 验证签名(回调来源可信) // 2. 解析订单号和交易状态 Order order = orderMapper.selectByOrderNo(notifyDto.getOrderNo()); if (order.getStatus() == 2) { // 已支付过了,直接返回成功给支付平台,不再走业务 return "success"; } // 3. 加分布式锁或数据库锁后执行:置为已支付、扣减座位、生成取票码 orderService.processPaidOrder(order.getId()); return "success"; }

这段逻辑里有一行注释是"已支付过了,直接返回成功",很多新手会在这里纠结要不要更新一下某个"最后通知时间"字段,我的答案是不用,真正需要的是保证状态机只能向前流转,不能回头。另一个值得说的是,回调接口的返回体要严格返回支付平台要求的格式(通常是纯文本success或fail),有一次某同事把返回内容写成了JSON格式,支付平台不识别,一直重试,直到把日志刷爆才发现。

3.3 定时任务释放过期锁:不干扰高峰期的调度策略

前面提到seat_lock表的expire_at字段,后台需要一个定时任务去清理过期的锁。这个任务有个细节:如果影城周六晚上单量集中,清理任务最好避开整点,因为整点时刻前台会有整点会员特惠之类的秒杀场景,锁表操作容易雪上加霜。我习惯于把清理周期设为5分钟一次,偏差20到30秒再执行。

核心SQL我常写的版本是:

DELETE FROM seat_lock WHERE expire_at < NOW() AND order_id IS NULL LIMIT 500;

加了LIMIT 500是防止一次性删太多把binlog撑大,同时又保证每次任务都有进展。执行方式我用Spring的@Scheduled注解,固定延迟模式:

@Scheduled(fixedDelay = 300000, initialDelay = 120000) public void releaseExpiredLocks() { int deleted = seatLockMapper.deleteExpired(new Date()); log.info("释放过期座位锁 {} 条", deleted); }

fixedDelay表示上一次任务结束之后再过300秒跑下一次,和fixedRate的固定频率有本质区别——如果上一次任务因为数据库慢查询拖了40秒,fixedDelay不会追赶执行,这在清理任务里是我刻意要的效果。

4. Vue前端:从选座组件到订单倒计时的状态管理

4.1 选座组件的数据结构:二维数组比对象散列更好用

Vue端最核心的组件是电影选座页。很多初学者喜欢把座位数据放在一个对象里——key是座位的行列标识,value是座位状态,渲染时用Object.keys循环。这在座位数据量小的时候没问题,但影厅动辄8排12列一共96个座位,用二维数组可以更自然地控制渲染顺序和行列关系。

我惯用的数据结构是:

// hallSeats: 二维数组,行是排,列是具体座位 seats: [ [ { row: 1, col: 1, status: 'available' }, { row: 1, col: 2, status: 'locked' } ], [ { row: 2, col: 1, status: 'available' }, { row: 2, col: 2, status: 'sold' } ] ]

status字段有四个值:available可选、locked别人正在锁定、sold已售出、selected当前用户选中。选座交互的要点是:每次点击座位时,先判断status是available才能切换到selected;一旦切到selected,就要向后台发起一次锁定请求,而不是等用户点完"立即购买"再一次性锁多个座位。这样做的原因在于,如果用户先选中了五个座位,其中某一个已经被别人锁了,等到最后提交时才提示"座位已不可选",体验极其糟糕。

前端发出锁定请求的代码:

async function selectSeat(seat) { if (seat.status === 'locked' || seat.status === 'sold') { ElMessage.warning('该座位已被选走'); return; } if (seat.status === 'selected') { await unlockSeat(seat.row, seat.col); // 取消选中时调解锁接口 seat.status = 'available'; return; } const { data } = await axios.post('/api/seat/lock', { screeningId: currentScreeningId, row: seat.row, col: seat.col, expireMinutes: 15 }); if (data.success) { seat.status = 'selected'; selectedSeatsCount.value += 1; } else { ElMessage.error(data.message); } }

注意这里用户点击取消选中时也调用了后端unlockSeat接口,而不是只在本地把状态改成available。这是新手最容易漏的一步:如果只改前端状态,后端那个5分钟的锁还挂着,用户换了好几个座位之后重新进页面,会看到一堆"幽灵锁"占着自己刚才试过的位置。

4.2 场次切换时的票价联动与状态复位

另一个容易翻车的是"切换场次"时的前端状态。用户可能在14:00场选了3个座位,然后切到16:30场想看看余座,回来时发现14:00场的座位被自己选中的状态还在,但后端锁已经过期释放了。所以场次切换时,需要做三件事:清空当前选座数组、取消所有本地selected标记、重新拉取新场次的座位状态。

这里有两个值得注意的边界:第一,如果当前有订单还没提交,切换时直接清空会导致用户丢失已选座位,需要弹窗确认;第二,切换时要把前端倒计时的定时器清掉,否则会出现在A场次的倒计时显示在B场次的页面上。我在开发时习惯把这些动作包进一个resetSeatSelection()方法里,所有会重置选座的动作(切换场次、退出页面、提交完成)统一调它,避免散落各处的seat.status = 'available'逻辑互相污染。

4.3 订单倒计时:前端显示与后端过期之间的时间差处理

最后一个前端关键点是订单倒计时。后端座位锁定是15分钟,前端拿到锁定成功后开始倒计时,这个倒计时理论上应该以后端返回的expireAt字段为基准,而不是前端自己setInterval从15:00开始减。因为请求的网络耗时、后端当前时间与用户手机时间的差异,会让前端计算出来的剩余时间和后端实际到期时间出现偏差。

我建议的处理方式:

const expireAt = data.data.expireAt; // 后端返回的过期时间点 const remainingSeconds = Math.floor((new Date(expireAt).getTime() - Date.now()) / 1000); let timer = setInterval(() => { remainingSeconds.value -= 1; if (remainingSeconds.value <= 0) { clearInterval(timer); ElMessage.warning('座位锁定已过期,请重新选座'); resetSeatSelection(); } }, 1000);

用户在倒计时归零前点击"提交订单",后端还要再做一次校验:如果当前时间已经超过expireAt,则拒绝创建订单。这就是经典的前端交互友好、后端数据可靠的分工。前端倒计时用new Date(expireAt).getTime()保证和服务器时钟一致,而不是用相对时长累加,可以从根本上避免本地改时钟引发的显示错乱。

5. 管理员端核心:排片管理、报表统计与三个避坑现场

5.1 排片冲突校验:为什么"结束时间要加缓冲"是硬需求

管理员后台最常用的是排片功能。前端表单填电影、选影厅、选时间,后端判断是否与已有排片冲突。这里最常见的坑是:排片判断只看了start_time是否落在别的场次范围内,而忽略了散场后需要保洁和清场时间。

我一般会在排片时做两段校验,第一段是基本冲突:

// 新排片时间窗与已有排片重叠判断 SELECT COUNT(*) FROM screening WHERE hall_id = #{hallId} AND status != 4 AND start_time < #{newEndTime} AND end_time > #{newStartTime}

第二段是硬编码的散场缓冲。比如排片A是14:00开演,电影时长120分钟,那么end_time应该是16:24而不是16:00——因为散场后保洁需要约20分钟,再加上观众离场、下一场观众检票进场需要10分钟。你在排片表单里看到的"是否自动追加30分钟缓冲"选项,就是这个逻辑。真要上线给影城用,这个缓冲时间最好做成参数可配置的,不然每个影城的保洁效率不一样,会导致频繁误判冲突。

5.2 报表统计的坑:金额不能直接SUM订单表

经理最关心的是每日票房、每月经营报表,很多项目的报表SQL写成了:

SELECT movie_name, SUM(order_amount) FROM order_detail WHERE pay_status = 1 GROUP BY movie_name

这句SQL的单日数据下看起来没问题,但时间一长就会翻车,因为一个订单里可能同时包含电影票和卖品套餐,而退款订单你还没有纳入统计。更严重的是,有些影城还有会员充值赠送的活动,充100送10,那这10块赠款到底算不算票房?这是业务口径问题,不能只靠SQL解决。

我的做法是在订单表加一个biz_type字段区分电影票、卖品、充值三种订单类型,同时加一个refund_status字段区分正常、部分退、全额退。报表统计时按业务类型过滤、再按退单状态修正。可以参考这段:

SELECT DATE_FORMAT(pay_time, '%Y-%m-%d') AS biz_date, movie_name, SUM(CASE WHEN refund_status = 0 THEN ticket_amount WHEN refund_status = 2 THEN ticket_amount * -1 END) AS net_ticket_revenue FROM ticket_order_detail WHERE pay_status = 1 AND biz_type = 'MOVIE' GROUP BY biz_date, movie_name

这段SQL里refund_status的枚举值(0正常、2全额退)是我项目里约定的,不同系统可以改枚举名,但核心思想是:报表金额永远统计净收入,而不是流水总额。否则月末对账的时候,财务拿到的数字比实际到账钱数高出一截,你就要解释半天为什么"系统里卖出去20万,银行卡只到账18万"。

5.3 避坑现场:这一节的三个真实踩坑记录

第一坑,支付回调处理里有远程调用。某个版本的回调逻辑里,我先调了通知第三方卖品系统的接口才改订单状态,结果第三方系统超时,支付回调线程等5秒后返回失败,支付平台更疯狂地重推,最后单量翻倍。解决方法是:回调里只做本地事务、只改本库订单状态,通知第三方系统用消息队列异步发出去,不要拖支付回调节奏。

第二坑,数据库连接池默认配置扛不住晚高峰。有个周六傍晚瞬时并发涌进来,后端报HikariPool-1 - Connection is not available, request timed out。查下来是Spring Boot默认连接池最大10个连接,100个选座请求同时进来,每个都抢占连接,队列直接击穿,前端集体转圈。解决方法是按机器配置调大连接池并设置合理的等待超时,一般压测估一个保守值。经验值是单实例4核8G机器开到50个连接起步,所有接口的数据库耗时都控制在100ms内,才会从容。

第三坑,Redis缓存了电影列表但没处理失效。运营在后台改了某部电影的排片状态,前台首页展示的电影列表仍然是旧数据。这类问题现在普遍的做法是不用Redis缓存这些实时性要求高的内容,或者用CacheEvict在后台修改时主动清缓存。我给运营手册里写过一句特别直白的话:"改完东西看不见变化就刷新页面,刷新还不行就重启后端服务"——虽然粗暴,但治好了很多次"数据到底改没改对"的困惑。

6. 上线验证与进阶:用压测报告和几个配置项收尾

系统写完不是终点,让它能扛住真实影城的业务节奏才是。我给这套SpringBoot + Vue的售票系统做上线前验证时,有一套固定的流程,可以照着做,步骤不复杂但很有区分度。

第一步,用并发工具打选座接口。我习惯用简单的压测命令模拟50个并发同时抢同一个场次的一个座位,看超卖发生概率。如果系统实现正确,预期结果是:1个成功,49个返回"座位已被锁定"。如果出现了超过1个成功,说明事务或锁没有加对,要立刻查日志。压测命令不需要复杂的平台,命令行工具或Postman并发跑脚本都可以:

# 模拟并发选座:向锁定接口同时发起50个请求 seq 1 50 | xargs -P 50 -I {} curl -X POST http://localhost:8080/api/seat/lock \ -H "Content-Type: application/json" \ -d '{"screeningId":11, "row":4, "col":7, "expireMinutes":15}'

这段压测命令的实质含义是:把"1到50"的数字通过管道喂给xargs,-P 50表示同时起50个进程,每个进程执行一次curl请求。你把row和col改成自己库里某个场次的具体座位,跑完观察成功返回的请求数量。配合数据库查询能看出来并发逻辑对不对,比看代码更直白。

第二步,核对支付回调的幂等日志。故意向回调接口发送两次相同内容的通知请求,一次正常、一次重复,确认第二次没有生成重复的取票码。这一步的价值在于:支付平台的重试机制你拦不住,但如果内核幂等做对了,重试就只是一条无害日志。

第三步,检查定时任务与高峰期的叠加效果。把释放过期锁的定时任务时间设到每分钟执行一次,压测选座接口的同时观察该任务的耗时,如果超过100ms就要错开时间调度,避免两边抢数据库资源。很多线上事故就是这样来的——定时任务凌晨没问题,白天一叠加就卡顿。

关于进阶方向,最后给两个可以花时间深入研究的技术点。第一个是座位分区定价,把影厅的中间黄金区域、两侧普通区域在数据库里维护一个价格系数,排片时根据基础价乘以系数生成真实票价,这是影城营收优化的刚需;第二个是影厅会员日营销,比如周二会员日所有票打7折,这个场景需要你在订单创建时动态计算折扣,并且要处理"结算时会员等级刚过期"的极端情况。

我今天回头看这套系统的开发过程,最大的教训是:业务边缘条件远比技术框架复杂,数据库约束能挡住的脏数据、幂等设计能避免的重复出票、前端状态复位能消除的幽灵锁,都是上线后真正省心的地方。如果你也是在自己的影城项目或课程设计里趟这条河,遇到问题优先想这三个方向,能少走很多弯路。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询