支付链路规格:失败场景与重试矩阵
我评审过的大多数支付规格,都用三页篇幅描写 happy path,再用一句话描写失败行为:“出错时重试”。生产环境里 80% 的事故,就是从这一句话里冒出来的。一份支付流程规格值不值得存在,要看它有没有逐类别地讲清楚:当卡组织、发卡行或客户拒绝配合时,系统究竟要做什么。
先定义失败分类,而不是先画流程图
在我画出第一个方框之前,我会逼着规格先回答一个问题:这条工作流能产生哪些类别的失败?我坚持分成五类,因为把它们全塞进一个笼统的“error”,就是所有混乱的起点。
- 网络超时(Network timeout)。处理器从未应答。这笔 charge 可能存在,也可能不存在。这是唯一必须用同一个 idempotency key 重试的类别。
- 软拒付(Soft decline)。发卡行出于可恢复的原因拒绝:余额不足、do-not-honor、卡片过期。允许重试,但必须借助客户操作或延后的重试窗口。
- 硬拒付(Hard decline)。卡被盗、pickup card、欺诈。重试永远是错的,在某些卡组织上甚至会抬高你的风险分。规格必须禁止重试。
- 风控审查(Fraud review)。处理器接受了 auth,但没给出结论。响应是异步的。规格必须描述等待状态,以及结束这个状态的 webhook。
- 3DS 挑战。发卡行要求 SCA(Strong Customer Authentication)。这不是失败——这是一个分支。客户会看到跳转或嵌入式 iframe,工作流会暂停,直到客户完成验证。
规格里所有下游决策——重试策略、用户提示、可观测性——都挂在这张五行矩阵上。分类错了,后面再怎么写也救不回来。
重试规则必须真正匹配类别
下面这句话我会一字不差地写进每一份支付规格:重试策略是失败类别的函数,不是 HTTP 状态码的函数。Stripe 返回的 402 既可能是你应当让客户修复的软拒付,也可能是你永远不该再碰的硬拒付。规格必须基于处理器的 decline code 分支,而不是传输层的状态码。
具体来讲:对于 Stripe 返回 card_declined 且 decline_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 时间表,而不是含糊其辞地说“我们会重试”。我默认采用的时间表是:
- Attempt 1:续费时立即执行。
- Attempt 2:+3 天,静默重试。
- Attempt 3:+7 天,提前 24 小时发邮件。
- Attempt 4:+14 天,最终通知邮件。
- 取消:+21 天,订阅转为
canceled,访问权限在下一个计费周期边界撤销。
每一次转换都是状态表里的一行:前一个状态、触发条件、新状态、副作用(邮件、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 检查了文章定位,并收紧下一步链接,让页面更像可操作参考,而不是孤立长文。
专题阅读路径
这篇文章归入 API 契约 主题。先读 Hub,再结合下面的清单、模板或工具落到具体项目里。
继续阅读
编辑说明与免责声明
最近复核:2026-04-28。编辑部检查了示例、内链和可复制评审片段,确保内容更适合真实项目使用。
本文用于软件工程教学与实践参考,不构成法律、税务或投资建议。示例场景用于解释规格方法,不对应真实客户数据。
- 作者信息:Spec Coding 编辑部
- 编辑政策:编辑与事实核查政策
- 联系方式:联系页面
本页合并覆盖的主题
为了让文章库更聚焦,这篇主文章现在作为「支付链路规格:失败场景与重试矩阵」的规范入口,同时覆盖下面这些原本分散的相关主题。读者可以在一个页面里完成判断、复制和评审,不必在多篇相似文章之间来回跳转。
- 计费对账规格:容差机制与异常升级
- 平台结算规格:对账与争议处理
- 复盘:用 Spec-First 预防账单事故
- 订阅变更规格:分摊计费与续费边界条件