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:
28
internal/common/redis.go
Normal file
28
internal/common/redis.go
Normal 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
|
||||
}
|
||||
@ -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 接口
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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 自动过期,无需手动清理
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user