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

订单状态机别写散:我在 Rust CRM 里把 6 个状态收进领域模型 写 CRM 的时候订单状态是一个特别容易被低估的东西。一开始你可能只想在表里放个字段pending / completed / cancelled然后页面上渲染一下 badge好像就结束了。但家政业务不是这样。一个订单从客户需求转过来以后可能要确认、派工、上门服务、完工、结算中间还可能取消、改时间、换阿姨。状态一旦写散最后就会变成这种代码// 伪代码别这么写iforder.status!completedorder.status!cancelled{order.statuspayload.status;}短期能跑长期很难维护。因为你根本不知道某个接口到底允许从哪个状态跳到哪个状态。所以我在 Pico-CRM 里把订单状态机放进了领域模型。一、订单只有 6 个状态但不是随便跳项目里的订单状态定义在backend/src/domain/crm/order/model.rs#[derive(Debug, Clone, Copy, PartialEq, Eq)]pubenumOrderStatus{Pending,Confirmed,Dispatching,InService,Completed,Cancelled,}对应业务含义大概是Pending 待处理通常由服务需求转成订单 Confirmed 已确认客户和服务内容基本确认 Dispatching 派工中已经进入排班/派单阶段 InService 服务中家政人员已开始服务 Completed 已完成服务结束 Cancelled 已取消终态这里看起来只是枚举真正关键的是can_transitionpubfncan_transition(current:OrderStatus,next:OrderStatus)-bool{currentnext||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_statuspubasyncfnupdate_status(self,uuid:String,payload:UpdateOrderStatus,operator_uuid:OptionString,)-ResultSharedOrder,String{ifpayload.statuscancelled{returnErr(use cancel order endpoint when cancelling order.to_string());}OrderStatus::parse(payload.status)?;letupdatedself.order_repo.update_order_status(uuid,payload.status,operator_uuid).await?;letschedule_statusScheduleStatus::from_order_status(updated.status);let_self.schedule_repo.update_status(updated.uuid.clone(),schedule_status).await?;Ok(updated.into())}取消订单必须走cancel_order原因很简单取消不是一个普通状态切换它需要取消原因还要同步排班。领域模型里也有明确限制pubfncancel(mutself,reason:String)-Result(),String{ifreason.trim().is_empty(){returnErr(Cancellation reason is required.to_string());}ifself.statusOrderStatus::Completed{returnErr(Completed orders cannot be cancelled.to_string());}self.statusOrderStatus::Cancelled;self.cancellation_reasonSome(reason.trim().to_string());self.updated_atUtc::now();Ok(())}这比status cancelled多了两个约束必须有取消原因已完成订单不能再取消我的习惯是如果某个状态变化需要额外业务信息就不要把它塞进通用状态接口。否则接口看起来统一了业务语义反而丢了。三、状态校验不在 Handler 里而在领域层状态更新最终会进入事件溯源的 DecisionimplDecisionforUpdateOrderStatusDecision{typeEventOrderEventEnvelope;typeStateQueryOrderState;typeErrorString;fnprocess(self,state:Self::StateQuery)-ResultVecSelf::Event,Self::Error{if!state.exists{returnErr(format!(order {} not found,self.order_uuid));}ifself.next_statusOrderStatus::Cancelled{returnErr(use cancel order flow when changing to cancelled.to_string());}letcurrent_statusOrderStatus::parse(state.status.as_deref().unwrap_or(OrderStatus::Pending.as_str()),)?;OrderStatus::validate_transition(current_status,self.next_status)?;letcompleted_atifself.next_statusOrderStatus::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会被挡掉OrderStatus::validate_transition(OrderStatus::Pending,OrderStatus::Completed)// Err(invalid order status transition: pending - completed)这样做的好处是规则只在一个地方成立。不管状态变化来自页面按钮、server function、排班联动最后都要经过同一套 Decision。接口层可以做参数校验但不能绕过领域规则。四、排班会反推订单状态订单状态机不是孤立存在的。家政 CRM 里还有一个排班状态pubenumScheduleStatus{Planned,InService,Done,Cancelled,}订单和排班之间有一层映射pubfnfrom_order_status(status:OrderStatus)-Self{matchstatus{OrderStatus::Pending|OrderStatus::Confirmed|OrderStatus::Dispatching{ScheduleStatus::Planned}OrderStatus::InServiceScheduleStatus::InService,OrderStatus::CompletedScheduleStatus::Done,OrderStatus::CancelledScheduleStatus::Cancelled,}}pubfntarget_order_status(self)-OptionOrderStatus{matchself{ScheduleStatus::PlannedNone,ScheduleStatus::InServiceSome(OrderStatus::InService),ScheduleStatus::DoneSome(OrderStatus::Completed),ScheduleStatus::CancelledSome(OrderStatus::Cancelled),}}这块很业务。排班从planned进入in_service订单也应该进入in_service。排班完成订单也应该变成completed。排班取消订单也要走取消链路。创建排班时还有一个自动推进pubfnnext_after_schedule_assignment(current:OrderStatus)-OrderStatus{matchcurrent{OrderStatus::Pending|OrderStatus::ConfirmedOrderStatus::Dispatching,otherother,}}也就是说一个pending订单只要创建了排班就会自动推进到dispatching。这比让前端连续调用两个接口更可靠错误做法 创建排班成功 - 前端再调一次更新订单状态 - 第二个请求失败订单和排班状态不一致 当前做法 应用服务编排 - 创建/更新排班 - 更新订单派工信息 - 自动推进订单状态状态机不是只管自己的字段还要定义和其他聚合协作时的边界。五、Completed 和 Cancelled 是“业务封口”订单完工或取消后很多字段就不应该再动了。比如核心字段更新pubfnupdate_details(mutself,update:OrderDetailsUpdate)-Result(),String{ifself.statusOrderStatus::Completed||self.statusOrderStatus::Cancelled{returnErr(Completed or cancelled orders cannot update core fields.to_string());}ifupdate.customer_uuid.trim().is_empty(){returnErr(Customer is required.to_string());}ifupdate.amount_cents0{returnErr(Amount cents must be non-negative.to_string());}self.customer_uuidSome(update.customer_uuid);self.amount_centsupdate.amount_cents;self.notesupdate.notes;self.updated_atUtc::now();Ok(())}这里不是为了“代码洁癖”而是为了业务事实。一个已经完工的订单如果还能随便改客户和金额那审计日志就会变得很奇怪一个已经取消的订单如果还能改核心字段也会让后续统计出现脏数据。所以我把Completed和Cancelled当作业务封口不能随便改核心字段Completed不能再取消Cancelled不能通过普通状态接口跳回其他状态这类规则越早放进领域层后面页面越多、接口越多收益越明显。六、测试用 Given/When/Then 更直观事件溯源的一个好处是状态机测试可以写得很像业务剧本。比如非法流转#[test]fnit_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());}读起来就是Given 一个刚创建的 pending 订单 When 尝试直接改成 completed Then 应该报 invalid transition再比如已完成订单不能更新派工.then_err(schedule assignment can only be updated in planned status (current: done).to_string(),);状态机这种东西光靠脑子想很容易漏边界。测试最好覆盖两类允许的主路径pending - confirmed - dispatching - in_service - completed不允许的捷径和回退pending - completed、completed - confirmed、cancelled - pending规则不复杂但要把“不能发生什么”写清楚。总结订单状态机这块我现在的体会是不要把status当成普通字段它其实是业务流程的压缩包。Pico-CRM 里的做法可以概括成几条用 Rust enum 收敛状态值不让字符串满天飞用can_transition明确合法路径Cancelled拆成独立取消流程强制带取消原因排班状态和订单状态互相映射由应用服务统一编排Completed/Cancelled作为业务封口限制核心字段修改用事件溯源 Decision 和 Given/When/Then 测试兜住边界状态机不用一上来就搞得很“架构感”。先把状态、流转、终态、跨模块联动这几件事放对位置系统后面就不会被一堆if status ...拖住。你们项目里的订单状态是集中管理还是散在各个接口里判断评论区聊聊。下一篇准备写 N1 查询优化批量is_inHashMap内存 join怎么把逐条查询收回来。