前几年在学校写课设的时候,我做过一个商城项目,当时最头疼的就是“订单下单后超过30分钟还没支付,就要自动取消”。一开始想简单,用户点开订单列表时页面上直接判断一下状态。后来发现只要用户不刷新页面,那些烂单就永远躺在那,导致库存还被占着,数据库里全是废数据。
后来实习时接到一个活儿,正好重构这套逻辑。项目用的是 ThinkPHP 8,我这才体会到,原来这类需求最合适的做法,是把“核单”的活儿交给一个命令行指令定时跑,再把“关单后要干的事”交给模型事件。既不用写一堆杂乱的控制器方法,也不会把业务逻辑憋在模型里。
今天把整个过程捋一遍,也算记录一下我这个菜鸟踩了几次坑之后,终于跑通的完整流程。
先明白要干什么
整个流程其实分两块:
- 定时找到那些超时未支付的订单,把它们状态改成“已关闭”——这是写一个自定义命令,不用走Web请求。
- 订单状态改成“已关闭”后,需要去释放库存、记录一条操作日志,甚至给用户发个站内信——这些辅助动作最好别散落在命令里,而是让订单模型自己知道自己被关单了。
这样以后项目的关单入口可能不止定时命令,哪天运营后台上线了“手动取消订单”按钮,只要调用同一个变更状态方法,那些该有的副产品都会自动完成。
先设计一下表结构
我这里的订单表字段大概长这样:
CREATE TABLE `order` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `order_sn` varchar(32) NOT NULL COMMENT '订单号', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '0待支付 1已支付 2已关闭', `created_at` datetime DEFAULT NULL, `expired_at` datetime DEFAULT NULL COMMENT '过期自动关闭时间', PRIMARY KEY (`id`), KEY `idx_expired_status` (`expired_at`, `status`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
下单时,肯定会把 expired_at 设成下单时间往后推30分钟,并且 status 先设为0。现在要做的命令,就是不停“扫”这张表。
写一条自定义指令
ThinkPHP 8 里生成命令非常简单,直接去项目根目录执行:
php think make:command OrderClose
执行完以后,你会在 appcommand 目录下看到 OrderClose.php 这个文件。打开它,里面已经有一个简单的占位符:
<?php
declare (strict_types = 1);
namespace appcommand;
use thinkconsoleCommand;
use thinkconsoleInput;
use thinkconsoleOutput;
class OrderClose extends Command
{
protected function configure()
{
// 指令名称
$this->setName('order:close')->setDescription('关闭超时未支付订单');
}
protected function execute(Input $input, Output $output)
{
$output->writeln('执行中...');
}
}
光这样还不够,TP 从 6.0 就已经支持自动加载命令,你在 config/console.php 里可以把刚才的类填到 commands 数组里:
'commands' => [
appcommandOrderClose::class
]
然后在命令行试一下:
php think order:close
如果你能看到 执行中... 就说明命令已经注册成功了。
把核心查询放进去
关单命令要干的事情,就是找出所有 status=0 且 expired_at 小于当前时间的单子。我建议用模型查询,而不是直接写 DB。为了更清爽,我先把订单表对应的模型创建了:
<?php
declare(strict_types=1);
namespace appmodel;
use thinkModel;
class Order extends Model
{
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
// 状态常量
const STATUS_PENDING = 0;
const STATUS_PAID = 1;
const STATUS_CLOSED = 2;
// 关闭订单
public function close()
{
$this->status = self::STATUS_CLOSED;
$this->save();
}
}
接下来在命令的 execute 里这样写:
use appmodelOrder;
protected function execute(Input $input, Output $output)
{
// 找出已过期但还没支付的订单
$expiredOrders = Order::where('status', Order::STATUS_PENDING)
->where('expired_at', '<', date('Y-m-d H:i:s'))
->select();
$count = 0;
foreach ($expiredOrders as $order) {
$order->close();
$count++;
}
$output->writeln('本次自动关闭订单数:' . $count);
}
写完以后,手动执行一次,你会发现库里那些过期的单子状态都变成了2。确实,就这么简单。
但为了不写死动作,引出模型事件
如果单单只是改 status,那当然用不上模型事件。但真实项目里有库存、有优惠券、有可能还有积分退回。如果在命令里写一堆逻辑,那每换一个入口(管理员手动关单)就要复制粘贴一遍。
我的做法是:把“关单后做什么”放到订单模型的事件里。TP8 中,模型事件可以在模型类里通过 static::onAfterUpdate 来注册,因为修改状态属于更新操作。也有一类更直观的:直接在模型里定义一个 onAfterUpdate 静态方法,TP 会自动把它注册为事件回调。
这里我采用了第二种,在 appmodelOrder.php 里面加一个静态方法:
// 模型事件回调:更新后执行
public static function onAfterUpdate($order)
{
// 如果本次更新把status变成了关闭状态,再执行附加业务
if ($order->status === self::STATUS_CLOSED) {
// 在这里恢复库存
// 记录日志
// 或者调用异步队列去推送消息
trace('订单号' . $order->order_sn . ' 自动关闭成功', 'order');
}
}
你可能注意到了,这个回调会在任何一次订单更新时被触发,所以我才在里面加了一行状态判断,只有真正变成已关闭时才去处理后面的事。
避免死循环
但如果我在 onAfterUpdate 里又去调用了某些方法导致订单再次 save(),那会再次触发这个事件,形成无限递归。所以严谨一点,可以在模型里加一个“关闭中”临时属性,或者使用事件标志。最简单粗暴的做法是把恢复库存这些动作放到队列里,并不在事件回调里直接对当前订单再次更新。
比如这样:
public static function onAfterUpdate($order)
{
if ($order->status !== self::STATUS_CLOSED) {
return;
}
// 此处仅把参数推送到队列
Queue::push(CloseOrderJob::class, ['order_id' => $order->id]);
}
但为了咱们这个教程代码能看懂,我只用了 trace 写个日志。真正线上业务里,再根据自己的实际环境把队列加上就行。
让命令只负责执行,模型负责行为
有人可能会说,既然 close() 方法里面已经调用 save() 了,那模型事件就自动会触发,没错。所以现在我们的命令里什么都不用多写,关单的事情还是那几行,但是后面的恢复库存等等已经在模型事件里去搞定了。
这样组件边界特别清晰:
- 命令管“找出谁该被关”,只负责遍历并调用 close()。
- 模型管“关单时自己应该怎么应对”,把状态修改和配套动作全封装在一个方法里。
- 事件监听“关单后还该干点啥”,利用统一入口自动执行。
如何做测试
如果你不写单元测试,可以直接造一条数据放进订单表,把 expired_at 改成过去时间,接着在命令行执行 php think order:close,最后去表里看状态,顺便看日志文件里有没有那个 trace 信息。
我当时犯过一个低级错误:拿模型 find() 查询时,明明订单存在,但它不触发事件。后来才发现是因为我把事件写错了方法名。TP8 模型事件里面,onAfterUpdate 表示“更新后”,不是“新增后”。订单创建时也插入了一条待支付数据,但新增时不会触发 onAfterUpdate,只有改成关闭时(更新操作)才触发。搞清楚这一点之后才算通顺。
让命令行跑起来:Crontab
手动测完,总不能让运营每天自己去跑命令吧?所以在服务器上要加上一条定时任务。假设你的网站路径是 /var/www/project,PHP 在 /usr/bin/php,crontab 里可以加:
* * * * * cd /var/www/project && /usr/bin/php think order:close >> /var/www/project/runtime/auto_order_close.log 2>&1
这里设成了每分钟跑一次。要注意了,如果你的订单表数据特别多,每分钟全表扫一次未免浪费数据库。建议 cron 安排在业务低谷,比如每五分钟执行一次,同时命令的查询要利用好索引。上面的表结构里我已经加上联合索引 idx_expired_status,这样会快不少。
锁表问题不严重,但也要注意:如果你的站点是一个集群,跑着多台应用服务器,那每台都会每分钟去执行一次这个命令,导致同一个订单被同时操作两次。这个问题要么你弄一个全局锁(例如在缓存里设一个 key),要么就用 SELECT … FOR UPDATE 把记录锁住。说白了就是加个“防并发”的保障措施。
这样设计的后续扩展
想一下,如果后面增加了一个“催付款”功能:超时前3分钟发一条短信提醒用户。那完全可以把刚才的 close() 拆成两个方法:remindExpiring() 和 close(),然后让定时命令在每天的几个点去执行提醒任务。但提醒也只是记录一条消息,未必需要改变订单状态。如果再配合队列,整个压力也小很多。
还有一个点需要小心:不要在一个 PHP 进程里用命令行处理成千上万的订单。我建议命令里先查出一个 id 数组,然后用 chunk 分批处理,否则一次性把数百条记录载入内存,后续可能会内存溢出。可以考虑:
Order::where('status', Order::STATUS_PENDING)
->where('expired_at', '<', nowTime)
->chunk(100, function ($orders) {
foreach ($orders as $order) {
$order->close();
}
});
写代码时的几个小细节
TP8 里 time() 返回的是 Unix 时间戳,所以跟数据库里的 datetime 比较时,我得使用 date('Y-m-d H:i:s', time()),不然字符串会不符。我第一次直接用了 datetime < time(),被框架转换成整数后再比较,结果没匹配出来,尴尬了好一阵。
另外,如果你在模型里定义了事件静态方法,又想在别的地方看到具体日志,最好给原本 trace 配置设置好日志级别。否则你买一包辣条的时间,日志文件可能连个屁都不写。
最后一点碎碎念
其实 ThinkPHP 本身并不限制你去怎么写这种自动任务,它只是给你提供了命令、提供模型事件,但很多初学者都会忽略这些“轻量级”的存在。往往为了快速完成,直接把关单逻辑塞进一个接口里,然后再让前端定时调这个接口。等访问量稍微大一点,那个接口就会成为压垮服务器的最后一根稻草。
从这以后,我碰上有天然延迟属性的任务,比如“超过多少分钟未支付关闭”“超过多少天自动收货”,都会先想是否能用命令行指令配合模型事件来搞定。如果你也还在用奇怪的法子做这种事,不妨干脆试一下 TP 的 command,真的能少掉很多头发。

