PAYATHON 2026

如何通过 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
  • packageName
  • productId
  • purchaseToken

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

基本配置步骤如下:

  1. 在 Google Cloud 项目中启用 Google Play Developer API。
  2. 创建 Pub/Sub topic。
  3. 授予 Google Play 对该 topic 的发布权限。
  4. 在 Play Console 的开发者通知设置中填写该 topic。
  5. 创建 Pub/Sub push subscription,将消息推送到后端的 HTTPS endpoint。
  6. 配置服务账号和 API 权限,使后端能够调用 Google Play Developer API。
  7. 使用 Play Console 的测试通知功能验证连接。

官方文档入口:

  • https://developer.android.com/google/play/billing/rtdn-reference
  • https://developer.android.com/google/play/billing/getting-ready
  • https://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}

响应中需要重点关注:

  • subscriptionState
  • acknowledgementState
  • latestOrderId
  • linkedPurchaseToken
  • lineItems[].productId
  • lineItems[].expiryTime
  • lineItems[].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 存在替换关系。

后端应当:

  1. 查找 linkedPurchaseToken 对应的旧订阅记录。
  2. 将旧记录标记为已替换。
  3. 把新的 purchaseToken 关联到同一个业务用户。
  4. 避免旧 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 更新权益及订单记录。

备注:内容仅供参考。