tva
← 洞察

国际电商的 Stripe Checkout 集成:边界场景与经验

Stripe 文档确实出色,初次集成通常一次就能成功。但实际开展国际电商业务时,还会遇到快速入门指南没有覆盖的一类问题:基础设施重启期间的 webhook 投递可靠性、非结算币种退款的细微机制,以及在多个环境中管理 API 密钥的运营开销。本文记录我们遇到的边界场景,以及由此作出的调整。

结账会话模型及其假设

Stripe Checkout 是托管支付页面。您在服务器上创建会话,将客户重定向过去,支付完成后 Stripe 调用您的 webhook。这个流程本来很直接,但涉及多币种,或履约时需要查看完整支付对象,就会变得复杂。

默认情况下,Checkout Session 响应只包含精简的支付信息。payment_intent 字段是字符串 ID,而非展开后的对象。如果履约逻辑需要读取 payment_intent.latest_charge.payment_method_details,例如记录银行卡所属国家,供增值税判定逻辑使用,这一区别就很重要。(在 2022-11-15 之前的 API 版本中,路径为 payment_intent.charges.data[0];Stripe 在该版本中移除了 PaymentIntents 的 charges 属性。)webhook 事件从不包含展开后的对象,因此在履约时获取这些数据,需要为每个事件再调用一次 API。

expand 参数接受点分路径数组。它只作用于请求展开的那次 API 调用的响应:在创建会话时设置它,不会改变 Stripe 随后在 webhook 中发送的会话对象,因为 Stripe 始终以精简形式发送事件中的对象。应在 webhook 处理函数中获取会话,并展开 ["payment_intent.latest_charge"]。代价是每个事件都需要额外调用一次 API,且响应更大;如果要将原始响应写入日志存储,就需要考虑这一点。

webhook 可靠性

Stripe 的 webhook 投递依赖重试:如果端点未返回 2xx 状态或请求超时,Stripe 会以指数退避方式重试,正式模式下最长持续 3 天;沙盒事件则会在几小时内重试 3 次。这种设计很好,但处理函数若不具备幂等性,就会产生正确性问题。订单创建、库存预留和邮件发送等副作用,都应恰好执行一次。

标准做法是在执行副作用前记录已处理的事件 ID。我们使用一个简单的数据库表,以 Stripe 事件 ID 为主键。处理函数读取传入的 id 字段,尝试插入,只有插入成功才继续执行。重复的事件 ID 会引发冲突,处理函数立即返回 200。要保证安全,插入必须与副作用在同一个数据库事务中提交(或仅在副作用成功后将该行标记为已处理);否则,插入后发生崩溃会让 Stripe 的重试看起来像重复事件,订单就永远无法履约。

文档没有充分强调的是:收到 checkout.session.completed 事件,与支付在 Stripe 结算时间线上真正完成扣款,可能并不同步。某些支付方式,尤其是德国和荷兰可用的银行转账及部分先买后付(BNPL)方式,会在资金确认前完成结账会话。这些情况下,会话以 payment_status 为 unpaid 的状态完成,正确的事件是用于触发履约的 checkout.session.async_payment_succeeded 和用于处理失败的 checkout.session.async_payment_failed。不检查 payment_status 就对每个 checkout.session.completed 触发履约,会发出付款随后失败的订单。

我们还遇到过容器重启周期带来的问题。部署时,正在传递的 webhook 请求可能恰好在旧容器关闭、新容器启动时到达。Nginx 会在上游切换期间保持连接,但我们仍观察到一个短暂窗口内发生事件丢失。解决方式是在 Nginx 配置中显式添加 keepalive_timeout 和 proxy_read_timeout 设置,并依靠 Stripe 的重试机制,而非自行让 webhook 接收端的部署窗口完全零停机。

正式与测试密钥管理

我们看到的 Stripe 集成中,最常见的错误是没有真正分离正式密钥和测试密钥。明显的情况是在测试环境误用正式密钥,产生真实扣款。更隐蔽的情况是:预发布环境使用测试模式密钥,却共享一个含有类生产数据的数据库。

我们的约定是使用三个环境,各自拥有完整的 Stripe 凭据:development、staging 和 production。开发环境使用 Stripe 测试模式。预发布环境也使用测试模式,但采用另一套受限密钥,并在 Stripe 控制面板中单独注册 webhook 端点。生产环境使用正式模式。所有密钥都在运行时通过环境变量注入,绝不提交到版本控制,也绝不在构建时写入 Docker 镜像。

webhook 签名密钥与 API 密钥是不同的凭据。Stripe 控制面板中,每个注册的端点都有独立的签名密钥。三个环境就有三个签名密钥,设置新部署时尤其容易复制错。我们在内部运维手册中明确记录 Stripe 账户、端点、签名密钥和密钥前缀之间的对应关系。这看似增加了开销,但否则就可能需要花时间排查正式模式事件为何悄然无法通过签名核验。

Stripe 的受限密钥功能没有得到充分使用。无需向每项服务提供完整的私密密钥,可以创建只允许该服务所需操作的密钥。只需读取支付意向并创建退款的履约工作进程,不需要产品或订阅的写入权限。我们为每项服务分发受限密钥,并独立于主私密密钥进行轮换。

币种处理与退款问题

Stripe 支持超过 135 种支付展示币种,但结算币种由 Stripe 账户所在国家决定。如果账户以 EUR 结算,而客户以 SGD 支付,Stripe 会在扣款时进行换汇。这个过程原本无需额外处理,直到您发起退款。

退款按支付展示币种处理,也就是客户付款时使用的币种。但从 Stripe 余额扣除的金额,按退款时的当前汇率计算,而非原始扣款时的汇率。如果两次操作之间汇率出现不利变动,以结算币种计算的退款金额就可能高于原先收到的金额。这并非缺陷,而是预期行为。Stripe 的退款和争议指南已有说明,但容易被忽略;对于多币种、低利润率业务,这会产生实际成本影响。(截至 2026 年 10 月,Stripe 的 Adaptive Pricing 是一个例外:其退款使用原始交易的汇率。)

实际的缓解方式是将可接受币种限制在已明确了解汇率风险的范围内,或保留能够覆盖汇率波动的资金缓冲。高交易量业务可以通过 Stripe 多币种结算账户,持有特定币种余额,按币种分别结算。这样可免去这些币种退款时的换汇,代价是资金管理更复杂。

我们还遇到过一种部分退款的边界场景:订单以不同于内部基准币种的币种扣款。(一个 Checkout Session 只使用一种币种,因此订单中的所有订单项都使用同一币种。)部分退款必须指定原始扣款币种的金额。如果订单管理系统只存储内部基准币种金额,就需要在订单记录中同时保存原始支付展示金额和币种,而非只有换算后的金额。首次部分退款因难以理解的校验错误而失败后,我们在订单表中添加了这两个字段。

各国支付方式的可用性

Stripe Checkout 的动态支付方式功能,会根据客户所在地,在支付表单中显示相关支付方式。这很方便,但需要谨慎使用。让 Stripe 根据控制面板设置选择支付方式,而不传入明确的列表时,创建会话时无法完全预测可用方式的集合;它取决于 Stripe 在页面呈现时评估的因素。

这会影响测试套件。如果结账流程测试断言特定支付方式会出现,那么测试在不同地区的表现就会不同。我们的集成测试使用显式的 payment_method_types 参数,而非动态支付方式,以获得确定的选项集合。生产环境的 Checkout 则使用动态支付方式。这种差异是有意设计的,也已记录在文档中。

SEPA Direct Debit、iDEAL、Bancontact 和 Przelewy24 各有银行卡支付不具备的要求。托管式 Checkout 会自行收集特定支付方式所需的信息,例如 SEPA 的 IBAN 和授权同意,但还有其他限制:Checkout 中的 SEPA Direct Debit 要求所有订单项均以 EUR 定价,且属于延迟通知的支付方式,因此适用上述异步事件。我们对每种已启用的支付方式测试完整结账流程,而非只测试支付确认步骤。

首批生产问题后作出的调整

集成经历最初几个月的真实交易后,我们作出三项结构调整。首先,将幂等键存储从内存迁移到数据库,因为内存存储无法在容器重启后保留。其次,每次 webhook 处理函数调用都明确记录 Stripe 事件 ID、事件类型和处理结果,而非只记录错误,以便排查订单状态不一致时重建事件顺序。第三,不再依赖 Stripe Dashboard 的事件日志作为主要诊断工具,而是以自身日志为准。

Stripe Dashboard 适合交互式查看事件,但展示的是 Stripe 对投递的观察,而非应用对接收的观察。Dashboard 标记为成功的投递,只表示我们的端点返回了 2xx 状态;它无法说明该响应背后的工作是否完成。自身日志反映了完整过程,Dashboard 提供的信息则不完整。


相关洞察

延伸阅读