Swift实现苹果内购全流程:StoreKit 2、服务端校验与避坑指南 简介iOS应用内购In-App Purchase是开发者向用户提供应用内付费功能的关键途径。这份Swift实现的内购支付工具Demo工程面向需要接入IAP功能的iOS开发者与移动端研发人员完整覆盖从Apple Developer后台配置内购商品、导入StoreKit框架到SKProductsRequest商品信息请求、SKPayment发起支付、paymentQueue交易状态更新、恢复购买及服务端收据验证等核心流程并针对消耗品、非消耗品与订阅等不同产品类型给出了处理思路。压缩包共31个文件、约66KB以swift源文件9个为代码主体辅以plist配置文件、h头文件、storyboard界面布局、工程配置文件与资源目录结构清晰便于直接对照学习或抽取复用。目前已有1702人学习下载适合希望快速掌握StoreKit接口调用方式、在项目中落地合规内购模块的初中级iOS开发者。1. 苹果内购支付工具Swift交易不是点一下“购买”就结束的事很多从业者第一次碰苹果内购IAP时最容易犯的错是把它当成“拉起弹窗、等回调、发个货”的按钮。我在模拟项目X里接IAP时也这么想过真正上线后才意识到一次购买至少经历客户端发起、StoreKit校验、服务端对账、订单发货四个环节每一环都有各自的失败路径。这里按一个支付工具真正要落地的顺序来拆——先选对产品类型和API代次再用 Swift 把购买闭环写成可复用组件接着把服务端校验和对账补上最后用沙盒与 StoreKit 配置文件的调试手段把订阅、恢复购买这些边角场景跑通。适合已经有 Swift 基础、正在准备接入 IAP或正被恢复购买、审核、退款问题反复折腾的人。2. IAP 产品模型与选型先定四类商品和两代 StoreKit再做任何 UI动手写代码前先想清楚你到底在卖什么。苹果内购的产品模型直接决定了订单状态机怎么写能不能恢复、要不要续期、审核要准备什么材料。我在给某跨平台系统做支付模块时第一版草稿把消耗型和非消耗型混在一起处理结果后台对账时发现“解锁功能被二次扣费”追根到底就是产品类型选错了。代码写得再漂亮也救不回来这一章的选型直接决定你后面要不要返工。2.1 消耗型、非消耗型、订阅按业务形态选产品类型IAP 的产品类型一共有四类落到实际业务上是两个选择题“这个东西用户需不需要重复购买”和“买了之后要不要一直有效”。四类产品的特征和典型场景放在一起看更清楚产品类型行为特征是否支持恢复典型场景消耗型扣一次费消耗一个否虚拟币、道具、抽奖次数非消耗型一次购买永久有效是解锁功能、去除广告自动续订订阅周期扣费系统自动续期是系统自动恢复会员、内容付费非续订订阅一次购买有效期固定否需自行迁移体验期、活动包选型的判断逻辑我一般是这样用的如果用户买完之后可能想在新设备上拿回来就选非消耗型或自动续订如果资源是一次性的就选消耗型。最容易翻车的是把“功能解锁”做成消耗型——用户换设备后购买记录找不回来客服工单会教你重新做人。非续订订阅现在新提交的产品已经不再接受老产品依然存在迁移思路是把有效期同步到服务端靠登录态恢复而不是靠 StoreKit 恢复。这里有一个容易忽略的细节消耗型产品没有恢复购买的概念。审计的时候不要给消耗型做“恢复”按钮否则同一件商品会被发货两次这在后面对账时就是实打实的资金损失。自动续订订阅则相反它是唯一一个系统会在后台自动帮你执行续期的产品但前提是用户没有在设置里关闭自动续订这个状态只能通过服务端查询拿到客户端拿不到实时开关。2.2 StoreKit 2 还是 StoreKit 1维护成本与交易校验的取舍选 API 代次比选第三方库重要。新项目我建议直接上 StoreKit 2前提是你的最低系统版本允许。StoreKit 2 需要 iOS 15 及以上如果你的 App 还在支持 iOS 14就得同时维护两套路径那不如全线用 StoreKit 1 更省心。两代 API 的差异集中在交互模型上。StoreKit 1 是“代理回调 交易队列”所有结果都从SKPaymentTransactionObserver的队列里来开发者要自己判断交易状态、自己管理未完成交易稍不留神就会漏处理一笔。StoreKit 2 把流程改成了async/await购买结果直接以返回值的方式拿到未完成交易由Transaction.updates统一推送代码量能少三分之一。在验证能力上StoreKit 2 的VerificationResultTransaction内置了签名校验拿到即可区分verified和unverifiedStoreKit 1 需要自己拿着整包 receipt 去做二次解析验签逻辑基本都堆在服务端。老项目已经用 StoreKit 1 跑了好几年不要为了新 API 强行重构交易链路最怕动作太大。我的一般做法是老项目继续用 1但把交易记录统一收敛到一个工具类里后续要迁移时只动一个文件新项目直接上 2。还有一个现实因素苹果内购的所有购买弹窗、交易队列、订单确认都是系统接管开发者能控制的只有业务回调时机选对 API 代次能让你少写很多“和系统打架”的兼容代码。2.3 产品 ID 命名与后台配置影响排查效率的小细节产品 ID 是你和 App Store 之间唯一的钥匙。常见做法是反向域名风格com.你的域名.项目名.功能名。ID 一旦创建就改不了删除后也不能重新占用所以命名要一次想清楚。大小写要统一我一般全用小写字母、数字和点号不做特殊符号——日志检索时特殊符号容易转义出问题服务端按 productID 做统计时也更干净。后台配置时把产品 ID、价格等级、本地化文案一次配齐。截图和审核备注的坑也在这个环节埋下自动续订订阅要额外提供订阅协议链接、隐私说明缺一个审核就会被打回。测试账号要单独建沙盒账号和普通账号是两套体系密码丢了只能重置不能找回。配完产品后先在 App Store Connect 里看一眼“产品状态”是不是“准备提交”不是的话客户端Product.products(for:)会返回空数组代码层面查不到任何错误提示这是 IAP 调试里最容易让人误判的起点。3. Swift 实现购买闭环用 StoreKit 2 写一个可复用的支付工具类产品模型定完开始碰代码。这一章给的是最小可运行方案一个PaymentManager持有商品缓存、发起购买、监听未完成交易同时把发货动作留给上层。这样做的原因是支付工具类越独立后续接退款通知、订阅状态同步时越容易改。如果购买逻辑散落在各个页面里等到要加服务端校验时你会发现自己要改十几个文件。3.1 最小闭环请求商品、发起购买、监听交易结果先看一个完整的购买工具类这段代码可以直接跑通沙盒环境下的“加载商品 → 发起购买 → 处理结果”全流程import StoreKit final class PaymentManager { static let shared PaymentManager() private var products: [String: Product] [:] // 从 App Store 拉取商品信息 func loadProducts(_ ids: [String]) async throws - [Product] { let storeProducts try await Product.products(for: ids) for p in storeProducts { products[p.id] p } return storeProducts } // 发起一次购买 func purchase(_ productID: String) async throws - PurchaseOutcome { guard let product products[productID] else { throw PaymentError.productNotFound } let result try await product.purchase() return try await handle(result) } // 校验购买结果 private func handle(_ result: Product.PurchaseResult) async throws - PurchaseOutcome { switch result { case .success(let verification): switch verification { case .verified(let transaction): // 验签通过这里才允许 finish 并通知上层 await transaction.finish() return .success(transaction.id) case .unverified(_, let error): // 签名校验失败丢弃并上报 throw PaymentError.invalidReceipt(error) } case .pending: // 等待确认典型场景是 Ask to Buy return .pending case .userCancelled: return .cancelled unknown default: throw PaymentError.unknown } } }代码的核心逻辑有三个地方。第一loadProducts用的是Product.products(for:)它一次可以拉多个产品 ID返回后用id建立索引。这个方法只返回“价格已在 App Store 后台配置好”的商品如果 ID 写错或后台没上线返回数组里就缺项不会抛错——这是新手最容易误判的点排查时先回后台确认产品状态。第二product.purchase()是 StoreKit 2 的 async 方法会直接带出系统购买弹窗传入的商品必须来自Product.products(for:)的返回值不能用Product(id:)拼一个假商品那样购买不会发起。第三handle方法里对Product.PurchaseResult做了分支.success里的VerificationResult再分两层verified代表签名和证书链校验通过这时finish交易并返回transaction.id给上层发货unverified代表 JWS 校验失败直接丢弃记账不能发货。上面的代码只处理了“购买时”的结果还有一类交易是用户上次购买后 App 被杀、交易没走完系统会在下次启动时重新推给你。处理它需要一个常驻的监听任务// 在 App 启动时调用一次进程存活期间不要销毁 func listenForTransactions() async { for await update in Transaction.updates { do { let transaction try await update.payloadValue // 走到这里说明签名校验通过 await transaction.finish() // 通知业务层这笔交易已完成请进行服务端确认 NotificationCenter.default.post( name: Notification.Name(iapTransactionFinished), object: transaction ) } catch { // 未通过校验的交易记录日志不发货 logger.error(未通过校验的更新: \(error)) } } }这段代码解决的是“交易丢失”问题。Transaction.updates是一个异步序列App 启动后要立刻开始遍历不能等用户点击购买时才注册。它会把历史未完成交易再次投递出来所以回调里收到交易后第一件事不是直接发货而是问服务端“这笔 transactionId 我处理过没有”。把这段监听放在AppDelegate或启动入口的最前面是避免丢单的基础。3.2 交易结果判定与错误映射把 Swift 错误变成用户看得懂的话购买失败不能总弹一个“支付失败”就完事。我一般把结果分成四类成功、用户取消、等待确认、验签失败。分不清pending和userCancelled就会遇到用户没付钱、后台却攒了一堆“待确认订单”的情况。判定方式用户文案备注verified成功“支付成功正在到账”进入发货流程pending“等待确认请留意系统提示”Ask to Buy、家长控制、延时到账userCancelled“你已取消支付”不做任何记录unverified“订单异常请稍后重试”记录日志并上报pending的典型来源是家庭共享里的 Ask to Buy购买请求会挂起等家长确认后才真正扣款。这个状态下用户已经看到弹窗但钱还没付客户端不发货是对的但要把状态保存下来等Transaction.updates后续推送确认结果。我见过不少团队把pending当成失败弹窗“支付失败”用户再点一次购买结果弹出两个待确认订单体验非常差。3.3 把 IAP 封装成工具类的边界透传 orderID、不要碰发货逻辑支付工具最容易写坏的边界是“什么都管”。我的习惯是PaymentManager只负责一件事——把交易做完并通知上层发货、库存、优惠券全部由业务层处理。具体流程是购买前先让业务层生成orderID通过applicationUsername参数传给苹果购买成功后把苹果返回的transactionID、productID、orderID一起交给服务端服务端校验通过后再触发发货。调用侧的写法应该是这样的let orderID OrderService.generate() let outcome try await PaymentManager.shared.purchase(com.demo.game.coins) switch outcome { case .success(let txID): // 把订单号和苹果交易号一起发给服务端 try await api.confirm(orderID: orderID, transactionID: txID) case .pending: show(等待确认完成后自动到账) case .cancelled: break }applicationUsername这个参数很多人忽略它可以在你还没有拿到系统回调时先把业务订单号和苹果的交易绑定在一起避免异步回调时出现“不知道是谁付的”的问题。服务端确认接口收到orderID和transactionID后要做匹配对不上就直接拒单。这个工具类保持无状态设计就够了产品缓存和监听任务用进程级单例不需要往里面塞数据库或发货队列否则后续每个业务方都要依赖一个臃肿的支付类。4. 服务端校验与订单对账别让客户端凭证成为唯一依据第 3 章的代码能跑通沙盒但离上线还差一步你拿什么证明“这笔交易真的发生了”客户端verified只说明 JWS 签名有效说明交易确实发生在 App Store它不保证你的服务端记住了这笔交易、别人没重放、也没退款。支付工具的可靠性不在客户端在服务端账本。这一章把客户端凭证、验签流程和服务端二次确认的关系讲清楚。4.1 本地校验为什么不可靠重放、退款与黑匣子本地校验有三类问题。第一是重放一个人把上次成功的凭证存下来换一个账号再次提交如果你的客户端只验签不记账他就白嫖了一次。第二是退款退款是苹果服务端主动推过来的事件客户端拿不到撤销通知用户的钱退了、你发的货却收不回来这就是支付里的“黑匣子”期。第三是越狱和逆向环境客户端代码可以被替换“信任客户端结果”等于把大门打开。所以.verified校验的是“签名是不是苹果签的”它不验证“这笔交易是不是该发货”。你仍然需要服务端理解transactionId、originalTransactionId、过期时间这些字段把它们落到订单状态里。一个完整的交易闭环应该是客户端拿到验签通过的凭证 → 上传服务端 → 服务端再用自己的渠道向 App Store 确认一次 → 确认通过才改订单状态。这样即使客户端被改订单状态也在服务端手里。4.2 拿到 JWS 后的验签流程payload 数据能信到什么程度Service 端拿到的是 JWS 格式的原始凭证。客户端要做的事很简单从Transaction里取出凭证原样上传不要自己解析、不要自己拼字段。case .verified(let transaction): // 客户端不自己解析 JWS直接把原始凭证透传给服务端 let jws transaction.payloadData try await api.verify(token: jws, orderID: orderID) await transaction.finish()JWS 的格式是header.payload.signature三段都是 Base64URL 编码。服务端要做的第一件事是用苹果的公钥验signature验签通过后再解payload读里面的transactionId、originalTransactionId、productId、purchaseDate、expiresDate。到这里payload 里的字段才能被信任。校验顺序不能反先校验签名再使用字段。忽略这个顺序攻击者改一个字段就能骗过业务逻辑服务端看到的价格和商品可能和实际交易不一致。4.3 服务端二次校验两条技术路线的取舍客户端上传凭证只是第一步服务端还要再向 App Store 发一次查询拿到的返回数据才是最终发货依据。这条技术路线现在有新旧两套选择时看你的系统阶段对比项verifyReceiptApp Store Server API接口定位整包 receipt 的批量校验单个交易与订阅状态的精确查询订阅状态需要自己解 latest_receipt_info结构化字段直接返回沙盒切换需要手动切换环境入口凭证自带环境标识当前状态仍可用但已标记弃用苹果当前推荐的新接法适用建议存量系统可继续用新系统直接走新 API服务端做二次确认的同时要建好幂等机制。交易记录用transactionId建唯一索引重复提交直接拒绝订阅产品要把originalTransactionId作为用户维度的身份它从第一次购买开始不变是续订链条的锚点。退款这种事没有后悔药所以发货前必须拿到服务端确认。我一般会在订单表里加一个verify_status字段客户端上传后置为待确认服务端向 App Store 查完再置为已确认整个链路在后台可追溯。5. 避坑实录沙盒、恢复购买与审核环节的三类翻车IAP 的坑不在代码量在环境和流程。这一章四条踩坑记录都来自真实调试现场每条都按“现象、原因、解决”写。这几条覆盖了大多数内购项目上线前会遇到的典型问题值得拿自己的代码逐条对照。5.1 沙盒账号的“免费”逻辑消耗型商品反复扣 0 元现象用沙盒账号连续购买消耗型商品每次都成功账本上全是 0 元交易金币数量越买越多测试人员以为系统有漏洞。 原因沙盒环境不会真实扣款它模拟的是一套完整支付流程所以交易可以无限重复余额也不减少。沙盒的用途是验证流程不是验证经济系统。 解决在代码里读transaction.environment沙盒环境.sandbox打上明显标记不进入真实报表。上线前的价格、余额逻辑要在 TestFlight 里跑那里虽然不是真实扣款但至少能走一遍真实的购买流程和后台对账。消耗型要反复测非消耗型在沙盒里还要单独验证恢复购买。5.2 Transaction.updates 在启动时也会回调初始化阶段别丢监听现象用户反馈“我昨天买的道具今天没了”查日志发现购买成功、发货成功但 App 被杀掉重装后发货记录丢了。 原因Transaction.updates不只处理“正在发生的交易”它还会把历史中未完成unfinished的交易再次投递出来。如果监听任务是在用户点击购买时才注册启动阶段的这部分交易就没人接。 解决把listenForTransactions()放在 App 启动流程里最先执行且保证进程存活期间不销毁。收到 update 后先查服务端是否已处理过这笔transactionId已处理就直接finish没处理再走发货链路。这个顺序是硬性的不能反过来先发货再查重。5.3 恢复购买与重复发货restore 不是第二笔交易现象用户换了新手机点了“恢复购买”结果后台出现两条发货记录实物重复发放。 原因恢复购买触发AppStore.sync()或老代码里的restoreCompletedTransactions后StoreKit 会把该用户所有可恢复交易重新回调一遍。很多实现者把这些回调当成“新交易”去发货没有做幂等。 解决给交易记录建唯一约束transactionId作为主键恢复流程只做“把历史权限同步给用户”不做扣款和新增发货。对已经发货过的商品恢复时要返回“已购买”状态而不是重新发货。这个坑在新手项目里出现频率极高核心就一句话恢复是同步不是购买。5.4 审核往返浪费的真实原因后台配置、订阅文案、测试账号现象审核被拒邮件里提到的原因和“购买流程”没关系反复沟通后才发现是后台产品信息没配全。 原因内购审核不只是跑一遍流程苹果还会核对产品类型、文案、链接和测试说明。自动续订订阅没提供协议链接、产品截图包含其他支付方式、测试账号没有说明都可能被打回。 解决提交审核前把后台的产品信息作为上线清单的一部分逐项检查。审核备注里写清楚“包含内购测试账号已提供购买了 XX 功能的解锁页面可见”。常见做法是准备一个沙盒测试账号把账号角色和密码写在备注里再配一条能走到购买弹窗的路径说明。这些信息看似琐碎实际上能省掉一到两轮审核往返。6. 进阶调试用 StoreKit 配置文件模拟订阅的完整生命周期这里给一个我常用的进阶技巧StoreKit 配置文件StoreKit Configuration File。它可以不通过真实网络就测试订阅的开启、续订、退款甚至把“用户在设置里关掉自动续订”这种场景都拉出来演练一遍。调试时先在 Xcode 的 Scheme 里指定这个配置文件App 跑起来后所有交易都走本地模拟不污染沙盒账号数据。代码里通过environment字段区分来源if let tx try await Transaction.latest(for: com.demo.membership) { switch tx.environment { case .xcode: // 来自本地 StoreKit 配置文件适合快速验证状态流转 print(本地模拟环境) case .sandbox: // 沙盒真实凭证验证服务端验签用 print(沙盒环境) case .appStore: // 生产环境绝不在此环境做假充值 print(生产环境) unknown default: break } }配置文件最有价值的能力是时间线。你可以给订阅设置一个极短的续订周期比如“1 分钟”然后在 Xcode 里快进时间观察Transaction.updates是否按预期推送续订事件。订阅续期、失效、退款通知都能在本地跑出一个完整闭环不用等真实的一天或一个月。我自己的习惯是每次动交易链路先在配置文件里把续订周期改成 1 分钟把边界场景全部过一遍再切回沙盒做服务端验签联调。这套流程帮我挡住过不止一次“订阅到期后没有解锁状态同步”的线上问题。IAP 的调试没有捷径但这些工具用熟后再回头看那些“玄学”问题大多只是环境判断写错了。希望帮到你。本文还有配套的精品资源点击获取