jim800121chen 4d0b870480 feat(visionA-backend): DB 接入 — 6 store 接 PostgreSQL/Redis 持久化(塊 0-5)
把 visionA-backend 6 個 in-memory store 接到資料庫持久化,範圍=完整
(PG 全接 + session 接 Redis + 交易韌性)。interface / handler 不動,
只加 DB 實作 + 換 wiring,config 未設 DB 時保留 in-memory fallback。

- 塊 0 基礎建設:pgx/v5 連線池 + DatabaseConfig/RedisConfig + golang-migrate
  runner(embed)+ cmd/migrate + testcontainers 測試基礎建設
- 塊 1 model → Postgres:array 映射、upsert 保留 CreatedAt、faa_object_key、
  三維 filter(owner/chip/source)、soft-delete partial index
- 塊 2 device → Postgres:partial unique(已刪 serial 可重註冊)、雙狀態欄位
- 塊 3 token → Postgres:pairing_tokens + session_tokens 分表、token_hash 當 PK
- 塊 4 userSession → Redis:idle + absolute 雙 TTL 取代 cleanup goroutine
  (tunnel session 維持 in-memory,yamux handle 不可序列化)
- 塊 5 交易/韌性:WithTx helper + 刪 device cascade 撤銷 token(同 tx 原子)
  + /healthz ping PG/Redis(fail-fast 503)+ pgx error 統一映射(不洩漏 raw error)

降級策略(fail-fast):PG 掉 → 持久資料 API 回 503;Redis 掉 → session 失敗
不自動 fallback in-memory(避免多機 session 不同步)。

DB:PostgreSQL 14.23(gen_random_uuid 內建、無 citext → email 用 lower() unique
index)。每塊經 Reviewer 審查 + 真 PG/Redis testcontainers 全量 dbtest 綠燈,
in-memory fallback 未受影響。

docs: 同步更新 database.md(schema/config/migration 清單)+ api-spec.md
(409/503 錯誤碼、/healthz 新行為、device unpair cascade)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 18:28:04 +08:00

314 lines
10 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"`
// 狀態
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,
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 → 發 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{}
}