StoreKit 2 退款检测归结于交易上的一个属性,也就是 revocationDate
当 Apple 为你的某位客户退款时,在你的服务器任务运行之前,这笔退款其实已经通过交易的 revocationDate 出现在你的 app 内部了。本文说明 StoreKit 2 退款检测在设备上出现的位置,revocationDate 和 revocationReason 告诉你什么,以及为什么客户端负责速度、服务器负责真相。

要点
- 在 StoreKit 2 中,已退款的购买会在其 Transaction 上带有非 nil 的 revocationDate,因此你的 app 无需调用服务器即可自行检测到退款。
- revocationDate 会在 App Store 为某笔交易退款、或客户通过 Family Sharing 失去该购买时被设置,所以非 nil 的日期并不总是意味着退款。
- revocationReason 告诉你原因:developerIssue 表示客户提到了你 app 中的问题,other 则涵盖其余所有退款原因。
- Transaction.currentEntitlements 已经排除了已退款和已撤销的购买,所以最干净的客户端访问判断,就是看某个产品是否仍出现在其中。
- 只有当你在启动时就开始监听,Transaction.updates 才会送达在 app 关闭期间发生的退款,因此缺少一个 Task 就意味着漏掉一笔退款。
- 客户端检测只在 app 打开时触发,这正是 App Store Server Notifications V2 的 REFUND 仍然是权威信号的原因,它能阻止你继续为已退款用户付费提供服务。
- 退款可以被撤销,一旦撤销,交易上的撤销字段会被移除,你需要恢复此前切断的访问权限。
大多数 app 是从自己的服务器、通过一条 App Store Server Notification 才得知 Apple 退款的,却从未注意到同一笔退款其实早已存在于 app 内部。它就在交易上,位于一个名为 revocationDate 的属性里,读取它可以让你的 app 在已退款客户下次打开时就切断其访问权限,而不必等待后端任务。StoreKit 2 退款检测是一个大多数团队会忽略的客户端信号。下面就说明退款究竟在设备上的哪里浮现,它告诉你什么、不告诉你什么,以及为什么它应当与你的服务器通知并存,而不是取而代之。
StoreKit 2 中退款出现的位置
StoreKit 2 以签名值的形式把交易交给你,退款并不会删除交易,而是给它做上标记。Transaction 上有两个属性承载这个标记,在一笔健康购买的整个生命周期里,它们都保持 nil。一旦其中之一变为非 nil,就说明 App Store 已经收回了这笔购买。
revocationDate 是会翻转的那个字段
revocationDate 是一个可选的 Date。Apple 自己的描述很精确:它是 App Store 为该交易退款、或将其从 Family Sharing 中撤销的日期。对于仍然有效的购买,它是 nil。退款一旦被处理,它就保存那次退款的时间戳。这一项检查,revocationDate 是否非 nil,就是客户端退款检测的全部。其余的一切,都是在它之上叠加的细节。
revocationReason 告诉你 Apple 为何收回它
revocationReason 就在日期旁边,解释其原因。StoreKit 为退款给出两个有意义的值。developerIssue 表示客户告诉 Apple,退款是由于你 app 中实际存在或被认为存在的问题。other 涵盖其余所有原因。第三个值 upgradedToBundle 根本不是退款;它标记的是 App Store 因客户转到某个订阅捆绑包而撤销的交易。在采取行动前先读取原因,因为 developerIssue 才是值得统计的那个:一批这样的原因,就是你自己的产品在告诉你它在哪里出了问题。
| 属性 | 类型 | 非 nil 值意味着什么 |
|---|---|---|
revocationDate | Date? | App Store 在这一天为此交易退款,或通过 Family Sharing 撤销了它 |
revocationReason 为 developerIssue | 原因 | 客户提到了你 app 中实际存在或被认为存在的问题 |
revocationReason 为 other | 原因 | 退款出于 Apple 未逐项列出的其他原因 |
revocationReason 为 upgradedToBundle | 原因 | 不是退款;该交易因客户切换到订阅捆绑包而被撤销 |
currentEntitlements 已经会剔除已退款的购买
你并不总是需要自己去读取那些撤销字段。Transaction.currentEntitlements 是客户此刻仍然有权享用的购买序列,Apple 在构建它时就会剔除那些你不应再兑现的项。被 App Store 退款或撤销的产品不会出现在其中。已过期的订阅、以及一旦用完就消失的消耗型商品也不会出现。
这使得 currentEntitlements 成为最干净的访问判断。问它客户拥有什么,就恰好授予那些,退款会替你移除相应权益,而无需任何一次 revocationDate 检查。撤销字段用于你想要细节的时候,也就是日期和原因,用来记录事件或对其作出反应。权益列表用于回答那个朴素的问题:是否继续为其亮灯。
实践中的 StoreKit 2 退款检测:启动时与运行中
你的 app 在设备上有两个时刻可以捕获退款,它们需要不同的代码。一个是在 app 打开时,退款实时发生或在另一台设备上发生。另一个是在启动时,补上关闭期间发生的所有变化。漏掉第二个,你的 StoreKit 2 退款检测就会在大多数退款所落之处正好留下一个漏洞,因为客户在申请退款时很少还开着你的 app。
在启动时就开始监听,否则你会漏掉关闭期间发生的退款
Transaction.updates 是一个异步序列,每当系统在你的 app 之外或另一台设备上创建或更新一笔交易时,它就会发出这笔交易,退款也在其中。Apple 的指示很直接:在你的 app 一启动时就开一个 Task 去迭代它,否则你可能会漏掉那些只在启动时送达一次的交易。一笔在夜间落下的退款,会在 app 下次打开时通过 updates 送达,但前提是已经有一个监听器在运行以接收它。没有监听器,就没有事件,退款会一直不可见,直到别的东西来对账。
同一台设备上的购买不会通过 updates 送达
有一个陷阱会绊倒那些手动测试退款的人。在同一台设备上完成的普通购买不会通过 updates 到达;StoreKit 会直接从购买调用的结果中返回它。updates 用于那些带外变化:退款、Ask to Buy 的批准、兑换码的兑换,以及在别处完成的购买。所以要围绕 updates 和 currentEntitlements 来构建你的退款处理,而不是围绕购买流程,因为退款永远不会沿着销售所走的那条路径退回来。

客户端检测无法为你做到什么
在设备上读取退款既快又免费,但它有一个上限,假装它没有上限,正是收入流失的方式。设备只知道 StoreKit 告诉它的东西,而 StoreKit 只在你的 app 运行时才说话。一位拿到退款后再也不打开你 app 的客户,就是你的客户端检查永远看不到的客户。
revocationDate 并不总是意味着退款
同一个字段也会因 Family Sharing 而翻转。当客户失去对某笔共享购买的访问权,因为组织者移除了他、或共享结束了,那笔交易同样会得到一个 revocationDate。所以非 nil 的日期意味着客户不再拥有这笔购买,这正是你做访问控制所需要的,但它并不总是意味着有钱退了出去。如果你在为收入统计退款,先把 Family Sharing 的撤销和真正的退款分开,再去相信那个数字。
退款可以被撤销
退款并不总是最终的。Apple 可以撤销它,一旦撤销,交易上的撤销字段会被移除,购买重新有效。如果你在退款时切断了访问,你需要在撤销时把它恢复。在设备上,这会表现为又一个 updates 事件,带着一笔干净的交易;在你的服务器上,它是一条独立的 REFUND_REVERSED 通知。只处理退款,你就会把一位付费客户晾在那里,没有访问权,却握着一张有效的收据。
一次迟来的撤销究竟要花多少钱
退款很少只是售价从你账户里离开。等它结清时,你通常已经为那笔购买实际花掉了真金白银,而这笔支出并不会回来。生成的图片消耗了 GPU 分钟。聊天回答消耗了按 token 计费的模型 API 调用。上传消耗了你至今仍在付费保存的存储。如果这笔购买资助了给某位创作者的付款,那笔钱早已出门。这些都不会随退款而回。
客户端检测会在你仍能控制的那一部分上收窄窗口,也就是未来的支出。你越早知道一笔购买被退款,就越早停止为它提供服务。但设备只在 app 打开时才告诉你,所以一位再也不回来的已退款用户,会保留你在服务器端授予的一切访问权,每当一个后台任务或一台已同步的设备代表他行动时,都在悄悄让你付出代价。客户端让撤销变快,却不能让它有保障。
用客户端求速度,用服务器求真相
干净的设计会让两种信号各展所长。在设备上,Transaction.updates 和 currentEntitlements 在已退款客户打开 app 的那一刻给你一个即时的本地反应,适合处理 UI、也适合在无需往返服务器的情况下完成权益变更。在服务器上,App Store Server Notifications V2 会发出一条 REFUND 消息,无论 app 是否会再被打开它都会到达,这是唯一能可靠阻止你的后端在已退款账户上继续花钱的信号。
| 信号 | 它存在于何处 | 何时触发 | 信任它来 |
|---|---|---|---|
交易上的 revocationDate | 设备,StoreKit 2 | 你的 app 读取该交易 | 告诉你某笔具体购买被退款或撤销 |
Transaction.updates | 设备,StoreKit 2 | 退款在 app 运行时落下,或在启动时(如果你监听) | 为在场的客户即时作出反应 |
currentEntitlements | 设备,StoreKit 2 | 你检查客户此刻拥有什么 | 无需自己追踪退款即可管控访问 |
REFUND 通知 | 你的服务器,App Store Server Notifications V2 | Apple 处理退款,无论 app 是否打开 | 停止在一位再也不回来的客户身上的服务器端支出 |
为拿着手机的客户接入设备信号,为不在场的那位接入服务器通知。退款出现在两个地方是有意为之。只读取其中之一,正是一个已退款账户在销售早已消失后仍持续让你花钱的方式。
常见问题解答
- 我如何在 StoreKit 2 中检测退款?
- 检查交易的 revocationDate。对于有效购买它是 nil,一旦 App Store 为该交易退款它就保存一个日期,所以非 nil 的 revocationDate 就是某笔购买被退款或撤销的信号。
- revocationDate 和 revocationReason 有什么区别?
- revocationDate 是 App Store 收回购买的时间,revocationReason 是原因。当客户提到你 app 中的问题时原因是 developerIssue,其他情况则是 other。
- 已退款的购买还会出现在 currentEntitlements 中吗?
- 不会。Transaction.currentEntitlements 会排除被 App Store 退款或撤销的购买,所以已退款的产品会自行从客户的权益中掉出,这使它成为一个安全的访问判断。
- StoreKit 会把 app 关闭期间发生的退款告诉我的 app 吗?
- 只有当你从启动时就监听才会。Transaction.updates 会在启动时把那些变化送达一次,所以你必须在 app 启动时就开一个 Task 去迭代它,否则退款会被漏掉,直到别的东西来对账。
- 仅靠客户端退款检测就够了吗?
- 不够。设备只在你的 app 运行时才得知退款,所以一位再也不重新打开 app 的客户对它是不可见的。App Store Server Notifications V2 的 REFUND 才是无论如何都会到达你这里的信号。
- revocationDate 是否总是意味着客户被退款了?
- 不是。当客户通过 Family Sharing 失去某笔购买时也会设置 revocationDate,所以非 nil 的日期意味着他们不再拥有该购买,但并不总是意味着钱被退了回来。
来源和延伸阅读
- Apple Developer: Transaction.revocationDate (StoreKit)
- Apple Developer: Transaction.RevocationReason (StoreKit)
- Apple Developer: Transaction.currentEntitlements (StoreKit)
- Apple Developer: Transaction.updates (StoreKit)
- Apple Developer: App Store Server Notifications V2 notificationType (REFUND, REFUND_REVERSED)
- Apple Developer Tech Talk: Support customers with StoreKit 2 and App Store Server API
RefundHalt
App Store 和 Google Play 退款自动驾驶
继续阅读
App Store 和 Google Play 的每一个退款窗口都是一场倒计时,下面是每个窗口留给你多少小时
App Store 和 Google Play 的每一笔退款都会启动一个计时,而其中大多数都在你不知情的情况下运行。Apple 最短的退款窗口是 12 小时,Google 的拒付窗口是 24 小时,而从 2026 年 8 月 3 日起,错过一个拒付窗口就是一张账单,而不只是丢掉一笔销售。下面是每一个影响你账户的期限。
作废购买通知让你的 Google Play 服务器在退款到账的那一刻撤销访问权限
Google Play 可以在一笔购买被退款、发生拒付或被作废的瞬间,向你的服务器推送一条作废购买通知。它携带 purchaseToken、orderId、productType 和 refundType,只意味着一件事,撤销访问权限。下面介绍如何解读它并接入它。