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.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))
|
||||
|
||||
@ -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