Google Billing API 首次续订前错误触发 Subscription Expired
结论
这通常不是 Acknowledge 失败,也不能只凭通知顺序认定 Google Play “错误取消”了订阅。
在 internal testing/许可证测试环境中,订阅周期被压缩,而续订结算、到期判断和 RTDN 投递并非原子过程。某个测试周期结束后,Google Play 可能短暂地将订阅判定为已到期。等续订交易完成后,系统会更新订阅状态并发送 SUBSCRIPTION_RENEWED。此外,RTDN 通过 Pub/Sub 投递,可能出现延迟、重复或乱序,不能作为撤销权益的唯一依据。
后端应将 RTDN 视为”状态可能已变化”的提示。收到通知后,重新查询 Google Play,并以服务端返回的有效期和订阅状态为准。对于刚到期但可能很快恢复的订阅,可以设置短暂的权益宽限期,在宽限期结束后再次校验,而不是一收到 SUBSCRIPTION_EXPIRED 就禁用用户。
为什么会先 Expired、后 Renewed
测试订阅的续订周期通常只有几分钟。生产环境中不易察觉的处理延迟,在测试环境里会非常明显:
- 当前测试周期到达
expiryTimeMillis。 - 续订交易尚未完成,Google Play 暂时没有识别到有效的新周期。
- RTDN 生成或投递
SUBSCRIPTION_EXPIRED。 - 续订结算稍后完成,新的到期时间被写入订阅状态。
- RTDN 随后生成或投递
SUBSCRIPTION_RENEWED。
这里需要区分两个时间:
- 通知到达后端的时间;
- Google Play 生成该状态或完成续订的时间。
Pub/Sub 不保证不同消息会按照业务状态发生的顺序到达,也可能重复投递。因此,日志中的接收顺序不能视为完整且严格有序的订阅状态机。
测试订阅连续续订若干次后自动停止,也是测试环境的预期行为,不能直接套用到生产订阅的时间尺度上。
cancelReason: 0 表示什么
旧版 purchases.subscriptions.get 可能返回以下结构:
{
"autoRenewing": false,
"cancelReason": 0,
"userCancellationTimeMillis": "..."
}
这表示 Google Play 当时记录的状态是”用户取消自动续订”。它与 acknowledgementState 属于两个独立维度:
acknowledgementState: 1:本次购买已经确认;autoRenewing: false:当前未开启自动续订;cancelReason: 0:Google Play 将停止续订归类为用户取消。
Acknowledge 只用于确认购买已经交付,不会强制维持自动续订,也不会覆盖取消状态。因此,购买已经 Acknowledge,并不能证明 autoRenewing 必须为 true。
如果每次测试都稳定出现相同的 userCancellationTimeMillis,还需要检查:
- 测试账号是否曾在 Play Store 的订阅管理页面取消订阅;
- 是否使用了模拟延迟、失败或恢复的测试付款方式;
- 客户端或测试脚本是否打开过订阅管理页面;
- 是否复用了旧的
purchaseToken; - 服务端查询使用的
packageName、subscriptionId和purchaseToken是否属于同一笔购买; - 设备上是否同时登录了多个测试账号,或存在多个相同 SKU 的测试购买。
仅凭这段状态数据,无法确定取消记录来自哪一次测试操作。还需要结合 Play Console、订单记录和完整的 RTDN 日志核对。
推荐的后端处理方式
1. RTDN 到达后重新查询 Google Play
不要直接执行下面的状态转换:
收到 SUBSCRIPTION_EXPIRED
→ 立即 disabled = true
可以改为:
收到任意订阅 RTDN
→ 解析 purchaseToken
→ 查询 Google Play 当前状态
→ 比较 expiryTime
→ 更新订阅记录
→ 必要时进入短暂宽限期
RTDN 本身没有足够的信息来决定最终权益。通知类型只能告诉后端,这笔订阅的状态发生了变化。
2. 使用服务端时间判断权益
基本判断可以写成:
const expiryTimeMs = Number(subscription.data.expiryTimeMillis ?? 0);
const isCurrentlyEntitled = expiryTimeMs > Date.now();
不要因为 autoRenewing === false 就立即撤销当前权益。即使用户取消了自动续订,通常仍可使用已经支付的剩余周期。
权益判断至少应区分以下情况:
- 当前有效:
expiryTimeMillis > now - 已取消但尚未到期:继续保留权益
- 已到期但仍在后端宽限期内:暂时保留权益并安排复查
- 已到期且复查后仍没有新的有效期:撤销权益
- 续订后出现更大的
expiryTimeMillis:恢复或延长权益
3. 对 Expired 增加延迟复查
后端可以先将订阅标记为 pending_expiration,等待 1~2 分钟后重新查询。这个时间只是针对当前测试现象采取的工程策略,并非 Google Play 保证的固定时限。生产环境应根据业务风险自行设置,并配合 Google Play 的 grace period/account hold 配置。
type EntitlementState =
| "active"
| "pending_expiration"
| "expired";
async function reconcileSubscription(
packageName: string,
subscriptionId: string,
purchaseToken: string
) {
const response =
await playDeveloperApiClient.purchases.subscriptions.get({
packageName,
subscriptionId,
token: purchaseToken,
});
const subscription = response.data;
const expiryTimeMs = Number(subscription.expiryTimeMillis ?? 0);
const now = Date.now();
if (expiryTimeMs > now) {
await updateEntitlement(purchaseToken, {
state: "active",
disabled: false,
expirationDateMs: expiryTimeMs,
autoRenewing: subscription.autoRenewing ?? false,
rawData: subscription,
});
return;
}
await updateEntitlement(purchaseToken, {
state: "pending_expiration",
disabled: false,
expirationDateMs: expiryTimeMs,
rawData: subscription,
recheckAfterMs: now + 2 * 60 * 1000,
});
await enqueueSubscriptionRecheck({
packageName,
subscriptionId,
purchaseToken,
runAtMs: now + 2 * 60 * 1000,
});
}
延迟任务执行时再次查询:
async function finalizeExpiration(
packageName: string,
subscriptionId: string,
purchaseToken: string
) {
const response =
await playDeveloperApiClient.purchases.subscriptions.get({
packageName,
subscriptionId,
token: purchaseToken,
});
const expiryTimeMs = Number(response.data.expiryTimeMillis ?? 0);
if (expiryTimeMs > Date.now()) {
await updateEntitlement(purchaseToken, {
state: "active",
disabled: false,
expirationDateMs: expiryTimeMs,
rawData: response.data,
});
return;
}
await updateEntitlement(purchaseToken, {
state: "expired",
disabled: true,
expirationDateMs: expiryTimeMs,
rawData: response.data,
});
}
实际实现可以使用 Cloud Tasks 等支持持久化和重试的机制,不要依赖 Cloud Function 内的 setTimeout。
4. 防止旧通知覆盖新状态
数据库更新必须保持单调性。较旧的 EXPIRED 通知不应覆盖已经处理过的、更晚的到期时间。
await admin.firestore().runTransaction(async transaction => {
const ref = admin.firestore()
.collection("subscriptions")
.doc(purchaseToken);
const snapshot = await transaction.get(ref);
const storedExpiry = Number(snapshot.data()?.expirationDateMs ?? 0);
const fetchedExpiry = Number(subscription.expiryTimeMillis ?? 0);
if (fetchedExpiry < storedExpiry) {
return;
}
transaction.set(ref, {
expirationDateMs: fetchedExpiry,
disabled: fetchedExpiry <= Date.now(),
autoRenewing: subscription.autoRenewing ?? false,
rawData: subscription,
updatedAt: admin.firestore.FieldValue.serverTimestamp(),
}, { merge: true });
});
除了比较 expiryTimeMillis,还应做到:
- 根据 Pub/Sub
messageId去重; - 允许同一事件被重复处理;
- 使用事务更新订阅记录和用户权益;
- 保存通知类型、接收时间和查询结果,便于审计;
- 不依赖通知到达顺序。
修正现有条件表达式
现有代码存在运算符优先级问题:
if (
subscription.status === 200 &&
subscription?.data?.paymentState === 1 ||
subscription?.data?.paymentState === 2
)
JavaScript 会将其解释为:
if (
(subscription.status === 200 &&
subscription?.data?.paymentState === 1) ||
subscription?.data?.paymentState === 2
)
只要 paymentState === 2,即使 HTTP 状态不是 200,条件也可能成立。应明确加上括号:
const paymentState = subscription.data.paymentState;
if (
subscription.status === 200 &&
(paymentState === 1 || paymentState === 2)
) {
// Process the subscription.
}
不过,paymentState 不应成为处理所有 RTDN 的唯一入口。取消、宽限、暂停和到期等状态不一定都满足这两个值,但后端仍需记录并协调这些状态。
Acknowledge 也应与数据库写入解耦
当前代码先更新 Firestore,再调用 acknowledge。如果数据库写入成功,而 acknowledge 暂时失败,后端可能已经授予权益,但 Google Play 仍将这笔购买视为未确认。
更稳妥的流程是:
- 查询并验证购买;
- 幂等地执行 acknowledge;
- 将 Google Play 状态和权益状态写入数据库;
- 任一步骤失败后都能安全重试。
客户端和服务端也不应在缺少控制的情况下同时确认同一笔购买。如果确实需要两端都能执行确认,应将”已经确认”视为成功状态,并正确处理重复 acknowledge 的响应。
新接口迁移建议
如果当前使用的 Google Play Developer API 版本支持,可以优先采用 purchases.subscriptionsv2.get。它提供了更明确的订阅生命周期、line item、自动续订计划和订阅状态字段,比旧版 SubscriptionPurchase 更适合构建状态机。
无论使用旧接口还是 subscriptionsv2,处理原则都一样:
- RTDN 是刷新信号,不是最终的授权结果;
- 查询结果比通知类型更适合用于决定权益;
- 取消自动续订不等于立即失去权益;
- 到期事件需要幂等处理,并防范延迟、重复和乱序;
- 对刚到期的订阅进行短暂复查,可以避免测试环境中的瞬时断权。
备注:内容仅供参考。
