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,很容易引起误判。
为什么通知会晚一天
免费试用到期后,订阅不一定会立即进入宽限期。通常会经历以下过程:
- 免费试用到期。
- Google Play 尝试收取首个付费周期的费用。
- 扣款失败或支付方式出现问题。
- Google Play 确认订阅进入 grace period。
- 状态变化产生 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 后:
- 验证 Pub/Sub 消息和开发者通知数据。
- 读取
packageName、subscriptionId和purchaseToken。 - 调用
purchases.subscriptions.get。已经迁移的系统则使用对应的purchases.subscriptionsv2.get。 - 用 API 返回的最新状态更新数据库。
- 根据订阅状态决定是否继续提供权益。
- 使用事件时间或查询结果的更新时间,避免旧通知覆盖新状态。
- 保证处理逻辑幂等,以应对重复投递。
订阅处于 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 响应,才能分辨支付状态变化、投递延迟、缓存数据和秒与毫秒的转换问题。
备注:内容仅供参考。
