Webhook 处理机制
Webhook 是钱变成订单和积分的主通道。本文说明三家渠道的事件如何收敛到同一套履约逻辑。
三层结构
app/api/{stripe,creem,paypal}/webhook/route.ts
│ 只做三件事:验签 → switch 分发 → 按错误类型决定 HTTP 状态码
│ 不含任何业务逻辑
▼
lib/billing/{stripe,creem,paypal}.ts
│ 适配器:状态归一化、主体解析、把渠道负载翻译成统一入参
│ SDK 类型到此为止,不再外泄
▼
lib/billing/fulfillment.ts
provider 无关的履约核心,一个事务写完订单 + 订阅 + 积分新增一家渠道 = 新增一个路由 + 一个适配器,核心不动。
签名校验
| 渠道 | 机制 | 环境变量 |
|---|---|---|
| Stripe | stripe.webhooks.constructEvent(官方 SDK) | STRIPE_WEBHOOK_SECRET |
| Creem | verifyWebhookSignature(HMAC-SHA256,兼容 Standard Webhooks 三header 与旧版单 header) | CREEM_WEBHOOK_SECRET |
| PayPal | verifyPayPalWebhookSignature(调用 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.completed | fulfillCheckoutSession —— 积分包购买履约;订阅模式下主要用于补记 |
invoice.paid | fulfillInvoicePaid —— 订阅首期、续费、以及 billing_reason=subscription_update 的按比例补差账单 |
customer.subscription.created / .updated | syncStripeSubscription —— 仅同步状态 |
customer.subscription.deleted | handleSubscriptionDeleted —— 清空订阅积分桶 |
charge.refunded | applyRefundForCharge —— 退款处理 |
invoice.payment_failed | handleInvoicePaymentFailed —— 给用户发续费失败通知 |
radar.early_fraud_warning.created | handleEarlyFraudWarning —— 见下文 |
未订阅的事件类型静默 ack。
Radar 早期欺诈预警
通过环境变量 STRIPE_RADAR_EARLY_FRAUD_WARNING_TYPE 配置响应行为:
| 值 | 行为 |
|---|---|
refund,email | 自动退款 + 邮件通知管理员 |
refund | 仅自动退款 |
email | 仅通知管理员,不退款 |
| 留空 | 不处理 |
Creem 事件
端点:POST /api/creem/webhook
| 事件 | 处理 |
|---|---|
checkout.completed | fulfillCreemCheckout —— 一次性购买履约 |
subscription.paid | fulfillCreemSubscriptionPaid —— 订阅首期/续费 |
subscription.active / .update / .trialing / .paused / .scheduled_cancel / .past_due / .unpaid | syncCreemSubscription —— 状态同步 |
subscription.canceled / .expired | handleCreemSubscriptionEnded —— 清空订阅积分桶 |
refund.created | applyCreemRefund |
dispute.created | handleCreemDispute |
Creem 路由有一个额外设计:只有已订阅的事件类型才会进入 SDK 的严格类型解析。未订阅的类型在验签后直接 ack,不做解析。这样 Creem 新增字段或改 schema 时,不会因为我们没关心的事件把整个端点搞挂。
已订阅类型的严格解析失败则视为「我们的代码过时了」这一真实错误,返回 5xx 进入 Creem 的 24 小时重试窗口,同时告警。
PayPal 事件
端点:POST /api/paypal/webhook
一次性购买(capture 资源)
| 事件 | 处理 |
|---|---|
PAYMENT.CAPTURE.COMPLETED | handlePayPalCaptureCompleted —— 履约;如果已有 pending 行则原地提升为 completed 并发放积分 |
PAYMENT.CAPTURE.PENDING | recordPendingPayPalCapture —— 写 pending 订单行 |
PAYMENT.CAPTURE.DENIED / .DECLINED | handlePayPalCaptureDenied —— 标记 failed |
PAYMENT.CAPTURE.REFUNDED / .REVERSED | handlePayPalCaptureRefunded |
/api/paypal/capture-order 路由是同步的事实来源,webhook 是幂等的兜底 —— 那条路由挂了的时候,在途的钱仍然会留下一条 pending 订单行可供追踪。
订阅
| 事件 | 处理 |
|---|---|
PAYMENT.SALE.COMPLETED | handlePayPalSaleCompleted —— 订阅扣款履约(PayPal 订阅走 v1 sale 资源) |
PAYMENT.SALE.REFUNDED / .REVERSED | handlePayPalSaleRefunded |
BILLING.SUBSCRIPTION.ACTIVATED / .UPDATED | handlePayPalSubscriptionActivated |
BILLING.SUBSCRIPTION.SUSPENDED | handlePayPalSubscriptionSuspended |
BILLING.SUBSCRIPTION.PAYMENT.FAILED | handlePayPalSubscriptionPaymentFailed |
BILLING.SUBSCRIPTION.CANCELLED / .EXPIRED | handlePayPalSubscriptionEnded |
错误处理约定
三个路由共用同一套策略:
履约抛错
│
├─ 是 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 负责收尾:
- 履约核心在事务锁内判定谁是「先履约的赢家」
- 输家的订阅行在锁内标记为 canceled,并把赢家的引用返回给适配器
- 适配器负责调渠道 API 取消 + 自动退款 + 告警(核心不反向调用渠道 API)
- 退款失败会抛出,让渠道重试
告警同样经 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.completedCreem / PayPal
两家都需要公网可达的地址,本地用 ngrok 之类的隧道工具转发,然后在各自后台把 webhook 地址指过来。PayPal 在 NODE_ENV=development 下会跳过验签,方便用工具直接 POST 构造的事件。
验证清单
一笔测试付款之后,确认这四处:
orders表有新行,status正确,credits_granted符合预期- 订阅类的话,
subscriptions表状态正确,年付套餐的next_grant_at/remaining_grants已初始化 credit_transactions有对应流水,快照数值连贯- 重放同一个事件,数据不发生第二次变化