PAYATHON 2026

Google Play Store 不同账号的 in-app billing 问题

支付小周

结论

仅凭 app 内的账号 A,无法自动查询 Google Account X 名下的订阅。BillingClient.queryPurchasesAsync() 只能返回当前 Google Play 账号可见的有效购买。应用既不能遍历设备上的其他 Google 账号,也不能指定 BillingClient 查询某个账号。

要可靠处理订阅关联,不能把”购买成功后由客户端通知服务器”作为唯一链路。应在发起购买前关联 app 用户与购买流程,再通过服务端验签、Real-time Developer Notifications(RTDN)和幂等补偿机制保存订阅。

如果订单已经发生,但服务器没有留下任何关联信息,通常只能让用户切换回原 Google Play 账号恢复购买,或者提交 Google Play 订单号,由客服人工核验。

为什么无法直接恢复

Google Play 的购买记录属于实际完成付款的 Google 账号,不属于 app 内创建的账号或 Facebook 账号。

当用户切换到 Google Account Y 后:

billingClient.queryPurchasesAsync(
    QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.SUBS)
        .build()
) { billingResult, purchases ->
    // purchases 只包含当前 Google Play 账号可见的有效订阅
}

即使 app 仍然登录 account A,查询结果也不会自动包含 Google Account X 的购买记录。

应用还受到以下限制:

  • Billing Library 没有遍历其他 Google Play 账号购买记录的接口。
  • 应用不能通过 queryPurchasesAsync() 指定 Google 账号。
  • Purchase 对象不会向开发者提供可用于找回订单的 Gmail 地址。
  • 清除应用数据后,本地保存的 purchase token、重试任务和临时映射都会丢失。
  • 仅凭 app 用户 ID,Google Play Developer API 也无法反向列出该用户在所有 Google 账号下的购买。

因此,不能用 Gmail 地址关联订阅,也不能依赖本地 cache 保存尚未上报的 purchase token。

推荐的购买流程

1. 发起购买前建立服务端关联

用户点击订阅前,先由后端创建一条购买意图记录:

{
  "purchaseContextId": "random-non-sensitive-id",
  "appUserId": "account-A",
  "productId": "subscription-A",
  "status": "CREATED"
}

purchaseContextId 应是随机、不可逆且不含个人信息的值。不要直接传递邮箱、手机号或明文用户 ID。

启动购买时,将它设置为 obfuscatedAccountId:

val billingFlowParams = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(
        listOf(
            BillingFlowParams.ProductDetailsParams.newBuilder()
                .setProductDetails(productDetails)
                .setOfferToken(offerToken)
                .build()
        )
    )
    .setObfuscatedAccountId(purchaseContextId)
    .build()

val result = billingClient.launchBillingFlow(activity, billingFlowParams)

这个标识可以帮助后端将 Google Play 订单关联到 app 用户,但它不能用来查询其他 Google 账号,也不能获取用户的 Google 邮箱。

2. 客户端收到购买后立即提交 token

private fun handlePurchase(purchase: Purchase) {
    if (purchase.purchaseState != Purchase.PurchaseState.PURCHASED) {
        return
    }

    backendApi.registerPurchase(
        purchaseToken = purchase.purchaseToken,
        productIds = purchase.products
    )
}

后端必须使用 Google Play Developer API 验证 purchase token,不能直接相信客户端上传的产品 ID、购买状态或有效期。

服务端至少应检查:

  • package name 是否正确;
  • purchase token 是否有效;
  • 订阅商品是否符合预期;
  • 当前订阅状态和有效期;
  • token 是否已经绑定其他 app 用户;
  • 订单是否已经取消、撤销、退款或过期。

验证成功后,再向 account A 发放权益。

3. 注册 RTDN 作为独立补偿链路

Real-time Developer Notifications 可以通过 Google Cloud Pub/Sub 将订阅状态变化发送给后端。即使付款成功后客户端调用 API 失败,服务器仍有机会收到 purchase token,并通过 Google Play Developer API 查询真实状态。

如果购买前已经设置并保存 obfuscatedAccountId,后端可以尝试将 RTDN 中的 purchase token 与相应的 app 用户关联。具体能够获得哪些字段,取决于使用的 Google Play Developer API 资源和购买类型,实现时应以当前官方接口的返回值为准。

RTDN 不包含完整的订单数据。收到通知后,仍需调用 Developer API 查询订阅详情。由于消息可能重复、延迟或乱序,处理逻辑必须保证幂等。

4. 服务端以 purchase token 为幂等键

数据库应为 purchase token 建立唯一约束。例如:

CREATE UNIQUE INDEX subscriptions_purchase_token_uq
ON subscriptions (purchase_token);

客户端重复提交、RTDN 重复通知和应用启动后的恢复查询,都可以进入同一套处理流程:

收到 purchase token
        ↓
向 Google Play 验证
        ↓
按 token 幂等更新订阅
        ↓
绑定 app 用户并发放权益
        ↓
确认购买

续订、升级、降级和替换订阅可能产生新的 token 或关联 token。后端需要根据 Developer API 返回的订阅关系更新记录,不能假定一个 token 会覆盖订阅的整个生命周期。

5. 验证并发放权益后确认购买

购买成功后,必须在 Google Play 要求的期限内完成 acknowledgement,否则交易可能被自动退款。业务 API 暂时失败时,也不能让确认操作完全依赖容易丢失的本地缓存。

确认购买可以由服务端或客户端完成,但必须明确由哪一侧负责,避免职责混乱。例如,由服务端在验证并发放权益后确认:

verify purchase
→ persist entitlement
→ acknowledge purchase
→ return success

在权益写入成功后再确认更稳妥。如果确认请求失败,服务端应持续重试,并分别记录”权益是否已发放”和”购买是否已确认”。

应用启动时提供恢复购买

每次 BillingClient 连接成功后,以及用户主动点击”恢复购买”时,都可以查询当前 Google Play 账号的有效订阅:

billingClient.queryPurchasesAsync(
    QueryPurchasesParams.newBuilder()
        .setProductType(BillingClient.ProductType.SUBS)
        .build()
) { billingResult, purchases ->
    if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
        return@queryPurchasesAsync
    }

    purchases
        .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
        .forEach { purchase ->
            backendApi.registerPurchase(
                purchaseToken = purchase.purchaseToken,
                productIds = purchase.products
            )
        }
}

这可以处理”服务器调用失败,但用户仍登录原购买账号”的情况。如果当前 Google Play 使用的是 Account Y,而订单属于 Account X,查询仍不会返回该订阅。

恢复页面应明确提示用户:

  1. 打开 Google Play,切换到付款时使用的 Google 账号。
  2. 返回应用,重新连接 BillingClient。
  3. 点击”恢复购买”。
  4. 如果仍未恢复,提交 Google Play 订单号并联系支持。

切换 Google Play 账号后,可能需要重新启动应用或重新建立 BillingClient 连接。

历史订单完全失联时如何处理

如果同时满足以下条件:

  • 后端从未收到 purchase token;
  • 没有 RTDN,或者 RTDN 无法关联到 app 用户;
  • 本地数据已经被清除;
  • 用户当前没有登录原购买 Google 账号;

应用就无法自动证明 account A 对应哪一笔购买,只能转由人工支持处理。

可以要求用户提供 Google Play 收据中的订单号,通常形如:

GPA.1234-5678-9012-34567

客服在开发者后台或内部订单工具中核实商品、状态和付款时间后,再恢复权益。不能只凭付款截图发放订阅,因为截图容易缺少关键状态,也无法可靠证明订单当前仍然有效。

人工绑定时还应检查:

  • 订单是否确实属于该应用;
  • 商品是否与用户申请的订阅一致;
  • 订单是否已经退款、撤销或过期;
  • 同一 purchase token 或订单是否已经绑定其他 app 用户;
  • 是否存在账号转移或重复领取风险。

还需要注意的边界

同一个 Google Play 订阅应该绑定哪个 app 账号,需要提前制定规则。常见做法是”一笔有效订阅只能绑定一个 app 账号”。如果用户尝试将已经绑定 account A 的 token 提交给 account B,后端应拒绝自动转移,并引导用户登录原 app 账号或联系支持。

queryPurchasesAsync() 主要用于获取当前账号可见的有效购买,不能将其当成完整的历史订单查询接口。已过期、已退款或已撤销的订阅未必会出现在结果中,最终状态应以服务端通过 Google Play Developer API 得到的验证结果为准。

较为稳妥的完整方案是:购买前创建服务端关联,购买时设置 obfuscatedAccountId,客户端上传 token,由 RTDN 提供独立补偿,服务端完成幂等验证和确认,应用提供”恢复购买”,同时保留通过订单号人工核验的通道。这样仍然无法跨 Google 账号扫描购买记录,但能将需要人工处理的情况降到较少。

备注:内容仅供参考。