ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

SpringBoot+Vue汽车票预订系统:前后端分离实战与防超卖设计

SpringBoot+Vue汽车票预订系统:前后端分离实战与防超卖设计 汽车票网上预订系统应该是前后端分离项目里最亲民的一类选题了。业务不复杂但完整度够高——用户查票、下单、退票管理员维护车次、查看订单角色和流程都清晰适合拿来做毕业设计也适合想完整走通一套Java全栈项目的朋友练手。我最近帮人把这么一套基于SpringBootVue、数据层用MySQLMyBatis的系统从头过了一遍从建表到联调再到现在改得能稳定跑起来整理了不少可以直接照搬的做法和容易忽略的细节。这篇文章不是把源码从头贴到尾而是把技术选型逻辑、数据库设计、核心接口实现、前端环境问题和部署步骤都讲清楚。不管你是Java基础还不错但没碰过SpringBoot的还是前端只写过静态页面的看完都应该能把这套系统在自己电脑上跑起来并且明白每个环节为什么这么设计。1. 定位与选型这套系统到底解决什么问题1.1 业务角色与核心流程汽车票预订系统不需要去对标12306那种量级但业务角色至少要理清楚。常规设计是两个角色普通乘客和管理员。乘客端要能做的动作很明确注册登录、按出发地/目的地/日期查车次、查看车次余票和票价、选择车次下单、模拟支付、查看我的订单、发起退票。管理员端则是维护车次信息新增、修改、停运、查看所有乘客订单、简单的用户管理。核心业务流程是一条线查票→选车次→下单→扣减余票→支付→出票。这看起来简单但扣减余票这一步如果处理不好就会出现超卖——两个人同时订最后一趟车的最后一张票数据库里余票却变成负数这种问题在面试和实际开发中都特别容易被问到。1.2 技术选型的取舍逻辑先说为什么不是 JSP Servlet。老一代Java Web项目里确实有大量汽车票、机票预订系统的JSP写法页面和服务端逻辑耦合在一起改一次需求要动一堆页面。前后端分离之后前端Vue负责交互渲染后端SpringBoot只提供JSON接口两边可以并行开发维护成本低很多。再一个是SpringBoot对比传统SSM。SpringBoot最核心的价值是约定大于配置——内嵌Tomcat、自动装配数据源、Starter机制统一管理依赖。你不需要再去写web.xml、Spring配置文件的扫描配置一个SpringBootApplication启动类加几个注解就能把项目跑起来。对于练手和毕业设计来说省下来的时间可以放在业务逻辑上。数据访问层我选MyBatis而不是JPA主要原因是汽车票查询场景里有大量动态SQL比如根据出发地、目的地、日期、价格区间组合查询车次MyBatis的XML动态SQL对这种场景非常友好SQL完全在自己手里JPA虽然写起来省但遇到复杂查询排查成本高对新人反而更难掌握。这里单独提醒一下版本匹配问题。网上搜索springboot版本太高能搜出一堆报错核心原因是SpringBoot 3.x最低要求JDK 17而且javax.servlet包名改成了jakarta.servlet很多老教程的代码直接搬过去会报类找不到。我建议做主稳妥的组合JDK 8 SpringBoot 2.7.x MyBatis Spring Boot Starter 2.x这套组合资料最多、兼容性最好。如果非要用SpringBoot 3.x记得所有依赖坐标都换成3.x对应版本并且确认自己的JDK是17以上。2. 数据库设计用户、车次、订单三张表怎么扛住业务2.1 核心表结构与字段规划麻雀虽小五脏俱全汽车票系统最少需要三张核心表用户表、车次表、订单表。字段设计直接决定后面代码好不好写我建议按下面这个思路来。用户表 sys_user字段类型说明idbigint主键自增usernamevarchar(50)登录名加唯一索引passwordvarchar(100)加密后的密码real_namevarchar(50)乘客姓名出票用phonevarchar(20)手机号id_cardvarchar(20)身份证号roletinyint角色0乘客 1管理员create_timedatetime注册时间车次表 bus_schedule字段类型说明idbigint主键schedule_novarchar(30)车次编号如 JD1024start_cityvarchar(50)出发城市end_cityvarchar(50)到达城市start_datedate发车日期start_timevarchar(10)发车时间如 08:30end_timevarchar(10)预计到达时间pricedecimal(10,2)票价total_seatsint总座位数remain_seatsint剩余座位数statustinyint0停运 1正常订单表 bus_order字段类型说明idbigint主键order_novarchar(40)订单号唯一索引user_idbigint下单用户IDschedule_idbigint车次IDticket_countint购票张数total_amountdecimal(10,2)总金额statustinyint0待支付 1已支付 2已出票 3已退票create_timedatetime下单时间pay_timedatetime支付时间几个字段细节想多说两句。第一个是订单状态建议存整数代码里用常量或者枚举去对应不要直接在数据库里存中文已支付已退票否则统计和条件查询都很痛苦。第二个是车次编号单独用一个字段而不是依赖自增主键因为业务上要给乘客展示车次号自增ID不适合直接暴露。第三个是金额一定用 decimal 不要用 float你永远不知道浮点数精度问题会在哪一刻咬你一口。2.2 余票扣减与防超卖设计这是整个数据库设计的重点。汽车票的座位本质是一个数量乘客下单那张票就相当于把车次表的 remain_seats 减掉对应的张数。最朴素的设计是先查询余票Java代码里判断够不够再执行更新。问题是如果两个用户同时读到余票是1同时下单两个判断都通过最后就卖出2张票超卖了。解决思路是把判断和扣减放到数据库层面一次完成。核心SQL就这么写UPDATE bus_schedule SET remain_seats remain_seats - #{count} WHERE id #{scheduleId} AND remain_seats #{count}这条语句的特点是只有当当前余票数量足够时才会真的扣减如果余票不足影响行数为0。Java代码只需要检查update的返回值返回0就直接抛异常提示余票不足不需要查询再判断这套两个步骤的操作。更严谨一点的做法是用SELECT ... FOR UPDATE悲观锁先把车次记录锁住再操作但这会增加数据库锁竞争对于练手级别完全没必要。条件更新 事务已经能把超卖问题堵死了。另外订单表的 order_no 一定要加唯一索引防止用户重复点击下单按钮导致同一车次反复扣票这在幂等设计里是最低成本的一道防线。3. 后端落地SpringBoot分层、MyBatis写法和下单防超卖3.1 后端工程结构与分层规范后端代码我习惯按下面的包结构组织三层架构清晰面试被问起也能说得明白com.example.busticket ├── BusticketApplication.java ├── config # CORS配置等 ├── controller # 接收请求返回Result结果包 ├── service # 业务逻辑事务边界 ├── mapper # MyBatis接口 ├── entity # 数据库实体类 └── common # 统一返回结果、全局异常统一返回结果的类很关键。不要每个接口返回不同类型的数据建议写一个ResultT包装类包含 code、message、data 三个字段前后端约定好这个格式联调的时候省心很多。全局异常处理器用RestControllerAdvice实现业务异常直接抛到最外层返回给前端而不是在Controller里写一堆 try-catch。配置文件 application.yml 的核心部分server: port: 8088 spring: datasource: url: jdbc:mysql://localhost:3306/bus_ticket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true type-aliases-package: com.example.busticket.entitymap-underscore-to-camel-case: true这一行建议必开数据库字段remain_seats自动映射成 Java 属性的remainSeats不用在每一条SQL里写resultMap能少写大量重复代码。3.2 MyBatis的使用XML动态SQL与高频坑MyBatis有两种写法注解和XML。简单查询用注解没问题比如根据用户名查用户Select(SELECT * FROM sys_user WHERE username #{username}) User selectByUsername(String username);但车次组合查询这种动态SQL我强烈推荐用XML。比如前端传几个可选的查询条件出发地、目的地、日期XML里写where加if标签就能灵活拼SQLselect idselectByCondition resultTypecom.example.busticket.entity.BusSchedule SELECT * FROM bus_schedule where if teststartCity ! null and startCity ! AND start_city #{startCity} /if if testendCity ! null and endCity ! AND end_city #{endCity} /if if teststartDate ! null AND start_date #{startDate} /if AND status 1 /where ORDER BY start_date, start_time /selectwhere标签会自动处理掉第一个条件前面多余的那个 AND这个细节如果自己拼字符串很容易踩。还有一个高频坑是启动时报Invalid bound statement (not found)十有八九是 Mapper 接口和 XML 的 namespace 不匹配或者mapper-locations没指向classpath:mapper/*.xml。另外接口方法名必须和XML里的id完全一致包括包名和大小写。顺带说一句缓存问题。MyBatis默认带一级缓存和二级缓存但在SpringBoot工程里如果用MyBatis-Spring的整合方式每个Service方法调用时SqlSession的生命周期短一级缓存基本感知不到二级缓存默认不开别指望缓存帮你解决性能问题概念面试会问实战里把SQL写对才是重点。3.3 下单接口事务边界与并发处理下单接口是后端最核心的一段逻辑我的代码大致是Override Transactional(rollbackFor Exception.class) public OrderResult createOrder(OrderCreateRequest request) { int rows scheduleMapper.deductSeats(request.getScheduleId(), request.getTicketCount()); if (rows 0) { throw new BusinessException(余票不足请刷新后重试); } BusOrder order new BusOrder(); order.setOrderNo(generateOrderNo()); order.setUserId(request.getUserId()); order.setScheduleId(request.getScheduleId()); order.setTicketCount(request.getTicketCount()); order.setTotalAmount(request.getTotalAmount()); order.setStatus(0); // 待支付 order.setCreateTime(new Date()); orderMapper.insert(order); return OrderResult.create(order); }事务一定加在Service层用Transactional标注这样扣减余票和创建订单两步操作要么同时成功要么同时回滚。如果我把这段逻辑放在Controller层事务控制会非常混乱这也是为什么三层架构里事务边界永远在Service。rollbackFor 指定Exception.class因为Spring默认只回滚运行时异常受检异常不会自动触发回滚这个细节很多人不知道。订单号生成建议用时间戳加随机数或者UUID但数据库里要保证唯一。我常用的格式是yyyyMMddHHmmss加四位随机数再加用户ID后四位基本不会撞而且看订单号就能知道下单时间。4. 前端Vue从环境版本匹配到接口联调4.1 环境准备版本不匹配才是最大的坑前端部分如果你搜vue安装及环境配置会看到一堆报错帖。这个领域最大的问题不是不会写代码而是版本不匹配。Node.js版本太老跑不起新版Vue CLI版本太新有些老依赖又编译失败。我的建议是二选一别纠结Vue 2 Element UI Vue CLI。配SpringBoot 2.x资料最多、最稳适合毕业设计和新手。Vue 3 Vite Element Plus。工程启动快写法更现代但要求Node.js 16以上部分搭建过程遇到TS相关的报错概率更高。如果是克隆别人的前端源码第一步先看 package.json 里的依赖版本再对照自己的Node版本。网上那个高频报错Failed to load tsconfig vue/tsconfig/tsconfig.web.json通常就是项目用了TypeScript模板但vue/tsconfig依赖没装对版本要么按报错提示安装匹配版本要么干脆用JS模板省事。安装依赖时如果npm install很慢建议配置国内镜像源npm config set registry https://registry.npmmirror.com装完依赖跑npm run serve或者npm run dev看到 Local 地址正常弹出就算是环境通了。4.2 页面结构与路由守卫前端页面按角色拆开乘客端是登录/注册、首页查票、车次列表、订单页、退票操作管理员端是车次管理、订单管理。页面拆成组件之后大概有Login、Register、Home、ScheduleList、OrderList、AdminSchedule、AdminOrder。路由用Vue Router页面一多就必须做路由守卫。核心逻辑是用户登录后把token存到localStoragevuex或pinia里存用户信息路由配置里给需要登录的页面加meta: { requiresAuth: true }然后写一个全局前置守卫router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next(/login) } else { next() } })这一步能挡住没登录就访问订单页的请求也是整个系统权限控制的前端入口。管理员页面再加一层角色判断普通用户直接跳回首页。4.3 接口联调axios拦截器与跨域处理接口请求统一用axios我习惯建一个 request.js 封装这样拦截器可以统一处理token加头和错误提示const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config })后端接口如果写成/api/user/login前端请求路径直接写/api/...开发环境下跨域最好用代理解决而不是在后端开CORS。Vue CLI工程在 vue.config.js 里配devServer: { proxy: { /api: { target: http://localhost:8088, changeOrigin: true } } }这样前端8080端口请求/api前缀会自动转发到后端8088浏览器层面不存在跨域是最干净的方案。如果选择在后端加CORS配置也能跑通但生产环境部署到同源域名下其实用不上CORS代理或Nginx才是更贴近真实的做法。还有一个联调时必定会遇到的坑后端返回值里如果有 LocalDateTime 类型字段默认序列化出来是一长串数字时间戳前端显示很别扭。解决方法是SpringBoot里加Jackson配置统一输出yyyy-MM-dd HH:mm:ss格式这个一定要提前处理否则每个日期字段都要前端手动格式化非常痛苦。5. 跑通源码的完整步骤与实测踩坑记录5.1 从装MySQL到前后端启动的完整流程拿到完整源码之后按下面顺序走一遍基本不会卡壳安装MySQL。5.7或者8.0都行注意8.0安装时设置好root密码安装教程网上很多。安装完成后确认MySQL服务已经启动。初始化数据库。用命令行或Navicat执行CREATE DATABASE bus_ticket DEFAULT CHARACTER SET utf8mb4;然后把项目里的bus_ticket.sql导入。sql文件里如果已经建好库看清楚再执行避免重复创建报错。修改后端配置。打开 backend 模块的application.yml把数据库用户名密码改成你自己的确认端口号没被占用。启动后端。IDEA里直接运行BusticketApplication.java或者命令行mvn spring-boot:run。看到 Started BusticketApplication 就说明起来了可以用Postman测一个车次查询接口验证。启动前端。切换到 frontend 目录依次执行npm install如果已有 node_modules 可以跳过、npm run serve。等编译完成浏览器访问 http://localhost:8080。登录验证。用管理员账号登录去车次管理里新增一条车次数据再到前端首页查这条车次然后注册一个普通账号下单看订单列表数据是否正常。5.2 实测高频问题与解决思路我把这套系统跑通过程中遇到的实际问题整理成一个表方便你对照排查问题现象原因分析解决办法启动报 Server returns invalid timezoneMySQL 8驱动要求指定时区JDBC URL加serverTimezoneAsia/ShanghaiuseSSLfalseInvalid bound statement (not found)Mapper XML没被扫描到检查mapper-locations: classpath:mapper/*.xml确认XML文件名和接口名一致前端请求到不了后端跨域或代理没配好开发环境用 devServer.proxy生产用Nginx同源转发8080端口被占用其他程序占用了端口改server.port或npm run dev -- --port 4000LocalDateTime显示乱码/时间戳缺少Jackson配置加配置类统一yyyy-MM-dd HH:mm:ss格式余票可以扣成负数没有做数据库层防超卖用条件更新remain_seats #{count}并检查返回行数SpringBoot启动报JDK版本错误SpringBoot版本和JDK不匹配要么JDK8配SpringBoot2.x要么JDK17配SpringBoot3.x最后再说一点我个人的体会这套系统真正值得花时间的不是跑通而是把里面一两个环节做深。如果你有余力可以在现有基础上加一个座位图选座功能把余票从数量升级成座位状态那超卖问题和座位区间都会变成新的挑战也可以对接支付宝沙箱或者微信支付测试环境把模拟支付换成真实支付流程。数据库字段统一命名、订单号生成策略这些细节看起来不起眼但等你做真正项目的时候它们才是决定代码好不好维护的地方。反正我自己的经验是先把这个小系统的数据流彻底吃透SpringBoot和Vue之间那层接口契约你就再也不会觉得玄乎了。
RELATED READING

延伸阅读

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