visionA/visionA-backend/internal/api/device_register.go
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

217 lines
8.2 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.

// device_register.go — POST /api/devices/:id/register 與 /unregister 的 handler。
//
// 「註冊」語意軸feature-device-mgmt-tdd §3 / §4api/api-device-mgmt.md
// - register把 device 的 registered_at 由 NULL 翻成 now()(未註冊 → 已註冊)。
// - unregister把 registered_at 清成 NULL退回未註冊**保留裝置列**。
//
// 🔴 與 unpair 完全不同TDD §1 紅線unpair 軟刪整台 + cascade 撤 tokendevice 從清單
// 消失unregister 只清單欄 registered_atdevice 仍在清單、顯示為未註冊)。兩端點各走各的,
// 本檔**絕不呼叫** DeviceUnpairer / Delete / 撤 token也**不改** devicesUnpairHandler。
//
// 皆為純雲端 DB 操作(只翻 registered_at、不路由 local agent用 UUID `:id` 識別
// (對齊 ADR-018 FE-ADB 操作用 UUID、路由操作才用 serial
package api
import (
"context"
"errors"
"net/http"
"time"
"github.com/gin-gonic/gin"
"visiona-backend/internal/device"
)
// deviceRegisterCommon 執行 register / unregister 共用的前置檢查(步驟 1-5兩端點一致
//
// 1. 缺 UserContext → 500auth middleware 沒配好,不可 fallthrough
// 2. :id 空 → 400 VALIDATION_FAILED
// 3. Get deviceErrNotFound → 404其他 DB error → WriteDBError
// 4. owner 檢查IDOR 主防線d.OwnerUserID != userID → 403 FORBIDDEN
// 5. representative 檢查d.IsRepresentative → 409 REPRESENTATIVE_DEVICE
//
// 回傳 (device, userID, ok)ok=false 時已寫好回應caller 直接 return。
//
// owner 檢查對 register/unregister 都必做——不能因「只是翻 flag」省略TDD §7.2 IDOR
func deviceRegisterCommon(c *gin.Context, deps Deps, ctx context.Context) (*device.Device, string, bool) {
id := c.Param("id")
if id == "" {
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "device id required", nil)
return nil, "", false
}
// Phase 0.7 security fix C1強制要求 UserContext 非空(見既有 devices.go 範式)。
uc, ok := UserContextFrom(c)
if !ok || uc.UserID == "" {
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
"missing user context (auth middleware misconfigured?)", nil)
return nil, "", false
}
userID := uc.UserID
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 nil, "", false
}
// DB 錯誤經 errors.go 映射PG down → 503其餘 → 500不洩漏 raw DB error。
WriteDBError(c, deps.Logger, "get device", err)
return nil, "", false
}
// owner 檢查IDOR 主防線TDD §7.1/§7.2):沿用既有 handler 慣例devices.go:197-201
if d.OwnerUserID != userID {
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner of this device", nil)
return nil, "", false
}
// representative 檢查TDD §7.3representative 是 agent 連線佔位、非真 USB
// 註冊語意不適用。縱深——即使 List 已濾掉 representative前端拿不到其 UUID
// handler 仍自己擋repo SetRegistered 的 WHERE 帶 is_representative=false 為第三層。
if d.IsRepresentative {
WriteError(c, http.StatusConflict, ErrCodeRepresentativeDevice,
"representative device cannot be registered", nil)
return nil, "", false
}
return d, userID, true
}
// devicesRegisterHandler 實作 POST /api/devices/:id/register。
//
// 行為順序api-device-mgmt.md §1共用前置1-5→ 已註冊檢查6409 ALREADY_REGISTERED
// → SetRegistered(now())7→ 200 + 更新後 DeviceListItemregistered_at 非 null
func devicesRegisterHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
if deps.DeviceRepo == nil {
WriteNotImplemented(c, "device repo not configured")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
d, userID, ok := deviceRegisterCommon(c, deps, ctx)
if !ok {
return
}
// 已註冊檢查TDD §3.2registered_at 非 nil → 409 ALREADY_REGISTERED。
// 前端據此顯示「此裝置已註冊」並 refetch。
if d.RegisteredAt != nil {
WriteError(c, http.StatusConflict, ErrCodeAlreadyRegistered,
"device already registered", nil)
return
}
now := time.Now().UTC()
if err := deps.DeviceRepo.SetRegistered(ctx, d.ID, &now); err != nil {
if errors.Is(err, device.ErrNotFound) {
// 競態Get 之後、SetRegistered 之前 device 被軟刪 / 轉 representative。
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
return
}
WriteDBError(c, deps.Logger, "register device", err)
return
}
logOrDefault(deps.Logger).Info("devices: registered",
"device_id", d.ID,
"user_id", userID,
"request_id", RequestIDFrom(c))
writeDeviceItemAfterRegister(c, deps, ctx, d.ID, userID)
}
}
// devicesUnregisterHandler 實作 POST /api/devices/:id/unregister退回未註冊
//
// 行為順序api-device-mgmt.md §2共用前置1-5→ SetRegistered(nil)(冪等,未註冊也回 200
// → 200 + 更新後 DeviceListItemregistered_at=null
//
// 🔴 絕不軟刪、不呼叫 DeviceUnpairer、不撤 token、不動 sessionTDD §1.2)。與 unpair 各走各的。
func devicesUnregisterHandler(deps Deps) gin.HandlerFunc {
return func(c *gin.Context) {
if deps.DeviceRepo == nil {
WriteNotImplemented(c, "device repo not configured")
return
}
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer cancel()
d, userID, ok := deviceRegisterCommon(c, deps, ctx)
if !ok {
return
}
// 冪等TDD §4.1 步驟 2不做「已註冊才可取消」的硬擋。SetRegistered(nil) 對已 NULL
// 的列 UPDATE 到相同值、RowsAffected 仍為 1WHERE 命中),避免使用者連點兩次第二次報錯。
if err := deps.DeviceRepo.SetRegistered(ctx, d.ID, nil); err != nil {
if errors.Is(err, device.ErrNotFound) {
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
return
}
WriteDBError(c, deps.Logger, "unregister device", err)
return
}
logOrDefault(deps.Logger).Info("devices: unregistered",
"device_id", d.ID,
"user_id", userID,
"request_id", RequestIDFrom(c))
writeDeviceItemAfterRegister(c, deps, ctx, d.ID, userID)
}
}
// writeDeviceItemAfterRegister 重新 Get device 並回 200 + 更新後 DeviceListItem。
//
// 為什麼重新 Get 而非就地拼裝SetRegistered 只回 error最新的 registered_at / updated_at
// 以 DB 為準最不易出錯(避免手動拼裝與 DB 值漂移)。合併 tunnel 狀態沿用既有 list/get 範式。
//
// register/unregister 後 device 必然存在(剛剛才 UPDATE 成功Get 理論上不會 NotFound
// 若極端競態下被刪,回 404不 panic
func writeDeviceItemAfterRegister(c *gin.Context, deps Deps, ctx context.Context, id, userID string) {
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
}
WriteDBError(c, deps.Logger, "get device after register", err)
return
}
// tunnel 狀態合併:獨立 ctx 給 3s 預算(對齊 list/get避免被前面 DB 呼叫吃掉 → R-3 誤判)。
tunnelCtx, tunnelCancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
defer tunnelCancel()
tunnelAlive, lastSeen := resolveTunnelStatus(
tunnelCtx, deps.SessionStore, userID, deps.Logger, "register", 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
}
WriteSuccess(c, http.StatusOK, item)
}