// 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" "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)) } // 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"` // 狀態 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 的;為空不致命) 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, 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。 // // 雛形:直接從 DeviceRepo 讀(不走 tunnel)。ownership 檢查以 OwnerUserID 比對。 // 若要即時查 USB 狀態,前端可再打 POST /api/devices/:id/connect 等 proxy 端點。 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, 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 } 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 必非空。 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 UserID(M2 待人工介入)。 if s.UserID == "" || s.UserID == userID { return true, s.LastHeartbeat } } return false, time.Time{} }