feat(iap): 移除苹果IAP商品列表接口并添加接入指南文档
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 6m44s
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:
parent
62186ca672
commit
0e493caf16
228
doc/ios-iap-guide.md
Normal file
228
doc/ios-iap-guide.md
Normal 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 即可绑定;建议后续让两端保持一致以减少维护成本。
|
||||||
|
- 权益冲突:若用户同时存在多来源订阅,服务端按最高等级与最晚到期计算权益。
|
||||||
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@ -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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -723,7 +723,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
iapAppleGroupRouter := router.Group("/v1/public/iap/apple")
|
iapAppleGroupRouter := router.Group("/v1/public/iap/apple")
|
||||||
iapAppleGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
iapAppleGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||||
{
|
{
|
||||||
iapAppleGroupRouter.GET("/products", publicIapApple.GetAppleProductsHandler(serverCtx))
|
|
||||||
iapAppleGroupRouter.GET("/status", publicIapApple.GetAppleStatusHandler(serverCtx))
|
iapAppleGroupRouter.GET("/status", publicIapApple.GetAppleStatusHandler(serverCtx))
|
||||||
iapAppleGroupRouter.POST("/transactions/attach", publicIapApple.AttachAppleTransactionHandler(serverCtx))
|
iapAppleGroupRouter.POST("/transactions/attach", publicIapApple.AttachAppleTransactionHandler(serverCtx))
|
||||||
iapAppleGroupRouter.POST("/restore", publicIapApple.RestoreAppleTransactionsHandler(serverCtx))
|
iapAppleGroupRouter.POST("/restore", publicIapApple.RestoreAppleTransactionsHandler(serverCtx))
|
||||||
|
|||||||
@ -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
|
|
||||||
}
|
|
||||||
Loading…
x
Reference in New Issue
Block a user