代码 用户被自动退款、后台已发货:Google Play 订阅的 acknowledge 三天窗口与 RTDN 去重

2026-10-22 09:01:32

Google Play 订阅接入:漏掉 acknowledge 会被自动退款,RTDN 不去重会重复发货

上线一周,后台冒出两类工单。一类是用户截图说钱被退了、会员却还在用;另一类是同一个 orderId 发了两份货,运营在后台手工回滚。两件事最后都落到同一个位置:服务端对 acknowledge 三天窗口和 RTDN 回查顺序的处理。下面按事故链拆开写,环境是 Android 客户端用 Play Billing 调起支付,服务端用 Google Play Developer API(androidpublisher v3)+ Cloud Pub/Sub 接 RTDN;只适用于 Google Play 渠道的数字商品。结论来自官方文档(2026-09/10 更新),代码片段未在本机实测。

RTDN 只是“状态变了”的信号,不能当真相

RTDN 走的是 Pub/Sub:手机端不直接打你的服务器,Google Play 往你自己 GCP 项目里的 topic 发布,后端用 push 或 pull 订阅消费。topic 上要把 google-play-developer-notifications@system.gserviceaccount.com 加成 Pub/Sub Publisher,否则 Play Console 里「Send Test Message」直接失败。

消息体长这样:

{"message":{"data":"","messageId":"..."},"subscription":"projects/p/subscriptions/s"}

data 解开是 DeveloperNotification:version、packageName、eventTimeMillis,以及互斥的 subscriptionNotification / oneTimeProductNotification / voidedPurchaseNotification / pendingRefundReviewNotification / testNotification。通知只说明状态发生变化,不携带完整信息。拿通知体里的金额、到期时间当真相去发货,就是第一类事故的起点:必须先回查 purchases.subscriptionsv2.get(订阅)或 purchases.productsv2.getproductpurchasev2(一次性商品),再改本地状态。

messageId 必须用来去重。Pub/Sub 是至少一次投递,重复是常态;不去重就不只是重复发货,还会白烧 API 配额。同一订单重复发货基本都能在这里找到原因。

还有一个容易踩的配置:通知类型是二选一,要么「订阅 + 所有作废购买」,要么「全部(含一次性商品)」。只开前一档却卖一次性商品,ONE_TIME_PRODUCT_PURCHASED 永远收不到,用户付了钱后台一片安静。

三天窗口:不 acknowledge 就自动退款

新订阅、重新订阅(过期后在 Play 商店重新购买)、预付费计划,都必须在窗口内调用 purchases.subscriptions.acknowledge 或客户端 acknowledgePurchase()。超时不确认,Play 自动退款,而你的后端可能已经发了货——这就是“用户被自动退款 + 后台已发货”。

自动续订的续费(SUBSCRIPTION_RENEWED)不需要 acknowledge,只更新到期时间;对续费也去 ack 是多余调用。

一次性商品分两种:消耗型用 purchases.products.consume(同时完成 ack,并让用户能再次购买),非消耗型用 purchases.products.acknowledge。调用前先看返回体的 consumptionState(0 未消耗 / 1 已消耗)和 acknowledgementState(0 未确认 / 1 已确认),别重复调。

预付费计划的窗口更短:时长 ≥1 周按 3 天;<1 周按计划时长的一半,3 天计划只有 1.5 天。充值(top-up)各自要确认,漏掉会「撤回充值 + 撤回剩余订阅 + 退款」。

PENDING(延迟付款方式、预订单)不要发货也不要 ack。purchaseState:0 Purchased、1 Canceled、2 Pending。三天窗口从 PENDING 转到 PURCHASED 才开始算;在 PENDING 期间确认,等于把付款还没完成的订单标成已完成。

拒绝订单走 revoke,别等窗口过期

文档明确说不要把「不 ack」当拒绝手段:窗口过期后订单停在未确认状态,Play 无法区分「开发者主动拒绝」和「后端处理超时」。

  • 订阅:purchases.subscriptionsv2.revoke,revocationContext 传 {"fullRefund": {}}。
  • 一次性商品:orders.refund 传 revoke=true,需要 orderId。
  • orders.refund 不传 revoke 只退款不撤权,也不会产生作废通知。
  • 被拒订单不要再调 consume / acknowledge。

漏单兜底与对账

voidedpurchases.list 查已作废订单(退款/撤单/chargeback/开发者撤销),最多只返回近 30 天,startTime 默认 30 天前;默认 type=0 只返回一次性商品,要订阅必须 type=1。订阅场景必须用 orderId 而不是 purchaseToken 作主键,因为同一 token 会有多笔续订订单。接口需要「查看财务报告」权限,配额是每包每天 6000 次,且任意 30 秒内不超过 30 次。

purchaseToken 从订阅签约到过期后 60 天有效,超过就查不了。本地必须存 purchaseToken + orderId(GPA. 开头)+ expiryTime。

linkedPurchaseToken:升级/降级/重新签约/预付费与自动续订互转时,旧 token 出现在新订阅的这个字段里,用来合并同一用户的两条订阅记录。过期后从商店中心重新购买则不带 linkedPurchaseToken,改看 outOfAppPurchaseContext(含 expiredPurchaseToken 与 expiredExternalAccountIdentifiers),该字段在 acknowledge 之后消失。

用户归属靠 setObfuscatedAccountId:服务端在 externalAccountIdentifiers(订阅)/ obfuscatedExternalAccountId(一次性商品)里取值挂账号。应用外完成的购买没有这些标识,需要兜底策略(发给当前登录用户或让用户选账号)。

测试与促销单要过滤:ProductPurchase.purchaseType 0=测试(license testing 账号)、1=促销码、2=看广告奖励;订阅资源里有 testPurchase 字段。不过滤,测试单会进财务报表。

其他通知类型:SUBSCRIPTION_ON_HOLD、SUBSCRIPTION_IN_GRACE_PERIOD、SUBSCRIPTION_PAUSED、SUBSCRIPTION_DEFERRED、SUBSCRIPTION_REVOKED、SUBSCRIPTION_EXPIRED、SUBSCRIPTION_PENDING_PURCHASE_CANCELED、SUBSCRIPTION_CANCELLATION_SCHEDULED、SUBSCRIPTION_PRICE_STEP_UP_CONSENT_UPDATED;subscriptionState 枚举为 SUBSCRIPTION_STATE_ACTIVE / PENDING / PAUSED / IN_GRACE_PERIOD / ON_HOLD / CANCELED / EXPIRED / PENDING_PURCHASE_CANCELED。宽限期和账号保留要单独给权益策略,不要当成过期直接断权。

消费顺序与 Go 骨架

  1. 校验请求来自 Pub/Sub:push 端点用 OIDC token,pull 用服务账号。裸端点会被伪造消息刷。
  2. base64 解 data,取 messageId,先 INSERT 去重表(唯一索引)再处理。
  3. 按 notificationType 分流,不信任通知体里的业务字段。
  4. 回查 Get API,检查 subscriptionState / purchaseState。
  5. 事务内写订单 + 发权益,幂等键用 orderId。
  6. 发货成功后 ack/consume;失败重试,ack 放在最后。
  7. 定时任务扫「已发货但 acknowledgementState 仍为 PENDING」的订单(窗口内补 ack),以及 voidedpurchases.list 兜底撤权。
// 代码片段未在本机实测
type rtdnEnvelope struct {
Message struct {
Data      string `json:"data"`
MessageID string `json:"messageId"`
} `json:"message"`
}

func handlePush(w http.ResponseWriter, r *http.Request) {
// 1. 先验 OIDC token,再读 body
var env rtdnEnvelope
json.NewDecoder(r.Body).Decode(&env)

raw, _ := base64.StdEncoding.DecodeString(env.Message.Data)
var n DeveloperNotification // version/packageName/eventTimeMillis + 互斥的通知字段
json.Unmarshal(raw, &n)

// 2. messageId 唯一索引,INSERT 失败即重复投递,直接返回 200
if !dedup.Insert(env.Message.MessageID) {
w.WriteHeader(http.StatusOK)
return
}

// 3. 按通知类型分流;4. 回查权威状态
//    sub, _ := subV2.Get(pkg, token).Do()        // purchases.subscriptionsv2.get
//    op,  _ := prodV2.Getproductpurchasev2(...).Do()

// 5. 事务:orderId 幂等写订单 + 发权益
// 6. 发货成功后 acknowledge / consume,ack 放最后
//    renew(SUBSCRIPTION_RENEWED)只更新到期时间,不 ack
}

文档

出问题时的排查顺序

  1. 翻重复发货的 orderId,看去重表有没有 messageId 唯一索引,以及 ack 是否在发货之前被调用。
  2. 拉出「已发货但 acknowledgementState=0」的订单,按购买时间排序,检查是否跨过三天窗口(预付费按 ≥1 周 3 天、<1 周半时长)。
  3. 检查消费路径:消耗型有没有先看 consumptionState,非消耗型有没有先看 acknowledgementState,判断是否重复调用。
  4. 核对 purchaseState 与 PENDING 订单,确认没有在 PENDING 期间发货或确认。
  5. 检查 Play Console 的通知类型档位,确认一次性商品的通知能收到。
  6. 跑 voidedpurchases.list(订阅用 type=1,主键 orderId),比对本地已发货记录,撤掉已作废订单的权益。
  7. 检查本地是否存了 purchaseToken + orderId + expiryTime,以及 60 天有效期的清理任务是否会误删仍需回查的 token。

推荐文章

程序员茄子在线接单