jim800121chen 9031153553 feat(adr-019): 影片/圖片/批次上傳走同機 localhost 直連 local-agent
實作 ADR-019 混合路徑:影片/圖片/批次的檔案上傳改由瀏覽器同機直連
local-agent localhost endpoint(繞過雲端 tunnel),控制面 + MJPEG 結果 +
推論 WS 仍走 tunnel。解決大檔頻寬雙倍 + nginx 100M + 300s timeout。

三條 stream(全數過 reviewer + security code-level 複審 APPROVED):

local-agent(Go):
- CORS 雲端 origin 完整精確比對 + Allow-Credentials:false + HostGuard(loopback)
  + PNA header(middleware.go)
- 新 route /api/local/media/upload/*(一律要 token、不看 Origin,關 C1 後門)
- one-time token store(crypto/rand、TTL 120s、綁 deviceId、single-flight consume、
  上限 32→429;200 goroutine -race 綠)
- GET /api/local/hello(回 salted SHA-256 serialHashes、最小揭露)
  + POST /api/local/issue-token(Host-based)
- LocalUploadGuard(token+size 驗證放 FormFile 前);video≤500MB / batch 合計 80MB
  → 413;stopActivePipeline + batch 生命週期 temp 檔清理

cloud(visionA-backend):
- POST /api/devices/:serial/local-upload-ticket(OIDC + 裝置歸屬 + 經 tunnel
  轉發 issue-token;IDOR-safe、錯誤不洩漏)

frontend(visionA-frontend):
- lib/local-agent.ts(port 探測 3721-3740 並發+快取、Web Crypto serial hash 比對
  同機判定、uploadToLocalAgent 通用函式)
- validateBatchFiles 合計大小檢查(MAX_BATCH_TOTAL_BYTES=80MB,消 50×19MB 撞 413 地雷)

回歸:ADR-019 相關 270 測試全綠、既有 tunnel 路徑未被打斷、無 regression。
既有 tunnel(無 Origin)不要求 token(C1 route 分離相容性保證)。

Refs: ADR-019。WP-0(PNA 實機)/WP-4(影片分頁接線)下一批。

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

358 lines
13 KiB
Go
Raw 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.

// devices.go — /api/devices/* 的 handler 實作。
//
// 雛形分兩種資料來源:
// 1. 純雲端(讀 DeviceRepoGET /api/devices、GET /api/devices/:id
// — 回報使用者已配對的裝置清單,合併即時 tunnel 連線狀態
// 2. 走 tunnel proxy呼叫 local agentscan / connect / disconnect / flash / inference
// — 這些操作實際執行在 local agentUSB 插的那台機器)
//
// 對齊 api-spec.md §3 + feature-device-management.md。
package api
import (
"context"
"errors"
"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-5localhost 直連上傳的 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 = truelast_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 的;為空不致命)
tunnelAlive, lastSeen := resolveTunnelStatus(ctx, deps.SessionStore, userID)
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-2driver-status-source-gap-diagnosis.md
// 1. DB metadata + tunnel 狀態DeviceRepo + SessionStoreid/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 fallbackdevice 無序號 / proxy 失敗 / tunnel 離線 / timeout → 用 DB 的靜態
// statusGET :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
}
tunnelAlive, lastSeen := resolveTunnelStatus(ctx, deps.SessionStore, userID)
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 → 發 CloseSessionbest-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.2database.md §6
// - DeviceUnpairer 非 nilmain.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 必非空。
func resolveTunnelStatus(ctx context.Context, store session.Store, userID string) (bool, time.Time) {
if store == nil || userID == "" {
return false, time.Time{}
}
summaries, err := store.List(ctx)
if err != nil {
return false, time.Time{}
}
for _, s := range summaries {
// 寬鬆比對:暫接受 s.UserID == "" 直到 relay 端 backfill UserIDM2 待人工介入)。
if s.UserID == "" || s.UserID == userID {
return true, s.LastHeartbeat
}
}
return false, time.Time{}
}