IAP 恢复购买跨设备失效:6 个原因 + 服务端权威修复

已付费用户点 Restore Purchases 却看到 "Nothing to restore"。逐层修好 StoreKit、校验和 entitlement。

用户邮件说:去年在旧 iPhone 买了你的 Pro 订阅,刚配好新 iPhone,用同一个 Apple ID 登进 App,点 Restore Purchases,看到 “Nothing to restore”——而 App Store Connect 显示这笔交易还在活跃。或者同一台设备上卸了重装,你的 App 就把他当免费用户了。交易记录明明还在他的 Apple 账户里,是你的 App 没把它读回来。

最快的修法(StoreKit 2,覆盖 80% 场景):启动时静默Transaction.currentEntitlements(不弹窗),按它返回的内容授权。在可见的 Restore Purchases 按钮后——也只在那里——调 try await AppStore.sync(),然后重新读 currentEntitlementscurrentEntitlements 读的是本地缓存,在全新安装或设备从备份恢复后可能返回空;AppStore.sync() 会强制 StoreKit 从 Apple 重新拉取,但它会弹 Apple 账户密码框,所以绝不能在启动时调。

恢复失败几乎都是 App 侧的问题,不是 Apple。三层——StoreKit API 调用、服务端校验、entitlement 持久化——各自有典型失败模式。三层都对,restore 对用户就近乎无感:启动时静默完成,按钮只作为手动兜底。

你属于哪一类?

现象最可能原因跳到
第一台设备正常,同一台重装后失效本地 “isPro” 标记卸载就清原因 1 / Step 1
Restore 返回了交易但服务器拒绝校验路由或 shared secret 错(2100721003原因 2 / Step 2
新买家正常,换过账号的用户失效Entitlement 绑 App 账号而非 Apple 账户原因 3 / Step 1
只有订阅者失效改名后的 product ID 没映射原因 4 / Step 3
迁到 StoreKit 2 后老买家恢复不出来SK1 收据与 SK2 JWS 不匹配原因 5 / Step 4
家人恢复不出共享购买没处理 ownershipType == .familyShared原因 6 / Step 4
有效购买却 currentEntitlements 返回本设备本地 StoreKit 缓存从没同步过Step 5

常见原因

按命中率排序。

1. 用本地 “purchased” 标记,重装就清

UserDefaults(React Native 里 AsyncStorage、Android 上 SharedPreferences)在 App 卸载时被清空。代码启动时读 defaults.bool(forKey: "isPro"),标记是 false 就把功能锁掉。交易其实还在用户的 Apple 账户里,你只是没去重新核对。

如何判断:搜代码里所有把购买状态当成真源UserDefaults@AppStorage 或类似本地存储。Gating 逻辑依赖它就有这个 bug。

2. 收据 / 交易校验配错了

你拿错 shared secret 调 Apple 的 verifyReceipt、把沙盒收据发到生产端点(返回 21007),或者后端拒绝超过 X 天的收据。Restore 返回了交易但你的服务器标记为无效,App 就丢弃了。

如何判断:看 restore 时校验端点的服务器日志状态码。21002(畸形收据)、21003(认证失败)、21007(沙盒收据发到生产端点)、21008(生产收据发到沙盒端点)任何一个都是路由 / shared secret 问题。

注意(截至 2026 年 6 月):Apple 已经弃用 verifyReceipt 和 App Store Server Notifications V1。它们仍能用、也还没公布停用日期,但不再加新功能。新代码应改用 App Store Server API 校验(见 Step 2)。

3. Entitlement 绑你 App 的账号,不绑 Apple 账户

用户用邮箱密码登你的 App。他的 Apple 账户买了 Pro,但你账号表里 entitlement 记到了错的 user_id 下,或记到设备的匿名用户下。新设备 → 新匿名用户 → 没权限。

如何判断:在库里查这个用户的所有账号行,看 is_pro = true 是不是设在他当前登录的那一行。设在别的行就是账号关联 bug。

4. Product ID 改过名

旧产品 com.acme.pro_monthly,这一版换成 com.acme.pro_monthly_v2(降了价)。你的 entitlement 代码只认新 ID,老订阅者 restore 拿到旧 ID 你就忽略了。

如何判断:搜代码里 product ID 字面量。只出现当前 ID,老购买就当不存在。去 App Store Connect,选你的 App,进 Subscriptions 看完整 ID 历史。

5. StoreKit 1 → StoreKit 2 迁移把收据处理弄坏

迁到了 Transaction.currentEntitlements(SK2),但 SK1 时期的购买绑在 bundle 收据上。SK2 读的是签名过的 JWS 交易历史,不是旧收据 blob;如果本设备的本地交易缓存还没同步,老购买者从 SK2 路径里就返回空。

如何判断:用受影响用户的 session 跑 Transaction.currentEntitlements,再在按钮后跑 AppStore.sync() 并重新读一次。如果 entitlement 只在 sync() 之后才出现,说明本地缓存是旧的——见 Step 5。

6. Family Sharing / Ask to Buy 没处理

原购买者用 Family Sharing 分享给家人;家人那条交易的 ownershipType == .familyShared。你代码只 case .purchased,就跳过这条。iOS 26.x 上还有个已知回归:family-shared 的非消耗型购买在 restore 之后可能从 currentEntitlements 里消失,直到调用 AppStore.sync() 才回来。

如何判断:在 restore handler 里检查每个 transaction 的 ownershipType 字段。只 switch .purchased、不处理 .familyShared,家庭共享用户就挂。

需要收集的信息

  • 用户在 App Store Connect,进 Sales,再进 Transactions 里的 originalTransactionID
  • Client 端 restore 调用日志:返回 0 条交易还是多条。
  • Server 端校验端点日志:状态码和响应体。
  • App 期望的 product ID 集合 vs 用户实际拥有的集合。
  • 用户跨设备是不是同一个 Apple 账户(设置 → 点最上方姓名查)。

最短修复路径

Step 1:让 entitlement 服务端权威

停止把设备当真源。App 每次启动、每次成功购买或恢复时,把交易(StoreKit 2 里是签名过的 JWS)发到服务器,让服务器和 Apple 校验,再返回当前 entitlement 状态。本地只用短 TTL 缓存以备离线。

// Swift / StoreKit 2:启动时静默读,不弹窗
for await result in Transaction.currentEntitlements {
    guard case .verified(let transaction) = result else { continue }
    await syncToServer(jws: transaction.jsonRepresentation)
}

服务端用 Apple 的 App Store Server Library 校验 JWS 签名,再按用户账号 upsert entitlement。

Step 2:用 App Store Server API 做服务端校验

新代码(截至 2026 年 6 月)应改用 App Store Server API 校验,而非已弃用的 verifyReceipt。你用 ES256 签出 JWT 做认证(aud 设为 appstoreconnect-v1,通常 5 分钟过期),按 originalTransactionId 查状态:

GET https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/{originalTransactionId}
Authorization: Bearer <ES256 JWT>

沙盒交易用沙盒 host https://api.storekit-sandbox.itunes.apple.com。响应是签名过的 JWS 数据,结构化、已解析好的交易信息——不用再自己解收据。

如果你还在用旧的 verifyReceipt 路径,规则是:总先打生产端点,返回 21007 就再打沙盒。绝不要硬编码单个端点。

// Node.js:旧 verifyReceipt 兜底(已弃用,仍能用)
async function verify(receipt) {
  const prod = await fetch("https://buy.itunes.apple.com/verifyReceipt", {
    method: "POST",
    body: JSON.stringify({ "receipt-data": receipt, password: SHARED_SECRET })
  });
  const data = await prod.json();
  if (data.status === 21007) {
    const sb = await fetch("https://sandbox.itunes.apple.com/verifyReceipt", { /* 同 body */ });
    return sb.json();
  }
  return data;
}

Step 3:把所有历史 product ID 映射到 entitlement

在服务端建映射表(不是 App 里):

const ENTITLEMENT_MAP = {
  "com.acme.pro_monthly": "pro",
  "com.acme.pro_monthly_v2": "pro",
  "com.acme.pro_yearly": "pro",
  "com.acme.pro_lifetime": "pro_forever",
  "com.acme.coins_100": "coins:100",  // consumable
};

校验时按 productID 查表给权限。老购买继续生效。

Step 4:处理 StoreKit 2 + Family Sharing

for await result in Transaction.currentEntitlements {
    guard case .verified(let txn) = result else { continue }
    let isOwned = txn.ownershipType == .purchased || txn.ownershipType == .familyShared
    if isOwned {
        await grantEntitlement(productID: txn.productID)
    }
}

不要只过滤 .purchased,否则每个 Family Sharing 成员都会挂。

Step 5:把启动静默读和交互式 Restore 按钮分开

这是最多 App 搞错的一步。这里有两套不同机制,不能互换:

  • Transaction.currentEntitlements静默的:每次启动跑它,从本地缓存授权。不弹窗。
  • AppStore.sync()(StoreKit 2)和 SKPaymentQueue.default().restoreCompletedTransactions()(StoreKit 1)会弹 Apple 账户密码框。Apple 的指引是只在用户明确操作时调用。绝不要在启动时调。

currentEntitlements 对一笔有效、活跃的购买也可能合法地返回条交易——当 StoreKit 本地缓存在本设备上从没同步过时常见,比如全新安装、从备份恢复的设备、或换过 Apple 账户。AppStore.sync() 会强制从 Apple 重新拉取。把它挂在 Restore 按钮后,跑完再重新读 currentEntitlements

func restorePurchases() async {
    do {
        try await AppStore.sync()  // 弹 Apple 账户密码框
    } catch {
        // 用户取消了弹窗,或 sync 失败;给个重试,别崩。
        return
    }
    await refreshEntitlements()  // sync 之后重新读 currentEntitlements
}

注意:如果用户取消密码框,AppStore.sync() 可能不抛错就返回、也没真同步。之后务必重新读 currentEntitlements,并给一条清晰的”还是找不到你的购买?“路径,而不是默认成功。

Step 6:用沙盒 + StoreKit 配置文件测

Xcode 12 及以上加 .storekit 配置文件,本地用来模拟购买 / 退款 / 续订,不走真沙盒。然后用沙盒账号跑完整路径:买 → 卸 → 重装 → 启动,确认启动静默读生效——再单独测 Restore 按钮,确认 AppStore.sync() 能从空缓存里救回来。

怎么确认已经修好

  • 沙盒账号能在设备 A 买 → 卸 → 重装 → 启动后不点任何东西就看到 Pro 功能(静默 currentEntitlements 读)。
  • 本地缓存为空时,点 Restore Purchases 会跑 AppStore.sync()、弹一次密码,重新读后解锁 Pro。
  • 同一沙盒账号在设备 B 用同一 Apple 账户登录后,启动后(或点一次 Restore 后)看到 Pro 功能。
  • 服务器日志显示每次 restore 都成功向 Apple 校验。
  • Family Sharing 测试者(单独沙盒账号)也能恢复到 entitlement。
  • 之前抱怨的生产用户在下次 App 更新后确认问题消失。

常见问题

为什么购买明明活跃,Transaction.currentEntitlements 却什么都不返回? 它读的是 StoreKit 从 Apple 同步来的本地缓存。在全新安装、从备份恢复的设备、或换过 Apple 账户后,这次同步可能没跑过,流就是空的。在 Restore 按钮后调 AppStore.sync(),再重新读。

该在 App 启动时调 AppStore.sync() 自动恢复吗? 不该。它会弹 Apple 账户密码框。Apple 的指引是只在用户明确操作时调用。启动用静默的 currentEntitlements 读,sync() 留给 Restore 按钮。

verifyReceipt 是不是废了?必须马上迁移吗? 截至 2026 年 6 月,verifyReceipt 和 App Store Server Notifications V1 已弃用但仍能用,没有公布停用日期。现有 App 继续工作;新的校验代码应改用 App Store Server API 和 ASN V2。

用户是同一个 Apple 账户,还是 “Nothing to restore”,怎么办? 先在设置 → 点最上方姓名确认 Apple 账户,确保它就是购买账户,而不只是 iCloud。再让他点 Restore(强制走 AppStore.sync()),查服务器日志里的校验状态码。21003 指向 shared secret 问题;client 端返回空则指向缓存陈旧。

为什么模拟器里 restore 正常,真实用户却失败? 模拟器和 .storekit 配置文件用的是本地 store,不走 Apple 服务器,永远碰不到缓存同步这个边界情况。一定要在装了真沙盒账号的设备上跑完整的 买 → 重装 → 恢复 流程。

如果还是没修好

  1. 让受影响用户邮箱回传收据或交易 JWS,本地走一遍你的校验流水,错误码或 originalTransactionId 会指向问题。
  2. 核对 App Store Connect,进 Apps,进 Subscription,进 App-Specific Shared Secret,怀疑泄露就轮换。
  3. Apple System Status 页面 是否有 App Store / 收据校验故障,沙盒尤其。
  4. 兜底建一条”联系客服恢复”路径:收用户 originalTransactionID,服务端手动赋权,记录 case。

预防建议

  • 把 entitlement 当成服务端状态机;设备是个查询的客户端,不是真源。
  • 加 CI 集成测试:每个 PR 用 StoreKit 配置文件跑一遍 买 → 重装 → 恢复。
  • 维护 LEGACY_PRODUCTS.md 列出每个旧 product ID 和当前映射,改产品名时强制更新它。
  • 订阅 App Store Server Notifications V2 webhook,让服务器靠推送知道续费 / 退款,不用轮询。
  • Restore Purchases 按钮保持可见但不关键——启动静默读覆盖 95% 场景,按钮(带它的密码框)是明确的兜底。

相关阅读

标签: #排查 #App Store #App 审核 #IAP / 内购