下载
Build docker and publish / build (20.15.1) (push) Has been cancelled

This commit is contained in:
2026-01-23 03:48:30 -08:00
parent cd0ef80d15
commit a98fcbfe73
19 changed files with 528 additions and 100 deletions
+5 -4
View File
@@ -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 {
+21
View File
@@ -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,
+22
View File
@@ -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,
@@ -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
}
@@ -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
}
+9 -1
View File
@@ -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(),
@@ -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 {
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))