取消 Android 测试订阅时返回 500,如何彻底移除?
结论
这种情况并非应用端可以正常取消的订阅,而是 Google Play 账号中残留了一条异常的测试订阅记录。
getPurchases() 没有返回 purchaseToken,应用和 Google Play Developer API 因此都拿不到操作这条订阅所需的唯一凭据。清除应用数据、重新安装应用、清除 Google Play 商店缓存或重新登录账号,只会影响本地状态,无法删除服务器上的订阅记录。API 在没有具体说明的情况下返回 HTTP 500,也说明问题很可能出在 Google Play 后端,而非取消请求的业务参数。
彻底解决通常有两种办法:
- 从 Play Console、历史日志或服务端数据库中找回
purchaseToken,再调用相应的取消或撤销接口。 - 如果订单号和
purchaseToken都不存在,联系 Google Play Developer Support,请支持人员检查并清理账号中的异常测试订阅。
等待处理期间,应用应跳过结构不完整的购买记录,以免影响其他订阅测试。
为什么普通取消操作无效
Google Play Developer API 不能只凭订阅商品 ID 取消订阅。完整的请求至少需要:
- 应用包名
packageName - 订阅商品 ID
subscriptionId - 购买令牌
token
purchaseToken 用来标识某一次具体购买。即使商品 ID 相同,不同账号或不同购买周期产生的购买记录也不能相互替代。
正常的旧版购买数据通常如下:
{
"orderId": "GPA.1234-5678-9012-34567",
"packageName": "com.example.app",
"productId": "premium_subscription",
"purchaseTime": 1710000000000,
"purchaseState": 0,
"purchaseToken": "example-purchase-token",
"autoRenewing": true
}
如果商品 ID 出现在 INAPP_PURCHASE_ITEM_LIST 中,但 INAPP_PURCHASE_DATA_LIST 中对应的项目为空、缺失或无法解析,应用就无法构造有效的取消请求。反复调用同一个 API 通常也修复不了底层记录。
单凭 HTTP 500 无法确定具体故障原因。不过,网页端、Play 商店客户端和 Developer API 同时失败,基本可以排除单纯的应用界面问题。
先尝试找回 purchaseToken
联系支持人员前,可以检查以下位置是否保存过这次购买的数据:
- 应用自己的服务端数据库
- Real-time Developer Notifications 的历史消息
- 当时的调试日志
- Google Play Console 中的订单管理页面
- Google Payments 或账号的订单历史
- 后端调用订阅验证接口时留下的请求记录
如果能找到订单号,也可以在 Play Console 的订单管理功能中搜索。控制台的具体入口可能随界面版本变化,重点检查是否有取消、退款或撤销权益的操作。
取消和撤销的效果不同:
- 取消通常只会关闭自动续订,权益可能保留到当前订阅周期结束。
- 撤销通常会让订阅权益立即失效。
具体行为取决于使用的 Google Play Developer API 版本和订阅状态,操作前应查看对应接口的文档。
例如,旧版取消接口的请求形式是:
POST https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptions/{subscriptionId}/tokens/{token}:cancel
即使已经知道 subscriptionId,只要无法取得 {token},这个接口就不能准确定位异常购买。
没有 purchaseToken 时的处理办法
如果所有来源都找不到 purchaseToken,应直接向 Google Play Developer Support 提交工单。它不是普通的用户付款问题,工单中应明确说明这是测试购买和订阅后端异常。
建议提供以下信息:
- 应用包名
- 订阅商品 ID
- 测试账号的完整邮箱地址,或支持页面要求的其他账号标识
- 异常订阅首次出现的大致时间
- 每次续订的大致周期
- Play 商店网页端和客户端显示的错误
- Developer API 返回的 HTTP 状态码
getPurchases()返回结果的脱敏副本- Play Console 订单页面的截图
- 已尝试的操作及其结果
不要公开 OAuth access token、服务账号私钥或其他账号凭据。如果需要提供 purchaseToken,也只能通过官方支持渠道提交。
工单中可以直接说明关键异常:
The subscription SKU is returned in
INAPP_PURCHASE_ITEM_LIST, but its corresponding purchase-data entry contains noorderIdorpurchaseToken. Cancellation fails in both Google Play and the Android Publisher API, which returns HTTP 500. Please inspect and remove or repair the stuck test subscription record for this tester account.
如果普通消费者支持将问题归为付款咨询,可以要求其转交 Google Play Developer Support 或负责 Play Billing 的技术支持团队。只有 Google 能修复或删除这种缺少有效购买令牌的服务器记录。
应用端应避免被异常记录拖垮
应用端虽然无法删除这条订阅,但也不应让一条损坏的数据中断整个购买恢复流程。使用旧版 getPurchases() 时,需要同时检查列表长度、元素内容和 JSON 字段。
Bundle result = billingService.getPurchases(
3,
context.getPackageName(),
"subs",
null
);
int responseCode = result.getInt("RESPONSE_CODE", 0);
if (responseCode != 0) {
Log.w(TAG, "getPurchases failed: " + responseCode);
return;
}
ArrayList<String> itemList =
result.getStringArrayList("INAPP_PURCHASE_ITEM_LIST");
ArrayList<String> dataList =
result.getStringArrayList("INAPP_PURCHASE_DATA_LIST");
ArrayList<String> signatureList =
result.getStringArrayList("INAPP_DATA_SIGNATURE_LIST");
if (itemList == null || dataList == null) {
Log.w(TAG, "Google Play returned incomplete purchase lists");
return;
}
int count = Math.min(itemList.size(), dataList.size());
for (int i = 0; i < count; i++) {
String sku = itemList.get(i);
String purchaseData = dataList.get(i);
if (purchaseData == null || purchaseData.trim().isEmpty()) {
Log.w(TAG, "Ignoring malformed purchase record for SKU: " + sku);
continue;
}
try {
JSONObject purchase = new JSONObject(purchaseData);
String token = purchase.optString("purchaseToken", "");
if (token.isEmpty()) {
Log.w(TAG, "Ignoring purchase without purchaseToken: " + sku);
continue;
}
// 验证签名,并将 token 发送到可信服务端进行订阅状态验证。
processPurchase(sku, token, purchaseData);
} catch (JSONException e) {
Log.w(TAG, "Ignoring invalid purchase JSON for SKU: " + sku, e);
}
}
如果三个数组的长度不一致,不要假定相同下标的项目必然对应,也不要用空字符串伪造 purchaseToken。应用应记录并隔离损坏的数据,同时继续处理其他结构完整的购买记录。
使用较新的 Google Play Billing Library 时,也需要进行防御性处理:
billingClient.queryPurchasesAsync(
QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build(),
(billingResult, purchases) -> {
if (billingResult.getResponseCode()
!= BillingClient.BillingResponseCode.OK) {
return;
}
for (Purchase purchase : purchases) {
String token = purchase.getPurchaseToken();
if (token == null || token.isBlank()) {
Log.w(TAG, "Ignoring subscription without purchase token");
continue;
}
verifyPurchaseOnServer(purchase);
}
}
);
实际使用时,还应验证购买签名,并通过可信服务端向 Google Play 查询订阅状态。不能只因客户端返回了商品 ID,就直接授予权益。
不建议采用的做法
以下操作只能改变设备的本地状态,无法永久删除账号中的订阅:
- 清除应用数据或重新安装应用
- 清除 Google Play 商店和 Google Play 服务的数据
- 从设备移除账号后重新添加
- 更换设备
- 创建同名订阅商品
- 反复调用缺少有效
purchaseToken的取消接口
不要猜测或伪造 purchaseToken。这个令牌无法根据商品 ID、订单号或账号邮箱推导出来。
临时换用另一个许可测试账号可以继续测试,但这只是绕过异常记录。要永久解决问题,仍需找回有效令牌并撤销订阅,或者请 Google Play 支持人员修复账号中的记录。
备注:内容仅供参考。
