同意的生命周期:年龄核验之后还要交付什么
2025年11月4日,Apple 向 App Store Server Notifications 新增了一种通知类型。它的触发条件不是付款,而是家长的一个决定:RESCIND_CONSENT,表示”家长或监护人已撤回对孩子使用该 App 的同意”。12
在该服务的 23 种类型中,只有它的负载携带 appData 对象而非交易信息;而 appData 里装的是一份签名的 app transaction,这份记录”即便顾客从未进行任何 App 内购买”也依然存在。319 于是,一个从未卖出过任何东西的 App,第一次有了运行通知端点的理由。
社交媒体声明一文讲的是如何读取一个人的年龄区间:权限、年龄门槛,以及返回的上下界意味着什么。那部分就当已经解决,我们从下一步开始。年龄核验回答的是这个人多大。本文的三个 API 回答的是另外三件事:谁来批准;已经拿到的批准能否在 App 发生变更后继续有效;以及监护人把批准收回去之后会怎样。21012
要点速览
- 年龄核验的下游有三个 API,而 Apple 是把它们作为一套交付的:PermissionKit 之下的 Significant Change API、StoreKit 中的
AppStore.ageRatingCode,以及RESCIND_CONSENT服务器通知。45 - Apple 把这份四件套工具清单绑定的是得克萨斯、犹他和路易斯安那三个州,而不只是得克萨斯;它为这三个州给出的每一个日期都已过去;同时 Apple 又把义务范围限定在”某些地区,在法律要求的情况下”。5815
- 决定你是否需要跑这套流程的关口是
AgeRangeService.requiredRegulatoryFeatures,26.4 新增;而它把守的完整流程,写全需要 iOS 26.5。736 - 什么算重大变更,Apple 拒绝定义,而且说了四次,把你推给法律顾问。它公开的唯一一个具体例子,还被归到法条头上:Apple 写道,得州法律把 App 年龄分级的变化算作重大变更。48
- 你的分级可以在你不提交任何构建的情况下发生变化。2026年6月18日,Apple 取消了澳大利亚的 15+ 分级,并给越南启用了一套新的四级方案,两项都没有要求任何开发者提交任何东西。937
- 撤回是没人会去实现的那条分支,而 Apple 的文档也几乎没有实现它:在服务器团队真正会去看的那个页面上,
RESCIND_CONSENT没有出现在任何一张生命周期表里。2
授予、重新授予、撤回。前两条路径,Apple 用一个示例工程和一张沙箱测试矩阵作了说明。第三条只得到一个枚举值和四个字段——事实证明,它在我自己的代码里得到的关注也差不多是这个量级。
Apple 分四次发布的时间表
下面每一个日期都出自 Apple 自己的开发者新闻,而顺序比其中任何单独一条都更重要。
2025年10月8日,Apple 宣布了得州 SB2420,标注的生效点是”自2026年1月1日起”,而它描述的是自己的应对,不是法条原文:18岁以下用户的新 Apple 账户将被要求加入家人共享群组,并且”家长或监护人需要为未成年人的所有 App Store 下载、App 购买以及使用 Apple App 内购买系统的交易提供同意”。13 11月4日,Apple 点名了这些工具,并在 26.2 的 beta 版中交付。4 12月23日,计划中止:”一家地区法院近期签发的禁令暂停了得克萨斯州法律 SB2420 的执行……Apple 将暂停此前宣布的实施计划。”14 2026年6月3日,计划重启:”由于近期一项法院裁定解除了对得州法律 SB 2420 的禁令……这些变更将自2026年6月4日起生效。”5
一部法律,八个月来回折腾,而这些 API 一次也没动过。它们在 26.2 交付,在整个禁令期间都可以在沙箱中测试,禁令解除时它们就在原地等着。14 谁要是把12月那次暂停理解成”可以往后拖”,就白白损失了五个月的准备期。
版图的其余部分在2026年2月24日到位,而且越出了得州。
| 司法辖区 | Apple 说明的适用日期 | Apple 说明的适用内容 |
|---|---|---|
| 澳大利亚、巴西、新加坡 | 2026年2月24日 | Apple 阻止下载分级为 18+ 的 App,”除非已通过合理方法确认其为成年人”15 |
| 犹他州 | 2026年5月6日 | 应请求,为新 Apple 账户共享年龄类别15 |
| 得克萨斯州 | 2026年6月4日 | 新 Apple 账户”现已受该法律约束”:代表18岁以下未成年人对下载、App 内购买和重大变更给予同意,且监护人可以撤回5 |
| 路易斯安那州 | 2026年7月1日 | 应请求,为新 Apple 账户共享年龄类别15 |
得州的特殊之处在门槛,不在工具。Apple 把得州的要求描述为”代表18岁以下未成年人”给予同意,并把年龄类别公布为”13岁以下、13至15岁、16至17岁、18岁以上”,这恰好就是这个 API 产出的东西:”您最多可以指定三个年龄门槛,从而产生最多四个可能的年龄区间。”4529 对犹他和路易斯安那,Apple 提到的是应请求共享年龄类别,随后又声明这四件工具”已扩展,以帮助开发者满足路易斯安那州和犹他州的合规义务”,并把 Significant Change API 列在其中。15 工具并非得州专属。至于义务是不是,Apple 拒绝表态:它把义务范围限定为”某些地区,在法律要求的情况下”,然后把这个问题转给了法律顾问。8
请不要把这张表当成法律陈述或合规判定。它记录的只是 Apple 宣布了什么,以及 Apple 给它标了什么日期。我是工程师,不是律师,下文的一切都止步于 API 边界。
那份把合规问题推给法律顾问的 Q&A,对发布规划倒是带来了好消息:被问到这一切是否会改变审核时,Apple 回答”不会,App 审核流程没有任何变化”。8 义务落在被点名辖区的运行期,而不是提交环节。
所以分工很清楚。你的 App 在得州、犹他或路易斯安那是否负有同意义务,这是要问当地执业律师的问题。用哪个 SDK 编译则不是:Apple 明确说,想用上这些框架,”您必须使用 iOS 26.2 和 iPadOS 26.2 SDK 或更高版本,并配合 Xcode 26.2 (17C52) 或更高版本来构建 App”,而运行 iOS 18 或更早系统的现有账户”不会受到影响”。8
Apple 不会告诉你”重大”是什么意思
这个定义上的空洞正好坐在功能的正中央,而 Apple 的四处文档都把它推了回来,没有一处把它填上:符号页(”由您根据适用法规判定什么构成重大更新”),12 两篇新闻稿(”判断 App 何时发生重大变更是开发者的责任”),45 以及那份 Q&A——被直接问到条款或隐私政策的变更算不算时,回答是”视情况而定。由您根据适用法律判定什么构成重大 App 更新”。8
Apple 只给出了一个具体示例,而且它来自法条,不是来自 Apple 自己:”得克萨斯州法律将 App 年龄分级的变化视为重大变更,开发者应在 App Store Connect 中保持年龄分级选择为最新。”4
Apple 真正定义了的,是你要写的那串文字。SignificantAppUpdateTopic 把一个好例子和一个坏例子并排摆着,对一个符号页来说,这种坦率并不多见:
// Specific
let topic = SignificantAppUpdateTopic(
description: "This update adds video calling and location sharing features."
)
// Vague
let topic = SignificantAppUpdateTopic(
description: "We've made improvements to the app."
)
Apple 围绕这段代码给出的指示很直白:”请使用简洁易懂的语言,清楚说明您的 App 有哪些变化。家长和监护人在决定是否授予权限时会看到这段描述。”12 于是,那种套话式的更新说明,如今要摆在一位正在决定孩子还能不能继续使用的家长面前——这让它成为大多数 App 有史以来风险最高的一段更新日志文案。
触发条件被 Apple 含糊带过,但附着在答案上的义务一点也不含糊。Apple 让你”在必要时负责阻止对您的 App 或功能的访问”,然后干脆地写道:”在家长给予同意之前,必须阻止儿童访问该重大更新,这可能包括全部 App 和账户数据,或特定功能。”8 这里”全部 App 和账户数据”几个字承担了很重的分量。影响半径由你自己划,而 Apple 把整个账户点名为上限。
状态机,以及 Apple 自家示例漏在哪里
Apple 发布了一个示例工程 “Implementing age assurance and permissions”,这是对整个流程唯一一份端到端的说明。6 把它当作状态机来读,会浮现出四条分支,以及一段值得重写的代码。
第一条分支最省事。AgeRangeService.requiredRegulatoryFeatures 返回 Set<AgeRangeService.RegulatoryFeature>,共三个成员:declaredAgeRangeRequired、significantAppChangeRequiresParentalConsent 和 significantAppChangeRequiresAdultNotification。7 Apple 的示例首先检查它,并且”当两项功能都不存在时,App 会完全跳过整个流程”。6 Apple 把这个属性描述为反映一个人的”地区和账户设置”,在我看来这像是在承诺:被点名辖区之外的用户拿回来的是空集——尽管 Apple 并未给出任何这类保证。7
其余分支按年龄、以及年龄是如何确定的来分岔:
| 用户 | 所需的监管功能 | Apple 示例的做法 |
|---|---|---|
| 未成年人 | significantAppChangeRequiresParentalConsent |
向监护人发送 PermissionQuestion6 |
| 成年人,有已确认的支付方式 | significantAppChangeRequiresAdultNotification |
弹出系统确认表单6 |
| 成年人,无已确认的支付方式 | 两者之一 | 阻止访问,”直到本人在设置中验证其账户”6 |
| 拒绝共享 | 两者之一 | 解析了这一情形,却没有为它给出任何分支6 |
第三行值得多停一会儿。一个从未给自己的 Apple 账户绑定过支付方式的成年人,会被 Apple 的参考实现挡在门外——而这套流程本是为保护儿童而建的。Apple 没有提供替代分支,也没有提供绕过方式。
按已公布的声明拼起来,路由大致是这样。成年人与未成年人的分岔依据是 Apple 的示例加上沙箱测试矩阵:18岁以上的账户返回 lowerBound 为 18、没有上界,ageRangeDeclaration 为 confirmed 或 selfDeclared。注意可用性下限:读取 .confirmed 要付出 iOS 26.5 的代价,比这条流程里其他所有东西都晚一个版本。61136
import DeclaredAgeRange
import PermissionKit
import SwiftUI
@available(iOS 26.5, *)
struct SignificantChangeGate: View {
enum Phase {
case checking
case clear
case awaitingGuardian(PermissionQuestion<SignificantAppUpdateTopic>)
case blocked
}
let changeDescription: String
@Environment(\.requestAgeRange) private var requestAgeRange
@Environment(\.showSignificantUpdateAcknowledgment) private var acknowledge
@State private var phase: Phase = .checking
var body: some View {
switch phase {
case .checking:
ProgressView().task { await resolve() }
case .clear:
ChangedFeatureView()
case .awaitingGuardian(let question):
PermissionButton(question: question) { Text("Ask a parent to approve") }
case .blocked:
AccountVerificationPrompt()
}
}
private func resolve() async {
let features = (try? await AgeRangeService.shared.requiredRegulatoryFeatures) ?? []
guard !features.isEmpty else {
phase = .clear // nothing applies to this person
return
}
guard let response = try? await requestAgeRange(ageGates: 18),
case let .sharing(range) = response else {
phase = .blocked // declined or unavailable: Apple documents no branch
return
}
let isAdult = range.lowerBound == 18
let isConfirmed = range.ageRangeDeclaration == .confirmed
switch (isAdult, isConfirmed) {
case (true, true) where features.contains(.significantAppChangeRequiresAdultNotification):
try? await acknowledge(updateDescription: changeDescription)
phase = .clear
case (true, false):
phase = .blocked // adult with no confirmed method
case (true, true):
phase = .clear
case (false, _):
guard features.contains(.significantAppChangeRequiresParentalConsent) else {
phase = .clear
return
}
let topic = SignificantAppUpdateTopic(description: changeDescription)
phase = .awaitingGuardian(PermissionQuestion(significantAppUpdateTopic: topic))
}
}
}
发送提问是简单的那一半。接收回答才是 Apple 示例漏水的地方,而这段代码短到足以逐行细看:6
for await response in AskCenter.shared.responses(for: SignificantAppUpdateTopic.self) {
guard response.choice.answer == .approval else {
return
}
versionManager.handleAllChanges()
}
for await 里的 return 会直接放弃整个序列。只要被拒绝一次,App 就不再观察该主题,直到下次启动为止;于是一位点了”拒绝”、一分钟后又改了主意的监护人,把同意送进了一条没人在读的流。应该写 continue,同时把这次拒绝记录下来。把这段代码读成缺陷而非有意为之,是我的推断;无论哪种解读,修复的代价都只是一个关键字。设计监听器时就当后台唤起不存在:只有它取代的那个已废弃序列承诺过这件事。2021
待定是一种状态,而且它没有你能控制的超时
真正需要做设计的地方在生命周期的中段,塑造它的是五条已被文档记录的行为。先从 Apple 只答了一半的那条说起:PermissionQuestion.expirationDate 是一个 Optional<Date>,过了这个时间点”收到提问的人就无法再作答”,而 Apple 没有公布它的默认值。16
孩子可以在监护人看到提问之前就取消:”在发送请求流程中的任何时刻,孩子都可以选择取消请求,决定不把问题发给家长或监护人……在这种情形下,系统不会就该问题向调用方 App 投递任何响应。”17 没有响应,没有错误,也没有回调。除非你自己给它设超时,否则待定状态会一直待定下去。
向成年人提问会抛出错误。沙箱那篇文章记录了符号页留白的一种情形:”对成年用户调用 AskCenter.ask(_:) 会抛出此错误,因为他们不符合家长权限请求的条件。”11 AskError.notAvailable 是该枚举中两个在自己页面上没有摘要的成员之一,而它正是成年人会触发的那个。18 请在提问之前按年龄路由,而不是靠捕获抛出的错误来兜底。
批准状态该放在 iCloud,而不是 App 容器里。Apple 的示例把每一次已确认的变更写入 NSUbiquitousKeyValueStore,并监听 didChangeExternallyNotification,”这样其他设备就不会再次呈现同一流程”。6 家长在 iPhone 上批准之后,不该在 iPad 上又冒出第二次请求,而这些 PermissionKit 一样都不会替你做。
示例中最出彩的细节,解决的是大多数团队会做错的一个问题。全新安装的用户不该为自己从未经历过的变更给出同意,所以 Apple 读取 AppTransaction.originalAppVersion,并”自动把 App 在该版本或更早引入的所有变更标记为已处理”。619 因此,同意的追踪是按变更、按人来做的:需要的是带引入版本号的变更标识符,而不是一个布尔值。
你的分级可以在没有任何构建的情况下改变
AppStore.ageRatingCode 是一个异步返回 Int? 的 static var,自 26.2 起在 iOS、iPadOS、macOS、tvOS、visionOS 和 watchOS 上均可用。10 Apple 给出的用途是比较,而不是解释:”使用此属性获取您 App 的年龄分级,并将其与最后已知的年龄分级比较,以检查其是否发生变化。”10 你能拿到的就只有比较,因为 Apple 在整份文档的任何地方都没有公布整数到分级档位的映射。你无法问”我现在是不是 13+”,只能问”我和上次是不是一样”——所以真正的契约是:你自己持久化上一个值,并且自己处理第一次运行。
一旦注意到分级会在没有发版的情况下变动,App 为什么要盯着自己的分级也就显而易见了。2026年5月21日,Apple 宣布自6月18日起,”15+ 年龄分级将不再在澳大利亚的 App Store 上提供”,并根据现有问卷答案给越南启用了一套地区专属的四级方案。9 两项都已落地:App Store Connect 帮助文档的澳大利亚表格现在公布的是 16+ 和 R 18+,没有 15+ 那一行;越南则有了自己的表格。37 两项变更都没有要求提交、构建或任何开发者操作,而 Apple 写道,得州法律把分级变化算作重大变更。4
对比一下由你发起的分级变更:”当开发者更新其 App 的年龄分级后,版本一旦上线,该分级就会在所有用户设备上更新。”4 你自己的变更搭的是一趟你可以埋点的发版。Apple 的变更不搭——这就是这个属性要填的缺口。
测试这一块的问题比缺口更糟。开发者论坛上一条 Apple Staff 的回复说得很直接:”在从 Xcode 和沙箱环境(包括 TestFlight)构建并运行 App 的开发阶段,返回 0 是预期行为。”22 零不是 nil,所以 Apple 自己文档里的示例会顺利通过它的 guard let,把 0 当成有效分级交回去——发帖的那位开发者在真机上看到的正是这个。1022
顺着往下推。把 0 存成基线,那么 App Store 上的第一次启动会读到一个真实的编码,判定发生了变化,然后请家长为一个根本不存在的变更重新给出同意。把 0 当作永远不写入基线的哨兵值,是我从 Apple 这两处表述中做出的推断;而这是我绝不会漏掉的那一行防御性代码。
Apple 的可用性元数据里还藏着一条任何正文页面都没有写出来的发现。这四项能力的平台行,把窟窿开在了不同位置:
| 能力 | iOS / iPadOS | macOS | Mac Catalyst | visionOS | tvOS / watchOS |
|---|---|---|---|---|---|
AppStore.ageRatingCode10 |
26.2 | 26.2 | 无此行 | 26.2 | 26.2 |
requiredRegulatoryFeatures7 |
26.4 | 26.4 | 26.4 | 无此行 | 无此行 |
SignificantAppUpdateTopic、PermissionButton1226 |
26.2 | 26.2 | 26.2 | 26.2 | 无此行 |
showSignificantUpdateAcknowledgment27 |
26.4 | 无此行 | 26.4 | 无此行 | 无此行 |
由此掉出两处不对称。原生 macOS App 可以得知 significantAppChangeRequiresAdultNotification 适用于某个人,却没有任何 API 可以满足它:showSignificantUpdateAcknowledgment(in:updateDescription:) 接收的是 UIWindowScene,没有公布 macOS 行,而 SwiftUI 的 SignificantUpdateAction 也止步于同样这三个平台。27 Apple 倒是为相邻的 requestAgeRange 调用提供了 NSWindow 版本,所以这处缺失读起来像是遗漏而非政策——这是我的推断。28
其二,检测的覆盖面比补救更广。ageRatingCode 公布了 tvOS 和 watchOS 行;而任何请求同意的东西都没有。10 这些目标平台可以观察到分级变化,却没有任何有文档依据的应对方式,而 Return 恰好这两个平台都发布了:它的 tvOS 1.0.1 在 App Store Connect 中处于 READY_FOR_DISTRIBUTION,已分发的 iOS App 中还内嵌了一个 watch App。30 这两种解读都假设 Apple 的元数据是完整的,而同一份元数据恰恰削弱了这个假设:相邻的符号全都有 Mac Catalyst 行,唯独 ageRatingCode 没有。10
当 Apple 已经拦下启动,服务器还有什么用
Apple 用一句话说明了撤回行为:”当家长或监护人撤回其孩子访问某个 App 的同意后,Apple 将阻止该 App 启动。要处理同意撤回,请使用 notificationType 中的 RESCIND_CONSENT 值。”8
注意这两个分句的顺序。系统已经阻止了访问,所以 RESCIND_CONSENT 不是用来执行拦截的钩子,把它当成钩子来实现就是浪费。它是你能拿到的、关于某个用户的唯一信号——而你的代码已经无法在那个用户的设备上运行了。这个事件该如何归档,Apple 只字未提,我找到的最接近的类比是账户删除请求:有订阅要对账,有状态要冻结,还有一个可能会再次出现的家庭。
这份负载薄得反常。appData 只带四个字段:appAppleId、bundleId、environment 和 signedAppTransactionInfo。3 没有交易,没有订阅,没有续订信息,也没有属于你的账户标识符。与人的关联要走那份签名的 app transaction,App Store 会”为每一个下载您 App 的 Apple 账户生成一个 appTransactionID,并为支持家人共享的 App 的每一位家庭成员各生成一个”。19 所以免费 App 确实有一个稳定的、按账户的键可供匹配;而从未存过这个键的 App,收到的会是一条它无法归因到任何人的通知。
传输层没有意外:在 App Store Connect 中为每个环境配置一个版本 2 URL,走 TLS 1.2 或更高版本,白名单里放行 17.0.0.0/8,200 到 206 视为成功,返回 40x 或 50x 则换来五次重试,分别在 1、12、24、48 和 72 小时后进行,且仅限生产环境。2324
RESCIND_CONSENT 也不带任何 subtype。已公布的 19 个 subtype 值各自都限定在某个具名的通知类型上,而 RESCIND_CONSENT 这个字符串在那个页面上根本不出现。25 更能说明问题的是,Apple 的 notificationType 页面在 “Handle use cases for In-App Purchase life-cycle events” 标题下用八张表格映射了 40 个事件,RESCIND_CONSENT 一张也没进;这个值只存在于可能值列表里,页面其他任何地方都没有。2 Apple 把这条通知写进了年龄核验的材料,却从未把它接进服务器团队真正会读的那份参考文档。
我自己的项目已经错在哪里
在写别人的代码之前,我先搜了自己的。选取规则是机械的,而问题恰恰出在规则上:我自己的 CLAUDE.md 里 Active Projects 表格中每一行背后有 Xcode 工程的项目,一共七个项目、508 个文件,涵盖所有 Swift 文件、entitlements 文件、属性列表和 project.pbxproj。31 PermissionKit、AskCenter、SignificantAppUpdateTopic、FamilyControls、ManagedSettings、DeviceActivity、CKShare、sharedCloudDatabase 和 ageRatingCode 的匹配数全为零。七个项目里有五个有 App Store Connect 记录。Water 和 Yawara 都没有,也从未发布过,所以这两个只能当作代码来看,而不是任何人手上的 App。31 Ace Citizenship 看上去最可能有未成年用户,实际上却最不可能:它的配套网站把 N-400 的资格条件写作”年满18岁”,而 App 在任何地方都不询问出生日期。31
随后我把完全相同的模式跑了一遍 ~/Projects 下的每一个仓库——这本该是我最先跑的那次扫描。同样的模式命中了 21 个文件,其中 7 个是代码,而这 7 个里有 6 个位于同一个 iOS 项目中,那个项目在登记表里从来就没有过一行。32
Randori 是一个柔术训练记录 App,而它恰恰拥有这份模式清单本就是为了抓出来的那种同意面:CKShare 和 sharedCloudDatabase 在 ConnectionStore.swift、RandoriApp.swift、CKPostTransport.swift、ProfileCardView.swift、CloudShareSheet.swift 和 SocialContracts.swift 中共命中 20 处;App 已在运行时,接受邀请走 userDidAcceptCloudKitShareWith,冷启动时则走 connectionOptions.cloudKitShareMetadata。32 Randori 还自带了一个针对”挂上别人名字”的同意原语:TagConsentStore 采用失败即拒绝的策略,因此如果某位运动员的客户端尚未发布标记策略,他就完全无法被标记。32
于是,唯一拥有真实同意面的那个项目,自己写了一套台账,却一样也没采用 Apple 的。它欠着三样我还没做的东西。把它的契约中一直休眠的人对人功能打开,就是教科书式的 SignificantAppUpdateTopic 场景。ageRatingCode 没有任何已存基线,而一个尚未发布的 1.0 只从 Xcode、沙箱或 TestFlight 运行过——这恰恰是 Apple 所说该属性返回 0 的地方。22 撤回则无处落地:Randori 不卖任何东西,也没有自己的服务器。32
更有意思的发现是:监护人同意其实早就以一个更老的名字,出现在最初那七个项目中的三个里了。
购买前询问(Ask to Buy)就是同一套机制、同一个形状,Apple 的描述用词几乎一模一样:”启用购买前询问后,当孩子想要进行符合条件的购买或下载时,系统会把购买请求发送给家长或监护人。”34 它在代码里表现为 Product.PurchaseResult.pending,而批准是通过 Transaction.updates 到达的,不在调用点返回,因为那条序列承载的是”在 App 之外发生的交易,例如购买前询问的交易”。35 拒绝则什么都不投递:”因为您拒绝了购买前询问,您的 App 不会收到交易。”34
七个项目里有三个在卖东西,而它们处理待定状态的方式各不相同:33
| App | 售卖内容 | .pending 的处理方式 |
|---|---|---|
| ResumeGeni | 月度订阅 | 一个具名的 .pending 结果,配有明确记录的对账路径 |
| Reps | 订阅,两个档位 | case .userCancelled, .pending: 被合并成同一条分支 |
| Ace Citizenship | 非消耗型项目 | 单独的 case .pending:,返回 false,与取消完全相同 |
三个里有两个,把”监护人正在斟酌”报告成了”用户拒绝”,而用户看到的东西比一个标错的标签更糟。Reps 只有在 purchase 返回 true 时才关闭付费墙;Ace 只有在自己的调用返回成功时才越过付费墙。遇上 .pending,两处调用都不会返回任何可操作的东西,于是两道付费墙都原地敞着,没有错误、没有提示条、也没有加载指示:一次点了等于没点。33 家长几分钟后批准,交易落进了一个监听器里,而界面从头到尾都没承认过有一次请求发生过。
这条被合并的分支,正是 PermissionKit 会在更高风险下诱发的同一个失败,而在 Apple 给这个模式配上第二个 API 之前,我已经把它发布了两次。待定不是什么冷僻分支。待定就是同意流程从 App 内部看过去的样子,而正确的默认做法,是渲染一种状态,而不是返回一个值。
有一个 App 已经站在服务器那一半的下游,这让缺失的分支变得具体。ResumeGeni 的版本 2 端点自2026年6月26日以来至少记录了 56 条通知,其中 55 条来自沙箱,1 条来自生产环境——我正是据此知道两个环境的 URL 都已注册,而不是只注册了一个。它的处理器点名了 13 种通知类型,实际派发 8 种;其余 5 种只出现在注释里。RESCIND_CONSENT 两组都不在,整个仓库的其他任何地方也没有。33
常见问题
哪个 API 向监护人请求同意,哪个面向成年人?
分属不同框架,而且很容易搞反。家长同意走 PermissionKit:用 PermissionQuestion(significantAppUpdateTopic:) 包住一个 SignificantAppUpdateTopic,通过 PermissionButton 发送,再从 AskCenter.shared.responses(for:) 取回答复。12162126 成年人确认走的则是 Declared Age Range,对应 showSignificantUpdateAcknowledgment(in:updateDescription:)。27 请先检查 requiredRegulatoryFeatures,因为通过 PermissionKit 向成年人提问会抛出 AskError.notAvailable。711
没有真实的家庭账户,如何测试同意撤回?
先开启开发者模式,然后依次进入”设置”—“开发者”—“沙箱 Apple 账户”,登录后选中该账户,点按”管理”,再选择”撤回 App 同意”。输入你的 bundle 标识符并点按”撤回同意”,系统会显示 “Notification Triggered”。11 只要配置了版本 2 URL,你的服务器就会收到 RESCIND_CONSENT,其中带有一个 appData 对象,它的 bundleId 和 environment 字段可以确认这条通知对应的是正确的 App。311 沙箱环境对每条通知只发送一次、不重试,所以测试途中返回 50x 的端点不会有第二次机会。24
App 为什么要在运行时盯着自己的年龄分级?
因为 Apple 会在你不提交任何东西的情况下改变商店里的分级,而 Apple 写道,得州法律把分级变化算作重大变更,随后又把你指向 Significant Change API 去请求家长同意。4 2026年6月18日就是现成的例子:澳大利亚失去了 15+ 档,越南多了一套四级方案,两者都是依据既有的问卷答案直接套用到现有 App 上的。937 Apple 没有公布这个属性的整数到分级档位的映射,所以与你自己存下的值做比较,是它支持的唯一操作。10
RESCIND_CONSENT 会替我拦住用户吗?
系统已经这么做了,而这正是多数实现忽略的一点。Apple 明确指出,当监护人撤回同意时,”Apple 将阻止该 App 启动”,随后把你指向这条通知,是让你处理这个事件,而不是让你去执行拦截。8 所以剩下的工作是服务器形态的:冻结账户状态、对账任何订阅,并停止向一台打不开你 App 的设备推送。请像对待账户删除那样对待它,而不是像对待一次失败的授权检查。
关键要点
给 iOS 开发者:
- 今天就去审一遍你现有的 Product.PurchaseResult switch。购买前询问就是早已上线的监护人同意,.pending 就是它浮现出来的方式;而把这个 case 并进 .userCancelled,正是 PermissionKit 将在更高风险下诱发的那个缺陷。3435
- 如果你的流程要区分”已确认”的成年人和”自行声明”的成年人,请按 iOS 26.5 而不是 26.4 来规划。36
- 复制 Apple 示例监听器之前,先把里面的 return 改掉;并且永远不要让 0 进入你存储的 ageRatingCode 基线。622
给要发布到多个 Apple 平台的团队:
- 动手规划之前先看可用性行。原生 macOS App 可以检测到需要成年人确认,却没有任何 API 来呈现它;tvOS 和 watchOS 能读取 ageRatingCode,背后却没有任何请求同意的 API。71027
- 把确认状态按变更为键存进 NSUbiquitousKeyValueStore,并用 AppTransaction.originalAppVersion 把新安装用户从早于他们的变更中豁免出去。619
给后端和发布负责人:
- 在你需要它之前,就按账户记录好 appTransactionID,然后即便是免费 App 也把版本 2 端点搭起来。事后才建的端点,收到的撤回事件将无法归因到任何人。319
- 把重大变更的描述文案交给负责产品文案的人过一遍。这是监护人在决定你的 App 能否留住一个用户时,唯一会读到的一段文字。12
这一轮里的四个强制点,有三个触发在你能控制的东西上:启动屏幕键触发在 SDK 上,@State 宏触发在工具链上,社交媒体声明触发在一次提交上。而监护人同意,触发在一份法院案卷和一张商店分级表上。完整的系列入口是 Apple 生态系统系列。
参考资料
-
Apple,App Store Server Notifications changelog。在2025年11月4日的标题下,New features 一节写着:”已更新
responseBodyV2DecodedPayload,加入新的负载对象appData“,以及”已向notificationType添加通知类型RESCIND_CONSENT“。变更日志中随后的两条是2025年12月10日和2026年4月27日,均与同意无关。由于 HTML 页面通过 JavaScript 渲染,此处于2026年7月26日读取自 Apple 文档的 JSON。 ↩ -
Apple,notificationType,App Store Server Notifications。
RESCIND_CONSENT定义的出处(”一种通知类型,表示家长或监护人已撤回对孩子使用该 App 的同意”),也是本文所用计数的出处:该页面公布了 23 个可能值(CONSUMPTION_REQUEST、DID_CHANGE_RENEWAL_PREF、DID_CHANGE_RENEWAL_STATUS、DID_FAIL_TO_RENEW、DID_RENEW、EXPIRED、EXTERNAL_PURCHASE_TOKEN、GRACE_PERIOD_EXPIRED、METADATA_UPDATE、MIGRATION、OFFER_REDEEMED、ONE_TIME_CHARGE、PRICE_CHANGE、PRICE_INCREASE、REFUND、REFUND_DECLINED、REFUND_REVERSED、RENEWAL_EXTENDED、RENEWAL_EXTENSION、RESCIND_CONSENT、REVOKE、SUBSCRIBED、TEST)。字符串RESCIND_CONSENT在页面负载中恰好出现一次,位于可能值列表内;”Handle use cases for In-App Purchase life-cycle events” 标题下的八张表格合计承载 40 个事件行(连同各自表头分别为 4、6、7、7、8、6、6 和 4 行),没有任何一张点到它。请注意,REVOKE指的是家人共享权益的丧失,而非同意的撤回:”顾客此前通过家人共享获得的某项 App 内购买项目,已不再通过共享提供。”于2026年7月26日读取自 Apple 文档的 JSON。 ↩↩↩↩ -
Apple,appData,App Store Server Notifications,自版本 2.19 引入。以下内容的出处:”
appData对象是responseBodyV2DecodedPayload的一部分。当notificationType为RESCIND_CONSENT时,负载中会包含此对象”,以及那四个属性:appAppleId(”适用于用户从 App Store 下载的 App。沙箱环境中不存在此字段”)、bundleId、environment和signedAppTransactionInfo(一个JWSAppTransaction)。responseBodyV2DecodedPayload 页面独立地陈述了这种互斥性,把appData描述为”当notificationType为RESCIND_CONSENT时出现”的字段,并补充说”data、appData、summary和externalPurchaseToken字段互斥。负载只包含其中一个字段”。这两个页面就是把RESCIND_CONSENT称为唯一负载携带appData的通知类型的依据;字符串appData在notificationType页面上任何地方都不出现。 ↩↩↩↩ -
Apple,Next steps for apps distributed in Texas,Apple 开发者新闻,2025年11月4日。以下内容的出处:得州年龄类别(”13岁以下、13至15岁、16至17岁、18岁以上”)、Apple 使用的框架名称(”PermissionKit 框架之下的 Significant Change API”)、开发者责任那句话(”判断 App 何时发生重大变更是开发者的责任”)、年龄分级示例(”得克萨斯州法律将 App 年龄分级的变化视为重大变更,开发者应在 App Store Connect 中保持年龄分级选择为最新。当开发者更新其 App 的年龄分级后,版本一旦上线,该分级就会在所有用户设备上更新”)、StoreKit 的定位(”开发者可以使用 StoreKit 中的一种新属性类型,自动检查其 App 的年龄分级在用户设备上是否发生变化,然后使用 Significant Change API 请求家长同意”)、撤回行为(”得州的家长或监护人可以撤回对任何 App 的同意,这将阻止该 App 在儿童或青少年的设备上启动”),以及那份四条的 “Next steps” 实施清单。同时也是把 beta 可用性定位到 iOS 26.2 和 iPadOS 26.2 的出处。抓取于2026年7月26日。 ↩↩↩↩↩↩↩↩↩
-
Apple,Update for Apps Distributed in Texas,Apple 开发者新闻,2026年6月3日。以下内容的出处:禁令解除(”由于近期一项法院裁定解除了对得州法律 SB 2420 的禁令,得克萨斯州的新 Apple 账户现已受该法律约束”)、适用范围(”针对下载、Apple App 内购买以及与 App 相关的重大变更,需要进行年龄核验并代表18岁以下未成年人取得家长或监护人同意。家长或监护人还将能够撤回其先前为孩子批准的任何 App 的同意”)、生效日期(”这些变更将自2026年6月4日起生效”)、再次重申的开发者责任提醒,以及同一份四条实施清单。抓取于2026年7月26日。 ↩↩↩↩↩↩
-
Apple,Implementing age assurance and permissions,Declared Age Range 示例代码。可用性:iOS 26.5、iPadOS 26.5、Mac Catalyst 26.5、Xcode 27.0 beta;文章要求在运行前于”搭载 iOS 26.4 或更高版本的设备”上登录 iCloud。以下内容的出处:空集行为(”当两项功能都不存在时,App 会完全跳过整个流程”)、18 岁的年龄门槛、解析出的四种类别(
.minor、.verifiedAdult,描述为”拥有已确认支付方式的成年人”、.unverifiedAdult,即”未完成账户验证的成年人”、.declinedSharing)、未验证成年人的处理结果(”App 将阶段设为.blocked,并在本人于设置中验证其账户之前阻止访问”)、未成年人路径中构建SignificantAppUpdateTopic和PermissionQuestion的做法、通过PermissionButton发送、本文逐字引用的AskCenter.shared.responses(for:)监听代码、拒绝行为(”当家长拒绝该请求时,App 会阻止未成年人使用它”)、配合didChangeExternallyNotification的NSUbiquitousKeyValueStore确认状态追踪(”这样其他设备就不会再次呈现同一流程”),以及originalAppVersion豁免(”在重大变更已经存在时才安装 App 的人,无需对其进行确认”)。该工程还需要 Declared Age Range 能力和 iCloud 键值存储服务。于2026年7月26日读取自 Apple 文档的 JSON。 ↩↩↩↩↩↩↩↩↩↩↩↩↩ -
Apple,AgeRangeService.requiredRegulatoryFeatures 与 AgeRangeService.RegulatoryFeature,Declared Age Range。两者均自 iOS 26.4、iPadOS 26.4、Mac Catalyst 26.4 和 macOS 26.4 起可用,没有 visionOS、tvOS 或 watchOS 行。声明为
var requiredRegulatoryFeatures: Set<AgeRangeService.RegulatoryFeature> { get async throws },在”监管功能的服务不可用”时抛出notAvailable。该枚举恰好公布三个成员:declaredAgeRangeRequired(”表示此人需要与您的 App 共享其年龄区间”)、significantAppChangeRequiresAdultNotification(”表示成年用户必须确认您 App 的重大变更”)和significantAppChangeRequiresParentalConsent(”表示需要家长或监护人确认并同意 App 的重大变更”)。 ↩↩↩↩↩↩ -
Apple,Age assurance frameworks Q&A,Apple 开发者支持。以下内容的出处:SDK 下限(”您必须使用 iOS 26.2 和 iPadOS 26.2 SDK 或更高版本,并配合 Xcode 26.2 (17C52) 或更高版本来构建 App”)、老账户豁免(”运行 iOS 18 和 iPadOS 18 或更早版本的现有 Apple 账户……不会受到影响”,被省略的从句写明”包括面向儿童和青少年的成人账户与儿童账户”)、责任问答(”是的,开发者需为自己的年龄限制负责”以及”有关合规义务的问题,请咨询您的法律顾问”)、本文两次引用的地区范围限定(”在某些地区,在法律要求的情况下,Apple 会使用年龄核验方法确认 Apple 账户持有人的年龄,并通过 Declared Age Range API 与您共享年龄类别。在这些地区,您必须检查使用您 App 的人的年龄”,后文又重述为”在法律要求的地区,您需要使用 Declared Age Range API 检查使用您 App 的人的年龄”)、访问义务(”对于重大 App 更新,您需要在必要时负责阻止对您的 App 或功能的访问,并负责处理来自家长或监护人的响应。在家长给予同意之前,必须阻止儿童访问该重大更新,这可能包括全部 App 和账户数据,或特定功能”)、撤回行为(”当家长或监护人撤回其孩子访问某个 App 的同意后,Apple 将阻止该 App 启动。要处理同意撤回,请使用
notificationType中的RESCIND_CONSENT值”)、App 审核问答(”不会,App 审核流程没有任何变化”),以及条款与隐私政策的问答(”视情况而定。由您根据适用法律判定什么构成重大 App 更新”)。读取于2026年7月26日。 ↩↩↩↩↩↩↩↩↩ -
Apple,Upcoming changes to age ratings in Australia and Vietnam,Apple 开发者新闻,2026年5月21日。以下内容的出处:”自2026年6月18日起,App Store 上澳大利亚和越南的年龄分级将进行更新”、澳大利亚的变更(”15+ 年龄分级将不再在澳大利亚的 App Store 上提供。当前分级为 15+ 且带有以下内容描述符的 App 将被更新为 16+”)及其三个描述符(不受限制的网页访问;频繁的医疗或治疗信息;战利品箱),以及越南的变更(”为符合越南第 147 号法令第 38 条的规定,在越南 App Store 上架的 App 将需要一个地区专属的年龄分级。根据您在 App Store Connect 中的年龄分级问卷答案,您的 App 将获得四种分级之一:00+(所有年龄)、12+、16+ 或 18+”)。两项变更都不要求开发者提交构建。抓取于2026年7月26日。 ↩↩↩
-
Apple,AppStore.ageRatingCode,StoreKit。声明为
static var ageRatingCode: Int? { get async },自 iOS 26.2、iPadOS 26.2、macOS 26.2、tvOS 26.2、visionOS 26.2 和 watchOS 26.2 起可用,没有 Mac Catalyst 行。返回值:”表示当前年龄分级编码的整数;若年龄分级不可用则为nil。”以下内容的出处:比较式定位(”使用此属性获取您 App 的年龄分级,并将其与最后已知的年龄分级比较,以检查其是否发生变化”),以及通往 PermissionKit 的链路(”如果您 App 的年龄分级已发生变化,可考虑使用 Significant Change API 告知家长或监护人”)——在这句话里,”Significant Change API”是链接文字,而 Apple 的SignificantAppUpdateTopic页面是链接目标。Apple 在该页面上给出的示例,是围绕该属性的一个guard let,在取值不可用时打印信息。于2026年7月26日在 Apple 文档中检索这些整数到分级档位(4+、9+、13+、16+、18+)的映射,在本页面、AppStore类型页面以及 App Store Connect 帮助文档的年龄分级参考中均未找到。 ↩↩↩↩↩↩↩↩↩ -
Apple,Testing age assurance in sandbox,StoreKit。以下内容的出处:设备端路径(”设置”—“开发者”—“沙箱 Apple 账户”—“管理”,然后是”年龄核验或撤回 App 同意”)、六行测试矩阵(其中 18 岁以上的行返回下界 18、无上界,年龄声明为
selfDeclared或confirmed)、面向成年人提问的行为(”对于 18+ 测试用例,PermissionKit 会抛出AskError.notAvailable,而不是返回PermissionChoice。对成年用户调用AskCenter.ask(_:)会抛出此错误,因为他们不符合家长权限请求的条件”)、以确认提示 “Notification Triggered” 结束的撤回步骤(并附有”通知将很快发送到开发者服务器”),以及负载说明(”您的服务器会收到RESCIND_CONSENT的notificationType。通知负载中包含一个带有 App 元数据的appData对象,其中包括bundleId和environment字段”)。矩阵中的三个未成年人行分别是 13 岁以下已批准、13 至 15 岁已批准、16 至 17 岁已拒绝,年龄声明均为guardianDeclared。 ↩↩↩↩↩ -
Apple,SignificantAppUpdateTopic,PermissionKit。自 iOS 26.2、iPadOS 26.2、Mac Catalyst 26.2、macOS 26.2 和 visionOS 26.2 起可用;声明为
struct SignificantAppUpdateTopic,遵循QuestionTopic,带有init(description: String)。以下内容的出处:定义上的推诿(”由您根据适用法规判定什么构成重大更新”)、描述文案指引(”请使用简洁易懂的语言,清楚说明您的 App 有哪些变化。家长和监护人在决定是否授予权限时会看到这段描述”),以及本文逐字复现的 Specific/Vague 代码中的两段注释。 ↩↩↩↩↩↩ -
Apple,New requirements for apps available in Texas,Apple 开发者新闻,2025年10月8日。以下内容的出处:最初的公告(”自2026年1月1日起,得克萨斯州的一部新州法……为 App 市场和开发者引入了年龄核验要求”,省略部分点名了 SB2420)、家人共享要求(”所有18岁以下用户的新 Apple 账户都将被要求加入家人共享群组,且家长或监护人需要为未成年人的所有 App Store 下载、App 购买以及使用 Apple App 内购买系统的交易提供同意”),以及对犹他和路易斯安那的预告(”类似要求将于明年晚些时候生效”)。抓取于2026年7月26日。 ↩
-
Apple,Update on age requirements for apps distributed in Texas,Apple 开发者新闻,2025年12月23日。以下内容的出处:禁令(”一家地区法院近期签发的禁令暂停了得克萨斯州法律 SB2420 的执行……鉴于该裁定,Apple 将暂停此前宣布的实施计划,并关注后续法律进程”)、四件工具在沙箱中持续可用,以及它们向犹他和路易斯安那的扩展。抓取于2026年7月26日。 ↩↩
-
Apple,Age requirements for apps distributed in Brazil, Australia, Singapore, Utah, and Louisiana,Apple 开发者新闻,2026年2月24日。以下内容的出处:18+ 下载限制(”自2026年2月24日起,Apple 将阻止澳大利亚、巴西和新加坡的用户下载分级为 18+ 的 App,除非已通过合理方法确认其为成年人。App Store 将自动执行此确认。不过,开发者可能另有义务独立确认其用户为成年人”)、犹他和路易斯安那的日期(”对于犹他州自2026年5月6日起、路易斯安那州自2026年7月1日起创建的新 Apple 账户,当通过 Declared Age Range API 请求时,年龄类别将与开发者的 App 共享”)、本文引用的扩展句及其后的四个链接(”我们此前宣布的工具已扩展,以帮助开发者满足路易斯安那州和犹他州的合规义务,包括:”Declared Age Range API、PermissionKit 之下的 Significant Change API、StoreKit 中新的年龄分级属性类型、App Store Server Notifications)、巴西战利品箱的后续影响,以及 Significant Update Action 的首次公开命名(”开发者可以使用 Declared Age Range API,通过现处于 beta 阶段的 Significant Update Action,向这些州的成年人呈现重大更新通知”)。抓取于2026年7月26日。 ↩↩↩↩↩
-
Apple,PermissionQuestion 与 expirationDate,PermissionKit。
final class PermissionQuestion<Topic> where Topic : QuestionTopic,自 iOS 26.0 起可用,带有四个初始化方法:init(handle:)、init(handles:)、init(communicationTopic:)和init(significantAppUpdateTopic:),最后一个自 iOS 26.2 引入,描述为创建”一个权限提问,用于在发生重大更新后请求家长或监护人许可继续使用您的 App”。expirationDate声明为final var expirationDate: Date?,讨论部分写着”该日期一过,收到提问的人就无法再作答”。Apple 没有为该属性公布默认值,也没有为重大更新主题给出设值指引。 ↩↩ -
Apple,Creating a communication experience,PermissionKit。本文引用的取消行为出处:”在发送请求流程中的任何时刻,孩子都可以选择取消请求,决定不把问题发给家长或监护人。在这种情形下,系统不会就该问题向调用方 App 投递任何响应。”也是该框架 iMessage 约束的出处,PermissionKit 首页把它作为 Important 提示写明:”使用
PermissionKit框架构建的沟通体验仅在 iMessage 中可用。”请注意,该文章只记录了CommunicationTopic流程;Apple 没有为重大更新流程发布对应文章,而且截至2026年7月26日,该文章的代码示例调用的CommunicationLimits.current.permissionResponses这一符号,在 Apple 文档中返回 404。 ↩ -
Apple,AskError,PermissionKit。
enum AskError,遵循LocalizedError,共六个成员。其中四个带摘要且自 iOS 26.1 起可用:unknown、communicationLimitsNotEnabled(”表示未启用沟通限制,无法发送权限请求”)、contactSyncNotSetup和invalidQuestion。另有两个在自己的页面上没有摘要:iOS 26.1 的systemError(underlyingError:),以及 iOS 26.2 的 notAvailable,后者与SignificantAppUpdateTopic同时到来。notAvailable的含义只出现在注 11 引用的沙箱测试文章里;systemError至少还在签名中点明了成因。communicationLimitsNotEnabled或contactSyncNotSetup是否也可能在重大更新的提问中出现,文档未作说明。 ↩ -
Apple,
AppTransaction上的 appTransactionID 与 originalAppVersion,StoreKit。以下内容的出处:标识符语义(”App Store 会为每一个下载您 App 的 Apple 账户生成一个全局唯一的appTransactionID,并为支持家人共享的 App 的每一位家庭成员各生成一个”)、它在重新下载、退款、再次购买和店面变更后的稳定性、它在 App Store Server Notifications 版本 2 负载中的存在,以及对免费 App 至关重要的那一句:”即便顾客从未进行任何 App 内购买,appTransactionID也依然可用。”originalAppVersion是”顾客最初从 App Store 购买的 App 版本”,在 macOS 上携带CFBundleShortVersionString,其他平台携带CFBundleVersion,在沙箱环境中始终为1.0。服务器端对应的类型是 App Store Server API 中的 appTransactionId,自版本 1.15 引入。 ↩↩↩↩↩ -
Apple,CommunicationLimits 与 updates,PermissionKit。
updates声明为final var updates: some AsyncSequence<PermissionResponse<CommunicationTopic>, Never> { get },摘要为”向系统注册该沟通主题,以便您的 App 可以按需在后台被唤起以接收权限更新”。Apple 的文档把updates和两个CommunicationLimits.ask(_:in:)重载都归入 Deprecated API 标题下;该类本身在isKnownHandle(_:)和knownHandles(in:)上仍然有效。替代它的序列丢掉了那句承诺:AskCenter.responses(for:)的摘要与讨论都完全没有提到后台唤起——这正是正文所指出的那处文档缺口的全部内容。 ↩ -
Apple,AskCenter 与 responses(for:),PermissionKit,两者均自 iOS 26.2、iPadOS 26.2、Mac Catalyst 26.2、macOS 26.2 和 visionOS 26.2 起可用。
AskCenter是通过static let shared访问的final class,描述为把”您的提问经由适当的家人共享渠道路由”,并在”家长做出决定时把响应投递回您的 App”。responses(for:)声明为final func responses<Topic>(for topicType: Topic.Type) -> some AsyncSequence<PermissionResponse<Topic>, Never> where Topic : QuestionTopic,摘要为”向系统注册该主题类型,并返回一个响应的异步序列”,完全没有提到后台唤起。共存在四个ask(_:in:)重载:iOS、iPadOS 和 visionOS 上两个接收UIViewController,macOS 上两个接收NSWindow,每种主题类型各一个。PermissionResponse暴露choice和question;PermissionChoice.Answer恰好公布两个成员,approval和denial。 ↩↩ -
Apple Staff 在 AppStore.ageRatingCode always returns 0 on real device 中的回复,Apple 开发者论坛。原帖发于2026年4月,报告在运行 iOS 26.4 的真机上、已登录沙箱账户并在 App Store Connect 中配置了年龄分级的情况下仍返回
0。标记为 Apple Staff 的作者回复称:”ageRatingCodeAPI 应当用于通过与最后已知值比较来观察 App 年龄分级随时间的变化。如果您 App 的年龄分级编码发生了变化,可考虑使用 Significant Change API 告知家长或监护人”,以及”在从 Xcode 和沙箱环境(包括 TestFlight)构建并运行 App 的开发阶段,返回0是预期行为”。于2026年7月26日两次抓取,措辞一致;该论坛通过 JavaScript 渲染,且把回复的时间显示为相对时间戳 “1w” 而非具体日期,因此此处不报告发布日期。开发者论坛的回复在证据强度上弱于文档页面,而 Apple 关于ageRatingCode的文档完全没有提到0这个值。本文由此得出的结论——把0存为基线会在 App Store 的第一个构建上制造出一次虚假变更——是我根据该回复以及 Apple 已记录的比较模式做出的推断。 ↩↩↩↩ -
Apple,Enabling App Store Server Notifications。以下内容的出处:TLS 下限(”您的服务器必须支持传输层安全协议(TLS)1.2 或更高版本”)、在 App Store Connect 中按环境配置 URL、端口限制(443,或 1024 及以上),以及白名单子网(”添加 IP 地址子网
17.0.0.0/8“,且该要求”同时适用于沙箱和生产环境”)。配套文章 Receiving App Store Server Notifications 描述了经 JWS 签名的signedPayload,并且截至2026年7月26日,只提到data对象,未提及appData。 ↩ -
Apple,Responding to App Store Server Notifications。以下内容的出处:成功状态码(”发送 HTTP
200,或200到206之间的任意 HTTP 状态码”)、重试触发条件(”发送 HTTP50x或40x可让 App Store 重试该通知”)、版本 2 的重试时间表(”它会重试五次,分别在上一次尝试之后的 1、12、24、48 和 72 小时”)、沙箱限制(”重试通知仅在生产环境中提供。在沙箱环境中,App Store 服务器只尝试发送该通知一次”),以及通过Get-Notification-History的补救路径。 ↩↩ -
Apple,subtype,App Store Server Notifications。该页面公布 19 个可能值(ACCEPTED、ACTIVE_TOKEN_REMINDER、AUTO_RENEW_DISABLED、AUTO_RENEW_ENABLED、BILLING_RECOVERY、BILLING_RETRY、CREATED、DOWNGRADE、FAILURE、GRACE_PERIOD、INITIAL_BUY、PENDING、PRICE_INCREASE、PRODUCT_NOT_FOR_SALE、RESUBSCRIBE、SUMMARY、UPGRADE、UNREPORTED、VOLUNTARY),每一个都限定在某个具名的通知类型上。字符串
RESCIND_CONSENT在该页面负载中任何地方都不出现,读取于2026年7月26日。 ↩ -
Apple,PermissionButton,PermissionKit。
@MainActor @preconcurrency struct PermissionButton<Topic, Label> where Topic : QuestionTopic, Label : View,自 iOS 26.2、iPadOS 26.2、Mac Catalyst 26.2、macOS 26.2 和 visionOS 26.2 起可用,带有两个init(question:label:)重载,分别约束到CommunicationTopic和SignificantAppUpdateTopic。它取代了CommunicationLimitsButton,后者被 Apple 列在框架页面的 Deprecated API 之下。 ↩↩↩ -
Apple,AgeRangeService.showSignificantUpdateAcknowledgment(in:updateDescription:)、SignificantUpdateAction,以及 SwiftUI 环境值 showSignificantUpdateAcknowledgment。该方法声明为
@MainActor func showSignificantUpdateAcknowledgment(in windowScene: UIWindowScene, updateDescription: String) async throws,公布 iOS 26.4、iPadOS 26.4 和 Mac Catalyst 26.4,没有 macOS 行;AgeRangeService在”Displaying update acknowledgments”下只列出这一个重载。SignificantUpdateAction与该环境值公布的是同样三个平台。Apple 在该方法上的 Important 提示:”调用此函数之前,请检查RegulatoryFeature,以确定是否必须由本人确认您 App 的重大变更。”该环境值的讨论部分补充说,你应该”从Button或onAppear(perform:)中调用此动作”。 ↩↩↩↩ -
Apple,requestAgeRange(ageGates:::in:),Declared Age Range。Apple 公布了两个重载:一个在 iOS 26.0、iPadOS 26.0 和 Mac Catalyst 26.0 上接收
in viewController: UIViewController,另一个在 macOS 26.0 上接收in window: NSWindow。年龄区间请求存在NSWindow版本,而注 27 中的确认表单却没有——这正是把 macOS 上的缺失读作遗漏而非政策决定的依据;Apple 对此从未表态。 ↩ -
Apple,Requesting people’s age range information in your app,Declared Age Range。以下内容的出处:正文引用的门槛算术(”您最多可以指定三个年龄门槛,从而产生最多四个可能的年龄区间”)、间距约束(”每个区间的跨度必须至少为两年”),以及边界语义(”当
lowerBound值为nil时,此人低于您设定的最低年龄门槛”以及”当upperBound为nil时,此人达到或超过您设定的最高年龄门槛”)。把门槛设在 13、16 和 18,恰好返回 Apple 为得州公布的那四个区间,而两个有界区间也都满足两年的最小跨度:13 至 15 跨三年,16 至 17 跨两年。该文章还提醒,一个人的账户所属地区”决定了系统用来返回年龄区间的年龄门槛,它可能与您在请求中指定的年龄门槛不同”。于2026年7月26日读取自 Apple 文档的 JSON。 ↩ -
作者调研,2026年7月26日:平台相关论断是如何确立的。平台取值读自各项目的
SUPPORTED_PLATFORMS构建设置,而不是部署目标键——后者无论目标平台如何 Xcode 都会写入:Reps 声明appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator,外加一个独立的watchos watchsimulatortarget;Ace Citizenship 只声明iphoneos iphonesimulator。这种方法会低报平台,而 Return 就是证明:Return 唯一的SUPPORTED_PLATFORMS取值是iphoneos iphonesimulator macosx xros xrsimulator,而它的 TV 与 watch target 携带的是SDKROOT = appletvos和SDKROOT = watchos,因此单看SUPPORTED_PLATFORMS的扫描两个都看不到。所以本文中每一处”发布了平台 X”的论断,依据都来自 App Store Connect,而非构建设置。Return:TV_OS 1.0 和 1.0.1 均为 READY_FOR_DISTRIBUTION,IOS 与 MAC_OS 1.0.1 同样如此,且 iOS target 通过 Embed Watch Content 阶段内嵌了ReturnWatch Watch App(com.941apps.Return.watchkitapp)——手表 App 正是这样到达手腕上的。Reps:IOS 1.1 和 MAC_OS 1.1 为 READY_FOR_DISTRIBUTION,TV_OS 从未有任何版本达到该状态,而 TV_OS 1.2 自2026年6月2日起一直停在 WAITING_FOR_REVIEW,所以 Reps 发布的是 iOS 和 macOS。 ↩ -
作者调研,2026年7月26日,环境为 macOS 26.5.2(构建 25F84)、Xcode 26.6(构建 17F113)和 Swift 6.3.3。选取规则在此写明,是为了让范围可以被核查和复现,而不是靠信任:我自己的代理配置(
~/.claude/CLAUDE.md)中 Active Projects 表格里每一行背后有 Xcode 工程的项目,恰好得到七个:Reps、Return、Banana List(以 Get Bananas 名义发布)、Ace-Citizenship、Water、ResumeGeniApp和Yawara。这条规则同时也是本次调研的缺陷所在,因为Randori在那张表里没有一行,而Randori恰恰是真正重要的那个项目。七个中有五个有 App Store Connect 记录(Reps 6776044339、Return 6756242021、Get Bananas 6756241534、Ace Citizenship 6532592671、ResumeGeni 6771154645);Water和Yawara在该账户的 18 个 App 中都找不到,所以两者都从未提交过,把它们中的任何一个称为 App 都言过其实。各项目的 Swift 文件数:77、57、55、26、34、71 和 143。同意扫描覆盖*.swift、*.entitlements、*.plist和project.pbxproj:85、65、63、34、38、74 和 149,合计 508。要复现这些计数需要排除八个目录名,而不是六个:build、DerivedData、.build、Pods、.git、worktrees,以及仅在 Reps 中存在的.venv(Reps/.venv和Reps/server/.venv下共 142 个属性列表)和.xcode-state-backups(18 个)。只排除前六个的话,Reps 会读成 245,总数会读成 668,可见这份排除清单是承重的,所以它依赖的每一个名字都印在这里。16 个模式,全部区分大小写:PermissionKit、CommunicationLimits、AskPermission、AskCenter、SignificantAppUpdateTopic、SignificantUpdateAction、PermissionTopic、com.apple.developer.family-controls、FamilyControls、ManagedSettings、DeviceActivity、AuthorizationCenter、CKShare、sharedCloudDatabase、publicCloudDatabase和ageRatingCode。七个项目的匹配文件数全为零,AskCenter也不例外。通过完全相同的管道跑了两个对照模式,结果非零(StoreKit在 Reps 中命中 3 个文件,CKContainer|NSPersistentCloudKitContainer|SwiftData在 Banana List 中命中 17 个),我据此知道这套管道确实在读文件,而不是悄无声息地失败。Ace Citizenship 的引导流程(IntroCarouselView、WelcomeView、AddStateView、AddRepresentativeView)不含任何年龄或出生日期字段,其PrivacyInfo.xcprivacy声明的NSPrivacyCollectedDataTypes为空;”年满18岁”这条资格说明出自~/Projects/acecitizenship.app/content/blog/n400-application-guide.md。 ↩↩↩ -
作者调研,2026年7月26日:更大范围的扫描与 Randori。这次更大范围的扫描在
~/Projects下的每一个仓库上运行了同样的 16 个模式,排除同样的八个目录名外加node_modules,并跳过被 gitignore 的路径,共命中 21 个文件。其中 7 个是代码:Randori/Randori/下的六个 Swift 文件,以及_archive/Oishii-AI/Oishii AI/Services/CloudKitManager.swift(publicCloudDatabase,位于一个已归档的项目中)。其余 14 个是文稿或机器状态:九份 Randori 的方案与设计文档、本站content/blog/中的三篇文章(其中一篇是本文的草稿)、一份 Obsidian 交接笔记,以及obsidian-signals/40-Projects/blakecrosley-com/wwdc-2026/.pulse_state.json——后者由脚本写入,而非人工。Randori 的标识符是com.wayofyawara.randori,App Store Connect 应用编号 6789693294,1.0 版本处于 PREPARE_FOR_SUBMISSION。CKShare和sharedCloudDatabase在六个文件中共命中 20 行:ConnectionStore.swift(10)、RandoriApp.swift(5)、ProfileCardView.swift(2),以及CKPostTransport.swift、CloudShareSheet.swift和SocialContracts.swift各 1 行。ConnectionStore在文件头注释中自述其形状为”一个 zone(ProfileCardZone)、一种记录类型(ConnectionCard)、一个 share”,同时持有privateCloudDatabase和sharedCloudDatabase,并在第 355 行实现accept(_ metadata: CKShare.Metadata)、第 547 行实现ensureOutboundShare(),以及按账户的屏蔽与解除屏蔽。RandoriApp.swift在 app delegate(第 73 行)和 window scene delegate(第 97 行,注释为”热态:链接被打开时 App 正在运行”)上都实现了userDidAcceptCloudKitShareWith,而RandoriSceneDelegate.scene(_:willConnectTo:options:)在第 88 行读取connectionOptions.cloudKitShareMetadata,注释为”冷启动:邀请随 connection options 一同到达”——这正是正文把冷启动归到 connection options 而非接受回调的依据。TagConsentStore是这个 App 自己的同意台账,按 iCloud 账户划分范围,其文档化的默认行为是失败即拒绝:策略记录尚未送达的伙伴无法被标记。Randori/Randori.entitlements申请了 CloudKit、HealthKit 和aps-environment。除了那两个 CloudKit 共享相关的模式之外,所有同意模式在该仓库中的匹配数都为零,StoreKit、signedPayload和notificationType也是零,所以这个 App 不卖任何东西,也没有自己的服务器;docs/asc-metadata.md准备的是免费定价和 4+ 年龄分级。 ↩↩↩↩ -
作者调研,2026年7月26日:StoreKit 调用点与服务器端证据。StoreKit 出现在三个项目中:
Reps/Reps/Services/RepsProStore.swift(自动续订,两个档位)、Ace Citizenship/StoreKitManager.swift(一个非消耗型项目)和ResumeGeni/Subscription/(一个月度订阅,在服务器端把关)。表格中引用的.pending处理分别位于RepsProStore.swift:134(case .userCancelled, .pending:)、StoreKitManager.swift:69-73(一个独立的case .pending:,其return false在第 73 行)和SubscriptionStore.swift:209-212(一个具名的 pending 结果)。真正把用户晾住的是调用点:RepsProPaywallView.swift:359-361只在if await store.purchase(plan)内部关闭付费墙,ContentView.swift:484-485只在if success内部解锁,所以为待定购买返回的false在这两个 App 的屏幕上都不会改变任何东西。ResumeGeni 走到的则是PaywallView.swift:526的一条.pending分支,它弹出提示”正在等待批准。批准通过后您即可获得访问权限。”ResumeGeni 的 App Store Server Notifications 端点是~/Projects/resumegeni/app/routers/appstore.py中的POST /api/appstore/notifications;向其POST一个{"signedPayload":"probe"}会返回 HTTP 400{"status":"invalid"},这个响应只有越过启用开关、进入 Apple 的证书链验证器之后才可能拿到,而GET返回 405。两个环境的 URL 都已注册,其依据是投递记录而非运维手册:941-analyticsD1 数据库的funnel_events表中有 56 行满足platform='ios'且元数据携带notification_type,时间跨度为 2026-06-26T15:48:51Z 至 2026-07-25T16:40:11Z,其中 55 行的environment为sandbox,1 行为production(2026-07-21T18:08:21Z)。请把 56 当作下界,因为该服务只把五个事件名映射到漏斗行;实际观察到的四种类型是 DID_RENEW(42)、SUBSCRIBED(6)、EXPIRED(5)和 DID_CHANGE_RENEWAL_STATUS(3)。app/services/app_store_notification_service.py点名了 13 种通知类型,其中八种在_funnel_event_name(第 75 至 89 行)中有可执行的派发分支:SUBSCRIBED、OFFER_REDEEMED、DID_RENEW、REFUND、REVOKE、EXPIRED、DID_CHANGE_RENEWAL_STATUS 和 DID_FAIL_TO_RENEW。其余五种只出现在注释里:第 73 行的 PRICE_INCREASE、RENEWAL_EXTENDED 和 METADATA_UPDATE,第 187 行的 EXTERNAL_PURCHASE_TOKEN 和 TEST。RESCIND_CONSENT、CONSUMPTION_REQUEST、REFUND_DECLINED和REFUND_REVERSED在整个仓库中的匹配数都是零。为完整起见,说明什么不算证据:docs/SUBSCRIPTION_GO_LIVE.md:83确实写着”同时设置生产环境和沙箱环境的 URL”,但这一行位于一个注明该步骤”需要你重新认证”的标题之下的运维手册中,所以它记录的是意图而非已完成的注册;上文关于注册的论断依据的是已投递的通知。 ↩↩↩ -
Apple,Testing Ask to Buy in Xcode,StoreKit。Apple 对该机制的描述(”启用购买前询问后,当孩子想要进行符合条件的购买或下载时,系统会把购买请求发送给家长或监护人”)以及拒绝行为(”因为您拒绝了购买前询问,您的 App 不会收到交易”)的出处。该文章还记录了 StoreKit 配置编辑器中 Purchase Options 下的购买前询问开关,以及事务管理器中的 Pending Ask to Buy、Ask to Buy Approved 和 Ask to Buy Declined 三种状态。 ↩↩↩
-
Apple,Product.PurchaseResult.pending 与 Transaction.updates,StoreKit,两者均自 iOS 15.0 起可用。以下内容的出处:该成员的摘要(”购买处于待定状态,需要顾客采取操作”)、解决路径(”如果待定购买成功,StoreKit 会在事务
updates中投递相应的Transaction“),以及该序列的用途(”该序列接收在 App 之外发生的交易,例如购买前询问的交易、优惠码兑换,以及顾客在 App Store 中进行的购买”)。Product.PurchaseResult 枚举公布三个成员:success(_:)、pending和userCancelled。Apple 在该枚举页面上的示例,为 pending 分支加的注释是”该购买需要顾客采取操作。如果交易完成,可通过Transaction.updates获取”。 ↩↩ -
上文路由代码中的每一个符号,均于2026年7月26日对照 Apple 文档的 JSON 核实。
AgeRangeService.shared是static let shared: AgeRangeService(iOS 26.0)。SwiftUI 环境值分别是requestAgeRange,var requestAgeRange: DeclaredAgeRangeAction { get }(iOS 26.0),以及showSignificantUpdateAcknowledgment,var showSignificantUpdateAcknowledgment: SignificantUpdateAction { get }(iOS 26.4)。代码中的@available(iOS 26.5, *)下限并非来自这两者:AgeRangeService.AgeRangeDeclaration.confirmed公布的是 iOS 26.5、iPadOS 26.5、Mac Catalyst 26.5 和 macOS 26.5,比确认动作晚一个版本,所以任何要区分”已确认”成年人的代码都会继承这个更高的下限。Apple 自己的示例工程公布的也是同样的 26.5 可用性。6AgeRangeService.AgeRange暴露lowerBound、upperBound、声明为var ageRangeDeclaration: AgeRangeService.AgeRangeDeclaration?的ageRangeDeclaration,以及activeParentalControls。对该可选值使用== .confirmed比较是有效的,因为按其 relationships 一节,AgeRangeDeclaration遵循Equatable和Hashable。PermissionButton的初始化方法是init(question: PermissionQuestion<Topic>, @ViewBuilder label: @escaping () -> Label),此处使用的重载约束到SignificantAppUpdateTopic。26ChangedFeatureView和AccountVerificationPrompt是你自己视图的占位符,并非 Apple 的符号。 ↩↩↩ -
Apple,Age ratings values and definitions,App Store Connect 帮助文档,读取于2026年7月26日,作为注 9 所述2026年6月18日变更已生效的确证来源。在”Australia age rating values”下,表格现在公布两个分级——16+ 和 R 18+——没有 15+ 那一行。此外新增了一个独立的”Vietnam age rating values”小节,开头写明”依据越南第 147 号法令第 38 条的要求”,其 00+ 行的定义是”不含令人反感的内容,但可能包含以下内容的情形”,随后列出家长控制、年龄核验、用户生成内容、消息与聊天、广告以及不频繁的竞赛。Apple 5月21日为澳大利亚迁移列出的描述符清单,与该页面当前 16+ 的触发条件清单并不吻合;这处出入我尚未查清,也没有依赖它,因为此处得出的论断仅限于 15+ 档已消失、越南表格已存在这两点。 ↩↩↩