diff --git a/apis/common.api b/apis/common.api index a196945..62ddb9b 100644 --- a/apis/common.api +++ b/apis/common.api @@ -93,6 +93,16 @@ type ( OtherContact string `json:"other_contact,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 ( @@ -140,4 +150,12 @@ service ppanel { @doc "Get Client" @handler GetClient 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) } diff --git a/apis/types.api b/apis/types.api index fb965c3..9df583f 100644 --- a/apis/types.api +++ b/apis/types.api @@ -90,11 +90,17 @@ type ( SubscribeType string `json:"subscribe_type"` } ApplicationVersion { - Id int64 `json:"id"` - Url string `json:"url"` - Version string `json:"version" validate:"required"` - Description string `json:"description"` - IsDefault bool `json:"is_default"` + Id int64 `json:"id"` + Url string `json:"url"` + Version string `json:"version" validate:"required"` + 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"` + IsInReview bool `json:"is_in_review"` + CreatedAt int64 `json:"created_at"` } ApplicationResponse { Applications []ApplicationResponseInfo `json:"applications"` diff --git a/configs/ppanel.yaml b/configs/ppanel.yaml index a24f8e0..5fec560 100644 --- a/configs/ppanel.yaml +++ b/configs/ppanel.yaml @@ -1,91 +1,56 @@ -# PPanel Server Configuration -# 完整配置示例 - -# 运行模式: debug, release, test -Model: release -# 监听地址 Host: 0.0.0.0 -# 监听端口 Port: 8080 -# 是否开启调试模式 Debug: false - -# JWT 认证配置 JwtAuth: - AccessSecret: "ppanel-secret-key-change-me" # 请务必修改此密钥 - AccessExpire: 604800 # Token 过期时间 (秒), 默认 7 天 - MaxSessionsPerUser: 3 # 每个用户最大并发登录数 - -# 日志配置 + AccessSecret: 8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918 + AccessExpire: 604800 + MaxSessionsPerUser: 3 + Logger: - ServiceName: "PPanel" - Mode: "file" # console, file, volume - Encoding: "json" # json, plain - Path: "logs" # 日志文件路径 - Level: "info" # debug, info, warn, error - Compress: true - KeepDays: 7 - Rotation: "daily" # daily, size - -# MySQL 数据库配置 + ServiceName: PPanel + Mode: console + Encoding: plain + TimeFormat: '2025-01-01 00:00:00.000' + Path: logs + Level: debug + MaxContentLength: 0 + Compress: false + Stat: true + KeepDays: 0 + StackCooldownMillis: 100 + MaxBackups: 0 + MaxSize: 0 + Rotation: daily + FileTimeFormat: 2025-01-01T00:00:00.000Z00:00 MySQL: - Addr: "mysql:3306" # Docker 服务名:端口 - Username: "root" - Password: "ppanel_password" # 与 docker-compose 中的 MYSQL_ROOT_PASSWORD 保持一致 - Dbname: "ppanel_db" - Config: "charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai" + Addr: 154.12.35.103:3306 + Dbname: ppanel + Username: root + Password: jpcV41ppanel + Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai MaxIdleConns: 10 - MaxOpenConns: 100 - SlowThreshold: 1000 # 慢查询阈值 (ms) + MaxOpenConns: 10 + SlowThreshold: 1000 -# Redis 配置 Redis: - Host: "redis:6379" # Docker 服务名:端口 - Pass: "" + Host: 127.0.0.1:6379 + Pass: DB: 0 + -# 管理员初始化配置 (仅在首次初始化有效,后续请在数据库管理) Administrator: - Email: "admin@ppanel.dev" - Password: "password" - -# 站点配置 -Site: - Title: "PPanel" - Dec: "PPanel Panel" - Url: "https://your-domain.com" - SubUrl: "https://sub.your-domain.com" - -# 邮件服务配置 -Email: + Password: + Email: +Telegram: Enable: false - # platform: "smtp" - # platform_config: "..." + BotID: 0 + BotName: "" + BotToken: "8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0" + GroupChatID: "-5012065881" + EnableNotify: true + WebHookDomain: "" -# 验证配置 -Verify: - TurnstileSiteKey: "" - TurnstileSecret: "" - LoginVerify: false - RegisterVerify: false - ResetPasswordVerify: false -# 注册配置 -Register: - StopRegister: false - 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: "" +Site: + Host: api.airoport.co + SiteName: HiFastVPN \ No newline at end of file diff --git a/internal/handler/common/getappversionhandler.go b/internal/handler/common/getappversionhandler.go new file mode 100644 index 0000000..da94770 --- /dev/null +++ b/internal/handler/common/getappversionhandler.go @@ -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) + } +} diff --git a/internal/handler/common/getdownloadlinkhandler.go b/internal/handler/common/getdownloadlinkhandler.go new file mode 100644 index 0000000..f06b7a5 --- /dev/null +++ b/internal/handler/common/getdownloadlinkhandler.go @@ -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) + } +} diff --git a/internal/handler/public/user/deleteAccountHandler.go b/internal/handler/public/user/deleteAccountHandler.go index ca74212..f5ac536 100644 --- a/internal/handler/public/user/deleteAccountHandler.go +++ b/internal/handler/public/user/deleteAccountHandler.go @@ -2,11 +2,16 @@ package user import ( "context" + "encoding/json" + "fmt" "net/http" + "time" "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/svc" + "github.com/perfect-panel/server/pkg/constant" "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()}) return } @@ -43,12 +48,43 @@ func DeleteAccountHandler(serverCtx *svc.ServiceContext) gin.HandlerFunc { } } +// CacheKeyPayload 验证码缓存结构 type CacheKeyPayload struct { Code string `json:"code"` 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 } diff --git a/internal/handler/public/user/deleteAccountHandler_test.go b/internal/handler/public/user/deleteAccountHandler_test.go new file mode 100644 index 0000000..96c36c9 --- /dev/null +++ b/internal/handler/public/user/deleteAccountHandler_test.go @@ -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) + }) +} diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 705c0e4..128944c 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -647,6 +647,12 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) { // Get Client 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 commonGroupRouter.POST("/contact", common.SubmitContactHandler(serverCtx)) diff --git a/internal/logic/auth/deviceLoginLogic.go b/internal/logic/auth/deviceLoginLogic.go index e8c2984..fdf3389 100644 --- a/internal/logic/auth/deviceLoginLogic.go +++ b/internal/logic/auth/deviceLoginLogic.go @@ -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()) } - 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()) - } - // If device had a previous session, invalidate it first + // If device had a previous session, invalidate it first (MUST be before EnforceUserSessionLimit) 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) @@ -156,6 +153,10 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ _ = 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 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 { diff --git a/internal/logic/auth/emailLoginLogic.go b/internal/logic/auth/emailLoginLogic.go index e3605f8..dffa910 100644 --- a/internal/logic/auth/emailLoginLogic.go +++ b/internal/logic/auth/emailLoginLogic.go @@ -233,14 +233,35 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types. if err != nil { 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 { return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "enforce session limit error: %v", err.Error()) } 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 { 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 return &types.LoginResponse{ Token: token, diff --git a/internal/logic/auth/userLoginLogic.go b/internal/logic/auth/userLoginLogic.go index 9d6eb16..3121c29 100644 --- a/internal/logic/auth/userLoginLogic.go +++ b/internal/logic/auth/userLoginLogic.go @@ -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())) 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 { return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "enforce session limit error: %v", err.Error()) } 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 { 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 return &types.LoginResponse{ Token: token, diff --git a/internal/logic/common/getappversionlogic.go b/internal/logic/common/getappversionlogic.go new file mode 100644 index 0000000..1c8bcd0 --- /dev/null +++ b/internal/logic/common/getappversionlogic.go @@ -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 +} diff --git a/internal/logic/common/getdownloadlinklogic.go b/internal/logic/common/getdownloadlinklogic.go new file mode 100644 index 0000000..6083ec1 --- /dev/null +++ b/internal/logic/common/getdownloadlinklogic.go @@ -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 +} diff --git a/internal/logic/common/sendEmailCodeLogic.go b/internal/logic/common/sendEmailCodeLogic.go index bcd2b4a..0298d1d 100644 --- a/internal/logic/common/sendEmailCodeLogic.go +++ b/internal/logic/common/sendEmailCodeLogic.go @@ -95,14 +95,22 @@ func (l *SendEmailCodeLogic) SendEmailCode(req *types.SendCodeRequest) (resp *ty expireTime = 900 } fmt.Printf("expireTime: %v\n", expireTime) + expireMinutes := expireTime / 60 taskPayload.Content = map[string]interface{}{ "Type": req.Type, "SiteLogo": l.svcCtx.Config.Site.SiteLogo, "SiteName": l.svcCtx.Config.Site.SiteName, - "Expire": expireTime / 60, + "Expire": expireMinutes, "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 + payload = CacheKeyPayload{ Code: code, LastAt: time.Now().Unix(), diff --git a/internal/logic/common/sendEmailCodeLogic_test.go b/internal/logic/common/sendEmailCodeLogic_test.go new file mode 100644 index 0000000..f18b1d6 --- /dev/null +++ b/internal/logic/common/sendEmailCodeLogic_test.go @@ -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) +} diff --git a/internal/logic/public/user/deleteAccountLogic.go b/internal/logic/public/user/deleteAccountLogic.go index 4fa5cb5..441ea19 100644 --- a/internal/logic/public/user/deleteAccountLogic.go +++ b/internal/logic/public/user/deleteAccountLogic.go @@ -78,13 +78,7 @@ func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse, if isMainAccount { 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 准确删除) 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, "删除原设备认证失败") @@ -94,6 +88,14 @@ func (l *DeleteAccountLogic) DeleteAccount() (resp *types.DeleteAccountResponse, if err := tx.Where("id = ?", currentDeviceId).Delete(&user.Device{}).Error; err != nil { return errors.Wrap(err, "删除原设备记录失败") } + + // 为当前设备创建新用户并迁移 + newUser, err := l.registerUserAndDevice(tx, currentDevice.Identifier, currentDevice.Ip, currentDevice.UserAgent) + if err != nil { + return err + } + newUserId = newUser.Id + } else { l.Infow("纯设备账号注销,执行物理删除并重置", logger.Field("user_id", currentUser.Id), logger.Field("device_id", currentDeviceId)) diff --git a/internal/types/types.go b/internal/types/types.go index 7fddcf0..65537e6 100644 --- a/internal/types/types.go +++ b/internal/types/types.go @@ -108,11 +108,17 @@ type ApplicationResponseInfo struct { } type ApplicationVersion struct { - Id int64 `json:"id"` - Url string `json:"url"` - Version string `json:"version" validate:"required"` - Description string `json:"description"` - IsDefault bool `json:"is_default"` + Id int64 `json:"id"` + Url string `json:"url"` + Version string `json:"version" validate:"required"` + 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"` + IsInReview bool `json:"is_in_review"` + CreatedAt int64 `json:"created_at"` } type AuthConfig struct { @@ -783,6 +789,19 @@ type GetAdsResponse struct { 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 { Page int64 `form:"page"` Size int64 `form:"size"` diff --git a/pkg/constant/types.go b/pkg/constant/types.go index a2db39b..9c7014b 100644 --- a/pkg/constant/types.go +++ b/pkg/constant/types.go @@ -15,6 +15,8 @@ type VerifyType uint8 const ( Register VerifyType = iota + 1 Security + _ + DeleteAccount ) func ParseVerifyType(i uint8) VerifyType { @@ -27,6 +29,8 @@ func (v VerifyType) String() string { return "register" case Security: return "security" + case DeleteAccount: + return "delete_account" default: return "unknown" } diff --git a/ppanel b/ppanel new file mode 100755 index 0000000..c271a95 Binary files /dev/null and b/ppanel differ