All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 6m44s
移除不再使用的苹果IAP商品列表相关代码,包括handler、logic和测试文件 新增详细的iOS内购接入指南文档,包含StoreKit2使用流程和接口规范
229 lines
8.8 KiB
Markdown
229 lines
8.8 KiB
Markdown
# 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 <token>`。
|
||
- 绑定购买(attach)
|
||
- `POST /v1/public/iap/apple/transactions/attach`
|
||
- 请求体(映射一致时,仅需 `signed_transaction_jws`):
|
||
```json
|
||
{
|
||
"signed_transaction_jws": "<StoreKit返回的signedData>"
|
||
}
|
||
```
|
||
- 请求体(映射不一致时的回退):
|
||
```json
|
||
{
|
||
"signed_transaction_jws": "<signedData>",
|
||
"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": ["<signedData-1>", "<signedData-2>"]
|
||
}
|
||
```
|
||
- 响应示例:
|
||
```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 即可绑定;建议后续让两端保持一致以减少维护成本。
|
||
- 权益冲突:若用户同时存在多来源订阅,服务端按最高等级与最晚到期计算权益。
|