Apple 只给你十二小时时间用证据证明退款是否合理。如果你面前的这笔交易背后的客户是谁都无法确定,这十二小时的窗口期也就毫无意义。appAccountToken 正是提供这个"身份"的 UUID,而它只能在购买发生的那一刻被附加上去。
想象一下通知到达的那一刻。Apple 想知道这位买家究竟消耗了多少产品、他成为客户多长时间了、他总共花了多少钱。这些都是合理的问题,而你的数据库中存有所有答案。问题在于,摆在你面前的负载只提供了一个 originalTransactionId 和一个产品标识符,这两者都无法对应到你的用户表中的任何记录。你手里拿着一张没有署名的账单,而你需要的答案却是按姓名归档的。
这种错配有解决方法,而这个方法也有时间限制。appAccountToken 是由你的系统生成、在结账时交给 StoreKit 的一个 UUID,随后 Apple 会把它盖印到生成的交易上。从那一刻起,Apple 告诉你关于这笔购买的任何信息都会自带身份标识。然而一旦错过这个时机,这笔销售就会永久保持匿名状态。接下来将介绍:这个字段究竟是什么、如何附加它、能追溯性地挽救你的应用从未见过的购买的那个接口,以及匿名退款实际上会从你的业务中拿走多少。
核心要点
将一个 UUID 附加到某笔 App Store 购买上,由此产生的交易就能直接指向你记录中的某个具体用户。Apple 会把这个值保留在交易上,并在该交易出现的任何地方把它原样返还给你。
Apple 唯一会审查的就是格式,而这个格式必须是 UUID。如果你传入的是数据库 id、电子邮箱地址,或是自己拼接出来的字符串,StoreKit 会不声不响地将其丢弃,让本该是 appAccountToken 的位置变成 nil。
StoreKit 2 将其以单一购买选项的形式暴露出来,即 Product.PurchaseOption.appAccountToken(_:),传入的是你的系统事先生成并归档在该账户下的 UUID。
将它附加到首次购买上,Apple 就会在该订阅链条上的每一次续订、每一次重试扣款、以及每一次升级中,原样传递这个相同的值。
从 2025 年起,Set App Account Token 接口让你的服务器能够为那些应用内流程从未经手的购买打上标记,其中包括优惠代码兑换和推广内购。
正是因为有这个 token,Apple 的 CONSUMPTION_REQUEST 才变得可以回答。如果没有它,你就无法将这笔退款与那位你需要在 12 小时窗口期内详细说明其消费情况的客户关联起来。
无法识别身份的退款,就是无法申诉的退款。即便你手握销售证明,钱依然会被退回,而为交付这些产品已经消耗掉的算力、API 调用和支出也会一并化为泡影。
这个字段究竟做了什么
Apple 把这个值当作毫无意义的内容,而这正是重点所在。无论你在结账时传给 StoreKit 的是哪个 UUID,它都会被写入交易并无限期保留,Apple 不会尝试解读它、验证它所指向的内容,也不会检查它是否真的对应着什么。Apple 自己的定义称其为"将该交易与你自己服务上的用户账户关联起来的 UUID"。如何解读完全是你的事。Apple 只是单纯地携带这个值,并在被请求时把它返还。
这个字段的价值在于它会在哪些地方被返还。服务器通知会在已签名的交易内携带它。向 App Store Server API 请求 Get Transaction Info 时,它也会出现在其中。只要原始购买携带了这个值,订阅链条中后续的每一次续订都会复现它。换句话说,只要在某一刻附加了这个值,只要该客户持续向你付费,它就会一直反复出现。
传入的不是 UUID,它就会悄无声息地消失
开发者通常是在违反这条规则之后才发现它的存在。你传入了一个看起来合理的值——内部账户 id、电子邮箱地址,或是拼接在一起的几个字段——而 StoreKit 完全不会提出任何异议。没有异常、没有警告、购买也不会失败。交易只是会直接返回,appAccountToken 停留在 nil,而你以为已经建立的关联其实从未存在过。Apple 的开发者论坛上源源不断地出现这种困惑,通常表述为"appAccountToken is missing in the transaction payload",而原因几乎总是某个值未能满足格式要求。
这条要求非常严格:StoreKit 2 只接受符合 RFC 4122 标准的 UUID,别无其他。因此安全的做法是:在你的服务器上生成这个 UUID,将其持久化保存到该账户下,并确保永远不会有其他类型的值被传入购买调用。
正是这种无声无息让它变得危险。格式错误的值不会给你带来任何报错,也不会导致购买失败,唯一被悄悄丢弃的只有这个关联本身,而其他一切都照常进行。在值传递给 StoreKit 之前,用一行代码检查它是否符合 UUID 格式,就能彻底消除这种失败模式。
在结账时附加 token
整个实现只需要一个购买选项。在客户注册时,或者客户首次进入结账流程时,在服务器端生成这个 UUID,将其写入该客户的账户记录,然后在之后每次购买时都传入这个完全相同的值。
该选项的签名是 Product.PurchaseOption.appAccountToken(_ token: UUID),调用方式则类似 try await product.purchase(options: [.appAccountToken(token)])。无论最终的交易以何种方式到达你手中——无论是你自己通过 App Store Server API 验证它,还是以服务器通知的形式收到它——这个 UUID 都会随之而来,这使得客户身份识别简化为一次索引查找。
每位客户一个 token,永久复用
一个很诱人但要避免的错误是为每笔交易都生成一个新 token。这样做会悄悄地破坏这个机制的全部价值。Apple 会将原始购买上携带的 token,原样复现在该链条内的续订、重试扣款以及升级中,这意味着每个账户使用一个持久不变的 UUID,才能让客户的整段付款历史保持关联。一旦更换这个值,每笔购买都会变成一个孤岛。稳定性才是这里真正重要的属性,所以应该在账户创建时选定一个 UUID,并永远不再改动它。
挽救那些你的应用从未经手的购买的接口
过去有两种购买路径会让这一机制完全失效。兑换优惠代码是在 App Store 中完成的,而不是在你的应用内。从商店列表页购买推广内购也是如此。这两种路径都不会执行你的购买代码,这意味着根本没有任何时刻可以提供 token,于是这些交易会永久保持匿名状态。
这一限制在 2025 年的 WWDC 上随着 Set App Account Token 接口的推出而终结。你的服务器可以向 App Store Server API 发出 PUT /inApps/v1/transactions/{originalTransactionId}/appAccountToken 请求,在请求体中携带 UUID,Apple 就会把它写入该交易。这项能力覆盖所有产品类型,优惠代码兑换和推广内购自然也包含在内,而通过这种方式发送的值会替换掉交易上原有的 token(如果有的话)。因此,对于那些你的应用从未参与过的购买,身份识别现在也可以事后完成,而且完全在服务器端进行。
在哪里附加 token,取决于这笔购买是如何发生的:
对于普通的应用内购买,在购买发生的那一刻,通过 Product.PurchaseOption.appAccountToken(UUID) 在 StoreKit 的购买选项中设置它。
对于优惠代码兑换,通过你的服务器调用 Set App Account Token 接口来设置它,因为应用内没有任何可以挂钩的环节,你必须在事后补充附加。
对于来自 App Store 的推广内购,同样使用 Set App Account Token 接口来设置它,因为这笔交易发生在你的应用之外。
对于订阅续订,你什么都不需要做。token 会自动从原始购买中继承而来。
关联断裂会对你的收入造成什么影响
这里要讨论的重点并不是记录是否整洁。重点在于,Apple 在退款过程中会向开发者提出恰好一个问题,以 CONSUMPTION_REQUEST 的形式发送,而要回答这个问题,就必须知道所讨论的是你哪一位客户。
针对消耗型商品和不可自动续订订阅的退款请求会触发这个通知,而 Send Consumption Information 就是你在十二小时内作出回应的方式。Apple 期望得到的是关于某一个具体个人的详细信息:产品的消耗比例、账户存续时长、终身消费总额、交付状态。这个 token 会作为字段出现在请求本身当中,但它更重要的作用在于结构层面——因为它是唯一能把这条通知转化为你自己数据库中一条记录的东西。去掉这个 token,无论你的使用情况追踪做得多么完善,这个请求都将无法回答。
什么都不回复所要付出的代价
一旦有一笔退款以无法识别身份的方式到来,你就只剩下两个选项,而且都不是好选项。提交一份空的消费回应,Apple 会将其解读为极少的使用量,从而让决策向批准退款倾斜,重度用户也不例外。换成编造一个估计值,你就是在向平台方汇报虚构信息。无论走哪条路,那些你本可以拿出证据证明的销售,最终照样会被退款,而你却早已为交付它们付出了成本。
真正让人难受的是交付成本,因为它与销售价格毫无关系。一次生成过程中消耗的模型 API 调用、生成的图片、渲染并导出的视频、下游发放给创作者的报酬——这些钱在交付的那一刻就已经离开了你的账户,而退款给客户并不能把它们追回来。现在把这个问题放大来看:同一个匿名客户反复提出退款申请。惯性退款者会专门盯上那些无法识别其身份的商家。到了那个地步,被跳过的 token 就不再是一个小小的疏漏,而变成了一个常开的水龙头。
Apple 提出的问题关乎某一位客户的真实消费情况,而要回答它就必须知道这位客户是谁。没有这个 token,你就会白白放弃那些本可以证明的销售,连同已经为交付它们投入的算力和 API 支出一起损失掉。
Android 平台也在用不同的名字做同样的事
在 Google Play 上,setObfuscatedAccountId 承担着同样的工作,它将一个账户标识符绑定到购买上,这样通过 orders.reviewrefund 进行拒付审查时,就有一个真实存在的人可供审查。两者的具体实现差异很大,但背后的原则完全一致:要在交易发生时绑定身份,否则日后争议就会默认判负。Apple 版本的这一原则就是 appAccountToken,并且它坚持要求使用 UUID。
让每笔购买都保持可识别身份的三条做法
为每个账户生成一个 UUID 并持久化保存。每位客户只有一个持久不变的 token,在注册或首次结账时创建,写入其记录,并在其之后的每一次购买中重复使用。
在传递这个值之前先对它进行检查。在你的购买代码内部验证它确实是一个 UUID,这样格式错误的标识符就永远不会悄悄变成交易上的 nil token。
为任何在应用之外完成的购买补充 token。当你收到一条关于优惠代码兑换或推广内购、却不携带 token 的服务器通知时,调用 Set App Account Token 接口,附加上正确的 token。
同时采用这三条做法之后,身份识别就不再是你事后才去尝试做的事情。它会变成每笔交易在 Apple 交到你手中时就已经具备的属性,退款请求也完全不例外。
Refund Sensor 正是直接建立在这一前提之上的。我们从每一笔交易和每一条服务器通知中提取 appAccountToken,将它与该账户已经记录的消费数据进行关联,并在十二小时窗口期内从容地向 Apple 的请求提交回应,使用的是真正描述这位买家的具体数据。一笔能叫出名字的退款,才是一笔你能够申诉的退款,而这也是唯一值得去申诉的退款。
这些规则的官方文档出处
常见问题
它是一个由你的系统所拥有、StoreKit 只是负责记录的 UUID。你在结账时提供它,Apple 会把它写入由此产生的交易,从那以后,凡是这笔购买出现的地方,它都会一同出现:交易信息中、与之相关的任何服务器通知中,以及同一链条中后续的每一次续订中。实际效果就是,之后 Apple 告诉你的任何信息——包括退款请求——都可以精确指向你自己数据库中正确的那一条记录。
几乎可以肯定是格式问题。RFC 4122 是 StoreKit 2 接受的标准,未能通过该检验的值会被直接丢弃,不会产生任何报错,只会留下 nil,而购买本身依然会照常完成,仿佛什么都没出错一样。内部记录 id、电子邮箱地址,以及拼接而成的字符串,都会以这种方式失败。另一种可能性是,你的应用根本没有处理过这笔购买——通常是因为优惠代码兑换——所以你的代码从未运行过,也就从未提供过 token。
自 2025 年起,可以了。Apple 为正是这种情况在 App Store Server API 中引入了 Set App Account Token。向 /inApps/v1/transactions/{originalTransactionId}/appAccountToken 发出 PUT 请求,并在请求体中提供 UUID,就能把 token 写入一个已经存在的交易,或者替换掉其上已经存在的 token。所有产品类型都适用,而这项功能本身就是为了应对你的应用从未经手的购买而设计的,尤其是优惠代码和推广内购。
是的,毫无例外。这个 UUID 在你那一侧究竟指向什么,完全是你自己的事,Apple 从不检查这一点,但这个值本身必须满足 UUID 格式。不满足的话,StoreKit 就会拒绝保留它,使交易上留下 nil。
退款会触发一个 CONSUMPTION_REQUEST,Apple 期望你在十二小时内作出回应,说明这位特定买家究竟如何使用了该产品。要做出这样的回应,就需要把通知中的交易转化为一条你真正可以查询的客户记录,而这正是这个 token 的作用所在。它本身也会作为字段包含在请求之中。没有它,你的回应就只能是空白或者编造出来的,而空白的回应通常会导致退款被批准。



