You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

Node.js中Stripe Webhook原子性实现及异常处理咨询

解决方案:MERN集成Stripe订阅的原子性处理方案

你的思路方向是对的——跨系统操作无法做到严格意义上的分布式原子事务,但可以通过本地事务+错误补偿机制实现类似效果,确保用户不会出现「扣费但无权限」的情况。以下是具体实现步骤和代码示例:

核心思路

  1. 先验证Stripe事件有效性:拒绝伪造请求,避免无效操作
  2. 幂等性控制:防止Stripe重试Webhook导致重复操作
  3. 数据库原子更新:用事务包裹用户状态更新和事件记录
  4. 错误补偿:数据库更新失败时,调用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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.08.12 15:41:13