
简介飞机订票系统是一套基于计算机技术的在线预订平台整合航班信息、座位安排、价格计算、支付处理等核心功能适合后端开发、前端交互设计以及需要快速理解订票业务闭环的学习者参考。压缩包采用zip格式整体大小约14.77MB平台未同步文件总数与类型明细因此不虚构目录结构实际解压后可按工程分类检索。资源覆盖前端交互设计、后端分布式架构、数据库存储、第三方支付安全集成等模块并涉及航班数据实时同步、动态座位管理、灵活票价与退改签计算、异常处理与重试机制、报表统计、用户账户体系、API对接及移动端适配等关键点能帮助读者从业务逻辑到技术实现建立整体认知。目前已有1450人浏览学习适合作为课程设计、毕业设计或实际项目起步参考通过阅读资源内容可对照理解下单状态流转、座位扣减、支付回调与异常补偿等常见问题。1. 飞机订票系统看着像 CRUD真正翻车的都在扣余票那一刻飞机订票系统是开发练手和课程设计里出现频率最高的题目之一很多人的第一反应是把航班列表查出来、用户点一下、生成订单纯增删改查没什么技术含量。真正做下来你会发现这个系统最难的不是页面而是「两个人同时订最后一张票」时数据库怎么决定谁成功谁失败是支付回调、用户取消、后台改单同时发生时订单状态会不会被互相覆盖。这篇笔记从功能拆解、表结构设计、下单事务写法到压测验证完整走一遍适合正在做课设的在校生也适合想用真实业务场景练手的一线开发者。2. 先画业务流程再碰代码订票系统的功能边界与技术选型拿到题目先别急着建工程。我见过不少开发者包括当年的自己都是先建项目再想功能写到一半发现退票改签的流程没想明白表结构推倒重来前端页面跟着返工。订票系统的功能边界其实是业务先定死的把边界画清楚技术选型就是顺水推舟的事。2.1 六个模块拆清楚查询、订票、支付、退改、用户和后台先看这个系统要服务谁。C 端用户要做的是查航班、选舱位、填乘机人、下单付款、退票改签后台管理员要做的是维护航班计划、调整舱位价格、查看订单。一张表把职责边界列出来后面开发时就不会漏功能| 模块 | 典型页面/接口 | 关键规则 | | 航班查询 | 按日期、出发城市、到达城市搜航班 | 展示舱位、价格、余票需处理跨天航班 | | 在线订票 | 选航班 - 选舱位 - 填乘机人 - 生成订单 | 余票校验、一个订单可含多个乘机人 | | 支付 | 模拟支付页面与回调接口 | 回调幂等状态不允许回退 | | 退票/改签 | 订单详情里的操作入口 | 退票恢复余票改签涉及差价计算 | | 用户管理 | 注册、登录、常用乘机人 | 密码加密存储 | | 管理后台 | 航班维护、订单查询 | 管理员角色航班状态管理 |把模块拆开后能清楚看到真正的复杂度集中在「订票-支付-退改」这条主链路上其他地方基本是常规 CRUD。这条主链路共享一个核心资源——航班舱位的余票所以系统的技术难点才会落在并发和状态一致上。这也是我建议先拆模块再做选型的原因不是所有功能都需要高深技术大多数模块用最简单的 CRUD 实现就好把精力留给主链路。2.2 技术栈为什么选 Spring Boot Vue 前后端分离订票系统这个规模技术栈的选择原则是「团队最熟、生态最全、部署最简单」。我一般会推荐 Spring Boot 做后端、Vue 做前端、MySQL 存数据原因有三条。第一Spring Boot 的事务管理用注解就能搞定订票、退票这种「多个更新必须同生共死」的场景写起来非常直接第二Vue 处理表单和表格类页面效率很高查航班、填乘机人这类页面天然适合组件化第三前后端通过 JSON 接口通信后期接支付网关沙箱、换管理后台框架都不用动页面结构。早期很多教程用 JSP Servlet 做这套系统不是不能做而是会话管理、页面渲染和业务逻辑混在一起到支付回调、订单状态这些带异步交互的场景代码会越写越拧巴。前端页面处理回调结果、后端只暴露接口两边可以并行开发对单人完成整个项目也更友好。数据库方面MySQL 足够先不要引入缓存和消息队列——这个阶段的瓶颈在业务正确性不在性能等服务真正部署后有明显热点再针对具体接口加缓存不迟。2.3 项目骨架和开发环境照着搭就能开工选型定了项目骨架建议按前后端分两个目录接口路径保持 REST 风格。后端用 Maven 管理依赖前端用 npm 或 pnpm 管理依赖。目录结构我一般这么建backend/ src/main/java/com/example/flight/ controller/ # 接收前端请求做参数校验 service/ # 业务逻辑下单、退票事务都在这里 mapper/ # 数据库访问接口 entity/ # 对应数据表的实体类 src/main/resources/ mapper/ # MyBatis 的 XML 文件放 SQL frontend/ src/ api/ # 封装请求方法统一处理返回码 views/ # 页面航班查询、订单详情、管理后台 router/ # 路由与页面鉴权开发环境准备四样JDK选当前 LTS 版本、Maven、MySQL8.x 即可、Node.js。后端启动后监听 8080 端口前端开发环境监听 5173 端口并通过代理把 /api 请求转发到后端避免跨域问题。前端跑起npm run dev后端启动主类整个骨架就能动了。3. 表设计定生死订票系统六张核心表与关键索引业务拆完了下一步是把业务落到表结构。订票系统的表设计有一个核心原则职责单一。余票是余票、订单是订单、乘机人是乘机人不要为了少写几张表把字段硬塞在一起否则后面每一行业务代码都要为当初的「省事」买单。3.1 六张核心表怎么拆职责单一才能撑住业务变化系统需要六张核心表用户表、航班表、舱位表、乘机人表、订单表、订单乘机人关联表。每张表的职责如下| 表名 | 核心字段 | 职责 | | tb_user | id, username, password_hash, phone | 登录账号 | | tb_flight | id, flight_no, departure_city, arrival_city, departure_time, arrival_time, status | 航班基础信息与状态 | | tb_seat | id, flight_id, seat_class, price, remaining | 每个航班的舱位价格与余票 | | tb_passenger | id, user_id, name, id_card | 用户维护的常用乘机人 | | tb_order | id, order_no, user_id, flight_id, status, total_amount, created_at | 订单主表 | | tb_order_passenger | id, order_id, passenger_name, passenger_id_card | 订单与乘机人的快照关联 |为什么订单乘机人要单独拆一张关联表去存快照因为一个订单可以给多个乘机人买票帮家人订是刚需而乘机人的姓名和证件号必须在下单那一刻定格。如果订单直接外键关联 tb_passenger用户后面编辑了常用乘机人历史订单里的信息也跟着变这就出事了。所以 tb_order_passenger 存的是下单时的快照不是引用。3.2 建表 SQL从用户到订单的一整套落库脚本有了表清单就可以写建表 SQL。下面这个脚本是这套系统的基础字段命名统一用下划线Java 侧映射成驼峰CREATE TABLE tb_user ( id BIGINT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL, password_hash VARCHAR(100) NOT NULL, phone VARCHAR(20), created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_username (username) ); CREATE TABLE tb_flight ( id BIGINT AUTO_INCREMENT PRIMARY KEY, flight_no VARCHAR(10) NOT NULL, departure_city VARCHAR(30) NOT NULL, arrival_city VARCHAR(30) NOT NULL, departure_time DATETIME NOT NULL, arrival_time DATETIME NOT NULL, status VARCHAR(20) NOT NULL DEFAULT SCHEDULED, INDEX idx_city_time (departure_city, arrival_city, departure_time) ); CREATE TABLE tb_seat ( id BIGINT AUTO_INCREMENT PRIMARY KEY, flight_id BIGINT NOT NULL, seat_class VARCHAR(20) NOT NULL, price DECIMAL(10,2) NOT NULL, remaining INT NOT NULL DEFAULT 0, UNIQUE KEY uk_flight_class (flight_id, seat_class) ); CREATE TABLE tb_passenger ( id BIGINT AUTO_INCREMENT PRIMARY KEY, user_id BIGINT NOT NULL, name VARCHAR(30) NOT NULL, id_card VARCHAR(30) NOT NULL ); CREATE TABLE tb_order ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_no VARCHAR(32) NOT NULL, user_id BIGINT NOT NULL, flight_id BIGINT NOT NULL, status VARCHAR(20) NOT NULL DEFAULT CREATED, total_amount DECIMAL(10,2) NOT NULL, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_order_no (order_no) ); CREATE TABLE tb_order_passenger ( id BIGINT AUTO_INCREMENT PRIMARY KEY, order_id BIGINT NOT NULL, passenger_name VARCHAR(30) NOT NULL, passenger_id_card VARCHAR(30) NOT NULL, INDEX idx_order_id (order_id) );这段 SQL 里有三个设计点值得说。第一个是 tb_seat 的uk_flight_class唯一约束同一个航班同一个舱位只能有一行数据程序即使写错重复插入也会被数据库拦下来。第二个是 tb_flight 的idx_city_time组合索引航班查询最核心的过滤条件是「出发城市 到达城市 日期」这三列的组合索引能让查询直接走索引而不是全表扫。第三个是金额字段全部用DECIMAL(10,2)这个选择背后是浮点精度问题下面单独说。3.3 字段类型的小选择后期的大麻烦时间与金额表设计里最容易被忽略的就是字段类型。时间字段我见过有人用VARCHAR存2025-06-01 10:30看起来很直观但排序时是字典序同一天没问题跨年、跨月就乱套更重要的是没法用数据库的时间函数做区间运算起飞时间过滤只能靠字符串比较一旦格式不统一就会漏数据。正确做法是用DATETIME让数据库帮你做时间比较和索引排序。金额字段则是另一个高频翻车点。FLOAT和DOUBLE是二进制浮点0.1 在二进制里是无限循环小数两个 470 元的票价加总可能得到939.999...而不是 940。订票系统里不仅有票价加总还有退票比例计算、改签补差价全是乘除运算浮点误差会在这些场景中被放大。所以从建表这一刻起所有金额都用DECIMAL(10,2)Java 侧用BigDecimal对应。状态字段我习惯用VARCHAR存语义化的代码值CREATED、PAID比0/1/2可读性好排查问题时不用对着数字查字典。4. 订票下单链路的核心代码行锁扣余票与支付回调的写法表结构定了开始写核心代码。这条链路从用户搜航班开始到支付回调更新状态结束中间最关键的就是下单事务——它要同时完成「检查余票、扣减余票、创建订单、绑定乘机人」四件事任何一步失败前面已经做的操作必须全部回滚。4.1 航班查询接口用左闭右开区间代替 DATE() 函数航班查询是入口SQL 写得好不好直接决定后面压测能不能扛住。查询条件是出发城市、到达城市、出发日期返回该日期所有航班的舱位与余票SELECT f.flight_no, f.departure_city, f.arrival_city, f.departure_time, f.arrival_time, s.seat_class, s.price, s.remaining FROM tb_flight f JOIN tb_seat s ON f.id s.flight_id WHERE f.departure_city #{departureCity} AND f.arrival_city #{arrivalCity} AND f.departure_time #{dayStart} AND f.departure_time #{dayEnd} AND f.status SCHEDULED ORDER BY f.departure_time;这里dayStart是查询当天的00:00:00dayEnd是第二天的00:00:00左闭右开把一整天完整覆盖还能精确命中00:00整点起飞的航班。新手常见写法是DATE(departure_time) #{flightDate}逻辑上没错但DATE()函数包住了字段MySQL 在绝大多数情况下没法用idx_city_time索引航班数据一多查询立刻变慢。参数方面departureCity和arrivalCity传城市代码或名称都可以但前后端必须约定同一套值dayStart和dayEnd在 Java 侧用LocalDateTime计算不要在前端拼好字符串传过来时区不一致会出问题。4.2 下单事务用行锁把「检查余票」和「扣减余票」做成原子操作下单是订票系统的心脏。最直观的想法是先查余票大于 0 就插入订单、再扣余票。这个思路在单线程下没问题并发下一旦两个请求同时读到余票为 1两个都通过检查就会卖掉同一张票。正确做法是把检查和扣减放进同一个事务并且先加行锁Transactional(rollbackFor Exception.class) public Order createOrder(CreateOrderRequest req) { // 1. 锁定该航班的舱位行其他事务必须等这里释放 Seat seat seatMapper.selectByFlightAndClassForUpdate( req.getFlightId(), req.getSeatClass()); if (seat null || seat.getRemaining() 0) { throw new BusinessException(该舱位已售罄); } // 2. 扣减余票带上 remaining 0 条件做二次兜底 int updated seatMapper.decreaseRemaining( req.getFlightId(), req.getSeatClass()); if (updated 0) { throw new BusinessException(该舱位已售罄); } // 3. 创建订单状态为待支付 Order order new Order(); order.setOrderNo(generateOrderNo()); order.setUserId(req.getUserId()); order.setFlightId(req.getFlightId()); order.setStatus(OrderStatus.CREATED); order.setTotalAmount(seat.getPrice() .multiply(BigDecimal.valueOf(req.getPassengers().size()))); orderMapper.insert(order); // 4. 绑定乘机人快照 for (PassengerInfo p : req.getPassengers()) { orderPassengerMapper.insert(order.getId(), p.getName(), p.getIdCard()); } return order; }selectByFlightAndClassForUpdate对应的 SQL 是SELECT ... FROM tb_seat WHERE flight_id ? AND seat_class ? FOR UPDATEFOR UPDATE会对命中行加排他锁锁释放前其他事务的相同查询会阻塞。这样两个并发请求会排队执行第一个扣掉余票并提交第二个等锁释放后读到剩余 0直接抛「已售罄」。decreaseRemaining里UPDATE tb_seat SET remaining remaining - 1 WHERE id ? AND remaining 0是第二道保险即使锁在极端情况下没生效影响行数为 0 也会阻止超卖。代码里有三个细节容易踩坑。Transactional默认只回滚RuntimeException和Error如果BusinessException是受检异常事务不会回滚——所以我在注解里显式写了rollbackFor Exception.class。另外createOrder必须通过 Spring 代理调用同一个类里别的 public 方法直接调它事务注解会失效。总价计算一定要用BigDecimal的multiply用double乘完再转回BigDecimal精度已经丢过了。4.3 支付回调幂等与状态校验缺一不可订单创建成功前端跳到支付页。自己模拟支付的话后端要提供一个回调接口支付网关在用户付款后调用。回调接口的第一个要求是幂等——网关因为网络超时会重试同一个支付成功的通知可能到达多次处理第二次时必须直接忽略而不是再改一次状态public void handlePaymentCallback(PaymentCallback callback) { // 1. 按订单号找到订单 Order order orderMapper.selectByOrderNo(callback.getOrderNo()); if (order null) { throw new BusinessException(订单不存在); } // 2. 幂等已经是已支付状态直接返回 if (OrderStatus.PAID.equals(order.getStatus())) { return; } // 3. 只有待支付状态才允许变成已支付 if (!OrderStatus.CREATED.equals(order.getStatus())) { throw new BusinessException(订单当前状态不允许支付); } // 4. 条件更新防止并发覆盖 int updated orderMapper.updateStatusByCondition( order.getId(), OrderStatus.PAID, OrderStatus.CREATED); if (updated 0) { throw new BusinessException(订单状态已变化支付结果无法落库); } }这段代码的关键在第 4 步更新语句的WHERE里带上当前状态UPDATE tb_order SET status PAID WHERE id ? AND status CREATED。数据库层面保证只有CREATED状态的订单能被改成PAID即使前面读到的状态是旧值更新时也会被数据库挡住。这比「先查再改、改完再看结果」可靠得多省掉了一整类并发问题。回调的另一个细节是记录原始报文支付渠道的报文里有订单号、金额、交易号落库后即便后续状态乱了也能靠这份原始记录做人工对账。5. 飞机订票系统避坑指南并发、日期和状态流转的 5 个典型案例这一章是我把开发中遇到的坑集中成册每一条都是「现象 → 原因 → 解决」的完整链路覆盖了订票系统最容易翻车的五个方向。如果时间只够读一章建议读这一章——前四章决定系统能不能写出来这一章决定系统敢不敢上线。5.1 余票扣成负数先查后扣在并发下的翻车现场现象用 30 个并发请求抢一个只有 10 张余票的航班跑完后订单建了 30 条余票变成了 -20。原因下单代码写的是「先 SELECT 查余票判断大于 0再 UPDATE 扣减」。两个请求同时读到余票为 10都判断为有票各自扣 1最终结果就是库存少扣了。问题本质是「检查」和「操作」之间没有原子性——查和改之间隔着一个网络往返的时间窗口。解决把扣减 SQL 改成条件更新UPDATE tb_seat SET remaining remaining - 1 WHERE flight_id ? AND seat_class ? AND remaining 0让数据库在一次操作里完成「判断余票并扣减」。影响行数为 0 就是没票了直接抛业务异常。这条经验是我在一次压测里被上了深刻一课从那以后任何库存类字段我都默认用条件更新不再手动先查后改。5.2 把昨天的航班查出来了日期边界与索引失效的复合坑现象查询「6 月 1 日从 A 市到 B 市的航班」结果前一天 23:50 起飞的航班也出现在列表里。原因条件写成了DATE(departure_time) 2025-06-01。DATE()函数把字段包住后MySQL 对departure_time建好的idx_city_time索引用不上更隐蔽的是如果某条departure_time存储值里带有时分秒日期边界处理不对就会把前一天的晚班机也算进来。解决统一用左闭右开区间departure_time 2025-06-01 00:00:00 AND departure_time 2025-06-02 00:00:00。这个写法同时解决索引失效和边界漏查两个问题。改完之后可以在EXPLAIN结果里确认 key 列显示的是idx_city_time而不是NULL。5.3 支付成功被「已取消」覆盖状态更新必须带条件现象用户下单后没付款点击取消订单同一秒支付网关回调说支付成功。最终订单状态是「已取消」但钱已经扣了。原因取消订单和支付回调各自「先查状态 → 再更新状态」后到的请求不知道订单已经被对方改了直接把状态覆盖成自己预期的值。这是典型的丢失更新问题两个并发写操作互相覆盖。解决所有状态更新都带上前置状态条件。取消时执行UPDATE tb_order SET status CANCELLED WHERE order_no ? AND status CREATED支付回调同样带AND status CREATED两者只有一个能更新成功另一个影响行数为 0按冲突处理。状态机完整定义放在第 6 章这里先记住原则写状态之前先想清楚这个状态从哪个状态来。5.4 退票成功余票没恢复事务边界没包住第二个更新现象退票接口返回成功订单状态变成「已退款」但航班列表里的余票没有加回来。原因退票逻辑把两个更新拆开了。先改了订单状态然后调了一个独立的「恢复余票」方法而后者不在同一个事务里第一个更新提交了第二个因为异常回滚或压根没执行整体没有回滚。解决退票必须是一个事务恢复余票 订单状态变更同时成功或同时失败。一个订单可能买了 3 张票恢复余票时要按订单关联的乘机人数量加回对应舱位而不是盲目加 1。排查时先看事务日志确认两个 UPDATE 是否在同一事务提交再确认恢复的是tb_seat.remaining别把余票加到了航班表里某个不存在的字段上。5.5 金额对不上账FLOAT 的精度坑在累计计算中引爆现象订单明细里两张票各 470 元页面合计显示 939.99和用户实际支付金额差 0.01。原因价格字段用了FLOAT或DOUBLE二进制浮点数无法精确表示 470 元这样的十进制数两个浮点相加产生误差。这种误差在单笔订单里很难发现但退票按比例退款、改签补差价这些乘除运算会把误差放大到用户可见的程度。解决库表字段一律DECIMAL(10,2)Java 侧用BigDecimal运算前端展示时再用toFixed(2)格式化。规则很简单涉及金额的入库、计算、展示从头到尾不要碰float和double。如果已经用了浮点字段赶紧写个迁移脚本改成DECIMAL越晚改数据越难修。6. 从能跑到能上线订单状态机定义与并发压测验证坑填完系统能跑了。但「能跑」和「敢上线」之间还差两件事把订单状态流转收敛成一张状态机表再用并发请求把超卖问题压出来。6.1 订单状态机把散落的 if 判断收敛成一张迁移表订单状态在前面几章反复出现如果每个接口都自己写 if 判断迟早会漏掉某种组合。我会把合法迁移定义成一张表代码里所有状态变更都走同一个校验方法| 当前状态 | 允许的事件 | 目标状态 | | CREATED待支付 | 支付成功 | PAID | | CREATED待支付 | 用户取消 | CANCELLED | | CREATED待支付 | 超时未支付 | CANCELLED | | PAID已支付 | 申请退票 | REFUNDED | | PAID已支付 | 航班已起飞 | COMPLETED | | CANCELLED | 到达支付回调 | 拒绝只记录冲突日志 | | REFUNDED | 任何事件 | 拒绝 |实现上把所有合法迁移放在一个集合里变更前先校验目标状态是否可达再配合第 5 章的条件更新 SQL代码逻辑和数据库约束双保险。这样支付回调遇到已取消订单时系统不是去覆盖状态而是记录一条冲突日志等人工处理。6.2 压测验证不超卖一条命令复现并发抢票验证系统是否真的不超卖不需要复杂的压测工具一条 bash 循环就能复现。先准备一个余票为 10 的航班再用 30 个并发请求抢同一个舱位# 并发 30 个请求抢同一个航班舱位初始余票 10 for i in $(seq 1 30); do curl -s -X POST http://localhost:8080/api/order \ -H Content-Type: application/json \ -d {flightId:1001,seatClass:ECONOMY,passengers:[{name:压测乘客}]} \ -o /dev/null -w req_$i:%{http_code}\n done wait跑完后查两件事SELECT remaining FROM tb_seat WHERE flight_id 1001 AND seat_class ECONOMY期望是 0SELECT COUNT(*) FROM tb_order WHERE flight_id 1001期望是 10。两个都对得上说明没有超卖如果订单数大于 10 或者余票为负回到 5.1 检查扣减 SQL 是否带了remaining 0条件。有 JMeter 的话可以用线程组模拟更真实的混合场景查航班和下单同时进行但上面这条命令在没装任何工具的环境里也能完成验证。我现在养成的习惯是任何状态更新先写 WHERE 条件再看影响行数任何涉及钱的字段先确认是 DECIMAL 再谈逻辑。这两条在订票系统里帮我省了很多次返工希望也能帮到你。本文还有配套的精品资源点击获取