当前位置: 首页 > news >正文

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

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

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

pending / completed / cancelled

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

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

// 伪代码:别这么写iforder.status!="completed"&&order.status!="cancelled"{order.status=payload.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_transition

pubfncan_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

pubasyncfnupdate_status(&self,uuid:String,payload:UpdateOrderStatus,operator_uuid:Option<String>,)->Result<SharedOrder,String>{ifpayload.status=="cancelled"{returnErr("use cancel order endpoint when cancelling order".to_string());}OrderStatus::parse(&payload.status)?;letupdated=self.order_repo.update_order_status(uuid,payload.status,operator_uuid).await?;letschedule_status=ScheduleStatus::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.status==OrderStatus::Completed{returnErr("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:

implDecisionforUpdateOrderStatusDecision{typeEvent=OrderEventEnvelope;typeStateQuery=OrderState;typeError=String;fnprocess(&self,state:&Self::StateQuery)->Result<Vec<Self::Event>,Self::Error>{if!state.exists{returnErr(format!("order {} not found",self.order_uuid));}ifself.next_status==OrderStatus::Cancelled{returnErr("use cancel order flow when changing to cancelled".to_string());}letcurrent_status=OrderStatus::parse(state.status.as_deref().unwrap_or(OrderStatus::Pending.as_str()),)?;OrderStatus::validate_transition(current_status,self.next_status)?;letcompleted_at=ifself.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会被挡掉:

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::InService=>ScheduleStatus::InService,OrderStatus::Completed=>ScheduleStatus::Done,OrderStatus::Cancelled=>ScheduleStatus::Cancelled,}}pubfntarget_order_status(&self)->Option<OrderStatus>{matchself{ScheduleStatus::Planned=>None,ScheduleStatus::InService=>Some(OrderStatus::InService),ScheduleStatus::Done=>Some(OrderStatus::Completed),ScheduleStatus::Cancelled=>Some(OrderStatus::Cancelled),}}

这块很业务。

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

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

pubfnnext_after_schedule_assignment(current:OrderStatus)->OrderStatus{matchcurrent{OrderStatus::Pending|OrderStatus::Confirmed=>OrderStatus::Dispatching,other=>other,}}

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

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

错误做法: 创建排班成功 -> 前端再调一次更新订单状态 -> 第二个请求失败,订单和排班状态不一致 当前做法: 应用服务编排 -> 创建/更新排班 -> 更新订单派工信息 -> 自动推进订单状态

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

五、Completed 和 Cancelled 是“业务封口”

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

比如核心字段更新:

pubfnupdate_details(&mutself,update:OrderDetailsUpdate)->Result<(),String>{ifself.status==OrderStatus::Completed||self.status==OrderStatus::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_cents<0{returnErr("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 更直观

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

比如非法流转:

#[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 -> 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,怎么把逐条查询收回来。

http://www.jsqmd.com/news/893936/

相关文章:

  • 科普|论文查重为什么能免费?书匠策AI这个平台到底什么来头?
  • SkiaSharp实战:5分钟为你的C# WinForm应用添加一个“可移动的小球”
  • 找片头AE模版不用愁!12个优质素材平台汇总
  • 扩散模型驱动3D生成:从2D先验到3D空间扩散的技术演进
  • 2026年河北滤筒除尘器厂家实力厂商选择标准深度剖析 - 2026年企业资讯
  • 别再像我一样踩坑!用PSIM和Multisim手把手教你推导Buck电路的正确传递函数
  • 别再死记硬背了!用Python手把手教你实现匈牙利算法,搞定任务分配难题
  • Python数据可视化实战
  • 基于mlp的神经网络的红酒品质回归预测
  • 27考研311教育学历年真题PDF
  • 趣味智能陪伴!基于魔珐星云的宠物专属数字助手
  • 臺灣大學校總區無車化執行方案與推動時程整體規劃案(繁) 2025
  • 别再为高维数据发愁了!用Python手把手教你实现粗糙集属性约简(附完整代码)
  • ubuntu下stlink(v1/v2/v3)实现GD32下载程序
  • 从美术资源到可动角色:聊聊Unity中序列帧动画的性能优化与最佳实践
  • 告别龟速!实测FastCopy 3.92在Windows 11上拷贝百万小文件,速度提升10倍不止
  • 2026年4月成都火锅品牌口碑推荐,烧菜火锅/特色美食/美食/社区火锅/火锅,成都火锅品牌找哪家 - 品牌推荐师
  • 告别调参玄学:用Python手把手实现L1-ball投影,给你的模型加个‘稀疏’开关
  • 2026年5月江夏地区高亮LED大灯专业服务对接与品牌深度解析 - 2026年企业资讯
  • 基于CT+NMF+ANN的鲁棒图像水印技术:原理、实现与优化
  • 悄悄用 Go 重写 AI 基础设施:NVIDIA 的 GPU 云平台为何选择 Go?
  • 基于Vision Transformer的无监督域自适应行人重识别:提示与调优两阶段方法
  • 网络排障手记:同网段内两个IP,为何Ping的结果一好一坏?
  • 2026靠谱爱普生UV打印机品牌推荐:图文数码打印机、小批量包装打印机、烫金增效打印机、礼盒数码打样机、逆向UV数码打印机选择指南 - 优质品牌商家
  • 2026绵阳沟通障碍康复机构优质推荐榜:绵阳语言障碍/绵阳刻板行为康复/绵阳发育迟缓/绵阳多动症/绵阳孤独症/绵阳感统训练/选择指南 - 优质品牌商家
  • 数据分析师证书在营销策划岗位中的重要性
  • SHINE:基于内存解耦架构的分布式HNSW索引设计与优化
  • 2026中式瓦厂家权威名录:四川青瓦厂家、小青瓦厂家、仿古建筑砖瓦厂家、仿古建筑青瓦厂家、仿古琉璃瓦厂家、仿古瓦厂家选择指南 - 优质品牌商家
  • 实战派指南:用Python的sklearn库,5分钟搞定PCA、LDA和t-SNE可视化
  • 跨模态检索新突破:从一对一配对到多对多语义关系建模