
做了小半年的内网项目——基于ThinkPHP和Laravel双框架的交通旅游计划飞机订票系统最近总算完整上线交付了。之所以把这两个框架一起写在标题里是因为这个系统本身就玩了个“双核架构”面向C端用户的查询、下单、支付核心链路跑在Laravel上面向内部运营和第三方渠道的航班管理、出票处理、报表统计则跑在ThinkPHP上。两个老熟人这次成了同一条流水线上的搭档既有分工又有协作。这个系统解决的是一个很具体的场景旅游公司或者差旅平台需要一套能对接航司数据、处理实时库存、支撑支付出票全流程的独立订票系统而不是去填第三方机票API的表单。合适谁来参考PHP开发、尤其是刚接触企业级系统架构的朋友或者公司里需要从单体项目往多框架分层过渡的团队。这篇文章我打算把整条链路拆开讲透从框架选型到数据建模、从订单状态机到支付回调、再从PDF行程单生成到SQL性能排查全是实操里踩出来的一手经验。1. 整体设计为什么一个系统里同时出现ThinkPHP和Laravel1.1 两个框架的真实定位差异先说框架选型。很多朋友一看到项目里既有ThinkPHP又有Laravel第一反应就是“技术栈不统一怕不是历史包袱”。但这次是有意为之。Laravel在API开发、队列调度、事件驱动、Eloquent ORM这些能力上确实成熟写业务核心能吃上红利而ThinkPHP在快速CRUD、内置后台模板、轻量部署这些环节特别顺手尤其是国内服务器环境TP的兼容性和上手成本都很友好。实际划分是这样的乘客端的机票搜索、航班报价、创建订单、接入支付、出票状态、订单查询全部走Laravel内部运营端的航班上下架、舱位价格维护、渠道订单同步、每日销售报表、异常订单标记放在ThinkPHP。两侧操作同一套MySQL数据库但代码层面完全隔离。这样划分的好处是C端追求性能和接口规范Laravel的中间件、API Resource、队列让这块很容易做好B端追求快速开发和运维省心ThinkPHP的自动表单、validate、脚手架能帮运营团队省下大量重复劳动。1.2 双框架之间如何打通数据层两个框架连同一个数据库看起来简单实际有一些容易翻车的地方。首先为了保证“同一数据库、两套代码、互不干扰”我用了独立的数据库账号分配方案Laravel用的账号具备读写权限ThinkPHP用的账号同样具备读写权限但两边各自只操作自己职责范围内的表避免业务代码错位引发脏数据。其次两边共用一个公共配置区比如支付秘钥、航司接口地址我放在数据库的system_config表里用各自的缓存机制去读取改配置不用两边同时发版。还有一点值得提醒两套框架的时间时区、字符集、JSON字段解析方式必须手动校准。Laravel默认UTCThinkPHP默认使用的是PHP的date.timezone配置这个差异如果不处理订单创建时间、支付回调时间就会对不上甚至会影响到对账。我们统一让两边都使用Asia/Shanghai并在每个请求入口做了时区显式设置这样后面不管日志、报表还是退款计算时间口径都能保持一致。1.3 请求流转路径与部署形态整个系统的请求链路大概是前端小程序或者H5发起搜索请求 - Nginx按路径前缀分发例如/api/开头的走Laravel/admin/开头的走ThinkPHP - Laravel先查Redis缓存有报价直接返回没有缓存就实时查询航司/供应商库存接口 - 用户下单后订单写入MySQL支付拉起走异步回调 - 支付成功触发队列任务调用出票接口 - 出票结果回写状态同时上报ThinkPHP后台。部署上我们用的是两台轻量应用服务器一台跑NginxPHP-FPM一台跑MySQL和Redis。前期的开发阶段也可以单服务器跑但支付回调接口和生产出票任务建议单独拆进程避免高峰期PHP-FPM阻塞导致回调超时。我们踩过一次回调超时的坑支付平台重试了三次结果订单状态被“幂等”逻辑兜住了但这个以后必须提前设计不然就会出现重复出票。2. 机票数据模型与搜索核心逻辑的实现2.1 航班、价格与库存到底该怎么建表机票订票系统的数据模型核心不是“订单表”而是“航班库存表”。一看到“库存”两个字很多人会联想到电商SKU但机票逻辑复杂得多同一航班不同舱位代码有不同价格同一天同一个航班不同渠道销售配额可能还不一样临近起飞价格可能动态调整。所以我们的核心表设计是这样的flights表航班基础信息字段包括航班号、起飞机场、到达机场、计划起飞时间、计划到达时间、航司二字码、航班状态。flight_dates表航班在某个具体日期是否有飞行计划。这个表很重要因为机票卖的是“某年某月某日某航班某舱位”的组合。flight_inventory表库存与价格按航班日期和舱位代码细分核心字段有舱位代码、舱位名称、销售价格、税费、剩余座位数、总配额、锁定截止时间、渠道类型。orders表订单主表关联航班日期、乘客数量、订单状态、支付订单号。order_passengers表乘客子表存储姓名、证件号、乘机人类型。outbound_ticket表出票记录包括PNR码、票号、出票状态。另外还需要airports机场表和airlines航司表用于搜索和展示。机场表只有几十上百条数据但前端搜索框的联想、航线的自动匹配、以及航程展示都要靠它不要图省事写死在代码里不然后面加机场就是一次发版噩梦。2.2 搜索条件组合与索引优化策略机票搜索页往往是这样选出发地、到达地、日期、乘客人数偶尔筛选舱位类型。搜索的核心SQL类似SELECT f.flight_no, f.dep_airport, f.arr_airport, fd.flight_date, fi.cabin_code, fi.sale_price, fi.tax_fee, fi.inventory FROM flight_dates fd INNER JOIN flights f ON f.id fd.flight_id INNER JOIN flight_inventory fi ON fi.flight_date_id fd.id AND fi.channel 1 WHERE fd.flight_date 2025-05-20 AND f.dep_airport PEK AND f.arr_airport SHA AND fi.inventory 0 AND fi.sale_status 1 ORDER BY fi.sale_price ASC LIMIT 50;这里最关键的索引组合是flight_dates表的(flight_date, flight_id)联合索引以及flight_inventory表的(flight_date_id, channel, cabin_code)联合索引。没有这两个索引数据量过五万条以后一次搜索会直接拖垮数据库。我们最早开发时只给flight_date加了单列索引线上查询经常超过两秒后来分析慢查询日志才发现问题加上联合索引后立刻降到几十毫秒。对于热门航线比如北上广深互飞我们会把当天搜索结果缓存到Redis过期时间设在五分钟到十五分钟之间因为航班价格和库存是实时变动的缓存太久会造成报价失效。而冷门航线就直接穿透查库保证报价能拿到最新数据。2.3 余票扣减与防超卖设计票量控制是订票系统里最要命的一块。常见的做法是两种一种是在数据库层面用UPDATE ... WHERE inventory 0进行条件更新影响行数为0就说明没票了另一种是先用SELECT FOR UPDATE锁行再做判断然后更新。我们生产环境用的是第一种简单高效再配合一个安全的订单创建流程。核心流程是这样的创建订单时先向orders表插入一条状态为“待支付”的订单记录同时发起库存预占执行$affected FlightInventory::where(id, $inventoryId) -where(inventory, , 0) -decrement(inventory); if (!$affected) { // 扣减失败释放订单返回筛选其他航班 }这个decrement方法在Laravel里会生成原子性的UPDATE语句MySQL默认行锁能保证并发下不会超卖。用户超过支付时间未付款订单自动取消再执行一个increment把库存还回去。需要注意的是归还库存的操作必须带一个“订单状态是否为待支付”的二次校验防止一个已经支付成功的订单又被后台任务误取消、误释放库存。注意库存归还一定要用队列延迟任务去处理不要图省事在用户关闭支付页时同步执行。因为用户可能支付成功但前端没回调通知此时扣款回调正在飞来同步归还会发生竞态。3. 订单状态机与支付回调的安全处理3.1 订单状态机设计从待支付到已出票订单状态这块如果只用一个字符串字段乱改短期没问题后期绝对会失控。我画出来的一套状态流转是待支付(PENDING) - 已支付(PAID) - 出票中(ISSUING) - 已出票(ISSUED) 待支付(PENDING) - 已取消(CANCELED) 已支付(PAID) - 出票中(ISSUING) - 出票失败(ISSUE_FAILED) - 待退款(REFUNDING) - 已退款(REFUNDED)每个状态变更都要求记录操作日志写进order_status_logs表。这个表看起来不起眼但在跟客服扯皮、跟支付平台对账、排查问题时能救大命。举例来说用户说“我付了钱但没出票”我们查一下日志就能看到是支付回调延迟、出票接口超时还是风控拦截而不是打开数据库猜。状态变更我统一走Laravel的一个状态机服务类不直接允许在业务代码里随意update status字段。这样能保证所有状态流转都经过合法性校验比如“已取消”的订单不能再次进入“出票中”非法流转直接抛异常。3.2 支付回调的验签与幂等逻辑支付回调是项目里最容易出事故的环节。很多初学者喜欢“回调一进来就直接改订单状态”这在接口没做验签的情况下等于给攻击者留了个后门。我们对接的是微信支付和支付宝两者回调流程大同小异用户支付成功后支付平台把异步通知POST到我们的回调URL携带签名和业务参数我们要做的是第一步验签。支付宝用RSA2验签微信用平台证书验签。验签失败直接返回“FAILURE”字符串让支付平台知道需要重试。第二步查询订单号关联本地订单校验订单金额和支付实付金额是否一致不一致就要告警。第三步幂等处理。同一个支付成功的通知支付平台可能会发送多次重复消费会造成状态覆盖。我们的做法是在redis里用一个setNXkey是支付平台流水号value是订单号只有第一次插入成功才执行后续逻辑。$lockKey pay_callback_ . $platformTradeNo; if (!Redis::set($lockKey, $orderId, EX, 120, NX)) { return SUCCESS; // 已有请求在处理中 }第四步事务中更新订单状态为“已支付”调用队列任务去触发第三方出票接口。注意更新订单状态的操作和触发队列入队操作必须保证要么都成功、要么都不影响数据一致性这里我会把state更新作为主事务队列入队用afterCommit回调确保事务提交后才投递。3.3 出票任务队列与失败重试出票是一个典型的异步、耗时、可能失败的环节。我们用的是Laravel的Redis队列任务名IssueTicketJob。这个Job里做三件事请求航司/供应商的出票接口拿PNR码和票号把票号写入outbound_ticket表把订单状态改成已出票。出票接口偶尔会超时或者返回“舱位已售罄”等错误。这种情况必须区分处理超时可以重试舱位售罄不能重试而是要更新库存并通知运营改签。我们在Job里设置了最大尝试次数5次退避时间指数增长第一次失败后1分钟重试第二次5分钟第三次30分钟以此类推。如果5次都失败订单状态置为ISSUE_FAILED同时给运营后台推送一条告警记录。这里最怕的是“把所有异常一律无限重试”一旦是舱位失效重试十次也白搭还占着队列资源更严重的是会把正常出票任务卡在后面。实操经验出票任务一定要设置单独的高优先级队列。像我们线上分了ticket-issue和notify两个队列头等舱、商务舱出票走ticket-issue队列普通经济舱和通知类消息走同一个队列靠优先级区分。高峰期不会互相拖后腿。4. 电子行程单PDF生成与Laravel Storage CORS实战4.1 为什么PDF文件要放storage而不是public订单出票成功后系统需要给乘客生成电子行程单PDF并且提供在线预览和下载。刚开始我们直接把PDF文件存到public/pdf目录下URL直接暴露后来发现几个问题第一个是安全问题行程单上包含乘客姓名、证件号、票号等敏感信息直接放在public目录下等于任何人拿到链接就能下载没法做访问控制。第二个是备案问题用户访问路径如果直接带.pdf文件在部分CDN环境下会被浏览器直接下载而不是预览体验不好。正确的做法是把PDF写到storage/app/private/pdf/目录Laravel默认的local磁盘就是这个私有的storage目录外部不能直接访问。需要下载时通过一个控制器接口动态读取文件校验用户登录态和订单归属然后返回二进制流或者生成一个短时效的临时签名URL。4.2 storage PDF访问遇到CORS错误的场景与解决这里必须详细说说那个很典型的“laravel storage pdf cors 错误”。很多朋友在开发时会遇到前端网页请求PDF预览接口浏览器控制台报No Access-Control-Allow-Origin header is present on the requested resource但接口在Postman里测试又是正常的。这个问题的根子在于当浏览器以iframe或embed方式加载PDF时是浏览器发起的跨域请求不是普通的AJAX请求所以CORS中间件必须覆盖两个层面一是响应头二是对OPTIONS预检请求的处理。我们项目里的实际场景是前端部署在ticket.example.com后端接口在api.example.comPDF预览请求跨域。刚开始只给普通API接口加了全局CORS中间件结果PDF预览接口还是报错。排查后发现是因为Laravel的CORS中间件默认只处理了部分路由而我们那个下载PDF的接口用了自定义路由前缀中间件没覆盖到。解决方案是在app/Http/Middleware/里单独定义一个PdfCorsMiddleware核心逻辑如下public function handle($request, Closure $next) { if ($request-isMethod(OPTIONS)) { return response(, 204) -header(Access-Control-Allow-Origin, https://ticket.example.com) -header(Access-Control-Allow-Methods, GET, POST, OPTIONS) -header(Access-Control-Allow-Headers, Content-Type, Authorization) -header(Access-Control-Max-Age, 86400); } $response $next($request); $response-header(Access-Control-Allow-Origin, https://ticket.example.com); $response-header(Access-Control-Allow-Credentials, true); return $response; }用完这个中间件之后还需要在route上下发层面对PDF接口启用它比如Route::get(/pdf/{orderId}, [PdfController::class, download]) -middleware([auth:api, pdf.cors]);路径上一定要同时处理OPTIONS预检不处理的话浏览器会先发一个OPTIONS后面真实的GET就会直接在预检阶段被拦掉。这是“接口用Postman通、浏览器就挂”的最常见原因。4.3 动态PDF生成与输出行程单PDF的生成我们用的是barryvdh/laravel-dompdf通过Blade模板渲染HTML然后转PDF。关键点是中文字体dompdf默认字体不支持中文必须手动上传一个中文字体文件到storage/fonts然后在Blade里设置body { font-family: SimSun, sans-serif; }如果不设置字体生成出来的PDF里的中文全变成方块这个问题我第一次踩的时候排查了两个小时。另外PDF文件名不要用订单号裸奔比如TK202505201234.pdf而应该用航班号-乘客姓名-行程单这种可读格式比如CA1831-张三-电子行程单.pdf这样乘客下载后能一眼认出是什么文件。5. ThinkPHP后台与SQL监听、性能排查实战5.1 为什么后台管理用ThinkPHP更顺手聊完了Laravel这边的大半业务再回头说说ThinkPHP在系统里承担的那些事。后台运营管理端有大量表单页面航班信息录入、舱位价格调整、渠道政策配置、每日订单对账。这类功能有一个共同点——CRUD密集、权限粒度细、报表需求多。ThinkPHP的快速生成和模板布局确实省心拿一个“航班管理”页面来说控制器里写一个列表方法配合内置的分页、搜索、字段校验一个页面上线也就个把小时。两个框架在同一个项目里协同还有一个隐性好处——可以渐进式迁移。如果未来运营后台要往Laravel迁移thinkphp这边先不动新功能直接用Laravel写两边通过统一的权限表和操作日志表连接不用一上来就做“大爆炸”式重构。这个思路非常适合脱离单框架思维、逐步演进的老项目。5.2 ThinkPHP监听SQL的代码到底加在哪里热词里出现了“ThinkPHP监听SQL的代码一般添加在哪里”这个话题几乎是每个TP项目都会碰到的。TP框架本身提供了查询日志和监听机制但要统一记录SQL和参数常见的做法是在数据库连接的配置项里开启SQL监听。以ThinkPHP 6.x为例在config/database.php的connections下可以这样配置connections [ mysql [ // ...原有配置 trigger_sql true, ], ],不过更通用、更好用的方案是使用ThinkPHP的事件监听机制在服务注册阶段挂一个DB事件监听器。单独建一个监听器类比如app/listener/DbSqlListener.phpnamespace app\listener; use think\facade\Log; class DbSqlListener { public function handle($event) { // $event 是 PDOStatement 实例可通过 $event-queryString 拿到SQL if (isset($event-queryString)) { Log::channel(sql)-info([SQL] . $event-queryString); } } }然后在上面的app/event.php里注册事件return [ listen [ DbListen [ \app\listener\DbSqlListener::class, ], ], ];实际使用中我更喜欢直接在全局的app/middleware.php里加一个请求结束的中间件把整个请求周期内执行的SQL统一收集起来按请求级打印或记录。一方面能避免每条SQL都写一个日志行、日志量过大另一方面可以附带当前请求的URL、管理员ID等信息排查问题时能直接对应上“哪个操作引发了哪些SQL”。5.3 慢SQL排查思路与索引优化案例这套系统上线后我们遇到过一个典型的慢查询问题运营后台的订单列表页打开要四秒。用TP的监听SQL日志一看发现查询orders表时没有带上status索引每当运营点击“异常订单”筛选时条件WHERE status IN (ISSUE_FAILED, REFUNDING)没法走索引全表扫描。当时orders表大概有三十万条数据每次筛选扫一遍就是几百毫秒。解决方式很简单给orders表的status字段加上普通索引再把订单创建时间、支付时间、出票时间三个字段建立联合索引。经过EXPLAIN验证扫描行数从三十万降到了几百条。另外把订单列表的每次请求改为只查询当前页数据通过简单的paginate分页机制彻底避免一次性拉全量。像这样的SQL性能排查完全可以在开发阶段就通过SQL监听日志提前发现。ThinkPHP的dump慢日志配合EXPLAIN是最基础也最有效的优化手段。6. 常见问题速查与项目复盘6.1 开发者高频踩坑清单把整个项目从开发到上线的过程中遇到的问题整理成一个速查表很多都是“不跑一遍根本不知道”的坑现象可能原因解决建议支付回调后订单状态没更新回调URL未加CSRF、验签失败、幂等锁被卡检查路由是否排除CSRF打印日志确认回调是否到达出票任务在队列里堆积出票接口响应慢、失败重试次数设置过高单独队列、设置指数退避、监控队列长度PDF中文变方块dompdf未配置中文字体在storage/fonts加入中文字体并在Blade模板声明font-familyCORS预检失败OPTIONS没返回正确的CORS头单独中间件处理OPTIONS请求并匹配到具体路由机票库存超卖扣减库存SQL不是原子操作使用WHERE inventory 0结合decrement原子扣减双框架时间差八小时Laravel默认UTCThinkPHP按PHP配置统一时区到Asia/Shanghai入口处显式date_default_timezone_set这些坑每一个背后都对应一条线上的血泪教训但一旦把排查方法沉淀成文档后面新人接手也不会再掉进同一个坑里。6.2 上线前后的几条经验性总结真正把这个系统从零跑通有几个判断一直在我脑子里转比较值得拿出来分享。第一双框架架构不是设计上的“不干净”而是解决现实问题的务实手段。团队熟TP、又要依靠Laravel生态做C端API那就不必纠结“到底该用哪个”。关键是边界要清晰千万别出现一个订单创建逻辑上午写在Laravel里、下午又复制到ThinkPHP里两边各自为政的情况。第二订单系统的核心不是“写功能”而是“管状态”。状态机、幂等、日志、对账这些基础设施花的时间越多上线后的客服和返工成本就越低。我们光是支付回调的日志表就保留了近半年的数据线上几乎没因为支付问题扯过皮。第三SQL监听这个事无论用ThinkPHP还是Laravel一定要在项目初期就配置好。等数据量上来、慢查询出来再看虽然也能查但开发阶段看不到全貌埋下的隐患会在你最不想出问题的时候爆出来。最后再分享一个处理这类系统的小技巧给每个线上订单和乘客都生成一个统一的“业务流水号”内部关联的时候用它不要直接把自增主键暴露到外部。比如订单号用TK20250520加随机串乘客编号用短哈希这样既方便日志检索也避免别人通过订单号猜测业务量。做订票系统也好做其他交易类系统也好这种编号习惯越早统一越好后期想改都是动筋骨的事。