feat(iap/apple): 实现苹果IAP非续期订阅功能
Build docker and publish / build (20.15.1) (push) Successful in 6m37s

新增苹果IAP相关接口与逻辑,包括产品列表查询、交易绑定、状态查询和恢复购买功能。移除旧的IAP验证逻辑,重构订阅系统以支持苹果IAP交易记录存储和权益计算。

- 新增/pkg/iap/apple包处理JWS解析和产品映射
- 实现GET /products、POST /attach、POST /restore和GET /status接口
- 新增apple_iap_transactions表存储交易记录
- 更新文档说明配置方式和接口规范
- 移除旧的AppleIAP验证和通知处理逻辑
This commit is contained in:
2025-12-13 20:54:50 -08:00
parent a80d6af035
commit 62186ca672
41 changed files with 1715 additions and 474 deletions
@@ -1,20 +1,13 @@
package notify
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/notify"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/result"
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/result"
)
// AppleIAPNotifyHandler 处理 Apple Server Notifications v2
// 参数: 原始 HTTP 请求体
// 返回: 处理结果(空体 200
func AppleIAPNotifyHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
l := notify.NewAppleIAPNotifyLogic(c.Request.Context(), svcCtx)
err := l.Handle(c.Request)
result.HttpResult(c, gin.H{"success": err == nil}, err)
}
return func(c *gin.Context) {
result.HttpResult(c, map[string]bool{"success": true}, nil)
}
}
@@ -0,0 +1,24 @@
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/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
func AttachAppleTransactionHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.AttachAppleTransactionRequest
_ = c.ShouldBind(&req)
if err := svcCtx.Validate(&req); err != nil {
result.ParamErrorResult(c, err)
return
}
l := appleLogic.NewAttachTransactionLogic(c.Request.Context(), svcCtx)
resp, err := l.Attach(&req)
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,219 @@
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")
}
}
@@ -0,0 +1,17 @@
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)
}
}
@@ -0,0 +1,72 @@
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")
}
}
@@ -0,0 +1,17 @@
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 GetAppleStatusHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
l := appleLogic.NewGetStatusLogic(c.Request.Context(), svcCtx)
resp, err := l.GetStatus()
result.HttpResult(c, resp, err)
}
}
@@ -0,0 +1,24 @@
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/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
func RestoreAppleTransactionsHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.RestoreAppleTransactionsRequest
_ = c.ShouldBind(&req)
if err := svcCtx.Validate(&req); err != nil {
result.ParamErrorResult(c, err)
return
}
l := appleLogic.NewRestoreLogic(c.Request.Context(), svcCtx)
err := l.Restore(&req)
result.HttpResult(c, map[string]bool{"success": err == nil}, err)
}
}
@@ -1,29 +0,0 @@
package iap
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/public/iap"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// VerifyHandler 处理 iOS IAP 初购验证并生成已支付订单
// 参数: IAPVerifyRequest
// 返回: IAPVerifyResponse
func VerifyHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.IAPVerifyRequest
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := iap.NewVerifyLogic(c.Request.Context(), svcCtx)
resp, err := l.Verify(&req)
result.HttpResult(c, resp, err)
}
}
+13 -11
View File
@@ -26,8 +26,8 @@ import (
authOauth "github.com/perfect-panel/server/internal/handler/auth/oauth"
common "github.com/perfect-panel/server/internal/handler/common"
publicAnnouncement "github.com/perfect-panel/server/internal/handler/public/announcement"
publicDocument "github.com/perfect-panel/server/internal/handler/public/document"
publicIAP "github.com/perfect-panel/server/internal/handler/public/iap"
publicDocument "github.com/perfect-panel/server/internal/handler/public/document"
publicIapApple "github.com/perfect-panel/server/internal/handler/public/iap/apple"
publicOrder "github.com/perfect-panel/server/internal/handler/public/order"
publicPayment "github.com/perfect-panel/server/internal/handler/public/payment"
publicPortal "github.com/perfect-panel/server/internal/handler/public/portal"
@@ -672,7 +672,7 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
publicAnnouncementGroupRouter.GET("/list", publicAnnouncement.QueryAnnouncementHandler(serverCtx))
}
publicDocumentGroupRouter := router.Group("/v1/public/document")
publicDocumentGroupRouter := router.Group("/v1/public/document")
publicDocumentGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
{
@@ -681,14 +681,7 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Get document list
publicDocumentGroupRouter.GET("/list", publicDocument.QueryDocumentListHandler(serverCtx))
}
publicIAPGroupRouter := router.Group("/v1/public/iap")
publicIAPGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
{
publicIAPGroupRouter.POST("/verify", publicIAP.VerifyHandler(serverCtx))
}
}
publicOrderGroupRouter := router.Group("/v1/public/order")
publicOrderGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
@@ -727,6 +720,15 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
publicPaymentGroupRouter.GET("/methods", publicPayment.GetAvailablePaymentMethodsHandler(serverCtx))
}
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))
}
publicPortalGroupRouter := router.Group("/v1/public/portal")
publicPortalGroupRouter.Use(middleware.DeviceMiddleware(serverCtx))