资讯动态

StoreKit 2内购支付层封装与幂等发货实践

发布时间:2026/10/27 2:08:52 来源:尧图企业网站定制
简介面向iOS开发者的苹果内购支付工具示例聚焦StoreKit在内购场景中的工程化落地适合需要为应用接入消耗品、非消耗品或订阅购买流程的初中级开发者参考。压缩包共31个文件、约66KB以9个Swift源码为主体配合5个plist配置、2个storyboard界面及桥接头、工程配置等构成结构清晰的Xcode工程便于直接查阅源码组织与配置关系。内容围绕内购产品请求、支付队列监听、交易状态处理、恢复购买与错误提示等关键环节展开并提供内购管理类和多页面交互示例覆盖收据验证、订阅续订与取消等常见业务考量帮助读者理解购买流程如何与界面、权限文件及服务端校验协同。已有1704人学习可作为搭建内购模块、排查交易回调、梳理审核合规要求的参考底稿也适合课程演示或项目原型复用。1. 内购 iOS 的支付层把 StoreKit 调用收敛成一个 Swift 工具内购 iOS 代码最容易写散商品拉取塞在首页 ViewModel购买逻辑埋在按钮回调校验逻辑藏在某个网络层等要加订阅升降级、接服务端回调、处理退款掉单才发现没有一处能改。这份 Swift 内购支付工具解决的就是这个问题——它把 StoreKit 2 的商品、交易、校验、权益四类调用收敛到独立的服务层业务侧只碰purchase和entitlements两个入口支付相关的状态机、重试、幂等都在工具内部消化。它适合已经上线或准备上线订阅/消耗型商品、又不想让支付逻辑污染业务代码的 iOS 项目也适合刚接手一个历史内购模块、需要先摸清边界的人。2. 商品与配置Product.products 返回空数组的六个前置条件商品拉取是整个内购链路的第一道闸。它失败的表现极其统一——返回一个空数组不抛错、不提示、日志干净得像没问题。新手容易往代码里找原因实际上九成的空数组跟代码无关。2.1 为什么走 StoreKit 2 的 async 接口而不是 SKPaymentQueue老 API 是观察者模型一个全局SKPaymentQueue、一个SKPaymentTransactionObserver、回调里靠transactionState分支。它的麻烦不在难写而在状态分散——队列是全局单例代理只能挂一个多个业务方想同时监听就得自己造转发层写到最后没人说得清哪个模块在处理哪笔交易。StoreKit 2 换成了值语义加结构化并发Product.products(for:)是 async 函数product.purchase()直接返回结果Transaction.updates是 AsyncSequence每条交易自带VerificationResult包装。选它的理由有三条一是并发安全actor 隔离天然适配支付这种要求顺序的场景二是可测试性async 函数在单测里能直接 await不用构造假队列三是校验内建签名验证由框架完成不用自己拼收据解析。边界也要说清楚StoreKit 2 需要较新的系统版本项目里如果还压着更老的系统支持只能走老 API 或做双实现。工具层如果只封装 StoreKit 2接入前先确认最低支持版本别写到一半发现要回退。2.2 六个前置条件与商品拉取实现空数组的成因基本落在这六条里按排查成本从低到高排检查项说明典型现象商品 ID 完全一致大小写、点号、连字符都要对上部分商品有、部分没有商品状态可用至少被一个版本引用首次上架需随版本提交全部为空付费应用协议生效税务与收款信息未填完商品接口不给数据全部为空且沙盒也一样本地配置挂载用.storekit文件时要在 Scheme 的 Run 选项里选中真机正常、模拟器为空商店地区支持当前账号所属商店不提供该商品单条缺失设备时间时间严重偏移会导致请求异常偶发重试可恢复代码上工具层把商品 ID 集中管理避免字符串散落import StoreKit // 商品 ID 集中一处改动只在这里发生 enum IAPProductID { static let monthly app.pro.sub.monthly static let yearly app.pro.sub.yearly static let coin60 app.coin.60 static let all: [String] [monthly, yearly, coin60] } // actor 隔离多个页面并发拉取时不会重复打请求 actor ProductService { private var snapshot: [String: Product] [:] func load(force: Bool false) async throws - [Product] { if !force, !snapshot.isEmpty { return Array(snapshot.values) // 冷启动先给 UI 兜底数据 } let products try await Product.products(for: IAPProductID.all) // 返回顺序不保证与入参一致UI 要按自己的顺序重排 for p in products { snapshot[p.id] p } return products } }这里的force参数是给「用户下拉刷新」和「支付失败后重试」用的正常进页面别传 true。snapshot存在的意义不是缓存商品价格——价格以Product.displayPrice为准随时会变——而是让 UI 在首屏就能渲染出占位结构不至于白屏等网络。真正展示价格时必须用product.displayPrice而不是自己拼字符串否则面对不同地区的货币符号和税率会出洋相。2.3 退避重试与 UI 兜底商品接口失败大多是瞬时的一失败就弹「无法加载商品」是体验灾难。工具层应该内置有限次退避// 0.5s / 1s / 2s 退避弱网下不要把请求打满 func loadWithRetry(_ ids: [String], attempts: Int 3) async throws - [Product] { var lastError: Error IAPError.emptyProducts for i in 0..attempts { do { let products try await Product.products(for: ids) if !products.isEmpty { return products } // 空数组也当失败处理 lastError IAPError.emptyProducts } catch { lastError error } let delay 0.5 * pow(2, Double(i)) try? await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000)) } throw lastError }注意把「空数组」也纳入重试范围。Product.products在商品配置有问题时是静默返回空的不重试的话你根本区分不出「网络抖动」和「配置错误」——三次都空基本可以断定是配置问题这时候再引导用户联系客服才有意义。3. 购买闭环purchase() 的三种返回与 Transaction.updates 的全局监听购买流程的坑不在调用本身而在「调用之后发生了什么」。一次成功的购买可能跨越三个时间点用户点击、商店扣款、服务端发货任何一环崩了都需要有兜底路径。3.1 PurchaseResult 三个分支的业务含义product.purchase()返回Product.PurchaseResult只有三种情况但每种背后的业务处理完全不同.success(VerificationResultTransaction)交易已生成但还需要验签、落库、finish()。.pending交易进入待批准状态常见于家长控制或需要二次确认的场景。此时不能给用户任何权益也不能提示「购买失败」正确文案是「等待批准」。.userCancelled用户主动取消静默处理不要弹任何提示。把.pending当成失败是最常见的误判。它会在批准后通过Transaction.updates补发如果这期间你把订单标成了失败后续补发就对不上账了。enum PurchaseOutcome { case success(Transaction) case pending case cancelled case unverified // 验签没过绝不发货 } func purchase(_ product: Product, accountToken: UUID) async throws - PurchaseOutcome { let result try await product.purchase(options: [ .appAccountToken(accountToken) // 把交易绑到业务用户退款时能反查 ]) switch result { case .success(let verification): switch verification { case .verified(let transaction): // 顺序不能反先落库再 finish try await orderStore.record(transaction) await transaction.finish() return .success(transaction) case .unverified: return .unverified } case .pending: return .pending case .userCancelled: return .cancelled unknown default: return .cancelled } }appAccountToken是个 UUID由业务侧生成并持久化到用户档案上。它的价值在事后服务端收到通知或对账时能凭这个 token 直接定位到是哪个用户不用靠originalID反查。如果业务用户体系还没有稳定的用户 ID建议在注册时就生成一个 UUID 存下来别等到接内购才补。3.2 监听必须早于 UIupdates 的重放语义Transaction.updates是整条链路的黑匣子。它在两种时机吐出交易一是购买流程之外产生的交易待批准被批准、订阅自动续订、家庭共享、跨设备购买、退款撤销二是上次没finish()的交易在冷启动时被重放。第二条是关键。StoreKit 会一直保留未完结的交易直到你调用finish()。这意味着如果 App 在扣款后崩溃下次启动时这条交易会重新出现——这是官方的「后悔药」也是掉单恢复的唯一可靠手段。前提是你的监听必须足够早。final class TransactionListener { private var task: TaskVoid, Never? // 在 App 启动、任何支付 UI 出现之前调用 func start() { guard task nil else { return } task Task.detached(priority: .background) { [weak self] in for await update in Transaction.updates { guard case .verified(let transaction) update else { continue } do { try await self?.orderStore.record(transaction) await transaction.finish() // 落库成功才完结 } catch { // 落库失败就不 finish下次启动会再补发 } } } } func stop() { task?.cancel(); task nil } }start()的调用位置放在 App 生命周期最早的入口别放在首页viewDidLoad。用户如果从推送或深链直接进到支付结果页首页可能根本没被创建监听就漏了。同理stop()只在明确要停止服务时调用App 退到后台不要停——后台交易回调依赖它。3.3 VerificationResult、finish() 与「先落库后完结」每笔Transaction都以 JWS 形式从 App Store 下发签名链由 App Store 的证书签发设备端用内置根证书验签结果封装成VerificationResult。.verified说明这条交易确实由 App Store 签发且未被篡改.unverified通常意味着设备环境异常注入、劫持、系统时间严重错乱这种交易一律不发货。但要注意.verified的边界它证明的是「这条交易是真的」不证明「这个用户现在还有权」。退款、撤销、订阅过期都不体现在签名上得看字段和服务端状态。所以客户端校验只是第一道闸不能当最终判据。finish()的语义是「这条交易我已交付完毕」。对消耗型商品必须在服务端发货成功之后再finish()对订阅和永久解锁落库成功即可finish()。调早了会丢单调晚了会让交易一直重放两个方向都是血泪经验。注意finish()不会结束订阅也不会影响续订。它只影响这条交易记录是否还会被重放别把它和「取消订阅」搞混。4. 校验与对账从 verifyReceipt 迁到 App Store Server API 的落地路径客户端拿到交易只是拿到了「凭证」真正的权益判定在服务端。这一层的设计决定了你后面处理退款、掉单、跨设备同步时有多痛苦。4.1 客户端能做的和不能做的老路子是SKReceiptRefreshRequest刷一张收据把 base64 丢给服务端的 verifyReceipt 接口换取解析结果。这条路正在被逐步收窄新项目不建议再从它起步。现在更常见的是两条腿客户端用 StoreKit 2 的Transaction做即时反馈服务端用 App Store Server API 查交易详情、用 App Store Server Notifications V2 接收状态变更。客户端解析出的字段只用来打日志和做 UI 提示判权一律以服务端为准。客户端能可靠拿到的字段有限但足够做本地兜底字段用途常见误用transactionID发货幂等键用自家订单号做幂等重装后对不上originalID订阅归属、家庭共享忽略它同一订阅被重复发货appAccountToken关联业务用户不传退款时找不到人environment区分沙盒与正式不判沙盒单进了正式订单表revocationDate退款与撤销不判退款后权益还在expirationDate订阅到期时间用设备本地时间比较currentEntitlements是 StoreKit 提供的权威权益视图它已经把过期、退款、撤销的交易过滤掉了可以直接用来刷新本地权益缓存// 拉取当前有效权益别看自己数据库里的字段先对齐这个视图 func refreshEntitlements() async - SetString { var active: SetString [] for await result in Transaction.currentEntitlements { guard case .verified(let t) result else { continue } if t.revocationDate ! nil { continue } // 已退款 if let exp t.expirationDate, exp .now { continue } // 已过期 active.insert(t.productID) } return active }这段代码适合放在「App 启动」「支付成功」「恢复购买」三个时机跑跑完和本地缓存的权益做一次 diff只在有变化时才刷新 UI。4.2 appAccountToken 与幂等发货服务端发货必须幂等幂等键用transactionID不要用订单号。原因是同一笔交易可能在多个设备上被重复上报用户换机、重装、Transaction.updates重放都会让同一个transactionID到达服务端多次。# 服务端发货的最小幂等骨架 import json, base64 def decode_payload(signed: str) - dict: # 这里只拆 payload 看字段正式环境必须先验签再用 _, payload_b64, _ signed.split(.) padded payload_b64 * (-len(payload_b64) % 4) return json.loads(base64.urlsafe_b64decode(padded)) def handle(payload: dict) - str: data payload.get(data, {}) if data.get(environment) ! Production: return ignore # 沙盒通知不写正式订单表 if data.get(revocationDate): return revoke tx data.get(transactionId) if grant_exists(tx): # 幂等检查 return duplicate grant(tx, data.get(appAccountToken)) return grantedenvironment的判断要放在最前面。沙盒环境的通知和正式环境的通知走同一套结构只是environment字段不同混着处理会让测试单污染真实数据清理起来极其麻烦。4.3 订阅状态机续订、退款、宽限期、掉单补发订阅比消耗型多出来的复杂度全在状态迁移上。常见事件类型包括订阅成功、续订成功、续订失败、进入宽限期、宽限期结束、过期、退款、撤销。这些事件从通知进来落到本地的权益表需要一张明确的状态表。我的做法是本地只存三个东西productID、expiresAt、statusactive / grace / expired / revoked。通知进来时按事件更新这三项不做复杂的中间态。宽限期单独标一个 status因为它的业务语义是「权益还在但要求用户更新支付方式」UI 上要区分。掉单补发依赖Transaction.updates和currentEntitlements两条腿前者负责实时补后者负责启动时对账。两者都跑完还没对上的才是真正需要人工介入的异常单这个量级通常在千分之几以内。5. 避坑排查内购联调里最容易翻车的五件事5.1 沙盒交易写进了正式订单表现象测试环境跑完正式库里多出一批金额为 0 的订单用户权益被误开通。原因本地.storekit配置、沙盒账号、正式账号三种环境共用一套落库逻辑没有任何一处判断environment。解决在orderStore.record()入口做硬判断transaction.environment ! .production时写测试表或直接丢弃。这条判断要写在落库函数里而不是调用方否则总有一个调用点会漏。5.2 finish() 漏调同一条交易发了两次货现象用户只付了一次后台看到两条发货记录权益时长翻倍。原因购买成功分支里finish()被写在了异步回调里回调失败就再没执行过下次启动交易被重放服务端幂等键又用的自家订单号重复发了。解决幂等键换成transactionID同时把finish()放到落库成功的同一个do块里。落库失败就不finish()让 StoreKit 下次继续重放比吞掉错误安全得多。5.3 恢复购买按钮点太多次AppStore.sync() 直接报错现象用户连点恢复购买第二次开始抛错界面卡在 loading。原因AppStore.sync()有调用频率限制短时间内重复调用会被拒。解决恢复购买按钮加节流同时给一个明确的进行中状态。恢复流程本身也应该以currentEntitlements为准sync()只是触发一次同步不是查询接口。5.4 商品列表一直为空日志里连请求都没发现象模拟器上商品永远为空真机正常。原因.storekit配置文件没挂到 Scheme 的 Run 选项里模拟器没有可用的商店环境。解决检查 Scheme 的 Run → Options → StoreKit Configuration。团队协作时把这个配置写进文档新同事拉代码第一件事就是配它。5.5 用设备本地时间判断订阅过期现象用户改系统时间后权益状态错乱或者跨时区后订阅提前失效。原因拿Date()和expirationDate直接比Date()是设备时间用户可改。解决过期判断一律用服务端时间客户端只用currentEntitlements这个由系统维护的视图。本地做展示时可以比但不能作为判权依据。6. 上线前自检与一个幂等技巧让沙盒把六条链路跑满上线前我习惯用一张清单把链路跑一遍缺一条都不敢发版链路验证手段通过标准商品拉取冷启动 强网/弱网各一次三秒内出价格重试不弹错正常购买沙盒账号完整走一遍落库、finish()、权益刷新都到位待批准开沙盒的 Ask to Buy 模拟显示「等待批准」不误判失败中断恢复扣款后立刻杀进程重启后自动补发并只发一次订阅续订沙盒缩短续订周期updates收到续订到期时间前移退款沙盒后台发起退款权益被撤销revocationDate非空清单里最容易漏的是第四条。很多人测试时只在正常路径上点从来不模拟「扣款成功但 App 崩了」。这条链路一旦没跑通线上就是真实的掉单。进阶一点的做法是把发货幂等做成两层第一层用transactionID做唯一索引第二层用appAccountTokenproductIDexpiresAt做业务侧的一致性校验。// 发货请求带上双键服务端任一层命中都视为重复 struct GrantRequest: Encodable { let transactionID: String // 第一层交易唯一 let accountToken: String // 第二层业务归属 let productID: String let expiresAt: Date? let environment: String // Production / Sandbox }environment一定要跟着请求走服务端据此决定写哪张表。少了这个字段测试数据混进正式库只是时间问题。从那以后我每次接新项目的内购都会先把Transaction.updates的启动点钉在 App 最早的入口再去动任何 UI——这个顺序反过来后面补监听的成本会翻好几倍。这份工具的分层大致就是按上面这几节拆的拿到之后建议先挂.storekit本地配置把六条链路跑一遍再换沙盒账号验第二遍两遍都过再谈提审。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑