把 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>
86 lines
3.5 KiB
Go
86 lines
3.5 KiB
Go
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 成功 → commit;commit 失敗回 commit error。
|
||
// - panic → rollback 後重新 panic(不吞,讓上層 recovery middleware 處理)。
|
||
//
|
||
// ctx 取消會讓 Begin / fn 內的查詢 / Commit 各自因 context 失敗而中止——交易不會半開。
|
||
//
|
||
// 注意:fn 內所有 DB 操作都必須用傳入的 q(tx),不可再用外層 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 後又 rollback(pgx 對已結束的 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 為 nil)時,rollback 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)
|