Lesson 24:支付集成 — Stripe 在线支付
🧩 本节信息卡(学习前先看)
- 阶段定位:Phase 3(实战篇)
- 推荐时长:120~180 分钟(首次学习)
- 先修要求:完成 L23 的待支付订单、库存预留、取消与订单所有权检查
- 学习产出:连接测试模式 Checkout,验证回调、幂等付款状态与库存释放
✅ 本节完成标准(自检清单)
- [ ] 我可以独立复现文中的核心代码片段
- [ ] 我能解释“为什么这样实现”,而不只是“照着写”
- [ ] 我记录了至少 1 个踩坑点和修复方法
🧭 本节统一学习流程
- 学习目标:先明确本节要解决的业务问题与核心 API。
- 主线实战:跟随课程实现可运行功能(先跑通,再优化)。
- 原理深挖:理解为什么这样设计,以及常见误区。
- 练习挑战:完成 L1/L2(阶段收官课建议加 L3)巩固迁移能力。
- 本节小结:回顾“做了什么 / 学到了什么 / 下节前检查项”。
建议节奏:阅读 20% + 编码 60% + 复盘 20%。
🎯 本节目标:对接 Stripe 支付网关,实现测试模式下的付款、状态确认和恢复流程。
📦 本节产出:用户可以通过 Stripe Checkout 完成测试模式付款,并通过 Webhook 自动更新订单状态。
一、支付流程全景
二、安装与配置 Stripe
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 提供,两者不能混用:
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 最低收款额。
// 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 模型增加初始化支付的时间,用于限制会话重建窗口,然后迁移:
// 合并到 model Order,不要重建整个模型。
paymentStartedAt DateTime?npx prisma migrate dev --name add-payment-started-at三、创建 Checkout Session(Server Action)
L23 已经预留库存并建立订单。本课只接收 orderId,使用订单里的名称、数量和整数分价格快照,避免再次建单或再次扣库存。
下面的服务端模块统一创建/恢复会话。Stripe 创建参数保持稳定,同一订单始终使用同一个幂等键;数据库保存 session ID 后直接查询它。若请求结果未知,就重试原请求,不能换新键“再付一次”。
// 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
}// 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)
}// 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 接入付款按钮”的提示改为“订单尚未支付”。在金额下方加入:
{['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 也可能表示异步支付正在处理中。
// 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 重试;只有数据库成功提交后才返回成功。发货、发邮件等外部副作用若要加入,还需要单独的幂等任务或事务发件箱。
// 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 幂等请求。
六、练习
- 重放同一个 Webhook,并发处理同一个付款或过期事件,确认订单状态和库存只变化一次。
- 不访问返回页面,直接关闭支付页,确认 Webhook 仍能更新订单。
📌 本节小结
| 你做了什么 | 你学到了什么 |
|---|---|
| 创建了 Stripe Checkout Session | 服务端安全定价,不信任前端传值 |
| 编写了 Webhook 接收支付回调 | API Route vs Server Actions 的使用场景区分 |
| — | 支付安全:签名验证与幂等性 |
| — | Webhook 解决网络不可靠导致的状态丢失 |
七、进阶实战:支付失败与订单恢复策略
仅有「支付成功」路径不够,生产环境必须覆盖失败与中断场景。
7.1 返回页面只展示已确认的状态
成功回跳参数不是付款证明,知道 session ID 也不等于拥有订单。页面按当前用户过滤数据库,只在已确认收款后显示成功;Webhook 尚未提交时显示“确认中”。
// 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 告警。仅添加此路由不会自动创建定时任务。
// 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 与对账共用条件状态更新;重复投递不重复确认订单或归还库存。
八、联调与排障清单(推荐保存)
- 按 Stripe CLI 安装说明 安装 CLI,执行
stripe login,再运行stripe listen --forward-to localhost:3000/api/webhook/stripe;把该次监听给出的whsec_...写入.env.local,重启 Next.js。 - 从本应用购物车创建订单并点击支付,不要用缺少本地订单 metadata 的通用
stripe trigger事件假装完成应用联调。可用 Stripe 官方测试卡4242 4242 4242 4242、未来有效期和任意测试 CVC;详见 测试文档。 - 确认同一订单重复点击、刷新或网络重试,数据库只有一个 session ID;支付金额与订单整数分金额、币种一致。
- 错误签名返回 400;数据库处理失败返回 500;合法重复事件最终返回 2xx。记录事件 ID 和订单 ID用于追踪,避免日志记录完整密钥或卡数据。
- 分别验证:未支付回跳、Webhook 延迟、成功事件重复、过期释放库存、另一用户访问订单被拒绝,以及对账失败保留库存。
- 正式部署时配置 HTTPS Webhook endpoint、与 SDK 匹配的事件版本、上述四种事件和服务器调度器。没有 Stripe 测试账户、密钥和调度环境时,只能验证本地类型与状态逻辑,不能声称完成支付端到端验收。