Google Play Billing Library 如何确定 base plan 和订阅到期时间
结论
Purchase 和 PurchaseHistoryRecord 都无法可靠提供 basePlanId,也没有准确的订阅到期时间。
要获取可信结果,后端需要使用购买令牌调用 Google Play Developer API:
purchases.subscriptionsv2.get
响应中的 lineItems 通常包含:
offerDetails.basePlanId:用户购买的 base planofferDetails.offerId:用户使用的 offer,未使用时可能不存在expiryTime:当前订阅权益的到期时间
不要从 Android 客户端直接调用这个 API,否则会暴露服务账号凭据。客户端应将 Purchase.purchaseToken 发送给自己的后端,再由后端完成校验和查询。
为什么客户端拿不到这些信息
Billing Library 中的 Purchase 主要记录一次购买及其客户端状态,例如:
productspurchaseTokenpurchaseTimepurchaseStateisAutoRenewingisAcknowledged
这些字段的含义需要区分:
products是订阅产品 ID,不是basePlanIdpurchaseTime是购买时间,不是到期时间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、私钥或长期访问凭据打包进客户端后,凭据可能被提取和滥用。
备注:内容仅供参考。
