机器人租赁订单系统开发全流程:下单、签约、支付、履约的状态机设计
机器人租赁系统开发中,订单模块是最容易被低估的部分。表面上它就是"创建一条订单记录",但真正做起来你会发现:拒收、取消、退款、逾期归还、支付回调重复通知……每一条异常路径都在考验状态机的严谨性。我们第一次给订单加状态字段时只有 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 个状态与异常分支
我们最终的订单状态全景如下(状态码 + 说明):
| 状态码 | 状态 | 说明 |
|---|---|---|
| 10 | CREATED | 已创建,未签约 |
| 11 | SIGNED | 已签约,待支付 |
| 12 | PAYING | 支付中(生成支付单) |
| 13 | PAID | 已支付,待交付 |
| 14 | PARTIAL_PAID | 部分支付(分期/对公到账部分) |
| 20 | DELIVERING | 交付中(物流/服务商配送) |
| 21 | DELIVERED | 已签收,租赁生效 |
| 22 | RETURNING | 归还中 |
| 23 | RETURNED | 已归还待验收 |
| 24 | FINISHED | 验收完成,订单完结 |
| 30 | CANCELING | 取消中(待确认退款) |
| 31 | CANCELED | 已取消 |
| 32 | REFUNDING | 退款中 |
| 33 | REFUNDED | 已退款 |
| 40 | REJECTED | 拒收(交付异常分支) |
| 41 | OVERDUE | 逾期未归还 |
| 42 | COMPENSATING | 赔偿处理中(设备损坏/丢失) |
| 99 | CLOSED | 异常关闭 |
异常分支的关键迁移路径:
DELIVERING --客户拒收--> REJECTED --协商--> REFUNDING --> REFUNDED
DELIVERED --到期未还--> OVERDUE --催还成功--> RETURNING
OVERDUE --催还失败--> COMPENSATING --> FINISHED(扣押金完结)
PAID --交付前取消--> CANCELING --> REFUNDING --> REFUNDED
画状态机时我们总结了两条原则:
- 状态要按"阶段"分段编码(1x 交易前、2x 履约中、3x 退出中、4x 异常),一眼就能看出订单处在生命周期的哪个阶段,排查问题、做报表都受益。
- 异常不是流程的敌人,是一等公民。拒收、逾期、赔偿必须显式建模,用
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 天各一档)。通知配置同样挂在迁移表的事件上,事件触发时投递到消息队列异步发送。这样通知逻辑和状态逻辑彻底解耦——运营改通知文案、增减触达渠道,都不用碰状态机代码。曾有人建议把通知同步写进迁移方法里图省事,被我们否掉了:迁移链路必须保持轻和快,任何慢操作(发短信、调微信接口)都不该拖住状态变更本身。
五、最佳实践清单
- 订单状态按生命周期分段编码,异常状态显式建模,不用备注字段装状态;
- 迁移规则收敛为迁移表,统一入口执行,行锁 + CAS 双重防护并发迁移;
- 副作用通过状态迁移事件异步发布,状态机本体保持纯粹;
- 幂等分三层:通知幂等(唯一约束)、迁移幂等(自迁移规则)、资金幂等(流水表);
- 把异常路径写进自动化测试:重复回调、并发取消+支付、拒收后退款,一个都不能少;
- 每次新增状态先改迁移表、画进状态图、更新文档,三步缺一不可。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐


所有评论(0)