资讯动态

iOS内购完整指南:购买流程、恢复购买与验单实践

发布时间:2026/10/15 7:57:17 来源:尧图企业网站定制
1. 购买流程的设计思路与用户场景做 iOS 内购绕不开一个核心问题用户点了“购买”之后整个链路该怎么走。很多第一次接内购的开发者以为只要调一个SKPaymentQueue的方法把商品 ID 传进去就完事了结果上线之后各种莫名其妙的状况——用户付了钱没到账、重复扣款投诉、恢复购买点了几次没反应。这多半不是苹果的锅而是购买和恢复这两个环节的设计本身就没想清楚。先说购买流程。在一款 App 里购买入口通常不会只有一个首页的会员卡片、设置页的升级按钮、功能解锁时的弹窗甚至运营活动页都能发起购买。如果每个入口都各自写一套购买逻辑后期维护就是灾难。正确做法是建立一个统一的内购管理器把“发起购买”“监听结果”“落库验单”全部收拢到一个模块里业务层只负责告诉它“我要买哪个商品”然后等回调。再说恢复。恢复购买不是简单的“再调一次购买”它的本质是帮用户找回那些已经付过款、但设备上还没生效的购买记录。典型场景有三个用户换了新手机登录同一账号后需要把之前的会员同步回来用户卸载重装 App本地订单记录清空了用户在同一部手机上切换到另一个账号需要把原来账号购买的内容迁移过来。如果 App 不做恢复入口用户只能干瞪眼找客服甚至直接给差评。还有一个容易忽略的设计点购买和恢复在用户体感上应该是两条不同的路径。购买是“新的交易”需要走支付确认恢复是“找回已有权益”本质上不涉及新的扣款。把这两件事混在一起处理最常见的问题就是用户点了“恢复”却看到支付弹窗心里咯噔一下以为要被重复扣钱。所以入口文案也要区分购买按钮写“立即开通”恢复按钮写“恢复购买”别让用户产生误解。从场景往回收购买和恢复的代码结构其实可以共用一套回调处理逻辑因为它们最终都会回到同一个结果某个商品 ID 对应的权益是否已经解锁。区别只在于发起方式不同。接下来我就按实际开发顺序把购买和恢复的核心实现拆开讲。2. 购买核心代码与回调处理2.1 拉取商品信息解决“商品列表为空”的坑在发起购买之前必须先拿到商品的合法信息。这一步用SKProductsRequest做商品 ID 必须和 App Store Connect 后台配置的完全一致一个字符都不能差。很多新手在这一步就翻车拉回来的商品列表为空排查半天发现是 Product ID 大小写不一致或者后台配置了但还没“准备提交”状态。import StoreKit final class IAPManager: NSObject { static let shared IAPManager() private override init() { super.init() SKPaymentQueue.default().add(self) } /// 拉取商品信息 func fetchProducts(with ids: SetString) { let request SKProductsRequest(productIdentifiers: ids) request.delegate self request.start() } } extension IAPManager: SKProductsRequestDelegate { func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { let validProducts response.products let invalidIDs response.invalidProductIdentifiers // validProducts 为可售商品invalidIDs 是后台未配置正确的 ID } }注意didReceive回调里的两个数组缺一不可。products是正常返回的商品invalidProductIdentifiers是无效 ID。如果后者不为空说明后台商品配置有问题业务层要及时上报日志而不是闷头继续走。2.2 发起购买的两种方式与选择发起购买在 StoreKit 1 里是SKPaymentQueue.default().add(_ payment:)在 iOS 15 之后的 StoreKit 2 里是try await Product.purchase()。如果你的 App 最低支持版本还在 iOS 14 以下老老实实走 StoreKit 1如果最低版本是 iOS 15可以考虑直接用 StoreKit 2代码会简洁不少。但这里有个现实问题很多项目还在用老代码维护或者要兼顾老版本系统所以下面的核心逻辑我以 StoreKit 1 为主但在结尾会补充 StoreKit 2 的注意点。func purchase(product: SKProduct) { // 先校验是否允许支付 guard SKPaymentQueue.canMakePayments() else { // 用户设备限制了购买提示用户去“屏幕使用时间”里打开购买权限 return } let payment SKPayment(product: product) SKPaymentQueue.default().add(payment) }canMakePayments()这个校验不能省。有些家长模式下用户设备是禁止购买内购项目的不校验直接add会得到一个无法完成的交易甚至可能卡在队列里。这个校验就是一道保险提前拦截了不合法的购买环境。2.3 交易回调的完整监听与状态机交易回调是所有内购代码里最核心的部分没有之一。它是整个购买链路的“中枢神经”——支付成功、失败、取消、延迟全都要在这里分流。StoreKit 1 的监听方式是实现SKPaymentTransactionObserver协议extension IAPManager: SKPaymentTransactionObserver { func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { for transaction in transactions { switch transaction.transactionState { case .purchased: // 购买成功但此时不能立刻发货 // 要做的事情很多验证票据、解锁内容、标记订单 handlePurchased(transaction) case .failed: // 用户取消或支付失败终结这条交易 handleFailed(transaction) case .restored: // 恢复购买成功 handleRestored(transaction) case .deferred: // 家长同意或企业批量购买场景等待后续结果 break case .purchasing: // 正在处理中什么都不做 break unknown default: finishTransaction(transaction) } } } }这里最容易被忽略的是finishTransaction的调用时机。很多初学者在支付成功回调里直接finishTransaction然后把货发出去这会导致一个严重问题如果这笔交易后续被苹果判定为异常比如退款、欺诈你已经发了货却没收回到款只能自己兜底损失。正确的做法是先验单、先落库、先发货全部处理成功之后再finishTransaction。只有确认整条链路走完了才能让这笔交易从队列里消失。2.4 交易成功后的验单与发货顺序验单的顺序我建议做成这样拿到transaction.payment.transactionIdentifier把交易凭证transaction.transactionReceipt连同 productID、userID 一起发给自己的服务端由服务端向苹果的验证接口请求校验。注意iOS 客户端拿到的 receipt 可能不是最新的因为苹果允许合并交易所以更稳妥的做法是让服务端用SKReceiptRefreshRequest刷新后再验但这一步会增加延迟具体取舍看你App的类型。对于大多数内购产品直接用交易时的 receipt 验就够用。验单通过后再给用户解锁权益。解锁的动作必须放在服务端确认之后不能客户端自己验完就发权益。为什么客户端的验证请求可以被伪造或者把关掉网络时钟改到过去让票据过期服务端验证才是一个可信的锚点。哪怕你的 App 是纯离线工具没有服务端也要至少做一次本地二次校验比如用 OpenSSL 验证苹果签名的 receipt不能直接信任 StoreKit 返回的购买状态。发货完成后调用finishTransaction(transaction)。这时候用户在 App 内的购买流程才真正闭环了扣款成功、权益到账、交易出队。3. 恢复购买的正确实现方式3.1 恢复购买的入口设计恢复购买的入口必须放在用户“找得着”的位置。苹果的审核指引里对恢复购买入口有明确要求如果 App 里有非消耗型内购比如永久会员、去广告付费但没有提供恢复按钮审核被拒的概率非常高。所以无论在哪个页面放了购买入口都要在附近放一个“恢复购买”的按钮文案统一用系统惯用的“Restore Purchases”或中文“恢复购买”。触发放置在设置页、账号页这种用户主动管理的页面不要放在首次启动的引导页上。首次启动时用户还没购买过任何东西你放个恢复按钮不仅多余还会让用户困惑。审核人员也会觉得这页面逻辑有问题。3.2 恢复的底层机制不是一个新交易恢复购买的核心方法是SKPaymentQueue.default().restoreCompletedTransactions()。这个方法不会创建新的交易而是去苹果服务器查询该 Apple ID 过去在当前 App 下的非消耗型购买记录以.restored状态的交易回传到观察者回调里。func restorePurchases() { guard SKPaymentQueue.canMakePayments() else { return } SKPaymentQueue.default().restoreCompletedTransactions() }这里有个关键认知恢复的结果不是一次性返回一个“恢复成功”的总体状态而是每个历史订单对应一笔transactionState .restored的交易。如果你的用户历史上买过三个商品回调里就会收到三笔 restored 交易每一笔都要分别处理查本地订单表发现对应权益没解锁就补解锁发现已经在有效期内就跳过。处理每一笔 restored 交易之后同样要执行finishTransaction。不要遗漏否则交易队列会越积越多下次恢复时可能重复回调。3.3 恢复失败的场景化处理恢复购买不是每次都能成功的。常见的失败有网络超时、用户取消恢复、苹果服务器返回异常。StoreKit 1 里恢复失败和成功的标志是这两个回调func paymentQueue(_ queue: SKPaymentQueue, restoreCompletedTransactionsFailedWithError error: Error) { // 恢复失败这里要提示用户稍后重试 } func paymentQueueRestoreCompletedTransactionsFinished(_ queue: SKPaymentQueue) { // 恢复流程整体走完了但可能一笔 restored 交易都没有 // 要区分“恢复成功但无购买记录”和“恢复失败”两种情况 }第一次写这里的人容易踩一个坑看到paymentQueueRestoreCompletedTransactionsFinished回调就认为恢复成功了直接提示用户“恢复完成”。但其实这个回调只代表“恢复流程结束了”不代表“找到历史购买记录”。如果用户压根没买过这个回调也会触发交易的transactions数组是空的。正确的提示逻辑是在restoreCompletedTransactionsFinished里检查是否收到了任意一笔.restored交易如果有按订单处理如果没有提示用户“该账号下没有找到可恢复的购买记录”。恢复结果的高频问题在第四部分详细列这里先记住一个原则恢复购买是“查询旧账”的过程不是“再买一次”的过程两者在用户提示语、结果处理、统计分析上要完全分开。4. 常见问题与排查技巧实录4.1 常见问题速查表把我在多个项目里遇到的内购问题整理成了一张表基本可以覆盖 90% 的日常排查场景现象可能原因排查与解决商品列表为空Product ID 与后台不一致商品未提交审核核对后台配置确认商品状态为“准备提交”或已上架点购买没反应无任何回调网络异常交易队列被卡住杀掉 App 重进查看队列里是否有未 complete 的旧交易支付弹窗出现后用户付款成功但 App 没到账验单失败或发货逻辑未执行检查验单接口日志确认为何发货失败恢复购买按钮点了没有 restored 回调该 Apple ID 下无购买记录iOS 15 的恢复接口行为变化提示用户无记录或检查是否用了新 API 但没适配用户退款后权益仍在缺少退款监控接入服务端通知 V2监听退款事件并主动回收权益审核员反馈找不到恢复按钮入口太深或文案不明显每个内购页面显眼处放恢复按钮文案“恢复购买”沙箱测试买不了商品测试账号未在后台添加设备登录了正式账号在后台“用户与访问”添加沙箱测试员登出正式账号4.2 沙箱测试环境的搭建与常见误区内购上线前必须用沙箱环境完整走一遍购买和恢复流程。不少团队是第一次接触内购测试时怎么也弹不出沙箱支付框原因基本是这几种设备上登录了正式的 Apple ID没有切换成沙箱账号沙箱测试账号没有在 App Store Connect 后台“用户与访问”里创建或者商品没提交审核沙箱拉不到商品。一个非常容易忽略的细节是在沙箱环境里你用同一个测试账号反复购买同一个非消耗型商品第二次购买时苹果会返回“你已购买此项目”的提示。这是正常的不代表 Bug。非消耗型商品在同一个账号下只能成功购买一次后续想再走购买流程必须先把原交易恢复出来或者换一个测试账号。这个逻辑在产品层也要处理用户再次点击购买非消耗型商品时App 应该主动引导他走恢复流程而不是盲目继续发起购买。4.3 交易卡在 purchasing 状态的处理内购最讨厌的问题之一就是交易队列卡在.purchasing状态既不成功也不失败。发生一次后面所有购买都受影响因为队列里永远有一笔未完结的交易在阻塞。常见的引发原因是支付过程中用户按了 Home 键切走 App或者网络从 Wi-Fi 切到蜂窝导致连接中断或者苹果服务器发起了延迟确认deferred但用户侧一直没有后续结果。处理思路在启动 App 时检查SKPaymentQueue.default().transactions如果里面有purchasing状态的交易先等待一段时间比如 30 秒如果仍然没有变化可以把这笔交易标记为失败并finishTransaction提示用户重新购买。这里有争议直接 finish 一笔可能处于中间态的交易会不会有风险我的经验是在沙箱和正式环境里等了超过 30 秒还不动基本就是死交易留着只会阻塞队列。如果担心账务问题可以先记录日志、拍照留痕后再 finish但不要一直挂着不管。4.4 验单后服务端返回 21007 还是 21008服务端验单时苹果的验证接口有两个地址生产环境buy.itunes.apple.com/verify/receipt和沙箱环境sandbox.itunes.apple.com/verify/receipt。很多团队第一次接入时在沙箱环境用了生产地址返回21007就怀疑是不是接口调错了。其实21007的意思是“这是一个沙箱票据请改用沙箱地址验证”21008则反过来表示“这是生产票据你却在用沙箱地址验证”。正确的做法先请求生产环境如果返回21007再请求沙箱环境或者反过来先沙箱后生产两条路都试一遍以最终通过的那个结果为准。服务端验单的处理逻辑一定要写对否则上线后会出现大量用户支付成功但 App 不发货的差评。4.5 苹果的“退款”与“撤销”事件用户发起退款后苹果会通过服务端通知Server Notification V2推送给你的服务端类型有REFUND、REVOKE、CONSUMPTION_REQUEST等。很多团队完全没有接这个通知导致一个严重问题用户买完会员用了两个月然后申请退款钱退回去了但你这边还在给他提供会员服务。这种事一旦被苹果查到轻则下架重则账号封禁。接入服务端通知 V2 不算复杂核心是接收苹果的 JWS 签名的 payload解密后解析notificationType和data字段。收到退款事件后要主动把用户的权益收回比如把会员状态置为过期、限制高级功能访问。这一步必须在服务端做客户端没法可靠地感知退款。另外千万注意不要把退款等同于取消购买。用户退款后他之前生成的那笔交易已经从苹果的订单体系里撤销了但你的订单表如果还保留着“已购买”状态那后续他对该商品的“恢复购买”就会出现奇怪的现象——苹果返回没有可恢复记录但你本地却以为他买过。所以在响应退款事件时不仅要回收权益还要同步更新本地订单状态把“已购买”改成“已退款”或“已撤销”。4.6 恢复购买在 iOS 15 之后的 API 变化iOS 15 引入了 StoreKit 2恢复购买的新接口是AppStore.sync()它不再像 StoreKit 1 那样以“多笔交易逐个回调”的形式返回历史订单而是直接同步当前账号的 Entitlements权益。如果你的项目最低版本已经到了 iOS 15可以直接换成 StoreKit 2代码会清爽很多try await AppStore.sync()之后从Product.SubscriptionInfo或Transaction.currentEntitlements里读取当前有效订阅即可。但如果你还要兼容 iOS 14 及以下就得沿用 StoreKit 1 那套方案。注意在低版本系统上不要调用 StoreKit 2 的接口否则会直接崩溃。很多大厂的做法是iOS 15 用 StoreKit 2iOS 14 用 StoreKit 1 兜底业务层把两边返回的权益数据转换成同一个模型再统一上抛给 UI。这个兼容思路建议所有跨版本的项目都照做。5. 购买与恢复的工程化落地建议5.1 订单模型的统一与幂等不管购买还是恢复最终你都要在本地存一份订单记录。建表时至少包含这几个字段transaction_id唯一、product_id、purchase_date、expires_date订阅类、state成功/失败/已退款/已消费、raw_receipt原始票据或票据摘要。其中transaction_id必须唯一这能帮你拦住重复发货的问题。幂等处理特别重要。网络抖动时回调可能触发两次如果你的发货逻辑是“收到成功回调就加会员天数”那用户就会被加两次。解决方案就是先查订单表transaction_id已存在就跳过发货只做finishTransaction。这是内购开发里性价比最高的一条防御代码。5.2 并发创建的边界问题在用户快速重复点击购买按钮时SKPaymentQueue.default()会收到多笔相同的支付请求产生多个交易。苹果不会阻止你重复创建但用户会在短时间内看到两个支付弹窗体验极差。解决办法用一个布尔值isPurchasing做全局锁在交易回调进入.purchasing或.purchased状态时置为true在失败/完成后置回false。业务层发起购买前先检查这个锁为true就拦截掉这次点击并提示“上一笔购买正在处理中”。这个锁必须和 UI 是联动的。也就是说isPurchasing为true时所有购买按钮都要置灰防止用户通过不同入口发起并发请求。5.3 用日志和埋点还原现场内购问题最难排查的就是“用户说付了钱但后台没到账”。这类问题的直接原因千奇百怪可能是客户端验单失败可能是服务端回调延迟也可能是用户退款后又重新购买导致的幂等冲突。如果事前没有完善的日志埋点排查时完全靠猜。我的习惯是在购买链路的关键节点全部埋点发起购买时记录 productID收到.purchased回调时记录 transactionID验单请求发出时记录时间戳服务端返回验单结果时记录 statusCode 和 body发货完成记录权益变化。这些日志统一上传到后台问题出现时按用户维度一查很快就能定位。恢复流程同样要埋点恢复开始时间、restore 回调的交易数量、每一笔 restored 交易的处理结果、是否有卡在 purchasing 的交易。有了这些再配合用户反馈的截图大部分问题都能在 10 分钟内定位。5.4 审核注意事项与提审前的自检清单提审前把下面这张清单过一遍能少很多审核来回非消耗型内购是否有恢复入口入口是否放在显眼位置所有内购文案是否准确没有“免费”“折扣”这类误导描述每个商品 ID 都在后台配置且状态正确沙箱环境完整走一遍购买和恢复流程服务端验单、退款通知接口都已经联调通过测试账号已清理App 内没有残留的沙箱订单订阅类 App 有没有接入“同意续订”的条款展示审核时如果点不到弹窗检查是不是设备处于“限制内购”状态审核期间如果被拒最常见的原因就是恢复按钮找不到或者功能不生效。多花 10 分钟把恢复流程和入口再走一遍比来回提审省心得多。5.5 一个容易翻车的小细节本地校验票据如果你的 App 没有服务端也不打算接服务端至少要做一次本地票据校验。最简单的方式是使用 OpenSSL 验证票据签名验明票据确实是苹果签发的、没有过期、没有被篡改。很多人会用社区里流传的版本但直接集成到工程里容易踩链接符号冲突的坑。更稳健的做法是用苹果官方的SKReceiptRefreshRequest刷新后再把 receiptData base64 编码后向https://buy.itunes.apple.com/verify/receipt发一次请求。虽然客户端请求可能被伪造但总比完全不验要强。本地和远程校验二选一时我无脑推荐远程。因为本地校验只能确认票据是苹果签发的但没法确认这笔票据当前是否有效、是否被撤销。只有服务端拿着 receipt 去苹果的验证接口才能同时拿到订阅状态、退款状态这些动态信息。所以哪怕你再不喜欢写服务端也要做一个简单的验单接口这是对自己也是给用户的保障。6. 个人实操里的一些体会这段算是不成体系的经验分享想到哪说到哪。第一内购模块的代码要独立成库不要散落在业务页面里。我做过的项目里凡是内购逻辑散落在各个页面的后期无一例外出现重复发货或者订单状态混乱的问题。独立成一个组件对外只暴露purchase(productID:)和restore()两个方法业务层无感才好维护。第二不要迷信苹果的“官方示例”。苹果的示例代码往往是最简路径不含工程化的防御逻辑生产环境直接用很容易踩坑。比如官方示例里很少处理paymentQueue(_:shouldAddStorePayment:)这个回调但当你开启了“App 内购买”的推广营销时用户在 App 外点了购买系统会唤醒你的 App 并直接把交易加入队列。如果没有处理这个回调用户会看到一片空白交易也没法正常继续。所以多读文档的 “Handling Transactions” 部分再回到自己的项目里补防御才是正确姿势。第三退款和高风险交易一定要有自动化处理方案。别等到用户退款了再去人工查量一多根本看不完。服务端做一个定时任务拉取苹果的Transaction History接口自动比对本地订单表状态不一致就自动调整。我见过不少项目上线半年都好好的一到大型促销活动结束退款量上来之后数据各种对不上号最后只能硬扛着改数据。提前做好退款监控能帮你避免这种状况。第四也是我很想强调的一点恢复按钮和购买按钮的用户体验要区别对待。购买是“期望值拉高”的动作用户会期待立刻得到权益恢复是“找回旧货”的动作用户往往心里没底。所以购买成功后可以给一个炫酷的动效恢复成功后简单展示“已恢复”即可。反过来购买失败时要给足安慰和重试路径恢复失败时则要保持平静提示用户检查网络和账号。不同的心理预期匹配不同的交互反馈这是内购体验优化的一个隐藏加分项。最后说一个很多开发者不重视的点内购的上报统计。至少要把“发起购买”“购买成功”“购买失败”“恢复成功”“恢复失败”这五个事件接入数据平台。产品运营要看这个漏斗来优化入口位置和文案而你也可以通过这些数据反向发现代码逻辑问题——比如某个版本上线后“购买失败”率突然飙升多半是接口或验单逻辑在这个版本里被改坏了。购买和恢复看着都是 StoreKit 的几个方法真正做扎实了牵扯到的工程细节比预想的多得多。希望这篇能帮你少踩几个坑。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑