简介:这是一份面向影院信息化建设与全栈开发学习者的影院售票管理系统完整工程资源,涵盖电影排片管理、在线选座购票、会员积分、票房统计分析及后台管理等核心业务模块,既能支撑毕业设计或课程项目落地,也可作为中小型影院的轻量级票务解决方案。资源包共310个文件,约16.23MB,以Java后端代码、Vue前端页面、JS脚本、XML配置为主,并包含JPG设计图、PNG图标、CSS样式及YML配置文件,便于前后端联调与二次开发。系统采用SpringBoot与Vue3组合,兼顾PC端与移动端响应式适配,后台管理界面覆盖排片、销售监控、会员事务与财务结算等运营场景,扩展性设计为后续增加新功能模块预留了空间。压缩包内附部署文档、使用说明及常见问题处理指引,可帮助开发者快速完成环境安装与系统上线。目前已有80人学习浏览,对需要掌握前后端分离架构、影院业务建模及响应式开发实践的人群具有较高参考价值。
1. 从购票页面到出票:这套全栈票务平台到底解决了什么
周末的影院前台永远在排队,而真正让运营方头疼的往往不是卖票本身,而是排片表改了没人同步、某个座位卖出去了系统不知道、会员积分算错被投诉。基于SpringBoot和Vue3的影院电影售票管理系统,就是把这套从排片、选座、购票到统计的链路一次性打通的全栈方案。SpringBoot负责订单、锁座、积分这些业务底座,Vue3承担用户端和管理端的交互,移动端响应式设计让观众在手机浏览器里也能完成选座购票,不用被迫下载App。
这篇文章适合两类人:一类是拿它做课程设计或作品集的学生,另一类是打算给中小影院、剧场做内部票务系统改造的从业者。前者关心怎么把代码搭起来、答辩时能讲清楚哪些点;后者关心数据模型怎么建、高并发下怎么不超卖、部署上线前要堵住哪些坑。下面从业务模型聊到前端适配,再到统计报表和上线清单,全程按可复现的方式写。
2. SpringBoot 后端:排片管理、锁座策略与积分事务的建模
后端是整个系统的核心,排片、选座、会员积分这三块业务纠缠在一起,建模阶段没想清楚,后面每一个功能都会被动。这一章先把表结构定下来,再看并发锁座和积分事务怎么落地。
2.1 排片与场次的数据模型:三张核心表怎么设计
影院业务的起点是电影和影厅。一部电影可以排多个场次,一个场次落在某个影厅里,影厅决定座位布局。建议先建三张基础表:影片表、场次表、场次座位表。
CREATE TABLE film ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL COMMENT '片名', duration INT NOT NULL COMMENT '片长,单位分钟', release_date DATE, status TINYINT DEFAULT 1 COMMENT '0已下架 1上映中 2即将上映', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE schedule ( id BIGINT PRIMARY KEY AUTO_INCREMENT, film_id BIGINT NOT NULL, hall_id BIGINT NOT NULL COMMENT '影厅ID,决定座位模板', start_time DATETIME NOT NULL, end_time DATETIME NOT NULL, base_price DECIMAL(10,2) NOT NULL COMMENT '基础票价', status TINYINT DEFAULT 0 COMMENT '0待开场 1售票中 2已开场 3已结束', INDEX idx_film_start (film_id, start_time) ); CREATE TABLE schedule_seat ( id BIGINT PRIMARY KEY AUTO_INCREMENT, schedule_id BIGINT NOT NULL, row_no INT NOT NULL, col_no INT NOT NULL, status TINYINT DEFAULT 0 COMMENT '0可售 1锁定 2已售 3故障', version INT DEFAULT 0, UNIQUE KEY uk_schedule_seat (schedule_id, row_no, col_no) );这三张表把核心关系串起来:schedule通过film_id关联影片,通过hall_id确定是哪块银幕;schedule_seat则是按场次展开的座位明细,每个场次有多少个座位,这里就有多少条记录。为什么要冗余一张schedule_seat而不是直接查影厅模板?因为座位状态是动态的,同一影厅不同场次,座位售卖情况完全不同,把状态落到场次级才方便锁定和统计。
索引设计上,(film_id, start_time)联合索引支撑排片列表按影片和时间的筛选;schedule_seat的唯一键(schedule_id, row_no, col_no)从数据库层保证同一场次同一个座位只有一条记录,这是后面防超卖的最后一道防线。冗余字段version是给乐观锁用的,稍后在锁座部分会再提到。
排片接口的业务逻辑也不难,录入一个场次时,先校验影片和影厅存在,再检查时间是否冲突,最后根据影厅的座位模板批量生成schedule_seat记录。常见做法是排片这个操作发生在非高峰时段,批量插入的耗时可以接受,没必要为它单独引入队列。
2.2 在线选座锁座:并发下不超卖的核心
在线选座最怕的事是两个观众同时盯着同一个座位提交订单。数据库主键唯一可以挡住最后的重复,但用户体验已经坏了,必须在上游就把冲突拦截掉。这里用 Redis 分布式锁加数据库条件更新双保险。
@Service public class SeatLockService { private final StringRedisTemplate redisTemplate; private final ScheduleSeatMapper seatMapper; /** * 锁定一个座位 * @param scheduleId 场次ID * @param rowNo 排号 * @param colNo 列号 * @param userId 当前用户ID * @param lockSeconds 锁的持有时间 * @return true 表示锁定成功 */ public boolean lockSeat(Long scheduleId, int rowNo, int colNo, Long userId, long lockSeconds) { String key = "seat:lock:" + scheduleId + ":" + rowNo + ":" + colNo; String owner = userId.toString(); Boolean ok = redisTemplate.opsForValue().setIfAbsent( key, owner, Duration.ofSeconds(lockSeconds)); if (!Boolean.TRUE.equals(ok)) { return false; } int rows = seatMapper.markLocked(scheduleId, rowNo, colNo); if (rows != 1) { redisTemplate.delete(key); return false; } return true; } }setIfAbsent是 Redis 的 SET NX 命令,同一个座位 key 同时只有一个用户能写入,写入成功才获得锁。lockSeconds是锁的持有时间,一般来说 30 到 60 秒足够用户完成下单,时间太长会让后面排队的人等太久,太短又可能在下单流程完成前锁就被释放,造成两个订单同时处理同一个座位的风险。
拿到 Redis 锁之后,还要执行markLocked这一条数据库更新。对应的 SQL 要带上状态条件,防止把已经售出的座位重新置为锁定,这是比分布式锁更硬的一层保障。如果更新影响行数不为 1,说明座位状态已经变化,删除 Redis 锁并返回失败,让用户看到“座位被抢走了”的提示。这个组合方案的好处是 Redis 负责并发排队,数据库负责最终一致性,两头都不虚。
看门狗是个加分项。实际项目中,下单流程涉及写订单表、写积分流水、改座位状态,耗时可能超过预设的锁过期时间。常见做法是用 Redisson 的RLock替代手写setIfAbsent,它默认带续期机制,只要业务线程活着,锁就不会提前过期。手写方案的话,最好把lockSeconds设得宽裕一点,并在 finally 里按 owner 校验后删除锁,避免误删别人的锁。
2.3 会员积分与票价计算的耦合点
积分系统的业务逻辑不复杂,但坑在事务边界。常规规则是消费 1 元积 1 分,积分可以抵扣票价或者兑换优惠券,退票时要回退积分。这三条操作分别落在这几个地方:结算时计算可抵扣金额、支付成功后写入积分流水、退票时把积分扣回来。
@Transactional(rollbackFor = Exception.class) public Order createOrder(CreateOrderRequest req, Long userId) { List<SeatLock> locks = seatLockService.verifyLocks(req.getScheduleId(), req.getSeats(), userId); BigDecimal payable = calcPayable(req.getScheduleId(), req.getSeats(), req.getUsePoints()); Order order = new Order(); order.setUserId(userId); order.setScheduleId(req.getScheduleId()); order.setSeatInfo(req.getSeats()); order.setPayableAmount(payable); order.setStatus(OrderStatus.CREATED); orderMapper.insert(order); if (req.getUsePoints() > 0) { pointsMapper.deduct(userId, req.getUsePoints(), "下单抵扣"); } return order; }@Transactional保证下单、积分扣减、座位状态变更要么全部成功,要么全部回滚。这里最容易踩的坑是把积分扣减放在订单支付回调之后做,一旦用户支付成功但积分扣减失败,账就对不上。我的习惯是下单时就预扣积分,支付超时订单取消时再返还,这样财务上永远只有“已扣未支付待返还”和“已扣已支付”两种状态,对账时一目了然。
积分回退也要落流水,不能只改余额。建议单独建一张points_log表,字段包括用户ID、变化值、原因、关联订单号,每一条积分变化都可追溯。观众投诉“我的积分少了”的时候,按订单号一查就知道发生了什么,不用去翻代码猜逻辑。
3. Vue3 前端:座位图组件、移动端适配与请求层设计
后端的模型定了,前端要把这些业务能力变成观众能操作的界面。座位图是交互密度最高的模块,移动端适配决定用户会不会在手机上完成购票,请求层则关系到登录态和接口稳定,这一章逐个拆开。
3.1 座位图用 Canvas 还是 DOM 网格
座位图的基础需求是渲染几十个到几百个座位,支持点选、标记已售和已锁定。常见有两种做法:Canvas 绘制和 DOM 网格。座位数在一两百的影厅里,DOM 方案完全够用,代码直观、事件绑定简单、CSS 控制样式也方便;如果是上千座的大型剧院,或者要做缩放拖拽、座位朝向旋转,Canvas 会更稳。
<script setup> import { ref, computed } from 'vue' const props = defineProps({ seats: { type: Array, required: true } // 每项结构:{ row: 3, col: 5, status: 0 } status: 0可售 1锁定 2已售 3故障 }) const selected = ref(new Set()) function toggleSeat(seat) { if (seat.status === 2 || seat.status === 3) return const key = `${seat.row}-${seat.col}` if (selected.value.has(key)) { selected.value.delete(key) } else { selected.value.add(key) } } const selectedList = computed(() => { return props.seats.filter(s => selected.value.has(`${s.row}-${s.col}`)) }) </script> <template> <div class="seat-grid"> <div v-for="seat in seats" :key="seat.row + '-' + seat.col" class="seat" :class="{ selected: selected.has(seat.row + '-' + seat.col), sold: seat.status === 2, locked: seat.status === 1 }" @click="toggleSeat(seat)" > {{ seat.row }}-{{ seat.col }} </div> </div> </template>这个组件的核心是selected这个 Set,用Set而不是数组的好处是去重和删除都高效。toggleSeat只处理可售座位,已售、锁定、故障的座位直接返回,前端先把能点的范围卡死,减少无效请求打到后端。selectedList是计算属性,组件外部通过它获取选中结果,提交订单时把row和col传给接口就行,不需要在父组件里再维护一份选中状态。
DOM 方案在布局上有一个细节:座位间距用gap模拟过道会被挤压。我的做法是把座位区域分成几个区块,每个区块是一个独立的 grid,区块之间用固定宽度留白,效果比单纯加大gap更接近真实影厅布局。这块属于打磨项,但观众对座位图的直觉感受很敏感,值得花时间调。
3.2 移动端响应式:viewport 单位和 100vh 的取舍
标题里特意写了移动端响应式,这意味着前端要同时面对桌面管理后台和手机购票页两种形态。常见的实现思路是管理端用固定宽度的侧边栏布局,用户端走流式布局加viewport单位适配。
/* 移动端页面基础样式 */ html { font-size: calc(100vw / 750 * 100); } .seat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(40px, 1fr)); gap: 8px; padding: 16px; max-width: 420px; margin: 0 auto; } .submit-bar { position: fixed; bottom: 0; left: 0; right: 0; height: calc(80px + env(safe-area-inset-bottom)); padding-bottom: env(safe-area-inset-bottom); background: #fff; box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06); }100vw/750*100是以 750px 设计稿为基准的 rem 换算,配合postcss-px-to-viewport这类插件,开发时直接写设计稿像素,构建后自动转成 vw。这个方案比flexible.js更干净,不需要在运行时动态改font-size。桌面端页面则单独写一套布局,避免 rem 单位把页面撑得过大。
env(safe-area-inset-bottom)是给全面屏手机底部的安全区预留空间,否则固定在底部的提交订单栏会被 Home 指示条遮挡,这是移动端适配里很容易漏掉的一环。另一个高频问题是100vh在浏览器地址栏收起展开时出现滚动条跳动,后面的避坑章节会单独说。
3.3 请求层设计:Axios 拦截器与 Token 刷新
前后端分离的项目必须在一开始就把请求层封装好,否则每个页面都写一遍fetch,一旦要加 token、处理 401、统一错误提示,改动量会非常痛苦。常见的做法是封装一个 axios 实例,把业务通用的逻辑都塞进拦截器。
import axios from 'axios' import { useUserStore } from '@/stores/user' const http = axios.create({ baseURL: '/api', timeout: 10000 }) http.interceptors.request.use(config => { const store = useUserStore() if (store.token) { config.headers.Authorization = `Bearer ${store.token}` } return config }) http.interceptors.response.use( res => res.data, async err => { const store = useUserStore() const { config, response } = err if (response?.status === 401 && !config._retried) { config._retried = true try { const { data } = await axios.post('/api/auth/refresh', { refreshToken: store.refreshToken }) store.setToken(data.accessToken) config.headers.Authorization = `Bearer ${data.accessToken}` return http.request(config) } catch (refreshErr) { store.logout() return Promise.reject(refreshErr) } } return Promise.reject(err) } )拦截器里_retried标记是防递归的,没有它的话,refresh 接口本身也走这个拦截器,一旦 refresh 返回 401,就会陷入无限循环。token 存 Pinia 而不是 localStorage 的原因是为了在多个标签页之间同步登录态,刷新页面时再从持久化存储恢复。每次请求都在Authorization头带上 Bearer token,后端统一解析,不要在接口参数里传 token。
如果后端接口支持,请求层还应该统一处理业务错误码。比如后端返回{ code: 40301, msg: '座位已被锁定' },拦截器里检测到code不是 0,统一弹 toast 并把错误码透传给调用方,页面就能根据错误码做差异化提示。
4. 票房统计与后台管理:从订单流水到运营报表
后台功能是售票系统的另一半,排片员要改场次定价,财务要对账,运营要看每天哪部片子上座率最高。统计这一块看似只是查表,但口径搞错了,报表就是误导运营决策的元凶。
4.1 统计口径:订单快照还是实时聚合
票房统计最忌讳用错误的口径。按什么时间算票房?是订单创建时间、支付时间还是观影时间?按票面价格还是实付金额?一张订单如果用了积分抵扣,实付金额和票面金额就不一致。统计系统应该把这个问题在一开始就统一:日票房按支付成功且未退款的订单的实付金额求和。
SELECT f.title AS 影片名称, COUNT(DISTINCT o.id) AS 订单数, SUM(o.paid_amount) AS 票房, SUM(o.seat_count) / h.capacity AS 上座率 FROM orders o JOIN schedule s ON o.schedule_id = s.id JOIN film f ON s.film_id = f.id JOIN hall h ON s.hall_id = h.id WHERE o.status = 'PAID' AND o.paid_at >= #{startTime} AND o.paid_at < #{endTime} GROUP BY f.id, f.title, h.id ORDER BY 票房 DESC这个查询的关键是o.status = 'PAID'和paid_at时间范围。如果按订单创建时间统计,大量未支付订单会污染数据;如果漏掉退款订单,票房会被高估。seat_count是下单时就冗余在订单里的字段,h.capacity是影厅总座位数,上座率用这两个字段相除,比在schedule_seat里数状态要快得多。
数据量小的时候实时查询没问题,但影院经营几个月后订单量轻松到几十万条,按天查询还行,按年统计、按影片维度聚合就会变慢。我一般会在订单表上建立(status, paid_at)联合索引,同时把常用报表结果落到一张聚合表里,每天凌晨用定时任务跑一次前一天的数据,运营第二天早上看表就是秒开。
统计模块还要处理一个特殊场景:退票发生在统计之后怎么办?常见做法是聚合表里同时记录总票房和退款票房,两个值分开列,对账时总票房减去退款就是净票房,而不是在退款发生时去改历史记录。历史报表一旦被修改,财务对账就失去了锚点。
4.2 后台权限模型:RBAC 的最小可用实现
后台管理通常要区分三类人:超级管理员、排片运营、财务。一个最小可用的 RBAC 模型需要五张表:用户表、角色表、菜单表、用户角色关联表、角色菜单关联表。Spring Boot 里最常见的落地方案是 Spring Security + JWT,权限数据放数据库,登录时加载用户角色。
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.csrf(csrf -> csrf.disable()) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/login", "/api/film/list", "/api/schedule/list").permitAll() .requestMatchers("/api/user/**").hasRole("USER") .requestMatchers("/api/operator/**").hasAnyRole("ADMIN", "OPERATOR") .requestMatchers("/api/admin/**").hasRole("ADMIN") .requestMatchers("/api/finance/**").hasAnyRole("ADMIN", "FINANCE") .anyRequest().authenticated() ) .exceptionHandling(eh -> eh.authenticationEntryPoint(restAuthenticationEntryPoint())); return http.build(); }这里的路径划分要跟业务模块对应好:用户端接口都在/api/user/**下面,普通观众登录后只能访问自己的订单和积分;排片员能操作/api/operator/**下的排片和场次接口,但碰不到财务数据;财务只能访问/api/finance/**的统计和退款审核接口。角色继承通过hasAnyRole表达,ADMIN默认拥有全部权限。
运营端还有一个容易忽略的点:操作日志。排片员改了一场电影的时间,财务在后台退款了一笔订单,这些操作都应该记录到日志表里。它不仅仅是为了出事追责,更是为了方便排查排片冲突这类问题。常见做法是在修改类接口上加一个切面注解,自动记录操作人、操作时间、请求参数和结果,落地成本不高,但对管理类系统的价值很大。
5. 避坑指南:选座购票系统高频踩坑与排查
全栈系统的坑往往不在单点技术,而在模块交接处。锁座和订单之间、移动端适配和用户习惯之间、统计和数据口径之间,都有藏得很深的暗礁。这一章把几个最高频的问题按现象、原因、解决三个层次写清楚。
5.1 锁超时导致同座位双出票
现象是高峰时段两个用户几乎同时下单同一个座位,系统竟然都返回了成功。排查 Redis 锁日志,发现第一笔订单处理耗时超过了锁的过期时间,锁被自动释放,第二个用户趁虚而入。
原因是手写的 Redis 锁没有续期机制,业务处理时间超过预设的lockSeconds后锁自然失效。解决方法是把锁过期时间设得足够宽裕,或者在拿到锁后启动一个定时续期任务,业务结束或异常时显式释放。用 Redisson 的RLock是最省心的方案,看门狗默认每 10 秒续一次期,业务线程不结束锁就不会过期。释放锁的时候记得判断持有者,防止线程 A 把线程 B 的锁误删。
5.2 订单超时未支付,座位锁一直不释放
现象是用户在选座页锁了座位但不支付,离开页面后座位状态一直是锁定,后面的人永远选不了。原因是前端关闭页面时没有调用取消锁接口,后端也没有兜底的超时释放任务。
解决方法是双管齐下。前端在组件卸载时调用一次释放锁接口,能覆盖大部分正常关闭场景;后端再加一个定时任务,每 30 秒扫描一次超过 10 分钟仍处于CREATED状态的订单,将它们取消并把座位状态回滚为可售。这个兜底任务必须幂等,同一订单扫到多次不能重复处理,判断条件就是订单状态由CREATED更新为CANCELLED影响行数为 1。
5.3 移动端 100vh 在浏览器地址栏收起时布局跳动
现象是用户在底部选座时点击输入框,浏览器地址栏收起或展开,页面底部突然多出一块空白或遮挡。原因是height: 100vh在移动端浏览器里不等于可见视口高度,地址栏伸缩会让 vh 值变化。
解决方法是改用100dvh或100svh,它们是动态视口单位,跟着地址栏状态实时变化。设置height: 100dvh就能保证底部提交栏始终在可见区域内。iOS 下的env(safe-area-inset-bottom)也要配合使用,否则页面底部还会被 Home 指示条盖住。
5.4 用户在支付页连点两次提交,生成两笔订单
现象是同一用户提交订单后,支付页短时间内点了两次确认,后台出现两笔金额相同、座位完全一样的订单,但库存居然没有超卖。原因是座位锁在第一次提交时已经占用,第二次下单锁座失败,但订单记录仍然被创建了。
解决方法是前端在点击后立即把提交按钮置为loading/disabled,后端在下单接口里做幂等校验。用户在创建订单前先检查该场次的相同座位是否已经关联了当前用户的有效订单,有就直接返回已有订单。还可以要求前端传一个幂等键,后端记录(userId, idempotentKey)唯一索引,重复请求直接命中已存在记录。
6. 部署上线前必须做的三件事:压测、订单补偿和核心指标监控
上线前再怎么自查都不为过。我习惯把风险集中在三个地方验证:并发锁座的正确性、订单与支付状态的最终一致性、线上运行时可观测性。
压测不只是为了看 QPS,更重要的是验证极端场景不超卖。我一般写一个模拟脚本,让 200 个并发用户同时抢同一场次的同一个座位,反复跑几轮,断言最终只有一笔订单成功、座位状态为已售。这个脚本的断言比压测报告里的数字更有价值,它能暴露锁失效和库存不一致的问题。压测时看两个指标:Redis 锁冲突数和数据库更新影响行数,两者加起来应该等于总请求数。
订单补偿是支付环节的必修课。支付回调可能丢失、延迟或者重复,系统里必须有定时任务去扫描那些已支付但订单状态没更新的记录。典型场景是用户支付成功但回调没到达,订单卡在CREATED状态,定时任务扫描时发现支付平台侧有成功记录,主动把订单状态修正为PAID,再触发后续的积分发放和出票通知。
监控指标不需要贪多,选几个能直接反映业务健康的就够:订单支付成功率、座位锁冲突率、Redis 内存与连接数、数据库慢查询数。给每个指标配一个报警阈值,比如订单支付成功率低于 95% 就触发告警。这些指标的采集用 Spring Boot Actuator 加轻量级监控组件就能覆盖,不需要把系统做得很重。
说实话,这套系统的技术难度不在某个单点框架上,而在把这些模块粘在一起时不留缝隙。我也是在一次双出票事故之后才养成了先写压测断言再上线的习惯,每次改动涉及锁状态和订单状态,先把极端场景跑一遍再谈新功能。希望帮到你,动手做起来,踩过一两个坑就会真正掌握这套全栈方案。
本文还有配套的精品资源,点击获取