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

102 lines
4.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.

// errors_db.go — 統一把 DBpgx錯誤映射成對外 API 錯誤DB 接入塊 5.4)。
//
// 兩個目的:
// 1. **fail-fast 落地**PG 連線失敗 / context 逾時 → 503 SERVICE_UNAVAILABLE不回假資料、
// 不誤報 500讓 load balancer 把這台拉出。對齊使用者拍板的「PG 都 fail-fast、不自動降級」。
// 2. **收掉塊 3 M1 技術債**handler 過去把 raw DB error 字串串進 response
// `"... failed: "+err.Error()`)會洩漏 schema / SQL / 連線細節。本檔統一映射,
// 對外只給穩定的 error code + 通用 messageraw error 只進 server log含 request_id
//
// 映射規則(依優先序):
//
// | 來源 | HTTP | code |
// |----------------------------------------|------|-----------------------|
// | context 逾時 / 取消DeadlineExceeded / Canceled| 503 | SERVICE_UNAVAILABLE |
// | 連線層失敗pgconn 無 SQLSTATErefused/reset/EOF| 503 | SERVICE_UNAVAILABLE |
// | unique violationSQLSTATE 23505 | 409 | CONFLICT |
// | 其餘有 SQLSTATE 的 PG 錯誤(語法/約束等)| 500 | INTERNAL_ERROR |
// | 非 DB 錯誤 / 未知 | 500 | INTERNAL_ERROR |
//
// ⚠️ not found 不在此映射domain 層device.ErrNotFound / auth.ErrInvalidToken 等)已把
// pgx.ErrNoRows 轉成自己的 sentinelhandler 應先用 errors.Is 比對那些 sentinel 回 404
// **再**把剩下的「真 DB 錯誤」交給 WriteDBError。這樣「正常的 not found」絕不會被誤判成 503。
package api
import (
"context"
"errors"
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
"github.com/jackc/pgx/v5/pgconn"
)
// dbErrorClass 是 DB 錯誤分類結果。
type dbErrorClass struct {
status int
code string
message string // 對外通用訊息(不含 raw DB 細節)
}
// classifyDBError 把一個 error 分類為對外的 (HTTP status, code, message)。
//
// 不洩漏 raw DB error回傳的 message 是固定通用字串raw error 由 WriteDBError 寫進 server log。
func classifyDBError(err error) dbErrorClass {
// 1) context 逾時 / 取消 → 503依賴慢/不可用fail-fast
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
return dbErrorClass{http.StatusServiceUnavailable, ErrCodeServiceUnavailable,
"service temporarily unavailable"}
}
// 2) pgconn.PgErrorPG server 端回的錯誤(有 SQLSTATE
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
switch pgErr.Code {
case "23505": // unique_violation
return dbErrorClass{http.StatusConflict, ErrCodeConflict, "resource already exists"}
default:
// 其餘 PG 錯誤(語法、約束、權限…)對外一律 500不洩漏 SQLSTATE / 欄位名。
return dbErrorClass{http.StatusInternalServerError, ErrCodeInternalError,
"internal error"}
}
}
// 3) pgconn.ConnectError 等「連不上 / 連線中斷」(無 SQLSTATE→ 503。
// pgxpool 連線層失敗connection refused / reset / EOF多半包成 *pgconn.ConnectError
// 或為底層 net error。用 errors.As 抓 ConnectError抓不到再保守視為連線問題前先看下一步。
var connErr *pgconn.ConnectError
if errors.As(err, &connErr) {
return dbErrorClass{http.StatusServiceUnavailable, ErrCodeServiceUnavailable,
"service temporarily unavailable"}
}
// 4) 其餘未知錯誤 → 500保守不假設是 DB down 以免把程式 bug 也報成 503
return dbErrorClass{http.StatusInternalServerError, ErrCodeInternalError, "internal error"}
}
// WriteDBError 把一個 DB 操作錯誤映射成對外 API 錯誤並寫回 response同時把 raw error 進 log。
//
// op 是操作描述(如 "get device" / "list models"),只進 log、不對外。
// 對外只給 classifyDBError 算出的穩定 code + 通用 message不洩漏 raw DB error
//
// 用法handler 內,已先處理過 domain sentinel 如 ErrNotFound 後):
//
// if err != nil {
// WriteDBError(c, deps.Logger, "list devices", err)
// return
// }
func WriteDBError(c *gin.Context, log *slog.Logger, op string, err error) {
cls := classifyDBError(err)
// raw error 只進 server log附 request_id + 對外 code方便對照不進 response。
logOrDefault(log).Error("db operation failed",
"op", op,
"error", err,
"http_status", cls.status,
"code", cls.code,
"request_id", RequestIDFrom(c))
WriteError(c, cls.status, cls.code, cls.message, nil)
}