机器人租赁系统开发中,订单模块是最容易被低估的部分。表面上它就是"创建一条订单记录",但真正做起来你会发现:拒收、取消、退款、逾期归还、支付回调重复通知……每一条异常路径都在考验状态机的严谨性。我们第一次给订单加状态字段时只有 6 个状态,上线半年后膨胀到 18 个——这篇文章就是我们重构状态机之后的完整复盘。


一、从一个事故说起:为什么 if-else 撑不住订单系统

重构前我们的订单推进逻辑长这样:

// 历史代码,勿效仿
public void payCallback(Order order) {
    if (order.getStatus() == 1) {          // 待支付
        order.setStatus(2);                 // 已支付
    } else if (order.getStatus() == 2) {
        // 已支付又收到支付回调?直接忽略
    } else {
        log.error("unknown status: " + order.getStatus());
    }
}

在这里插入图片描述

问题很快暴露:签署合同中途客户取消,订单停在"待签约"状态,既不能支付也不能退款,客服只能找研发改库;B 端客户用了对公转账,财务人工核销后要推订单进履约,又写了一段新的 if-else。状态迁移规则散落在十几个业务方法里,谁也说不清"到底哪些状态能到哪些状态"。

订单系统复杂度的根源在于:它不是一个 CRUD 对象,而是一个受事件驱动的状态机。事件(支付成功、签约完成、客户拒收、逾期 N 天)驱动状态迁移,每个迁移又触发副作用(通知、退款、档期释放、押金冻结)。不先把状态机画清楚,任何代码都会烂掉。

二、订单状态机全景图:18 个状态与异常分支

我们最终的订单状态全景如下(状态码 + 说明):

状态码状态说明
10CREATED已创建,未签约
11SIGNED已签约,待支付
12PAYING支付中(生成支付单)
13PAID已支付,待交付
14PARTIAL_PAID部分支付(分期/对公到账部分)
20DELIVERING交付中(物流/服务商配送)
21DELIVERED已签收,租赁生效
22RETURNING归还中
23RETURNED已归还待验收
24FINISHED验收完成,订单完结
30CANCELING取消中(待确认退款)
31CANCELED已取消
32REFUNDING退款中
33REFUNDED已退款
40REJECTED拒收(交付异常分支)
41OVERDUE逾期未归还
42COMPENSATING赔偿处理中(设备损坏/丢失)
99CLOSED异常关闭

异常分支的关键迁移路径:

DELIVERING --客户拒收--> REJECTED --协商--> REFUNDING --> REFUNDED
DELIVERED --到期未还--> OVERDUE --催还成功--> RETURNING
OVERDUE --催还失败--> COMPENSATING --> FINISHED(扣押金完结)
PAID --交付前取消--> CANCELING --> REFUNDING --> REFUNDED

画状态机时我们总结了两条原则:

  1. 状态要按"阶段"分段编码(1x 交易前、2x 履约中、3x 退出中、4x 异常),一眼就能看出订单处在生命周期的哪个阶段,排查问题、做报表都受益。
  2. 异常不是流程的敌人,是一等公民。拒收、逾期、赔偿必须显式建模,用 remark 字段装异常明细的做法在第二次事故后就再也没人提了。

还有一个容易被跳过但非常重要的环节:给每个状态定义"进入条件"和"停留时限"。比如 CREATED 状态停留超过 15 分钟未签约,说明客户大概率中途流失了,订单应该自动关闭并释放预占的档期(这是我们档期系统预占 TTL 的另一面);CANCELING 状态超过 48 小时未推进,说明退款审批卡住了,要触发催办。状态机的"出不去"比"走错"更隐蔽——走错的迁移会被校验拦住,而"停住了"只有靠时限监控才能发现。我们为此加了一张状态停留时长配置表,每个状态可配置最大停留时间,超时自动触发对应的善后事件或人工工单。

三、迁移合法性校验:迁移表驱动 vs if-else

重构的核心动作是把"哪些状态允许迁到哪些状态"收敛成一张迁移表

CREATE TABLE t_order_transition (
  id INT PRIMARY KEY,
  from_status INT NOT NULL,
  event VARCHAR(40) NOT NULL COMMENT '触发事件',
  to_status INT NOT NULL,
  UNIQUE KEY uk_transition (from_status, event)
);
# 代码里维护的迁移表(与 SQL 同步,双保险)
transitions:
  - {from: 12, event: PAY_SUCCESS,     to: 13}
  - {from: 12, event: PAY_PARTIAL,     to: 14}
  - {from: 13, event: DELIVER_START,   to: 20}
  - {from: 20, event: DELIVER_ACCEPT,  to: 21}
  - {from: 20, event: DELIVER_REJECT,  to: 40}
  - {from: 40, event: NEGOTIATE_REFUND,to: 32}
  ...

执行迁移统一走一个入口方法:

public Order doTransition(Long orderId, String event) {
    Order order = orderMapper.selectForUpdate(orderId); // 行锁防并发迁移
    OrderTransition t = transitionTable.find(order.getStatus(), event);
    if (t == null) {
        throw new IllegalTransitionException(order.getStatus(), event);
    }
    int rows = orderMapper.casUpdate(orderId,
            order.getStatus(), t.getToStatus());   // 乐观锁 CAS
    if (rows == 0) {
        throw new ConcurrentTransitionException(orderId);
    }
    eventPublisher.publish(new OrderTransitionedEvent(orderId,
            order.getStatus(), t.getToStatus(), event)); // 副作用走事件
    return orderMapper.selectById(orderId);
}

对比一下两种实现:

维度if-else 散落式迁移表驱动
迁移规则可视性藏在代码里,靠人肉梳理一张表全部可见
新增状态/事件改 N 处业务方法加一行配置
副作用管理每处自己写,容易漏统一事件发布,订阅者各取所需
并发安全各写各的,容易漏锁统一入口,锁与 CAS 收敛
测试成本全链路回归迁移表可自动穷举测试

迁移表驱动几乎全面占优,唯一代价是前期抽象成本。我们的判断标准:超过 8 个状态、有 2 人以上参与开发的订单系统,直接上迁移表,不要过渡。

迁移表还有一个意外的红利:它天然驱动了前端交互。订单详情页上"去支付"“取消订单”"申请退款"这些操作按钮的可见性,过去靠前端写死一整套 status === 13 的判断,状态一改前后端各改一遍。重构后我们直接把迁移表通过接口下发,前端按"当前状态 + 可用事件"渲染按钮,按钮点了就调 doTransition(event)。前后端只认事件名,不认状态码,联调成本大幅下降。小程序端还可以按事件配置文案("去支付"对应 PAY_START 事件),文案怎么改都不影响状态逻辑。

并发迁移的竞态场景也必须专门测试。最典型的是"取消与支付的赛跑":客户同时点了小程序里的支付按钮和客服后台的取消操作。我们的处理方式是让两者都通过 doTransition 入口竞争行锁,先到者赢:取消先拿到锁,订单进入 CANCELING,随后的支付回调会被迁移校验拒绝,支付系统走"已取消订单的退款"流程;支付先到,取消请求在校验层被拒绝并提示"订单已支付,请走退款流程"。这个赛跑场景我们写进了回归用例集,两个请求各并发打 100 次,断言最终状态唯一且资金流水守恒。

四、幂等设计:支付回调重复通知与接口重试

状态机解决了"乱",幂等解决的是"重"。真实环境里,支付回调重复通知、客户端重试、MQ 重复投递是常态而非异常。

第一层:支付回调幂等。 以支付单号为幂等键,用数据库唯一约束兜底:

@Transactional
public void handlePayCallback(PayCallback cb) {
    // 幂等插入:同一支付单的通知只处理一次
    int rows = payNotifyMapper.insertIgnore(
        new PayNotify(cb.getPayNo(), cb.getStatus()));
    if (rows == 0) {
        log.info("duplicate callback, payNo={}", cb.getPayNo());
        return; // 直接返回 success,防止微信重试轰炸
    }
    orderService.doTransition(cb.getOrderId(), "PAY_SUCCESS");
}

第二层:状态迁移本身的幂等。 迁移表里加 PAY_SUCCESS 事件重复到达时的规则:from=13, event=PAY_SUCCESS, to=13(自迁移),自迁移直接返回成功不触发副作用。这样即使第一层的唯一约束失效(比如通知先于事务提交重复到达),订单状态也不会被破坏。

第三层:副作用幂等。 退款、押金扣减这类资金操作,必须以业务单号 + 操作类型为幂等键建流水表,先插流水再执行动作:

-- 唯一约束:同一订单同类型操作只允许一条
UNIQUE KEY uk_idem (order_id, op_type, biz_no)

三层幂等做完之后,我们把"人为制造 100 次重复回调"加进了回归用例,状态与资金流水分毫不差,这条才算过关。

状态机之外,消息通知也要和迁移联动。每个状态迁移对应一组通知模板:支付成功通知客户和销售、交付中通知物流与客户签收时间、逾期触发催缴短信梯度(第 1 天、第 3 天、第 7 天各一档)。通知配置同样挂在迁移表的事件上,事件触发时投递到消息队列异步发送。这样通知逻辑和状态逻辑彻底解耦——运营改通知文案、增减触达渠道,都不用碰状态机代码。曾有人建议把通知同步写进迁移方法里图省事,被我们否掉了:迁移链路必须保持轻和快,任何慢操作(发短信、调微信接口)都不该拖住状态变更本身

五、最佳实践清单

  1. 订单状态按生命周期分段编码,异常状态显式建模,不用备注字段装状态;
  2. 迁移规则收敛为迁移表,统一入口执行,行锁 + CAS 双重防护并发迁移;
  3. 副作用通过状态迁移事件异步发布,状态机本体保持纯粹;
  4. 幂等分三层:通知幂等(唯一约束)、迁移幂等(自迁移规则)、资金幂等(流水表);
  5. 把异常路径写进自动化测试:重复回调、并发取消+支付、拒收后退款,一个都不能少;
  6. 每次新增状态先改迁移表、画进状态图、更新文档,三步缺一不可。
Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐