ThinkPHP 8 订单状态别再写数字了:枚举、状态机与模型事件三步落地

2026-11-02 0 984

先说个真事。上个月排查一个订单列表白屏的问题,最后定位到数据库里有一条 status 等于 3 的记录,而前端拿到之后去映射“已发货”的图标,映射表里根本没有 3,直接抛了个 undefined。翻代码发现是某个临时脚本为了赶进度,手动把状态改成了 3,但它其实想表达的是“已付款”。

这类问题靠加注释是治不住的。这次索性把订单状态整条链路改了一遍:用 PHP 8.1 的枚举定义状态,用 think-orm 的获取器修改器保证模型层拿到的一定是枚举,再写一个小状态机收敛流转规则,最后挂到模型事件上,让非法变更在入库前就报错。整个过程不复杂,代码加起来一百多行。

一、先看看原来的写法

改造之前,项目里到处是这种东西:

// 控制器里
if ($order->status == 1) {
    // 发货
}

// 服务层里
Order::where('status', '<>', 4)->update(['status' => 9]);

// 前端返回
return json(['status' => $order->status, 'status_text' => '已付款']);

问题有三个。第一,数字本身没有语义,3 到底是“已发货”还是“已付款”,得去翻文档或者看注释。第二,没有地方能拦住非法变更,只要有人写了个 update 语句,已取消的订单也能被改成已完成。第三,接口返回的时候要手动拼 status_text,一旦漏了前端就得自己映射,映射表还容易过期。

二、用枚举把状态定义收敛

PHP 8.1 的枚举正好适合这种场景。新建 app/enum/OrderStatus.php:

<?php

namespace appenum;

enum OrderStatus: int
{
    case Pending   = 1;
    case Paid      = 2;
    case Shipped   = 3;
    case Completed = 4;
    case Cancelled = 9;

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

    public function allowedNext(): array
    {
        return match ($this) {
            self::Pending   => [self::Paid, self::Cancelled],
            self::Paid      => [self::Shipped, self::Cancelled],
            self::Shipped   => [self::Completed],
            self::Completed => [],
            self::Cancelled => [],
        };
    }
}

这里用了 backing type 为 int 的枚举,因为数据库字段是 tinyint。label() 返回中文,allowedNext() 描述允许流转到哪些状态。两个方法放在枚举里而不是单独的服务类,是因为它们跟状态本身强相关,放在一起改起来方便。

注意 allowedNext() 的返回值是枚举数组,不是 int 数组。后面做判断时要用 in_array($to, $from->allowedNext(), true),第三个参数别省,枚举比较必须用严格模式。

三、让模型吐出来的一定是枚举

关键的一步来了。数据库里存的还是 tinyint,但模型层要保证业务代码拿到的永远是 OrderStatus 实例。用获取器做入向转换,修改器做出向转换。新建 app/model/Order.php:

<?php

namespace appmodel;

use appenumOrderStatus;
use thinkModel;

class Order extends Model
{
    protected $name = 'order';

    protected $autoWriteTimestamp = true;

    public function getStatusAttr($value): OrderStatus
    {
        if ($value instanceof OrderStatus) {
            return $value;
        }
        return OrderStatus::from((int) $value);
    }

    public function setStatusAttr($value): int
    {
        if ($value instanceof OrderStatus) {
            return $value->value;
        }
        return (int) $value;
    }

    public function getStatusTextAttr(): string
    {
        return $this->status->label();
    }
}

获取器里的类型判断不能省。ThinkPHP 8 的模型在 save() 之后不会重新从数据库读值,内存里的 data 数组此时已经是 int,如果获取器直接 from() 也没问题,但像 toArray() 这类调用在某些情况下会把枚举对象再传回获取器,多一层判断更稳。

status_text 这个虚拟字段是给前端用的。toArray() 输出时会自动带上 status_text,前端就不用再维护一份映射表了。要隐藏某些字段用 $hidden 就行,这个不影响。

顺便说一句,think-orm 3.x 的 $type 属性其实也支持直接写枚举类名,写法是 protected $type = ['status' => OrderStatus::class];。效果跟手写获取器差不多,但我在查询条件那里吃了亏,后面坑里细说。手写获取器更透明一点,出了问题好排查。

四、把流转规则收进状态机

有了枚举之后,状态流转的判断可以写得很短。新建 app/service/OrderStateMachine.php:

<?php

namespace appservice;

use appenumOrderStatus;
use appexceptionOrderStatusException;

class OrderStateMachine
{
    public static function transit(OrderStatus $from, OrderStatus $to): void
    {
        if ($from === $to) {
            throw new OrderStatusException(
                sprintf('订单状态已经是 %s,无需重复变更', $from->label())
            );
        }

        if (!in_array($to, $from->allowedNext(), true)) {
            throw new OrderStatusException(
                sprintf(
                    '订单状态不允许从 %s(%d) 变更为 %s(%d)',
                    $from->label(), $from->value,
                    $to->label(), $to->value
                )
            );
        }
    }
}

异常类 OrderStatusException 继承 RuntimeException 就行,不必贴代码了。有了这个类,业务代码里想改状态之前调一下 transit(),非法流转会立刻抛异常,比等到数据库里出现脏数据再回头查要省事得多。

五、挂到模型事件上兜底

光靠业务代码里手动调 transit() 还是可能漏。更稳的做法是在模型更新前自动检查一遍。在 Order 模型里加一个 onBeforeUpdate 静态方法:

protected static function onBeforeUpdate(self $model): void
{
    if (!$model->isDirty('status')) {
        return;
    }

    $origin = $model->getOrigin('status');
    if ($origin === null) {
        return;
    }

    $from = OrderStatus::from((int) $origin);
    $to   = $model->status;

    OrderStateMachine::transit($from, $to);
}

几个细节需要注意。getOrigin() 取的是模型加载时的原始值,不会走获取器,所以拿到的是 int,需要手动 from() 转一下。isDirty('status') 判断字段有没有被改过,没改就直接返回,避免不必要的检查。$model->status 走的是获取器,此时已经是枚举实例。

这个钩子的好处是,只要是通过模型 save() 或 update() 走的更新,都会被拦一道。哪怕某个同事对状态机的存在一无所知,他也改不出非法状态。

六、查询条件别直接把枚举传进去

拿到枚举之后很容易顺手写成这样:

// 错误写法
$list = Order::where('status', OrderStatus::Paid)->select();

这段代码会抛出类似 “Object of class OrderStatus could not be converted to string” 的错误,因为查询构造器最终要把参数绑给 PDO,PDO 不认识枚举对象。正确写法是取 value:

// 正确写法
$list = Order::where('status', OrderStatus::Paid->value)->select();

// 取状态集合也可以用
$pendingAndPaid = array_map(
    fn (OrderStatus $s) => $s->value,
    [OrderStatus::Pending, OrderStatus::Paid]
);

$list = Order::whereIn('status', $pendingAndPaid)->select();

如果确实觉得每次写 ->value 麻烦,可以在枚举里加一个静态方法返回所有 int 值,比如 OrderStatus::values(),但查询条件那处还是得显式取值,这个躲不掉。

七、验证器里也能用上

后台管理有个接口允许运营手动改订单状态,参数是 status=3 这种数字。以前是写个 in:1,2,3,4,9 的规则,加一个状态就得改一次规则。现在可以这样:

use appenumOrderStatus;
use thinkValidate;

$validate = new Validate([
    'order_no' => 'require',
    'status'   => 'require|integer',
]);

$data = $this->request->only(['order_no', 'status']);

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

$target = OrderStatus::tryFrom((int) $data['status']);
if ($target === null) {
    return json(['code' => 400, 'msg' => '无效的订单状态']);
}

// 后续用 $target 去走状态机
$order = Order::where('order_no', $data['order_no'])->findOrFail();
OrderStateMachine::transit($order->status, $target);

$order->status = $target;
$order->save();

tryFrom() 是 backed enum 自带的,传入非法整型返回 null,不会抛异常,适合这种需要自己控制错误信息的场景。枚举扩展新状态时,验证代码一行都不用动。

八、四个实际踩到的坑

批量更新绕过了模型事件。像 Order::where('status', 1)->update(['status' => 9]) 这种写法直接生成 SQL,不经过任何模型事件,状态机的检查形同虚设。项目里如果允许这种写法,得自己包一层服务方法,在里面先查出来再用模型更新,别图快。

查询表达式里的枚举不会自动转成值。前面提过一次,这里再强调一下,where('status', OrderStatus::Paid) 会报错,必须写 ->value。这个问题不会在开发阶段暴露,往往要到某条特定路径跑起来才发现,建议在代码评审时专门看一眼查询条件。

JSON 输出时要确认格式。ThinkPHP 8 的 json() 响应在遇到枚举对象时,较新的版本会输出其 value,但如果项目里还是老版本,可能会输出成对象形式。稳妥的办法是在 toArray() 回来之后手动处理一下,或者直接依赖前面定义的 status_text,让前端只用文本字段。

新加枚举值时要同步改状态机。最大的隐患其实不在代码里,而在流程上。只要有人往枚举里加了一个新 case,却没更新 allowedNext(),那个状态就永远流转不出去。建议在枚举里给新加 case 的地方留一行注释,或者干脆在 allowedNext() 的 match 里不写 default,让 PHP 自己报错提醒漏了分支。

九、改完之后

收益还是比较明显的。控制器和前端之间少了一份状态映射表,接口返回里直接带 status_text,前端不用维护。后台手动改状态那个接口,非法请求会在验证阶段就被拒掉,不会像以前那样先写进数据库再靠人工排查。最直接的一个变化是,之前那个临时脚本如果再想改状态,代码在 save() 这一步就抛异常了,压根落不了库。

改动量其实不大,枚举加状态机不到八十行,模型里多了三个方法。如果你手上也有这种“数字状态满天飞”的项目,不用一次全换,挑一个核心模型先试,把获取器、状态机和模型事件这套走通,后面复制到别的模型上会很快。

ThinkPHP 8 订单状态别再写数字了:枚举、状态机与模型事件三步落地
收藏 (0) 打赏

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

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

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

淘吗网 thinkphp ThinkPHP 8 订单状态别再写数字了:枚举、状态机与模型事件三步落地 https://www.taomawang.com/server/thinkphp/3009.html

常见问题

相关文章

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

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