基于 InstantDB 的 Stripe 一次性购买模式:Token 先行、Webhook 记账与字段级权限解锁
【免费下载链接】instantInstant is the best backend for AI-coded apps. You get auth, permissions, storage, presence, and streams — everything you need to ship apps your users will love.项目地址: https://gitcode.com/gh_mirrors/inst/instant
导读
本文讲解examples/stripe-one-off示例中沉淀的一套「买断制数字商品」支付模式:用户在 Stripe Checkout 付费前,先在客户端生成一个随机 token 并存入 localStorage,通过 metadata 随结账会话传递给 Stripe;支付完成后由 Webhook 以该 token 为凭证写入购买记录,最后由 InstantDB 字段级权限规则决定「持有有效 token 的用户才能看到受保护内容」。读完本文,你将掌握无需账号体系即可完成数字商品售卖、购买恢复与服务端强制访问控制的完整可落地方案。
整体思路:为什么 token 要在付款前生成
这套模式的核心只有一条原则:token 在付款之前生成,而不是付款之后。
完整流程如下:
1. 用户点击 "Buy" 2. 生成一个 token,保存到 localStorage 3. 通过 metadata 把 token 传给 Stripe Checkout 4. Webhook 用该 token 创建购买记录 5. 用户落地到成功页 —— token 已在 localStorage 中 6. 携带 token 查询 → 受保护内容被解锁(流程原文见 stripe-strategy.md)
关键收益是消除了竞态条件:由于 token 先于支付存在,Webhook 触发时只是「激活」了它(创建购买记录),而成功页早已持有同一 token,无需等待 Webhook、无需额外的 API 调用即可立刻渲染已购内容。若改为付款后再生成 token,成功页与 Webhook 之间就会出现时序竞争。
这套模式由三个移动部件构成,下文逐一展开,并给出仓库中对应的真实实现文件。
三个移动部件之一:Buy 按钮(客户端)
用户在首页点击购买按钮时,客户端组件需要完成三件事:生成 UUID、写入 localStorage、把 token 交给结账 API。仓库实现见 BuyButton.tsx:
"use client"; import { useState } from "react"; import { TOKEN_KEY } from "@/lib/constants"; export function BuyButton() { const [isLoading, setIsLoading] = useState(false); const handleBuy = async () => { setIsLoading(true); try { // Generate token BEFORE checkout and save to localStorage const token = crypto.randomUUID(); localStorage.setItem(TOKEN_KEY, token); const response = await fetch("/api/checkout", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ token }), }); const data = await response.json(); if (data.url) { window.location.href = data.url; } } catch (error) { console.error("Checkout error:", error); setIsLoading(false); } }; return ( <button onClick={handleBuy} disabled={isLoading}> {isLoading ? "Processing..." : "Buy Full Pack — $5"} </button> ); }其中TOKEN_KEY在 constants.ts 中定义为"wallpaper_pack_token"。仓库还封装了 usePurchaseToken.ts hook,统一提供token、saveToken、clearToken三个能力,供成功页、恢复页等复用 localStorage 中的 token。
三个移动部件之二:Checkout API(服务端)
结账 API 路由接收客户端传来的 token,并将其写入 Stripe Checkout 会话的metadata。仓库实现见 checkout/route.ts:
import { NextResponse } from "next/server"; import { stripe } from "@/lib/stripe"; export async function POST(request: Request) { try { const { origin } = new URL(request.url); const { token } = await request.json(); if (!token) { return NextResponse.json({ error: "Token required" }, { status: 400 }); } const session = await stripe.checkout.sessions.create({ allow_promotion_codes: true, payment_method_types: ["card"], line_items: [ { price_data: { currency: "usd", product_data: { name: "Premium Wallpaper Pack", description: "9 high-resolution wallpapers", }, unit_amount: 500, }, quantity: 1, }, ], mode: "payment", success_url: `${origin}/success`, cancel_url: `${origin}`, metadata: { token }, }); return NextResponse.json({ url: session.url }); } catch (error) { console.error("Checkout error:", error); return NextResponse.json( { error: "Failed to create checkout session" }, { status: 500 } ); } }要点说明:
metadata: { token }是 token 传递到 Webhook 的唯一通道,这是本模式的关键一行;unit_amount: 500表示 5.00 美元(以美分为单位);allow_promotion_codes: true与后文「生产环境测试」一节配合使用;- Stripe 客户端在 stripe.ts 中创建,密钥只允许出现在服务端代码(API 路由)中,绝不能暴露给客户端组件。
三个移动部件之三:Webhook(记账的唯一入口)
支付完成后,Stripe 向服务器发送 Webhook,此时才创建购买记录。仓库实现见 webhook/stripe/route.ts:
import { NextResponse } from "next/server"; import { headers } from "next/headers"; import { stripe } from "@/lib/stripe"; import { createPurchase, findPurchaseBySessionId } from "@/lib/purchases"; export async function POST(request: Request) { const body = await request.text(); const headersList = await headers(); const signature = headersList.get("stripe-signature")!; let event; // Verify the webhook signature try { event = stripe.webhooks.constructEvent( body, signature, process.env.STRIPE_WEBHOOK_SECRET! ); } catch (err) { console.error("Webhook signature verification failed:", err); return NextResponse.json({ error: "Invalid signature" }, { status: 400 }); } if (event.type === "checkout.session.completed") { const session = event.data.object; const token = session.metadata?.token; if (!token) { console.error("No token in session metadata"); return NextResponse.json({ error: "Missing token" }, { status: 400 }); } // Check for duplicate const existing = await findPurchaseBySessionId(session.id); if (existing) { return NextResponse.json({ received: true, duplicate: true }); } await createPurchase({ token, email: session.customer_details?.email || "", stripeSessionId: session.id, stripePaymentIntentId: (session.payment_intent as string) || "", amount: session.amount_total || 500, currency: session.currency || "usd", }); } return NextResponse.json({ received: true }); }两步防御缺一不可:
- 签名校验:
stripe.webhooks.constructEvent(body, signature, secret)证明请求确实来自 Stripe;如果不校验,任何人都可以伪造 Webhook 白嫖内容。 - 幂等处理:Stripe 可能重复投递同一 Webhook,
findPurchaseBySessionId(session.id)已存在记录时直接返回duplicate: true,避免重复创建购买。
createPurchase的实现见 purchases.ts,它先查询全部壁纸,再创建一条与所有壁纸关联的购买记录:
export async function createPurchase(params: CreatePurchaseParams) { // Get all wallpapers to link to the purchase const { wallpapers } = await adminDb.query({ wallpapers: {} }); const wallpaperIds = wallpapers.map((w) => w.id); const purchaseId = id(); await adminDb.transact( adminDb.tx.purchases[purchaseId] .update({ token: params.token, email: params.email, stripeSessionId: params.stripeSessionId, stripePaymentIntentId: params.stripePaymentIntentId, amount: params.amount, currency: params.currency, status: "completed", createdAt: Date.now(), }) .link({ wallpapers: wallpaperIds }) ); return params.token; }这里的adminDb来自 adminDb.ts,使用@instantdb/admin初始化,需要服务端环境变量INSTANT_APP_ID与INSTANT_APP_ADMIN_TOKEN——管理端 API 拥有绕过权限规则的完全写权限,因此只应在服务端使用。
数据模型:purchases 实体与关联
在 InstantDB 中,购买记录被建模为purchases实体,并通过purchaseWallpapers链接与wallpapers建立多对多关系。完整 schema 见 instant.schema.ts:
const _schema = i.schema({ entities: { // ... existing entities purchases: i.entity({ token: i.string().unique().indexed(), email: i.string().indexed(), stripeSessionId: i.string().unique().indexed(), stripePaymentIntentId: i.string().optional(), amount: i.number(), currency: i.string(), status: i.string().indexed(), createdAt: i.number().indexed(), }), }, links: { // ... existing links purchaseWallpapers: { forward: { on: "purchases", has: "many", label: "wallpapers" }, reverse: { on: "wallpapers", has: "many", label: "purchases" }, }, }, });各字段语义与约束(以仓库源码为准):
| 字段 | 类型与约束 | 用途 |
|---|---|---|
token | string().unique().indexed() | 购买凭证,存于用户 localStorage |
email | string().indexed() | 购买时填写的邮箱,用于恢复流程 |
stripeSessionId | string().unique().indexed() | 幂等去重,防止重复 Webhook |
stripePaymentIntentId | string().optional() | 支付意图 ID,对账用 |
amount | number() | 支付金额 |
currency | string() | 币种,如usd |
status | string().indexed() | 记录状态,示例中写入"completed" |
createdAt | number().indexed() | 创建时间戳 |
schema 变更后执行推送:
npx instant-cli push schema --yes访问控制:字段级权限实现服务端强制解锁
这是整套方案安全性的根基。InstantDB 的权限规则在服务端执行,客户端无论怎样修改代码都无法绕过。权限定义见 instant.perms.ts:
const rules = { wallpapers: { allow: { view: "true", // Everyone can see wallpapers create: "false", update: "false", delete: "false", }, fields: { // Only return fullResUrl if token matches a linked purchase fullResUrl: "ruleParams.token in data.ref('purchases.token')", }, }, purchases: { allow: { // Viewable if authenticated user's email matches view: "data.email == auth.email", create: "false", update: "false", delete: "false", }, }, };语义拆解:
- 壁纸的缩略图等元数据对所有人可见(
view: "true"); - 但
fullResUrl字段受字段级规则保护:ruleParams.token in data.ref('purchases.token')—— 只有当调用方通过ruleParams传入的 token 存在于与该壁纸关联的某条购买的token字段中时,fullResUrl才会被返回; purchases实体仅允许邮箱匹配的已认证用户查看自己的购买记录,用于恢复流程。
权限推送命令:
npx instant-cli push perms --yes客户端查询时通过ruleParams携带 token(见 success/page.tsx):
const { data, isLoading: queryLoading } = db.useQuery( { wallpapers: { $: { order: { order: "asc" } } } }, token ? { ruleParams: { token } } : undefined ); // If token is valid, wallpaper.fullResUrl exists // If token is invalid or missing, fullResUrl is omitted const isUnlocked = !!wallpaper.fullResUrl;客户端db在 db.ts 中通过@instantdb/react初始化,使用公开的NEXT_PUBLIC_INSTANT_APP_ID。最终效果是:有效 token → 返回fullResUrl;无效或缺失 token → 该字段被省略,解锁判断直接由字段是否存在决定。
购买恢复:无账号体系下的邮箱魔法码找回
用户清空 localStorage 或更换设备后,需要通过邮箱找回购买。方案不引入独立账号体系,而是利用 InstantDB 自带的魔法码(magic code)认证做一次性邮箱所有权验证。流程见 recover/page.tsx:
- 用户输入邮箱 →
db.auth.sendMagicCode({ email })发送验证码; - 用户输入验证码 →
db.auth.signInWithMagicCode({ email, code })完成认证; - 认证后
db.useQuery(user ? { purchases: {} } : null)查询该邮箱名下的购买记录(受data.email == auth.email权限约束); - 命中第一条购买记录后,将
purchase.token写回 localStorage,然后立即db.auth.signOut()登出——认证只是临时验证邮箱归属,产品本身仍不需要账号。
const handleSendCode = async () => { await db.auth.sendMagicCode({ email }); setStep("code"); }; const handleVerifyCode = async () => { await db.auth.signInWithMagicCode({ email, code }); // Auth triggers the useQuery above };整个过程无需外部邮件服务,InstantDB 已内置处理。
测试与常见错误
测试模式(免费)
Stripe 测试模式下使用官方测试卡:
| 卡号 | 结果 |
|---|---|
4242 4242 4242 4242 | 成功 |
4000 0000 0000 0002 | 被拒绝 |
任何未来到期日、任意 3 位 CVC、任意邮编均可。
生产环境测试
- 在 Stripe Dashboard(live 模式)创建 100% 折扣优惠券;
- Checkout 会话中启用
allow_promotion_codes: true(仓库的 checkout/route.ts 已默认开启); - 结账时使用该优惠券完成真实流程;
- 测试结束后移除该配置。
本地与生产的 Webhook 配置
本地开发使用 Stripe CLI 转发:
# Install Stripe CLI brew install stripe/stripe-cli/stripe # Login (one time) stripe login # Forward webhooks to your local server stripe listen --forward-to localhost:3000/api/webhook/stripeCLI 会输出一个whsec_...签名密钥,写入.env.local的STRIPE_WEBHOOK_SECRET。生产环境则需在 Stripe Dashboard → Developers → Webhooks 添加端点:URL 指向https://your-app.com/api/webhook/stripe,事件选择checkout.session.completed。忘记配置生产 Webhook 是上线后最常见的漏项。
六个高频踩坑点
- 忘记校验 Webhook 签名——
JSON.parse(body)会让任何人伪造请求;必须用stripe.webhooks.constructEvent(body, signature, secret)。 - 未处理重复 Webhook——Stripe 可能多次投递同一事件,务必用
findPurchaseBySessionId做幂等。 - 只信任 localStorage——localStorage 里有 token 不代表支付成功(用户可能中途取消结账);应以「查询结果中是否存在
fullResUrl」为准,即wallpapers.some((w) => !!w.fullResUrl)。 - 付款后才生成 token——会引入 Webhook 与成功页之间的竞态;必须在跳转 Checkout 前生成并落盘。
- 生产环境未配置 Webhook 端点——本地能跑通不代表线上能记账。
- 在客户端暴露 Stripe 密钥——
sk_...只能用于 API 路由等服务端代码。
方案小结
回顾这套 buy-once 模式的四个设计支柱:
- Token 先行:付款前生成 token 并落盘,成功页零等待、无竞态;
- Webhook 唯一记账入口:只有 Stripe 签名合法且去重通过的请求才能创建购买,单一事实来源;
- 权限服务端强制:字段级规则在 InstantDB 服务端执行,任何客户端手段都无法绕过;
- localStorage + 邮箱恢复:全程无需账号系统,用户体验轻量。
完整的按步骤实现指南见 tutorial.md,示例工程的整体说明见 README.md。仓库中还提供了 link-purchases.ts 与 seed-wallpapers.ts 等配套脚本,可参考其完成初始数据填充与购买关联的批量处理。若要扩展订阅、多商品或按量计费,可在此基础上沿用同一套「metadata 传凭证 + Webhook 记账 + 权限解锁」骨架。
【免费下载链接】instantInstant is the best backend for AI-coded apps. You get auth, permissions, storage, presence, and streams — everything you need to ship apps your users will love.项目地址: https://gitcode.com/gh_mirrors/inst/instant
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考