☰
iOS内购完整指南:购买流程、恢复购买与验单实践
2026/10/15 7:57:16 网站建设 项目流程

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: Set<String>) { 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 2,iOS 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 的几个方法,真正做扎实了,牵扯到的工程细节比预想的多得多。希望这篇能帮你少踩几个坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询