把 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>
102 lines
4.5 KiB
Go
102 lines
4.5 KiB
Go
// errors_db.go — 統一把 DB(pgx)錯誤映射成對外 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 + 通用 message,raw error 只進 server log(含 request_id)。
|
||
//
|
||
// 映射規則(依優先序):
|
||
//
|
||
// | 來源 | HTTP | code |
|
||
// |----------------------------------------|------|-----------------------|
|
||
// | context 逾時 / 取消(DeadlineExceeded / Canceled)| 503 | SERVICE_UNAVAILABLE |
|
||
// | 連線層失敗(pgconn 無 SQLSTATE:refused/reset/EOF)| 503 | SERVICE_UNAVAILABLE |
|
||
// | unique violation(SQLSTATE 23505) | 409 | CONFLICT |
|
||
// | 其餘有 SQLSTATE 的 PG 錯誤(語法/約束等)| 500 | INTERNAL_ERROR |
|
||
// | 非 DB 錯誤 / 未知 | 500 | INTERNAL_ERROR |
|
||
//
|
||
// ⚠️ not found 不在此映射:domain 層(device.ErrNotFound / auth.ErrInvalidToken 等)已把
|
||
// pgx.ErrNoRows 轉成自己的 sentinel,handler 應先用 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.PgError:PG 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)
|
||
}
|