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

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

5 min read

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

Purikura 项目系统设计
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
Featured

Parameter Alignment Guide - Frontend to Vendor API Integration

7 min read

Comprehensive guide for aligning parameters across frontend, API Gateway, Vendor Adapter, and database configuration with KIE documentation. Covers parameter validation, transformation rules, and common pitfalls.

技术指南API 集成
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