支付链路规格:失败场景与重试矩阵

支付链路规格:失败场景与重试矩阵
Spec Coding 编辑部 · Spec-First 工程实践内容

我评审过的大多数支付规格,都用三页篇幅描写 happy path,再用一句话描写失败行为:“出错时重试”。生产环境里 80% 的事故,就是从这一句话里冒出来的。一份支付流程规格值不值得存在,要看它有没有逐类别地讲清楚:当卡组织、发卡行或客户拒绝配合时,系统究竟要做什么。

发布于 2026-03-01 · ✓ 已更新 2026-05-06 · 阅读约 7 分钟 · 作者:Spec Coding 编辑部 · 审校:编辑与事实核查政策

先定义失败分类,而不是先画流程图

在我画出第一个方框之前,我会逼着规格先回答一个问题:这条工作流能产生哪些类别的失败?我坚持分成五类,因为把它们全塞进一个笼统的“error”,就是所有混乱的起点。

规格里所有下游决策——重试策略、用户提示、可观测性——都挂在这张五行矩阵上。分类错了,后面再怎么写也救不回来。

重试规则必须真正匹配类别

下面这句话我会一字不差地写进每一份支付规格:重试策略是失败类别的函数,不是 HTTP 状态码的函数。Stripe 返回的 402 既可能是你应当让客户修复的软拒付,也可能是你永远不该再碰的硬拒付。规格必须基于处理器的 decline code 分支,而不是传输层的状态码。

具体来讲:对于 Stripe 返回 card_declineddecline_code: insufficient_funds 的情况,我最多允许三次重试,按照下文的 dunning 时间表间隔,每一次都必须由客户主动操作或者定时任务触发。对于 card_declined 配合 decline_code: stolen_card,规格会在支付方式上设置一个永久标志,此后任何一次尝试都必须在命中网络之前就 fail closed。对于连接错误,或者返回体为空的 HTTP 5xx,规格要求立即用同一个 Idempotency-Key 重试,因为处理器可能已经扣了款,换一个新 key 会造成重复扣费。

Idempotency Key 属于 attempt,不属于请求

我见到最多的错误是:idempotency key 的作用域绑在了 HTTP 请求上,而不是逻辑意义上的 attempt。如果超时触发重试,而重试生成了新的 key,处理器就会把它当作一笔新的 charge。规格必须用一句话说清楚:key 在 attempt 开始时生成,并在该 attempt 的每一次重传中保持不变。只有新的 attempt——也就是新的客户动作、新的 dunning 周期或新的订单——才会获得新的 key。中间任何环节都不会。

我也会在规格里写明 key 的生命周期:处理器通常承认 key 的时间是 24 小时。如果重试跨越了这个边界,规格就要求先用处理器的账本(按 metadata 列出 charges)做对账,而不是默认这次重试是安全的。

3DS 分支是一等公民状态

SCA 不是错误。如果规格把它当成错误,前端就会做出愚蠢的事情——比如发卡行正在 challenge 中途,前端却显示了一条红色告警。规格里必须有一个名为 requires_action 的状态(或者你的处理器给它起的别名),并写明显式转换:auth 返回 challenge URL 时进入该状态,webhook 确认成功或失败时退出。

我会把两种形态分别 spec 出来。In-flow challenge:客户端 SDK 挂载 iframe,阻塞交互,直到解析完成。Redirect challenge:浏览器跳转到发卡行的 ACS URL,再回到我们控制的 return URL。规格会钉死 return URL、预期的 query 参数,以及客户在 challenge 途中关闭标签页时该怎么办。最后那种情形总是被遗忘,而它恰恰是在生产环境里造成卡死订阅的典型场景。

Auth、capture 与七天断崖

如果你的工作流是 auth-now / capture-later,规格必须点名 auth 的过期时间。多数处理器会在大约七天后自动 void 未 capture 的 auth(Stripe 是 7 天,Adyen 随卡组织不同而异,一些借记卡方案还更短)。规格必须回答:如果履约任务在第 8 天运行怎么办?我的答案始终一致——规格要求在第 5 天之后的任何 capture 尝试之前先做一次新的 auth,并且把对已过期 auth 的 capture 尝试视为硬失败,开启新的授权,而不是重试。

Multi-capture 会让这件事更糟。如果你要针对同一次 auth 做分次 capture,规格必须写清楚部分 capture 的顺序、是否允许 over-capture(通常不允许),以及在 final capture 之前发起的 refund 如何与剩余已授权金额相互作用。我见过团队凌晨两点才发现:他们那笔“简单”的退款把可 capture 的余额清零了,把下一次发货也一同干掉了。

Dunning 是一个状态机,把它写下来

对于订阅失败,规格里要原封不动地写出 dunning 时间表,而不是含糊其辞地说“我们会重试”。我默认采用的时间表是:

每一次转换都是状态表里的一行:前一个状态、触发条件、新状态、副作用(邮件、webhook、访问标志)。没有这张表,团队每个季度都会重新争论一次时间表该怎么排。

Webhook 才是真相之源

这一条我会写成不可商量的条款:处理器的同步响应只是参考,webhook 才是账本。规格必须禁止任何仅依赖 API 响应得出的状态转换——所有有意义的事件(capture 已确认、refund 已结算、dispute 已开启、3DS 已完成)都必须等到对应的事件到达。

这条规则有一个具体后果:规格需要一个 outbox 或对账任务。如果 webhook 延迟,UI 展示的 “processing” 会比客户预期的更久。规格要认领这个权衡,并挑一个超时阈值,在此之后任务会直接轮询处理器。对交互流程我取 30 秒,对后台流程我取 15 分钟。

验收标准:一个真实的重试场景

- Given a customer with a Visa ending 4242 and a recurring $29 subscription
  When the renewal charge returns card_declined / insufficient_funds
  Then the payment is marked past_due
    And attempt 2 is scheduled for +3 days with the same payment method
    And no email is sent on this attempt
    And the customer retains access until the grace period expires

- Given attempt 3 has just failed with the same decline_code
  When the dunning job runs
  Then a past_due_final email is sent
    And attempt 4 is scheduled for +14 days
    And the subscription remains active until attempt 4 resolves

- Given the client receives a connection timeout on charge creation
  When the client retries within 24 hours
  Then it reuses the original Idempotency-Key
    And the processor returns the original charge, not a duplicate

规格必须点名的可观测性

有三个指标,我绝不允许一份支付规格不写就上线:按 BIN 段和卡组织拆分的授权率;保留处理器原始 decline_code(不要笼统归为 “declined”)的拒付原因分布;以及以“发起挑战数 vs 完成挑战数”计算的 3DS 流失率。这三个中任何一个缺席,当某家发卡行第一次悄悄调整风控模型、一夜之间把你的通过率砸下去时,团队就会两眼一抹黑。

我还会要求一块 webhook lag 的看板——处理器事件时间戳与我方摄入时间戳之间的差值。这个差值变大,通常是支付链路即将触发告警的最早信号。

我给每个团队的最终结论

支付规格不是“描述 charge 接口”。它是一份失败处理文档,附带一条小小的 happy path。把分类写对,给每一行配上重试规则,把 3DS 当作分支而不是错误,让 webhook 成为真相之源。其他一切——dunning 文案、看板、退款流程——都会从这四个决策里自然推导出来。跳过它们,你就要用接下来的两个季度不停地打补丁。

可复制产物:契约评审包

当工作涉及 API 行为、schema、事件、重试或消费者预期时使用。它会把兼容性和发布证据提前摊开。

API 契约评审包:支付链路规格:失败场景与重试矩阵

本次要做的决策:
- 确认契约变化是否兼容,消费者需要什么迁移动作,发布后如何观察风险。

责任人检查:
- 产品责任人:
- 工程责任人:
- QA 或运维评审:

范围边界:
- 本次包含:
- 本次不包含:
- 仍需确认的假设:

验收证据:
- 测试或 fixture:
- 日志、指标或截图:
- 人工复核步骤:

契约边界:没有兼容性分类、消费者影响、重试行为和回滚说明,不进入发布。

评审追问:
- 没参加需求会的人还会误解哪里?
- 哪个证据能证明这次改动足够安全,可以发布?

编辑复核记录

复核日期:2026-04-28。本次补充了可复用产物,按相关主题 Hub 检查了文章定位,并收紧下一步链接,让页面更像可操作参考,而不是孤立长文。

关键词:payment workflow spec · idempotency key · 3DS challenge · dunning 状态机 · decline code 分类 · webhook 作为真相之源

专题阅读路径

这篇文章归入 API 契约 主题。先读 Hub,再结合下面的清单、模板或工具落到具体项目里。

编辑说明与免责声明

最近复核:2026-04-28。编辑部检查了示例、内链和可复制评审片段,确保内容更适合真实项目使用。

本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。