
简介面向iOS应用开发者的Swift内购支付工具示例聚焦App Store内购流程的落地实现涵盖StoreKit导入、商品请求与响应、支付队列观察、交易状态处理、恢复购买及基础收据验证思路支持消耗品、非消耗品与订阅等多类商品场景适合需要快速搭建内购模块或对照排查交易问题的中初级开发者参考。压缩包共31个文件以9个Swift源码、5个plist配置、3个Objective-C头文件及JSON等辅助文件为主另有storyboard界面与Xcode工程配置整体约66KB结构完整便于直接打开工程梳理调用关系。已有1704人学习下载。借助工程内的管理器封装、购买与恢复流程示例读者可对照理解商品配置、支付回调与异常处理的关键环节并据此调整业务逻辑提升内购功能的稳定性与合规性。1. iOS 内购支付工具一个把虚拟商品收款链路跑通的 Swift 工程上个月一位做工具类 App 的朋友来找我说上线三周后台订单流水和商店后台的数据差了七十多单用户钱付了、会员没到账客诉堆成山。问题几乎都出在同一处iOS 内购支付的交易监听没做全finish()调早了或者干脆忘了调。这份 Swift 写的内购支付工具解决的正是这件事——它把商品拉取、购买发起、JWS 交易校验、订单写入本地存储、掉单补发这几段串成一条完整链路客户端和服务端都能直接接着改。它适合两类人。一类是第一次接内购、被VerificationResult和沙盒环境绕晕的 iOS 开发照着把配置文件挂上就能在模拟器里跑通全流程另一类是手里已经有个能跑但不敢上线的支付模块、想找份结构清晰的参考实现对着重构的熟手。整套代码走 StoreKit 2 的 async/await 写法没有老式SKPaymentQueue的回调嵌套交易状态全部收敛到一处排查翻车时不用再翻五个文件找监听器。2. 工程骨架与技术选型StoreKit 2 下的五层拆分2.1 目录结构商品、交易、订单、网络各归各位拿到一个支付项目我第一眼看的不是购买那段代码而是目录。内购逻辑很容易写成一坨商品 ID、购买、校验、发货、UI 刷新全塞在 ViewController 里三个月后自己都不敢动。这套工程按职责切成了五个目录互相之间只通过明确的类型通信。IAPKit/ ├── App/ │ ├── AppEntry.swift // 启动入口负责初始化监听 │ └── StoreKitConfig.storekit // 本地测试用的商品配置 ├── Sources/ │ ├── Catalog/ │ │ ├── ProductID.swift // 商品 ID 的唯一来源 │ │ └── ProductCatalog.swift // 商品拉取与缓存 │ ├── Purchase/ │ │ ├── PurchaseService.swift // 发起购买、处理结果 │ │ ├── PurchaseOutcome.swift // 购买结果的业务语义 │ │ └── TransactionListener.swift // 常驻的交易监听 │ ├── Order/ │ │ ├── OrderStore.swift // 订单持久化 │ │ ├── OrderRecord.swift // 订单模型 │ │ └── OrderState.swift // 状态枚举与迁移规则 │ ├── Verify/ │ │ ├── JWSDecoder.swift // 客户端解析 JWS 载荷 │ │ └── ServerVerifyClient.swift // 服务端二次校验 │ └── Support/ │ ├── IAPError.swift │ └── Logger.swift └── Tests/ └── OrderStateTests.swiftCatalog只负责「有哪些商品」Purchase只管「把交易拿回来」Order负责「这笔交易在本地意味着什么」Verify负责「服务端认不认」。这样切的直接好处是换后端接口只动Verify加商品只动ProductID和配置文件改发货逻辑只动Order三件事互不牵连。血泪经验是很多团队把「发货」写进购买回调里结果用户点一次购买、系统回放一次未完成交易就发两次货后来补数据补到凌晨。2.2 为什么用 StoreKit 2 而不是 SKPaymentQueue老项目里最常见的写法是SKPaymentQueue.default().add(observer)然后在paymentQueue(_:updatedTransactions:)里 switch 一堆SKPaymentTransactionState。这套东西不是不能用但它有三个绕不开的麻烦回调在非主线程、交易状态需要自己维护、未完成交易在每次启动时全量回放稍不注意就是重复发货。StoreKit 2 把这套状态机收进了框架内部Product.purchase()直接返回一个带验证结果的结构Transaction.updates是一个持续吐交易的异步序列。对开发者来说最实质的变化是「验证」从「拿收据去服务端解析」变成了「框架先给你一个VerificationResult」签名不合法的那部分在类型层面就被标出来了。维度SKPaymentQueue旧StoreKit 2本工程最低系统iOS 3iOS 15回调形式代理方法非主线程async/await可指定 actor交易验证拿收据字符串去服务端VerificationResult先行过滤未完成交易手动遍历queueTransaction.unfinished异步序列订阅状态收据里自己算Product.SubscriptionInfo.Status测试只能沙盒真机配置文件可在模拟器全流程跑选型的判断标准其实很简单新项目、最低支持版本能到 iOS 15就用 StoreKit 2要兼容 iOS 14 及以下只能留在旧 API但那时也建议把验证和发货逻辑抽出来别跟回调绑死。2.3 版本、依赖与初始化时机工程本身零第三方依赖纯系统框架最低 iOS 15.0、Swift 5.9、Xcode 15 以上。之所以卡在 15.0是因为 StoreKit 2 的Product、Transaction这一整套类型就是随 iOS 15 落地的如果你的项目要上 iOS 17 的新特性比如用Observation做状态订阅把Observable换掉ObservableObject就行支付层不用动。初始化的时机比很多人想的重要。交易监听必须在 App 启动后尽早挂上而不是等用户点进会员页才挂否则冷启动期间系统回放的未完成交易会漏掉。import SwiftUI main struct AppEntry: App { StateObject private var orderStore OrderStore() State private var listenerTask: TaskVoid, Never? var body: some Scene { WindowGroup { ContentView() .environmentObject(orderStore) .task { // 启动即挂监听早于任何 UI 交互 listenerTask TransactionListener.shared.start(orderStore: orderStore) } } } }Task挂在根视图的.task修饰符上生命周期跟随视图TransactionListener内部是单例保证重复进入根视图不会起第二个监听循环。这里的listenerTask只是持有引用方便在登出账号时cancel()掉避免不同账号的订单串在一起——这个坑后面第 5 章会细讲。2.4 商品 ID 的单一事实来源商品 ID 散落在各处是另一个高频翻车点。UI 里写一遍、购买时写一遍、服务端配置里再写一遍任何一个字符写错Product.products(for:)就静默返回空数组不报错、不提示你只能对着日志怀疑人生。import Foundation /// 所有可售商品 ID 的唯一来源与商店后台配置逐字一致 enum ProductID: String, CaseIterable { case coin60 com.example.iap.coin60 // 消耗型60 金币 case coin300 com.example.iap.coin300 // 消耗型300 金币 case vipMonthly com.example.iap.vip.monthly // 自动续期订阅月卡 case vipYearly com.example.iap.vip.yearly // 自动续期订阅年卡 /// 订阅类商品单独归组恢复购买时只查这些 static var subscriptionIDs: [String] { [vipMonthly.rawValue, vipYearly.rawValue] } /// 一次性商品恢复购买时不应该补发 static var consumableIDs: [String] { [coin60.rawValue, coin300.rawValue] } }把 ID 收进枚举有两个好处编译期就能发现拼写错误CaseIterable让「拉全部商品」变成一行ProductID.allCases.map(\.rawValue)。注意rawValue必须和商店后台填的标识符完全一致大小写敏感。我一般会在 CI 里加个脚本把这份枚举和后台导出的商品清单做一次比对对不上就构建失败——听起来重但比上线后用户点购买没反应强得多。3. 核心链路拆解一次购买从发起到落库经过哪几段3.1 拉取商品Product.products 的批量与容错商品列表只拉一次拉完缓存别在每次进页面时都请求。Product.products(for:)内部有网络开销而且它有个特性传入 10 个 ID只要其中 1 个在后台不存在或者状态异常返回的数组里就可能少这一项其余正常返回不抛错。import StoreKit /// 商品目录actor 保证缓存读写安全 actor ProductCatalog { private var cache: [String: Product] [:] /// 批量拉取返回成功拿到的商品缺失的单独记录 func load(ids: [String]) async throws - (products: [Product], missing: [String]) { let fetched try await Product.products(for: ids) for product in fetched { cache[product.id] product } let fetchedIDs Set(fetched.map(\.id)) let missing ids.filter { !fetchedIDs.contains($0) } if !missing.isEmpty { Logger.warn(商品缺失: \(missing.joined(separator: , ))) } return (fetched, missing) } /// 按 ID 取缓存未命中返回 nil func product(for id: String) - Product? { cache[id] } }返回的是元组把「拿到的」和「没拿到的」分开。missing不是用来抛错的是用来打日志和上报的——线上环境里商品缺失通常意味着后台配置没生效或者税务协议没签这两件事都不是客户端能修的但你需要知道。缓存用actor而不是普通字典是因为Product的读取可能来自多个并发任务列表页、详情页、恢复购买不加隔离迟早出数据竞争。3.2 发起购买purchase 的四种返回结果怎么处理product.purchase()的返回值是个枚举看着简单但每个分支的处理方式不同尤其是.pending和.userCancelled很多人直接忽略结果就是「用户点了取消界面一直转圈」。import StoreKit /// 购买结果转成业务侧能直接用的语义 enum PurchaseOutcome { case finished(Transaction) // 交易成功并已验证 case pending // 待批准家长控制、银行延迟等 case cancelled // 用户主动取消 case unverified(Error) // 签名验证失败不可发货 } MainActor final class PurchaseService { private let verifyClient: ServerVerifyClient private let orderStore: OrderStore init(verifyClient: ServerVerifyClient, orderStore: OrderStore) { self.verifyClient verifyClient self.orderStore orderStore } func purchase(_ product: Product) async throws - PurchaseOutcome { let result try await product.purchase() switch result { case .success(let verification): switch verification { case .verified(let transaction): // 先落单再调 finish顺序不能反 try await orderStore.insertPending(from: transaction) try await verifyClient.submit(transaction) await transaction.finish() return .finished(transaction) case .unverified(_, let error): // 签名不可信绝不能发货也不 finish留给系统重试 Logger.error(交易验证失败: \(error)) return .unverified(error) } case .pending: // 交易挂起等 Transaction.updates 回调这里只更新 UI return .pending case .userCancelled: return .cancelled unknown default: return .cancelled } } }逐段说。.success里包着的是VerificationResultTransaction必须再解一层只有.verified分支里的Transaction才是签名可信的.unverified那条路无论如何都不能发货也不该调finish()留着让系统下次重放。insertPending在finish()之前执行是为了保证「先记账、后销账」——万一finish()之后进程被杀本地还有一条 pending 记录下次启动能补上。.pending对应的是需要家长批准或者支付渠道延迟的场景这时候交易还没结束真正的结果会从Transaction.updates出来所以购买页只该把按钮变成「待确认」不要显示成功。unknown default是 Swift 对非冻结枚举的强制要求别忘了写。3.3 交易验证VerificationResult 只在 verified 分支往下走VerificationResult这个类型是整个 StoreKit 2 里设计得最漂亮的一处它在类型层面把「已验证」和「未验证」分开了你没法在不拆包的情况下拿到Transaction。但它验证的是签名链不是「这笔交易该不该发货」——后者是服务端的事。import Foundation /// 客户端解析 JWS 载荷仅用于展示与对账不作为发货依据 struct JWSDecoder { /// 取出 JWS 中间那段 payload 并做 base64url 解码 static func payload(from jws: String) throws - [String: Any] { let parts jws.split(separator: .) guard parts.count 3 else { throw IAPError.malformedJWS } var base64 String(parts[1]) .replacingOccurrences(of: -, with: ) .replacingOccurrences(of: _, with: /) // base64url 去掉了填充符解码前必须补回来 while base64.count % 4 ! 0 { base64.append() } guard let data Data(base64Encoded: base64), let json try JSONSerialization.jsonObject(with: data) as? [String: Any] else { throw IAPError.malformedJWS } return json } }两个细节必须踩准。第一JWS 用的是 base64url-和_分别对应标准 base64 的和/直接Data(base64Encoded:)会返回 nil。第二base64url 通常省略了末尾的填充长度不是 4 的倍数时必须补齐否则解码同样失败。很多团队在这里翻车日志里只看到「解析失败」查了半天以为是签名问题。这段解析出来的字段transactionId、productId、purchaseDate、expiresDate可以用来做客户端展示和日志对账但绝不能作为发货依据——签名校验的信任根在服务端。3.4 交易监听与掉单补发Transaction.updates 必须常驻这是整个工程里最关键的一段。Transaction.updates是一个异步序列它会在三种情况下吐交易购买完成、系统重放未完成交易、以及跨设备同步过来的交易比如用户在另一台设备上买的订阅。import StoreKit /// 常驻交易监听App 存活期间不应停止 final class TransactionListener { static let shared TransactionListener() private var task: TaskVoid, Never? func start(orderStore: OrderStore) - TaskVoid, Never { // 先补历史未完成交易再进长监听顺序不能反 let task Task.detached(priority: .background) { for await result in Transaction.unfinished { await Self.handle(result, orderStore: orderStore) } for await result in Transaction.updates { await Self.handle(result, orderStore: orderStore) } } self.task task return task } private static func handle( _ result: VerificationResultTransaction, orderStore: OrderStore ) async { guard case .verified(let transaction) result else { Logger.error(监听到未验证交易跳过) return } // 幂等已完成的订单不重复发货 guard await !orderStore.isFinished(transaction.id) else { await transaction.finish() return } do { try await orderStore.insertPending(from: transaction) try await ServerVerifyClient.shared.submit(transaction) await transaction.finish() } catch { // 失败不 finish等下一轮系统重放 Logger.error(补单失败: \(error)) } } }先说顺序。先遍历Transaction.unfinished再进Transaction.updates是为了把「上次启动到这次启动之间」积压的交易先清掉再进入持续监听否则会有一小段空窗期。Task.detached用的是后台优先级因为这个循环基本不占 CPU但会一直活着。再说幂等。orderStore.isFinished(transaction.id)这个判断不能省。系统重放是常态用户换机、重装、网络恢复都会触发没有幂等键就意味着重复发货。transaction.id是这笔交易的唯一标识拿它做去重是最稳的。最后说失败处理。整个do块里任何一步抛错都不调finish()。这不是疏忽是设计finish()一旦调用系统就认为这笔交易处理完了再也不会重放。宁可让交易在系统里挂几天也不能在服务端没确认的情况下销账。3.5 服务端二次校验JWS 透传与幂等落单客户端信任的是「签名有效」服务端要判断的是「这笔交易该不该给权益」。这两件事必须分开。import Foundation /// 服务端校验客户端负责把交易透传给业务后端 final class ServerVerifyClient { static let shared ServerVerifyClient() private let endpoint URL(string: https://api.example.com/iap/verify)! /// 提交交易服务端负责验签、幂等、发货 func submit(_ transaction: Transaction) async throws { var request URLRequest(url: endpoint) request.httpMethod POST request.setValue(application/json, forHTTPHeaderField: Content-Type) // signedTransactionInfo 是完整的 JWS原样透传客户端不参与验签 let body: [String: Any] [ signedTransactionInfo: transaction.jsonRepresentation.base64EncodedString(), transactionId: String(transaction.id), productId: transaction.productID ] request.httpBody try JSONSerialization.data(withJSONObject: body) let (data, response) try await URLSession.shared.data(for: request) guard let http response as? HTTPURLResponse, (200..300).contains(http.statusCode) else { throw IAPError.serverRejected } // 服务端返回的 grant 决定是否真的发货 let payload try JSONDecoder().decode(VerifyResponse.self, from: data) guard payload.granted else { throw IAPError.notGranted } } } struct VerifyResponse: Decodable { let granted: Bool let orderId: String }transaction.jsonRepresentation拿到的是Transaction对应的 JWS 字符串服务端用它验签。客户端把transactionId单独带一份是为了服务端做幂等键时有稳定的主键可用——JWS 里的字段名是transactionId注意大小写和编码格式服务端解析出来对不上会导致幂等失效进而重复发货。granted这个字段是刻意的服务端有最终决定权。有些情况下交易签名有效但业务侧判断不该发货比如同一个 Apple ID 在多个游戏角色上重复绑定这时候返回granted: false客户端不finish()交易留着等人工处理。4. 订单状态机与订阅兜底续订、退款、恢复购买怎么处理4.1 本地订单表五个状态和两条合法迁移订单状态不做成状态机后患无穷。工程里定义了五个状态只允许特定方向迁移非法迁移直接抛错。import Foundation /// 订单状态迁移方向固定不允许回退 enum OrderState: String, Codable { case pending // 交易已拿到尚未完成服务端校验 case verified // 服务端确认权益已发放 case finished // 已调用 finish流程终结 case refunded // 已退款权益需回收 case failed // 校验多次失败进入人工处理 } struct OrderRecord: Codable, Identifiable { let id: String // 即 transaction.id let productId: String let purchaseDate: Date var state: OrderState var retryCount: Int } extension OrderState { /// 合法迁移表其余组合一律拒绝 var allowedNext: SetOrderState { switch self { case .pending: return [.verified, .failed, .refunded] case .verified: return [.finished, .refunded] case .failed: return [.verified, .refunded] case .finished: return [.refunded] case .refunded: return [] } } func canTransit(to next: OrderState) - Bool { allowedNext.contains(next) } }pending → verified → finished是主干路径。verified → refunded是事后退款finished → refunded同理退款可以在任何阶段发生包括发货很久之后。pending → failed是重试耗尽后的兜底进入人工工单。refunded是终态没有再往外的迁移——被退款的订单永远不能再被当成有效订单处理。把迁移表写成Set而不是if-else链好处是测试好写。OrderStateTests里直接遍历所有状态组合验证非法组合都被拒绝一次覆盖十几条规则。4.2 订阅状态SubscriptionInfo.Status 与 currentEntitlements消耗型商品处理完就结束了订阅不一样它会自动续期也会过期、进入宽限期、进入重试期。判断用户当前有没有权益靠的不是订单表而是Transaction.currentEntitlements。import StoreKit /// 权益查询只读不产生副作用 struct EntitlementChecker { /// 判断用户当前是否持有某个订阅 static func isSubscribed(to productID: String) async - Bool { for await result in Transaction.currentEntitlements { guard case .verified(let transaction) result else { continue } guard transaction.productID productID else { continue } // 被撤销的交易不算有效权益 if transaction.revocationDate ! nil { continue } // 有过期时间的必须还没过期 if let expiry transaction.expirationDate, expiry Date() { continue } return true } return false } /// 读取订阅的具体状态用于展示「即将到期」「扣款失败」等提示 static func subscriptionStatus(of product: Product) async - Product.SubscriptionInfo.Status? { guard let subscription product.subscription else { return nil } let statuses try? await subscription.status return statuses?.first } }currentEntitlements吐的是「当前有效的」交易框架已经过滤掉了过期和撤销的但工程里还是显式判断了revocationDate和expirationDate原因是这两个字段在边界情况下会有延迟——尤其在本地配置文件里模拟退款时过期判断最靠谱。subscription.status返回的state有五种正常订阅、已过期、账单重试中、宽限期、已撤销。宽限期和重试期这两个状态最有业务价值宽限期里用户还能用重试期里不能用但还有机会挽回。UI 上对应「会员即将到期请更新支付方式」这类提示比一句冷冰冰的「已过期」体验好得多。4.3 服务端通知 V2续订失败和退款的异步兜底客户端不可能常驻用户可能几个月不打开 App。订阅扣款失败、退款、撤销这些事件必须靠服务端的异步通知来兜。// 服务端伪代码处理商店异步通知 func handleNotification(_ body: NotificationBody) error { // 1. 先验签未通过直接丢弃 payload, err : verifyJWS(body.signedPayload) if err ! nil { return err } // 2. 用通知里的 notificationUUID 做幂等重复投递直接返回 200 if seen(payload.NotificationUUID) { return nil } switch payload.NotificationType { case DID_RENEW: // 续订成功延长权益 return extendEntitlement(payload) case DID_FAIL_TO_RENEW: // 扣款失败进入重试期 return markBillingRetry(payload) case EXPIRED: // 订阅到期回收权益 return revokeEntitlement(payload) case REFUND: // 用户退款回收权益并记账 return refundOrder(payload) case REVOKE: // 家庭共享撤销等场景 return revokeEntitlement(payload) default: return nil // 未知类型直接吞掉返回 200 } }服务端处理通知有两个必须做的事。一是验签signedPayload是 JWS不验签就等于让任何人都能伪造「续订成功」。二是幂等商店的通知是至少一次投递同一个notificationUUID可能来好几次不去重就会重复延长权益用户白嫖几个月你都不知道。返回 200 的语义是「我收到了」不管业务处理成功与否。如果业务处理失败但你想让商店重投返回 5xx如果业务逻辑上就该忽略比如未知类型返回 200 让它别再来了。这个区分搞反会收到大量重复通知。4.4 恢复购买别让用户换机后权益丢失恢复购买是审核和客诉的高发区。用户换了新手机登录同一个账号订阅还在但本地订单表是空的界面显示「未开通」。import StoreKit /// 恢复购买只恢复非消耗型与订阅消耗型绝不补发 MainActor func restorePurchases(orderStore: OrderStore) async throws { try await AppStore.sync() // 触发系统级的同步可能弹登录框 for await result in Transaction.currentEntitlements { guard case .verified(let transaction) result else { continue } guard ProductID.subscriptionIDs.contains(transaction.productID) else { // 消耗型商品不参与恢复直接跳过 continue } guard await !orderStore.isFinished(transaction.id) else { continue } try await orderStore.insertPending(from: transaction) try await ServerVerifyClient.shared.submit(transaction) await transaction.finish() } }AppStore.sync()会弹系统登录框必须在用户主动点击「恢复购买」时调用不能在启动时自动调——自动调会打断正常流程体验极差。恢复范围限定在订阅和非消耗型ProductID.consumableIDs里的商品一律跳过金币买过一次就是买过一次换机后不该再给一份。5. 跑起来与常见问题排查本地配置、沙盒联调、五个高频翻车点5.1 本地跑通Xcode 配置与 StoreKit 配置文件克隆下来到能跑大概十分钟。第一步是用 Xcode 打开工程在 Target 的 Signing 里选自己的开发者账号Bundle Identifier 改成自己的——注意这个值要和后面配置文件里的商品前缀对得上对不上不影响本地测试但会影响真机沙盒。第二步是挂 StoreKit 配置文件。工程里带了一个StoreKitConfig.storekit里面预置了四个商品两个消耗型、一个月订阅、一个年订阅。挂载路径是 Scheme 的 Run → Options → StoreKit Configuration选中这个文件。第三步是直接 Command R 跑模拟器。有配置文件的情况下购买流程完全在本地走通不联网、不弹登录框、不需要测试账号。购买成功的交易会真实产生TransactionTransaction.updates也会真实回调跟线上行为一致。配置文件里还能模拟一些线上难复现的场景订阅加速续期把一个月压缩成几分钟、模拟退款、模拟扣款失败。这些开关在配置文件编辑器里都能点调订阅相关的逻辑时非常省事。5.2 沙盒联调测试账号与真实环境差异模拟器跑通不等于真机能跑。切到真机时要把 Scheme 里的 StoreKit Configuration 清掉否则交易还是走本地不会真的连测试环境。真机测试需要一个专用的测试账号在商店后台的「用户与访问」里建不要用日常账号。测试环境的交易不产生真实扣费但订阅时长会被压缩一个月订阅在测试环境里大概几分钟就到期续订好几次。这个特性很好用但也容易误导——本地测出来的「续订六次」在线上要半年才发生。测试环境的收据和线上是两套服务端验签时环境字段必须匹配。常见做法是服务端先按线上验证如果返回「环境不匹配」再降级到测试环境重试一次且这次降级只对特定前缀的测试账号生效。5.3 常见问题排查五个现象、原因与解决现象一Product.products(for:)返回空数组界面商品列表一片空白。原因通常有三个商品 ID 和后台配置不一致最常见多一个空格都算错后台的商品还没通过审核状态是待处理或者服务端的税务、银行信息没填完商品处于不可售状态。排查顺序是先打印传入的 ID 数组和后台逐个字符比对确认无误后再让运营查后台状态。如果本地测试用的是配置文件检查 Scheme 里是否真的挂上了挂上后 Xcode 有时需要重启才生效——这个玄学问题我遇到过两次。现象二点购买后一直转圈既不成功也不报错杀掉重进发现交易还在。这是交易处于.pending或者被重复购买挡住。前者是正常的家长批准、支付延迟界面上应该显示「待确认」而不是转圈后者是上一次购买没调finish()系统认为这笔交易没完成阻止同一商品再次购买。解决办法是在启动时遍历Transaction.unfinished把积压的交易处理掉处理完再finish()。现象三用户反馈付了钱没到账后台查不到订单但商店后台有流水。十有八九是finish()调在了服务端校验之前。交易在本地被标记完成系统不再重放但服务端那边网络超时或者返回 500订单根本没落库。这类掉单只能靠客户端日志和服务端对账补预防手段就是把顺序写死落单 → 服务端校验 → 校验返回成功 → 才finish()。现象四服务端解析 JWS 一直失败报 base64 解码错误。base64url 和标准 base64 的区别就在-、_两个字符和省略的填充符。用标准库直接解 JWS 的 payload 段必然失败。正确做法是先做字符替换再补齐到 4 的倍数。这个错在 Java 和 Go 的服务端都见过属于典型的「代码看着对、就是跑不通」。现象五用户退款了但 App 里会员还在能一直免费用。客户端没有监听revocationDate或者服务端没处理退款通知。两条路都要补客户端在currentEntitlements遍历时显式跳过带revocationDate的交易服务端在收到退款类通知时把订单状态迁到refunded并回收权益。只做一边都会漏。测试时可以用配置文件里的「请求退款」开关复现比真的去申请退款快得多。6. 二次开发把这套工程改成自己项目的支付模块把这套代码塞进现有 App改动量比想象中小但有三处不能图省事。第一处是ProductID和相关配置。改成自己后台的商品标识同步更新StoreKitConfig.storekit两份必须一致改完立刻跑一次Product.products验证返回数量对得上。订阅类商品还要在subscriptionIDs里登记否则恢复购买会漏。第二处是ServerVerifyClient的接口约定。工程里用的是signedTransactionInfotransactionIdproductId三个字段换成自己后端时最好把transactionId作为幂等键在数据库里建唯一索引——客户端去重靠内存进程重启就没了服务端唯一索引才是最后一道防线。第三处是发货逻辑的位置。工程里OrderStore只记录状态真正的权益发放加金币、开会员应该放在服务端客户端收到granted: true后只做 UI 刷新和本地缓存更新。把发货写在客户端等于把收银台交给了顾客。加监控的话我一般会在这几个点埋上报商品拉取失败的 ID 列表、pending状态的停留时长、unverified的次数、补单重试次数。前两个反映配置问题后两个反映线上异常四个指标基本能覆盖八成内购故障。验证改造结果别只测「买成功」。用配置文件把退款、续订失败、宽限期这三个场景各跑一遍确认订单状态迁移正确、权益该收的收了、该留的留了。我吃过一次亏只在模拟器上测了正常购买上线第二天就遇到一批退款权益没收回来用户白嫖了半个月才被发现。从那以后我每次改支付相关代码都强制走一遍「购买 → 校验 → 退款 → 权益回收」这条完整回路不管改动多小。希望这些踩过的坑能帮你少走一遍。本文还有配套的精品资源点击获取