把 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>
90 lines
3.2 KiB
Go
90 lines
3.2 KiB
Go
// Package db 提供 visionA-backend 的 PostgreSQL 連線池(pgxpool)與 migration runner。
|
||
//
|
||
// DB 接入塊 0(DB 基礎建設):本套件只負責「連線池建池 + 啟動 ping + graceful shutdown +
|
||
// migration」這些跨 domain 共用的基礎設施;不含任何 store / repository 的 Postgres 實作
|
||
// (那是塊 1–3,會新增各 domain 的 postgres_repository.go,實作既有 interface)。
|
||
//
|
||
// 對齊 docs/autoflow/04-architecture/database.md §5、§5.5。
|
||
package db
|
||
|
||
import (
|
||
"fmt"
|
||
"net/url"
|
||
"strconv"
|
||
|
||
"visiona-backend/internal/config"
|
||
)
|
||
|
||
// BuildDSN 從 DatabaseConfig 組出 pgxpool 用的 PostgreSQL DSN。
|
||
//
|
||
// 格式(對齊 database.md §5.5.1):
|
||
//
|
||
// postgres://{User}:{Password}@{Host}:{Port}/{DBName}?sslmode={SSLMode}&pool_max_conns={MaxConns}&pool_min_conns={MinConns}
|
||
//
|
||
// User / Password 走 url.UserPassword 做 percent-encoding,避免密碼含特殊字元(@ : / ?)破壞 DSN。
|
||
//
|
||
// 安全:回傳值含明文密碼,呼叫端**不可**將其寫入 log。需要在 log 標識連線目標時,
|
||
// 改用 SafeTarget()(只含 host:port/dbname、不含密碼)。
|
||
func BuildDSN(c config.DatabaseConfig) string {
|
||
u := baseURL(c)
|
||
|
||
q := baseQuery(c)
|
||
// pgxpool 專屬參數:只有 pgxpool 認得,golang-migrate 的標準 database/sql 連線會被 PG
|
||
// server 端拒絕(FATAL: unrecognized configuration parameter "pool_max_conns"),
|
||
// 故這些參數**只**加在 BuildDSN(pgxpool 路徑),不加在 baseDSN(migrate 路徑)。
|
||
if c.MaxConns > 0 {
|
||
q.Set("pool_max_conns", strconv.Itoa(c.MaxConns))
|
||
}
|
||
if c.MinConns > 0 {
|
||
q.Set("pool_min_conns", strconv.Itoa(c.MinConns))
|
||
}
|
||
u.RawQuery = q.Encode()
|
||
|
||
return u.String()
|
||
}
|
||
|
||
// baseDSN 組「只含標準 libpq 連線參數」的 PostgreSQL DSN(host/port/user/password/dbname/sslmode),
|
||
// 不含任何 pgxpool 專屬參數(pool_max_conns / pool_min_conns)。
|
||
//
|
||
// 供 golang-migrate 的 pgx5 driver 使用:它走標準 database/sql 單連線,PG server 端不認得
|
||
// pool_* 參數會直接 FATAL,故 migrate 路徑必須走這個「乾淨」DSN。
|
||
func baseDSN(c config.DatabaseConfig) string {
|
||
u := baseURL(c)
|
||
u.RawQuery = baseQuery(c).Encode()
|
||
return u.String()
|
||
}
|
||
|
||
// baseURL 組 DSN 的 scheme / 帳密 / host / path 部分(不含 query)。
|
||
func baseURL(c config.DatabaseConfig) url.URL {
|
||
return url.URL{
|
||
Scheme: "postgres",
|
||
User: url.UserPassword(c.User, c.Password),
|
||
Host: net_JoinHostPort(c.Host, c.Port),
|
||
Path: "/" + c.DBName,
|
||
}
|
||
}
|
||
|
||
// baseQuery 組標準 libpq 連線參數(目前只有 sslmode)。pgxpool 與 migrate 共用這組基礎參數。
|
||
func baseQuery(c config.DatabaseConfig) url.Values {
|
||
q := url.Values{}
|
||
sslMode := c.SSLMode
|
||
if sslMode == "" {
|
||
sslMode = "require"
|
||
}
|
||
q.Set("sslmode", sslMode)
|
||
return q
|
||
}
|
||
|
||
// SafeTarget 回傳「可安全寫入 log」的連線目標字串(host:port/dbname),不含使用者名稱或密碼。
|
||
func SafeTarget(c config.DatabaseConfig) string {
|
||
return fmt.Sprintf("%s/%s", net_JoinHostPort(c.Host, c.Port), c.DBName)
|
||
}
|
||
|
||
// net_JoinHostPort 是 net.JoinHostPort 的小包裝(避免在多檔 import net 只為一個呼叫)。
|
||
func net_JoinHostPort(host string, port int) string {
|
||
if port == 0 {
|
||
port = 5432
|
||
}
|
||
return host + ":" + strconv.Itoa(port)
|
||
}
|