Menu

支付流程详解

本文档详细说明用户购买定价计划的完整流程,包括前端交互、API 调用、支付处理和结果验证。

流程概览

用户选择计划

点击购买按钮

创建结账会话 (POST /api/payment/checkout-session)

跳转到支付提供商页面

用户完成支付

重定向回应用 (GET /payment/success)

验证支付状态 (GET /api/payment/verify-success)

Webhook 处理 (POST /api/stripe/webhook /api/creem/webhook)

订单创建和积分授予

第一步:用户选择计划

用户在定价页面看到所有可用的定价计划。每个计划卡片显示:

  • 计划标题和描述
  • 价格信息
  • 功能列表
  • 购买按钮

前端组件

PricingCardDisplay 组件负责显示计划卡片:

<PricingCardDisplay
  plan={plan}
  localizedPlan={localizedPlan}
/>

PricingCTA 组件处理购买按钮的点击事件:

<PricingCTA plan={plan} localizedPlan={localizedPlan} />

第二步:创建结账会话

当用户点击购买按钮时,前端调用 /api/payment/checkout-session API 创建结账会话。

API 请求

Endpoint: POST /api/payment/checkout-session

请求体 (Stripe):

{
  "provider": "stripe",
  "stripePriceId": "price_xxx",
  "couponCode": "DISCOUNT10"    // 可选
}

请求体 (Creem):

{
  "provider": "creem",
  "creemProductId": "prod_xxx",
  "couponCode": "DISCOUNT10"    // 可选
}

API 处理逻辑

Stripe 结账会话创建

app/api/payment/checkout-session/route.ts
// 1. 验证用户身份
const session = await getSession();
const user = session?.user;
if (!user) return apiResponse.unauthorized();
 
// 2. 获取计划信息
const plan = await db
  .select()
  .from(pricingPlansSchema)
  .where(eq(pricingPlansSchema.stripePriceId, stripePriceId))
  .limit(1);
 
// 3. 创建或获取 Stripe Customer
const customerId = await getOrCreateStripeCustomer(user.id);
 
// 4. 创建 Stripe Checkout Session
const checkoutSession = await stripe.checkout.sessions.create({
  customer: customerId,
  line_items: [{ price: stripePriceId, quantity: 1 }],
  mode: isSubscription ? 'subscription' : 'payment',
  success_url: getURL('payment/success?session_id={CHECKOUT_SESSION_ID}&provider=stripe'),
  cancel_url: getURL(process.env.NEXT_PUBLIC_PRICING_PATH!),
  metadata: {
    userId: user.id,
    planId: plan.id,
    planName: plan.cardTitle,
    priceId: stripePriceId
  }
});
 
// 5. 返回会话 URL
return apiResponse.success({
  sessionId: checkoutSession.id,
  url: checkoutSession.url
});

Creem 结账会话创建

app/api/payment/checkout-session/route.ts
// 1. 验证用户身份
const session = await getSession();
const user = session?.user;
if (!user) return apiResponse.unauthorized();
 
// 2. 获取计划信息
const plan = await db
  .select()
  .from(pricingPlansSchema)
  .where(eq(pricingPlansSchema.creemProductId, creemProductId))
  .limit(1);
 
// 3. 创建 Creem Checkout Session
const checkoutSession = await createCreemCheckoutSession({
  product_id: creemProductId,
  units: 1,
  discount_code: couponCode,
  customer: {
    email: user.email
  },
  success_url: getURL('payment/success?provider=creem'),
  metadata: {
    userId: user.id,
    planId: plan.id,
    planName: plan.cardTitle,
    productId: creemProductId
  }
});
 
// 4. 返回会话 URL
return apiResponse.success({
  sessionId: checkoutSession.id,
  url: checkoutSession.checkout_url
});

前端处理

components/home/PricingCTA.tsx
const handleCheckout = async () => {
  setIsLoading(true);
  
  try {
    const response = await fetch('/api/payment/checkout-session', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Accept-Language': locale
      },
      body: JSON.stringify({
        provider: plan.provider,
        stripePriceId: plan.stripePriceId,  // Stripe
        creemProductId: plan.creemProductId, // Creem
        couponCode: plan.stripeCouponId || plan.creemDiscountCode // 最多存在一个
      })
    });
 
    const result = await response.json();
 
    if (!response.ok) {
      throw new Error(result.error || 'Failed to create checkout session');
    }
 
    if (result.data.url) {
      // 跳转到支付页面
      router.push(result.data.url);
    }
  } catch (error) {
    toast.error(error.message);
  } finally {
    setIsLoading(false);
  }
};

第三步:用户完成支付

用户被重定向到支付提供商的支付页面(Stripe Checkout 或 Creem Checkout),完成支付流程。

Stripe Checkout

  • 用户输入支付信息
  • Stripe 处理支付
  • 支付成功后重定向到 success_url

Creem Checkout

  • 用户输入支付信息
  • Creem 处理支付
  • 支付成功后重定向到 success_url

第四步:支付成功回调

支付成功后,用户被重定向回应用的 payment/success 页面。

页面路由

app/[locale]/payment/success/page.tsx
import { redirect } from 'next/navigation';
import { Suspense } from 'react';
 
export default function PaymentSuccessPage({
  searchParams
}: {
  searchParams: { session_id?: string; checkout_id?: string; provider?: string }
}) {
  const provider = searchParams.provider;
  
  if (!provider) {
    redirect('/pricing');
  }
 
  return (
    <div className="container mx-auto py-12">
      <Suspense fallback={<div>Verifying payment...</div>}>
        <PaymentVerification
          provider={provider}
          sessionId={searchParams.session_id}
          checkoutId={searchParams.checkout_id}
        />
      </Suspense>
    </div>
  );
}

支付验证组件

async function PaymentVerification({
  provider,
  sessionId,
  checkoutId
}: {
  provider: string;
  sessionId?: string;
  checkoutId?: string;
}) {
  const session = await getSession();
  if (!session?.user) {
    redirect('/login');
  }
 
  // 调用验证 API
  const verifyUrl = new URL('/api/payment/verify-success', process.env.NEXT_PUBLIC_SITE_URL);
  verifyUrl.searchParams.set('provider', provider);
  if (provider === 'stripe' && sessionId) {
    verifyUrl.searchParams.set('session_id', sessionId);
  }
  if (provider === 'creem' && checkoutId) {
    verifyUrl.searchParams.set('checkout_id', checkoutId);
  }
 
  const response = await fetch(verifyUrl.toString(), {
    headers: {
      Cookie: cookies().toString()
    }
  });
 
  const result = await response.json();
 
  if (!result.success) {
    return (
      <div className="text-center">
        <h1 className="text-2xl font-bold text-red-600">Payment Verification Failed</h1>
        <p>{result.error}</p>
      </div>
    );
  }
 
  // 显示成功信息
  return (
    <div className="text-center">
      <h1 className="text-2xl font-bold text-green-600">Payment Successful!</h1>
      <p>Your order has been processed successfully.</p>
      {result.data.order && (
        <div className="mt-4">
          <p>Order ID: {result.data.order.id}</p>
        </div>
      )}
    </div>
  );
}

第五步:验证支付状态

/api/payment/verify-success API 负责验证支付状态并返回订单信息。

API 处理流程

Stripe 验证

app/api/payment/verify-success/stripe-handler.ts
export async function verifyStripePayment(req: NextRequest, userId: string) {
  const sessionId = req.nextUrl.searchParams.get('session_id');
  
  // 1. 获取 Checkout Session
  const session = await stripe.checkout.sessions.retrieve(sessionId, {
    expand: ['line_items', 'payment_intent', 'subscription']
  });
 
  // 2. 验证用户 ID
  if (session.metadata?.userId !== userId) {
    return apiResponse.unauthorized('User ID mismatch');
  }
 
  // 3. 验证会话状态
  if (session.status !== 'complete') {
    return apiResponse.badRequest(`Session status is not complete: ${session.status}`);
  }
 
  // 4. 处理订阅或一次性支付
  if (session.mode === 'subscription') {
    return handleStripeSubscription(session, userId, sessionId);
  } else if (session.mode === 'payment') {
    return handleStripePayment(session, userId);
  }
}

Creem 验证

app/api/payment/verify-success/creem-handler.ts
export async function verifyCreemPayment(req: NextRequest, userId: string) {
  const checkoutId = req.nextUrl.searchParams.get('checkout_id');
  
  // 1. 获取 Checkout Session
  const session = await retrieveCreemCheckoutSession(checkoutId);
 
  // 2. 验证用户 ID
  if (session.metadata?.userId !== userId) {
    return apiResponse.unauthorized('User ID mismatch');
  }
 
  // 3. 验证支付状态
  if (session.status !== 'completed') {
    return apiResponse.badRequest(`Checkout status is not completed: ${session.status}`);
  }
 
  // 4. 处理订阅或一次性支付
  if (session.order.type === 'recurring') {
    return handleCreemSubscription(session, userId, checkoutId);
  } else {
    return handleCreemPayment(session, userId);
  }
}

返回数据结构

一次性支付:

{
  "success": true,
  "data": {
    "order": {
      "id": "order-id",
      "status": "succeeded",
      "amountTotal": "29.00",
      "currency": "USD"
    }
  }
}

订阅支付:

{
  "success": true,
  "data": {
    "subscription": {
      "id": "subscription-id",
      "status": "active",
      "currentPeriodEnd": "2024-02-01T00:00:00Z"
    }
  }
}

第六步:Webhook 处理

支付提供商会在支付完成后发送 Webhook 事件。Webhook 处理是支付流程的关键部分,负责:

  1. 创建订单记录
  2. 授予用户积分
  3. 同步订阅状态

Webhook 事件

Stripe Webhook 事件

  • checkout.session.completed - 结账完成(一次性支付)
  • invoice.paid - 发票支付成功(订阅续费)
  • customer.subscription.created - 订阅创建
  • customer.subscription.updated - 订阅更新
  • customer.subscription.deleted - 订阅取消
  • charge.refunded - 退款

Creem Webhook 事件

  • checkout.completed - 结账完成(一次性支付)
  • subscription.paid - 订阅支付成功
  • subscription.active - 订阅激活
  • subscription.update - 订阅更新
  • subscription.canceled - 订阅取消
  • subscription.expired - 订阅过期
  • refund.created - 退款

详细的 Webhook 处理逻辑请参考 Webhook 处理机制

支付流程时序图

用户浏览器          前端应用          后端 API          支付提供商
    |                |                |                  |
    |--点击购买------>|                |                  |
    |                |--创建会话------>|                  |
    |                |                |--创建会话-------->|
    |                |                |<--返回URL---------|
    |                |<--返回URL-------|                  |
    |<--重定向--------|                |                  |
    |                |                |                  |
    |--跳转支付页面--->|                |                  |
    |                |                |                  |
    |--完成支付------>|                |                  |
    |                |                |                  |
    |<--重定向回应用--|                |                  |
    |                |                |                  |
    |--验证支付------>|--验证支付------>|                  |
    |                |                |--查询订单-------->|
    |                |                |<--返回订单-------|
    |                |<--返回结果------|                  |
    |<--显示结果------|                |                  |
    |                |                |                  |
    |                |                |<--Webhook事件----|
    |                |                |--处理事件-------->|
    |                |                |--创建订单--------|
    |                |                |--授予积分--------|

安全考虑

1. 用户身份验证

所有支付相关的 API 都需要用户登录:

const session = await getSession();
if (!session?.user) {
  return apiResponse.unauthorized();
}

2. Webhook 签名验证

所有 Webhook 请求都必须验证签名:

Stripe:

const sig = headers.get('stripe-signature');
const event = stripe.webhooks.constructEvent(body, sig, webhookSecret);

Creem:

const signature = headers.get('creem-signature');
if (!verifySignature(body, signature, webhookSecret)) {
  return apiResponse.badRequest('Signature verification failed');
}

3. 幂等性处理

订单创建使用幂等性检查,避免重复创建:

const { order, existed } = await createOrderWithIdempotency(
  provider,
  orderData,
  providerOrderId
);
 
if (existed) {
  return; // 订单已存在,跳过处理
}

4. 用户 ID 验证

验证支付会话中的用户 ID 与当前登录用户匹配:

if (session.metadata?.userId !== userId) {
  return apiResponse.unauthorized('User ID mismatch');
}

测试支付流程

使用 Stripe 测试模式

  1. 在 Stripe Dashboard 获取测试 API Key
  2. 创建测试产品和价格
  3. 使用测试卡号:4242 4242 4242 4242
  4. 配置 Webhook URL 指向你的应用或使用 Stripe CLI 转发 Webhook:stripe listen --forward-to localhost:3000/api/stripe/webhook

使用 Creem 测试模式

  1. 在 Creem Dashboard 获取测试 API Key
  2. 创建测试产品
  3. 使用测试卡号:4242 4242 4242 4242
  4. 配置 Webhook URL 指向你的应用