jim800121chen 47a1d4d0ef feat(backend): 設備註冊 + 模型共享 backend(B 設備管理 + C 模型共享)
B 設備管理(feature-device-mgmt-tdd):
- POST /api/devices/:id/register + /unregister(owner 檢查 + representative 擋
  + 已註冊擋 + SetRegistered 單欄翻轉,不碰 unpair 軟刪)
- error codes ALREADY_REGISTERED / REPRESENTATIVE_DEVICE(409)
- 不需 migration(registered_at 欄/index/讀寫已在 0005)

C 模型共享(feature-model-sharing-tdd,security 深審 APPROVE):
- migration 0006:models.visibility enum DEFAULT 'private'(零行為改變)+ model_shares 表
- canAccessModel single source(owner ∪ share ∪ public ∪ tenant):profile + download 共用
- GET /library(cursor keyset)/ GET /:id/profile(404 防列舉、GetWithOwner join name 不洩 email)
  / PATCH /:id/visibility(owner-only)/ shares CRUD / download 放寬
- tenant 因 OIDC 無 org claim 留 stub(恆空、安全預設;補 org claim 需重送 security 深審)

reviewer 通過(B 三條紅線 / C security APPROVE 無 C/M)。130 dbtest 全綠、gosec 新檔 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 16:29:50 +08:00

413 lines
16 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"
"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))
// 註冊軸feature-device-mgmt P0純雲端 DB 操作、UUID :id、不 proxy
// registerregistered_at NULL→now()unregister清 registered_at保留列與 unpair 分開)。
g.POST("/devices/:id/register", devicesRegisterHandler(deps))
g.POST("/devices/:id/unregister", devicesUnregisterHandler(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 的;為空不致命)。
// 用獨立 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-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
}
// 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 → 發 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 必非空。
//
// 可觀測性R-3 離線誤判排查,見 .autoflow/05-implementation/r3-offline-misjudge-rootcause.md
// list 與 detail 都呼叫此函式,但 detail 曾用較緊的 ctx timeout 導致 store.List 逾時被靜默
// 判離線。加 log 以在 stage 重現時分辨兩個嫌疑:
// - 嫌疑 1store.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 {
// 嫌疑 1List 逾時 / 報錯 → 靜默判離線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 UserIDM2 待人工介入)。
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{}
}