In-App Billing subscription 的取消与同步问题
结论
这些现象通常不能简单归因于应用尚未发布,OnPurchaseStateChange 也解决不了所有问题。可以这样理解:
- 取消订阅页面显示
Item not found,通常是因为应用、订阅商品或测试账号尚未满足 Google Play 的可见性条件。 - 订阅权益通常绑定 Google Account,而不是某一台设备,但购买状态事件不会保证同步推送到该账号的所有设备。
- 取消自动续订不代表订阅立即失效。用户一般仍可使用到当前计费周期结束,因此设备没有马上收到”已失效”通知,可能属于正常情况。
- 客户端应在应用启动、恢复前台、用户登录和进入付费功能时主动刷新购买状态。生产环境最好由服务端验证和维护权益,不要只比较本地时间。
- Merchant Center 中的
$0 FAILED记录可能来自支付验证、授权尝试或后台记账。具体含义取决于当时使用的 Google Play Billing 版本和支付渠道,不能仅凭这条记录判断订阅状态。
OnPurchaseStateChange 是早期 In-App Billing API 的机制。新项目应优先使用当前的 Google Play Billing Library 和服务端订阅状态通知。维护旧项目时,也不能把广播事件作为唯一的状态来源。
1. 取消订阅页面为什么显示 Item not found
这个错误通常表示 Google Play 无法为当前账号识别应用或订阅商品。常见原因包括:
- 安装包不是通过 Google Play 测试轨道安装的;
- 当前测试账号不在相应的测试名单中;
- 应用的
packageName、签名或版本与 Play Console 中的构建不一致; - 订阅商品尚未激活,或商品 ID 不匹配;
- 测试账号所在国家或地区无法访问该应用或商品;
- Google Play 尚未完成应用或商品配置的同步;
- 取消链接使用了错误的应用包名或订阅商品。
一般不需要把应用正式发布给所有用户。可以先将构建上传到 Internal testing、Closed testing 等测试轨道,把测试账号加入名单,再让该账号通过 Google Play 提供的测试链接安装应用。
还需要检查以下配置:
- Play Console 中已经创建并启用订阅和对应的 base plan。
- 测试设备登录的是已获得授权的测试账号。
- 应用是从 Google Play 安装的,而不是直接通过 Android Studio 或
adb install安装。 - 应用 ID、签名和商品 ID 与 Play Console 配置完全一致。
- Google Play Store 和 Google Play services 已更新,并已处理旧测试安装可能造成的账号缓存问题。
如果项目使用的是早期 In-App Billing API,其测试条件可能与当前 Play Console 不同。此时应查阅项目实际版本对应的官方文档,不要直接套用新版测试流程。
2. 订阅是否绑定设备
订阅权益原则上与购买时使用的 Google Account 关联,并非永久绑定某台设备。同一账号在其他设备上安装相同应用后,可以通过查询已有购买记录恢复权益。
不过,账号级权益并不代表所有设备都会实时收到 OnPurchaseStateChange。购买回调或广播主要用于通知当前设备上的购买流程或状态变化,不能作为可靠的跨设备同步通道。
例如,用户在设备 A 上订阅后,设备 B 上正在运行的应用可能收不到任何事件。因此,设备 B 应在这些时机主动查询:
- 应用启动时;
- 用户登录后;
- 应用从后台恢复时;
- 用户进入需要订阅权益的页面时;
- 用户点击”恢复购买”时。
伪代码可以这样写:
@Override
protected void onResume() {
super.onResume();
refreshSubscriptionStatus();
}
private void refreshSubscriptionStatus() {
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build(),
(billingResult, purchases) -> {
if (billingResult.getResponseCode()
!= BillingClient.BillingResponseCode.OK) {
return;
}
boolean entitled = purchases.stream()
.anyMatch(this::isLocallyEligible);
updateLocalEntitlement(entitled);
sendPurchaseTokensToBackend(purchases);
}
);
}
其中,isLocallyEligible() 只能用于快速更新界面。最终的权益判断最好交给服务端,因为客户端返回的购买记录仍需验证。
3. 后台取消后为什么没有立即通知设备
首先要区分以下两种操作:
- 取消自动续订:停止下一个周期的扣款,当前周期通常仍然有效;
- 撤销或退款:权益可能立即或提前终止,具体取决于执行的操作和订单状态。
如果用户只是取消自动续订,那么设备在当前周期结束前仍将订阅判断为有效,这是正常的。“不再续费”和”当前没有权益”是两种不同的状态。
如果商家后台执行的是退款或撤销,客户端也不能假定自己一定会马上收到回调。设备可能处于离线状态,应用可能没有运行,通知本身也可能延迟。较可靠的实现应以服务端状态为准:
Google Play
├── 客户端购买查询:用于快速恢复界面状态
├── 服务端购买令牌验证:用于确认真实权益
└── 服务端订阅通知:用于跟踪续订、取消、暂停、退款和撤销
推荐采用以下流程:
- 客户端购买成功后,将
purchaseToken和商品信息发送到业务服务端。 - 服务端通过 Google Play Developer API 查询订阅状态。
- 服务端保存到期时间、自动续订状态和当前权益状态。
- 收到服务端订阅通知后,再次调用 Developer API 获取最终状态。
- 客户端从业务服务端读取权益,并在启动或恢复前台时刷新。
不要只根据客户端保存的 expirationDate 判断最终权益。设备时间可以被修改,本地缓存也可能已经过期。如果暂时没有服务端,可以把”当前时间超过已知到期时间后重新查询购买记录”作为过渡方案。刷新间隔应使用系统经过时间控制,并以 Google Play 返回的状态为准:
private boolean shouldRefresh(long knownExpiryTimeMillis) {
return System.currentTimeMillis() >= knownExpiryTimeMillis;
}
这段判断只能决定”是否需要刷新”,不能直接证明订阅已经失效。用户关闭自动续订后,也不能在到期前立即撤销当前周期的权益。
使用早期 API 时,可以通过 restoreTransactions() 重新获取购买状态。当前 Billing Library 应使用购买查询接口,新代码不应继续依赖旧的恢复交易机制。
4. 为什么会出现 $0 FAILED transaction
实际扣款记录旁出现一条 $0 FAILED 记录,不一定表示用户被重复收费,也不应直接影响订阅权益。这条记录可能来自支付方式验证、预授权尝试、结算系统内部记录,或一次未完成的支付尝试。
仅凭现有描述无法确定它的具体用途。Google Play 的账务展示、测试购买行为和支付处理流程会随版本及支付渠道变化,因此不能把”每次订阅都会产生一笔 $0 FAILED 记录”当成固定规则。
排查时应重点核对:
- 两条记录的
orderId是否相同; - 是否只有一笔实际扣款;
- 有效订单是否对应客户端取得的
purchaseToken; - Developer API 返回的订阅状态是否有效;
$0 FAILED记录是否只出现在特定测试账号或支付方式下。
业务代码不应根据 Merchant Center 中的交易行数量授予权益。权益判断应基于经过验证的 purchaseToken、订阅有效期,以及撤销、退款等状态。
如果 $0 FAILED 记录持续出现在真实交易中,或同时发生重复扣款、订阅无法激活等问题,应保存订单号、发生时间、测试账号类型和 Billing Library 版本,再提交给 Google Play 支持团队核查。
实现时的注意事项
- 不要把购买回调当成完整的订阅状态同步机制。
- 不要把取消订阅等同于立即过期。
- 不要依赖设备本地时间直接授予或撤销权益。
- 不要根据 Merchant Center 的交易行数量判断购买是否成功。
- 多设备场景必须提供主动刷新机制或”恢复购买”入口。
- 仍在开发的应用应通过 Play Console 测试轨道和测试账号完成端到端测试。
- 生产环境应保存并验证
purchaseToken,同时正确处理续订、宽限期、暂停、到期、退款和撤销等状态。
跨设备没有收到事件,以及取消订阅后客户端没有立即收到通知,通常与同步方式和订阅生命周期有关。取消页面出现 Item not found,更可能是测试发布或商品配置有误。对于 $0 FAILED 记录,则需要结合订单和支付数据进一步判断。
备注:内容仅供参考。
