把 visionA-backend 6 個 in-memory store 接到資料庫持久化,範圍=完整 (PG 全接 + session 接 Redis + 交易韌性)。interface / handler 不動, 只加 DB 實作 + 換 wiring,config 未設 DB 時保留 in-memory fallback。 - 塊 0 基礎建設:pgx/v5 連線池 + DatabaseConfig/RedisConfig + golang-migrate runner(embed)+ cmd/migrate + testcontainers 測試基礎建設 - 塊 1 model → Postgres:array 映射、upsert 保留 CreatedAt、faa_object_key、 三維 filter(owner/chip/source)、soft-delete partial index - 塊 2 device → Postgres:partial unique(已刪 serial 可重註冊)、雙狀態欄位 - 塊 3 token → Postgres:pairing_tokens + session_tokens 分表、token_hash 當 PK - 塊 4 userSession → Redis:idle + absolute 雙 TTL 取代 cleanup goroutine (tunnel session 維持 in-memory,yamux handle 不可序列化) - 塊 5 交易/韌性:WithTx helper + 刪 device cascade 撤銷 token(同 tx 原子) + /healthz ping PG/Redis(fail-fast 503)+ pgx error 統一映射(不洩漏 raw error) 降級策略(fail-fast):PG 掉 → 持久資料 API 回 503;Redis 掉 → session 失敗 不自動 fallback in-memory(避免多機 session 不同步)。 DB:PostgreSQL 14.23(gen_random_uuid 內建、無 citext → email 用 lower() unique index)。每塊經 Reviewer 審查 + 真 PG/Redis testcontainers 全量 dbtest 綠燈, in-memory fallback 未受影響。 docs: 同步更新 database.md(schema/config/migration 清單)+ api-spec.md (409/503 錯誤碼、/healthz 新行為、device unpair cascade)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
107 lines
3.4 KiB
Go
107 lines
3.4 KiB
Go
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)
|
||
}
|