☰
ThinkPHP+Laravel双框架火车售票系统设计与实现
2026/9/30 9:25:47 网站建设 项目流程

做火车售票系统这类业务,第一反应通常是先挑框架。我当时接手这个项目时,代码库里躺着两套 PHP 框架:老的管理后台跑在 ThinkPHP 上,新的对外 API 和核心交易链路已经切到了 Laravel。这种“双框架共存”的状态听起来有点别扭,但在真实团队里其实很常见——历史代码不能丢,新业务又想用更好的工具。这篇文章就围绕这套基于 ThinkPHP 和 Laravel 的火车售票系统,从需求拆解、数据库设计、余票库存、下单支付、后台管理、部署运行到安全排查,完整过一遍设计和实现过程。

不管你是正在做毕业设计的学生,还是刚入行的 PHP 开发,又或者接手了类似“新旧系统并行”项目的同学,这篇文章里提到的表结构、扣库存逻辑、接口对接方式、常见报错排查,都是可以直接抄作业的。我尽量把“为什么这么做”讲清楚,而不是只丢一段代码,毕竟你也不想抄完发现自己完全没法维护。

1. 双框架并存:ThinkPHP 与 Laravel 在售票系统中的分工

1.1 一个项目里为什么要同时用两个 PHP 框架

这套系统最早是用 ThinkPHP 写的,后台管理、车次维护、订单查询这些内部功能都已经跑了好几年。后来业务要对外开放 App 和小程序端,需要更灵活的 API 设计、队列处理、事件驱动和更完善的中间件机制,Laravel 明显更合适。但把整个后台重写成 Laravel 成本太高,风险也大,所以最终采用了“双轨并行”的架构:ThinkPHP 负责管理端,Laravel 负责 API 端。

两个框架之间不是物理隔离,而是通过 HTTP 接口通信。管理后台里点击“发布车次”时,ThinkPHP 会调用 Laravel 的 API,把车次信息同步到核心交易库;用户端的购票、余票查询、支付回调全部走 Laravel。这样既保住了老系统的稳定性,又让新业务享受到 Laravel 的工具链。双框架的关键是边界要清晰,不能这边请求写到一半又去直接操作对方的表,否则出了问题很难排查。我在项目里还写了一份接口文档,把两个框架之间的调用关系固定下来,新增功能时先看文档再动手,避免越权访问对方的数据库。

1.2 核心需求拆解与模块边界划分

火车售票系统的功能看起来简单,真做起来很琐碎。我按用户和管理员两个视角拆了一下核心需求,大致是这样的:

角色功能说明
用户车次查询按出发地、目的地、日期查车次与余票
用户下单购票选择车次、乘客、席别,生成订单
用户在线支付对接支付渠道,回调后出票
用户退票退款按规则退票,释放库存,原路退款
管理员车次管理车次、站点、开点、到点、票价维护
管理员余票监控查看各车次各日期的销售情况
管理员订单管理订单查询、异常处理、导出报表

模块边界上,我建议把“库存服务”单独拎出来,因为它被查询、下单、退票、超时释放这几条链路共同使用。再往下拆分就是:用户服务、车次服务、订单服务、支付服务、后台管理。Laravel 里的路由和中间件正好能按这个边界组织,比如/api/train/search、/api/order/create、/api/order/pay/notify,对应的控制器和 Service 类也按模块分目录,后续加功能不会把代码越搞越乱。

补充一点选型理由:ThinkPHP 自带的分页、验证器、模板引擎,做管理后台效率很高;Laravel 的 Eloquent、队列、事件、中间件、测试工具,对核心交易链路更友好。不是说一个框架比另一个强,而是要看场景。我们后来在压力测试中也验证过,同样的查询在 Laravel 中通过 query builder 和缓存层可以压到很低的响应时间,ThinkPHP 管理后台因为流量不大,反而不用过度优化。

2. 数据库设计与核心表结构

2.1 用户、车次、订单等表怎么设计

这个系统里,最核心的表是车次、站点、车次停靠、订单、支付记录。刚开始图省事,我把好几个表合成一张大表,结果一到高峰期查询就慢得离谱。后来老老实实拆表,加索引,数据模型清晰多了。下面是几张大表的关键字段:

stations站点表:id、name、city、pinyin、status。trains车次表:id、train_no、train_type、start_station_id、end_station_id、departure_time、arrival_time、status。train_stops车次停靠表:id、train_id、station_id、stop_order、arrival_time、departure_time、stop_duration。orders订单表:id、order_no、user_id、train_id、travel_date、total_amount、status、created_at、expired_at。order_items订单明细表:id、order_id、passenger_name、id_card、seat_type、carriage_no、seat_no、fare。payments支付记录表:id、order_id、pay_no、channel、amount、status、paid_at。

有几个细节很重要。订单号不要用数据库自增 id,而是生成唯一业务编号,比如“日期+随机数”,方便后续对账和支付回调时定位。订单状态字段建议用整数或简短字符串,统一约定:0 待支付、1 已支付、2 已出票、3 已退票、4 已取消,代码里写成常量,不要散落在业务逻辑里。所有涉及钱的表都要记录金额单位,我习惯用“分”存储,避免浮点数误差。还有,乘客身份证号这类敏感信息要做脱敏和权限控制,日志里不要打印完整号码。上线后有一次排查问题需要查用户信息,我才体会到这些细节有多值钱——不然日志一打,等于把用户隐私直接贴脸上。

2.2 余票库存的数据模型与一致性方案

火车票的库存模型比普通商品库存复杂,因为一条车次包含多个区间。比如 T1 次列车从北京到上海,中间经停济南,一个乘客买北京到上海,占用的实际上是北京-济南和济南-上海两段库存。所以设计余票表时不能只记“整列车剩余多少张”,而要把库存粒度放到区间上。

我用的表结构是train_stocks:id、train_id、travel_date、segment_from、segment_to、seat_type、total_stock、sold_count、version。查询某段余票时,看该区间 sold_count 是否已达到 total_stock。下单扣库存时,把起点站到终点站之间经过的所有库存区间都减一。这里必须保证原子性,我推荐用 Redis 做热数据的原子扣减,数据库表作为最终准确数据。Redis 扣减用DECR命令,如果 key 的值小于 0 就说明卖超了,需要回滚;数据库里再用事务更新 sold_count 做兜底。

为什么不只依赖数据库行锁?因为余票查询是高并发读,数据库行锁在热点车次上容易变成瓶颈。Redis 的内存操作可以扛住绝大多数查询和扣减,异步再把已售数量同步回数据库。同步可以用 Laravel 队列写一个定时对账任务,每 30 秒把 Redis 里的销量同步到 MySQL,同时记录版本号,用乐观锁防止并发更新丢失。压测时我们发现,这种“Redis 扣减 + 数据库兜底 + 定时对账”的方案,在突发瞬时流量下也能保持数据一致。

3. 核心业务逻辑实现(Laravel API 部分)

3.1 车次与余票查询的实现

车次查询是用户接触最多的功能。我按“出发城市、到达城市、日期”的条件搜索,先查trains和train_stops关联出符合条件的车次,再批量读取对应日期的余票。代码看起来大概是这样的:

$trains = Train::with(['stops' => function ($query) use ($from, $to) { $query->whereIn('station_id', [$from, $to]) ->orderBy('stop_order'); }]) ->whereHas('stops', function ($query) use ($from) { $query->where('station_id', $from); }) ->whereHas('stops', function ($query) use ($to) { $query->where('station_id', $to) ->where('stop_order', '>', $fromOrder); }) ->get();

这段查询的写法不是重点,重点是加索引。train_stops表里train_id、station_id、stop_order都要有联合索引,不然车次一多必然慢。余票数据我直接用 Redis 批量取,key 设计成train:stock:{date}:{train_id}:{from}:{to}:{seat_type},值存剩余张数。查询接口先查 Redis,没有缓存或缓存过期再查 MySQL,回填缓存时设置 10 秒过期,防止热点数据不一致太久。

这里有个小坑:同一车次不同日期是不同 key,如果不加日期的参数,会出现“查到了车次但无票”的乌龙。我一开始就漏了日期维度,结果测试人员反馈说“明天显示无票、后天又显示有票”,查了半天才发现是 key 没区分日期。所以 key 设计一定要把业务维度写全,特别是带日期维度的数据,缓存和查询都要把日期作为第一条件,不能想当然认为“今天能查的明天也一定一样”。后来我们在测试用例里专门补了跨日查询的场景,才把这个问题堵住。余票查询接口还有一个优化点:热门车次和冷门车次的缓存策略要分开。冷门车次缓存 30 秒都没问题,热门车次最好只缓存 5 秒,并且加缓存击穿保护,否则一旦缓存过期,流量会直接打到数据库上,慢查询立马就冒出来。

3.2 下单、支付回调与出票流程

下单是最容易出 bug 的环节。我的实现顺序是:先校验车次和余票,然后创建订单,同时扣减 Redis 库存,再创建支付流水。扣减库存这一段,注意用原子操作,并且要做好“扣减成功后创建订单失败”的补偿:

$lockKey = 'lock:train_stock:' . $trainId . ':' . $date . ':' . $from . ':' . $to; $lock = Redis::lock($lockKey, 10); try { if (!$lock->get()) { return response()->json(['message' => '系统繁忙,请稍后重试'], 429); } $key = 'train:stock:' . $date . ':' . $trainId . ':' . $from . ':' . $to . ':' . $seatType; $left = Redis::decr($key); if ($left < 0) { Redis::incr($key); return response()->json(['message' => '余票不足'], 422); } DB::transaction(function () use (...) { $order = Order::create([...]); $order->items()->create([...]); Payment::create([...]); }); } finally { $lock->release(); }

Redis 的DECR命令返回负数时,千万要把数量加回去,否则就真的“卖多了”。锁用的是 Laravel 自带的 Redis 锁,防止极端并发下同一个订单重复扣减。注意锁的粒度越小越好,钥匙里一定要有日期和区间,不然两个不同区间本来可以并行买,结果被锁串行化了。

支付完成后,支付平台会回调解锁。回调最关键的是验签和幂等。验签失败直接拒绝;同一个订单可能收到多次回调,所以要先查订单状态,如果已经是“已支付”就直接返回成功,不要重复处理。确认支付成功后,更新订单为已支付,然后推一条“出票任务”到消息队列。出票是异步动作,如果列车座位需要分配真实座号,这步可以在队列里慢慢处理,完成后给订单绑定车厢和座号。这样用户支付成功后立刻看到“出票中”,体验比同步等很久要好。

为什么一定用队列而不是同步出票?因为支付平台回调的响应时间有要求,如果同步去执行写票、通知、短信等操作,超过回调超时时间,支付平台会认为失败,反复回调,反而增加压力。队列还能带来重试机制,出票失败可以在消费者里重试三次,彻底失败后进入人工处理队列。出票成功之后还要做一件事:更新train_stocks表的sold_count,把 Redis 里的扣减结果同步给数据库。这个动作可以在出票队列里一并处理,避免再开一个定时任务。

3.3 退票与退款处理

退票的流程是反向下单。先判断当前时间和发车时间的关系,在退票规则允许的窗口内才执行。然后在一个数据库事务里做三件事:更新订单状态为已退票、把途经区间的库存加回、创建退款记录。退款调用支付平台的退款接口,同样要做幂等处理。

实现时注意顺序不能乱:先释放库存,再更新订单,最后发起退款。如果退款失败要把订单状态标记为“退款中”,同时记录失败原因,后续由定时任务扫描重试。退票还有一个小陷阱:如果用户买的是联程票或者套餐,退其中一段时要检查剩余区间的库存能否满足原订单信息;不过在单张车票的场景下,直接把区间库存加回就行。

这里的核心原则是:所有状态变更都放进DB::transaction,并且要用乐观锁或唯一约束保证退款订单不能重复。我在refunds表里给order_id加了唯一索引,防止同一个订单被发起两次退款。退款还会涉及手续费,这个可以单独用一张refund_fees表记录计算规则,而不是在代码里写死,方便运营后续调整退票费率。

4. ThinkPHP 后台管理模块实现

4.1 后台框架与权限控制

后台沿用 ThinkPHP,主要是因为它内置的分页、验证器和模板布局能省不少事。我把后台分成站点管理、车次管理、停靠管理、订单管理、用户管理、权限管理这几个模块。权限控制用简单的 RBAC:管理员表、角色表、菜单表和它们的关联表,管理员登录后从角色查菜单,再逐项校验按钮权限。

ThinkPHP 里做权限中间件很简单,比如在app/middleware.php注册一个全局中间件,在进入控制器之前校验当前用户的菜单权限。校验不通过时返回统一 JSON 或者跳转到无权限页面。用户密码不要用 md5 或者明文,我用 password_hash 加密,校验用 password_verify。登录状态用 session,配合 Session 中间件,后台保持七天登录可以直接延长 session 过期时间。

权限粒度建议至少到“操作方法”一级。比如订单列表可以看,但退款按钮只有财务角色能用。我在权限表里存的是“控制器/方法”的字符串,比如order/refund,然后用中间件比对。这样后续增加操作只需在菜单表里加一行记录,不用改代码。权限这东西做得太粗,运营同事会时不时误操作;做得太细,又会让开发累死。所以我会把按钮权限和菜单权限分开,菜单管“能不能看到这个页面”,按钮管“点了有没有效果”。

4.2 车次、票价与订单管理

车次管理的核心是 CRUD 加数据校验。添加车次时需要检查:起点站和终点站不能相同、时间上到达时间必须晚于出发时间、停靠站顺序不能重复。ThinkPHP 的验证器可以直接在控制器里做:

$validate = new TrainValidate(); if (!$validate->check($data)) { return json(['code' => 1, 'msg' => $validate->getError()]); }

校验通过后写入 trains 表和 train_stops 表,同时调用 Laravel 的接口把车次同步到 API 端。这里我加了一个同步标记字段sync_status,同步成功才置为正常,否则后台列表里会明显标红,方便运营发现问题。同步失败还会写一条告警记录,通过后台通知或邮件提醒管理员处理。

订单管理页面主要处理搜索、导出和异常退款。搜索条件包括日期段、车次号、订单号、手机号。写 SQL 时注意不要用%订单号%这种全模糊查询,会全表扫。订单号一般可以精确匹配,日期范围走索引。导出用fputcsv或 PHPExcel,数据量大时直接分块读,防止内存溢出。我在这个模块里踩过的坑是导出几十万条数据时把 PHP 内存撑爆,后来改成每次读 5000 条写入临时文件,再合并下载。如果只是给运营提供几万条数据,这种方式完全够用,没必要上大数据组件。

5. 双框架部署与 ThinkPHP 项目运行

5.1 项目目录与接口对接

这套系统的源码目录我分成两个根项目:api放 Laravel,admin放 ThinkPHP。线上用 Nginx 同一个域名下通过不同路径分发,或者直接用两个子域名。同一域名下配置需要注意:

server { listen 80; server_name train.example.com; location /admin { alias /www/wwwroot/train/admin/public/; try_files $uri $uri/ /admin/index.php?s=$uri&$args; } location /api { alias /www/wwwroot/train/api/public/; try_files $uri $uri/ /api/index.php?$query_string; } location ~ \.php$ { fastcgi_pass 127.0.0.1:9000; fastcgi_param SCRIPT_FILENAME $request_filename; include fastcgi_params; } }

实际生产环境我更推荐两个独立的 server 块,甚至两个 PHP 进程池,防止一边挂了把另一边拖死。双框架之间调用 API 时,我用了一个简单的签名机制:ThinkPHP 端发起请求时带app_id、timestamp、sign,sign 是请求参数加上密钥按约定排序后做 MD5。Laravel 这边写一个中间件验签,同时检查时间戳误差不超过 10 分钟,防止重放攻击。这个机制不复杂,但足以拦截掉大部分乱来的请求。

接口对接还要考虑日志。ThinkPHP 调用 Laravel 接口时,我会在两边各写一条请求日志,记录请求参数、返回结果、耗时。这样一旦后台操作没有生效,直接查日志就能看到是请求没发出去,还是接口返回异常。分布式系统最怕两边日志孤岛,能把一条请求从入口到出口串起来,排查效率能提升很多。

5.2 ThinkPHP 项目运行常见报错与解决

很多同学拿到一个 ThinkPHP 项目,本地怎么都跑不起来,其实问题集中在几个地方。

第一是权限。ThinkPHP 运行时会往runtime目录写缓存、日志。Linux 下经常遇到runtime 目录没有写权限的报错,直接执行:

chmod -R 777 runtime

本地调试图省事可以 777,生产环境建议把 owner 改成 PHP-FPM 进程用户,不要真的用 777,安全风险太大。

第二是伪静态。Nginx 下如果访问首页正常,跳转到其他路由就 404,多半是伪静态规则没配好。ThinkPHP 的 Nginx 伪静态规则是:

if (!-e $request_filename) { rewrite ^(.*)$ /index.php?s=$1 last; }

Apache 环境则需要开启 mod_rewrite 并放.htaccess。

第三是缺 PHP 扩展。比如fileinfo、opcache、pdo_mysql,缺少时有些功能会静默失败。用php -m检查,缺哪个装哪个即可。用命令行调试 ThinkPHP 也会很顺手。进入项目根目录后执行php think run可以启动内置开发服务器,适合快速验证路由是否正常。如果发现某个接口报 500,打开runtime/log/年月日.log看具体报错,比看浏览器空白页高效得多。

再分享一个实际场景。有个同事接手项目后一直说“后台登录不进去”,本地检查了一下午,最后发现是.env数据库密码配错,但数据库连接失败后 ThinkPHP 把异常吞掉了,直接 500。后来我让他把app_debug打开,报错马上显示出来。所以排查这些问题时,第一步永远是开 debug、看日志,而不是猜。

6. 安全防护与排查技巧实录

6.1 Laravel 与 ThinkPHP 的安全基线

这个项目上线前,团队还把安全加固仔细过了一遍。前阵子社区里讨论过 Laravel 的一些编号型 CVE 公告,比如 CVE-2024-29291 的相关修复。这种漏洞公告出来后,最有效的响应不是去网上找所谓“漏洞复现脚本”,而是老老实实对照项目依赖版本,及时执行依赖升级。

比如 Laravel 项目根目录的composer.json里锁定了框架版本,看到安全公告后第一时间执行composer update --dry-run查看可升级的包,确认后更新并跑一遍回归测试。我自己的习惯是每周用composer audit扫一次依赖安全问题,有问题就升级。同时,无论 ThinkPHP 还是 Laravel,生产环境必须做到这几条:.env文件禁止被 Web 访问,APP_DEBUG关闭,数据库连接使用最小权限账号,控制器里对用户输入做参数校验,所有 SQL 用参数绑定,所有表单加 CSRF Token。

反序列化攻击也要防。给用户的输入永远不要直接unserialize,而是走 JSON 格式,配合白名单校验。日志、缓存、文件上传目录尽量不暴露在 Web 根目录可访问的区域。安全没有银弹,像 CVE-2024-29291 这类公告提醒我们的是:依赖版本管理、代码复核和定期审计,才是真正的防线。

我这里说一个自己踩过的安全坑:刚开始上线时APP_DEBUG=true忘了关,结果线上一个异常页面把数据库密码打印出来了。当时运维截图给我,我冷汗都下来了。后来我在部署脚本里加了一步强制检查,如果.env中APP_DEBUG不为 false,CI 直接失败,绝不允许发布。这个坑太典型了,只要 debug 开在线上,数据库密码、密钥、目录结构分分钟暴露,即使没有任何主动攻击者,日志被搜索引擎收录也是风险。

6.2 线上问题排查的实操方法

火车票系统最容易出现的问题无非三类:查询慢、余票不一致、支付回调丢单。我按这三类给排查思路做一个速查表:

问题现象排查思路常用处理
余票查询接口慢看 MySQL 慢查询日志,是否走索引调整索引,增加 Redis 缓存
Redis 库存为负查看扣减逻辑是否忘记回滚补事务补偿,加锁
回调丢单看支付平台日志和本地订单状态加定时对账任务,人工补单
订单状态卡在“支付成功”查看队列是否消费失败观察队列日志,重试失败任务
后台列表打开很慢是否存在全表扫描分页大小、索引检查、字段索引优化

排查时第一步永远是看日志。Laravel 日志默认写在storage/logs/laravel.log,ThinkPHP 在runtime/log/下按天生成。如果日志太多,可以用tail -f实时跟踪筛选。开启 SQL 日志也很有用:Laravel 里写DB::listen监听或直接用log驱动记录每条 SQL 的耗时;ThinkPHP 可以打开配置文件的 SQL 日志开关。

我自己的经验是:支付回调这种关键路径一定要加“流水日志”,每次回调进来、验签结果、订单状态、处理结果都打一条,后面排查丢单直接按订单号 grep,十分钟就能定位。比在代码里临时瞎输出强太多。还有对账脚本,每天凌晨跑一次,对比支付平台账单和本地支付表,金额不一致自动告警。第一次上线后的第二天,对账脚本就发现有两笔订单因为网络原因没有收到回调,全靠脚本捞回来。从那以后,我再也不敢把“能看日志”当成唯一防线了。同时,库存对账脚本也不能省,Redis 里的余票和数据库里的 sold_count 每天对一次,发现问题就重新快照,宁可多跑几遍也不能让错账过夜。

这么一轮折腾下来,我最大的体会是:设计售票系统的时候,先别急着写代码,把库存模型和订单状态机画明白,后面就是往骨架上填肉。如果你手里正好有 ThinkPHP 和 Laravel 共存的旧项目,也不用急着迁移,把边界画清楚、接口收敛好、日志补完整,这套模式能稳稳跑好几年。最后分享一个小技巧:上线前把“支付回调重复请求”“用户重复点击买票”“退票时网络超时”这三个场景单独做成测试用例,反复跑几轮,你能躲掉线上八成以上的事故。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询