feat: 实现 Redis 会话存储,支持内存/Redis 双模式自动切换

- 新增 go-redis/v9 依赖
- 新增 config.yaml redis 配置段 + RedisConfig 结构体 + REDIS_PASSWORD 环境变量覆盖
- 新增 common/redis.go Redis 客户端初始化
- 完整实现 session/redis_store.go,实现 Store 接口全部 7 个方法
- Redis TTL 自动过期,无须后台清理 goroutine
- deps_core.go 根据 cfg.Redis.Enabled 自动选择 RedisStore/MemoryStore
- router.go + main.go 透传 redisClient 参数
This commit is contained in:
2026-05-31 11:24:39 +08:00
parent 55c408d86c
commit d6d03a7c3c
11 changed files with 332 additions and 41 deletions

28
internal/common/redis.go Normal file
View File

@ -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
}

View File

@ -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"` // 不记住我:空闲超时(分钟),默认 144024小时
RememberTimeout int `mapstructure:"remember_timeout"` // 记住我:空闲超时(分钟),默认 4320030天
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 接口

View File

@ -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)

View File

@ -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)

View File

@ -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())

View File

@ -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 自动过期,无需手动清理
}