news 2026/10/10 3:11:30

Swift内购支付全链路指南:从SKProductsRequest到收据校验避坑

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Swift内购支付全链路指南:从SKProductsRequest到收据校验避坑

简介:面向 iOS 开发者的 Swift 内购支付工具代码包,完整覆盖 StoreKit 框架集成、SKProductsRequest 产品请求、SKPaymentQueue 交易监听与购买恢复等核心环节,适合希望快速接入应用内购买功能的中初级开发者对照使用。压缩包内共 31 个文件,以 9 个 Swift 源码文件为主体,另含 Objective-C 的 .m/.h 文件、storyboard 界面布局、plist 配置文件、Xcode 工程描述与 entitlements 权限声明,整体仅 66KB,是一个可直接打开运行的轻量示例工程。作者在代码中实现了支付流程回调、交易状态处理、恢复购买与订阅管理,并给出内购收据验证及错误处理的思路;工程还包含多个视图控制器和 ApplePayManager 管理类,便于观察不同页面下的调用方式与跳转逻辑。已有 1702 人学习下载,适合作为从零搭建 IAP 功能时的脚手架,也可作为学习 StoreKit 内部机制的参考代码。

1. 苹果内购接入:为什么支付回调没触发,才是大多数工程的第一步坑

做 Swift 内购支付,代码本身并不复杂,真正卡住人的往往是回调不触发、商品列表拉不到、沙盒和线上环境分不清这类基础问题。这套 Swift 内购支付工具的核心,是把 StoreKit 1.0 的完整链路串起来:从 SKProductsRequest 拉取商品、SKPaymentQueue 发起支付、transactionObserver 统一处理回调,再到服务端二次收据校验。适合刚接触内购的新手直接照抄流程,也适合被审核拒绝后想重新梳理支付链路的老手对照排查。因为绝大多数支付事故,都发生在支付弹窗出现之前。

2. 工程初始化与商品拉起:三处开关、商品标识与 SKProductsRequest 的首次配置

2.1 苹果开发者后台与 Xcode 的两侧配置

内购不是写完代码就能跑的,第一个容易被忽略的步骤是后台商品配置。登录苹果开发者后台,进入“App 内购买项目”,新建一个商品时,需要先选对类型。消耗型项目适合道具、金币这类买完就消失的;非消耗型项目适合解锁功能、去广告这类永久有效的;自动续期订阅适合会员这类周期性扣费的;非续期订阅则是买一次生效一段时间、不自动续费。选错类型会导致后面逻辑全部跟着错,尤其是订阅类型,服务端的票据校验字段都不一样。

商品创建完之后,要填写显示名称、价格、审核截图等信息,一直把状态推进到“准备提交”或“已批准”,沙盒测试才能拉到这个商品。很多开发者在这里翻车:商品 ID 建了,但详情没填完,状态还是“缺失元数据”,客户端怎么请求都拿不到。

Xcode 侧就简单了,Target 的 Signing & Capabilities 里添加 In-App Purchase 能力,然后确认 build 的 Bundle Identifier 和后台 App 的 ID 一致。商品 ID 在后台创建时是挂在 App ID 下面的,如果 Bundle ID 不匹配,SKProductsRequest 会静默失败,返回 invalidProductIdentifiers。

2.2 拉取商品列表:SKProductsRequest 的一次调用

商品 ID 在后台维护,客户端这边用一个 ProductFetcher 统一处理。最核心的代码是下面这段。

import StoreKit final class ProductFetcher: NSObject, SKProductsRequestDelegate { private var productIDs: Set<String> private var completion: (([SKProduct]) -> Void)? init(productIDs: Set<String>) { self.productIDs = productIDs super.init() } func fetch(completion: @escaping ([SKProduct]) -> Void) { self.completion = completion let request = SKProductsRequest(productIdentifiers: productIDs) request.delegate = self request.start() } func productsRequest(_ request: SKProductsRequest, didReceive response: SKProductsResponse) { if !response.invalidProductIdentifiers.isEmpty { print("无效商品标识: \(response.invalidProductIdentifiers)") } completion?(response.products) } }

这段代码的逻辑是:把后台维护的商品 ID 集合传给 SKProductsRequest,系统会去 App Store 拉取对应商品的元数据。回调里如果 invalidProductIdentifiers 非空,说明这些 ID 在后台不存在或状态不可用,需要回后台逐个核对。

启动请求用的是 request.start(),它和 perform() 的区别在于 start() 是异步发起且不需要在调用前设置 runloop 模式,日常使用写 start() 就行。completion 用逃逸闭包传回来,避免把 delegate 回调的结果散落在各个页面里。

2.3 展示价格:绕开 displayPrice 的隐性问题

拉取到 SKProduct 之后,商品标题、描述可以直接用 localizedTitle 和 localizedDescription,但价格处理要小心。SKProduct.price 是 NSDecimalNumber,priceLocale 是当前商店区域的 Locale,正确的做法是用 NumberFormatter 格式化。

let formatter = NumberFormatter() formatter.numberStyle = .currency formatter.locale = product.priceLocale let priceText = formatter.string(from: product.price) ?? "\(product.price)"

有些版本的习惯是把 product.displayPrice 直接拿来用,这个属性在 iOS 11.2 之后可用,但在部分系统版本和地区下可能返回空字符串,而且它内部用的是设备当前区域,不是商店区域,可能出现设备改语言后价格显示和实际扣费不一致的情况。我一般会坚持用 NumberFormatter 手动格式化,虽然多三行代码,但显示稳定性好很多。

2.4 发起支付:canMakePayments 检查与交易入队

用户点了购买按钮之后,不是直接把 SKPayment 丢进队列,先检查设备是否允许内购。家长控制、企业限制都会让 canMakePayments() 返回 false。

guard SKPaymentQueue.canMakePayments() else { // 弹出提示:当前设备不允许应用内购买 return } let payment = SKMutablePayment(product: product) payment.quantity = 1 SKPaymentQueue.default().add(payment)

SKMutablePayment 是可变的支付对象,product 参数决定这次买的是哪个商品,quantity 默认是 1,消耗型商品如果允许批量购买可以调大,但非消耗型和订阅类型不要改这个值。add(payment) 会把支付请求投递到系统队列,这时候系统会自动弹出付款确认框,后续的结果由 paymentQueue 的 observer 回调分发。

这里有一个非常关键的细节:paymentQueue 的 observer 必须在 App 启动时就挂上,而不是等用户点购买按钮才挂。很多人把 addObserver 写在商品详情页的 viewDidLoad 里,结果支付完成的回调晚于页面释放,交易队列里的事务永远没人处理,越积越多。最稳妥的做法是在 AppDelegate 的 didFinishLaunchingWithOptions 里挂一个全局单例 observer。

3. 收据校验与服务端发货:支付成功后的十秒时序

3.1 观察者回调:交易状态机的六个分支

SKPaymentQueue 的 observer 回调是内购的核心事件源,它会在交易状态变化时触发 paymentQueue(_:updatedTransactions:)。

extension IAPManager: SKPaymentTransactionObserver { func paymentQueue(_ queue: SKPaymentQueue, updatedTransactions transactions: [SKPaymentTransaction]) { for transaction in transactions { switch transaction.transactionState { case .purchased: // 支付成功,走收据上报逻辑 handlePurchased(transaction) case .failed: if let error = transaction.error as? SKError { print("支付失败: \(error.code.rawValue) - \(error.localizedDescription)") } SKPaymentQueue.default().finishTransaction(transaction) case .restored: // 恢复购买成功,走恢复逻辑 handleRestored(transaction) case .deferred: // 家长同意或询问流程,等待外部结果 break case .purchasing: // 正在处理中,什么都不做 break @unknown default: break } } } }

purchased 分支里不要立刻调 finishTransaction。正确顺序是:拿出收据、上报服务端、等服务端返回验证通过并完成发货之后,再调用 finishTransaction。finishTransaction 的意义是告诉系统这笔交易已经处理完毕,可以从队列里移除。如果客户端只发货不上报,中途 App 被杀,服务端就永远收不到这笔交易。反过来如果服务端已经发货了,finishTransaction 又迟迟不调用,系统会在下次启动时重新投递这个事务,造成重复发货的隐患。

failed 分支一定要调用 finishTransaction,否则失败的交易会一直卡在队列里,后续新的购买会被阻塞。

3.2 客户端读取收据文件

交易状态确认之后,客户端要做的是把收据数据取出来,传给自己的服务端。收据不是从 SKPaymentTransaction 对象里拿,而是从 App 的 sandbox 目录里读取。

guard let receiptURL = Bundle.main.appStoreReceiptURL, let receiptData = try? Data(contentsOf: receiptURL) else { // 这里需要发起收据刷新请求 let refreshRequest = SKReceiptRefreshRequest() refreshRequest.delegate = self refreshRequest.start() return } let receiptString = receiptData.base64EncodedString() // 把 receiptString 传到自己的服务端

appStoreReceiptURL 在生产环境和沙盒环境下指向不同的物理路径,这套逻辑对两端都适用。极少情况下收据文件还没生成,会走到 else 分支,这时候用 SKReceiptRefreshRequest 向 App Store 请求重新生成收据。等到回调完成后再重试上报。

3.3 服务端二次验证:verifyReceipt 的参数与沙盒回退

客户端拿到的 receiptString 是不能直接信任的,真正的发货依据来自服务端向 App Store 验证的结果。下面是 Python 侧常见的验证写法。

import requests def verify_receipt(receipt_data, shared_secret): payload = { "receipt-data": receipt_data, "password": shared_secret, "exclude-old-transactions": True, } # 生产环境验证 resp = requests.post( "https://buy.itunes.apple.com/verifyReceipt", json=payload, timeout=10, ) result = resp.json() # 沙盒收据会返回 21007,此时回退到沙盒地址重新验证 if result.get("status") == 21007: resp = requests.post( "https://sandbox.itunes.apple.com/verifyReceipt", json=payload, timeout=10, ) result = resp.json() return result

先请求生产环境地址,如果返回 status 21007,再切到沙盒地址验证。这个顺序不能反,因为沙盒地址只接受测试环境的收据,真实线上收据打过去会返回 21008,表示这是一个生产环境的收据,不应该走沙盒。

三个参数的用途:receipt-data 是客户端传来的 base64 字符串,必须原样传,不能做 URL 解码或换行清洗;password 是订阅类商品的共享密钥,在开发者后台的“App 内购买项目”详情页里找到,消耗型和非消耗型商品可以不传;exclude-old-transactions 设为 true 后,验证结果里的 latest_receipt_info 只会包含当前这笔最新交易,避免把历史购买记录都带回来,服务端解析时省很多事。

3.4 验证结果解析:防重复发货与订阅状态

苹果返回的 JSON 结构里有两个关键数组。latest_receipt_info 是最新交易的明细,数组最后一项是当次购买的信息;in_app 是收据里所有交易的集合,订阅续期时每一期都会追加一条。pending_renewal_info 是订阅续期状态数组,里面包含 auto_renew_status、expiration_intent 这些判断下一个月是否还会扣费的字段。

服务端判定发货时要拿 transaction_id 做唯一性校验,这个 ID 在每笔交易里是唯一的,同一笔交易重复上报时不会变。

latest = result["latest_receipt_info"] transaction_id = latest[-1]["transaction_id"] if not already_paid(transaction_id): deliver_goods(latest[-1]) mark_paid(transaction_id)

这套逻辑解决的是最头疼的重复发货问题。客户端网络波动时,同一个交易可能被上报两次,如果没有去重,用户就会收到双倍道具。我一般会把 transaction_id 写入带唯一索引的数据库表,或者用 Redis 的 set 结构做判重,双保险。

4. 内购避坑:沙盒环境、审核账号与四个高频翻车记录

4.1 商品列表请求成功但返回空数组

现象:SKProductsRequest 的回调正常执行了,但 response.products 是空数组,invalidProductIdentifiers 也是空的。

原因:后台的商品 ID 状态还是“缺失元数据”或“等待审核”,沙盒环境只能测试“准备提交”和“已批准”状态的商品。另一个常见原因是后台虽然填完了信息,但商品没有提交审核,沙盒环境读不到。

解决:登录开发者后台,打开对应商品的编辑页,确认所有必填项都填完,状态推进到“准备提交”。如果刚改过状态,等一分钟再拉一次,商店缓存不是实时刷新。另外确认代码里传的 Set 里的字符串和后台商品 ID 完全一致,包括大小写和连字符。

4.2 支付成功但 updatedTransactions 一直不触发

现象:付款弹窗正常弹出,指纹或面容验证也通过了,但代码里的 paymentQueue(_:updatedTransactions:) 一直没有走到 purchased 分支。

原因:交易观察者没有被正确持有。如果是在某个商品页面里写的 SKPaymentQueue.default().add(self),这个页面 pop 掉之后 observer 被释放,回调自然就丢了。更隐蔽的情况是 App 启动时系统把上一次遗漏的未完成交易重新投递,但 observer 挂载时机晚了,错过了投递窗口。

解决:把 observer 挂到一个全局单例上,在 AppDelegate 的 didFinishLaunchingWithOptions 里越早越好。单例生命周期和 App 一致,不会因为页面销毁而丢失。还有一点,如果之前测试时产生过未完成交易,重新启动后拿到旧的 transaction,要在处理完调 finishTransaction 清掉,否则同一笔交易每次启动都过来一遍。

4.3 沙盒账号登录后购买弹窗出不来

现象:点击购买按钮后一直转圈,不弹密码输入框,过一会提示“无法连接到 App Store”。

原因:设备上已经登录了一个正式的 Apple ID,并且开启了双重认证,沙盒账号无法直接在设置里切换。或者是沙盒测试账号密码输错多次被临时锁定。

解决:在系统的 App Store 里退出当前账号,注意是“退出登录”,不是“切换地区”。然后回到 App 里触发购买,系统会先弹登录框,在登录界面输入沙盒账号。沙盒账号的密码规则比较宽松,但如果连续输错锁定了,回到开发者后台创建一个新的沙盒账号测试,别在原有账号上等解锁。

4.4 服务端验证返回 21002

现象:客户端上传收据后,服务端调用 verifyReceipt 返回 status 21002,提示 receipt-data 格式异常。

原因:客户端在 base64 编码时做了多余的加工,最常见的是用String(data: encoding: .utf8)把收据二进制先转成字符串再 base64,或者拼接 JSON 时自动转义了换行符,导致服务端拿到的字符串不够“纯”。

解决:客户端严格用Data(contentsOf: receiptURL).base64EncodedString()生成,不要做任何 trim、替换、格式化。服务端在调 verifyReceipt 前可以先用base64.b64decode(receipt_data, validate=True)自检一遍,抛异常就说明客户端数据有问题,直接返回参数错误,比打到苹果接口再排查快得多。

4.5 沙盒与生产环境验证串台

现象:同一套后端代码,测试环境验证通过,线上支付后却报 21008,或者反过来线上收据在测试环境返回 21007。

原因:代码里沙盒地址和生产地址的处理顺序写反了。先把请求打到沙盒地址,线上收据会被拒绝;或者只在配置项里填了一个地址,导致两种环境的收据混着走。

解决:服务端验证固定写成“先生产,后回退”,只有遇到 21007 才切沙盒。不要根据环境变量去猜当前是测试还是线上,因为客户端传到服务端的收据可能是沙盒的也可能是在线上的,以状态码为准最稳妥。收到 21008 时不要盲目切换地址,先检查这段逻辑有没有把地址写反。

5. 上线前验证清单与订阅续期判断:从沙盒到提审前的最后一遍自查

提审前我习惯把内购相关检查项列成表格,逐条过,漏掉任何一项都可能是审核拒绝的理由。

验证项通过标准常见遗漏
真机完整购买链路沙盒账号完成下单、支付、发货、掉单补偿只在模拟器上测试
服务端环境回退生产收据用时验证通过,沙盒收据走 21007 回退地址顺序写反
商品状态与 ID所有商品 ID 在后台处于“准备提交”以上的状态新增商品忘记推进状态
弱网与断网重试支付中断后重新启动,能恢复未完成交易没有处理事务重投
重复发货防护同一 transaction_id 重复上报只发一次货数据库没做唯一索引
审核测试账号后台创建沙盒账号,并在审核备注里写明测试路径只留内部测试账号,审核员无法复现

订阅类型的续期判断是内购里最容易出边界问题的地方。很多开发者只判断 expires_date 是否大于当前时间,但这个字段在用户主动取消订阅后的宽限期内仍然显示有效,导致服务端多放行了好几天。更可靠的判断要结合 pending_renewal_info。

def subscription_active(receipt_result): latest = receipt_result["latest_receipt_info"] pending = receipt_result.get("pending_renewal_info", []) expires_ms = latest[-1]["expires_date_ms"] expires_ts = int(expires_ms) / 1000.0 now = time.time() if now >= expires_ts: return False # expiration_intent=1 表示用户已取消,但还没到账期结束 if pending and pending[0].get("expiration_intent"): return False return True

expires_date_ms 是毫秒级时间戳,除以 1000 转成秒再和当前时间比,这里很多人会忘记做单位换算。expiration_intent 的值 1 表示用户主动取消,2 表示计费错误,3 表示无法续期,只要这个字段存在,哪怕 expires_date 还在未来,也要按不续期处理。另外订阅订单的 original_transaction_id 是用户在这个 App 里的唯一订阅标识,续期订单的 transaction_id 会不断变化,但 original_transaction_id 从首购到退订始终不变,判断是否同一个用户订阅时只认这个字段。

审核备注里写测试路径也是一门玄学。我会在提审时明确写出:使用提供的沙盒账号登录,进入设置页,点击订阅入口,完成购买,然后到某个页面查看会员状态。沙盒账号密码一起附上,尽量减少审核员的操作步骤。好几次审核被拒都是因为审核员找不到内购入口,而不是功能有问题。

从最早一次因为 observer 挂载位置不对导致线上用户购买后不发道具开始,我每次提审前都强制走一遍这张清单,尤其是重新确认商品状态和服务端环境回退逻辑。这套 Swift 内购支付工具最值钱的部分也正是这些经验沉淀,而不是那几十行能跑的代码。希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/10 3:11:15

LLM辅助代码审查:人机协同Review工作流落地指南

代码审查遇上LLM&#xff1a;流程会变成什么样&#xff1f;人机协同Review工作流落地指南如果你们团队已经开始用LLM辅助写代码&#xff0c;那一定会遇到一个新问题&#xff1a;代码审查还按原来的流程走吗&#xff1f;以前是开发写完、提交MR、维护者打开Diff逐行看&#xff1…

作者头像 李华
网站建设 2026/10/10 3:10:07

内存带宽如何决定本地大模型推理速度?从1.22TB/s看LLM部署

在本地跑大模型时&#xff0c;很多人会先看算力&#xff1a;CPU是几核&#xff0c;GPU有多少 TOPS。结果真正跑起来却发现&#xff0c;设备明明算力不差&#xff0c;生成速度却始终提不上去。这个现象背后的关键瓶颈&#xff0c;往往不是计算单元&#xff0c;而是内存带宽。最近…

作者头像 李华
网站建设 2026/10/10 3:10:07

mitmproxy脚本集实战:从插件机制到流量改写与Mock自动化

1. 整体设计&#xff1a;为什么要做一套 mitmproxy 脚本集做客户端开发或者接口联调的朋友&#xff0c;肯定都经历过这种痛苦&#xff1a;后端接口还没写好&#xff0c;前端页面已经等着联调了&#xff1b;线上环境出了个偶现问题&#xff0c;需要把请求参数改一下复现&#xf…

作者头像 李华
网站建设 2026/10/10 3:10:01

AI公司利润拆解:从算力成本到技术变现的分析框架

商汤能不能赚钱&#xff0c;一直是AI行业争论最多的话题之一。过去几年&#xff0c;市场习惯性把AI公司归类为“高风险、高投入、长周期亏损”的典型&#xff0c;而“6亿利润”这个数字一旦出现&#xff0c;很容易让人产生一连串疑问&#xff1a;钱是从哪来的&#xff1f;是靠模…

作者头像 李华
网站建设 2026/10/10 3:09:46

Spring Boot多数据源切换与分库分表实战指南

简介&#xff1a;本资源是一份面向Java后端开发者与分布式系统学习者的分库分表实战项目包&#xff0c;聚焦企业级大数据量场景下的数据库水平扩展难题&#xff0c;以Sharding-JDBC为核心框架实现多数据源动态切换与透明化分片。资源共73个文件&#xff0c;涵盖18个Java业务与配…

作者头像 李华
网站建设 2026/10/10 3:06:07

电商后台管理系统模板实战:从骨架到业务系统的二次封装与避坑指南

简介&#xff1a;这是一套面向前端开发者与后台管理系统初学者的电商后台数据管理模板&#xff0c;基于HTML构建&#xff0c;适合用于快速搭建网站管理界面或作为课程设计、个人练手项目。资源包共276个文件&#xff0c;包含71个HTML页面、101个JavaScript脚本、29个CSS样式表&…

作者头像 李华