跨端订阅
Web 结账与 App Store 内购共享的一人一行权威订阅记录。
订阅在各渠道之间是统一的:通过 Web 结账(Creem 或 Stripe)购买和通过
App Store 内购(经 RevenueCat)购买,最终都写入同一条一人一行的
subscription 快照;Web 页面和原生客户端用同一个服务端规则从它计算
权益。Web 购买、App 解锁——反之亦然。
模型:一人一行
现有 subscription 表就是权威快照——subscription.userId 是唯一约束,
一名用户同一时间最多只有一条当前订阅。没有额外的 provider_subscriptions
或 user_entitlements 表;权益始终由当前行计算。(provider, subscriptionId)
唯一约束还能防止同一个外部订阅(例如同一个 Apple 订阅)绑定到多个账户。
各 Provider 的字段映射:
| 字段 | Web(Creem / Stripe) | App Store(经 RevenueCat) |
|---|---|---|
provider | creem / stripe | app_store(预留 play_store) |
customerId | 支付平台 Customer ID | RevenueCat original App User ID |
subscriptionId | 支付平台 Subscription ID | Apple original transaction ID |
lastOrderNo | 订单号 | RevenueCat transaction ID |
productId | Provider 产品/价格 ID | App Store 产品 ID |
productType | one_time | month | year | 相同 |
status | trialing | active | unpaid | canceled | expired | 相同 |
providerEventId / providerEventAt | Webhook 事件 ID / 时间戳——幂等与乱序保护 | 相同 |
权益:一个纯函数
src/server/app/subscriptions/entitlement.ts 是所有界面共用的唯一规则——
Web 页面、App API,以及(通过共享测试向量)Swift 客户端:
active/trialing——有权益;one_time永不过期,周期性套餐要求periodEnd > now。canceled——在periodEnd之前仍有权益。unpaid/expired/ 未知——无权益。- 无记录——无权益。
原生客户端通过 GET /api/app/v1/subscription 读取:返回快照(或 null)和
服务端计算的 isEntitled——客户端不得自行推导权益。
阻止重复购买
同一时间一条订阅,也意味着同一时间一个购买渠道。每个购买入口都在服务端 检查当前记录:
- Web 结账(
/api/payment/create)在用户已有权益时返回409——隐藏 购买按钮不是保护手段,服务端检查才是。 - 原生 Paywall:App 在展示 Paywall 前刷新
/api/app/v1/subscription。 若存在有效的 Web 订阅,不得提供 App Store 购买;若订阅来自 App Store,则 展示 Apple 的订阅管理入口。 - 只有当前订阅到期后才允许切换购买渠道。
RevenueCat Webhook
App Store 购买通过 RevenueCat 的 Webhook 同步:
POST /api/payment/notify/revenuecat设置 Authorization header
在 RevenueCat 控制台把 Webhook 的 Authorization header 配置为与
REVENUECAT_WEBHOOK_AUTHORIZATION 完全一致(包括 Bearer 前缀)。header
不匹配的请求在解析之前就会被拒绝。
显式映射产品
设置全部三个映射——绝不会用产品名称推断套餐周期:
REVENUECAT_PRODUCT_PRO_MONTHLY=com.example.app.pro.monthly
REVENUECAT_PRODUCT_PRO_YEARLY=com.example.app.pro.yearly
REVENUECAT_PRODUCT_LIFETIME=com.example.app.lifetime启用生命周期事件
初次购买、续费、取消、过期、扣费问题、退款/撤销,以及非续期购买。
禁用 Transfer
保持 RevenueCat 的 Restore Behavior 为 Transfer disabled。收到 TRANSFER
事件会确认但忽略,并记录为配置漂移警告;收据与账户冲突交由人工支持处理,
而不是在用户之间静默转移权益。
Webhook 会把 app_user_id 解析为 Better Auth 用户(原生 App 把 RevenueCat
appUserID 设置为 Better Auth 用户 ID),并拒绝匿名 ID。客户端还有一条
镜像要求:只有 RevenueCat 身份与已登录用户同步完成后才允许购买。
Webhook 安全:重放、乱序、冲突
三个 Webhook(Creem、Stripe、RevenueCat)都经由同一个写入策略
decideSubscriptionWrite
(src/server/app/subscriptions/subscriptionWritePolicy.ts):
| 决策 | 触发条件 |
|---|---|
duplicate | 相同 providerEventId 已应用过——重放收敛到同一状态 |
stale | 同 Provider 但 providerEventAt 更旧——乱序事件不会把记录回退 |
preserve-longer-entitlement | 另一渠道试图缩短已付费权益——保留较长的记录,并记录冲突交人工处理 |
apply | 其余情况 |
RevenueCat 会重试非 2xx 的投递,因此重放是预期内且安全的。极低概率的跨渠道 并发购买刻意不做自动对账:已付费用户保留较长权益,结构化日志将冲突暴露 给人工处理。
在 App 中管理订阅
POST /api/app/v1/subscription/portal 为 creem/stripe 订阅返回短时效的
平台托管管理 URL;没有托管入口时返回 null——App Store 订阅通过 Apple 自己
的订阅设置管理,客户端根据 provider 打开对应入口。
