Android In App Subscription 始终返回初始收据,无法获取续订信息
结论
这是正常现象,一般不是 cordova-plugin-purchase、Crosswalk 或 IABHelper 的缓存问题。
getPurchases()、queryInventoryAsync() 和现代 Google Play Billing Library 的 queryPurchasesAsync() 查询的是用户当前拥有的商品或订阅权益,并非每次自动续订产生的交易记录。对于一段连续订阅:
purchaseToken通常代表整个订阅生命周期,不会随每次续订而变化。purchaseTime通常是首次购买时间,不是最近一次续订时间。- 客户端查询结果不保证包含最近续订的
orderId。 - 最近的到期时间、付款状态和取消状态,需要由服务端通过 Google Play Developer API 查询。
所以,比较客户端 receipt、purchaseTime 或 orderId,无法可靠判断订阅是否已经续订。
为什么始终返回初始 receipt
调用:
mService.getPurchases(
3,
mContext.getPackageName(),
itemType,
continueToken
);
返回的是当前账号拥有的 purchase,不是订单历史。Google Play 可以在后台生成新的续订交易,但客户端仍可能收到代表当前订阅权益的原始 purchase 数据。
需要区分以下三类数据:
| 数据 | 用途 |
|---|---|
| 客户端 purchase | 证明用户拥有某项商品或订阅,并取得 purchaseToken |
| 服务端 subscription resource | 判断当前到期时间、续订状态、取消状态、暂停状态等 |
| 财务订单记录 | 表示初始付款和后续续订交易,不应作为客户端判断权益的依据 |
即使 Google Play Console 或支付记录中出现了 GPA.xxxx..0、GPA.xxxx..1 之类的续订订单号,getPurchases() 也不一定会把这些订单号作为新的客户端 receipt 返回。
这种情况并非测试订阅所独有,生产订阅同样可能一直返回最初的 purchase 数据。清除 Play Store 缓存不会改变接口本身的含义。
正确的订阅处理方式
比较稳妥的结构是:客户端上传 token,服务端负责验证,由实时通知触发更新,并通过定期任务补漏。
1. 客户端取得并上传 purchaseToken
客户端完成购买或恢复购买后,把以下信息提交给自己的服务端:
{
"packageName": "com.example.app",
"productId": "monthly_subscription",
"purchaseToken": "example-purchase-token"
}
服务端应把 token 与自己的用户账号关联。不要只按设备或 Google 账号保存订阅信息,因为用户可能更换设备、退出登录或重新安装应用。
客户端提交的信息不能直接作为发放权益的最终依据。服务端收到 token 后,仍然需要向 Google 验证。
2. 服务端查询 Google Play Developer API
新系统应优先使用 purchases.subscriptionsv2.get:
GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}
Authorization: Bearer ACCESS_TOKEN
Node.js 示例:
import { google } from "googleapis";
const androidPublisher = google.androidpublisher({
version: "v3",
auth: new google.auth.GoogleAuth({
scopes: ["https://www.googleapis.com/auth/androidpublisher"]
})
});
async function getSubscription(packageName, purchaseToken) {
const response =
await androidPublisher.purchases.subscriptionsv2.get({
packageName,
token: purchaseToken
});
return response.data;
}
服务端应结合响应中的订阅状态和各个 lineItems 的到期时间判断权益,不能只检查 token 是否存在。
简化后的处理逻辑如下:
function calculateEntitlement(subscription) {
const activeStates = new Set([
"SUBSCRIPTION_STATE_ACTIVE",
"SUBSCRIPTION_STATE_IN_GRACE_PERIOD"
]);
const latestExpiry = (subscription.lineItems ?? [])
.map(item => item.expiryTime)
.filter(Boolean)
.sort()
.at(-1);
return {
active:
activeStates.has(subscription.subscriptionState) &&
Boolean(latestExpiry),
state: subscription.subscriptionState,
expiryTime: latestExpiry ?? null
};
}
实际业务还需要明确宽限期、账号保留、暂停、取消但尚未到期等状态的处理方式。例如,订阅显示为”已取消”,通常不表示权益立即失效,用户可能仍可使用到当前计费周期结束。
旧系统可能仍在使用:
purchases.subscriptions.get
这个接口适用于旧版订阅资源,但新实现不应继续围绕旧字段设计数据模型。迁移前,需要根据项目实际使用的 Google API 版本核对字段。
3. 使用 Real-time Developer Notifications
在 Google Play Console 中配置 Real-time Developer Notifications(RTDN)后,订阅状态发生变化时,Google Play 会通过 Google Cloud Pub/Sub 通知服务端。
RTDN 本身并不包含完整的订阅状态。服务端收到通知后,应使用其中的 purchaseToken 再次调用 purchases.subscriptionsv2.get,并以 Google 返回的当前状态为准。
典型流程如下:
Google Play 产生续订或状态变化
↓
Pub/Sub 推送 RTDN
↓
服务端解析 packageName、subscriptionId、purchaseToken
↓
调用 purchases.subscriptionsv2.get
↓
更新数据库中的状态和 expiryTime
↓
按最新状态授予或撤销权益
处理 RTDN 时需要注意:
- 通知可能重复投递,因此更新逻辑需要具备幂等性。
- 通知到达的顺序不一定与业务事件的发生顺序相同。
- 收到通知不表示续订一定成功,服务端必须再次查询 API。
- 只有安全解析并处理消息后,才能确认 Pub/Sub 消息。
- 服务端应验证 Pub/Sub 推送请求的身份,不能信任任意外部请求携带的 token。
- 退款、撤销等场景可能还需要结合 Voided Purchases API 处理。
4. 保留定期对账任务
RTDN 可以大幅减少轮询,但不建议完全取消定期对账。通知配置错误、消息处理失败或临时服务故障,都可能导致本地状态未能及时更新。
定期任务可以优先检查:
- 即将到期,但尚未收到续订结果的订阅;
- 处于宽限期、账号保留、暂停等过渡状态的订阅;
- 最近收到过 RTDN,但本地更新失败的订阅;
- 本地仍显示有效,但很久没有与 Google 对账的订阅。
没有必要每天扫描所有历史订阅。可以按照 expiryTime、状态和上次验证时间建立索引,只查询需要复核的记录。
SELECT id, package_name, purchase_token
FROM subscriptions
WHERE
next_verification_at <= CURRENT_TIMESTAMP
OR expiry_time <= CURRENT_TIMESTAMP + INTERVAL '1 day'
OR status IN ('PENDING', 'GRACE_PERIOD', 'ON_HOLD')
ORDER BY next_verification_at
LIMIT 500;
API 请求需要设置并发控制、指数退避和失败重试。不要假设”单个请求可以包含 1,000 个订阅”。普通的 subscriptionsv2.get 按 token 查询,是否支持 HTTP batch、批次大小和配额限制,取决于当前 API 与 Google 基础设施的规则,应以项目正在使用的官方 API 文档为准。
数据库应保存什么
建议至少保存以下字段:
user_id
package_name
product_id
purchase_token
subscription_state
expiry_time
auto_renew_enabled
last_verified_at
next_verification_at
latest_notification_time
purchaseToken 属于敏感数据,不要写入公开日志,也不要发送给无关的第三方服务。
订阅升级、降级、重新订阅或其他替换流程可能产生新的 token。服务端需要处理 token 之间的关联,避免同时为旧订阅和替换后的订阅重复发放权益。
关于 GPA.xxxx..0、GPA.xxxx..1
客户端没有收到这些编号,并不表示相关订单号规则已经失效,也不能据此认定官方文档已经过时。
续订订单号是支付和订单层面的标识,getPurchases() 返回的则是当前拥有的 purchase。两者之间并不存在”每续订一次,就必须返回一张新的客户端 receipt”这样的对应关系。订单号的具体格式、显示位置和特殊场景可能发生变化,因此不应通过解析 ..0、..1 后缀来计算续订次数。
要判断订阅是否续订,应检查 Google 服务端返回的状态和到期时间,或者在收到 RTDN 后重新查询订阅资源,而不是等待客户端出现新的 orderId。
对现有 Cordova 实现的建议
即使插件正确调用了 IInAppBillingService.getPurchases(),也无法通过该方法取得完整的续订历史。因此,仅凭当前现象不能认定插件存在 bug。
不过,问题中使用的插件和 IABHelper 属于较早期的 Billing 接入方式。如果继续维护,需要确认插件是否支持当前的 Google Play Billing 要求,包括:
- 当前要求的 Play Billing Library 版本;
- purchase acknowledgement;
- pending purchases;
- 订阅升级和降级;
- 多个订阅 line item;
- token 替换和关联;
- 恢复购买及服务断线重连。
无论客户端最终使用哪个插件,订阅权益都应由服务端作出权威判断。客户端查询适合用来发现 purchase 和提供恢复购买入口,不适合作为续订账本。
备注:内容仅供参考。
