火车票订票系统这个题,可以说是毕业设计里的常青树了。难度适中、业务链路清晰、前后端都能涉及,而且无论是做课设还是毕设,给老师讲解时"查票→下单→支付→出票"这条主线非常容易讲明白。但正因为做的人多,能不能拿出一点差异化设计、能不能把细节讲透,就变成了拉开档次的关键。
我今年刚带完一个用 Java + Vue + SpringBoot 做的火车票订票系统项目,从数据库建模到后端接口、从前端页面到最终打包部署,再到论文和答辩,整条链路都走了一遍。这篇文章就按我实际带项目的顺序,把整套系统的设计思路、核心代码逻辑、部署步骤和答辩准备一次性讲清楚。
1. 系统架构与技术选型:为什么这个选题是"毕业设计友好型"
1.1 功能边界:先明确系统做什么、不做什么
很多人一拿到"火车票订票系统"这个题目,第一反应就是把功能往多了堆:在线选座、退票改签、积分商城、会员等级、优惠券、站内换乘……全塞进去。我劝你打住。毕设系统的核心目标不是做一个12306,而是用一套清晰的技术栈把核心业务跑通,并且能自圆其说。
我最后敲定的功能边界是这样的:
- 用户端:注册登录、车次查询(按出发城市、到达城市、出发日期)、车次经停站详情、下单购票、模拟支付、我的订单、取消订单。
- 管理端:车次管理(增删改查)、车站管理、用户管理、订单管理(查看、统计)。
没有做选座(12306的选座逻辑太复杂,对毕设性价比低),没有做真实的第三方支付对接(用一个模拟支付的弹层代替),没有做退票和改签(但订单取消做了,相当于逻辑留了一半)。这样做的好处非常明显:业务闭环完整,但工作量可控,核心模块每个都能讲出深度,而不是把精力耗在一堆边角功能上。
1.2 技术栈选型逻辑:每个选择都要能说出理由
技术栈的选型不能光说"我用了SpringBoot",答辩时老师一定会问"为什么用这个不用那个"。选型理由一定要提前准备好,而且要合理。
| 技术 | 选型 | 理由 |
|---|---|---|
| 后端框架 | SpringBoot 2.7.x | 生态成熟、自动装配省去大量XML配置、内置Tomcat一键启动;比SSH、SSM更符合当前企业主流 |
| 前端框架 | Vue 2 + Element UI | Vue上手曲线平缓、双向绑定写起来顺畅;Element UI组件库做后台管理页面效率极高 |
| 数据库 | MySQL 8.x | 开源、跨平台、在火车票这类事务型系统上没有性能压力 |
| ORM | MyBatis-Plus | 单表CRUD直接继承BaseMapper,复杂查询手写SQL,兼顾效率与可控性 |
| 认证方案 | JWT | 无状态、适合前后端分离架构,比Session更贴合项目实际 |
| 构建工具 | Maven | 主流标配,依赖管理方便 |
| 前端构建 | npm + Vue CLI | 标准Vue项目脚手架,开箱即用 |
这套组合是我认为对毕设来说"性价比"最高的方案。如果非要纠结一下,可以聊聊为什么不用SpringCloud——单机系统用微服务就是过度设计,徒增部署和运维成本,这个理由面试和答辩都认。
2. 数据库设计:六张表如何撑起一次完整的订票流程
2.1 实体关系与建表思路
数据库设计是这类系统最见功力的一环。很多人的版本是 user 表、order 表、train 表各一张,车次表里直接写死"北京-上海"。这样做查询简单,但根本没有办法支持多站经停的线路。比如 G101 次是从北京始发、经停济南、终点到上海,你如果只存起点终点,用户查"济南到上海"就查不到这趟车——这在实际场景里是不可接受的。
我最终设计的核心表如下:
user(用户表)
- id、username、password(BCrypt加密存储)、real_name、id_card、phone、role(USER/ADMIN)、create_time
station(车站表)
- id、name、city
train(车次表)
- id、train_no(车次号)、train_type(G/D/K等)、start_station_id、end_station_id、depart_time、arrive_time、distance、status
train_station(车次经停站表)
- id、train_id、station_id、station_order(站序)、arrive_time、leave_time、stop_minutes
train_seat(车次座位/余票表)
- id、train_id、seat_type(二等座/一等座/硬卧/软卧)、price、total_count、remain_count
orders(订单表)
- id、order_no、user_id、train_id、depart_station_id、arrive_station_id、seat_type、price、ticket_count、depart_date、passenger_name、passenger_id_card、status(0待支付/1已支付/2已取消)、create_time、pay_time
这套设计的核心亮点是把"车次"和"经停站"拆成了两张表。车次表只存整体信息,train_station 表存每一站的顺序和时间,train_seat 表存各席别的价格和余票。查询"从A站到B站能坐哪趟车"时,只需要判断同一车次下 A 站的站序小于 B 站的站序即可。
2.2 经停站表的设计:看起来多一张表,其实是省了大功夫
我见过很多项目在这里图省事,train表里直接加一个 text 字段存所有经停站,比如"北京,天津,济南,南京,上海",查询的时候用 LIKE 去匹配。这种做法有两个致命问题:第一,无法判断 A 站到 B 站的方向是否可行;第二,完全没法扩展票价和到发时间。
有了 train_station 表之后,很多接口写起来反而更顺了。前端展示车次详情时,直接查这张表按 station_order 排序返回列表。查票时用一条 SQL 就能关联出起点和终点的站名:
SELECT DISTINCT t.*, s1.name AS depart_station_name, s2.name AS arrive_station_name, ts1.leave_time AS depart_station_time, ts2.arrive_time AS arrive_station_time FROM train t JOIN train_station ts1 ON t.id = ts1.train_id JOIN train_station ts2 ON t.id = ts2.train_id JOIN station s1 ON ts1.station_id = s1.id JOIN station s2 ON ts2.station_id = s2.id WHERE s1.city = #{departCity} AND s2.city = #{arriveCity} AND ts1.station_order < ts2.station_order AND t.status = 1关键就是最后一行的判断条件。两站在同一车次上,且起点站的站序小于终点站的站序,这趟车才是"顺方向可乘坐"的。这段逻辑我在答辩时一定会展开讲,因为它直接体现了数据库设计对业务场景的适配能力。
2.3 余票扣减与订单状态机设计
余票字段放在 train_seat 表的 remain_count 里,下单时要做两件事:扣减余票 + 生成订单。这两个操作必须在一个事务里完成,否则会出现"订单生成了但票没扣掉"或"票扣了但订单没生成"的脏数据。
订单状态我用了整型字段表示:
0:待支付(下单后锁定余票)1:已支付(支付成功后)2:已取消(超时未支付或用户手动取消)
这里有一个细节很多人会漏:用户取消订单后,余票要加回来;但如果用户已经支付了,就不能直接取消了(我简化了退票逻辑,只保留未支付订单的取消)。所以取消订单的接口里一定要先判断 status 是否为 0。
补一个实用的加票逻辑——还是用同一行数据的乐观锁版本号,配合UPDATE train_seat SET remain_count = remain_count + 1 WHERE id = ? AND remain_count >= 0,保证并发场景下数据一致性。这些细节写进论文里,比空谈"高并发设计"有说服力得多。
3. 后端核心实现:查票、下单、支付的完整链路解析
3.1 项目结构与统一返回体
后端包结构我是按经典的分层方式组织的:controller / service / mapper / entity / dto / config / common。其中common里放统一返回体Result、业务异常类BizException、全局异常处理器GlobalExceptionHandler。
统一返回体的代码很值得写出来,因为你会发现几乎所有接口都靠它来统一格式:
@Data public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> success(T data) { Result<T> result = new Result<>(); result.setCode(200); result.setMessage("success"); result.setData(data); return result; } public static <T> Result<T> error(String message) { Result<T> result = new Result<>(); result.setCode(500); result.setMessage(message); return result; } }所有 Controller 的返回值都包成 Result,前端 Axios 拦截器统一处理 code 和 message。这套规范虽然简单,但在答辩展示时,接口返回格式的统一性是一个加分项。
3.2 查票接口:城市级查询 + 日期过滤
查票接口的请求参数是departCity、arriveCity、departDate三个。
前端把城市传给后端,后端先查出城市下的所有车站 ID,再进入前面那段关联 SQL 查车次。日期这块需要多说一句:余票和车次是按日期区分的,但车次基础信息(经停站、时间、价格)是每天重复的。我做的时候是用一个train_daily表(车次日期表)来记录某车次在某天是否运营以及当天余票,这样最符合真实逻辑——同一天同一车次可能有多张余票记录。
如果你的项目不想做这个过度设计,有一个偷懒但可解释的方案:直接在内存里预生成近几天的车次快照,用日期字段区分。但这种方案在论文里不容易写清楚,我建议还是按"车次日期表 + train_seat 余票表"来设计,逻辑更干净。
3.3 下单接口:事务与乐观锁守住余票
下单是核心事务,代码如下:
@Transactional(rollbackFor = Exception.class) public Order createOrder(CreateOrderRequest request) { // 1. 查询车次余票 TrainSeat trainSeat = trainSeatMapper.selectById(request.getTrainSeatId()); if (trainSeat == null) { throw new BizException("该席别不存在"); } // 2. 乐观锁扣减余票 int rows = trainSeatMapper.deductRemainCount(trainSeat.getId(), request.getTicketCount()); if (rows == 0) { throw new BizException("余票不足,请重新选择"); } // 3. 生成订单(状态为待支付) Order order = new Order(); // ... 填充订单字段 ... order.setStatus(0); orderMapper.insert(order); return order; }deductRemainCount的核心 SQL 是:
UPDATE train_seat SET remain_count = remain_count - #{ticketCount} WHERE id = #{id} AND remain_count >= #{ticketCount}注意最后一行的remain_count >= #{ticketCount},这是防止超卖的关键。MySQL 的行锁会保证并发情况下只有一个事务能更新成功,另一个事务会阻塞后重新判断余票是否充足。这就是我在论文里写的高并发下的库存防超卖方案——虽然简单,但确实有效且易理解。
3.4 JWT 登录与权限控制
JWT 的引入很常规,但有几个细节容易踩坑:
- 密码必须用 BCrypt 加密存储,不能明文。Spring Security 的
BCryptPasswordEncoder可以直接用。 - 后端生成 JWT 时要设置过期时间,每次请求在拦截器里校验 token 是否过期。
- 需要区分普通用户和管理员接口,通过 JWT 里携带的
role字段来判断,管理员接口加一个@RequireAdmin注解或者专门的管理员拦截器。
JWT 拦截器配置好后,前端只需要在登录后把 token 存在 localStorage,每次请求在 Axios 请求拦截器里塞进 Header。这整条链路是两个模块合作的关键,也最能体现"前后端分离"认证的核心思路。
4. 前端页面与交互:Vue 这边怎么把体验做舒服
4.1 前端工程结构与路由设计
前端我用 Vue CLI 创建项目,目录结构大致是:
src/ api/ # axios 接口封装,按模块分文件 assets/ # 静态资源 components/ # 公共组件(头部、底部、分页等) router/ # 路由配置 store/ # Vuex(或 Pinia,看你用 Vue2 还是 Vue3) views/ # 页面组件 home/ # 首页(查询入口) train/ # 车次列表、车次详情 order/ # 订单确认、支付模拟、订单列表 admin/ # 后台管理 user/ # 登录注册、个人中心路由设计上有一个关键点:需要做权限控制。管理员的页面不允许普通用户访问,但这里有个很容易忽略的问题——Vue Router 的导航守卫只控制前端页面跳转,真正的安全防线还是后端接口的权限校验。我在项目里把两者都做了:前端用beforeEach在路由跳转前判断 token 和 role,后端用拦截器校验管理员接口。
4.2 核心页面:查票结果列表与订单确认
查票首页是用户第一眼看到的东西,布局参考了12306的经典样式:出发城市、到达城市、出发日期三个输入框,一个"查票"按钮。城市输入框我用 Element UI 的el-select+el-option实现,选项数据来自后端车站接口。这里有个性价比很高的体验优化:日期选择器用el-date-picker限制只能选今天及以后的日期,避免用户选过去的日期后还要后端报错提示。
车次结果列表是整站最核心的列表页。每一行显示车次号、出发/到达站名和时刻、历时、各席别余票与票价。余票数量决定按钮状态:有余票显示"预订",没有则显示"无票"并禁用按钮。这个交互逻辑简单但必须做,否则用户点了预订才发现没票,体验很差。
这里涉及计算"历时"的方法,需要用到 JS 的时间计算。注意拿到的是后端返回的开点到点字符串,例如 "08:00" 和 "13:32",要自行转成时间戳再算差值,再格式化回 "05小时32分"。很多同学在这里直接用字符串相减,结果输出一堆奇怪数字。把小函数写好,放在utils/time.js里统一调用,前端到处都能复用。
4.3 请求封装与状态管理
Axios 请求封装是我认为每个前端项目都该做但很多人偷懒不做的事。统一封装后,每个页面里写接口调用会变成这样:
import request from '@/utils/request' export function searchTrains(data) { return request({ url: '/api/train/search', method: 'post', data }) }整个request.js至少要处理三件事:
- 请求拦截器:从 localStorage 拿 token 加到请求头
- 响应拦截器:统一处理业务状态码,非 200 时弹出错误提示(ElMessage)
- 网络错误处理:401 跳转登录页并清理 token,500 统一提示"服务器开小差了"
状态管理这块,如果用的 Vue2 就配 Vuex,Vue3 配 Pinia。毕设里最需要全局管理的其实是用户信息(用户名、头像、角色)和购物车/订单草稿。我的做法是登录成功后把用户信息存进 Vuex,同时持久化到 localStorage,刷新页面后从 localStorage 恢复。订单草稿我直接放在路由传参里了,因为订单确认页数据量不大,不需要全局状态。
5. 部署全流程:从开发环境到正式运行
5.1 后端打包与配置文件调整
本地开发环境跑通后,部署上线又是一个坎。我先讲后端:
SpringBoot 的配置文件需要区分环境,我建了application-dev.yml和application-prod.yml。生产环境的配置必须调整以下几项:
- 数据库连接:把 localhost 改成服务器的实际地址,密码改成强密码
- 端口:默认 8080,如果服务器有多个项目建议改成 8081、8082
- JWT 密钥:不要用默认值,换一个足够长的随机字符串
- MySQL 时区:URL 里加上
serverTimezone=Asia/Shanghai,否则日期字段会差 8 小时
打包命令很简单:
mvn clean package -DskipTests生成target/*.jar后,用nohup java -jar xxx.jar > app.log 2>&1 &在服务器后台启动。启动后先看日志,确认没有报错再用 curl 测一个接口通不通。
这里必须提一个热词里反复出现的经典坑:JDK 版本不匹配。很多同学电脑上装了 JDK 17,SpringBoot 2.7 虽然支持,但如果你用的某些依赖是旧版本编译的,会出现奇怪的编译错误。另外 IDEA 里如果报 "源发行版 17 需要目标发行版 17",通常是因为 project structure 和 settings 里编译器版本没对齐。直接全部统一到 JDK 8 是稳的,这年头 JDK 8 + SpringBoot 2.7 依然是兼容性最好的组合。
5.2 前端构建与 Nginx 反向代理
前端打包前一定要先改接口地址。你在本地开发时,vue.config.js里配置了代理/api指向http://localhost:8080,但打包后没有 devServer 了,所以要把 axios 的 baseURL 改成实际部署地址,或者统一走 Nginx 反向代理。
推荐后一种方案,改动最小:前端构建时不去改任何代码,构建产物dist里的请求路径还是/api/xxx,然后在 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://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }最关键的配置是try_files $uri $uri/ /index.html——这句解决了 Vue Router 的 history 模式刷新 404 问题。不加这行,用户点击浏览器刷新,Nginx 会在磁盘上找你请求的那个路径,找不到就返回 404,加了它就会把所有前端路由请求都指向 index.html,交给 Vue Router 自己处理。
5.3 最容易翻车的三个环境坑
按我带的实际项目经验,部署阶段最容易出问题的就是下面三件事,遇到不要慌:
坑一:跨域。前后端分离项目最常见的报错是Access-Control-Allow-Origin缺失。如果你是用 Nginx 反代方式部署,一般不会碰到跨域(因为前后端域名相同)。但如果你前端是直接npm run serve起的开发服务器、后端是 8080,那前端要通过vue.config.js的 devServer.proxy 转发,或者在后端加 CORS 配置类。二选一即可,不要两边都加。
坑二:MySQL 时区报错。连接数据库时如果报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,说明 MySQL 时区信息不完整。最简单的方法就是在 JDBC URL 末尾加serverTimezone=Asia/Shanghai。换成中文乱码这种就不用说了,characterEncoding=utf8是标配。
坑三:内存不足报java.lang.OutOfMemoryError: Insufficient memory。一般出现在服务器内存较小,或者启动参数没设置的情况下。给 java 进程限制一下堆内存就好:
java -Xms256m -Xmx512m -jar app.jar这个坑特别隐蔽,因为装了一堆开发工具的 Windows 本上可能没事,但 Linux 服务器剩余内存只有 300M 时,Java 默认堆大小可能分配失败。
6. 报告写作与答辩:让评委相信这是你自己做的
6.1 论文/报告的核心章节安排
报告(毕业设计论文)结构其实有很强的套路性。我建议按这个顺序来:摘要 → 绪论(背景与意义、国内外研究现状)→ 需求分析 → 系统设计(架构设计、数据库设计)→ 系统实现(重点章节,截图+核心代码)→ 系统测试 → 总结与展望。
最容易写好的是"系统实现"章节,直接对应代码的实际功能。最容易被导师批的是"需求分析"章节写得像凑字数。这里分享一个技巧:需求分析时不要写空话,直接列功能点清单,用表格列出"功能模块/功能描述/优先级"。比如"车票查询/根据出发城市、到达城市、出发日期查询符合条件的车次列表/高"。表格比大段文字清晰得多,导师看了也知道你确实调研过。
6.2 高频答辩问题与答题思路
答辩我最怕的不是你不会,而是你明明会却被紧张带偏。下面几个问题提前想好答案,基本能覆盖80%的命中率:
"为什么选择这个题目?"答题思路:结合场景。可以说火车票订票系统覆盖了典型的信息管理系统核心业务链路(查询、下单、支付、状态流转),非常适合用前后端分离的方式呈现所学知识。万一被问为什么不用12306的技术,那是两个量级,没有可比性。
"数据表之间是什么关系?"答题思路:直接画 ER 图,讲清楚 user 与 order 是一对多,train 与 train_station 是一对多,train 与 train_seat 是一对多。把这些表如何串起"一次订票流程"用最简洁的话讲清楚。
"如果并发量大怎么办?"答题思路:先说我们是怎么防超卖的(乐观锁 + 数据库行锁),再说如果在更大并发场景下可以怎么演进:引入 Redis 做分布式锁或 Lua 脚本扣减库存、RabbitMQ 做订单削峰、索引优化查询。这里的关键是:把当前实现的完整逻辑讲透,再谦逊地讲升级方案,比上来就背一堆 Redis、MQ 名词更让老师认可。
"项目里你觉得最难的地方是什么?"答题思路:这是一个送分题。不要说什么"都挺简单的",要说出一个确实有难度且你解决了的问题。我通常推荐讲"经停站查询"或者"余票扣减",因为这两个点有清晰的思路变化过程:一开始怎么设计的,遇到了什么问题,后来怎么改的。这个过程本身就是项目最大的亮点。
写在最后的经验
项目做的时间长了,我最大的体会是:这类系统的难点从来都不在某个单个技术上,而在把整条业务链路完整地串起来。数据库设计的经停站拆分、后端接口的分层与事务控制、前端页面的交互状态、部署时的环境配置,每一环都像一个齿轮,咬合在一起才能让系统完整运作。
如果你正在做这个题,我的建议是:第一,先把数据库建好再写代码,改表结构比改代码痛苦十倍;第二,前后端接口文档先列出来,哪怕用 Excel 写一版都要有;第三,留出至少三天做部署和答辩走查,别把全部时间都花在写新功能上。这套流程走下来,拿到一个良好以上的成绩是大概率事件。