把 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>
109 lines
3.3 KiB
Go
109 lines
3.3 KiB
Go
package db
|
||
|
||
import (
|
||
"context"
|
||
"fmt"
|
||
"log/slog"
|
||
"time"
|
||
|
||
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
||
"visiona-backend/internal/config"
|
||
)
|
||
|
||
// Pool 包裝 pgxpool.Pool,提供 visionA-backend 統一的連線池進出點。
|
||
//
|
||
// 設計:
|
||
// - 薄包裝,不隱藏 *pgxpool.Pool(repository 實作直接拿 .Pool() 用 pgx API)。
|
||
// - 持有建池時的 config 供 log / health check 標識連線目標(不含密碼)。
|
||
// - Close() 為 graceful shutdown 用,main.go 在收到 SIGTERM 後呼叫。
|
||
type Pool struct {
|
||
pool *pgxpool.Pool
|
||
cfg config.DatabaseConfig
|
||
log *slog.Logger
|
||
}
|
||
|
||
// NewPool 依 DatabaseConfig 建立 pgxpool 連線池,並在啟動時跑一次 ping 確認 DB 可達。
|
||
//
|
||
// fail-fast 語意:ping 失敗即回 error,由 main.go 決定是 fatal(DB 啟用時連不上應該停機)。
|
||
// 這避免「DB 設定了卻連不上」卻靜默 fallback in-memory 造成資料不一致的隱患。
|
||
//
|
||
// 逾時:建池與啟動 ping 共用 cfg.ConnTimeout(預設 5s)。
|
||
//
|
||
// 安全:log 只印 SafeTarget(host:port/dbname),DSN 與密碼永遠不入 log。
|
||
func NewPool(ctx context.Context, cfg config.DatabaseConfig, log *slog.Logger) (*Pool, error) {
|
||
if log == nil {
|
||
log = slog.Default()
|
||
}
|
||
|
||
dsn := BuildDSN(cfg)
|
||
poolCfg, err := pgxpool.ParseConfig(dsn)
|
||
if err != nil {
|
||
// 不把 err 直接外露 DSN(pgx ParseConfig error 不含密碼,但保守起見只回固定訊息)。
|
||
return nil, fmt.Errorf("db: parse pool config failed: %w", err)
|
||
}
|
||
|
||
// 套用連線池參數(ParseConfig 已從 DSN query 解析 pool_max_conns/pool_min_conns,
|
||
// 此處再以 config 值覆寫,確保 DSN 與 config 不一致時以 config 為準)。
|
||
if cfg.MaxConns > 0 {
|
||
poolCfg.MaxConns = int32(cfg.MaxConns)
|
||
}
|
||
if cfg.MinConns > 0 {
|
||
poolCfg.MinConns = int32(cfg.MinConns)
|
||
}
|
||
if cfg.MaxConnLifetime > 0 {
|
||
poolCfg.MaxConnLifetime = cfg.MaxConnLifetime
|
||
}
|
||
|
||
connTimeout := cfg.ConnTimeout
|
||
if connTimeout <= 0 {
|
||
connTimeout = 5 * time.Second
|
||
}
|
||
|
||
buildCtx, cancel := context.WithTimeout(ctx, connTimeout)
|
||
defer cancel()
|
||
|
||
pgPool, err := pgxpool.NewWithConfig(buildCtx, poolCfg)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("db: create pool failed (target=%s): %w", SafeTarget(cfg), err)
|
||
}
|
||
|
||
// 啟動 ping:確認 DB 真的可達(NewWithConfig 不會立即連線)。
|
||
pingCtx, pingCancel := context.WithTimeout(ctx, connTimeout)
|
||
defer pingCancel()
|
||
if err := pgPool.Ping(pingCtx); err != nil {
|
||
pgPool.Close()
|
||
return nil, fmt.Errorf("db: ping failed (target=%s): %w", SafeTarget(cfg), err)
|
||
}
|
||
|
||
log.Info("postgres pool initialized",
|
||
"target", SafeTarget(cfg),
|
||
"sslmode", cfg.SSLMode,
|
||
"max_conns", poolCfg.MaxConns,
|
||
"min_conns", poolCfg.MinConns,
|
||
"max_conn_lifetime", poolCfg.MaxConnLifetime,
|
||
)
|
||
|
||
return &Pool{pool: pgPool, cfg: cfg, log: log}, nil
|
||
}
|
||
|
||
// Pool 回傳底層 *pgxpool.Pool,供 repository 實作使用 pgx API。
|
||
func (p *Pool) Pool() *pgxpool.Pool {
|
||
return p.pool
|
||
}
|
||
|
||
// Ping 對 DB 跑一次連線檢查,供 /healthz 等 health check 用。
|
||
func (p *Pool) Ping(ctx context.Context) error {
|
||
return p.pool.Ping(ctx)
|
||
}
|
||
|
||
// Close 關閉連線池(graceful shutdown)。等待所有 checked-out 連線歸還。
|
||
// 重複呼叫安全(pgxpool.Close 內部冪等)。
|
||
func (p *Pool) Close() {
|
||
if p == nil || p.pool == nil {
|
||
return
|
||
}
|
||
p.log.Info("closing postgres pool", "target", SafeTarget(p.cfg))
|
||
p.pool.Close()
|
||
}
|