深色模式
iOS内购回调环境
概述
App Store Connect 只提供 Production Server URL 和 Sandbox Server URL,这两个地址区分的是 Apple 交易环境。公司的正式、预发布、测试环境属于另一套部署维度,App 当前连接哪个业务后端,也不会改变 StoreKit 使用的 Apple 环境。
环境概念没有拆开时,最常见的配置是把 Production URL 指向正式后端、Sandbox URL 指向测试后端。普通用户购买看起来没有问题,但 App Review 使用沙盒交易,通知会进入测试后端;多个测试环境也只能争用同一个 Sandbox URL。解决问题的重点不是继续增加环境判断,而是建立独立的通知入口和交易路由。
本文按 2026-08-04 查阅的 Apple Developer 文档整理。StoreKit 2 的交易验证与订阅事件处理可继续参考 iOS自动续期订阅。
三层环境
内购链路中同时存在三种环境:
| 维度 | 常见取值 | 决定的内容 |
|---|---|---|
| App 分发方式 | App Store、App Review、TestFlight、开发签名、StoreKit Testing | StoreKit 连接生产、沙盒还是 Xcode 本地环境。 |
| 业务部署环境 | 正式、预发布、测试、开发 | App 请求哪个业务 API,权益写入哪套数据库。 |
| Apple 交易环境 | Production、Sandbox、Xcode | JWS 中的 environment、Server API 地址和通知投递地址。 |
三者有关联,但不能互相替代。尤其是“正式签名的包”和“生产交易”并不完全等价:Apple 在 App Review 中使用沙盒测试内购,因此送审候选包可能产生 Sandbox 交易;TestFlight 和开发签名包的内购也使用沙盒。公开 App Store 中普通用户的真实购买才是 Production 交易。
| 场景 | Apple 交易环境 | Server Notifications |
|---|---|---|
| 公开 App Store 下载,普通用户真实购买 | Production | Production URL |
| App Review 审核内购 | Sandbox | Sandbox URL |
| TestFlight | Sandbox | Sandbox URL |
| 开发签名包配合 Sandbox Apple Account | Sandbox | Sandbox URL |
| Xcode StoreKit Testing | Xcode | 不经过 App Store Server Notifications |
Apple 不会在两个地址之间随机投递。还有一条容易造成误判的规则:如果没有配置 Sandbox URL,沙盒通知会自动发送到 Production URL。此时“生产地址收到了通知”只代表发生了地址回退,载荷里的 data.environment 仍然是 Sandbox。
因此,HTTP 请求打到了哪个 URL,不能作为交易环境的最终依据。服务端应在验签后读取 data.environment,并校验内层 signedTransactionInfo 或 signedRenewalInfo 中的环境。
购买主链路
首次购买不应该等待 Server Notifications 后才发放权益。通知是异步事件,可能晚于客户端购买结果;沙盒通知失败后只投递一次,更不适合作为购买接口的同步确认条件。
推荐的主链路是:
客户端上传的交易 JWS 负责让当前购买尽快闭环;Server Notifications 负责续期、退款、撤销、账单失败等 App 外事件;App Store Server API 和定时任务负责补偿。三条链路共同维护最终状态,不能把其中任意一条当成唯一事实来源。
调用新版 App Store Server API 时,API 环境也要跟随 Apple 交易环境,不能跟随业务部署环境:
Production交易调用生产 Server API。Sandbox交易调用沙盒 Server API。- 接口返回
4040010时,要检查bundleId和请求的 Apple 环境,不能直接发放权益。
服务端数据模型至少应同时保存 apple_environment 和 business_environment。前者决定如何验证和查询 Apple,后者决定事件最终写入哪套业务系统。
错位原因
直接把两个 Apple 地址分别绑定到公司的正式、测试后端,会出现下面的错位:
App Review 的 App 通常连接正式后端,但它产生的是 Sandbox 交易,通知却进入测试后端。测试环境 A、B 即使使用不同的 API 域名,对 Apple 来说也都是同一个 App 的沙盒交易,无法仅靠 Sandbox URL 判断应该写入哪套数据库。
通知载荷不会自动携带公司的 dev、staging、prod 标记。用 bundleVersion、商品 ID 或通知 URL 猜业务环境,只在这些值被严格隔离时才成立,长期维护很容易串库。
生产优先
如果目标是先保证正式包和 App Review 跑通,可以放弃测试包的异步回调,采用一套较小的改造方案:
- 将 Production URL 和 Sandbox URL 都配置到稳定的生产级通知入口。显式配置两个地址比依赖 Sandbox URL 缺省回退更清楚,两个地址可以相同。
- 正式后端同时具备
Production与SandboxJWS 验证能力。普通用户产生前者,App Review 产生后者。 - App 购买前向当前后端取得由服务端生成的
appAccountToken,购买时通过Product.PurchaseOption.appAccountToken(_:)传给 StoreKit。 - 正式通知入口只处理已经映射到正式业务账号的交易。App Review 使用的审核账号及其
appAccountToken属于正式环境,因此沙盒通知仍能更新正式数据库。 - 测试包产生但没有正式映射的沙盒通知只做验签、留档并返回成功,不能因为它是
Sandbox就写入正式权益。
测试后端仍然可以完成首次购买验证,因为 App 会把交易 JWS 直接上传到它当前连接的后端。若测试流程不依赖回调,再补上这些动作即可:
- App 启动和登录时读取
Transaction.currentEntitlements,把有效交易同步给测试后端。 - 常驻监听
Transaction.updates,把续期或撤销后的交易同步给测试后端。 - 测试后端根据已保存的交易 ID 调用沙盒 Server API,按需刷新订阅状态。
- 退款、账单失败等用例需要验证服务端异步时,再使用下一节的完整方案。
这个方案放弃的是测试环境的“实时服务端通知”,不是交易验签本身。生产购买也不应依赖 webhook 才完成首次发货,否则 App Review 和真实用户都会受到通知延迟影响。
完整回调
所有业务环境都需要接收通知时,可以增加一个与业务部署环境无关的 内购通知网关。App Store Connect 的两个 URL 都指向网关,由网关验签、识别交易,再投递到对应业务环境。
appAccountToken 是适合这项路由的关联键。它必须是 UUID,由业务服务生成并在购买时传给 StoreKit。Apple 会把同一个值放进签名交易和订阅续期信息,网关可以在 JWS 验签后使用它查询路由表。
路由表可以保存:
| 字段 | 用途 |
|---|---|
app_account_token | 全局唯一的随机 UUID,通知路由主键。 |
business_environment | prod、staging、test 等目标环境。 |
user_id | 目标环境中的用户标识。 |
bundle_id | 防止不同 App 之间误关联。 |
original_transaction_id | 首次交易完成后补写,作为订阅链路的备用路由键。 |
apple_environment | 首次验签后记录,用于后续 Server API 查询。 |
购买上下文必须在调用 StoreKit 之前 写入路由表,避免通知先到、映射后到。客户端完成购买并上传 JWS 后,再补写 transactionId 和 originalTransactionId。老交易或没有 appAccountToken 的通知,可以按已登记的 originalTransactionId 路由;仍然无法识别的事件进入隔离队列,等待客户端同步或人工处理,不要广播给所有环境。
这套方案会自然得到四种组合:
| 业务目标 | Apple 环境 | 场景 | 处理 |
|---|---|---|---|
| 正式 | Production | App Store 普通用户 | 正常生产交易。 |
| 正式 | Sandbox | App Review | 允许,写入审核账号的正式权益。 |
| 测试或预发布 | Sandbox | TestFlight、开发签名包 | 路由到对应测试环境。 |
| 测试或预发布 | Production | 非预期组合 | 隔离并告警,不能自动写入。 |
如果不希望维护共享网关和路由表,另一种硬隔离方式是为正式、测试 App 使用不同的 Bundle ID 和 App Store Connect App 记录。每个 App 记录都有自己的 Production URL 与 Sandbox URL,路由会简单很多,但商品、证书、TestFlight、审核配置也要分别维护。只有确实需要环境级隔离时,这项成本才划算。
接收规范
无论采用哪种方案,通知入口都应遵守以下规则:
- 使用 Apple 官方 App Store Server Library 验证外层
signedPayload,并验证内层交易与续期 JWS。 - 未验签的载荷只能用于选择验证器,不能据此路由或更新权益;验签后再校验
bundleId、environment和商品范围。appAppleId在沙盒通知中不存在,只在生产通知中额外校验。 - 使用
notificationUUID做通知幂等,使用transactionId、originalTransactionId维护交易与订阅链路。 - 持久化原始通知并成功写入队列后尽快返回
2xx,耗时业务处理放到异步消费者。 appAccountToken使用服务端生成的随机 UUID,不要把可猜测的用户 ID 直接转换成 UUID,也不要接受客户端任意指定归属。- 生产通知失败后 Apple 会重试,沙盒通知只尝试一次。定时使用
Get Notification History、Get Transaction History或Get All Subscription Statuses补偿,不能假设 webhook 永不丢失。 - 监控要同时标记
apple_environment与business_environment,否则看到一条沙盒通知进入正式系统时,很难区分它是 App Review 还是串环境。
最终配置原则可以压缩成一句话:Apple 环境决定如何验签和调用哪个 Server API,业务环境决定权益写到哪里;两者通过已验签交易中的稳定标识建立映射。
参考
- Enabling App Store Server Notifications
- Enter server URLs for App Store Server Notifications
- Testing In-App Purchases with sandbox
- Testing at all stages of development with Xcode and the sandbox
- App Review
- Testing App Store server notifications
- Responding to App Store Server Notifications
- Product.PurchaseOption.appAccountToken
- JWSTransactionDecodedPayload
- TransactionIdNotFoundError
