简介面向iOS开发者的苹果内购IAP支付工具资源包基于Swift语言实现。内容涵盖Apple Developer后台内购项目配置、StoreKit框架导入、SKProductsRequest产品信息请求、SKPaymentQueue支付队列及交易状态更新处理并包含恢复购买、订阅管理、收据验证、错误提示等关键环节适合需要在App内接入付费功能的中高级iOS开发者参考。资源包共31个文件其中swift源码9个、plist配置5个、Objective-C的h/m文件5个另有storyboard界面布局与entitlements权限配置文件并附带完整的Xcode工程结构与工作区配置压缩包仅66KB结构紧凑清晰便于快速定位内购核心代码与配置项。实现中提供SKProductsRequestDelegate与SKPaymentTransactionObserver的完整代理方法覆盖产品响应、交易更新、恢复交易成功与失败等回调并细化交易状态分支成功、失败、取消、退款的处理思路可作为内购模块的脚手架直接复用。目前已有1701人学习下载可帮助开发者避开常见的收据验证与订阅恢复陷阱高效完成合规内购功能。1. 苹果内购支付工具Swift先把交付场景想清楚再动手写代码跟 iOS 支付打交道的第一年我被苹果内购的凭证校验折磨得不轻用户明明付了款服务端却查不到订单沙盒环境一切正常上线后恢复购买永远静默失败。后来我把这套 Swift 内购支付工具从 StoreKit 1 迁移到 StoreKit 2用Transaction.currentEntitlements和 JWS 票据把购买状态变成可查询、可验证的数据后掉单率才真正降下来。这篇笔记就是这套工具的落地过程适合正在做 iOS 付费功能、想一次接对苹果内购的客户端或服务端工程师。读完你能弄清两代 API 怎么选、购买与恢复怎么接、票据怎么验、以及那些让新手上线翻车的隐藏坑。2. 选型Swift 内购用 StoreKit 2 还是 StoreKit 1很多 Swift 工程师拿到内购需求后第一反应是打开旧文档找SKPaymentQueue。这不能怪大家网上大量教程还停留在 StoreKit 1 的回调时代。实际从 iOS 15 开始StoreKit 2 已经是苹果主推的 Swift 原生接口它把异步购买、交易结果、权益校验都做成了 await 语法写起来比老 API 舒服得多。先明确一个判断新项目默认选 StoreKit 2只有在需要兼容 iOS 14 以下、或者老项目里的订阅服务端逻辑已经绑死SKPaymentTransaction时才回退到 StoreKit 1。2.1 StoreKit 2 与 StoreKit 1 的核心差异从回调到异步StoreKit 1 的典型写法是在SKPaymentQueue.default()里加 observer然后等paymentQueue(_:updatedTransactions:)回调。这个回调天然是全局的、跨控制器的业务代码一多状态机就会变得难以追踪。掉单、重复到账、恢复购买没反应多半都出在这个回调模型上。StoreKit 2 把购买变成了一次异步调用let products try await Product.products(for: [com.demo.coin.100]) let result try await products[0].purchase()买就是买取消就是取消结果直接返回。配合Transaction.currentEntitlements和Transaction.updates你还能随时知道「当前用户到底拥有哪些权益」这相当于苹果替你把购买状态管理做掉了一半。两代 API 在关键行为上的差异维度StoreKit 2StoreKit 1购买调用Product.purchase()异步返回SKPaymentQueue.add(_:)回调通知交易结果VerificationResultTransaction自带验签状态SKPaymentTransaction验签要自己做权益查询Transaction.currentEntitlements异步遍历restoreCompletedTransactions恢复回调外部变动监听Transaction.updates统一处理需要 delegate 区分多个回调场景退款与订阅过期返回expirationDate与revocationDate等结构化字段需要解析原始 receipt最低系统版本iOS 15iOS 12Swift 使用体验原生 async/await类型安全大量Any和 delegate 方法2.2 Swift 内购支付工具该封装哪些职责很多人把内购接成「点击按钮 → 调 purchase → 收到结果 → 解锁功能」然后就没有然后了。这样的项目在审核和线上运营时一定会出问题。一个及格的 Swift 内购工具至少要封装四件事第一商品加载与缓存。Product.products(for:)是网络请求反复调用会慢、会消耗电量应该把商品列表拉到内存并做失效策略。第二购买结果的归一化。把苹果返回的success / pending / userCancelled / failed统一成业务枚举调用方不需要关心 StoreKit 细节。第三权益恢复和同步。App 启动、切后台、账号切换时都要检查currentEntitlements。第四票据上报接口。把交易凭证Transaction JWS 或原始 receipt统一交给服务端由服务端做最终校验。我在项目里通常把这些放在一个IAPManager里对外只暴露loadProducts、purchase、restore三个方法。调用方拿到的是明确的枚举结果而不是 StoreKit 的对象。2.3 什么时候必须继续用 StoreKit 1老项目与订阅逻辑不要为了用新而用新。如果你服务的用户里有大量 iOS 14 及以下的机型StoreKit 2 直接不可用只能走 StoreKit 1。另一种情况是服务端已经用verifyReceipt旧接口存储了全部原始票据客户端如果切成 StoreKit 2需要考虑新旧票据格式的兼容。还有一个容易被忽略的点自动续订订阅的「坑位」。StoreKit 1 时代有些团队用original_transaction_id做用户权益的唯一键StoreKit 2 里这个字段依然存在但交易对象变成了Transaction字段获取方式不同。迁移时如果只是把SKPaymentTransaction换成Transaction就上线风险很高。我的建议是如果是老项目迁移先保留 StoreKit 1 的恢复路径新购买走 StoreKit 2两边并行一两个版本观察掉单率和用户反馈后再摘掉旧代码。3. 实操搭一个最小可运行的 Swift 内购支付流程选型定了接下来就是动手。这一章从 App Store Connect 的配置开始到 Swift 代码实现再到恢复购买按顺序走一遍。3.1 在 App Store Connect 配置内购商品标识符、类型与价格代码写得再好商品配置错了也白搭。进 App Store Connect → 你的 App → 「App 内购买项目」→ 点加号创建内购商品。这里要选对产品类型消耗型项目游戏币、道具、非消耗型项目永久解锁、自动续订订阅会员、非续订订阅一次性时长权益。三个最常配错的地方第一商品 ID 要和代码里传给Product.products(for:)的字符串完全一致包括大小写和后缀。第二本地化名称和描述至少填一种语言否则审批会被打回。第三沙盒测试账号不要用自己的 Apple ID 测试在「用户和访问」里创建沙盒账号不然内购弹窗会出现诡异的多次询问问题。配置完成后商品会进入「准备提交」状态这很正常不需要等审核通过就能在沙盒环境测试。真正需要注意的是「App 内购买项目」的协议和税务信息是否填写没填的话内购功能在特定地区不可用。3.2 加载商品与发起购买StoreKit 2 的最小实现我建议把内购逻辑收敛到一个类里用ObservableObject承载方便 SwiftUI 界面监听状态变化。下面是项目里实际可跑的骨架import StoreKit import Foundation MainActor final class IAPManager: ObservableObject { enum PurchaseResult { case success(transactionId: UInt64) case pending // 等待用户确认、家长同意或临时故障 case cancelled // 用户主动取消 case failed(String) } /// 已经加载到内存的商品避免每个页面重复请求 private var products: [String: Product] [:] /// 拉取 App Store 侧的商品配置 /// - Parameter ids: App Store Connect 里配置的标识符列表 func loadProducts(_ ids: [String]) async { do { let fetched try await Product.products(for: ids) var map: [String: Product] [:] for product in fetched { map[product.id] product } products map } catch { // 常见失败商品标识符没接通、bundle id 不匹配、协议未填写 print(load products failed: \(error)) } } /// 发起购买返回业务层可识别的结果 func purchase(_ productID: String) async - PurchaseResult { guard let product products[productID] else { return .failed(product not loaded) } do { let result try await product.purchase() switch result { case .success(let verification): // 本地先验一次签最终以服务端校验为准 switch verification { case .verified(let transaction): await transaction.finish() return .success(transactionId: transaction.id) case .unverified(_, let error): return .failed(verification failed: \(error)) } case .pending: // 常见于家长控制或 Ask to Buy此时不要解锁权益 return .pending case .userCancelled: return .cancelled unknown default: return .failed(unknown result) } } catch { return .failed(purchase error: \(error)) } } }这段代码里有几个值得细说的地方。product.purchase()默认参数适用于绝大多数场景不需要额外传PurchaseOption如果你需要模拟沙盒订阅的优惠价格才需要研究Product.PurchaseOption里的promotionalOffer参数。.pending状态非常关键它对应的是 Ask to Buy 或家长审批用户还没真正付钱此时无论如何不能解锁功能等Transaction.updates里的后续结果。.verified分支里调用的transaction.finish()是告诉 StoreKit「这笔交易已经处理完」不调用的话StoreKit 会认为你还没处理完可能在下次启动时继续推送这笔交易。很多人漏掉finish()导致同一笔购买被处理多次。真实项目里还要注意一个细节transaction.id是UInt64类型传到服务端时如果服务端用 JavaScript 处理会超过Number.MAX_SAFE_INTEGER丢失精度。正确做法是客户端先把transaction.id转成字符串再传给服务端后面服务端签名校验那章会再提到。3.3 恢复购买用 currentEntitlements 而不是 restoreCompletedTransactionsStoreKit 1 时代的恢复购买写法是SKPaymentQueue.default().restoreCompletedTransactions()它会弹出一个密码输入框体验很差。StoreKit 2 的恢复逻辑更简洁遍历Transaction.currentEntitlements把所有处于有效状态的交易找出来。/// 恢复当前账号的购买权益 /// - Returns: 当前有效的商品 ID 集合 func restore() async - SetString { var owned SetString() for await entitlement in Transaction.currentEntitlements { switch entitlement { case .verified(let transaction): // 自动续订订阅可能已经过期这里由调用方决定是否需要过滤 owned.insert(transaction.productID) case .unverified: // 验签失败的交易不应该解锁任何权益 break } } return owned }currentEntitlements返回的是当前用户所有有效的授权交易包括已购买的非消耗型商品和仍在订阅期内的订阅。它的好处是苹果通过 JWS 签名返回我们不需要解析原始 receipt 数据直接信任验证结果即可。注意已过期的订阅不会出现在currentEntitlements里所以如果你要展示订阅历史需要自己保留交易记录或者向服务端查询。3.4 监听外部交易变动Transaction.updates 的用法用户可能在你 App 里出账后马上被系统自动续订又或者家长在网页端退款这些变动不会主动通知你的 App。StoreKit 2 提供了Transaction.updates这个异步序列App 启动时要起一个长期监听 Taskfunc listenForTransactionUpdates() async { for await update in Transaction.updates { switch update { case .verified(let transaction): await transaction.finish() // 通知业务层刷新权益 case .unverified: break } } }这个 Task 必须在 App 启动后就启动并且要在scenePhase变活跃时再同步一次currentEntitlements。很多团队只做了购买流程漏掉了被动更新结果用户在网页端退款后 App 里权益还在被投诉后才发现问题。4. 服务端校验把 Swift 内购凭证变成可信的支付状态客户端拿到的Transaction已经经过 StoreKit 验签但这还不够。攻击者可以伪造客户端回调或者用一个 HTTP 抓包工具改写内存对象。苹果官方一直强调最终判断支付是否有效的依据是服务端校验结果。4.1 为什么客户端校验不能作为唯一依据如果只信客户端的verificationResult,一个简单的攻击路径是用越狱设备 hook 掉purchase()的返回让验证过程永远返回.verified。更常见的问题是网络层服务端没收到客户端上传的订单记录用户的钱已经扣了这笔账记在谁头上可靠做法是把「交易 ID 或 JWS 票据」上传到自己的服务端由服务端去问苹果这笔交易是否真实存在、归属哪个 bundle id、是什么状态。流程上我一般这么做客户端购买成功后拿到transaction.id转字符串和signedTransactionInfoJWS 格式一起 POST 给服务端服务端用 App Store Server API 的Get Transaction Info接口拿权威数据跟客户端给的商品 ID、订单号做比对最后服务端自己落库、自己发货客户端只展示发货结果。4.2 App Store Server API 与 verifyReceipt 的取舍苹果在 2023 年正式标记verifyReceipt为废弃接口新开发一律推荐 App Store Server API。两者的核心区别是verifyReceipt要你把整段 Base64 receipt 发过去苹果再返回一整坨 JSON信息全但笨重而且存在数据过期问题App Store Server API 是按transactionId或订单号精确查询返回 JWS 签名数据性能更好也更能适配自动续订的复杂场景。我给的选型建议新系统直接上 App Store Server API老系统如果还在用verifyReceipt先想办法平滑升级不要在新代码里再加一个verifyReceipt调用。4.3 用 App Store Server API 校验交易Python 示例服务端语言不限关键是认证逻辑。App Store Server API 要求用 ES256 签名的 JWT密钥在 App Store Connect 后台下载。下面的示例用 Python 展示最小可用实现import time import jwt import requests def make_appstore_token(issuer_id: str, key_id: str, private_key: str, bundle_id: str) - str: now int(time.time()) header {alg: ES256, kid: key_id, typ: JWT} payload { iss: issuer_id, # App Store Connect 里的 Issuer ID iat: now, exp: now 3600, # 苹果要求过期时间,最长 20 分钟 aud: appstoreconnect-v1, bid: bundle_id, # 你自己的 App bundle id } return jwt.encode(payload, private_key, algorithmES256, headersheader) def verify_transaction(transaction_id: str, token: str) - dict: url fhttps://api.storekit.itunes.apple.com/inApps/v1/transactions/{transaction_id} resp requests.get( url, headers{Authorization: fBearer {token}}, timeout10, ) if resp.status_code ! 200: # 常见错误401 是 JWT 无效404 是交易号不存在 raise RuntimeError(fApp Store Server API error: {resp.status_code}) data resp.json() return data核心逻辑是先用jwt.encode生成带kid的 ES256 令牌再把令牌放在请求头里。苹果后台下载的AuthKey_XXX.p8文件就是私钥它的内容是 PKCS#8 格式jwt.encode会直接读取。transaction_id参数注意用字符串传递因为 UInt64 转成十进制字符串后可能超过 20 位JavaScript 风格的后端容易踩精度坑。令牌exp苹果建议不超过 20 分钟老写 3600 秒也能用但没必要。沙盒环境请求地址换成api.storekit-sandbox.itunes.apple.com生产环境保持不变。4.4 拿到的数据怎么判断transactionInfo 里的关键字段Get Transaction Info返回的signedTransactionInfo是一个 JWS 字符串你需要解码 payload 部分去判断这笔交易到底值不值得发货。关键字段包括transactionId交易唯一 IDoriginalTransactionId原始交易 ID订阅续订时所有续订事件都指向同一条原始交易bundleId必须是你 App 的 bundle idproductId必须和你收到的订单商品一致purchaseDate购买时间expiresDate自动续订订阅的过期时间没有这个字段说明购买类型不是订阅quantity购买数量type值为Auto-Renewable Subscription、Non-Consumable、Consumable等inAppOwnershipType用于区分是用户购买还是家人共享。服务端拿到后至少要检查三件事bundleId 匹配、productId 在自家商品列表里、交易状态不是退款。检查通过才发货任何一项对不上就拒绝并记录告警日志。退款判断要看revocationDate字段字段存在且非空表示这笔交易已经被苹果退款此时服务端应该主动回收权益。如果你暂时不能用 App Store Server API老的verifyReceipt也能顶上但要注意环境切换生产环境请求https://buy.itunes.apple.com/verifyReceipt沙盒请求https://sandbox.itunes.apple.com/verifyReceipt返回码 21007 意味着沙盒票据发到了生产环境21008 正好相反。这个错误码在联调时几乎每个人都会遇到一次。5. 苹果内购避坑记录高频问题的现象、原因与排查下面这几条是我在开发排障中反复踩过的坑每一条都按「现象 → 原因 → 解决」的顺序记录希望能帮你节省几天的排查时间。5.1 沙盒环境购买返回 21007 / 21008现象客户端在沙盒环境内购成功后服务端用verifyReceipt校验提示21007或21008或者用 App Store Server API 时无论怎么调都查不到交易。原因21007 表示你把沙盒环境的 receipt 发到了生产环境的验证端点21008 则相反。请求地址选错了。另外一个隐藏原因是服务端缓存了生产环境的 Apple 证书导致环境判断逻辑混乱。解决区分环境的关键在代码里做。用 App Store Server API 时沙盒和生产环境分别用不同 base URL并确保配置项随构建环境切换用verifyReceipt时先请求生产环境如果收到 21007再拿同一段 receipt 请求沙盒环境。这是苹果官方推荐的降级逻辑很多老文档没写清楚。5.2 恢复购买时 currentEntitlements 返回空现象用户重新安装 App、登录同一 Apple ID 后点击恢复购买restore()返回空集合但购买明明成功过。原因currentEntitlements只包含当前有效的授权交易。如果订阅已经过期或者非消耗型商品因退款被撤销它不会出现在列表里。另一个原因是交易还没走到finish()StoreKit 的本地队列里仍把它当未处理交易此时currentEntitlements不会正确返回。解决先确认商品类型。非消耗型商品只要没退款就一直有效自动续订订阅要检查expiresDate过滤出「当前时间小于过期时间」的才解锁。对于已经过期的订阅如果产品设计上允许查看历史订阅记录那应该从服务端拉取而不是靠客户端枚举。另外在恢复前先调用AppStore.sync()让 StoreKit 从 App Store 拉取最新状态能解决大部分本地缓存的假阴性。5.3 用户退款后 App 里权益还在现象用户通过reportaproblem.apple.com退款App 内权益没有消失用户继续用着付费功能。原因退款是异步事件苹果不会主动通知 App。App Store Server API 的Transaction Info里有一个revocationDate字段退款会把对应交易标上这个字段但你得自己去拉取、去轮询。解决服务端在做交易状态检查时把revocationDate非空的交易标记为已退款同时触发权益回收逻辑。客户端每次启动和切回前台时调一次服务端接口同步权益状态。苹果的Transaction.updates也会推送退款消息但它的实时性不能保证服务端轮询兜底是必须的。5.4 购买成功但服务端查不到交易掉单问题现象客户端purchase()返回.success用户也收到了扣款短信但服务端按transactionId调用 App Store Server API 返回404订单无法发货。原因大多数情况是transaction.id在传输过程中被截断或转成了非十进制。transaction.id是 64 位整数某些服务端框架会把它解析成 float 存到数据库精度丢失后查不到真实交易。还有一种是客户端用了交易所产生的transactionDate做查询条件而不是transactionId当然查不到。解决客户端把transaction.id用String(transaction.id)包装后上传服务端字段类型用字符串接收数据库也存 VARCHAR不要用 BIGINT。如果服务端确实查不到让客户端把整段signedTransactionInfo上传服务端解 JWS 后也能得到全部字段这种方式不需要额外调苹果接口适合兜底但不适合作为唯一校验手段。5.5 审核被拒内购入口和验证逻辑的隐藏要求现象App 提审被拒理由通常写着「App 包含隐藏功能」或者「无法验证内购流程」。原因最常见的是审核人员在沙盒账号下点了某个按钮内购弹窗没出现或者出现了但点击购买后没有反应。另一个集中问题是有些团队把内购入口藏得很深审核人员找不到。苹果不允许做「先看到内容、再通过内购解锁」之外隐藏的付费逻辑。解决给审核账号一个明确的测试入口比如在设置页放一个「恢复购买」按钮并把完整的沙盒账号信息写进审核备注。购买流程必须在沙盒环境跑通注意沙盒环境的Transaction.updates和currentEntitlements需要 App 从后台唤起一次才能正确刷新直接在审核备注里说明「安装后先切后台再回到 App」可显著降低被拒概率。别用假的内购弹窗或自定义支付页面只要发现用的是自己的收银 UI一律 2.1 大礼包。6. 上线前必做的一组测试沙盒切换与审核检查清单内购上线前一天晚上我会固定花 40 分钟过一遍下面的清单每次都能捞出一两个问题。沙盒测试需要一个专门准备的 Apple ID不要用主账号。在 App Store Connect 的「用户和访问」里创建沙盒测试员登录的步骤是设置 → App Store → 沙盒账号。注意那个账号不需要也是真邮箱只要格式合法就能创建。切换沙盒账号时如果旧账号的授权缓存还在把 App 从后台杀掉重进否则容易出现在当前账号里恢复出上一个账号的购买记录。测试用例按顺序执行第一首次购买消耗型和非消耗型各一单拿到.success后杀掉 App 重进看权益是否已在currentEntitlements第二不点购买直接点恢复购买确认没购买过的商品不会出现在恢复结果里第三断网状态下点购买确认走到.failed(purchase error)而不是崩溃第四用沙盒账号走一遍家庭成员共享场景下的 Ask to Buy确认.pending分支不解锁功能第五服务端用生产环境签发 JWT 调沙盒环境接口确认返回 401 而不是 200防止环境配置混乱。还有两个经常被漏掉的检查点一个是Transaction.updates的长监听是否在 App 启动时拉起另一个是服务端revocationDate字段是否被索引、查询性能是否过关。前者影响退款实时性后者影响退款批量处理时的稳定性。这套 Swift 内购支付工具跑通后我会在服务端加一个每日统计任务对比「苹果侧有效交易数」和「我方发货数」差值超过阈值就报警因为这两者长期不一致意味着有退款漏回收或丢单风险。希望这些记录能帮你在接内购时少走弯路一次把购买链路做结实。本文还有配套的精品资源点击获取