这段时间我在持续迭代一套电影院购票管理系统,技术栈就是经典中的经典:Spring Boot做后端,Vue做前端。做它的起因很实际——本地一家小型影院还在用Excel排片和人工记票,顾客能不能买到票要看运气,售票员还要在开演前反复核对余票。于是,我带着一个很朴素的诉求启动了这个项目:让用户在手机上能看电影排期、选座、下单、支付,让管理员在后台能维护电影和场次,所有数据实时统一。
现在这套系统已经跑得比较顺,源码、说明文档、调试方法都整理成了一份可交付的成果。我会把整体架构、核心模块、前端交互、调试踩坑和文档组织方式全部摊开讲一遍。适合三类人:一是想拿Java+Vue做大型课程设计的学生,二是想快速给门店或小影院搭建线上售票入口的开发者,三是准备应聘全栈开发但还差点完整作品的朋友。文章不按什么“从入门到精通”的流水账来写,我直接从我和这个系统磨合的过程说起。
1. 购票管理系统到底在解决什么问题:先理解业务再动手
很多人一听到电影院购票系统,第一反应就是“这不是电商吗?电影列表、场次列表、订单,三个表搞定”。真上手做就会发现,购票业务和普通商品交易有一个本质区别:商品有库存,座位也有库存,但座位的库存还带着坐标和相邻关系。你要做的不是简单地扣一个数字,而是要管住“哪个座位被谁占了”“并发请求同时抢同一个座位怎么办”“开演前多久停止退票”这些细节。
1.1 购票业务里最容易被忽略的三件事
第一件是座位锁定与订单有效期的配合。用户选好座位后,不能直接就把座位状态改成已售,万一他没付款呢?所以常见做法是“锁定”状态,锁定后还要给一个倒计时,比如10分钟内未支付自动释放。这个倒计时如果只在前端做,用户一刷新就失效;正确做法是后端记录锁定时间,定时任务或者懒释放兜底。
第二件是超卖和重复下单。多个用户同时点击同一个座位,后端要是只用普通的select再update,会有并发问题。要么给座位表加锁,要么用数据库的唯一索引,要么引入Redis锁,选一种适合自己项目规模的方案。我在这套系统里用的是数据库行锁加状态机校验,简单、好解释,也足够应对一个普通影院的峰值流量。
第三件是场次、影片、影院三者之间的关联。一个影院有多个厅,一个厅在不同时间段放映不同电影,这其实就是经典的排片问题。排片表要存影厅ID、影片ID、开始时间、结束时间、票价、余票状态。票价还分普通厅、情侣厅、VIP厅,这些信息必须落在场次里,而不是简单的“电影表有价格字段”。
1.2 为什么我坚持用 Spring Boot + Vue 这套组合
后端用Spring Boot,核心原因是它把大量配置都“约定好了”,提供内嵌Tomcat,打个jar包就能跑,不需要单独部署Web容器。这对一个要频繁交付、换环境调试的项目来说太重要了。前端用Vue,是因为它的组件化开发非常适合“选座”这种高强度交互页面,而且Vue生态里的Element Plus组件库可以快速搭出后台管理界面,表格、表单、弹窗都是现成的。
更重要的是,这套技术栈在中文社区里的资料极多。遇到任何报错,把关键报错信息复制到搜索引擎,基本都能找到同款问题。对于需要自学、需要找人答疑的开发者来说,这种“试错成本低”的优势非常明显。你不太会卡在一个冷门技术上两周出不来。
1.3 一套完整的交付物该包含哪些东西
我交付这个项目时,给它定义成“源码+文档+调试+基础修改+答疑”五个部分,而不是只丢一个压缩包。源码就不多说了,一定是能直接编译运行的;文档要包括环境安装说明、数据库初始化脚本、接口说明和启动手册;调试是帮对方把本地环境跑通,并教会他自己处理常见报错;基础修改是支持改改影院名称、票价、座位图、订单超时时间这些常规配置;答疑则是提供一段时间的持续支持。
如果你是自己学习,我建议也按这个标准来要求自己。一个项目值不值钱,不在于代码多炫,而在于别人能不能快速看明白、跑起来、改得动。
2. 技术架构与数据库表设计:先把地基打稳
这类管理系统的架构已经高度成熟,不需要创新,关键是规范。后端我采用经典的四层结构:Controller层接收请求、Service层处理业务逻辑、Mapper层操作数据库,再加上一个实体类层和DTO层。前端采用Vue单页应用,通过Axios调用后端接口。
2.1 后端项目结构和依赖清单
一个标准的Spring Boot项目,包结构是这样的:
controller:用户端接口、管理员端接口分开建包service:核心业务逻辑,事务都在这一层控制mapper:MyBatis的接口层,配合XML或注解SQLentity:与数据库表对应的实体类dto:前端传参和接口返回的数据对象config:跨域配置、拦截器配置、WebMvc配置common:统一返回结果类、异常处理类、工具类
依赖方面我选的是Spring Boot 2.7.14、MyBatis-Plus 3.5.3、MySQL 8.0、Hutool工具库、JWT做登录令牌、Lombok简化实体类。我没有引入过于复杂的Spring Cloud或者分布式组件,因为一个电影院购票系统是典型的单体应用,引入微服务反而是负担。如果你想练手,也可以把Redis加进来做缓存和分布式锁,但初版尽量别给自己加戏。
选MyBatis-Plus而不是Spring Data JPA,是看重它的BaseMapper能直接提供单表CRUD,分页查询也有现成插件,开发效率高。但真正复杂的SQL我还是写在XML里,比如多表关联查询场次和影片信息,这样后期调优的时候能一眼看清Sql执行计划。
2.2 核心数据表设计:从用户到订单的完整链路
一个可用的电影院购票系统,数据表至少要覆盖这些:用户表、电影表、影厅表、场次表、座位表、订单表、订单明细表。表关系并不复杂,但每张表都有一些关键字段不能省。
| 表名 | 关键字段 | 设计说明 |
|---|---|---|
| t_user | id, username, password, phone, role | 角色字段区分普通用户和管理员 |
| t_movie | id, title, cover_url, duration, release_date, status | 上映状态为上架/下架 |
| t_hall | id, name, seat_layout | seat_layout储存默认座位图JSON |
| t_schedule | id, movie_id, hall_id, start_time, end_time, price, status | 排片核心表,价格放到场次级 |
| t_seat | id, schedule_id, row_no, col_no, status, lock_time, order_id | 座位状态:可用/锁定/已售 |
| t_order | id, order_no, user_id, total_amount, status, create_time | 订单状态:待支付/已支付/已取消/已退款 |
座位表这里需要特别解释一下。我之前也考虑过用Redis里的位图来表示座位状态,性能最高,但对于一个教学型、交付型项目,把座位落到MySQL表里更容易理解,也方便随时检查数据。每条场次记录会生成对应影厅的一批座位记录,比如一个厅10排、每排12座,那就是120条记录。场次上座率、座位状态统计都直接查这张表,逻辑一目了然。
2.3 统一返回结果和全局异常处理
前后端分离的项目,最忌讳每个接口返回的数据格式不一样。我的做法是定义一个Result<T>类,里面包含code、message、data三个字段。成功返回200,业务异常返回自定义错误码,未登录返回401。前端Axios统一拦截,看到code != 200就直接弹出错误提示,不需要每个页面重复处理。
public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 200; r.message = "success"; r.data = data; return r; } public static <T> Result<T> fail(String message) { Result<T> r = new Result<>(); r.code = 500; r.message = message; return r; } }全局异常处理用@RestControllerAdvice,把参数校验异常、业务异常、数据库异常分别捕获,转换成统一的Result返回。这样做的好处是前端永远只需要处理同一种结构,排查问题时翻阅后端日志也能快速定位是哪一层抛出的异常。
3. 后端核心模块实现:登录鉴权、排片、选座与订单
这一章是整个系统真正值钱的部分。增删改查谁都会写,但登录鉴权、选座锁座、订单状态流转这些需求,才是决定系统能不能真正上线运行的关键。
3.1 基于JWT的登录鉴权方案
现在的管理系统基本不会再使用Session方案了,接口要支持微信小程序、App、网页多端调用,无状态是最省心的。我采用的是JWT方案:用户登录成功后,后端生成一个包含用户ID和角色信息的token,前端存储在localStorage里,之后每次请求在Authorization头带上这个token。
后端用一个拦截器来统一校验token,白名单放行登录接口、电影列表接口等不需要登录就能访问的接口。受保护的接口在拦截器里解析token,解析通过就把用户信息放入ThreadLocal,Controller里直接获取当前登录用户,这样接口就不用每次都传用户ID了。
@Component public class JwtInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { return true; } String token = request.getHeader("Authorization"); if (StringUtils.isBlank(token)) { throw new BusinessException(401, "未登录"); } Long userId = JwtUtil.parseToken(token); UserContext.set(userId); return true; } }密码存储一定要用BCrypt加密,不要明文存数据库,这也是我在文档里反复强调的安全底线。哪怕是个课程设计,也应该从第一行代码就养成这种习惯。
3.2 排片管理:场次生成与影厅冲突校验
管理员创建场次的时候,必须校验同一影厅在同一时间段是否已经安排了其他场次。这个校验放在Service层,插入前先查询:
SELECT COUNT(*) FROM t_schedule WHERE hall_id = #{hallId} AND status != 2 AND #{startTime} < end_time AND #{endTime} > start_time这样就把时间交叉的场次拦住了。同时系统要根据场次自动生成座位记录,我在新增场次的方法上加了@Transactional,保证场次和座位记录要么同时成功,要么同时回滚。生成座位的逻辑就是从影厅表读取默认座位布局JSON,循环生成t_seat记录,初始状态全部为可用。
这里有一个很实际的经验:用户端展示场次列表时,不能把已结束的场次也展示出来。查询的时候要用当前时间过滤,而且最好把end_time也算出来,避免出现“电影放了半小时还能买票”的滑稽情况。
3.3 选座锁定与并发防超卖
这是整个系统技术含量最高的地方。我采用的方案是:用户在选座页面点选座位后,前端先把座位临时标灰,同时调用后端lockSeat接口,后端用数据库行锁保证同一时间只有一个请求能操作同一个座位。
@Transactional public List<SeatVO> lockSeats(Long scheduleId, List<String> seatCodes, Long userId) { Schedule schedule = scheduleMapper.selectByIdForUpdate(scheduleId); for (String code : seatCodes) { Seat seat = seatMapper.selectByScheduleAndCodeForUpdate(scheduleId, code); if (seat.getStatus() != 0) { throw new BusinessException("座位已被锁定或售出"); } seat.setStatus(1); seat.setLockTime(new Date()); seat.setUserId(userId); seatMapper.updateById(seat); } return getSeatList(scheduleId); }selectByIdForUpdate和selectByScheduleAndCodeForUpdate这两句SQL都会在查询时加上行级排他锁,事务提交后锁才释放。也就是说,同一时间多个用户抢同一个座位,只有一个事务能成功执行更新,其他事务会等待锁,一旦获得锁后发现状态已经不是可用,就会抛出业务异常。
同时我会在Redis里给该用户设置一个10分钟的临时键,当用户提交订单后清除。如果前端拿到“座位已被锁定”这个异常,就提示用户重新选择其他座位。这套逻辑不需要引入消息队列和复杂分布式锁,已经能保证一个几千人同时在线的影院的正常购票流程。
3.4 订单生成、支付模拟与超时释放
锁座成功之后,前端跳转订单确认页,后端根据锁定的座位和场次信息计算出订单金额,生成一条待支付订单。订单号我用年月日时分秒加随机数生成,避免使用数据库自增ID作为对外订单号,那样太容易被人遍历。
支付环节做了两层设计:第一层是模拟支付接口,用户在前端点“立即支付”,后端直接调用模拟支付方法,把订单状态更新为已支付,座位状态更新为已售;第二层保留了真实支付渠道的接入位置,以后要接微信支付或支付宝,只需要在Service层增加一个接口实现类。这种设计对交付型项目特别友好,因为对方不一定有商户号,但也要能完整跑通购票流程。
超时释放的逻辑用一个定时任务,每两分钟扫描一次锁定时间超过10分钟且未支付订单,将对应座位还原为可用状态,订单状态置为已取消:
@Scheduled(fixedDelay = 120000) public void releaseExpiredLocks() { List<Order> orders = orderMapper.findExpiredPendingOrders(); for (Order order : orders) { orderMapper.updateStatus(order.getId(), ORDER_CANCELED); seatMapper.releaseSeatsByOrderId(order.getId()); } }还要注意一个边界问题:买家已经下单了,但支付回调还没确认,此时座位应该是什么状态?我的处理是座位锁定状态下不允许同时被另一个订单关联,只有支付成功才改成已售,这样即使支付结果延迟,也不会出现一票多卖。
4. Vue前端的关键交互:从首页到选座的完整链路
后端做得再完善,前端体验不行,用户照样不会用。这一章我挑重点讲,尤其是选座组件的实现和前后端联调最容易出问题的地方。
4.1 前端工程结构与管理端布局
Vue项目我使用的是Vue 3加Vite,配合Vue Router和Pinia。目录结构上,views下面按照home、movie、booking、order、admin分模块,components里放了SeatPicker、MovieCard、ScheduleList等通用组件。管理端单独使用一套路由,并且通过路由守卫判断用户角色,普通用户访问后台会被重定向到首页。
很多人会在前端用localStorage存登录状态,但我建议把登录状态交给Pinia统一管理,同时配合路由守卫在跳转前判断是否存在token和用户信息。刷新页面后Pinia状态会丢失,所以要写一个初始化逻辑,从localStorage恢复用户信息。这个细节不写会导致刷新后菜单状态错乱、接口401等问题。
4.2 开发环境代理解决跨域问题
前后端分离开发,最容易遇到的第一个大坑就是跨域。后端虽然做了CORS配置,但我建议前端在开发环境用Vite的proxy代理来转发请求,因为这样生产环境和开发环境的请求路径完全一致。
// vite.config.js export default defineConfig({ server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '') } } } })配置好代理后,前端所有请求写成/api/movie/list,Vite开发服务器会把它转发到http://localhost:8080/movie/list,前端页面不会有任何跨域报错。等到打包部署时,再把后端接口路径和前端静态资源放在同一个域名下,更不会出现跨域问题。
4.3 选座组件的核心逻辑
选座是前端所有页面里交互最复杂的。我在SeatPicker组件里用一个二维数组表示座位图,每个座位有四种状态:可选、已售、已锁定、自己选中的。渲染时直接遍历二维数组,生成带坐标的div或button。
const seatMap = ref([]) const selected = ref(new Set()) function toggleSeat(row, col) { const key = `${row}-${col}` const seat = seatMap.value[row][col] if (seat.status === 'available') { if (selected.value.has(key)) { selected.value.delete(key) } else { selected.value.add(key) } } }点击座位后,要立即调用后端锁座接口,而不是等到最后一步才锁。这个体验和主流购票平台是一致的:你选中一个座位的瞬间,它就被别人锁定了。锁座成功后更新本地座位状态,如果锁定失败就把座位状态回退为可选,并弹出提示。前端状态和接口必须同步管理,不能出现前端显示可选、后端已经卖出这种不一致。
我还会限制单选和连坐的逻辑:普通厅允许单个座位购买,但两张以上时尽量引导选择相邻座位;VIP厅支持一次整排锁定。用一个checkAdjacent方法在提交前校验,减少用户下单后因为座位不相邻而产生的投诉。
4.4 订单确认与支付页面
用户点“提交订单”后,前端把场次ID、座位编号列表、用户ID传给后端。后端生成订单后返回订单号和待支付金额,前端跳转到订单确认页,展示订单详情和倒计时。倒计时一到,前端自动刷新订单状态,提示用户订单已取消。
支付按钮点击后,如果是模拟支付模式,直接调后端接口标记支付成功,然后跳转到支付成功页。这个流程虽然短,但要注意按钮防重复提交:用户手快连点两次,会产生两笔相同订单或一次请求被发送两次。我的做法是点击后立刻把按钮置为loading,同时加一个不可重复提交的标识,直到接口返回成功或失败才恢复。
5. 调试排错实战:这些坑我替各位踩过了
交付源码只是第一步,真正拉开差距的是调试能力。一个项目在别人电脑上能跑,在你的电脑上报错,80%都出在环境配置、依赖版本、路径这三类问题上。我按照自己实际踩过的坑,把最典型的几个排错链路完整写出来。
5.1 端口占用:后端8080起不来
这是最简单的坑。启动Spring Boot时报Port 8080 was already in use,第一反应不应该是去改配置,而是先查谁占用了端口。Windows用netstat -ano | findstr 8080,Linux用lsof -i:8080,找到PID后结束进程,或者确实需要保留那个进程时再改端口。改端口建议在application.yml里显式配置server.port,而不是依赖默认值。
改完之后还要注意前端代理的target端口要对应修改,很多人改了后端端口,忘了改Vite里的代理配置,导致前端仍然访问旧端口,结果一堆404。
5.2 Maven依赖下载缓慢或版本冲突
国内Maven仓库默认是中央仓库,下载速度很慢。我的方案是在settings.xml里配置阿里云镜像。如果你的代码是从别处拿来的,还要检查pom.xml里各依赖版本是否与JDK版本兼容。Spring Boot 2.x一般配JDK 8或11,Spring Boot 3.x必须配JDK 17以上。如果下载的依赖版本太高,比如Spring Boot 3.2配了旧版MyBatis-Plus,启动阶段就可能报ClassNotFoundException。
排查依赖冲突,我习惯在运行项目前执行mvn dependency:tree,看有没有多个版本的相同jar包。遇到无法解决的冲突,直接去掉子依赖里的传递依赖,显式引入需要的版本。一个问题如果不解决,后面每启动一次报一次,非常消耗时间。
5.3 IDEA调试后端的正确姿势
我很少用System.out.println调试接口。后端调试基本是断点加观察变量。在Service层接口实现方法上打断点,用IDEA的调试模式启动项目,前端触发请求后,可以逐行看参数传递、SQL执行、状态变更。step over是逐行执行,step into是进入方法内部,evaluate expression可以临时执行表达式,这些基础操作要熟练。
如果接口出问题,我更建议先看日志。打印SQL的配置要开起来:
logging: level: com.example.mapper: debug这样MyBatis执行的每条SQL和参数都会打印出来,很多时候问题一眼就能看出来——可能是参数没传上、SQL写错了、查询结果为空。
5.4 Vue前端调试和打包问题
前端调试优先用浏览器的开发者工具。Network面板看接口请求状态码、请求参数、响应内容;Console面板看前端报错。Vue项目打印信息默认带组件的文件路径和行号,点击即可跳转到源码。
还有一个高频报错是[Vue warn]: Property "xxx" was accessed during render but is not defined on instance,这是模板里引用了未定义的变量。直接在模板里写console.log排查是不管用的,建议用{{ JSON.stringify(obj) }}临时输出到页面,或者用Vue Devtools检查组件状态。
打包阶段最常见的坑是静态资源路径。Vite默认构建出的index.html引用/assets绝对路径,如果部署到服务器子目录下,需要切换为相对路径。在vite.config.js里设置base: './'。很多本地开发没问题、部署后白屏,十有八九就是这个问题。
5.5 把Vue打包结果放进Spring Boot运行
有时候为了简化部署,我会把Vue构建出的dist目录复制到Spring Boot的src/main/resources/static下,这样前端和后端就是一个工程。操作前要把前后端接口地址调整好,前端请求必须使用相对路径/api/**,不能再写http://localhost:8080这种硬编码。
启动Spring Boot后,访问http://localhost:8080就能看到首页。但如果你的前端路由使用了history模式,部署到Spring Boot后刷新页面会报404。这个问题的根源是后端没有把前端路由请求转发到index.html。我提供两个解决办法:一是后端写一个转发控制器或加一个HistoryFallback配置,二是前端改用hash模式。对于单机小项目,hash模式更省事,URL里会多一个#,但不会出现刷新404的尴尬。
6. 文档、基础修改与答疑:交付之后才是价值开始
源码能跑通,只能说完成了一半。一个项目从“能跑”到“能被别人复现和使用”,文档和答疑占了剩余的一半。我在整理交付物的时候,尽量把自己放在一个完全新手的立场上,想象对方拿到压缩包后打开README会发生什么。
6.1 一份合格文档必须写清楚的六块内容
第一,环境要求。JDK版本、Maven版本、Node版本、MySQL版本都要写清楚,最好用表格列出。第二,数据库初始化。SQL脚本要放在根目录的db文件夹里,README里要写“先执行create_database.sql再执行init_data.sql”。第三,启动步骤。后端怎么启动、前端怎么启动,每一步都给出命令行和预期结果。第四,默认账号。管理员账号、密码、用户测试账号都要写出来。第五,接口说明。核心接口至少列个表格,写明路径、请求方式、主要参数、返回结果。第六,常见问题。把自己调试时踩过的坑按“问题—原因—解决方法”的格式列进去。
写文档最基本的检验标准是:找一个没接触过这个项目的人,只让他看README,看他能不能10分钟内把系统跑起来。如果可以,文档就是合格的。
6.2 “基础修改”通常改哪些,我给你列个清单
基础修改本质上就是“不改代码逻辑,只改配置或少量代码就能适配新场景”。比如项目里的影院名称、联系电话、首页轮播图,这些如果有配置表就直接改表,没有配置表就把常量抽出来统一维护。票价修改一般在场次表直接改price字段,不需要动代码。座位图修改有两种方式:直接在数据库里改座位表的row_no和col_no,或者改写影厅表的默认座位布局JSON,重新生成场次时应用新布局。订单超时时间是一个很常见的修改需求,我把时长抽成了application.yml里的一个配置项:
order: expire-minutes: 10这样后台人员不用改代码,只需改配置文件的参数再重启即可。做项目时,凡是业务人员可能调整的数值,都应该尽量配置化,这是从业余开发走向专业交付的一个重要标志。
6.3 答疑的本质:帮对方建立排查思维
答疑不只是回答问题,更重要的是教对方怎么自己发现问题。我收到过的最典型问题是“我按你说的做了,怎么跑不起来”。这种时候我不会上来就试,而是先让他提供三个信息:报错截图、启动日志、操作步骤。80%的问题在提供这三个信息的过程中,对方自己就找到原因了。剩下20%,我能通过日志明确告诉他是环境问题还是代码问题。
在答疑过程中,我会用“你告诉我你看到了什么”而不是“你应该这样”来引导,让他学会自己观察现象、定位模块、分析原因。这样几次之后,对方基本就具备了独立排错的能力,后面再出现问题,他自己就能处理,而不是继续依赖别人。这也是为什么成熟源码比较少见但持续答疑会使项目圈层更牢固的原因,我不主张无限期问题回答,但建议是建立一套针对常见问题的FAQ库,把每一次新的报错和解决过程补充进去。
6.4 后续还能怎么扩展
如果你准备把这个项目当作毕设或上线前的基础,扩展方向其实很多:接入微信小程序,复用同一套后端接口,只需要新增一个前端应用;引入Redis缓存热门电影列表和场次信息,把数据库压力降下来;接入真实微信支付或支付宝,把模拟支付Service替换成真实实现;增加会员积分和优惠券模块,让系统更接近商业体形态;把不同影院的独立系统改造成多租户架构。
这些扩展不会改变核心的“Spring Boot+Vue”骨架,都是在外围加模块。所以把基础架构和核心业务逻辑做踏实了,后面无论加什么功能都不会伤筋动骨。
我个人在实际操作中最深的体会是:这类系统项目的重心永远不在“会写几个接口”,而在于“把数据状态理清楚”。选座锁定、订单状态、座位状态这三者的流转,哪怕看代码十遍,也不如自己断点跟踪一遍订单流程来得扎实。如果你刚拿到这套源码,我强烈建议你先不跑前端,直接从后端的OrderServiceImpl里面下单方法开始打断点,一步步看座位状态怎么变、订单状态怎么变、异常怎么回滚,这个过程走完,你才算真正拥有了这个项目。