订阅
通过 RevenueCat 和 Supabase 读模型实现账号绑定的 Pro 权益。
Kotlin 模板通过 Google Play 和 RevenueCat 销售 Pro,但应用本身不会把
RevenueCat 当成权益真相。购买事件会写入共享的 Supabase subscriptions
表,应用门禁读取这条账号级记录。
提供什么
| Surface | Status | Implementation |
|---|---|---|
| RevenueCat SDK 生命周期 | Integrated | REVENUECAT_ANDROID_KEY 存在时由 PurchasesController 配置 RevenueCat |
| 账号身份 | Integrated | RevenueCat 使用 Supabase user UUID 作为 app_user_id 登录 |
| 权益状态 | Integrated | EntitlementController 通过 SubscriptionRepository 读取 public.subscriptions |
| 付费墙 | Integrated | PaywallScreen 渲染当前 offering 返回的 packages |
| 管理订阅 | Integrated | ManageSubscriptionScreen 展示 RevenueCat / Google Play 管理状态 |
| 兑换码 | Integrated | RedeemCodeScreen 打开 Google Play 兑换页面 |
| Pro 内容示例 | Removable demo | feature/guide/ 用 PremiumGate 锁住内置开发者指南 |
关键文件
权益流程
Google Play purchase
-> RevenueCat customer event
-> revenuecat-webhook Edge Function
-> public.subscriptions, provider = play_store
-> EntitlementController.state
-> Home license card, Me row, PremiumGate, guide locks关键点是账号绑定权益。PurchasesController 观察
SessionController.currentUser,调用 RevenueCat logIn(userId),并把
Supabase Auth UUID 作为 RevenueCat app_user_id。Webhook 会拒绝非 UUID 的
app_user_id,所以匿名 RevenueCat customer 不能解锁账号门禁内容。
EntitlementController 是应用侧权威。它读取当前用户的 subscriptions 行,
暴露 EntitlementState,登录后刷新,退出后清空,RevenueCat customer-info
更新后刷新,并在购买后轮询一小段时间等待 webhook 写入。
RevenueCat 仍然很重要:它提供 Google Play Billing UI、当前 offering、价格、 restore 流程、customer-info 更新和管理 URL。它不是决定 Pro 内容是否解锁的门禁。
商品模型
模板预期一个 RevenueCat entitlement:
const val PRO_ENTITLEMENT_ID = "pro"这个常量在 PurchasesController.Companion 中。如果你重命名 entitlement,要同步修改
这里和 RevenueCat 控制台。
推荐的 Google Play product id:
| Product id | Store type | Supabase product_type | Access model |
|---|---|---|---|
pro_monthly | 自动续订订阅,月付 base plan | month | active、trialing 或已取消但仍在已付费周期内时有权益 |
pro_yearly | 自动续订订阅,年付 base plan | year | 同样规则,周期为一年 |
pro_lifetime | 一次性应用内商品 | one_time | 激活后永久有权益 |
这些 product id 没有硬编码在 Android 应用里。RevenueCat offering 决定付费墙收到的 子集、价格、本地化文案和 package identifier。
付费墙行为
PaywallViewModel 读取当前 offering,并按下面顺序排序 packages:
- Monthly
- Annual
- Lifetime
- 其他 RevenueCat package type
如果 Annual 存在,会默认选中 Annual。否则选中排序后的第一个 package。UI 会渲染 offering 返回的任意子集,所以构建可以只提供月付/年付、只提供 Lifetime,或三者都提供。
PaywallScreen 的状态行为:
| State | 用户看到的行为 |
|---|---|
| RevenueCat 尚未 ready | Loading spinner |
| Supabase 权益已有效 | 已拥有 license card |
缺少 REVENUECAT_ANDROID_KEY | 提示当前构建无法使用应用内购买 |
| Offering 没有 packages | 可重试的加载错误块 |
| Packages 存在 | 选择 package、继续购买、restore、兑换码、法律链接 |
Annual package 会显示 "Most popular",并在月付和年付价格可计算出有效折扣时显示 savings。 Lifetime package 会显示 "Best value"。
被锁定的指南
应用内 Pro 示例是开发者指南:
GuideIndexScreen在用户未拥有权益时展示指南列表和锁图标。GuideDocumentScreen用PremiumGate包住文档正文。PremiumGate只有在EntitlementState.isEntitled()为 true 时显示内容。
这些指南属于可移除的 Showcase 分支。你可以把它们当作自己 Pro-only 功能的模式参考; 删除 Showcase tab 时也可以一起删除或替换。
权益规则
Subscription.isEntitled(now) 的规则刻意保持简单:
| Subscription status | Product type | Entitled when |
|---|---|---|
active / trialing | one_time | 永远 |
active / trialing | month / year | period_end 在未来 |
canceled | month / year | 仍处于已付费周期内 |
unpaid / expired / unknown | 任意 | 从不 |
未知 enum 值会解码为 Unknown,默认锁住,直到应用显式支持这个新值。
跨平台行归属
public.subscriptions 每个用户只有一行。RevenueCat webhook 对同 provider 的
Play Store 事件总是应用,因为 Play 对自己的续订、取消、账单问题和过期状态是权威。
如果当前行来自另一个 provider,只有 incoming event 延长 period_end 时才会接管。
这意味着较短的 Play Store 订阅不会缩短更长的 Creem 或 App Store 权益,其他平台的
较短事件也不会缩短当前有效的 Play Store 周期。
验证
- 添加
REVENUECAT_ANDROID_KEY,登录后确认 RevenueCat 使用 Supabase user UUID 登录。 - 打开付费墙,确认当前 offering 的 packages 能渲染。
- 移除 key、重新构建,确认付费墙显示 unavailable 信息而不是崩溃。
- 完成 sandbox purchase,确认解锁来自
subscriptions行,而不只是 RevenueCatCustomerInfo。 - 在 Play 测试环境取消订阅,确认访问会保留到
period_end。
