PAYATHON 2026

如何通过 Pub/Sub 接收 Google Play 应用内购买通知

支付小周

明确结论

Google Play Console 没有专门用来“开启 in-app purchase 通知”的额外开关。只要 RTDN 已正确绑定 Google Cloud Pub/Sub,受支持的一次性商品购买就会通过 oneTimeProductNotification(旧文档中可能写作 OneTimePurchaseNotification)发送到同一个 Pub/Sub topic。

不过,RTDN 并不是一套完整的支付状态 Webhook。对于一次性商品,它主要发送以下两类事件:

  • ONE_TIME_PRODUCT_PURCHASED:一次性商品购买成功。
  • ONE_TIME_PRODUCT_CANCELED:处于 PENDING 状态的购买最终被取消。

确认、消耗等状态变化不会逐一触发通知。客户端购买成功后,仍应立即把 purchaseToken、productId 和 packageName 发送给后端,再由后端调用 Google Play Developer API 验证购买并发放权益。RTDN 主要用于处理客户端未能上报、延迟付款、退款和撤销等异步情况,不能作为唯一的支付入口。

退款或撤销购买通过 voidedPurchaseNotification 接收,也可以定期使用 Voided Purchases API 对账。

一次性商品支持哪些通知

一次性商品通知的数据结构通常如下:

{
  "version": "1.0",
  "packageName": "com.example.app",
  "eventTimeMillis": "1720000000000",
  "oneTimeProductNotification": {
    "version": "1.0",
    "notificationType": 1,
    "purchaseToken": "purchase-token",
    "sku": "product_id"
  }
}

notificationType 的含义如下:

值常量含义
1ONE_TIME_PRODUCT_PURCHASED一次性商品购买完成
2ONE_TIME_PRODUCT_CANCELED延迟付款或其他待处理购买最终被取消

所谓“一些类型”,准确地说,是 RTDN 只覆盖一次性商品生命周期中的部分事件,并不会提供完整的状态变更流。例如:

  • 普通一次性商品购买成功时,可以产生 ONE_TIME_PRODUCT_PURCHASED。
  • 原本处于 PENDING 状态的购买后来付款成功,也可以产生购买完成通知。
  • 原本处于 PENDING 状态的购买最终取消,可以产生 ONE_TIME_PRODUCT_CANCELED。
  • acknowledge、consume 和客户端查询购买记录等操作不会分别产生通知。
  • 促销、测试购买、旧版结算流程或某些特殊购买渠道是否产生通知,可能受购买方式和 Billing Library 版本影响,不能只根据 Pub/Sub 测试消息判断。

旧资料使用 OneTimePurchaseNotification,较新的文档和消息字段通常使用 OneTimeProductNotification 或 oneTimeProductNotification。实现时应以实际收到的 JSON 字段和当前官方 RTDN reference 为准。

正确的整体处理流程

正常购买和异步通知应分成两条处理路径:

正常路径:
客户端完成购买
  → 客户端把 purchaseToken 发送到后端
  → 后端调用 Google Play Developer API 验证
  → 后端保存订单并发放权益
  → 后端或客户端 acknowledge/consume

补偿路径:
Google Play 发布 RTDN
  → Pub/Sub 推送或拉取消息
  → 后端提取 purchaseToken
  → 再次调用 Google Play Developer API 查询真实状态
  → 幂等更新订单和权益

RTDN 消息本身不是购买凭证,也没有提供完整的订单状态。收到消息后,必须使用 purchaseToken 查询 Google Play Developer API,不能仅根据 notificationType 发放权益。

Google Play Console 和 Pub/Sub 配置

1. 创建 Pub/Sub topic

在 Google Cloud 中创建 topic,例如:

projects/example-project/topics/google-play-rtdn

然后创建 subscription,可选择以下两种方式:

  • Push subscription:Pub/Sub 将消息发送到 HTTPS 地址。
  • Pull subscription:后端通过 Pub/Sub client library 拉取消息。

如果只有 topic,没有 subscription,应用后端就无法消费消息。

2. 授予 Google Play 发布权限

需要允许 Google Play 对该 topic 执行 pubsub.topics.publish。通常应为 Google Play 使用的服务账号或服务代理授予:

roles/pubsub.publisher

将权限授予具体 topic 即可,不需要给出整个项目的管理权限。

3. 在 Play Console 中填写 topic

在应用的 monetization setup / real-time developer notifications 配置中填写完整的 topic 名称:

projects/example-project/topics/google-play-rtdn

保存配置后发送测试通知。

测试通知成功只能证明以下链路已经打通:

Play Console → Pub/Sub topic → subscription → 后端

它不能证明实际购买满足通知触发条件,也不能证明后端能够正确处理 oneTimeProductNotification。

4. 检查应用和购买条件

如果测试通知正常,但真实购买没有通知,可以重点检查:

  • 购买是否发生在配置了 RTDN 的同一个 Play Console 应用中。
  • packageName 是否完全一致。
  • 商品是否已激活,购买是否通过从 Google Play 安装的应用完成。
  • 客户端是否使用受支持版本的 Google Play Billing Library。
  • 后端是否只处理了 subscriptionNotification,却遗漏了 oneTimeProductNotification。
  • Pub/Sub subscription 是否存在积压、重试或 dead-letter 消息。
  • JSON 解析代码是否错误地假设每条消息都包含同一种通知对象。
  • 测试是否在购买后过早结束。RTDN 不保证与客户端购买回调同时到达。

Pub/Sub Push 接收示例

Pub/Sub push 请求中的业务数据位于 message.data,并经过 Base64 编码。下面是一个简化的 Node.js/Express 示例:

import express from "express";

const app = express();
app.use(express.json());

app.post("/google-play/rtdn", async (req, res) => {
  try {
    const encodedData = req.body?.message?.data;

    if (!encodedData) {
      return res.status(400).send("Missing Pub/Sub message data");
    }

    const notification = JSON.parse(
      Buffer.from(encodedData, "base64").toString("utf8")
    );

    if (notification.testNotification) {
      console.log("Received RTDN test notification");
    }

    if (notification.oneTimeProductNotification) {
      const event = notification.oneTimeProductNotification;

      await handleOneTimeProduct({
        packageName: notification.packageName,
        productId: event.sku,
        purchaseToken: event.purchaseToken,
        notificationType: event.notificationType,
        eventTimeMillis: notification.eventTimeMillis
      });
    }

    if (notification.voidedPurchaseNotification) {
      await handleVoidedPurchase(
        notification.voidedPurchaseNotification
      );
    }

    // 返回 2xx 后 Pub/Sub 才认为消息已处理。
    res.sendStatus(204);
  } catch (error) {
    console.error(error);

    // 非 2xx 会触发 Pub/Sub 重试。
    res.sendStatus(500);
  }
});

处理一次性商品时,还需要调用 Google Play Developer API 查询购买:

import { google } from "googleapis";

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

async function handleOneTimeProduct({
  packageName,
  productId,
  purchaseToken,
  notificationType
}) {
  const auth = new google.auth.GoogleAuth({
    scopes: ["https://www.googleapis.com/auth/androidpublisher"]
  });

  const result = await androidPublisher.purchases.products.get({
    auth,
    packageName,
    productId,
    token: purchaseToken
  });

  const purchase = result.data;

  if (purchase.purchaseState === 0) {
    // 使用 purchaseToken 或订单号执行幂等写入。
    await grantEntitlementOnce({
      packageName,
      productId,
      purchaseToken,
      orderId: purchase.orderId
    });
  }

  if (purchase.purchaseState === 2) {
    // 仍为 pending,不应发放权益。
  }

  if (purchase.purchaseState === 1) {
    // 已取消。根据本地订单状态撤销或保持未发放状态。
  }
}

部分新购买模型可以使用 purchases.productsv2.getproductpurchasev2。具体使用哪个接口,取决于当前的商品模型和 API 支持情况。处理原则不变:使用 RTDN 中的 token 查询服务端状态,不直接信任通知本身。

如何接收退款和撤销通知

退款、拒付及其他购买作废事件使用 voidedPurchaseNotification:

{
  "version": "1.0",
  "packageName": "com.example.app",
  "eventTimeMillis": "1720000000000",
  "voidedPurchaseNotification": {
    "purchaseToken": "purchase-token",
    "orderId": "GPA.1234-5678-9012-34567",
    "productType": 2,
    "refundType": 1
  }
}

常见字段包括:

  • purchaseToken:原购买 token。
  • orderId:Google Play 订单号。
  • productType:区分订阅与一次性商品。
  • refundType:区分全额退款与受支持的部分退款形式。

收到通知后,应通过 purchaseToken 或 orderId 查找本地订单,再根据业务规则撤销尚未消费的权益:

async function handleVoidedPurchase(event) {
  const order = await findOrder({
    purchaseToken: event.purchaseToken,
    orderId: event.orderId
  });

  if (!order) {
    // 记录后交给对账任务处理,不能静默丢弃。
    return;
  }

  await revokeEntitlementIdempotently({
    orderId: order.orderId,
    refundType: event.refundType
  });
}

退款后不一定能直接“回收商品”。例如,消耗型商品可能已经兑换成虚拟货币并被使用,因此具体撤销方式需要由业务系统决定。遇到部分数量退款时,也不能无条件撤销整笔订单。

为避免漏单,还应定期调用 Voided Purchases API 对账。RTDN 用于提供低延迟通知,Voided Purchases API 则负责补偿消息丢失、处理失败、部署中断以及历史数据回查。

必须注意的可靠性问题

Pub/Sub 和 RTDN 都应按至少一次投递设计:

  • 同一通知可能重复到达。
  • 通知可能延迟到达。
  • 不同事件可能乱序。
  • 后端处理失败时,Pub/Sub 会重试。
  • 测试通知与真实购买通知的数据结构不同。

数据库应围绕 purchaseToken、orderId 和事件类型建立幂等处理机制。即使收到两次 ONE_TIME_PRODUCT_PURCHASED,也不能发放两次商品。

使用 push subscription 时,还应验证 Pub/Sub 推送请求携带的 OIDC token,并限制 endpoint 只接受预期的 audience、issuer 和 service account。能够解析 Base64 数据,并不能证明请求确实来自 Google。

购买成功的主链路仍然是“客户端上报 token,后端立即验证”。RTDN 只负责异步补偿和状态同步。这样,即使一次性商品通知发生延迟,或者某个购买流程没有产生 RTDN,支付处理也不会被卡住。

备注:内容仅供参考。