使用 in-app billing 时如何同步订阅状态?
结论
后端可以定时查询 App Store 和 Google Play 的订阅状态,但不能只靠每天运行一次的 cron job。比较可靠的做法是:
- 购买完成后,客户端将交易标识或 purchase token 发给后端。
- 后端向对应商店验证交易,并保存订阅状态。
- 通过 App Store Server Notifications V2 和 Google Play Real-time Developer Notifications(RTDN) 接收状态变化。
- 收到通知后,调用商店服务端 API 查询最新的权威状态。
- 用定时任务补偿对账,处理通知丢失、服务中断等问题。
- 客户端每次启动、登录或访问付费功能时,都从应用后端获取权益,不信任本地保存的购买状态。
需要注意,“取消订阅”通常只是关闭自动续订,权益不会立即失效。用户一般仍可使用到当前计费周期结束。退款、撤销或商店主动终止订阅则可能导致权益立即失效,需要分别处理。
推荐架构
订阅权益应绑定到应用自己的用户账号,不能绑定到设备、Apple ID 或 Google 账号。
Android / iOS 客户端
│
│ purchase token / transaction ID
▼
应用后端
│
├── Google Play Developer API
├── App Store Server API
│
├── Google RTDN
└── App Store Server Notifications V2
│
▼
统一的订阅与权益记录
无论用户在哪个平台登录,客户端都只向后端查询权益:
GET /api/me/entitlements
Authorization: Bearer <access-token>
后端返回统一后的权益,例如:
{
"premium": true,
"expiresAt": "2026-10-15T08:00:00Z",
"source": "google_play",
"willRenew": false
}
其中,premium 表示用户当前能否使用付费功能,willRenew 表示订阅到期后是否会续订。这两个含义不能合并到同一个字段中。
购买后的处理流程
客户端购买成功后,不能直接向后端写入 subscriptionActive=true。客户端提交的数据可能被伪造,后端必须向商店验证。
Android 通常上传:
packageNameproductIdpurchaseToken- 当前登录用户的内部 ID
iOS 通常上传:
transactionId或originalTransactionId- 当前登录用户的内部 ID
- 必要时上传商店返回的签名交易数据
验证成功后,后端再将商店订阅与内部用户关联。数据库可以采用类似的结构:
subscriptions
-------------
user_id
store // apple 或 google
product_id
original_transaction_id // Apple 使用
purchase_token // Google 使用
status
expires_at
auto_renew_enabled
environment // sandbox 或 production
last_verified_at
raw_store_status
purchaseToken、交易 ID 等字段应设置唯一约束,避免同一笔购买被绑定到多个应用账号。
Google Play 的实时同步
Google Play 提供 Real-time Developer Notifications。订阅续订、取消、进入宽限期、账号保留、恢复、暂停、撤销或到期时,Google 会通过 Google Cloud Pub/Sub 发送通知。
RTDN 只表示”状态发生了变化”,后端不能根据通知类型直接修改最终权益。收到通知后,应提取 purchaseToken,再调用 Google Play Developer API,例如 purchases.subscriptionsv2.get,获取当前的权威状态。
示意代码:
[HttpPost("webhooks/google-play")]
public async Task<IActionResult> GooglePlayWebhook(
[FromBody] PubSubEnvelope envelope)
{
// 实际项目还应验证请求来源和 Pub/Sub 身份。
var json = Encoding.UTF8.GetString(
Convert.FromBase64String(envelope.Message.Data));
var notification =
JsonSerializer.Deserialize<GooglePlayNotification>(json);
var token = notification?.SubscriptionNotification?.PurchaseToken;
if (string.IsNullOrWhiteSpace(token))
return Ok();
// RTDN 只负责触发同步,最终状态从 Google Play API 获取。
var storeState = await googlePlayService.GetSubscriptionAsync(
notification.PackageName,
token);
await subscriptionService.UpsertGoogleSubscriptionAsync(
token,
storeState);
return Ok();
}
整个处理过程必须具有幂等性,因为 Pub/Sub 可能重复投递。同一条通知即使收到多次,也不能重复创建订阅或错误延长有效期。
对于新购买,还要按照 Google Play Billing 的要求及时完成 acknowledgement。交易是否已经验证、是否已经完成 acknowledgement、用户当前是否拥有权益,是三个不同的问题。
Apple App Store 的实时同步
Apple 提供 App Store Server Notifications V2。订阅续订、续订失败、自动续订状态改变、退款、撤销、宽限期变化和到期等事件发生时,Apple 可以主动向后端配置的 HTTPS 地址发送通知。
V2 通知包含 signedPayload,它是 JWS 数据。后端必须验证签名和证书链,并检查环境、Bundle ID 等信息,不能只对 payload 进行 Base64 解码后直接信任其中的数据。
[HttpPost("webhooks/app-store")]
public async Task<IActionResult> AppStoreWebhook(
[FromBody] AppStoreNotificationBody body)
{
// VerifyAndDecodeAsync 应验证 JWS 签名、证书链、
// Bundle ID、环境以及相关时间字段。
var notification =
await appStoreService.VerifyAndDecodeAsync(body.SignedPayload);
var transactionId =
notification.Data?.SignedTransactionInfo?.TransactionId;
if (string.IsNullOrWhiteSpace(transactionId))
return Ok();
// 收到通知后查询 App Store Server API,
// 以服务端返回的最新订阅状态更新本地记录。
var storeState =
await appStoreService.GetSubscriptionStatusAsync(transactionId);
await subscriptionService.UpsertAppleSubscriptionAsync(
transactionId,
storeState);
return Ok();
}
Apple 通知也可能重复、延迟或乱序到达。更新数据库时,应比较交易时间、到期时间或通知签发时间,不能简单地用最后收到的 HTTP 请求覆盖现有状态。
正确判断用户是否仍有权益
不要只用一个 isSubscribed 布尔字段记录订阅。至少需要保存:
- 当前计费周期结束时间;
- 是否开启自动续订;
- 是否处于宽限期;
- 是否处于 billing retry 或 account hold;
- 是否已经退款或撤销;
- 商店返回的当前生命周期状态;
- 最近一次成功验证时间。
不同商店的状态可以统一为应用内部的权益模型:
public static bool HasPremiumAccess(SubscriptionState subscription, DateTimeOffset now)
{
if (subscription.IsRevoked || subscription.IsRefunded)
return false;
if (subscription.ExpiresAt > now)
return true;
// 是否允许宽限期内继续使用,应根据产品策略和商店状态决定。
if (subscription.IsInGracePeriod &&
subscription.GracePeriodExpiresAt is { } graceEnd &&
graceEnd > now)
{
return true;
}
return false;
}
例如,用户在 Google Play 中关闭了自动续订,但当前计费周期还剩十天:
{
"status": "active",
"autoRenewEnabled": false,
"expiresAt": "2026-09-25T12:00:00Z"
}
此时不能立即关闭功能。只有到达 expiresAt,并且订阅没有成功续订,后端才能移除权益。
cron job 是否还需要
需要,但它更适合用来补偿异常,不应承担主要的同步工作。
定时任务可以检查:
- 当前仍然有效的订阅;
- 正处于宽限期、重试期或 account hold 的订阅;
- 即将在近期到期的订阅;
- 超过一定时间没有收到通知或没有验证过的订阅;
- Webhook 处理失败并进入重试队列的记录。
没有必要每天扫描所有历史订阅。可以根据 expires_at、status 和 last_verified_at 筛选需要复核的记录:
SELECT *
FROM subscriptions
WHERE
status IN ('active', 'grace_period', 'billing_retry', 'on_hold')
OR expires_at BETWEEN CURRENT_TIMESTAMP
AND CURRENT_TIMESTAMP + INTERVAL '2 days'
OR last_verified_at < CURRENT_TIMESTAMP - INTERVAL '1 day';
具体的查询频率取决于系统可以接受的状态延迟、订阅数量和商店 API 配额。每天查询一次在技术上可行,但状态更新最多可能延迟近一天,也很难及时处理退款或立即撤销。更稳妥的方案是同时使用实时通知和定时对账。
Webhook 的可靠性要求
服务端通知处理需要满足以下条件:
- 使用 HTTPS;
- 验证 Apple 签名或 Google Pub/Sub 请求身份;
- 尽快返回成功响应,将耗时工作放入队列;
- 支持重复投递和幂等更新;
- 保存事件 ID、交易 ID 和处理结果;
- 自动重试失败事件;
- 区分 sandbox 与 production;
- 保存原始通知,同时避免在日志中泄露凭证或用户隐私;
- 不根据通知到达顺序判断订阅状态;
- 收到事件后重新查询商店 API。
常见的处理流程如下:
接收通知
→ 验证来源和签名
→ 记录事件并去重
→ 放入消息队列
→ 查询商店最新状态
→ 更新统一订阅记录
→ 重新计算用户权益
客户端也应触发按需刷新
除了实时通知和定时任务,还可以在以下时机重新验证订阅:
- 用户完成购买;
- 用户执行 Restore Purchases;
- 用户登录新设备;
- 应用从后台恢复;
- 后端发现订阅状态已经过期或长时间未验证;
- 用户反馈已经购买,但功能仍未解锁。
客户端触发的刷新只能作为补充。用户可能再也不会打开最初购买订阅的平台应用,因此同步订阅状态的责任最终仍在后端。
跨平台使用时的注意事项
要跨平台共享权益,用户必须登录同一个应用账号,商店交易也要与该账号绑定。同时还要处理这些情况:
- 用户购买时尚未登录;
- 同一商店账号尝试向多个应用账号恢复购买;
- 一个应用账号同时存在 Apple 和 Google 订阅;
- 用户在两个平台重复购买订阅;
- 家庭共享、退款、撤销和价格变更;
- Apple 与 Google 的订阅周期不完全一致;
- 用户删除账号后,商店订阅仍可能继续续费。
如果同一用户同时拥有两份有效订阅,可以继续提供权益,但应提醒用户可能被重复扣费。不要擅自将一个商店的订阅”迁移”成另一个商店的订阅。后端共享的是应用内权益,不会改变原有的支付关系。
如果允许用户使用其他平台购买的订阅解锁当前平台功能,还需要确认应用类型、购买入口和页面文案符合 Apple、Google 当时有效的审核与支付政策。商店规则可能调整,上线前应根据应用所属类别核对最新的官方要求。
App Store 和 Google Play 是订阅支付状态的权威来源,应用后端则统一管理跨平台权益。实时通知保证状态更新及时,商店 API 验证保证状态准确,定时对账用于处理遗漏和故障。
备注:内容仅供参考。
