Node.js中Stripe Webhook原子性实现及异常处理咨询
解决方案:MERN集成Stripe订阅的原子性处理方案
你的思路方向是对的——跨系统操作无法做到严格意义上的分布式原子事务,但可以通过本地事务+错误补偿机制实现类似效果,确保用户不会出现「扣费但无权限」的情况。以下是具体实现步骤和代码示例:
核心思路
- 先验证Stripe事件有效性:拒绝伪造请求,避免无效操作
- 幂等性控制:防止Stripe重试Webhook导致重复操作
- 数据库原子更新:用事务包裹用户状态更新和事件记录
- 错误补偿:数据库更新失败时,调用Stripe API取消订阅并退款
具体实现
1. 新增幂等性记录表(Prisma Schema)
创建表记录已处理的Stripe事件ID,避免重复操作:
model WebhookEvent { id String @id @default(cuid()) stripeEventId String @unique // 存储Stripe事件的唯一ID createdAt DateTime @default(now()) }
2. 修改Webhook控制器代码
import Stripe from 'stripe'; import { prisma } from '../prisma'; import { ExtendedRequest, Response } from '../types'; const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!, { apiVersion: '2024-06-20', // 使用Stripe最新稳定API版本 }); export const postStripeWebhook = async (req: ExtendedRequest, res: Response) => { let event: Stripe.Event; // 第一步:验证Stripe事件签名,必须执行,防止伪造请求 try { const sig = req.headers['stripe-signature']!; event = stripe.webhooks.constructEvent( req.rawBody, // Express需配置raw body解析,不能直接用json()中间件 sig, process.env.STRIPE_WEBHOOK_SECRET! ); } catch (err) { console.error('Stripe签名验证失败:', err); return res.sendStatus(400); } // 第二步:幂等性检查,跳过已处理的事件 const existingEvent = await prisma.webhookEvent.findUnique({ where: { stripeEventId: event.id }, }); if (existingEvent) { console.log('事件已处理,跳过:', event.id); return res.sendStatus(200); } const eventType = event.type; let subscription: Stripe.Subscription | undefined; try { switch (eventType) { case "checkout.session.completed": const session = event.data.object as Stripe.Checkout.Session; subscription = session.subscription as Stripe.Subscription; // 第三步:用Prisma事务包裹数据库操作,确保原子性 await prisma.$transaction(async (tx) => { // 更新用户订阅状态 await tx.users.update({ where: { id: session.metadata.userId }, data: { stripeUserId: session.customer as string, subscriptionStatus: true, }, }); // 记录已处理的事件,避免重复操作 await tx.webhookEvent.create({ data: { stripeEventId: event.id }, }); }); // 可选:发送订阅成功通知等后续操作 break; // 补充其他关键事件处理,比如订阅取消/支付失败 case "customer.subscription.deleted": const deletedSub = event.data.object as Stripe.Subscription; await prisma.users.update({ where: { stripeUserId: deletedSub.customer as string }, data: { subscriptionStatus: false }, }); break; default: console.log(`未处理的事件类型: ${eventType}`); } res.sendStatus(200); } catch (err) { console.error('Stripe事件处理失败:', err); // 第四步:错误补偿,数据库更新失败时触发退款/取消订阅 if (eventType === "checkout.session.completed" && subscription) { try { // 取消订阅并全额退款(prorate: false 表示不按使用时长扣费) await stripe.subscriptions.cancel(subscription.id, { prorate: false, invoice_now: true, }); console.log('已取消订阅并退款:', subscription.id); } catch (refundErr) { console.error('退款操作失败,需人工处理:', refundErr); // 这里可接入告警系统,通知运维人员手动处理 } } // 返回500让Stripe重试(若退款已成功,也可返回200避免重复退款) res.sendStatus(500); } };
关键注意事项
- Express配置:Webhook路由需使用
express.raw({ type: 'application/json' })中间件,确保能获取原始请求体用于签名验证 - Stripe重试机制:Stripe会在Webhook返回非200状态时自动重试(24小时内多次),幂等性检查是必须的
- 测试场景:用Stripe CLI模拟数据库失败场景,验证退款逻辑是否生效
- 状态同步:除了
checkout.session.completed,必须处理customer.subscription.deleted、invoice.payment_failed等事件,确保用户权限与Stripe订阅状态一致
内容的提问来源于stack exchange,提问作者Pawan
相关产品推荐
相关产品推荐

