PAYATHON 2026

In-App Billing 中 ProductID 和 SubscriptionID 是否相同?

架构老王

明确结论

是的。对于订阅商品,getPurchases() 返回的 productId 就是旧版 purchases.subscriptions.get API 路径参数中的 subscriptionId。

验证订阅时,各参数的来源如下:

服务端参数来源
packageName应用包名,建议在服务端配置
subscriptionId客户端购买记录中的 productId
token客户端购买记录中的 purchaseToken

旧版请求格式如下:

GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptions/{subscriptionId}/tokens/{token}

这里的 subscriptionId 并不是另一套独立 ID。API 使用这个参数名,只是为了表明它是订阅商品的 ID。

为什么有 products 和 subscriptions 两个 API

Google Play 将可售项目统称为商品,但会根据购买类型使用不同的 API:

  • products:一次性应用内商品,也就是旧文档中的 managed product,客户端类型通常是 inapp。
  • subscriptions:订阅商品,客户端类型是 subs,包含续订、取消、宽限期、暂停和到期等生命周期状态。

两类购买使用的都是在 Play Console 中配置的商品 ID。它们的区别不在于 ID 来源,而在于购买模型、状态字段和服务端验证接口。

具体对应关系如下:

Play 商品类型客户端商品 ID旧版服务端验证 API
一次性商品productIdpurchases.products.get
订阅商品productIdpurchases.subscriptions.get,作为 subscriptionId 传入

因此,不能只根据参数名称判断它们是否为同一种 ID。在这个场景中,productId 和 subscriptionId 指向同一个 Play Console 商品标识,只是名称随 API 上下文不同。

服务端验证步骤

客户端完成购买后,至少需要把以下信息发送给自己的服务端:

{
  "productId": "premium_monthly",
  "purchaseToken": "example-purchase-token",
  "productType": "subs"
}

packageName 应由服务端保存或确定,不要依赖客户端提交。验证时,再根据 productType 选择接口:

productType == "subs"
    -> purchases.subscriptions.get
    -> subscriptionId = productId

productType == "inapp"
    -> purchases.products.get
    -> productId = productId

例如,订阅商品 premium_monthly 的旧版验证请求可以写成:

GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/com.example.app/purchases/subscriptions/premium_monthly/tokens/PURCHASE_TOKEN
Authorization: Bearer ACCESS_TOKEN

一次性商品使用:

GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/com.example.app/purchases/products/remove_ads/tokens/PURCHASE_TOKEN
Authorization: Bearer ACCESS_TOKEN

服务端代码示例

以下示例使用 Node.js 和 googleapis。相关方法是否仍适用于新项目,需要以当前 Android Publisher API 文档和实际使用的 SDK 版本为准。

import { google } from "googleapis";

const auth = new google.auth.GoogleAuth({
  credentials: {
    client_email: process.env.GOOGLE_CLIENT_EMAIL,
    private_key: process.env.GOOGLE_PRIVATE_KEY.replace(/\\n/g, "\n"),
  },
  scopes: ["https://www.googleapis.com/auth/androidpublisher"],
});

const androidPublisher = google.androidpublisher({
  version: "v3",
  auth,
});

async function verifySubscription({
  packageName,
  productId,
  purchaseToken,
}) {
  const response = await androidPublisher.purchases.subscriptions.get({
    packageName,
    subscriptionId: productId,
    token: purchaseToken,
  });

  return response.data;
}

一次性商品的验证逻辑如下:

async function verifyOneTimeProduct({
  packageName,
  productId,
  purchaseToken,
}) {
  const response = await androidPublisher.purchases.products.get({
    packageName,
    productId,
    token: purchaseToken,
  });

  return response.data;
}

调用订阅验证方法时,直接传入对应字段:

await verifySubscription({
  packageName: "com.example.app",
  productId: purchase.productId,
  purchaseToken: purchase.purchaseToken,
});

不要只保存 purchaseToken

purchaseToken 是服务端验证购买的核心凭据,但通常还需要保存:

  • productId
  • purchaseToken
  • 商品类型,即 subs 或 inapp
  • 用户账户或业务用户 ID
  • 服务端首次验证时间
  • 服务端最后一次确认的购买状态
  • 必要时保存订单号,但不要将订单号用作验证凭据

如果只保存 purchaseToken,服务端调用旧版订阅接口时仍会缺少 subscriptionId。没有保存 productId 的情况下,只能额外恢复购买记录,或者查询自己的业务映射。

服务端也不能只凭客户端提交的数据授予权益。客户端数据的作用是告诉服务端需要验证哪笔购买,最终状态应以 Google Play 服务端的返回结果为准。

订阅取消不等于立即失效

订阅生命周期不能简单地处理成 purchased -> cancelled -> invalid。

用户关闭自动续订后,通常仍然可以使用已经付费的剩余订阅期。服务端需要分别判断:

  • 当前是否拥有有效权益
  • 是否会自动续订
  • 当前订阅周期何时结束
  • 是否处于宽限期、账号保留、暂停或撤销等状态

autoRenewing == false 通常只能说明订阅不会自动续订,不能单独证明订阅已经到期。授予或收回权益时,需要结合服务端返回的有效期和具体状态。

状态同步建议

不建议通过高频轮询每一笔订阅来发现所有状态变化。可以采用以下流程:

  1. 客户端购买后,将 productId、purchaseToken 和商品类型提交给服务端。
  2. 服务端立即调用 Google Play Developer API 验证。
  3. 服务端保存 token 与业务用户的绑定关系。
  4. 使用 Real-time Developer Notifications 接收续订、取消、暂停和撤销等事件。
  5. 收到通知后,再调用 Developer API 获取权威状态。
  6. 在用户登录、恢复购买或权益即将到期时补充校验。

Google Play 后续增加了新版订阅查询接口,例如 purchases.subscriptionsv2.get。新系统应先查看当前官方文档推荐使用哪个接口。无论使用旧版 subscriptions.get 还是相应的新接口,购买令牌都是验证订阅的关键标识。

对于题目中的旧版 purchases.subscriptions.get,直接将客户端返回的 productId 作为 subscriptionId 即可。

备注:内容仅供参考。