Back
NimBuild AI

NimBuild AI

Stripe Webhook 幂等,让积分账务保持可信

Stripe Webhook 幂等,让积分账务保持可信

Webhook 是支付提供商和产品数据库交汇的地方,也会暴露重复投递、重试和乱序事件对数据模型的考验。

NimBuild 的 Stripe Webhook 流程从两个要求开始:证明事件确实来自 Stripe,并证明这个支付没有被处理过。

先验证签名,再信任请求体

NimBuild 暴露:

POST /api/payments/stripe/webhook

每个请求都通过 Stripe 官方 webhook helper 验证。系统会用 STRIPE_WEBHOOK_SECRET 校验 stripe-signature 请求头和原始请求体。

原始请求体很重要。如果先解析 JSON,可能破坏签名校验。事件数据进入账务状态之前,必须先完成签名验证。

重复事件是常态

Stripe 可能多次投递同一事件。NimBuild 会检查 payment 表中的 providerPaymentId。如果同一提供商支付 ID 已存在,Webhook 直接确认,不重复处理。

这个规则能防止常见生产故障:

  1. 客户支付一次。
  2. 提供商两次发送同一支付事件。
  3. 应用插入两条支付记录。
  4. 积分重复发放。
  5. 用户余额和收入报表不一致。

确认重复事件不是丢失信息。原始支付记录仍是事实来源。

改变产品状态的事件

NimBuild 处理文档列出的 Stripe 事件:

事件动作
checkout.session.completed创建支付、发放积分、发送邮件
invoice.paid处理订阅续费支付
customer.subscription.created适用时标记订阅激活
customer.subscription.updated适用时标记订阅激活
customer.subscription.deleted标记订阅取消

关键是让每个事件只改变状态机中明确的部分。订阅状态更新不应盲目发放积分;积分账务由已支付的 Checkout 或发票事件驱动。

让记录保持关联

成功的支付事件后,NimBuild 会对齐:

  • 用户套餐
  • 支付记录
  • 订阅记录
  • 积分余额
  • 积分账本
  • 确认邮件结果

这正是只更新一个套餐标签不够的原因。续费可能只更新周期,积分必须由发票驱动;取消订阅会移除未来权益,但不应改写历史支付。

年付方案使用分期发放

NimBuild 年付方案不会一次性发放全部积分。第一个月的积分立即发放,剩余 11 期写入 subscription_credit_schedule

每小时运行的 cron 路由处理到期发放,形成可重复审计的模式:

  1. 年付 Checkout 完成。
  2. 首期积分发放。
  3. 后续积分写入计划。
  4. Cron 只处理到期记录。
  5. 每次发放写入 subscription_schedule 账本历史。

如果某期发放失败,系统可以重试,而不需要猜测已经发过多少积分。

测试失败模式,而不只测试成功

上线前至少运行:

  1. 有效 Checkout 完成。
  2. 同一个 payment ID 重复投递。
  3. 无效签名。
  4. 续费发票支付。
  5. 订阅取消。
  6. 年付首期发放。
  7. 年付计划发放。
  8. 下游部分失败后的 Cron 重试。

然后检查数据库,而不只是看 UI。页面可能看起来正确,但账本记录缺失。

排查清单

如果 Stripe Webhook 不工作:

  1. 查看 Stripe Dashboard 投递日志。
  2. 确认 endpoint 公网可访问。
  3. 核对 STRIPE_WEBHOOK_SECRET
  4. 查看服务端签名错误日志。
  5. 确认原始请求体未被提前修改。
  6. 将 Stripe 事件 ID 与本地支付和账本记录关联。

幂等 Webhook 账务并不炫酷,但它是订阅积分在重试和续费后仍然可信的基础。

对事件建模,而不只对支付建模

payment 表足以防止重复发放积分,但如果同时保存提供商事件,支持和事故复盘会更容易。可以用一张小表记录每个事件 ID 和处理结果:

create table provider_webhook_event (
  id uuid primary key default gen_random_uuid(),
  provider text not null,
  event_id text not null,
  event_type text not null,
  provider_payment_id text,
  status text not null,
  error_message text,
  processed_at timestamptz,
  created_at timestamptz not null default now(),
  unique (provider, event_id)
);

(provider, event_id) 上的唯一约束就是硬边界。即使两个 worker 同时收到同一次投递,也只能有一个插入成功。

处理顺序应尽量收敛:

  1. 验证签名。
  2. 解析事件。
  3. status = 'processing' 插入事件行。
  4. 如果插入冲突,加载已有事件并确认,不重复入账。
  5. 在事务中处理支付、订阅和积分。
  6. 标记事件为 processed

如果第 5 步失败,应让事件行保留失败或处理中状态,由受控任务重试。不要在 handler 仍缺少并发保护时,盲目要求 Stripe 重发事件。

入账必须放在事务里

签名验证和事件存储应在入账前完成,但入账本身仍必须原子。一个简化事务如下:

begin;

insert into payment (
  id, user_id, provider, provider_payment_id,
  amount_cents, currency, status, credits_granted
) values (...);

update subscription
set status = $status,
    current_period_end = $periodEnd
where provider_subscription_id = $providerSubscriptionId;

update "user"
set credits = credits + $creditsGranted
where id = $userId;

insert into credit_ledger (
  user_id, amount, reason, reference_id
) values (
  $userId, $creditsGranted, 'subscription_cycle', $providerPaymentId
);

commit;

如果提交后确认邮件发送失败,不要回滚支付。应把邮件放入重试队列。账务事实比短暂邮件失败更重要,邮件任务可以安全读取已提交记录。

从三个来源对账

每次账务事故都应能从下面三类数据还原:

  1. Stripe 的事件和支付对象。
  2. 本地 webhook 事件表。
  3. 本地 payment、subscription、balance、ledger 表。

排查积分缺失时,比较:

  • Stripe event ID 和创建时间
  • Stripe payment intent 或 invoice ID
  • 本地 providerPaymentId
  • 本地账本 reason 和 reference
  • 事件前后用户余额

如果 Stripe 显示发票已支付但没有本地事件行,说明 ingestion 失败;如果事件行已处理但没有账本行,说明入账失败;如果三方一致但 UI 错误,那是展示层 Bug。

年付分期需要独立保护

年付订阅还有第二个重放风险:发票可能只送达一次,但定时发放 cron 可能会在到期时间附近运行多次。

计划行至少应包含:

alter table subscription_credit_schedule
  add column grant_status text not null default 'scheduled',
  add column processed_at timestamptz,
  add column attempts integer not null default 0;

Cron worker 应在发放积分前先更新状态并占用这一行:

begin;

update subscription_credit_schedule
set grant_status = 'processing',
    attempts = attempts + 1
where id = $scheduleId
  and grant_status = 'scheduled'
returning *;

-- 如果没有返回行,说明另一个 worker 已占用。

commit;

然后发放积分并标记 processed。如果 worker 在占用后、发放前崩溃,监控可以发现卡住的 processing 行,并用同一个 schedule ID 安全重试。

测试真实投递顺序

自动化测试不要只发送一个成功事件。覆盖这些序列:

  1. checkout.session.completed 比浏览器返回先到达。
  2. 同一事件几秒内重复投递。
  3. 同一支付的两个不同事件。
  4. 续费 invoice.paid
  5. 续费后 customer.subscription.deleted
  6. 年付首期发放加后续月度计划发放。
  7. 同一 schedule 行的重复 cron 尝试。
  8. 本地数据库写入之间的提供商超时。

每个测试都应断言完整状态:payment、subscription、用户套餐、快速余额、积分桶和账本。只断言 UI 可能掩盖缺失的账本记录。

可观测性

每个 webhook 至少记录四个稳定标识:

  • Stripe event ID
  • event type
  • 本地用户或客户引用
  • 提供商 payment 或 subscription ID

同时记录处理时长和最终状态。这样事故排查是一次查询,而不是考古。

工程目标很简单:Stripe 可以重试,时钟可能不一致,worker 可能重叠,但数据库对每个已支付事件仍然只能说出一个事实。