每年到毕设季,我都能遇到一堆被“飞机票务管理系统”这类题目卡住的学生。这个题目看起来遍地都是“参考”,但真上手做就会发现:要么代码跑不起来,要么数据库表设计得一塌糊涂,要么答辩时被老师问一句“并发下超卖怎么处理”就愣住。这篇博客我把这个Java方向毕设的全流程拆开讲透——从选题逻辑到数据库设计,从核心代码到避坑排查,尽量说人话,把能直接抄作业的步骤和背后原理都摆出来。
这个系统适合谁?一是做Java课程设计或毕业设计、想选“航空票务/航班管理”方向的学生,二是刚入行想练手Web项目、想知道一个完整管理系统怎么落地的新人。我会用实际开发视角去讲,不整那些虚头巴脑的“技术展望”,只讲怎么把系统做出来,而且做扎实。
1. 选题与需求分析:这个系统真正要解决的是哪几类问题?
1.1 功能需求梳理
飞机票务管理系统的业务复杂度,恰恰是它成为热门毕设题目的原因。它比图书管理系统多了一层“库存”逻辑,又比电商系统简单到可以从头写完整个闭环,非常适合撑起一篇毕设的篇幅。先别急着写代码,把需求列全才是第一步。
站在用户角色看,系统要做到这些事:
- 注册与登录:用户能注册账号、登录系统,修改个人信息。
- 航班查询:按出发城市、到达城市、出发日期查询航班,能看价格、余票、起降时间。
- 在线订票:选定航班后下单,选择乘机人数,生成订单。
- 订单管理:查看我所有的订单,待支付订单可以取消,已出票订单可以申请退票。
- 模拟支付:毕设不需要接真的支付网关,但要在下单和支付之间留出一个“待支付”状态,模拟支付成功后的流程。
管理员端则负责另外一套事务:
- 航班管理:新增航班、修改航班信息、停用航班。
- 航线管理:维护航线基本信息,比如航线编号、起降城市、飞行时长。
- 订单管理:查看所有订单,处理退票申请。
- 用户管理:查看用户列表、禁用违规账号。
- 统计数据:按日期统计出票量、销售额(这是加分项,大部分学生不会做)。
我把这些需求画过一张脑图,后来做数据库设计时就是直接从脑图映射到表的。这里最核心的一点是:订票逻辑不是简单的增删改查,它涉及“库存扣减”和“状态流转”两个业务痛点,这也是答辩时最能体现你水平的点。
1.2 两类角色与核心业务流程
系统至少要有两个角色:普通用户和管理员。权限控制如果做得简单,就用一张用户表加一个 role 字段;如果想让项目看起来更规范,就拆成用户表、角色表、权限表三张,但这会让你在写权限拦截时代码量多不少。
我的建议是:毕设阶段用两张表——用户表 user,管理员不是独立表,而是在同一张表里用字段区分(role=1 是管理员,role=0 是普通用户),登录后用拦截器判断。理由很简单:三表五表的 RBAC 权限模型放到答辩里固然好讲,但需求本身没到那个复杂度,强行上模型反而显得堆砌。
再理一下核心业务流程,这是画用例图、时序图的基础:
text复制用户注册登录 → 搜索航班 → 选择航班 → 填写乘机人信息 → 生成待支付订单 → 模拟支付 → 订单状态更新为已出票 → 行程结束 → 如需退票,乘客发起退票 → 管理员审核 → 退款(只做状态更新)
这个主流程里有一个容易被忽略但其实很关键的点:订单生成时就要扣减航班余票,而不是支付成功后才扣。如果支付后才扣,那下单但没支付的乘客就把库存占住了,超卖风险极高;如果下单时就扣,又可能出现恶意下单不支付导致余票被锁死的情况。所以合理的做法是,下单时扣减余票、同时给订单设定一个支付有效期,到期未支付则自动取消并回补余票。这个逻辑讲明白,你的系统就已经超过一半的同题选手了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 技术选型的取舍逻辑:Spring Boot 还是 Servlet/JSP?
2.1 三套常见技术路线对比
这个题目网上能找到的技术方案主要就三种,我直接说结论:首推 Spring Boot + MyBatis + MySQL + 前端模板引擎,技术新、结构清晰、答辩也好解释。
| 方案 | 技术组合 | 适合场景 | 缺点 |
|---|---|---|---|
| 方案 A | JSP + Servlet + JDBC | 课程设计 | 代码耦合高,前后端不分,维护麻烦 |
| 方案 B | Spring Boot + MyBatis + Thymeleaf | 毕业设计主推 | 需要理解依赖注入、自动配置等概念 |
| 方案 C | Spring Boot + Vue 前后端分离 | 基础好、想冲优秀毕设 | 工作量大,后端还要写接口文档 |
方案 B 是我这几年看到最稳妥的选择。它不会像方案 C 那样让你在 Vue、Axios、跨域问题上耗费大量时间,又能甩开方案 A 那种十年前的写法,让答辩老师觉得你的技术体系是新且完整的。
2.2 Spring Boot 核心组件的作用与协作流程
用 Spring Boot 做这个项目,你至少要理解它这几样东西怎么配合:Controller 接收前端的请求,Service 层写业务逻辑,Mapper(或 Repository)负责和数据库打交道。数据流向是:页面提交 → Controller → Service → Mapper → MySQL。反过来,查完数据再从 Mapper → Service → Controller → 页面渲染。这个链路图最好刻在脑子里,面试或者答辩被问到“请求流程是什么”的时候,闭着眼都能画出来。
Spring Boot 的自动配置是它的杀器。比如用 MyBatis 时,你需要引入 mybatis-spring-boot-starter 依赖,在 application.yml 里配置数据源和 XML 映射文件的位置。这本身不难,但很多学生栽在 Maven 依赖跑到一半失败的问题上。所以我的建议是:Maven 仓库镜像一定要配阿里云镜像,否则你下载依赖的耐心会在前五分钟内消耗光。
2.3 前端方案与页面结构
不要一上来就想着上 Vue。这个毕设用 Thymeleaf 模板引擎配合 Bootstrap 就够了,Thymeleaf 能让 Java 对象直接渲染到 HTML 里,不用像 JSP 那样写一堆 <% %>,页面写起来干净得多。
页面结构大致分为:
- 前台(用户端):首页、航班查询列表页、航班详情页、下单页、订单列表页、个人中心页。
- 后台(管理端):登录页、控制台首页、航班管理页、订单管理页、用户管理页、统计页。
前台用 Bootstrap 做一个顶部导航栏,左侧是条件查询面板,右侧是航班列表表格。实现上就是一个 HTML 页面里用 Thymeleaf 的 th:each 循环渲染航班列表,点“预订”按钮跳转到下单页。别小看这个交互,它涉及多表联查、状态传递、表单回显,做完这几个页面,你对 Java Web 的理解会上一个台阶。
3. 数据库设计:围绕“余票”与“订单状态”做核心建模
3.1 五张核心表的设计思路
先给出这五张表,这是我反复改过多次之后沉淀下来的最佳结构:
用户表 t_user
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| username | varchar(50) | 用户名,唯一 |
| password | varchar(100) | 盐值加密后的密码 |
| real_name | varchar(50) | 真实姓名 |
| id_card | varchar(20) | 身份证号 |
| phone | varchar(20) | 手机号 |
| role | tinyint | 0用户,1管理员 |
| status | tinyint | 1正常,0禁用 |
| create_time | datetime | 注册时间 |
航班表 t_flight
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| flight_no | varchar(20) | 航班号,如 CA1234 |
| airline | varchar(50) | 航空公司 |
| depart_city | varchar(50) | 出发城市 |
| arrive_city | varchar(50) | 到达城市 |
| depart_time | datetime | 起飞时间 |
| arrive_time | datetime | 到达时间 |
| aircraft_type | varchar(50) | 机型,如空客A320 |
| seat_count | int | 总座位数 |
| available_seats | int | 当前余票数 |
| ticket_price | decimal(10,2) | 票价 |
| status | tinyint | 1正常,0停飞 |
这里我要强调一个容易做错的地方:余票字段直接放在航班表上,不要单独建一个“座位明细表”存每一个座位。座位明细表看起来很精细,但会在下单时复杂度爆炸:选座、锁定、释放、连座分配……每一项都要额外处理。毕设的核心是走通业务闭环,不是模拟航空公司的 PNR 系统,余票用一个整数表示是目前最合理的做法。
订单表 t_order
| 字段 | 类型 | 说明 |
|---|---|---|
| id | bigint | 主键 |
| order_no | varchar(50) | 订单号,全局唯一 |
| user_id | bigint | 下单用户 |
| flight_id | bigint | 关联航班 |
| passenger_name | varchar(50) | 乘机人姓名 |
| passenger_id_card | varchar(20) | 乘机人身份证号 |
| ticket_num | int | 订票数量 |
| total_price | decimal(10,2) | 总价 |
| status | tinyint | 0待支付,1已出票,2已取消,3已退票,4审核中 |
| create_time | datetime | 下单时间 |
| pay_time | datetime | 支付时间 |
订单表里直接用 passenger_name 和 passenger_id_card 这样两个冗余字段,有人可能觉得不符合“三大范式”,但在这个场景里是对的。查询维度是“订单”,不是“乘客”,乘客信息只是订单的附属快照,拆出去反而要多一次联查。
还有四张表可以按需补充:航线表(航班和航线的多对一)、座位等级表(经济舱/商务舱两种价格)、公告表(管理员发通知)、操作日志表(加分项)。
3.2 订单状态机设计:避免状态混乱的关键
订单状态是票务系统里最容易出现逻辑漏洞的地方,我强烈建议你在设计阶段就把状态流转画出来,而不是边写代码边想。
text复制状态 0:待支付 → 用户点击支付 → 状态 1:已出票
状态 0:待支付 → 用户取消 或 超时未支付 → 状态 2:已取消
状态 1:已出票 → 用户申请退票 → 状态 4:退票审核中
状态 4:退票审核中 → 管理员同意 → 状态 3:已退票
状态 4:退票审核中 → 管理员拒绝 → 状态 1:已出票
这个状态机里最容易被忽略的是“待支付→超时取消”。我当时实现了一个定时任务,每隔五分钟扫描一次数据库里 create_time 超过30分钟且状态仍为0的订单,把它们改成已取消,同时 UPDATE t_flight SET available_seats = available_seats + ticket_num 回补余票。用一个 @Scheduled 注解就能搞定,但这个细节很多人没做,恰恰是这类题的加分点。
3.3 订单号生成:不要用数据库自增主键直接展示
订单表的 order_no 字段我建议单列出来,不要直接用主键 id 展示给用户。原因是主键自增会暴露平台订单量,而且用户在输入订单号查询时也不方便。最常用的做法是:年月日时分秒 + 用户ID后四位 + 随机数,例如 20240521153000123456。生成逻辑放在 Service 层,写成工具方法,确保唯一性时再加上数据库的唯一索引兜底。
4. 步入核心代码链路:登录、查询、下单、状态处理
4.1 登录鉴权与密码安全
登录几乎是所有系统都要做的模块。很多人直接用明文密码,这到答辩时就是硬伤。我用的是 MD5 加盐方案,虽然现在更推荐 BCrypt,但 MD5 + 盐对毕设来说已经够讲、够实现。你只需要在用户注册时生成一个随机盐值,然后加密存储:
java复制public class Md5Util {
public static String encrypt(String password, String salt) {
String raw = password + salt;
for (int i = 0; i < 3; i++) {
raw = DigestUtils.md5DigestAsHex(raw.getBytes());
}
return raw;
}
}
注册时把盐值存到用户的 salt 字段里,登录时取出盐值,把输入密码做相同加密再比对。这段代码大家基本能看懂,而且答辩时老师会因为你考虑了“彩虹表”问题而点头。
登录成功后,我用 Spring Boot 的拦截器(HandleInterceptor)把用户 ID 存进去并校验 Session,没有登录就跳转到登录页。写一个拦截器类,注册到 WebMvcConfigurer 里,排除掉登录接口、静态资源和注册接口即可。这一步很基础,但很多人会忘掉拦截器的“白名单”配置,导致页面一跳转就 404。
4.2 航班查询的条件拼装
航班查询有个实用技巧:不是每个查询条件都是必填的。用户可能只填出发城市,不填到达城市,也可能什么都不填直接点查询,所以 SQL 要用动态拼接。MyBatis 的 <where> 标签正好解决这个问题:
xml复制<select id="searchFlights" resultType="com.example.entity.Flight">
SELECT * FROM t_flight
<where>
<if test="departCity != null and departCity != ''">
AND depart_city LIKE CONCAT('%', #{departCity}, '%')
</if>
<if test="arriveCity != null and arriveCity != ''">
AND arrive_city LIKE CONCAT('%', #{arriveCity}, '%')
</if>
<if test="departDate != null">
AND DATE_FORMAT(depart_time, '%Y-%m-%d') = #{departDate}
</if>
AND status = 1
</where>
ORDER BY depart_time
</select>
注意我把 status = 1 直接放在 <where> 的末尾固定条件里,这样就确保查询结果里不会有已经停飞的航班。如果用户选了日期,我这里用 DATE_FORMAT 按天匹配,虽然在大数据量下会影响索引命中,但毕设的数据量完全不用纠结。
4.3 下单扣减余票的并发控制(重点中的重点)
这是整个系统最值得展开写的一点,也是答辩被问得最多的一个点。假设用户 A 和用户 B 同时下单买同一航班的最后一张票,如果代码是这样的:
java复制Flight flight = flightDao.findById(flightId);
if (flight.getAvailableSeats() > 0) {
// 扣减余票
flightDao.updateSeats(flightId);
// 生成订单
orderDao.insert(order);
}
两个请求都同时通过了 availableSeats > 0 判断,但数据库只执行了一次扣减,结果就会超卖。正确的做法是用数据库行锁加条件更新双重保险:
java复制@Transactional
public void createOrder(OrderCreateRequest req) {
// 1. 悲观锁锁住航班行
Flight flight = flightDao.selectByIdForUpdate(req.getFlightId());
if (flight.getAvailableSeats() < req.getTicketNum()) {
throw new BusinessException("余票不足");
}
// 2. 条件更新扣减余票
int rows = flightDao.decreaseSeats(req.getFlightId(), req.getTicketNum(), flight.getAvailableSeats());
if (rows == 0) {
throw new BusinessException("余票不足,请刷新重试");
}
// 3. 生成订单
...
}
对应的 Mapper 方法:
java复制@Update("UPDATE t_flight SET available_seats = available_seats - #{num} " +
"WHERE id = #{flightId} AND available_seats >= #{num}")
int decreaseSeats(@Param("flightId") Long flightId, @Param("num") Integer num);
SELECT ... FOR UPDATE 把这一行锁住了,后进入的事务只能等待;AND available_seats >= #{num} 又兜了一层底,哪怕前一步判断出错,条件更新返回 0 也能拦住。记住:查出来判断再更新是典型的并发地雷,判断和更新必须放进同一个原子操作里,或者都用数据库锁包住。
4.4 模拟支付与状态回写
支付功能不要接真网关,但流程必须完整。下单成功后订单是“待支付”,跳转到模拟支付页面,页面上显示应付金额和一个“确认支付”按钮。点击后把订单状态更新为“已出票”,记录支付时间。
java复制@Transactional
public void payOrder(String orderNo) {
Order order = orderDao.findByOrderNo(orderNo);
if (order == null || order.getStatus() != 0) {
throw new BusinessException("订单状态异常");
}
orderDao.updateStatus(orderNo, 1, new Date());
}
这里为什么还要查一次订单状态?因为要防重复支付。如果用户双击“确认支付”按钮,两个请求同时进来,没有状态校验的话订单状态可能被更新两次。虽然最终结果不算严重,但日志会不好看。用状态做乐观锁的兜底永远不亏:
java复制@Update("UPDATE t_order SET status = 1, pay_time = #{payTime} " +
"WHERE order_no = #{orderNo} AND status = 0")
int payOrder(@Param("orderNo") String orderNo, @Param("payTime") Date payTime);
只要更新行数是 1,说明支付成功;是 0,说明这个订单已经支付过或者状态不对,直接抛异常。
5. 真实翻车记录:环境配置、中文乱码、并发超卖的排查链路
5.1 环境配置最耗时的不是写代码,而是 JDK 和 Maven 的“第一次见面”
很多学生第一周都在配环境。我遇到过最典型的问题有:JDK 8 和 JDK 17 版本混淆、Maven 下载依赖卡在中央仓库几十个小时、Tomcat 端口被占用、IDEA 的 Lombok 插件版本不匹配导致 log 变量找不到编译失败。热搜关键词里面有一堆关于“java环境变量配置”“java安装”的搜索记录,说明新手阶段折腾环境是普遍痛点。
我的建议是毕设统一用 JDK 8 + Maven 3.6.3 + Spring Boot 2.7.x 组合。为什么不用 Spring Boot 3?因为 Spring Boot 3 强制要求 JDK 17,而很多跟着视频学的教程还是 JDK 8 语法,两者共存很容易出问题。选 JDK 8 不是说它新,而是它的生态资料最多、踩坑成本最低。环境变量配置时的要点是 JAVA_HOME 一定指向 JDK 安装目录,而不是 bin 目录;Path 里添加 %JAVA_HOME%\bin;配完在命令行执行 java -version 验证。
Maven 的 settings.xml 里换上阿里云镜像仓库是国内项目最需要做的一件事,不换的话,新拉一个 Spring Boot 项目光下载依赖都能卡半小时。这是我带项目时永远放在第一节课讲的。
5.2 中文乱码问题的两条排查路径
乱码这个问题,多数时候不在 Java 代码,而在连接层的字符集配置。我曾经接锅过的项目,页面新增航班后中文变问号,排查了半天发现是 MySQL 数据库安装时字符集默认是 latin1。查询方式:
sql复制SHOW VARIABLES LIKE 'character_set%';
要确保结果是 utf8mb4。另外还要检查两点,一是建库语句的编码,二是连接串。Spring Boot 的 application.yml 里连接串配置:
yaml复制spring:
datasource:
url: jdbc:mysql://localhost:3306/flight_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
如果数据库、连接串、响应头三处都设了 utf8 还是乱码,那就从 Tomcat 层面做检查,在 IDEA 的运行配置里加上 -Dfile.encoding=UTF-8。排查链路一定是“页面请求 → Servlet 过滤器 → 数据库存取”逐步缩圈,不要上来就改代码。
5.3 并发超卖问题:如何复现,如何证明你解决好了
“超卖”听起来是个概念,但你得在项目里真正复现一次才能理解得深。我当时用两个浏览器窗口模拟并发请求:先往数据库里插一条只有 1 张余票的航班,再在不同浏览器里同时提交购买 1 张票的订单。不改代码之前,结果是两个订单都成功,余票变成 -1;改完之后,第二个请求直接弹“余票不足”。
更规范的验证方式是用压测工具,但你也可以在代码里用一个循环模拟多线程并发。我当时的简单复现方式是写一个带 CountDownLatch 的测试类,让 10 个线程同时抢剩余 5 张的航班,看看最终能生成几单。
java复制ExecutorService pool = Executors.newFixedThreadPool(10);
CountDownLatch latch = new CountDownLatch(1);
for (int i = 0; i < 10; i++) {
pool.execute(() -> {
latch.await();
try {
orderService.createOrder(cRequest);
} catch (Exception e) {
System.out.println("失败: " + e.getMessage());
}
});
}
latch.countDown();
测试结束后一定要检查两件事:订单数能否大于5、余票数是不是0。毕设报告里把这个测试过程和前后结果对比放进去,很有说服力。
6. 答辩亮点与项目扩展方向
6.1 答辩时可以深讲的四个技术点
如果让我挑答辩 PPT 里最值得放大的内容,我会选这四个:
- 并发下的库存扣减方案:讲清楚为什么
if + update不行,如何用悲观锁与条件更新兜底。 - 订单状态机:把状态流转图画出来,说明每个状态由谁触发,超时取消是怎么实现的。
- 密码存储方案:MD5 加盐、三次摘要的目的,以及为什么不能明文存储。
- 动态 SQL 与多条件查询:MyBatis
<where>标签的代码和空值判断逻辑。
这四点能体现的不只是“会写增删改查”,而是对业务复杂性有意识。老师问完这几个问题基本就不会再刁难别的。
6.2 可以继续深挖的方向
如果时间充裕或者想冲“优秀毕设”,可以考虑这些方向:把定时任务从单机 Schedule 改成分布式;引入 Redis 做航班热点缓存和分布式锁;用 ECharts 给管理员端加一个出票量折线图和票价分布饼图;给下单流程增加短信通知的模拟接口;甚至可以做一个小程序端。这里每一个方向都比单纯加一个 CRUD 页面更有技术含量,写论文时扩展点也更充足。
另外,项目的 README 一定要写好。我见过太多学生答辩现场不会操作,因为项目启动方式、数据库脚本、账号密码全部没有读我文档。README 至少包含三块:运行环境、数据库初始化步骤、默认管理员密码账号。这不是面子工程,是能帮你减少大量现场事故的兜底措施。
一些个人体会
带过的毕设项目里,做票务管理系统的不少,但真正能让我眼前一亮的人,几乎都有一个共同点:不满足于“能运行”,而是追着问题往下问一层。比如下单时为什么要在事务里同时处理航班表的锁和订单表的插入;比如状态机的每一种状态变化是否都有对应的实现;比如出现异常时事务能不能正确回滚。这些问题一开始可能想不明白,但只要项目是自己一行一行敲的,踩过几个坑再看这些概念,会比背十篇八股文都管用。最后说一句实在的:代码跑通只算起步,把里面的业务逻辑讲明白,才算真正把毕设做完了。
