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

已付费用户点 Restore Purchases 却什么也没恢复。

用户邮件说去年在 iPhone 买了你的 Pro 订阅,刚换新 iPhone,用同 Apple ID 登进 App,点 Restore Purchases,看到 “Nothing to restore”——而你的 App Store Connect Sales 报表显示这笔交易还在活跃。或者更糟:用户在同一设备卸了重装,你的 App 把他当成免费用户了。收据明明在他的 Apple ID 里,是你的 App 没把它读对。

恢复失败几乎都是 App 侧的问题,不是 Apple。三层——StoreKit API 调用、收据校验、entitlement 持久化——各自有典型失败模式。三层都对,restore 对用户就是无感的(应该 App 启动时自动跑,不该藏在按钮后)。

常见原因

按命中率排序。

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

UserDefaults(React Native 里 AsyncStorage、SharedPreferences 同类)在 App 卸载时清零。代码启动时读 defaults.bool(forKey: "isPro"),标记是 false 就把功能锁掉。Bundle.main.appStoreReceiptURL 里的收据还在,你压根没去看。

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

2. 收据校验路由错了

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

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

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

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

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

4. Product ID 改过名

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

如何判断:搜代码里产品 ID 字面量。只出现当前 ID 就把老购买当不存在。App Store Connect → Subscriptions 看完整 ID 历史。

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

迁到 Transaction.currentEntitlements(SK2),但 SK1 时期购买的收据还在 bundle 里。SK2 的 API 不读那种收据,它读 JWS payload。老购买者从 SK2 代码路径里 restore 不出来。

如何判断:用受影响用户的 session,同时跑 Transaction.currentEntitlementsSKPaymentQueue.default().restoreCompletedTransactions()。只有 SK1 返回 entitlement 就缺 SK2 对应处理,或要刷收据。

6. Family Sharing / Ask to Buy 没处理

原购买者用 Family Sharing 分享给家人;家人 restore 拿到的交易 originalTransaction.ownershipType == .familyShared。你代码只 case .purchased,跳过这条。

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

动手前先确认

  • 弄清问题是 client 侧(StoreKit 代码)还是 server 侧(校验流水)——两边可能都要改,但先隔离面。
  • 改之前在沙盒账号上复现一次,确认你能稳定触发问题。
  • 受影响用户保持可联系——restore bug 多半要用户侧反复测。
  • 改之前备份当前服务端 entitlement 表,万一脚本写错能回滚。

需要收集的信息

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

最短修复路径

Step 1:让 entitlement 服务端权威

停止把设备当真源。App 每次启动 + 每次成功购买 / 恢复,把收据或 JWS 发到服务器,让服务器和 Apple 校验,返回当前 entitlement 状态。本地用短 TTL 缓存以备离线。

// Swift / StoreKit 2 示例
for await result in Transaction.currentEntitlements {
    guard case .verified(let transaction) = result else { continue }
    await syncToServer(transaction: transaction)
}

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

Step 2:修校验路由(沙盒 vs 生产)

Apple pre-Server-API 模板:总先打生产端点。返回 21007 就再打沙盒。不要硬编码端点。

// Node.js 示例
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;
}

现在生产首选 App Store Server API,是签名过的现代端点。

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

Step 5:启动时自动 restore,不只藏按钮后

@MainActor
class StoreKitManager: ObservableObject {
    init() {
        Task {
            await refreshEntitlements()  // 每次启动跑
        }
    }

    func refreshEntitlements() async {
        for await result in Transaction.currentEntitlements {
            // ... 同步到服务器,更新本地状态
        }
    }
}

用户不该需要翻 Settings 才被识别为付费。

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

Xcode 12+ 加 .storekit 配置文件。本地用来模拟购买 / 退款 / 续订,不走真沙盒。再用沙盒账号跑完整路径:买 → 卸 → 重装 → 启动 → 看是否自动 restore。

怎么确认已经修好

  • 沙盒账号能在设备 A 买 → 卸 → 重装 → 启动后直接看到 Pro 功能,不用点按钮。
  • 同沙盒账号在设备 B 用同 Apple ID 登录后立即看到 Pro 功能。
  • 服务器日志显示每次 restore 都成功向 Apple 校验。
  • Family Sharing 测试者(单独沙盒账号)也能恢复到 entitlement。
  • 之前抱怨的生产用户在下次 App 更新后确认问题消失。

如果还是没修好

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

预防建议

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

相关阅读

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