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:
@ -3,3 +3,4 @@
|
|||||||
|
|
||||||
DATABASE_PASSWORD=your_password_here
|
DATABASE_PASSWORD=your_password_here
|
||||||
JWT_SECRET=generate-a-random-64-char-string-here
|
JWT_SECRET=generate-a-random-64-char-string-here
|
||||||
|
REDIS_PASSWORD=your_redis_password_here
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"metazone.cc/metalab/internal/theme"
|
"metazone.cc/metalab/internal/theme"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/driver/postgres"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@ -69,6 +70,18 @@ func main() {
|
|||||||
r.Static("/uploads/avatars", "./storage/uploads/avatars")
|
r.Static("/uploads/avatars", "./storage/uploads/avatars")
|
||||||
r.Static("/uploads/posts", "./storage/uploads/posts")
|
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 持久化 + 内存缓存)
|
// 站点设置管理器(DB 持久化 + 内存缓存)
|
||||||
siteSettings, err := config.NewSiteSettings(db)
|
siteSettings, err := config.NewSiteSettings(db)
|
||||||
if err != nil {
|
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)
|
log.Printf("MetaZone.FAN 启动于 0.0.0.0:%s", cfg.Server.Port)
|
||||||
|
|||||||
10
config.yaml
10
config.yaml
@ -18,7 +18,15 @@ database:
|
|||||||
session:
|
session:
|
||||||
idle_timeout: 120 # 不记住我:空闲超时(分钟),120 = 2 小时
|
idle_timeout: 120 # 不记住我:空闲超时(分钟),120 = 2 小时
|
||||||
remember_timeout: 43200 # 记住我:空闲超时(分钟),默认 43200 = 30 天
|
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:
|
bcrypt:
|
||||||
cost: 12
|
cost: 12
|
||||||
|
|||||||
3
go.mod
3
go.mod
@ -5,6 +5,7 @@ go 1.26.0
|
|||||||
require (
|
require (
|
||||||
github.com/chai2010/webp v1.4.0
|
github.com/chai2010/webp v1.4.0
|
||||||
github.com/gin-gonic/gin v1.12.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
|
github.com/spf13/viper v1.21.0
|
||||||
golang.org/x/crypto v0.52.0
|
golang.org/x/crypto v0.52.0
|
||||||
golang.org/x/image v0.41.0
|
golang.org/x/image v0.41.0
|
||||||
@ -16,6 +17,7 @@ require (
|
|||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.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/cloudwego/base64x v0.1.6 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // 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/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.3.1 // indirect
|
github.com/ugorji/go/codec v1.3.1 // indirect
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // 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
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/net v0.54.0 // indirect
|
golang.org/x/net v0.54.0 // indirect
|
||||||
|
|||||||
12
go.sum
12
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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
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 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
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 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
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 h1:6DA2pkkRUPnbOHvvsmGI3He1hBKf/bkRlniAiSGuEko=
|
||||||
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
|
github.com/chai2010/webp v1.4.0/go.mod h1:0XVwvZWdjjdxpUEIf7b9g9VkHFnInUSYujwqTLEuldU=
|
||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
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/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 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
|
||||||
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
|
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 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
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=
|
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/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 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
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 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
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 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
|
||||||
|
|||||||
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"`
|
Server ServerConfig `mapstructure:"server"`
|
||||||
Database DatabaseConfig `mapstructure:"database"`
|
Database DatabaseConfig `mapstructure:"database"`
|
||||||
Session SessionConfig `mapstructure:"session"`
|
Session SessionConfig `mapstructure:"session"`
|
||||||
|
Redis RedisConfig `mapstructure:"redis"`
|
||||||
Bcrypt BcryptConfig `mapstructure:"bcrypt"`
|
Bcrypt BcryptConfig `mapstructure:"bcrypt"`
|
||||||
Roles RolesConfig `mapstructure:"roles"`
|
Roles RolesConfig `mapstructure:"roles"`
|
||||||
Audit AuditConfig `mapstructure:"audit"`
|
Audit AuditConfig `mapstructure:"audit"`
|
||||||
@ -45,7 +46,16 @@ type DatabaseConfig struct {
|
|||||||
type SessionConfig struct {
|
type SessionConfig struct {
|
||||||
IdleTimeout int `mapstructure:"idle_timeout"` // 不记住我:空闲超时(分钟),默认 1440(24小时)
|
IdleTimeout int `mapstructure:"idle_timeout"` // 不记住我:空闲超时(分钟),默认 1440(24小时)
|
||||||
RememberTimeout int `mapstructure:"remember_timeout"` // 记住我:空闲超时(分钟),默认 43200(30天)
|
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 {
|
type BcryptConfig struct {
|
||||||
@ -127,6 +137,7 @@ func loadEnvFile(path string) {
|
|||||||
// bindEnvOverride 将环境变量映射到 config 的嵌套键
|
// bindEnvOverride 将环境变量映射到 config 的嵌套键
|
||||||
func bindEnvOverride(v *viper.Viper) {
|
func bindEnvOverride(v *viper.Viper) {
|
||||||
_ = v.BindEnv("database.password", "DATABASE_PASSWORD")
|
_ = v.BindEnv("database.password", "DATABASE_PASSWORD")
|
||||||
|
_ = v.BindEnv("redis.password", "REDIS_PASSWORD")
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetIdleTimeout 返回临时会话空闲超时(分钟),实现 controller.sessionConfig 接口
|
// GetIdleTimeout 返回临时会话空闲超时(分钟),实现 controller.sessionConfig 接口
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package router
|
package router
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"log"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"metazone.cc/metalab/internal/config"
|
"metazone.cc/metalab/internal/config"
|
||||||
@ -11,6 +12,7 @@ import (
|
|||||||
"metazone.cc/metalab/internal/service"
|
"metazone.cc/metalab/internal/service"
|
||||||
"metazone.cc/metalab/internal/session"
|
"metazone.cc/metalab/internal/session"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -29,20 +31,29 @@ type dependencies struct {
|
|||||||
siteSettingCtrl *adminCtrl.SiteSettingController
|
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,
|
*repository.UserRepo, *session.Manager, *service.AuthService,
|
||||||
*middleware.RateLimiter, *controller.AuthController,
|
*middleware.RateLimiter, *controller.AuthController,
|
||||||
*service.AvatarService, *middleware.AuthMiddleware, *adminCtrl.AdminController,
|
*service.AvatarService, *middleware.AuthMiddleware, *adminCtrl.AdminController,
|
||||||
) {
|
) {
|
||||||
userRepo := repository.NewUserRepo(db)
|
userRepo := repository.NewUserRepo(db)
|
||||||
|
|
||||||
// Session 存储:内存模式(后续可切换为 RedisStore)
|
idleTimeout := time.Duration(cfg.Session.IdleTimeout) * time.Minute
|
||||||
sessionStore := session.NewMemoryStore(
|
rememberTimeout := time.Duration(cfg.Session.RememberTimeout) * time.Minute
|
||||||
time.Duration(cfg.Session.IdleTimeout)*time.Minute,
|
|
||||||
time.Duration(cfg.Session.RememberTimeout)*time.Minute,
|
var sessionStore session.Store
|
||||||
)
|
|
||||||
// 启动后台过期清理 goroutine
|
if cfg.Redis.Enabled && redisClient != nil {
|
||||||
sessionStore.StartCleanup(time.Duration(cfg.Session.CleanupInterval) * time.Second)
|
// 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)
|
sessionMgr := session.NewManager(sessionStore, userRepo, cfg)
|
||||||
authService := service.NewAuthService(userRepo, sessionMgr, cfg, siteSettings)
|
authService := service.NewAuthService(userRepo, sessionMgr, cfg, siteSettings)
|
||||||
|
|||||||
@ -8,11 +8,12 @@ import (
|
|||||||
"metazone.cc/metalab/internal/repository"
|
"metazone.cc/metalab/internal/repository"
|
||||||
"metazone.cc/metalab/internal/service"
|
"metazone.cc/metalab/internal/service"
|
||||||
|
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
func buildDeps(db *gorm.DB, cfg *config.Config, siteSettings *config.SiteSettings) *dependencies {
|
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)
|
userRepo, sessionMgr, authService, _, authCtrl, avatarSvc, authMdw, adminController := buildDepsCore(db, cfg, siteSettings, redisClient)
|
||||||
|
|
||||||
auditRepo := repository.NewAuditRepo(db)
|
auditRepo := repository.NewAuditRepo(db)
|
||||||
notificationRepo := repository.NewNotificationRepo(db)
|
notificationRepo := repository.NewNotificationRepo(db)
|
||||||
|
|||||||
@ -5,15 +5,16 @@ import (
|
|||||||
"metazone.cc/metalab/internal/middleware"
|
"metazone.cc/metalab/internal/middleware"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Setup 注册所有路由
|
// 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.SecurityHeaders())
|
||||||
r.Use(middleware.InjectSiteInfo(siteSettings))
|
r.Use(middleware.InjectSiteInfo(siteSettings))
|
||||||
|
|
||||||
deps := buildDeps(db, cfg, siteSettings)
|
deps := buildDeps(db, cfg, siteSettings, redisClient)
|
||||||
|
|
||||||
// 维护模式中间件(全局,在路由匹配之前)
|
// 维护模式中间件(全局,在路由匹配之前)
|
||||||
r.Use(deps.maintenanceMdw.Handler())
|
r.Use(deps.maintenanceMdw.Handler())
|
||||||
|
|||||||
@ -1,29 +1,231 @@
|
|||||||
package session
|
package session
|
||||||
|
|
||||||
// RedisStore Redis 会话存储(预留实现,切换到 Redis 时启用)
|
import (
|
||||||
// import "github.com/redis/go-redis/v9"
|
"context"
|
||||||
//
|
"encoding/json"
|
||||||
// type RedisStore struct {
|
"fmt"
|
||||||
// client *redis.Client
|
"log"
|
||||||
// ttl time.Duration
|
"time"
|
||||||
// }
|
|
||||||
//
|
|
||||||
// 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)
|
|
||||||
|
|
||||||
// 当前使用内存存储,Redis 支持预留接口,未来需切换时:
|
"github.com/redis/go-redis/v9"
|
||||||
// 1. go get github.com/redis/go-redis/v9
|
)
|
||||||
// 2. 取消本文件注释并实现 RedisStore
|
|
||||||
// 3. 在 deps_core.go 中将 NewMemoryStore → NewRedisStore
|
// Redis key 前缀前缀,避免与其他业务 key 冲突
|
||||||
// 4. 注入 redis client(从 config 读取 Redis 连接信息)
|
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