大宗商品销采运储系统・六层架构连载(业务服务层)
上一篇我们通过 DDD(领域驱动设计)划清了微服务的边界,把系统拆分为交易、履约、库存、风控、业财五大核心域。
边界划好了,但当我们真正开始写业务代码时,立刻会遇到一个巨大的拦路虎:单据的状态流转。
在大宗商品系统里,一张采购单或者销售单,生命周期极长。它可能经历:草稿 → 待审核 → 审核驳回 → 重新提交 → 审核通过 → 待付款 → 部分付款 → 待发货 → 部分发货 → 待质检 → 质检不合格打回 → 已完结/已作废……
如果再加上撤回、关闭等操作,状态多达十几个。
传统的开发模式在这里会直接崩溃。如果你在代码里写满了if (status == 1 && action == "pay") { status = 2 } else if ...,这不仅是一堆难以维护的“屎山代码”,更致命的是,它无法防止越权流转(比如财务还没确认收款,单据直接跳到了发货状态)。
一句话定调:
状态机是复杂单据流转的“红绿灯”和“守门员”。它不仅管状态怎么变,更管谁、在什么时候、发生什么动作才能变。
一、为什么大宗商品单据必须用状态机?
在深入代码之前,先看看如果不用状态机,业务上会踩什么坑:
状态越级流转:业务员通过前端接口篡改参数,把“待审核”的采购单直接提交成了“已入库”,导致财务凭证凭空生成。
逻辑分散在 Controller 和 Service:校验能否发货的逻辑,不仅分散在订单服务,甚至物流服务里也有一份。一旦业务规则变了(比如新增了“必须质检合格才能发货”),要改好几处代码。
流转无痕迹:出了问题查不到,是谁在什么时候把单据变成了“已作废”?
并发导致状态错乱:两个操作员同时点击“审核”,由于没有锁,单据被处理了两次,或者状态直接被覆盖。
二、技术选型:为什么我们选择 Cola StateMachine?
业界做状态机的组件不少:
Spring StateMachine:太重了!设计理念是基于事件驱动的有限状态机,学习成本极高,且重度依赖 Spring 上下文。
手写状态机(Switch/If-Else):维护成本高,且不优雅。
阿里开源的 Cola StateMachine:轻量级,利用 Fluent API 的 DSL 语法配置,符合人类直觉,非常适合业务单据流转。
架构设计:
我们将状态机作为“业务域基建”沉淀下来。状态(State)、事件(Event)、动作(Action)的设计,全部复用 DDD 聚合根的内部逻辑。
三、实战落地:构建采购单状态机
假设我们有一个采购单PurchaseOrder,核心状态流转如下:
初始状态:草稿(DRAFT)
动作:提交(SUBMIT) -> 状态:待审核(PENDING_REVIEW)
动作:驳回(REJECT) -> 状态:草稿(DRAFT)
动作:审核通过(APPROVE) -> 状态:待付款(PENDING_PAY)
动作:付款完成(PAY_SUCCESS) -> 状态:待发货(PENDING_DELIVERY)
1. 定义状态与事件枚举
public enum OrderState {
DRAFT, // 草稿PENDING_REVIEW, // 待审核PENDING_PAY, // 待付款PENDING_DELIVERY, // 待发货FINISHED, // 已完结CANCELLED // 已作废}public enum OrderEvent {SUBMIT, // 提交REJECT, // 驳回APPROVE, // 审核通过PAY_SUCCESS // 付款成功}
2. 基于 Cola StateMachine 的配置
通过 DSL 语法,把流转规则(红绿灯)显式地配置出来。
@Configuration public class PurchaseStateMachineConfig { @Bean public StateMachine<OrderState, OrderEvent, OrderContext> purchaseStateMachine() { StateMachineBuilder<OrderState, OrderEvent, OrderContext> builder = StateMachineBuilderFactory.create(); // 1. 草稿状态:提交 -> 待审核 builder.externalTransition() .from(OrderState.DRAFT) .to(OrderState.PENDING_REVIEW) .on(OrderEvent.SUBMIT) .when(checkCondition()) // 触发条件 .perform(doAction()); // 触发动作 // 2. 待审核状态:驳回 -> 草稿 builder.externalTransition() .from(OrderState.PENDING_REVIEW) .to(OrderState.DRAFT) .on(OrderEvent.REJECT); // 3. 待审核状态:审核通过 -> 待付款 builder.externalTransition() .from(OrderState.PENDING_REVIEW) .to(OrderState.PENDING_PAY) .on(OrderEvent.APPROVE); // 4. 待付款状态:付款成功 -> 待发货 builder.externalTransition() .from(OrderState.PENDING_PAY) .to(OrderState.PENDING_DELIVERY) .on(OrderEvent.PAY_SUCCESS); return builder.build("purchaseStateMachine"); } // 前置校验条件:比如判断当前操作人是否有审核权限 private Condition<OrderContext> checkCondition() { return (ctx) -> ctx.getOperatorRole().equals("MANAGER"); } // 状态流转成功后触发的动作:比如发送 MQ、写日志 private Action<OrderState, OrderEvent, OrderContext> doAction() { return (from, to, event, ctx) -> { log.info("状态从 {} 流转到 {},操作单号:{}", from, to, ctx.getOrderNo()); // 触发领域事件... }; } }3. 在业务 Service 中调用
业务代码不再关心状态能不能变,而是把一切交给状态机。如果状态不合法,状态机会抛出异常。
@Service public class PurchaseOrderServiceImpl { @Autowired private StateMachine<OrderState, OrderEvent, OrderContext> purchaseStateMachine; public void submitOrder(String orderNo, String operator) { // 1. 查库获取当前单据 PurchaseOrder order = orderMapper.selectByNo(orderNo); // 2. 组装上下文参数 OrderContext context = new OrderContext(); context.setOrderNo(orderNo); context.setOperatorRole(operator); // 3. 触发状态机流转 // 如果当前状态不是 DRAFT,或者角色不对,这里会直接抛出异常,拒绝流转 OrderState nextState = purchaseStateMachine.fireEvent( order.getStatus(), OrderEvent.SUBMIT, context ); // 4. 只有流转成功,才更新数据库 order.setStatus(nextState); orderMapper.updateById(order); } }四、高阶难点:状态机与并发控制(防并发覆写)
在真实生产环境,有一个致命的坑:ABA 问题。
假设销售单当前是“待审核(PENDING_REVIEW)”。
操作员 A 和 B 同时点开了这单。
A 点“审核通过”,状态变为“待付款”。
B 网络卡顿,晚了一步点“驳回”。
如果没有并发控制,B 的请求会把状态从“待付款”强行拉回“草稿”。
如何解决?
利用我们在持久层讲过的乐观锁(Version)。
在更新数据库时,加上version校验。状态机在内存里流转成功后,落库时必须带上原来的version。
// 数据库乐观锁更新int rows = orderMapper.updateStatus(orderNo, nextState, oldVersion);if (rows == 0) {throw new BusinessException("单据状态已发生变化,请刷新后重试");}
五、无缝对接基础服务与中间件
光有状态流转还不够,状态流转往往会触发一系列后续动作,这正是我们前面连载的底座大显身手的地方:
与基础服务(日志服务)联动:
我们不要只存一个最终状态,而是要把每一次流转都记录下来(谁、何时、把什么状态改成了什么状态)。这些日志异步打到 ELK,方便后续追溯(符合大宗商品严格的审计要求)。与公共中间件(RocketMQ)联动:
上面代码中的doAction()方法,在流转成功后,应当立刻向外抛出一个领域事件(如OrderPaidEvent)。RocketMQ 收到事件后,异步通知履约域去派车,或通知库存域去锁库。状态机负责业务正确性,MQ 负责性能与解耦。
六、本篇小结
告别几百行的if-else,状态机让业务代码变得像诗一样优雅。
记住三句话:
状态机不是简单的枚举判断,它管理的是状态流转的合法性、权限和触发动作。
Cola StateMachine 配置即文档,一眼看清单据能怎么流转。
状态机流转成功的落库,必须加上乐观锁,防止并发覆写。
下篇预告:业务域基建(下):动态定价与规则引擎,拆解如何用规则引擎(Drools/QLExpress)替代写死的价格计算逻辑,实现大宗交易的灵活定价。