Android Play Billing legacy 模型如何检测 paused subscription 并避免重复
结论
在这些约束下,仅靠设备端的 Google Play Billing Library,无法可靠防止 paused subscription 与新订阅同时存在。
原因如下:
queryPurchasesAsync()不会返回已进入 paused 状态的订阅。空列表只表示客户端当前查不到有效购买,不能证明该 Play 账号没有暂停中的订阅。- Billing Library 没有用于枚举当前 Play 账号 paused subscriptions 的客户端 API,也没有单独的”恢复购买”API来补足这部分信息。
- 客户端无法通过官方支持的可靠接口,按 SKU 找回当前暂停中订阅的
purchaseToken。 - Google Play Developer API 可以查询 paused 状态,但查询时仍需提供已有的
purchaseToken,不能按 Google 账号或 SKU 反向枚举某个用户的订阅。 - 如果没有持久身份,后端也没有保存历史 token,就无法严格保证”两个套餐永不并存”。
这不是 legacy subscription 的参数配置问题,而是客户端购买查询接口本身的能力限制。
为什么 queryPurchasesAsync() 不够
Google 对暂停订阅生命周期的说明明确提到,订阅进入暂停期后,queryPurchasesAsync() 不会返回该订阅。暂停结束并恢复计费后,它才可能重新出现在活动购买查询结果中。
官方说明参见:Subscription lifecycle: Paused subscriptions。
因此,以下两种情况在客户端上可能产生完全相同的结果:
- 当前 Play 账号从未购买过这些订阅。
- 当前 Play 账号有一个暂停中的订阅。
也就是说:
purchasesResult.purchasesList.isEmpty()
它只能表示”当前没有查询到 active purchase”,不能表示”现在可以安全创建新订阅”。
客户端逻辑至少要在语义上区分这两个概念:
sealed interface SubscriptionCheck {
data class Active(val purchase: Purchase) : SubscriptionCheck
data object NoActivePurchaseVisible : SubscriptionCheck
}
这里的 NoActivePurchaseVisible 表示状态未知,并非已经确认的 NoSubscription。
没有额外的 restore API
Google Play Billing 中通常所说的”恢复购买”,并不是一个可以查询全部历史订阅状态的独立操作。客户端恢复 entitlement 的标准方式,仍是在建立 Billing 连接后调用 queryPurchasesAsync(),并在应用回到前台时重新查询。
paused subscription 原本就不会出现在这项查询中。因此,重复调用、重新连接 BillingClient、重启或重新安装应用,都不会让暂停中的订阅变成可见的活动购买。
某些旧版 Billing Library 曾提供 queryPurchaseHistoryAsync()。它返回购买历史记录,而不是订阅的当前状态。即使查询到了带有 purchaseToken 的 PurchaseHistoryRecord,客户端仍无法据此可靠判断:
- token 对应的订阅目前是否 paused;
- 它是否已经 expired、revoked 或被替换;
- 它是否就是本次需要替换的当前订阅;
- 历史记录是否完整,能否用于互斥判断。
因此,purchase history 既不能作为 entitlement 的判断依据,也无法支持严格的互斥规则。使用新版 Billing Library 时,应以当前版本文档为准,不要围绕已经废弃或移除的历史查询接口设计新的关键流程。
无法按 Play 账号反查旧 token
SubscriptionUpdateParams#setOldPurchaseToken(...) 要求调用方事先知道被替换订阅的 token:
val updateParams =
BillingFlowParams.SubscriptionUpdateParams.newBuilder()
.setOldPurchaseToken(oldPurchaseToken)
.build()
Billing Library 不支持下面这种查询:
当前 Google Play 账号 + packageName + SKU
-> 查找 paused subscription
-> 返回 purchaseToken
客户端也无法取得可供后端使用的 Google Play 账号 ID、邮箱或其他稳定标识。
Google Play Developer API 的订阅查询同样以 token 为入口。以 purchases.subscriptionsv2.get 为例,请求路径中必须包含 purchase token:
applications/{packageName}/purchases/subscriptionsv2/tokens/{token}
在 token 已知的情况下,它可以确认订阅状态,包括暂停等生命周期状态,但不能按用户、SKU 或 Google 账号列出全部订阅。参见 purchases.subscriptionsv2.get。
因此,Developer API 可以回答:
“这个 token 当前是什么状态?”
但无法回答:
“这个 Play 账号是否有一个我不知道 token 的暂停订阅?”
可行的完整方案
如果必须严格保证套餐互斥,就要在首次购买时保存 token,并将它关联到一个可在重装后恢复的身份。典型流程如下:
- 客户端完成首次购买后,将
purchaseToken发送给后端。 - 后端通过 Google Play Developer API 验证购买,不直接信任客户端提交的数据。
- 后端保存 token、产品、应用账号和当前订阅状态。
- 使用 Real-time Developer Notifications 跟踪暂停、恢复、续订、取消和过期事件。
- 用户购买另一个套餐前,客户端向后端发起 preflight 检查。
- 如果后端发现 paused 或 active subscription,就返回对应的 token。
- 客户端将该 token 传给
setOldPurchaseToken(...),通过订阅替换流程购买新套餐。
简化后的客户端决策可以写成:
when (val result = queryActiveSubscriptions()) {
is SubscriptionCheck.Active -> {
launchReplacement(
newProduct = selectedProduct,
oldPurchaseToken = result.purchase.purchaseToken
)
}
SubscriptionCheck.NoActivePurchaseVisible -> {
when (val preflight = backend.checkSubscription()) {
is Preflight.ActiveOrPaused -> {
launchReplacement(
newProduct = selectedProduct,
oldPurchaseToken = preflight.purchaseToken
)
}
Preflight.NoExistingSubscription -> {
launchNewPurchase(selectedProduct)
}
Preflight.Unknown -> {
showUnableToVerifySubscription()
}
}
}
}
Real-time Developer Notifications 会在订阅状态变化时提供相关购买 token,适合用来维护后端状态。但它无法解决另一个问题:新安装怎样对应到过去的匿名用户。参见 Real-time Developer Notifications reference。
登录 ID 不必等于 Google Play 账号
应用账号与实际付款的 Google Play 账号确实不是同一个身份,但应用仍然可以建立自己的 entitlement 体系。
应用账号不是用来推断用户的 Google 账号,而是为了让同一用户在重新安装、更换设备或清除数据后,仍能找到之前已经验证并保存的 purchase token。购买是否有效,仍由 Google Play Developer API 根据 token 验证。
实现时还要防范以下情况:
- 一个应用账号在不同设备上使用不同 Play 账号购买;
- 多个应用账号尝试绑定同一个 purchase token;
- 客户端伪造 token 或重放旧 token;
- 用户切换 Play Store 账号后发起新的购买。
setObfuscatedAccountId(...) 有助于关联应用侧账号并进行欺诈检测,但它不会暴露 Google Play 账号,也不能让客户端反查 paused subscription。该 ID 必须来自可以恢复的稳定账号。每次安装时随机生成的 UUID 无法解决重装后的关联问题。
如果坚持完全无登录、无后端
此时只能采取 best-effort 措施,例如在本地保存最近一次 token,并在 token 仍然存在时尝试订阅替换流程。但以下任何一种情况都会使该方案失效:
- 卸载后重新安装;
- 清除应用数据;
- 更换设备;
- 本地存储损坏;
- 在另一台设备上购买或暂停订阅。
此外,将未经状态验证的历史 token 传给 setOldPurchaseToken(...),可能导致购买流程失败,也不能证明该 token 当前仍对应 paused subscription。
如果”永不同时持有两个套餐”是硬性约束,那么在无法确认状态时只能 fail closed,也就是不允许发起新购买。但真正的新用户与暂停订阅用户在客户端上都可能表现为空列表,这样也会阻止新用户购买,通常不具备实际可用性。
所以,在 legacy、pause enabled、无稳定身份、无历史 token,且只能使用客户端 Billing APIs 的条件下:
- 无法可靠检测 paused subscription;
- 无法可靠找回其 purchase token;
- 无法保证两个不同订阅 SKU 永不并存。
要实现严格互斥,必须放宽至少一项约束:引入可恢复的应用身份,并在后端保存 token;或者调整产品和业务模型,不再让并行订阅的处理依赖客户端预先发现 paused token。
备注:内容仅供参考。
