visionA/visionA-backend/internal/api/device_driver_status.go
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

152 lines
6.8 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.

// device_driver_status.go — GET /api/devices/:id 的「即時 driver status」合併邏輯方案 Y-2
//
// 背景driver-status-source-gap-diagnosis.md 方案 Y-2
// 雲端 DB 只有 tunnel-level 的 RemoteStatus 與靜態 USBStatusonline/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 的靜態 statusGET :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。
//
// 刻意設短2sdriver status 是詳情頁的加值資訊,拿不到就 fallback DB status。
// 不能讓 local agent hang 住時把整個詳情頁載入拖到跟 defaultProxyRequestTimeout(300s) 一樣久。
const driverStatusProxyTimeout = 2 * time.Second
// driverStatusFetcher 抽象「向 local agent 查某序號的即時 driver status」。
//
// 回傳的 string 是 local agent DeviceInfo.Statusdriver 七態,如 "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.StatuscamelCase 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 時回 nilcaller 據此略過即時查詢、只回 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 tokenpickActiveSessionToken
// 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) {
// 短 timeoutdriver 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-Clocal 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 組 defaultproduction
// - 兩者皆缺 → 回 nilhandler 略過即時查詢、只回 DB status
func resolveDriverStatusFetcher(deps Deps) driverStatusFetcher {
if deps.DriverStatusFetcher != nil {
return deps.DriverStatusFetcher
}
return newForwarderDriverStatusFetcher(deps)
}