PAYATHON 2026

billingClient.queryPurchases 返回已取消订阅

支付小周

结论

可以继续使用 Play Billing Library,无需回退到旧版 In-App Billing API。

对于出现该问题的旧版实现,可以先调用 queryPurchaseHistoryAsync,让客户端与 Google Play 同步,然后再次调用 queryPurchases,读取更新后的本地缓存。不要直接通过购买历史判断用户当前是否享有订阅权益,因为历史记录也会包含已经取消或到期的订阅。

在正式环境中,客户端查询结果只适合用来及时更新界面。订阅权益是否仍然有效,应由服务端使用购买令牌向 Google Play Developer API 验证。

原因

queryPurchases 返回 Google Play 本地缓存中的“已拥有购买项”。在相关版本中,这个调用不会主动发起网络同步。因此,即使订阅已经到期,本地缓存也可能暂时保留旧记录。

“取消订阅”和“订阅到期”也不是同一个状态:

  • 用户取消自动续订后,在当前计费周期结束前通常仍然享有权益。
  • Purchase.getPurchaseState() 表示购买交易是否完成,例如 PURCHASED 或 PENDING,并不表示完整的订阅生命周期。
  • queryPurchaseHistoryAsync 返回的是购买历史。某个商品出现在历史记录中,不代表对应订阅目前仍然有效。

因此,不能仅凭购买历史中是否存在某个 SKU 来判断订阅是否有效。

客户端处理方式

如果使用的是带有同步 queryPurchases 方法的旧版 Billing Library,可以先刷新购买历史,再查询当前购买项:

billingClient.queryPurchaseHistoryAsync(
        BillingClient.SkuType.SUBS,
        (billingResult, purchaseHistoryRecordList) -> {
            if (billingResult.getResponseCode()
                    != BillingClient.BillingResponseCode.OK) {
                return;
            }

            Purchase.PurchasesResult purchasesResult =
                    billingClient.queryPurchases(BillingClient.SkuType.SUBS);

            if (purchasesResult.getResponseCode()
                    != BillingClient.BillingResponseCode.OK) {
                return;
            }

            List<Purchase> purchases = purchasesResult.getPurchasesList();

            boolean hasSubscription = purchases != null
                    && purchases.stream().anyMatch(
                            purchase -> purchase.getSkus().contains(SUBSCRIPTION_SKU)
                                    && purchase.getPurchaseState()
                                    == Purchase.PurchaseState.PURCHASED
                    );

            updateSubscriptionUi(hasSubscription);
        }
);

这段代码适用于题目涉及的旧接口。新版 Billing Library 的类型和方法名称可能已经变为 ProductType.SUBS、queryPurchasesAsync、QueryPurchasesParams 和 Purchase.getProducts(),具体应以项目所使用版本的 API 为准。

新版接口的基本查询方式如下:

QueryPurchasesParams params = QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.SUBS)
        .build();

billingClient.queryPurchasesAsync(
        params,
        (billingResult, purchases) -> {
            if (billingResult.getResponseCode()
                    != BillingClient.BillingResponseCode.OK) {
                return;
            }

            boolean hasPurchasedItem = purchases.stream().anyMatch(
                    purchase -> purchase.getProducts().contains(SUBSCRIPTION_PRODUCT_ID)
                            && purchase.getPurchaseState()
                            == Purchase.PurchaseState.PURCHASED
            );

            updateSubscriptionUi(hasPurchasedItem);
        }
);

即使客户端返回 PURCHASED,这个结果也不能作为安全可靠的订阅状态依据,因为客户端数据可能有延迟,也可能被篡改。

推荐的生产方案

应用完成购买后,将以下信息发送到自己的服务端:

  • purchaseToken
  • 商品 ID
  • 应用包名
  • 当前登录用户的内部账号标识

服务端通过 Google Play Developer API 查询该购买令牌对应的订阅状态,再根据返回的到期时间、取消信息和支付状态决定是否授予权益。

客户端可以采用以下流程:

客户端查询购买项
        ↓
取得 purchaseToken
        ↓
提交给业务服务端
        ↓
服务端向 Google Play 验证
        ↓
服务端返回最终权益状态

如果客户端没有查询到购买项,也不要仅凭这一次结果永久撤销服务端权益。可以让服务端再次验证已有的 purchaseToken,或者根据 Google Play 的订阅状态通知更新权益。

注意事项

  • queryPurchaseHistoryAsync 可以用来触发同步或恢复历史购买,但不适合直接判断当前权益。
  • 用户取消自动续订后,订阅通常要到当前周期结束才会失效,不能在用户执行“取消”操作时立即视为到期。
  • 不要把 PurchaseState.PURCHASED 当作 SUBSCRIBED。它描述的是购买交易状态,不是完整的订阅状态。
  • 重新连接 BillingClient、再次查询或等待缓存更新,可以缓解客户端的显示问题,但不能替代服务端验证。
  • 无需通过旧版 In-App Billing API 绕过 Play Billing Library。旧接口同样无法提供可靠的服务端订阅校验,还会增加兼容和维护成本。
  • 如果应用完全没有服务端,可以使用刷新后的 queryPurchases 做有限的本地判断,但需要接受状态延迟和无法可靠防止篡改的问题。

备注:内容仅供参考。