PAYATHON 2026

Android In-App Billing V3 订阅验证出现 404

支付阿杰

结论

404 purchaseTokenNotFound 表示 Google Play Developer API 找不到与指定 packageName、订阅商品 ID 和 purchaseToken 相匹配的购买记录。它并不是“订阅已取消”或“订阅已过期”的正常状态。

如果仍有部分记录能够验证成功,服务账号授权和 API 调用方式通常没有整体失效。此时应优先检查:

  1. purchaseToken 从 App 传到服务器的过程中是否被截断、转义或替换。
  2. 传入的 packageName 或订阅商品 ID 是否与该 token 对应的购买一致。
  3. 客户端是否上报了缓存数据、重复数据或并非由 Google Play 生成的购买数据。
  4. Google Play Developer API 是否出现临时异常。只有完全相同的请求稍后又能成功,才更符合这种情况。

先检查原始参数

记录失败请求的以下信息,但不要把完整 token 写入普通日志:

  • packageName
  • productId
  • purchaseToken 的长度
  • token 的哈希值
  • 客户端购买 JSON 中的商品 ID
  • 购买时间
  • Google API 返回的 HTTP 状态码和 Reason

例如:

using System;
using System.Security.Cryptography;
using System.Text;

static string TokenFingerprint(string token)
{
    if (string.IsNullOrEmpty(token))
        return "<empty>";

    byte[] data = SHA256.HashData(Encoding.UTF8.GetBytes(token));
    return Convert.ToHexString(data).Substring(0, 16);
}

日志中记录指纹和长度即可:

logger.LogInformation(
    "Verify subscription: package={Package}, product={Product}, tokenLength={Length}, tokenHash={Hash}",
    packageName,
    productId,
    purchaseToken?.Length ?? 0,
    TokenFingerprint(purchaseToken));

再对比客户端发送前与服务器接收后的长度和哈希。两边不一致,说明问题出在传输或解析环节。

避免 purchase token 在传输中损坏

purchaseToken 是不透明字符串。不要假设它采用固定的字符格式,也不要自行拆分或规范化。尤其不要:

  • 调用 Trim() 后再保存;
  • 将 + 自动转换为空格;
  • 按 .、:、/ 等字符拆分;
  • 存入长度不足的数据库字段;
  • 使用未经正确编码的查询字符串传递;
  • 对 token 重复执行 URL decode。

建议通过 HTTPS 请求体发送 JSON:

{
  "packageName": "com.example.app",
  "productId": "monthly_subscription",
  "purchaseToken": "opaque-token-from-google-play"
}

数据库字段需要留出足够空间,例如使用 TEXT 或较大的字符串字段,不要根据历史 token 的长度设置过小的上限,也不要用旧 token 样本推断未来格式。

确认商品 ID 与 token 匹配

调用:

service.Purchases.Subscriptions.Get(
    packageName,
    productId,
    purchaseToken);

这里的 productId 必须是该次购买对应的订阅商品 ID,不能传入:

  • 订单号 orderId;
  • 基础方案 ID;
  • 优惠 ID;
  • 本地展示名称;
  • 当前用户计划的默认商品 ID;
  • 另一个应用或环境中的同名商品。

应从客户端购买结果中读取实际商品信息,再由服务器与允许的商品列表核对。不要只根据用户选择的页面或本地缓存推导 productId。

如果应用有多个包名、测试版、白标版本或经历过应用迁移,还要确认 token 和传入的 packageName 属于同一个 Google Play 应用。

不要把 404 当作订阅失效

订阅取消后,购买记录通常仍然存在。接口会通过返回对象中的状态字段说明续订、到期或取消情况。因此,不能直接把 purchaseTokenNotFound 解释成“用户取消了订阅”。

遇到 404 时,应采用保守的处理方式:

  1. 不授予新的订阅权益。
  2. 不立即删除已有权益或购买记录。
  3. 使用完全相同的参数进行有限次数的重试。
  4. 如果持续失败,将记录标记为待核查。
  5. 要求客户端重新查询 Google Play 中的有效购买并再次上报。

示例:

try
{
    var request = service.Purchases.Subscriptions.Get(
        packageName,
        productId,
        purchaseToken);

    SubscriptionPurchase purchase = request.Execute();

    // 根据 purchase 返回的订阅状态决定权益。
}
catch (Google.GoogleApiException ex)
    when (ex.HttpStatusCode == System.Net.HttpStatusCode.NotFound)
{
    // 不要把 404 直接当作“订阅已取消”。
    // 记录 packageName、productId、token 长度和哈希,进入重试或人工核查流程。
    throw;
}

重试只能用来排除短暂的服务异常。如果参数有误或 token 已经损坏,反复请求也无法解决问题。

检查非真实购买或异常客户端

如果失败主要出现在特定设备、应用版本、安装来源或用户群体中,还要考虑客户端上报的数据是否真的由 Google Play 签发。例如:

  • 应用来自非 Google Play 安装渠道;
  • 设备上的支付环境被修改;
  • 客户端伪造购买成功回调;
  • 本地签名验证实现有误;
  • 服务端信任了客户端传入的“验证成功”结果。

签名验证必须使用原始购买 JSON 和 Google Play 公钥完成。服务端最终应根据 Google Play Developer API 的结果判断是否发放权益,不能只凭客户端报告购买成功。

如何判断是否是 Google 侧临时故障

同时出现以下特征时,才更像是临时 API 问题:

  • packageName、productId 和 token 的原始字节完全没有变化;
  • 同一请求此前成功过;
  • 同一请求稍后重试又能成功;
  • 异常集中发生在较短时间内;
  • 异常与特定应用版本、传输方式或数据库记录无关。

这种情况下,可以保留失败时间、请求参数指纹、响应状态和相关日志,并查询 Google Play 服务状态,或向 Google Play 开发者支持提交案例。

如果同一组参数始终返回 404,而其他购买一直能够正常验证,应先检查 token 是否完整、商品 ID 和包名是否正确,以及购买数据是否真实,而不是先把问题归因于 API 调用代码。

备注:内容仅供参考。