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