ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

iOS内购掉单排查:StoreKit 2事务监听与幂等发货

iOS内购掉单排查:StoreKit 2事务监听与幂等发货

简介:面向 iOS 开发者的 Swift 苹果内购支付工具,围绕 StoreKit 与 In-App Purchase 场景,提供商品请求、支付队列监听、交易状态处理、恢复购买及收据验证等实现思路,适合需要接入应用内购买、订阅或解锁功能的初中级开发者参考。压缩包共 31 个文件,约 66KB,包含 9 个 swift 源码、5 个 plist 配置、3 个 json、2 个 storyboard 界面文件,以及 Objective-C 头文件与实现、工程配置、entitlements 和测试文件,便于直接查看完整 Xcode 工程结构与类组织方式。已有 1704 人学习下载。项目预览可见 AppDelegate、SceneDelegate、多个 ViewController 与 ApplePayManager 等模块,可帮助读者对照内购管理器的代理回调、支付流程和异常处理梳理代码脉络,也能参考桥接头文件、工程权限与测试目录的配置方式,适合作为 IAP 接入练习与排查思路的参考样例。

1. 一次掉单排查引出的内购工具箱:Swift 侧到底该自己处理哪些事

内购 iOS 相关的线上事故里,最不好查的一类表现是“用户说扣款了,金币没到”。日志里只有一行 purchase success,客服只能截图给开发,开发只能让用户重启 App 试试。这种问题的根源通常不在支付环节,而在支付的后半段——事务没 finish、服务端没记账、环境判断错了、回调重复了。我后来把这条链路整个重写了一遍,做成一套 Swift 侧的内购封装骨架:商品加载、购买、验签解包、事务监听、服务端校验、幂等发货,每段职责单一。下面分享的这份工具解决的就是“付款到发货之间那段黑匣子”;适合正在接内购、被掉单或重复发货折腾过的 iOS 开发者,也适合想给现有代码加一层回归验证的老项目。

2. 交易链路拆解:商品配置、购买、校验、发货四段各自的边界

写内购最容易犯的错是“一上来就写购买按钮”。等代码写到一半才发现订阅要处理续订、非消耗型要处理恢复购买、消耗型还不能靠客户端去重,于是再回头改结构,改到最后发现事务队列已经乱了。我一般会先把这四段边界划清,再动键盘。

2.1 商品类型先定死,再谈代码怎么写

所有内购的实现差异,百分之八十来自商品类型。后台商品一配错,客户端写得多优雅都没用。所以第一步不是打开工程,是对着业务把商品清单拉出来,标好类型。

类型能否重复购买客户端要不要恢复服务端记录的重点
消耗型可以,同一商品无限次不需要每一笔交易都要落库,发货后即闭环
非消耗型只能买一次必须支持以原始交易号维护永久权益,重复购买要挡住
自动续期订阅按周期自动扣必须支持续订、退款、升级降级、宽限期事件
非续期订阅可以重复购买视业务到期时间由自己算,客户端不提供续订状态

消耗型是最容易出问题的。“金币”这种东西没有天然的唯一性,同一笔交易在回调重投、App 重装、换设备登录时可能被处理多次,所以服务端必须拿交易号做唯一键。非消耗型相反,它天然唯一,但你要处理“用户换了账号登录,权益跟着谁走”的问题;我一般把它绑定到业务账号,原始交易号只作为凭证留档。订阅最麻烦,它的状态是随时间变化的:今天有效不代表明天有效,退款之后还要回滚权益,所以本地永远不要自己算到期时间,用系统给的权益快照。

注意:商品类型上线后基本改不了,尤其是消耗型改成非消耗型,后台不允许。配商品之前先把业务规则和运营确认清楚,这一步省下来的时间,后面会以小时为单位还给你。

2.2 为什么这套代码基于 StoreKit 2 的事务模型

老一代的 SKPaymentQueue 是观察者加队列的模型:加监听、等回调、自己维护未完成队列、恢复购买走单独接口。它的毛病不是不能用,而是状态散。处理一次购买,逻辑会被拆到三四个 delegate 方法里,任何一处 return 早了,事务就悬在那儿,下次启动又冒出来。

StoreKit 2 把这套东西收成了异步序列和值类型事务,几个关键变化决定了代码结构:

第一,购买接口直接返回结果枚举,成功、用户取消、待批准三种状态是显式的,不再靠 error code 猜。第二,返回的不是裸数据,而是带验签结果的包装类型,签名有效性在客户端就能判断,服务端校验变成第二道防线而不是唯一防线。第三,所有事务推送收敛到一个异步序列,交易在后台产生(比如订阅续订、家长批准、退款)时会从同一个入口进来,你只需要在那里做一次幂等处理。

需要说清楚的边界是系统版本。异步序列这套需要较新的系统版本,老设备上要么做降级分支,要么直接放弃这部分用户。我一般按主版本分两层实现,但对上层暴露同一套协议,业务代码不感知底层用的是哪一代 API。这也是下面这个模块骨架存在的理由。

2.3 模块骨架:把内购收进一个协议后面

先看目录,这是我在几个项目里收敛出来的形状:

IAPKit/ ├── IAPClient.swift // 对外协议,业务只依赖它 ├── StoreKitClient.swift // 真实实现,包住 StoreKit ├── FakeIAPClient.swift // 测试替身,可脚本化返回各种状态 ├── IAPModels.swift // 购买结果、错误、权益模型 ├── TransactionObserver.swift // 事务监听与补单 └── EntitlementCache.swift // 本地权益快照,冷启动用

对外只暴露一个协议,业务层调它就行:

import StoreKit // 购买结果:把「成功/取消/待批准/失败」显式化,避免业务层靠 error 猜 enum PurchaseOutcome { case success(transactionID: UInt64) case pending // 待批准:家长同意、银行验证中,绝不能发货 case cancelled case failed(IAPFailure) } enum IAPFailure: Error { case productMissing(String) // 商品未返回 case unverified // 签名校验失败 case commitFailed(Error) // 服务端记账失败 case unknown } // 业务层只认这个协议,测试时换成 FakeIAPClient 即可跑全流程 protocol IAPClient: AnyObject { func loadProducts() async throws -> [Product] func purchase(productID: String, userID: String) async throws -> PurchaseOutcome func restore() async throws func startObserving() func stopObserving() }

协议里刻意没有暴露 Product 之外的 StoreKit 类型,除了loadProducts这一处——因为商品的价格、标题、周期这些展示信息必须来自系统返回的对象,自己拼字符串会因为地区定价和展示差异翻车。其余方法全部用自己的模型,这样在单元测试里可以完整模拟“购买成功但服务端失败”“待批准后转成功”这类线上难复现的场景。

分工上,StoreKitClient只负责和系统打交道,不做发货;发货统一走一个OrderAPI的服务端接口,客户端只管把交易号和用户标识送上去。这个切分看起来多写了一层,但它把“支付”和“权益”解耦了——支付是系统的事,权益是你自己数据库里的事,两者之间必然存在时间窗口,而所有的掉单都藏在这个窗口里。

3. 落地代码:商品加载、购买、事务收尾的三段实现

骨架定完,剩下的就是三个函数。这三段代码值得逐行看,因为每一行的顺序都有讲究,顺序错了就是线上事故。

3.1 商品加载与缓存:别在每次进页面都拉一遍

商品列表是可以缓存的。用户在商店页反复进出,每次都发一次商品查询请求,不仅慢,还容易在弱网下返回空数组,页面直接白屏。合理的做法是进程内缓存一份,加一个强刷入口,只在支付失败或者用户手动下拉时重拉。

import StoreKit @MainActor final class StoreKitClient: IAPClient { private(set) var products: [String: Product] = [:] private let productIDs: Set<String> = ["coin_60", "coin_300", "vip_monthly", "remove_ads"] func loadProducts() async throws -> [Product] { // 有缓存直接返回,避免商店页反复请求 if !products.isEmpty { return Array(products.values) } let list = try await Product.products(for: productIDs) products = Dictionary(uniqueKeysWithValues: list.map { ($0.id, $0) }) // 关键:把「没返回」的商品单独打出来,这是配置问题最常见的表现形式 let missing = productIDs.subtracting(products.keys) if !missing.isEmpty { print("[IAP] 未返回的商品: \(missing.sorted())") } return list } }

这段的核心不在查询,而在missing那一行。商品查询不报错但少返回几个,是后台配置阶段的高频现象:ID 拼错一个字符、商品还没过审、应用的 bundleId 和后台不一致、税务协议没签署。系统不会告诉你原因,只会安静地少给你一个。把差集打出来,排查时间从半天缩短到五分钟。

参数上有两个可调项。productIDs建议从远程配置或后台下发布尔值,而不是写死在客户端——节日活动临时上架的商品包,写死就意味着要发版。缓存策略上,如果你的商品包含订阅,价格会随地区变化,缓存建议加一个 5 到 10 分钟的软过期,超过就后台静默刷新一次,用户无感知。

3.2 购买与验签解包:三种返回分支,只有一种能发货

购买函数是整个工具里最需要讲顺序的地方。正确的顺序是:验签 → 服务端记账 → 结束事务。三步颠倒任何一步都会出问题。

extension StoreKitClient { func purchase(productID: String, userID: String) async throws -> PurchaseOutcome { guard let product = products[productID] else { return .failed(.productMissing(productID)) } let result = try await product.purchase() switch result { case .success(let verification): // 第一步:验签。不通过直接返回,绝不往下走 guard case .verified(let transaction) = verification else { return .failed(.unverified) } do { // 第二步:服务端记账 + 发货,接口内部必须幂等 try await OrderAPI.commit( transactionID: String(transaction.id), originalID: String(transaction.originalID), productID: transaction.productID, userID: userID ) } catch { // 记账失败:不 finish,让系统下次重投 return .failed(.commitFailed(error)) } // 第三步:只有记账成功才结束事务 await transaction.finish() return .success(transactionID: transaction.id) case .pending: // 待批准:家长同意、支付渠道审核中,此时不发货也不 finish return .pending case .userCancelled: return .cancelled @unknown default: return .failed(.unknown) } } }

pending这个分支是最容易被漏掉的。它的业务含义是“用户点了购买,但支付链路还没最终确认”,典型场景是家庭共享里孩子发起购买、需要家长批准。很多实现把它当成失败处理,提示“购买失败请重试”,用户再点一次,最后批准下来变成两笔;也有实现把它当成功,直接发金币,结果批准被拒绝,货已经出去了。正确做法就是什么都不做,等事务从推送入口再进来一次。

finish()的调用时机也值得强调。它是告诉系统“这笔事务我处理完了,可以从队列里删掉”。一旦提前调用,后面记账失败就再也没有补偿机会——队列里没有这笔事务了,用户下次启动也不会重投,这就是标准的掉单。所以顺序只能是把finish放在最后,并且只在确认发货之后调。

originalID这个字段对订阅和非消耗型是必需的。同一订阅的每一次续订,transaction.id都不同,但originalID相同,它才是识别“同一个用户的同一份订阅”的稳定标识。服务端做权益绑定时认它,不要认单次交易号。

3.3 事务监听:掉单补偿的主要入口

前台购买只是入口之一。订阅续订、退款、家长批准后的补发、在其他设备上完成的购买,全都会从后台推过来。这个序列必须在 App 启动时就挂上,而且要保证同一个交易被处理多次也不会重复发货。

final class TransactionObserver { private var task: Task<Void, Never>? func start(userID: String) { guard task == nil else { return } // 防止重复挂载 task = Task.detached(priority: .background) { for await update in Transaction.updates { guard case .verified(let transaction) = update else { continue } do { // 幂等:同一交易号重复到达时,服务端应返回「已处理」 try await OrderAPI.commit( transactionID: String(transaction.id), originalID: String(transaction.originalID), productID: transaction.productID, userID: userID ) await transaction.finish() } catch { // 处理失败就别 finish,留到下次启动再补 print("[IAP] 补单失败,保留事务: \(error)") } } } } func stop() { task?.cancel() task = nil } }

这里的guard task == nil不是防御性编程的洁癖。页面切换、账号登出再登录、场景重新激活都可能触发挂载,挂两个监听器之后,同一笔交易会被两条序列各处理一次,服务端如果没做幂等,就是双倍发货。

另一个细节是Task.detached。用 detached 而不是继承当前 actor,是为了避免监听任务被 UI 生命周期牵连——它应该活到进程结束,而不是活到某个视图消失。代价是内部不能直接访问@MainActor隔离的状态,需要的话再显式跳一次主线程。

Transaction.updates还有一个特性值得知道:它只推送新产生且未完成的事务,历史事务不会重放。所以冷启动时如果发现有用户投诉掉单,光靠这个序列补不回来,得配合Transaction.unfinished主动扫一遍,或者走恢复购买。

3.4 恢复购买与权益重建

恢复购买解决的是“用户换了设备、重装了 App、换了登录方式”之后的权益找回。它的实现很短,但两个步骤不能省。

extension StoreKitClient { func restore() async throws { // 第一步:向系统请求同步,可能弹系统账号验证 try await AppStore.sync() // 第二步:从当前权益快照重建本地状态 for await entitlement in Transaction.currentEntitlements { guard case .verified(let transaction) = entitlement else { continue } EntitlementCache.shared.apply( productID: transaction.productID, originalID: String(transaction.originalID), expiresAt: transaction.expirationDate ) // 顺带把服务端权益补齐,防止服务端丢数据 try? await OrderAPI.reconcile(originalID: String(transaction.originalID)) } } }

currentEntitlements只包含“当前仍然有效”的权益,已过期的订阅、已退款的商品不会出现在里面。这意味着它是重建本地状态的正确数据源,而不是历史账单——账单永远以服务端为准。expirationDate只有订阅类商品有值,非消耗型是 nil,缓存层要做空值分支。

AppStore.sync()会弹系统级验证,可能失败,可能被用户取消,所以它必须挂在用户主动点击的按钮后面,而不是启动时自动调。自动调的结果是打开 App 就弹登录框,用户会直接卸载。

4. 服务端校验与状态机:把「已付款」变成「已发货」

客户端做到上面那一步,能保证“系统告诉我付完了我就上报”。但系统说的是不是真话、这笔交易之前有没有处理过、退款了要不要回滚,都得服务端接住。

4.1 载荷字段与订单表设计

新的交易凭证是一段带签名的 JWS 字符串,结构是头部、载荷、签名三段。载荷里有几个字段是必须核对业务正确性的,其余可以原样存档备查。

字段用途校验方式
bundleId防跨应用重放必须等于自己应用的标识
productId防跨商品重放必须在白名单里
transactionId幂等键数据库唯一索引
originalTransactionId权益绑定键与用户账号建立映射
purchaseDate / signedDate防过期凭证重放与服务器时间比对,允许几分钟误差
environment区分沙盒与生产生产环境拒绝沙盒凭证
revocationDate退款标记非空即回滚权益

签名校验不能只看“能不能解出 JSON”。正确的做法是校验证书链:JWS 头部携带证书链,需要逐级验证到可信根证书,再验签,最后才解析载荷。根证书从开发者后台下载,随服务部署,不要写死在客户端,也不要运行时从网络随意拉取。

订单表的设计直接决定了幂等能不能做干净:

CREATE TABLE iap_order ( id BIGINT PRIMARY KEY AUTO_INCREMENT, transaction_id VARCHAR(64) NOT NULL COMMENT '单次交易号,幂等键', original_transaction_id VARCHAR(64) NOT NULL COMMENT '原始交易号,权益绑定', user_id BIGINT NOT NULL, product_id VARCHAR(64) NOT NULL, purchase_time DATETIME NOT NULL, environment TINYINT NOT NULL DEFAULT 2 COMMENT '1 沙盒 2 生产', state TINYINT NOT NULL DEFAULT 0 COMMENT '0 待发货 1 已发货 2 已退款', raw_payload JSON COMMENT '原始载荷,排查用', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_tx (transaction_id), KEY idx_original (original_transaction_id), KEY idx_user (user_id, product_id) ) ENGINE=InnoDB;

uk_tx那个唯一索引是整套幂等的地基。没有它,所有的“先查再插”在并发下都会漏。

4.2 幂等与并发:同一笔交易只发一次货

客户端可能重复上报,推送可能重复到达,网络超时后客户端可能重试。所以服务端的处理顺序必须是“先落库、后发货”,并且落库本身用数据库的唯一约束兜底,而不是用应用层的 if 判断。

# 记账接口的骨架:插入靠唯一键兜底,发货靠受影响行数判断 def commit_order(tx: dict, user_id: int) -> str: # 1) 校验:环境、商品、时间窗 assert tx["bundleId"] == EXPECTED_BUNDLE, "bundle 不匹配" assert tx["productId"] in ALLOWED_PRODUCTS, "商品不在白名单" assert tx["environment"] != "Sandbox" or IS_STAGING, "生产环境拒绝沙盒凭证" # 2) 落库,重复回调在这里被唯一键吃掉 affected = db.execute(""" INSERT INTO iap_order (transaction_id, original_transaction_id, user_id, product_id, purchase_time, environment, state, raw_payload) VALUES (%s, %s, %s, %s, %s, %s, 0, %s) ON DUPLICATE KEY UPDATE transaction_id = transaction_id """, (tx["transactionId"], tx["originalTransactionId"], user_id, tx["productId"], tx["purchaseDate"], env_code(tx["environment"]), json.dumps(tx))) # 3) 抢占发货权:只有把 state 从 0 改成 1 的那次调用才发货 updated = db.execute(""" UPDATE iap_order SET state = 1 WHERE transaction_id = %s AND state = 0 """, (tx["transactionId"],)) if updated == 0: return "already_processed" # 重复请求,直接回成功给客户端 grant_entitlement(user_id, tx["productId"]) return "ok"

这段的关键在第 3 步。即使两个请求同时进来,也都通过了第 2 步(一个插入成功、一个命中唯一键忽略),第 3 步的WHERE state = 0只会让其中一个拿到 affectedRows=1,另一个拿到 0,发货只发生一次。这比“查一下有没有记录,没有再发货”可靠得多,因为查和写之间有时间窗口。

客户端拿到的响应要区分对待:ok和already_processed都返回成功,客户端才finish();如果返回业务错误(比如用户不存在),客户端就不要 finish,保留事务等下次重试。

4.3 订阅续订、退款与降级事件

订阅类商品会持续产生服务端通知,种类比一次性购买多得多。常见的几类我一般这样处理:

事件处理动作易错点
首次购买建权益,记录到期时间别和续订混用同一分支
续订成功顺延到期时间用系统给的到期时间,别自己加 30 天
进入宽限期保持权益,标记风险宽限期内用户仍可用
进入保留期权益可保留,停止续订别在这里立刻收回
退款回滚权益,标记订单已消费的消耗型无法回滚
升级/降级立即生效或到期生效生效时间点不同,要分开处理

续订时间戳必须用载荷里的到期时间,不能按“上次到期 + 一个周期”自己算。系统会有补扣、延迟扣款、促销周期,自己算的时间几乎一定对不上,误差累积几次之后,用户的会员会在半夜莫名其妙地失效。

提示:退款事件一定要处理。不处理的结果是用户可以反复“买了退、退了还享受”,这种漏洞在社区里传播得很快。

5. 沙盒联调避坑:五个把测试同学逼疯的现象

这一章是踩坑记录。下面五条都是我在真实项目里遇到过的,每条都按“现象 → 原因 → 解决”写,能省你几天。

现象一:沙盒里买得飞快,上线后首单必掉。

原因通常是环境判断写反了。开发阶段为了方便,服务端干脆跳过了环境校验;上线后同一个接口收到沙盒凭证还当生产处理,或者反过来,生产凭证被沙盒分支拦掉。更隐蔽的一种是缓存:商品信息在沙盒环境加载过一次,切到生产后客户端复用了缓存,拿到的是沙盒价格档位。

解决办法是把环境字段作为凭证的一部分参与判断,而不是靠客户端传参。服务端从载荷里的environment字段判定,生产环境收到沙盒一律拒绝并告警。客户端的商品缓存加一个环境标识,切换后强制失效。上线前留一个专门的用例:生产包 + 生产账号 + 真实小额商品,走完整链路。

现象二:用户每次打开 App 都弹“您已购买此项目”,或者金币一直重复到账。

这是事务没有 finish 的典型表现。系统认为这笔交易没被处理完,每次启动都重投一次。常见于两种实现:一是记账失败后没有重试逻辑,事务悬着;二是finish()放在了提前 return 的分支后面,正常路径走到了,异常路径漏了。

排查方式是数一下未完成事务的数量,如果启动后一直大于 0,说明有事务卡住。解决上,把所有finish()调用收敛到一个包装函数里,只有“服务端确认收到”这一个条件成立才调用,其他地方一律不允许直接调finish()。同时给服务端加一个“已处理”返回值,让客户端知道这是重复上报而不是新交易。

现象三:测试账号买完非消耗型商品,再点一次提示“已购买”,但功能没解锁。

非消耗型的购买按钮点下去,系统发现这个账号已经买过了,不会产生新交易,直接返回一个已存在的状态或者干脆走恢复购买流程。如果客户端只在“购买成功”的回调里发货,这条路径上永远发不出去。

正确的做法是把非消耗型的权益判定和购买解耦:App 启动时先扫一遍当前权益,有就直接解锁,购买按钮只负责触发,不负责解锁。同时给“已购买”这个状态一个明确的产品提示,引导用户点“恢复购买”,而不是报一个看不懂的错误码。

现象四:商品列表在某些设备上返回空数组,但接口不报错。

这是最消耗耐心的一类。可能原因有五个:后台商品还没过审、应用的税务与银行信息未完善、商品 ID 大小写不一致(这个真的很难看)、bundleId 与后台不一致、查询频率过高被限流。

排查顺序我一般这样走:先在后台确认商品状态是“准备提交”还是“已批准”,再看missing差集打印出来的是哪几个 ID,然后比对 bundleId。如果都正常,把查询的productIDs缩小到只剩一个再试,能把网络因素排掉。另外商品查询不要放在启动关键路径上,失败时用一个降级列表兜住 UI,别让用户看到空白页。

现象五:订阅续订在沙盒里几秒就续一次,测试数据乱成一团。

沙盒的订阅周期是压缩的,几分钟就走完一个月,测试的时候一天能产生几十条续订记录,把测试账号的权益时间推到几年后,之后就再也测不出正常状态了。

应对办法是给测试环境单独一套商品和单独的权益表,测试数据不污染生产库;同时在日志里把originalTransactionId和续订次数一起打出来,方便识别异常的续订频率。测试账号准备两三个轮换用,一个被推到未来就换下一个。这些听起来都是脏活,但没有这些准备,订阅相关的 bug 你根本复现不出来。

6. 上线前的自检与灰度验证:把掉单率变成一条能看的曲线

上线前我都会跑一遍固定的自检脚本,不是走形式,是因为这里面有三项在过去两年里各救过我一次。

#if DEBUG func iapSelfCheck() async { // 1. 商品是否全部返回(对比预期 ID 集合) let list = try? await IAPClient.shared.loadProducts() print("[自检] 商品数量: \(list?.count ?? 0)") // 2. 未完成事务数量,正常应为 0 var unfinished = 0 for await _ in Transaction.unfinished { unfinished += 1 } print("[自检] 未完成事务: \(unfinished)") // 3. 当前有效权益数量与到期时间 for await ent in Transaction.currentEntitlements { guard case .verified(let tx) = ent else { continue } print("[自检] 权益 \(tx.productID) 到期: \(String(describing: tx.expirationDate))") } } #endif

三项分别对应三类线上问题:商品缺失、事务卡住、权益时间错乱。第一项在打包前跑,第二项在每次冷启动后跑,第三项在用户反馈“会员没了”时跑。

灰度阶段我会盯四个指标,做成一条看板:购买成功率(发起购买到拿到成功结果的比例)、记账成功率(客户端上报到服务端确认的比例)、掉单数(上报失败且超过 10 分钟未补偿的笔数)、重复发货数(同一交易号触发两次以上发货的次数)。前两个跌了说明链路有问题,后两个涨了说明幂等有问题,四种情况对应完全不同的排查方向,混在一起看只会瞎猜。

还有一个我后来养成的习惯:任何一次购买,无论成功失败,都往本地日志里写一行结构化记录——交易号、商品 ID、结果枚举、耗时、服务端返回码。这行日志在开发阶段看着碍眼,等客服拿着用户截图来找你的时候,它就是唯一能还原现场的东西。从那以后我每次接内购,第一步不是写购买按钮,而是先把这条日志埋好,再跑一遍全链路自检,确认四段边界都有人负责。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进