PAYATHON 2026

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。

因此,以下两种情况在客户端上可能产生完全相同的结果:

  1. 当前 Play 账号从未购买过这些订阅。
  2. 当前 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,并将它关联到一个可在重装后恢复的身份。典型流程如下:

  1. 客户端完成首次购买后,将 purchaseToken 发送给后端。
  2. 后端通过 Google Play Developer API 验证购买,不直接信任客户端提交的数据。
  3. 后端保存 token、产品、应用账号和当前订阅状态。
  4. 使用 Real-time Developer Notifications 跟踪暂停、恢复、续订、取消和过期事件。
  5. 用户购买另一个套餐前,客户端向后端发起 preflight 检查。
  6. 如果后端发现 paused 或 active subscription,就返回对应的 token。
  7. 客户端将该 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。

备注:内容仅供参考。