ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

SpringBoot+Vue汽车票预订系统:从表结构到并发扣票完整实战

SpringBoot+Vue汽车票预订系统:从表结构到并发扣票完整实战 我从大四做毕设到后来带学弟学妹做课设汽车票预订这种“票务类管理系统”可以说是Java Web方向最经典、最容易出效果、也最适合写进论文的一类题目。选题本身不新颖但胜在业务逻辑完整、技术栈通用、可扩展空间大——SpringBootVueMySQL这套组合面试的时候也拿得出手。下面我把这套系统的设计思路、核心实现、踩坑记录完整拆一遍打算做毕设或者想拿它练手提升的同学可以直接照着复现。1. 项目整体设计与技术选型思路1.1 这类票务系统真正要解决的是什么很多同学拿到“汽车票网上预订”这个题目第一反应是做几个页面登录、查车次、下单、后台列表完事。但实际做下来你会发现票务系统的核心难点不是页面多不多而是三个词状态、并发、一致性。先说状态。一张订单从用户下单到最终乘车中间要经历“待支付、已支付、已出票、已检票、已退票”这些环节每个环节对应数据库里某个字段的变化。如果状态设计得太随意会出现“钱付了票没出”“后台改了车次用户不知道”这种问题。再说并发。热门线路在节假日高峰期同一时刻可能有好几个人在抢同一个座位如果库存扣减逻辑写得不对就会出现超卖——车上一共40个座结果卖出41张票。最后是一致性涉及到支付回调、退票退款这种操作必须保证数据库里的数据和用户实际的操作结果是一致的。这套SpringBootVue的汽车票预订系统本质上就是围绕这三点展开的用合理的表结构承载状态用事务和乐观锁解决并发用清晰的接口设计保证前后端数据流转一致。理解了这一点你再看下面的模块拆解和代码思路会顺很多。1.2 技术栈选型的真实理由SpringBoot选它不是因为“大家都在用”而是因为它确实最适合这种单体重度业务系统。自动配置帮你省掉一大半XML配置内嵌Tomcat让启动和部署变得极其简单Spring生态自带的事务管理、MyBatis整合、参数校验这些都开箱即用。对于毕设来说SpringBoot能让你把精力放在业务逻辑而不是环境配置上这太重要了。Vue前端选Vue的原因有两个。一是它和SpringBoot搭配做前后端分离天然就是目前企业的主流开发模式面试讲起来有东西可说二是Vue的学习曲线相对平缓就算你之前只写过JSP或者静态HTML花两周也能上手。而且Vue配合Element-UI做后台管理页面非常快表格、表单、分页、弹窗这些现成组件一套就有比手写jQuery舒服太多。MySQL市面上最普及的关系型数据库没有之一。8.0版本安装配置简单和SpringBoot的兼容性也好网上参考资料最多出了问题基本都能搜到解决方案。虽然理论上Oracle或者PostgreSQL也能做但毕设场景下MySQL就是最优解——指导老师熟悉答辩评委也认可本地跑起来资源占用还低。这套组合还有一个隐藏优势招人认可度。你去翻招聘网站的Java后端岗位十个里面有六七个都写着“熟悉SpringBoot”“了解Vue”“掌握MySQL”做过一个完整的全栈项目简历上写项目和面试聊项目的时候都有素材。1.3 系统整体蓝图三类角色一条主线我把这个系统拆成三条线来讲你脑子里先搭个框架旅客前端用户注册登录、搜索车次、在线选座购票、查看订单、退票改签、查看个人信息和乘车记录。管理员后台用户登录、车次管理、车辆管理、站点管理、订单管理、用户管理、数据统计。系统主线业务发布车次 → 用户搜索 → 选择班次下单 → 支付 → 出票 → 站务检票 → 退票退款。架构上就是标准的前后端分离Vue项目跑在8080端口dev模式通过axios发请求到SpringBoot的8081端口后端Controller层统一返回JSONService层写业务逻辑Mapper层用MyBatis去操作MySQL。中间用跨域配置把前后端打通部署的时候前端打包后的dist目录也可以扔进后端的static目录一个Tomcat全搞定。这个架构我画过很多次给学弟学妹讲最核心的一句话前端只管展示和交互后端只管数据和业务数据库只管持久化。记住这个边界写代码的时候就不会乱。2. 核心功能模块拆解与业务流程设计2.1 用户端从注册到出票的完整业务闭环用户端的核心流程是注册 → 登录 → 搜索车次 → 下单购票 → 支付 → 查看订单 → 退票。每一步都有对应的后端接口和数据表支撑我按顺序拆给你看。注册登录这块密码存储我建议用BCrypt加密而不是明文或者MD5。MD5虽然也能用但彩虹表攻击防不住正规项目早就淘汰了。SpringSecurity里自带BCryptPasswordEncoder单独引入一个spring-security-crypto依赖也能用加密后的字符串形如$2a$10$...同一密码每次加密结果都不同安全等级明显不一样。对于毕设答辩评委问到密码安全你答出这两个字“BCrypt”印象分会高不少。搜索车次是用户端流量最大的接口也是优化空间最大的地方。核心SQL是按起点站、终点站、出发日期三个条件去查班次表关联查询出对应的车辆信息和剩余票数。这里有一个细节要注意不要每次都对全表扫描对出发日期、线路ID这些查询频率高的字段要建索引。我有一个学弟就是这个环节没做索引测试数据塞了5000条车次记录后列表接口直接从100毫秒涨到3秒加上一条普通索引就降回80毫秒。下单购票是整个系统的业务核心也是最容易出错的地方。我把它设计成两段式前端预下单用户选择车次和座位后前端先调一个“预下单”接口。后端在这里检查该车次剩余票数是否充足如果充足就在订单表插入一条“待支付”状态的订单同时把可售票数锁住。前端发起支付用户点击确认支付后前端调“支付确认”接口后端在这里把订单状态从“待支付”改为“已支付”扣减剩余票数生成乘车凭证。为了模拟真实支付我提供一个简单的策略测试环境下设置一个“模拟支付开关”打开后点击支付按钮直接走支付成功逻辑不需要接微信支付宝的SDK。这样就避开了申请商户号、域名备案、回调签名验证这一堆麻烦事又能把完整的支付状态流转跑通。论文里写“接入真实支付网关”就是一两句话的事答辩时也能讲清楚这是为了演示做的简化。退票模块要同步处理两件事订单状态改为“已退票”同时把车次的剩余票数加回来要判断是否超过该车次的发车时间发车后不允许退票。有些同学只改了订单状态忘了回补库存导致的结果就是可售票越来越少最后变成负数。这种bug在测试阶段很容易漏掉因为你自己测试一般是“买一张退一张”数量少看不出问题。所以我在订单状态变更的工具类里把“退票回补库存”逻辑写成了一个独立方法在Service层强制调用。2.2 管理端车次、车辆、订单的运营视角管理端面向的是“汽车客运站运营人员”核心诉求是三个管车、管班次、管订单。我把它拆成5个菜单每个菜单对应一组接口用户管理查询所有注册用户支持按用户名/手机号模糊搜索可以禁用恶意用户。这个模块很简单就是一个分页列表加状态字段但别忘了做用户状态枚举不然“该用户已被禁用”这种提示没法优雅实现。车辆管理维护车辆信息车牌号、车型、座位数比如49座、53座、所属公司、车辆状态。注意车辆的座位数直接决定了可售票总数要保证车辆关联的班次剩余票数不能超过座位数这个校验我在Service层做了。站点管理维护所有客运站名称比如“成都东站汽车客运站”“绵阳汽车总站”。它本质是一张字典表被车次表的起点站终点站字段外键引用。车次管理这是管理端最核心的模块。管理员发布一个新班次时要选择车辆、选择线路起点站→终点站、设置出发时间和到达时间、定票价。我设计车次表时把“线路”单独拆了一张表因为现实中同一条线路比如成都→绵阳一天可能发很多班次共用同一个起点终点和基础票价拆出来就避免了大量冗余数据。订单管理管理端能看到所有订单列表支持按订单号、用户名、车次ID查询可以对异常订单进行取消操作比如某班次因故停运管理员需要批量取消未检票的订单。批量取消这里我用了事务循环处理中途任何一条失败都会整体回滚保证不出现“一半订单取消了另一半还是已支付”的情况。还有一个模块必须单独说数据统计。我用ECharts做了一个简单的可视化面板左边显示近7天订单量折线图右边显示各线路售票占比饼图下面放一个“今日营收总金额”和“总订单量”的卡片。数据源就是从订单表做GROUP BY按日期/线路聚合SQL两句搞定但视觉效果好答辩时评委通常会在这个页面多停留几秒。2.3 权限设计与订单状态机设计权限这块我没有引入SpringSecurity的完整权限体系而是做了一个轻量的“登录拦截器角色判断”后端定义一个RequireRole(ADMIN)注解加在管理端的Controller方法上拦截器在请求进来时解析token里的角色字段不是管理员就统一返回403。用户在登录成功时后端返回一个JWT令牌前端的axios请求拦截器把token放到Header里后端每次请求都验证token的签名。Vue前端用vue-router的导航守卫在路由跳转前检查当前用户的角色不是管理员就重定向到登录页。这种轻量方案对毕设来说够用而且代码量少容易讲清楚。你要是想整得更“专业”也可以用Sa-Token或者SpringSecurityJWT但说实话对于这个系统的复杂度——只有两个角色、几十个接口——拦截器方案已经绰绰有余重框架反而是过度设计。订单状态机我强烈建议你用常量类或枚举统一管理不要到处写数字。我是这样定义的public enum OrderStatus { PENDING(0, 待支付), PAID(1, 已支付), COMPLETED(2, 已完成), CANCELLED(3, 已取消), REFUNDED(4, 已退票); private final Integer code; private final String desc; // 构造函数、getter省略 }状态流转必须合法待支付可以变成已支付也可以变成已取消已支付可以变成已完成也可以变成已退票但已退票绝对不能变成已支付。我在Service层写了一个状态变更校验方法非法流转直接抛业务异常。这个设计在答辩时特别加分——你体现的不仅是“能跑”而是“设计过”。3. 数据库设计与核心代码实现细节3.1 五张核心表的结构设计与关系数据库设计是这类系统的地基。我把表拆成5张核心表这是最精简又能支撑全部业务的方案用户表sys_user字段类型说明idbigint主键自增usernamevarchar(50)用户名唯一索引passwordvarchar(100)BCrypt加密后的密码real_namevarchar(50)真实姓名phonevarchar(20)手机号rolevarchar(20)USER/ADMINstatustinyint1正常 0禁用create_timedatetime注册时间车辆表bus_vehicle字段类型说明idbigint主键license_platevarchar(20)车牌号唯一vehicle_typevarchar(30)车型如大型高一seat_countint座位数companyvarchar(50)所属客运公司statustinyint1可用 0维护中线路表bus_route字段类型说明idbigint主键start_stationvarchar(50)起点站end_stationvarchar(50)终点站distanceint里程公里base_pricedecimal(10,2)基础票价车次表bus_schedule字段类型说明idbigint主键route_idbigint外键关联线路表vehicle_idbigint外键关联车辆表depart_datedate出发日期depart_timetime发车时间arrive_timetime到达时间pricedecimal(10,2)实际票价可调整remain_seatsint剩余票数statustinyint1正常 0停运订单表bus_order字段类型说明idbigint主键order_novarchar(32)订单号业务唯一user_idbigint下单用户schedule_idbigint关联车次passenger_namevarchar(50)乘车人姓名id_cardvarchar(18)乘车人身份证号seat_novarchar(10)座位号pricedecimal(10,2)实付金额statustinyint订单状态码create_timedatetime下单时间pay_timedatetime支付时间表关系一句话概括车次表是枢纽一端关联线路和车辆一端被订单关联。订单表拿到车次的线路信息后就能拼出起点→终点、发车日期时间、票价这些关键信息。建表时我给order_no加了唯一索引给bus_order.schedule_id和user_id都加了普通索引。因为查询订单列表最常见的场景就是“按用户查”和“按车次查”没有索引的话订单量一大就会慢。3.2 购票扣库存的事务处理与超卖防护这一段是整个系统技术的含金量所在也是答辩时最值得展开讲的点。我先说简单地扣库存为什么有问题。最简单的写法是这样Schedule schedule scheduleMapper.selectById(scheduleId); if (schedule.getRemainSeats() 0) { schedule.setRemainSeats(schedule.getRemainSeats() - 1); scheduleMapper.updateById(schedule); // 创建订单 }这个写法在单用户测试时没有任何问题但一旦两个用户同时抢同一车次的票就会出现经典的“并发脏读”A读到剩1张B也读到剩1张A扣完变0B继续用旧值1去扣结果是0但两个订单都创建成功了。这就是超卖。解决办法有两个层面我在系统里两层都做了第一层数据库层面的乐观锁。给bus_schedule表加一个version字段更新时带上版本号判断UPDATE bus_schedule SET remain_seats remain_seats - 1, version version 1 WHERE id #{scheduleId} AND remain_seats 0 AND version #{oldVersion}如果更新的影响行数是0说明这一秒已经有其他请求把库存扣掉了当前请求必须撤销订单并提示“余票不足”。第二层Service层的事务控制。用Transactional把“扣库存 创建订单”绑在同一个事务里任何一步失败都整体回滚。Transactional(rollbackFor Exception.class) public OrderResult createOrder(OrderCreateDTO dto) { // 1. 乐观锁扣减库存 int rows scheduleMapper.deductSeats(dto.getScheduleId(), dto.getVersion()); if (rows 0) { throw new BusinessException(余票不足或车次已变更请刷新后重试); } // 2. 构建订单数据生成唯一订单号 // 3. 插入订单记录 // 4. 返回预下单结果 }这里还有一个容易被忽略的细节乐观锁冲突后要保证用户的体验。实际线上场景中两个用户抢票只有一个能成功失败的那个我不建议直接抛500而是捕获后提示“当前班次余票紧张请尝试其他班次”前端同时刷新一下余票信息。代码层面把这个体验优化写在全局异常处理器里就好。有的同学问要不要用Redis分布式锁我的回答是毕设场景没必要。你就一台应用服务器、一个MySQL实例乐观锁完全够用。引入了Redis反而多一个环境依赖部署成本更高答辩时候你得额外解释为什么要用它。把乐观锁和事务讲清楚已经能证明你理解并发问题。3.3 前端Vue的页面结构与核心API对接前端我用的Vue3 Vite Element-PlusVue Router使用Hash模式状态管理用了Pinia比Vuex写起来简单很多。整个前端的src结构是这样规划的src/ ├── api/ │ ├── auth.js # 登录、注册、验证码 │ ├── schedule.js # 车次查询、购票 │ ├── order.js # 订单列表、退票 │ └── admin/ # 后台管理相关接口 ├── router/ │ └── index.js # 路由表含角色守卫 ├── store/ │ └── user.js # 用户状态token、角色、用户信息 ├── views/ │ ├── Home.vue # 首页 车次搜索 │ ├── OrderList.vue # 我的订单 │ ├── Login.vue │ ├── Register.vue │ └── admin/ # 后台五个管理页面 └── utils/ └── request.js # axios 封装拦截器前端对接后端的核心在utils/request.js。我在这里做了三件事请求时把token从Pinia取出来挂到Header响应时统一解析后端返回的{code, message, data}结构code不是200就直接弹ElMessage提示捕获到401状态码时自动清空本地登录态并跳回登录页。这三件套几乎是每个真实前端项目的标配做了它你的前端工程质量感会上一个台阶。车次查询页有一个交互细节值得说用户在首页输入起点、终点和出发日期后点击搜索应该跳转到车次列表页列表页根据路由query参数去拉接口。这个做法比在首页直接内嵌列表体验好很多因为它把“搜索条件”和“结果展示”分离开以后想加筛选、排序都方便扩展。我实际开发时还加了“加载中”的骨架屏状态虽然是小事但能给答辩评委留下“这个学生关注用户体验”的印象。车次卡片上每个班次我放了一个“预订”按钮点击后弹出抽屉展示车辆信息、座位数、票价下面是一个座位图按车辆座位数生成网格已售座位灰色禁用可选座位绿色高亮。座位图这块是前端最复杂的组件用Element-Plus的Popconfirm和动态class就能实现不需要引入任何第三方座位组件。因为理想情况下座位图应该实时反映后端库存但真实项目里你不可能每个座位都同步一次数据库所以我的策略是进入选座抽屉时拉取一次“该班次已售座位号集合”前端渲染时排除掉提交订单时后端再做一次最终校验。3.4 后端核心接口一览我把后端Controller层的接口整理一张表你把它当checklist用——写代码前先把这些接口定义好再去做实现事半功倍模块接口方法说明认证/api/auth/registerPOST用户注册认证/api/auth/loginPOST用户登录返回JWT车次/api/schedule/searchGET按站点日期查车次车次/api/schedule/seats/{id}GET获取已售座位号订单/api/order/pre-createPOST预下单购票订单/api/order/pay/{orderNo}POST模拟支付订单/api/order/refund/{orderNo}POST退票订单/api/order/my-listGET我的订单分页列表管理/api/admin/schedule/pageGET后台车次分页管理/api/admin/schedule/upsertPOST新增/修改车次管理/api/admin/order/pageGET全部订单分页管理/api/admin/stats/summaryGET运营数据统计统一返回结构我建议定义为ResultT包含code、message、data三个字段。Controller层的每个接口都返回它不要再出现各地返回值不统一的情况。这个规范越早定下来后面联调时越省心。另外参数校验建议用Spring的Valid注解加上NotNull、Size这类注解在DTO上直接标注合法性要求。比如注册接口的DTO上用户名标NotBlank手机号标Pattern(regexp^1[3-9]\\d{9}$)。这样就能自动拦截非法请求不用在Service层写一堆if判断。这点很多同学会忽略但代码规范里是加分项。4. 部署启动与高频问题排查4.1 本地开发环境搭建与启动顺序从零开始跑通这个项目我的建议顺序是“数据库 → 后端 → 前端”。第一步装MySQL并初始化。我用的是MySQL 8.0Windows下安装时有个容易被坑的地方8.0默认的认证插件是caching_sha2_password有些老版本的连接驱动不认识会报“Unable to load authentication plugin”。解决办法是在连接URL里指定allowPublicKeyRetrievaltrueuseSSLfalse或者创建用户时指定mysql_native_password。我先说结论用MySQL官方的Connector/J 8.0以上版本就不会有这个问题。初始化数据库时执行项目里的init.sql里面包含建库语句、建表语句和一批演示数据。演示数据非常关键我塞了4辆车、6条线路、20多个班次、几个测试账号否则你前端页面上什么都没得看。测试账号我留了一个管理员admin/123456和普通用户test/123456。第二步启动后端。用IDEA打开后等待Maven把依赖拉完修改application.yml里的数据库账号密码然后运行主类。SpringBoot默认端口8081在配置文件里通过server.port指定。启动日志打到“Started ... in x.xx seconds”就说明后端起来了可以在浏览器直接访问http://localhost:8081/api/schedule/search?startStation成都endStation绵阳来验证接口是否通。第三步启动前端。前端用Vite脚手架创建进入项目目录后先npm install装依赖然后npm run dev启动开发服务器默认端口是5173。Vite启动非常快基本秒开。要注意的是前端开发环境通过VITE_API_BASE_URL这个环境变量指向http://localhost:8081axios的baseURL就从这个变量读取。跨域问题我是这样解决的后端写了一个CorsConfig配置类添加CorsFilter允许所有来源访问同时前端不额外设置代理。实际开发时我更推荐用Vite的proxy代理配置写在vite.config.js里这样前端请求的就是同源地址更接近生产环境的表现。两种方案你选一种就行。4.2 打包上线的两种典型方式毕设最后一般要部署一台服务器给答辩评委看或者提交一个能一键运行的包我提供两个方案方案一前后端分开部署适合服务器上有Nginx的情况。后端先mvn clean package -DskipTests打成jar包java -jar app.jar运行。前端在项目根目录执行npm run build生成dist目录把dist里的文件扔到Nginx的html目录并配置一个反向代理把/api开头的请求转发到后端的8081端口。Nginx配置核心就这一段server { listen 80; server_name your-domain.com; location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://localhost:8081; proxy_set_header Host $host; } }注意try_files那行必须有否则直接访问前端路由比如/admin/orders会404因为Vue是单页应用刷新页面时要全部落到index.html再走前端路由。方案二单jar包全家桶部署适合云服务器配置低、不爱折腾Nginx的情况。把前端dist目录复制到后端的src/main/resources/static下然后重新打包。SpringBoot会把static下的文件当作静态资源直接暴露这样一台服务器一个8081端口就同时托管了前端页面和后端接口。启动后访问http://服务器IP:8081就能看到首页。这个方案我在带学弟做课设演示时经常用部署成本最低稳定性最高。缺点是没有Nginx层的高并发承载能力但毕设演示场景完全够。4.3 高频踩坑实录与排查清单我把做这个系统前后踩过的坑整理成了一张速查表大部分问题都是开发环境或者基础配置相关提前看一眼能帮你省下不少排查时间现象原因解决前端请求后端报CORS错误后端没配跨域过滤器在Config里加CorsFilter或前端用Vite proxy启动报端口被占用上次进程没关干净换端口或netstat -ano找到PID后taskkill接口能通但登录后没权限JWT token没传或已过期检查axios请求拦截器打印Authorization头确认查询中文条件查不到连接URL没指定UTF-8加characterEncodingutf8且建库时指定utf8mb4时间显示差8小时时区未配置连接URL加serverTimezoneAsia/Shanghai打包后前端页面白屏前端资源路径不对静态资源上传到static目录并配置spring.web.resources路径退票后余票没回补只改了状态没调回补方法Service层退票方法里先改状态再回补库存必须在同一事务前端调用后台接口返回401Token校验拦截器把登录接口也拦截了配置拦截器时放行/api/auth/login和/api/auth/register两个地址这里我重点说两个最隐蔽的问题。**第一个是时间字段的时区问题。**MySQL连接URL里如果不加serverTimezoneAsia/Shanghai很可能出现数据库存的时间比实际快8小时或慢8小时的情况。原因很简单驱动和数据库默认时区不一致导致的。这个问题的坑是它不一定会报错只有你查出来的时间对不上才会发现。**第二个是Vue打包后刷新404。**如果你采用单jar包部署方式后端直接托管前端dist不需要Nginx的try_files所以一般没这个问题。但如果用Nginx部署刷新任何非首页的路由比如刷新/order/list就会404。原因是Nginx默认找不到这个路径对应的真实文件必须加上try_files $uri $uri/ /index.html;配置。这个坑几乎每个用Vue的兄弟都会踩一次。还有一个容易被新手忽略的小事后端接口的日期参数格式。前端传“2025-06-01”这种字符串后端如果直接用LocalDate接收有时会报格式转换错误。我在全局配置里注册了一个LocalDate的转换器或者更省事的办法是DTO里用String接收Service层再转换成LocalDate。两种方式都用过第二种更直观适合新手还不容易出乱子。开发过程中如果遇到报错我给你一个定位思路先看控制台日志堆栈的第一行不要直接翻到最后。找到是哪个类哪个方法抛的异常再去对应的Service层断点基本两三分钟就能定位。很多新手一看到红字就慌其实Java的异常信息已经告诉了你足够多的信息关键是要练出“看异常找代码”的条件反射。5. 一点过来人的建议这套系统从设计到跑通我前后用了三周其中第一周基本都花在熟悉Vue3和Element-Plus上。如果你也是从零开始我给你两个切实际的建议。第一个建议是先画表结构再写接口最后写页面。很多同学上来就写前端页面结果发现字段对不上、接口定得不合理翻工量大得惊人。而先把数据库五张表建好你就已经完成了整个系统一半的设计——前端需要什么数据、后端要提供什么接口全都从表结构推导出来。后端接口先按我上面那张表列出来每个接口的入参和返回类型先在纸上写明白再动手写Controller和Service顺序对了效率至少翻倍。第二个建议是给自己留一个“炫技点”。这个系统技术栈虽然常用但答辩时大家都做一样的题目靠什么拉开差距就是看你在某个点上有超出课程要求的思考。我这里说的炫技不是花架子而是真实的工程实践比如乐观锁解决超卖、BCrypt加密存储密码、JWT无状态认证、Vue路由守卫控制权限、Nginx静态资源缓存。这些点不需要额外引入复杂框架就是把你已经用到的技术做得更规范、更深入。答辩时候评委问你“为什么这样设计”你能答出背后的理由分数自然就上去了。项目做到最后你会发现自己最受益的不是写完了一个系统而是经历了“设计 → 开发 → 调试 → 部署”的完整闭环。这套能力远远超出某个技术点的本身它会成为你做下一个项目时底气的一部分。
返回列表