This commit is contained in:
2026-01-27 03:13:15 -08:00
parent 5def1cf6d8
commit 48c92ea374
36 changed files with 1189 additions and 123 deletions
+1 -1
View File
@@ -207,7 +207,7 @@ type InviteConfig struct {
ForcedInvite bool `yaml:"ForcedInvite" default:"false"`
ReferralPercentage int64 `yaml:"ReferralPercentage" default:"0"`
OnlyFirstPurchase bool `yaml:"OnlyFirstPurchase" default:"false"`
GiftDays int64 `yaml:"GiftDays" default:"0"`
GiftDays int64 `yaml:"GiftDays" default:"3"`
}
// KuttConfig Kutt 短链接服务配置
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
@@ -31,6 +32,9 @@ func DeleteAccountHandler(serverCtx *svc.ServiceContext) gin.HandlerFunc {
return
}
// 统一处理邮箱格式:转小写并去空格,与发送验证码逻辑保持一致
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
// 校验邮箱验证码
if err := verifyEmailCode(c.Request.Context(), serverCtx, req.Email, req.Code); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -52,6 +52,10 @@ func (l *DeleteUserDeviceLogic) DeleteUserDevice(req *types.DeleteUserDeivceRequ
_ = l.svcCtx.Redis.Del(ctx, sessionIdCacheKey).Err()
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, device.UserId)
_ = l.svcCtx.Redis.ZRem(ctx, sessionsKey, sessionId).Err()
l.Infow("[SessionMonitor] 管理员删除设备触发 Session 清理",
logger.Field("user_id", device.UserId),
logger.Field("session_id", sessionId),
logger.Field("device_id", device.Id))
}
// 使用事务同时删除设备记录和关联的认证方式
@@ -2,6 +2,9 @@ package user
import (
"context"
"fmt"
"math"
"time"
"github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
@@ -63,8 +66,9 @@ func (l *GetUserListLogic) GetUserList(req *types.GetUserListRequest) (*types.Ge
// Set MemberStatus and update LastLoginTime from traffic
if info, ok := activeSubs[item.Id]; ok {
u.MemberStatus = info.MemberStatus
days := math.Ceil(info.ExpireTime.Sub(time.Now()).Hours() / 24)
u.MemberStatus = fmt.Sprintf("%s*%d", info.MemberStatus, int(days))
if info.LastTrafficAt != nil {
trafficTime := info.LastTrafficAt.Unix()
if trafficTime > u.LastLoginTime {
+106 -2
View File
@@ -2,6 +2,7 @@ package auth
import (
"context"
"encoding/json"
"fmt"
"time"
@@ -35,6 +36,14 @@ func NewDeviceLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Devic
}
func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *types.LoginResponse, err error) {
// 打印请求参数
l.Infow("DeviceLogin 请求参数",
logger.Field("identifier", req.Identifier),
logger.Field("ip", req.IP),
logger.Field("user_agent", req.UserAgent),
logger.Field("cf_token", req.CfToken),
)
if !l.svcCtx.Config.Device.Enable {
return nil, xerr.NewErrMsg("Device login is disabled")
}
@@ -97,6 +106,18 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ
}
}
// [AuthDebug] Log detailed User Info
if userInfo != nil {
l.Infow("[AuthDebug] User Info Loaded",
logger.Field("user_id", userInfo.Id),
logger.Field("enable", userInfo.Enable),
logger.Field("is_admin", userInfo.IsAdmin),
logger.Field("balance", userInfo.Balance),
logger.Field("member_status", userInfo.MemberStatus),
logger.Field("created_at", userInfo.CreatedAt),
)
}
if createdNewDevice {
deviceInfo, err = l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)
if err != nil {
@@ -118,24 +139,55 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ
)
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update device failed: %v", err.Error())
}
// [AuthDebug] Log detailed Device Info
l.Infow("[AuthDebug] Device Info",
logger.Field("device_id", deviceInfo.Id),
logger.Field("identifier", deviceInfo.Identifier),
logger.Field("ip", deviceInfo.Ip),
logger.Field("enabled", deviceInfo.Enabled),
logger.Field("online", deviceInfo.Online),
logger.Field("user_agent", deviceInfo.UserAgent),
)
}
// Check if device has an existing valid session - reuse it instead of creating new one
var sessionId string
var reuseSession bool
deviceCacheKey := fmt.Sprintf("%v:%v", config.DeviceCacheKeyKey, req.Identifier)
l.Infow("[SESSION_DEBUG] logic start: checking device cache",
logger.Field("identifier", req.Identifier),
logger.Field("device_cache_key", deviceCacheKey),
)
if oldSid, getErr := l.svcCtx.Redis.Get(l.ctx, deviceCacheKey).Result(); getErr == nil && oldSid != "" {
l.Infow("[SESSION_DEBUG] device cache hit",
logger.Field("identifier", req.Identifier),
logger.Field("old_session_id", oldSid),
)
// Check if old session is still valid AND belongs to current user
oldSessionKey := fmt.Sprintf("%v:%v", config.SessionIdKey, oldSid)
if uidStr, existErr := l.svcCtx.Redis.Get(l.ctx, oldSessionKey).Result(); existErr == nil && uidStr != "" {
l.Infow("[SESSION_DEBUG] session cache hit",
logger.Field("old_session_id", oldSid),
logger.Field("session_user_id", uidStr),
logger.Field("current_user_id", userInfo.Id),
)
// Verify session belongs to current user (防止设备转移后复用其他用户的session)
if uidStr == fmt.Sprintf("%d", userInfo.Id) {
sessionId = oldSid
reuseSession = true
// Check TTL
ttl, _ := l.svcCtx.Redis.TTL(l.ctx, oldSessionKey).Result()
l.Infow("reusing existing session for device",
logger.Field("user_id", userInfo.Id),
logger.Field("identifier", req.Identifier),
logger.Field("session_id", sessionId),
logger.Field("session_ttl", ttl.Seconds()),
)
} else {
l.Infow("device session belongs to different user, creating new session",
@@ -144,7 +196,17 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ
logger.Field("identifier", req.Identifier),
)
}
} else {
l.Infow("[SESSION_DEBUG] session cache miss or invalid",
logger.Field("old_session_id", oldSid),
logger.Field("error", existErr),
)
}
} else {
l.Infow("[SESSION_DEBUG] device cache miss",
logger.Field("identifier", req.Identifier),
logger.Field("error", getErr),
)
}
if !reuseSession {
sessionId = uuidx.NewUUID().String()
@@ -156,10 +218,21 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ
}
// Generate token (always generate new token, but may reuse sessionId)
nowTime := time.Now().Unix()
accessExpire := l.svcCtx.Config.JwtAuth.AccessExpire
l.Infow("[AuthDebug] Generating Token",
logger.Field("iat", nowTime),
logger.Field("expire_seconds", accessExpire),
logger.Field("calculated_exp", nowTime+accessExpire),
logger.Field("user_id", userInfo.Id),
logger.Field("session_id", sessionId),
)
token, err := jwt.NewJwtToken(
l.svcCtx.Config.JwtAuth.AccessSecret,
time.Now().Unix(),
l.svcCtx.Config.JwtAuth.AccessExpire,
nowTime,
accessExpire,
jwt.WithOption("UserId", userInfo.Id),
jwt.WithOption("SessionId", sessionId),
jwt.WithOption("LoginType", "device"),
@@ -199,6 +272,37 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "set device id error: %v", err.Error())
}
// [Debug] Store session detail for troubleshooting
// This helps us see "who is online" when multiple devices are logged in.
sessionDetail := map[string]interface{}{
"session_id": sessionId,
"user_id": userInfo.Id,
"identifier": req.Identifier,
"ip": req.IP,
"user_agent": req.UserAgent,
"login_time": time.Now().Format("2006-01-02 15:04:05"),
"device_id": deviceInfo.Id,
}
if detailJson, err := json.Marshal(sessionDetail); err == nil {
detailKey := fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sessionId)
_ = l.svcCtx.Redis.Set(l.ctx, detailKey, string(detailJson), time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err()
}
// 登录成功 - 打印详细信息用于调试
l.Infow("========== 设备登录成功 ==========",
logger.Field("user_id", userInfo.Id),
logger.Field("identifier", req.Identifier),
logger.Field("device_id", deviceInfo.Id),
logger.Field("session_id", sessionId),
logger.Field("reuse_session", reuseSession),
logger.Field("login_type", "device"),
logger.Field("login_ip", req.IP),
logger.Field("user_agent", req.UserAgent),
logger.Field("session_limit", l.svcCtx.SessionLimit()),
logger.Field("auth_methods_count", len(userInfo.AuthMethods)),
logger.Field("devices_count", len(userInfo.UserDevices)),
)
loginStatus = true
return &types.LoginResponse{
Token: token,
+31
View File
@@ -39,6 +39,18 @@ func NewEmailLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *EmailL
}
func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.LoginResponse, err error) {
// 打印请求参数
l.Infow("EmailLogin 请求参数",
logger.Field("email", req.Email),
logger.Field("code", req.Code),
logger.Field("identifier", req.Identifier),
logger.Field("invite", req.Invite),
logger.Field("ip", req.IP),
logger.Field("user_agent", req.UserAgent),
logger.Field("login_type", req.LoginType),
logger.Field("cf_token", req.CfToken),
)
loginStatus := false
var userInfo *user.User
var isNewUser bool
@@ -234,10 +246,14 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
if uidStr == fmt.Sprintf("%d", userInfo.Id) {
sessionId = oldSid
reuseSession = true
// Check TTL
ttl, _ := l.svcCtx.Redis.TTL(l.ctx, oldSessionKey).Result()
l.Infow("reusing existing session for device",
logger.Field("user_id", userInfo.Id),
logger.Field("identifier", req.Identifier),
logger.Field("session_id", sessionId),
logger.Field("session_ttl", ttl.Seconds()),
)
} else {
l.Infow("device session belongs to different user, creating new session",
@@ -285,6 +301,21 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
_ = l.svcCtx.Redis.Set(l.ctx, deviceCacheKey, sessionId, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err()
}
// 登录成功 - 打印详细信息用于调试
l.Infow("========== 邮箱登录成功 ==========",
logger.Field("user_id", userInfo.Id),
logger.Field("identifier", req.Identifier),
logger.Field("device_id", deviceId),
logger.Field("session_id", sessionId),
logger.Field("reuse_session", reuseSession),
logger.Field("login_type", req.LoginType),
logger.Field("login_ip", req.IP),
logger.Field("user_agent", req.UserAgent),
logger.Field("session_limit", l.svcCtx.SessionLimit()),
logger.Field("auth_methods_count", len(userInfo.AuthMethods)),
logger.Field("devices_count", len(userInfo.UserDevices)),
)
loginStatus = true
return &types.LoginResponse{
Token: token,
@@ -40,6 +40,18 @@ func NewTelephoneLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *Te
}
func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r *http.Request, ip string) (resp *types.LoginResponse, err error) {
// 打印请求参数 (隐藏密码)
l.Infow("TelephoneLogin 请求参数",
logger.Field("telephone_area_code", req.TelephoneAreaCode),
logger.Field("telephone", req.Telephone),
logger.Field("has_password", req.Password != ""),
logger.Field("has_code", req.TelephoneCode != ""),
logger.Field("identifier", req.Identifier),
logger.Field("ip", ip),
logger.Field("user_agent", r.UserAgent()),
logger.Field("login_type", req.LoginType),
)
phoneNumber, err := phone.FormatToE164(req.TelephoneAreaCode, req.Telephone)
if err != nil {
return nil, errors.Wrapf(xerr.NewErrCode(xerr.TelephoneError), "Invalid phone number")
@@ -158,10 +170,14 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r
if uidStr == fmt.Sprintf("%d", userInfo.Id) {
sessionId = oldSid
reuseSession = true
// Check TTL
ttl, _ := l.svcCtx.Redis.TTL(l.ctx, oldSessionKey).Result()
l.Infow("reusing existing session for device",
logger.Field("user_id", userInfo.Id),
logger.Field("identifier", req.Identifier),
logger.Field("session_id", sessionId),
logger.Field("session_ttl", ttl.Seconds()),
)
} else {
l.Infow("device session belongs to different user, creating new session",
@@ -209,6 +225,21 @@ func (l *TelephoneLoginLogic) TelephoneLogin(req *types.TelephoneLoginRequest, r
_ = l.svcCtx.Redis.Set(l.ctx, deviceCacheKey, sessionId, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err()
}
loginStatus = true
// 登录成功 - 打印详细信息用于调试
l.Infow("========== 手机登录成功 ==========",
logger.Field("user_id", userInfo.Id),
logger.Field("telephone", phoneNumber),
logger.Field("identifier", req.Identifier),
logger.Field("session_id", sessionId),
logger.Field("reuse_session", reuseSession),
logger.Field("login_type", req.LoginType),
logger.Field("login_ip", ip),
logger.Field("user_agent", r.UserAgent()),
logger.Field("session_limit", l.svcCtx.SessionLimit()),
logger.Field("auth_methods_count", len(userInfo.AuthMethods)),
logger.Field("devices_count", len(userInfo.UserDevices)),
)
return &types.LoginResponse{
Token: token,
Limit: l.svcCtx.SessionLimit(),
+30
View File
@@ -38,6 +38,17 @@ func NewUserLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UserLog
}
func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.LoginResponse, err error) {
// 打印请求参数 (隐藏密码)
l.Infow("UserLogin 请求参数",
logger.Field("email", req.Email),
logger.Field("password_len", len(req.Password)),
logger.Field("identifier", req.Identifier),
logger.Field("ip", req.IP),
logger.Field("user_agent", req.UserAgent),
logger.Field("login_type", req.LoginType),
logger.Field("cf_token", req.CfToken),
)
loginStatus := false
var userInfo *user.User
// Record login status
@@ -130,10 +141,14 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
if uidStr == fmt.Sprintf("%d", userInfo.Id) {
sessionId = oldSid
reuseSession = true
// Check TTL
ttl, _ := l.svcCtx.Redis.TTL(l.ctx, oldSessionKey).Result()
l.Infow("reusing existing session for device",
logger.Field("user_id", userInfo.Id),
logger.Field("identifier", req.Identifier),
logger.Field("session_id", sessionId),
logger.Field("session_ttl", ttl.Seconds()),
)
} else {
l.Infow("device session belongs to different user, creating new session",
@@ -182,6 +197,21 @@ func (l *UserLoginLogic) UserLogin(req *types.UserLoginRequest) (resp *types.Log
_ = l.svcCtx.Redis.Set(l.ctx, deviceCacheKey, sessionId, time.Duration(l.svcCtx.Config.JwtAuth.AccessExpire)*time.Second).Err()
}
// 登录成功 - 打印详细信息用于调试
l.Infow("========== 用户登录成功 ==========",
logger.Field("user_id", userInfo.Id),
logger.Field("identifier", req.Identifier),
logger.Field("device_id", deviceId),
logger.Field("session_id", sessionId),
logger.Field("reuse_session", reuseSession),
logger.Field("login_type", req.LoginType),
logger.Field("login_ip", req.IP),
logger.Field("user_agent", req.UserAgent),
logger.Field("session_limit", l.svcCtx.SessionLimit()),
logger.Field("auth_methods_count", len(userInfo.AuthMethods)),
logger.Field("devices_count", len(userInfo.UserDevices)),
)
loginStatus = true
return &types.LoginResponse{
Token: token,
@@ -32,7 +32,7 @@ func (l *GetDownloadLinkLogic) GetDownloadLink(req *types.GetDownloadLinkRequest
host := l.svcCtx.Config.Site.Host
if host == "" {
// 保底域名
host = "tapi.airoport.co"
host = "api.airoport.co"
}
// 2. 版本号 (后续可以从数据库或配置中读取)
@@ -53,8 +53,13 @@ func (l *GetDownloadLinkLogic) GetDownloadLink(req *types.GetDownloadLinkRequest
ext = ".bin"
}
// 4. 构建文件名: 平台-版本号-ic_邀请码.扩展名
filename := fmt.Sprintf("%s-%s-ic_%s%s", req.Platform, version, req.InviteCode, ext)
// 4. 构建文件名: 平台-版本号[-ic_邀请码].扩展名
var filename string
if req.InviteCode != "" {
filename = fmt.Sprintf("%s-%s-ic_%s%s", req.Platform, version, req.InviteCode, ext)
} else {
filename = fmt.Sprintf("%s-%s%s", req.Platform, version, ext)
}
// 5. 构建完整 URL (Nginx 会拦截此路径进行虚拟更名处理)
url := fmt.Sprintf("https://%s/v1/common/client/download/file/%s", host, filename)
@@ -0,0 +1,64 @@
package common
import (
"context"
"testing"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/stretchr/testify/assert"
)
func TestGetDownloadLinkLogic_GetDownloadLink(t *testing.T) {
svcCtx := &svc.ServiceContext{
Config: config.Config{
Site: config.SiteConfig{
Host: "test.example.com",
},
},
}
ctx := context.Background()
l := NewGetDownloadLinkLogic(ctx, svcCtx)
tests := []struct {
name string
req *types.GetDownloadLinkRequest
wantSubStr []string // strings that should be in the URL
notSubStr []string // strings that should NOT be in the URL
}{
{
name: "With Invite Code",
req: &types.GetDownloadLinkRequest{
Platform: "windows",
InviteCode: "TESTCODE",
},
wantSubStr: []string{"-ic_TESTCODE.exe"},
notSubStr: []string{},
},
{
name: "Without Invite Code",
req: &types.GetDownloadLinkRequest{
Platform: "mac",
InviteCode: "",
},
wantSubStr: []string{".dmg"},
notSubStr: []string{"-ic", "ic_"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resp, err := l.GetDownloadLink(tt.req)
assert.NoError(t, err)
assert.NotNil(t, resp)
for _, s := range tt.wantSubStr {
assert.Contains(t, resp.Url, s)
}
for _, s := range tt.notSubStr {
assert.NotContains(t, resp.Url, s)
}
})
}
}
+18 -3
View File
@@ -37,6 +37,11 @@ func NewEPayNotifyLogic(ctx *gin.Context, svcCtx *svc.ServiceContext) *EPayNotif
}
func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
l.Logger.Info("[EPayNotify] 收到支付回调",
logger.Field("orderNo", req.OutTradeNo),
logger.Field("tradeNo", req.TradeNo),
logger.Field("tradeStatus", req.TradeStatus),
logger.Field("money", req.Money))
// Find payment config
data, ok := l.ctx.Request.Context().Value(constant.CtxKeyPayment).(*payment.Payment)
@@ -51,6 +56,12 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
return errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order not exist: %v", req.OutTradeNo)
}
l.Logger.Info("[EPayNotify] 找到订单",
logger.Field("orderNo", orderInfo.OrderNo),
logger.Field("currentStatus", orderInfo.Status),
logger.Field("userId", orderInfo.UserId),
logger.Field("orderType", orderInfo.Type))
var config payment.EPayConfig
if err := json.Unmarshal([]byte(data.Config), &config); err != nil {
l.Logger.Errorw("[EPayNotify] Unmarshal config failed", logger.Field("error", err.Error()))
@@ -59,7 +70,7 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
// Verify sign
client := epay.NewClient(config.Pid, config.Url, config.Key, config.Type)
if !client.VerifySign(urlParamsToMap(l.ctx.Request.URL.RawQuery)) && !l.svcCtx.Config.Debug {
l.Logger.Error("[EPayNotify] Verify sign failed")
l.Logger.Error("[EPayNotify] Verify sign failed", logger.Field("orderNo", req.OutTradeNo))
return nil
}
if req.TradeStatus != "TRADE_SUCCESS" {
@@ -67,9 +78,11 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
return nil
}
if orderInfo.Status == 5 {
l.Logger.Info("[EPayNotify] 订单已完成,跳过处理", logger.Field("orderNo", req.OutTradeNo))
return nil
}
// Update order status
l.Logger.Info("[EPayNotify] 更新订单状态为已支付(2)", logger.Field("orderNo", req.OutTradeNo))
err = l.svcCtx.OrderModel.UpdateOrderStatus(l.ctx, req.OutTradeNo, 2)
if err != nil {
l.Logger.Error("[EPayNotify] Update order status failed", logger.Field("error", err.Error()), logger.Field("orderNo", req.OutTradeNo))
@@ -87,10 +100,12 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
task := asynq.NewTask(queueType.ForthwithActivateOrder, bytes)
taskInfo, err := l.svcCtx.Queue.EnqueueContext(l.ctx, task)
if err != nil {
l.Logger.Error("[EPayNotify] Enqueue task failed", logger.Field("error", err.Error()))
l.Logger.Error("[EPayNotify] Enqueue task failed", logger.Field("error", err.Error()), logger.Field("orderNo", req.OutTradeNo))
return err
}
l.Logger.Info("[EPayNotify] Enqueue task success", logger.Field("taskInfo", taskInfo))
l.Logger.Info("[EPayNotify] ✅ 回调处理成功,已入队激活任务",
logger.Field("orderNo", req.OutTradeNo),
logger.Field("taskId", taskInfo.ID))
return nil
}
@@ -432,7 +432,7 @@ func (l *BindEmailWithVerificationLogic) transferDeviceToEmailUser(deviceUserId,
l.Errorw("清理原SessionId缓存失败", logger.Field("error", err.Error()), logger.Field("session_id", currentSessionId))
// 不返回错误,继续执行
} else {
l.Infow("清理原SessionId缓存", logger.Field("session_id", currentSessionId))
l.Infow("[SessionMonitor] 绑定邮箱成功后立即清理原 Session", logger.Field("session_id", currentSessionId))
}
}
@@ -38,7 +38,7 @@ func (l *BindInviteCodeLogic) BindInviteCode(req *types.BindInviteCodeRequest) e
// 检查用户是否已经绑定过邀请码
if currentUser.RefererId != 0 {
return errors.Wrapf(xerr.NewErrCode(xerr.UserExist), "user already bound invite code")
return errors.Wrapf(xerr.NewErrCode(xerr.UserBindInviteCodeExist), "用户已绑定邀请人")
}
// 查找邀请人
@@ -139,6 +139,10 @@ func (l *DeleteAccountLogic) clearCurrentSession(userId int64) {
// 从用户会话集合中移除当前session
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId)
_ = l.svcCtx.Redis.ZRem(l.ctx, sessionsKey, sessionId).Err()
l.Infow("[SessionMonitor] 注销账号清除 Session",
logger.Field("user_id", userId),
logger.Field("session_id", sessionId))
}
}
@@ -126,8 +126,9 @@ func (l *UnbindDeviceLogic) UnbindDevice(req *types.UnbindDeviceRequest) error {
l.svcCtx.DeviceManager.KickDevice(u.Id, identifier)
// clean user cache
_ = l.svcCtx.UserModel.ClearUserCache(l.ctx, u)
l.Infow("设备解绑完成",
l.Infow("[SessionMonitor] 设备解绑触发 Session 清理",
logger.Field("device_identifier", identifier),
logger.Field("user_id", u.Id),
logger.Field("elapsed_ms", duration.Milliseconds()))
return nil
}
+45 -5
View File
@@ -5,6 +5,7 @@ import (
"fmt"
"strings"
jwtGo "github.com/golang-jwt/jwt/v5"
"github.com/perfect-panel/server/pkg/constant"
"github.com/perfect-panel/server/pkg/logger"
@@ -36,7 +37,25 @@ func AuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) {
// parse token
claims, err := jwt.ParseJwtToken(token, jwtConfig.AccessSecret)
if err != nil {
logger.WithContext(c.Request.Context()).Errorw("[AuthMiddleware] parse token failed", logger.Field("error", err.Error()))
logger.WithContext(c.Request.Context()).Errorw("[AuthMiddleware] parse token failed",
logger.Field("error", err.Error()),
)
// [AuthDebug] Try to parse unverified to see why it failed (expired or invalid signature)
parser := jwtGo.NewParser()
unverifiedToken, _, _ := parser.ParseUnverified(token, jwtGo.MapClaims{})
if unverifiedToken != nil {
if unverifiedClaims, ok := unverifiedToken.Claims.(jwtGo.MapClaims); ok {
logger.WithContext(c.Request.Context()).Errorw("[AuthDebug] Token Parsing Failure Details",
logger.Field("exp", unverifiedClaims["exp"]),
logger.Field("iat", unverifiedClaims["iat"]),
logger.Field("uid", unverifiedClaims["UserId"]),
logger.Field("sid", unverifiedClaims["SessionId"]),
logger.Field("sub", unverifiedClaims["sub"]),
)
}
}
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.ErrorTokenExpire), "Token Invalid"))
c.Abort()
return
@@ -60,9 +79,17 @@ func AuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) {
value, err := svc.Redis.Get(c, sessionIdCacheKey).Result()
if err != nil {
if errors.Is(err, redis.Nil) {
logger.WithContext(c.Request.Context()).Infow("[AuthMiddleware] session not found", logger.Field("sessionId", sessionId))
logger.WithContext(c.Request.Context()).Infow("[AuthMiddleware] Session无效或已过期",
logger.Field("session_id", sessionId),
logger.Field("user_id", userId),
logger.Field("redis_key", sessionIdCacheKey),
logger.Field("ip", c.ClientIP()),
logger.Field("path", c.Request.URL.Path))
} else {
logger.WithContext(c.Request.Context()).Errorw("[AuthMiddleware] redis get failed", logger.Field("error", err.Error()), logger.Field("sessionId", sessionId))
logger.WithContext(c.Request.Context()).Errorw("[AuthMiddleware] Redis 查询失败",
logger.Field("error", err.Error()),
logger.Field("session_id", sessionId),
logger.Field("redis_key", sessionIdCacheKey))
}
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access"))
c.Abort()
@@ -71,7 +98,10 @@ func AuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) {
//verify user id
if value != fmt.Sprintf("%v", userId) {
logger.WithContext(c.Request.Context()).Errorw("[AuthMiddleware] user mismatch", logger.Field("userId", userId), logger.Field("sessionId", sessionId), logger.Field("value", value))
logger.WithContext(c.Request.Context()).Errorw("[AuthMiddleware] user mismatch",
logger.Field("userId_in_token", userId),
logger.Field("userId_in_redis", value),
logger.Field("sessionId", sessionId))
result.HttpResult(c, nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access"))
c.Abort()
return
@@ -92,7 +122,17 @@ func AuthMiddleware(svc *svc.ServiceContext) func(c *gin.Context) {
c.Abort()
return
}
logger.WithContext(c.Request.Context()).Infow("[AuthMiddleware] auth ok", logger.Field("userId", userId), logger.Field("loginType", loginType), logger.Field("path", c.Request.URL.Path))
// Get TTL details for debugging
ttl, _ := svc.Redis.TTL(c.Request.Context(), sessionIdCacheKey).Result()
logger.WithContext(c.Request.Context()).Infow("[AuthMiddleware] auth ok",
logger.Field("userId", userId),
logger.Field("loginType", loginType),
logger.Field("path", c.Request.URL.Path),
logger.Field("session_ttl", ttl.Seconds()),
logger.Field("sessionId", sessionId),
logger.Field("deviceId", deviceId),
)
ctx = context.WithValue(ctx, constant.LoginType, loginType)
ctx = context.WithValue(ctx, constant.CtxKeyUser, userInfo)
ctx = context.WithValue(ctx, constant.CtxKeySessionID, sessionId)
-3
View File
@@ -13,7 +13,6 @@ import (
"github.com/gin-contrib/sessions"
"github.com/gin-contrib/sessions/redis"
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/initialize"
"github.com/perfect-panel/server/internal/handler"
"github.com/perfect-panel/server/internal/middleware"
"github.com/perfect-panel/server/internal/svc"
@@ -32,8 +31,6 @@ func NewService(svc *svc.ServiceContext) *Service {
func initServer(svc *svc.ServiceContext) *gin.Engine {
// start init system config
initialize.StartInitSystemConfig(svc)
// init gin server
r := gin.Default()
r.RemoteIPHeaders = []string{"X-Original-Forwarded-For", "X-Forwarded-For", "X-Real-IP"}
+36 -8
View File
@@ -18,6 +18,7 @@ import (
"github.com/perfect-panel/server/internal/model/auth"
"github.com/perfect-panel/server/internal/model/coupon"
"github.com/perfect-panel/server/internal/model/document"
iapapple "github.com/perfect-panel/server/internal/model/iap/apple"
"github.com/perfect-panel/server/internal/model/log"
logmessage "github.com/perfect-panel/server/internal/model/logmessage"
"github.com/perfect-panel/server/internal/model/order"
@@ -27,7 +28,6 @@ import (
"github.com/perfect-panel/server/internal/model/ticket"
"github.com/perfect-panel/server/internal/model/traffic"
"github.com/perfect-panel/server/internal/model/user"
iapapple "github.com/perfect-panel/server/internal/model/iap/apple"
"github.com/perfect-panel/server/pkg/limit"
"github.com/perfect-panel/server/pkg/nodeMultiplier"
"github.com/perfect-panel/server/pkg/orm"
@@ -56,13 +56,13 @@ type ServiceContext struct {
ClientModel client.Model
TicketModel ticket.Model
//ServerModel server.Model
SystemModel system.Model
CouponModel coupon.Model
PaymentModel payment.Model
DocumentModel document.Model
SubscribeModel subscribe.Model
TrafficLogModel traffic.Model
AnnouncementModel announcement.Model
SystemModel system.Model
CouponModel coupon.Model
PaymentModel payment.Model
DocumentModel document.Model
SubscribeModel subscribe.Model
TrafficLogModel traffic.Model
AnnouncementModel announcement.Model
IAPAppleTransactionModel iapapple.Model
Restart func() error
@@ -126,6 +126,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
}
func (srv *ServiceContext) SessionLimit() int64 {
// check custom data
cd := srv.Config.Site.CustomData
if cd != "" {
var obj map[string]interface{}
@@ -134,10 +135,12 @@ func (srv *ServiceContext) SessionLimit() int64 {
switch val := v.(type) {
case float64:
if val > 0 {
fmt.Printf("[SessionLimit] Using CustomData deviceLimit: %d\n", int64(val))
return int64(val)
}
case string:
if n, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64); err == nil && n > 0 {
fmt.Printf("[SessionLimit] Using CustomData deviceLimit: %d\n", n)
return n
}
}
@@ -146,16 +149,19 @@ func (srv *ServiceContext) SessionLimit() int64 {
switch val := v.(type) {
case float64:
if val > 0 {
fmt.Printf("[SessionLimit] Using CustomData DeviceLimit: %d\n", int64(val))
return int64(val)
}
case string:
if n, err := strconv.ParseInt(strings.TrimSpace(val), 10, 64); err == nil && n > 0 {
fmt.Printf("[SessionLimit] Using CustomData DeviceLimit: %d\n", n)
return n
}
}
}
}
}
fmt.Printf("[SessionLimit] Using Config MaxSessionsPerUser: %d\n", srv.Config.JwtAuth.MaxSessionsPerUser)
return srv.Config.JwtAuth.MaxSessionsPerUser
}
@@ -173,6 +179,22 @@ func (srv *ServiceContext) EnforceUserSessionLimit(ctx context.Context, userId i
return err
}
if count > max {
// [SessionDebug] Log all current sessions before eviction
// Fetch all sessions (oldest to newest)
sessions, _ := srv.Redis.ZRange(ctx, sessionsKey, 0, -1).Result()
fmt.Printf("[SessionMonitor] ⚠️ Session Limit Exceeded (Count: %d, Max: %d). User %d has the following active sessions:\n", count, max, userId)
for i, sid := range sessions {
detailKey := fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sid)
val, err := srv.Redis.Get(ctx, detailKey).Result()
if err == nil {
fmt.Printf(" [%d] SessionID: %s | Detail: %s\n", i+1, sid, val)
} else {
fmt.Printf(" [%d] SessionID: %s | (No Detail - Likely old session)\n", i+1, sid)
}
}
// Log before eviction
popped, err := srv.Redis.ZPopMin(ctx, sessionsKey, count-max).Result()
if err != nil {
return err
@@ -180,6 +202,12 @@ func (srv *ServiceContext) EnforceUserSessionLimit(ctx context.Context, userId i
for _, z := range popped {
sid := fmt.Sprintf("%v", z.Member)
_ = srv.Redis.Del(ctx, fmt.Sprintf("%v:%v", config.SessionIdKey, sid)).Err()
// Also delete detail
_ = srv.Redis.Del(ctx, fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sid)).Err()
// 记录被踢出的 Session 信息
fmt.Printf("[SessionMonitor] ❌ KICKED OUT Session: user_id=%d session_id=%s reason=exceed_limit\n",
userId, sid)
}
}
_ = srv.Redis.Expire(ctx, sessionsKey, time.Duration(srv.Config.JwtAuth.AccessExpire)*time.Second).Err()