SpringBoot飞机票预定系统毕设:从数据库设计到防超卖实战
做过毕业设计的人都知道选题这事儿卡住一大半人。要么方向太大做不出完整功能要么太简单撑不起一篇论文。我当初选的这个基于SpringBoot的飞机票预定系统说白了就是个典型的民航电子客票销售平台但正因为业务场景足够复杂——涉及航班查询、余票管理、订单状态流转、支付回调、定时清理才让我在盲审和答辩环节都没被问到哑火。这篇文章我把自己从数据库设计到最终部署的完整思路、踩过的坑和能直接抄作业的核心代码逻辑全部复盘出来不管你是在纠结毕设选题方向还是已经开工想往项目里加亮点做差异化都值得花十分钟看完。1. 项目整体设计与技术选型思路1.1 为什么选定SpringBoot而不是SSM或微服务先说结论SpringBoot几乎是当前Java后端毕设的最优解没有之一。SSMSpring SpringMVC MyBatis虽然在很多教材里还是主流但光是一堆XML配置就能劝退一大半人微服务那套Spring Cloud Alibaba更适合作为项目亮点去谈但不适合作为主架构因为本来毕业设计周期就紧分布式事务、服务拆分、链路追踪这些概念如果只是停留在写了但没跑通的程度答辩的时候反而会被追问到破绽百出。SpringBoot最大的价值在于自动装配和约定优于配置。你只需要引入spring-boot-starter-web一个标注了SpringBootApplication的主类就能跑起一个内嵌Tomcat的Web服务。这意味着你可以把几乎全部精力投入到业务功能本身而不是和配置文件搏斗。对于民航客票系统这种业务逻辑密集的项目这是最划算的选择。我当时的技术栈是这样的层次选型说明后端框架SpringBoot 2.7.x稳定资料多避免版本过高导致的兼容性问题持久层MyBatis-Plus单表CRUD几乎不用写SQL分页插件直接拖进来用数据库MySQL 8.0主流窗口函数、JSON类型都支持缓存Redis存热点航班数据和分布式锁后面细说前端Vue 2 Element UI前后端分离开发后期打包进jar包统一部署鉴权JWT无状态登录适合前后端分离场景这里我要特别提一下SpringBoot版本的选择。很多同学直接去官网拉最新版结果发现spring-boot-starter-parent版本太高MyBatis-Plus或者某些第三方依赖还没适配启动直接报Caused by: java.lang.NoSuchMethodError。我用的2.7.x市面上绝大多数教程和依赖都兼容出了问题也能快速搜到解决方案。这点对毕设来说太重要了——你永远不想在深夜一点排查一个jar包冲突问题。1.2 功能模块划分先画清楚边界再写代码民航电子客票系统表面看就是查航班、下订单、付款、出票但真正落地的时候功能模块必须拆细否则代码会越写越乱。我最终划分成下面这些模块用户模块注册、登录、个人信息维护角色分普通用户和管理员。航班管理模块管理员维护航班基础信息起降城市、时间、机型、舱位、票价、余票普通用户按条件搜索航班。订单模块下单锁定座位、生成待支付订单、支付确认后出票、退票改签。支付模块因为毕设不可能接真实的支付宝微信支付我做了模拟支付接口用一个状态字段模拟支付回调成功。票务管理模块已出票的订单生成电子客票号可以查看行程单。统计报表模块管理员查看每日订单量、销售额趋势这里用到了MySQL的时间函数和聚合查询是论文里比较好的分析点。在正式写代码之前花了一整天画功能结构图和数据流图这一步非常值得。因为论文里必须有需求分析和系统设计章节而这些图就是你设计思路的直接体现。更重要的是只有把模块边界画清楚了写代码的时候才知道每个Controller该做什么、哪些逻辑应该下沉到Service层。1.3 这个系统真正的难点在哪里航班预订系统和普通的增删改查系统最大的区别在于它天然带有并发和交易属性。想象一个场景上午十点某热门航线的折扣舱位只剩3张票同时有5个用户点击预订按钮。如果代码不做任何特殊处理就会出现超卖——5个人都下单成功但航班上只有3个座位。这不是什么极端情况而是真实业务里必然会发生的。所以整个项目的核心不是把功能跑通而是把数据一致性做对。事务、锁、订单状态机、幂等处理这些概念必须在这个项目里真正落地。这也是为什么我在设计阶段就决定要加入Redis分布式锁和本地事务而不仅仅是做一个演示用的CRUD。答辩的时候老师大概率会问你这个系统怎么防止超卖如果你能说出乐观锁、Redis分布式锁、数据库唯一约束这几层方案并且能讲清楚每一层的适用场景这个项目的基本盘就稳了。2. 数据库建模与核心业务设计2.1 三张核心表的设计要点数据库设计是整个系统的基础表结构如果设计得不合理后面写SQL和业务逻辑都会很痛苦。我把自己实际使用的核心表结构拆开来讲。用户表核心字段就是id, username, password, phone, id_card, role, create_time。密码不能明文存储我用的是Spring Security自带的BCryptPasswordEncoder做哈希盐值随机的安全性有保障。角色字段用一个tinyint区分0代表普通用户1代表管理员不做复杂的权限框架够用就行。航班表是整个系统的信息中枢。表的关键字段我列一下CREATE TABLE flight ( id bigint NOT NULL AUTO_INCREMENT, flight_no varchar(20) NOT NULL COMMENT 航班号, departure_city varchar(50) NOT NULL COMMENT 出发城市, arrival_city varchar(50) NOT NULL COMMENT 到达城市, departure_time datetime NOT NULL COMMENT 起飞时间, arrival_time datetime NOT NULL COMMENT 到达时间, airline varchar(50) DEFAULT NULL COMMENT 航空公司, aircraft_type varchar(30) DEFAULT NULL COMMENT 机型, economy_price decimal(10,2) NOT NULL COMMENT 经济舱票价, business_price decimal(10,2) DEFAULT NULL COMMENT 商务舱票价, economy_seats int NOT NULL COMMENT 经济舱总座位数, business_seats int DEFAULT NULL COMMENT 商务舱总座位数, economy_remain int NOT NULL COMMENT 经济舱余票, business_remain int DEFAULT NULL COMMENT 商务舱余票, status tinyint NOT NULL DEFAULT 1 COMMENT 1可预订 0停售, PRIMARY KEY (id), KEY idx_city_time (departure_city, arrival_city, departure_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有个设计上的取舍需要说清楚。有人会把余票放在单独一张库存表里用独立的行锁来保证并发安全。但毕设项目把余票作为字段直接放进航班表配合UPDATE ... WHERE economy_remain 0这种条件更新语句完全能满足需求而且实现更简单。关于并发和锁的问题我在下一节详聊。订单表是数据一致性要求最高的地方CREATE TABLE t_order ( id bigint NOT NULL AUTO_INCREMENT, order_no varchar(32) NOT NULL COMMENT 订单号, user_id bigint NOT NULL, flight_id bigint NOT NULL, seat_type tinyint NOT NULL COMMENT 1经济舱 2商务舱, ticket_price decimal(10,2) NOT NULL, status tinyint NOT NULL COMMENT 0待支付 1已支付 2已出票 3已取消 4已退票, contact_name varchar(30) NOT NULL, contact_phone varchar(20) NOT NULL, id_card varchar(20) DEFAULT NULL COMMENT 乘机人证件号, create_time datetime NOT NULL, pay_time datetime DEFAULT NULL, ticket_no varchar(20) DEFAULT NULL COMMENT 电子客票号, PRIMARY KEY (id), UNIQUE KEY uk_order_no (order_no), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;订单号和电子客票号的唯一约束是必须加的。订单号我直接用yyyyMMddHHmmss 随机数生成不依赖数据库自增ID这样在分布式场景下也可以保证唯一性论文里也能写一笔全局唯一ID生成策略。2.2 防止超卖一条SQL保住数据底线这是我整个项目里最有含金量的一段逻辑。先看核心代码Transactional(rollbackFor Exception.class) public Order createOrder(OrderCreateDTO dto) { // 1. 条件更新余票影响行数为0则说明余票不足 int updated flightMapper.deductSeat(dto.getFlightId(), dto.getSeatType()); if (updated 0) { throw new BusinessException(余票不足下单失败); } // 2. 生成订单状态为待支付 Order order buildOrder(dto); orderMapper.insert(order); return order; }对应的Mapper SQLUPDATE flight SET economy_remain economy_remain - 1 WHERE id #{flightId} AND economy_remain 0这个方案的精髓在于把检查余票是否充足和扣减余票合并成了一条原子SQL。由数据库的行锁保证并发安全——同一时刻多个请求同时更新这一行InnoDB会让它们排队执行后执行的那个请求因为economy_remain 0条件不满足更新的影响行数为0从而拒绝下单。这就是教科书上常说的乐观锁思想的SQL化实现。不需要额外的Redis锁就能解决绝大多数据超卖问题代码简单性能也好。我当时在答辩PPT里专门放了一张图对比先查后扣和条件更新两种方案老师点头的频率明显提高了。2.3 航班查询的SQL优化思路航班搜索是这个系统最高频的接口也是论文里系统性能优化章节的素材来源。搜索条件一般是出发城市、到达城市和日期。我做了两件事第一联合索引。上面表结构里我已经加了idx_city_time索引顺序是(departure_city, arrival_city, departure_time)。这个顺序严格遵循最左前缀原则——先城市后时间因为用户查询时城市条件是必填的时间可以细化到具体日期。如果反过来(departure_time, departure_city)那用户不带时间查询时索引就失效了。第二日期边界处理。用户搜索的是2024-06-01这天的航班而不是一个精确到秒的时间点。如果直接在SQL里写departure_time 2024-06-01会因为存储的是datetime类型而匹配不到。正确做法是用范围查询WHERE departure_city ? AND arrival_city ? AND departure_time 2024-06-01 00:00:00 AND departure_time 2024-06-02 00:00:00这样既能让索引生效又能把当天所有航班全部捞出来。我在这个坑里栽过一次排错半天才发现是因为时间边界没处理好后来也成了我教下一届学弟必讲的案例。3. 核心功能实操实现全记录3.1 JWT登录鉴权无状态方案的前后端分离实践既然前端是Vue后端是SpringBoot两者分离部署登录态就必须用token而不是Session。我用了JWTJSON Web Token方案它的特点是服务端不存储会话信息token本身携带用户ID和过期时间非常适合前后端分离。实现思路分三步第一步登录接口签发token。用户提交用户名密码后端校验通过后用io.jsonwebtoken.JJWT库生成token载荷里放userId和role有效时间设置为2小时。第二步拦截器统一校验。我写了一个JwtInterceptor实现HandlerInterceptor在preHandle里从请求头的Authorization字段解析token解析失败直接返回401。然后把这个拦截器注册到Spring MVC中并排除登录、注册和航班查询等白名单接口。第三步ThreadLocal保存用户信息。拦截器校验通过后把从token里解析出的userId放入ThreadLocal这样在Controller和Service层都能直接通过UserContext.getUserId()拿到当前登录用户避免了在每层方法里传递userId参数。这套方案里容易出问题的点在于JWT是无状态的一旦签发就无法在服务端主动失效。如果用户修改密码或者被管理员封禁旧的token依然有效只要没过期。在毕设项目里这不算大问题但如果你答辩想秀操作可以引入Redis黑名单机制——将注销的token key加入Redis直到过期拦截器里先查Redis再解析token。这段代码量不大但足够体现你对安全性的理解。3.2 下单出票事务边界与状态流转下单是整个系统最关键的业务链路必须有清晰的状态机和事务边界。我设计的订单状态流如下待支付(0) - 已支付(1) - 已出票(2) | | | v -- 已取消(3) 已退票(4)用户点击立即预订后端执行扣减余票 创建待支付订单这两步必须在同一个事务里。然后跳转到模拟支付页面用户点确认支付后端执行更新订单状态为已支付 生成电子客票号 扣减模拟账户余额这又是另一个事务。最后系统异步或同步地将订单状态更新为已出票。这里要特别强调的是创建订单和扣减余票必须在一个事务里否则会出现票扣了但订单没创建成功或者订单建了但票没扣掉的数据不一致。我在Service层方法上用Transactional(rollbackFor Exception.class)来声明事务边界。注意必须指定rollbackFor因为Spring默认只在遇到运行时异常时才回滚如果代码里抛出的是自定义受检异常不写这个参数事务就不会回滚。乘客信息和联系人信息的校验也要做足。身份证号要校验格式手机号要走正则匹配姓名过滤敏感词。这些细节虽然不起眼但评审老师翻阅代码时如果看到这种细节整体印象分会高不少。3.3 定时任务扫描过期订单SpringBoot的Scheduled实战订单创建后如果用户在15分钟内不支付订单应该自动取消余票自动回滚。这个功能在真实民航系统里是必须的也是检验你对SpringBoot定时任务掌握程度的好场景。我的实现方案是在启动类上加EnableScheduling然后新建一个定时任务组件Component public class OrderTimeoutTask { private static final long EXPIRE_MINUTES 15; Scheduled(cron 0 */1 * * * ?) // 每分钟执行一次 public void cancelExpiredOrders() { // 1. 查出所有待支付且创建时间超过15分钟的订单 ListOrder expiredOrders orderMapper.selectExpiredOrders(LocalDateTime.now().minusMinutes(EXPIRE_MINUTES)); for (Order order : expiredOrders) { try { // 2. 逐个处理内部加分布式锁避免与手动取消并发 cancelOrderAndRestoreSeat(order); } catch (Exception e) { log.error(处理过期订单失败订单号{}, order.getOrderNo(), e); } } } }使用Scheduled(cron 0 */1 * * * ?)让任务每分钟执行一次。这里有个优化点不要在循环里逐条查库可以直接用一个UPDATE ... WHERE create_time ? AND status 0批量处理但余票回滚需要根据每个订单的航班舱位逐条操作所以我还是保持了循环。数据量上来了可以改成批量处理但毕设项目的订单量根本到不了那个级别没必要过度设计。定时任务还有一个容易踩的坑Scheduled默认是单线程串行执行的如果一个任务执行时间过长会阻塞其他定时任务。如果系统里还有别的定时需求建议在配置类里设置一个线程池。我当时加了个SchedulingConfigurer配置类指定了5个线程的线程池防止某个任务卡住影响全局。3.4 模拟支付回调幂等处理不能忘既然是模拟支付就有人会用接口测试工具反复调用支付接口。如果不做幂等处理用户支付一次订单刷新再调一次接口就可能出现重复出票。我的做法是支付回调接口在处理业务之前先检查订单当前状态。只有当状态是待支付时才允许流转到已支付否则直接抛异常提示订单状态不允许支付。这就是最简单的状态机校验用数据库的update语句实现Update(UPDATE t_order SET status 1, pay_time NOW(), ticket_no #{ticketNo} WHERE id #{orderId} AND status 0) int markPaid(Long orderId, String ticketNo);影响行数为0就说明订单不是待支付状态直接返回失败。这种条件更新的思路跟扣减余票是同一个套路一脉相承代码里体现出来的设计一致性是很加分的。4. 性能优化、前端打包与上线部署全流程4.1 用Redis缓存热点航班降低数据库压力航班查询是读多写少的典型场景尤其是热门航线同一个搜索条件每天可能有成千上万次访问。每次都去MySQL里查一遍不仅慢而且会给数据库造成不必要的压力。我用Redis做了两层优化。第一层是查询结果的列表缓存。在Service层查数据库之前先查Redis以搜索条件拼接为key比如flight:query:SHA:BEI:20240601value存JSON数组。设置30分钟的过期时间过期后自动更新。这样热门航线的搜索请求绝大部分直接命中缓存响应时间从几十毫秒降到了个位数毫秒。第二层是航班详情缓存以flight:id:123为keyvalue存航班详情JSON。用户下单前通常要点击查看航班详情这个接口同样很热。用Redis的时候要注意一个细节缓存和数据库的一致性。航班改期、价格调整后缓存必须同步失效否则用户看到的价格和实际下单价格不一致会造成差评。我的做法是管理员修改航班信息时除了更新MySQL同时删除对应的Redis缓存key让下次查询自动回填新数据。4.2 Vue项目打包放进SpringBoot前后端统一部署技巧这是很多同学卡壳的地方。开发阶段前后端分离很舒服Vue跑8080端口SpringBoot跑8081端口但正式部署时总不可能让老师先去装Node环境再开两个进程。最省事的办法是把Vue打包后的静态资源放进SpringBoot的静态资源目录。具体操作很简单# 在Vue项目根目录执行 npm run build这会生成一个dist目录里面是打包后的静态文件。有两种放法方法一推荐把dist里的文件复制到 SpringBoot 的src/main/resources/static目录下。SpringBoot启动时会自动把这个目录作为静态资源根路径。然后在application.yml里配置一下spring: web: resources: static-locations: classpath:/static/方法二不合并代码直接把dist目录原样放到服务器上用Nginx做反向代理前端请求/api时转发到后端。但这种方法对毕设来说偏重如果你对服务器运维不熟反而容易折在部署环节。我选择的是方法一。好处是最终交付物就是一个jar包冷启动部署无压力。注意一个细节Vue用router的 history 模式时刷新页面会出现404因为SpringBoot找不到对应的路由。解决办法是写一个 Controller 把非接口路径全部转发到 index.htmlController public class ForwardController { RequestMapping(value /{path:[^\\.]*}) public String forward(PathVariable String path) { return forward:/index.html; } }这个小功能真的是很多人的救命稻草如果没有处理刷新一个https://域名/order/list页面就直接白屏了。4.3 Jar包打包与服务器部署的实操记录项目最终在Linux服务器上跑通了。整个部署流程分三步第一步配置数据库。服务器上装好MySQL 8.0把项目里的建表SQL和初始数据执行一遍再创建对应的用户账号并授权。第二步修改配置文件。application.yml里的数据库地址、密码改成服务器的实际配置。Redis地址也要改如果服务器上没装Redis可以先用本地开发环境做演示但实际要部署上线就必须装一个命令就两条apt install redis-server systemctl enable redis-server第三步打包并启动。用Maven的package命令打jar包mvn clean package -DskipTests然后把生成的target/xxx.jar上传到服务器用如下命令启动nohup java -jar xxx.jar app.log 21 nohup和配合让程序后台运行日志输出到app.log方便排查。最后用curl localhost:8080/api/flight/search验证接口是否正常。部署环节的最大风险是环境不一致导致的玄学问题。比如本地MySQL版本是8.0服务器是5.7那SQL语法和驱动兼容性可能会出问题。最好的做法是从一开始就让本地和服务器保持同一主版本能省掉大量半夜调试的糟心事。5. 常见问题与避坑指南血泪经验5.1 SpringBoot版本过高引发的连锁反应我在前面提过不要用最新的SpringBoot版本做毕设项目。这里展开说说具体症状好让你踩坑的时候能快速定位。用SpringBoot 3.x的同学会频繁遇到这个报错Caused by: java.lang.ClassNotFoundException: javax.servlet.Filter原因很简单SpringBoot 3.x把包名从javax.servlet改成了jakarta.servlet同时最低要求是JDK 17。如果你的项目里用了大量基于javax.servlet的老版本依赖比如某些旧版文件上传组件这些代码会直接编译不过或者运行时报ClassNotFound。还有 MyBatis-Plus 在 SpringBoot 3.x 需要引入专门的mybatis-plus-spring-boot3-starter很多人不知道一路报错下去心态直接炸裂。我的建议很明确如果你不打算花一整天去适配新版特性就老老实实选 2.7.x JDK 8/17 MyBatis-Plus 3.5.x 这套组合。不是说你技术不行而是毕设的核心目标是完整跑通并展示业务能力版本适配这种高成本低收益的事情不值得在这个阶段死磕。5.2 自动装配原理引发的启动失败SpringBoot启动报Failed to configure a DataSource是最经典的低级错误。这个错误看起来吓人翻译过来就是你引入了spring-boot-starter-jdbc或mybatis-spring-boot-starter但配置文件里没有指定数据源自动装配找不到Bean就报错了。排查步骤很简单先看application.yml里有没有配置spring.datasource.url/username/password再看依赖里有没有引入对应的数据库驱动。很多同学MySQL驱动版本没写对也会导致启动报无法加载驱动。理解SpringBoot的自动装配对答辩非常有帮助。简单说SpringBootApplication是一个组合注解核心是EnableAutoConfiguration它会根据classpath下的jar包依赖自动帮你配置Bean。比如 classpath 里有 Tomcat 嵌入包的依赖它就自动配置内嵌Servlet容器有 Redis 的依赖它就自动配置RedisTemplate。这套机制就是你在答辩时可以展开讲五分钟的素材前提是你真的理解它而不是只会背概念。5.3 跨域问题、时间格式和事务失效这些都是送分题也是送命题跨域问题。前后端分离开发时Vue跑在5173端口SpringBoot跑在8081端口浏览器默认会拦截跨域请求。解决方法是加一个WebMvcConfigurer配置类重写addCorsMappings方法允许指定来源访问。注意要允许携带凭证否则登录接口的token在请求头传不过去。时间格式化问题。前端Vue显示日期总是出现2024-06-01T12:30:00.00000:00这种带T的格式原因是后端返回的是LocalDateTime默认序列化格式不带时区控制。解决方式是在application.yml里统一配置spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8如果配了还不行可能是字段上标了JsonFormat但没有指定时区。检查一下是否有JsonFormat(pattern yyyy-MM-dd HH:mm:ss, timezone GMT8)。事务失效问题。这是最阴险的坑之一。很多人发现在Service方法上加了Transactional但抛出异常后数据依然被写进去了。最常见的一个原因是同一个类内部方法调用事务注解不生效。比如OrderService.createOrder()调用了OrderService.buildOrderAndDeductSeat()后者标了Transactional但因为是通过this直接调用而不是通过Spring代理调用事务拦截器根本没被触发注解自然就失效了。解决办法是把事务方法放到独立的Bean里或者在主方法上也标事务注解保证入口拦截。这些坑如果你自己踩过并且能说出原理那么在论文的遇到的问题与解决方案章节基本可以直接往下抄答辩时被问到时也能对答如流。5.4 常见问题速查表现象原因解决方案启动报DataSource错误配置缺失或驱动未引入检查url/username/password检查驱动依赖前端页面刷新404Vue路由history模式未处理添加ForwardController转发到index.html下单成功但余票没变事务未生效检查是否同类内部调用方法调整事务边界定时任务不执行启动类忘了加EnableScheduling在启动类添加注解搜索航班特别慢没走索引或时间边界未处理好建联合索引时间条件改用范围查询登录后API返回401token过期或被拦截器拦截检查token有效期检查白名单配置这个项目我前后断断续续做了一个半月真正耗时的地方不是写代码而是设计方案和踩坑。你要是时间紧可以直接按照文中的表结构和业务逻辑先把后端跑通再找时间补细节。我个人最大的心得是毕业设计不要追求技术多新多花哨把事务、缓存、定时任务、鉴权这几个经典技术点真正弄懂、用对、能讲清楚就已经超过绝大多数人了。最后再分享一个小建议——答辩前把项目部署到云服务器现场用手机访问前端界面演示比在本地IDE里演示观感好太多了老师对完整闭环项目的印象分会高出一大截。