Menu

Webhook 处理机制

Webhook 是钱变成订单和积分的主通道。本文说明三家渠道的事件如何收敛到同一套履约逻辑。

三层结构

app/api/{stripe,creem,paypal}/webhook/route.ts
    │  只做三件事:验签 → switch 分发 → 按错误类型决定 HTTP 状态码
    │  不含任何业务逻辑
    ▼
lib/billing/{stripe,creem,paypal}.ts
    │  适配器:状态归一化、主体解析、把渠道负载翻译成统一入参
    │  SDK 类型到此为止,不再外泄
    ▼
lib/billing/fulfillment.ts
       provider 无关的履约核心,一个事务写完订单 + 订阅 + 积分

新增一家渠道 = 新增一个路由 + 一个适配器,核心不动。

签名校验

渠道机制环境变量
Stripestripe.webhooks.constructEvent(官方 SDK)STRIPE_WEBHOOK_SECRET
CreemverifyWebhookSignature(HMAC-SHA256,兼容 Standard Webhooks 三header 与旧版单 header)CREEM_WEBHOOK_SECRET
PayPalverifyPayPalWebhookSignature(调用 PayPal 校验接口,带指数退避重试)PAYPAL_WEBHOOK_ID

本地开发注意

PayPal 无法向 localhost 投递可校验的真实事件,因此 NODE_ENV=development 时会跳过 PayPal 的签名校验,并在日志里打出警告。Stripe 和 Creem 不跳过,本地请用 CLI 转发。

履约核心的能力

三家适配器最终都调用 lib/billing/fulfillment.ts 里的这几个函数:

函数做什么
fulfillOneTimePurchase积分包购买:写订单 + 发放购买积分,一个事务
recordPendingOneTimePurchase挂起付款:写一条 pending 订单行,零积分
fulfillSubscriptionPayment订阅首期/续费:同步订阅行 + 幂等写订单 + 重置订阅积分 + 初始化年付定投
fulfillSubscriptionUpgrade周期内变更套餐:按差额增量补发积分,详见订阅变更
syncSubscription仅同步订阅状态,不碰积分和定投计数
handleSubscriptionEnded订阅终止:清空订阅积分桶
applyRefund退款:累计退款额、改写原订单状态、按比例回收积分

幂等性从哪来

不是靠代码里判重,而是靠数据库约束:

  • orders 表上 (provider, provider_order_id) 唯一索引,写入用 onConflictDoNothing
  • 退款累计 amount_refunded,而不是追加退款行
  • credit_transactions.refunded_transaction_id 唯一,保证一笔消费最多被退回一次

所以 webhook 重放、成功页重复触发、定时任务重放,都天然安全。

订阅状态归一化

各家渠道的原生状态在适配器里就被翻译成统一集合,数据库里只存这一套:

active | trialing | past_due | unpaid | canceled | incomplete | expired | paused

其中 canceled 和 expired 是终态:一个渠道订阅 ID 一旦到达终态就再也不会复活(重新订阅 = 新的订阅 ID = 新的行)。syncSubscription 据此拒绝乱序 webhook 造成的「诈尸」回写。paused 不在终态集合里,因为 Stripe 的 paused 可以恢复成 active。

Stripe 事件

端点:POST /api/stripe/webhook

事件处理
checkout.session.completedfulfillCheckoutSession —— 积分包购买履约;订阅模式下主要用于补记
invoice.paidfulfillInvoicePaid —— 订阅首期、续费、以及 billing_reason=subscription_update 的按比例补差账单
customer.subscription.created / .updatedsyncStripeSubscription —— 仅同步状态
customer.subscription.deletedhandleSubscriptionDeleted —— 清空订阅积分桶
charge.refundedapplyRefundForCharge —— 退款处理
invoice.payment_failedhandleInvoicePaymentFailed —— 给用户发续费失败通知
radar.early_fraud_warning.createdhandleEarlyFraudWarning —— 见下文

未订阅的事件类型静默 ack。

Radar 早期欺诈预警

通过环境变量 STRIPE_RADAR_EARLY_FRAUD_WARNING_TYPE 配置响应行为:

值行为
refund,email自动退款 + 邮件通知管理员
refund仅自动退款
email仅通知管理员,不退款
留空不处理

Creem 事件

端点:POST /api/creem/webhook

事件处理
checkout.completedfulfillCreemCheckout —— 一次性购买履约
subscription.paidfulfillCreemSubscriptionPaid —— 订阅首期/续费
subscription.active / .update / .trialing / .paused / .scheduled_cancel / .past_due / .unpaidsyncCreemSubscription —— 状态同步
subscription.canceled / .expiredhandleCreemSubscriptionEnded —— 清空订阅积分桶
refund.createdapplyCreemRefund
dispute.createdhandleCreemDispute

Creem 路由有一个额外设计:只有已订阅的事件类型才会进入 SDK 的严格类型解析。未订阅的类型在验签后直接 ack,不做解析。这样 Creem 新增字段或改 schema 时,不会因为我们没关心的事件把整个端点搞挂。

已订阅类型的严格解析失败则视为「我们的代码过时了」这一真实错误,返回 5xx 进入 Creem 的 24 小时重试窗口,同时告警。

PayPal 事件

端点:POST /api/paypal/webhook

一次性购买(capture 资源)

事件处理
PAYMENT.CAPTURE.COMPLETEDhandlePayPalCaptureCompleted —— 履约;如果已有 pending 行则原地提升为 completed 并发放积分
PAYMENT.CAPTURE.PENDINGrecordPendingPayPalCapture —— 写 pending 订单行
PAYMENT.CAPTURE.DENIED / .DECLINEDhandlePayPalCaptureDenied —— 标记 failed
PAYMENT.CAPTURE.REFUNDED / .REVERSEDhandlePayPalCaptureRefunded

/api/paypal/capture-order 路由是同步的事实来源,webhook 是幂等的兜底 —— 那条路由挂了的时候,在途的钱仍然会留下一条 pending 订单行可供追踪。

订阅

事件处理
PAYMENT.SALE.COMPLETEDhandlePayPalSaleCompleted —— 订阅扣款履约(PayPal 订阅走 v1 sale 资源)
PAYMENT.SALE.REFUNDED / .REVERSEDhandlePayPalSaleRefunded
BILLING.SUBSCRIPTION.ACTIVATED / .UPDATEDhandlePayPalSubscriptionActivated
BILLING.SUBSCRIPTION.SUSPENDEDhandlePayPalSubscriptionSuspended
BILLING.SUBSCRIPTION.PAYMENT.FAILEDhandlePayPalSubscriptionPaymentFailed
BILLING.SUBSCRIPTION.CANCELLED / .EXPIREDhandlePayPalSubscriptionEnded

错误处理约定

三个路由共用同一套策略:

履约抛错
    │
    ├─ 是 PermanentFulfillmentError?
    │     重试永远不可能成功(比如买家已注销账号且无组织主体)
    │     → 告警(Redis 去重)后返回 200 ack,不进重试循环
    │
    └─ 其他错误
          可能是临时故障,也可能是我们的 bug(修好部署后重试就能补上)
          → 返回 5xx,渠道按指数退避重试

另外,涉及钱的事件(Stripe 的 checkout.session.completed / invoice.paid,Creem 的 checkout.completed / subscription.paid,PayPal 的对应事件)失败时会额外触发 notifyCreditGrantFailed,给管理员发邮件 —— 因为有个付了钱的用户正在等。

这个告警按对象 ID 在 Redis 里去重:同一笔的重试和定时任务重放只会记日志,不会重复轰炸邮箱。

lib/billing/notify.ts 提供的四个通知:

函数场景
notifyCreditGrantFailed履约失败,告警管理员
notifyInvoicePaymentFailed续费扣款失败,通知用户换卡
notifyFraudWarningAdmin欺诈预警,告警管理员
notifyFraudRefundUser因欺诈自动退款,通知用户

重复订阅的结算

用户绕过前端守卫制造出两份并发订阅时(比如付了一个 24 小时前开的旧 session),lib/billing/duplicate.ts 负责收尾:

  1. 履约核心在事务锁内判定谁是「先履约的赢家」
  2. 输家的订阅行在锁内标记为 canceled,并把赢家的引用返回给适配器
  3. 适配器负责调渠道 API 取消 + 自动退款 + 告警(核心不反向调用渠道 API)
  4. 退款失败会抛出,让渠道重试

告警同样经 Redis 去重。

丢失 webhook 的补救

lib/billing/reconcile.ts 由定时任务 /api/cron/credits 每分钟驱动:

  • reconcileOverdueRenewals —— 逾期未续费的订阅,重放各渠道最新一张账单(每轮最多 20 条)
  • reconcilePendingPayPalCaptures —— 把挂起捕获推进为成功或失败(每轮最多 20 条)

定时任务保证的是送达,不是发生:积分只在钱真正结清之后才发放。配置方法见定时任务。

本地测试

Stripe

stripe listen --forward-to localhost:3000/api/stripe/webhook
# 输出的 whsec_xxx 填进 STRIPE_WEBHOOK_SECRET
 
# 触发指定事件
stripe trigger checkout.session.completed

Creem / PayPal

两家都需要公网可达的地址,本地用 ngrok 之类的隧道工具转发,然后在各自后台把 webhook 地址指过来。PayPal 在 NODE_ENV=development 下会跳过验签,方便用工具直接 POST 构造的事件。

验证清单

一笔测试付款之后,确认这四处:

  1. orders 表有新行,status 正确,credits_granted 符合预期
  2. 订阅类的话,subscriptions 表状态正确,年付套餐的 next_grant_at / remaining_grants 已初始化
  3. credit_transactions 有对应流水,快照数值连贯
  4. 重放同一个事件,数据不发生第二次变化