ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SpringBoot+Vue电影订票系统实战:从需求分析到前后端联调

SpringBoot+Vue电影订票系统实战:从需求分析到前后端联调 我做过的毕业设计里被问得最多的就是“XX管理系统怎么做”。坦白讲大多数选题都是一个壳子换一个场景真正拉开差距的从来不是页面多炫而是业务流程能不能跑通。就拿这次要拆解的项目来说——基于SpringBootVue的spring电影订票系统名字里带着“spring”其实指的就是Spring技术栈。表面上它是个“系统设计与实现”内核却是一套完整的在线交易闭环用户选座、生成订单、支付回调、座位状态回滚再加上后台的排片管理和数据统计。JavaMySQLMyBatis的经典组合决定了它的学习价值不在“新”而在“全”——前后端交互、数据库事务、状态机流转、接口权限控制一个都没落下。这篇文章我会直接从实战角度拆解这套系统。从需求分析到数据库设计从后端接口到Vue页面联动再到我实际开发中踩过的坑尽量还原一个“能跑、能答辩、能讲清楚”的完整项目。适合正在做毕业设计、想转Java全栈、或者想拿真实项目练手的朋友直接抄作业。1. 电影订票系统的需求分析与整体设计1.1 角色划分还是老一套但业务流必须想清楚先别急着敲代码我见过太多人上来建了三四张表就开写写到一半发现订单和座位对不上、退票改签没法做、排片时间和场次关系一团乱麻。订票系统本质是个交易系统角色就三类游客、注册用户、后台管理员。游客能看热映电影、查看场次和座位图但下单前必须登录。注册用户在前台完成完整的购票链路选电影、选场次、选座位、提交订单、支付模拟、查看我的订单、取消未支付订单。管理员进后台维护电影信息上映时间、海报、时长、简介、管理影厅和座位布局、排片管理把电影分配给某个影厅的具体场次、订单查询与统计、用户管理。业务流的核心是“座位—场次—订单”这条链。同一部电影可以排多个场次同一个影厅在不同时间有不同场次一个场次对应一张座位表。用户选定场次后系统要根据座位状态判断能否选中下单锁定座位支付成功后座位变成已售超时未支付则释放。这些都是状态流转问题设计阶段把它理顺后面CRUD才不会写成一锅粥。1.2 系统模块划分与功能清单拆开来看整个系统分两端四个模块用户端核心模块用户注册与登录密码以加密形式存库登录后发放Token电影浏览首页轮播、热映榜、按类型搜索、电影详情排片查询按日期和城市如果有多个影院过滤场次显示影厅、时间、票价、余座在线选座二维座位图渲染可选、已售、锁定三种状态订单确认展示所选座位和总价提交后生成订单号支付模拟对接一个本地模拟支付接口轮询支付状态订单管理查看历史订单未支付订单可取消已支付订单不可退简化版管理端核心模块管理员登录与鉴权电影管理增删改查、上映/下架切换影厅管理维护影厅名称、座位行列数排片管理配置场次、价格、开始时间同时校验排片冲突订单管理订单列表、按状态筛选、退票处理统计看板票房趋势、热门电影TOP10、上座率统计我当时给这个系统定的原则就一条功能可以简化流程不能断。很多答辩翻车就是因为支付完订单状态没变、座位图刷新后状态不一致或者管理员改排片时间没做冲突检测。这些不是锦上添花是基础正确性。2. 技术选型为什么是SpringBootVue数据层又凭什么选MyBatis2.1 SpringBoot Vue的搭配逻辑选型没有绝对标准但你得能讲出为什么。SpringBoot的优势是“零配置起步 starter整合 内嵌容器”一个jar包就能跑起来这对毕设和中小型项目非常友好。不需要像老Spring那样配一堆XML依赖管理和自动装配帮我们解决掉了环境搭建的杂事。而且MVC分层天然对应前端请求、业务逻辑、数据访问三层代码结构清晰后期维护成本低。Vue这边单页应用带来的体验提升是很直观的。电影订票的最大痛点是页面状态多电影列表的筛选条件、座位图的选中状态、订单页的倒计时。这些如果用传统多页jQuery写得靠大量DOM操作和全局变量来回传值很容易混乱。Vue的数据双向绑定配合Vuex/Pinia管理全局状态组件间通信顺畅得多。而且Vue生态里的Element UI组件库非常适合做后台管理界面表格、弹窗、表单校验都现成。其实这套架构现在已经是中小企业项目的主流标配了你在简历上写它至少说明你对“前后端分离”这件事有实战认知。2.2 数据层为什么不用JPA而是MyBatisJPASpring Data JPA写起来确实快但快是快在单表CRUD、关系映射由框架自动处理。问题在于订票系统里有大量“多表关联 动态条件”的查询查某场次的余座数量、查某电影的票房汇总、查某影厅排片冲突列表。用JPA写这些复杂查询要么拼接JPQL要么走原生SQL反而别扭。MyBatis的核心优势就是“SQL交给开发者”。它能让你精确控制每一条SQL语句动态SQL解决多条件组合查询特别顺手数据库侧的性能优化比如联合索引、分页优化也能直接落到XML里保证执行效率。对复杂业务来说这种“把SQL摆到明面上”的方式排查问题比对着JPA生成的Hibernate SQL debug要省心得多。再考虑到毕设评委会问“数据库索引怎么设计的”“这条报表SQL怎么写”MyBatis天然比JPA更容易展示你的SQL功底。纯个人观点做管理系统类的项目MyBatis优先。2.3 完整技术栈清单与版本建议前端Vue 2.6或Vue 3 Vite、Vue Router、Vuex/Pinia、Element UI、Axios、ECharts做统计图后端SpringBoot 2.7别上3.x为什么后文说、Spring MVC、MyBatis、MyBatis-Plus可选单表CRUD省事、MySQL 5.7/8.0、Lombok、JWT用jjwt库工具Maven 3.8、Node.js 14/16、Navicat、Postman、Git版本这里有个坑SpringBoot 3.x要求JDK17并且javax包改成jakarta很多老教程直接跑不起来。如果照着网上文章做SpringBoot 2.7 JDK8肯定更稳。数据库方面MySQL 8.0的驱动依赖和5.7略有不同这两个版本都行但我个人推荐5.7理由很单纯云数据库和评测环境兼容性更好。3. 数据库设计五张核心表搞定全套业务3.1 概念模型到逻辑表结构先画概念模型——这个可以在答辩PPT里放。核心实体有用户、电影、影厅、场次、订单、座位。关系是用户和订单一对多电影和场次一对多影厅和场次一对多场次和订单一对多。座位是绑在影厅上的但“某个座位的状态”和场次相关需要结合场次看。我最终的物理表分为六张user、movie、hall、session场次为了避免和MySQL的session冲突起名schedule、orders、seat。再加一张可选的seat_status表用来记录某场次下每个座位的状态。但你仔细想就会发现seat_status本身就是场次ID 座位行号 座位列号三维唯一。我建议直接建一张表把场次ID和座位行列联合起来这样查余座、锁座、释放都清晰。最终为了简化我在设计中把座位状态冗余到了seat表用hall_id定位座位坐标另外在order表里记录选座详情。但更规范的做法还是独立seat_status表下面我给的是我实际采用的方案seat表存影厅物理座位order_seat关联表存订单选座集合。表结构概览表名字段示例作用userid, username, password, phone, create_time用户信息movieid, title, cover, duration, type, director, release_date, status电影信息hallid, name, row_count, col_count影厅与座位布局scheduleid, movie_id, hall_id, start_time, end_time, price场次与价格ordersid, user_id, schedule_id, order_no, total_price, status, create_time订单主表order_seatid, order_id, schedule_id, row_num, col_num订单座位明细adminid, username, password管理员账号3.2 核心SQL与唯一性约束设计数据库的核心不是表多而是约束严。我在设计时特别注意几个点订单单号要唯一。直接用自增ID当订单号给用户看很不专业我前端展示用order_no业务内部关联用主键id。order_no生成规则我用的是“yyyyMMddHHmmss 机器位 随机数”最后给订单号加了unique索引防并发冲突。座位状态要防止重复下单。同一场次的同一座位两个用户同时下单只能一个人成功。这里我用了“先锁后卖”的思路用户在选座页提交时后端先去order_seat查该场次下是否存在同一座位的有效订单有就拒绝。同时给schedule_id, row_num, col_num加联合唯一索引即使并发插入数据库索引也会拦下重复数据。排片冲突校验。新建场次时要判断同一个影厅当前时间区间有没有重叠场次。SQL条件大概是SELECT COUNT(*) FROM schedule WHERE hall_id #{hallId} AND status 1 AND ( (start_time #{endTime} AND end_time #{startTime}) )这条查询要配合索引hall_id, start_time, end_time才能高效执行。别小看这个设计我见过太多项目的排片逻辑不校验冲突导致两个电影在同一影厅同时上映答辩的时候被评委一句话问住。3.3 索引设计与查询优化再聊聊索引。系统刚上线数据量不大但你要让评委知道你有索引意识。我的实际设计如下user表的username加唯一索引因为登录时要根据用户名查询。orders表的user_id加普通索引记住查“某用户的订单列表”是最常见的高频查询。orders表的status字段也建索引后台筛选订单状态时有用。order_seat表的schedule_id, row_num, col_num加联合唯一索引这是座位锁的核心。schedule表的hall_id, start_time复合索引排片冲突查询必备。movie表的status字段加索引首页只显示上映中的电影。索引不是越多越好。MySQL最多能用上一个复合索引的部分左前缀所以你需要根据“最常组合的查询条件”来设计联合索引。实际开发中我用Explain查过几条慢SQL发现原来有个报表统计查询连表太多、少了索引加了movie_id, status联合索引后查询时间从800ms降到60ms。具体建表SQL我贴一段核心的orders表CREATE TABLE orders ( id bigint(20) NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单编号, user_id bigint(20) NOT NULL, schedule_id bigint(20) NOT NULL, total_price decimal(10,2) NOT NULL, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已取消, create_time datetime NOT NULL, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_schedule_id (schedule_id), KEY idx_status (status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;4. 后端核心模块实现从接口设计到事务控制4.1 统一的返回格式与异常处理后端开发第一步我建议先把统一的返回结构定下来。原因很简单前端每个接口都要判断成功失败、取数据、弹错误提示如果每个Controller返回类型都不一样Axios拦截器就没法统一处理。我的做法是定义一个Result类Data public class ResultT { private Integer code; private String msg; private T data; public static T ResultT success(T data) { ResultT result new Result(); result.setCode(200); result.setMsg(操作成功); result.setData(data); return result; } public static T ResultT error(String msg) { ResultT result new Result(); result.setCode(500); result.setMsg(msg); return result; } }配合RestControllerAdvice做全局异常捕获业务上抛出我自定义的BizException时返回对应的错误信息数据库异常、参数异常不会被用户直接看到原文关键信息用日志记录下来。有一个细节千万不要把SQL异常直接抛给前端既暴露表结构也不安全。4.2 JWT登录鉴权与拦截器配置登录这块我用了JWT方案。用户登录成功后后端生成一个Token前端把它存在localStorage每次请求在Axios请求头里带上Authorization字段。后端通过拦截器统一校验。JWT的好处是服务端无状态不需要存session集群部署时不需要做session共享。核心实现思路Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws IOException { String token request.getHeader(Authorization); if (StringUtils.isBlank(token)) { throw new BizException(未登录); } // 解析token校验是否过期 Claims claims JwtUtil.parseToken(token.replace(Bearer , )); if (claims null) { throw new BizException(登录已过期请重新登录); } // 把用户id放入request上下文方便Controller使用 request.setAttribute(userId, claims.get(userId)); return true; } }拦截器要配置到WebMvcConfigurer里同时把登录接口、注册接口、电影列表等公开接口排除掉。前端路由也要做登录守卫防止用户跳进未登录页面。双端校验不能省就算前端拦截了接口不校验依然等于裸奔。这里我踩过一个坑Token过期时间设得太短比如30分钟用户在选座过程中Token过期了提交订单时直接被拦截体验极差。后面我把Token有效期调整为2小时并在选座接口里前置校验前端在剩余时间不足时主动弹提示引导刷新登录状态。4.3 选座接口的设计一把锁的完整生命周期选座是整个项目里最值得讲的部分。我先描述用户在前端看到的样子座位矩阵图可选是浅色已售是灰色锁定是橙色。用户点选后座位变成预选状态提交订单后锁座。问题的关键是怎么实现“锁座”。我的方案是“提交订单时锁座”。用户点选座后提交订单的接口后端要做这几件事校验用户是否登录。校验所选座位是否都存在于该影厅中防止参数伪造。校验这些座位在当前场次下没有被未取消的订单占用。计算订单总价按场次单价乘座位数不要信前端传的价格。插入orders表和order_seat表。这一步要用事务包裹。给前端返回订单ID和待支付金额。提交订单接口的核心方法大致是这样Transactional(rollbackFor Exception.class) public Long createOrder(OrderCreateDTO dto, Long userId) { Schedule schedule scheduleMapper.selectById(dto.getScheduleId()); // 检查场次状态与时间 if (schedule null || schedule.getStatus() ! 1) { throw new BizException(场次不存在或已下架); } // 遍历座位并查重 ListOrderSeat seatList new ArrayList(); for (SeatPosition pos : dto.getSeats()) { OrderSeat exists orderSeatMapper.selectLockedSeat(schedule.getId(), pos.getRowNum(), pos.getColNum()); if (exists ! null) { throw new BizException(座位已被占座 pos.getRowNum() 排 pos.getColNum() 座); } OrderSeat seat new OrderSeat(); seat.setScheduleId(schedule.getId()); seat.setRowNum(pos.getRowNum()); seat.setColNum(pos.getColNum()); seatList.add(seat); } // 生成订单 Orders order new Orders(); order.setOrderNo(generateOrderNo()); order.setUserId(userId); order.setScheduleId(schedule.getId()); order.setTotalPrice(schedule.getPrice().multiply(BigDecimal.valueOf(seatList.size()))); order.setStatus(0); ordersMapper.insert(order); // 插入座位明细 batchInsertOrderSeat(order.getId(), seatList); return order.getId(); }注意第3步查重复时要用“状态不为已取消”作为条件因为已取消的订单对应的座位是能再次购买的。另外真正的生产环境分布式并发下单还需要悲观锁或乐观锁或者Redis分布式锁来防止超卖。这套毕设里我用的是数据库行锁唯一索引兜底的方式虽然模拟环境体现不出极端并发但你把原理讲清楚问答环节就不会露怯。4.4 支付回调与订单超时关闭支付环节在毕设里通常不会接入真实的微信或支付宝支付——需要商户资质而且申请流程复杂。所以我做的是“模拟支付”用户点击“模拟支付”按钮前端调用后端支付接口后端直接把订单状态改为已支付并记录支付时间。不过这里有一个体验上的细节未支付订单怎么释放座位常见的做法有两个方向。一个是前端倒计时比如15分钟未支付就取消订单、释放座位。另一个是后端定时任务扫描创建时间超过15分钟且状态为待支付的订单将其置为已取消。我采用的是“前端倒计时 后端兜底”双保险。前端倒计时到了立刻提示刷新座位后端用Spring的Scheduled定时任务每5分钟扫描一次。Scheduled(fixedDelay 300000) public void closeExpiredOrders() { ListOrders expiredOrders ordersMapper.selectExpiredWaitingOrders(15); for (Orders order : expiredOrders) { ordersMapper.updateStatusToCancel(order.getId()); } }为什么不用数据库事件而用定时任务因为毕设项目通常部署在本地环境数据库事件计划在云数据库上容易受到权限限制。Spring的Scheduled加个开关开发调试也直观。注意别忘了在启动类上加EnableScheduling很多新手就卡在这代码写了但任务没跑。还有个隐藏问题用户在两个浏览器同时登录了账号A浏览器下单后B浏览器刷新也能看到这个订单但是B去支付时这个订单已经被A取消。所以后端支付时要再校验一次订单状态不能无脑更新int rows ordersMapper.payOrder(orderId, userId, new Date()); if (rows 0) { throw new BizException(订单状态已变更支付失败); }用受影响行数来判断能有效防止脏写。这也是事务里常见的乐观锁思想——不是用版本号而是通过“状态条件更新”来保证并发安全。5. 前端页面与交互实现Vue怎么和这些接口拼起来5.1 项目结构与路由安排前端我用Vue CLI初始化的项目目录结构按模块划分src/api所有axios请求封装按模块拆成movie.js、schedule.js、order.js、admin.jssrc/router路由表区分用户端和管理端配合路由守卫做权限控制src/storeVuex管理用户登录状态和选座暂存数据src/views页面组件用户端有Home.vue、MovieDetail.vue、ChooseSeat.vue、OrderConfirm.vue、MyOrders.vue管理端有AdminLogin.vue、Dashboard.vue、ScheduleManage.vue等src/components公共组件比如SeatMap.vue、NavHeader.vue、Pagination.vue路由权限是重点。用户端的路由在meta里加requiresAuth: true后router.beforeEach里判断有没有Token、Token有没有过期。管理端用单独的前缀路径/admin判断当前用户是否为管理员不是就直接踢回首页。最怕的情况是用户手动在浏览器地址栏输入/admin结果能看到后台界面。哪怕接口都有鉴权前端也得守住这层。5.2 选座组件的交互逻辑SeatMap组件是这个项目前端最核心的部分。每个座位渲染成一个方块属性有状态available/sold/locked/selected。数据来源是后端返回的座位表格式大概这样{ rowCount: 8, colCount: 10, seats: [ {row: 1, col: 1, status: 1}, {row: 1, col: 2, status: 0} ] }渲染时遍历二维数组每个座位绑定点击事件。点击时切换选中状态并塞进当前订单的座位集合里。这里的核心是状态同步——用户选了座位后回到列表再进去已选状态不应该丢。我的处理方式是把“本次选座会话”存放在Vuex里在选座页面初始化时读取提交订单时把座位传给后端成功后清空Vuex里的暂存数据。还有一个交互细节座位选中之后最好在页面上实时显示“已选 X排X座、共Y张、总价Z元”用户确认信息时才不容易出错。再加一个座位图例说明已售、可选、正在选择分别用什么颜色体现。界面不复杂但对用户体验的提升立竿见影。Axios请求封装时要注意提交订单这个接口有一定的耗时因为要查重、插库、算价前端按钮要加loading状态禁止用户重复点击。如果真的因为网络原因超时用户也不知道订单是否创建成功了。我建议接口返回一个“orderCreated”标志还是一个“请求中”状态更好的方案是前端生成一个前端请求ID幂等键后端根据这个键判断是否已经创建过订单防止重复下单。5.3 管理端可视化看板的实现管理端如果只有增删改查列表视觉上会很单薄。我加了统计看板用ECharts画图数据来自后端统计接口。主要有三块票房趋势折线图按日期统计总票房。热门电影排行榜横向柱状图列出票房TOP10的电影。上座率统计按场次维度统计某影厅某日的平均上座率。后端统计接口的SQL核心要么用GROUP BY结合日期函数要么用子查询取每场次的售出座位数。比如每日票房SELECT DATE(create_time) AS day, SUM(total_price) AS amount FROM orders WHERE status 1 AND create_time #{startDate} GROUP BY DATE(create_time) ORDER BY day;前端拿到这个数组直接塞给ECharts的xAxis和series整个过程很快。ECharts的安装和引入比想象中简单按官方文档引入模块化版本再用Vue封装成图表组件就行了。需要注意的一点是ECharts在管理端页面初始化时如果容器还没渲染完成图表会画不出来。解决方案是放在mounted钩子里或者使用v-if确保DOM存在再初始化。再看管理端排片管理。前端我用的是Dialog弹窗里面嵌表单选择电影、选择影厅、选择开始时间、填写票价、系统自动计算结束时间根据片长。因为影厅和电影都是下拉框动态加载级联关系其实不强核心是提交前要后端做冲突检测。前端也能先校验开始时间不能早于当前时间但真正的并发冲突必须靠后端。6. 常见问题与排坑实录6.1 端口冲突、跨域和Session问题这套系统在本地跑起来最容易遇到的是端口占用。后端8080端口被占用时要么改端口要么用命令行查占用进程。第二个高频问题是跨域——前端跑在8080Vue脚手架默认端口后端跑在8080或者9090前后端不同源所以要配CORS。在后端定义一个配置类加上CrossOrigin简单粗暴但能用。生产环境推荐用Nginx反向代理统一端口开发阶段用Vue脚手架自带的proxy代理配置在vue.config.js里把/api请求转发到后端地址。还有Session的问题。不少同学一开始习惯从前端传sessionId或者用Cookie存登录状态但前后端分离后Cookie里有同源策略的坑尤其是跨域时Cookie默认不会被携带。这也是我选择JWT方案的原因之一因为Token存在前端请求头里没有Cookie跨域问题的困扰。6.2 MyBatis动态SQL拼接报错与数据懒加载MyBatis写动态SQL时最常犯的错是if标签里没用test属性判断空值导致拼接后SQL语法错误。排查方法很简单打开MyBatis的日志输出把执行的SQL打印出来复制到Navicat里执行一看便知。还有一个MyBatis 一对多查询的经典坑查询订单带座位明细列表时如果用collection映射很可能出现“一个订单被拆成多个重复行”的情况。根源是数据库查询结果是笛卡尔积MyBatis映射时未指定resultMap里的id。这时候需要用collection的select属性做子查询或者用分步加载。我用第一种方式直接连表查询然后用resultMap做自动映射。6.3 项目答辩时可能会被问到的问题给自己做答辩准备时建议重点梳理这几个方向“为什么选这个课题”——站在用户购票体验、影院排片管理效率的角度说不要只说为了拿学分。“系统安全性怎么保证的”——JWT鉴权、后台权限校验、密码加密存储BCrypt、SQL预编译防注入。“如果用户同时抢最后一个座位怎么办”——联合唯一索引兜底 事务以及优化方向是引入Redis分布式锁。“数据库有冗余吗具体在哪”——比如order_seat里冗余了schedule_id不是因为表设计者偷懒而是查询用户订单时避免大量跨表join属于空间换时间的取舍。“这个系统怎么扩展成真实商业系统”——接入真实支付网关、增加缓存层、部署在云服务器上、引入消息队列削峰。如果你能把这些问题讲清楚这个系统在答辩中的专业度会提升两个档次。7. 实操心得与建议前后端联调阶段发现了一个特别典型的流程坑用户在选座页停留超过15分钟生成的未支付订单会被定时任务关闭但前端座位图不会自动刷新用户看到的依然是可选的座位。点击提交时后端提示“座位已被占用”用户体验非常差。我在实际项目中做的优化是选座页面加了一个心跳轮询每30秒请求一次后端查询当前场次的余座状态如果座位有变化就对比本地已选座位发现冲突就弹窗提示并刷新座位图。这个功能工作量不大但实际体验提升非常明显。这个项目做完之后建议你在本地跑通全流程录一段演示视频用户注册登录、购买电影票、模拟支付、进入后台添加电影和排片、查看统计图表。演示过程不仅能在答辩时节省时间还能让你在演示前自己回忆起每个模块的代码位置熟悉程度高了回答评委问题时就不紧张。毕设这件事早起晚睡敲代码是一部分真正拉开分数的其实是你能不能把系统里的每一个小设计都讲出“为什么”。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进