如何使用 Google Play Developer API 检测订阅套餐变更?
明确结论
识别套餐变更时,不应根据旧订阅的 startTime、expiryTime 或 autoRenewing 推断是否发生了替换。建议按以下方式处理:
- 使用
purchases.subscriptionsv2.get分别查询新旧purchaseToken。 - 读取新订阅的
linkedPurchaseToken,确认新旧购买之间是否存在关联。 - 查看旧订阅的
canceledStateContext.replacementCancellation,确认旧订阅是否因套餐替换而取消。 - 通过 Real-time Developer Notifications(RTDN)接收状态变化,并在收到通知后调用 Developer API 查询最终状态。
- 将每个
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 后,按以下顺序处理:
- 保存新购买,不覆盖旧购买。
- 查询新 token,读取
linkedPurchaseToken。 - 如果存在旧 token,继续查询对应的旧购买。
- 只有当旧购买包含
replacementCancellation,或本地已经记录了可信的替换请求时,才将其标记为套餐替换。 - 把当前权益切换到新 token。
- 根据新订阅
lineItems中的expiryTime授予权益。 - 保留旧记录及新旧购买之间的替换关系。
这种设计既能识别套餐替换,也能区分用户取消、系统取消和开发者取消等其他情况。
按比例折算后,新订阅会不会超过正常周期
结果取决于客户端发起替换时选择的 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 简单合并为一个订阅。
备注:内容仅供参考。