PAYATHON 2026

如何使用 Google Play Developer API 检测订阅套餐变更?

支付小周

明确结论

识别套餐变更时,不应根据旧订阅的 startTime、expiryTime 或 autoRenewing 推断是否发生了替换。建议按以下方式处理:

  1. 使用 purchases.subscriptionsv2.get 分别查询新旧 purchaseToken。
  2. 读取新订阅的 linkedPurchaseToken,确认新旧购买之间是否存在关联。
  3. 查看旧订阅的 canceledStateContext.replacementCancellation,确认旧订阅是否因套餐替换而取消。
  4. 通过 Real-time Developer Notifications(RTDN)接收状态变化,并在收到通知后调用 Developer API 查询最终状态。
  5. 将每个 purchaseToken 保存为独立的购买记录,同时维护 token 之间的替换链,不要覆盖旧记录。

使用 WITH_TIME_PRORATION 替换模式时,旧套餐的剩余价值会折算为新套餐的可用时间。因此,用户从价格较高的年度套餐切换到价格较低的月度套餐后,新订阅的首次到期时间可能远远超过一个月。

如果用户之后取消新订阅,通常只是关闭自动续订。在当前 expiryTime 到来之前,订阅仍然可以使用。已经折算成订阅时间的余额不会单独存入 Google Play 钱包,也不能留到以后重新使用。

为什么旧订阅的时间没有变化

套餐替换不要求 Google Play 改写旧购买的历史时间。

旧订阅的 startTime 和 expiryTime 表示该笔购买对应的权益区间。即使订阅后来因套餐替换进入取消状态,这两个字段也可能保持不变。由此需要注意:

  • expiryTime 没有缩短,并不表示旧订阅还会继续续费。
  • autoRenewing = false 只能说明自动续订已经关闭,无法单独证明发生了套餐替换。
  • 新旧订阅的时间可能连续衔接。按时间折算后,新订阅的首个周期也可能很长。
  • RTDN 事件可能重复或乱序,不能根据通知的到达顺序判断替换关系。

套餐替换会产生新的 purchaseToken。旧 token 应继续保留,用于审计和追踪购买关系,但不能再把它当作当前订阅的唯一依据。

使用 SubscriptionPurchaseV2 识别套餐替换

推荐调用:

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

旧版 purchases.subscriptions.get 需要提供 subscriptionId,而 subscriptionsv2.get 只需要包名和 purchaseToken。

新订阅的响应可能包含:

{
  "linkedPurchaseToken": "OLD_PURCHASE_TOKEN",
  "subscriptionState": "SUBSCRIPTION_STATE_ACTIVE",
  "lineItems": [
    {
      "productId": "monthly_subscription",
      "expiryTime": "2027-03-20T12:00:00Z"
    }
  ]
}

如果旧订阅因套餐替换而取消,应重点检查以下内容:

{
  "subscriptionState": "SUBSCRIPTION_STATE_CANCELED",
  "canceledStateContext": {
    "replacementCancellation": {}
  }
}

判断逻辑可以写成:

function isReplacementCancellation(subscription) {
  return Boolean(
    subscription.canceledStateContext &&
    subscription.canceledStateContext.replacementCancellation
  );
}

function getReplacedPurchaseToken(subscription) {
  return subscription.linkedPurchaseToken || null;
}

linkedPurchaseToken 表示当前购买与之前的某笔购买有关,但这个字段并非只用于普通的套餐升级或降级。重新订阅、预付费订阅与自动续订之间的转换,也可能产生这种关联。

因此,不能仅凭 linkedPurchaseToken 认定发生了套餐替换。还要检查旧购买是否包含 replacementCancellation,并结合本地保存的替换请求信息进行判断。

服务端查询示例

下面使用 Node.js 的 googleapis 查询 SubscriptionPurchaseV2:

import { google } from "googleapis";

const auth = new google.auth.GoogleAuth({
  scopes: ["https://www.googleapis.com/auth/androidpublisher"]
});

const androidpublisher = google.androidpublisher({
  version: "v3",
  auth
});

async function getSubscription(packageName, purchaseToken) {
  const response =
    await androidpublisher.purchases.subscriptionsv2.get({
      packageName,
      token: purchaseToken
    });

  return response.data;
}

async function inspectSubscriptionChange(packageName, newPurchaseToken) {
  const newPurchase = await getSubscription(
    packageName,
    newPurchaseToken
  );

  const oldPurchaseToken = newPurchase.linkedPurchaseToken;

  if (!oldPurchaseToken) {
    return {
      changedPlan: false,
      newPurchase
    };
  }

  const oldPurchase = await getSubscription(
    packageName,
    oldPurchaseToken
  );

  const replaced = Boolean(
    oldPurchase.canceledStateContext?.replacementCancellation
  );

  return {
    changedPlan: replaced,
    oldPurchaseToken,
    newPurchaseToken,
    oldPurchase,
    newPurchase
  };
}

生产环境还需要校验以下内容:

  • packageName 是否属于自己的应用。
  • lineItems[].productId 是否属于允许销售的产品。
  • subscriptionState 对应的状态是否允许授予权益。
  • lineItems[].expiryTime 是否晚于当前时间。
  • 新购买是否已完成 acknowledgement。
  • token 是否已经绑定到其他账号。
  • obfuscatedExternalAccountId 或服务端账号标识是否一致。

建议的数据结构

不要把订阅表设计成”每个用户只有一条记录,新数据直接覆盖旧数据”。更稳妥的做法是将购买事实与当前权益分开保存。

购买记录可以包含:

purchase_token
package_name
product_id
base_plan_id
subscription_state
start_time
expiry_time
auto_renew_enabled
linked_purchase_token
cancellation_type
acknowledgement_state
user_id
last_verified_at
raw_response

另外建立一张当前权益表:

user_id
active_purchase_token
entitlement_start_time
entitlement_expiry_time
entitlement_status

收到新 token 后,按以下顺序处理:

  1. 保存新购买,不覆盖旧购买。
  2. 查询新 token,读取 linkedPurchaseToken。
  3. 如果存在旧 token,继续查询对应的旧购买。
  4. 只有当旧购买包含 replacementCancellation,或本地已经记录了可信的替换请求时,才将其标记为套餐替换。
  5. 把当前权益切换到新 token。
  6. 根据新订阅 lineItems 中的 expiryTime 授予权益。
  7. 保留旧记录及新旧购买之间的替换关系。

这种设计既能识别套餐替换,也能区分用户取消、系统取消和开发者取消等其他情况。

按比例折算后,新订阅会不会超过正常周期

结果取决于客户端发起替换时选择的 replacement mode。

如果使用 WITH_TIME_PRORATION,旧订阅尚未使用的价值会折算成新套餐的使用时间。可以简单理解为:

新套餐增加的时间
≈ 旧套餐剩余价值 ÷ 新套餐单位时间价格

例如,年度套餐的剩余价值较高,而月度套餐的单位价格较低,最终可能折算出数个月的使用时间。此时,新订阅首次返回的 expiryTime 会明显晚于”购买时间加一个月”。

服务端不应自行精确计算到期时间。税费、币种、价格阶段、优惠以及 Google Play 的舍入规则都可能影响结果。授予权益时,应以 Developer API 返回的 expiryTime 为准。

不同 replacement mode 的处理结果也不同:

  • WITH_TIME_PRORATION:将剩余价值折算为新套餐的使用时间,下一次扣费日期通常会后移。
  • CHARGE_PRORATED_PRICE:可能立即收取新旧套餐之间按比例计算的差价。
  • WITHOUT_PRORATION:立即切换权益,但通常不会马上按比例收费,具体扣费时间由替换配置决定。
  • CHARGE_FULL_PRICE:立即收取新套餐全价,旧套餐的剩余价值可能被折算成额外使用时间。
  • DEFERRED:旧套餐继续使用到当前周期结束,之后再切换到新套餐。

实际可用的模式还会受到升降级方向、基础方案类型和 Google Play Billing 配置的限制。服务端不能假定所有套餐替换都采用同一种折算方式。

取消新订阅后的处理

对于自动续订订阅,用户取消新订阅后通常会发生以下变化:

  • 自动续订关闭。
  • 在当前 expiryTime 之前,订阅仍然有效。
  • 到期后不再续费。
  • 通过 WITH_TIME_PRORATION 折算出的额外时间已经包含在 expiryTime 中。
  • 尚未使用的时间不会作为独立余额保留,供用户以后再次消费。

因此,服务端看到取消状态时不能立即撤销权益,还需要结合订阅状态和到期时间判断。

示例:

function hasEntitlement(subscription, now = new Date()) {
  const validStates = new Set([
    "SUBSCRIPTION_STATE_ACTIVE",
    "SUBSCRIPTION_STATE_IN_GRACE_PERIOD",
    "SUBSCRIPTION_STATE_CANCELED"
  ]);

  if (!validStates.has(subscription.subscriptionState)) {
    return false;
  }

  return subscription.lineItems?.some((item) => {
    if (!item.expiryTime) {
      return false;
    }

    return new Date(item.expiryTime) > now;
  }) ?? false;
}

这里将 SUBSCRIPTION_STATE_CANCELED 纳入有效状态,是因为”已取消”有时只表示停止续订,当前权益可能尚未到期。退款、撤销、过期、暂停、宽限期和账号保留等状态需要分别处理,不能全部按普通取消处理。

兼容旧版 API 时的注意事项

旧版 SubscriptionPurchase 中,cancelReason 的定义曾包含表示套餐被替换的专用取值。不过,仅依赖这个字段仍然不够可靠,原因包括:

  • 旧接口能表达的状态和上下文有限。
  • 不同购买状态的更新时间可能有先后差异。
  • 历史数据或迁移数据不一定包含完整上下文。
  • 单独出现 linkedPurchaseToken 时,它不一定表示套餐升级或降级。

新实现应优先迁移到 purchases.subscriptionsv2.get,并使用结构化的 canceledStateContext。旧版字段可以保留作为兼容信息,但不应继续充当核心判断条件。

最容易出错的地方

  • 旧订阅的时间没有变化,不代表套餐替换失败。
  • 不能仅凭 autoRenewing = false 判断发生了套餐替换。
  • 不能把 linkedPurchaseToken 当作套餐替换的唯一证据。
  • 收到 RTDN 后,应重新查询 Developer API,不能直接把通知内容当作最终状态。
  • 不要假设通知会严格按照事件发生顺序到达。
  • 不要覆盖旧 token,应保存完整的替换链。
  • 不要自行计算最终到期时间,应采用 Google Play 返回的 expiryTime。
  • 普通取消不应导致权益被立即收回。退款或 revoke 才可能要求立即终止权益。
  • 如果同一账号可以拥有多个独立订阅,应按照订阅产品和权益范围分别判断,不能把所有关联 token 简单合并为一个订阅。

备注:内容仅供参考。