毕业设计选了航班管理系统这个题目说实话这个选题在SpringBoot毕设里算标准款既没有惊艳到让评委眼前一亮也没有冷门到让人无从下手。但这恰恰是它的优势——业务链路完整、需求边界清晰、技术点能撑得住答辩追问上限和下限都掌握在你自己手里。我做完这个基于SpringBoot的航空客运服务平台之后最大的感受是题目越常规越要往深处挖把并发、事务、权限这些环节做出真东西来。这篇就围绕我的完整开发过程从选题逻辑、技术选型、数据库设计到核心代码和踩坑记录给准备做同类项目的同学一份可以照着走的参考。1. 选题为什么选它一个问不倒的毕设题目长什么样毕业设计答辩有个残酷的现实评委问你的问题大多不是围绕你用什么技术而是围绕你为什么用这个技术业务上这个逻辑怎么兜底展开。一个题目能不能抗住追问比它听起来是否高大上重要得多。航班管理系统正好属于那种业务链条完整、每个环节都有真实问题可以深挖的题目。1.1 核心价值一条完整业务闭环带来的天然优势航班管理系统从用户端看是一条清晰的交易链路注册登录、航班查询、下单购票、订单查看。从管理端看又是一条完整的数据维护链路航班信息管理、舱位价格配置、订单处理、数据统计。这看起来简单但真正动手设计的时候你会发现它天然包含了几个非常值得展开的技术点航班余票的并发扣减要防超卖、订单状态要防重复提交、航班信息变更后已有订单如何处理、多角色权限如何控制。这些问题随便抓一个出来都能在答辩时讲上三五分钟而且每个问题都有实际业务场景做支撑评委一听就知道你不是背的八股文。1.2 系统角色与功能边界别一上来就想做大而全我在开始编码之前花了整整两天梳理需求最后把功能边界定成下面这样供你参考用户端旅客角色注册与登录基于JWT的无状态认证支持密码加密存储航班查询按出发城市、到达城市、出发日期组合检索支持按时间/价格排序在线购票选择航班、填写乘客信息、确认下单、模拟支付出票订单管理查看订单状态、取消未出票订单管理端管理员角色航班管理航班的增删改查、上下架启停售航线管理维护起降城市对与航班周期订单管理按条件检索全部订单处理异常订单数据概览按日/按航线统计订单量与基础营收数据这里有个建议退改签、会员积分、舱位等级矩阵这类功能除非你的开题报告里明确写了否则放到后续扩展里提一嘴就够了不要在V1.0版本里强行做。毕业设计的核心是把一个闭环做扎实不是把功能列得多功能太多反而每个都浅答辩时容易被问到细节就露馅。1.3 非功能需求决定系统质量的那20%功能性需求能让系统跑起来但真正体现工作量的是非功能需求。我在这套系统里重点做了三个方向第一是数据一致性。购票时余票扣减必须和订单生成放到同一个事务里而且要用行级锁防止并发超卖这是整个系统里技术含量最高的地方也是我答辩时被追问最多的点。第二是接口安全性。管理端接口必须做权限校验普通用户不能访问管理员接口。我通过拦截器统一校验JWT中的角色字段再配合自定义注解做细粒度控制。第三是可维护性。数据访问层统一用MyBatis Plus业务逻辑全部下沉到Service层Controller只做参数接收和响应封装保证代码结构是看起来能让别人接手的水平。2. 技术选型SpringBoot之外还要配什么技术选型是毕设里最容易无脑选热门但最值得讲清楚的部分。我最终的技术栈是SpringBoot、MyBatis Plus、MySQL、Redis、JWT和VueElement UI下面逐一说选择理由。2.1 SpringBoot本身解决了什么问题SpringBoot对Spring的贡献是零配置启动——它通过自动配置和起步依赖把过去Spring项目里繁琐的XML配置和组件整合全部简化成了引入一个依赖加几行配置。我做这个系统用的是当前稳定的Spring Boot 2.7.x版本最直接的好处是内嵌了Tomcat本地开发不需要单独装容器打成JAR包扔到服务器上就能跑。更重要的是SpringBoot生态对后续业务扩展是友好的。比如后期我想加个消息通知功能直接引入Spring Boot的Mail或WebSocket starter就能无缝集成。这种扩展性对于毕设来说意味着就算你答辩时说要加功能也不是推倒重来而是在现有骨架上加模块。2.2 辅助技术组件的选型逻辑我整理一下这套系统里其它技术的选型图谱技术组件用途为什么选它而非备选项MyBatis PlusORM数据持久层内置分页插件、条件构造器代码量比原生MyBatis少很多比JPA的实体关系更好理解MySQL主数据库数据量级在毕设范围内远未达到瓶颈运维简单资料多出问题容易查Redis分布式缓存缓存热点航班查询结果同时可辅助实现接口幂等。备选方案是Caffeine本地缓存但Redis能讲出缓存一致性的深度JWT用户认证无状态服务器不需要维护会话前后端分离时天然适用。备选项是SessionRedis但JWT能讲清楚为什么不用SessionVue 2 Element UI管理端前端前后端分离展示工程化能力Element UI表格和表单组件能快速出页面Hutool工具类库省去写日期处理、随机数、Bean拷贝等重复代码主打一个效率这里特别说下Redis。有个容易被忽略的点我除了拿它做缓存还利用Redis的SETNX命令做了一个防重复提单的幂等键。用户在提交订单时前端会生成一个唯一的requestId后端收到后先尝试在Redis里写入这个key写不进去说明是重复请求直接拦截。这个设计答辩时讲出来很有亮点而且实现成本很低。2.3 关于前后端分离和非分离的取舍我见过不少同学毕设用的是SprinBoot Thymeleaf模板渲染让Java直接渲染页面。这种做法省去了跨域、联调这些麻烦但我最后还是选了前后端分离理由是现实中企业项目基本都走前后端分离毕设期间提前把跨域处理、AJAX交互、接口联调这套流程走一遍面试时能拿出来说的项目经验会扎实很多。代价就是前端需要单独维护一套Vue工程开发周期多花大概三到五天个人觉得值。3. 数据库设计三张核心表如何撑起整个系统数据库设计是整个系统里最不能赶工的部分。我一开始直接用Navicat手工建表建到订单表的时候发现字段怎么安排都有冗余感后来老老实实画了ER图重新拆了一遍。最终核心表是五张用户表、航班表、航线表、订单表、乘客表。这里重点讲三张核心表的设计思路。3.1 航班信息表冗余字段的取舍很关键航班表是查询的主表也是余票库存的载体。我的建表语句核心部分如下CREATE TABLE flight ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键ID, flight_no VARCHAR(20) NOT NULL COMMENT 航班号, route_id BIGINT NOT NULL COMMENT 航线ID, departure_city VARCHAR(30) NOT NULL COMMENT 出发城市, arrival_city VARCHAR(30) NOT NULL COMMENT 到达城市, departure_time DATETIME NOT NULL COMMENT 计划起飞时间, arrival_time DATETIME NOT NULL COMMENT 计划到达时间, aircraft_type VARCHAR(50) DEFAULT NULL COMMENT 机型, total_seats INT NOT NULL COMMENT 总座位数, remaining_seats INT NOT NULL COMMENT 剩余座位数, price DECIMAL(10,2) NOT NULL COMMENT 经济舱价格, status TINYINT NOT NULL DEFAULT 1 COMMENT 状态1可售 0停售, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_departure_city (departure_city), KEY idx_departure_date (departure_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT航班信息表;这里有个设计决定想重点说下我把出发城市、到达城市直接冗余到了航班表里同时保留了route_id指向航线表。为什么这么做因为用户查询航班时是按照起降城市来筛选的如果只存route_id每次查询都得多一次关联。冗余城市字段用空间换了查询性能这是很典型的报表/交易类系统设计习惯。代价是如果城市名称变更需要同步更新航班表但这种变更在业务上极少发生可以接受。另一个关键点是remaining_seats直接放在航班表上而不是单独一张库存表。这样做在复杂度上是够用且低耦合的配合SQL层的行锁可以很好地解决超卖问题。如果你把库存拆到单独表表面上更规范但在下单时要同时维护两张表的数据一致性毕设级别的系统没必要冒这个险。3.2 用户表与订单表状态机设计决定业务边界用户表没什么特殊的重点是密码用BCrypt加密存储加上username唯一索引和role字段区分管理员与普通用户。订单表就比较讲究了它的核心是状态设计和唯一性约束CREATE TABLE orders ( id BIGINT NOT NULL AUTO_INCREMENT, order_no VARCHAR(32) NOT NULL COMMENT 订单编号, user_id BIGINT NOT NULL COMMENT 下单用户ID, flight_id BIGINT NOT NULL COMMENT 航班ID, flight_no VARCHAR(20) NOT NULL COMMENT 航班号冗余, passenger_id BIGINT NOT NULL COMMENT 乘机人ID, seat_count INT NOT NULL DEFAULT 1 COMMENT 购票数量, total_price DECIMAL(10,2) NOT NULL COMMENT 订单总价, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付 2已出票 3已取消 4已退票, remark VARCHAR(255) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id), KEY idx_flight_id (flight_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT订单表;订单编号我采用的生成规则是日期yyyyMMdd 用户ID后四位 随机流水号例如2025061212345678。用日期前缀的好处是排查问题时能一眼看出订单发生的时间而且带唯一索引后天然防止重复下单。这里不建议直接用数据库自增ID当作订单号对外展示一是暴露系统订单总量二是并发下ID可猜测性太高这属于安全习惯的训练。订单状态我用TINYINT数字枚举0待支付、1已支付、2已出票、3已取消、4已退票。状态流转必须是单向的待支付可以变为已支付或已取消已支付可以变为已出票或已退票已出票之后只能变为已退票。这种状态机约束不能只靠前端隐藏按钮必须在Service层写状态校验逻辑比如只有待支付订单才能取消否则脏状态会产生连锁问题。我在代码里统一封装了一个OrderStatusEnum枚举类Service里所有状态流转都通过枚举判断这个习惯强烈建议养成。3.3 乘客表和航线表两张辅助表怎么简化逻辑乘客表用于记录常用乘机人信息姓名、证件号、手机号属于订单的依赖数据。航线表则维护城市对信息比如北京-上海是一条航线某条航线每天执行几个班次对应到多张航班表记录。真正的查询主表还是航班表航线表在管理端维护航班时做辅助。把这两张表单独拆出来其实主要是为了让管理端的操作逻辑更清晰避免所有城市信息都塞在航班表里导致难以维护。4. 核心业务落地从航班查询到购票出票的完整链路数据库定稿后就开始写业务代码。整个系统的核心模块可以分成四块航班查询、购票流程、管理端航班维护、登录认证和权限控制。下面按模块讲实现思路和关键代码。4.1 航班查询多条件组合筛选与排序航班查询是系统里的高频接口用户的典型操作是选定出发城市、到达城市和日期再从结果里按价格或时间排序。如果直接用SQL拼接条件一多代码就开始发散。MyBatis Plus的条件构造器在这里非常顺手Override public PageResultFlightVO queryFlights(FlightQueryDTO dto) { LambdaQueryWrapperFlight wrapper new LambdaQueryWrapper(); // 支持出发城市为空时查询全部 if (StrUtil.isNotBlank(dto.getDepartureCity())) { wrapper.eq(Flight::getDepartureCity, dto.getDepartureCity()); } if (StrUtil.isNotBlank(dto.getArrivalCity())) { wrapper.eq(Flight::getArrivalCity, dto.getArrivalCity()); } // 日期范围从当天00:00:00到23:59:59 if (dto.getDepartureDate() ! null) { wrapper.between(Flight::getDepartureTime, DateUtil.beginOfDay(dto.getDepartureDate()), DateUtil.endOfDay(dto.getDepartureDate())); } wrapper.eq(Flight::getStatus, 1); // 只查可售航班 // 排序规则默认起飞时间升序可切换为价格升序 if (price.equals(dto.getSort())) { wrapper.orderByAsc(Flight::getPrice); } else { wrapper.orderByAsc(Flight::getDepartureTime); } PageFlight page new Page(dto.getPageNum(), dto.getPageSize()); flightMapper.selectPage(page, wrapper); return PageResult.of(page); }这套代码的核心便利点是LambdaQueryWrapper不会把列名硬编码成字符串字段经过编译期校验重构时不容易出错。日期范围用DateUtil转换起始和结束时间点可以避免用户在凌晨时段查询时数据少算一天的问题。这里还有个体验细节如果出发城市和到达城市相同直接在Service层拦截返回空页不做数据库查询省一次无意义的IO。4.2 购票流程事务边界与库存扣减购票是整套系统里最需要严谨的环节。它的业务链条是校验航班存在且可售、校验余票足够、锁定航班的行记录、再次校验余票、扣减余票、创建订单待支付、模拟支付成功后更新订单状态为已支付并出票。如果任意一步失败整个操作回滚。这里最关键的技术决策是用悲观锁。我直接在SQL层面使用SELECT ... FOR UPDATE锁定航班行保证同一时刻只有一个事务能修改该航班的余票Transactional(rollbackFor Exception.class) public OrderVO createOrder(OrderCreateDTO dto) { // 1. 前置校验 Flight flight flightMapper.selectById(dto.getFlightId()); if (flight null || flight.getStatus() ! 1) { throw new BizException(航班不存在或已停售); } // 2. 锁定航班行 Flight lockedFlight flightMapper.selectByIdForUpdate(dto.getFlightId()); if (lockedFlight.getRemainingSeats() dto.getSeatCount()) { throw new BizException(余票不足); } // 3. 扣减余票并生成订单 flightMapper.decrementSeats(dto.getFlightId(), dto.getSeatCount()); Order order buildOrder(lockedFlight, dto); orderMapper.insert(order); // 4. 模拟支付实际项目这里是调用支付网关 boolean paySuccess mockPay(order.getOrderNo()); if (!paySuccess) { throw new BizException(支付失败订单已回滚); } return OrderVO.from(order); }对应的Mapper方法Select(SELECT * FROM flight WHERE id #{id} FOR UPDATE) Flight selectByIdForUpdate(Long id); Update(UPDATE flight SET remaining_seats remaining_seats - #{count} WHERE id #{id} AND remaining_seats #{count}) int decrementSeats(Long id, Integer count);很多人会问为什么不用乐观锁version字段乐观锁在竞态不激烈时性能更好而且UPDATE带条件判断天然防超卖。但在商品抢购场景下乐观锁会频繁重试或直接放弃更新用户体验差悲观锁虽然串行化但在航班购票这种写多读少的场景里更可控。毕设答辩时把这两种方案的取舍讲清楚本身就是加分的亮点。Transactional(rollbackFor Exception.class)这个注解也很重要。如果不显式指定rollbackForSpring默认只在RuntimeException时回滚遇到检查异常不会回滚很容易出现支付失败但订单还在的诡异状态。这是事务边界最容易踩的坑后面我会单独展开。4.3 管理端航班维护与订单处理管理端航班维护就是标准的CRUD但有几个细节要注意。新建航班时要校验航班号不能重复同一航线同一时段不能出现两个班次冲突。停售航班时还需要检查是否存有待支付订单如果有应该提示管理员先把订单处理掉否则用户支付成功后突然发现航班停飞体验会很糟糕。订单处理模块主要是按条件查询和状态修改。这里推荐一个做法管理端的查询尽可能使用单独写SQL的Mapper方式不要复用用户端的查询逻辑。管理端往往需要多表关联比如查订单时关联出用户昵称、航班座舱信息直接用Select写联表SQL比用Wrapper硬拼清晰得多。系统整理功能的统计概览我用了SELECT聚合加日期分组返回给前端渲染趋势图这部分代码量不大但视觉效果好尤其适合在答辩PPT里展示。4.4 登录认证与权限控制JWT加拦截器的组合方案前后端分离项目里Session不太好使我选择的方案是JWT。用户登录成功后后端生成一个包含userId和role的Token返回给前端前端后续所有请求在HTTP头里带上Authorization: Bearer token。后端通过拦截器统一解析校验。Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 放行登录接口和静态资源 if (request.getRequestURI().contains(/api/auth/login) || request.getRequestURI().contains(/api/auth/register)) { return true; } String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { throw new BizException(未登录或Token缺失); } // 验证并解析Token Claims claims JwtUtil.parseToken(token.substring(7)); Long userId claims.get(userId, Long.class); Integer role claims.get(role, Integer.class); // 保存到请求上下文供Controller直接获取当前用户 UserContext.set(new LoginUser(userId, role)); if (request.getRequestURI().contains(/api/admin/) role ! 1) { throw new BizException(无管理员权限); } return true; } Override public void afterCompletion(...) { UserContext.clear(); } }JWT方案带来的好处是服务端无状态水平扩展时不需要共享会话信息这在分布式场景里的价值能直接说出来。缺点是Token有效期管理比Session复杂比如用户被禁用后Token依然有效直到过期。我在系统里给Token设了24小时过期时间原因是毕设答辩演示时不想频繁登出登录这个环节浪费时间。另外权限校验光写在拦截器里还是不够细我额外给管理端的一些写操作加了自定义注解RequireAdmin在Controller方法上声明后由AOP切面统一拦截。这个设计可能比拦截器更优雅一点给读者留个进阶方向。5. 联调与部署阶段踩过的坑写代码的过程还算顺利真正让我头皮发麻的是联调和测试阶段。这些坑单靠看文档是避不开的写出来希望对你有帮助。5.1 时间字段莫名差了8个小时第一次联调时我在前端页面上看到航班起降时间全都比数据库里存的少了8小时。排查到末尾发现是JSON序列化的时区问题。Jackson默认把LocalDateTime序列化时使用的时间源是UTC而MySQL驱动里存的又是东八区时间两边一折算就偏了。解决办法是在application.yml里配置spring: jackson: time-zone: Asia/Shanghai date-format: yyyy-MM-dd HH:mm:ss同时数据库连接串上加上serverTimezoneAsia/Shanghai。这应该是所有SpringBoot项目的标配配置但几乎每个新手都会踩一遍。建议你在建项目的第一步就把这两个配置配上后面能省很多排查时间。5.2 第一轮并发压测超卖问题当场暴露我用JMeter模拟100个并发用户抢同一航班的最后10张余票结果发现订单表里生成了12条记录库存变成了负数。当时第一反应是加锁但后来又琢磨出真正的根因不止一个。第一个问题是扣减SQL没有带余票充足的条件。如果只写SET remaining_seats remaining_seats - #{count}那就算有锁多线程串行执行时也可能出现负数扣减。所以我改成了UPDATE ... WHERE id #{id} AND remaining_seats #{count}让数据库在更新时做第二层校验。第二个问题是有些操作没有加事务。比如说先查余票、判断充足、再扣减这三步不在同一个事务里中间时刻可能有别的请求插入修改。所以套路必须统一先锁行FOR UPDATE、再判断、再修改整个过程必须在一个事务内完成。改完之后再压测数据就完全对得上了。5.3 try-catch 把事务回滚给吞了这个坑是测试退票流程时发现的。退票操作要先校验订单状态、再修改状态、最后恢复航班余票整个方法加了Transactional。但我为了给前端返回友好错误提示在Service方法内部用try-catch包住了业务逻辑然后catch里抛出BizException。结果事务就是不回滚数据停留在半修改状态。原因很好解释Spring的事务代理是通过方法抛出的异常来触发回滚的。如果你在方法内部就把异常捕获并吞掉了事务管理器根本感知不到状态异常自然就不会回滚。正确做法是Service方法内不做try-catch让业务异常层层往上抛由全局异常处理器统一捕获并转换成前端提示消息。只有一种情况允许方法内catch就是你明确知道这个异常不需要回滚事务并且已经做了补偿处理。这个边界要分清楚。5.4 字段命名前端要驼峰后端返回下划线还有一个不大不小但很磨人的问题。数据库表字段是下划线风格比如departure_cityMyBatis Plus默认映射到后端实体是驼峰departureCity这没问题。但接口返回给前端时如果你手动封装了VO并且字段名是departure_city前端用row.departureCity取值就会一直是undefined。我最后统一了规范数据库表字段用下划线Java实体用驼峰VO和前端交互统一用驼峰JSON序列化时靠Jackson的驼峰命名自动转换。这里你只需要记住一点——所有前后端交互的字段选择一种命名风格写到底不要一会儿下划线一会儿驼峰联调时切换来切换去极易出bug。6. 答辩准备与后续可扩展的方向代码写完、测试通过剩下的就是答辩这一关。答辩的好坏和代码写得好坏不完全对等你需要把系统里最值得讲的技术亮点提前打磨成一段流畅的叙述。6.1 评委最可能追问的几个点我梳理了答辩现场被问得最多的夺命题逐一准备好答案你也能照着准备一份评委常问参考回答思路为什么用悲观锁而不用乐观锁购票属于高冲突写场景悲观锁能保证事务串行化避免乐观锁频繁重试导致体验差同时说明了悲观锁在极端高并发下的吞吐瓶颈以及可替换为Redis预扣库存的演进空间事务回滚的触发条件是什么默认RuntimeException回滚检查异常不回滚我们通过rollbackForException.class显式指定同时说明了try-catch吞异常导致回滚失效这个反例JWT和Session各有什么优缺点JWT无状态、适合水平扩展但无法主动失效Session服务端可控但需要维护会话存储跨域和分布式会比较麻烦航班查询缓存怎么保持一致性Redis缓存航班信息后管理端更新航班时删除对应缓存查询时缓存未命中再回源数据库并重建缓存通过先删缓存再更新DB避免脏数据订单状态机为什么不用字符串TINYINT枚举占空间小且索引效率高状态流转通过枚举类保证合法路径非法状态变更在代码层就被拦截这些问题的答案没有标准抄法关键是你要真正理解自己代码里的取舍逻辑。真诚地说这个方案在当时的数据规模下够用如果并发量再高我会换成XXX方案比硬吹强得多。6.2 这个系统再往前走两步会变成什么样如果你答辩完还有余力或者想把系统作为找工作的项目亮点有三个方向的升级建议一是把模拟支付替换成真实支付网关对接比如支付宝沙箱环境。这会让系统从演示品变成可上线系统含金量直接提升一个级别。二是引入消息队列比如RabbitMQ处理订票后的异步通知让购票成功短信通知这类辅助流程和主流程解耦可讲述性和团队合作痕迹都会更强。三是基于Redis的库存预热和令牌桶限流方案把秒杀场景的抗压能力做上去非常适合在面试时展示高并发设计能力。我个人在实际开发中的体会是航班管理系统看起来是个普通项目但如果每一步都追问为什么这么设计它就能变成一门小型的架构课。数据库的冗余和反范式要能讲清理由、并发控制要能讲清冲突场景、事务边界要能讲清回滚机制——这些经验放到任何一个Shop项目或者Booking项目上都通用。最后再分享一个小技巧开发过程中把你自己踩过的坑和对应解法同步记录在项目文档里答辩前翻一遍很多题你根本不用临时想直接就有真实素材可讲。