iOS内购支付全链路:StoreKit 2验签、幂等发货与通知补单

发布时间:2026/11/4 12:17:42
iOS内购支付全链路:StoreKit 2验签、幂等发货与通知补单

简介:面向 iOS 应用开发者的 Swift 内购支付示例包,聚焦使用 StoreKit 完成应用内购买的核心流程,适合希望理解产品配置、交易监听、收据验证、恢复购买与订阅管理的初中级开发者参考。资源围绕消耗品、非消耗品及订阅三类内购项目展开,示范产品标识设置、产品信息请求与展示、支付请求入队和交易状态处理等关键环节,同时提醒服务端收据校验、错误提示与 ATS 配置等实践要点。压缩包共 31 个文件,约 66KB,以 9 个 Swift 源码为主,辅以 plist 配置、Objective-C 混编的 h/m 文件、storyboard 界面以及 json、xcodeproj、entitlements 等工程配置,目录结构较完整,可直接打开对照学习。已有 1705 人学习。代码中展示了内购管理类对 SKProductsRequestDelegate 与 SKPaymentTransactionObserver 的实现方式,覆盖产品请求、支付队列回调、恢复交易成功与失败处理等环节,便于读者快速梳理可运行的 IAP 支付链路,并在此基础上按业务需求扩展。

1. 内购 iOS 支付工具:先把「扣了钱没到账」这条链路堵上

凌晨一点,运营转来一条反馈:用户截图显示付款成功,打开 App 还是普通用户。翻日志才发现,客户端拿到交易后只弹了个「购买成功」,既没做验签也没发货,更没调finish(),队列里那条交易第二天又冒出来一次,重复走了同一段逻辑。这类事在内购 iOS 场景里出现频率高得离谱,跟代码写得好不好关系不大,跟链路有没有被完整拆开关系很大。

这份 Swift 内购支付工具,拆开看就是一套能直接落进工程的 StoreKit 2 封装:商品加载、购买发起、交易验签、发货幂等、订阅状态监听、恢复购买,外加本地 StoreKit 配置和自动化回归用例。它适合已经上线或即将上线、商品类型是消耗型、非消耗型或自动续期订阅的 iOS 项目,也适合手里那套 StoreKit 1 代码已经没人敢动的团队。下面按「先看懂状态机、再跑通最小闭环、再补服务端、最后看坑」的顺序往下走。

2. 交易状态机:StoreKit 2 里四个对象和一条不能跳的链路

内购最容易被写坏的地方,不是 API 调用,是心智模型。很多人把purchase()当成一个「付款请求」,付款成功就等于发货成功。真正的模型是一个交易状态机:交易被创建、被签名、被验证、被发货、被finish()关闭,中途任何一步断掉,下一次启动时它还会回来找你。工具包里的封装就是围绕这个状态机设计的。

2.1 Product、Transaction、VerificationResult、Transaction.updates 各管什么

Product是商品描述,来自Product.products(for:),它只代表「商店里存在这个商品」,不代表用户买过。价格、本地化标题、订阅周期、优惠信息都在这一层,把它当成配置读一次缓存起来即可,不需要每次购买都重新拉。

Transaction是交易凭据,代表「某一笔已经发生的购买行为」。它带id、originalID、productID、purchaseDate、expirationDate、revocationDate、ownershipType、appAccountToken这些字段,服务端发货、判重、算会员到期时间全靠它。

VerificationResult<Transaction>是签名校验结果。StoreKit 2 返回的交易是 JWS 格式,设备侧会拿系统内置的根证书解一遍,你拿到的永远是.verified或.unverified两种之一。这里有个血泪经验:.unverified不是「可能有问题」,是「不可信」,一律当攻击处理,不要为了「提高成功率」放行。

Transaction.updates是一个长期存在的异步序列,负责把「不在当前调用栈里发生的交易」推给你:家长审批通过、银行强验证完成、订阅自动续订、退款、跨设备购买同步。它必须在 App 启动早期就被监听,晚一步就会漏事件。

对象生命周期典型误用
Product一次会话内可缓存每次购买都重新拉,遇到网络抖动就报「商品不存在」
Transaction一直存在,直到finish()拿到就发货,不看revocationDate
VerificationResult瞬时用try?一把梭,把验签失败当成功
Transaction.updates全程监听只在购买页面启动,导致续订事件全丢

2.2 一次购买从点按钮到 finish 的完整时序

把链路写死成七步,后面所有代码都对着这七步看:

  1. 启动时先起Transaction.updates监听任务,早于任何购买动作。
  2. 进商品页时Product.products(for:)拉商品,失败要区分「网络错」和「商品 ID 配错」。
  3. 用户点购买,调product.purchase(options:),把业务账号以appAccountToken的形式塞进去。
  4. 拿到PurchaseResult:.success走验签,.userCancelled静默返回,.pending只更新 UI 文案。
  5. 验签通过后把交易送给服务端发货,服务端返回「发货成功」。
  6. 发货成功才调transaction.finish(),顺序反了就等于主动丢单。
  7. 冷启动或前台恢复时,用Transaction.currentEntitlements重建一次权益状态。

第 5 步和第 6 步的顺序是这套工具最核心的一条约束。finish()的语义是「这条交易我处理完了,商店可以把队列里的它删掉」。先 finish 再发货,一旦发货接口超时,交易已经从队列消失,用户的钱就真的悬空了,只能靠用户主动点「恢复购买」或者服务端通知补单来救,这叫后悔药,能不 relying on 就别 relying on。

2.3 为什么这套工具用 StoreKit 2 而不是继续维护 SKPaymentQueue

选型理由不是「新」,是旧模型在内购 iOS 场景下要额外写太多补偿逻辑。StoreKit 1 的SKPaymentQueue是回调式,paymentQueue(_:updatedTransactions:)里塞一个巨大 switch,事务状态靠SKPaymentTransactionState判断,票据要靠appStoreReceiptURL读出二进制再 base64 送服务端verifyReceipt。这条路能走,但每一环都得自己兜底。

StoreKit 2 换掉了三件事:异步 API 让购买流程可以线性阅读,不用维护状态标志位;本地 JWS 验签让「这条交易是不是商店发的」在设备侧就能先筛一遍;currentEntitlements把「用户当前拥有什么」变成一个可直接遍历的序列,恢复购买和冷启动对账不用再自己拼票据。边界也要说清楚:StoreKit 2 的主 API 从 iOS 15 起可用,AppTransaction要 iOS 16,Product.products(for:)在极老系统上要降级。工具包里对 iOS 15 以下留了 StoreKit 1 的兼容分支,但只保购买和恢复两条主路径,不做完整功能对齐。

另一个实际收益是测试。StoreKit 1 想跑自动化,得靠沙盒账号手工点,CI 里基本没法做。StoreKit 2 配.storekit配置文件和SKTestSession之后,续订、退款、到期这些过去只能靠人等的时间维度,可以在一秒内跑完,这部分放到第 6 章展开。

3. 最小闭环:从配置商品到拉起支付的六步可抄步骤

这一章是可复现的部分。假定工程里已经有一个能编译的 iOS target,Bundle Identifier 已确定,接下来把这条链路从零接起来。

3.1 App Store Connect 与本地 .storekit 配置文件的准备

线上环境侧要做三件事:在「协议、税务和银行业务」里把付费 App 协议签掉并生效,否则Product.products(for:)会返回空数组而不是报错,这是个特别好用的黑匣子;在「App 内购买项目」里建商品,商品 ID 建议用统一前缀加语义化后缀,例如com.example.app.vip_monthly;订阅类商品还要建订阅群组,同一群组内的商品互斥,不同群组可以并存。

本地开发和 CI 不该依赖沙盒账号。做法是 File → New → File → StoreKit Configuration File,建一个IAPProducts.storekit,把同样的商品 ID、类型、价格填进去,然后在 Scheme 的 Run → Options → StoreKit Configuration 里选它。这样模拟器里点购买不会有任何真实扣款,弹出的是本地确认框,可以随意重置。

提示:.storekit文件里的商品 ID 必须和线上完全一致,包括大小写。工具包里有一段启动自检,会对比配置文件里的 ID 集合和代码里硬编码的 ID 集合,不一致直接断言失败,比上线后收到一堆「商品不存在」强。

3.2 商品加载与购买发起

购买服务的入口收在一个@MainActor的类里,避免多线程改字典。下面是工具包中的核心实现:

import StoreKit enum PurchaseError: Error { case productNotFound case unverified(Error) } @MainActor final class PurchaseService { // 商品 ID 必须和配置完全一致,大小写敏感 private let productIDs: Set<String> = [ "com.example.app.coin_60", // 消耗型 "com.example.app.vip_monthly" // 自动续期订阅 ] private var products: [String: Product] = [:] func loadProducts() async throws { let items = try await Product.products(for: productIDs) // 返回不保证顺序,也不保证全部命中,缺失的商品要单独记日志排查 products = Dictionary(uniqueKeysWithValues: items.map { ($0.id, $0) }) } func purchase(productID: String, accountToken: UUID) async throws -> Transaction? { guard let product = products[productID] else { throw PurchaseError.productNotFound } // appAccountToken 会写进交易本身,服务端解 JWS 后能直接拿到,用来绑定业务账号 let result = try await product.purchase(options: [.appAccountToken(accountToken)]) switch result { case .success(let verification): // 验签放在发货之前,不通过直接抛错 switch verification { case .verified(let transaction): return transaction case .unverified(_, let error): throw PurchaseError.unverified(error) } case .userCancelled: return nil case .pending: // 家长审批或银行强验证,结果只会从 Transaction.updates 回来 return nil @unknown default: return nil } } }

逻辑上这段代码干了三件事。第一,把商品 ID 集中成一个常量集合,Product.products(for:)一次拉完,返回的是[Product]而非字典,所以要自己按id建索引,否则每次购买都要线性查找。第二,purchase(options:)的appAccountToken参数是这套设计的关键,它要求传一个UUID,商店会把这个 UUID 原样写进交易的 JWS 载荷里,服务端校验时能直接从appAccountToken字段读出来,不需要再靠「客户端说这是谁买的」来建立关联。

第三,.pending不能当失败处理。它对应的真实场景是「交易已提交,但需要外部动作才能完成」,比如家长用「询问购买」审批、或者部分地区要求银行二次验证。此时 UI 应该显示「等待批准」,而不是弹「购买失败」。批准之后交易会从Transaction.updates推过来,这条路径必须由监听器接住。

参数上有两个细节经常被忽略。accountToken必须是 UUID 格式,自己拼字符串塞进去会在编码阶段就抛错;同一个用户在不同设备上要生成稳定的 UUID,工具包的做法是在 Keychain 里存一份由服务端下发的 UUID,重装 App 也不变,这样服务端按appAccountToken聚合就能对齐同一用户的跨设备购买。

3.3 交易验证、发货与 finish 的先后顺序

监听器是一个独立对象,在 App 启动时构造并start(),比任何 UI 都早:

final class TransactionObserver { private var task: Task<Void, Never>? // 必须早于任何 purchase() 调用,否则会漏掉启动前遗留的待处理交易 func start(handler: @escaping (Transaction) async -> Bool) { task = Task.detached(priority: .background) { for await result in Transaction.updates { guard case .verified(let transaction) = result else { // 验签失败只记日志,不发货,也不 finish continue } // 先让业务侧发货,成功才关闭交易 let granted = await handler(transaction) if granted { await transaction.finish() } } } } deinit { task?.cancel() } }

handler返回Bool而不是Void,是为了把「发货失败就保留交易」这件事变成类型约束。发货失败时交易留在队列里,下次启动Transaction.updates还会把它推一次,相当于系统帮你做了一次重试。这也是为什么第 4 章要坚持服务端幂等——重试是必然发生的,重复发货必须在服务端被挡住。

Task.detached而不是Task {},是为了不让监听任务继承调用方的 actor 上下文,否则它会被@MainActor绑住,在后台线程推事件时表现得很怪。这个坑不常遇到,遇到一次就很难查。

3.4 恢复购买与订阅状态监听

恢复购买不是「补一次购买」,是「让用户在新设备上拿回已有权益」。StoreKit 2 提供了两条路:AppStore.sync()会弹系统登录框强制同步,Transaction.currentEntitlements只读本地已知状态。工具包的做法是冷启动只读本地,恢复按钮才调同步:

@MainActor func refreshEntitlements() async -> Set<String> { var active: Set<String> = [] 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 < Date() { continue } // 订阅已过期 active.insert(t.productID) } return active } @MainActor func restoreByUserAction() async throws { do { try await AppStore.sync() // 会弹系统登录框,只能由用户点击触发 } catch StoreKitError.userCancelled { return } _ = await refreshEntitlements() }

currentEntitlements里会包含已退款和已过期的交易,所以两行过滤不能省。退款交易的revocationDate非空,订阅过期看expirationDate,这两个判断写反了就会出现「退款用户还是会员」或者「刚续费就被踢下线」。AppStore.sync()有速率限制,不能放在viewDidLoad里自动调,只能绑在明确的用户动作上,否则会触发系统节流,表现是点了没反应——又一个典型的黑匣子式故障。

4. 服务端校验:JWS、幂等表和通知补单怎么串起来

客户端能做的到此为止。真正决定「钱和权益对不对得上」的是服务端,因为客户端环境不可信,越狱设备、重放请求、伪造回包都得在服务端被识别出来。

4.1 客户端 finish 为什么不等于发货成功

finish()只对本地交易队列生效,它不产生任何网络请求,商店也不知道你发没发货。这意味着两件事:客户端说「已发货」不能作为记账依据;客户端崩溃、卸载、断网都会导致交易卡在中间态。所以发货这个动作必须由服务端发起,客户端只是把交易凭据递上去。

递上去的东西就是 JWS 字符串。StoreKit 2 的Transaction在jsonRepresentation里能拿到原始载荷,但更推荐的做法是客户端把transaction.id传给服务端,服务端用 App Store Server API 反查。原因是:客户端传来的 JWS 虽然能验签,但无法证明「这条交易是刚发生的」,攻击者可以把一条合法 JWS 重放任意多次。服务端反查拿到的是商店侧的权威状态,重放没有意义。

老项目可能还在用verifyReceipt,那条路要记住两个坑。请求体里receipt-data是 base64 后的票据,password是订阅的共享密钥,exclude-old-transactions设成true能显著减小响应体。生产地址返回21007表示「这是沙盒票据」,必须改打沙盒地址重试;沙盒地址返回21008则反过来。把这两个状态码当失败处理,是上线首日最容易翻车的一类问题。

4.2 App Store Server API 的调用参数与返回字段

工具包的服务端部分走的是新接口,GET /inApps/v1/transactions/{transactionId}拿单笔交易,GET /inApps/v1/subscriptions/{transactionId}拿订阅状态。鉴权用 ES256 签的 JWT,header 里带kid,payload 里iss是发行方 ID,bid是 Bundle ID,aud固定为appstoreconnect-v1,有效期别超过 60 分钟。

字段含义用法
transactionId单笔交易 ID幂等键,唯一索引落在这一列
originalTransactionId首购或首次订阅的 ID订阅类按它聚合,同一订阅的所有续订共享
appAccountToken客户端传入的 UUID映射业务用户,缺失说明客户端没传
productId商品 ID校验和下单时的商品是否一致
purchaseDate购买时间(毫秒)落库时转成 DATETIME
expiresDate订阅到期时间只有订阅类有,消耗型为空
type商品类型Consumable/Non-Consumable/Auto-Renewable Subscription
inAppOwnershipType购买归属PURCHASED或FAMILY_SHARED,家庭共享要单独处理

响应体是signedTransactionInfo包着的 JWS,需要拿商店的证书链验签。header 里的x5c是一个三段的证书链,要用固定根证书去校验证书链,再校验签名,最后才读 payload。跳过验签直接 base64 解 payload,等于把服务端降级成和客户端一样不可信。

4.3 幂等落库:用唯一索引和状态机挡住重复发货

幂等不是靠if判断做出来的,是靠数据库约束。表结构里transaction_id直接做主键:

CREATE TABLE iap_transaction ( transaction_id VARCHAR(32) NOT NULL COMMENT '交易唯一 ID,来自 Transaction.id', original_id VARCHAR(32) NOT NULL COMMENT '首购或首次订阅的交易 ID', app_account_id BIGINT NOT NULL COMMENT '业务用户 ID,由 appAccountToken 映射而来', product_id VARCHAR(128) NOT NULL, environment ENUM('Sandbox','Production') NOT NULL, purchase_date DATETIME NOT NULL, expire_date DATETIME NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0待发货 1已发货 2已退款', created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (transaction_id), KEY idx_user (app_account_id, product_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

发货逻辑用一条 upsert 拿到「这次到底是不是第一次」:

INSERT INTO iap_transaction (transaction_id, original_id, app_account_id, product_id, environment, purchase_date, expire_date, status) VALUES (?, ?, ?, ?, ?, ?, ?, 0) ON DUPLICATE KEY UPDATE transaction_id = transaction_id;

ON DUPLICATE KEY UPDATE transaction_id = transaction_id是个故意写得很「空」的更新,它的作用是命中唯一键时不做任何修改。MySQL 在这种情况下返回的影响行数是 0,插入新行返回 1,用这个返回值就能判断要不要继续走发货。这里有个容易出错的点:某些驱动默认开启CLIENT_FOUND_ROWS,会把 0 变成 1,必须在连接串里关掉,否则幂等直接失效。

发货本身也要拆成两步:先把status从 0 改成 1(条件更新WHERE status = 0),更新成功的那一方才去调业务发货接口。这样即使两个请求同时到达,也只有一个能拿到更新权,另一个影响行数为 0 直接返回。这套「先占位再执行」的写法比分布式锁轻,也不需要额外组件。

4.4 服务端通知 V2 的验签与补单

客户端会掉线、会被杀、会卸载,所以只靠客户端上报必然漏单。App Store Server Notifications V2 是唯一的兜底通道,它把DID_RENEW、EXPIRED、REFUND、DID_FAIL_TO_RENEW、GRACE_PERIOD_EXPIRED这些事件推到你配置的 HTTPS 地址上。

接收端要做四件事。第一,验signedPayload的签名,和上面一样走证书链校验。第二,按notificationType分派,SUBSCRIBED和DID_RENEW走发货或续期,REFUND走撤权,DID_FAIL_TO_RENEW配合subtype判断是进宽限期还是直接过期。第三,用transactionId回查一次交易详情,不要直接信通知里的字段,通知可能乱序到达。第四,无论处理成功失败都返回 200,处理失败靠自己的重试队列补,返回非 2xx 会让商店重推,重推频率不可控。

乱序是这块最反直觉的地方。DID_RENEW可能比SUBSCRIBED先到,退款通知可能早于当初的购买通知。所以落库逻辑必须是「按expiresDate单调推进」,而不是「收到什么就写什么」。工具包里的做法是更新时加条件WHERE expire_date IS NULL OR expire_date < ?,用时间戳本身来抗乱序,比维护事件版本号简单得多。

5. 避坑与排查:内购上线前后最容易翻车的五件事

下面五条都是实际项目里踩出来的,每条按现象、原因、解决写,能对上号就直接照做。

5.1 沙盒环境买得通,线上商品列表是空的

现象:TestFlight 里商品能正常显示、能付款,换成 App Store 下载的正式包,商品页一直转圈然后报「暂无商品」。

原因:付费 App 协议没生效,或者「协议、税务和银行业务」里有待处理的协议。这种情况下Product.products(for:)不会抛错,它返回空数组,所以日志里看不到任何异常。另一个常见原因是商品 ID 在 App Store Connect 里刚创建,还没同步到所有商店节点,通常要等几分钟到几小时。

解决:先在 App Store Connect 里把协议状态确认成「有效」,再在代码里断言items.count == productIDs.count,把「少返回了哪个 ID」打进日志。工具包的启动自检就是干这个的,上线前跑一遍,比等用户反馈快得多。

5.2 订阅用户续订后,权益被提前收回

现象:用户续订成功,服务端也收到了DID_RENEW,但客户端重启后会员没了。

原因:currentEntitlements里同时存在旧交易和新交易,旧交易的expirationDate已经过期,代码里遍历时遇到过期就continue,但如果先处理旧交易、后处理新交易,并且用了「覆盖」而不是「取最大值」的写法,最终写入的到期时间反而是旧的。

解决:重建权益时不要逐条写入,先把所有有效交易的expirationDate收集起来取最大值,再一次性写。同一originalTransactionId下的多条交易属于同一个订阅,只有到期时间最大的那条有意义。

5.3 同一条交易被发了两次货

现象:用户买了一个消耗型商品,账户里到账两次,日志里能看到两条完全相同的transactionId。

原因:Transaction.updates是至少一次投递。发货接口超时但服务端其实已经成功,客户端认为失败于是没有finish(),下次启动交易被重新推送,又发一次货。如果没有幂等约束,这里必然重复。

解决:服务端以transaction_id为主键做唯一约束,发货前用条件更新占位(WHERE status = 0),影响行数为 0 就直接返回成功。客户端的handler返回true让它finish(),避免这条交易被无限重推。

5.4 点了恢复购买毫无反应

现象:用户换机后点「恢复购买」,界面没有任何变化,也没有报错。

原因:AppStore.sync()有调用频率限制,短时间内重复调用会被系统静默节流,既不弹框也不抛错。另一个原因是恢复后没有重新读currentEntitlements,UI 数据源还是旧的。

解决:把sync()绑在一个明确的按钮上,加一个本地冷却时间(工具包里设的是 30 秒),冷却期内按钮置灰。调用完成后必须重新跑一次refreshEntitlements()并刷新 UI,两步缺一不可。

5.5 审核被拒,理由只写了一句「商品无法完成购买」

现象:提审被拒,附件里只有一张购买失败的截图,看不出具体原因。

原因:审核员用的是沙盒账号,如果 App 在沙盒环境下的商品 ID 和线上不一致、或者订阅群组配置错误导致同一群组内商品冲突、或者购买流程里有必须登录才能进入的入口,审核员就走不到购买那一步。

解决:提审前用沙盒账号完整走一遍首购、续订、取消、恢复四条路径,把每一步的截图留在审核备注里。购买入口不要藏,未登录状态下也要能进到商品页。这条没什么技术含量,但被拒一次至少耽误三天。

6. 进阶用法:用 SKTestSession 把续订和退款跑成回归用例

内购最难受的是时间维度不可控。续订要等一个月,宽限期要等六天,退款要等审核。靠手工点根本覆盖不了,而这类路径恰恰是最容易出问题的。工具包里带了一套基于StoreKitTest的用例,把时间压缩到秒级。

import XCTest import StoreKitTest final class SubscriptionTests: XCTestCase { var session: SKTestSession! override func setUpWithError() throws { // 配置文件要在当前 target 的 Bundle 里,且勾选 Target Membership session = try SKTestSession(configurationFileNamed: "IAPProducts") session.resetToDefaultState() session.clearTransactions() session.disableDialogs = true // 关掉系统弹窗,否则 CI 会卡住 session.askToBuyEnabled = false session.timeRate = .oneSecondIsOneDay // 一天压成一秒,续订几秒内跑完 } func testMonthlyRenewalKeepsEntitlement() async throws { let id = "com.example.app.vip_monthly" try session.buyProduct(identifier: id) // 手动推进一个订阅周期,触发自动续订 try session.expireSubscription(identifier: id) let active = await PurchaseService.shared.refreshEntitlements() XCTAssertTrue(active.contains(id), "续订后权益不应丢失") } func testRefundRevokesEntitlement() async throws { let id = "com.example.app.vip_monthly" try session.buyProduct(identifier: id) try session.refundTransaction(identifier: id) let active = await PurchaseService.shared.refreshEntitlements() XCTAssertFalse(active.contains(id), "退款后必须撤权") } }

几个参数值得单独说。timeRate的取值是SKTestSession.TimeRate枚举,.oneSecondIsOneDay是最常用的档位,跑月订阅大约三十秒;CI 里想更快可以用.oneSecondIsOneHour,但要注意某些续订逻辑按天算,压缩太狠会掩盖边界问题。disableDialogs必须开,否则测试会在系统确认框上无限等待,表现是 CI 超时而不是断言失败,很难定位。clearTransactions()放在setUp里保证用例之间互不污染,忘记加会导致第二个用例一启动就带着上一个用例的交易,出现「退款测试通过但其实是上一条交易被撤了」这种假阳性。

上线前的自检我一般会照这张表走一遍,比在脑子里过一遍靠谱:

检查项通过标准
商品 ID 一致性代码常量、.storekit文件、线上配置三方一致
验签分支.unverified有独立日志且不发货
finish()时机只在发货成功后调用
幂等约束transaction_id是主键或唯一索引
通知接收返回 200,乱序有到期时间兜底
恢复购买有冷却,调用后重读权益
沙盒回归首购、续订、取消、退款四条路径跑通

从那以后,我每次接内购都强制先把「商品加载、购买验签、服务端幂等、续订补单」这四段写成可跑的测试再写 UI,宁可前期多花半天,也不想半夜被「扣了钱没到账」叫起来。这套 Swift 内购支付工具里的封装和用例都是照着这个顺序攒的,按第 3 章的六步接进去,再把第 4 章的服务端校验补上,基本能覆盖绝大多数内购 iOS 场景。希望帮到你。

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