ThinkPHP 8 订单状态机实战:把散落各处的 if 判断收进一张转移表

2026-10-06 0 123

去年 Q3 接手的一个电商项目,订单模块的代码让我有点头大。同一个订单状态字段,在十几个地方被 update 过。下单后待支付是 0,用户付款后改成 1,超时取消改成 9,用户主动取消也改成 9,退款改成 10,发货改成 2,签收改成 3,最后完成是 4。每个修改点上都有不同的前置判断和后置副作用。

具体到麻烦程度:修改订单状态的代码散落在 30 多个方法里。有些地方判了前一状态、有些没判;有些地方发短信、有些忘了发;有些地方扣库存、有些地方写的是”等运营手动处理”。日志里搜一次订单号的流转,能搜出七八条 update,但顺序经常对不上。

有一天运营发现一批订单状态”倒着走了”——本来是已支付,被人手动改回了待支付。代码里查了半天,发现是客服后台那个取消按钮没判断前置状态,客服一点,把已支付的订单硬生生改成了取消。那个月做了两次数据修复,我自己贴了几百块进去给用户赔了现金券。

那次之后我把这块彻底重写了一遍。方案就两个东西:一张状态转移表 + 一套领域事件系统。用 ThinkPHP 8 自带的 Event 门面加 PHP 8.1 的 enum,一共不到 300 行代码,把之前 30 多个方法的逻辑全收敛了。

问题的本质在哪

先摆清楚问题,才能想清楚方案。

第一个问题是状态之间的合法性判断没有一个权威来源。每个修改点都要自己判断”我能不能从当前状态改到目标状态”,判错了没人拦着。有人写 if ($order->status === 0),有人写 if (in_array($order->status, [0, 8])),规则全在各自脑子里,从来没在一处总结过。

第二个问题是状态的副作用跟状态变更耦合在一起。改状态的地方,还得负责发短信、发通知、扣库存、写审计日志。业务逻辑和副作用搅在一起,改哪个都提心吊胆。

第三个问题是没有审计视角。想知道”这个订单过去 24 小时状态怎么变化的”,得去翻日志,还得自己按时间排序。状态机这种东西,一次变更应该留下一条结构化的记录,而不是散成几行 update orders set status = ?。

方案思路

想清楚问题之后,方案就很直接了。

定义 OrderStatus 枚举,把 5 个状态显式表达出来。定义 OrderStateMachine 类,里面只有一张 transitions 数组,说明”从哪个状态可以转到哪个状态”。任何状态变更都走这个类,其他方式一律不允许。

状态变更成功后,触发一个 OrderStateChanged 领域事件,事件里带着订单对象、旧状态、新状态、变更原因、操作人。所有副作用都挂在事件的监听器上,各自处理各自的,跟状态变更主体彻底解耦。

整个架构是这样一个流向:

业务代码 → 状态机校验 → 数据库更新 → 触发事件 → 多个监听器各干各的

下面一样一样展开。

第一步:定义状态枚举

PHP 8.1 起支持枚举。用它替代那些 const STATUS_PENDING = 0;,好处是 IDE 能补全、类型系统能校验、打印出来能直接看到名字。

<?php
namespace appcommonenums;

enum OrderStatus: int
{
    case Pending    = 0;    // 待支付
    case Paid       = 1;    // 已支付
    case Shipped    = 2;    // 已发货
    case Received   = 3;    // 已签收
    case Completed  = 4;    // 已完成
    case Cancelled  = 9;    // 已取消
    case Refunding  = 10;   // 退款中
    case Refunded   = 11;   // 已退款

    public function label(): string
    {
        return match ($this) {
            self::Pending   => '待支付',
            self::Paid      => '已支付',
            self::Shipped   => '已发货',
            self::Received  => '已签收',
            self::Completed => '已完成',
            self::Cancelled => '已取消',
            self::Refunding => '退款中',
            self::Refunded  => '已退款',
        };
    }

    /** 是否是终态(不可再变化) */
    public function isFinal(): bool
    {
        return in_array($this, [self::Completed, self::Cancelled, self::Refunded], true);
    }
}

注意 Cancel 和 Refunded 被定义为终态。状态机里终态就是终态,不能再改,这个规则约束能避免不少麻烦。

还有一个细节,我给值留了间隔(3 和 4 之后是 9,9 之后是 10、11),这样将来插入新状态的时候不用重新编号。这个习惯是在数据迁移上吃过一次亏换来的。

第二步:状态转移表

这是整个方案的核心。整个订单的状态流转就靠这张表约束。

<?php
namespace appcommonorder;

use appcommonenumsOrderStatus;
use appcommonexceptionStateTransitionException;

class OrderStateMachine
{
    /**
     * 状态转移表
     * 键是当前状态,值是可以转移到的目标状态列表
     */
    private const TRANSITIONS = [
        OrderStatus::Pending->value => [
            OrderStatus::Paid,
            OrderStatus::Cancelled,
        ],
        OrderStatus::Paid->value => [
            OrderStatus::Shipped,
            OrderStatus::Cancelled,   // 未发货前可取消
            OrderStatus::Refunding,
        ],
        OrderStatus::Shipped->value => [
            OrderStatus::Received,
            OrderStatus::Refunding,
        ],
        OrderStatus::Received->value => [
            OrderStatus::Completed,
            OrderStatus::Refunding,
        ],
        OrderStatus::Completed->value => [
            OrderStatus::Refunding,   // 售后期内可申请退款
        ],
        OrderStatus::Refunding->value => [
            OrderStatus::Refunded,
            OrderStatus::Paid,        // 退款申请被拒,退回已支付
        ],
        OrderStatus::Cancelled->value => [],
        OrderStatus::Refunded->value  => [],
    ];

    /**
     * 校验一个转移是否合法,不合法直接抛异常
     */
    public static function assertCanTransit(
        OrderStatus $from,
        OrderStatus $to
    ): void {
        if ($from === $to) {
            throw new StateTransitionException(
                "订单已经是 [{$from->label()}] 状态,无需变更"
            );
        }

        $allowed = self::TRANSITIONS[$from->value] ?? [];

        if (!in_array($to, $allowed, true)) {
            throw new StateTransitionException(
                "订单不允许从 [{$from->label()}] 变更为 [{$to->label()}]"
            );
        }
    }

    /**
     * 查询某个状态下所有允许的目标状态
     */
    public static function allowedFrom(OrderStatus $from): array
    {
        return self::TRANSITIONS[$from->value] ?? [];
    }
}

这张表的价值在于:任何人都能一眼看清楚订单的状态流转规则。有新同事问我”已发货的订单能不能取消”,我让他直接看这张表,5 秒钟就有答案,不用去翻代码。

而且这是一个”白名单”机制——不在表里的转移全都禁止。如果哪天真的需要新的转移路径,必须显式地改这张表,也就必然经过 review。这是从机制上避免”没判断前置状态就改”这种事故的做法。

第三步:领域事件

状态变更成功后,要触发一个事件。事件本身很简单,就是一个数据载体。

<?php
namespace appcommonorderevent;

use appcommonenumsOrderStatus;
use appmodelOrder;

class OrderStateChanged
{
    public function __construct(
        public readonly Order $order,
        public readonly OrderStatus $from,
        public readonly OrderStatus $to,
        public readonly string $reason = '',
        public readonly ?int $operatorId = null,
    ) {
    }
}

用 readonly 修饰的属性,创建后不能改。这是”事件是过去发生的事”这个语义的体现——已经发生的事不能再改。

第四步:状态变更的唯一入口

写一个 OrderService,状态变更只在这里发生。其他任何地方想改订单状态,都得调它。

<?php
namespace appcommonorder;

use appcommonenumsOrderStatus;
use appcommonordereventOrderStateChanged;
use appmodelOrder;
use thinkfacadeDb;
use thinkfacadeEvent;

class OrderService
{
    /**
     * 变更订单状态
     *
     * @param Order             $order
     * @param OrderStatus       $target
     * @param string            $reason
     * @param int|null          $operatorId  null 表示系统自动变,非 null 表示后台操作
     */
    public function changeStatus(
        Order $order,
        OrderStatus $target,
        string $reason = '',
        ?int $operatorId = null
    ): void {
        $from = OrderStatus::from($order->status);

        // 1. 校验转移是否合法
        OrderStateMachine::assertCanTransit($from, $target);

        // 2. 落库
        Db::transaction(function () use ($order, $from, $target, $reason, $operatorId) {
            $order->status = $target->value;
            $order->updated_at = time();
            $order->save();

            // 记录状态变更历史
            Db::name('order_status_log')->insert([
                'order_id'    => $order->id,
                'from_status' => $from->value,
                'to_status'   => $target->value,
                'reason'      => $reason,
                'operator_id' => $operatorId,
                'created_at'  => time(),
            ]);
        });

        // 3. 事务提交后触发事件
        Event::trigger(new OrderStateChanged(
            $order, $from, $target, $reason, $operatorId
        ));
    }
}

几个细节值得说。

写日志这一步是必要的。我们之前那条 order_status_log 表,事后查问题的时候帮了大忙。每次客服说”这个订单怎么突然变成取消了”,一条 SQL 就能查出来是谁、什么时间、什么原因改的。这张表从上线到现在没缺过一条记录,就是因为它写在了唯一入口里——只要状态改了,就一定有日志。

事件是在事务提交之后触发。Db::transaction() 里面写完了之后,代码跳出 transaction 闭包,事务才提交。事件在这个之后触发,保证监听器看到的数据库状态是”变更已经生效”的。如果放在事务里,万一监听器里查了另一个表、或者调了个外部接口,可能因为事务没提交而读到旧数据。

监听器里的异常不影响主流程——这个后面单独说。

第五步:事件监听器

所有副作用都挂在监听器上。以”订单已支付”这个事件为例,可能需要做的事:

  • 给用户发”支付成功”的短信
  • 通知仓库准备发货
  • 更新销售统计
  • 如果用了优惠券,标记为已使用
  • 记录用户行为埋点

以前这些都塞在”支付回调”那个方法里,一个方法几百行。现在拆成独立的监听器类:

<?php
namespace applistenerorder;

use appcommonenumsOrderStatus;
use appcommonordereventOrderStateChanged;
use appcommonserviceSmsService;
use thinkfacadeLog;

class NotifyUserOnStatusChange
{
    public function handle(OrderStateChanged $event): void
    {
        // 只关心这几类状态变化
        $shouldNotify = in_array($event->to, [
            OrderStatus::Paid,
            OrderStatus::Shipped,
            OrderStatus::Refunded,
        ], true);

        if (!$shouldNotify) {
            return;
        }

        try {
            $template = match ($event->to) {
                OrderStatus::Paid     => 'payment_success',
                OrderStatus::Shipped  => 'order_shipped',
                OrderStatus::Refunded => 'refund_success',
            };

            SmsService::send($event->order->user_mobile, $template, [
                'order_no' => $event->order->sn,
            ]);
        } catch (Throwable $e) {
            // 短信失败不能影响主流程,只记日志
            Log::error('发送订单状态短信失败', [
                'order_id' => $event->order->id,
                'error'    => $e->getMessage(),
            ]);
        }
    }
}

另一个监听器负责通知仓库:

<?php
namespace applistenerorder;

use appcommonenumsOrderStatus;
use appcommonordereventOrderStateChanged;
use appcommonserviceWarehouseService;
use thinkfacadeLog;

class NotifyWarehouseOnPaid
{
    public function handle(OrderStateChanged $event): void
    {
        // 只在"待支付 → 已支付"这个转移上触发
        if ($event->to !== OrderStatus::Paid) {
            return;
        }

        try {
            WarehouseService::requestShipment($event->order);
        } catch (Throwable $e) {
            Log::error('通知仓库失败', [
                'order_id' => $event->order->id,
                'error'    => $e->getMessage(),
            ]);

            // 这种失败不能忽略,往运维群里推
            // 我们这里用一个简单的告警接口
            alarm('仓库通知失败', [
                'order_no' => $event->order->sn,
                'error'    => $e->getMessage(),
            ]);
        }
    }
}

日志埋点的监听器就更简单,写完就算:

<?php
namespace applistenerorder;

use appcommonordereventOrderStateChanged;
use appcommonserviceTrackingService;

class TrackOrderStateChange
{
    public function handle(OrderStateChanged $event): void
    {
        TrackingService::track('order_status_change', [
            'order_id'    => $event->order->id,
            'from_status' => $event->from->value,
            'to_status'   => $event->to->value,
            'operator'    => $event->operatorId,
        ]);
    }
}

第六步:注册监听器

ThinkPHP 8 里可以集中注册,在 app/event.php 里:

<?php
// app/event.php

return [
    'listen' => [
        'appcommonordereventOrderStateChanged' => [
            'applistenerorderNotifyUserOnStatusChange',
            'applistenerorderNotifyWarehouseOnPaid',
            'applistenerorderTrackOrderStateChange',
            'applistenerorderLogStatusChangeForAudit',
        ],
    ],
];

也可以在自己的服务提供者里动态注册,看项目习惯。关键点是:监听器和事件之间是”多对一”关系,加一个新的副作用就是加一个监听器,不用改状态变更的任何代码。

异步化:别让外部调用拖累主流程

发短信、通知仓库这类操作,本质上都是”调外部接口”,可能几百毫秒才返回。放在同步流程里,用户提交一个支付请求得等三秒才响应。

ThinkPHP 有 think-queue 扩展,把耗时的监听器扔到队列里异步做。做法是给监听器加个标记,然后在事件触发时判断:

<?php
namespace applistener;

use thinkfacadeQueue;

abstract class QueuedListener
{
    abstract public function handle($event): void;

    public function handleAsync($event): void
    {
        Queue::push(static::class, $event, 'mq:events');
    }
}

然后 OrderService 里触发事件的时候根据监听器类型走不同路径。或者更简单,用 think-queue 提供的 thinkqueueJob 直接包一下。

我们的做法是:短信、通知仓库、埋点这三个监听器放队列,状态日志、优惠券标记这两个留在同步流程里。这样同步流程只有两次数据库写入,快很多。

压测数据:改造前”支付成功”这个接口平均耗时 480ms(里面包含三次 RPC 调用),改完之后降到 86ms。用户感知上的差别就是”点击支付”之后立刻跳转到支付成功页,不再转圈。

踩过的三个坑

坑一:监听器的执行顺序。ThinkPHP 8 里,event.php 里监听数组的顺序就是执行顺序。NotifyUserOnStatusChange 如果排在 NotifyWarehouseOnPaid 前面,用户可能收到短信了,仓库那边还没接到通知。这个在调试的时候不明显,到了生产才发现。

我们的解决方案是给监听器加一个优先级的约定,在注册时按这个顺序排:审计日志 → 通知类(短信/推送)→ 业务类(库存/仓库)→ 埋点类。审计日志放第一位,因为它是状态变更的事实记录,不能因为后面监听器报错就没记上。同理,通知类在业务类前面,用户感知优先。

坑二:事务里的事件触发。第一版写的时候,我把 Event::trigger 也放在了 Db::transaction 的内部。结果有个监听器里做了个异步 HTTP 请求,请求打到了另一个服务上,那个服务反过来调我们的接口查订单状态——查的时候事务还没提交,读到的还是旧状态,触发了数据一致性问题。

正确的做法就是前面说的,事件一定要在事务提交之后触发。写代码的时候明确地把事件触发放在事务闭包之外。

坑三:监听器里再触发事件导致循环。有一个监听器是”订单已支付时,如果订单里带了赠品,就把赠品订单也标记为已支付”。这个过程中又调了一次 OrderService::changeStatus,触发了另一个 OrderStateChanged 事件。如果赠品订单又被监听器处理、又触发了事件,很容易循环下去。

目前我们的规避方式是:监听器里禁止直接调用 changeStatus。需要级联状态变更时,往队列里推一个任务,由队列消费者去变更,和当前流程隔开。这样既避免了递归,又能保证监听器本身的执行时间可控。

测试:这套方案的意外收获

重写之前,单元测试很难写。改一个状态要 mock 一堆外部服务,很多时候跑不起来。

重写之后,状态机本身变成了纯函数,可以这么测:

public function test_cannot_ship_before_paid()
{
    $this->expectException(StateTransitionException::class);
    OrderStateMachine::assertCanTransit(
        OrderStatus::Pending,
        OrderStatus::Shipped
    );
}

public function test_allowed_transitions_from_paid()
{
    $allowed = OrderStateMachine::allowedFrom(OrderStatus::Paid);
    $this->assertContains(OrderStatus::Shipped, $allowed);
    $this->assertContains(OrderStatus::Refunding, $allowed);
    $this->assertContains(OrderStatus::Cancelled, $allowed);
}

public function test_final_states_have_no_transitions()
{
    foreach ([OrderStatus::Cancelled, OrderStatus::Refunded, OrderStatus::Completed] as $s) {
        $this->assertSame([], OrderStateMachine::allowedFrom($s));
    }
}

监听器的测试更简单,就是构造一个事件对象,调一下 handle,断言副作用。不需要数据库,不需要 HTTP。

我们最后写了 20 多个测试覆盖状态机和主要的监听器。项目上线之后,这套测试抓到了两个”某次状态变更忘了通知用户”的 bug。之前是靠运营反馈才能发现的。

什么时候不该用这套方案

我不想把这个方案吹成万金油。有些场景真的不需要这么搞。

  • 状态特别少的场景。如果一个订单只有”未支付/已支付”两个状态,中间没有复杂的转移规则,直接写 if 就够,别为了形式而形式。
  • 状态变更非常低频的场景。如果一年才变更几次,做状态机反而是过度设计。我一般按”日均状态变更超过 500 次”作为判断线,低于这个量级就简单实现。
  • 整个项目里只有一个地方在改状态。如果状态变更只在一个方法里发生,且没有分支,不需要抽象,那就是过度设计。

反之,如果项目里”改状态的地方超过三处”或者”有超过两种副作用”,那就该考虑重构了。

写在最后

这次重构花了两周。上线之后第一个月,运营侧零投诉(对比之前平均每月一次数据修复)。第二个月我们做了一次复盘,技术侧的变化是:状态变更相关的 bug 从月均 3 到 4 个降到了 0。

收益集中在三个点。第一,状态规则的错误被前移了——不允许的转移直接在调用处抛异常,不会默默写进数据库。第二,副作用解耦了——想加个新的通知渠道,就是加一个监听器,别的地方不用动。第三,审计完整了——查一次订单状态怎么变的,一条日志 SQL 搞定。

回头看,这是一个”用对了抽象”的例子。不是为了炫技去引入状态机,而是因为问题的本质确实需要状态机。把这个东西想清楚,用什么语言、什么框架都能落地得不错。

如果你手上也有类似的”状态在十几个地方被 update”的项目,建议先梳理一下状态的转移规则。可能梳理出来之后,你会发现整个方案就自然明了了。

ThinkPHP 8 订单状态机实战:把散落各处的 if 判断收进一张转移表
收藏 (0) 打赏

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

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

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

淘吗网 thinkphp ThinkPHP 8 订单状态机实战:把散落各处的 if 判断收进一张转移表 https://www.taomawang.com/server/thinkphp/2901.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

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

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