This commit is contained in:
parent
cd0ef80d15
commit
a98fcbfe73
@ -93,6 +93,16 @@ type (
|
|||||||
OtherContact string `json:"other_contact,optional"`
|
OtherContact string `json:"other_contact,optional"`
|
||||||
Notes string `json:"notes,optional"`
|
Notes string `json:"notes,optional"`
|
||||||
}
|
}
|
||||||
|
GetDownloadLinkRequest {
|
||||||
|
InviteCode string `form:"invite_code,optional"`
|
||||||
|
Platform string `form:"platform" validate:"required,oneof=windows mac ios android"`
|
||||||
|
}
|
||||||
|
GetDownloadLinkResponse {
|
||||||
|
Url string `json:"url"`
|
||||||
|
}
|
||||||
|
GetAppVersionRequest {
|
||||||
|
Platform string `form:"platform" validate:"required,oneof=windows mac ios android"`
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@server (
|
@server (
|
||||||
@ -140,4 +150,12 @@ service ppanel {
|
|||||||
@doc "Get Client"
|
@doc "Get Client"
|
||||||
@handler GetClient
|
@handler GetClient
|
||||||
get /client returns (GetSubscribeClientResponse)
|
get /client returns (GetSubscribeClientResponse)
|
||||||
|
|
||||||
|
@doc "Get Download Link"
|
||||||
|
@handler GetDownloadLink
|
||||||
|
get /client/download (GetDownloadLinkRequest) returns (GetDownloadLinkResponse)
|
||||||
|
|
||||||
|
@doc "Get App Version"
|
||||||
|
@handler GetAppVersion
|
||||||
|
get /app/version (GetAppVersionRequest) returns (ApplicationVersion)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -93,8 +93,14 @@ type (
|
|||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
Version string `json:"version" validate:"required"`
|
Version string `json:"version" validate:"required"`
|
||||||
Description string `json:"description"`
|
MinVersion string `json:"min_version"`
|
||||||
|
ForceUpdate bool `json:"force_update"`
|
||||||
|
Description map[string]string `json:"description"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
FileHash string `json:"file_hash"`
|
||||||
IsDefault bool `json:"is_default"`
|
IsDefault bool `json:"is_default"`
|
||||||
|
IsInReview bool `json:"is_in_review"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
}
|
}
|
||||||
ApplicationResponse {
|
ApplicationResponse {
|
||||||
Applications []ApplicationResponseInfo `json:"applications"`
|
Applications []ApplicationResponseInfo `json:"applications"`
|
||||||
|
|||||||
@ -1,91 +1,56 @@
|
|||||||
# PPanel Server Configuration
|
|
||||||
# 完整配置示例
|
|
||||||
|
|
||||||
# 运行模式: debug, release, test
|
|
||||||
Model: release
|
|
||||||
# 监听地址
|
|
||||||
Host: 0.0.0.0
|
Host: 0.0.0.0
|
||||||
# 监听端口
|
|
||||||
Port: 8080
|
Port: 8080
|
||||||
# 是否开启调试模式
|
|
||||||
Debug: false
|
Debug: false
|
||||||
|
|
||||||
# JWT 认证配置
|
|
||||||
JwtAuth:
|
JwtAuth:
|
||||||
AccessSecret: "ppanel-secret-key-change-me" # 请务必修改此密钥
|
AccessSecret: 8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
|
||||||
AccessExpire: 604800 # Token 过期时间 (秒), 默认 7 天
|
AccessExpire: 604800
|
||||||
MaxSessionsPerUser: 3 # 每个用户最大并发登录数
|
MaxSessionsPerUser: 3
|
||||||
|
|
||||||
# 日志配置
|
|
||||||
Logger:
|
Logger:
|
||||||
ServiceName: "PPanel"
|
ServiceName: PPanel
|
||||||
Mode: "file" # console, file, volume
|
Mode: console
|
||||||
Encoding: "json" # json, plain
|
Encoding: plain
|
||||||
Path: "logs" # 日志文件路径
|
TimeFormat: '2025-01-01 00:00:00.000'
|
||||||
Level: "info" # debug, info, warn, error
|
Path: logs
|
||||||
Compress: true
|
Level: debug
|
||||||
KeepDays: 7
|
MaxContentLength: 0
|
||||||
Rotation: "daily" # daily, size
|
Compress: false
|
||||||
|
Stat: true
|
||||||
# MySQL 数据库配置
|
KeepDays: 0
|
||||||
|
StackCooldownMillis: 100
|
||||||
|
MaxBackups: 0
|
||||||
|
MaxSize: 0
|
||||||
|
Rotation: daily
|
||||||
|
FileTimeFormat: 2025-01-01T00:00:00.000Z00:00
|
||||||
MySQL:
|
MySQL:
|
||||||
Addr: "mysql:3306" # Docker 服务名:端口
|
Addr: 154.12.35.103:3306
|
||||||
Username: "root"
|
Dbname: ppanel
|
||||||
Password: "ppanel_password" # 与 docker-compose 中的 MYSQL_ROOT_PASSWORD 保持一致
|
Username: root
|
||||||
Dbname: "ppanel_db"
|
Password: jpcV41ppanel
|
||||||
Config: "charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai"
|
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
||||||
MaxIdleConns: 10
|
MaxIdleConns: 10
|
||||||
MaxOpenConns: 100
|
MaxOpenConns: 10
|
||||||
SlowThreshold: 1000 # 慢查询阈值 (ms)
|
SlowThreshold: 1000
|
||||||
|
|
||||||
# Redis 配置
|
|
||||||
Redis:
|
Redis:
|
||||||
Host: "redis:6379" # Docker 服务名:端口
|
Host: 127.0.0.1:6379
|
||||||
Pass: ""
|
Pass:
|
||||||
DB: 0
|
DB: 0
|
||||||
|
|
||||||
# 管理员初始化配置 (仅在首次初始化有效,后续请在数据库管理)
|
|
||||||
Administrator:
|
Administrator:
|
||||||
Email: "admin@ppanel.dev"
|
Password:
|
||||||
Password: "password"
|
|
||||||
|
|
||||||
# 站点配置
|
|
||||||
Site:
|
|
||||||
Title: "PPanel"
|
|
||||||
Dec: "PPanel Panel"
|
|
||||||
Url: "https://your-domain.com"
|
|
||||||
SubUrl: "https://sub.your-domain.com"
|
|
||||||
|
|
||||||
# 邮件服务配置
|
|
||||||
Email:
|
Email:
|
||||||
|
Telegram:
|
||||||
Enable: false
|
Enable: false
|
||||||
# platform: "smtp"
|
BotID: 0
|
||||||
# platform_config: "..."
|
BotName: ""
|
||||||
|
BotToken: "8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0"
|
||||||
|
GroupChatID: "-5012065881"
|
||||||
|
EnableNotify: true
|
||||||
|
WebHookDomain: ""
|
||||||
|
|
||||||
# 验证配置
|
|
||||||
Verify:
|
|
||||||
TurnstileSiteKey: ""
|
|
||||||
TurnstileSecret: ""
|
|
||||||
LoginVerify: false
|
|
||||||
RegisterVerify: false
|
|
||||||
ResetPasswordVerify: false
|
|
||||||
|
|
||||||
# 注册配置
|
Site:
|
||||||
Register:
|
Host: api.airoport.co
|
||||||
StopRegister: false
|
SiteName: HiFastVPN
|
||||||
EnableTrial: false
|
|
||||||
TrialSubscribe: 0
|
|
||||||
TrialTime: 0
|
|
||||||
TrialTimeUnit: "hour"
|
|
||||||
EnableIpRegisterLimit: false
|
|
||||||
IpRegisterLimit: 0
|
|
||||||
IpRegisterLimitDuration: 0
|
|
||||||
|
|
||||||
# 订阅配置
|
|
||||||
Subscribe:
|
|
||||||
SingleModel: false
|
|
||||||
SubscribePath: "/v1/subscribe/config"
|
|
||||||
SubscribeDomain: ""
|
|
||||||
PanDomain: false
|
|
||||||
UserAgentLimit: false
|
|
||||||
UserAgentList: ""
|
|
||||||
32
internal/handler/common/getappversionhandler.go
Normal file
32
internal/handler/common/getappversionhandler.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/common"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetAppVersionHandler 获取 App 版本
|
||||||
|
func GetAppVersionHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.GetAppVersionRequest
|
||||||
|
if err := c.ShouldBind(&req); err != nil {
|
||||||
|
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "parse params failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
validate := validator.New()
|
||||||
|
if err := validate.Struct(&req); err != nil {
|
||||||
|
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "validate params failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := common.NewGetAppVersionLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.GetAppVersion(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
32
internal/handler/common/getdownloadlinkhandler.go
Normal file
32
internal/handler/common/getdownloadlinkhandler.go
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/common"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetDownloadLinkHandler 获取下载链接
|
||||||
|
func GetDownloadLinkHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.GetDownloadLinkRequest
|
||||||
|
if err := c.ShouldBind(&req); err != nil {
|
||||||
|
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "parse params failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
validate := validator.New()
|
||||||
|
if err := validate.Struct(&req); err != nil {
|
||||||
|
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidParams), "validate params failed: %v", err))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := common.NewGetDownloadLinkLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.GetDownloadLink(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,11 +2,16 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
"github.com/perfect-panel/server/internal/logic/public/user"
|
"github.com/perfect-panel/server/internal/logic/public/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
"github.com/perfect-panel/server/pkg/result"
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -27,7 +32,7 @@ func DeleteAccountHandler(serverCtx *svc.ServiceContext) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 校验邮箱验证码
|
// 校验邮箱验证码
|
||||||
if err := verifyEmailCode(c.Request.Context(), serverCtx, req.Code); err != nil {
|
if err := verifyEmailCode(c.Request.Context(), serverCtx, req.Email, req.Code); err != nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -43,12 +48,43 @@ func DeleteAccountHandler(serverCtx *svc.ServiceContext) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CacheKeyPayload 验证码缓存结构
|
||||||
type CacheKeyPayload struct {
|
type CacheKeyPayload struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
LastAt int64 `json:"lastAt"`
|
LastAt int64 `json:"lastAt"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func verifyEmailCode(ctx context.Context, serverCtx *svc.ServiceContext, code string) error {
|
// verifyEmailCode 校验邮箱验证码
|
||||||
|
// 支持 DeleteAccount 和 Security 两种场景的验证码
|
||||||
|
func verifyEmailCode(ctx context.Context, serverCtx *svc.ServiceContext, email string, code string) error {
|
||||||
|
// 尝试多种场景的验证码
|
||||||
|
scenes := []string{constant.DeleteAccount.String(), constant.Security.String()}
|
||||||
|
var verified bool
|
||||||
|
var cacheKeyUsed string
|
||||||
|
var payload CacheKeyPayload
|
||||||
|
|
||||||
|
for _, scene := range scenes {
|
||||||
|
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, email)
|
||||||
|
value, err := serverCtx.Redis.Get(ctx, cacheKey).Result()
|
||||||
|
if err != nil || value == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(value), &payload); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// 检查验证码是否匹配且未过期
|
||||||
|
if payload.Code == code && time.Now().Unix()-payload.LastAt <= serverCtx.Config.VerifyCode.ExpireTime {
|
||||||
|
verified = true
|
||||||
|
cacheKeyUsed = cacheKey
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !verified {
|
||||||
|
return fmt.Errorf("verification code error or expired")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证成功后删除缓存
|
||||||
|
serverCtx.Redis.Del(ctx, cacheKeyUsed)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
98
internal/handler/public/user/deleteAccountHandler_test.go
Normal file
98
internal/handler/public/user/deleteAccountHandler_test.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package user
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestVerifyEmailCode(t *testing.T) {
|
||||||
|
// 1. Setup Miniredis
|
||||||
|
mr, err := miniredis.Run()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
defer mr.Close()
|
||||||
|
|
||||||
|
rdb := redis.NewClient(&redis.Options{
|
||||||
|
Addr: mr.Addr(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Setup ServiceContext
|
||||||
|
serverCtx := &svc.ServiceContext{
|
||||||
|
Redis: rdb,
|
||||||
|
Config: config.Config{
|
||||||
|
VerifyCode: config.VerifyCode{
|
||||||
|
ExpireTime: 300, // 5 minutes validity
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
email := "test@example.com"
|
||||||
|
code := "123456"
|
||||||
|
|
||||||
|
// Helper to set code in redis
|
||||||
|
setCode := func(scene string, c string, lastAt int64) {
|
||||||
|
payload := CacheKeyPayload{
|
||||||
|
Code: c,
|
||||||
|
LastAt: lastAt,
|
||||||
|
}
|
||||||
|
val, _ := json.Marshal(payload)
|
||||||
|
key := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, scene, email)
|
||||||
|
rdb.Set(ctx, key, string(val), time.Hour)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Run("Success_DeleteAccountScene", func(t *testing.T) {
|
||||||
|
mr.FlushAll()
|
||||||
|
setCode(constant.DeleteAccount.String(), code, time.Now().Unix())
|
||||||
|
|
||||||
|
err := verifyEmailCode(ctx, serverCtx, email, code)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify key is deleted
|
||||||
|
key := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.DeleteAccount.String(), email)
|
||||||
|
exists := rdb.Exists(ctx, key).Val()
|
||||||
|
assert.Equal(t, int64(0), exists)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Success_SecurityScene", func(t *testing.T) {
|
||||||
|
mr.FlushAll()
|
||||||
|
setCode(constant.Security.String(), code, time.Now().Unix())
|
||||||
|
|
||||||
|
err := verifyEmailCode(ctx, serverCtx, email, code)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Fail_WrongCode", func(t *testing.T) {
|
||||||
|
mr.FlushAll()
|
||||||
|
setCode(constant.DeleteAccount.String(), code, time.Now().Unix())
|
||||||
|
|
||||||
|
err := verifyEmailCode(ctx, serverCtx, email, "wrong")
|
||||||
|
assert.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "verification code error")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Fail_Expired", func(t *testing.T) {
|
||||||
|
mr.FlushAll()
|
||||||
|
// Set time to 301 seconds ago (expired)
|
||||||
|
expiredTime := time.Now().Add(-301 * time.Second).Unix()
|
||||||
|
setCode(constant.DeleteAccount.String(), code, expiredTime)
|
||||||
|
|
||||||
|
err := verifyEmailCode(ctx, serverCtx, email, code)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("Fail_NoCode", func(t *testing.T) {
|
||||||
|
mr.FlushAll()
|
||||||
|
err := verifyEmailCode(ctx, serverCtx, email, code)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
@ -647,6 +647,12 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
// Get Client
|
// Get Client
|
||||||
commonGroupRouter.GET("/client", common.GetClientHandler(serverCtx))
|
commonGroupRouter.GET("/client", common.GetClientHandler(serverCtx))
|
||||||
|
|
||||||
|
// Get Download Link
|
||||||
|
commonGroupRouter.GET("/client/download", common.GetDownloadLinkHandler(serverCtx))
|
||||||
|
|
||||||
|
// Get App Version
|
||||||
|
commonGroupRouter.GET("/app/version", common.GetAppVersionHandler(serverCtx))
|
||||||
|
|
||||||
// Submit contact info
|
// Submit contact info
|
||||||
commonGroupRouter.POST("/contact", common.SubmitContactHandler(serverCtx))
|
commonGroupRouter.POST("/contact", common.SubmitContactHandler(serverCtx))
|
||||||
|
|
||||||
|
|||||||
@ -141,10 +141,7 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = l.svcCtx.EnforceUserSessionLimit(l.ctx, userInfo.Id, sessionId, l.svcCtx.SessionLimit()); err != nil {
|
// If device had a previous session, invalidate it first (MUST be before EnforceUserSessionLimit)
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "enforce session limit error: %v", err.Error())
|
|
||||||
}
|
|
||||||
// If device had a previous session, invalidate it first
|
|
||||||
oldDeviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, req.Identifier)
|
oldDeviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, req.Identifier)
|
||||||
if oldSid, getErr := l.svcCtx.Redis.Get(l.ctx, oldDeviceCacheKey).Result(); getErr == nil && oldSid != "" {
|
if oldSid, getErr := l.svcCtx.Redis.Get(l.ctx, oldDeviceCacheKey).Result(); getErr == nil && oldSid != "" {
|
||||||
oldSessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, oldSid)
|
oldSessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, oldSid)
|
||||||
@ -156,6 +153,10 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ
|
|||||||
_ = l.svcCtx.Redis.Del(l.ctx, oldDeviceCacheKey).Err()
|
_ = l.svcCtx.Redis.Del(l.ctx, oldDeviceCacheKey).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err = l.svcCtx.EnforceUserSessionLimit(l.ctx, userInfo.Id, sessionId, l.svcCtx.SessionLimit()); err != nil {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "enforce session limit error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
// Store session id in redis
|
// Store session id in redis
|
||||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||||
|
|||||||
@ -233,14 +233,35 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
// If device had a previous session, invalidate it first (MUST be before EnforceUserSessionLimit)
|
||||||
|
if req.Identifier != "" {
|
||||||
|
oldDeviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, req.Identifier)
|
||||||
|
if oldSid, getErr := l.svcCtx.Redis.Get(l.ctx, oldDeviceCacheKey).Result(); getErr == nil && oldSid != "" {
|
||||||
|
oldSessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, oldSid)
|
||||||
|
if uidStr, _ := l.svcCtx.Redis.Get(l.ctx, oldSessionKey).Result(); uidStr != "" {
|
||||||
|
_ = l.svcCtx.Redis.Del(l.ctx, oldSessionKey).Err()
|
||||||
|
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, uidStr)
|
||||||
|
_ = l.svcCtx.Redis.ZRem(l.ctx, sessionsKey, oldSid).Err()
|
||||||
|
}
|
||||||
|
_ = l.svcCtx.Redis.Del(l.ctx, oldDeviceCacheKey).Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err = l.svcCtx.EnforceUserSessionLimit(l.ctx, userInfo.Id, sessionId, l.svcCtx.SessionLimit()); err != nil {
|
if err = l.svcCtx.EnforceUserSessionLimit(l.ctx, userInfo.Id, sessionId, l.svcCtx.SessionLimit()); err != nil {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "enforce session limit error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "enforce session limit error: %v", err.Error())
|
||||||
}
|
}
|
||||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||||
|
|
||||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store device-to-session mapping
|
||||||
|
if req.Identifier != "" {
|
||||||
|
deviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, req.Identifier)
|
||||||
|
_ = l.svcCtx.Redis.Set(l.ctx, deviceCacheKey, sessionId, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err()
|
||||||
|
}
|
||||||
|
|
||||||
loginStatus = true
|
loginStatus = true
|
||||||
return &types.LoginResponse{
|
return &types.LoginResponse{
|
||||||
Token: token,
|
Token: token,
|
||||||
|
|||||||
@ -131,13 +131,35 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
|
|||||||
l.Logger.Error("[UserLogin] token generate error", logger.Field("error", err.Error()))
|
l.Logger.Error("[UserLogin] token generate error", logger.Field("error", err.Error()))
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "token generate error: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
// If device had a previous session, invalidate it first (MUST be before EnforceUserSessionLimit)
|
||||||
|
if req.Identifier != "" {
|
||||||
|
oldDeviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, req.Identifier)
|
||||||
|
if oldSid, getErr := l.svcCtx.Redis.Get(l.ctx, oldDeviceCacheKey).Result(); getErr == nil && oldSid != "" {
|
||||||
|
oldSessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, oldSid)
|
||||||
|
if uidStr, _ := l.svcCtx.Redis.Get(l.ctx, oldSessionKey).Result(); uidStr != "" {
|
||||||
|
_ = l.svcCtx.Redis.Del(l.ctx, oldSessionKey).Err()
|
||||||
|
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, uidStr)
|
||||||
|
_ = l.svcCtx.Redis.ZRem(l.ctx, sessionsKey, oldSid).Err()
|
||||||
|
}
|
||||||
|
_ = l.svcCtx.Redis.Del(l.ctx, oldDeviceCacheKey).Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err = l.svcCtx.EnforceUserSessionLimit(l.ctx, userInfo.Id, sessionId, l.svcCtx.SessionLimit()); err != nil {
|
if err = l.svcCtx.EnforceUserSessionLimit(l.ctx, userInfo.Id, sessionId, l.svcCtx.SessionLimit()); err != nil {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "enforce session limit error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "enforce session limit error: %v", err.Error())
|
||||||
}
|
}
|
||||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
sessionIdCacheKey := fmt.Sprintf("%v:%v", config.SessionIdKey, sessionId)
|
||||||
|
|
||||||
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
if err = l.svcCtx.Redis.Set(l.ctx, sessionIdCacheKey, userInfo.Id, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err(); err != nil {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set session id error: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Store device-to-session mapping
|
||||||
|
if req.Identifier != "" {
|
||||||
|
deviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, req.Identifier)
|
||||||
|
_ = l.svcCtx.Redis.Set(l.ctx, deviceCacheKey, sessionId, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err()
|
||||||
|
}
|
||||||
|
|
||||||
loginStatus = true
|
loginStatus = true
|
||||||
return &types.LoginResponse{
|
return &types.LoginResponse{
|
||||||
Token: token,
|
Token: token,
|
||||||
|
|||||||
40
internal/logic/common/getappversionlogic.go
Normal file
40
internal/logic/common/getappversionlogic.go
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetAppVersionLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGetAppVersionLogic 获取 App 版本信息
|
||||||
|
func NewGetAppVersionLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetAppVersionLogic {
|
||||||
|
return &GetAppVersionLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAppVersion 根据平台返回最新版本信息
|
||||||
|
func (l *GetAppVersionLogic) GetAppVersion(req *types.GetAppVersionRequest) (resp *types.ApplicationVersion, err error) {
|
||||||
|
// TODO: 后续对接数据库实现
|
||||||
|
resp = &types.ApplicationVersion{
|
||||||
|
Version: "1.0.0",
|
||||||
|
MinVersion: "1.0.0",
|
||||||
|
ForceUpdate: false,
|
||||||
|
Description: map[string]string{
|
||||||
|
"zh-CN": "初始版本",
|
||||||
|
"en-US": "Initial version",
|
||||||
|
},
|
||||||
|
IsDefault: true,
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
65
internal/logic/common/getdownloadlinklogic.go
Normal file
65
internal/logic/common/getdownloadlinklogic.go
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type GetDownloadLinkLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGetDownloadLinkLogic 获取下载链接
|
||||||
|
func NewGetDownloadLinkLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetDownloadLinkLogic {
|
||||||
|
return &GetDownloadLinkLogic{
|
||||||
|
Logger: logger.WithContext(ctx),
|
||||||
|
ctx: ctx,
|
||||||
|
svcCtx: svcCtx,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDownloadLink 根据邀请码和平台动态生成下载链接
|
||||||
|
// 生成的链接格式: https://{host}/v1/common/client/download/file/{platform}-{version}-ic_{invite_code}.{ext}
|
||||||
|
// Nginx 会拦截此请求,将其映射到实际文件,并在 Content-Disposition 中设置带邀请码的文件名
|
||||||
|
func (l *GetDownloadLinkLogic) GetDownloadLink(req *types.GetDownloadLinkRequest) (resp *types.GetDownloadLinkResponse, err error) {
|
||||||
|
// 1. 获取站点域名 (数据库配置通常会覆盖文件配置)
|
||||||
|
host := l.svcCtx.Config.Site.Host
|
||||||
|
if host == "" {
|
||||||
|
// 保底域名
|
||||||
|
host = "tapi.airoport.co"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 版本号 (后续可以从数据库或配置中读取)
|
||||||
|
version := "1.0.0"
|
||||||
|
|
||||||
|
// 3. 根据平台确定文件扩展名
|
||||||
|
var ext string
|
||||||
|
switch req.Platform {
|
||||||
|
case "windows":
|
||||||
|
ext = ".exe"
|
||||||
|
case "mac":
|
||||||
|
ext = ".dmg"
|
||||||
|
case "android":
|
||||||
|
ext = ".apk"
|
||||||
|
case "ios":
|
||||||
|
ext = ".ipa"
|
||||||
|
default:
|
||||||
|
ext = ".bin"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 构建文件名: 平台-版本号-ic_邀请码.扩展名
|
||||||
|
filename := fmt.Sprintf("%s-%s-ic_%s%s", req.Platform, version, req.InviteCode, ext)
|
||||||
|
|
||||||
|
// 5. 构建完整 URL (Nginx 会拦截此路径进行虚拟更名处理)
|
||||||
|
url := fmt.Sprintf("https://%s/v1/common/client/download/file/%s", host, filename)
|
||||||
|
|
||||||
|
return &types.GetDownloadLinkResponse{
|
||||||
|
Url: url,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@ -95,14 +95,22 @@ func (l *SendEmailCodeLogic) SendEmailCode(req *types.SendCodeRequest) (resp *ty
|
|||||||
expireTime = 900
|
expireTime = 900
|
||||||
}
|
}
|
||||||
fmt.Printf("expireTime: %v\n", expireTime)
|
fmt.Printf("expireTime: %v\n", expireTime)
|
||||||
|
expireMinutes := expireTime / 60
|
||||||
taskPayload.Content = map[string]interface{}{
|
taskPayload.Content = map[string]interface{}{
|
||||||
"Type": req.Type,
|
"Type": req.Type,
|
||||||
"SiteLogo": l.svcCtx.Config.Site.SiteLogo,
|
"SiteLogo": l.svcCtx.Config.Site.SiteLogo,
|
||||||
"SiteName": l.svcCtx.Config.Site.SiteName,
|
"SiteName": l.svcCtx.Config.Site.SiteName,
|
||||||
"Expire": expireTime / 60,
|
"Expire": expireMinutes,
|
||||||
"Code": code,
|
"Code": code,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Override for account deletion
|
||||||
|
if constant.ParseVerifyType(req.Type) == constant.DeleteAccount {
|
||||||
|
taskPayload.Subject = "注销账号验证"
|
||||||
|
taskPayload.Content["Content"] = fmt.Sprintf("您正在申请注销账号,验证码为:%s,有效期 %d 分钟。如非本人操作,请忽略。", code, expireMinutes)
|
||||||
|
}
|
||||||
// Save to Redis
|
// Save to Redis
|
||||||
|
|
||||||
payload = CacheKeyPayload{
|
payload = CacheKeyPayload{
|
||||||
Code: code,
|
Code: code,
|
||||||
LastAt: time.Now().Unix(),
|
LastAt: time.Now().Unix(),
|
||||||
|
|||||||
53
internal/logic/common/sendEmailCodeLogic_test.go
Normal file
53
internal/logic/common/sendEmailCodeLogic_test.go
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSendEmailCodeLogic_DeleteAccountContent(t *testing.T) {
|
||||||
|
// 这是一个模拟测试,主要展示逻辑判断是否正确
|
||||||
|
// 实际运行需要 Mock Redis 和 asynq
|
||||||
|
_ = &SendEmailCodeLogic{
|
||||||
|
svcCtx: &svc.ServiceContext{
|
||||||
|
Config: config.Config{
|
||||||
|
VerifyCode: config.VerifyCode{ExpireTime: 900},
|
||||||
|
Site: config.SiteConfig{
|
||||||
|
SiteLogo: "logo.png",
|
||||||
|
SiteName: "PPanel",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
code := "123456"
|
||||||
|
expireMinutes := 15
|
||||||
|
|
||||||
|
// 模拟逻辑中的覆盖逻辑
|
||||||
|
// 注意:这里我们不能直接测试 SendEmailCode 函数(因为它依赖 Redis/Asynq),
|
||||||
|
// 但我们可以验证我们写的覆盖逻辑片段是否符合预期。
|
||||||
|
|
||||||
|
taskPayloadSubject := "Verification code"
|
||||||
|
taskPayloadContent := map[string]interface{}{
|
||||||
|
"Type": uint8(4),
|
||||||
|
"SiteLogo": "logo.png",
|
||||||
|
"SiteName": "PPanel",
|
||||||
|
"Expire": expireMinutes,
|
||||||
|
"Code": code,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 触发我们的逻辑
|
||||||
|
if constant.ParseVerifyType(uint8(4)) == constant.DeleteAccount {
|
||||||
|
taskPayloadSubject = "注销账号验证"
|
||||||
|
taskPayloadContent["Content"] = fmt.Sprintf("您正在申请注销账号,验证码为:%s,有效期 %d 分钟。如非本人操作,请忽略。", code, expireMinutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
assert.Equal(t, "注销账号验证", taskPayloadSubject)
|
||||||
|
assert.Contains(t, taskPayloadContent["Content"].(string), "注销账号")
|
||||||
|
assert.Contains(t, taskPayloadContent["Content"].(string), code)
|
||||||
|
}
|
||||||
@ -78,13 +78,7 @@ func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse,
|
|||||||
if isMainAccount {
|
if isMainAccount {
|
||||||
l.Infow("主账号解绑,仅迁移当前设备", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId))
|
l.Infow("主账号解绑,仅迁移当前设备", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId))
|
||||||
|
|
||||||
// 为当前设备创建新用户并迁移
|
// 【重要】先删除旧的认证记录,再创建新用户,避免唯一键冲突
|
||||||
newUser, err := l.registerUserAndDevice(tx, currentDevice.Identifier, currentDevice.Ip, currentDevice.UserAgent)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
newUserId = newUser.Id
|
|
||||||
|
|
||||||
// 从原用户删除当前设备的认证方式 (按 Identifier 准确删除)
|
// 从原用户删除当前设备的认证方式 (按 Identifier 准确删除)
|
||||||
if err := tx.Where("user_id = ? AND auth_type = ? AND auth_identifier = ?", currentUser.Id, "device", currentDevice.Identifier).Delete(&user.AuthMethods{}).Error; err != nil {
|
if err := tx.Where("user_id = ? AND auth_type = ? AND auth_identifier = ?", currentUser.Id, "device", currentDevice.Identifier).Delete(&user.AuthMethods{}).Error; err != nil {
|
||||||
return errors.Wrap(err, "删除原设备认证失败")
|
return errors.Wrap(err, "删除原设备认证失败")
|
||||||
@ -94,6 +88,14 @@ func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse,
|
|||||||
if err := tx.Where("id = ?", currentDeviceId).Delete(&user.Device{}).Error; err != nil {
|
if err := tx.Where("id = ?", currentDeviceId).Delete(&user.Device{}).Error; err != nil {
|
||||||
return errors.Wrap(err, "删除原设备记录失败")
|
return errors.Wrap(err, "删除原设备记录失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 为当前设备创建新用户并迁移
|
||||||
|
newUser, err := l.registerUserAndDevice(tx, currentDevice.Identifier, currentDevice.Ip, currentDevice.UserAgent)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
newUserId = newUser.Id
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
l.Infow("纯设备账号注销,执行物理删除并重置", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId))
|
l.Infow("纯设备账号注销,执行物理删除并重置", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId))
|
||||||
|
|
||||||
|
|||||||
@ -111,8 +111,14 @@ type ApplicationVersion struct {
|
|||||||
Id int64 `json:"id"`
|
Id int64 `json:"id"`
|
||||||
Url string `json:"url"`
|
Url string `json:"url"`
|
||||||
Version string `json:"version" validate:"required"`
|
Version string `json:"version" validate:"required"`
|
||||||
Description string `json:"description"`
|
MinVersion string `json:"min_version"`
|
||||||
|
ForceUpdate bool `json:"force_update"`
|
||||||
|
Description map[string]string `json:"description"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
FileHash string `json:"file_hash"`
|
||||||
IsDefault bool `json:"is_default"`
|
IsDefault bool `json:"is_default"`
|
||||||
|
IsInReview bool `json:"is_in_review"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthConfig struct {
|
type AuthConfig struct {
|
||||||
@ -783,6 +789,19 @@ type GetAdsResponse struct {
|
|||||||
List []Ads `json:"list"`
|
List []Ads `json:"list"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type GetDownloadLinkRequest struct {
|
||||||
|
InviteCode string `form:"invite_code,optional"`
|
||||||
|
Platform string `form:"platform" validate:"required,oneof=windows mac ios android"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetDownloadLinkResponse struct {
|
||||||
|
Url string `json:"url"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type GetAppVersionRequest struct {
|
||||||
|
Platform string `form:"platform" validate:"required,oneof=windows mac ios android"`
|
||||||
|
}
|
||||||
|
|
||||||
type GetAnnouncementListRequest struct {
|
type GetAnnouncementListRequest struct {
|
||||||
Page int64 `form:"page"`
|
Page int64 `form:"page"`
|
||||||
Size int64 `form:"size"`
|
Size int64 `form:"size"`
|
||||||
|
|||||||
@ -15,6 +15,8 @@ type VerifyType uint8
|
|||||||
const (
|
const (
|
||||||
Register VerifyType = iota + 1
|
Register VerifyType = iota + 1
|
||||||
Security
|
Security
|
||||||
|
_
|
||||||
|
DeleteAccount
|
||||||
)
|
)
|
||||||
|
|
||||||
func ParseVerifyType(i uint8) VerifyType {
|
func ParseVerifyType(i uint8) VerifyType {
|
||||||
@ -27,6 +29,8 @@ func (v VerifyType) String() string {
|
|||||||
return "register"
|
return "register"
|
||||||
case Security:
|
case Security:
|
||||||
return "security"
|
return "security"
|
||||||
|
case DeleteAccount:
|
||||||
|
return "delete_account"
|
||||||
default:
|
default:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user