jim800121chen 4d0b870480 feat(visionA-backend): DB 接入 — 6 store 接 PostgreSQL/Redis 持久化(塊 0-5)
把 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>
2026-06-20 18:28:04 +08:00

86 lines
3.5 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package db
import (
"context"
"errors"
"fmt"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// Querier 抽象「能跑查詢的東西」——同時被 *pgxpool.Pool 與 pgx.Tx 滿足。
//
// 設計目的DB 接入塊 5.2 跨 store 交易):
//
// repository 方法收 Querier 而非寫死 *pgxpool.Pool就能在「池上直接跑自動 commit
// 或「在某個 tx 內跑(隨 tx commit/rollback」之間自由切換而呼叫端 / handler 一行不改。
// cascade 撤銷(刪 device → 同 tx 撤 pairing + session token正是靠這個介面讓 device repo
// 與 token store 在同一個 pgx.Tx 下操作、達成「整筆成功或整筆回滾」。
//
// 方法集刻意只取 repository 實際用到的三個Exec / Query / QueryRow不暴露 Begin/Commit
// 等交易控制——交易邊界由 WithTx 統一掌控repository 不該自行 commit/rollback。
//
// pgxpool.Pool 與 pgx.Tx 都已實作這三個方法(簽章完全相符),故下方兩個編譯期斷言成立。
type Querier interface {
Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error)
Query(ctx context.Context, sql string, args ...any) (pgx.Rows, error)
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
}
// WithTx 在一個 pgx 交易內執行 fn成功則 commit、失敗fn 回 error 或 panic則 rollback。
//
// 語意(塊 5.1
// - Begin 失敗 → 直接回 error連線層問題fail-fast
// - fn 回 error → rollback 並回 fn 的 error保留原因呼叫端可 errors.Is 比對 domain error
// - fn 成功 → commitcommit 失敗回 commit error。
// - panic → rollback 後重新 panic不吞讓上層 recovery middleware 處理)。
//
// ctx 取消會讓 Begin / fn 內的查詢 / Commit 各自因 context 失敗而中止——交易不會半開。
//
// 注意fn 內所有 DB 操作都必須用傳入的 qtx不可再用外層 pool否則那些操作不在交易內、
// 失敗時不會被 rollback。
func WithTx(ctx context.Context, pool *pgxpool.Pool, fn func(q Querier) error) (err error) {
if pool == nil {
return errors.New("db: WithTx requires non-nil pool")
}
tx, beginErr := pool.Begin(ctx)
if beginErr != nil {
return fmt.Errorf("db: begin tx: %w", beginErr)
}
// committed 避免 commit 後又 rollbackpgx 對已結束的 tx rollback 會回 ErrTxClosed
committed := false
defer func() {
if committed {
return
}
// Rollback 用 background context若是 ctx 已取消才走到這裡,仍要盡力 rollback。
if rbErr := tx.Rollback(context.Background()); rbErr != nil && !errors.Is(rbErr, pgx.ErrTxClosed) {
// 只有在 fn 本身沒回 error亦即 err 為 nilrollback error 才升級成回傳值;
// 否則保留 fn 的原始 error更有診斷價值rollback error 只是次要訊號。
if err == nil {
err = fmt.Errorf("db: rollback tx: %w", rbErr)
}
}
}()
if fnErr := fn(tx); fnErr != nil {
return fnErr // defer 會 rollback
}
if cErr := tx.Commit(ctx); cErr != nil {
return fmt.Errorf("db: commit tx: %w", cErr)
}
committed = true
return nil
}
// 編譯期斷言:*pgxpool.Pool 與 pgx.Tx 都滿足 Querier。
//
// pgx.Tx 是 interface無法直接取 (pgx.Tx)(nil) 當靜態斷言對象nil interface 沒有具體型別),
// 故只對 *pgxpool.Pool 做編譯期斷言pgx.Tx 的相符性由 WithTx 內 `fn(tx)` 的傳參處由編譯器保證。
var _ Querier = (*pgxpool.Pool)(nil)