所有文章
Deep dive阅读需 8 分钟

退款后撤销访问权限: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 那边不会替你做这件事。

通知含义你的服务器必须做什么
REFUNDApple 为一笔消耗型、非消耗型、自动续订或非续订购买办理了退款立即撤销非消耗型项目和订阅的访问权限;自行扣减消耗型项目的余额
REFUND_DECLINEDApple 拒绝了客户的退款请求无需处理,访问权限从未受到影响
REFUND_REVERSEDApple 撤回了此前已批准的退款恢复因 REFUND 通知而被移除的任何访问权限或余额
CONSUMPTION_REQUESTApple 需要你的证据才能决定是否退款在 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 次查询,因此它是为配合分页令牌的定时拉取而设计的,而不是持续轮询。

来源和延伸阅读

RefundHalt

App Store 和 Google Play 退款自动驾驶

继续阅读

下一项退款请求已经在路上。

读完又一封关于未能申辩退款的支持邮件所需的时间,足够您完成 RefundHalt 设置。