Skip to content

Lesson 24:支付集成 — Stripe 在线支付 ​

🧩 本节信息卡(学习前先看) ​

  • 阶段定位:Phase 3(实战篇)
  • 推荐时长:120~180 分钟(首次学习)
  • 先修要求:完成 L23 的待支付订单、库存预留、取消与订单所有权检查
  • 学习产出:连接测试模式 Checkout,验证回调、幂等付款状态与库存释放
✅ 本节完成标准(自检清单)
  • [ ] 我可以独立复现文中的核心代码片段
  • [ ] 我能解释“为什么这样实现”,而不只是“照着写”
  • [ ] 我记录了至少 1 个踩坑点和修复方法

🧭 本节统一学习流程 ​

  1. 学习目标:先明确本节要解决的业务问题与核心 API。
  2. 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
  3. 原理深挖:理解为什么这样设计,以及常见误区。
  4. 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
  5. 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。

建议节奏:阅读 20% + 编码 60% + 复盘 20%。

🎯 本节目标:对接 Stripe 支付网关,实现测试模式下的付款、状态确认和恢复流程。

📦 本节产出:用户可以通过 Stripe Checkout 完成测试模式付款,并通过 Webhook 自动更新订单状态。

一、支付流程全景 ​


二、安装与配置 Stripe ​

bash
npm install stripe@20

本课固定 Stripe Node SDK 20 系列,提交 lockfile(本次校对为 20.4.1)。使用该 SDK 的默认 API 版本,不把旧的 2024-12-18.acacia 硬塞给其他版本的 SDK。Webhook endpoint 的事件 API 版本也要与已安装 SDK 对齐,升级时一起核对,见 Stripe API 版本说明。

在 .env.local 添加以下服务端配置。密钥取自 Stripe 测试模式,Webhook 密钥由本地 CLI 或对应部署 endpoint 提供,两者不能混用:

dotenv
STRIPE_SECRET_KEY=sk_test_替换为测试密钥
STRIPE_WEBHOOK_SECRET=whsec_替换为当前接收端密钥
APP_URL=http://localhost:3000
CRON_SECRET=替换为另一段随机密钥

本例跳转到 Stripe 托管页面,不使用 Stripe.js,因此不需要 publishable key。部署时 APP_URL 改为真实 HTTPS 源站。账户必须支持本课使用的 CNY/card;本节只用测试卡,不输入真实银行卡。

L23 的金额范围是应用自身的限制,不代表其中每个金额都能被 Stripe 收取。联调前还要按账户结算币种和支付方式检查 Stripe 最低与最高收款金额,并把适用限制加入服务端结算校验;例如合法的 1 分订单仍可能低于 Stripe 最低收款额。

ts
// src/lib/stripe.ts
import 'server-only'
import Stripe from 'stripe'

let client: Stripe | undefined
export function getStripe() {
  const key = process.env.STRIPE_SECRET_KEY
  if (!key) throw new Error('缺少 STRIPE_SECRET_KEY')
  return client ??= new Stripe(key) // 首次调用时创建,构建导入模块时不要求密钥
}

export function getAppUrl() {
  const value = process.env.APP_URL
  if (!value) throw new Error('缺少 APP_URL')
  const url = new URL(value)
  if (url.protocol !== 'https:' && !(url.protocol === 'http:' && url.hostname === 'localhost')) {
    throw new Error('APP_URL 必须是 HTTPS 地址,本地 localhost 除外')
  }
  return url.origin
}

在 L19 的 Order 模型增加初始化支付的时间,用于限制会话重建窗口,然后迁移:

prisma
// 合并到 model Order,不要重建整个模型。
paymentStartedAt DateTime?
bash
npx prisma migrate dev --name add-payment-started-at

三、创建 Checkout Session(Server Action) ​

L23 已经预留库存并建立订单。本课只接收 orderId,使用订单里的名称、数量和整数分价格快照,避免再次建单或再次扣库存。

下面的服务端模块统一创建/恢复会话。Stripe 创建参数保持稳定,同一订单始终使用同一个幂等键;数据库保存 session ID 后直接查询它。若请求结果未知,就重试原请求,不能换新键“再付一次”。

ts
// src/lib/checkout-session.ts
import 'server-only'
import { prisma } from '@/lib/prisma'
import { getStripe, getAppUrl } from '@/lib/stripe'

export async function getOrderCheckoutSession(orderId: string, userId: string) {
  const order = await prisma.order.findFirst({
    where: { id: orderId, userId },
    include: { items: { orderBy: { productId: 'asc' } } },
  })
  if (!order) throw new Error('订单不存在')
  const stripe = getStripe()
  if (order.stripeSessionId) return stripe.checkout.sessions.retrieve(order.stripeSessionId)
  if (order.status !== 'payment_pending' || !order.paymentStartedAt) throw new Error('订单尚未开始支付')
  // Stripe 至少保留幂等键 24 小时。留出余量,超过窗口绝不自动新建会话。
  if (Date.now() - order.paymentStartedAt.getTime() > 23 * 60 * 60 * 1000) {
    throw new Error('会话创建结果需要人工核对,不可再次创建')
  }
  const appUrl = getAppUrl()
  const session = await stripe.checkout.sessions.create({
    mode: 'payment', payment_method_types: ['card'],
    client_reference_id: order.id,
    metadata: { orderId: order.id, userId: order.userId },
    line_items: order.items.map(item => ({
      price_data: {
        currency: order.currency,
        product_data: { name: item.name },
        unit_amount: item.price, // 数据库已经是分,不要再乘 100
      },
      quantity: item.quantity,
    })),
    success_url: `${appUrl}/checkout/success?session_id={CHECKOUT_SESSION_ID}`,
    cancel_url: `${appUrl}/checkout/${order.id}`,
  }, { idempotencyKey: `checkout:${order.id}` })

  const saved = await prisma.order.updateMany({
    where: { id: order.id, status: 'payment_pending', OR: [{ stripeSessionId: null }, { stripeSessionId: session.id }] },
    data: { stripeSessionId: session.id },
  })
  if (saved.count !== 1) {
    const current = await prisma.order.findUnique({ where: { id: order.id } })
    if (current?.stripeSessionId !== session.id) throw new Error('会话关联状态异常,需要核对')
  }
  return session
}
ts
// src/app/checkout/payment-actions.ts(保留 L23 actions.ts 中的 cancelOrder)
'use server'
import { prisma } from '@/lib/prisma'
import { requireUser } from '@/lib/authorization'
import { getOrderCheckoutSession } from '@/lib/checkout-session'
import { redirect } from 'next/navigation'

type PaymentState = { error: string } | null

export async function startPayment(orderId: string, _previous: PaymentState): Promise<PaymentState> {
  void _previous
  const user = await requireUser()
  if (typeof orderId !== 'string' || orderId.length > 100) return { error: '订单 ID 无效' }
  let destination: string
  try {
    const candidate = await prisma.order.findFirst({ where: { id: orderId, userId: user.id } })
    if (!candidate) return { error: '订单不存在' }
    // 课程商店自定最低订单 ¥10,不是 Stripe 对所有账户的统一下限。
    if (candidate.total < 1000) return { error: '本店在线支付订单金额至少为 ¥10.00' }
    // 与 L23 cancelOrder 争夺同一 pending 状态,只有一个操作能成功。
    await prisma.order.updateMany({
      where: { id: orderId, userId: user.id, status: 'pending', stripeSessionId: null },
      data: { status: 'payment_pending', paymentStartedAt: new Date() },
    })
    const order = await prisma.order.findFirst({ where: { id: orderId, userId: user.id } })
    if (!order || !['payment_pending', 'paid', 'shipped', 'completed'].includes(order.status)) {
      return { error: '订单不可支付,请检查订单状态' }
    }
    const session = await getOrderCheckoutSession(order.id, user.id)
    destination = session.status === 'open' && session.url
      ? session.url
      : `/checkout/success?session_id=${encodeURIComponent(session.id)}`
  } catch {
    // 网络失败时不能判断远端是否创建成功;保留预留库存和原幂等键。
    return { error: '支付初始化未完成,请重试;持续失败请联系管理员核对会话' }
  }
  redirect(destination)
}
tsx
// src/app/checkout/PayButton.tsx
'use client'
import { useActionState } from 'react'
import { startPayment } from './payment-actions'

export default function PayButton({ orderId }: { orderId: string }) {
  const [state, action, pending] = useActionState(startPayment.bind(null, orderId), null)
  return <form action={action} className="mt-6">
    {state?.error && <p role="alert">{state.error}</p>}
    <button disabled={pending} className="bg-indigo-600 text-white px-6 py-3 rounded-xl disabled:opacity-50">
      {pending ? '正在打开支付页面…' : '确认金额并去支付'}
    </button>
  </form>
}

在 L23 的 src/app/checkout/[orderId]/page.tsx 顶部导入 PayButton from '../PayButton',将“L24 接入付款按钮”的提示改为“订单尚未支付”。在金额下方加入:

tsx
{['pending', 'payment_pending'].includes(order.status) && <PayButton orderId={order.id} />}

本课额外将在线支付订单限制为至少 ¥10.00,并在开始支付前返回可见错误。Stripe 还按币种和账户结算币种规定收款上下限,部署时应据此调整服务端校验;见 Stripe 金额限制。

保留 pending 才显示的取消按钮。开始支付后变为 payment_pending,即使用户从 Stripe 返回 cancel_url,也不能直接归还库存:支付可能在另一标签页完成,应等待 Stripe 确认会话过期或付款结果。


四、Webhook 接收支付通知 ​

先写共用的状态同步函数。它同时供 Webhook 和后面的对账任务调用:核对订单、session ID、用户标识、总金额和币种;只有 Stripe 明确给出 paid 才确认收款。checkout.session.completed 也可能表示异步支付正在处理中。

ts
// src/lib/payment-sync.ts
import 'server-only'
import type Stripe from 'stripe'
import { prisma } from '@/lib/prisma'

export async function syncCheckoutSession(session: Stripe.Checkout.Session, terminalFailure = false) {
  const orderId = session.metadata?.orderId
  if (!orderId) throw new Error('会话缺少订单标识')
  await prisma.$transaction(async tx => {
    const order = await tx.order.findUnique({ where: { id: orderId }, include: { items: true } })
    if (!order || order.stripeSessionId !== session.id ||
        session.client_reference_id !== order.id || session.metadata?.userId !== order.userId ||
        session.mode !== 'payment' || session.amount_total !== order.total || session.currency !== order.currency) {
      throw new Error('会话与订单不匹配,或本地会话关联尚未写入')
    }
    const paid = session.status === 'complete' && session.payment_status === 'paid'
    const expired = session.status === 'expired' && session.payment_status !== 'paid'
    const failed = terminalFailure && session.status === 'complete' && session.payment_status === 'unpaid'
    if (!paid && !expired && !failed) return // 仍然 open / 正在异步支付,保留库存
    const nextStatus = paid ? 'paid' : expired ? 'expired' : 'failed'
    if (order.status === nextStatus || (paid && ['paid', 'shipped', 'completed'].includes(order.status))) return
    const changed = await tx.order.updateMany({
      where: { id: order.id, stripeSessionId: session.id, status: 'payment_pending' },
      data: { status: nextStatus, ...(paid ? { paidAt: new Date() } : {}) },
    })
    if (changed.count !== 1) throw new Error('订单状态冲突,需核对支付和库存')
    if (!paid) {
      for (const item of order.items) await tx.product.update({
        where: { id: item.productId }, data: { stock: { increment: item.quantity } },
      })
    }
  })
}

状态条件更新和库存恢复在同一事务中,重复事件不会重复归还库存。多个处理器并发碰到数据库冲突时,下方接口返回 500,让 Stripe 重试;只有数据库成功提交后才返回成功。发货、发邮件等外部副作用若要加入,还需要单独的幂等任务或事务发件箱。

ts
// src/app/api/webhook/stripe/route.ts
import { NextRequest, NextResponse } from 'next/server'
import type Stripe from 'stripe'
import { getStripe } from '@/lib/stripe'
import { syncCheckoutSession } from '@/lib/payment-sync'

export const runtime = 'nodejs'

export async function POST(request: NextRequest) {
  const secret = process.env.STRIPE_WEBHOOK_SECRET
  if (!secret) return NextResponse.json({ error: 'Webhook 未配置' }, { status: 500 })
  const signature = request.headers.get('stripe-signature')
  if (!signature) return NextResponse.json({ error: '缺少签名' }, { status: 400 })
  const body = await request.text() // 必须使用原始文本,不能先 request.json()
  const stripe = getStripe()
  let event: Stripe.Event
  try {
    event = stripe.webhooks.constructEvent(body, signature, secret)
  } catch {
    return NextResponse.json({ error: '签名验证失败' }, { status: 400 })
  }
  try {
    if (event.type === 'checkout.session.completed' ||
        event.type === 'checkout.session.async_payment_succeeded' ||
        event.type === 'checkout.session.async_payment_failed' ||
        event.type === 'checkout.session.expired') {
      // 再读取当前会话,避免迟到的旧事件覆盖更新的支付状态。
      const session = await stripe.checkout.sessions.retrieve(event.data.object.id)
      await syncCheckoutSession(session, event.type === 'checkout.session.async_payment_failed')
    }
  } catch {
    console.error('Stripe 事件处理失败', { eventId: event.id, type: event.type })
    return NextResponse.json({ error: '处理失败,请重试' }, { status: 500 })
  }
  return NextResponse.json({ received: true })
}

本例仅启用 card,但仍区分异步支付完成/失败事件。如果以后启用延迟到账方式,需要额外验证该方式的处理流程;不能收到 completed 就直接发货。签名、原始请求体与重试说明见 Stripe Webhook 文档。


五、🧠 深度专题:支付安全与幂等性 ​

5.1 为什么需要 Webhook? ​

网络不可靠!用户支付成功后,浏览器重定向回 success_url 可能会:

  • 网断了
  • 用户关闭了页面
  • 浏览器崩溃了

只依赖浏览器返回地址更新订单,会漏掉已经付款但没有返回页面的用户。

Webhook 由 Stripe 服务器推送,失败时在有限的重试窗口内重试,并非永远重试,也不保证事件有序。因此仍需要后面的对账任务和失败告警。

5.2 幂等性 (Idempotency) ​

Stripe 的 Webhook 可能因为网络问题而重复发送同一个事件。你的处理逻辑必须是幂等的:执行一次和执行十次的结果完全相同。

“先查状态,再无条件 update”存在并发竞态。前面的 updateMany({ where: { status: 'payment_pending' }, ... }) 将旧状态作为数据库条件;库存释放与状态更新一起提交。浏览器禁止重复点击只改善体验,不能代替服务端幂等控制。

Stripe 的幂等响应会缓存成功或失败结果,包括某些 500;同一键的参数改变会报错,键至少保留 24 小时。超出本课 23 小时恢复窗口且本地没有 session ID 时,必须核对 Stripe 中已有会话,不能自动换键新建。见 Stripe 幂等请求。


六、练习 ​

  1. 重放同一个 Webhook,并发处理同一个付款或过期事件,确认订单状态和库存只变化一次。
  2. 不访问返回页面,直接关闭支付页,确认 Webhook 仍能更新订单。

📌 本节小结 ​

你做了什么你学到了什么
创建了 Stripe Checkout Session服务端安全定价,不信任前端传值
编写了 Webhook 接收支付回调API Route vs Server Actions 的使用场景区分
—支付安全:签名验证与幂等性
—Webhook 解决网络不可靠导致的状态丢失

七、进阶实战:支付失败与订单恢复策略 ​

仅有「支付成功」路径不够,生产环境必须覆盖失败与中断场景。

7.1 返回页面只展示已确认的状态 ​

成功回跳参数不是付款证明,知道 session ID 也不等于拥有订单。页面按当前用户过滤数据库,只在已确认收款后显示成功;Webhook 尚未提交时显示“确认中”。

tsx
// src/app/checkout/success/page.tsx
import { requireUser } from '@/lib/authorization'
import { prisma } from '@/lib/prisma'
import { notFound } from 'next/navigation'
import Link from 'next/link'

export default async function PaymentResult({ searchParams }: {
  searchParams: Promise<Record<string, string | string[] | undefined>>
}) {
  const user = await requireUser()
  const params = await searchParams
  const sessionId = typeof params.session_id === 'string' ? params.session_id : ''
  if (!sessionId || sessionId.length > 255) notFound()
  const order = await prisma.order.findFirst({ where: { stripeSessionId: sessionId, userId: user.id } })
  if (!order) notFound()
  const paid = ['paid', 'shipped', 'completed'].includes(order.status)
  const closed = ['expired', 'failed', 'cancelled'].includes(order.status)
  return <div className="max-w-2xl mx-auto px-4 py-12">
    <h1 className="text-2xl font-bold">{paid ? '支付成功' : closed ? '订单未完成支付' : '正在确认支付结果'}</h1>
    <p>订单号:{order.id}</p><p>订单金额:¥{(order.total / 100).toFixed(2)}</p>
    <p>状态:{order.status}</p>
    {!paid && !closed && <a href={`/checkout/success?session_id=${encodeURIComponent(sessionId)}`} className="underline">刷新确认状态</a>}
    <Link href={`/checkout/${order.id}`} className="block mt-4">返回订单</Link>
    <Link href="/products" className="block mt-4">继续购物</Link>
  </div>
}

cancel_url 回到 L23 的订单确认页,允许继续打开同一会话,不代表取消订单。测试过期流程时,可在 Stripe 侧显式过期仍为 open 的会话,等待 checkout.session.expired;不能仅凭用户关闭页面就恢复库存。

7.2 订单恢复任务(补偿机制) ​

对账区分两个阶段:从未发起支付且超过 30 分钟的 pending 订单可以安全过期;payment_pending 必须查询 Stripe。只有确认 paid、expired 或终态失败后才改变本地状态,open 或正在处理的支付继续保留库存。

下面是供服务器调度器调用的受保护入口。每次处理最多 100 条,返回下一页游标;调度器每 10 分钟从无游标开始,按 nextCursor 请求后续页,直到为空,并对非 2xx 告警。仅添加此路由不会自动创建定时任务。

ts
// src/app/api/internal/reconcile-payments/route.ts
import { NextRequest, NextResponse } from 'next/server'
import { prisma } from '@/lib/prisma'
import { getOrderCheckoutSession } from '@/lib/checkout-session'
import { syncCheckoutSession } from '@/lib/payment-sync'
import { getStripe } from '@/lib/stripe'

export const runtime = 'nodejs'

export async function POST(request: NextRequest) {
  const secret = process.env.CRON_SECRET
  if (!secret || request.headers.get('authorization') !== `Bearer ${secret}`) {
    return NextResponse.json({ error: '未授权' }, { status: 401 })
  }
  const cursor = request.nextUrl.searchParams.get('cursor')
  if (cursor && cursor.length > 100) return NextResponse.json({ error: '游标无效' }, { status: 400 })
  const orders = await prisma.order.findMany({
    where: { status: { in: ['pending', 'payment_pending'] } },
    orderBy: { id: 'asc' }, take: 100,
    ...(cursor ? { cursor: { id: cursor }, skip: 1 } : {}),
  })
  const failures: string[] = []
  for (const order of orders) {
    try {
      if (order.status === 'pending') {
        await prisma.$transaction(async tx => {
          const changed = await tx.order.updateMany({
            where: {
              id: order.id, status: 'pending', stripeSessionId: null,
              createdAt: { lt: new Date(Date.now() - 30 * 60 * 1000) },
            },
            data: { status: 'expired' },
          })
          if (changed.count !== 1) return
          const items = await tx.orderItem.findMany({ where: { orderId: order.id } })
          for (const item of items) await tx.product.update({
            where: { id: item.productId }, data: { stock: { increment: item.quantity } },
          })
        })
      } else {
        // 关联写入中断时,在幂等键有效窗口内恢复同一会话。
        const session = await getOrderCheckoutSession(order.id, order.userId)
        let terminalFailure = false
        if (session.status === 'complete' && session.payment_status === 'unpaid' && session.payment_intent) {
          const intentId = typeof session.payment_intent === 'string' ? session.payment_intent : session.payment_intent.id
          const intent = await getStripe().paymentIntents.retrieve(intentId)
          terminalFailure = intent.status === 'canceled'
        }
        await syncCheckoutSession(session, terminalFailure)
      }
    } catch {
      failures.push(order.id)
      console.error('订单对账需要重试或人工核对', { orderId: order.id })
    }
  }
  return NextResponse.json({
    nextCursor: orders.length === 100 ? orders[orders.length - 1].id : null,
    failures,
  }, { status: failures.length ? 503 : 200 })
}

对账失败不等于未付款,不能在 catch 中放库存。若 Stripe 会话创建结果未知且超过恢复窗口,或异步支付状态长期不明确,应保留库存、告警并由管理员核对。调度器运行时限、分页重试和持久告警需按部署平台配置;不能把上面的入口称为已部署的后台任务。

7.3 幂等键与重复点击防护 ​

  • L23 的 checkoutKey 防止同一购物车重复建单;本课用 checkout:订单ID 防止重复创建 Stripe 会话。
  • 前端 pending 状态禁用支付按钮;服务端检查当前用户、订单状态和已有 session ID。
  • Webhook 与对账共用条件状态更新;重复投递不重复确认订单或归还库存。

八、联调与排障清单(推荐保存) ​

  1. 按 Stripe CLI 安装说明 安装 CLI,执行 stripe login,再运行 stripe listen --forward-to localhost:3000/api/webhook/stripe;把该次监听给出的 whsec_... 写入 .env.local,重启 Next.js。
  2. 从本应用购物车创建订单并点击支付,不要用缺少本地订单 metadata 的通用 stripe trigger 事件假装完成应用联调。可用 Stripe 官方测试卡 4242 4242 4242 4242、未来有效期和任意测试 CVC;详见 测试文档。
  3. 确认同一订单重复点击、刷新或网络重试,数据库只有一个 session ID;支付金额与订单整数分金额、币种一致。
  4. 错误签名返回 400;数据库处理失败返回 500;合法重复事件最终返回 2xx。记录事件 ID 和订单 ID用于追踪,避免日志记录完整密钥或卡数据。
  5. 分别验证:未支付回跳、Webhook 延迟、成功事件重复、过期释放库存、另一用户访问订单被拒绝,以及对账失败保留库存。
  6. 正式部署时配置 HTTPS Webhook endpoint、与 SDK 匹配的事件版本、上述四种事件和服务器调度器。没有 Stripe 测试账户、密钥和调度环境时,只能验证本地类型与状态逻辑,不能声称完成支付端到端验收。

项目驱动 · 边写边学 · React 19