# storekit2Client **Repository Path**: Cdiam/storekit2-client ## Basic Information - **Project Name**: storekit2Client - **Description**: No description available - **Primary Language**: Unknown - **License**: MIT - **Default Branch**: main - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-09-14 - **Last Updated**: 2026-09-14 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # StoreKit2Client 独立维护的 Swift Package,提供 StoreKit 2 购买、补单和恢复能力。仅依赖系统 `Foundation`、`StoreKit`,支持 iOS 15+、macOS 12+。 ## Swift Package 接入 这是供 App 链接的 SPM **library**,不是构建期执行的 Swift Package 插件。无需依赖其他业务工程。 在 Xcode 中选择 **File → Add Package Dependencies → Add Local…**,选择当前目录,将 `StoreKit2Client` product 加入 App target,然后: ```swift import StoreKit2Client ``` 如果宿主也是 Swift Package,可使用相邻目录的本地依赖: ```swift dependencies: [ .package(path: "../StoreKit2Client") ] ``` 并在需要支付能力的 target 的 `dependencies` 中加入: ```swift .product(name: "StoreKit2Client", package: "StoreKit2Client") ``` 包可以单独打开、运行 `swift test`、提交代码和发布 Git 版本标签。以后推送到独立 Git 仓库后,其他项目也可以通过仓库 URL 和版本号依赖,无需复制源码。 ## 单文件复制 将 **`Sources/StoreKit2Client/StoreKit2Client.swift` 这一个文件**复制到目标工程并加入 App target 即可;文件底部的 StoreKit 适配代码也要一起复制。仅依赖系统 `Foundation`、`StoreKit`,支持 iOS 15+、macOS 12+。 `Package.swift` 和 `Tests` 用于独立构建及验证,选择单文件复制方式时不需要它们,也不需要 `import StoreKit2Client`。 每个 App 支付流程持有一个长生命周期实例。不要在每次打开付费页时重新创建。所有公开方法在 `@MainActor` 上调用,网络操作通过异步交付回调完成。 ## 公开接口 | 接口 | 用途 | | --- | --- | | `start(deliveryHandler:onEvent:)` | 先监听交易更新,再自动扫描未完成交易及当前权益;可重复调用,不重复注册 | | `loadProducts(identifiers:)` | 获取最新商品,明确返回缺失 ID;用于重试加载及展示本地化价格 | | `purchase(productID:options:)` | 发起购买,区分 Apple 已验证、等待批准、用户取消 | | `reconcile(includeHistory:)` | 静默补单和权益同步;适用于启动、回到前台、网络恢复、登录完成及手动重试 | | `restorePurchases(includeHistory:)` | 用户点击恢复时调用 `AppStore.sync()`,随后重新对账;默认包括交易历史 | | `currentEntitlements()` | 只读 Apple 权益快照,包括宽限期和校验失败信息;不会自动授予服务端权益 | | `stop()` | 停止监听及本地重试,取消处理中任务;不结束任何未交付交易 | | `canMakePayments` | 查询设备是否允许内购 | | `isPerformingUserOperation` | 是否有购买或恢复操作正在执行,辅助 UI 禁用按钮 | ### 配置项 `Configuration` 全部有默认值,通常只在需要调整服务端压力或重试节奏时才改。 | 配置 | 默认值 | 说明 | | --- | --- | --- | | `productIDs` | `nil` | 处理全部商品;传白名单时记得包含已下架商品,否则它们的交易不会被结束 | | `retryDelays` | `[2, 5, 15, 30, 60, 300]` | 交付失败的退避序列,之后固定按最后一个值重试,不会因次数上限丢弃交易 | | `retryPollInterval` | `2` 秒 | 重试检查间隔。**只在存在待重试交易时运行**,队列排空后定时器自动停止,空闲实例不唤醒主线程 | | `maxConcurrentDeliveries` | `4` | 不同交易之间的交付并发上限。同一笔交易始终串行。设为 `1` 即完全串行 | `maxConcurrentDeliveries` 主要影响恢复购买:长期订阅用户的 `Transaction.all` 可能有几十笔,串行执行会让恢复按钮长时间置灰。调高可缩短等待,但服务端瞬时压力同比上升;服务端接口脆弱时下调或设为 `1`。 ### 最小接入示例 以下 `backend` 是宿主工程实现的服务端适配器,不是工具类内置接口。工具类不猜测服务器地址、订单字段或业务错误码。 ```swift import StoreKit @MainActor final class Payments { let store = StoreKit2Client() private let backend: PaymentBackend private(set) var hasAccess = false init(backend: PaymentBackend) { self.backend = backend } func start() { store.start(deliveryHandler: { [weak self] transaction in guard let self else { return .retryLater(reason: "Payment coordinator unavailable") } // 适配器负责:登录、上传完整 JWS、服务端验签、幂等对账、返回最新权益。 let result = try await backend.synchronize( signedTransactionInfo: transaction.signedTransactionInfo ) switch result { case .applied(let hasAccess): // 退款、过期或已被替换的交易可以成功对账,但 hasAccess 为 false。 self.hasAccess = hasAccess return .delivered case .processing: return .retryLater(reason: "Server is still processing the transaction") case .accountConflict: return .requiresUserAction(reason: "Resolve purchase ownership before retrying") } }) } func buy(productID: String, serverIssuedToken: UUID) async throws { let result = try await store.purchase( productID: productID, options: [.appAccountToken(serverIssuedToken)] ) switch result { case .verified(_, .delivered): // 支付已对账;根据 hasAccess 显示当前可用权益。 break case .verified(_, .retryScheduled(let reason)): // 显示“支付已确认,权益同步中”,不要提示重新付款。 print(reason) case .verified(_, .requiresUserAction(let reason)): // 显示登录/账号归属处理入口。 print(reason) case .verified(_, .ignored): // 旧状态或作用域之外的交易;重新查询业务权益,不直接解锁。 break case .pending: // 等待家长批准等外部操作;之后通过交易监听处理。 break case .cancelled: break } } func restore() async throws { let report = try await store.restorePurchases() if report.hasOutstandingWork { // 显示仍需同步/处理的问题;不能仅因 sync() 成功就提示恢复成功。 return } // 如果没有交易触发回调,仍需向后端刷新账号权益,处理空快照及权益撤销。 hasAccess = try await backend.currentAccess() // 按 hasAccess 区分“已恢复”与“没有可恢复的权益”。 } func becameActive() async throws { _ = try await store.reconcile() hasAccess = try await backend.currentAccess() } } @MainActor protocol PaymentBackend { func synchronize(signedTransactionInfo: String) async throws -> BackendResult func currentAccess() async throws -> Bool } enum BackendResult { // 服务端已经持久化对账结果,而不是仅接收了异步任务。 case applied(hasAccess: Bool) case processing case accountConflict } ``` 收到后台 `transactionProcessed` 事件时也可以刷新 UI。示例直接在交付回调中更新状态,因此等待批准、续费或恢复触发的交付也会生效。 交付回调**必须**设置有界的网络超时。回调永久挂起时,本工具不会替它兜底——它只能保证其他交易不被这一笔拖住,无法让这一笔自己结束。不要在回调内部等待本工具的 `reconcile()`、`restorePurchases()` 或同笔交易,否则会形成等待环。 不同交易的回调默认最多 4 路并发执行(见 `maxConcurrentDeliveries`),同一笔交易仍然串行。回调都在 `@MainActor` 上,但会在 `await` 挂起点交错,因此服务端和宿主的权益更新都要防止旧响应覆盖新状态。 ## 补单与结束交易的规则 1. 本地校验通过的交易统一进入交付回调。订单号为空、Token 为空、退款和过期都不能成为直接丢弃交易的理由。 2. **只有 `.delivered` 才调用 `finish()`。** 该结果表示服务端已经持久化处理结果,或幂等确认此前已经处理;不代表该交易一定授予当前会员资格。 3. 回调抛错或返回 `.retryLater` 时保留交易,按 2、5、15、30、60、300 秒退避;后续间隔保持 300 秒。没有“超过次数就删除交易”的逻辑。重试定时器只在队列非空时运行,排空后自动停止。 4. `.requiresUserAction` 不做自动重试,也不结束交易。用户解决问题后调用 `reconcile()` 或恢复购买。 5. 内存重试队列只用于当前进程调度。App 重启后依靠 `Transaction.unfinished` 重建;没有独立的本地订单库,没有 24 小时删除订单逻辑。 6. 已完成但未在当前账号同步的有效订阅,通过 `currentEntitlements` 再次提交服务端;手动恢复默认还扫描 `Transaction.all`,支持历史补偿、退款和过期状态对账。 7. 同一交易的并发回调共用处理结果;同一个交易 ID 后续出现退款或升级状态变化时会重新处理。旧的退款前快照不会覆盖已观察到的较新交易状态。 8. 购买抛错、结果未知或 UI 任务在付款后被取消时,独立安排一次静默恢复扫描,不把异常等同于“未扣款”,也不要求本机购买成功必须出现在 `updates` 中。 9. **不同交易**的交付回调按 `maxConcurrentDeliveries`(默认 4)并发执行,**同一笔交易**始终串行。因此一笔交易的服务端调用变慢,不会拖住其他交易的补单和重试。 这是 **至少一次交付**:启动、手动恢复、账号切换后,服务端可能再次收到同一交易。不能用客户端内存去重替代服务端幂等。即使 Apple 确认 `finish()` 前崩溃,服务端也应接受重放并返回已经处理的结果。 ## 订单号与账号关联 - 推荐服务端分配稳定的账号 UUID 作为 `appAccountToken`。服务器通过 `transactionId` 记录每笔支付,通过 `originalTransactionId` 关联订阅链。 - 如果使用业务订单 UUID 作为 Token,服务端必须在调用 Apple 购买前保存 Token、账号和订单的关联。非 UUID 订单号也应由服务端生成映射 UUID,不要仅在客户端随机生成后丢失关联。 - 工具类不要求 Token 必须存在。旧交易、家庭共享或外部购买必须按服务端的归属规则处理,不能直接绑定到当前登录账号。 - 自动续费产生新的交易 ID,不能把初始订单关联 Token 当成每次续费的新订单号。 - 服务端至少应接受完整 JWS,完成签名、bundle ID、环境、商品和账号归属校验;处理重复交付、续费、退款、撤销、升级及宽限期。 - API 返回“处理中”、超时、5xx、限流或认证失败,不应映射为 `.delivered`。业务适配器可以刷新认证后有界重试;持久化对账与权益变更应具有事务一致性。 - Token 只是关联信息,不是授权凭证。服务端不能只相信客户端传入的交易 ID 或 Token。 如果既有服务端只能接收“本地订单号 + transactionId”,必须先补齐从 JWS/交易链恢复关联的能力;这个工具类不会凭空消除后端接口的限制。 ## 恢复、前后台与权益边界 - `AppStore.sync()` 只在用户点击恢复时调用;日常补单调用不弹 Apple 登录框的 `reconcile()`。 - `start()` 应尽早调用,先建立监听;认证可以在交付回调里等待。登录完成、网络恢复和 `scenePhase == .active` 时调用 `reconcile()`。 - `reconcile()` 同步当前权益时不会按本地 `expirationDate` 再次过滤自动续费订阅,以保留 Apple 返回的计费宽限期权益。非自动续期订阅时长仍由业务层核算。 - 报告中的 Apple 权益、交付结果和服务端可用权益是不同信息;`hasOutstandingWork == false` 也可能表示没有任何可恢复的交易。 - 取消自动续费不等于立刻失去权益。退款、过期后当前权益快照可能为空,宿主应同时刷新服务端权益;服务端需要处理 App Store Server Notifications V2 并做定期对账。 - 在账号切换前 `stop()`,切换后重新 `start()`,不要共享不同账号的已授权 UI 状态。已提交的服务器请求或已打开的 Apple 支付弹窗无法被本地取消撤回。 - App 被挂起或杀死时,客户端无法持续执行重试;服务端通知和再次启动补单负责覆盖这段时间。没有任何单个客户端类可以保证 App 永不再打开时仍完成后端交付。 - 已经错误 `finish()` 的消耗型交易,默认不一定能从交易历史取回;需要服务端账本补偿。历史中是否包含已完成消耗型交易取决于系统版本及 `SKIncludeConsumableInAppPurchaseHistory` 配置。 - `.pending` 可能尚未产生交易,不能把它伪造为失败或已购买。业务侧“已创建但尚未支付”的订单由服务端查询和关闭;本工具不保存待批准 UI 状态,也不持续阻塞其他购买。 商品订阅组、等级、试用优惠、家庭共享和宽限期应在 App Store Connect 正确配置。管理订阅/退款弹窗需要宿主窗口或 Scene,属于 UI 接入层;本工具保留 Apple 交易 ID,宿主可直接接入对应 StoreKit API。促销优惠和数量等购买参数通过原生 `Product.PurchaseOption` 传入。 ## 测试 在本目录运行: ```sh swift test ``` 测试使用可控的 StoreKit 边界和时钟,覆盖启动补单、无 Token、断网/服务端失败、退避、重启恢复、已完成交易恢复、宽限期、历史退款、校验失败、并发去重、同 ID 退款更新、停止期间回调、恢复快照时序、操作互斥、商品加载重试、并发交付限流、单笔交易串行、重试定时器按需启停,以及并发扫描失败不传染给调用方。 这些测试只覆盖 `StoreKit2Client` 的调度与交付协议。文件底部的 `AppleStoreKit2Driver`——即真正调用 `Product.purchase`、`Transaction.updates` 并把 `VerificationResult` 映射成 `TransactionRecord` 的那一层——**没有被单元测试执行过**,字段映射错误无法靠 `swift test` 发现。接入真实工程后必须使用 StoreKit Configuration / Sandbox 验证真实购买、Ask to Buy、续费、退款、恢复、跨设备以及后端幂等。 ## 许可证 [MIT](LICENSE)。 ## Apple 参考 - [Transaction.updates](https://developer.apple.com/documentation/storekit/transaction/updates) - [Transaction.unfinished](https://developer.apple.com/documentation/storekit/transaction/unfinished) - [Transaction.currentEntitlements](https://developer.apple.com/documentation/storekit/transaction/currententitlements) - [Transaction.all](https://developer.apple.com/documentation/storekit/transaction/all) - [Transaction.finish()](https://developer.apple.com/documentation/storekit/transaction/finish()) - [AppStore.sync()](https://developer.apple.com/documentation/storekit/appstore/sync()) - [Transaction.appAccountToken](https://developer.apple.com/documentation/storekit/transaction/appaccounttoken)