package db import ( "context" "fmt" "log/slog" "time" "github.com/redis/go-redis/v9" "visiona-backend/internal/config" ) // RedisClient 包裝 go-redis 的 *redis.Client,提供 visionA-backend 統一的 Redis 進出點。 // // DB 接入塊 4(userSession 接 Redis):本型別只負責「建連線 + 啟動 ping + graceful close」 // 這些基礎設施;userSession 的 store 邏輯在 internal/usersession/redis.go(RedisUserSessionStore)。 // // 設計對齊 pool.go(Postgres Pool)的風格: // - 薄包裝,不隱藏底層 client(store 實作直接拿 .Client() 用 go-redis API)。 // - 持有建連時的 config 供 log / health check 標識連線目標(不含密碼)。 // - Close() 為 graceful shutdown 用,main.go 在收到 SIGTERM 後呼叫。 // // 對齊 docs/autoflow/04-architecture/database.md §5.5.2。 type RedisClient struct { client *redis.Client cfg config.RedisConfig log *slog.Logger } // NewRedisClient 依 RedisConfig 建立 go-redis client,並在啟動時跑一次 ping 確認可達。 // // fail-fast 語意:ping 失敗即回 error,由 main.go 決定是否 fatal // (Redis 啟用時連不上,與 Postgres 同樣應停機,而非靜默 fallback in-memory 造成行為不一致)。 // // 逾時:建連與啟動 ping 共用 cfg.ConnTimeout(預設 5s)。 // // 安全:log 只印 SafeRedisTarget(host:port/db);Password 永遠不入 log。 // visionA 專用 Redis 可能無密碼(stage 內網),Enabled() 只看 Host,故此處不強制 Password。 func NewRedisClient(ctx context.Context, cfg config.RedisConfig, log *slog.Logger) (*RedisClient, error) { if log == nil { log = slog.Default() } connTimeout := cfg.ConnTimeout if connTimeout <= 0 { connTimeout = 5 * time.Second } port := cfg.Port if port == 0 { port = 6379 } client := redis.NewClient(&redis.Options{ Addr: fmt.Sprintf("%s:%d", cfg.Host, port), Password: cfg.Password, // 空字串 = 無密碼(visionA 專用 Redis stage 內網可不設密碼) DB: cfg.DB, DialTimeout: connTimeout, ReadTimeout: connTimeout, WriteTimeout: connTimeout, }) // 啟動 ping:確認 Redis 真的可達(NewClient 不會立即連線)。 pingCtx, cancel := context.WithTimeout(ctx, connTimeout) defer cancel() if err := client.Ping(pingCtx).Err(); err != nil { _ = client.Close() return nil, fmt.Errorf("db: redis ping failed (target=%s): %w", SafeRedisTarget(cfg), err) } log.Info("redis client initialized", "target", SafeRedisTarget(cfg), "db", cfg.DB, ) return &RedisClient{client: client, cfg: cfg, log: log}, nil } // Client 回傳底層 *redis.Client,供 store 實作使用 go-redis API。 func (r *RedisClient) Client() *redis.Client { return r.client } // Ping 對 Redis 跑一次連線檢查,供 /healthz 等 health check 用。 func (r *RedisClient) Ping(ctx context.Context) error { return r.client.Ping(ctx).Err() } // Close 關閉 Redis client(graceful shutdown)。重複呼叫安全。 func (r *RedisClient) Close() { if r == nil || r.client == nil { return } r.log.Info("closing redis client", "target", SafeRedisTarget(r.cfg)) _ = r.client.Close() } // SafeRedisTarget 回傳「可安全寫入 log」的 Redis 連線目標字串(host:port/db),不含密碼。 func SafeRedisTarget(c config.RedisConfig) string { port := c.Port if port == 0 { port = 6379 } return fmt.Sprintf("%s:%d/%d", c.Host, port, c.DB) }