hi-server/doc/ios-iap-guide.md
shanshanzhong 0e493caf16
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 6m44s
feat(iap): 移除苹果IAP商品列表接口并添加接入指南文档
移除不再使用的苹果IAP商品列表相关代码,包括handler、logic和测试文件
新增详细的iOS内购接入指南文档,包含StoreKit2使用流程和接口规范
2025-12-14 18:47:37 -08:00

229 lines
8.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 即可绑定;建议后续让两端保持一致以减少维护成本。
- 权益冲突:若用户同时存在多来源订阅,服务端按最高等级与最晚到期计算权益。