Android 如何获取应用内订阅到期时间并处理 403 错误
结论
BillingClient 可以取得购买记录和 purchaseToken,但不会返回订阅到期时间。订阅状态、当前计费周期的到期时间以及服务端取消操作,都应由后端通过 Google Play Developer API 处理。
403 projectNotLinked 与 Billing Library 版本无关。这个错误表示调用 API 所用的 Google Cloud 项目没有正确关联到对应的 Google Play Console 开发者账号,或当前服务账号没有应用和订阅管理权限。
建议按以下流程处理:
- App 完成购买并取得
purchaseToken。 - App 将
purchaseToken、productId和必要的用户标识发送给后端。 - 后端调用
purchases.subscriptionsv2.get查询订阅。 - 后端根据
subscriptionState、lineItems[].expiryTime等字段判断用户权益。 - App 从业务后端读取订阅状态,不保存 Google API 凭据。
为什么购买成功,查询却返回 403
客户端购买和服务端查询使用不同的权限体系。
App 内购买由 Google Play Billing Library 完成。应用签名、商品配置和测试账号等设置正确后,就能拉起购买流程。调用 Google Play Developer API 则要求 Google Cloud 项目、API、服务账号和 Play Console 权限全部配置正确。因此,购买成功并不能说明 Google Play Developer API 已配置完成。
错误中的:
reason: "projectNotLinked"
表示当前 OAuth 访问令牌所属的 Google Cloud 项目,没有被 Play Console 识别为该开发者账号使用的项目。常见原因有:
- Play Console 没有关联当前 Google Cloud 项目。
- 启用 API 的项目与创建服务账号的项目不同。
- 程序实际加载了另一份服务账号凭据。
- 服务账号没有添加到 Play Console 的“用户和权限”中。
- 服务账号有账号级权限,但无权访问目标应用。
- 使用 API Explorer、个人 OAuth 客户端或默认凭据时,访问令牌来自另一个 Cloud 项目。
- 权限刚配置完成,Google 侧尚未同步。
配置 Google Play Developer API
1. 确认 Google Cloud 项目
在 Google Cloud Console 中选定一个项目并记录项目 ID。后续创建服务账号、启用 API 和生成凭据时,都要使用这个项目。
核对时应查看实际的 project_id,不要只比较项目名称。
2. 启用 API
在该项目中启用:
Google Play Android Developer API
对应的服务名称通常为:
androidpublisher.googleapis.com
启用 API 后,还要在 Play Console 中允许该身份访问目标应用。
3. 关联 Play Console
在 Google Play Console 中进入类似以下位置:
设置(Setup) → API access
将前面选定的 Google Cloud 项目关联到当前 Play Console 开发者账号。
Google Play Console 的菜单名称和权限界面可能会变化。如果新版控制台不再显示单独的“关联项目”按钮,需要检查服务账号是否已加入“用户和权限”,以及程序所用凭据是否确实来自预期项目。如果旧配置明确返回 projectNotLinked,仍需按照控制台提示完成项目关联。
4. 创建服务账号
在同一个 Google Cloud 项目中创建服务账号,例如:
play-subscription-api@example-project.iam.gserviceaccount.com
生产环境应优先使用运行平台提供的工作负载身份或默认服务账号。如果必须使用 JSON 密钥,只能将密钥保存在后端,不能打包进 APK。
5. 授予 Play Console 权限
在 Play Console 的“用户和权限”中添加服务账号邮箱,并允许它访问目标应用。
查询订阅通常需要查看订单或财务数据的权限。取消、退款或撤销订阅还需要相应的订单及订阅管理权限。具体权限名称会随 Play Console 的界面语言和版本变化,可根据实际界面授予以下能力:
- 查看目标应用。
- 查看订单和订阅状态。
- 管理订单与订阅。仅当后端需要执行取消、退款或撤销操作时授予。
权限配置可能不会立即生效。如果项目和账号均确认无误,可以等待权限同步后重试。
App 端取得 purchaseToken
Billing Library 6 可以查询设备上当前账号的订阅购买记录:
val params = QueryPurchasesParams.newBuilder()
.setProductType(BillingClient.ProductType.SUBS)
.build()
billingClient.queryPurchasesAsync(params) { billingResult, purchases ->
if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
purchases.forEach { purchase ->
val purchaseToken = purchase.purchaseToken
val productIds = purchase.products
// 通过 HTTPS 发送给自己的后端进行校验
uploadPurchaseToBackend(
purchaseToken = purchaseToken,
productIds = productIds
)
}
}
}
purchaseToken 属于敏感业务数据。后端还要确认它对应的 packageName、商品和应用用户,防止将某笔 Google Play 购买错误绑定到其他账号。
后端查询订阅到期时间
新代码应优先使用:
purchases.subscriptionsv2.get
请求形式如下:
GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}
Authorization: Bearer {access_token}
Node.js 示例:
const { google } = require("googleapis");
async function getSubscription(packageName, purchaseToken) {
const auth = new google.auth.GoogleAuth({
scopes: ["https://www.googleapis.com/auth/androidpublisher"],
});
const androidPublisher = google.androidpublisher({
version: "v3",
auth,
});
const response =
await androidPublisher.purchases.subscriptionsv2.get({
packageName,
token: purchaseToken,
});
return response.data;
}
每个订阅项目的到期时间位于 expiryTime 字段:
const subscription = await getSubscription(
"com.example.app",
purchaseToken
);
const lineItems = subscription.lineItems ?? [];
for (const item of lineItems) {
console.log({
productId: item.productId,
expiryTime: item.expiryTime,
});
}
expiryTime 通常采用 RFC 3339 格式,例如:
2026-10-23T12:30:00Z
旧接口 purchases.subscriptions.get 通常通过 expiryTimeMillis 返回到期时间。该字段是毫秒时间戳,可以这样转换:
const expiryDate = new Date(
Number(subscriptionPurchase.expiryTimeMillis)
);
历史代码或文档中仍可能出现旧接口。新接入应优先评估 purchases.subscriptionsv2.get,因为它可以表示新的订阅模型和多个 lineItems。
判断权益时还要检查订阅状态
自动续订订阅的“到期时间”通常指当前计费周期的结束时间,不是固定不变的最终日期。续费成功后,这个时间会继续延后。
判断用户当前是否有权益时,还要检查:
subscriptionStatelineItems[].expiryTime- 宽限期
- 账号保留期
- 暂停状态
- 待处理交易
- 套餐升级或降级
- 预付费方案
- 退款或撤销
如果响应中包含多个 lineItems,应分别处理每个项目的商品、状态和时间,不能随意取数组第一项。
生产系统通常还会接入 Real-time Developer Notifications,以便及时同步续订、取消、暂停、宽限期和撤销等变化。收到通知后,后端仍需调用 Google Play Developer API 查询完整状态,不能仅根据通知内容修改用户权益。
取消订阅应该放在哪里
取消操作分为两类。
用户自行取消自动续订
App 可以将用户引导至 Google Play 的订阅管理页面:
val uri = Uri.parse(
"https://play.google.com/store/account/subscriptions" +
"?sku=$productId&package=$packageName"
)
startActivity(Intent(Intent.ACTION_VIEW, uri))
用户需要在 Google Play 页面确认取消。取消自动续订后,用户通常仍可使用服务,直到当前已付费周期结束。具体结果以后端查询到的状态为准。
开发者主动取消
如果业务系统需要主动执行取消,应由后端调用 Google Play Developer API,例如:
async function cancelSubscription(
packageName,
productId,
purchaseToken
) {
const auth = new google.auth.GoogleAuth({
scopes: ["https://www.googleapis.com/auth/androidpublisher"],
});
const androidPublisher = google.androidpublisher({
version: "v3",
auth,
});
await androidPublisher.purchases.subscriptions.cancel({
packageName,
subscriptionId: productId,
token: purchaseToken,
});
}
取消自动续订、退款和立即撤销权益是不同的操作。调用前要先确认业务目标,再选择对应的 API。取消成功后不能直接删除用户权益。只有执行了会立即终止权益的操作,并且 API 返回的状态也表明权益已经失效,才能立即终止服务。
前后端职责建议
| 操作 | 建议位置 |
|---|---|
| 发起购买 | App |
获取 purchaseToken | App |
| 把购买信息提交到业务服务器 | App |
| 验证购买和订阅状态 | Back end |
| 保存当前权益及到期时间 | Back end |
| 接收实时订阅通知 | Back end |
| 开发者主动取消、退款或撤销 | Back end |
| 跳转 Google Play 供用户自行取消 | App |
| 保存服务账号凭据 | 只能在 Back end |
App 不应直接调用 Google Play Developer API,否则必须将服务账号密钥或其他高权限凭据放入客户端。APK 被反编译后,这些凭据可能被用于查询或操作开发者账号下的订单。
排查当前 403 时,先核对服务账号 JSON 中的 project_id,再确认该项目是否已在 Play Console 配置,以及服务账号是否拥有目标应用和订单订阅权限。确认这三项后,继续检查后端实际加载的凭据,通常可以据此找到 projectNotLinked 的来源。
备注:内容仅供参考。
