2026.07.19
一个 Cloudflare Worker 里同时装 Stripe 和 Creem
一份 index.js,Stripe 和 Creem 两个支付通道并行,前端 ?provider=creem 一个 query 参数切。Webhook 端点分开走,D1 表加一列 provider 就够。加起来大概 500 行代码。
先说结论。
一个 Cloudflare Worker · 一份 index.js · Stripe 和 Creem 两个支付通道并行 · 前端 ?provider=creem 或 ?provider=stripe 一个 query 参数切。webhook 端点分开走 · D1 表加一列 provider 就够。加起来大概 500 行代码。
这是我最近给 datenix 的做法。花了两天写完 · 中间打断多次去帮朋友的 KYC 出的坑填坑。
为什么两个通道
Stripe 是行业标准 · 但对国内独立开发者不友好。CN 身份激活不了 · 只能 HK Ltd / Delaware LLC / UK Ltd 三选一。哪一条都要一到四周 · 都要钱。
Creem 是 Merchant of Record · 意思是他替我收钱、替我交税。对独立开发者最舒服 · CN 身份直接开个人号就能上。费率 3.9% + 0.4 美金 · 略高于 Stripe 的 2.9% + 0.3 美金 · 但比自己搞跨境税值。
问题是 Creem 相对新 · 我担心它自己会挂。给客户报的时候要说「我们用 Creem」· 一个客户没听过就要解释一遍。所以我留一个 Stripe 通道作 backup · 万一 Creem 出问题 · 换一个 query 参数就能切回来 · D1 里已有的 Stripe 订户完全不受影响。
这不是过度设计。这是我在国内 · 我在一个人做产品 · 我要 blast radius 越小越好。
PaymentProvider 抽象要不要现在做
一开始想得比较大。TypeScript · 定义一个 PaymentProvider interface · Stripe 和 Creem 各实现 · factory 函数根据 env 或 query 选一个。看着很干净。
写完发现问题:
- 现有 index.js 是 JS · 加 TypeScript build 步骤等于给部署管道加复杂度
- 每加一个方法就要在 3 处更新 (interface / stripe impl / creem impl)
- 我实际只用了 checkout / webhook / portal / cancel 四个方法
后来决定 defer。TS providers 我确实写完了 · 每个文件 200 行左右 · 都存在 deploy/cf-worker/src/providers/ 里 · 通过了 tsc --strict --noEmit。但没接进生产。生产还是老老实实用 JS · index.js 直接 import ./creem.js 那个 12-export 的模块。
啥时候接 TS · 等这两个通道都在生产环境跑了半年、遇到过至少两次「新加一个方法要在多处同步」的痛之后。现在没痛 · 就不接。
Webhook 那点事
Stripe 和 Creem 的 webhook 签名格式不一样。
Stripe 用 stripe-signature: t=1234567890,v1=abc... · 把时间戳和 body 拼起来 HMAC 一下。防重放攻击有效。
Creem 用 creem-signature: abc... · 直接一个 hex 字符串是 body 的 HMAC。
在 Cloudflare Worker 里两个都要 WebCrypto 做 · Node 那套 crypto.createHmac 用不了。
const enc = new TextEncoder()
const key = await crypto.subtle.importKey(
'raw', enc.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' }, false, ['sign'],
)
const mac = await crypto.subtle.sign('HMAC', key, enc.encode(rawBody))
const bytes = new Uint8Array(mac)
const computed = Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('')
比较那一步一定要 constant-time · 别用 ===。字符串前几位相同后几位不同 · 时间会不一样 · 理论上能被 timing attack 泄露信息。写一个专门的比较函数 · 逐字符 XOR 累加 · 结果为 0 才返回 true。
我一开始没写这个 · reviewer 后来提醒。这类细节独立开发者容易漏 · 但一旦上线就必须有。
Creem 那边有个坑 · event 结构和 Stripe 不一样。Stripe 是 { type: 'checkout.session.completed', data: { object: {...} } }。Creem 是 { eventType: 'checkout.completed', object: {...} }。都是 completed 事件 · 名字不同 shape 不同。所以我在处理函数里两种都接:
const eventType = event.eventType || event.type
const obj = event.object || event.data?.object || event.data
Provider 独立 vocabulary + 兼容旧 shape · 这样以后加 LemonSqueezy 之类的 · 也是这种写法一直加下去。
D1 表的调整
subscriptions 表本来只有 stripe 相关的列 (stripe_customer_id · stripe_subscription_id)。要加 Creem 就多 4 列:
- provider TEXT DEFAULT 'stripe'
- creem_customer_id TEXT
- creem_subscription_id TEXT
- creem_product_id TEXT
Migration 002 就干这个。加上 4 个 index (email · provider · creem_customer_id · creem_subscription_id) · 让后面按 email 或按 provider 查订户很快。
有一个坑我踩了。写 upsert 用 ON CONFLICT(email) DO UPDATE · 结果 SQLite 报「ON CONFLICT clause does not match any PRIMARY KEY or UNIQUE constraint」。因为 email 没有 UNIQUE 约束。得加个 unique index。
一开始想省事写 CREATE UNIQUE INDEX ... WHERE email IS NOT NULL (partial index) · 结果 partial index 又不能做 conflict target。最后老老实实 CREATE UNIQUE INDEX ux_subs_email ON subscriptions(email) · 才通。
数据里如果已经有重复邮箱 · 这一步会失败。我这里因为都是新表 · 无所谓。存量表要先做去重才能加 unique。
Webhook 审计表
上生产之后我再加了一层保险 —— 一个 webhook_events 表 · 每次 webhook 都写一行 · 包括:
- provider (creem / stripe)
- event_type
- event_id UNIQUE (幂等 · 同 event_id 只处理一次)
- payload (原始 body · 截断 16 KB)
- signature_valid (0/1)
- processed (0/1)
- action (grant_access / revoke_access / pause / update / refund_logged / ...)
- error (如果有)
- received_at / processed_at
有这张表最大的好处不是审计 · 是调试。上线之后如果客户说「我付了钱没生成 API key」· 我查这张表按 email 拉最近的 event 记录 · 一眼看得出是签名没过 · 还是签名过了但 D1 写入失败 · 还是根本没收到 webhook。
Webhook 重试也依赖这张表。Creem 重试 30 秒 / 1 分钟 / 5 分钟 / 1 小时四轮 · 收到相同 event_id 我 INSERT OR IGNORE · 已处理过就直接返 200 · Creem 那边就不再重试。
Provider-aware 路由
前端只有一个 POST /api/checkout 端点 · body 是 {email, plan}。Provider 通过 query string 决定:
POST /api/checkout?provider=creem → 走 Creem 通道
POST /api/checkout?provider=stripe → 走 Stripe 通道
POST /api/checkout → 走默认 (env.PAYMENT_PROVIDER 决定)
这样 landing 页 A/B 测试就很好做。给一半用户 URL 里带 ?provider=creem · 另一半 ?provider=stripe · 看哪个 conversion 更高 · 哪个客户投诉更少。切换只是 landing 里改一行链接。
12 event 端到端测试是必须的
Creem 有 12 种 event 类型: checkout.completed · subscription.active/paid/trialing/canceled/expired/paused/past_due/scheduled_cancel/update · refund.created · dispute.created。
我写了个 test-webhook-events.mjs · 里面 12 个 event 类型各造一个 sample payload · 用 webhook secret HMAC 签名 · POST 到我 worker 的 endpoint。跑一遍 · D1 里 12 行 · signature_valid 全 1 · processed 全 1 · action 全对上。
没这一步别上生产。webhook 上线之前不 dry-run 一遍 · 到时候客户拿真钱来测 · 中间任何一个 event 处理错 · D1 就是脏的 · 排查特别难。
用不用 KV 缓存
Thanks 页需要在客户支付后 poll 一下「我的 API key 出来了没」。三种做法:
- 每次都查 D1 (慢 · 但简单)
- KV 缓存 (30 秒 · 中等复杂)
- WebSocket (太重)
我先走 D1 直查。响应 ~50ms · 客户不觉得慢。后面订户到 1000+ 再考虑 KV。
不要在没痛的时候优化。
一份可以复制到别的 SaaS 的骨架
这套东西我在 datenix 上先跑通了。后面 DocStruct (PDF 转 JSON 的工具站) · SleepStack (睡眠相关的内容站带电子书) 都能复用。
真正 datenix-specific 的东西只有:
- 产品 ID 和价格
- landing 页文案
- D1 里的业务表 (datenix 是 tools / shops 表 · docstruct 会是 extractions 表)
其他全部可以照搬。creem.js 那个 11 KB 模块 · admin CLI 那 17 KB 脚本 · monitor 那 10 KB 探针 · webhook 审计表 · unique-index-on-email 的 migration —— 都可以直接 copy。
一个东西写透 · 后面产品少花 80% 时间。这是我最近相信的一个道理。
现在的状态
datenix 的 Creem 通道全通了 · 4 个产品在生产环境 · webhook 12 event 端到端过。就等 KYC 那边 Sumsub 解锁 · 客服 24-72 小时回信。
Stripe 那边是 sandbox · 还没激活。因为 CN 身份必须走 HK Ltd 或 Delaware LLC 才能拿到 live key。我在申 Stripe Atlas · 五到七个工作日到手。到时候 · 前端加一个 ?provider=stripe 就多一条腿。
这套东西暴露的接口和路径 · 数据表结构 · 部署脚本 · 都放在 deploy/cf-worker/ 里。如果哪天有第二个人接手 · 我的 SESSION_HANDOFF 那份文档里第一段 30 秒 resume · 应该够他上手。这是我最近的一个执念 —— 每份东西都要为「明天不是我打开」写。
一个人写产品 · 就是不断和明天的自己交接。这也算是一种独角戏。