Unified Platform Host

跨端订阅

Web 结账与 App Store 内购共享的一人一行权威订阅记录。

订阅在各渠道之间是统一的:通过 Web 结账(Creem 或 Stripe)购买和通过 App Store 内购(经 RevenueCat)购买,最终都写入同一条一人一行的 subscription 快照;Web 页面和原生客户端用同一个服务端规则从它计算 权益。Web 购买、App 解锁——反之亦然。

模型:一人一行

现有 subscription 表就是权威快照——subscription.userId唯一约束, 一名用户同一时间最多只有一条当前订阅。没有额外的 provider_subscriptionsuser_entitlements 表;权益始终由当前行计算。(provider, subscriptionId) 唯一约束还能防止同一个外部订阅(例如同一个 Apple 订阅)绑定到多个账户。

各 Provider 的字段映射:

字段Web(Creem / Stripe)App Store(经 RevenueCat)
providercreem / stripeapp_store(预留 play_store
customerId支付平台 Customer IDRevenueCat original App User ID
subscriptionId支付平台 Subscription IDApple original transaction ID
lastOrderNo订单号RevenueCat transaction ID
productIdProvider 产品/价格 IDApp Store 产品 ID
productTypeone_time | month | year相同
statustrialing | active | unpaid | canceled | expired相同
providerEventId / providerEventAtWebhook 事件 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)都经由同一个写入策略 decideSubscriptionWritesrc/server/app/subscriptions/subscriptionWritePolicy.ts):

决策触发条件
duplicate相同 providerEventId 已应用过——重放收敛到同一状态
stale同 Provider 但 providerEventAt 更旧——乱序事件不会把记录回退
preserve-longer-entitlement另一渠道试图缩短已付费权益——保留较长的记录,并记录冲突交人工处理
apply其余情况

RevenueCat 会重试非 2xx 的投递,因此重放是预期内且安全的。极低概率的跨渠道 并发购买刻意不做自动对账:已付费用户保留较长权益,结构化日志将冲突暴露 给人工处理。

在 App 中管理订阅

POST /api/app/v1/subscription/portalcreem/stripe 订阅返回短时效的 平台托管管理 URL;没有托管入口时返回 null——App Store 订阅通过 Apple 自己 的订阅设置管理,客户端根据 provider 打开对应入口。

相关页面

本页目录