☰
iOS内购购买与恢复完整指南:SKPaymentQueue到凭证校验的工程实践
2026/10/15 12:20:35 网站建设 项目流程

老读者应该还记得,系列第一篇把内购的工程配置、商品搭建和市场审核的基本盘捋了一遍。今天这篇集中啃两块硬骨头:购买和恢复。这两个动作是内购模块的命门,商品列表搭得再漂亮,购买流程一断、恢复逻辑一漏,轻则用户付费后解锁不了内容去给差评,重则审核时直接吃一个“功能不完整”的拒绝。这篇文章会把从点击购买按钮到内容真正到账的完整链路拆开讲,配合完整的Swift代码示例、服务端校验要点和真实踩过的坑,适合正在给App接入内购、或者已经接了一半但卡在购买和恢复逻辑上的开发者参考。

1. 内购点亮按钮背后的完整链路:从点击到到账

很多刚接触内购的开发者会以为,用户点一下购买按钮,App Store扣完钱,回调里告诉一声就成了。真实情况比这绕得多,核心原因在于内购的完成不是一个瞬间动作,而是一串异步事件组成的链条。你点的那个IAP按钮,只是往一个叫交易队列(SKPaymentQueue)的东西里面塞了一条支付请求,后面所有的状态流转、余额校验、内容解锁,甚至用户中途杀掉App再重新打开,都交给队列去驱动。

理解这个队列模型是内购开发的第一课。SKPaymentQueue是系统级单例,App进程活着的时候在跑,App被用户杀掉之后,如果交易还在处理中,系统会在App下次启动时把这个未完成的交易重新抛给开发者。这个机制保证了用户的钱不会因为App闪退而凭空消失,但也意味着开发者在写购买逻辑时,必须把“重复收到交易回调”当作常态来处理,而不是特例。我在第一版内购代码里就吃过这个亏,交易回调里没做幂等,用户连续快速点击购买按钮,结果同一笔充值被处理了两次,解锁逻辑执行了两遍。虽然内容解锁重复执行不致命,但如果是消耗型商品,余额就翻了倍,用户白拿了一倍的东西。

我们需要把内购购买链路分成四段来理解。

第一段是商品信息拉取。App去App Store请求商品的名称、价格、本地化描述。这一步是为了把商品ID映射成用户眼睛看到的价格,All商品信息都是在App Store侧配置好的,App本身不存价格,也就意味着苹果调整售价之后,App里展示的价格会自动跟随变化。

第二段是购买请求提交。用户点了购买,App把选中的SKProduct包装成一个SKPayment对象,丢进SKPaymentQueue。从这一刻起,App Store的服务器开始介入,用户会看到系统弹出的付款确认框,Face ID、Touch ID或者密码校验都发生在这一层,App的代码碰不到用户的账户密码,也不需要碰。

第三段是交易状态回调。付款完成之后,SKPaymentQueue会通过你注册的观察者对象,把这笔交易的状态推回给App。状态有四种:purchased(已购买)、failed(失败)、restored(已恢复)、deferred(延后处理)。每一种状态都对应不同的处理分支,第四段是服务端凭证校验与内容解锁。客户端拿到交易凭证之后,最稳妥的做法不是直接在本地解锁内容,而是把凭证上传到自己的服务器,由服务器和App Store的校验接口交互,确认这笔支付真实有效之后,再下发解锁指令。这一层是为了防破解和防篡改,纯客户端的解锁逻辑太容易被钩子绕过了。

这段链路如果画成业务时序,大概是:用户点击购买 -> 系统弹窗确认 -> 交易入队 -> 系统回执给客户端 -> 客户端上传凭证到自有服务器 -> 服务器走App Store校验 -> 校验通过 -> 服务器下发解锁 -> 客户端展示内容。任何一个环节断了,购买体验都会出问题,其中又以第三段的状态处理和第四段的凭证校验最容易翻车。

2. 购买流程:SKPaymentQueue的正确打开方式

购买流程的代码骨架看起来不长,但每一步埋着的细节都值得掰开揉碎讲。

2.1 注册交易观察者,时机比你想象的更关键

交易观察者(SKPaymentTransactionObserver)是App接收交易状态回调的唯一通道。注册时机不是进入购买页面的时候,而是App启动阶段就注册。原因很简单,前面说到交易队列是系统级的,有可能用户上次购买过程中杀掉了App,这次启动之后系统要把未完成的交易重新投递过来。如果观察者在启动阶段没注册,这个回执就丢了,用户付了钱但是App永远不知道。

注册的代码我自己习惯放在AppDelegate的didFinishLaunchingWithOptions里,或者SceneDelegate里,具体看你项目的生命周期承载方式:

import StoreKit class StoreKitManager: NSObject, SKPaymentTransactionObserver { static let shared = StoreKitManager() func startObserving() { SKPaymentQueue.default().add(self) } 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: // 等待家长同意或者Ask to Buy场景,不需要主动处理 break @unknown default: break } } } }

这个观察者的生命周期是整个App生命周期的,不是购买页面的。很多新人在购页面的viewDidLoad里注册,页面销毁时remove,然后发现交易回调偶尔收不到,其实就是这个原因。观察者注册之后不要轻易移除,除非你的App要彻底退出内购功能。

2.2 商品信息请求:SKProductsRequest的显式用法

购买之前需要先拉取商品信息。SKProductsRequest需要一个商品ID集合,我通常把商品ID定义成常量枚举,而不是散落在各处写字符串,防止拼错。

enum ProductID { static let coinPack1 = "com.example.coins.pack1" // 示例商品ID static let removeAd = "com.example.removead" }

请求的开启方式如下:

func fetchProduct(_ productID: String) { let request = SKProductsRequest(productIdentifiers: [productID]) request.delegate = self request.start() }

回调里你会拿到SKProduct对象列表,然后基于它来展示价格。价格展示有一条铁律:必须用SKProduct的price和priceLocale来格式化,不要自己在代码里写死“6元”或者“¥6”。因为苹果允许按地区设置不同的售价,如果自己拼字符串,用户在海外会看到错乱的价格,审核人员也可能因为价格不符直接拒掉。

func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { guard let product = response.products.first else { // 商品ID未配置,或者没有权限 return } let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.locale = product.priceLocale let priceString = formatter.string(from: product.price) ?? "" // 展示 product.localizedTitle + priceString }

2.3 发起购买:支付对象的构建与队列提交

用户确认购买后,代码要同步构建SKPayment对象并加入队列。这一步看似简单,但有个优先级问题:什么时候去fetch商品,什么时候去发起支付。我的习惯是进入购买页就fetch商品,按钮展示出来用户点击时直接使用已经缓存好的SKProduct发起支付。如果等到点击时才请求商品,需要祈祷网络争气,用户看到转菊花很可能就直接退出支付流程了。

func purchase(_ product: SKProduct) { // 确保没有正在进行的支付 guard SKPaymentQueue.default().transactions.filter({ $0.transactionState == .purchasing }).isEmpty else { // 提示用户支付正在处理中 return } let payment = SKPayment(product: product) SKPaymentQueue.default().add(payment) }

这个guard逻辑是我后来补的,就是为了堵住用户连续点击购买按钮的漏洞。系统弹窗是异步的,用户如果手速够快,在弹窗出现之前又点了一次购买,队列里就会有两笔同样的交易。虽然StoreKit在个别情况下会合并,但可靠性说不好,防御一下更稳妥。

2.4 购买成功回调中的完整处理分支

把前面的观察者回调展开,购买成功(.purchased)时的处理是一个完整链路,缺一环都是事故现场。

private func handlePurchased(_ transaction: SKPaymentTransaction) { // 第一步:拿到交易凭证 guard let receiptData = loadReceipt() else { // 凭证读取失败,记录日志并提示联系客服 SKPaymentQueue.default().finishTransaction(transaction) return } // 第二步:上传凭证到自己的服务端 serverManager.verifyReceipt(receiptData) { [weak self] result in switch result { case .success(let serverState): // 服务端校验通过,解锁本单对应的内容 self?.unlockContent(for: transaction.payment.productIdentifier) // 第三步:把交易标记为已完成 SKPaymentQueue.default().finishTransaction(transaction) case .failure: // 校验失败,保留交易,等待用户下次启动重试 break } } }

loadReceipt的实现是读取Bundle里的App Store收据,这个收据是整包的,不是单笔的,所以校验逻辑要在服务端做笔具体商品的匹配。启动恢复处理未完成交易的场景下,这笔读取逻辑同样复用。

完事之后还有一个容易踩的坑:在没确定服务端解锁成功之前,调用finishTransaction。这个顺序反了就麻烦,一旦先finishTransaction,系统端这笔交易就清了,如果服务端还没来得及解锁,下次启动也不会再发这笔交易,用户的钱和内容可能就对不上账了。所以务必先做服务端校验,确认解锁结果之后,再finish。

2.5 失败分支的分类处理

失败分支看起来简单,实际也有讲究。判断标准主要是error.code是否是SKError.Code.paymentCancelled,这个值表示用户手动取消了支付。用户取消不是技术上的失败,不应该弹错误提示打扰用户,静默处理就好。其他错误码,比如网络断连、支付信息验证失败,需要给出友好提示,同时把当前购买页改成可重试的状态。

private func handleFailed(_ transaction: SKPaymentTransaction) { if let error = transaction.error as? SKError { if error.code == .paymentCancelled { // 用户主动取消,不做弹窗,记录埋点即可 } else { // 展示"购买失败,请重试"的提示 showPurchaseError(error.localizedDescription) } } SKPaymentQueue.default().finishTransaction(transaction) }

失败之后调用finishTransaction没有争议,这笔交易已经不会再被触发了,留在队列里反而会造成脏数据,等下次启动还会拿到同样的失败回调。

3. 恢复购买并不是调用一个API那么简单

非消耗型商品(比如去广告、解锁永久功能)和可恢复型订阅,一定要提供“恢复购买”按钮。这既是苹果的审核硬性要求,也是用户真实的诉求—用户换设备之后不可能要求他重新付费。恢复购买在代码层面的入口是restoreCompletedTransactions,但它背后的行为,很多人理解得不透。

3.1 restoreCompletedTransactions的真实行为

调用这个接口后,系统会带着当前登录的App Store账号,去查找这个账号之前在这款App里购买过、且还没有被消费掉的交易记录。找到之后,会以.restored状态把这些交易重新投递给你的观察者。关键点是:**这里的“当前登录账号”是系统设置里App Store的账号,不是App自己账号体系里的用户ID。**用户如果换了一个App Store账号来“恢复”,系统是查不到原账号的购买记录的。

func restorePurchases() { SKPaymentQueue.default().restoreCompletedTransactions() }

有同学问过,为什么恢复购买不传商品ID?因为restoreCompletedTransactions是恢复这个账号在当前App里的所有非消耗型购买记录,不是指定某一件商品。一次性把数据拉回来,然后你在回调里一个个处理。

3.2 恢复回调里的两个坑

第一个坑:恢复完成的标记。StoreKit提供paymentQueueRestoreCompletedTransactionsFinished回调,但很多开发者发现等到这个回调时,所有.restored状态交易都已经逐笔回调过了。所以在观察者里,既要有逐笔的.restored状态处理,也要有这个整体完成的回调,两者配合干活。

第二个坑:同一笔交易可能被恢复多次。有些App的购买记录本身就是一套账号体系,用户在服务端有账号,App Store侧只是凭证。恢复接口把历史记录全部拉回来时,可能把同一件商品的购买凭证重复投递。如果在恢复处理里只做“发现解锁了就完事”,可能没什么问题;但如果你在恢复处理里还做了服务端凭证核对,就一定要做幂等校验,服务端按交易ID和商品ID去重。

3.3 恢复与购买的代码分支如何共用

恢复出来的交易状态是.restored,但解锁逻辑和购买成功几乎一摸一样的,唯一的区别是购买来自新支付,恢复来自历史记录。我的做法是共用一套解锁方法,只在上报埋点的时候区分来源:

private func handleRestored(_ transaction: SKPaymentTransaction) { guard let receiptData = loadReceipt() else { SKPaymentQueue.default().finishTransaction(transaction) return } // 绑定商品ID,走同样的服务端校验和内容下发逻辑 serverManager.verifyReceipt(receiptData, productID: transaction.payment.productIdentifier) { [weak self] result in if case .success = result { self?.unlockContent(for: transaction.payment.productIdentifier) } SKPaymentQueue.default().finishTransaction(transaction) } }

还有一个细节:恢复购买时为用户提供了一个明确的入口,但用户完全没有购买过任何东西的时候点击恢复,StoreKit会返回一个空的恢复记录,并且paymentQueueRestoreCompletedTransactionsFinished会正常触发。所以用整体完成回调来关掉恢复中的loading动画是安全的。

3.4 恢复按钮的界面要求

审核对恢复按钮的检查很细。形式上必须是一个明确的按钮,文案一般用“恢复购买”或“Restore Purchases”,放在设置页或者内购页的底部都行,不要藏在二级菜单里。功能上必须真的能恢复,不能只是一个弹窗提示“请联系客服”。有个项目当初为了省事,把恢复按钮隐藏在一个很不显眼的位置,审核被拒了一次。后来把按钮挪到显眼位置,同时在恢复完成的回调里给出“没有可恢复的购买记录”提示,才算通过。

4. 交易状态机的边界情况:失败、取消、中断与后台唤醒

正常路径的代码啰嗦但直白,真正需要花心思的是各种边界条件。这一章把我在生产环境里遇到过和同行交流过的边界情况整理出来。

4.1 用户取消与网络闪断的区分

SKError的枚举值很明确,但实际开发中容易混淆的是弹窗消失的瞬间,回调还没回来,用户就又进了一遍流程。上面章节里提到的“购买中状态检查”很重要,但还有一种情况更隐蔽:用户付款成功,但网络极差,App在收到系统回执之前进程就被系统杀死。这种情况下,App Store服务器已经记录了这笔交易,下次App启动,SKPaymentQueue会把这笔交易重新投递。如果你在启动阶段没注册观察者,或者观察者注册晚于用户主动发起购买,就可能出现重复投递或者丢失。解决方案其实只有一个:观察者越早注册越好,并且把购买成功后的处理逻辑挪到观察者回调里做,不要依赖购买页的局部状态。

4.2 deferred状态到底是什么场景

deferred状态集中在“Ask to Buy”(家人共享)场景。用户是未成年人,设备上开启了家庭共享,购买时会弹出“请求购买,需要家长批准”的提示。在这一刻,客户端收到的transactionState就是.deferred,而不是.purchased。很多开发者没处理这个状态,导致结果就是孩子点击购买后,App一直无响应,看起来像卡死了。正确的处理是:收到.deferred时提示“等待家长批准”,然后把按钮置为可重试状态。家长批准之后,这笔交易会在后续某个时刻自动变成.purchased。

4.3 启动唤醒时的历史交易清理

App冷启动后,观察者注册完成的那一刻,系统会立刻把未完成的交易回调发过来。此时登录状态、缓存数据可能还没准备好。如果此时jump到处理逻辑里去解锁内容,容易因为依赖的服务或缓存缺失而失败。我在生产环境遇到过一种情况:用户支付成功后立即杀掉App,第二天打开App,上午的服务端凭证校验接口偶发失败,结果这笔交易一直被跳过,直到用户手动刷新好几次才终于解锁。后来加了一层重试策略:处理交易时如果服务端校验失败,把这笔交易保留,启动恢复逻辑时优先重试历史交易。实际的用户体验是,只要网络恢复正常,内容会在启动后几秒内自动解锁。

4.4 交易凭证的时效性问题

App Store的收据凭证不是永久的。订阅凭证涉及到续期,非消耗型凭证长期有效,但客户端本地保存的收据可能被系统回收。所以处理购买事件时,loadReceipt出来的数据最好立刻上传,或者做本地缓存,不要依赖“下次启动再读收据”的思路。有次调试一个客户反馈的“购买后第二天内容消失”的问题,最后查下来是客户端本地收据过期了,但我们的解锁状态又是根据本地收据读取结果判断的。后来改为解锁状态以服务端下发的订阅状态为准,本地收据只作为触发校验的凭据。

5. 凭证校验:客户端购买、服务端确认才算到账

我一直对团队里说:客户端的内购代码写到“完成交易”这一步,只是完成了用户体验闭环,但安全性闭环必须由服务端补齐。反编译iOS客户端的成本不高,任何内购破解工具都是建立在直接篡改客户端回调上的。如果App把解锁逻辑全放在本地,一个hook就能无限解锁。

5.1 整包凭证与单笔交易的绑定关系

iOS的收据是一个整包级的文件,里面包含这个账号在这个App实例上所有的购买记录。服务端拿到整个收据后,需要做的事是:先和App Store校验接口通讯,确认真伪,再从返回的收据字典里解析出in_app数组,根据product_id字段筛选出对应商品的购买记录,再核对transaction_id在服务端是否已经处理过。

5.2 校验接口的选择:生产环境与沙盒环境的切换

App Store的校验接口地址有两个。生产环境是buy.itunes.apple.com/verifyReceipt,沙盒环境是sandbox.itunes.apple.com/verifyReceipt。真机测试使用沙盒账号时走的都是沙盒地址,但这里有个坑:如果你在发布版的App里用沙盒环境测试,在这个环境下校验一个生产凭证或反过来校验,接口会返回特定的状态码(21007表示当前环境是沙盒,21008表示应该切到生产环境)。服务端正确的处理姿势是:请求生产环境校验,如果收到21007,转为沙盒地址再校验一次。这个先后顺序固定,不要反过来。

5.3 服务端校验的响应解析

响应报文里最关键的字段是status和receipt。status为0表示校验成功,非0的状态码各有含义,其中21002是receipt-data字段格式问题,一般是我们传参时把收据当成了普通JSON字符串,其实它需要base64编码后的字符串。如果服务端是Node.js或Java,接收时要用base64解码后传给校验接口,再对接口返回的响应体做JSON解析。有时候签名字段的编码问题会造成21002,实际操作时要留意。

服务端解析出in_app数组之后,按product_id取最近一条购买记录,这里的“最近”以purchase_date_ms排序,取最大值。同时还要比对original_transaction_id,这个ID是这笔购买最早产生时的交易ID,订阅续期场景下多笔交易共享同一个original_transaction_id,非消耗型商品也靠它关联账户,所以服务端建表时一定不要只存transaction_id,要同时存original_transaction_id和product_id。

5.4 本地收据读取的几个异常

loadReceipt的常规实现是Bundle.main.appStoreReceiptURL然后读文件,但这段代码有两个边界情况需要兜底。一是App刚启动但收据还未生成的可能,首次购买前收据文件可能不存在,返回nil是正常的。二是在模拟器上,appStoreReceiptURL常常读到的是无效路径,建议在Debug模式下多套一层判断,模拟器上给个模拟凭证,方便调试流程而不用每次都麻烦审核环境。

提示:不要把服务端校验做成同步阻塞流程。网络波动、服务器重启都可能让校验接口在几秒内不可用,客户端如果一直等在回调里,用户会越来越焦虑。更合理的做法是校验失败先保留交易,用户下次启动再触发重试,同时显示“购买处理中,稍后将自动解锁”的兜底文案。

6. 审核与生产环境的那些坑:沙盒账号、测试流程与常见拒审原因

很多功能在开发自测时一切正常,一上审核就被打回,内购是最容易踩坑的区域。这一章把审核相关的实操经验集中说一下。

6.1 沙盒账号的创建与测试流程

审核人员在测试你的App时,操作系统里登录的是沙盒账号,和普通App Store账号是隔离的。开发者在测试时也要走同样流程:真机上用普通账号登录App Store,进入内购流程时提示切换沙盒账号,这时输入苹果开发者后台创建的沙盒测试账号。有一点要提醒:沙盒账号不能是普通Apple ID,必须是后台“用户和访问”里创建的测试员账号。测试时每次发起购买,系统不会再真正扣钱,但沙盒环境的支付速度非常快,可能需要多等几秒让弹窗出现。

6.2 生产环境与沙盒环境的混合陷阱

服务端的双环境校验逻辑如果写反,线上发布的版本会频繁出现“校验失败”。我们曾遇到过生产环境偶尔返回21007的情况,后来排查发现是收据接口本身在Apple侧有阶段性的路由延迟,个别请求被路由到了沙盒校验池。服务端设计校验分支时,21007和21008必须都要处理,不能只处理其中一个。更稳妥的方案是先请求生产地址,收到21007再切沙盒地址,收到21008则返回“生产凭证被送去沙盒校验”的错误提示,不要让客户端死循环重试。

6.3 审核时常见的内购相关拒审理由

结合我自己和同行遇到的情况,内购相关的拒审高频原因有这么几类:一是没有恢复购买入口,这个前面说过,是最常见也最容易修复的;二是商品价格与显示不符,多数是因为客户端没有用SKProduct的priceLocale格式化价格;三是虚拟商品走成了第三方支付,安卓侧习惯了接支付宝和微信,迁移到iOS时直接沿用了,结果被拒得明明白白——iOS端的虚拟商品、数字服务、订阅内容,只能走Apple内购;四是审核人员用沙盒账号测试却无法弹出购买弹窗,这通常是当前设备网络问题或者沙盒账号被锁定,回复审核时可以附上测试视频并说明沙盒环境的限制。

6.4 审核对话中怎么回复内购问题

收到审核拒绝的邮件,先别急着申诉,把原因逐条对应到代码层检查。如果有任何一条确实改了代码,在审核对话框里附上变更说明,并且提供一段完整的演示视频,从进入内购页、点击购买、弹窗确认、内容解锁到恢复购买,一条龙录完整。我没有靠文字申诉成功的案例,全靠视频把流程讲清楚。视频里的账号不要用普通个人账号,直接声明用沙盒测试账号,减少审核人员的疑心。

7. 购买与恢复联动设计的心得

把购买和恢复放在一起讲,是因为他们在代码层面虽然有独立的入口,但最终都汇聚到“凭证校验并下发内容”这条统一管道上。我之前看到过一些团队把购买成功和恢复成功的解锁逻辑各写一套,代码翻倍,bug也翻倍,后续维护极其痛苦。统一管道之后,购买和恢复的差异就只剩埋点上报和部分文案了。

还有一点心得:内购的完整闭环,必须靠服务端参与,客户端只是把用户的钱投进队列、把回执递给服务器、再把服务器确认的结果展示给用户。在设计和开发时,把交易的幂等性放到最高优先级,宁可多校验一次,也不要少校验一次。用户在同一个账号下偶尔触发重复的交易,如果服务端没有按transaction_id去重,就会出现余额翻倍或者订阅状态错乱的问题。

最后说一个我个人的习惯:赶上发版前一天改动内购相关的代码,我都会列一个checklist,照着走一遍才安心。这个清单包含:观察者是否在启动阶段注册、恢复按钮是否在显眼位置、购买取消是否静默处理、服务端校验失败时是否保留交易、沙盒和生产环境的校验分支是否都覆盖、是否有对重复交易的去重逻辑。每一条看着不起眼,但遗漏任何一条,都有可能在线上或者审核时翻车。把这份清单沉淀下来,也算是对自己踩过的坑的一种存档。

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

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

立即咨询