Android Google Play 如何检查用户是否有有效订阅?
明确结论
queryPurchases() 返回当前仍属于用户的有效购买权益。订阅即使已经 acknowledge,也不应因此消失。这个接口并非只返回尚未处理的购买。
queryPurchaseHistoryAsync() 返回历史购买记录,不能用来判断订阅当前是否有效。记录中的订阅可能已经取消、过期或退款。
因此:
- internal testing 与正式发布的基本查询语义一致,正式发布后不会自动变成另一种行为。
- 客户端可以通过
queryPurchases()恢复当前订阅权益,新版本对应queryPurchasesAsync()。 - 安全性要求较高的应用应把购买凭据交给服务端,再通过 Google Play Developer API 验证订阅状态。
- 不要把
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 可以用来立即恢复界面状态,但客户端环境不能作为最终可信来源。更可靠的处理流程是:
- Android 客户端通过 Billing Library 获取
purchaseToken。 - 客户端把
purchaseToken、产品标识和应用账号标识发送给自己的服务端。 - 服务端使用 Google Play Developer API 查询订阅。
- 服务端根据订阅状态、到期时间、撤销情况和关联购买关系计算用户权益。
- 客户端从自己的服务端获取最终授权结果。
- 服务端通过 Real-time Developer Notifications 接收续订、取消、退款、暂停和恢复等变化,并在收到通知后重新查询 Google API。
服务端查询应优先使用适合当前订阅模型的 purchases.subscriptionsv2.get。旧项目中可能仍有 purchases.subscriptions.get,新实现不要直接照搬这个旧接口。
服务端通常需要保存这些核心数据:
appUserId
packageName
productId
purchaseToken
entitlementStatus
expiryTime
lastVerifiedAt
entitlementStatus 和 expiryTime 应来自服务端验证结果,不能由客户端根据购买时间自行推算。
为什么不能自行计算到期时间
不能简单使用下面的公式判断订阅是否有效:
purchaseTime + subscriptionPeriod
订阅生命周期可能涉及:
- 自动续订;
- 用户取消,但权益保留到当前周期结束;
- 宽限期;
- 账号保留;
- 暂停与恢复;
- 升级或降级;
- 退款、撤销和拒付;
- 测试订阅的加速续订与特殊生命周期。
本地记录还可能被清除或修改,也可能在更换设备后丢失。因此,SharedPreferences 只能缓存上一次验证结果,不能充当订阅凭据。
针对当前异常的排查顺序
建议按以下顺序检查:
- 查询是否发生在
onBillingSetupFinished()返回OK之后。 - 是否每次都记录了
responseCode和debugMessage,而不是只观察列表是否为null。 - 购买和查询是否使用同一个 Google Play 账号。
- 应用是否确实通过 Google Play 测试链接安装,之后有没有被 Android Studio 覆盖安装。
- 当前安装包的
packageName、签名是否与 Play Console 中的应用一致。 - 查询类型是否为
SUBS,商品 ID 是否与实际订阅产品一致。 handlePurchase()是否因为本地“已处理”标记而直接跳过整个订阅。- 代码是否把“已经 acknowledged”误判成“无效”。
- 测试订阅是否因为测试周期较短而已经过期。
- 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,并定期或在关键场景下交由服务端重新验证。本地缓存只用于离线容错和改善启动体验。
备注:内容仅供参考。
