ENGINEERING NOTES · 工程笔记

支付和入驻流程为什么要用显式状态机,而不是 if-else

做支付和商家入驻的同行大概都有类似经历:第一版只有"没支付""支付了"两个状态,一个 if-else 就够。然后回调重试、定时关单、后台手工操作、退款接口一个个来了。三个月后,状态判断散落在回调处理器、补偿任务、后台接口、退款逻辑四处,每处都写着"if (status == PAID)",却没人说得清 status 共有几种合法值。

我们在多租户小程序 SaaS(Java 21 / Spring Boot / PostgreSQL,27 个业务域)里踩过这类坑之后,把支付单和入驻申请全部重构成了显式状态机。这篇文章讲清楚为什么,以及怎么做。

一、散落的条件判断是怎么演化成事故的

三类典型事故,各对应一种反模式:

重复回调。渠道回调是"至少一次"投递,同一成功回调可能到达两次;两个入口各自判断"未支付则置已支付",并发下双双通过,发货执行两次。

非法迁移。订单超时被关单任务置为"已关闭",两秒后刷卡成功回调姗姗来迟,处理器只判"金额相符",把已关闭单强行改成"已支付"——钱收了,单子却是关闭态,对账对不平。

结果未知。调渠道支付接口超时,代码习惯性 catch 后置"失败"。但超时不等于失败——请求可能已扣款成功,只是响应没回来;用户重新支付,渠道躺着两笔成功交易,次日对账才发现,退款退得手忙脚乱。

一次被拦下的二次退款(复盘)

时间与单号为脱敏示意。某年 11 月 27 日 14:02:09,退款成功回调首次到达,订单从"退款中"迁到"已退款",落库发事件,正常。14:02:11,同一条回调因重试再次到达。旧退款入口只判"金额相符",没有一行代码问"是否退过"——二次退款几乎发出,靠渠道幂等键兜底才没资损,两人翻对账和日志查了大半天。

重构后同场景:回调仍被受理,但它要触发的迁移是"已退款 → 退款中",终态无出边,直接拒绝、留痕,退款请求不会发往渠道:

14:02:11 WARN [state-transition] order=10**4
  rejected: REFUNDED -> REFUNDING 不在迁移表(REFUNDED 为终态)
  source=退款成功回调(渠道重试第2次) reason=refund_callback
  action=忽略本次迁移,留痕上报,不外呼渠道

这行日志就是分界线:if-else 把资金安全外包给渠道幂等键,状态机在自己这侧拦下并留证据链。

共同根源:状态不是被统一管理的值,而是一堆局部判断的副产品。每个入口单独看都对,合在一起就没人能推理;想问"这单能不能退款",得读懂四段代码、三个布尔字段的两两组合——无法靠"更小心"解决。

二、显式状态机的三个组成

我们最终收敛为三件套:

  1. 状态枚举:所有状态显式列出,禁止用 boolean(paidclosed 组合出隐式状态是最危险的);
  2. 迁移表:一张"当前状态 → 允许到达的状态集合"的静态表,表外迁移一律拒绝,拒绝即报错留痕;
  3. 唯一写入口:所有状态变更(回调、定时任务、后台操作、退款)收敛到一个方法,在事务内做表校验加并发校验,通过才落库。

第三点最容易被忽略:迁移表再漂亮,各入口各自 UPDATE 就等于没设防。写入口的骨架是"一个事务模板 + 一组按目标状态注册的守卫"——模板管事务与并发裁决,守卫管业务校验,伪代码见第五节。

另一个被低估的收益:迁移表是静态的,可全量审查、穷举测试,把资金流程的正确性从运行时提前到了建表期。

三、以支付为例的迁移图

stateDiagram-v2
    [*] --> 待支付 : 创建订单
    待支付 --> 支付中 : 发起支付
    支付中 --> 成功 : 回调明确成功
    支付中 --> 失败 : 回调明确失败
    支付中 --> 结果未知 : 超时/网络异常
    待支付 --> 已关闭 : 超时关单
    支付中 --> 已关闭 : 查单确认未受理
    结果未知 --> 成功 : 查单补偿
    结果未知 --> 失败 : 查单确认
    结果未知 --> 已关闭 : 人工核验
    成功 --> 退款中 : 发起退款
    退款中 --> 已退款 : 退款成功
    退款中 --> 成功 : 退款失败回退

图越画越密,评审和测试里真正当"合同"用的是文字版全图——逐状态盘点出入边,缺一条是实现缺口,多一条是越权路径:

  • 待支付(CREATED):入=创建订单;出=→支付中、→已关闭(10 分钟未支付)。
  • 支付中(PAYING):入=←待支付;出=→成功、→失败(回调明确)、→结果未知(超时或网络异常,不猜结果)、→已关闭(查单确认渠道未受理)。
  • 结果未知(UNKNOWN):入=←支付中,仅此一条;出=→成功、→失败(查单确认)、→已关闭(超窗人工核验)。
  • 成功(PAID):入=←支付中、←结果未知、←退款中(退款失败回退);出=→退款中,且是唯一出边——改金额、作废都得先走退款。
  • 退款中(REFUNDING):入=←成功;出=→已退款、→成功(退款失败回退)。
  • 失败、已退款、已关闭为终态,无出边:入边分别为←支付中或结果未知、←退款中、←待支付/支付中/结果未知。第一节的二次退款就拦在"已退款无出边"上。

这张表本身就是资金安全的边界——表里没有的路,代码就走不通。

四、"结果未知"态的设计价值

多数手写 if-else 的支付代码只有成功/失败两态,这是"结果未知"事故的根源。显式状态机让我们坦然引入第三个值:调第三方超时,不猜结果,置为"结果未知",进恢复路径——

  • 查单补偿:定时任务对"结果未知"的单子主动调渠道查单,按真实结果迁到成功或失败;
  • 人工核验:超补偿窗口仍未确认的,进人工核验队列,处理人带查单凭据做决定,动作也走同一个写入口。

配合全图,"结果未知"的纪律只有两条:入边唯一;出边必须带凭据(查单结果或人工决定),任何入口都不许"超时即失败"地猜。不确定性被显式建模,而非被错误默认值吞掉。附带的好处:客服和运营看后台时,"结果未知"是有处理流程的状态,而非"订单好像卡住了"——显式建模最终会变成组织里可交接的流程。

五、脱敏伪代码

Java 风格伪代码(脱敏,仅示意结构):

enum PayState { CREATED, PAYING, PAID, FAILED, CLOSED, UNKNOWN, REFUNDING, REFUNDED }

// 迁移表:静态、集中、可审查
static final Map<PayState, Set<PayState>> ALLOWED = Map.of(
    CREATED,   Set.of(PAYING, CLOSED),
    PAYING,    Set.of(PAID, FAILED, UNKNOWN, CLOSED),
    UNKNOWN,   Set.of(PAID, FAILED, CLOSED),
    PAID,      Set.of(REFUNDING),
    REFUNDING, Set.of(PAID, REFUNDED));   // 退款失败回退

// 唯一写入口:回调、定时任务、后台操作、退款全部走这里
@Transactional
public void transition(long orderId, PayState target, String reason) {
    var order = orderRepo.find(orderId);
    if (!ALLOWED.getOrDefault(order.state, Set.of()).contains(target))
        throw new IllegalTransitionException(order.state, target, reason); // 拒绝即留痕

    guard(order, target);  // 守卫:表管"能不能走",守卫管"该不该走"

    // CAS 式更新:UPDATE ... SET state=target WHERE id=? AND state=旧状态
    int n = orderRepo.casUpdateState(orderId, order.state, target, reason);
    if (n != 1) throw new ConcurrentTransitionException(orderId); // 并发裁决点

    events.publish(new PayStateChanged(orderId, order.state, target));
}

守卫按目标状态注册,一个守卫只管一件事(伪代码):

// 进 PAID 前:验回调签名、比金额
void guardToPaid(Order o, Callback c) {
    verifySignature(c);                      // 验签失败直接拒
    if (o.amount != c.paidAmount) throw ...; // 金额不符,拒绝进成功态
}

// 进 REFUNDING 前:确认无在途退款,二次退款的第一道闸
void guardToRefunding(Order o, RefundReq r) {
    if (refundRepo.existsActiveByOrderId(o.id)) throw new DuplicateRefundException(o.id);
}

迁移表管"能不能走",守卫管"该不该走",两层缺一不可:只查表不守卫,表内但业务不该的动作会畅通无阻;只守卫不查表,路径又散回各入口。

六、踩坑清单

每条按"现象 → 定位 → 修法"展开:

并发双写。现象:回调与查单补偿几乎同时到达,都把"结果未知"迁往"成功",两次 UPDATE 都成功,下游收两份发货事件。定位:同一单两条迁移记录相差 17 毫秒——两边都"先查再改",谁也没锁谁。修法:条件更新当唯一裁决,UPDATE ... SET state=新 WHERE id=? AND state=旧,影响行数不为 1 即冲突、放弃并记日志;重路径可加 SELECT ... FOR UPDATE 行锁减少冲突,裁决仍以影响行数为准。

回调乱序。现象:退款成功回调先于支付成功回调 3 秒到达,按到达顺序处理反复触发告警。定位:对比回调里的渠道事件完成时间戳与库内迁移时间戳,是到达与事件顺序不一致,不是数据错。修法:按事件完成时间戳判新旧,过期事件触发的迁移直接丢弃;拿不准时以查单结果做校正迁移。

穷举迁移矩阵测试。现象:新增"结果未知"态漏改了关单任务,多出一条表外路径,测试环境当天没炸,演练环境隔周才炸。定位:人肉列组合永远列不全。修法:把迁移表当测试输入,两层循环遍历枚举的 N×N 组合生成用例,ALLOWED 内断言成功、其余断言抛 IllegalTransitionException;新增状态矩阵自动扩列,漏一条边当场红。全仓 1241 个测试方法里这套矩阵性价比最高。

终态不可逆。已关闭、已退款永远不给入边,想"复活"就新建关联单据,别改历史。运营曾要求"把误关的单打开",方案是新建一笔引用原单号的支付单,审计链完整可追。

七、三句话总结

  1. 状态机的价值不在画图,而在把"哪些迁移合法"从散落的 if-else 收敛成一张可审查的静态表;
  2. 所有状态变更收敛到唯一写入口,在事务内做表校验加 CAS,并发问题才有统一的裁决点;
  3. 给不确定性留显式状态——"结果未知"配合查单补偿,比任何"超时当失败"的默认值都安全。

运营主体:北京位元跃迁科技有限公司。本文同步发布于本号技术专栏,可搬运至 CSDN/掘金。

本文为位元跃迁原创内容,仅代表编辑观点,不构成经营或投资建议;文中涉及的品牌与案例仅作公开信息分析示例。

LET'S TALK

把方法用进你的生意

如果你正在为门店做数字化规划,欢迎和我们聊聊。位元跃迁是微信支付合作伙伴、微信开放平台第三方平台,提供小程序定制、开发咨询与企业数字化服务。

18601279913

业务咨询 · 项目合作 · 代理商合作

了解位元跃迁的服务 →
位元跃迁业务联系微信二维码
微信咨询