shanshanzhong 62186ca672
All checks were successful
Build docker and publish / build (20.15.1) (push) Successful in 6m37s
feat(iap/apple): 实现苹果IAP非续期订阅功能
新增苹果IAP相关接口与逻辑,包括产品列表查询、交易绑定、状态查询和恢复购买功能。移除旧的IAP验证逻辑,重构订阅系统以支持苹果IAP交易记录存储和权益计算。

- 新增/pkg/iap/apple包处理JWS解析和产品映射
- 实现GET /products、POST /attach、POST /restore和GET /status接口
- 新增apple_iap_transactions表存储交易记录
- 更新文档说明配置方式和接口规范
- 移除旧的AppleIAP验证和通知处理逻辑
2025-12-13 20:54:50 -08:00

220 lines
6.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package apple
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/config"
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
submodel "github.com/perfect-panel/server/internal/model/subscribe"
usermodel "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/constant"
"github.com/redis/go-redis/v9"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
// TestIAPAttachFlow 覆盖完整一次用户购买绑定的接口流程
// 步骤初始化内存DB+Redis → 配置产品映射 → 创建用户与订阅计划 → 调用attach接口 → 断言返回与落库
func TestIAPAttachFlow(t *testing.T) {
gin.SetMode(gin.TestMode)
// sqlite 内存数据库
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite error: %v", err)
}
if err := db.AutoMigrate(
&usermodel.User{},
&iapmodel.Transaction{},
); err != nil {
t.Fatalf("automigrate error: %v", err)
}
// sqlite 手工创建 subscribe 与 user_subscribe 表,避免不兼容的默认值语法
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS subscribe (
id INTEGER PRIMARY KEY,
name TEXT,
language TEXT,
description TEXT,
unit_price INTEGER,
unit_time TEXT,
discount TEXT,
replacement INTEGER,
inventory INTEGER,
traffic INTEGER,
speed_limit INTEGER,
device_limit INTEGER,
quota INTEGER,
nodes TEXT,
node_tags TEXT,
show INTEGER,
sell INTEGER,
sort INTEGER,
deduction_ratio INTEGER,
allow_deduction INTEGER,
reset_cycle INTEGER,
renewal_reset INTEGER,
created_at DATETIME,
updated_at DATETIME
);
`).Error; err != nil {
t.Fatalf("create subscribe table error: %v", err)
}
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS user_subscribe (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
order_id INTEGER,
subscribe_id INTEGER NOT NULL,
start_time DATETIME,
expire_time DATETIME,
finished_at DATETIME,
traffic INTEGER DEFAULT 0,
download INTEGER DEFAULT 0,
upload INTEGER DEFAULT 0,
token TEXT UNIQUE,
uuid TEXT UNIQUE,
status INTEGER DEFAULT 0,
created_at DATETIME,
updated_at DATETIME
);
`).Error; err != nil {
t.Fatalf("create user_subscribe table error: %v", err)
}
// 内嵌 Redis
mr, err := miniredis.Run()
if err != nil {
t.Fatalf("start miniredis error: %v", err)
}
defer mr.Close()
rds := redis.NewClient(&redis.Options{Addr: mr.Addr()})
// 配置 IAP 产品映射
cd := `{
"iapProductMap": {
"com.airport.vpn.pass.30d": {
"description": "30天通行证",
"priceText": "¥28.00",
"durationDays": 30,
"tier": "Basic",
"subscribeId": 1001
}
},
"iapBundleId": "co.airoport.app.ios"
}`
s := &svc.ServiceContext{
DB: db,
Redis: rds,
Config: config.Config{
Site: config.SiteConfig{
CustomData: cd,
},
},
}
// 初始化模型(与生产保持一致)
s.UserModel = usermodel.NewModel(db, rds)
s.SubscribeModel = submodel.NewModel(db, rds)
s.IAPAppleTransactionModel = iapmodel.NewModel(db, rds)
// 创建可售订阅计划ID=1001
truePtr := func(b bool) *bool { return &b }
if err := db.Create(&submodel.Subscribe{
Id: 1001,
Name: "30D Pass",
Sell: truePtr(true),
Language: "",
}).Error; err != nil {
t.Fatalf("create subscribe plan error: %v", err)
}
// 创建用户
u := &usermodel.User{
Id: 1,
Password: "",
Avatar: "",
Balance: 0,
Commission: 0,
ReferralPercentage: 0,
OnlyFirstPurchase: truePtr(true),
Enable: truePtr(true),
IsAdmin: truePtr(false),
EnableBalanceNotify: truePtr(false),
EnableLoginNotify: truePtr(false),
EnableSubscribeNotify: truePtr(true),
EnableTradeNotify: truePtr(false),
}
if err := db.Create(u).Error; err != nil {
t.Fatalf("create user error: %v", err)
}
// 构造最小 JWS仅解析 payload
payload := map[string]interface{}{
"bundleId": "co.airoport.app.ios",
"productId": "com.airport.vpn.pass.unknown",
"transactionId": "1000000000001",
"originalTransactionId": "1000000000000",
"purchaseDate": float64(time.Now().UnixMilli()),
}
data, _ := json.Marshal(payload)
b64 := base64.RawURLEncoding.EncodeToString(data)
jws := "header." + b64 + ".signature"
// 组装路由(仅挂载 attach
r := gin.New()
r.POST("/v1/public/iap/apple/transactions/attach", AttachAppleTransactionHandler(s))
// 请求上下文注入登录用户
type attachReq struct {
SignedTransactionJWS string `json:"signed_transaction_jws"`
DurationDays int64 `json:"duration_days"`
Tier string `json:"tier"`
SubscribeId int64 `json:"subscribe_id"`
}
body := attachReq{SignedTransactionJWS: jws, DurationDays: 30, Tier: "Basic", SubscribeId: 1001}
bodyBytes, _ := json.Marshal(body)
req, _ := http.NewRequest(http.MethodPost, "/v1/public/iap/apple/transactions/attach", bytes.NewReader(bodyBytes))
req.Header.Set("Content-Type", "application/json")
ctx := context.WithValue(req.Context(), constant.CtxKeyUser, u)
req = req.WithContext(ctx)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("attach status != 200, got %d", w.Code)
}
// 解析响应包装
var wrap struct {
Code uint32 `json:"code"`
Msg string `json:"msg"`
Data struct {
ExpiresAt int64 `json:"expires_at"`
Tier string `json:"tier"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &wrap); err != nil {
t.Fatalf("unmarshal attach resp error: %v", err)
}
if wrap.Code != 200 {
t.Fatalf("attach code != 200, got %d, msg=%s", wrap.Code, wrap.Msg)
}
if wrap.Data.ExpiresAt <= time.Now().Unix() {
t.Fatalf("expires_at invalid: %d", wrap.Data.ExpiresAt)
}
// 校验 user_subscribe 落库
var count int64
if err := db.Model(&usermodel.Subscribe{}).Where("user_id = ? AND subscribe_id = ?", u.Id, 1001).Count(&count).Error; err != nil {
t.Fatalf("query user_subscribe error: %v", err)
}
if count == 0 {
t.Fatalf("user_subscribe not inserted")
}
}