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

8.8 KiB
Raw Blame History

iOS 内购接入与接口调用指南StoreKit 2 + 服务端接口)

概述

本指南面向 iOS App 开发者,说明使用 StoreKit 2 完成「非续期订阅/非消耗型」的购买、验证与绑定流程,并与后端接口打通,实现用户权益发放与恢复。

商品与映射

  • Apple 端必须在 App Store Connect 创建对应 productId 的内购商品(非续期订阅或非消耗型)。
  • 服务端维护「商品映射」:productId → {durationDays, tier, subscribeId},用于计算到期与绑定内部订阅计划(subscribeId)。
  • 若某 productId 暂未在服务端配置,客户端可在绑定请求中携带回退字段:duration_dayssubscribe_idtier。服务端将按 App 的定义进行绑定。

客户端整体流程StoreKit 2

  1. 检查支付能力
    • if !AppStore.canMakePayments { 隐藏商店并提示 }
  2. 拉取商品
    • 通过已知 productId 列表调用 Product.products(for:),展示价格与描述
  3. 发起购买并本地验证
    • 调用 try await product.purchase() 弹出系统确认表单
    • 成功后 let transaction = try verification.payloadValue,并取到 transaction.signedDataJWS
  4. 绑定购买(服务端 attach
    • signedData 作为 signed_transaction_jws POST 至 /v1/public/iap/apple/transactions/attach
    • 若服务端未配置该 productId,同时携带:duration_days(有效天数)、subscribe_id(内部订阅计划 IDtier(展示用标签)
  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
      {
        "signed_transaction_jws": "<StoreKit返回的signedData>"
      }
      
    • 请求体(映射不一致时的回退):
      {
        "signed_transaction_jws": "<signedData>",
        "duration_days": 30,
        "subscribe_id": 1001,
        "tier": "Basic"
      }
      
    • 响应示例:
      {
        "code": 200,
        "msg": "success",
        "data": { "expires_at": 1736860000, "tier": "Basic" }
      }
      
  • 恢复购买restore
    • POST /v1/public/iap/apple/restore
    • 请求体:
      {
        "transactions": ["<signedData-1>", "<signedData-2>"]
      }
      
    • 响应示例:
      {
        "code": 200,
        "msg": "success",
        "data": { "success": true }
      }
      
  • 查询状态status
    • GET /v1/public/iap/apple/status
    • 响应示例:
      {
        "code": 200,
        "msg": "success",
        "data": { "active": true, "expires_at": 1736860000, "tier": "Basic" }
      }
      

收银台统一返回契约(含 Apple IAP

  • 统一下单接口返回的 type 用于客户端决定支付方式;当为 Apple IAP 时,返回 Apple 商品 ID 列表,前端直接用 StoreKit 购买:
  • Apple IAP 收银台返回示例(建议规范):
    {
      "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 示例

拉取商品与展示

import StoreKit

let productIds = ["com.airport.vpn.pass.30d", "com.airport.vpn.pass.90d"]
let products = try await Product.products(for: productIds)
// 展示 products 的价格与描述

发起购买并绑定

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
    }
}

恢复购买(批量)

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)
}

查询状态

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
}

退款入口(帮助页)

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