把 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>
145 lines
4.9 KiB
Go
145 lines
4.9 KiB
Go
package db
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"io/fs"
|
||
"log/slog"
|
||
|
||
"github.com/golang-migrate/migrate/v4"
|
||
migratepgx "github.com/golang-migrate/migrate/v4/database/pgx/v5"
|
||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||
|
||
"visiona-backend/internal/config"
|
||
"visiona-backend/migrations"
|
||
)
|
||
|
||
// migrationsPath 是 iofs source 內的子路徑。embed FS 以 migrations 套件目錄為根,
|
||
// SQL 檔直接在根(embed.go 的 `//go:embed *.sql`),故路徑為 "."。
|
||
const migrationsPath = "."
|
||
|
||
// Migrator 包裝 golang-migrate,提供 visionA-backend 統一的 migration 進出點。
|
||
//
|
||
// 使用嵌入式 migration(migrations.FS)+ pgx/v5 database driver。
|
||
// 每個 Migrator 持有自己的 *migrate.Migrate,用完應 Close()。
|
||
type Migrator struct {
|
||
m *migrate.Migrate
|
||
log *slog.Logger
|
||
}
|
||
|
||
// NewMigrator 從 DatabaseConfig 建立 Migrator(連到 cfg 指定的 DB)。
|
||
//
|
||
// 注意:golang-migrate 自己開一條連線(與 pgxpool 分離),完成後 Close() 釋放。
|
||
// 這是 golang-migrate 的設計(migration 需要獨佔的 advisory lock 連線)。
|
||
func NewMigrator(cfg config.DatabaseConfig, log *slog.Logger) (*Migrator, error) {
|
||
if log == nil {
|
||
log = slog.Default()
|
||
}
|
||
src, err := iofs.New(migrations.FS, migrationsPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("db: open embedded migrations source: %w", err)
|
||
}
|
||
m, err := migrate.NewWithSourceInstance("iofs", src, "pgx5://"+migrateDSN(cfg))
|
||
if err != nil {
|
||
return nil, fmt.Errorf("db: init migrator (target=%s): %w", SafeTarget(cfg), err)
|
||
}
|
||
return &Migrator{m: m, log: log}, nil
|
||
}
|
||
|
||
// newMigratorFromDSN 直接以完整 DSN 建立 Migrator,供整合測試(testcontainers 給 raw DSN)使用。
|
||
func newMigratorFromDSN(rawDSN string, log *slog.Logger) (*Migrator, error) {
|
||
if log == nil {
|
||
log = slog.Default()
|
||
}
|
||
src, err := iofs.New(migrations.FS, migrationsPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("db: open embedded migrations source: %w", err)
|
||
}
|
||
m, err := migrate.NewWithSourceInstance("iofs", src, rawDSN)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("db: init migrator from dsn: %w", err)
|
||
}
|
||
return &Migrator{m: m, log: log}, nil
|
||
}
|
||
|
||
// Up 套用所有尚未執行的 migration。
|
||
//
|
||
// 冪等:已是最新版(migrate.ErrNoChange)視為成功、不回 error。
|
||
func (mg *Migrator) Up() error {
|
||
err := mg.m.Up()
|
||
if err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||
return fmt.Errorf("db: migrate up: %w", err)
|
||
}
|
||
if errors.Is(err, migrate.ErrNoChange) {
|
||
mg.log.Info("migrate up: no change (already at latest version)")
|
||
} else {
|
||
ver, _, _ := mg.m.Version()
|
||
mg.log.Info("migrate up: applied", "version", ver)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Down 回退一個版本(給整合測試 / cmd/migrate 用,正常啟動流程不呼叫)。
|
||
func (mg *Migrator) Down() error {
|
||
err := mg.m.Steps(-1)
|
||
if err != nil && !errors.Is(err, migrate.ErrNoChange) && !errors.Is(err, fs.ErrNotExist) {
|
||
return fmt.Errorf("db: migrate down one step: %w", err)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// Version 回傳目前 schema 版本與是否處於 dirty 狀態。
|
||
// 尚無任何 migration 時回 (0, false, migrate.ErrNilVersion)。
|
||
func (mg *Migrator) Version() (uint, bool, error) {
|
||
return mg.m.Version()
|
||
}
|
||
|
||
// Close 釋放 migrator 的 source 與 database 連線。
|
||
func (mg *Migrator) Close() error {
|
||
srcErr, dbErr := mg.m.Close()
|
||
if srcErr != nil {
|
||
return srcErr
|
||
}
|
||
return dbErr
|
||
}
|
||
|
||
// RunMigrations 是「建好池後自動 migrate up」的便利函式,供 main.go 在啟動流程呼叫。
|
||
//
|
||
// 它自建一個 Migrator、跑 Up、再 Close,呼叫端不需管 Migrator 生命週期。
|
||
func RunMigrations(cfg config.DatabaseConfig, log *slog.Logger) error {
|
||
mg, err := NewMigrator(cfg, log)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
defer func() {
|
||
if cerr := mg.Close(); cerr != nil {
|
||
log.Warn("migrator close error", "error", cerr)
|
||
}
|
||
}()
|
||
return mg.Up()
|
||
}
|
||
|
||
// migrateDSN 組 golang-migrate pgx5 driver 用的 DSN(不含 pgx5:// scheme prefix,
|
||
// 由呼叫端拼上)。
|
||
//
|
||
// 關鍵:golang-migrate 的 pgx5 driver 走標準 database/sql 單連線,PG server 端**不認得**
|
||
// pgxpool 專屬參數(pool_max_conns / pool_min_conns)會直接 FATAL
|
||
// (unrecognized configuration parameter, SQLSTATE 42704)。因此這裡走 baseDSN(只含
|
||
// 標準 libpq 參數 host/port/user/password/dbname/sslmode),**不**走 BuildDSN。
|
||
//
|
||
// golang-migrate pgx5 driver 接受 "pgx5://user:pass@host:port/db?sslmode=..." 形式,
|
||
// 故把 baseDSN 的 "postgres://" scheme 去掉、由 NewMigrator 拼回 "pgx5://"。
|
||
func migrateDSN(cfg config.DatabaseConfig) string {
|
||
dsn := baseDSN(cfg)
|
||
// baseDSN 回 "postgres://...", 去掉 "postgres://" 留下 "user:pass@host/db?..."。
|
||
const prefix = "postgres://"
|
||
if len(dsn) >= len(prefix) && dsn[:len(prefix)] == prefix {
|
||
return dsn[len(prefix):]
|
||
}
|
||
return dsn
|
||
}
|
||
|
||
// 確保 driver 被 link 進 binary(golang-migrate 用 blank import 註冊 driver;
|
||
// 這裡顯式 import + 引用避免被當 unused)。
|
||
var _ = migratepgx.ErrNilConfig
|