汽车票网上预订系统,应该是前后端分离项目里最"亲民"的一类选题了。业务不复杂但完整度够高——用户查票、下单、退票,管理员维护车次、查看订单,角色和流程都清晰,适合拿来做毕业设计,也适合想完整走通一套Java全栈项目的朋友练手。我最近帮人把这么一套基于SpringBoot+Vue、数据层用MySQL+MyBatis的系统从头过了一遍,从建表到联调再到现在改得能稳定跑起来,整理了不少可以直接照搬的做法和容易忽略的细节。
这篇文章不是把源码从头贴到尾,而是把技术选型逻辑、数据库设计、核心接口实现、前端环境问题和部署步骤都讲清楚。不管你是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:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键,自增 |
| username | varchar(50) | 登录名,加唯一索引 |
| password | varchar(100) | 加密后的密码 |
| real_name | varchar(50) | 乘客姓名,出票用 |
| phone | varchar(20) | 手机号 |
| id_card | varchar(20) | 身份证号 |
| role | tinyint | 角色,0乘客 1管理员 |
| create_time | datetime | 注册时间 |
车次表 bus_schedule:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| schedule_no | varchar(30) | 车次编号,如 JD1024 |
| start_city | varchar(50) | 出发城市 |
| end_city | varchar(50) | 到达城市 |
| start_date | date | 发车日期 |
| start_time | varchar(10) | 发车时间,如 08:30 |
| end_time | varchar(10) | 预计到达时间 |
| price | decimal(10,2) | 票价 |
| total_seats | int | 总座位数 |
| remain_seats | int | 剩余座位数 |
| status | tinyint | 0停运 1正常 |
订单表 bus_order:
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_no | varchar(40) | 订单号,唯一索引 |
| user_id | bigint | 下单用户ID |
| schedule_id | bigint | 车次ID |
| ticket_count | int | 购票张数 |
| total_amount | decimal(10,2) | 总金额 |
| status | tinyint | 0待支付 1已支付 2已出票 3已退票 |
| create_time | datetime | 下单时间 |
| pay_time | datetime | 支付时间 |
几个字段细节想多说两句。第一个是订单状态,建议存整数,代码里用常量或者枚举去对应,不要直接在数据库里存中文"已支付""已退票",否则统计和条件查询都很痛苦。第二个是车次编号,单独用一个字段而不是依赖自增主键,因为业务上要给乘客展示车次号,自增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 # 统一返回结果、全局异常统一返回结果的类很关键。不要每个接口返回不同类型的数据,建议写一个Result<T>包装类,包含 code、message、data 三个字段,前后端约定好这个格式,联调的时候省心很多。全局异常处理器用@RestControllerAdvice实现,业务异常直接抛到最外层返回给前端,而不是在Controller里写一堆 try-catch。
配置文件 application.yml 的核心部分:
server: port: 8088 spring: datasource: url: jdbc:mysql://localhost:3306/bus_ticket?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false 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>标签就能灵活拼SQL:
<select id="selectByCondition" resultType="com.example.busticket.entity.BusSchedule"> SELECT * FROM bus_schedule <where> <if test="startCity != null and startCity != ''"> AND start_city = #{startCity} </if> <if test="endCity != null and endCity != ''"> AND end_city = #{endCity} </if> <if test="startDate != null"> AND start_date = #{startDate} </if> AND status = 1 </where> ORDER BY start_date, start_time </select><where>标签会自动处理掉第一个条件前面多余的那个 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存到localStorage,vuex或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 timezone | MySQL 8驱动要求指定时区 | JDBC URL加serverTimezone=Asia/Shanghai&useSSL=false |
| Invalid bound statement (not found) | Mapper XML没被扫描到 | 检查mapper-locations: classpath:mapper/*.xml,确认XML文件名和接口名一致 |
| 前端请求到不了后端 | 跨域或代理没配好 | 开发环境用 devServer.proxy,生产用Nginx同源转发 |
| 8080端口被占用 | 其他程序占用了端口 | 改server.port或npm run dev -- --port 4000 |
| LocalDateTime显示乱码/时间戳 | 缺少Jackson配置 | 加配置类统一yyyy-MM-dd HH:mm:ss格式 |
| 余票可以扣成负数 | 没有做数据库层防超卖 | 用条件更新remain_seats >= #{count}并检查返回行数 |
| SpringBoot启动报JDK版本错误 | SpringBoot版本和JDK不匹配 | 要么JDK8配SpringBoot2.x,要么JDK17配SpringBoot3.x |
最后再说一点我个人的体会
这套系统真正值得花时间的不是跑通,而是把里面一两个环节做深。如果你有余力,可以在现有基础上加一个"座位图选座"功能,把余票从数量升级成座位状态,那超卖问题和座位区间都会变成新的挑战;也可以对接支付宝沙箱或者微信支付测试环境,把模拟支付换成真实支付流程。数据库字段统一命名、订单号生成策略这些细节,看起来不起眼,但等你做真正项目的时候,它们才是决定代码好不好维护的地方。反正我自己的经验是,先把这个小系统的数据流彻底吃透,SpringBoot和Vue之间那层"接口契约"你就再也不会觉得玄乎了。