如何通过 Google Play Billing 实现自动续订通知
结论
可以实现。目前推荐使用 Google Play 的 Real-time Developer Notifications(RTDN)。订阅状态变化后,Google Play 会通过 Google Cloud Pub/Sub 主动通知后端。
需要注意,RTDN 不是完整账单,也不能直接作为续订成功的最终凭据。通知通常只包含 packageName、purchaseToken、subscriptionId 和事件类型。后端收到通知后,必须调用 Google Play Developer API 的 purchases.subscriptionsv2.get 查询订阅的权威状态,再更新数据库。
整体流程如下:
首次购买
Android app → purchaseToken → backend
自动续订或状态变化
Google Play → Cloud Pub/Sub → backend
↓
purchases.subscriptionsv2.get
↓
验证并更新数据库
Android app 只需在首次购买、恢复购买或购买状态变化时,将 purchaseToken 与业务用户关联并发送给后端。后续自动续订不依赖用户再次打开 app。
为什么不能只处理通知内容
RTDN 只说明”订阅发生了某种变化”,并不是完整的订阅凭证。通知可能对应:
- 成功续订
- 续订付款失败
- 进入宽限期
- 账号保留
- 暂停或恢复
- 用户取消自动续订
- 到期
- 撤销
- 套餐替换
因此,收到 SUBSCRIPTION_RENEWED 后,不能简单地给到期时间增加一个月。套餐周期、宽限期、延期和套餐替换等情况,都可能让这种计算产生错误。
正确做法是用通知中的 purchaseToken 查询 Google Play,并以 API 返回的 subscriptionState 和 lineItems[].expiryTime 为准。
一、首次购买时建立用户与 purchaseToken 的关联
Android app 完成购买后,将以下信息发送给后端:
- 当前登录用户的内部 ID
packageNameproductIdpurchaseToken
Kotlin 示例:
val purchaseToken = purchase.purchaseToken
backendApi.registerGooglePlayPurchase(
userId = currentUser.id,
packageName = applicationContext.packageName,
productId = productId,
purchaseToken = purchaseToken
)
后端收到数据后,应立即调用 Google Play Developer API 验证,不能信任客户端提交的 productId 或购买状态。
验证成功后,在数据库中建立类似关系:
user_id
package_name
product_id
purchase_token
subscription_state
expiry_time
latest_order_id
updated_at
purchaseToken 是后端定位订阅的关键字段。不要依赖邮箱、设备 ID 或 Google Play 订单号关联业务用户。
首次购买还必须按照 Google Play Billing 的要求及时 acknowledge。Android app 和后端都可以完成 acknowledge,后端需要调用相应的 Developer API。无论采用哪种方式,都应确保每笔购买得到确认。
二、配置 Real-time Developer Notifications
基本配置步骤如下:
- 在 Google Cloud 项目中启用 Google Play Developer API。
- 创建 Pub/Sub topic。
- 授予 Google Play 对该 topic 的发布权限。
- 在 Play Console 的开发者通知设置中填写该 topic。
- 创建 Pub/Sub push subscription,将消息推送到后端的 HTTPS endpoint。
- 配置服务账号和 API 权限,使后端能够调用 Google Play Developer API。
- 使用 Play Console 的测试通知功能验证连接。
官方文档入口:
https://developer.android.com/google/play/billing/rtdn-referencehttps://developer.android.com/google/play/billing/getting-readyhttps://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2/get
Google Play Console 和 Google Cloud Console 更新后,控制台名称及授权界面可能发生变化,具体操作应以上述官方文档当前显示的内容为准。
三、后端接收 Pub/Sub 通知
Pub/Sub push 请求的主体通常是一个信封,其中 message.data 是经过 Base64 编码的 RTDN JSON。
解码后的订阅通知大致如下:
{
"version": "1.0",
"packageName": "com.example.app",
"eventTimeMillis": "1710000000000",
"subscriptionNotification": {
"version": "1.0",
"notificationType": 2,
"purchaseToken": "example-purchase-token",
"subscriptionId": "premium_monthly"
}
}
RTDN 还可能包含一次性商品通知或测试通知,因此代码不能假定每条消息都有 subscriptionNotification。
下面是一个简化的 Node.js/Express 示例:
import express from "express";
import { google } from "googleapis";
const app = express();
app.use(express.json());
const auth = new google.auth.GoogleAuth({
scopes: ["https://www.googleapis.com/auth/androidpublisher"]
});
const androidPublisher = google.androidpublisher({
version: "v3",
auth
});
app.post("/google-play/rtdn", async (req, res) => {
try {
const pubsubMessage = req.body?.message;
if (!pubsubMessage?.data) {
return res.status(400).send("Missing Pub/Sub message");
}
const notification = JSON.parse(
Buffer.from(pubsubMessage.data, "base64").toString("utf8")
);
const subscriptionNotification =
notification.subscriptionNotification;
if (!subscriptionNotification) {
// 可能是 testNotification 或其他类型的开发者通知。
return res.status(204).send();
}
const packageName = notification.packageName;
const purchaseToken =
subscriptionNotification.purchaseToken;
const notificationType =
subscriptionNotification.notificationType;
// 应先检查 packageName 是否属于自己的应用。
if (!isAllowedPackageName(packageName)) {
return res.status(400).send("Unknown package");
}
const result = await androidPublisher.purchases.subscriptionsv2.get({
packageName,
token: purchaseToken
});
await updateSubscriptionTransactionally({
packageName,
purchaseToken,
notificationType,
subscription: result.data,
pubsubMessageId: pubsubMessage.messageId
});
// 返回 2xx 表示消息已处理,Pub/Sub 可以确认该消息。
return res.status(204).send();
} catch (error) {
console.error("RTDN processing failed", error);
// 非 2xx 通常会触发 Pub/Sub 重试。
return res.status(500).send("Retry later");
}
});
生产环境还应验证 Pub/Sub push 请求的身份。不能仅凭请求的 JSON 格式正确,就认定它来自 Google。
四、查询订阅的真实状态
调用接口:
GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}
响应中需要重点关注:
subscriptionStateacknowledgementStatelatestOrderIdlinkedPurchaseTokenlineItems[].productIdlineItems[].expiryTimelineItems[].autoRenewingPlan
示意响应:
{
"subscriptionState": "SUBSCRIPTION_STATE_ACTIVE",
"latestOrderId": "GPA.1234-5678-9012-34567..0",
"acknowledgementState": "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED",
"lineItems": [
{
"productId": "premium_monthly",
"expiryTime": "2026-09-29T08:00:00Z",
"autoRenewingPlan": {
"autoRenewEnabled": true
}
}
]
}
更新数据库时,应直接使用 Google 返回的 expiryTime,不要在原到期时间上自行增加一个 billing cycle。
例如:
async function updateSubscriptionTransactionally({
packageName,
purchaseToken,
notificationType,
subscription,
pubsubMessageId
}) {
await database.transaction(async (tx) => {
if (await tx.processedEvents.exists(pubsubMessageId)) {
return;
}
const lineItems = subscription.lineItems ?? [];
const latestExpiryTime = lineItems
.map(item => item.expiryTime)
.filter(Boolean)
.sort()
.at(-1);
await tx.subscriptions.upsert({
packageName,
purchaseToken,
subscriptionState: subscription.subscriptionState,
acknowledgementState: subscription.acknowledgementState,
latestOrderId: subscription.latestOrderId ?? null,
expiryTime: latestExpiryTime ?? null,
rawResponse: subscription
});
await tx.processedEvents.insert({
eventId: pubsubMessageId,
notificationType,
processedAt: new Date()
});
});
}
如果一个订阅包含多个 lineItems,实际业务应根据具体 productId、套餐替换关系和 entitlement 规则逐项处理,不能只取最大的到期时间。上面的代码只是单套餐场景的简化示例。
五、如何判断用户是否仍有权益
不能只检查 autoRenewEnabled。
用户取消自动续订后,在当前已付款周期结束前通常仍有权益。付款失败时,订阅可能进入宽限期或账号保留状态。此时是否继续提供权益,取决于产品配置和业务策略。
建议分开存储以下两个概念:
subscription_state // Google Play 的订阅状态
entitlement_active // 业务系统当前是否授予权益
常见处理原则如下:
| 情况 | 一般处理 |
|---|---|
订阅有效且未过 expiryTime | 保持权益 |
| 用户关闭自动续订但当前周期未结束 | 保持权益,标记不会续订 |
| 宽限期 | 通常保持权益,具体取决于产品配置 |
| 账号保留 | 通常暂停权益 |
| 已到期或已撤销 | 移除权益 |
| API 查询暂时失败 | 不要立即删除权益,进入重试或待确认状态 |
最终判断需要同时参考 subscriptionState、expiryTime 和应用自身的权益策略。
六、套餐变更与 linkedPurchaseToken
用户升级、降级或重新订阅时,Google Play 可能返回 linkedPurchaseToken,表示当前购买与之前的订阅 token 存在替换关系。
后端应当:
- 查找
linkedPurchaseToken对应的旧订阅记录。 - 将旧记录标记为已替换。
- 把新的
purchaseToken关联到同一个业务用户。 - 避免旧 token 和新 token 同时重复授予权益。
不能假定一个用户始终只有一个不变的 token。
七、订单号和”最新 bill”的存储
可以存储 latestOrderId,作为最近订单的参考信息,但要区分以下概念:
purchaseToken:标识和查询订阅购买的关键凭据。latestOrderId:最近相关订单的标识,不适合作为订阅主键。- RTDN:状态变化提示。
subscriptionsv2.get响应:当前订阅状态的权威来源。
如果需要保存每次续订的完整财务流水,只保存订阅当前快照并不够。可以把每次观察到的新 latestOrderId 写入单独的订单表,同时设置唯一约束,因为 RTDN 可能重复投递。
CREATE UNIQUE INDEX uk_google_play_order_id
ON google_play_orders(latest_order_id);
Google Play Developer API 返回的是订阅状态和订单相关信息,不一定包含完整的”账单”字段。税务、结算和财务报表需求,通常还需要结合 Google Play Console 提供的财务报告处理。
重要注意事项
- RTDN 采用至少一次投递语义,同一事件可能收到多次,处理逻辑必须幂等。
- 通知可能延迟、重复或乱序,不能只按到达顺序修改状态。
- 每次收到通知后都重新查询当前状态,可以减少乱序通知造成的问题。
- 应先持久化事件或完成事务,再向 Pub/Sub 返回成功状态。
- API 暂时不可用时,可以返回非 2xx,或者先将消息写入可靠队列,再返回 2xx。
- 对
packageName使用白名单校验。 - 不要把服务账号密钥放进 Android app。
- 不要信任客户端提交的到期时间、订单号或商品 ID。
- 建议增加周期性 reconciliation 任务,定期复查仍处于有效、宽限期或异常状态的订阅。RTDN 用于实时触发,但不应是唯一的数据修复机制。
- app 启动时仍可调用 Billing Library 查询购买并同步到后端,用于首次绑定、恢复购买和容灾。自动续订本身不再依赖用户打开 app。
因此,这套方案整体可行,但不能把通知正文当作完整的 bill。更可靠的流程是:Google Play 发送 RTDN → 后端使用 purchaseToken 查询订阅 → 根据 API 返回的状态和 expiryTime 更新权益及订单记录。
备注:内容仅供参考。
