ThinkPHP与Laravel双框架实现交通旅游机票订票系统实战
发布时间:2026/9/9 19:53:13 作者:尧图编辑部 阅读量:1,286

看到项目标题的第一反应你应该也会有同样的疑问一个“交通旅游计划飞机订票系统”为什么要把 ThinkPHP 和 Laravel 两套框架放在一起这个问题我在实际落地这个系统时被团队问过很多次后来干脆把原因写进了项目文档的第一页。简单说这不是炫技而是我接手时发现用户前台和内部管理端对技术的诉求根本不在一个频道上。前台要接口响应快、队列任务稳定、事件驱动灵活后台要逻辑直接、报表查询顺手、部署维护省心把两套框架放到各自擅长的位置上开发效率比硬套一套框架高一倍不止。这篇内容就借这个项目把双框架的架构分工、机票业务核心表、从搜索到出票的全流程以及中间踩过的典型坑完整还原一遍给打算做同类交易系统的朋友一个参照。1. 项目整体思路两套框架怎么分工才不会打架1.1 这到底是个什么系统交通旅游计划飞机订票系统看名字挺绕落到业务上就是三件事查航班、做行程计划、下单订票。查航班要接多供应商数据做行程计划要把机票、酒店、景点交通串起来订票则要处理舱位锁定、支付回调、出票状态、退改签这些交易链路。我平时和团队聊的时候会直接说这就是一个标准的“交易系统”不是内容管理系统那种改改字段就能上线的项目。它比普通管理后台麻烦在几个地方。第一航班价格是实时变动的本地数据库里存的班表和价格只能算“展示参考”真正下单时必须以供应商返回的实时舱位为准。第二低价舱位的库存是有限的同一个航班同一个舱位可能被多个渠道同时占用扣减时要保证不超卖。第三订单状态不是一锤子买卖从待支付、已支付待出票、已出票到退改每一个节点都可能被支付回调、异步出票、超时任务触发状态机处理不好就会出现订单卡死或者重复出票。再说“旅游计划”这部分。用户搜了一个目的地系统要帮他规划航班和当地行程的衔接比如航班落地时间点是否赶得上酒店入住、景点开放时间、租车取车时段。这部分业务不像订票链路那么强一致但要做得好得把机票数据、目的地POI数据、日期时间规则都串起来。我个人觉得比纯做机票更难一点因为它没有固定答案完全看产品想做到多细。1.2 ThinkPHP 和 Laravel 各自的定位很多人在选型阶段会纠结其实想清楚项目里有两类完全不同的使用场景就不会纠结了。一类是面向用户的在线业务接口要求高并发、好扩展需要一个生态完善、队列和事件机制成熟的框架另一类是内部运营人员使用的管理后台CRUD 多、报表多、权限复杂追求快速交付、部署简单、中文资料齐全。我做了一个简单对比直接贴在项目文档里给团队看评估维度ThinkPHP 6 LTS 版本Laravel 11 系列擅长场景运营后台、定时任务、报表统计、供应商管理用户端 API、支付回调、队列任务、异步通知生命周期与容器概念轻量学习成本低生命周期完整服务容器和门面设计成熟队列与事件能力内置队列但生态偏弱Redis 队列、事件监听、延迟任务开箱即用事务与 ORM模型简单直观适合快速写业务Eloquent 关系映射强大适合复杂模型部署运维PHP-FPM 即可依赖少需要 queue worker、scheduler 常驻进程团队替换成本国内文档多新人上手快生态资料多但需要理解容器思想最终方案很直接用户端所有在线交易接口包括航班搜索、下单、支付、订单查询、PDF 行程单导出全部走 Laravel后台管理端包括航班库维护、舱位价格配置、异常订单处理、对账报表、定时同步任务全部走 ThinkPHP。两套代码独立部署、独立发版共用同一个 MySQL 和 Redis。1.3 双框架之间的调用关系两套框架之间不能搞成“互相调来调去”否则链路一长定位问题就头疼。我的约定是请求只从客户端进 LaravelThinkPHP 不接收任何外部业务请求只对 Laravel 暴露一个内部调用入口而且这个入口只给 Laravel 的服务器 IP 放行。涉及数据库共享时要格外谨慎。虽然两套系统操作的是同一个 MySQL但我要求所有表名、字段含义、枚举值必须统一维护一份数据字典任何一边要改表结构必须先同步文档不然 Laravel 里 Eloquent 模型和 ThinkPHP 里的查询构造器对同一字段的理解一旦出现偏差线上故障是跑不掉的。Redis 是两边共享的缓存层。比如后台管理员在 ThinkPHP 里修改了某个航班的舱位价格我要求必须同时删除或者更新 Redis 里的航班搜索缓存 key否则前台 Laravel 可能继续返回旧价格给用户。至于以后要不要拆微服务这套设计也不算白做因为 Laravel 对外已经是标准 HTTP JSON API数据库与 Redis 的耦合也收敛在可控范围内真到需要拆库拆服务那天不至于推倒重来。2. 核心数据模型机票系统里不能省的那几张表2.1 航线和航班基础表我见过不少新手做机票系统上来就设计一张订单表把航班信息、乘客信息、价格全部塞进去后面对账和报表简直要命。合理做法是遵循“一码一表”的思路基础数据分开存。航线与机场相关表主要是 airports 和 flights。airports 表存机场三字码、城市三字码、机场名称、城市名称、时区字段不复杂但要强调一点只要涉及跨时区航班时区字段必须保留。别觉得只有国际机票才关心时区国内航班涉及新疆、西藏地区也一样会有偏差我在实际项目里就碰到过起飞时间按 UTC 存、本地时间展示错乱的问题。flights 表字段大致是id、flight_no、airline_code、origin_code、destination_code、takeoff_time、landing_time、aircraft_type、status。这个表我是重点建索引的查询机票的第一步永远是按出发城市、到达城市、日期去捞航班班次所以联合索引(origin_code, destination_code, takeoff_time)必须建。另外flight_no takeoff_time这种组合通常是供应商同步数据的唯一标识建议加唯一索引否则同一天同一个航班号被同步两次后面价格表就全乱了。2.2 舱位库存与价格策略航班本身是一趟物理航班但它能卖多少钱、还有几个座位是另一个维度的问题。我用 flight_cabins 表来存舱位与库存字段包含flight_id、cabin_code、fare_price、tax_fee、fuel_fee、insurance_fee、refund_rule、available_count、status。这里插一句很多人分不清楚舱位代码和座位等级的区别。Y、B、H、K、L 这些字母是舱位代码同一个物理经济舱里可能同时挂着多个舱位代码价格和退改规则完全不同。设计系统时如果只按“经济舱/商务舱/头等舱”来建模后面处理低价票和退改规则会非常痛苦。我要求前后端所有地方都传 cabin_code不传模糊的“经济舱”字样这样才能精确对到供应商的价格库存。价格相关字段我单独说明一下。一张国内机票最终支付金额并不是票面价而是由票面价、民航发展基金、燃油附加费、可能的保险费用组成。我习惯在数据库里把这几个费用拆开存而不是只存一个总价因为退款和改签时经常只退票面价和部分税费拆开算才说得清楚。报价给用户时是汇总价拆账和退款时一分一分都要有出处。2.3 订单与PNR旅客信息的关联订单相关表是整个系统的核心我至少会拆成四张orders、order_passengers、pnr_records、payment_records。orders 表保存订单主信息包括订单号、关联航班和舱位、订单总金额、支付金额、状态、下单用户、过期时间、创建时间。order_no 要自己生成不要用自增 id 当订单号给用户看格式可以带上业务日期比如 20250101 开头的序列号。order_passengers 表保存这个订单里的乘客信息姓名、证件类型、证件号、手机号一个订单允许 1 到 9 名乘客所以是一对多关系。pnr_records 表用来存供应商返回的 PNR 信息和出票结果字段包含 order_id、pnr_code、supplier_code、status、e_ticket_no、issued_at。有人容易把 PNR 编码和票号搞混PNR 是订座记录编号一个 PNR 里可以有多个乘客出票成功后每个乘客才有一个独立的电子客票号。系统里前后端字段命名必须区分开我最开始在接口里把 pnr_code 直接叫做 ticket_no后来对账发现完全对不上改了才理顺。payment_records 表是支付流水表记录支付渠道、渠道交易号、支付金额、回调参数、回调时间、处理状态。支付回调必须做幂等这张表是核心保障后面章节会细说。3. 订票核心流程从搜索到出票的完整链路3.1 航班搜索与缓存策略航班搜索的入口是出发城市、到达城市、出发日期搜索结果的原始数据来自供应商接口不能每次都实时打供应商否则接口扛不住响应也慢。常规做法是加缓存我在 Redis 里用这样的 key 结构flight:search:{origin}:{dest}:{date},值是拍平的航班与舱位 JSON 数组缓存时间设在 3 到 5 分钟。为什么不是更长因为价格和库存是实时变化的缓存时间太长用户看到一个低价但下单时价格变了体验很差太短又把供应商请求量打上去。3 到 5 分钟是我实际项目里平衡下来的经验值。你还要处理缓存穿透、击穿、雪崩三兄弟。策略不复杂穿透恶意搜索不存在的航线先用 Redis 的布隆过滤器或者在本地校验机场合法性先挡掉再查库击穿热门航线缓存刚好过期瞬间大量请求打到供应商接口用一个互斥锁保证同一时间只有一个请求去回源并重建缓存其他请求短暂等待后直接读缓存雪崩给不同航线的缓存 key 设置随机过期时间比如 180 到 300 秒之间随机避免同一时间大面积失效。搜索接口本身要做到可降级。万一多供应商接口同时超时不能直接报错给用户我选择在缓存未命中的情况下返回本地预存班表数据并标注价格为“参考价”用户点击去下单时再实时核价。这样用户至少能完成“看行程”这一步不至于整个页面打不开。3.2 创建 PNR 与锁定舱位用户选好航班、填好乘客信息后进入下单流程。这个环节是机票系统里最容易出 bug 的地方因为涉及三个系统的状态同步你自己系统的订单、供应商的 PNR 记录、支付渠道的流水。我做了一个相对稳健的时序Laravel 接收下单请求先做参数校验和乘客信息校验服务端生成一个待支付订单同时调用供应商的 PNR 创建接口供应商创建 PNR 成功后把 PNR 编码写回 pnr_records 表若 PNR 创建失败本地订单直接取消并返回错误信息订单创建成功并拿到 PNR 后给用户一个支付倒计时比如 15 分钟超时未支付系统要主动调用供应商取消 PNR 接口再关闭本地订单。这里我踩过一个很深的坑最开始只考虑“订单超时关闭”忘了同时释放供应商侧已经锁定的 PNR导致一堆“幽灵占座”供应商月底对账打过来电话问为什么不取消座位。后来加上了一条规则凡是从待支付变更为已关闭状态必须先成功调用供应商取消 PNR 接口如果取消接口失败订单不能直接关闭要进入“待人工处理”队列由后台运营介入。这个兜底机制非常值得做强烈建议保留。创建 PNR 是一个耗时操作常规情况下要等 2 到 8 秒绝对不能同步阻塞在用户请求里。我最初按同步方式做用户支付倒计时都走完了接口还没返回体验极差。后来调整策略前端提交订单后先进入“处理中”状态后端用队列处理 PNR 创建创建完成通过轮询或者 WebSocket 通知前端用户看到的是已经生成订单号和倒计时但真正后台还在等供应商响应。3.3 订单状态机与支付回调订单状态我设计了非常明确的一套流转图不允许任意跳转。待支付只能去已支付或已关闭已支付待出票只能去已出票或退款中已出票可以转退款中但不允许再回到待支付。所有变更都走统一的状态机类不允许在控制器里直接 update status 字段否则时间长了代码里到处都是状态修改逻辑出了问题根本查不清是谁改的。支付回调处理是交易系统非常核心的一环要特别注意幂等。支付宝、微信等渠道会在同一个订单支付成功后以通知、查询、重推等多种方式把结果发到你后端处理不好就会重复出票。我的方案是回调先落 payment_records 表以渠道流水号加订单号做唯一索引落库成功后再进入订单状态变更流程如果发现同一条回调流水已经处理过直接返回“成功”后面流程不再执行。出票接口同样放在队列中。订单支付成功后把出票任务丢进队列出票成功后更新 PNR 记录里的电子客票号并给用户发送行程单和提醒通知。如果出票失败触发退款流程同时要通知用户退款原因。不要小看这个失败处理航空公司接口在大促期间经常性抖动没有自动退款和人工复核机制会把你客服团队累死。4. ThinkPHP 端管理后台和定时任务的落地经验4.1 为什么后台选 TP6 LTS 而不是直接用 Laravel如果你问我后台为什么不用 Laravel 统一掉理由其实挺实际。后台系统多数是内部运营人员使用开发节奏快功能以 CRUD、权限、报表为主逻辑直接ThinkPHP 6 的多应用模式、验证器、查询构造器刚好够用团队里随便拉一个人就能上手不需要特别深的框架理解。版本上我特意选了 ThinkPHP 6.0.x LTS 系列。原因有两点一是它属于长期支持版本不会像 TP5 那样踩到 PHP 版本兼容的坑PHP 8.0 和 8.1 环境都比较稳二是它的多应用模式相当省心一个项目里把 admin、api、cli 三个应用分开后台访问路径、API 入口路径、命令行任务路径一目了然不会互相干扰。部署上 ThinkPHP 也更轻传统 PHP-FPM 方式就够了不需要额外维护常驻任务进程。团队里如果主要是做业务开发、不太喜欢折腾运维的这个优势挺明显。4.2 SQL 监听到底写在哪里这个话题在社区里问得挺多。TP6 里面监听 SQL 的核心方法是 Db::listen关键是注册位置要干净别撒在控制器里。我推荐写在一个全局服务类的 boot 方法里比如在 app/service/AppService.php 中注册?php namespace app\service; use think\Service; use think\facade\Db; use think\facade\Log; class AppService extends Service { public function boot() { Db::listen(function (string $sql, float $time, array $explain) { // 只记录慢查询减少日志量 if ($time 1000) { Log::write([SQL] . $sql . [ . $time . ms]); } }); } }这样注册之后整个应用所有通过 Db 类执行的 SQL 查询都会被捕获。我不建议直接把所有 SQL 都打到日志文件因为业务高峰期日志量非常大排查问题时根本看不过来。实操里我只记录慢查询和异常查询比如查询时间超过 1 秒的或者执行 EXPLAIN 之后发现没有走索引的这样定位线上性能问题更高效。如果你想在开发环境看到完整 SQL更简单的方式是打开 config/database.php 里的 trigger_sql 配置开发环境下输出到页面或者日志。生产环境我一般关掉这个配置只在需要排查时临时打开排完马上关闭。4.3 舱位扣减不是简单“update一下”免费源码的坑别踩网上有很多叫“thinkphp出库系统源码免费”之类的项目核心逻辑本质上是库存扣减。但我看过的大部分免费源码对库存处理都是简单一句Db::name(flight_cabins)-where(id, $id)-dec(available_count)-update();这种写法在并发较低的小工具场景里没问题放机票系统里一定会超卖。原因是它没有校验 available_count 大于 0也没有配合事务与锁两个用户同时下单时可能都读到余票 1 张各自都执行减一最终库存变成负数。我在这套系统里实际使用的是“预占库存 确认/释放”的流程。下单创建 PNR 时预占库存真正扣减时用 Redis 锁加条件更新保证只有一个请求能成功$lockKey cabin_lock: . $cabinId; if (!Cache::set($lockKey, 1, 10, [nx true])) { throw new \Exception(当前舱位正在锁定请稍后重试); } try { $affected Db::name(flight_cabins) -where(id, $cabinId) -where(available_count, , 0) -dec(available_count) -update(); if ($affected 0) { throw new \Exception(舱位余票不足); } } finally { Cache::delete($lockKey); }这里有两个细节值得注意。第一个是where(available_count, , 0)必须和dec写在同一条更新语句里这样数据库层面才会校验库存而不是先查再改。第二个是 Redis 锁的超时时间要设置成合理值我用了 10 秒正常情况下一条更新语句几十毫秒就完成了10 秒足够兜底如果超过 10 秒锁还没释放就要怀疑是不是有长事务了。后台管理端还有一个核心定时任务就是每天和供应商对账。对账任务跑在 ThinkPHP 的命令行应用里按小时拉取供应商侧出票记录和本地 orders、pnr_records 表做比对发现不一致就生成异常工单。对账任务必须要幂等建议用任务批次表记录每次对账的执行时间、订单范围、处理结果避免重复执行导致重复告警。5. Laravel 端API 服务和 PDF 导出那些坑5.1 接口层的统一响应与校验Laravel 这边承担的是在线交易 API所有接口的返回结构要高度统一。我用的结构是{ code: 0, message: success, data: {} }很多刚开始写接口的同学喜欢把业务失败直接映射成 HTTP 4xx 状态码比如下单失败返回 422支付失败返回 500这样做前端处理起来很痛苦因为 HTTP 状态码本质是传输层语义不是业务语义。我的约定是 HTTP 状态码只在网关层和鉴权层使用进入业务逻辑后一律用业务 code 判断code 为 0 表示成功非 0 表示具体错误前端只对 code 做事。参数校验我使用 FormRequest 统一处理乘客信息这种嵌套结构很好用public function rules() { return [ origin [required, size:3], destination [required, size:3, different:origin], date [required, date_format:Y-m-d], passengers [required, array, min:1, max:9], passengers.*.name [required, string], passengers.*.id_number [required, regex:/^[A-Z0-9]$/], ]; }校验规则会把错误信息自动转换成结构化的 validation error我会在异常处理器里统一格式化成上面的返回结构前端拿起来很顺手。5.2 storage 目录 PDF 导出 CORS 错误这个坑是搜索热词里的常客恰好我也踩过。场景是这样的订单出票成功后用户要下载 PDF 形式的电子行程单系统把 PDF 文件生成到了 storage/app/public 目录然后前端用 axios 去异步请求这个文件结果浏览器直接报 CORS 错误。CORS 报错的根源在于 PDF 文件被当作跨域资源加载了。如果前端页面和 PDF 文件不在同一个域名下浏览器默认禁止 JavaScript 读取响应内容。而 Laravel 里访问 storage 目录下文件其实是通过/storage/xxx.pdf的公开路径走的这个路径如果没有在响应头里加上 CORS 字段前端 axios 就会拦截。解决方案我分三种写清楚按推荐程度排序第一如果下载不需要附带额外的身份验证头最省事的方案就是不用 axios而是用原生a href...或者window.open()让浏览器直接下载。浏览器跳转下载不受 CORS 限制代码简单不说还不需要改动后端。第二如果接口需要携带 token可以通过Authorization头获取文件那就要用流式下载而不是暴露静态文件路径。我自己推荐这种方式适合订单类 PDF 这种私密文件public function downloadTicket(string $orderNo) { // 从数据库查出订单校验当前用户是否有权限 $pdf PdfService::generateTicketPdf($orderNo); return response()-streamDownload(function () use ($pdf) { echo $pdf-output(); }, ticket- . $orderNo . .pdf, [ Content-Type application/pdf, ]); }第三如果确实把文件放在 public/storage 下又想用 axios 读取那就要在 Nginx 层加 CORS 响应头但要注意不能同时使用Access-Control-Allow-Origin: *和Access-Control-Allow-Credentials: true否则浏览器会直接拒绝。正确做法是返回请求方 Origin并允许携带凭证location /storage/ { if ($request_method OPTIONS) { add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Methods GET, OPTIONS; add_header Access-Control-Allow-Headers Authorization, Content-Type; add_header Access-Control-Max-Age 86400; return 204; } add_header Access-Control-Allow-Origin $http_origin always; add_header Access-Control-Allow-Credentials true always; }不过我提醒一下订单行程单涉及个人信息最好不要通过静态文件路径长期暴露。我后来把方案改成了生成 PDF 后存到私有盘下载时走带权限校验的 streamDownload前端的 CORS 问题也顺手解决掉了。5.3 队列处理耗时任务Laravel 的队列和任务调度是它相对于 ThinkPHP 一个明显的优势机票系统里大量耗时操作依赖它。我列举一下本项目里用到队列的场景创建 PNR、出票、支付超时取消、短信通知、邮件发送、微信模板消息、供应商接口重试、PDF 生成。以出票为例支付成功后用户不应该一直等着接口返回我采用异步方式IssueTicketJob::dispatch($orderId) -onQueue(order:issue) -delay(now()-addSeconds(5));延迟 5 秒是给支付回调落库和订单状态更新留一点缓冲时间。队列连接我用的是 Redis配置 QUEUE_CONNECTIONredis。任务失败重试考虑两部分tries控制最多尝试次数backoff控制每次重试的间隔如果连续失败达到上限任务进入失败队列后台可查到异常记录。这里必须提一个容易踩的坑出票任务的重试必须幂等。供应商出票接口可能返回成功但本地没收到响应或者本地超时但供应商实际已出票如果你不做幂等重试时可能会重复请求出票。我的解法是每次任务执行前先查 pnr_records 表如果该订单已经有成功出票记录直接跳过执行出票请求时带上本地生成的业务请求流水号供应商端也能做去重。6. 上线前后必须盯住的几个细节6.1 订单状态更新丢失问题做交易系统最怕的就是订单状态错乱。我遇到过一种典型的丢更新场景支付回调线程和超时关单定时任务同时处理同一笔订单支付线程正准备把状态从待支付改成已支付另一头超时任务判断时间到了把订单改成已关闭。如果两个操作互相没有锁最终状态就是后写入的覆盖前一个用户明明付了钱订单却显示关闭。我给出的解决思路是使用乐观锁更新订单状态时必须带上当前状态条件$result DB::table(orders) -where(id, $orderId) -where(status, Order::STATUS_PENDING) -update([status Order::STATUS_PAID, paid_at now()]); if ($result 0) { // 说明订单状态已经不是待支付需要再查一次走分支处理 }这样即使并发执行最终也只有一个操作能成功。同样逻辑应用在支付回调、超时关闭、出票完成等所有状态变更节点。写的时候可能觉得啰嗦但线上出一次乱子远比多写几行代码成本高。6.2 供应商超时与自动补偿机票系统对接的供应商接口并不总是像内部接口那么稳定。实际运行中供应商接口可能在高峰期出现 5 秒、10 秒、甚至更长的超时如果我们设置了默认超时时间太短大量请求会拿不到响应订单卡在“创建 PNR 中”如果超时时间太长用户请求一直挂着体验也不行。我的做法是分两层超时设计。第一层用户请求的 HTTP 层超时控制在 3 秒以内超过就直接提示“网络繁忙请稍后重试”不让用户一直等。第二层真正的供应商呼叫放到队列任务里队列任务内部设置独立的 HTTP 超时时间比如 15 秒队列里重试 3 次。用户无需感知后端还在重试他只需要知道订单创建中之后通过查询接口获取最新状态。如果重试 3 次都失败订单进入“创建失败”状态同时释放已预占库存并通知支付方退款如果已经支付。这里我要专门提一下退款幂等支付渠道退款接口一定要传退款单号并且保存退款流水避免重复退款。尤其在供应商频繁抖动那天这个自动补偿流程救了我一命不然全人工处理客服团队根本忙不过来。6.3 双框架环境的日志链路打通两套框架并存排查问题时最烦的是日志分散。Laravel 日志默认写到 storage/logsThinkPHP 日志默认写到 runtime/log两边时间格式、请求 ID 又不一致线上出了一个问题两边日志对不起来。我的办法是统一引入一个请求 ID 的中间件。用户请求从网关进入 Laravel 时生成一个 UUID通过响应头返回给前端同时写入 Laravel 日志。Laravel 内部调用或者队列任务也会带上这个 ID 继续传递。ThinkPHP 定时任务处理的数据如果与某笔订单有关在日志里记录关联的订单号与请求 ID。这样两边日志虽然物理上还是分开的但通过同一个 request_id 字段就能串起来查。这个细节虽然不算功能需求但上线之后对排查效率影响非常大。没有统一日志链路的前两周我们定位一次问题平均要花一两个小时后来把 request_id 加上大部分问题十分钟内就能锁定到具体是 Laravel 队列超时、ThinkPHP 定时任务重复执行、还是供应商回调延迟。最后再分享一个小经验。这套系统上线后我对双框架分工的感受越来越深ThinkPHP 适合做那些“确定性强”的内部系统Laravel 适合做“不确定性强”的在线交易系统与其争论哪个框架更好不如把业务按这个规律拆开。如果你正在规划类似的交易系统建议把两套框架的边界图、数据库数据字典、状态机图先画出来让团队所有人对齐后再动手写代码。后面开发踩坑的数量会少很多。