# iOS 内购接入与接口调用指南(StoreKit 2 + 服务端接口) ## 概述 本指南面向 iOS App 开发者,说明使用 StoreKit 2 完成「非续期订阅/非消耗型」的购买、验证与绑定流程,并与后端接口打通,实现用户权益发放与恢复。 ## 商品与映射 - Apple 端必须在 App Store Connect 创建对应 `productId` 的内购商品(非续期订阅或非消耗型)。 - 服务端维护「商品映射」:`productId → {durationDays, tier, subscribeId}`,用于计算到期与绑定内部订阅计划(`subscribeId`)。 - 若某 `productId` 暂未在服务端配置,客户端可在绑定请求中携带回退字段:`duration_days`、`subscribe_id`、`tier`。服务端将按 App 的定义进行绑定。 ## 客户端整体流程(StoreKit 2) 1) 检查支付能力 - `if !AppStore.canMakePayments { 隐藏商店并提示 }` 2) 拉取商品 - 通过已知 `productId` 列表调用 `Product.products(for:)`,展示价格与描述 3) 发起购买并本地验证 - 调用 `try await product.purchase()` 弹出系统确认表单 - 成功后 `let transaction = try verification.payloadValue`,并取到 `transaction.signedData`(JWS) 4) 绑定购买(服务端 attach) - 将 `signedData` 作为 `signed_transaction_jws` POST 至 `/v1/public/iap/apple/transactions/attach` - 若服务端未配置该 `productId`,同时携带:`duration_days`(有效天数)、`subscribe_id`(内部订阅计划 ID)、`tier`(展示用标签) 5) 恢复购买(restore) - `try await AppStore.sync()` 后,遍历 `Transaction.currentEntitlements` 并逐条 `verify()` - 收集每条 `signedData`,批量 POST 至 `/v1/public/iap/apple/restore` 6) 查询状态(status) - `GET /v1/public/iap/apple/status` 获取 `active/expires_at/tier`,用于 UI 展示与权限控制 7) 退款入口(HIG 建议) - 在购买帮助页提供「请求退款」按钮,调用 `beginRefundRequest(for:in:)` ## 接口详细 所有接口均需要携带用户登录态的 `Authorization: Bearer `。 - 绑定购买(attach) - `POST /v1/public/iap/apple/transactions/attach` - 请求体(映射一致时,仅需 `signed_transaction_jws`): ```json { "signed_transaction_jws": "" } ``` - 请求体(映射不一致时的回退): ```json { "signed_transaction_jws": "", "duration_days": 30, "subscribe_id": 1001, "tier": "Basic" } ``` - 响应示例: ```json { "code": 200, "msg": "success", "data": { "expires_at": 1736860000, "tier": "Basic" } } ``` - 恢复购买(restore) - `POST /v1/public/iap/apple/restore` - 请求体: ```json { "transactions": ["", ""] } ``` - 响应示例: ```json { "code": 200, "msg": "success", "data": { "success": true } } ``` - 查询状态(status) - `GET /v1/public/iap/apple/status` - 响应示例: ```json { "code": 200, "msg": "success", "data": { "active": true, "expires_at": 1736860000, "tier": "Basic" } } ``` ## 收银台统一返回契约(含 Apple IAP) - 统一下单接口返回的 `type` 用于客户端决定支付方式;当为 Apple IAP 时,返回 Apple 商品 ID 列表,前端直接用 StoreKit 购买: - Apple IAP 收银台返回示例(建议规范): ```json { "code": 200, "msg": "success", "data": { "type": "apple_iap", "product_ids": [ "merchant.hifastvpn.day7", "merchant.hifastvpn.day30" ], "hint": "Use StoreKit to purchase the given product_ids, then POST signedData to attach." } } ``` - 其他支付平台保持原有结构: - Stripe:`{ "type":"stripe", "stripe": { "publishable_key": "...", "client_secret":"..." } }` - URL 跳转:`{ "type":"url", "checkout_url": "https://..." }` - 二维码:`{ "type":"qr", "checkout_url": "..." }` ### 前端处理逻辑(统一出口) - 收银台响应分流: - `type === "apple_iap"` → 取 `product_ids`,用 StoreKit 拉取并购买,成功后将 `signedData` 调用 `attach` - `type === "stripe"` → 初始化 Stripe 支付组件 - `type === "url"` → 直接跳转到返回的 `checkout_url` - `type === "qr"` → 展示二维码URL ### 命名规则(推荐) - Apple 商品命名统一为:`merchant.hifastvpn.day${quantity}`,例如: - 7 天:`merchant.hifastvpn.day7` - 30 天:`merchant.hifastvpn.day30` - 90 天:`merchant.hifastvpn.day90` - 新增套餐时只需:在 App Store Connect 新增对应商品,并在 Web 后台/配置新增映射(`durationDays/tier/subscribeId`),前端无需改代码即可使用。 ## Swift 示例 ### 拉取商品与展示 ```swift import StoreKit let productIds = ["com.airport.vpn.pass.30d", "com.airport.vpn.pass.90d"] let products = try await Product.products(for: productIds) // 展示 products 的价格与描述 ``` ### 发起购买并绑定 ```swift import StoreKit func purchaseAndAttach(product: Product, token: String) async throws { let result = try await product.purchase() switch result { case .success(let verification): let transaction = try verification.payloadValue let jws = transaction.signedData struct AttachReq: Codable { let signed_transaction_jws: String // 若映射不一致,则补齐以下字段 let duration_days: Int64? let subscribe_id: Int64? let tier: String? } let body = AttachReq( signed_transaction_jws: jws, duration_days: nil, // 映射一致时可为 nil subscribe_id: nil, tier: nil ) var req = URLRequest(url: URL(string: "https://api.yourdomain.com/v1/public/iap/apple/transactions/attach")!) req.httpMethod = "POST" req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") req.addValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONEncoder().encode(body) let (data, _) = try await URLSession.shared.data(for: req) // 解析返回并更新 UI default: break } } ``` ### 恢复购买(批量) ```swift import StoreKit func restorePurchases(token: String) async throws { try await AppStore.sync() var signedDataList: [String] = [] for await t in Transaction.currentEntitlements { let v = try t.verificationResult.payloadValue signedDataList.append(v.signedData) } struct RestoreReq: Codable { let transactions: [String] } let body = RestoreReq(transactions: signedDataList) var req = URLRequest(url: URL(string: "https://api.yourdomain.com/v1/public/iap/apple/restore")!) req.httpMethod = "POST" req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") req.addValue("application/json", forHTTPHeaderField: "Content-Type") req.httpBody = try JSONEncoder().encode(body) let (_, _) = try await URLSession.shared.data(for: req) } ``` ### 查询状态 ```swift func fetchIAPStatus(token: String) async throws { var req = URLRequest(url: URL(string: "https://api.yourdomain.com/v1/public/iap/apple/status")!) req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization") let (data, _) = try await URLSession.shared.data(for: req) // 解析 active/expires_at/tier } ``` ### 退款入口(帮助页) ```swift // 在 App 内购买帮助页面提供按钮,调用系统退款流程 // try await beginRefundRequest(for: product, in: windowScene) ``` ## 错误处理建议 - 绑定失败(`code != 200`) - 校验用户登录态、`signedData` 是否来自 `transaction.verify()` 的成功结果 - 若提示 `unknown product`,请在请求体中按回退规范携带 `duration_days/subscribe_id/tier` - 网络与重试 - 建议对 `attach/restore` 做有限重试与幂等保护(相同 `originalTransactionId` 不重复绑定) - 恢复失败 - 确保已调用 `AppStore.sync()` 并遍历 `currentEntitlements` ## 调试与沙盒 - 使用 Sandbox 账号进行测试;购买成功后调用 `attach → status`,验证到期与等级;再次 `restore → status` 验证幂等。 - 建议打印 `request_id`(如有)便于后端排查。 ## HIG 注意事项 - 仅在可支付时显示商店;价格与文案清晰,不截断标题;使用系统确认表单,不自定义购买弹窗。 - 在帮助页提供退款入口与说明,文案简洁直达。 ## 常见问题 - `productId` 不一致:服务端未配置某商品时,客户端按回退规范补齐时长与订阅 ID 即可绑定;建议后续让两端保持一致以减少维护成本。 - 权益冲突:若用户同时存在多来源订阅,服务端按最高等级与最晚到期计算权益。