問題:連線後裝置詳情頁連線狀態顯示 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>
152 lines
6.8 KiB
Go
152 lines
6.8 KiB
Go
// 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)
|
||
}
|