最近我把一套影院购票系统的完整源码从零到一整理跑通了,SpringBoot后端+Vue前端+MySQL数据库,前后端分离,项目代码拿过来可以直接运行。很多人看到“可直接运行”这四个字会下意识觉得这是入门练手项目,但真上手之后你会发现,这套系统的难点根本不在某个单一技术点,而在座位锁定、订单状态流转、前后端状态同步这三件事怎么配合。这篇文章我会把整个项目的架构设计、数据库建模、后端扣座逻辑、前端选座交互、最后到联调和部署的完整链路拆开讲一遍,适合正在做毕业设计、想入门前后端分离项目、或者想快速复刻一套可用系统的朋友参考。
1. 影院购票系统的定位与整体架构设计
1.1 这套系统到底解决了什么问题
影院购票系统本质上是一个典型的交易类系统,它和普通的管理系统最大的区别在于:它需要同时处理“浏览”和“交易”两条业务线。浏览这条线很简单,影片列表、影片详情、场次查询,基本就是几个CRUD接口;交易这条线才是真正的核心,选座、锁座、下单、支付、出票,每一步都涉及到数据一致性和并发控制。
我在梳理这套源码的时候,第一件事就是把系统拆成两个端:用户端和管理端。用户端解决的是“观众怎么买到票”,包括注册登录、影片浏览、选座购票、订单管理、模拟支付、退票这几个模块;管理端解决的是“影院怎么排片卖票”,包括影片管理、影厅管理、排片管理、订单查看、数据统计。
把两个端分开想,整个项目的结构就清晰了。用户端走的是C端产品的交互逻辑,页面多、状态多、交互复杂;管理端走的是B端工具的逻辑,表格、表单、弹窗,套路相对固定。这套源码里,用户端和管理端是同一个Vue工程,通过路由和登录角色做区分,这种方案在中小型项目里非常常见,比拆成两个前端工程要省事得多。
1.2 前后端分离架构下的模块边界
这套系统采用的是经典的前后端分离架构。前端负责页面渲染和用户交互,后端负责业务逻辑和数据持久化,双方通过JSON格式的RESTful接口通信。
后端SpringBoot工程内部按照传统的三层结构划分:Controller层接收HTTP请求、参数校验、返回统一响应;Service层处理核心业务逻辑,比如锁座、下单、状态流转;Mapper层(配合MyBatis-Plus)负责数据库操作。这种分层方式看起来老套,但它是保证项目可维护性的底线,尤其是当你需要把某些接口给第三方对接的时候,Controller层的独立性会让你省很多事。
前端Vue工程按照Vue Router路由组织页面,页面组件按功能模块拆分成独立目录:首页、影片详情、选座、订单确认、支付结果、个人中心、后台管理。状态管理用Vuex(或者Pinia,取决于你用的Vue版本),主要负责用户登录信息、当前选中的影片和场次、已选座位这类跨页面共享的数据。
前后端交互的规范也很重要。这套源码里后端统一返回{ code, message, data }结构,前端Axios在响应拦截器里先判断code是否为200,再决定走正常逻辑还是异常提示。统一响应结构看起来是多写了几行代码,但在联调阶段能帮你少踩一半的坑,因为你不需要在每个接口里单独处理异常情况。
1.3 为什么是这个组合:SpringBoot、Vue、MySQL
先说结论:这个组合是目前中小型全栈项目里最稳的搭配,没有之一。
SpringBoot解决的是后端开发的效率问题。它内置了Tomcat,简化了Spring配置,配合起步依赖,基本上不用关心各种XML配置和jar包冲突问题。对于这种业务复杂度中等的系统,SpringBoot的约定优于配置能让你把精力放在业务逻辑本身,而不是框架搭建上。
Vue解决的是前端交互的复杂度问题。影院购票系统的选座页面天然适合用Vue的响应式数据来驱动:座位是二维数组,每个座位是一个对象,座位状态变了页面自动刷新,你不需要手动去操作DOM。组件化开发也能把选座、影片卡片、订单列表这类重复出现的界面元素抽象成组件,一套代码多处复用。
MySQL解决的是数据可靠性的问题。有人可能会说用Redis做缓存、用ElasticSearch做搜索、用MongoDB存日志,但对于影院购票这种体量的系统,MySQL单库就完全够用。更重要的是,订单、座位、排片这些数据之间有强一致性的要求,MySQL的ACID事务正是为这种场景设计的。
这套系统之所以强调“可直接运行”,是因为它没有引入任何重量级的外部依赖,只要你的机器上有JDK、Maven、Node.js和MySQL,按文档跑起来就能用。这种轻依赖的特性,对于学习、毕业设计、小规模商用来说,都是最务实的选型。
2. 数据库建模:从订单状态机到座位锁定的核心表设计
2.1 五张核心表的关系,别贪多
影院购票系统的数据库设计,核心就是五张表:用户表、影片表、影厅表、场次表、订单表。剩下的座位表看你的设计粒度,我强烈建议单独拆一张场次座位表出来,别把座位信息塞进场次表里。
用户表就是常规的账户信息,用户名、密码、手机号、注册时间。密码这里多说一句,很多课程项目直接用MD5加密就完事了,但如果你想让系统具备真实可用性,建议用BCrypt加盐哈希,后端加一个工具类就能搞定,成本很低。
影片表存的是电影的基础信息:片名、封面图、导演、主演、时长、上映日期、影片简介、状态(上架/下架)。封面图建议存相对路径,图片文件上传到服务器固定目录,不要往数据库里塞Base64,不然数据库会迅速膨胀,查询也会变慢。
影厅表很简单,影厅名称、行数、列数。它会关联到场次表和座位表,因为每个影厅的座位布局决定了一场电影可以卖多少张票。
场次表是整个系统的枢纽:影片ID、影厅ID、开场时间、散场时间、票价。散场时间可以手动维护,也可以开场时间加影片时长自动计算,源码里一般会做成自动计算,省得排片的时候还要自己算时间。
订单表和场次座位表是业务核心,下面单独讲。
表与表之间的关系大致是这样:用户对订单是一对多,影片对场次是一对多,影厅对场次是一对多,场次对场次座位是一对多,订单对座位通过一个schedule_seat_id关联。整个模型非常干净,画ER图也很直观。
2.2 场次座位表:锁座的关键
场次座位表的字段设计,直接决定了选座并发场景下系统的正确性。我的建议是每一行对应一个影厅里的一个具体座位,字段包括:场次ID、行号、列号、座位状态、锁定时间、锁定订单号。
座位状态是关键字段,我习惯用整型存:0代表可售,1代表已锁定,2代表已售出。这里有一个非常容易踩的坑:不要用“可售/不可售”这种二元状态,因为“锁定”和“售出”在业务上是两种完全不同的状态。锁定是暂时的,用户15分钟不支付就要释放;售出是永久的,支付完成后这个座就不能再动了。
为什么要把场次座位单独拆一张表?因为一个场次有几十上百个座位,如果你把座位信息JSON塞进场次表里,查询和更新的代价会非常大。拆成表之后,每个座位都是一条独立记录,更新单个座位状态只需要精确的UPDATE语句,锁的粒度最小,并发性能也最好。
在初始化排片的时候,后端需要遍历影厅的行列数,生成场次下的所有座位记录。这套源码里做了一个批量插入的接口,排片一次,座位记录全部生成,后面选座和锁定就都在座位表上操作了。
2.3 订单状态机:理解状态流转是理解整个系统的钥匙
订单表的核心字段包括:订单号、用户ID、场次座位ID、场次ID、票价、总价、状态、创建时间、支付时间、取消时间。订单号要唯一,一般由时间戳加随机数拼装,或者直接用雪花算法生成的ID。
订单状态机是整个系统最容易讲清楚但最容易实现错的地方。我用整型存状态:0待支付,1已支付,2已取消,3已退款。
状态的流转路径是这样的:创建订单时状态是待支付,同时把对应座位状态从可售改成锁定;用户点击支付并支付成功后,订单状态从待支付改成已支付,座位状态从锁定改成已售;用户主动取消或者超时未支付,订单状态从待支付改成已取消,座位状态从锁定改回可售;已支付之后用户申请退票,订单状态改成已退款,座位状态从已售改回可售。
| 当前状态 | 触发动作 | 目标状态 | 座位变化 |
|---|---|---|---|
| 待支付 | 支付成功回调 | 已支付 | 锁定 -> 已售 |
| 待支付 | 用户取消/超时未支付 | 已取消 | 锁定 -> 可售 |
| 已支付 | 用户申请退票 | 已退款 | 已售 -> 可售 |
这里有一个容易忽略的点:退款的时候座位已经售出,需要做的是把座位状态改回可售,同时校验这个座位在当前场次没有被其他订单占用。虽然正常情况下不会冲突,但严谨一点总没错。
订单状态机设计好之后,后端的Service层实现就按照这个状态机编码,每一处状态变更都要先校验当前状态是否合法,避免出现“已取消的订单又支付成功”这种脏数据。
3. SpringBoot后端:接口分层与高并发扣座逻辑
3.1 Controller-Service-Mapper三层的职责边界
后端代码的品质,很大程度体现在三层结构的职责是否清晰。Controller层只做三件事:接收请求参数、校验参数基本格式、调用Service接口。Service层做所有的业务判断和数据编排,是整个后端的核心。Mapper层就是简单的数据库读写,配合MyBatis-Plus,通常只需要继承一个BaseMapper就能获得基本的单表CRUD,复杂查询用@Select注解写SQL,或者用QueryWrapper。
我看过很多项目会犯一个典型的错误:把业务判断写在Controller里面。比如下单前判断座位是否可售,这个逻辑应该放在Service层,因为Controller层只负责事情“怎么进来”,Service层才负责“怎么处理”。一旦你把业务判断写进了Controller,后面管理端要调同一个下单逻辑的时候,你只能复制粘贴,然后代码就失控了。
这套源码里统一封装了一个Result类作为返回结构,所有接口都返回Result.success(data)或Result.error(code, message)。前端Axios拦截器只需要判断一次,就能统一弹出错误提示。前端开发对接的时候省了不知道多少重复的异常处理。
3.2 选座扣座的并发安全:用更新行数判断是否抢到
选座扣座是整个后端最核心的一段逻辑。用户在前端点了一个座位,提交锁定请求,后端要做的事情是:把这个座位从“可售”改成“锁定”,然后创建一条待支付订单。
这里最关键的并发问题是:两个用户同时抢同一个座位怎么办?如果先查询座位状态再更新,那在“查询”和“更新”之间是不安全的,两个用户都可能查到可售,然后都执行更新,最后超卖。
正确的做法是用一条带条件的UPDATE语句完成状态变更:
@Update("UPDATE schedule_seat SET status = 1, lock_user_id = #{userId}, lock_time = NOW() " + "WHERE id = #{seatId} AND status = 0") int lockSeat(@Param("seatId") Long seatId, @Param("userId") Long userId);这条SQL的核心在于AND status = 0,它利用数据库行锁保证同一时刻只有一个事务能成功更新。通过影响行数来判断是否锁座成功:返回1说明这个座位被你锁到了,返回0说明别人已经抢了。
锁座成功之后,再创建订单。这两个操作必须在同一个事务里,所以Service方法上要加@Transactional。我建议顺序是:先锁座,再创建订单,最后提交事务。如果创建订单失败,事务回滚,座位自动释放,不会出现“座位锁了但没有订单”的脏数据。
这套源码里还会给锁定操作加一个时间戳,比如当前时间往后推15分钟,作为锁定的过期时间。订单创建成功之后会把过期时间存进订单表或者座位表,后面定时任务扫描的时候,发现当前时间超过了锁定时间且订单还没支付,就自动取消订单并释放座位。
3.3 支付流程:模拟支付和真实支付回调的设计
真实项目中在线支付主要对接微信支付和支付宝,核心逻辑都是“前端拉起支付、用户完成支付、支付平台异步回调通知后端”。回调是支付系统里最容易出问题的一环,因为它涉及签名校验和幂等处理。
签名校验是最重要的安全措施。支付平台回调的时候会带一堆参数,这些参数按规则拼接后用商户密钥做签名,后端先用同样规则计算签名,对比一致才认为是合法回调。如果验签不通过直接丢弃,防止伪造回调。
幂等处理同样关键。回调可能会发送多次,后端拿到回调后应该先查询订单状态,只有当前状态是待支付的时候才更新为已支付,否则直接忽略。这套源码里做了模拟支付模块,提供一个/pay/mock接口,传订单号直接把订单改成已支付,方便你本地跑通整个流程,也不用真的去申请商户号。做成模拟支付的好处是让项目开箱即用,等你需要对接真实支付渠道的时候,只需要替换那个支付接口的内部实现即可。
3.4 管理端接口:排片与数据统计的实现思路
管理端接口相对常规,但排片这块有一个需要注意的联动逻辑:新增一个场次的时候,系统要读取对应影厅的行列数,批量生成这个场次的座位记录。前端选座页面展示的座位图,数据来源就是这批座位记录。
影片管理接口就是简单的增删改查,需要注意删除和上下架的区别。上架状态的影片才能被用户端看到,排片的时候也只能选择上架状态的影片。删除操作我建议用逻辑删除,加一个deleted字段,不然你删除了一部关联着历史订单的影片,那批历史数据就全脏了。
订单查看接口支持按用户、按场次、按状态筛选,管理端表格需要分页查询。数据统计这块可以做一个非常简单的看板:今日票房、今日订单数、热门影片Top5,用几个SELECT COUNT和GROUP BY就能搞定,不需要额外引入报表工具。
4. Vue前端:从选座组件到支付流程的状态同步
4.1 路由设计与页面结构
前端工程用Vue Router组织页面。用户端的路由包括首页、影片详情、选座、订单确认、支付结果、个人中心、订单列表;管理端的路由包括管理后台布局、影片管理、影厅管理、排片管理、订单管理、统计看板。
路由守卫是前端鉴权最容易忽略但必须做的一步。Vue Router的beforeEach守卫里检查本地存储的token,没有token的访问受保护页面直接重定向到登录页;有token的访问登录页则重定向到首页。管理端路由还要额外判断用户角色,不是管理员就提示无权限。
所有页面组件在src/views目录下按业务模块分文件夹,公共组件放在src/components目录下。我在这个项目里把影片卡片、分页器、空状态提示做成公共组件,减少了大量重复代码。比如首页和影片详情页都要展示影片信息,封装一个影片卡片组件之后,两个页面各传各的数据,界面表现完全一致。
4.2 选座组件的核心交互逻辑
选座组件是整个前端最复杂的一块。数据模型是二维数组,每一行对应影厅的一排座位,数组里每个元素的字段包括行号、列号、状态、ID。状态直接决定渲染样式:可售显示为灰色,已锁定显示为红色,已售显示为深灰且不可点击,当前选中的座位高亮显示。
交互逻辑上有几个必须处理的细节:
- 每个场次最多选座数量限制,通常单笔订单不超过6张票,超过要提示用户。
- 点击已售和已锁定的座位时不做任何反应,只提示座位不可选。
- 座位被选中后,前端会把座位ID加入一个数组,同时顶部显示已选座位数量和总价。
- 点击提交订单时,前端把座位ID数组和场次ID发给后端,后端锁定座位并创建订单。
这里有一个前后端状态同步的陷阱:你选好了座位,但提交订单之前另一个用户可能已经把某个座位抢走了。所以提交订单接口的响应里,需要把后端实际锁到的座位ID列表返回给前端。如果发现某个座位没锁成功,前端要立刻把该座位在界面上改成不可选状态,并提示用户重新选择。这套源码在前端做了这个差异处理,你可以在选座页面的交互逻辑里看到这一段。
4.3 Axios封装与Token鉴权
Axios封装是前端工程化的基础操作。我在src/utils/request.js里创建一个Axios实例,设置baseURL、超时时间、请求头。请求拦截器里从localStorage取出token,加到Authorization头里;响应拦截器统一判断code,非成功状态码弹出后端返回的错误信息,401跳转到登录页。
不封装Axios的结果就是每个页面都要重复写错误处理代码,而且一旦后端接口路径变了,你得全局搜索替换。封装完之后,页面组件里只需要写具体的业务逻辑,错误提示统一弹,登录失效统一跳转,维护成本瞬间降下来。
Token这块要注意:登录成功后后端返回token,前端存到localStorage。刷新页面时路由守卫会读到token,用户不需要重新登录。如果要做更安全一点的方案可以把token存到sessionStorage,但考虑到用户希望刷新不丢登录状态,直接用localStorage是这类项目的主流做法。
4.4 管理后台的表格表单与数据联动
管理端页面套路化很强,但有几个联动细节做不好会很别扭。比如排片管理页面,新增排片时,影片下拉框的数据来自影片管理接口,影厅下拉框的数据来自影厅管理接口,选择影片之后可以自动带出影片时长并计算散场时间,选择影厅之后选座页面才能正确渲染座位图。
表格操作列一般放编辑、删除、上架/下架按钮。删除操作要弹确认框,防止误删。编辑操作用弹窗表单,表单提交成功后刷新当前页表格数据,而不是整个页面重新加载。这套源码在管理端的这些交互细节符合后台管理系统的主流规范,用Element Plus的el-table、el-dialog、el-form组件结合,半天就能搭完一个模块。
5. 联调与部署:让“可直接运行”真正落地的关键配置
5.1 版本匹配是第一步,也是最容易翻车的一步
这套系统强调可直接运行,但“可直接运行”是建立在版本匹配前提下的。我整理了一下最稳妥的版本组合:JDK 1.8、Maven 3.6+、SpringBoot 2.7.x、MyBatis-Plus 3.5.x、MySQL 5.7或8.0、Node.js 14/16、Vue 2.7或Vue 3.2,UI库对应Element UI或Element Plus。
SpringBoot版本容错空间其实不大:SpringBoot 2.7.x默认支持的是javax.*包,SpringBoot 3.x全面切换到了jakarta.*包。你如果拿一套基于javax.servlet的旧代码跑到SpringBoot 3.x上,编译期报错反而教你做人,最怕的是某些第三方依赖在3.x环境下安静地出问题。所以我建议源码保持SpringBoot 2.7.x,稳定性最高。
MySQL这里有个老生常谈但必踩的坑:数据库连接串需要配置serverTimezone=Asia/Shanghai,否则8.0以上的MySQL会报时区错误。另外创建数据库的时候,字符集用utf8mb4,不然影片简介里的特殊字符会被截断。
5.2 数据库初始化与配置文件的三个关键点
数据库初始化要求一条命令能跑完。源码里提供了sql/init.sql脚本,按照先建库、再建表、再插入基础数据的顺序组织,注释要写清楚。你拿到源码后,在MySQL客户端执行一次,整个系统的基础数据就齐了,包括测试账号、测试影片、影厅和排片数据。
后端配置文件的三个关键点:
application.yml里的数据库连接信息改成你自己的地址、账号、密码。- MyBatis-Plus的逻辑删除配置要打开,对应的实体字段加
@TableLogic注解。 - 文件上传路径要配置一个你本机的绝对路径,比如
D:/upload或者/data/upload,封面图统一放到这个目录下。
这三个点只要有一个没配置对,系统就起不来,或者起来之后图片加载不出来。我在第一次跑的时候就是被文件上传路径坑了,找了半天才发现是路径不存在。
5.3 跨域的三种处理方式,直接说结论
开发环境下前端在8080端口,后端在8080端口,端口不同必然有跨域问题。主流处理方式有后端加@CrossOrigin、配置全局CorsFilter、前端配置Vite/Webpack的代理转发。
我的建议是:开发环境用前端代理,生产环境用后端CORS配置或Nginx反向代理。主要原因是你用@CrossOrigin一个个加到Controller类上,等接口多了你会发现在复制粘贴;而前端代理的方式只在开发环境生效,打包后的静态文件部署到Nginx,通过Nginx把/api开头的请求转发到后端服务,前端代码里不需要感知后端真实地址。
具体配置在前后端联调文档里写清楚,后端开发不需要关心前端怎么解决跨域,只需要保证CORS放行所有来源即可,这样才能满足前端多种启动方式的需求。
5.4 一键启动的完整步骤
我按照这套源码的启动顺序整理了一遍,你跟着做就能跑起来:
- 安装JDK 1.8、Maven、Node.js 14/16、MySQL 5.7/8.0,确认相关命令在命令行可用。
- 启动MySQL服务,执行
init.sql脚本,创建数据库和表结构并插入初始数据。 - 修改后端
application.yml中的数据库账号密码,确认端口未被占用,进入后端根目录执行mvn spring-boot:run。 - 进入前端目录执行
npm install安装依赖,然后执行npm run dev启动开发服务器。 - 浏览器访问前端地址,用初始化的管理员账号登录后台,检查影片、影厅、排片数据。
- 启动一个测试用户注册流程,选座购票,走一遍完整的下单支付流程。
整个过程正常的话,10分钟内能从零跑到下单成功。这也是这套源码最大的价值:你可以先跑通,再读代码,最后自己改。
如果你需要部署到服务器,前端执行npm run build生成dist目录,后端执行mvn clean package生成可执行jar包。把dist目录放到Nginx站点目录,配置好Nginx将/api请求代理到后端8080端口,jar包用java -jar直接启动,整套系统就上线了。
5.5 我实际跑通后发现值得注意的几个问题
最后说几个我在调这套系统时实际碰到的问题,都属于不致命但会卡你很久的典型情况。
第一个是端口占用。后端默认8080端口很容易被其他开发服务占用,启动报Port already in use。解决办法很简单,要么换端口,要么查出来占用进程直接停掉。Windows上用netstat -ano | findstr 8080查PID,然后任务管理器结束进程,Mac/Linux用lsof -i:8080加kill。
第二个是前端依赖安装失败。npm install偶尔会因为网络问题报错,这时候把镜像源切到淘宝镜像基本能解。不要随便改package-lock文件,否则依赖版本会乱掉,出现一些莫名其妙的兼容性报错。
第三个是座位锁定时间策略。源码里做的是15分钟过期,如果定时任务没启动,锁定的座位永远不会释放。所以启动说明里一定要包含定时任务开关的配置,要么依赖Spring自带的@Scheduled让应用启动就自动开启调度,要么明确告诉用户怎么手动打开。我倾向于前者,开箱即用的体验更好,项目跑起来后每隔一分钟扫一次超时未支付订单,直接把对应座位释放掉。
我实际用下来最深的体会是:这类全栈项目,骨架大家都能搭出来,真正的差异全在细节里。数据库的字段设计能不能支撑并发扣座,事务边界画得够不够清晰,前端选座组件对后端锁座失败的结果处理是否到位,定时任务有没有把座位泄漏的隐患堵上,这些细节决定了一套源码是只能用来演示,还是真的可以拿去应对真实场景。你把上面这些点逐个落实之后,再回头看“可直接运行”这五个字,会明白它背后意味着的是:任何环境下都能被顺利复现的工程质量。