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

224 lines
9.7 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 api 實作 api-server 的 REST + WebSocket 入口。
//
// 對齊 `.autoflow/04-architecture/api/api-spec.md`。
//
// **B4 範圍**Router / Middleware / 結構化錯誤回應骨架 + 少數 handler/healthz、
// /api/system/health、/api/system/info、/api/pairing/token、/api/pairing/status
//
// **B5 範圍**(本檔):
// - Authlogin / logout / mestubregister → 501
// - Pairinglist tokens / revoke token
// - Deviceslist / get讀雲端 repo + 合併 tunnel 狀態scan / connect /
// disconnect / flash / inference.start/stop 走 proxyunpair 軟刪
// - Modelslist / get / init upload / finalize / delete
// - System/system/deps走 proxy
// - ClustersGET /clusters 回空陣列;其他 stub
// - Storage/storage/* 的 LocalFS 假 presigned URL 代理GET/PUT
// - WebSocket保留 501 stub詳見 stubs.goB7 TODO
package api
import (
"log/slog"
"github.com/gin-gonic/gin"
"visiona-backend/internal/auth"
"visiona-backend/internal/conversion"
"visiona-backend/internal/converter"
"visiona-backend/internal/device"
"visiona-backend/internal/fileaccess"
"visiona-backend/internal/model"
"visiona-backend/internal/oidc"
"visiona-backend/internal/session"
"visiona-backend/internal/storage"
"visiona-backend/internal/usersession"
)
// Deps 匯整 api router 所需的所有依賴;由 cmd/api-server/main.go 在啟動時注入。
//
// 之所以集中在一個 struct是為了
// 1. 讓 NewRouter 簽章穩定(之後加新依賴只改 struct不破壞既有 caller
// 2. integration test 容易組裝(只需建一個 Deps 物件)
// 3. 各 handler 透過 closure 取得依賴,不需要 global state
//
// OB52026-04-26起 OIDC 是唯一認證路徑OIDCProvider + SessionManager 必填,
// validate() 在 NewRouter 啟動時就會 panic 提前暴露 misconfiguration。
type Deps struct {
Logger *slog.Logger
// PairingStore 管理 Pairing Token 生命週期。為 nil 時 /api/pairing/* 會回 501。
PairingStore auth.PairingStore
// ─── OIDCOB5 起為必填) ───
// OIDCProvider 封裝 OIDC clientauthorization URL 組裝、token exchange、id_token 驗證)。
OIDCProvider oidc.Provider
// SessionManager 管理 cookie sessionStartSession / GetSession / EndSession
SessionManager *usersession.Manager
// OIDCPostLoginURL 是 callback 完成後 302 回 frontend 的 base URL。
// 例http://localhost:3000dev/ https://app.visiona.cloudprod
// 為空字串時 callback handler 會 fallback 到 same-origin "/"(不建議生產配置)。
OIDCPostLoginURL string
SessionStore session.Store
Forwarder *session.Forwarder
DeviceRepo device.Repository
ModelRepo model.Repository
// DeviceUnpairer 將「軟刪 device + cascade 撤銷其 pairing/session token」包成單一原子
// Postgres tx或一致in-memory 依序操作DB 接入塊 5.2database.md §6
// 為 nil 時 unpair handler fallback 到「只軟刪 device、不 cascade」的舊行為
//(見 devices.go確保最小骨架仍可啟動。main.go 依 dbPool 是否非 nil 擇一注入。
DeviceUnpairer DeviceUnpairer
Storage storage.Store
Converter converter.Client
// Conversion 是 Phase 0.8 轉檔功能的 Service interface5 個 endpoint 共用)。
// 為 nil 時 /api/conversion/* 5 個 endpoint 全回 501 NOT_IMPLEMENTED
// main.go 在 cfg.Conversion.Enabled() 為 false 時不 wire對齊 api-conversion.md。
//
// 設計選擇:用 conversion.Service interface 而非 concrete type — 方便 unit test 注入 stub。
Conversion conversion.Service
// FileAccessIssuer 是 Phase 0.9「模型庫 model 直連 FAA 下載」的 download token 簽發者
// ADR-017 (a))。為 nil 時 GET /api/models/:id/download 回 501 NOT_IMPLEMENTED
// main.go 在 cfg.FileAccess.Enabled() 為 false 時不 wire
// 用 interface 方便 unit test 注入 fake。
FileAccessIssuer fileaccess.DownloadTokenIssuer
// FAABaseURL 是 File Access Agent 對外 base URL不帶結尾斜線用來組回給 Client
// 的 download_url`{FAABaseURL}/files/{object_key}`)。
// 由 cfg.FileAccess.FAABaseURL 注入FileAccessIssuer 非 nil 時必非空main.go 確保)。
FAABaseURL string
// CORSAllowedOrigins 是允許的瀏覽器 Origin 白名單;空 slice 預設放行
// http://localhost:3000前端 dev server
CORSAllowedOrigins []string
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
// StaticUserID 欄位已移除multi-tenant 環境下 fallback 到固定 user 是 latent multi-user
// 隔離破口OWASP A01 + A04。改 OIDC 後 AuthMiddleware 會擋下未登入請求,
// handler 拿不到 UserContext 一律 500safer than silent fallback
// dev seed / unit test 仍可獨立讀 cfg.Auth.StaticUserID env不再注入 Deps。
// MaxUploadSizeMB 是模型上傳大小上限MB0 代表不限(測試友善)。
// 對齊 feature-model-management.mdPhase 0 預設 100 MB由 config.Model.MaxSizeMB 注入)。
MaxUploadSizeMB int
// SessionTokenStore 保存 Pairing → Session 交換後發出的 Session Token。
// Phase 0 雛形用 in-memory 實作(由 main.go 注入Phase 1 改為 DB-backed。
// 為 nil 時 /api/pairing/exchange 會回 501 NOT_IMPLEMENTED。
SessionTokenStore auth.SessionTokenStore
// RelayPublicURL 是 agent 連 tunnel 用的 WSS URL對外可訪問
// 由 `POST /api/pairing/exchange` 回給 agent若為空會回預設 `wss://relay.visionA.cloud`(雛形 placeholder
// 對齊 build-deploy.md 的 VISIONA_RELAY_PUBLIC_URL 環境變數。
RelayPublicURL string
// HealthDBPool / HealthRedis 是 /healthz 要 ping 的依賴DB 接入塊 5.4)。
// 由 main.go 注入 db.Pool / db.RedisClient皆有 Ping(ctx))。為 nil 代表該依賴未啟用、
// /healthz 略過不檢查in-memory 模式維持「process 活著就 ok」
// 任一非 nil 依賴 ping 失敗 → /healthz 回 503讓 load balancer 拉出此實例。
HealthDBPool HealthPinger
HealthRedis HealthPinger
}
// validate 確認必要欄位都有;在 NewRouter 啟動時呼叫,避免 nil pointer panic 推到 runtime。
//
// 嚴格欄位(缺則 panic — fail fast避免半設定狀態跑進生產
// - OIDCProvider — OB5 起 OIDC 是唯一認證路徑
// - SessionManager — OIDC cookie session 必須
//
// 寬鬆欄位缺有預設Logger / CORSAllowedOrigins
//
// 其他欄位PairingStore / SessionStore 等)若為 nil 不擋 — 個別 handler 會回 501
// 允許「最小骨架」啟動跑 /healthz。
//
// Phase 0.7 security fix C1移除 StaticUserID 預設 "demo-user" 的 fallback。
func (d *Deps) validate() {
if d.Logger == nil {
d.Logger = slog.Default()
}
if len(d.CORSAllowedOrigins) == 0 {
d.CORSAllowedOrigins = []string{"http://localhost:3000"}
}
if d.OIDCProvider == nil {
panic("api.NewRouter: Deps.OIDCProvider is required (OB5: OIDC is the only auth path)")
}
if d.SessionManager == nil {
panic("api.NewRouter: Deps.SessionManager is required (OB5: OIDC cookie session is mandatory)")
}
}
// NewRouter 建立 Gin engine 並註冊所有路由與中介層。
//
// 為何回 *gin.Engine 而非 http.Handlercmd/api-server/main.go 需要 access
// engine.Run也可拿 .Handler() 給標準 http.Server 用,所以這個選擇沒讓
// caller 失去彈性)。
func NewRouter(deps Deps) *gin.Engine {
deps.validate()
// gin 的 ReleaseMode 由 caller 視環境設定cmd/api-server/main.go
// 這裡不主動設,避免測試環境被汙染。
r := gin.New()
// 註冊全域 middleware順序很重要Recovery 第一Logger 接著CORS 之後)
r.Use(RecoveryMiddleware(deps.Logger))
r.Use(RequestIDMiddleware())
r.Use(LoggerMiddleware(deps.Logger))
r.Use(CORSMiddleware(deps.CORSAllowedOrigins))
r.Use(ErrorMiddleware()) // 統一把 c.Errors 轉成 JSON
// /healthz 不需要 auth — K8s liveness/readiness 用。
// 塊 5.4啟用的依賴Postgres / Redis都 ping任一失敗回 503fail-fast
r.GET("/healthz", HealthzHandler(HealthDeps{
DBPool: deps.HealthDBPool,
Redis: deps.HealthRedis,
Logger: deps.Logger,
}))
// /storage/* 不走 AuthMiddleware改用 HMAC 簽章)— 對齊 api-spec.md §10
registerStorageRoutes(r, deps)
// /api/pairing/exchange 刻意不走 AuthMiddleware
// agent 尚未有 session token 時就得用 Pairing Token 換 Session Token
// Pairing Token 本身就是這個 endpoint 的憑證。詳見 security.md §1.2。
registerPairingPublicRoutes(r, deps)
// /ws/* 雛形全部 501B7 補齊 WebSocket proxy
registerWebSocketStubs(r)
// OIDC public routes不走 AuthMiddleware
// - GET /api/auth/login — 起始登入流程user 還沒登入)
// - GET /api/auth/callback — OIDC IdP 302 回來
// 必須註冊在 AuthMiddleware 群組之外,否則使用者沒登入根本進不去。
registerOIDCPublicRoutes(r, deps)
// /api 群組:所有路由都走 OIDC AuthMiddlewarecookie session → UserContext
apiGroup := r.Group("/api")
apiGroup.Use(AuthMiddleware(deps))
// B4 核心
registerSystemRoutes(apiGroup, deps)
registerPairingRoutes(apiGroup, deps)
// B5 新增:實際 handler
registerAuthRoutes(apiGroup, deps)
registerDeviceRoutes(apiGroup, deps)
registerModelRoutes(apiGroup, deps)
registerClusterRoutes(apiGroup, deps)
// Phase 0.8Conversion轉檔— 5 個 endpoint
// 對齊 .autoflow/04-architecture/api/api-conversion.md
registerConversionRoutes(apiGroup, deps)
// Stubs只註冊「還沒有實際 handler」的那些 endpoint
registerStubRoutes(apiGroup, deps)
return r
}