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,可以避免"用户关机三周 → 自动退款"。
服务端正确时序
- 收到 RTDN 的
SUBSCRIPTION_PURCHASED(notificationType=4)或 App 上送purchaseToken。 - 调状态接口:
GET https://androidpublisher.googleapis.com/androidpublisher/v3/applications/{packageName}/purchases/subscriptionsv2/tokens/{token}
- 判断
subscriptionState == SUBSCRIPTION_STATE_ACTIVE(PENDING 则不发权益、不 ack)。 - 校验:
purchaseToken是否已入库(全局唯一,可做唯一索引)、productId/basePlanId是否与售卖配置一致、externalAccountIdentifiers是否对得上你系统里的用户。 - 发权益(落库,幂等)。
- 立刻调 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常用值:1RECOVERED、2RENEWED、3CANCELED(用户主动取消,到期前别撤权)、4PURCHASED、5ON_HOLD(撤权但保留记录)、6IN_GRACE_PERIOD(保留权益,提示更新支付方式)、7RESTARTED、12REVOKED(立即撤权)、13EXPIRED(终态)、20PENDING_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)
}
自查清单
- 生产库里能不能查到 acknowledge 的调用日志?没有 = 三天后必退。
- ack 调用失败有没有重试和告警?静默失败等于没接。
- PENDING 状态是否被当成已支付发了权益?
- 升级换购时旧
linkedPurchaseToken的权益撤了吗? - 主动拒单走的是 revoke + fullRefund,还是"等它自动退"?
- 有没有按
orderId(不是purchaseToken)跑的每日 voidedpurchases 拉取? - 退款补漏作业停了 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
文中时间窗口、字段与错误码取自官方文档;三天窗口的实际到账节奏、退款回落时间未在本机实测。配额数字来自第三方文章,需自行核实。