// device_driver_status.go — GET /api/devices/:id 的「即時 driver status」合併邏輯(方案 Y-2)。 // // 背景(driver-status-source-gap-diagnosis.md 方案 Y-2): // 雲端 DB 只有 tunnel-level 的 RemoteStatus 與靜態 USBStatus(online/offline/unknown), // 沒有 local agent 的 driver 七態(detected/connected/flashing/...)。前端 gate // isDriverConnected 用 selectedDevice.status 判斷「driver 是否連上」,資料源必須是 // local agent 的即時值,否則永遠 unknown → 載入模型按鈕恆 disabled。 // // 本檔負責:讀完 DB metadata 後,額外 proxy 一次 local agent GET /api/devices/:serial // (走 serial 路由、對齊 ADR-018 / WP-C),把即時 driver status 覆蓋到回應的 status 欄。 // // 關鍵設計:**graceful fallback**。proxy 失敗 / timeout / tunnel 離線 / device 無序號時, // 一律退回 DB 的靜態 status,GET :id 照常回 200——driver status 是加值,拿不到不能讓 // 詳情頁整條掛掉。timeout 刻意設短(driverStatusProxyTimeout),避免詳情頁載入被拖慢。 // // 可測性:把「打 local agent 拿即時 status」抽成 driverStatusFetcher 介面,default 實作 // 包 session.Forwarder(走既有 proxy 基礎設施);unit test 注入 stub 驗合併 / 各種 fallback, // 不需要真 tunnel。 package api import ( "context" "encoding/json" "io" "net/http" "net/url" "time" "visiona-backend/internal/session" ) // driverStatusProxyTimeout 是「額外打 local agent 拿即時 driver status」的整體 timeout。 // // 刻意設短(2s):driver status 是詳情頁的加值資訊,拿不到就 fallback DB status。 // 不能讓 local agent hang 住時把整個詳情頁載入拖到跟 defaultProxyRequestTimeout(300s) 一樣久。 const driverStatusProxyTimeout = 2 * time.Second // driverStatusFetcher 抽象「向 local agent 查某序號的即時 driver status」。 // // 回傳的 string 是 local agent DeviceInfo.Status(driver 七態,如 "connected")。 // error 非 nil 代表拿不到(tunnel 離線 / local agent 不可達 / 該序號無 session / timeout / // 非 2xx 回應 / 解析失敗)—— caller 必須 graceful fallback 到 DB status,不得 raise。 // // default 實作 forwarderDriverStatusFetcher 走既有 session.Forwarder proxy 基礎設施; // unit test 注入 stub 驗合併與 fallback 分支。 type driverStatusFetcher interface { // FetchDriverStatus 打 local agent GET /api/devices/{serial} 拿即時 driver status。 // userID 用來挑當前 user 的 active session token(與其他 proxy 端點同一套 posture)。 FetchDriverStatus(ctx context.Context, userID, serial string) (string, error) } // localAgentEnvelope 是 local agent GET /api/devices/:id 的回應 envelope。 // // 對齊 local-agent device_handler.go GetDevice:{"success":true,"data": DeviceInfo{...}}。 // 只解出我們要的 status 欄(DeviceInfo.Status,camelCase JSON tag 為 "status")。 type localAgentEnvelope struct { Success bool `json:"success"` Data struct { Status string `json:"status"` } `json:"data"` } // forwarderDriverStatusFetcher 是 driverStatusFetcher 的 production 實作: // 透過 session.Forwarder 把 GET /api/devices/{serial} 經 tunnel 送到 local agent。 type forwarderDriverStatusFetcher struct { forwarder *session.Forwarder sessionStore session.Store } // newForwarderDriverStatusFetcher 從 Deps 組出 default fetcher。 // forwarder / sessionStore 任一為 nil 時回 nil(caller 據此略過即時查詢、只回 DB status)。 func newForwarderDriverStatusFetcher(deps Deps) driverStatusFetcher { if deps.Forwarder == nil || deps.SessionStore == nil { return nil } return &forwarderDriverStatusFetcher{ forwarder: deps.Forwarder, sessionStore: deps.SessionStore, } } // FetchDriverStatus 實作 driverStatusFetcher。 // // 流程(對齊 newProxyHandler,但目標 path 固定為 local agent 的 GET /api/devices/{serial}): // 1. 挑當前 user 的 active session token(pickActiveSessionToken) // 2. 組 GET /api/devices/{serial} request,經 Forwarder.ForwardHTTP 送到 local agent // 3. 解 envelope 取 data.status // // 任一步失敗都回 error(含非 2xx、success:false、空 status)——caller 一律 fallback DB。 func (f *forwarderDriverStatusFetcher) FetchDriverStatus(ctx context.Context, userID, serial string) (string, error) { // 短 timeout:driver status 是加值,別拖慢詳情頁。 ctx, cancel := context.WithTimeout(ctx, driverStatusProxyTimeout) defer cancel() token, err := pickActiveSessionToken(ctx, f.sessionStore, userID, nil) if err != nil { // tunnel 離線 / 無 active session → 拿不到即時 status,交由 caller fallback。 return "", err } // 走 serial 路由(ADR-018 / WP-C):local agent GetDevice 支援序號當 :id。 // path 用 url.PathEscape 保護序號(雖然序號目前是 0x... 十六進位、無特殊字元,仍防禦性處理)。 outReq, err := http.NewRequestWithContext(ctx, http.MethodGet, "/api/devices/"+url.PathEscape(serial), nil) if err != nil { return "", err } resp, err := f.forwarder.ForwardHTTP(ctx, token, outReq) if err != nil { // local agent 不可達 / dial 失敗 / timeout。 return "", err } defer resp.Body.Close() if resp.StatusCode < 200 || resp.StatusCode >= 300 { // local agent 回 404(該序號無 session)等 → 視為拿不到即時 status。 // drain 一小段 body 讓 conn 能重用(best-effort,錯誤忽略)。 _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 4*1024)) return "", errDriverStatusUnavailable } var env localAgentEnvelope if err := json.NewDecoder(io.LimitReader(resp.Body, 64*1024)).Decode(&env); err != nil { return "", err } if !env.Success || env.Data.Status == "" { return "", errDriverStatusUnavailable } return env.Data.Status, nil } // errDriverStatusUnavailable 表示 local agent 回應存在但沒帶可用的即時 driver status // (非 2xx / success:false / 空 status)。與「tunnel 離線」等傳輸層錯誤語意區隔, // 方便 caller 記 log 時分辨,但兩者都同樣 fallback DB status。 var errDriverStatusUnavailable = &driverStatusError{"driver status unavailable from local agent"} type driverStatusError struct{ msg string } func (e *driverStatusError) Error() string { return e.msg } // resolveDriverStatusFetcher 決定要用哪個 fetcher: // - Deps.DriverStatusFetcher 非 nil(測試注入 stub)→ 用它 // - 否則從 Forwarder + SessionStore 組 default(production) // - 兩者皆缺 → 回 nil(handler 略過即時查詢、只回 DB status) func resolveDriverStatusFetcher(deps Deps) driverStatusFetcher { if deps.DriverStatusFetcher != nil { return deps.DriverStatusFetcher } return newForwarderDriverStatusFetcher(deps) }