jim800121chen 67737334c8 fix(device): GET /api/devices/:id proxy 拿即時 driver status(方案 Y-2,解載入模型 disabled)
問題:連線後裝置詳情頁連線狀態顯示 unknown、載入模型按鈕永遠 disabled。
根因=雲端 GET /api/devices/:id 是純 DB 讀、沒 proxy 到 local agent → DB 只有
tunnel 層 status(online/offline/unknown)、沒有 driver 七態(detected/
connected/...)→ 前端 gate isDriverConnected 永遠 false。(上輪 C1/C5 查證
假設 status 拿得到、沒追到寫入點的漏洞)

修法(方案 Y-2、local agent 零改、gate 零改):
- backend device_driver_status.go(新):driverStatusFetcher 介面 +
  forwarderDriverStatusFetcher(走既有 session.Forwarder proxy)+ envelope 解析
- devices.go devicesGetHandler:讀 DB metadata 後,device 有序號時額外 proxy
  打 local agent GET /api/devices/{serial}(serial 路由對齊 WP-C)拿即時 driver
  status 覆蓋 USBStatus;remoteStatus/tunnel_online 保留(offline banner 不壞)
- graceful fallback(全回 200 不掛 500):無序號/tunnel 離線/不可達/timeout/
  非2xx/success:false/空status/Forwarder未配置 → 保留 DB status + Debug log
- 2s 短 timeout(不拖詳情頁)、serial path url.PathEscape 防禦
- frontend:DeviceHardwareStatus 加 unknown + coerceHardwareStatus(非七值→
  unknown)+ normalizeDevice fallback disconnected→unknown(修誤顯未連接)+
  i18n devices.status.unknown 兩語系(未確認/Unknown)

Reviewer 通過(0C/0M/3Mi/2Sug、Y-2 10/10、端到端追證 gate 放行 + 8 fallback
分支無一掛 500)。backend 8 測試 + frontend 49 passed、build/vet/test 綠、
gitleaks 0。端到端「即時 connected 覆蓋 unknown」需在線 agent+登入實測(單元
測試已覆蓋合併+fallback)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-17 09:25:26 +08:00

288 lines
14 KiB
Go
Raw Permalink 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/user"
"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
// OIDCLogoutURL 是「讓使用者連帶登出 IdPMember Centersession」的入口 URL。
//
// 背景MC 不支援標準 OIDC RP-initiated logout唯一瀏覽器可觸發的登出是
// MemberCenter.Web:7880的 /account/logoutGET 即 302。詳見 config.OIDCConfig.LogoutURL。
//
// 非空時logout handleroidc_auth.go會在 LogoutResponse 多回 idp_logout 欄位,
// 由前端 navigate 觸發 MC 登出。為空時不回該欄位、維持「只清本地 session」舊行為。
// 對齊 cfg.OIDC.LogoutURLenv VISIONA_OIDC_LOGOUT_URL
OIDCLogoutURL string
// UserStore 在 OIDC callback 驗 id_token 成功後 provisionupsert一筆 users 列
// DB-on FK 收尾,問題 #1。D1-BOIDC sub 直接當 users.idMember Center sub 為 UUID
//
// 為何必須DB-on有 FK真人登入後任何帶 owner_user_id FK 的寫入(上傳 model、配對、
// 發 pairing token都需要 users 表已有對應列;不 upsert → FK violation。
//
// 為 nil 時 callback 略過 upsert最小骨架 / 純 OIDC unit test 不強制注入);
// main.go 依 dbPool 注入 PostgresStore 或 InMemoryStore兩模式都會 upsert 對齊行為。
UserStore user.Store
SessionStore session.Store
Forwarder *session.Forwarder
// DriverStatusFetcher 是 GET /api/devices/:id「合併 local agent 即時 driver status」
// (方案 Y-2的可選注入點。為 nil 時 handler 從 Forwarder + SessionStore 組 default
//forwarderDriverStatusFetcher走既有 tunnel proxy。unit test 注入 stub 驗合併 /
// fallback不需真 tunnel。詳見 device_driver_status.go。
DriverStatusFetcher driverStatusFetcher
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
// PresetModelsDir 是 7 個系統預設模型 .nef 檔B8的本地目錄打包進 image
// 非空時 NewRouter 註冊 GET /preset-models/*filepath不簽 token、不走 auth
// 直接從此目錄串流檔案。為空時不註冊該路由preset download 會回 501。
PresetModelsDir string
// PresetBaseURL 是 preset download_url 的前綴(對外可直接 GET 的 base
// 例 http://localhost:3721api-server origindownload handler 組
// `{PresetBaseURL}/preset-models/{id}.nef` 回給前端導航下載。為空時 preset download 回 501。
PresetBaseURL string
// 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
// PairingExchanger 在 pairing exchange 時自建一筆 device 並建綁該 device 的 session token
// DB-on FK 收尾,問題 #2session_tokens.device_id NOT NULL FK但雛形流程從不建 device
// - Postgres用 db.WithTx 把「建 device + 建 session token」包成單一交易整筆原子。
// - in-memory依序執行無交易行為一致。
// 為 nil 時 exchange handler fallback 到舊行為(直接用 info.DeviceID 建 session token
// 不自建 device——僅 DB-off 雛形相容用。main.go 依 dbPool 擇一注入。
PairingExchanger PairingExchanger
// 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)
// /preset-models/* 不走 AuthMiddleware、不簽 tokenB8 系統預設模型公用、本不需授權)。
// 刻意用獨立 path 前綴而非掛在 /storage/* 底下gin(httprouter) 不允許同層級兩個
// wildcard/storage/*filepath 與 /storage/preset/*filepath 會 panic
registerPresetModelRoutes(r, deps)
// /api/pairing/exchange 刻意不走 AuthMiddleware
// agent 尚未有 session token 時就得用 Pairing Token 換 Session Token
// Pairing Token 本身就是這個 endpoint 的憑證。詳見 security.md §1.2。
registerPairingPublicRoutes(r, deps)
// /ws/* 雛形大多仍 501已實作的 WS tunnel proxy/ws/devices/:id/inference
// 與 /ws/devices/:id/flash-progress改掛在下方 wsAuthGroupAuthMiddleware group
registerWebSocketStubs(r)
// WS tunnel proxy group走 same-origin cookie AuthMiddlewaresecurity 定案,
// 不放 token 到 URL。目前有兩條裝置級 WS推論結果/ws/devices/:id/inference
// 與 flash 進度回顯(/ws/devices/:id/flash-progress兩者都走透明 tunnel proxy。
// 刻意獨立成 group 而非掛 /apiWS endpoint 對外路徑就是 /ws/*(對齊前端與
// api-spec但認證邏輯與 /api 共用 AuthMiddleware。
wsAuthGroup := r.Group("/ws")
wsAuthGroup.Use(AuthMiddleware(deps))
registerWebSocketRoutes(wsAuthGroup, deps)
// 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)
// Camera / Media 推論(走 tunnel proxy— /api/camera/* + /api/media/*
// 對齊 .autoflow/04-architecture/camera-e2e-effort-estimate.md
registerCameraRoutes(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
}