jim800121chen 59c57fa481 feat(device): WP-B repository 接 agents 模型 + exchange 重塑(A' 走向第二階段 Go 層)
- Device struct 加 4 欄(agent_id/agent_local_device_id/registered_at/
  is_representative)+ deviceColumns 13→17 + scanDevice/SaveTx 讀寫新欄
- 新增 internal/agent package(domain + interface + in-memory + PG repo):
  GetOrCreateAgentTx/GetByOwnerTx,advisory lock 序列化同 owner get-or-create
- exchange 重塑:建/復用 agent → representative device(綁 session_tokens、
  serial=NULL)→ loop 建 N 顆真 USB device(R1 完整 N 顆非只第一顆)
- List filter is_representative=false + DeviceListItem 回傳 agent_id/registered_at
- 併入 WP-0/0005 follow-up Minor:Mi#2 lost-update 收斂(tx 內查詢+局部更新)
  / Mi#3 過時註解 / Mi#4 空 serial 回 ErrNotFound / Mi#5 serial 白名單
  ^0x[0-9A-Fa-f]{8}$ + 去重 / S-1 firmware forward-compat / S-2 device Name 衍生

守 ADR-018 A'(一 owner N agents、session_tokens FK 物理不動、不加 owner
unique 為多機器留路)。Reviewer 通過(0C/0M)。5 套件 dbtest 130 全綠
(db 19/device 37/agent 13/api 172/cmd 60)、build/vet/test 綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:40:17 +08:00

324 lines
11 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))
}
// 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。
//
// 雛形:直接從 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,
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
}
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{}
}