App Store & Play Store Development

每一笔 App Store 购买都需要 appAccountToken,否则你的退款申诉将无从立足

当客户申请退款时,Apple 发送的 CONSUMPTION_REQUEST 从不会指明是谁。appAccountToken 是将一笔购买与你的用户关联起来的 UUID。设置了它,你就能用真实数据作答;跳过它,你就只能靠猜测。

5 min read
每一笔 App Store 购买都需要 appAccountToken,否则你的退款申诉将无从立足

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 的作用所在。它本身也会作为字段包含在请求之中。没有它,你的回应就只能是空白或者编造出来的,而空白的回应通常会导致退款被批准。

#appaccounttoken#storekit#refunds#in-app purchases#ios
Refund SensorRefund Sensor TeamRefund defense for App Store and Google Play developers