用户邮件说去年在 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.currentEntitlements 和 SKPaymentQueue.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 更新后确认问题消失。
如果还是没修好
- 让受影响用户邮箱回传收据 blob,本地走一遍你的校验流水,错误码会指向问题。
- 核对 App Store Connect → Apps → Subscription → App-Specific Shared Secret,怀疑泄露就轮换。
- 查 Apple System Status 是否有收据校验故障,沙盒尤其。
- 兜底建一条”联系客服恢复”路径:收用户 originalTransactionID,服务端手动赋权,记录 case。
预防建议
- 把 entitlement 当服务端状态机;设备是个查询的客户端,不是真源。
- 加 CI 集成测试:每 PR 用 StoreKit 配置文件跑一遍 买 → 重装 → 恢复。
- 维护
LEGACY_PRODUCTS.md列出每个旧 product ID 和当前映射,改产品名时强制更新它。 - 订阅 App Store Server Notifications V2 webhook 让服务器通过推送知道续费 / 退款,不用轮询。
- Restore Purchases 按钮可见但不关键——启动自动 restore 覆盖 95% 场景。