feat(iap): 移除苹果IAP商品列表接口并添加接入指南文档
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 6m44s

移除不再使用的苹果IAP商品列表相关代码,包括handler、logic和测试文件
新增详细的iOS内购接入指南文档,包含StoreKit2使用流程和接口规范
This commit is contained in:
shanshanzhong 2025-12-14 18:47:37 -08:00
parent 62186ca672
commit 0e493caf16
5 changed files with 228 additions and 132 deletions

228
doc/ios-iap-guide.md Normal file
View File

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

View File

@ -1,17 +0,0 @@
package apple
import (
"github.com/gin-gonic/gin"
appleLogic "github.com/perfect-panel/server/internal/logic/public/iap/apple"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/result"
)
func GetAppleProductsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
l := appleLogic.NewGetProductsLogic(c.Request.Context(), svcCtx)
resp, err := l.GetProducts()
result.HttpResult(c, resp, err)
}
}

View File

@ -1,72 +0,0 @@
package apple
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
)
// TestGetAppleProductsHandler 用于验证产品列表接口
// 参数:无
// 返回:无;断言接口返回的产品数量与字段正确性
func TestGetAppleProductsHandler(t *testing.T) {
gin.SetMode(gin.TestMode)
cd := `{
"iapProductMap": {
"com.airport.vpn.pass.30d": {
"description": "30天通行证",
"priceText": "¥28.00",
"durationDays": 30,
"tier": "Basic",
"subscribeId": 1001
},
"com.airport.vpn.pass.90d": {
"description": "90天通行证",
"priceText": "¥68.00",
"durationDays": 90,
"tier": "Pro",
"subscribeId": 1002
}
},
"iapBundleId": "co.airoport.app.ios"
}`
s := &svc.ServiceContext{
Config: config.Config{
Site: config.SiteConfig{
CustomData: cd,
},
},
}
r := gin.New()
r.GET("/v1/public/iap/apple/products", GetAppleProductsHandler(s))
w := httptest.NewRecorder()
req, _ := http.NewRequest(http.MethodGet, "/v1/public/iap/apple/products", nil)
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status != 200, got %d", w.Code)
}
type wrap struct {
Code uint32 `json:"code"`
Msg string `json:"msg"`
Data types.GetAppleProductsResponse `json:"data"`
}
var resp wrap
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("unmarshal error: %v", err)
}
if resp.Code != 200 {
t.Fatalf("code != 200, got %d", resp.Code)
}
if len(resp.Data.List) != 2 {
t.Fatalf("expect 2 products, got %d", len(resp.Data.List))
}
if resp.Data.List[0].ProductId == "" || resp.Data.List[0].DurationDays == 0 || resp.Data.List[0].SubscribeId == 0 {
t.Fatalf("invalid fields in product item")
}
}

View File

@ -723,7 +723,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
iapAppleGroupRouter := router.Group("/v1/public/iap/apple")
iapAppleGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
{
iapAppleGroupRouter.GET("/products", publicIapApple.GetAppleProductsHandler(serverCtx))
iapAppleGroupRouter.GET("/status", publicIapApple.GetAppleStatusHandler(serverCtx))
iapAppleGroupRouter.POST("/transactions/attach", publicIapApple.AttachAppleTransactionHandler(serverCtx))
iapAppleGroupRouter.POST("/restore", publicIapApple.RestoreAppleTransactionsHandler(serverCtx))

View File

@ -1,42 +0,0 @@
package apple
import (
"context"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
iapapple "github.com/perfect-panel/server/pkg/iap/apple"
)
type GetProductsLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
func NewGetProductsLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetProductsLogic {
return &GetProductsLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetProductsLogic) GetProducts() (*types.GetAppleProductsResponse, error) {
pm, _ := iapapple.ParseProductMap(l.svcCtx.Config.Site.CustomData)
resp := &types.GetAppleProductsResponse{
List: make([]types.AppleProduct, 0, len(pm.Items)),
}
for pid, m := range pm.Items {
resp.List = append(resp.List, types.AppleProduct{
ProductId: pid,
Description: m.Description,
PriceText: m.PriceText,
DurationDays: m.DurationDays,
Tier: m.Tier,
SubscribeId: m.SubscribeId,
})
}
return resp, nil
}