diff --git a/.env.example b/.env.example index f2e98de..018a191 100644 --- a/.env.example +++ b/.env.example @@ -3,3 +3,4 @@ DATABASE_PASSWORD=your_password_here JWT_SECRET=generate-a-random-64-char-string-here +REDIS_PASSWORD=your_redis_password_here diff --git a/cmd/server/main.go b/cmd/server/main.go index 9d4c35a..2620cd4 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -10,6 +10,7 @@ import ( "metazone.cc/metalab/internal/theme" "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" "gorm.io/driver/postgres" "gorm.io/gorm" ) @@ -69,6 +70,18 @@ func main() { r.Static("/uploads/avatars", "./storage/uploads/avatars") r.Static("/uploads/posts", "./storage/uploads/posts") + // Redis 客户端(可选) + var redisClient *redis.Client + if cfg.Redis.Enabled { + redisClient, err = common.NewRedisClient(cfg.Redis) + if err != nil { + log.Fatalf("Redis 连接失败: %v", err) + } + log.Printf("Redis 已连接 %s:%s (DB=%d)", cfg.Redis.Host, cfg.Redis.Port, cfg.Redis.DB) + } else { + log.Println("Redis 未启用,使用内存存储") + } + // 站点设置管理器(DB 持久化 + 内存缓存) siteSettings, err := config.NewSiteSettings(db) if err != nil { @@ -76,7 +89,7 @@ func main() { } // 路由注册 - router.Setup(r, db, cfg, siteSettings) + router.Setup(r, db, cfg, siteSettings, redisClient) // 启动 log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port) diff --git a/config.yaml b/config.yaml index 6725bb1..94b47a6 100644 --- a/config.yaml +++ b/config.yaml @@ -18,7 +18,15 @@ database: session: idle_timeout: 120 # 不记住我:空闲超时(分钟),120 = 2 小时 remember_timeout: 43200 # 记住我:空闲超时(分钟),默认 43200 = 30 天 - cleanup_interval: 300 # 后台清理过期会话间隔(秒),默认 300 + cleanup_interval: 300 # 后台清理过期会话间隔(秒),默认 300,仅内存模式使用 + +# Redis 配置(可选,enabled: false 时使用内存存储) +redis: + enabled: false # 是否启用 Redis 存储会话 + host: 127.0.0.1 + port: 6379 + password: "" # 敏感值由 .env 注入 + db: 0 bcrypt: cost: 12 diff --git a/go.mod b/go.mod index 7cb0234..ee8b1f2 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.26.0 require ( github.com/chai2010/webp v1.4.0 github.com/gin-gonic/gin v1.12.0 + github.com/redis/go-redis/v9 v9.20.0 github.com/spf13/viper v1.21.0 golang.org/x/crypto v0.52.0 golang.org/x/image v0.41.0 @@ -16,6 +17,7 @@ require ( github.com/bytedance/gopkg v0.1.3 // indirect github.com/bytedance/sonic v1.15.0 // indirect github.com/bytedance/sonic/loader v0.5.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.6 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/gabriel-vasile/mimetype v1.4.12 // indirect @@ -50,6 +52,7 @@ require ( github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/ugorji/go/codec v1.3.1 // indirect go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect + go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/arch v0.22.0 // indirect golang.org/x/net v0.54.0 // indirect diff --git a/go.sum b/go.sum index 9131652..660653b 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,15 @@ +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chai2010/webp v1.4.0 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko= github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU= github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= @@ -75,6 +81,8 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw= github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/redis/go-redis/v9 v9.20.0 h1:WnQYxLkgO2xiXTCJY0ldIiI8dNqCDlQAG+AtaH7a2a0= +github.com/redis/go-redis/v9 v9.20.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= @@ -107,8 +115,12 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE= go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= diff --git a/internal/common/redis.go b/internal/common/redis.go new file mode 100644 index 0000000..7ecae6c --- /dev/null +++ b/internal/common/redis.go @@ -0,0 +1,28 @@ +package common + +import ( + "context" + "fmt" + "time" + + "github.com/redis/go-redis/v9" + "metazone.cc/metalab/internal/config" +) + +// NewRedisClient 创建 Redis 客户端并验证连接 +func NewRedisClient(cfg config.RedisConfig) (*redis.Client, error) { + client := redis.NewClient(&redis.Options{ + Addr: fmt.Sprintf("%s:%s", cfg.Host, cfg.Port), + Password: cfg.Password, + DB: cfg.DB, + }) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := client.Ping(ctx).Err(); err != nil { + return nil, fmt.Errorf("Redis 连接失败: %w", err) + } + + return client, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index db2a061..1186d77 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,7 @@ type Config struct { Server ServerConfig `mapstructure:"server"` Database DatabaseConfig `mapstructure:"database"` Session SessionConfig `mapstructure:"session"` + Redis RedisConfig `mapstructure:"redis"` Bcrypt BcryptConfig `mapstructure:"bcrypt"` Roles RolesConfig `mapstructure:"roles"` Audit AuditConfig `mapstructure:"audit"` @@ -45,7 +46,16 @@ type DatabaseConfig struct { type SessionConfig struct { IdleTimeout int `mapstructure:"idle_timeout"` // 不记住我:空闲超时(分钟),默认 1440(24小时) RememberTimeout int `mapstructure:"remember_timeout"` // 记住我:空闲超时(分钟),默认 43200(30天) - CleanupInterval int `mapstructure:"cleanup_interval"` // 后台清理间隔(秒),默认 300 + CleanupInterval int `mapstructure:"cleanup_interval"` // 后台清理间隔(秒),默认 300,仅内存模式使用 +} + +// RedisConfig Redis 连接配置 +type RedisConfig struct { + Enabled bool `mapstructure:"enabled"` + Host string `mapstructure:"host"` + Port string `mapstructure:"port"` + Password string `mapstructure:"password"` + DB int `mapstructure:"db"` } type BcryptConfig struct { @@ -127,6 +137,7 @@ func loadEnvFile(path string) { // bindEnvOverride 将环境变量映射到 config 的嵌套键 func bindEnvOverride(v *viper.Viper) { _ = v.BindEnv("database.password", "DATABASE_PASSWORD") + _ = v.BindEnv("redis.password", "REDIS_PASSWORD") } // GetIdleTimeout 返回临时会话空闲超时(分钟),实现 controller.sessionConfig 接口 diff --git a/internal/router/deps_core.go b/internal/router/deps_core.go index ad09b9e..5461c50 100644 --- a/internal/router/deps_core.go +++ b/internal/router/deps_core.go @@ -1,6 +1,7 @@ package router import ( + "log" "time" "metazone.cc/metalab/internal/config" @@ -11,6 +12,7 @@ import ( "metazone.cc/metalab/internal/service" "metazone.cc/metalab/internal/session" + "github.com/redis/go-redis/v9" "gorm.io/gorm" ) @@ -29,20 +31,29 @@ type dependencies struct { siteSettingCtrl *adminCtrl.SiteSettingController } -func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) ( +func buildDepsCore(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) ( *repository.UserRepo, *session.Manager, *service.AuthService, *middleware.RateLimiter, *controller.AuthController, *service.AvatarService, *middleware.AuthMiddleware, *adminCtrl.AdminController, ) { userRepo := repository.NewUserRepo(db) - // Session 存储:内存模式(后续可切换为 RedisStore) - sessionStore := session.NewMemoryStore( - time.Duration(cfg.Session.IdleTimeout)*time.Minute, - time.Duration(cfg.Session.RememberTimeout)*time.Minute, - ) - // 启动后台过期清理 goroutine - sessionStore.StartCleanup(time.Duration(cfg.Session.CleanupInterval) * time.Second) + idleTimeout := time.Duration(cfg.Session.IdleTimeout) * time.Minute + rememberTimeout := time.Duration(cfg.Session.RememberTimeout) * time.Minute + + var sessionStore session.Store + + if cfg.Redis.Enabled && redisClient != nil { + // Redis 模式(生产环境推荐) + sessionStore = session.NewRedisStore(redisClient, idleTimeout, rememberTimeout) + log.Println("[Router] 会话存储: Redis") + } else { + // 内存模式(开发/单实例) + memStore := session.NewMemoryStore(idleTimeout, rememberTimeout) + memStore.StartCleanup(time.Duration(cfg.Session.CleanupInterval) * time.Second) + sessionStore = memStore + log.Println("[Router] 会话存储: 内存") + } sessionMgr := session.NewManager(sessionStore, userRepo, cfg) authService := service.NewAuthService(userRepo, sessionMgr, cfg, siteSettings) diff --git a/internal/router/deps_extra.go b/internal/router/deps_extra.go index 223699b..46fa32d 100644 --- a/internal/router/deps_extra.go +++ b/internal/router/deps_extra.go @@ -8,11 +8,12 @@ import ( "metazone.cc/metalab/internal/repository" "metazone.cc/metalab/internal/service" + "github.com/redis/go-redis/v9" "gorm.io/gorm" ) -func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) *dependencies { - userRepo, sessionMgr, authService, _, authCtrl, avatarSvc, authMdw, adminController := buildDepsCore(db, cfg, siteSettings) +func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) *dependencies { + userRepo, sessionMgr, authService, _, authCtrl, avatarSvc, authMdw, adminController := buildDepsCore(db, cfg, siteSettings, redisClient) auditRepo := repository.NewAuditRepo(db) notificationRepo := repository.NewNotificationRepo(db) diff --git a/internal/router/router.go b/internal/router/router.go index 4091796..e4baa08 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -5,15 +5,16 @@ import ( "metazone.cc/metalab/internal/middleware" "github.com/gin-gonic/gin" + "github.com/redis/go-redis/v9" "gorm.io/gorm" ) // Setup 注册所有路由 -func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) { +func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings, redisClient *redis.Client) { r.Use(middleware.SecurityHeaders()) r.Use(middleware.InjectSiteInfo(siteSettings)) - deps := buildDeps(db, cfg, siteSettings) + deps := buildDeps(db, cfg, siteSettings, redisClient) // 维护模式中间件(全局,在路由匹配之前) r.Use(deps.maintenanceMdw.Handler()) diff --git a/internal/session/redis_store.go b/internal/session/redis_store.go index bb4aa9f..b0278b7 100644 --- a/internal/session/redis_store.go +++ b/internal/session/redis_store.go @@ -1,29 +1,231 @@ package session -// RedisStore Redis 会话存储(预留实现,切换到 Redis 时启用) -// import "github.com/redis/go-redis/v9" -// -// type RedisStore struct { -// client *redis.Client -// ttl time.Duration -// } -// -// func NewRedisStore(client *redis.Client, ttl time.Duration) *RedisStore { -// return &RedisStore{client: client, ttl: ttl} -// } -// -// func (rs *RedisStore) Get(id string) (*Session, error) { ... } -// func (rs *RedisStore) Set(s *Session) error { ... } -// func (rs *RedisStore) Delete(id string) error { ... } -// func (rs *RedisStore) DeleteByUID(uid uint) error { ... } -// func (rs *RedisStore) Cleanup() int { return 0 } -// -// Redis key pattern: -// session:{sid} → serialized Session (TTL = idle timeout) -// user_sessions:{uid} → Set of sids (for batch deletion) +import ( + "context" + "encoding/json" + "fmt" + "log" + "time" -// 当前使用内存存储,Redis 支持预留接口,未来需切换时: -// 1. go get github.com/redis/go-redis/v9 -// 2. 取消本文件注释并实现 RedisStore -// 3. 在 deps_core.go 中将 NewMemoryStore → NewRedisStore -// 4. 注入 redis client(从 config 读取 Redis 连接信息) + "github.com/redis/go-redis/v9" +) + +// Redis key 前缀前缀,避免与其他业务 key 冲突 +const ( + keyPrefix = "metalab:session:" + userKeyPrefix = "metalab:user_sessions:" +) + +// RedisStore 基于 Redis 的会话存储(生产环境推荐) +// 利用 Redis TTL 自动过期,无须后台清理 goroutine +type RedisStore struct { + client *redis.Client + idleTimeout time.Duration // 不记住我:空闲超时 + rememberTimeout time.Duration // 记住我:空闲超时 +} + +// NewRedisStore 创建 Redis 会话存储实例 +func NewRedisStore(client *redis.Client, idleTimeout, rememberTimeout time.Duration) *RedisStore { + return &RedisStore{ + client: client, + idleTimeout: idleTimeout, + rememberTimeout: rememberTimeout, + } +} + +// userKey 返回用户会话索引的 Redis key +func (rs *RedisStore) userKey(uid uint) string { + return userKeyPrefix + fmt.Sprint(uid) +} + +// sessionKeys 将 sid 列表转为完整的 Redis session key 列表 +func (rs *RedisStore) sessionKeys(sids []string) []string { + keys := make([]string, len(sids)) + for i, sid := range sids { + keys[i] = keyPrefix + sid + } + return keys +} + +// Get 根据会话 ID 获取会话(过期返回 nil) +func (rs *RedisStore) Get(id string) (*Session, error) { + ctx := context.Background() + data, err := rs.client.Get(ctx, keyPrefix+id).Bytes() + if err == redis.Nil { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("Redis GET session: %w", err) + } + + var s Session + if err := json.Unmarshal(data, &s); err != nil { + return nil, fmt.Errorf("Redis 反序列化 session: %w", err) + } + return &s, nil +} + +// Set 写入/更新会话并设置 TTL +func (rs *RedisStore) Set(s *Session) error { + ctx := context.Background() + + data, err := json.Marshal(s) + if err != nil { + return fmt.Errorf("Redis 序列化 session: %w", err) + } + + ttl := rs.idleTimeout + if s.RememberMe { + ttl = rs.rememberTimeout + } + + pipe := rs.client.Pipeline() + + // 1. 写入会话数据 + pipe.Set(ctx, keyPrefix+s.ID, data, ttl) + + // 2. 将 sid 加入用户会话索引(Redis SET) + // 不设置 user_sessions 的 TTL——同一用户可能有不同 TTL 的会话, + // 设置单一 TTL 会导致长生命周期会话的索引被提前过期 + pipe.SAdd(ctx, rs.userKey(s.UserID), s.ID) + + _, err = pipe.Exec(ctx) + if err != nil { + return fmt.Errorf("Redis SET session: %w", err) + } + return nil +} + +// Delete 删除单个会话 +func (rs *RedisStore) Delete(id string) error { + ctx := context.Background() + + // 先读取 session 获取 UserID,再从集合中移除 + data, err := rs.client.Get(ctx, keyPrefix+id).Bytes() + if err == redis.Nil { + return nil + } + if err != nil { + return fmt.Errorf("Redis GET session for delete: %w", err) + } + + var s Session + if err := json.Unmarshal(data, &s); err == nil { + if sremErr := rs.client.SRem(ctx, rs.userKey(s.UserID), id).Err(); sremErr != nil { + log.Printf("[RedisStore] Delete: SRem failed sid=%s uid=%d err=%v", id[:16]+"...", s.UserID, sremErr) + } + } + + return rs.client.Del(ctx, keyPrefix+id).Err() +} + +// DeleteByUID 删除某用户的所有会话(改密/注销/强制下线) +func (rs *RedisStore) DeleteByUID(uid uint) error { + ctx := context.Background() + uidKey := rs.userKey(uid) + + sids, err := rs.client.SMembers(ctx, uidKey).Result() + if err != nil { + return fmt.Errorf("Redis SMEMBERS user_sessions: %w", err) + } + + if len(sids) == 0 { + return nil + } + + pipe := rs.client.Pipeline() + pipe.Del(ctx, rs.sessionKeys(sids)...) + pipe.Del(ctx, uidKey) + _, err = pipe.Exec(ctx) + if err != nil { + return fmt.Errorf("Redis DeleteByUID: %w", err) + } + + log.Printf("[RedisStore] DeleteByUID uid=%d count=%d", uid, len(sids)) + return nil +} + +// DeleteByUIDExclude 删除某用户的所有会话,但保留指定的会话 ID +func (rs *RedisStore) DeleteByUIDExclude(uid uint, excludeSID string) error { + ctx := context.Background() + uidKey := rs.userKey(uid) + + sids, err := rs.client.SMembers(ctx, uidKey).Result() + if err != nil { + return fmt.Errorf("Redis SMEMBERS user_sessions: %w", err) + } + + var toDelete []string + kept := 0 + for _, sid := range sids { + if sid == excludeSID { + kept++ + continue + } + toDelete = append(toDelete, sid) + } + + if len(toDelete) == 0 { + return nil + } + + pipe := rs.client.Pipeline() + pipe.Del(ctx, rs.sessionKeys(toDelete)...) + for _, sid := range toDelete { + pipe.SRem(ctx, uidKey, sid) + } + _, err = pipe.Exec(ctx) + if err != nil { + return fmt.Errorf("Redis DeleteByUIDExclude: %w", err) + } + + log.Printf("[RedisStore] DeleteByUIDExclude uid=%d removed=%d kept=%d", uid, len(toDelete), kept) + return nil +} + +// ListByUID 列出某用户的所有活跃会话 +func (rs *RedisStore) ListByUID(uid uint) ([]*Session, error) { + ctx := context.Background() + uidKey := rs.userKey(uid) + + sids, err := rs.client.SMembers(ctx, uidKey).Result() + if err != nil { + return nil, fmt.Errorf("Redis SMEMBERS user_sessions: %w", err) + } + + if len(sids) == 0 { + return nil, nil + } + + results, err := rs.client.MGet(ctx, rs.sessionKeys(sids)...).Result() + if err != nil { + return nil, fmt.Errorf("Redis MGET sessions: %w", err) + } + + var sessions []*Session + for _, r := range results { + if r == nil { + continue + } + data, ok := r.(string) + if !ok { + continue + } + var s Session + if err := json.Unmarshal([]byte(data), &s); err != nil { + continue + } + sessions = append(sessions, &s) + } + return sessions, nil +} + +// Cleanup Redis 自动 TTL 过期,无需手动清理 +func (rs *RedisStore) Cleanup() int { + return 0 +} + +// StartCleanup Redis 模式无需后台清理 goroutine +func (rs *RedisStore) StartCleanup(interval time.Duration) { + // Redis TTL 自动过期,无需手动清理 +}