把 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>
93 lines
3.1 KiB
Go
93 lines
3.1 KiB
Go
package api
|
||
|
||
import (
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// 錯誤碼常數 — 對齊 api-spec.md §11。
|
||
const (
|
||
ErrCodeUnauthorized = "UNAUTHORIZED"
|
||
ErrCodeForbidden = "FORBIDDEN"
|
||
ErrCodeNotFound = "NOT_FOUND"
|
||
ErrCodeValidationFailed = "VALIDATION_FAILED"
|
||
ErrCodeTunnelDisconnect = "TUNNEL_DISCONNECTED"
|
||
ErrCodeTunnelError = "TUNNEL_ERROR"
|
||
ErrCodeNotImplemented = "NOT_IMPLEMENTED"
|
||
ErrCodeRateLimited = "RATE_LIMITED"
|
||
ErrCodeInternalError = "INTERNAL_ERROR"
|
||
// ErrCodePayloadTooLarge 對齊 HTTP 413(例:模型上傳超過 MaxUploadSizeMB)。
|
||
ErrCodePayloadTooLarge = "PAYLOAD_TOO_LARGE"
|
||
// ErrCodeInvalidSignature 用於 /storage/* 驗簽失敗 / URL 過期。
|
||
ErrCodeInvalidSignature = "INVALID_SIGNATURE"
|
||
// ErrCodeConflict 對齊 HTTP 409(例:unique 約束衝突 — 同 owner+serial 重複註冊)。
|
||
ErrCodeConflict = "CONFLICT"
|
||
// ErrCodeServiceUnavailable 對齊 HTTP 503。
|
||
// DB 接入塊 5.4 fail-fast 策略:PG 連線失敗 / context 逾時 → 503,讓 load balancer 知道
|
||
// 這台不健康,而非回假資料或 500(500 會誤導為「程式 bug」,503 才是「依賴暫時不可用」)。
|
||
ErrCodeServiceUnavailable = "SERVICE_UNAVAILABLE"
|
||
)
|
||
|
||
// ErrorBody 是 API 錯誤回應的 envelope 結構。
|
||
//
|
||
// 對齊 api-spec.md:
|
||
//
|
||
// { "success": false, "error": { "code": "...", "message": "...", "request_id": "..." } }
|
||
//
|
||
// 為什麼用 envelope 而非裸 error:方便前端統一處理 + 與成功回應形狀一致。
|
||
type ErrorBody struct {
|
||
Success bool `json:"success"`
|
||
Error *ErrorDetail `json:"error"`
|
||
}
|
||
|
||
// ErrorDetail 是錯誤的具體資訊。
|
||
type ErrorDetail struct {
|
||
Code string `json:"code"`
|
||
Message string `json:"message"`
|
||
Details []FieldError `json:"details,omitempty"` // 例如 validation 細節
|
||
RequestID string `json:"request_id,omitempty"`
|
||
Extra map[string]any `json:"extra,omitempty"` // 給 specific error 帶結構化資料
|
||
}
|
||
|
||
// FieldError 描述單一欄位的驗證錯誤。
|
||
type FieldError struct {
|
||
Field string `json:"field"`
|
||
Message string `json:"message"`
|
||
}
|
||
|
||
// SuccessBody 是成功回應的 envelope。
|
||
//
|
||
// 對齊 api-spec.md:`{ "success": true, "data": ... }`。
|
||
type SuccessBody struct {
|
||
Success bool `json:"success"`
|
||
Data any `json:"data,omitempty"`
|
||
}
|
||
|
||
// WriteError 統一寫錯誤回應(會自動帶上 request_id)。
|
||
//
|
||
// 注意:呼叫後 caller 仍需自行 c.Abort()(如果是在 middleware 中要終止 chain);
|
||
// 在 handler 中只需 return 即可。
|
||
func WriteError(c *gin.Context, status int, code, message string, details []FieldError) {
|
||
c.JSON(status, ErrorBody{
|
||
Success: false,
|
||
Error: &ErrorDetail{
|
||
Code: code,
|
||
Message: message,
|
||
Details: details,
|
||
RequestID: RequestIDFrom(c),
|
||
},
|
||
})
|
||
}
|
||
|
||
// WriteSuccess 統一寫成功回應。
|
||
func WriteSuccess(c *gin.Context, status int, data any) {
|
||
c.JSON(status, SuccessBody{
|
||
Success: true,
|
||
Data: data,
|
||
})
|
||
}
|
||
|
||
// WriteNotImplemented 回應 501,給 B5 還沒實作的 handler 用。
|
||
func WriteNotImplemented(c *gin.Context, hint string) {
|
||
WriteError(c, 501, ErrCodeNotImplemented, hint, nil)
|
||
}
|