去年代理了一个客运站的信息化改造项目,其中一个核心模块就是汽车票网上预订系统。当时拿到需求第一反应是:这不就是个简化版12306吗?真做起来才发现完全不是一回事。长途客运有自己的业务规则——中途站能不能上车、同一班次不同区段票价怎么算、无固定座位的班线怎么卖票,这些东西都会直接决定数据库表怎么建、下单事务怎么写的边界。
这套系统我最终用SpringBoot+Vue完成,数据层采用MyBatis配合MySQL,前后端分离开发,整体跑通后,我把整个设计和实现过程完整复盘一遍。这篇内容适合正在做票务类管理系统、想系统学习SpringBoot+MyBatis+Vue全栈项目,或者准备接手客运站信息化改造的人。我不只讲代码,还会把当初为什么这么设计、中途踩过的坑一并说清楚。
1. 业务痛点与系统边界:汽车票和火车票的预订逻辑完全不一样
1.1 长途客运的"站到站"与"多中途站"特性
火车票的核心模型是从A站到B站,按车次、日期、席别三个维度卖票,座位是固定分配好的。而长途汽车不一样,一个班次从始发站开往终点站,中间可能停靠七八个配客站点。乘客既可以从始发站买到终点站,也可以从第三站上车买到第五站下车。
这就带来一个火车票系统里不太需要考虑的问题——同一辆车、同一个座位,在不同区段上是可能被重复售卖的。比如从杭州到成都的班车,乘客甲买杭州到宜昌,坐1号座,乘客乙买恩施到成都,如果全程是同一辆车的同一条线路,理论上他和甲可以坐同一个位置,因为两人的乘坐区间不重叠。这种"区段复用"逻辑,如果照搬火车票的座位分配方案,业务上肯定要出问题。
我在第一版设计里就犯了想当然的错误,直接把每个座位绑定到一张"班次-日期"表上,结果运营方提出中途站点上客的需求后,整个表结构全部推翻重来。后来和客运站调度员聊了几次才明白,汽车票系统真正要管理的核心对象不是"座位",而是**"班次在各个站点之间的可售票额"**。同一个物理座位在不同区段可以重复售卖,只有区段重叠时才需要做冲突校验。
1.2 系统的功能边界:前台购票、后台管理、调度三块
这套系统的功能边界我最终划成三块:
- 用户前台:注册登录、线路班次查询、在线选座购票、订单支付(模拟)、退票、个人订单中心。
- 后台管理:站点管理、线路管理、班次管理、车辆管理、订单管理、退票审核。
- 基础支撑:用户体系、角色权限(管理员/普通用户)、操作日志。
开发票务系统最容易犯的错是把所有功能都堆在一起做。我一开始也想把驾驶员排班、车辆维保、财务对账全部塞进去,后来砍掉了大半——不是做不了,而是业务需求一旦太宽,项目边界就会模糊,功能做出来没人用。汽车票预订系统的核心链路其实就是:用户查询班次、选定上下车站点、锁定座位、生成订单、支付出票、乘车核验、退票处理。管理端则围绕班次和订单做增删改查。
1.3 谁适合拿这套系统当学习样本
如果你是一个准备找全栈开发工作的学生,或者刚入行的Java后端工程师,这套系统值得花时间仔细拆一遍。它的技术栈非常主流:SpringBoot做接口层,MyBatis做数据访问,MySQL存业务数据,Vue做前端页面。业务复杂程度又恰到好处——不是增删改查那么简单,但又没有电商秒杀、分布式事务那种硬核难点,正好是练手和理解业务建模的好素材。
对于已经在做客运相关行业系统的朋友,我的建议是重点关注余票计算和订单状态流转这两个模块。客运行业的系统往往都是老古董,很多还在用C/S架构,能把这套前后端分离的思路迁移过去,对旧系统改造很有参考价值。
2. 技术选型复盘:SpringBoot+MyBatis+MySQL+Vue的组合理由
2.1 为什么后端用SpringBoot而不是传统SSH
放在五年前,这个项目大概率是SpringMVC+Spring+Hibernate的SSH组合。但现在做新项目用SpringBoot几乎是行业默认的选择,原因很实际:SpringBoot把大量配置工作消解掉了。以前搭一个SSH项目要配数据源、配事务管理器、配视图解析器、配一堆XML,我当年第一次搭环境就花了两天。SpringBoot引入了自动配置和Starter机制,大部分基础配置只需要加依赖就行,项目能在一个小时内启动起来跑通接口。
对这类中小型管理系统的开发节奏来说,SpringBoot还有一个隐藏优势是社区资料极度丰富。任何一个报错,搜一下基本都能找到解决方案,这对单人开发或者小团队开发来说,效率提升非常明显。
2.2 MyBatis在客运业务里的优势与劣势
选MyBatis而不是JPA,理由很直接:车次查询、余票统计这类查询逻辑有大量动态SQL场景。
比如用户输入出发站、到达站、日期,还可能有早班/晚班、只看剩余票量大于多少等筛选条件,这些条件在MyBatis里用动态SQL拼装非常自然。JPA虽然也能做动态查询,要用Specification或者QueryDSL,学习曲线陡,而且生成的SQL有时候让人看不懂。
但MyBatis的劣势我也要实事求是地说:它把SQL的控制权完全交给开发者,意味着每个开发人员的SQL水平直接决定了系统性能。我在这个项目里就见过同事写嵌套子查询把车次列表页拖慢到三秒的情况,后来用JOIN+LEFT JOIN和聚合函数优化到几百毫秒。团队里如果有人SQL基础不扎实,用MyBatis会是一场灾难,需要规范SQL review流程。
另外这年头还有一个选择是MyBatis-Plus,它内置了通用CRUD方法,日常的单表操作连XML都不用写。我在这套系统里用了MyBatis-Plus的条件构造器简化订单查询,但是核心的车次搜索、余票统计仍然手写XML,因为复杂查询用条件构造器反而更难维护。
2.3 Vue给前后端协作带来的变化
前端选Vue而不是React,没有特别复杂的原因——就是开发效率高,上手快。Vue的双向绑定和组件化设计,对开发这种以表单和列表为主的管理系统非常友好。vue-element-admin这类后台模板生态成熟,骨架一出来,剩下的工作主要是在写业务组件。
Vue在前端分离开发里扮演的角色很清晰:通过axios调用后端JSON接口,渲染页面数据,维护用户交互状态。相比之前用FreeMarker模板在后端拼页面的方式,前后端分离最大的好处是后端只管接口数据,前端只管页面交互,两边能并行开发。
2.4 这套组合的学习成本与团队适应性
对团队来说,SpringBoot+MyBatis+Vue是当前国内中小型项目里最稳妥的搭配。招聘市场上Java后端工程师基本人手一份SpringBoot经验,Vue更是前端入门标配。MySQL则天然与Java体系亲和,部署维护成本低。
如果你是个人学习,我强烈建议不要换技术栈。有人喜欢用PostgreSQL替代MySQL,用MyBatis-Plus替代MyBatis,这都没问题,但如果你主要是想学习这套系统的业务设计逻辑,那么尽量保持和我一致的技术栈,遇到问题能直接对照。
3. 数据库设计:六张核心表如何支撑起一整套购票流程
3.1 站点、线路、班次:把现实客运网络翻译成数据模型
先忽略用户表和管理员表这类基础表,业务核心总共六张表:站点表、线路表、班次表、车辆表、订单表、乘客信息表,另外还加了一张座位表用于支持选座业务。
我当时在表设计上花了很大精力,这里专门说几个关键点。
站点表很好理解,存站点名称、城市、站点类型(始发站/中途站/终点站)。线路表则是核心关系的起点:
| 字段 | 说明 |
|---|---|
| id | 主键 |
| line_name | 线路名称,如"杭州-成都" |
| start_station_id | 始发站ID |
| end_station_id | 终点站ID |
| distance | 线路里程 |
| base_price | 基础票价 |
| run_time | 预计运行时长 |
班次表schedule承接线路和实际发车日期的关系:
| 字段 | 说明 |
|---|---|
| id | 主键 |
| line_id | 线路ID |
| bus_id | 车辆ID |
| depart_date | 发车日期 |
| depart_time | 发车时间 |
| arrive_time | 预计到达时间 |
| full_price | 全程票价 |
| status | 班次状态(正常/停运/已发车) |
车辆表bus我一开始设计得很简单,只存车牌和车型,后来加上了seat_count字段,并且考虑是否支持选座,给加了一个seat_type字段,用于区分固定座位模式(如大型卧铺车)和不固定座位模式(如流水班车)。
3.2 订单与乘客信息:一个订单多个乘车人
订单表是整个系统的资金流转核心。这里有一个关键设计决策:一个订单对应一个乘车人还是多个乘车人。
客运站的实际情况是一个人一次买多张票,往往是帮家里人一起买的。如果一张订单只能对应一张票,那用户体验会很差。我最终采用了orders主表加order_passenger子表的设计,主表记录订单号、用户ID、班次ID、上下车站点、总金额、状态、创建时间,子表记录每个乘车人的姓名、身份证号、座位号信息。
订单状态我用一个状态字段管理,只允许单向流转:
- LOCKED(锁定/待支付)
- PAID(已支付)
- REFUNDED(已退款)
- FINISHED(已完成/已乘车)
这里有个容易忽略的问题:当用户买3张票,支付时间超过系统限定的15分钟,订单被自动取消,乘客子表里的数据怎么办?我选择保留这部分历史数据,只把主表状态改成CANCELLED,方便日后对账和问题追溯。
3.3 余票运算:座位表还是纯计数
这是整套系统里最需要想清楚的设计决策。
方案一:不建座位表,班次表里直接存"总票数"和"已售票数",下单时扣减"已售票数",退票时反向加回。这个方案简单粗暴,适合流水班车、上车随便坐的线路。
方案二:建座位表,每个班次预先生成N条座位记录,每条记录有seat_number和status,用户选座时锁定某一条座位记录。这个方案支持具体座位的选择,但座位数据量巨大,一个班次50个座位,一个月下来就是几十万条记录。
我最终采用了混合方案:支持固定座位的班次用座位表,不支持选座的流水班次走纯计数。在数据库层面,班次表保留total_seats和booked_seats两个统计字段,座位表只负责锁定具体座位号。
余票数计算逻辑如下:
remain_seats = total_seats - booked_seatsbooked_seats表示当前日期该班次所有有效订单(包含已支付和锁定中)的座位占用总数。之所以把锁定中的订单也算进去,是为了避免超卖。这个字段会在下单事务中执行UPDATE操作,配合WHERE booked_seats + #{buyCount} <= total_seats条件来实现并发控制。
4. 后端核心实现:锁票、订单状态机与MyBatis动态SQL
4.1 车次查询接口的SQL编写思路
用户在前端输入出发地、目的地和日期,后端需要返回符合条件且有余票的班次列表。这是整个系统访问频率最高的接口,SQL写法直接影响用户体验。
核心SQL长这样:
<select id="findAvailableSchedules" resultType="com.example.busticket.vo.ScheduleVO"> SELECT s.id, s.depart_date, s.depart_time, s.arrive_time, s.full_price, r.line_name, start_station.name AS start_station_name, end_station.name AS end_station_name, b.plate_number, (s.total_seats - s.booked_seats) AS remain_seats FROM schedule s INNER JOIN route r ON s.line_id = r.id INNER JOIN station start_station ON start_station.id = #{startStationId} INNER JOIN station end_station ON end_station.id = #{endStationId} INNER JOIN bus b ON s.bus_id = b.id WHERE s.depart_date = #{departDate} AND (s.total_seats - s.booked_seats) > 0 <if test="startTime != null"> AND DATE_FORMAT(s.depart_time, '%H:%i') >= #{startTime} </if> <if test="endTime != null"> AND DATE_FORMAT(s.depart_time, '%H:%i') <= #{endTime} </if> ORDER BY s.depart_time </select>注意这里有个业务细节:如果用户输入的出发站不是始发站,只过滤班次线路会经过该站。但为了简化第一版,我只支持了同线路的站点查询,也就是从线路关联的站点集合中过滤。
4.2 下单流程:先锁座再生成订单
购票接口是最需要小心的地方,并发情况下容易出现超卖。直接插入订单然后扣减booked_seats是不行的,两个用户同时下单,都读到余票还剩1张,都插入成功了就超卖了。
我在OrderService.createOrder()里做了三件事:
- 校验班次是否存在且有余票
- 扣减
booked_seats,同时更新seat表锁定座位 - 生成订单主记录和乘客子记录
扣减操作使用带条件的UPDATE语句:
UPDATE schedule SET booked_seats = booked_seats + #{buyCount} WHERE id = #{scheduleId} AND booked_seats + #{buyCount} <= total_seats这条SQL返回的影响行数如果为0,说明已经卖完了,直接抛出业务异常。这是最简单有效的乐观锁防超卖方案,不需要引入Redis分布式锁,对中小型系统来说完全够用。
同时给座位打上锁定标记:
UPDATE seat SET status = 'LOCKED', order_id = #{orderId} WHERE batch_id = #{batchId} AND status = 'AVAILABLE' LIMIT #{buyCount}锁座和扣减票数必须在同一个事务里。如果先锁座再扣减,扣减失败回滚时座位锁也要回滚,否则会出现死锁。
4.3 事务边界:哪些操作必须放在同一个事务里
我踩过的一个坑是:下单时把插入订单、扣减余票、锁定座位三个操作放在一个事务里,这是对的。但退票操作我一开始拆成了两个接口调用,前端先调"取消订单"再调"释放座位",结果用户网络异常时出现了订单已取消但座位没释放的情况。
正确的做法是:一次业务操作对应一个事务接口。退票接口内部完成状态变更、余票回补、座位释放三个动作,要么全部成功,要么全部失败。前端不应该参与后端事务流程的编排。
4.4 退票与订单状态迁移
退票逻辑里还有一个常见业务规则:发车前一定时间内允许退票,发车后不允许退票。这个判断必须在后端做权威校验,不能只靠前端按钮隐藏。
// 伪代码示意 if (order.getStatus() != OrderStatus.PAID) { throw new BizException("当前订单状态不可退票"); } if (schedule.getDepartTime().isBefore(LocalDateTime.now().plusHours(2))) { throw new BizException("距发车时间不足2小时,无法在线退票,请联系车站窗口"); }订单状态迁移我写成了一个独立的OrderStatusTransition类,每个状态能流转到哪些目标状态用Map配置好,非法迁移直接抛异常。这样比散落在各个Service里的if判断好维护得多。
5. 前端实现:Vue页面从查询到支付的全链路
5.1 页面架构与路由设计
前端我用Vue 3 + Vue Router + Pinia搭建,组件库选择了Element Plus。页面路由按业务模块拆分:
/:首页,班次查询入口/schedules:班次列表页/booking/:scheduleId:购票页,包含选座和填乘客信息/orders:我的订单列表/order/detail/:orderId:订单详情/admin/*:后台管理模块
路由配置里加了全局路由守卫,购物车、订单相关页面必须登录才能访问,未登录跳转到登录页并带上redirect参数。
5.2 班次列表与选座组件的交互
班次列表页的核心是一个可筛选的表格组件,展示发车时间、到达时间、余票数和票价。这里有一个交互细节值得说:余票大于5张时显示"充足",小于5张时显示具体数字并标红。这个规则是客运站运营人员提出的,因为他们发现如果把精确余票数全展示出来,黄牛会盯着余票少的班次扫票,而显示"充足"会降低被扫票的风险。
选座组件买票时如果班次支持选座,我会用座位图展示。座位图是一个二维数组,用seat.status控制可点击或禁用状态,点击座位后高亮并记录座位号。这里要注意座位图的数据加载时机:进入购票页预加载一次,用户确认下单前再校验一次,防止座位被别人抢先锁定。
选座组件的核心逻辑:
const seatMap = ref([]) function toggleSeat(row, col, seat) { if (seat.status !== 'AVAILABLE') return if (selectedSeats.value.length >= passengers.value.length) { ElMessage.warning('已选座位数不能超过乘车人数') return } seat.status = 'LOCKED_TEMP' selectedSeats.value.push(`${row}-${col}`) }这里的LOCKED_TEMP只是前端临时状态,不代表真正的座位锁定,真正的锁定发生在提交订单接口调用时。
5.3 axios封装与后端接口约定
axios封装是前后端协作的基础。我统一做了拦截器,请求自动携带token,响应统一处理错误码。
service.interceptors.response.use( response => { const res = response.data if (res.code === 200) return res ElMessage.error(res.msg || '请求失败') return Promise.reject(new Error(res.msg)) }, error => { if (error.response && error.response.status === 401) { router.push('/login') } return Promise.reject(error) } )后端返回结构统一为:
{ "code": 200, "msg": "success", "data": { } }这个约定越早定越好。不要小看接口规范,我见过不少前后端联调项目,后端返回一个JSON结构,前端又包一层自己理解的JSON,最后日志排查时两边数据结构对不上,白白浪费大量时间。
5.4 本地开发时的跨域问题
开发环境下前端跑在8080端口,后端跑在8081端口,直接请求必然跨域。这个问题我放在第六章详细讲,这里先说开发阶段的解决方案:后端开发环境配置@CrossOrigin或者全局CORS允许http://localhost:8080这样写死即可。生产环境则部署在同域下,通过Nginx把/api反向代理到后端服务,完全避免跨域问题。
6. 环境配置与部署:我踩过的版本兼容坑
这一章我写的内容,大概率是读者最容易遇到问题的部分。整套系统技术栈很主流,但版本兼容的坑真的很多。
6.1 MySQL版本选择:5.7还是8.0
我在起步阶段就踩过MySQL版本的坑。用MyBatis连接MySQL时,如果你的MySQL版本是5.x,驱动地址是:
jdbc:mysql://localhost:3306/bus_ticket?characterEncoding=utf8&useSSL=false如果是MySQL 8.0,驱动类从com.mysql.jdbc.Driver变成了com.mysql.cj.jdbc.Driver,而且必须指定时区:
jdbc:mysql://localhost:3306/bus_ticket?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai很多人在网上复制旧教程的连接字符串,连8.0的MySQL时就报The server time zone value 'Öйú±ê׼ʱ¼ä'这种错。遇到这个错别慌,在URL后面加上serverTimezone=Asia/Shanghai就能解决。我最终选择MySQL 5.7,主要是考虑到生产环境是客户的旧服务器,5.7运行更稳定,对内存要求也更低。
6.2 SpringBoot与MyBatis整合的版本陷阱
SpringBoot版本太高也会出问题。比如用SpringBoot 3.x时,它的javax.servlet包已经改成jakarta.servlet,如果按照2.x的写法去扫描包,Mapper扫描不到,启动直接报Invalid bound statement。
我用的组合是SpringBoot 2.7.x + MyBatis Starter 2.3.x + MySQL Connector 8.0.x,这是经过很多项目验证过的稳定组合。如果你决定用SpringBoot 3,那就要配套使用MyBatis Starter 3.x,并且注意JDK版本至少17。
还有一个高频坑:MyBatis的Mapper XML文件位置配置。SpringBoot打jar包时,默认只打包src/main/resources下的资源,如果你的Mapper XML放在src/main/java目录下,打包后就会找不到XML,启动后报Invalid bound statement (not found)。解决方案是在pom.xml里配置让mapper/**/*.xml也被打进包:
<build> <resources> <resource> <directory>src/main/resources</directory> <filtering>true</filtering> </resource> </resources> </build>或者更规范的做法是直接把XML放在resources/mapper/目录下,然后配置:
mybatis.mapper-locations=classpath:mapper/*.xml6.3 Vue环境:用Vue CLI还是Vite
Vue 3的项目创建,官方现在推荐Vite,但我第一次搭这个项目时用的还是Vue CLI。如果你用Vue CLI创建Vue 3项目,会遇到Node版本兼容问题,Vue CLI 5要求Node.js >= 14。Vite要求Node.js >= 16,如果你机器上Node版本比较旧,推荐先升级Node到18 LTS版本,省去很多麻烦。
vue install依赖卡住也算高频问题。如果你在中国大陆网络环境下,建议把npm源切换成国内镜像,不然一个npm install可能跑半小时都装不完。另外留意vue-router等插件的版本,Vue 2配的是Vue Router 3,Vue 3只能配Vue Router 4,装错版本直接白屏。
有的同学在启动Vue项目时还会遇到failed to load tsconfig '@vue/tsconfig/tsconfig.web.json'这类问题,这个场景通常出现在从别人的仓库clone代码过来,本地缺少依赖。先npm install,再检查tsconfig.json里有没有引用本地不存在的文件路径。我一般建议Vue模板项目不要乱改tsconfig路径配置。
6.4 前后端联调时的经典报错
联调阶段常见的三个问题,我挨个说:
第一个是跨域。开发环境后端加了CORS配置后,如果请求带了自定义header,需要在allowedHeaders里放开对应header,否则浏览器会报CORS error。我的统一写法是:
registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600);注意:allowedOrigins写成"*"配合allowCredentials(true)在某些SpringBoot版本会报错,这时候改用allowedOriginPatterns("*")。
第二个是数据库乱码。MySQL连接URL已经指定了characterEncoding=utf8,建表时如果表默认字符集不是utf8,插入中文还是会乱码。最稳妥的办法是建库时指定:
CREATE DATABASE bus_ticket DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;第三个是前端拿到的时间格式问题。后端LocalDateTime默认序列化成yyyy-MM-dd'T'HH:mm:ss,前端直接显示会带个T字母。我统一加了一个Jackson配置,把时间格式转成yyyy-MM-dd HH:mm:ss。
7. 源码结构导览与二次开发的扩展思路
7.1 后端包结构与关键类职责
后端我采用的是常见的Controller-Service-Mapper三层结构,外加entity、dto、vo分层:
com.example.busticket ├── controller // 接口层,接收前端请求 ├── service // 业务逻辑层 │ └── impl // 业务实现 ├── mapper // MyBatis Mapper接口 ├── entity // 数据库实体类 ├── dto // 入参对象 ├── vo // 返回值对象 ├── config // 各类配置(CORS等) ├── common // 统一返回结构、业务异常 └── utils // 工具类我的习惯是Controller层保持精简,不做任何业务判断,只负责参数接收、调用Service、返回统一结果。业务判断全部下沉到Service层,这样单元测试也好写。
7.2 前端目录组织
前端目录按这个结构组织:
src ├── api/ // 接口请求定义,按模块拆分 ├── assets/ // 静态资源 ├── components/ // 通用组件(分页、空状态等) ├── views/ // 页面级组件 │ ├── home/ // 首页与查询 │ ├── booking/ // 购票流程 │ ├── orders/ // 订单中心 │ └── admin/ // 后台管理 ├── router/ // 路由配置 ├── store/ // Pinia状态管理 ├── utils/ // 工具函数(date格式化等) └── App.vue后端写到自己身上,踩了几次坑后我总结出一个组织原则:前端目录按业务模块划分,不按技术类型划分。有些项目喜欢把所以的.vue文件全放一个目录,再用components放所有组件,页面一多起来根本找不到文件。
7.3 如果要加功能,从哪个入口下手
这套系统目前是个基础可用版本,但有三个方向可以进一步扩展,我自己也已经在做规划:
第一是接入真实的第三方支付。当前支付只是模拟状态切换,如果要接入微信/支付宝支付,需要在PayController里新增支付回调接口,并在订单状态机中增加PAYING和PAY_FAILED状态。注意支付回调接口必须做幂等处理,防止同一笔订单被回调多次时重复更新状态。
第二是增加余票的缓存层。现在余票统计每次查询都直接走MySQL,并发上来了数据库压力会很大。可以引入Redis,把车次余票数缓存起来,下单成功后异步扣减缓存中的余票数量。我这里说的异步,必须保证缓存最终一致,最简单的方案是先更新数据库,再删除对应缓存。
第三是增加运营数据分析功能。典型的需求是统计每条线路的上座率、热门时段分布、退票率等。这类报表很好做,SQL拉出来直接展示到管理后台,用ECharts画几个图就行。真正的难点在于字段口径的统一,一定要尽早和业务方对齐。
我在实际开发中最大的体会是:这种业务型管理系统,技术从来不是瓶颈,准确理解业务模型才是项目成败的关键。汽车票的区段售票、班次与座位的对应关系、订单状态机的流转规则,每一块都需要先理清业务再写代码。建议准备做类似系统的朋友,动手写第一行代码之前,先花两天泡在客运站售票窗口,把售票员的操作流程记录下来,再对照这套系统的表结构和代码,你会理解每个设计决策背后的原因。