资讯动态

Java毕设实战:基于SpringBoot的机票预订系统核心实现与避坑指南

发布时间:2026/10/9 3:22:44 来源:尧图企业网站定制
每到毕设选题季就会有人反复问同一个问题Java方向选什么题既能写出来又能在答辩时讲出东西我的答案里出现频率最高的就是“基于SpringBoot的机票预订系统”。这个题好在它处在一个甜点区不是纯增删改查的图书管理那种单薄题目也没有电商全流程那么复杂到一个人搞不定。航班检索、下单购票、模拟支付、退票恢复库存这一串操作把数据建模、事务控制、状态流转、前后端交互这些Java后端最核心的考察点全串起来了而且飞机会员、火车票、景区门票本质上都是票务预订模型换个业务名词就能衍生出好几个不同题目。这篇文章不是让你去买一份源码然后躺平而是把这类项目完整的实现思路拆开给你看技术栈怎么选、数据库怎么设计、扣库存怎么做才不出错、从零跑通要按什么顺序动手、答辩时老师大概率会问哪些点。无论你是想自己从零写一个还是手里已经有一份别人的源码但根本读不懂这篇文章都值得当参考手册用。1. 为什么选机票预订系统做Java毕设1.1 题目价值正好踩在毕业设计的“甜点区”先看一组直观对比。图书管理系统、新闻发布系统这类题目核心工作是单表CRUD写起来确实快但答辩老师问一句“你这个系统有哪些复杂的业务约束”你就很难答出花来。而电商系统、秒杀系统这类题目购物车、优惠券、库存一致性、分布式事务全塞进来一个人用一两个月很难做到可以稳定演示的程度。机票预订系统恰好站在中间有真实业务约束余票有限、一个订单可以多人同行、有状态流转待支付、已支付、已出票、已退票、有金额计算和订单号生成但没有需要高并发才能讲明白的深坑。而且这个题目很好“讲故事”。答辩时你可以理直气壮地说我用事务保证了“支付成功”和“扣减余票”这两个动作的原子性用条件更新避免了多用户同时下单导致的超卖用状态机管理了订单从创建到退票的完整生命周期。这些话术不是吹的下文我会把具体实现一步步写出来。顺带说一句这类题目换个名字就是高铁票预订系统、演唱会门票预订系统需求模板基本一致所以很多网上的源码标题会同时写“航空公司售票系统”和“机票预订系统”本质上是一个东西。1.2 技术栈选型与版本搭配推荐组合是 SpringBoot MyBatis-Plus MySQL前端直接用 Thymeleaf Bootstrap 做服务端渲染。不要一上来就拆 Vue SpringBoot 前后端分离毕设最怕的是复杂度失控。两个人以上的角色权限、页面跳转、状态展示服务端渲染反而更好控制。版本是翻车重灾区先给一张结论表组件推荐版本说明JDK8 / 11 / 17版本必须和SpringBoot匹配SpringBoot2.7.18对应JDK8/11/17稳定优先不要追最新SpringBoot 3.x3.2.x对应JDK17只有JDK17以上才选MyBatis-Plus3.5.3单表自动CRUD复杂SQL自己写MySQL5.7 或 8.08.0需要额外配置时区前端Thymeleaf Bootstrap 5CDN引入简单快捷网上很多人问“SpringBoot版本太高启动失败”大部分原因就是本机JDK是8却下了一个SpringBoot 3.x的工程。SpringBoot 3.x 强制要求 JDK 17你编译都过不去。所以如果你电脑里的JDK是8老老实实用 2.7.18功能上做毕设完全够而且网上教程多到看不完。反过来JDK是17就直接上3.2系列不用再去降级。持久层我强烈推荐 MyBatis-Plus 而不是 JPA。原因有三第一单表CRUD完全自动不需要写几十个XML方法第二航班查询这种动态条件SQL用 LambdaQueryWrapper 比 JPA 的 Specification 直观得多第三国内中小公司用 MyBatis 系的比例非常高你写完这个项目简历上和面试里也好往下聊。1.3 哪些人适合拿这个题目只要你符合下面任意一条都可以选学过Java基础但没独立写完一个完整Web项目的在校生需要一个能顺利做完、又能在答辩时讲出业务深度的课设/毕设手里已经有一份源码但只想改改前端和数据库字段就交差的人——我也建议你先读一遍后文的核心实现至少知道改完哪里会出问题。我给的学习路径是四个字“先跑后造”。先让项目启动起来再顺着一次完整下单流程理解请求从页面到Controller、Service、Mapper、数据库的每一条路径然后把字段、页面、功能改成你自己的风格最后试着自己从零写一个模块。这套路线比对着视频抄一遍要扎实得多。2. 系统整体设计与功能拆解2.1 用户端与管理员端功能清单机票预订系统通常分两个角色普通用户和管理员。功能清单按角色拆开会更清晰。用户端功能注册与登录用户名、密码、手机号、身份证号密码不允许明文存储航班检索按出发城市、到达城市、出发日期查询航班列表分页展示航班详情航班号、航空公司、起降机场、起降时间、经济舱票价、余票数下单购票一个订单最多选3张票填写每位乘客的真实姓名和身份证号模拟支付支付页选择支付方式支付宝/微信/银行卡模拟支付成功后订单变为已支付并完成出票我的订单按状态筛选查看支持待支付订单取消、已出票订单退票。管理员端功能管理员登录与权限校验航班管理新增航班、编辑航班信息、上下架航班、手动调整余票数订单管理查看全部订单处理异常订单强制退票简单统计航班总数、订单总数、销售总金额、各航线订票热度。从功能数量来看这个系统刚好是“两个人几周能完成”的工作量。用户端是主流程管理员端是支撑不需要做复杂的权限框架一个拦截器加角色判断就够。2.2 订单状态机让业务逻辑不再是一团浆糊很多票务类毕设写得像一锅粥问题不在代码复杂而是订单状态没有提前设计。这里推荐一套最基础的状态枚举状态值含义触发动作0待支付用户提交订单后生成1已支付/已出票用户完成模拟支付2已退票用户申请退票且管理员受理3已取消用户在待支付阶段主动取消状态流转规则很清晰0 → 1支付、0 → 3主动取消、1 → 2退票。不允许跳状态不允许从3回到其他状态。把这条规则写清楚后面所有代码都有章可循。比如退票接口首先要判断订单状态是否为已支付是才允许流转到已退票不是就直接抛业务异常。这种设计在答辩时非常好讲你甚至可以画一张状态流转图出来。2.3 角色权限与页面流转用户表里加一个 role 字段来区分普通用户和管理员是毕设项目里最省事的方案。登录成功后把用户对象放进 Session后续每个请求通过拦截器校验。页面适配逻辑也不复杂用户端导航显示“我的订单”“退出登录”管理员端导航显示“航班管理”“订单管理”。核心页面流转如下首页航班搜索页 → 航班列表页 → 航班详情页 → 确认订单页 → 模拟支付页 → 支付成功页 → 我的订单页我的订单页 → 订单详情页 → 退票确认 → 状态变为已退票。这四条路径跑通系统的主体演示就能顺利完成。后续的管理员页面属于补充部分只需要做好航班新增编辑、订单列表查询两件事。3. 核心细节解析数据库设计与后端实现3.1 数据表设计三张主表搞定核心业务数据库设计是整个项目的骨架我建议核心表控制在五张以内。主表就三张user用户、flight航班、orders订单表注意避开order这个关键字。另外可以加一张 passenger乘客信息表来存多人购票的乘客但为了降低复杂度也可以把乘客信息作为JSON冗余在订单表里或者用“乘客一、乘客二”这类字段直接平铺。下面是推荐的表结构CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50), id_card VARCHAR(20), phone VARCHAR(20), role TINYINT DEFAULT 0 COMMENT 0普通用户 1管理员, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE flight ( id INT PRIMARY KEY AUTO_INCREMENT, flight_no VARCHAR(20) NOT NULL, airline VARCHAR(50), from_city VARCHAR(50) NOT NULL, to_city VARCHAR(50) NOT NULL, departure_date DATE NOT NULL, departure_time TIME NOT NULL, arrival_time TIME NOT NULL, price DECIMAL(10,2) NOT NULL, total_seats INT NOT NULL, remaining_seats INT NOT NULL, status TINYINT DEFAULT 1 COMMENT 1上架 0下架, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, order_no VARCHAR(40) NOT NULL UNIQUE, user_id INT NOT NULL, flight_id INT NOT NULL, passenger_names VARCHAR(100), id_cards VARCHAR(200), ticket_count INT NOT NULL, total_amount DECIMAL(10,2) NOT NULL, status TINYINT DEFAULT 0 COMMENT 0待支付 1已支付 2已退票 3已取消, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, pay_time DATETIME, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP );两个细节要专门提示。第一表名不要用 order因为 ORDER 是SQL的保留字你用的时候要么加反引号要么整天被坑所以取名 orders 最省心。第二remaining_seats 是余票字段它必须在支付成功时才扣减而不是下单时扣减否则用户下单不付款也会把库存占住这是很多人容易理解错的地方。字段类型方面金额用 DECIMAL(10,2) 不用浮点型票数用 INT这些都属于Java后端开发的基本功。如果你不想手写建表SQL网上有些工具可以根据 MyBatis-Plus 的实体类自动生成建表SQL但那种生成出来的字段注释、索引往往不够规范我还是建议把建表SQL手写一遍这个过程本身就是对表结构的一次复盘——答辩时老师一定会问你的表是怎么设计的。3.2 航班检索条件查询与分页航班列表是用户最常看到的页面搜索条件通常是出发城市、到达城市、出发日期。城市输入我建议用精确匹配加下拉选择不要指望用户在文本框里打出标准的“上海虹桥”这种值。日期直接用日历控件选择减少手工输入。使用 MyBatis-Plus 的 LambdaQueryWrapper 写动态条件非常顺public PageFlight searchFlights(String fromCity, String toCity, String date, int pageNum, int pageSize) { LambdaQueryWrapperFlight wrapper new LambdaQueryWrapper(); wrapper.eq(StringUtils.hasText(fromCity), Flight::getFromCity, fromCity) .eq(StringUtils.hasText(toCity), Flight::getToCity, toCity) .eq(StringUtils.hasText(date), Flight::getDepartureDate, date) .eq(Flight::getStatus, 1) .orderByAsc(Flight::getDepartureTime); return flightMapper.selectPage(new Page(pageNum, pageSize), wrapper); }这里用到了 MyBatis-Plus 的分页插件需要提前在配置类里注册分页拦截器否则分页不生效。这个功能只需要一个查询接口不要把它写成复杂的动态SQL拼接字符串拼SQL不仅难看还有注入风险。3.3 防止超卖支付时如何安全扣减余票这是整个系统最值得写进论文、也最值得在答辩时详细讲的部分。先看错误做法查询当前余票判断余票是否大于购买数量如果大于就执行减库存把结果写回数据库。这个逻辑在单用户场景下没问题但在两个用户同时提交订单时会出现经典超卖两个事务同时读到的余票都是1都判断“够”然后都去减最后余票变成负数。正确的做法是在更新语句上加条件直接让数据库来保证原子性。核心SQL长这样UPDATE flight SET remaining_seats remaining_seats - #{count} WHERE id #{flightId} AND remaining_seats #{count}这条语句的意思简单说就是“只有当你扣完之后不会变成负数时才允许你扣”。MyBatis-Plus 的执行结果是受影响行数如果返回0说明余票已经被别人抢走了这时服务端直接抛出“余票不足”异常即可。对应的 Service 核心代码Transactional(rollbackFor Exception.class) public void payOrder(Integer orderId, Integer userId) { // 1. 查出订单校验属于当前用户且状态为待支付 Order order orderMapper.selectById(orderId); if (order null || !order.getUserId().equals(userId)) { throw new BizException(订单不存在或无权操作); } if (order.getStatus() ! 0) { throw new BizException(订单当前状态不可支付); } // 2. 条件更新余票防止超卖 int affected flightMapper.decreaseStock(order.getFlightId(), order.getTicketCount()); if (affected 0) { throw new BizException(余票不足请选择其他航班); } // 3. 更新订单状态为已支付写入支付时间 order.setStatus(1); order.setPayTime(new Date()); orderMapper.updateById(order); }方法还要加 Transactional因为扣余票和更新订单状态是两个库表操作要么都成功要么都回滚。这里就自然引出了事务的原子性概念答辩时老师问“为什么这个操作需要事务”你直接指这两行就够了。为了写得更有深度还可以体验一下带行锁的查询SELECT ... FOR UPDATE。在支付这样低频操作里先锁行再更新也能保证安全但条件更新是更简洁的方案也更贴近实际生产中高并发扣库存的写法。我建议在论文里把这两种方案都提一下然后说出你选了条件更新的理由。3.4 订单号生成与金额处理机票订单号不要直接用自增ID当成对外订单号原因一是容易暴露系统累单量二是拼接出的订单号太短没有辨识度。毕设阶段可以用一个简单方案时间戳加随机数加用户ID。String orderNo FLY System.currentTimeMillis() String.format(%04d, (int)(Math.random() * 10000)) userId;这样生成的订单号二十多位格式基本是“FLY 日期毫秒 四位随机数 用户ID”可以在订单列表页直接展示给用户。如果你愿意折腾也可以用雪花算法但要额外引入工具类对毕设项目来说属于可选项。金额处理遵循一条原则所有金额计算环节都用 BigDecimal 或 DECIMAL不用 double。比如 total_amount 就是 price 乘以 ticket_countJS中不要做浮点金额计算后端算好后传到页面展示即可。前端为了体验可以显示“待支付金额”但最终以服务端计算结果为准。4. 实操过程从零跑通一个能答辩的系统4.1 环境准备与项目初始化配置本地环境需要JDK、Maven、MySQL、一个JavaIDE。如果你用的是 IDEA 社区版也能完整支持SpringBoot开发不需要付费版。用 Spring Initializrstart.spring.io生成工程选好依赖Spring Web、Thymeleaf、MyBatis-Plus注意这个依赖需要手动加入坐标、MySQL Driver、Lombok。生成后修改 application.ymlserver: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/airline_ticket?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl global-config: db-config: logic-delete-field: deleted两个容易踩的配置点一是 MySQL 8 的 driver-class-name 是 com.mysql.cj.jdbc.Driver5.7 用 com.mysql.jdbc.Driver 也行但建议统一用萧writer二是数据库连接串必须加 serverTimezoneAsia/Shanghai不然时间字段会偏差8小时。IDE 里启动主类跑起来能看到 Thymeleaf 欢迎页环境就算通了。4.2 后端分层写法实体、Mapper、Service、Controller后端按主流四层结构组织包名建议entity、mapper、service、controller、common。实体类直接用 Lombok 简化Data TableName(flight) public class Flight { TableId(type IdType.AUTO) private Integer id; private String flightNo; private String airline; private String fromCity; private String toCity; private LocalDate departureDate; private LocalTime departureTime; private LocalTime arrivalTime; private BigDecimal price; private Integer totalSeats; private Integer remainingSeats; private Integer status; }Mapper 接口只需要继承 BaseMapper 就能获得单表 CRUDpublic interface FlightMapper extends BaseMapperFlight { Update(UPDATE flight SET remaining_seats remaining_seats - #{count} WHERE id #{flightId} AND remaining_seats #{count}) int decreaseStock(Param(flightId) Integer flightId, Param(count) Integer count); }写代码的顺序建议是“实体 → Mapper → Service → Controller → 页面”每完成一个模块就启动一次测试不要等全写完再启动。Controller 层代码越薄越好。比如航班查询Controller public class FlightController { GetMapping(/flights) public String list(String fromCity, String toCity, RequestParam(defaultValue ) String departureDate, RequestParam(defaultValue 1) Integer pageNum, Model model) { PageFlight page flightService.searchFlights(fromCity, toCity, departureDate, pageNum, 5); model.addAttribute(page, page); model.addAttribute(fromCity, fromCity); model.addAttribute(toCity, toCity); model.addAttribute(departureDate, departureDate); return flight-list; } }页面跳转直接用字符串返回值配合 Thymeleaf 的模板解析不需要 RestController 的 JSON因为服务器端渲染的页面数据直接放在 Model 里即可。4.3 Thymeleaf 页面联调的实际体验前端页面不需要写得很花哨。Bootstrap 5 通过 CDN 引进来页面结构用它的栅格系统就能做一个像样的PC端页面。航班列表页建议用卡片式布局一行放三到四个卡片或者用表格布局列出所有航班。列表页的核心片段table classtable table-hover thead tr th航班号/thth出发城市/thth到达城市/thth起飞时间/th th到达时间/thth票价/thth余票/thth操作/th /tr /thead tbody tr th:eachf : ${page.records} td th:text${f.flightNo}/td td th:text${f.fromCity}/td td th:text${f.toCity}/td td th:text${f.departureTime}/td td th:text${f.arrivalTime}/td td th:text${f.price}/td td th:text${f.remainingSeats}/td tda th:href{/flights/ ${f.id}} classbtn btn-primary btn-sm预订/a/td /tr /tbody /table联调时最常遇到的问题有两个第一个是 Thymeleaf 的 th:each 不认识后端返回的实体属性多半是大小写或下划线映射问题MyBatis-Plus 开启 map-underscore-to-camel-case 之后数据库的 from_city 才能映射成实体中的 fromCity第二个是列表页跳转到详情页时 ID 没传对路径拼接要严格对照 Controller 的 GetMapping(/flights/{id})。4.4 演示数据准备与演示路线空数据库没法演示准备一批初始航班数据很重要。我一般会在项目里放一个 data.sql启动时初始化到数据库中INSERT INTO flight (flight_no, airline, from_city, to_city, departure_date, departure_time, arrival_time, price, total_seats, remaining_seats, status) VALUES (CA1831, 中国国航, 北京, 上海, 2025-06-10, 07:30:00, 09:45:00, 1200.00, 180, 120, 1), (MU5123, 东方航空, 上海, 广州, 2025-06-10, 10:20:00, 12:50:00, 1300.00, 200, 98, 1), (CZ3451, 南方航空, 广州, 成都, 2025-06-10, 14:10:00, 16:30:00, 990.00, 150, 75, 1), (3U8891, 四川航空, 成都, 北京, 2025-06-11, 08:00:00, 10:40:00, 1100.00, 160, 80, 1);演示脚本我推荐按这个顺序走进入首页搜索“北京到上海”→ 注册新用户 → 登录 → 选择一个航班 → 填写2位乘客信息下单 → 在“我的订单”看到待支付订单 → 点击模拟支付 → 页面显示支付成功、订单状态变为已出票 → 到数据库或列表页看到余票减了2 → 执行退票 → 余票恢复加2。这八步把整个业务闭环都演示到了也把事务一致性、状态流转这些亮点都自然带了出来。5. 常见问题与避坑实录5.1 SpringBoot版本太高启动失败这个问题几乎每年都能遇到一批人。“SpringBoot版本太高”听起来很奇怪但本质是JDK和SpringBoot版本不匹配。SpringBoot 3.x 要求JDK17你如果还在用JDK8启动时会出现类版本错误或者干脆编译失败。解决办法不是硬升级JDK而是选了JDK8就用SpringBoot 2.7系列。如果你已经用 Spring Initializr 生成了3.x项目在 pom.xml 里把 parent 版本改成 2.7.18同时确认项目SDK是JDK8然后重新加载Maven基本都能解决。改版本后注意 Lombok 版本也要匹配太新的Lombok对旧JDK不一定兼容。5.2 数据库乱码与时区问题MySQL 8 的 serverTimezone 不配置接入连接池时可能直接报时区相关的SQLException或者存入的时间偏差8小时。解决方式就是连接串上写清楚 serverTimezoneAsia/Shanghai。乱码问题则要看三个地方连接串有没有 characterEncodingutf8、MySQL 库表字符集是不是 utf8mb4、页面 HTML 的 meta 是否声明 UTF-8三处都对了中文就不会乱。5.3 退票之后余票没恢复退票逻辑最容易漏的是一步恢复余票。很多初版只改了订单状态为2但没把 ticket_count 加回到 flight 表的 remaining_seats 上。恢复操作同样要放到事务中和更新订单状态同生共死。代码上可以加一个专门的方法Transactional(rollbackFor Exception.class) public void refundOrder(Integer orderId, Integer userId) { Order order orderMapper.selectById(orderId); if (order null || !order.getUserId().equals(userId)) { throw new BizException(订单不存在或无权操作); } if (order.getStatus() ! 1) { throw new BizException(只有已出票订单可以退票); } flightMapper.increaseStock(order.getFlightId(), order.getTicketCount()); order.setStatus(2); orderMapper.updateById(order); }提高库存的SQL也建议用条件语句防止各种异常情况下线程同时退票把库存加超虽然加了不会造成安全损失但数据会乱。5.4 典型报错速查表现象原因解决方案启动报 ClassNotFoundException: javax.servlet.*SpringBoot版本和Tomcat版本不匹配降低SpringBoot版本到2.7.x连接数据库报 time zone 相关错误缺 serverTimezone 参数连接串加 serverTimezoneAsia/Shanghai返回中文全是问号连接串/库字符集不是UTF-8统一改为 utf8mb4列表分页不生效没注册MyBatis-Plus分页插件在配置类加 MybatisPlusInterceptor页面跳转404Controller路径和Thymeleaf模板目录不对应检查 templates 下的文件路径查询返回字段全部为null没开启下划线转驼峰配置 map-underscore-to-camel-case: true下单扣库存后库存变负数用了先查后减改为 UPDATE 条件更新5.5 几个容易被答辩追问的深水区老师如果深问通常会集中在三个方面。第一个是为什么要用事务你直接回答“支付操作涉及两个数据表变更业务上要求要么都成功要么都失败”。第二个是超卖怎么解决你可以把“先查后减的错误方案”和“条件更新”对比一下讲讲数据库行锁概念。第三个是订单状态为什么用数字而不是字符串因为数字更节省存储而且枚举映射清晰不同状态可以定义成常量或枚举类。如果你想显得更有准备可以提一句生产环境可以加一个定时任务把“待支付超过30分钟”的订单自动取消并把这套逻辑写到论文的“未来改进方向”里但毕设演示阶段不建议做因为演示时需要给用户留足操作时间定时取消会让演示变成事故。最后再分享一点个人体会我做这类票务项目改过很多次印象最深的就是“余票扣减”这个问题。第一次写的时候跟大多数初学者一样先查余票再减库存用单机测试怎么测都没问题后来用两个浏览器开两个窗口同时下单立刻把这个bug暴露出来。从此以后我对所有涉及库存、余额之类的更新操作都改成了条件更新这个习惯后来在真实工作里也帮了大忙。如果你打算用这个题目做毕设我真心建议不要只停留在“跑起来”这个层面。把订单状态机画清楚把事务和条件更新这两个点吃透把用户注册、航班检索、下单支付、退票这一个闭环完完整整做出来你的收获会比想象中大得多。等这个系统真的能稳定跑通你会发现SpringBoot开发的基本功已经悄悄内化成了你自己的东西。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑