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

View File

@ -3,3 +3,4 @@
DATABASE_PASSWORD=your_password_here
JWT_SECRET=generate-a-random-64-char-string-here
REDIS_PASSWORD=your_redis_password_here

View File

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

View File

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

3
go.mod
View File

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

12
go.sum
View File

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

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