PAYATHON 2026

Google Play in-app billing subscription callbacks 如何实现?

支付阿杰

结论

可以跟踪订阅续费、取消、宽限期、账号保留、暂停、撤销、到期等状态变化,但不能依靠旧版 InAppBillingService 的客户端回调来完成。

推荐采用以下服务端方案:

  1. 在 Google Play Console 中启用 Real-time Developer Notifications(RTDN)。
  2. 通过 Google Cloud Pub/Sub 接收订阅事件。
  3. 收到通知后,使用其中的 purchaseToken 调用 Google Play Developer API 的 purchases.subscriptionsv2.get。
  4. 根据 Developer API 返回的订阅状态,更新本地数据库和用户权益。

RTDN 只用于提示订阅状态可能发生了变化。它既不是完整订单,也不能说明款项已经结算到 merchant account。是否续费成功,可以通过订阅状态判断;实际结算金额、手续费、退款和打款情况,则需要查看 Google Play 财务报表或付款报告。

为什么客户端回调不够

客户端可以在购买和订阅流程中获取购买结果及 purchaseToken,但订阅是一个持续变化的状态。即使应用没有运行,用户的订阅也可能发生以下变化:

  • 取消自动续订
  • 完成下一周期续费
  • 续费扣款失败
  • 进入宽限期
  • 进入账号保留状态
  • 恢复订阅
  • 被退款或撤销
  • 到期失效

因此,客户端购买响应只能反映当次购买流程的结果,无法持续反映后续周期的订阅状态。

旧版 InAppBillingService 已不适合用于新的实现。客户端应改用 Google Play Billing Library,服务端则通过 RTDN 和 Google Play Developer API 管理订阅状态。

推荐架构

Google Play
    │
    │ RTDN
    ▼
Google Cloud Pub/Sub
    │
    │ Push 或 Pull
    ▼
订阅通知处理服务
    │
    │ 使用 purchaseToken 查询
    ▼
Google Play Developer API
    │
    ▼
本地订阅数据库 / 用户权益系统

这套架构中,通知负责触发查询,Developer API 提供当前的权威状态。

配置实时订阅通知

在 Google Play Console 中为应用配置 Real-time Developer Notifications,并关联一个 Google Cloud Pub/Sub topic。

接着创建 Pub/Sub subscription。接收方式有两种:

  • Push:Pub/Sub 将消息发送到你的 HTTPS endpoint。
  • Pull:服务器主动拉取消息。

生产环境通常还需要完成以下配置:

  • 授予 Google Play 服务账号向 topic 发布消息的权限。
  • 为处理服务配置调用 Android Publisher API 所需的凭据。
  • 验证 Push endpoint 的身份。
  • 对消息做幂等处理。
  • 配置重试和死信队列。

Google Cloud 和 Google Play Console 更新后,具体入口和 IAM 角色名称可能发生变化,请以当前控制台及官方文档为准。

接收 RTDN 消息

Pub/Sub Push 请求中的消息数据位于 message.data,内容是经过 Base64 编码的 JSON。

下面是一个简化的 Node.js/Express 示例:

import express from "express";

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

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

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

  let notification;

  try {
    const json = Buffer.from(pubsubMessage.data, "base64").toString("utf8");
    notification = JSON.parse(json);
  } catch (error) {
    return res.status(400).send("Invalid notification payload");
  }

  try {
    const subscriptionNotification = notification.subscriptionNotification;

    if (!subscriptionNotification) {
      // 也可能是 oneTimeProductNotification、testNotification 等。
      return res.status(204).send();
    }

    const { purchaseToken, notificationType } = subscriptionNotification;

    // 先持久化消息,并使用 messageId 或业务键进行幂等去重。
    await saveIncomingNotification({
      messageId: pubsubMessage.messageId,
      packageName: notification.packageName,
      purchaseToken,
      notificationType,
      eventTimeMillis: notification.eventTimeMillis
    });

    // 不直接根据 notificationType 修改最终权益,
    // 而是查询 Developer API 获得订阅的当前状态。
    await refreshSubscriptionFromGooglePlay({
      packageName: notification.packageName,
      purchaseToken
    });

    return res.status(204).send();
  } catch (error) {
    console.error(error);

    // 返回非 2xx,使 Pub/Sub 按策略重试。
    return res.status(500).send("Processing failed");
  }
});

app.listen(3000);

saveIncomingNotification 和 refreshSubscriptionFromGooglePlay 是业务函数,需要根据数据库、鉴权方式和部署环境自行实现。

查询订阅的权威状态

收到 RTDN 后,调用以下接口:

GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}
Authorization: Bearer {access_token}

对应的 API 是:

purchases.subscriptionsv2.get

处理返回结果时,应重点检查:

  • subscriptionState
  • lineItems[].expiryTime
  • lineItems[].autoRenewingPlan.autoRenewEnabled
  • lineItems[].productId
  • acknowledgementState
  • 取消、暂停及其他上下文信息

示例处理逻辑:

async function refreshSubscriptionFromGooglePlay({
  packageName,
  purchaseToken
}) {
  const accessToken = await getGoogleAccessToken();

  const url =
    `https://androidpublisher.googleapis.com/androidpublisher/v3/` +
    `applications/${encodeURIComponent(packageName)}/` +
    `purchases/subscriptionsv2/tokens/${encodeURIComponent(purchaseToken)}`;

  const response = await fetch(url, {
    headers: {
      Authorization: `Bearer ${accessToken}`
    }
  });

  if (!response.ok) {
    throw new Error(
      `Google Play API returned ${response.status}: ${await response.text()}`
    );
  }

  const subscription = await response.json();

  await updateLocalSubscription({
    purchaseToken,
    subscriptionState: subscription.subscriptionState,
    acknowledgementState: subscription.acknowledgementState,
    lineItems: subscription.lineItems ?? [],
    rawResponse: subscription
  });
}

不要仅凭 RTDN 的 notificationType 判断用户是否仍有权益。通知可能延迟、重复或乱序,而 Developer API 的查询结果反映的是查询时的当前订阅状态。

如何判断“用户取消了订阅”

取消自动续订并不等于立即失去权益。

用户关闭自动续订后,通常仍可在已经支付的周期内继续使用服务,直到 expiryTime。因此,本地数据最好分别保存:

autoRenewEnabled = false
entitlementActive = true
expiryTime = 当前付费周期结束时间

只有当订阅到期、被撤销,或者查询结果明确表明权益已经失效时,才应停止服务。

可以把权益判断写成独立逻辑,例如:

function hasEntitlement(subscription, now = new Date()) {
  const activeStates = new Set([
    "SUBSCRIPTION_STATE_ACTIVE",
    "SUBSCRIPTION_STATE_IN_GRACE_PERIOD"
  ]);

  if (!activeStates.has(subscription.subscriptionState)) {
    return false;
  }

  return (subscription.lineItems ?? []).some((item) => {
    if (!item.expiryTime) {
      return false;
    }

    return new Date(item.expiryTime) > now;
  });
}

宽限期内是否继续提供服务,取决于产品策略,也会受到 Play Console 配置的影响。账号保留状态通常意味着需要暂停权益,但具体处理方式仍应结合业务要求和当前的 Google Play Billing 规则确认。

是否能由服务器取消订阅

服务器可以调用 Google Play Developer API 取消订阅,但需要谨慎选择取消方式。

当前 API 体系提供了类似以下接口:

POST https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}:cancel
Authorization: Bearer {access_token}
Content-Type: application/json

请求体示例:

{
  "cancellationContext": {
    "cancellationType": "USER_REQUESTED_STOP_RENEWALS"
  }
}

不同的取消类型可能只停止后续续费,也可能由开发者停止付款并影响现有权益。可用枚举及其具体行为,应以调用时的 Android Publisher API 文档为准。不能把所有“取消”都理解成立即失效。

如果只是让用户自行管理订阅,通常应打开 Google Play 的订阅管理页面,而不是由应用模拟取消流程。

如何判断每个周期是否续费成功

续费成功后,RTDN 会发送对应的订阅通知。处理服务收到消息后,应再次查询 purchases.subscriptionsv2.get,确认以下信息:

  • 订阅状态是否有效
  • expiryTime 是否已经推进到新的周期
  • 当前 line item 和产品是否符合预期
  • 订阅是否进入宽限期或账号保留状态

本地应记录每次查询结果和更新后的到期时间,但不能假设每个续费周期只会收到一条通知。消息可能重复,短时间内也可能连续出现多次状态变化。

可以结合以下信息设计幂等规则:

  • Pub/Sub messageId
  • purchaseToken
  • notificationType
  • eventTimeMillis
  • 查询得到的 expiryTime

续费成功不等于商户已收到结算款

RTDN 和订阅查询用于判断购买结果及订阅生命周期,不能用于确认银行结算情况。

即使订阅状态显示续费成功,实际款项仍可能受到以下因素影响:

  • 税费
  • Google Play 服务费
  • 汇率换算
  • 后续退款或拒付
  • 付款周期差异
  • 结算账户调整

因此,“用户续费成功”可以作为更新用户权益的依据。至于款项是否按期转入 merchant account,需要通过 Google Play 财务报告、收益报告或付款记录进行对账。这两类状态不应保存在同一个字段中。

实现时容易忽略的问题

确认购买

新的订阅购买需要按照 Google Play Billing 的要求及时执行 acknowledge。确认操作既可以在客户端完成,也可以通过对应的 Developer API 在服务端完成。购买如果没有在规定时间内确认,可能会被自动退款。

保存 purchaseToken

purchaseToken 是服务端查询订阅时使用的主要标识。购买完成后,应立即将它安全地上传到服务器,并与应用包名、用户账号和产品建立关联。

订阅升级、降级或重新订阅时,可能产生新的 token 或 token 关联关系,因此不能长期假设一个用户只有一个 token。

幂等与乱序

RTDN 通过 Pub/Sub 投递,业务处理必须允许通知重复。不能把“收到一次通知”直接理解为“执行了一次扣款”或“增加了一个订阅周期”。

定期校验

RTDN 应作为主要触发机制,同时建议增加低频后台校验任务。对于即将到期、处于宽限期或长时间没有更新的订阅,可以重新调用 Developer API,减少通知配置错误或处理异常造成的影响。

不要信任客户端状态

客户端返回值可以改善界面体验,但服务器必须结合 purchaseToken 和 Google Play Developer API 的查询结果决定最终权益。客户端提交的产品 ID、到期时间或“购买成功”标记,都不能直接作为授权依据。

备注:内容仅供参考。