Menu

Webhook 处理机制

Webhook 是支付系统的重要组成部分,它允许支付提供商在支付事件发生时主动通知你的应用。本文档详细说明 Stripe 和 Creem 的 Webhook 处理机制。

Webhook 概述

什么是 Webhook?

Webhook 是支付提供商向你的应用发送的 HTTP POST 请求,用于通知支付相关事件的发生,如:

  • 支付成功
  • 订阅创建/更新/取消
  • 退款处理
  • 发票支付失败

为什么需要 Webhook?

  1. 可靠性: 即使前端验证失败,Webhook 也能确保订单被正确处理
  2. 实时性: 支付提供商可以立即通知你的应用
  3. 完整性: 处理所有支付相关事件,包括续费、取消等

Stripe Webhook

Webhook 端点

URL: /api/stripe/webhook

方法: POST

签名验证

所有 Stripe Webhook 请求都必须验证签名以确保安全性:

export async function POST(req: Request) {
  const body = await req.text();
  const headersList = await headers();
  const sig = headersList.get('stripe-signature');
  const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
 
  if (!sig || !webhookSecret) {
    return apiResponse.badRequest('Webhook secret not configured');
  }
 
  // 验证签名
  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
  } catch (err) {
    return apiResponse.badRequest(`Webhook Error: ${err.message}`);
  }
 
  // 处理事件
  await processWebhookEvent(event);
}

支持的事件类型

系统处理以下 Stripe Webhook 事件:

1. checkout.session.completed

触发时机: 用户完成结账(仅一次性支付)

处理逻辑:

app/api/stripe/webhook/webhook-handlers.ts
export async function handleCheckoutSessionCompleted(
  session: Stripe.Checkout.Session
) {
  // 仅处理一次性支付
  if (session.mode !== 'payment') return;
 
  const userId = session.metadata?.userId;
  const planId = session.metadata?.planId;
  const priceId = session.metadata?.priceId;
 
  // 创建订单记录
  const orderData = {
    userId,
    provider: 'stripe',
    providerOrderId: session.payment_intent,
    orderType: 'one_time_purchase',
    status: 'succeeded',
    planId,
    priceId,
    amountTotal: toCurrencyAmount(session.amount_total),
    // ... 其他字段
  };
 
  const { order, existed } = await createOrderWithIdempotency(
    'stripe',
    orderData,
    session.payment_intent
  );
 
  if (!existed && order) {
    // 授予一次性积分
    await upgradeOneTimeCredits(userId, planId, order.id);
  }
}

2. invoice.paid

触发时机: 订阅发票支付成功(包括初始支付和续费)

处理逻辑:

app/api/stripe/webhook/webhook-handlers.ts
export async function handleInvoicePaid(invoice: Stripe.Invoice) {
  const subscriptionId = invoice.subscription as string;
  const customerId = invoice.customer as string;
 
  // 同步订阅数据
  await syncSubscriptionData(subscriptionId, customerId);
 
  // 如果是续费发票,创建订单并授予积分
  if (invoice.billing_reason === 'subscription_cycle') {
    const subscription = await stripe.subscriptions.retrieve(subscriptionId);
    const userId = subscription.metadata?.userId;
    const planId = subscription.metadata?.planId;
 
    // 创建续费订单
    const orderData = {
      userId,
      provider: 'stripe',
      providerOrderId: invoice.payment_intent,
      orderType: 'subscription_renewal',
      status: 'succeeded',
      planId,
      subscriptionId,
      amountTotal: toCurrencyAmount(invoice.amount_paid),
      // ... 其他字段
    };
 
    const { order, existed } = await createOrderWithIdempotency(
      'stripe',
      orderData,
      invoice.payment_intent
    );
 
    if (!existed && order) {
      // 授予订阅积分(重置月度积分)
      const currentPeriodStart = subscription.current_period_start * 1000;
      await upgradeSubscriptionCredits(
        userId,
        planId,
        order.id,
        currentPeriodStart
      );
    }
  }
}

3. customer.subscription.created / customer.subscription.updated

触发时机: 订阅创建或更新

处理逻辑:

app/api/stripe/webhook/webhook-handlers.ts
export async function handleSubscriptionUpdate(
  subscription: Stripe.Subscription
) {
  const customerId = typeof subscription.customer === 'string'
    ? subscription.customer
    : subscription.customer.id;
 
  // 同步订阅数据到数据库
  await syncSubscriptionData(subscription.id, customerId);
}

4. customer.subscription.deleted

触发时机: 订阅被取消或删除

处理逻辑:

app/api/stripe/webhook/webhook-handlers.ts
export async function handleSubscriptionUpdate(
  subscription: Stripe.Subscription,
  isDeleted: boolean = false
) {
  if (isDeleted) {
    // 同步订阅状态
    await syncSubscriptionData(subscription.id, customerId);
 
    // 撤销剩余订阅积分
    const userId = subscription.metadata?.userId;
    if (userId) {
      await revokeRemainingSubscriptionCreditsOnEnd(
        'stripe',
        subscription.id,
        userId,
        subscription.metadata
      );
    }
  }
}

5. invoice.payment_failed

触发时机: 订阅发票支付失败

处理逻辑:

app/api/stripe/webhook/webhook-handlers.ts
export async function handleInvoicePaymentFailed(
  invoice: Stripe.Invoice
) {
  const subscriptionId = invoice.subscription as string;
  const customerId = invoice.customer as string;
 
  // 发送支付失败通知邮件
  await sendInvoicePaymentFailedEmail({
    invoice,
    subscriptionId,
    customerId,
    invoiceId: invoice.id
  });
}

6. charge.refunded

触发时机: 支付被退款

处理逻辑:

app/api/stripe/webhook/webhook-handlers.ts
export async function handleRefund(charge: Stripe.Charge) {
  const paymentIntentId = charge.payment_intent as string;
 
  // 查找原始订单
  const originalOrder = await findOriginalOrderForRefund(
    'stripe',
    paymentIntentId
  );
 
  if (!originalOrder) {
    console.warn(`Original order not found for refund: ${paymentIntentId}`);
    return;
  }
 
  // 检查退款订单是否已存在
  const refundId = charge.refunds.data[0]?.id;
  if (await refundOrderExists('stripe', refundId)) {
    return; // 已处理过
  }
 
  // 创建退款订单
  const refundOrderData = {
    userId: originalOrder.userId,
    provider: 'stripe',
    providerOrderId: refundId,
    orderType: 'refund',
    status: 'succeeded',
    planId: originalOrder.planId,
    amountTotal: `-${toCurrencyAmount(charge.amount_refunded)}`,
    // ... 其他字段
  };
 
  await createOrderWithIdempotency('stripe', refundOrderData, refundId);
 
  // 更新原始订单状态
  await updateOrderStatusAfterRefund(
    originalOrder.id,
    charge.amount_refunded,
    toCents(originalOrder.amountTotal)
  );
 
  // 撤销积分
  if (originalOrder.orderType === 'one_time_purchase') {
    await revokeOneTimeCredits(
      charge.amount_refunded,
      originalOrder,
      refundId
    );
  } else {
    await revokeSubscriptionCredits(originalOrder);
  }
}

7. radar.early_fraud_warning.created

触发时机: Stripe Radar 检测到潜在的欺诈行为

处理逻辑:

app/api/stripe/webhook/webhook-handlers.ts
export async function handleEarlyFraudWarningCreated(
  warning: Stripe.Radar.EarlyFraudWarning
) {
  const chargeId = warning.charge as string;
  const charge = await stripe.charges.retrieve(chargeId);
 
  // 发送管理员警告邮件
  await sendFraudWarningAdminEmail({
    warningId: warning.id,
    chargeId,
    customerId: charge.customer as string,
    amount: charge.amount,
    currency: charge.currency,
    fraudType: warning.actionable ? 'Actionable' : 'Informational',
    chargeDescription: charge.description,
    actionsTaken: ['Email sent to admin']
  });
 
  // 如果确认是欺诈,可以自动退款
  if (warning.actionable) {
    // 执行退款
    const refund = await stripe.refunds.create({ charge: chargeId });
    
    // 发送用户退款通知
    await sendFraudRefundUserEmail({
      charge,
      refundAmount: refund.amount
    });
  }
}

Creem Webhook

Webhook 端点

URL: /api/creem/webhook

方法: POST

签名验证

Creem Webhook 使用 HMAC-SHA256 签名验证:

app/api/creem/webhook/route.ts
function verifySignature(
  payload: string,
  signature: string,
  secret: string
): boolean {
  const crypto = require('crypto');
  const computedSignature = crypto
    .createHmac('sha256', secret)
    .update(payload)
    .digest('hex');
  return computedSignature === signature;
}
 
export async function POST(req: Request) {
  const rawBody = await req.text();
  const headersList = await headers();
  const signature = headersList.get('creem-signature');
  const secret = process.env.CREEM_WEBHOOK_SECRET;
 
  if (!verifySignature(rawBody, signature, secret)) {
    return apiResponse.badRequest('Signature verification failed');
  }
 
  const payload = JSON.parse(rawBody);
  await processWebhookEvent(payload);
}

支持的事件类型

1. checkout.completed

触发时机: 一次性支付完成

处理逻辑:

app/api/creem/webhook/handlers.ts
export async function handleCreemPaymentSucceeded(
  payload: CreemCheckoutCompletedEvent
) {
  const payment = payload.object;
  const order = payment.order;
 
  // 仅处理一次性支付
  if (order.type !== 'onetime') return;
 
  const userId = payment.metadata.userId;
  const planId = payment.metadata.planId;
 
  // 创建订单记录
  const orderData = {
    userId,
    provider: 'creem',
    providerOrderId: order.id,
    orderType: 'one_time_purchase',
    status: payment.status === 'completed' ? 'succeeded' : payment.status,
    planId,
    amountTotal: toCurrencyAmount(order.amount_paid),
    // ... 其他字段
  };
 
  const { order: insertedOrder, existed } = await createOrderWithIdempotency(
    'creem',
    orderData,
    order.id
  );
 
  if (!existed && insertedOrder) {
    // 授予一次性积分
    await upgradeOneTimeCredits(userId, planId, insertedOrder.id);
  }
}

2. subscription.paid

触发时机: 订阅支付成功(包括初始支付和续费)

处理逻辑:

app/api/creem/webhook/handlers.ts
export async function handleCreemInvoicePaid(
  payload: CreemSubscriptionPaidEvent
) {
  const subscription = payload.object;
  const lastTransaction = subscription.last_transaction;
  const orderId = lastTransaction.order;
 
  const userId = subscription.metadata.userId;
  const planId = subscription.metadata.planId;
 
  // 创建订单记录
  const orderData = {
    userId,
    provider: 'creem',
    providerOrderId: orderId,
    orderType: 'recurring',
    status: lastTransaction.status === 'paid' ? 'succeeded' : lastTransaction.status,
    planId,
    subscriptionId: subscription.id,
    amountTotal: toCurrencyAmount(lastTransaction.amount_paid),
    // ... 其他字段
  };
 
  const { order: insertedOrder, existed } = await createOrderWithIdempotency(
    'creem',
    orderData,
    orderId
  );
 
  if (!existed && insertedOrder) {
    // 授予订阅积分
    const currentPeriodStart = new Date(subscription.current_period_start_date).getTime();
    await upgradeSubscriptionCredits(
      userId,
      planId,
      insertedOrder.id,
      currentPeriodStart
    );
  }
}

3. subscription.active / subscription.update

触发时机: 订阅激活或更新

处理逻辑:

app/api/creem/webhook/handlers.ts
export async function handleCreemSubscriptionUpdated(
  payload: CreemSubscriptionUpdateEvent | CreemSubscriptionActiveEvent
) {
  const subscription = payload.object;
  
  // 同步订阅数据
  await syncCreemSubscriptionData(
    subscription.id,
    subscription.metadata
  );
}

4. subscription.canceled / subscription.expired

触发时机: 订阅取消或过期

处理逻辑:

app/api/creem/webhook/handlers.ts
export async function handleCreemSubscriptionUpdated(
  payload: CreemSubscriptionCanceledEvent | CreemSubscriptionExpiredEvent,
  isDeleted: boolean = false
) {
  const subscription = payload.object;
 
  // 同步订阅状态
  await syncCreemSubscriptionData(subscription.id, subscription.metadata);
 
  if (isDeleted) {
    // 撤销剩余订阅积分
    let userId = subscription.metadata?.userId;
    if (!userId) {
      // 从数据库查询用户 ID
      const storeSubscription = await db
        .select({ userId: subscriptionsSchema.userId })
        .from(subscriptionsSchema)
        .where(eq(subscriptionsSchema.subscriptionId, subscription.id))
        .limit(1);
      userId = storeSubscription[0]?.userId;
    }
 
    if (userId) {
      await revokeRemainingSubscriptionCreditsOnEnd(
        'creem',
        subscription.id,
        userId,
        subscription.metadata
      );
    }
  }
}

5. refund.created

触发时机: 退款创建

处理逻辑:

app/api/creem/webhook/handlers.ts
export async function handleCreemPaymentRefunded(
  payload: CreemRefundCreatedEvent
) {
  const refund = payload.object;
  const orderId = refund.order;
 
  // 检查退款订单是否已存在
  if (await refundOrderExists('creem', refund.id)) {
    return; // 已处理过
  }
 
  // 查找原始订单
  const originalOrder = await findOriginalOrderForRefund('creem', orderId);
 
  if (!originalOrder) {
    console.warn(`Original order not found for refund: ${orderId}`);
    return;
  }
 
  // 创建退款订单
  const refundOrderData = {
    userId: originalOrder.userId,
    provider: 'creem',
    providerOrderId: refund.id,
    orderType: 'refund',
    status: 'succeeded',
    planId: originalOrder.planId,
    amountTotal: `-${toCurrencyAmount(refund.amount)}`,
    // ... 其他字段
  };
 
  await createOrderWithIdempotency('creem', refundOrderData, refund.id);
 
  // 更新原始订单状态
  await updateOrderStatusAfterRefund(
    originalOrder.id,
    refund.amount,
    toCents(originalOrder.amountTotal)
  );
 
  // 撤销积分
  if (originalOrder.orderType === 'one_time_purchase') {
    await revokeOneTimeCredits(
      refund.amount,
      originalOrder,
      refund.id
    );
  } else {
    await revokeSubscriptionCredits(originalOrder);
  }
}

Webhook 测试

Stripe Webhook 测试

  1. 使用 Stripe CLI:
# 安装 Stripe CLI
brew install stripe/stripe-cli/stripe
 
# 登录
stripe login
 
# 转发 Webhook 到本地
stripe listen --forward-to localhost:3000/api/stripe/webhook
 
# 触发测试事件
stripe trigger checkout.session.completed
  1. 使用 Stripe Dashboard:
  • 进入 Stripe Dashboard > Developers > Webhooks
  • 添加 Webhook 端点
  • 发送测试事件

Creem Webhook 测试

  1. 使用 Creem Dashboard:
  • 进入 Creem Dashboard > Settings > Webhooks
  • 添加 Webhook URL
  • 发送测试事件
  1. 本地测试:

使用 VS Code/Cursor 的 Ports 功能或者 ngrok 等工具将本地服务暴露到公网:

ngrok http 3000
# 使用 ngrok 提供的 URL 配置 Webhook

生产环境验证

产品上线后,你可以创建一个 100% 优惠码测试支付流程是否正确运行。