PAYATHON 2026

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 和提供恢复购买入口,不适合作为续订账本。

备注:内容仅供参考。