PAYATHON 2026

Android Google Play 如何检查用户是否有有效订阅?

支付阿杰

明确结论

queryPurchases() 返回当前仍属于用户的有效购买权益。订阅即使已经 acknowledge,也不应因此消失。这个接口并非只返回尚未处理的购买。

queryPurchaseHistoryAsync() 返回历史购买记录,不能用来判断订阅当前是否有效。记录中的订阅可能已经取消、过期或退款。

因此:

  1. internal testing 与正式发布的基本查询语义一致,正式发布后不会自动变成另一种行为。
  2. 客户端可以通过 queryPurchases() 恢复当前订阅权益,新版本对应 queryPurchasesAsync()。
  3. 安全性要求较高的应用应把购买凭据交给服务端,再通过 Google Play Developer API 验证订阅状态。
  4. 不要把 SharedPreferences、cookie 或本地保存的到期时间作为判断订阅状态的唯一依据。

目前这种“清除数据后只返回一次”的现象不符合正常的订阅查询行为。更可能的原因是代码没有检查 BillingResult、查询时 BillingClient 尚未连接、购买和查询使用的账号或安装来源不一致,或者业务代码在第一次处理购买后错误地忽略了这条记录。

两个查询接口的区别

queryPurchases():查询当前权益

在 Billing Library 2/3 中:

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

这个接口用于查询当前 Google Play 账号拥有的订阅。只要订阅仍然有效,完成 acknowledge 后,购买记录仍应继续返回。

下面几种操作的含义并不相同:

  • acknowledge:确认权益已经授予,防止购买被自动退款。
  • consume:消耗一次性商品,使其能够再次购买。
  • 查询购买:恢复或同步当前权益。

确认购买不等于消耗购买,订阅也不应按消耗型商品处理。

queryPurchaseHistoryAsync():查询历史记录

这个接口回答的是“这个账号过去购买过什么”,而不是“现在是否仍有权益”。

即使接口返回了某条订阅记录,也不能仅凭这条记录认定订阅有效,因为它可能已经:

  • 到期;
  • 被取消,并且已经超过有效期;
  • 退款或撤销;
  • 被其他订阅替换;
  • 进入暂停、宽限期或账号保留等状态。

购买历史可以用于辅助展示或找回购买 token,但不适合作为授权依据。

首先检查完整的查询结果

原代码直接读取 getPurchasesList(),没有检查响应码,这会掩盖真正的问题。应先记录并处理 BillingResult:

public void checkSubscriptions() {
    if (!billingClient.isReady()) {
        Log.e(TAG, "BillingClient is not ready");
        return;
    }

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

    BillingResult billingResult = result.getBillingResult();

    Log.d(TAG, "responseCode=" + billingResult.getResponseCode()
            + ", debugMessage=" + billingResult.getDebugMessage());

    if (billingResult.getResponseCode() != BillingClient.BillingResponseCode.OK) {
        Log.e(TAG, "Query failed: " + billingResult.getDebugMessage());
        return;
    }

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

    if (purchases == null || purchases.isEmpty()) {
        setSubscriptionEntitlement(false);
        return;
    }

    boolean entitled = false;

    for (Purchase purchase : purchases) {
        if (purchase.getPurchaseState() == Purchase.PurchaseState.PURCHASED
                && purchase.getSkus().contains("your_subscription_sku")) {
            entitled = true;
            handlePurchase(purchase);
        }
    }

    setSubscriptionEntitlement(entitled);
}

具体方法名会随 Billing Library 版本变化。例如,新版本使用 getProducts(),旧版本则使用 getSkus()。请以项目实际使用的依赖版本为准,不要混用不同版本的示例代码。

必须等 BillingClient 连接成功后再查询

查询应在 onBillingSetupFinished() 成功后执行。创建 BillingClient 并不代表它已经可以使用:

billingClient.startConnection(new BillingClientStateListener() {
    @Override
    public void onBillingSetupFinished(BillingResult billingResult) {
        if (billingResult.getResponseCode()
                == BillingClient.BillingResponseCode.OK) {
            checkSubscriptions();
        } else {
            Log.e(TAG, "Billing setup failed: "
                    + billingResult.getDebugMessage());
        }
    }

    @Override
    public void onBillingServiceDisconnected() {
        Log.w(TAG, "Billing service disconnected");
        // 可以采用退避策略重新连接。
    }
});

如果在连接完成前调用同步查询,不能把得到的结果视为“用户没有订阅”。

较新 Billing Library 的查询方式

较新的 Billing Library 使用异步接口,基本结构如下:

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

billingClient.queryPurchasesAsync(
        params,
        (billingResult, purchases) -> {
            if (billingResult.getResponseCode()
                    != BillingClient.BillingResponseCode.OK) {
                Log.e(TAG, "Query failed: "
                        + billingResult.getDebugMessage());
                return;
            }

            boolean entitled = false;

            for (Purchase purchase : purchases) {
                if (purchase.getPurchaseState()
                        == Purchase.PurchaseState.PURCHASED) {
                    entitled = true;
                    processPurchase(purchase);
                }
            }

            setSubscriptionEntitlement(entitled);
        }
);

不能为 PENDING 状态授予正式权益。只有购买状态为 PURCHASED,并且商品与应用预期的订阅产品匹配时,才能继续验证和授权。

正确处理并确认购买

无论是刚完成的购买,还是通过查询恢复的购买,都应进入同一个处理流程:

private void processPurchase(Purchase purchase) {
    if (purchase.getPurchaseState()
            != Purchase.PurchaseState.PURCHASED) {
        return;
    }

    verifyPurchaseWithBackend(purchase.getPurchaseToken());

    if (!purchase.isAcknowledged()) {
        AcknowledgePurchaseParams params =
                AcknowledgePurchaseParams.newBuilder()
                        .setPurchaseToken(purchase.getPurchaseToken())
                        .build();

        billingClient.acknowledgePurchase(params, billingResult -> {
            if (billingResult.getResponseCode()
                    != BillingClient.BillingResponseCode.OK) {
                Log.e(TAG, "Acknowledge failed: "
                        + billingResult.getDebugMessage());
            }
        });
    }
}

实际项目中,最好先让服务端验证购买,再授予权益并执行确认。确认操作必须具有幂等性。同一个 purchaseToken 即使被多次处理,也不应重复发放不可重复的奖励或引发错误。

推荐的可靠方案:服务端验证订阅

客户端返回的 Purchase 可以用来立即恢复界面状态,但客户端环境不能作为最终可信来源。更可靠的处理流程是:

  1. Android 客户端通过 Billing Library 获取 purchaseToken。
  2. 客户端把 purchaseToken、产品标识和应用账号标识发送给自己的服务端。
  3. 服务端使用 Google Play Developer API 查询订阅。
  4. 服务端根据订阅状态、到期时间、撤销情况和关联购买关系计算用户权益。
  5. 客户端从自己的服务端获取最终授权结果。
  6. 服务端通过 Real-time Developer Notifications 接收续订、取消、退款、暂停和恢复等变化,并在收到通知后重新查询 Google API。

服务端查询应优先使用适合当前订阅模型的 purchases.subscriptionsv2.get。旧项目中可能仍有 purchases.subscriptions.get,新实现不要直接照搬这个旧接口。

服务端通常需要保存这些核心数据:

appUserId
packageName
productId
purchaseToken
entitlementStatus
expiryTime
lastVerifiedAt

entitlementStatus 和 expiryTime 应来自服务端验证结果,不能由客户端根据购买时间自行推算。

为什么不能自行计算到期时间

不能简单使用下面的公式判断订阅是否有效:

purchaseTime + subscriptionPeriod

订阅生命周期可能涉及:

  • 自动续订;
  • 用户取消,但权益保留到当前周期结束;
  • 宽限期;
  • 账号保留;
  • 暂停与恢复;
  • 升级或降级;
  • 退款、撤销和拒付;
  • 测试订阅的加速续订与特殊生命周期。

本地记录还可能被清除或修改,也可能在更换设备后丢失。因此,SharedPreferences 只能缓存上一次验证结果,不能充当订阅凭据。

针对当前异常的排查顺序

建议按以下顺序检查:

  1. 查询是否发生在 onBillingSetupFinished() 返回 OK 之后。
  2. 是否每次都记录了 responseCode 和 debugMessage,而不是只观察列表是否为 null。
  3. 购买和查询是否使用同一个 Google Play 账号。
  4. 应用是否确实通过 Google Play 测试链接安装,之后有没有被 Android Studio 覆盖安装。
  5. 当前安装包的 packageName、签名是否与 Play Console 中的应用一致。
  6. 查询类型是否为 SUBS,商品 ID 是否与实际订阅产品一致。
  7. handlePurchase() 是否因为本地“已处理”标记而直接跳过整个订阅。
  8. 代码是否把“已经 acknowledged”误判成“无效”。
  9. 测试订阅是否因为测试周期较短而已经过期。
  10. Google Play 商店和 Google Play 服务是否可用。

尤其要检查下面这类逻辑:

if (wasProcessedLocally(purchase.getPurchaseToken())) {
    return;
}

这段逻辑可以防止一次性奖励被重复发放,却不能用于判断订阅是否仍然有效。每次启动应用时,都需要重新恢复或验证订阅权益。“这笔购买以前处理过”和“这笔订阅现在有效”是两个不同的问题。

对三个问题的直接回答

1. 正式发布后会不同吗?

不会。internal testing 和正式发布使用相同的核心购买查询语义。测试订阅的时间周期及部分生命周期表现可能不同,但有效订阅不应因为完成 acknowledge 而从当前购买查询中消失。

2. 应使用什么 API 检查当前订阅?

客户端使用 queryPurchases() 或当前版本的 queryPurchasesAsync() 获取当前购买权益,服务端使用 Google Play Developer API 验证 purchaseToken。如果应用涉及付费内容或跨设备账号体系,应以服务端的验证结果为准。

3. 是否应该把订阅状态保存到本地?

可以缓存,但不能只依赖本地状态。不要在首次 acknowledge 时自行保存一个到期时间,然后长期根据这个时间授权。正确做法是保存购买 token,并定期或在关键场景下交由服务端重新验证。本地缓存只用于离线容错和改善启动体验。

备注:内容仅供参考。