All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 6m44s
移除不再使用的苹果IAP商品列表相关代码,包括handler、logic和测试文件 新增详细的iOS内购接入指南文档,包含StoreKit2使用流程和接口规范
8.8 KiB
8.8 KiB
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)
- 检查支付能力
if !AppStore.canMakePayments { 隐藏商店并提示 }
- 拉取商品
- 通过已知
productId列表调用Product.products(for:),展示价格与描述
- 通过已知
- 发起购买并本地验证
- 调用
try await product.purchase()弹出系统确认表单 - 成功后
let transaction = try verification.payloadValue,并取到transaction.signedData(JWS)
- 调用
- 绑定购买(服务端 attach)
- 将
signedData作为signed_transaction_jwsPOST 至/v1/public/iap/apple/transactions/attach - 若服务端未配置该
productId,同时携带:duration_days(有效天数)、subscribe_id(内部订阅计划 ID)、tier(展示用标签)
- 将
- 恢复购买(restore)
try await AppStore.sync()后,遍历Transaction.currentEntitlements并逐条verify()- 收集每条
signedData,批量 POST 至/v1/public/iap/apple/restore
- 查询状态(status)
GET /v1/public/iap/apple/status获取active/expires_at/tier,用于 UI 展示与权限控制
- 退款入口(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": "..." }
- Stripe:
前端处理逻辑(统一出口)
- 收银台响应分流:
type === "apple_iap"→ 取product_ids,用 StoreKit 拉取并购买,成功后将signedData调用attachtype === "stripe"→ 初始化 Stripe 支付组件type === "url"→ 直接跳转到返回的checkout_urltype === "qr"→ 展示二维码URL
命名规则(推荐)
- Apple 商品命名统一为:
merchant.hifastvpn.day${quantity},例如:- 7 天:
merchant.hifastvpn.day7 - 30 天:
merchant.hifastvpn.day30 - 90 天:
merchant.hifastvpn.day90
- 7 天:
- 新增套餐时只需:在 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 即可绑定;建议后续让两端保持一致以减少维护成本。- 权益冲突:若用户同时存在多来源订阅,服务端按最高等级与最晚到期计算权益。