新功能(#48): 用户封禁链路接入 (#32)

Co-authored-by: multica-agent <github@multica.ai>
This commit is contained in:
2026-06-16 06:28:47 -07:00
committed by GitHub
parent 3d1a31a19f
commit 77fa0cadd2
17 changed files with 581 additions and 83 deletions
@@ -2,11 +2,7 @@ package user
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
@@ -50,84 +46,11 @@ func (l *KickOfflineByUserDeviceLogic) KickOfflineByUserDevice(req *types.KickOf
// clearAllSessions 清除指定用户的所有会话(通过 SCAN 查找,不依赖 sorted set
func (l *KickOfflineByUserDeviceLogic) clearAllSessions(userId int64) {
sessionSet := make(map[string]struct{})
userIDText := strconv.FormatInt(userId, 10)
pattern := fmt.Sprintf("%s:*", config.SessionIdKey)
var cursor uint64
for {
keys, nextCursor, scanErr := l.svcCtx.Redis.Scan(l.ctx, cursor, pattern, 200).Result()
if scanErr != nil {
l.Errorw("扫描会话键失败", logger.Field("user_id", userId), logger.Field("error", scanErr.Error()))
break
}
for _, sessionKey := range keys {
value, getErr := l.svcCtx.Redis.Get(l.ctx, sessionKey).Result()
if getErr != nil || value != userIDText {
continue
}
sessionID := strings.TrimPrefix(sessionKey, config.SessionIdKey+":")
if sessionID == "" || strings.HasPrefix(sessionID, "detail:") {
continue
}
sessionSet[sessionID] = struct{}{}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
deviceKeySet := make(map[string]struct{})
devicePattern := fmt.Sprintf("%s:*", config.DeviceCacheKeyKey)
cursor = 0
for {
keys, nextCursor, scanErr := l.svcCtx.Redis.Scan(l.ctx, cursor, devicePattern, 200).Result()
if scanErr != nil {
l.Errorw("扫描设备会话映射失败", logger.Field("user_id", userId), logger.Field("error", scanErr.Error()))
break
}
for _, deviceKey := range keys {
sessionID, getErr := l.svcCtx.Redis.Get(l.ctx, deviceKey).Result()
if getErr != nil {
continue
}
if _, exists := sessionSet[sessionID]; exists {
deviceKeySet[deviceKey] = struct{}{}
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
if len(sessionSet) == 0 {
return
}
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userId)
pipe := l.svcCtx.Redis.TxPipeline()
for sessionID := range sessionSet {
pipe.Del(l.ctx, fmt.Sprintf("%v:%v", config.SessionIdKey, sessionID))
pipe.Del(l.ctx, fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sessionID))
pipe.ZRem(l.ctx, sessionsKey, sessionID)
}
pipe.Del(l.ctx, sessionsKey)
for deviceKey := range deviceKeySet {
pipe.Del(l.ctx, deviceKey)
}
if _, err := pipe.Exec(l.ctx); err != nil {
if err := clearAllSessions(l.ctx, l.svcCtx, userId); err != nil {
l.Errorw("清理会话缓存失败",
logger.Field("user_id", userId),
logger.Field("error", err.Error()),
)
return
}
l.Infow("[KickOffline] 管理员踢设备-清除所有Session",
logger.Field("user_id", userId),
logger.Field("count", len(sessionSet)),
)
}
+81
View File
@@ -0,0 +1,81 @@
package user
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
)
func clearAllSessions(ctx context.Context, svcCtx *svc.ServiceContext, userID int64) error {
userIDText := strconv.FormatInt(userID, 10)
sessionSet := make(map[string]struct{})
pattern := fmt.Sprintf("%s:*", config.SessionIdKey)
var cursor uint64
for {
keys, nextCursor, err := svcCtx.Redis.Scan(ctx, cursor, pattern, 200).Result()
if err != nil {
return err
}
for _, sessionKey := range keys {
value, err := svcCtx.Redis.Get(ctx, sessionKey).Result()
if err != nil || value != userIDText {
continue
}
sessionID := strings.TrimPrefix(sessionKey, config.SessionIdKey+":")
if sessionID == "" || strings.HasPrefix(sessionID, "detail:") {
continue
}
sessionSet[sessionID] = struct{}{}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
if len(sessionSet) == 0 {
return nil
}
deviceKeySet := make(map[string]struct{})
devicePattern := fmt.Sprintf("%s:*", config.DeviceCacheKeyKey)
cursor = 0
for {
keys, nextCursor, err := svcCtx.Redis.Scan(ctx, cursor, devicePattern, 200).Result()
if err != nil {
return err
}
for _, deviceKey := range keys {
sessionID, err := svcCtx.Redis.Get(ctx, deviceKey).Result()
if err != nil {
continue
}
if _, exists := sessionSet[sessionID]; exists {
deviceKeySet[deviceKey] = struct{}{}
}
}
cursor = nextCursor
if cursor == 0 {
break
}
}
sessionsKey := fmt.Sprintf("%s%v", config.UserSessionsKeyPrefix, userID)
pipe := svcCtx.Redis.TxPipeline()
for sessionID := range sessionSet {
pipe.Del(ctx, fmt.Sprintf("%v:%v", config.SessionIdKey, sessionID))
pipe.Del(ctx, fmt.Sprintf("%s:detail:%s", config.SessionIdKey, sessionID))
pipe.ZRem(ctx, sessionsKey, sessionID)
}
pipe.Del(ctx, sessionsKey)
for deviceKey := range deviceKeySet {
pipe.Del(ctx, deviceKey)
}
_, err := pipe.Exec(ctx)
return err
}
+74
View File
@@ -0,0 +1,74 @@
package user
import (
"context"
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/redis/go-redis/v9"
)
func TestClearAllSessions(t *testing.T) {
redisServer, err := miniredis.Run()
if err != nil {
t.Fatalf("miniredis.Run() error = %v", err)
}
defer redisServer.Close()
rdb := redis.NewClient(&redis.Options{Addr: redisServer.Addr()})
defer rdb.Close()
svcCtx := &svc.ServiceContext{Redis: rdb}
ctx := context.Background()
userID := int64(42)
sessionID := "session-a"
otherSessionID := "session-b"
userSessionKey := config.SessionIdKey + ":" + sessionID
userDetailKey := config.SessionIdKey + ":detail:" + sessionID
otherSessionKey := config.SessionIdKey + ":" + otherSessionID
userSessionsZSet := config.UserSessionsKeyPrefix + "42"
deviceKey := config.DeviceCacheKeyKey + ":device-1"
unrelatedDeviceKey := config.DeviceCacheKeyKey + ":device-2"
setString(t, redisServer, userSessionKey, "42")
setString(t, redisServer, userDetailKey, "detail")
setString(t, redisServer, otherSessionKey, "99")
setString(t, redisServer, deviceKey, sessionID)
setString(t, redisServer, unrelatedDeviceKey, otherSessionID)
if _, err := redisServer.ZAdd(userSessionsZSet, 1, sessionID); err != nil {
t.Fatalf("seed session zset: %v", err)
}
if err := clearAllSessions(ctx, svcCtx, userID); err != nil {
t.Fatalf("clearAllSessions() error = %v", err)
}
assertMissing(t, redisServer, userSessionKey)
assertMissing(t, redisServer, userDetailKey)
assertMissing(t, redisServer, deviceKey)
assertMissing(t, redisServer, userSessionsZSet)
if !redisServer.Exists(otherSessionKey) {
t.Fatalf("unrelated session %q should remain", otherSessionKey)
}
if !redisServer.Exists(unrelatedDeviceKey) {
t.Fatalf("unrelated device mapping %q should remain", unrelatedDeviceKey)
}
}
func setString(t *testing.T, server *miniredis.Miniredis, key, value string) {
t.Helper()
if err := server.Set(key, value); err != nil {
t.Fatalf("set %q: %v", key, err)
}
}
func assertMissing(t *testing.T, server *miniredis.Miniredis, key string) {
t.Helper()
if server.Exists(key) {
t.Fatalf("expected key %q to be removed", key)
}
}
@@ -44,6 +44,14 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
if req.Avatar != "" && !tool.IsValidImageSize(req.Avatar, 1024) {
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Invalid Image Size")
}
if req.Enable != nil && !*req.Enable {
if userInfo.IsAdmin != nil && *userInfo.IsAdmin {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "admin user cannot be disabled")
}
if userInfo.Id == 2 {
return errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "demo user cannot be disabled")
}
}
err = l.svcCtx.UserModel.Transaction(l.ctx, func(tx *gorm.DB) error {
if req.Balance != nil && userInfo.Balance != *req.Balance {
@@ -176,6 +184,19 @@ func (l *UpdateUserBasicInfoLogic) UpdateUserBasicInfo(req *types.UpdateUserBasi
}
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "Update User Error")
}
if req.Enable != nil {
if cacheErr := logicCommon.InvalidateUserEnableCache(l.ctx, l.svcCtx, userInfo.Id); cacheErr != nil {
l.Errorw("[UpdateUserBasicInfoLogic] clear enable cache failed", logger.Field("err", cacheErr.Error()), logger.Field("userId", req.UserId))
}
if !*req.Enable {
if sessionErr := clearAllSessions(l.ctx, l.svcCtx, userInfo.Id); sessionErr != nil {
l.Errorw("[UpdateUserBasicInfoLogic] clear sessions failed", logger.Field("err", sessionErr.Error()), logger.Field("userId", req.UserId))
}
for _, device := range userInfo.UserDevices {
l.svcCtx.DeviceManager.KickDevice(userInfo.Id, device.Identifier)
}
}
}
return nil
}