PAYATHON 2026

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

测试订阅的续订周期通常只有几分钟。生产环境中不易察觉的处理延迟,在测试环境里会非常明显:

  1. 当前测试周期到达 expiryTimeMillis。
  2. 续订交易尚未完成,Google Play 暂时没有识别到有效的新周期。
  3. RTDN 生成或投递 SUBSCRIPTION_EXPIRED。
  4. 续订结算稍后完成,新的到期时间被写入订阅状态。
  5. 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 仍将这笔购买视为未确认。

更稳妥的流程是:

  1. 查询并验证购买;
  2. 幂等地执行 acknowledge;
  3. 将 Google Play 状态和权益状态写入数据库;
  4. 任一步骤失败后都能安全重试。

客户端和服务端也不应在缺少控制的情况下同时确认同一笔购买。如果确实需要两端都能执行确认,应将”已经确认”视为成功状态,并正确处理重复 acknowledge 的响应。

新接口迁移建议

如果当前使用的 Google Play Developer API 版本支持,可以优先采用 purchases.subscriptionsv2.get。它提供了更明确的订阅生命周期、line item、自动续订计划和订阅状态字段,比旧版 SubscriptionPurchase 更适合构建状态机。

无论使用旧接口还是 subscriptionsv2,处理原则都一样:

  • RTDN 是刷新信号,不是最终的授权结果;
  • 查询结果比通知类型更适合用于决定权益;
  • 取消自动续订不等于立即失去权益;
  • 到期事件需要幂等处理,并防范延迟、重复和乱序;
  • 对刚到期的订阅进行短暂复查,可以避免测试环境中的瞬时断权。

备注:内容仅供参考。