ThinkPHP 8 定时关单实战:让模型事件和自定义指令各司其职

2026-09-08 0 725

前几年在学校写课设的时候,我做过一个商城项目,当时最头疼的就是“订单下单后超过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=0expired_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,真的能少掉很多头发。

ThinkPHP 8 定时关单实战:让模型事件和自定义指令各司其职
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 thinkphp ThinkPHP 8 定时关单实战:让模型事件和自定义指令各司其职 https://www.taomawang.com/server/thinkphp/2731.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务