代码 Google Play 订阅三天后自动退款:acknowledge 不在 subscriptionsv2 上,在已弃用的 v1 里

2026-11-15 09:01:30

Google Play 订阅三天后自动退款:acknowledge 不在 subscriptionsv2 上,在已弃用的 v1 里

现象:首购后第 3 天规律退款

上线两周后,后台出现一批退款单。金额不大,但很规律:全部是首购后第 3 天左右退款。

Google Play 的退款回执里 voidedReason=8,对应 Unacknowledged_purchase(未确认购买)。

服务端日志看起来干净:没有 5xx,没有异常,订阅状态查询一直返回 SUBSCRIPTION_STATE_ACTIVE。用户确实拿到了权益,也确实扣了款,但三天后钱退回去了、权益被 revoke。

根因:v2 只负责读,ack 留在 v1

团队为了"不用已弃用的接口",把订阅状态全部读 purchases.subscriptionsv2.get。

这个资源是当前唯一正确的订阅状态读取入口,但它没有 acknowledge 方法。确认购买(acknowledge)只存在于被标记为 deprecated 的 purchases.subscriptions.acknowledge(v1)。

结果就是:状态读得对,权益发得对,就是从来没有调用过 ack。三天窗口一到,Google 自动退款并撤销权益。

purchases.subscriptions.get(v1 读接口)已弃用,不应在新接入中使用;但 v1 资源下的其它方法(含 acknowledge)仍在用。

一句话:v2 用来读,v1 的 acknowledge 用来确认,两者必须同时接。

ack 的时间规则

  • 订阅首购:必须在 3 天内 ack,否则自动退款并撤销。
  • 续期(renewal)不需要 ack,只有首购需要。
  • 预付费计划(prepaid plan):每次购买和每次 top-up 都要 ack。时长 ≥1 周,3 天内;时长 <1 周,必须在计划时长的一半内。例:3 天预付费计划只有 1.5 天。
  • PENDING 状态不开始计时:3 天窗口从 PENDING 转 PURCHASED 那一刻算起。PENDING 期间不要 ack,也不要在此时发权益。
  • 应用内确认只是兜底:用户不打开 App 时,服务端在收到 RTDN 后直接 ack,可以避免"用户关机三周 → 自动退款"。

服务端正确时序

  1. 收到 RTDN 的 SUBSCRIPTION_PURCHASED(notificationType=4)或 App 上送 purchaseToken。
  2. 调状态接口:
GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}
  1. 判断 subscriptionState == SUBSCRIPTION_STATE_ACTIVE(PENDING 则不发权益、不 ack)。
  2. 校验:purchaseToken 是否已入库(全局唯一,可做唯一索引)、productId/basePlanId 是否与售卖配置一致、externalAccountIdentifiers 是否对得上你系统里的用户。
  3. 发权益(落库,幂等)。
  4. 立刻调 ack:
POST .../purchases/subscriptions/{subscriptionId}/tokens/{token}:acknowledge

调用前先看 acknowledgementState,已经是 ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED 就跳过。

拒单不要靠"等窗口过期"

风控或校验没过时,不要"不 ack 等它自动退"。Google 文档明确说:不 ack 导致的状态是"未确认",Play 无法区分"开发者主动拒单"和"后端超时",这是有歧义的。

正确做法:

  • 订阅:POST purchases.subscriptionsv2.revoke,body 为 {"revocationContext":{"fullRefund":{}}},立即终止订阅、撤权、全额退款。
  • 一次性商品:orders.refund 并把 revoke=true。
  • 被拒绝的购买不要调 acknowledge / consume。
  • 自己主动 revoke 会触发 RTDN 的 SUBSCRIPTION_REVOKED 和一条 VoidedPurchaseNotification,Voided Purchases 里 voidedSource=1(1=开发者,0=用户,2=Google)。

升级、降级、换购:linkedPurchaseToken

用户从 basic 升级到 premium,会拿到一个新 purchaseToken,v2 响应里 linkedPurchaseToken 指向旧 token。

必须 ack 新 token,同时撤掉旧 token 的权益,否则同一份订阅在两个 token 上各发一次权益。

linkedPurchaseToken 出现的场景还包括:取消未到期后重新订阅、预付费与自动续期互转、预付费 top-up。跨版本换购时 v2 还会带 lineItems[].itemReplacement(replacementMode 如 CHARGE_PRORATED_PRICE)。

另一种是"完全过期后从 Play 订阅中心重新购买":这是全新 token,且 v2 响应里有 outOfAppPurchaseContext(含 expiredPurchaseToken 和 expiredExternalAccountIdentifiers)。这个字段在 ack 之后消失,想靠它把新 token 关联到老用户,就得在 ack 之前读。

token 生命周期与错误码

  • purchaseToken 从订阅创建有效到"到期后 60 天",之后不能再用于调 Developer API。
  • 410 subscriptionNoLongerAvailable:订阅已过期超过 60 天,别再查。
  • 410 purchaseTokenNoLongerValid:永久失效(用户账号被删 / 购买记录不存在)。
  • 400 purchaseTokenMismatch:token 与 packageName/subscriptionId/productId 对不上。
  • 400 invalidPurchaseState:状态不允许该操作(比如对已 consume 的再 consume)。
  • 400 productNotOwnedByUser:token 有效但用户当前不拥有(可能已退款 / 撤销 / 过期)。
  • 409 concurrentUpdate:并发修改,指数退避重试。
  • 400 subscriptionExpired:对已过期订阅做不允许的操作。

补漏:Voided Purchases API

GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/voidedpurchases
  • 只能查最近 30 天;startTime 不能早于 30 天前,且过滤依据是"系统记录为 voided 的时间",不是响应里的 voidedTimeMillis。
  • type=0 只返回一次性商品,type=1 同时返回订阅;要接订阅退款必须设 type=1。
  • 接订阅时必须用 orderId 做 join 键:一个 purchaseToken 覆盖该订阅的所有续期,续期单共享 token,靠 token 分不清是哪一期。
  • voidedReason:0 Other / 1 Remorse / 2 Not_received / 3 Defective / 4 Accidental_purchase / 5 Fraud / 6 Friendly_fraud / 7 Chargeback / 8 Unacknowledged_purchase。
  • voidedSource:0 用户 / 1 开发者 / 2 Google。
  • includeQuantityBasedPartialRefund=true 才会返回 quantity 部分退款的 voidedQuantity;剩余数量被全部退掉时该字段不返回,作为"已全退"的信号。
  • maxResults 默认 1000,最大 1000。
  • 开发者主动退款但不带 revoke=true,不会出现在这个 API 里,也不会撤权——想让用户失去权益就必须带 revoke。
  • 第三方文章提到配额约每天 6000 次、30 秒内 30 次(需自行核实),所以按时间窗分页拉,不要按订单逐条查。
  • 漏 ack 导致的退款(voidedReason=8)可能压根不在你的库里,所以补漏作业不能只对着本地订单表做。

RTDN 是 Pub/Sub,不是回调

  • 消息体是 Pub/Sub 信封,data 字段是 base64 的 DeveloperNotification。PHP 里要用 file_get_contents("php://input") 读原始 body,$_POST 是空的。
  • 语义:至少一次投递、不保证顺序、可能重复。
  • 所以先按 messageId 去重(保留 24–48 小时),再回查 v2 拿当前状态,用 API 结果覆盖本地,而不是按 notificationType 直接改状态。乱序的两条通知各自回查一次,最终状态会收敛到正确的值。
  • push 订阅默认 ack deadline 10 秒,可在订阅上调整;端点要在 deadline 内返回 2xx。5xx 会重试;4xx 可能被当成永久错误丢弃。
  • notificationType 常用值:1 RECOVERED、2 RENEWED、3 CANCELED(用户主动取消,到期前别撤权)、4 PURCHASED、5 ON_HOLD(撤权但保留记录)、6 IN_GRACE_PERIOD(保留权益,提示更新支付方式)、7 RESTARTED、12 REVOKED(立即撤权)、13 EXPIRED(终态)、20 PENDING_PURCHASE_CANCELED。
  • testNotification 是控制台发的测试消息,只用于验证链路。

Go 片段:ack 幂等

type SubV2 struct {
    SubscriptionState    string `json:"subscriptionState"`
    AcknowledgementState string `json:"acknowledgementState"`
    LatestOrderID        string `json:"latestOrderId"`
    LinkedPurchaseToken  string `json:"linkedPurchaseToken"`
    LineItems []struct {
        ProductID  string `json:"productId"`
        ExpiryTime string `json:"expiryTime"`
    } `json:"lineItems"`
}

func ensureAcked(ctx context.Context, pkg, subID, token string) error {
    sub, err := play.GetSubV2(ctx, pkg, token)
    if err != nil {
        return err
    }
    if sub.SubscriptionState == "SUBSCRIPTION_STATE_PENDING" {
        return nil // PENDING 不计时,也不发权益
    }
    if sub.AcknowledgementState == "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED" {
        return nil
    }
    // 失败要重试:窗口只有 3 天,别把 ack 失败吞掉
    return play.AckSubscription(ctx, pkg, subID, token)
}

自查清单

  1. 生产库里能不能查到 acknowledge 的调用日志?没有 = 三天后必退。
  2. ack 调用失败有没有重试和告警?静默失败等于没接。
  3. PENDING 状态是否被当成已支付发了权益?
  4. 升级换购时旧 linkedPurchaseToken 的权益撤了吗?
  5. 主动拒单走的是 revoke + fullRefund,还是"等它自动退"?
  6. 有没有按 orderId(不是 purchaseToken)跑的每日 voidedpurchases 拉取?
  7. 退款补漏作业停了 30 天以上会不会永久丢数据?

官方链接

  • 订阅生命周期:https://developer.android.com/google/play/billing/lifecycle/subscriptions
  • purchases.subscriptionsv2 资源:https://developers.google.com/android-publisher/api-ref/rest/v3/purchases.subscriptionsv2
  • Voided Purchases:https://developers.google.com/android-publisher/voided-purchases
  • RTDN 参考:https://developer.android.com/google/play/billing/rtdn-reference
  • 安全与验单建议:https://developer.android.com/google/play/billing/security

文中时间窗口、字段与错误码取自官方文档;三天窗口的实际到账节奏、退款回落时间未在本机实测。配额数字来自第三方文章,需自行核实。

复制全文 生成海报 支付 谷歌订阅 对账 接口对接

推荐文章

程序员茄子在线接单