裝置 detail endpoint(/api/devices/:id)原用單一 2s ctx,先跑 DeviceRepo.Get 殘餘時間才輪到打 relay 的 store.List → tunnel 狀態查詢逾時被靜默判離線 → 影片分頁 R-3 誤擋上傳(列表頁 3s 判在線、詳情頁 2s 判離線,同裝置相反)。 修法:tunnel 判定改用獨立 ctx(源自 request context、完整 3s、defer cancel), 與 list 對齊;detail 原 2s ctx 保留給 DeviceRepo.Get。list 也一併改獨立 ctx。 未動 resolveTunnelStatus 的逾時判離線 fail-safe 語意,只給足夠時間。 加可觀測性 log(deadline_exceeded / no-matching / 命中三分支,不含敏感資訊), 供 stage 分辨「真逾時」vs「UserID 比對不中」。前端未動(行為正確)。 reviewer 通過(0C/0M)。build/vet/全套 test 綠 + 3 新 test(含 ctx 完整預算斷言)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
408 lines
16 KiB
Go
408 lines
16 KiB
Go
// devices.go — /api/devices/* 的 handler 實作。
|
||
//
|
||
// 雛形分兩種資料來源:
|
||
// 1. 純雲端(讀 DeviceRepo):GET /api/devices、GET /api/devices/:id
|
||
// — 回報使用者已配對的裝置清單,合併即時 tunnel 連線狀態
|
||
// 2. 走 tunnel proxy(呼叫 local agent):scan / connect / disconnect / flash / inference
|
||
// — 這些操作實際執行在 local agent(USB 插的那台機器)
|
||
//
|
||
// 對齊 api-spec.md §3 + feature-device-management.md。
|
||
|
||
package api
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"log/slog"
|
||
"net/http"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
|
||
"visiona-backend/internal/device"
|
||
"visiona-backend/internal/session"
|
||
)
|
||
|
||
// registerDeviceRoutes 註冊 /api/devices/* 的 routes。
|
||
func registerDeviceRoutes(g *gin.RouterGroup, deps Deps) {
|
||
// 純雲端讀取類
|
||
g.GET("/devices", devicesListHandler(deps))
|
||
g.GET("/devices/:id", devicesGetHandler(deps))
|
||
|
||
// 走 tunnel proxy 的操作類
|
||
proxy := newProxyHandler(deps, proxyOptions{})
|
||
g.POST("/devices/scan", proxy)
|
||
g.POST("/devices/:id/connect", proxy)
|
||
g.POST("/devices/:id/disconnect", proxy)
|
||
g.POST("/devices/:id/flash", proxy)
|
||
g.POST("/devices/:id/inference/start", proxy)
|
||
g.POST("/devices/:id/inference/stop", proxy)
|
||
|
||
// Unpair(雛形實作:軟刪 DeviceRepo + CloseSession)
|
||
g.POST("/devices/:id/unpair", devicesUnpairHandler(deps))
|
||
|
||
// ADR-019 WP-5:localhost 直連上傳的 one-time token 取得路徑(經既有 tunnel 打
|
||
// local-agent issue-token)。契約 path 為 /api/devices/:serial/local-upload-ticket,
|
||
// 但 gin/httprouter 要求同層級同名,故沿用 :id 佔位(其值語意為裝置序號 serial,
|
||
// handler 用它走 GetBySerial 做歸屬檢查)。見 local_upload_ticket.go。
|
||
g.POST("/devices/:id/local-upload-ticket", localUploadTicketHandler(deps))
|
||
}
|
||
|
||
// DeviceListItem 是 GET /api/devices 回應中的單筆裝置。
|
||
//
|
||
// 合併雲端 DeviceRepo 的 metadata 與 Session 狀態(tunnel_online):
|
||
type DeviceListItem struct {
|
||
// 基本 metadata(來自 DeviceRepo)
|
||
ID string `json:"id"`
|
||
Name string `json:"name"`
|
||
DeviceType string `json:"device_type"`
|
||
SerialNumber string `json:"serial_number,omitempty"`
|
||
|
||
// A' 模型(WP-B B4):供前端三色(連線軸 × 註冊軸)與分組用。
|
||
// - AgentID:所屬 agent(同一 agent 下的 USB 共用一條 tunnel)。
|
||
// - RegisteredAt:註冊軸(nil=未註冊)。前端用「未註冊 + 在線 = 黃」算第三態(WP-F)。
|
||
AgentID string `json:"agent_id,omitempty"`
|
||
RegisteredAt *time.Time `json:"registered_at,omitempty"`
|
||
|
||
// 狀態
|
||
RemoteStatus string `json:"remote_status"`
|
||
LastSeenAt *time.Time `json:"last_seen_at,omitempty"`
|
||
LastConnectedAt *time.Time `json:"last_connected_at,omitempty"`
|
||
USBStatus string `json:"status"` // USB-level
|
||
|
||
// Tunnel 即時狀態(若有)
|
||
TunnelOnline bool `json:"tunnel_online"`
|
||
|
||
CreatedAt time.Time `json:"created_at"`
|
||
UpdatedAt time.Time `json:"updated_at"`
|
||
}
|
||
|
||
// devicesListHandler 實作 GET /api/devices。
|
||
//
|
||
// 行為:從 DeviceRepo 列出當前 user 的裝置,再合併 SessionStore 的 tunnel 狀態:
|
||
// - 若該 user 有 active session → tunnel_online = true,last_seen_at 從 session 更新
|
||
// - 無 active session → 仍列出,但 tunnel_online = false
|
||
//
|
||
// Phase 1 會改為 DB JOIN + presigned URL;雛形 in-memory 足夠。
|
||
func devicesListHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if deps.DeviceRepo == nil {
|
||
WriteSuccess(c, http.StatusOK, []DeviceListItem{})
|
||
return
|
||
}
|
||
|
||
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||
uc, ok := UserContextFrom(c)
|
||
if !ok || uc.UserID == "" {
|
||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||
"missing user context (auth middleware misconfigured?)", nil)
|
||
return
|
||
}
|
||
userID := uc.UserID
|
||
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||
defer cancel()
|
||
|
||
devices, err := deps.DeviceRepo.List(ctx, userID)
|
||
if err != nil {
|
||
// DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||
WriteDBError(c, deps.Logger, "list devices", err)
|
||
return
|
||
}
|
||
|
||
// 查 tunnel 狀態(雛形:列全部 session 找當前 user 的;為空不致命)。
|
||
// 用獨立 ctx(源自 request context)給 tunnel 判定完整 3s 預算,避免前面 DeviceRepo.List
|
||
// 吃掉共用 ctx 的時間導致 store.List 逾時被靜默判離線(R-3 離線誤判)。
|
||
tunnelCtx, tunnelCancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||
defer tunnelCancel()
|
||
tunnelAlive, lastSeen := resolveTunnelStatus(
|
||
tunnelCtx, deps.SessionStore, userID, deps.Logger, "list", RequestIDFrom(c))
|
||
|
||
out := make([]DeviceListItem, 0, len(devices))
|
||
for _, d := range devices {
|
||
item := DeviceListItem{
|
||
ID: d.ID,
|
||
Name: d.Name,
|
||
DeviceType: d.DeviceType,
|
||
SerialNumber: d.SerialNumber,
|
||
AgentID: d.AgentID,
|
||
RegisteredAt: d.RegisteredAt,
|
||
RemoteStatus: d.RemoteStatus,
|
||
LastSeenAt: d.LastSeenAt,
|
||
LastConnectedAt: d.LastConnectedAt,
|
||
USBStatus: d.Status,
|
||
TunnelOnline: tunnelAlive,
|
||
CreatedAt: d.CreatedAt,
|
||
UpdatedAt: d.UpdatedAt,
|
||
}
|
||
// 如果雲端沒記錄 LastSeenAt 但 tunnel 活著,就用 session 的 lastSeen 填
|
||
if item.LastSeenAt == nil && tunnelAlive && !lastSeen.IsZero() {
|
||
ls := lastSeen
|
||
item.LastSeenAt = &ls
|
||
}
|
||
out = append(out, item)
|
||
}
|
||
WriteSuccess(c, http.StatusOK, out)
|
||
}
|
||
}
|
||
|
||
// devicesGetHandler 實作 GET /api/devices/:id。
|
||
//
|
||
// 資料源(方案 Y-2,driver-status-source-gap-diagnosis.md):
|
||
// 1. DB metadata + tunnel 狀態(DeviceRepo + SessionStore):id/serial/type/name/
|
||
// remoteStatus/tunnel_online/lastSeenAt/... — 這些只有雲端 DB 有。
|
||
// 2. **額外 proxy 一次 local agent GET /api/devices/:serial 拿即時 driver status**,
|
||
// 把即時值(detected/connected/flashing/...)覆蓋到回應的 status 欄,供前端 gate
|
||
// isDriverConnected 判斷。走 serial 路由(ADR-018 / WP-C)。
|
||
//
|
||
// graceful fallback:device 無序號 / proxy 失敗 / tunnel 離線 / timeout → 用 DB 的靜態
|
||
// status,GET :id 照常回 200(不掛)。driver status 是加值,拿不到不能讓詳情頁整條失敗。
|
||
func devicesGetHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if deps.DeviceRepo == nil {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||
return
|
||
}
|
||
|
||
id := c.Param("id")
|
||
if id == "" {
|
||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "device id required", nil)
|
||
return
|
||
}
|
||
|
||
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||
uc, ok := UserContextFrom(c)
|
||
if !ok || uc.UserID == "" {
|
||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||
"missing user context (auth middleware misconfigured?)", nil)
|
||
return
|
||
}
|
||
userID := uc.UserID
|
||
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 2*time.Second)
|
||
defer cancel()
|
||
|
||
d, err := deps.DeviceRepo.Get(ctx, id)
|
||
if err != nil {
|
||
if errors.Is(err, device.ErrNotFound) {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||
return
|
||
}
|
||
// DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||
WriteDBError(c, deps.Logger, "get device", err)
|
||
return
|
||
}
|
||
|
||
// Ownership 檢查(雛形單一 user,但仍守住這道)
|
||
if d.OwnerUserID != userID {
|
||
WriteError(c, http.StatusForbidden, ErrCodeForbidden,
|
||
"not owner of this device", nil)
|
||
return
|
||
}
|
||
|
||
// R-3 離線誤判修復(見 .autoflow/05-implementation/r3-offline-misjudge-rootcause.md):
|
||
// detail 過去用同一個 2s ctx 先跑 DeviceRepo.Get 再跑 resolveTunnelStatus,前面的 DB
|
||
// 呼叫吃掉時間後,打 relay 的 store.List 常逾時被靜默判離線,導致前端 fallback 到恆
|
||
// offline 的 DB 靜態值、R-3 誤擋上傳。改用獨立 ctx(源自 request context)給 tunnel
|
||
// 判定完整 3s 預算,與 list endpoint 對齊。2s 對打 relay 的 HTTP 本來就偏緊。
|
||
tunnelCtx, tunnelCancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||
defer tunnelCancel()
|
||
tunnelAlive, lastSeen := resolveTunnelStatus(
|
||
tunnelCtx, deps.SessionStore, userID, deps.Logger, "detail", RequestIDFrom(c))
|
||
item := DeviceListItem{
|
||
ID: d.ID,
|
||
Name: d.Name,
|
||
DeviceType: d.DeviceType,
|
||
SerialNumber: d.SerialNumber,
|
||
AgentID: d.AgentID,
|
||
RegisteredAt: d.RegisteredAt,
|
||
RemoteStatus: d.RemoteStatus,
|
||
LastSeenAt: d.LastSeenAt,
|
||
LastConnectedAt: d.LastConnectedAt,
|
||
USBStatus: d.Status,
|
||
TunnelOnline: tunnelAlive,
|
||
CreatedAt: d.CreatedAt,
|
||
UpdatedAt: d.UpdatedAt,
|
||
}
|
||
if item.LastSeenAt == nil && tunnelAlive && !lastSeen.IsZero() {
|
||
ls := lastSeen
|
||
item.LastSeenAt = &ls
|
||
}
|
||
|
||
// 方案 Y-2:額外打 local agent 拿即時 driver status,覆蓋 DB 靜態值。
|
||
// 只在「device 有序號」時嘗試(serial 路由;無序號本來就不支援即時查詢)。
|
||
// 任何失敗都 graceful fallback:保留 item.USBStatus 的 DB 值,GET :id 照常回 200。
|
||
if d.SerialNumber != "" {
|
||
if fetcher := resolveDriverStatusFetcher(deps); fetcher != nil {
|
||
live, ferr := fetcher.FetchDriverStatus(c.Request.Context(), userID, d.SerialNumber)
|
||
if ferr == nil && live != "" {
|
||
item.USBStatus = live // 即時 driver status 覆蓋 DB 靜態值
|
||
} else if ferr != nil {
|
||
// fallback:保留 DB status。記 debug log 供排查(不是錯誤、不告警)。
|
||
logOrDefault(deps.Logger).Debug("devices: live driver status unavailable, fallback to DB status",
|
||
"device_id", d.ID,
|
||
"serial", d.SerialNumber,
|
||
"db_status", item.USBStatus,
|
||
"error", ferr.Error(),
|
||
"request_id", RequestIDFrom(c))
|
||
}
|
||
}
|
||
}
|
||
|
||
WriteSuccess(c, http.StatusOK, item)
|
||
}
|
||
}
|
||
|
||
// devicesUnpairHandler 實作 POST /api/devices/:id/unpair。
|
||
//
|
||
// 雛形行為:
|
||
// 1. 驗證 device ownership
|
||
// 2. 軟刪 DeviceRepo entry
|
||
// 3. 若該 user 有 active session → 發 CloseSession(best-effort)
|
||
//
|
||
// 真正的 Session Token 撤銷(Phase 1)需要 PairingStore/SessionTokenStore 支援。
|
||
func devicesUnpairHandler(deps Deps) gin.HandlerFunc {
|
||
return func(c *gin.Context) {
|
||
if deps.DeviceRepo == nil {
|
||
WriteNotImplemented(c, "device repo not configured")
|
||
return
|
||
}
|
||
|
||
id := c.Param("id")
|
||
if id == "" {
|
||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "device id required", nil)
|
||
return
|
||
}
|
||
|
||
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||
uc, ok := UserContextFrom(c)
|
||
if !ok || uc.UserID == "" {
|
||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||
"missing user context (auth middleware misconfigured?)", nil)
|
||
return
|
||
}
|
||
userID := uc.UserID
|
||
|
||
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||
defer cancel()
|
||
|
||
d, err := deps.DeviceRepo.Get(ctx, id)
|
||
if err != nil {
|
||
if errors.Is(err, device.ErrNotFound) {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||
return
|
||
}
|
||
// DB 錯誤經 errors.go 映射(PG down → 503、其餘 → 500),不洩漏 raw DB error。
|
||
WriteDBError(c, deps.Logger, "get device", err)
|
||
return
|
||
}
|
||
if d.OwnerUserID != userID {
|
||
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
|
||
return
|
||
}
|
||
|
||
// 軟刪 + cascade 撤銷該 device 的 pairing/session token(塊 5.2,database.md §6)。
|
||
// - DeviceUnpairer 非 nil(main.go 注入 Postgres tx 版 / in-memory 依序版)→ 走 cascade。
|
||
// - 為 nil(最小骨架)→ fallback 只軟刪 device,不 cascade(舊行為)。
|
||
var unpairResult UnpairResult
|
||
if deps.DeviceUnpairer != nil {
|
||
res, uErr := deps.DeviceUnpairer.Unpair(ctx, id)
|
||
if uErr != nil {
|
||
if errors.Is(uErr, device.ErrNotFound) {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||
return
|
||
}
|
||
WriteDBError(c, deps.Logger, "unpair device", uErr)
|
||
return
|
||
}
|
||
unpairResult = res
|
||
} else {
|
||
if err := deps.DeviceRepo.Delete(ctx, id); err != nil {
|
||
if errors.Is(err, device.ErrNotFound) {
|
||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||
return
|
||
}
|
||
WriteDBError(c, deps.Logger, "delete device", err)
|
||
return
|
||
}
|
||
}
|
||
|
||
// best-effort:關閉該 user 的 session(雛形單裝置假設)
|
||
if deps.SessionStore != nil {
|
||
if token, tokErr := pickActiveSessionToken(ctx, deps.SessionStore, userID, deps.Logger); tokErr == nil {
|
||
_ = deps.SessionStore.Unregister(ctx, token)
|
||
}
|
||
}
|
||
|
||
logOrDefault(deps.Logger).Info("devices: unpaired",
|
||
"device_id", id,
|
||
"user_id", userID,
|
||
"pairing_tokens_revoked", unpairResult.PairingRevoked,
|
||
"session_tokens_revoked", unpairResult.SessionRevoked,
|
||
"request_id", RequestIDFrom(c))
|
||
|
||
WriteSuccess(c, http.StatusOK, gin.H{"id": id, "unpaired": true})
|
||
}
|
||
}
|
||
|
||
// resolveTunnelStatus 回報當前 user 是否有 active tunnel,以及最新心跳時間。
|
||
//
|
||
// 雛形單裝置假設:只看第一筆 match 的 session。多裝置時 Phase 1 擴充。
|
||
// 失敗一律 return (false, zero time) 不 raise — 給 list/get 用,不該因此 fail。
|
||
//
|
||
// Phase 0.7 security audit M2:寬鬆比對暫保留待人工介入。
|
||
// 詳細理由見 pickActiveSessionToken 註解:relay 端 LocalHandle.Summary 不帶 UserID。
|
||
// 修復 caller (handler) 已先做 strict UserContext 檢查,userID 必非空。
|
||
//
|
||
// 可觀測性(R-3 離線誤判排查,見 .autoflow/05-implementation/r3-offline-misjudge-rootcause.md):
|
||
// list 與 detail 都呼叫此函式,但 detail 曾用較緊的 ctx timeout 導致 store.List 逾時被靜默
|
||
// 判離線。加 log 以在 stage 重現時分辨兩個嫌疑:
|
||
// - 嫌疑 1:store.List 回 err(尤其 context deadline exceeded)→ 逾時判離線。
|
||
// - 嫌疑 2:拿到 summaries 但沒有一筆命中 userID → 比對不中判離線。
|
||
//
|
||
// endpoint 參數("list" / "detail")標明呼叫來源,log 不帶 token 等敏感資訊。
|
||
func resolveTunnelStatus(
|
||
ctx context.Context,
|
||
store session.Store,
|
||
userID string,
|
||
logger *slog.Logger,
|
||
endpoint string,
|
||
requestID string,
|
||
) (bool, time.Time) {
|
||
if store == nil || userID == "" {
|
||
return false, time.Time{}
|
||
}
|
||
log := logOrDefault(logger)
|
||
summaries, err := store.List(ctx)
|
||
if err != nil {
|
||
// 嫌疑 1:List 逾時 / 報錯 → 靜默判離線(fail-safe,語意保留)。
|
||
// deadline 標記讓 stage log 能一眼分辨「ctx 逾時」vs「relay 其他錯誤」。
|
||
log.Warn("devices: resolveTunnelStatus store.List failed, treating tunnel as offline",
|
||
"endpoint", endpoint,
|
||
"user_id", userID,
|
||
"deadline_exceeded", errors.Is(err, context.DeadlineExceeded),
|
||
"error", err.Error(),
|
||
"request_id", requestID)
|
||
return false, time.Time{}
|
||
}
|
||
for _, s := range summaries {
|
||
// 寬鬆比對:暫接受 s.UserID == "" 直到 relay 端 backfill UserID(M2 待人工介入)。
|
||
if s.UserID == "" || s.UserID == userID {
|
||
log.Debug("devices: resolveTunnelStatus matched session, tunnel online",
|
||
"endpoint", endpoint,
|
||
"user_id", userID,
|
||
"session_user_id_empty", s.UserID == "",
|
||
"summaries_count", len(summaries),
|
||
"request_id", requestID)
|
||
return true, s.LastHeartbeat
|
||
}
|
||
}
|
||
// 嫌疑 2:拿到 list 但沒有一筆命中 → 比對不中判離線。
|
||
log.Debug("devices: resolveTunnelStatus no matching session, tunnel offline",
|
||
"endpoint", endpoint,
|
||
"user_id", userID,
|
||
"summaries_count", len(summaries),
|
||
"request_id", requestID)
|
||
return false, time.Time{}
|
||
}
|