☰
Swift苹果内购支付工具:StoreKit 2选型、服务端校验与避坑指南
2026/9/26 20:11:50 网站建设 项目流程

简介:面向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(_:)回调通知
交易结果VerificationResult<Transaction>,自带验签状态SKPaymentTransaction,验签要自己做
权益查询Transaction.currentEntitlements异步遍历restoreCompletedTransactions恢复回调
外部变动监听Transaction.updates统一处理需要 delegate 区分多个回调场景
退款与订阅过期返回expirationDate与revocationDate等结构化字段需要解析原始 receipt
最低系统版本iOS 15+iOS 12+
Swift 使用体验原生 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 而不是 restoreCompletedTransactions

StoreKit 1 时代的恢复购买写法是SKPaymentQueue.default().restoreCompletedTransactions(),它会弹出一个密码输入框,体验很差。StoreKit 2 的恢复逻辑更简洁:遍历Transaction.currentEntitlements,把所有处于有效状态的交易找出来。

/// 恢复当前账号的购买权益 /// - Returns: 当前有效的商品 ID 集合 func restore() async -> Set<String> { var owned = Set<String>() 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 启动时要起一个长期监听 Task:

func 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(转字符串)和signedTransactionInfo(JWS 格式)一起 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, algorithm="ES256", headers=header) def verify_transaction(transaction_id: str, token: str) -> dict: url = f"https://api.storekit.itunes.apple.com/inApps/v1/transactions/{transaction_id}" resp = requests.get( url, headers={"Authorization": f"Bearer {token}"}, timeout=10, ) if resp.status_code != 200: # 常见错误:401 是 JWT 无效,404 是交易号不存在 raise RuntimeError(f"App 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交易唯一 ID;originalTransactionId原始交易 ID,订阅续订时所有续订事件都指向同一条原始交易;bundleId必须是你 App 的 bundle id;productId必须和你收到的订单商品一致;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 内购支付工具跑通后,我会在服务端加一个每日统计任务:对比「苹果侧有效交易数」和「我方发货数」,差值超过阈值就报警,因为这两者长期不一致意味着有退款漏回收或丢单风险。希望这些记录能帮你在接内购时少走弯路,一次把购买链路做结实。

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

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

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

立即咨询