集成 Stripe(SaaS:Checkout + Webhook + Portal)
基于 Upload‑Ready 实战沉淀的可复用流程。目标:在新站点快速完成「可收款、可续费、可取消」的 Stripe 接入,并避开 Edge/Cloudflare 常见坑。
何时使用
- 用户要给站点接 Stripe(订阅、一次性买点、积分包等)
- 部署在 Cloudflare Pages / Workers / next-on-pages Edge
- 搞不清 Stripe Test/Live 与 Cloudflare Preview/Production
- Checkout 502、密钥形态不对、Webhook 验签失败、Pro 无法改订阅
执行原则
- 先决策清单,再改代码;每步完成后汇报,等用户 Review 再下一步
- 最小改动;Dashboard 操作由用户完成,Agent 给逐步点击路径与应粘贴的值名
- 产品 SKU / 金额 / 权益文案有歧义时 必须让用户决策,勿默认拍板
- 用中文沟通;密钥 永不 写入 Git / 聊天复述全文
参考实现(可选对照)
若当前仓库附近有 Upload‑Ready,可对照(勿整文件复制业务规则):
| 能力 | 参考路径 |
|---|---|
| Checkout(fetch,非 Node SDK) | apps/web/lib/payment/stripe.ts |
| 开关 / 密钥形态校验 | apps/web/lib/payment/paymentFlags.ts |
| 创建会话 API | apps/web/app/api/payment/stripe/create-checkout-session/ |
| Portal API | apps/web/app/api/payment/stripe/create-portal-session/ |
| Webhook | apps/web/app/api/webhooks/stripe/ |
| 环境说明长文 | doc/STRIPE.md |
详细踩坑与自检见 reference.md。
决策清单(开干前问清)
- [ ] 卖什么:一次性商品?订阅?两者都有?各几个 SKU?
- [ ] 币种(默认 USD)与标价表(权威来源:constants / 设计文档)
- [ ] 宿主:Cloudflare Pages+Edge / 纯 Workers / Node 服务器?
- [ ] 用户体系:登录后才能买?(Checkout metadata 是否要 userId)
- [ ] 订阅变更:是否要 Customer Portal(改卡/取消)?
- [ ] 练手阶段:正式域名是否先用 Stripe Test?(推荐:是)
- [ ] Cloudflare 变量策略:Secret 走 Dashboard?明文 Price 走 wrangler?
- [ ] Webhook URL 用哪个域名?(正式站 / Preview 是否各建 endpoint)
环境矩阵(必读,极易混):
| 概念 | 属于谁 | 含义 |
|---|---|---|
| Test / Live | Stripe 左上角 | 密钥与 Price 是否真扣款 |
| Preview / Production | Cloudflare 部署环境 | 哪次部署能读到变量 |
结论: Production ≠ 必须 Live。正式域名可先挂 sk_test_ 联调。Test 的 price_ 绝不能配 sk_live_(反之亦然)。
进度清单
Task Progress:
- [ ] 步骤 0:勘察仓库与 SKU 表
- [ ] 步骤 1:Stripe Dashboard(密钥 + Products/Prices)
- [ ] 步骤 2:部署环境变量(Secret + Price + 开关)
- [ ] 步骤 3:Webhook endpoint + Signing secret
- [ ] 步骤 4:数据模型(customer / subscription / 幂等支付记录)
- [ ] 步骤 5:服务端 API(Checkout + 可选 Portal)
- [ ] 步骤 6:Webhook 处理(权益落库)
- [ ] 步骤 7:前端 CTA + 验收
- [ ] 步骤 8:(可选)切 Live 上线收款
步骤 0:勘察仓库与 SKU 表
确认并输出一张 SKU 表(与用户确认后再建 Stripe 商品):
| # | 类型 | 内部 id | 金额 | 周期 | 环境变量名建议 |
|---|---|---|---|---|---|
| … | one_time / recurring | pack/planId | $x.xx | — / month / year | STRIPE_PRICE_ID_… |
同时确认:
- 是否已有用户表、积分/权益字段
- 是否 Edge runtime(有则 禁止依赖默认 Node Stripe SDK 做 Checkout)
wrangler.toml/ Pages 是否「明文只能写 toml、Secret 只能 Dashboard」
步骤 1:Stripe Dashboard(用户操作,Agent 引导)
全程先 Test mode。
1.1 Secret Key
- Dashboard → 左上角 Test mode
- Developers → API keys → 复制 Secret key(必须
sk_test_) - 写入部署平台 Secret(如
STRIPE_SECRET_KEY),勿进 Git
Checkout 跳转模式通常 不需要 前端
pk_。
1.2 创建 Product / Price
对 SKU 表逐行 Add product:
- 一次性 → Pricing Type One-off
- 订阅 → Recurring + Monthly / Yearly
- 币种与标价表一致;复制的是 Price ID(
price_…),不是prod_… - Name/Description 给顾客看;禁止写
price_、环境变量名、内部 id
1.3(可选)对账单描述符
多产品共账户时:主描述符用公司商号;单产品可用 statement_descriptor_suffix 区分。细节见 reference.md。
步骤 2:部署环境变量
推荐拆分:
| 变量 | 存放 | 示例 |
|---|---|---|
STRIPE_SECRET_KEY |
Secret | sk_test_… / sk_live_… |
STRIPE_WEBHOOK_SECRET |
Secret | whsec_… |
STRIPE_PRICE_ID_* |
明文配置(如 wrangler.toml [env.*.vars]) |
price_… |
PAYMENT_STRIPE_ENABLED |
明文 | "true" / "false" |
Cloudflare Pages 常见约束:
- Dashboard 只能加 Secret 时:Price / 开关必须进
wrangler.toml的 每个[env.preview.vars]/[env.production.vars](顶层[vars]不会自动合并进 env 段) - 本地:
.dev.vars覆盖密钥(勿提交)
改 toml 后必须 重新部署 才生效。
步骤 3:Webhook
- Test mode → Developers → Webhooks → Add endpoint
- URL:
https://<域名>/api/webhooks/stripe(路径按项目约定) - 至少订阅:
checkout.session.completedinvoice.paidcustomer.subscription.updatedcustomer.subscription.deleted
- 复制 Signing secret(
whsec_…)→ 对应环境的 Secret - Endpoint 所属 mode(Test/Live)必须与该环境的
sk_/price_同属一套
步骤 4:数据模型
最少需要(名称可按项目调整):
- 用户:
stripe_customer_id、stripe_subscription_id、权益字段(如plan、credits) - 支付流水:以
checkout.session.id/event.id/invoice.id做 幂等,防重复加权益
已有库则写 migration;执行一次远程迁移后再测支付。
步骤 5:服务端 API(硬约束)
5.1 创建 Checkout Session
- 需登录则校验 session;
metadata写入userId+pack或planId mode:payment(一次性)/subscriptionsuccess_url/cancel_url指回本站- Edge / Workers:用 Stripe HTTP API(
fetch)创建 Session,不要用会拖垮 isolate 的 Node SDK 默认路径 - 启动前校验密钥形态:仅接受
sk_test_/sk_live_(或项目约定的rk_);pk_/whsec_/ 乱码 → 503 JSON,禁止抛到 Worker 崩溃
5.2 Customer Portal(订阅管理)
POST创建 portal session,需已有stripe_customer_id- 返回 URL 后浏览器跳转;取消/换卡仍靠 同一套 Webhook 回写权益
5.3 业务互斥(按产品)
例:已有有效订阅时拒绝再开订阅 Checkout;积分包是否叠加由产品决定。
步骤 6:Webhook 处理
export const runtime = "edge"(若项目要求 Edge)const rawBody = await req.text()后再验签;禁止先req.json()- 校验
stripe-signature;失败 → 400,不落库 - 按事件更新权益;幂等
- 尽快
200;重逻辑保持短(Workers CPU 限制)
验签可用官方 SDK 的 async construct,或等价 HMAC;Checkout 创建仍优先 fetch。
步骤 7:前端 + 验收
- 定价页 CTA → 调 create-checkout-session →
window.location = url - 已订阅 → 「管理订阅」走 Portal
- Test 卡:
4242 4242 4242 4242,任意未来有效期 / CVC
验收清单:
- [ ] Secret 前缀正确,且与 Price 同属 Test 或同属 Live
- [ ] 五个(或 N 个)变量都是 price_ 不是 prod_
- [ ] 开关已开;Webhook whsec_ 对应该 endpoint
- [ ] 一次成功支付后权益正确;重复 Webhook 不加两次
- [ ] 订阅取消 / 更新后 plan 正确
- [ ] Portal 在有 customer id 时可打开
步骤 8:切 Live(可选)
左上角切 Live mode,整套重做:Live sk_、Live price_、Live Webhook whsec_,只改 Production 环境。Test/Live 禁止混用。
常见失败速查
| 现象 | 优先检查 |
|---|---|
| Cloudflare HTML 502 / isolate 崩 | 是否在 Edge 用了 Node Stripe SDK 建 Checkout → 改 fetch |
| Checkout 503 / keyPrefix 怪异 | Secret 不是 sk_test_/sk_live_(粘错 pk/whsec/截断) |
| 支付成功但无权益 | Webhook URL/环境、whsec_、是否用了 req.json() 验签 |
| Portal 失败 | 用户无 stripe_customer_id(从未成功订阅过) |
| Preview 无 Stripe 变量 | wrangler.toml 只写了顶层 [vars],未写入 [env.preview.vars] |
更多见 reference.md。