去年 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”的项目,建议先梳理一下状态的转移规则。可能梳理出来之后,你会发现整个方案就自然明了了。

