PAYATHON 2026

Google Play Billing 订阅 expiryTimeMillis 不正确

支付阿杰

结论

不要根据 Real-time Developer Notifications(RTDN)的到达时间或消息内容判断订阅的最终状态。RTDN 只表示订阅状态可能发生了变化。收到通知后,应立即使用 purchaseToken 调用 Google Play Developer API 重新查询订阅,并以查询结果为准。

试用期结束后,Google Play 可能还要尝试扣款并确认支付状态,之后才会发送 SUBSCRIPTION_IN_GRACE_PERIOD。因此,这条通知不一定会在原 expiryTimeMillis 对应的时刻送达,也可能出现延迟、重复或乱序。

题目中的时间值还混用了秒和毫秒:

  • 1610231839 是 10 位 Unix 秒。
  • 1610455205968 是 13 位 Unix 毫秒。

按照 API 定义,expiryTimeMillis 是毫秒字符串。如果代码将它除以 1000 后仍命名为 expiryTimeMillis,很容易引起误判。

为什么通知会晚一天

免费试用到期后,订阅不一定会立即进入宽限期。通常会经历以下过程:

  1. 免费试用到期。
  2. Google Play 尝试收取首个付费周期的费用。
  3. 扣款失败或支付方式出现问题。
  4. Google Play 确认订阅进入 grace period。
  5. 状态变化产生 RTDN,再通过 Pub/Sub 投递到后端。

支付处理、重试和 Pub/Sub 投递都需要时间,所以通知和原到期时间之间可能存在间隔。RTDN 不能用作精确计时器,也不保证只投递一次或严格按照状态变化的顺序到达。

如果实际观察到的延迟长期保持为固定时长,也不能仅凭这一现象认定 Google Play 对 trial subscription 有正式的“一天延迟”规则。还需要结合通知的 eventTimeMillis、API 查询结果和付款状态判断。

为什么宽限期中的 expiryTimeMillis 会变化

进入 grace period 后,不能简单地把 expiryTimeMillis 理解为:

原试用到期时间 + 配置的完整宽限期

在宽限期内,Google Play 可能动态更新订阅的到期时间。某次查询得到的时间可能只是服务端当时计算出的有效时间,后续查询仍可能得到不同结果。因此:

  • 不要一直使用第一次查询到的 expiryTimeMillis。
  • 不要根据 grace period 配置自行计算最终失效时间。
  • 不要假设 RTDN 到达时查到的状态不会继续变化。
  • 对仍处于 grace period 的订阅,应允许后续通知或主动查询更新本地记录。

还要特别区分题目中所说的“通知关联的 subscription”。RTDN 本身通常只包含通知类型、purchaseToken、subscriptionId 等定位信息,不会直接提供完整的订阅购买对象。如果 expiryTimeMillis 来自 SDK 包装层、缓存或数据库,应确认这个对象是否确实是在收到通知后重新调用 Developer API 得到的。

推荐处理流程

收到 RTDN 后:

  1. 验证 Pub/Sub 消息和开发者通知数据。
  2. 读取 packageName、subscriptionId 和 purchaseToken。
  3. 调用 purchases.subscriptions.get。已经迁移的系统则使用对应的 purchases.subscriptionsv2.get。
  4. 用 API 返回的最新状态更新数据库。
  5. 根据订阅状态决定是否继续提供权益。
  6. 使用事件时间或查询结果的更新时间,避免旧通知覆盖新状态。
  7. 保证处理逻辑幂等,以应对重复投递。

订阅处于 grace period 时,一般应继续提供权益。是否真正终止权益,应根据 Google Play API 返回的当前状态判断,而不是使用本地推算的宽限期结束时间。

Node.js 示例

下面的示例只展示核心处理方式。鉴权和 Pub/Sub HTTP 验证部分需要按实际项目补充:

import { google } from "googleapis";

const androidPublisher = google.androidpublisher("v3");

async function handleSubscriptionNotification(message) {
  const developerNotification = JSON.parse(
    Buffer.from(message.data, "base64").toString("utf8")
  );

  const subscriptionNotification =
    developerNotification.subscriptionNotification;

  if (!subscriptionNotification) {
    return;
  }

  const packageName = developerNotification.packageName;
  const subscriptionId = subscriptionNotification.subscriptionId;
  const purchaseToken = subscriptionNotification.purchaseToken;
  const notificationType = subscriptionNotification.notificationType;

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

  const authClient = await auth.getClient();

  const response = await androidPublisher.purchases.subscriptions.get({
    auth: authClient,
    packageName,
    subscriptionId,
    token: purchaseToken,
  });

  const subscription = response.data;

  // expiryTimeMillis 是字符串形式的毫秒时间戳。
  // 使用 BigInt 可避免在通用处理代码中引入精度问题。
  const expiryTimeMillis = BigInt(subscription.expiryTimeMillis);
  const expiryDate = new Date(Number(expiryTimeMillis));

  await updateSubscriptionIdempotently({
    packageName,
    subscriptionId,
    purchaseToken,
    notificationType,
    expiryTimeMillis: expiryTimeMillis.toString(),
    expiryDate,
    paymentState: subscription.paymentState,
    autoRenewing: subscription.autoRenewing,
    rawSubscription: subscription,
  });
}

不要根据通知到达时间计算到期时间:

// 不推荐:宽限期长度和状态转换不应由业务服务自行推算。
const calculatedExpiry =
  notificationReceivedAt + configuredGracePeriodMillis;

应直接保存 Google Play 返回的值:

const expiryTimeMillis = subscription.expiryTimeMillis;

await database.subscriptions.update({
  purchaseToken,
  expiryTimeMillis,
  lastVerifiedAt: Date.now(),
});

时间戳处理注意事项

expiryTimeMillis 通常以字符串形式返回,转换前应先确认单位:

function parseExpiryTimeMillis(value) {
  if (!/^\d{13,}$/.test(value)) {
    throw new Error(`Unexpected expiryTimeMillis: ${value}`);
  }

  return new Date(Number(value));
}

如果日志系统需要记录 Unix 秒,应明确修改字段名:

const expiryTimeSeconds =
  BigInt(subscription.expiryTimeMillis) / 1000n;

不要保留这种名称与数值含义不一致的记录:

// 错误命名:值已经是秒,却仍叫 expiryTimeMillis。
const expiryTimeMillis =
  Number(subscription.expiryTimeMillis) / 1000;

时区也要统一。Unix 时间戳本身基于 UTC,但转换为日期显示时,01-10-2021 可能因为服务器时区不同而落在相邻日期。排查问题时,建议统一输出 ISO 8601:

console.log(
  new Date(Number(subscription.expiryTimeMillis)).toISOString()
);

最终建议

应把 RTDN 当作重新查询订阅的触发信号,而不是订阅状态快照。无论是 trial subscription、grace period、account hold 还是续订失败,都应以 Developer API 的最新响应为准,并允许 expiryTimeMillis 随状态处理继续变化。

如果收到通知后立即查询得到一个时间,稍后手动查询又得到另一个时间,通常说明 Google Play 在这段时间内更新了订阅状态。此时应使用后一次经过认证的 API 查询结果覆盖前一次结果。同时保留原始 RTDN、eventTimeMillis、通知接收时间以及每次 API 响应,才能分辨支付状态变化、投递延迟、缓存数据和秒与毫秒的转换问题。

备注:内容仅供参考。