退款后撤销访问权限:Apple 和 Google 不会替你完成的这一步
Apple 和 Google 都可能在退款完成后,仍让客户保留其购买内容。以下是访问权限何时会自动移除,何时需要由你的服务器来处理,以及大多数集成从未处理过的那一个通知。

要点
- 在 Google Play 上,为订单退款本身并不会移除用户的访问权限。只有在 Play Console 中勾选了 "Remove entitlement",或者 API 调用传入了 revoke=true 时,访问权限才会被移除,而这并非默认设置。
- 未启用撤销选项而发出的退款,根本不会出现在 Voided Purchases API 中。如果撤销任务只读取这个数据源,就会悄无声息地遗漏每一笔这样的订单。
- 对于非消耗型项目和订阅,Apple 可以自动撤销授权,但消耗型项目一旦被使用,就已被视为已交付,因此 StoreKit 没有任何机制可以撤销它。你的服务器必须自行扣减余额。
- Apple 也可以撤回自己发出的退款。REFUND_REVERSED 通知意味着此前批准的退款已被撤回,revocationDate 会重新变回 null,你此前移除的任何访问权限或余额都必须恢复。
- Google 的 voidedReason 字段共有九种取值,从 remorse 和 accidental_purchase,到 fraud、friendly_fraud 以及 unacknowledged_purchase,因此告诉你要撤销访问权限的同一个数据源,也会告诉你哪些撤销属于滥用行为,哪些只是你自己的集成漏洞。
- 已退款的购买内容每多保留一天访问权限,你就要为背后的计算资源、存储和 API 调用多支付一天费用,而这笔钱早已退还给了客户。
批准退款和移除访问权限是两个独立的步骤,其中只有一个是自动发生的。认为退款处理完成就等于购买已被撤销的开发者,在两大应用商店上都想错了,只是原因和方式各不相同。清楚知道自动化到底止于何处,正是在退款后真正撤销访问权限所需要的,而不是几个月后才发现这件事从未发生。
为什么退款默认不会撤销访问权限
Apple 和 Google 都把资金和访问权限当作两种不同的操作来处理。资金这一侧是商店政策:退款一旦获批,就是最终结果,账本随之更新。访问权限这一侧则是可选的,或者是开发者自己的责任,或者两者兼有,具体取决于平台和购买类型。两家商店都不会假定你希望退款一完成访问权限就立刻消失,因为很多合理情形(部分退款、善意抵扣、客服姿态)本来就不打算把产品拿走。
Google Play:那个没人注意的复选框
在 Play Console 中,为订单退款是一个分两部分的表单:先选择退款金额,再单独决定是否勾选 "Remove entitlement"。大多数客服人员默认不勾选它,因为大多数退款请求都是善意举动,客户会保留自己买到的东西。问题在于,退款确认界面上没有任何提示告诉你,这同样意味着客户会在一笔你本想彻底了结的退款之后,继续保留订阅或应用内项目。
API 路径也有同样的陷阱
代码里也存在同样的选择。orders.refund 接口接受一个可选的查询参数 revoke;当它为 true 时,该项目的访问权限会立即终止,对于连续订阅,未来所有的付款也会一并取消。如果省略这个参数,它默认是 false:退款照常提交,购买记录随之更新,用户则继续像以前一样使用产品。
| 操作 | 是否移除访问权限? | 是否出现在 Voided Purchases API 中? |
|---|---|---|
| Play Console 退款,未勾选 "Remove entitlement" | 否 | 否 |
| Play Console 退款,已勾选 "Remove entitlement" | 是,立即生效 | 是 |
| orders.refund API,省略 revoke 参数(默认 false) | 否 | 否 |
| orders.refund API,revoke=true | 是,立即生效 | 是 |
最后那一行正是让集成栽跟头的地方。Voided Purchases API 的存在,是为了让你无需手动查看 Play Console 就能搭建撤销流程,但它只列出那些确实带撤销的作废订单。未勾选复选框、或未设置 revoke=true 处理的退款,对那个数据源来说是设计上的不可见,而不是漏洞。

解读 voidedReason 字段 当一笔购买确实显示为作废时,voidedReason 字段会告诉你原因,共有九种取值:other、remorse、not_received、defective、accidental_purchase、fraud、friendly_fraud、chargeback 和 unacknowledged_purchase。这不仅仅是记账。remorse 和 accidental_purchase 属于普通退款。fraud、friendly_fraud 和 chargeback 则值得转入欺诈审核队列,而不是简单地撤销了事,因为从统计上看,同一个账号更有可能再次出现这种情况。
如果你直接基于这个数据源开发,有一个操作细节很重要:purchases.voidedpurchases.list 每天最多 6,000 次查询,任何 30 秒时间窗口内最多 30 次查询。如果设计成紧密循环轮询,很快就会撞到这个上限;预期的模式是配合分页令牌的定时拉取,而不是持续轮询。
Apple:App Store 可以撤销授权,但撤销不了消耗型项目
Apple 的 App Store Server Notifications V2 会在退款完成的那一刻发送 REFUND 通知,无论是消耗型项目、非消耗型项目,还是自动续订订阅和非续订订阅,都一视同仁。这个通知之后会发生什么,完全取决于购买类型,因为 StoreKit 自身的撤销机制只覆盖这四种类型里的两种。
为什么消耗型项目是例外
非消耗型项目或订阅拥有一种持续性权益,Apple 的系统可以将其关闭。消耗型项目则不同:一旦被使用,就被视为已交付,StoreKit 里已经没有什么可撤销的了。如果客户购买了 500 枚金币,花掉之后又获得退款,从 Apple 的角度看这些金币早已不存在。唯一还留有这笔记录的账本是你自己的。REFUND 通知是你唯一的信号,让你去找到匹配的原始交易 ID,然后从自己的记录里扣减那笔余额;Apple 那边不会替你做这件事。
| 通知 | 含义 | 你的服务器必须做什么 |
|---|---|---|
| REFUND | Apple 为一笔消耗型、非消耗型、自动续订或非续订购买办理了退款 | 立即撤销非消耗型项目和订阅的访问权限;自行扣减消耗型项目的余额 |
| REFUND_DECLINED | Apple 拒绝了客户的退款请求 | 无需处理,访问权限从未受到影响 |
| REFUND_REVERSED | Apple 撤回了此前已批准的退款 | 恢复因 REFUND 通知而被移除的任何访问权限或余额 |
| CONSUMPTION_REQUEST | Apple 需要你的证据才能决定是否退款 | 在 12 小时内回应;此通知发生在 REFUND 之前,而不是之后 |
几乎没人处理的那个通知
REFUND_REVERSED 是大多数集成完全跳过的那个通知,因为它描述的是 Apple 撤回了自己的决定,这种情况很少有开发者在第一版设计里就考虑到。它到来时,交易上的 revocationDate 会重新变回 null:在 Apple 看来,这笔购买再次有效。如果你的服务器在最初的 REFUND 到来时删除了某项权益、取消了订阅记录、或把消耗型余额清零,那么 REFUND_REVERSED 就是把这一切恢复原状的信号。
如何逐步补上这个漏洞
- 在 Google Play 上,无论是通过 Play Console 还是 API,都把每条退款路径默认设为 revoke=true,除非某个具体的支持场景要求让客户保留访问权限。
- 使用带分页令牌的定时任务来查询 purchases.voidedpurchases.list,而不是紧密循环轮询,并将请求量控制在每天 6,000 次以内、每 30 秒 30 次以内。
- 把 voidedReason 值为 fraud、friendly_fraud 和 chargeback 的情况转入审核队列,而不是自动撤销并结案。
- 在 Apple 一侧,订阅 App Store Server Notifications V2,一旦 REFUND 通知中提到某个非消耗型项目或订阅,就立即撤销其访问权限。
- 为每一笔消耗型购买都保存好原始交易 ID,这样 REFUND 通知到达时就能直接找到要扣减的确切余额,而不必手动查找。
- 明确处理 REFUND_REVERSED,并将每一次撤销都与其交易 ID 或订单 ID 关联记录下来,这样在撤回发生时就能精确恢复被移除的内容。
这个漏洞实际上要付出什么代价
这与对客户是否公平无关,而是关于钱已经退回之后,仍在持续运转的那部分。一个已退款却仍在调用你的 API、存储数据、消耗计算资源的订阅,不是道德问题,而是一笔实实在在的账目:你为一笔已经退还了收入的购买支付了基础设施账单。商店不会替你追踪这笔成本,因为从它们的角度看,退款在资金转移的那一刻就已经完成。访问权限这一侧完全是你自己要控制的账单。
这正是 RefundHalt 把撤销当作退款流程的一部分,而不是事后补救的原因:REFUND 事件和访问权限变更被记录为同一个动作,REFUND_REVERSED 事件会自动恢复状态,而不是等着某个人注意到客户仍被锁在 Apple 已经归还的东西之外。
常见问题解答
- 在 Google Play 上为购买退款会自动撤销访问权限吗?
- 不会。只有在 Play Console 中勾选了 "Remove entitlement",或者 orders.refund 的 API 调用包含 revoke=true 时,访问权限才会被移除。这两者都不是默认设置,因此退款可能已经完成,客户却仍在继续使用产品。
- 在 App Store 上,一笔消耗型应用内购买被退款后会发生什么?
- Apple 会发送 REFUND 通知,但 StoreKit 没有办法撤销消耗型项目,因为它一旦被花费就被视为已交付。开发者的服务器必须自行找到原始交易,并手动扣减余额。
- 什么是 REFUND_REVERSED 通知,它是否需要特殊处理?
- REFUND_REVERSED 意味着 Apple 撤回了此前已批准的退款;revocationDate 会重新变回 null,该笔购买再次有效。最初的 REFUND 所移除的任何访问权限或余额都必须恢复,而大多数集成根本从未监听过这个通知。
- 为什么一笔已退款的 Google Play 订单不会出现在 Voided Purchases API 中?
- 该 API 只会列出带撤销的作废订单。如果一笔退款是在未启用撤销选项的情况下处理的,这个接口根本不会返回该订单,因此完全依赖它的流程会彻底遗漏这些退款。
- 我可以多频繁地查询 Google 的 Voided Purchases API?
- 该接口的速率限制为每天 6,000 次查询,任何 30 秒窗口内最多 30 次查询,因此它是为配合分页令牌的定时拉取而设计的,而不是持续轮询。
来源和延伸阅读
- Apple: Handling refund notifications
- Apple: notificationType (App Store Server Notifications)
- Apple: revocationReason (App Store Server Notifications)
- Google: Voided Purchases API overview
- Google: purchases.voidedpurchases REST resource
- Google: orders.refund API reference (revoke parameter)
- Google Play Console Help: manage your app's orders and issue refunds
RefundHalt
App Store 和 Google Play 退款自动驾驶
继续阅读
The refunds you cannot contest: what Apple and Google decide without asking you
Only two refund flows ever ask for your evidence: Apple's consumption request and Google Play's chargeback review. Everything else, the 48-hour Play refund, support refunds, voided purchases, unacknowledged trials, is decided without you. Here is the full map, what you can prevent, and what you can only record.
Apple's CONSUMPTION_REQUEST, explained: the 12 hours that decide your refund
When a customer asks Apple for a refund, Apple asks you first. Here's exactly what the CONSUMPTION_REQUEST notification contains, what Apple expects back, how to answer one end to end, and why most developers silently forfeit.