PAYATHON 2026

Google Play Billing Library 如何确定 base plan 和订阅到期时间

支付老李

结论

Purchase 和 PurchaseHistoryRecord 都无法可靠提供 basePlanId,也没有准确的订阅到期时间。

要获取可信结果,后端需要使用购买令牌调用 Google Play Developer API:

purchases.subscriptionsv2.get

响应中的 lineItems 通常包含:

  • offerDetails.basePlanId:用户购买的 base plan
  • offerDetails.offerId:用户使用的 offer,未使用时可能不存在
  • expiryTime:当前订阅权益的到期时间

不要从 Android 客户端直接调用这个 API,否则会暴露服务账号凭据。客户端应将 Purchase.purchaseToken 发送给自己的后端,再由后端完成校验和查询。

为什么客户端拿不到这些信息

Billing Library 中的 Purchase 主要记录一次购买及其客户端状态,例如:

  • products
  • purchaseToken
  • purchaseTime
  • purchaseState
  • isAutoRenewing
  • isAcknowledged

这些字段的含义需要区分:

  • products 是订阅产品 ID,不是 basePlanId
  • purchaseTime 是购买时间,不是到期时间
  • isAutoRenewing 只说明当前是否开启自动续订,不能据此计算到期时间

订阅到期时间还会受续订、取消、退款、撤销、宽限期、账号保留、暂停和订阅替换等状态影响,不能简单地用“购买时间 + base plan 周期”计算。

PurchaseHistoryRecord 记录的是历史购买,也不包含完整、实时的订阅状态,因此不适合用来判断当前权益。

推荐处理流程

1. 客户端展示并发起购买

查询 ProductDetails 后,可以从 subscriptionOfferDetails 中取得当前可购买的 basePlanId、offerId 和 offerToken。

val offer = productDetails.subscriptionOfferDetails
    ?.firstOrNull { details ->
        details.basePlanId == selectedBasePlanId &&
            details.offerId == selectedOfferId
    }
    ?: error("Selected base plan or offer is unavailable")

val productDetailsParams =
    BillingFlowParams.ProductDetailsParams.newBuilder()
        .setProductDetails(productDetails)
        .setOfferToken(offer.offerToken)
        .build()

val billingFlowParams = BillingFlowParams.newBuilder()
    .setProductDetailsParamsList(listOf(productDetailsParams))
    .build()

billingClient.launchBillingFlow(activity, billingFlowParams)

发起购买时,客户端知道用户选择了哪个 SubscriptionOfferDetails,可以暂时保存对应的 basePlanId。这项数据只能用于关联,不能替代服务端校验。

2. 将购买令牌发送到后端

在 PurchasesUpdatedListener 或 queryPurchasesAsync 中,取得购买成功且状态为 PURCHASED 的记录:

override fun onPurchasesUpdated(
    billingResult: BillingResult,
    purchases: MutableList<Purchase>?
) {
    if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
        return
    }

    purchases.orEmpty()
        .filter { it.purchaseState == Purchase.PurchaseState.PURCHASED }
        .forEach { purchase ->
            sendPurchaseToBackend(
                productId = purchase.products.first(),
                purchaseToken = purchase.purchaseToken
            )
        }
}

实际项目中,不要假设 products 永远只有一个元素。后端也不能只根据客户端提交的产品 ID 授予权益。

3. 后端查询 Google Play Developer API

后端使用应用包名和购买令牌发起请求:

GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}
Authorization: Bearer {accessToken}

响应结构可能如下:

{
  "subscriptionState": "SUBSCRIPTION_STATE_ACTIVE",
  "lineItems": [
    {
      "productId": "premium_subscription",
      "expiryTime": "2026-09-29T08:30:00Z",
      "offerDetails": {
        "basePlanId": "monthly",
        "offerId": "intro-offer"
      }
    }
  ]
}

读取响应时需要遍历 lineItems,不能默认只处理第一项:

for (SubscriptionPurchaseLineItem lineItem : subscriptionPurchase.getLineItems()) {
    String productId = lineItem.getProductId();
    String expiryTime = lineItem.getExpiryTime();

    String basePlanId = null;
    String offerId = null;

    if (lineItem.getOfferDetails() != null) {
        basePlanId = lineItem.getOfferDetails().getBasePlanId();
        offerId = lineItem.getOfferDetails().getOfferId();
    }

    // 根据 productId、basePlanId、expiryTime 和整体订阅状态更新用户权益
}

具体的 Java 类名和 getter 取决于项目使用的 Google API Client 版本,应以项目生成或引入的 Android Publisher API 类型为准。REST 响应字段的含义不变。

是否可以不调用 Developer API

可以在发起购买时,将用户选择的 basePlanId 与之后返回的 purchaseToken 关联起来,但这种做法只能覆盖部分场景:

  • 应用可能在购买完成前关闭
  • 购买可能在另一台设备上恢复
  • 客户端数据可能被清除
  • 订阅可能发生升级、降级或替换
  • 客户端提交的数据不能视为可信数据
  • 客户端无法可靠获取续订后的新到期时间

因此:

  • 如果只是临时显示用户刚刚选择的方案,可以使用客户端保存的 basePlanId
  • 如果需要判断是否应授予订阅权益,必须以后端查询结果为准
  • 如果需要持续维护订阅状态,建议结合 Real-time Developer Notifications,在收到状态变化后重新调用 purchases.subscriptionsv2.get

注意事项

不要根据 isAutoRenewing 判断订阅是否有效

用户取消自动续订后,订阅通常仍可使用到当前周期结束。因此:

isAutoRenewing == false

并不表示订阅已经到期。

不要自行计算到期时间

下面的计算方式并不可靠:

expiryTime = purchaseTime + billingPeriod

免费试用、优惠阶段、续订、延期、宽限期、暂停和订阅替换都会影响实际的订阅状态。

正确解析 expiryTime

expiryTime 是 RFC 3339 时间戳,应按绝对时间解析:

val expiryInstant = java.time.Instant.parse(expiryTime)
val isExpired = expiryInstant.isBefore(java.time.Instant.now())

是否授予权益不能只看时间,还要结合 subscriptionState、撤销或取消上下文以及业务规则进行判断。

不要把服务账号放进 APK

Google Play Developer API 的认证凭据只能保存在受控的服务端环境中。将服务账号 JSON、私钥或长期访问凭据打包进客户端后,凭据可能被提取和滥用。

备注:内容仅供参考。