订阅
了解账号绑定权益和订阅门禁。
Expo 模板带有 RevenueCat paywall、恢复购买、兑换码,以及账号绑定的权益模型。最重要的区别是:RevenueCat 负责卖产品,Supabase 决定账号能访问什么。
subscriptions 表是权益真相来源。RevenueCat CustomerInfo 只是购买状态和本地缓存;不要把它作为高级功能门禁。
提供什么
| Surface | 状态 | 说明 |
|---|---|---|
PurchasesProvider | Native-only | 封装 RevenueCat,获取 offerings,购买/恢复,并用 Supabase user UUID 登录 RevenueCat |
EntitlementProvider | Integrated | 通过 RLS 读取 subscriptions,暴露 useEntitlement().isEntitled |
app/paywall.tsx | Native-only | Modal paywall;从 RevenueCat 当前 offering 渲染 packages |
app/redeem-code.tsx | Native-only | iOS 兑换面板;Android 打开 Google Play 兑换 URL;Web 显示说明 |
app/manage-subscription.tsx | Native-only | 商店管理链接、恢复购买、来自 RevenueCat cache 的状态摘要 |
PremiumGate | Integrated | 用 Supabase entitlement boolean 锁住任意 UI |
home-paywall / home-redeem-code | Native-only | 复用 modal 体验,但使用 tab header chrome 的 tab-context wrapper |
未配置时购买会优雅降级。在 Web 上,或 iOS/Android 没有对应
EXPO_PUBLIC_REVENUECAT_*_KEY 时,isConfigured 为 false,不加载 offerings,购买/恢复调用返回友好错误。
Provider 行为
lib/purchases.tsx 按平台选择公开 RevenueCat SDK key:
Platform.OS === "ios" -> EXPO_PUBLIC_REVENUECAT_IOS_KEY
Platform.OS === "android" -> EXPO_PUBLIC_REVENUECAT_ANDROID_KEY
Platform.OS === "web" -> unconfigured当存在已登录 Supabase 用户时,PurchasesProvider 调用:
Purchases.logIn(user.id);这会让 RevenueCat app_user_id 等于 Supabase UUID,因此 webhook event 可以 upsert 同一个 subscriptions.user_id row。RevenueCat customer-info listener 触发时,应用也会刷新 Supabase entitlement query。
账号绑定流程
Store purchase
-> RevenueCat webhook
-> Supabase Edge Function
-> public.subscriptions row
-> EntitlementProvider
-> useEntitlement().isEntitled
-> PremiumGate / Home card / Me row / paywall owned state当前 supabase/functions/_shared/revenuecat.ts 服务端 mapper 接受 APP_STORE
和 MAC_APP_STORE events。PLAY_STORE events 目前会被忽略,所以 iOS 购买在随附后端中是端到端的;Android 需要先扩展 mapper 和数据库 enum,Google Play 购买才能授予共享 Supabase entitlement。
Entitlement 真值表
lib/entitlement.tsx 中的 isSubscriptionEntitled() 是唯一门禁:
subscriptions.status | 何时有权益 |
|---|---|
active / trialing + one_time | 永远 |
active / trialing + month 或 year | period_end 仍在未来 |
canceled | period_end 仍在未来 |
unpaid / expired | 从不 |
unknown | 从不 |
未登录、loading、error 状态都不是 entitled。状态不确定时门禁应保持锁定。
跨 provider 冲突规则
subscriptions 表中每个用户只有一个当前 row。这让 starter 保持单 tier,但意味着 webhook 需要确定性的接管规则:
- 同 provider event 总是应用,因此 renewals、cancellations、expirations 会被反映。
- 不同 provider event 只有在 incoming
period_end晚于现有 row 时才应用。
这样 Apple 购买不会缩短仍有效的 Creem desktop plan,反之亦然。
Paywall packages
应用不带产品 ID 配置。app/paywall.tsx 渲染
offerings.current.availablePackages 中存在的 packages,并按以下顺序排序:
Monthly -> Annual -> LifetimeMonthly 和 Annual 在 analytics 中标记为 tier: "pro";Lifetime 标记为
tier: "lifetime"。classifyPurchase() 的值只用于 purchase completed
analytics event,不参与门禁。
Paywall 会在 Annual 存在时默认选择 Annual;如果 RevenueCat 返回 price-per-month
数据,会显示年度节省比例。购买或恢复成功后会调用 entitlement.pollAfterPurchase(),让 UI 等待 webhook 写入。
单 tier 模型
starter 只有一个 Pro entitlement 和一个 boolean gate:
const { isEntitled } = useEntitlement();如果要添加真正的 tiers,请先改数据模型:移除或放宽
subscriptions.user_id 唯一假设,引入 product-to-feature 映射,并从
EntitlementProvider 暴露 tier-aware helpers。不要一开始就在 UI 组件中按
RevenueCat product ID 分支;那会漏掉 desktop、manual 或未来的服务端 grant。
