支付 Demo 很容易做:点一个按钮,跳到收银台,付款完成,再回到成功页面。
真正上线以后,麻烦从网络不稳定那一刻开始。用户点了付款,浏览器超时,于是又点一次;服务器请求支付平台成功了,但响应在路上断掉,于是代码重试;支付已经完成,webhook 过几秒才到;平台发现 webhook 没及时返回,又重新投递。
如果系统把每一次请求都当成新动作,结果可能是重复订单、重复扣款、重复发积分。
第一个概念:幂等
Stripe 官方 API 明确支持 idempotency key,用来安全重试创建或更新请求。相同 key 的后续请求会返回第一次请求的结果,从而避免因为网络重试再次执行同一操作。
但支付系统不能把幂等完全外包给支付平台。自己的数据库也要有业务唯一性:一个 checkout session、一个支付事件、一个订单发货动作,应该有稳定 ID 和唯一约束或事务保护。
否则 webhook 收到两次,平台没重复扣款,你仍然可能给用户发两次权益。
第二个概念:异步事实
用户从支付平台跳回成功页,只说明浏览器走到了这个 URL,不代表系统应该立刻把订单改成 paid。
成功页参数可以被手工构造,浏览器也可能在支付最终状态还没同步时先回来。更可靠的做法是:支付平台的签名事件或服务端查询成为付款事实来源,本地订单根据经过验证的事实更新。页面只读取当前订单状态并展示。
Stripe 的 webhook 文档也把支付确认、争议、周期性付款等列为典型异步事件,并要求服务端验证签名。
第三个概念:回调会重复
Webhook handler 要假设同一事件可能重新送达。
先记录 provider event id 是否处理过,再在事务内做状态变更和权益发放。处理成功后快速返回 2xx;复杂工作可以进入持久化任务队列,而不是让 webhook 请求一直等模型生成、发邮件或做大计算。
如果任务失败,要有可重试状态,而不是“接口报 500,下次看运气”。
第四个概念:付款和交付是两个状态
卖一个 PDF,支付成功后可以立即生成下载权限;卖 AI 解读,付款成功以后还要调用模型并产出结果;卖次数包,则付款成功是增加余额,真正使用时再消费。
这几个动作不能混成一个 paid 状态。至少要区分订单支付、权益和具体交付任务。这样生成失败时,才能明确是退款、重试还是释放预留次数,而不是靠人工猜用户现在到底欠没欠一次服务。
上线前至少做五个破坏性测试
连续点两次购买;创建订单响应超时后重试;同一个成功 webhook 重放两次;付款成功但交付任务失败;用户不经过收银台直接打开成功页。
再根据业务加入退款、取消订阅、支付失败等分支。
支付系统可靠的标准不是“正常付款一次成功”,而是异常发生时钱和权益仍然对得上。按钮只是入口,真正的支付产品是一台状态机。