订单状态机别写散:我在 Rust CRM 里把 6 个状态收进领域模型

写 CRM 的时候,订单状态是一个特别容易被低估的东西。

一开始你可能只想在表里放个字段:

text 复制代码
pending / completed / cancelled

然后页面上渲染一下 badge,好像就结束了。

但家政业务不是这样。一个订单从客户需求转过来以后,可能要确认、派工、上门服务、完工、结算;中间还可能取消、改时间、换阿姨。状态一旦写散,最后就会变成这种代码:

rust 复制代码
// 伪代码:别这么写
if order.status != "completed" && order.status != "cancelled" {
    order.status = payload.status;
}

短期能跑,长期很难维护。因为你根本不知道某个接口到底允许从哪个状态跳到哪个状态。

所以我在 Pico-CRM 里把订单状态机放进了领域模型。

一、订单只有 6 个状态,但不是随便跳

项目里的订单状态定义在 backend/src/domain/crm/order/model.rs

rust 复制代码
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OrderStatus {
    Pending,
    Confirmed,
    Dispatching,
    InService,
    Completed,
    Cancelled,
}

对应业务含义大概是:

text 复制代码
Pending      待处理,通常由服务需求转成订单
Confirmed    已确认,客户和服务内容基本确认
Dispatching  派工中,已经进入排班/派单阶段
InService    服务中,家政人员已开始服务
Completed    已完成,服务结束
Cancelled    已取消,终态

这里看起来只是枚举,真正关键的是 can_transition

rust 复制代码
pub fn can_transition(current: OrderStatus, next: OrderStatus) -> bool {
    current == next
        || matches!(
            (current, next),
            (OrderStatus::Pending, OrderStatus::Confirmed)
                | (OrderStatus::Pending, OrderStatus::Dispatching)
                | (OrderStatus::Pending, OrderStatus::InService)
                | (OrderStatus::Confirmed, OrderStatus::Dispatching)
                | (OrderStatus::Confirmed, OrderStatus::InService)
                | (OrderStatus::Dispatching, OrderStatus::Confirmed)
                | (OrderStatus::Dispatching, OrderStatus::InService)
                | (OrderStatus::InService, OrderStatus::Completed)
        )
}

这段代码表达了几个真实取舍。

第一,Pending -> Completed 不允许。一个刚创建的订单不能直接完工,至少要进入服务过程。

第二,Dispatching -> Confirmed 是允许的。家政业务里派工过程中可能发现时间、人员或客户信息要回退确认,状态机不能只按理想流程往前走。

第三,Pending -> InService 也允许。MVP 阶段有些线下订单已经安排好了,系统补录时没必要强迫用户点一遍"确认"和"派工"。

这就是业务系统里状态机最有意思的地方:它不是画一条最漂亮的流程线,而是把真实世界允许发生的路径收敛到代码里。

二、为什么 Cancelled 不放进普通流转

注意上面的 can_transition 里没有任何状态可以跳到 Cancelled

不是忘了写,而是我故意把取消拆成了独立流程。

普通状态更新走的是 update_status

rust 复制代码
pub async fn update_status(
    &self,
    uuid: String,
    payload: UpdateOrderStatus,
    operator_uuid: Option<String>,
) -> Result<SharedOrder, String> {
    if payload.status == "cancelled" {
        return Err("use cancel order endpoint when cancelling order".to_string());
    }

    OrderStatus::parse(&payload.status)?;
    let updated = self
        .order_repo
        .update_order_status(uuid, payload.status, operator_uuid)
        .await?;

    let schedule_status = ScheduleStatus::from_order_status(&updated.status);
    let _ = self
        .schedule_repo
        .update_status(updated.uuid.clone(), schedule_status)
        .await?;

    Ok(updated.into())
}

取消订单必须走 cancel_order,原因很简单:取消不是一个普通状态切换,它需要取消原因,还要同步排班。

领域模型里也有明确限制:

rust 复制代码
pub fn cancel(&mut self, reason: String) -> Result<(), String> {
    if reason.trim().is_empty() {
        return Err("Cancellation reason is required".to_string());
    }
    if self.status == OrderStatus::Completed {
        return Err("Completed orders cannot be cancelled".to_string());
    }
    self.status = OrderStatus::Cancelled;
    self.cancellation_reason = Some(reason.trim().to_string());
    self.updated_at = Utc::now();
    Ok(())
}

这比 status = "cancelled" 多了两个约束:

  • 必须有取消原因
  • 已完成订单不能再取消

我的习惯是:如果某个状态变化需要额外业务信息,就不要把它塞进通用状态接口。

否则接口看起来统一了,业务语义反而丢了。

三、状态校验不在 Handler 里,而在领域层

状态更新最终会进入事件溯源的 Decision:

rust 复制代码
impl Decision for UpdateOrderStatusDecision {
    type Event = OrderEventEnvelope;
    type StateQuery = OrderState;
    type Error = String;

    fn process(&self, state: &Self::StateQuery) -> Result<Vec<Self::Event>, Self::Error> {
        if !state.exists {
            return Err(format!("order {} not found", self.order_uuid));
        }

        if self.next_status == OrderStatus::Cancelled {
            return Err("use cancel order flow when changing to cancelled".to_string());
        }

        let current_status = OrderStatus::parse(
            state
                .status
                .as_deref()
                .unwrap_or(OrderStatus::Pending.as_str()),
        )?;
        OrderStatus::validate_transition(current_status, self.next_status)?;

        let completed_at = if self.next_status == OrderStatus::Completed {
            Some(self.updated_at)
        } else {
            None
        };

        Ok(vec![OrderEventEnvelope::OrderStatusChanged {
            merchant_id: self.merchant_id.clone(),
            order_uuid: self.order_uuid.clone(),
            operator_uuid: self.operator_uuid.clone(),
            status: self.next_status.as_str().to_string(),
            completed_at,
            updated_at: self.updated_at,
        }])
    }
}

这里有个设计点:process() 不直接改数据库,只根据当前状态判断能不能产生事件。

能流转,就返回 OrderStatusChanged 事件;不能流转,就直接报错。

比如 Pending -> Completed 会被挡掉:

rust 复制代码
OrderStatus::validate_transition(OrderStatus::Pending, OrderStatus::Completed)
// Err("invalid order status transition: pending -> completed")

这样做的好处是,规则只在一个地方成立。

不管状态变化来自页面按钮、server function、排班联动,最后都要经过同一套 Decision。接口层可以做参数校验,但不能绕过领域规则。

四、排班会反推订单状态

订单状态机不是孤立存在的。家政 CRM 里还有一个排班状态:

rust 复制代码
pub enum ScheduleStatus {
    Planned,
    InService,
    Done,
    Cancelled,
}

订单和排班之间有一层映射:

rust 复制代码
pub fn from_order_status(status: &OrderStatus) -> Self {
    match status {
        OrderStatus::Pending | OrderStatus::Confirmed | OrderStatus::Dispatching => {
            ScheduleStatus::Planned
        }
        OrderStatus::InService => ScheduleStatus::InService,
        OrderStatus::Completed => ScheduleStatus::Done,
        OrderStatus::Cancelled => ScheduleStatus::Cancelled,
    }
}

pub fn target_order_status(&self) -> Option<OrderStatus> {
    match self {
        ScheduleStatus::Planned => None,
        ScheduleStatus::InService => Some(OrderStatus::InService),
        ScheduleStatus::Done => Some(OrderStatus::Completed),
        ScheduleStatus::Cancelled => Some(OrderStatus::Cancelled),
    }
}

这块很业务。

排班从 planned 进入 in_service,订单也应该进入 in_service。排班完成,订单也应该变成 completed。排班取消,订单也要走取消链路。

创建排班时还有一个自动推进:

rust 复制代码
pub fn next_after_schedule_assignment(current: OrderStatus) -> OrderStatus {
    match current {
        OrderStatus::Pending | OrderStatus::Confirmed => OrderStatus::Dispatching,
        other => other,
    }
}

也就是说,一个 pending 订单只要创建了排班,就会自动推进到 dispatching

这比让前端连续调用两个接口更可靠:

text 复制代码
错误做法:
创建排班成功
-> 前端再调一次更新订单状态
-> 第二个请求失败,订单和排班状态不一致

当前做法:
应用服务编排
-> 创建/更新排班
-> 更新订单派工信息
-> 自动推进订单状态

状态机不是只管自己的字段,还要定义和其他聚合协作时的边界。

五、Completed 和 Cancelled 是"业务封口"

订单完工或取消后,很多字段就不应该再动了。

比如核心字段更新:

rust 复制代码
pub fn update_details(&mut self, update: OrderDetailsUpdate) -> Result<(), String> {
    if self.status == OrderStatus::Completed || self.status == OrderStatus::Cancelled {
        return Err("Completed or cancelled orders cannot update core fields".to_string());
    }
    if update.customer_uuid.trim().is_empty() {
        return Err("Customer is required".to_string());
    }
    if update.amount_cents < 0 {
        return Err("Amount cents must be non-negative".to_string());
    }

    self.customer_uuid = Some(update.customer_uuid);
    self.amount_cents = update.amount_cents;
    self.notes = update.notes;
    self.updated_at = Utc::now();
    Ok(())
}

这里不是为了"代码洁癖",而是为了业务事实。

一个已经完工的订单,如果还能随便改客户和金额,那审计日志就会变得很奇怪;一个已经取消的订单,如果还能改核心字段,也会让后续统计出现脏数据。

所以我把 CompletedCancelled 当作业务封口:

  • 不能随便改核心字段
  • Completed 不能再取消
  • Cancelled 不能通过普通状态接口跳回其他状态

这类规则越早放进领域层,后面页面越多、接口越多,收益越明显。

六、测试用 Given/When/Then 更直观

事件溯源的一个好处是,状态机测试可以写得很像业务剧本。

比如非法流转:

rust 复制代码
#[test]
fn it_rejects_invalid_status_transition() {
    TestHarness::given([seed_created_event(
        "11111111-1111-1111-1111-111111111111",
        &sample_order(),
        None,
    )])
    .when(UpdateOrderStatusDecision::new(
        "11111111-1111-1111-1111-111111111111",
        "order-1",
        OrderStatus::Completed,
        ts(2),
        None,
    ))
    .then_err("invalid order status transition: pending -> completed".to_string());
}

读起来就是:

text 复制代码
Given 一个刚创建的 pending 订单
When 尝试直接改成 completed
Then 应该报 invalid transition

再比如已完成订单不能更新派工:

rust 复制代码
.then_err(
    "schedule assignment can only be updated in planned status (current: done)".to_string(),
);

状态机这种东西,光靠脑子想很容易漏边界。测试最好覆盖两类:

  • 允许的主路径:pending -> confirmed -> dispatching -> in_service -> completed
  • 不允许的捷径和回退:pending -> completedcompleted -> confirmedcancelled -> pending

规则不复杂,但要把"不能发生什么"写清楚。

总结

订单状态机这块,我现在的体会是:不要把 status 当成普通字段,它其实是业务流程的压缩包。

Pico-CRM 里的做法可以概括成几条:

  • 用 Rust enum 收敛状态值,不让字符串满天飞
  • can_transition 明确合法路径
  • Cancelled 拆成独立取消流程,强制带取消原因
  • 排班状态和订单状态互相映射,由应用服务统一编排
  • Completed / Cancelled 作为业务封口,限制核心字段修改
  • 用事件溯源 Decision 和 Given/When/Then 测试兜住边界

状态机不用一上来就搞得很"架构感"。先把状态、流转、终态、跨模块联动这几件事放对位置,系统后面就不会被一堆 if status == ... 拖住。

你们项目里的订单状态是集中管理,还是散在各个接口里判断?评论区聊聊。


下一篇准备写 N+1 查询优化:批量 is_in + HashMap 内存 join,怎么把逐条查询收回来。

相关推荐
渐儿15 小时前
第 05 章 · SQL 写法
后端
invicinble15 小时前
对于spring的bean应该有哪些领域的认识
java·后端·spring
Amazing530715 小时前
docker compose 漏一个参数全失效
后端·代码规范
ZengLiangYi15 小时前
从零实现 Embedding 服务:文本转向量
人工智能·后端
韩小兔修媛史15 小时前
SpringBoot面试八股文(持续更新)
spring boot·后端·面试
恋喵大鲤鱼16 小时前
Rust 属性语法
rust·属性
码上出头16 小时前
地理围栏从0到1:我是怎么把轮询接口从每分钟2000次干到0次的
后端
神奇小汤圆16 小时前
搞懂数据库索引:它到底帮了什么忙,又埋了什么坑?
后端
浮游本尊16 小时前
Java学习第38天 - 企业级 REST API 设计、OpenAPI 契约与接口可靠性
后端