后端与接口
在服务端消费 RevenueCat Webhook…
当后端需要接收 RevenueCat Webhook 同步订阅权益时,用统一事件 schema 做验签、`event.id` 幂等和按 `event.type` 分发处理。
🧪 尚未被验证(还没有小呱复用过的记录,不代表不好)
GDI C 38/100
进化评分(GDI)
综合排序依据 —— 四个维度的加权结果,让你看出「为什么这条排在前面」
内在质量 55/100 ×0.35
使用度 0/100 ×0.30
口碑 50/100 ×0.20
新鲜度 60/100 ×0.15
适用场景
在服务端消费 RevenueCat We特定
解决思路
- 配置一个服务端 HTTPS endpoint,接受 POST 且请求体为 JSON。
- 在 RevenueCat dashboard 的 Integrations -> Webhooks 获取 webhook secret。
- 准备持久化存储,用于记录已处理的 event ID 以及每个 `app_user_id` 的权益状态。
- 不要在后端维护 product 到 entitlement 的映射表;RevenueCat 已把产品映射到权益,服务端按 `event.type` 分支并读取 `event.entitlement_ids`。
- 按 RevenueCat 统一外层结构解析事件:顶层有 `api_version`,业务数据在 `event` 对象;`event` 包含 `id`、`type`、`app_user_id`、`product_id`、`period_type`、`purchased_at_ms`、`expiration_at_ms`、`store`、`environment`、`entitlement_ids`、`transaction_id`。
- `INITIAL_PURCHASE`:在该用户和产品的第一笔付费交易时,按 `entitlement_ids` 授予权益。
- `RENEWAL`:订阅续费(包括 `EXPIRATION` 后重新订阅)时,按 `entitlement_ids` 授予或延长权益。
- `CANCELLATION`:用户关闭自动续费时,在 `expiration_at_ms` 调度撤销;不要立即撤销。
- `UNCANCELLATION`:用户在到期前重新开启自动续费时,取消任何已调度的撤销,保持权益有效。
- `EXPIRATION`:订阅实际结束时,立即撤销权益。
- `BILLING_ISSUE`:支付失败时标记账户;不要立即撤销;RevenueCat 在恢复失败时会发送 `EXPIRATION`。
- `PRODUCT_CHANGE`:用户切换套餐(升级、降级或交叉升级)时,更新记录中的 product;权益状态跟随 `entitlement_ids`。
- `SUBSCRIBER_ALIAS`:两个 `app_user_id` 合并为一个身份时,合并别名 ID 的本地记录。
- `TRANSFER`:交易从一个 `app_user_id` 转移到另一个时,把权益从旧 ID 移到新 ID。
- 把 `CANCELLATION` 当作意图信号而非访问变更;立即撤销是 bug,会删除用户仍拥有的付费访问。
- 把 `EXPIRATION` 当作访问变更;这时才撤销。
- 记住 `EXPIRATION` 后的重新订阅触发 `RENEWAL` 而不是 `INITIAL_PURCHASE`;`RENEWAL` 分支必须能对当前权益已撤销的用户安全运行,因此必须授予权益,不能只延长。
- 把 `BILLING_ISSUE` 当作非撤销事件;在 `BILLING_ISSUE` 上撤销会切断仍处于 Google Play grace period 或 account hold 的用户。
- 在服务端注册 POST 路由 `/revenuecat/webhook`,接收文本 body。
- 从请求头读取 `X-RevenueCat-Signature`。
- 调用 `verifySignature(body, signature, webhookSecret)` 验证签名;失败返回 `HttpStatusCode.Unauthorized` 并结束。
- 签名通过后用 JSON 解码 `RevenueCatEnvelope`,取出 `event`,调用 `handleEvent(event)`,成功后返回 `HttpStatusCode.OK`。
- 事件一持久化就尽快返回 2xx;如果处理慢,先入队并确认;慢 handler 会导致重试和重复投递。
- 用 `event.id` 作为幂等键去重:调用 `processedEvents.insertIfAbsent(event.id)`,只有首次插入成功才 `dispatch(event)`。
- `insertIfAbsent` 在存储层必须原子化;用 `event_id` 唯一索引加吞掉重复键错误的 insert 可以实现。
- 把所有下游写入与 event ID 插入放在同一事务中,避免 handler 中途崩溃后留下已标记但未应用的事件。
- 如果 `event.id` 已处理,直接落空但仍返回 200。
- 按 `e.type` 分发:`INITIAL_PURCHASE`、`RENEWAL`、`UNCANCELLATION` 调用 `db.grantEntitlements(e.appUserId, e.entitlementIds, e.expirationAtMs)`。
- 按 `e.type` 分发:`CANCELLATION` 调用 `db.scheduleRevocation(e.appUserId, e.entitlementIds, e.expirationAtMs)`。
- 按 `e.type` 分发:`EXPIRATION` 调用 `db.revokeEntitlements(e.appUserId, e.entitlementIds)`。
- 按 `e.type` 分发:`BILLING_ISSUE` 调用 `db.flagBillingIssue(e.appUserId)`。
- 按 `e.type` 分发:`PRODUCT_CHANGE` 调用 `db.updateProduct(e.appUserId, e.productId, e.entitlementIds)`。
- 按 `e.type` 分发:`SUBSCRIBER_ALIAS`、`TRANSFER` 调用 `db.mergeIdentity(e)`。
- 按 `e.type` 分发:其他类型走 `else -> Unit`,不做处理。
- `RENEWAL` 上的 `grantEntitlements` 必须幂等且可累加,使 `EXPIRATION` 后重新订阅能恢复访问。
- `scheduleRevocation` 按 `(app_user_id, entitlement_id)` 存储一个在 `expiration_at_ms` 触发的 pending job。
- 如果 `UNCANCELLATION` 先到,取消该 pending job。
- 如果 `EXPIRATION` 先到,让 `EXPIRATION` handler 执行撤销并丢弃 pending job。
- `CANCELLATION` payload 中,用户保留 `pro_access` 直到 `1702592000000`;按该时间戳调度撤销。
- `EXPIRATION` payload 中,处理事件后立即撤销 `user_12345` 的 `pro_access`。
- 对已失效后重新订阅的 `RENEWAL` payload,`RENEWAL` 分支必须授予权益而不是假设权益已存在;如果只延长已有 expiry,重新订阅用户会继续被锁。
- 验证签名拒绝缺失或错误的 `X-RevenueCat-Signature`。
- 验证相同 `event.id` 的重放事件是 no-op 且仍返回 200。
- 验证 `CANCELLATION` 不撤销访问;用户保留权益直到 `expiration_at_ms`。
- 验证 `EXPIRATION` 对 `entitlement_ids` 中的 ID 执行撤销。
- 验证 `EXPIRATION` 后到达的 `RENEWAL` 为同一 `app_user_id` 恢复访问。
- 验证 `BILLING_ISSUE` 只标记账户而不撤销。
- 验证 handler 即使下游异步,也在重试窗口内返回 2xx。
适用边界 · 注意事项
- 不使用 RevenueCat 作为订阅/权益来源,或无法访问 RevenueCat dashboard 获取 webhook secret。
- 没有服务端 HTTPS POST endpoint、持久化存储和可靠事务,无法安全验签、幂等去重及保存权益状态。
- 只需客户端本地展示订阅,不要求服务端权威权益同步。