Stripe Webhook中的防御性编程

2 min read
Zekari
Web开发系统设计StripeWebhook防御性编程API集成

当支付成功但积分未到账

客户完成了年度订阅支付,1500积分应该自动到账。Stripe确认支付成功,但用户账户余额没有变化。Webhook日志显示500错误。

这不是单一故障。这是三个假设叠加的失败。

每个假设在编写时看起来都合理。检查环境变量,合理。使用API返回的字段,合理。在需要的地方创建实例,合理。

但当这些假设遇到现实,系统崩溃了。

假设一:API总是返回你期待的类型

Stripe的invoice.payment_succeeded事件返回一个subscription字段。文档说这是订阅的引用。代码直接使用:

const subscriptionId = object.subscription;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);

看起来没问题。但Stripe的API有个细节:subscription字段可能是字符串ID,也可能是完整的订阅对象。

当它是对象时,stripe.subscriptions.retrieve()收到的不是字符串,而是{id: "sub_xxx", ...}。SDK报错:Argument must be a string, but got: undefined

这个假设的代价是:每次订阅续费失败,客户都需要人工补发积分。

// ❌ 错误:直接使用,假设是字符串
subscriptionId = object.subscription;

// ✅ 正确:检查类型,安全提取
subscriptionId = typeof object.subscription === 'string'
  ? object.subscription
  : object.subscription?.id;

// 添加null check,避免传递undefined
if (!subscriptionId) {
  console.error("Missing subscription ID in invoice.");
  return c.json({ error: 'Missing subscription ID' }, 400);
}

同样的逻辑应用到所有可能有多种类型的字段(customerpayment_intent等)。

API的灵活性是双刃剑。字段类型的不确定性需要防御。不要假设,要验证。

假设二:环境配置总是按你想的方式

代码启动时检查环境变量:

if (!env.SUPABASE_URL || !env.SUPABASE_ANON_KEY) {
  throw new Error("Missing Supabase environment variables...");
}

看起来是good practice。但生产环境配置的是SUPABASE_SERVICE_ROLE_KEY,没有SUPABASE_ANON_KEY

为什么?因为webhook处理是服务端操作,需要service role权限。ANON_KEY是给客户端用的。

代码假设了"用户场景",但实际是"服务端场景"。所有webhook请求在这一行就失败了。

export const createSupabaseClient = (env, userJWT = null) => {
  // 先检查URL(所有场景都需要)
  if (!env.SUPABASE_URL) {
    throw new Error("Missing SUPABASE_URL environment variable.");
  }

  const options = {
    auth: { autoRefreshToken: false, persistSession: false },
  };

  // 如果提供了userJWT,使用anon key(客户端场景)
  if (userJWT) {
    if (!env.SUPABASE_ANON_KEY) {
      throw new Error("Missing SUPABASE_ANON_KEY for user-context operations.");
    }
    options.global = {
      headers: { Authorization: `Bearer ${userJWT}` },
    };
    return createClient(env.SUPABASE_URL, env.SUPABASE_ANON_KEY, options);
  }

  // 服务端操作,使用service role key
  if (!env.SUPABASE_SERVICE_ROLE_KEY) {
    throw new Error("Missing SUPABASE_SERVICE_ROLE_KEY for server-side operations.");
  }

  return createClient(env.SUPABASE_URL, env.SUPABASE_SERVICE_ROLE_KEY, options);
};

分离检查逻辑。根据是否有userJWT来决定需要哪个key。错误消息更清晰,调试更容易。

环境变量不是全局常量。不同场景需要不同的配置。检查逻辑应该反映这种多样性。

假设三:变量在你需要的地方都可见

Webhook处理需要Supabase客户端来更新数据库。代码在checkout.session.completed的case内创建实例:

switch (type) {
  case 'checkout.session.completed': {
    const supabase = createSupabaseClient(c.env);
    // ... 处理checkout
    break;
  }

  case 'invoice.payment_succeeded': {
    // ❌ 这里没有supabase实例
    // ... 需要访问数据库,但访问不到
    break;
  }
}

// ❌ Switch后续代码也需要supabase
const { data: plan } = await supabase.from('plans')...

invoice事件和switch后续代码都会失败。这是最基础的作用域问题,却最容易被忽略。

为什么会这样写?因为看起来合理:在需要的地方创建资源。但这忽略了一个事实:多个地方都需要这个资源。

let orderId;
let userId;
let planId;
let subscriptionId;
let customerId;

// ✅ 在switch之前创建supabase实例
const supabase = createSupabaseClient(c.env);

switch (type) {
  case 'checkout.session.completed': {
    // 可以直接使用supabase
    break;
  }

  case 'invoice.payment_succeeded': {
    // 可以直接使用supabase
    break;
  }
}

// Switch后续代码也可以使用supabase
const { data: plan } = await supabase.from('plans')...

共享资源应该提升到所有使用者都能访问的作用域。不要在局部创建,然后期待它在其他地方可用。

作用域的规划是架构的基础。变量的可见性决定了代码的灵活性。

防御性编程的本质

这三个bug有一个共同点:假设

假设API总是返回字符串。假设环境总是配置特定的key。假设变量在所有地方都可见。

每个假设在写代码时都看起来合理。但现实不会按你的假设运行。

API会在不同场景下返回不同类型。环境会根据需求配置不同的值。变量会受作用域限制。

防御性编程不是paranoia。是对现实的尊重。

不假设API的返回类型,所以检查类型。不假设环境的配置方式,所以根据场景选择。不假设变量的可见性,所以规划作用域。

每个假设都可能让客户损失。三个假设叠加,系统就崩溃了。

本地测试

使用stripe trigger模拟不同事件:

stripe trigger customer.subscription.created
stripe trigger invoice.payment_succeeded
stripe trigger checkout.session.completed

结果

  • ✅ 签名验证通过
  • ✅ 环境变量检查通过
  • ✅ 类型检查生效(正确识别并提取ID)
  • ✅ 作用域问题解决(所有case都能访问supabase)

影响评估

修复前

  • checkout.session.completed:500错误(环境变量)
  • invoice.payment_succeeded:500错误(类型 + 作用域)

修复后

  • 所有webhook事件正常处理
  • 积分自动到账
  • 无需人工干预

代码的脆弱性

三个bug,三个假设,三次修复。

Bug不在于技术的复杂,在于对现实的简化。假设让代码简洁,也让代码脆弱。

最危险的假设是那些"显而易见"的假设。API当然会返回字符串。环境当然会配置正确的值。变量当然在需要的地方可见。

当然,直到不是。

相关文章:使用Secret Token验证回调请求的合法性探讨了webhook安全验证的另一个维度。错误隔离讨论了如何限制单个组件失败的影响范围。

Related Posts

Articles you might also find interesting

双重验证:Stripe生产模式的防御性切换

7 min read

从测试到生产不是更换API keys,而是建立一套双重验证系统。每一步都在两个环境中验证,确保真实支付不会因假设而失败。

系统设计Stripe
Read More

缺失值的级联效应

3 min read

一个NULL值如何在调用链中传播,最终导致错误的错误消息。理解防御层的设计,在失败传播前拦截。

系统设计防御性编程
Read More

What Monitoring Systems See

7 min read

Production logs showed errors everywhere. But most weren't errors at all. When test webhooks generate error-level logs, and successful validations leak customer emails, the monitoring system loses its ability to tell you what's actually wrong.

日志监控
Read More

适配器模式:对现实的妥协

4 min read

当 PayPro 要求 IP 白名单而 Stripe 不需要,当一个按秒计费另一个按请求计费,架构设计不是消除约束——而是管理约束。适配器模式不是优雅设计,而是对现实混乱的务实投降。

系统架构支付集成
Read More

管理后台需要两次设计

3 min read

第一次设计回答"发生了什么",第二次设计回答"我能做什么"。在第一次就试图解决所有问题,结果是功能很多但都不够深入。

系统设计API 设计
Read More

告警分级与响应时间

2 min read

不是所有问题都需要立即响应。RPC失败会在凌晨3点叫醒人。安全事件每15分钟检查一次。支付成功只记录,不告警。系统的响应时间应该匹配问题的紧急程度。

系统设计监控
Read More

文档标准是成本计算的前提

3 min read

API文档不只是写给开发者看的。它定义了系统的边界、成本结构和可维护性。统一的文档标准让隐性成本变得可见。

API文档
Read More

BullMQ 队列

3 min read

队列不是技术选型,而是对时间的承认,对顺序的尊重,对不确定性的应对

系统设计异步处理
Read More

BullMQ Worker

2 min read

Worker 的本质是对时间的重新分配,是对主线的解放,也是对专注的追求

系统设计异步处理
Read More

配置不会自动同步

2 min read

视频生成任务永远pending,代码完美部署,队列正确配置。问题不在代码,在于配置的独立性被低估。静默失败比错误更危险。

部署配置管理
Read More

CRUD 操作

2 min read

四个字母背后,是数据的生命周期,是权限的边界,也是系统设计的基础逻辑

系统设计软件工程
Read More

数据库参数国际化:从 13 个迁移学到的设计原则

3 min read

数据不该懂语言。当数据库参数嵌入中文标签时,系统的边界就被语言限制了。这篇文章从 13 个参数对齐迁移中提炼出设计原则——国际化不是功能,是系统设计的底层约束。

数据库国际化
Read More

错误隔离

3 min read

失败是必然的。真正的问题不是失败本身,而是失败如何蔓延。错误隔离不是为了消除失败,而是为了控制失败的范围。

系统设计可靠性工程
Read More

在运行的系统上生长新功能

3 min read

扩展不是推倒重来,而是理解边界,找到生长点。管理层作为观察者和调节器,附着在核心系统上,监测它,影响它,但不改变它的运行逻辑。

系统设计架构
Read More

实现幂等性处理,忽略已处理的任务

3 min read

在代码层面识别和忽略已处理的任务,不是简单的布尔检查,而是对时序、并发和状态的深刻理解

系统设计并发控制
Read More

单例模式管理 Redis 连接

5 min read

连接不是技术细节,而是系统与外部世界的第一次握手,是可靠性的起点

系统设计后端架构
Read More

监控观察期法

3 min read

部署不是结束,而是验证的开始。修复代码只是假设,监控数据才是证明。48小时观察期:让错误主动暴露,让数据证明修复。

系统设计监控
Read More

Props Drilling

3 min read

数据在组件树中层层传递,每一层都不需要它,却必须经手。这不是技术债,是架构对真实需求的误解。

React组件设计
Read More

队列生产者实例的工厂函数

4 min read

工厂函数不是设计模式的炫技,而是对重复的拒绝,对集中管理的追求,对变化的准备

系统设计设计模式
Read More

监听 Redis 连接事件 - 让不可见的脆弱变得可见

4 min read

连接看起来应该是透明的。但当它断开时,你才意识到透明不等于可靠。监听不是多余,而是对脆弱性的承认。

系统设计Redis
Read More

资源不会消失,只会泄露

2 min read

在积分系统中,用户的钱不会凭空消失,但会因为两个时间窗口而泄露:并发请求之间的竞争,和回调永不到达的沉默。

系统设计并发控制
Read More

RPC函数的原子化处理

1 min read

当一个远程函数做太多事情,失败就变得难以理解

分布式系统RPC
Read More

RPC函数

2 min read

关于远程过程调用的本质思考:当你试图让远方看起来像眼前

分布式系统RPC
Read More

使用Secret Token验证回调请求的合法性

2 min read

在开放的网络中,信任不能被假设。Secret Token 是对身份的确认,对伪装的识别,也是对安全边界的坚守

Web 安全系统设计
Read More

第三方回调的状态映射完整性

5 min read

KIE.AI 视频生成的三个修复揭示了一个本质问题:回调不只是接收结果,是建立状态映射。没有 vendor_task_id,系统就失去了追溯能力。

Purikura 项目系统设计
Read More