简介一套使用Swift语言实现的苹果内购IAP支付工具示例工程面向需要接入应用内购买功能的iOS开发者覆盖消耗品、非消耗品与订阅服务三类商品的完整交易流程。资源为完整Xcode演示项目Trydemo共31个文件、压缩包约66KB其中9个swift源文件承担产品请求、支付队列监听与交易状态处理等核心逻辑plist与entitlements文件负责权限及收据配置storyboard用于界面布局另有m/h源文件、json配置及工程组织文件等。已有1702人学习下载。示例展示了StoreKit框架的标准接入路径从SKProductsRequest获取可售商品到SKPayment发起购买再到paymentQueue回调中完成交易状态判断同时给出恢复购买、收据验证、错误提示与订阅管理等功能的具体实现思路。代码结构直观适合初中级iOS开发者对照学习可直接复用ApplePayManager等核心类快速搭建自有项目的内购模块。1. 苹果内购支付工具Swift不是接个SDK那么简单做苹果内购支付工具Swift最难受的时刻不是代码写不出来而是 App 明明已经上架、用户在支付页输入了密码你的服务端却迟迟没有收到凭证用户在 App 里骂骂咧咧地点了退款。这个场景我遇到过不止一次后来才意识到内购从来不是“客户端调一下购买接口”就完事的东西它是一套涉及 App Store、客户端、服务端三端协作的异步交易链路。这篇笔记就把这套链路拆开讲从商品类型选型、StoreKit 2 的落地代码到服务端验票、沙盒测试和那些能让新手翻车的隐蔽坑位全部过一遍。适合正在准备接入内购的独立开发者也适合接手了内购模块但一直被掉单问题折磨的 iOS 工程师。2. 内购商品选型四种 IAP 类型你的业务适合哪一种2.1 消耗型与非消耗型一次性买断的两种玩法内购的商品类型直接决定了你会拿到什么样的交易回调也决定了后续对账逻辑怎么写。消耗型Consumable很好理解买一次用一次再次购买需要再次支付。典型场景是游戏币、解锁次数、一次性道具。它的特点是交易结束之后没有“恢复购买”的概念——用户换了新手机历史上的消耗型购买记录不会也不应该被恢复否则就等于免费送。非消耗型Non-Consumable则是“买断制”适合去除广告、解锁完整版这类一次购买终身享有的场景。它的核心特点是苹果要求你提供恢复购买Restore入口新设备上通过恢复操作把历史购买记录找回来。代码层面恢复购买本质上就是遍历这个用户在当前 App 的已完成交易列表把买过的非消耗型商品分别给用户补上权益。选型时最常见的问题是把“一次性会员”做成非消耗型。这里要理解非消耗型没有有效期概念它只有“已买/未买”两种状态。服务端如果要给用户标一个“会员到期时间”非消耗型就满足不了。所以我一般给团队定的规则是只要业务上有到期时间这个概念就老老实实用订阅不要用非消耗型加服务端时间戳去模拟。2.2 自动续订订阅最复杂但最值得做的商品形态自动续订订阅Auto-Renewable Subscription是苹果内购里商业价值最高、但技术复杂度也最高的类型。它好在哪里用户一旦订阅系统会在每个周期结束时自动从用户的 App Store 账户扣款你不需要做任何提醒只要把订阅状态维护好就行。对做内容类、工具类产品的团队来说这是唯一能形成稳定现金流的内购形态。状态机的复杂度体现在时间维度上。一个订阅在苹果那边是一个处于不断变化的状态对象正常生效中、宽限期、计费重试中、已过期、用户主动取消但还未到期。客户端需要拿到的关键信息是expiresDate也就是当前订阅周期的到期时间。这里有个新手非常容易犯的错误用户取消了订阅不代表他的会员立刻失效苹果会让他继续用到当前周期结束。所以服务端判断用户是否有权益绝对不能看他是不是“已订阅”状态而是要看expiresDate有没有超过当前时间。用表格快速对比能帮你在选型阶段少走一些弯路商品类型是否可恢复是否带到期时间典型场景服务端职责消耗型否否游戏币、次数发货加币、加次数非消耗型是否去广告、解锁版记录已购买自动续订订阅是是会员、内容订阅维护有效期状态机非续订订阅否是老式订阅尽量不用2.3 非续订订阅我为什么不推荐非续订订阅Non-Renewing Subscription算是苹果内购里的历史遗留物。它的商业逻辑是你卖一个“有效期 N 天”的订阅到期后系统不会自动扣费需要用户手动重新购买。听着挺灵活但这里有个致命伤苹果不提供跨设备恢复能力也不保证交易记录能准确定位到每一个过期周期。用户换个设备你的服务端如果没有完整的用户体系把购买记录同步过去他就得再买一次投诉率极高。新项目我一般建议直接放弃这个类型如果确实想要“到期后不自动续费”的体验不如做成消耗型一次性解锁再由服务端控制到期时间。3. 用 StoreKit 2 把内购跑通从商品加载到交易回调3.1 最小可运行代码商品加载与发起购买从 iOS 15 开始苹果提供了全新的 StoreKit 2 框架API 设计比老版本 StoreKit 1 干净得多调用方式也改成了 Swift 原生风格的异步函数。下面这段代码是完整的最小闭环加载商品、发起购买、处理购买结果。import StoreKit // 商品 ID 列表必须在 App Store Connect 后台配置好 let productIDs: SetString [com.yourapp.subscription.monthly] /// 从 App Store 加载商品信息 /// - Returns: 可购买的 Product 数组空的说明商品 ID 配置有问题 func loadProducts() async throws - [Product] { let products try await Product.products(for: productIDs) for product in products { // displayPrice 已经是本地化后的价格字符串不需要自己做货币换算 print(加载到商品: \(product.id), 价格: \(product.displayPrice)) } return products } /// 发起购买 /// - Parameter product: 从 loadProducts 拿到的商品对象 func startPurchase(_ product: Product) async { // purchase() 会弹出系统支付页面必须从主线程调用 let result try? await product.purchase() switch result { case .success(let verification): // 拿到交易结果后先校验 Apple 的签名再处理业务逻辑 let transaction try? checkVerified(verification) if let transaction { print(购买成功交易 ID: \(transaction.id)) // 关键步骤将交易原始凭证转成字符串发给服务端做二次验证 let jwtData try? JSONEncoder().encode(transaction) // 通过你自己的网络层把 jwtData 发给服务端 await transaction.finish() } case .pending: // 支付在等待外部确认比如未成年用户的 Ask to Buy 场景 print(交易等待确认暂时不用发货) case .userCancelled: print(用户取消了支付不需要做任何业务处理) case .none: print(未返回有效结果) unknown default: break } }这段代码里有两个细节值得强调。第一product.purchase()返回的VerificationResult是 StoreKit 2 自动做过的签名校验结果你可以信任它的通过状态但服务端仍然要基于原始凭证做二次验证原因后面会单独讲。第二transaction.finish()必须在服务端确认收到凭证之后再调用否则交易会一直挂在待完成队列里下次启动 App 又会重复分发。3.2 校验交易结果Client 端验证逻辑怎么写上一节代码里的checkVerified是苹果推荐的校验方式它会自动验证 JWS 签名的有效性防止中间人篡改商品信息。完整写法如下/// 校验 App Store 返回的签名是否有效 /// - Parameter result: purchase 返回的原始验证结果 /// - Returns: 校验通过的 Transaction func checkVerifiedT(_ result: VerificationResultT) throws - T { switch result { case .unverified: // 签名校验失败此时绝不能给用户发货 throw StoreKitError.verificationFailed case .verified(let signedObject): // 签名合法返回业务数据 return signedObject } }注意unverified的情况。开发阶段很多人不理会这个分支但生产环境里如果用户越狱环境篡改了内购回调数据或者网络做了中间人代理这里是第一道防线。内购的凭证和支付流程是安全的但客户端到你自己服务端的那段网络未必安全。客户端校验只解决“App Store 确实返回了这笔交易”至于这笔交易会不会被恶意重复提交给服务端换多次发货就得靠服务端了。3.3 监听交易更新Transaction.updates 的完整用法新手最容易漏掉的部分是交易监听。用户在支付页付款成功后返回值正常处理了但很多场景下交易不是从当前purchase()调用直接产生的——比如用户在一个设备上购买在另一个设备上恢复或者交易因为网络原因已经成功但客户端没收到回调。这时候必须靠Transaction.updates这个异步序列来处理补发。// 在 App 启动时挂一个监听处理异步到达的交易回调 func listenForTransactions() async { for await update in Transaction.updates { guard let transaction try? checkVerified(update) else { print(收到未通过校验的交易丢弃) continue } // 把交易凭证发给服务端进行发货校验 await sendTransactionToServer(transaction) // 服务端确认入库后再调用 finish await transaction.finish() } }这个监听器必须在 App 启动早期就挂上并且在 App 整个生命周期内保持存活。常见做法是在 AppDelegate 或 SwiftUI App 的init里启动一个长期任务。很多掉单问题的根源都在这里product.purchase()返回成功后处理了但假设此时用户切到后台网络抖动导致你的发货请求失败交易一直没finish()下次启动 App 时Transaction.updates会再次分发这笔交易如果你没有监听这笔订单就永久丢失了。4. 服务端票据校验客户端显示“支付成功”不算成功4.1 本地校验与服务端校验的边界客户端收到success只是苹果支付链路确认了这笔钱扣走了但你的业务发货是否完成必须由服务端说了算。这里有个经典的信任模型问题客户端是用户能改动代码的环境你不能信任客户端告诉你“我支付成功了”这句话。所以服务端必须拿原始凭证向苹果的服务器验证这笔交易是否真实、是否未使用过。交易凭证有两条验证路径。老一代做法是拿transaction.jwsRepresentation发送到苹果旧接口换 JSON 响应新一代推荐做法是直接使用 App Store Server API你的服务端通过 Apple 提供的密钥认证后用自己的订单号去苹果的服务器查询最新状态。我接过的项目里很多老代码还在用旧接口能用但依赖较重。新项目我一般建议用 Server API把验票逻辑收敛成服务端一个标准函数。4.2 用 JWS 在服务端验票的完整流程JWS 格式的凭证本质上是一段三段式的 Base64URL 字符串Header、Payload、Signature。Payload 里直接包含了最重要的交易字段服务端只需要验证签名归属苹果再读字段做业务判断即可。下面是服务端解析的核心流程用 Swift Vapor 写的伪代码段方便你理解字段结构// 服务端伪代码用于校验 JWS 交易凭证 func verifyTransaction(jws: String, expectedProductId: String) async throws - Bool { // 第一步拆解 JWS 三段 let parts jws.split(separator: .) guard parts.count 3 else { return false } // 第二步解码 Payload 段得到交易数据 let payloadData base64URLDecode(String(parts[1])) let payload try JSONDecoder().decode(TransactionPayload.self, from: payloadData) // 第三步校验关键字段 // 1. productId 是否匹配 guard payload.productId expectedProductId else { return false } // 2. 交易日期是否在合理范围内防止重放攻击 guard Date().timeIntervalSince(payload.purchaseDate) 3600 * 24 else { return false } // 3. 订阅项目需要确认 expiresDate 是否在未来 if let expiresDate payload.expiresDate { guard expiresDate Date() else { return false } } // 第四步处理签名验证具体验证需使用 Apple 根证书做 RSA 验签 // 签名验证通过后入库 mark order delivered return true } // Payload 里的标准字段按 Apple 文档定义解码 struct TransactionPayload: Codable { let transactionId: String let originalTransactionId: String let productId: String let purchaseDate: Date let expiresDate: Date? let isUpgraded: Bool? }服务端验证的核心是四个动作验签名、查商品 ID、查交易时间、查订单状态。验签名这一步很多团队偷懒只解了 Payload 里的字段就当验证过了这是非常危险的做法——JWS 的 Payload 部分是明文编码谁都能伪造只有通过 Apple 证书做 RSA 签名校验才能确认这段数据确实是 App Store 签发的。自己实现 RSA 验签工作量不小常见的做法是直接用苹果的 Server Library SDK或者在后端找已封装好的验证中间件。4.3 订阅状态同步服务端必须能主动更新用户权益订阅不是一次性交易expiresDate之后如果用户没有取消并且扣款成功苹果会生成一个新的续订交易renewal transaction但这个新交易不会通过Transaction.updates推给你的客户端——用户此时可能根本没打开 App。要保证用户权益无缝延续服务端必须有两种手段拿到续订状态一种是通过 App Store Server API 定时查询originalTransactionId对应的最新订阅状态另一种是配置订阅状态回调通知Server Notifications苹果会在扣款成功、宽限期结束、用户退款时主动向你的服务端推送事件。这块做过订阅的团队应该深有体会客户端负责打开支付页和收首期交易服务端负责长期维护订阅状态。两端的职责必须分清楚否则就会出现“用户上个月订阅的会员这个月都忘记取消扣款了App 里还显示权益已过期”这种离奇事故。5. 内购避坑指南掉单、回调时机与沙盒环境那些破事5.1 交易成功但发货失败服务端没收到回调现象用户在支付页扣了款客户端也弹了“支付成功”但服务端对应的发货接口没日志用户没有拿到会员权益。原因客户端在拿到交易后直接调用了transaction.finish()还没等服务端把发货请求发出去这笔交易的消息就被标记成已消费后续网络重试也不知道该补发什么。解决把finish()的调用时机严格放到服务端确认入库之后。客户端可以先在本地暂存这笔交易的transactionId等服务端返回“已发货”再执行finish()。如果服务端请求失败保留这个 ID下次启动通过Transaction.updates再次补发。5.2 沙盒环境里订阅一分钟就到期现象在 TestFlight 或 Xcode 沙盒环境里测试自动续订发现订阅时长根本不是一个自然月几分钟就过期了。原因沙盒订阅的续订节奏是苹果故意压缩的用来模拟完整的订阅周期。一个月订阅在沙盒里可能只要 5 分钟走完一个周期一年订阅大概是 1 小时一个周期。这是正常行为不是程序 Bug。解决测试时不要用真实自然时间来判断订阅是否过期要看expiresDate的值是否按预期变化。另外可以在 Xcode 的 StoreKit 配置文件里直接把续订间隔调成自定义秒数方便针对宽限期、计费重试等场景做局部测试。5.3 权益在退款后仍然发放现象用户购买后发起退款苹果扣了钱又退回给他但你的 App 里会员权益还在正常使用。原因退款事件不会实时同步到客户端苹果通过订阅状态通知或 App Store Server API 下发退款事件你的服务端没有及时处理 REFUND 类型的通知。解决服务端必须监听 REFUND、REVOKE 类型的订阅回调收到后立刻吊销对应用户的权益。如果产品是消耗型商品退款后还要做风控判断必要时封禁或扣回虚拟币。5.4 恢复购买时重复发货现象用户点“恢复购买”客户端收到了一堆历史交易全部当成新交易送给服务端服务端没做重复判断把用户会员有效期延长了好几次。原因恢复购买会把该 Apple ID 下所有已完成交易都通过Transaction.currentEntitlements序列推给客户端此时会根据每一笔交易都生成一条发货请求。如果服务端发货逻辑是按“加时长”而不是按“设置到期时间为最新”就会重复叠加。解决处理恢复交易时不要累加时长而是取所有有效交易里expiresDate最大的那个值覆盖设置用户权益到期时间。5.5 测试员在沙盒环境却验证到线上票据现象内购测试时客户端传过去的凭证在服务端验票时偶尔成功偶尔失败环境判断混乱日志里一会儿显示沙盒一会儿显示生产。原因苹果的旧验票接口要求如果在生产环境 URL 收到沙盒凭证需要先用生产环境验一次失败后回退到沙盒环境再验一次。很多团队没做这个回退逻辑直接一刀切导致一部分凭证验证失败。解决如果你用旧接口验票逻辑必须做“生产 → 沙盒”顺序回退如果用新的 App Store Server API凭证自带environment字段直接区分处理即可。上线前务必确认测试账号和正式账号的凭证不会混传到服务端。6. 本地调试与进阶技巧用 StoreKit 配置文件把支付场景做透Xcode 自带了一个不起眼但非常好用的工具StoreKit Configuration File.storekit文件。在 Xcode 菜单里通过 File New 创建这个文件就能在本地模拟商品列表、订阅续期节奏、退款行为完全不用走沙盒环境。你可以在文件里直接指定“订阅 30 分钟后过期”“第一次购买失败”这类异常场景点击 Run 时选择这个配置文件作为 Scheme 的 StoreKit Configuration本地调试体验会提升一大截。我日常习惯是配三个场景正常首购、到期续订、宽限期进入把三个场景分别做成自动化测试用例跑一遍就能确认客户端的状态机没有漏算时间。另一个值得投入的技巧是把订阅状态的变更日志打完整。内购的坑大多出在状态变化上我会在客户端每次拿到交易、每次都解开expiresDate时打一行包含transactionId、productId、expiresDate的日志服务端每次同步订阅状态时同样记录originalTransactionId和状态变化前后值。这样一来线上如果出现用户投诉权益异常就能像查水电表一样把整条链路的时间线拉出来快速定位是客户端没送凭证、服务端没收到回调还是验票逻辑出了错。这个习惯帮我解决过至少三次线上问题每次排查时间都控制在半小时内。最后做内购一定要记住客户端代码写得再稳妥也只是整条链路的前半段真正保证用户钱货两清的那部分全在服务端和对账策略里。以后每次有人说“内购很简单加个 SDK 就行”我都会建议他把订阅状态机画出来再说话。希望这篇笔记能帮到你少走一些我当年踩过的弯路。本文还有配套的精品资源点击获取