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

534 lines
23 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.

// pairing_exchange.go — pairing exchange 建 agent + representative device + N 顆真 USB device
// 的協調者A' 模型ADR-018 / migration 0005WP-B B3
//
// 背景DB-on FK 收尾 #2 → A' 重塑):
//
// session_tokens.device_id 是 NOT NULL FK → devices(id)。exchange 驗完 pairing token 後需建
// 一筆 device 供 session token 綁定。原雛形是「每次 exchange 自建一筆佔位 device」A' 模型
// ADR-018 走向 A')把這件事重塑為:
// 1. 建/復用該 owner 的 agent一 agent = 一條 tunnel 連線agents 表migration 0005
// 2. 建/復用該 agent 的 representative deviceis_representative=true、serial=NULL
// session_tokens.device_id 綁它(維持現行「一條 tunnel 綁一個 device_id」的物理語意
// 但改綁「agent 代表 device」而非隨機佔位——session_tokens schema 一字不動)。
// 3. 對 agent 上報清單的每顆可用序號 USB建/復用一筆真 USB deviceis_representative=false、
// agent_id=同一 agent、填 serial——R1 完整 N 顆(非只第一顆)。
//
// 為什麼抽成 coordinator比照 unpair.go 的 DeviceUnpairer
// - 讓 handlerpairing.go 的 exchange維持薄。
// - Postgres 後端用 db.WithTx 把「建 agent + representative + N USB + session token」包成
// 單一交易——任一步失敗整筆 rollback杜絕中間態database.md §6 一致性精神)。
// - in-memory 後端依序執行(無交易),行為一致。
// - main.go 依 dbPool 是否非 nil 擇一注入 Deps.PairingExchanger。為 nil 時 exchange handler
// fallback 到「不自建 device、直接用 info.DeviceID可能為空建 session token」的舊行為
// (與 DB-off 雛形相容in-memory store 不檢查 FK空 deviceID 可接受)。
//
// 冪等pairing token 是一次性MarkUsed 後 Validate 回 ErrTokenUsed故同一 token 不會被
// exchange 兩次成功。同 owner 多次配對復用同一 agent + 同一 representative deviceA' 語意
// 「一 agent 一 representative」各建一個新 session token 綁該 representative。真 USB device
// 依 serial 去重復用GetBySerial。重試exchange 後 MarkUsed 失敗被 abort時 session token
// 已 revoke、agent/device 已建但無新 token 指向它(無安全風險,僅閒置紀錄;雛形可接受)。
package api
import (
"context"
"errors"
"fmt"
"log/slog"
"regexp"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"visiona-backend/internal/agent"
"visiona-backend/internal/auth"
"visiona-backend/internal/db"
"visiona-backend/internal/device"
)
// representativeDeviceName / representativeDeviceType 是 agent 的 representative device 預設值。
//
// representative device 代表「這條 tunnel 連線」本身(非任何一顆實體 USB綁 session_tokens。
// 真 USB device 的 Name 由 DeviceType/serial 衍生(見 deriveDeviceNameS-2不再共用此值。
const (
representativeDeviceName = "local-agent (paired)"
representativeDeviceType = "local-agent"
)
// ExchangeProvisionResult 回報 exchange 建 agent + representative + N USB + session token 的結果。
type ExchangeProvisionResult struct {
// DeviceID 是 session token 綁定的 device id——A' 下為該 agent 的 representative device
// is_representative=true。維持 session_tokens.device_id 綁一個 device 的物理語意。
DeviceID string
AgentID string // 本次建/復用的 agent id
USBDeviceIDs []string // 本次建/復用的真 USB device idsis_representative=false可為空
SessionPlaintext string // 新 session token 原文caller 只此一次能拿到)
SessionInfo *auth.SessionToken // session token 儲存層表示(含 ExpiresAt
}
// ExchangeDeviceInput 是 exchange payload 中 agent 上報的單顆實體 USB 裝置
// WP-0 / ADR-018 序號地基,對齊 agent 端 tunnel.exchangeDevice 的 JSON
//
// FirmwareS-1 forward-compatagent 上報韌體版本字串,目前 devices 表無 firmware 欄
// migration 0005 未建),故 WP-B 尚未消費——收下但不落 DB。未來若要顯示/記錄韌體版本,
// 需另開 migration 加 devices.firmware 欄 + 在此 struct → Device 的映射補上(超出 WP-B 範圍)。
// 保留此欄位讓 agent 端 payload 契約穩定、未來加欄時 agent 不需改。
type ExchangeDeviceInput struct {
SerialNumber string `json:"serial_number"`
DeviceType string `json:"device_type,omitempty"`
Firmware string `json:"firmware,omitempty"` // forward-compatWP-B 尚未消費(見上方說明)
}
// fakeSerialNumber 是 agent 端 pyusb fallback無 Kneron SDK如 macOS 缺 dylib
// 寫死上報的假序號。多顆無 SDK 裝置會撞同一個值,不可當唯一鍵 / 路由鍵——
// 視同「無序號」寫 NULLADR-018 §2.2 / task-1 mapping R2
const fakeSerialNumber = "0x00000000"
// serialPattern 是 Kneron kn_number 的合法格式白名單Mi#50x 前綴 + 8 位十六進位。
//
// 為什麼加白名單serial 是跨層路由鍵(雲端 device ↔ local agent sessions亂格式序號
// 會污染路由與去重。不符此格式者視同「無序號」(寫 NULL、不進去重、不路由與假序號同處理。
// 攻擊面受限(需持有效一次性 pairing token 才能觸發 exchangereviewer 已判不升級 Security。
var serialPattern = regexp.MustCompile(`^0x[0-9A-Fa-f]{8}$`)
// normalizeSerial 對上報序號做 trim + 白名單驗證。回傳 (正規化序號, 是否可用)。
//
// 不可用(空 / 假序號 / 不符白名單)→ 回 ("", false),呼叫端視同無序號。
func normalizeSerial(raw string) (string, bool) {
s := strings.TrimSpace(raw)
if s == "" || strings.EqualFold(s, fakeSerialNumber) {
return "", false
}
if !serialPattern.MatchString(s) {
return "", false
}
return s, true
}
// usableSerialDevices 從 agent 上報清單挑出所有「序號可用」的裝置R1完整 N 顆)。
//
// A' 模型WP-B B3一 agent 底下 N 顆實體 USB 各建一筆真 device。此函式過濾出可用序號者、
// 正規化序號、並對同一序號去重(同一次 exchange 上報重複序號只取第一顆,避免同 tx 內撞
// partial unique。空序號 / 假序號 / 不符白名單者跳過。
func usableSerialDevices(devices []ExchangeDeviceInput) []ExchangeDeviceInput {
out := make([]ExchangeDeviceInput, 0, len(devices))
seen := make(map[string]struct{}, len(devices))
for _, d := range devices {
serial, ok := normalizeSerial(d.SerialNumber)
if !ok {
continue
}
key := strings.ToLower(serial) // 去重用大小寫不敏感(對齊 GetBySerial 的實體語意)
if _, dup := seen[key]; dup {
continue
}
seen[key] = struct{}{}
d.SerialNumber = serial
out = append(out, d)
}
return out
}
// deriveDeviceName 由 agent 上報的 device type + serial 衍生真 USB device 的顯示名稱S-2
//
// 取代舊的固定 "local-tool (paired)":真 USB device 應能從名稱看出是哪顆晶片/序號,
// 如 "kneron_kl520 (0x1A2B3C4D)"。deviceType 空時退回 "USB device"。
func deriveDeviceName(deviceType, serial string) string {
dt := strings.TrimSpace(deviceType)
if dt == "" {
dt = "USB device"
}
if serial != "" {
return fmt.Sprintf("%s (%s)", dt, serial)
}
return dt
}
// PairingExchanger 把「建 agent + representative device + N 顆真 USB device + 建 session token」
// 包成一個原子Postgres tx或一致in-memory 依序操作A' 模型WP-B B3
//
// Provision 語意:成功回 ExchangeProvisionResult任一步失敗回 errorhandler 經 errors.go
// 映射成 5xx不洩漏 raw error
type PairingExchanger interface {
// Provision 建/復用該 owner 的 agent + 該 agent 的 representative device並建一筆綁
// representative device 的 session token再對 agent 上報清單的每顆可用序號 USB 建/復用
// 一筆真 USB device掛同一 agent
//
// parentTokenHash 為來源 pairing token 的 hash稽核鏈寫進 session_tokens.parent_token_hash
//
// devices 為 agent 上報的實體 USB 清單WP-0 序號地基,可為 nil = 舊 agent /
// 撈不到清單,此時只建 agent + representative + session token不建真 USB device
// 可用序號者(通過白名單):同 owner 已有同 serial 的未刪除 device → 復用(防
// uq_devices_owner_serial_active 23505「同序號重配 = 復用」R4否則新建並填 serial。
Provision(ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput) (ExchangeProvisionResult, error)
}
// ── Postgres 後端 ─────────────────────────────────────────────────────────────
// pgDeviceSaver 是 device 在 tx 內 upsert / 查詢的能力(由 device.PostgresRepository 滿足)。
//
// 全部走 tx 版Querier讓「查既有 → 復用/建」與 session token 在同一交易內序列化
// Mi#2 lost-update 收斂):
// - GetBySerialTx查同 owner 同 serial 的未刪除真 USB device去重復用R4
// - GetRepresentativeByAgentTx查該 agent 既有 representative device一 agent 一 representative
// - SaveTxupsert devicerepresentative / 真 USB 共用)。
type pgDeviceSaver interface {
SaveTx(ctx context.Context, q db.Querier, d *device.Device) error
GetBySerialTx(ctx context.Context, q db.Querier, ownerUserID, serial string) (*device.Device, error)
GetRepresentativeByAgentTx(ctx context.Context, q db.Querier, agentID string) (*device.Device, error)
}
// pgAgentProvisioner 是「在 tx 內建/復用 agent」的能力由 agent.PostgresRepository 滿足)。
type pgAgentProvisioner interface {
GetOrCreateAgentTx(ctx context.Context, q db.Querier, ownerUserID, name, platform, agentVersion string, pairedAt time.Time) (*agent.Agent, error)
}
// pgSessionTokenCreator 是「在 tx 內建 session token」的能力由 auth.PostgresSessionTokenStore 滿足)。
type pgSessionTokenCreator interface {
CreateTx(ctx context.Context, q db.Querier, userID, deviceID, parentTokenHash string, ttl time.Duration) (string, *auth.SessionToken, error)
}
// pgPairingExchanger 用單一 pgx 交易完成 A' 的 exchange 重塑。
type pgPairingExchanger struct {
pool *pgxpool.Pool
agents pgAgentProvisioner
devices pgDeviceSaver
sessionToken pgSessionTokenCreator
log *slog.Logger
}
// NewPostgresPairingExchanger 建立 Postgres 後端的 exchange 協調者。
func NewPostgresPairingExchanger(
pool *pgxpool.Pool,
agents pgAgentProvisioner,
devices pgDeviceSaver,
sessionToken pgSessionTokenCreator,
log *slog.Logger,
) PairingExchanger {
return &pgPairingExchanger{
pool: pool,
agents: agents,
devices: devices,
sessionToken: sessionToken,
log: logOrDefault(log),
}
}
// Provision 在單一交易內完成 A' exchange 重塑:
//
// 1. 建/復用該 owner 的 agentGetOrCreateAgentTxadvisory lock 序列化同 owner
// 2. 建/復用該 agent 的 representative deviceis_representative=true、serial=NULL
// 3. 建綁 representative device 的 session tokensession_tokens.device_id = representative.id
// 4. 對每顆可用序號 USB建/復用一筆真 USB deviceis_representative=false、掛同一 agent
//
// 任一步失敗整筆 rollback不留半建的 agent / device / token
//
// Mi#2 lost-update 收斂所有查詢GetRepresentativeByAgentTx / GetBySerialTx都在 tx 內、
// 復用路徑只更新必要欄位PairedAt / serial / agent 關聯),不全欄覆寫。
func (e *pgPairingExchanger) Provision(
ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput,
) (ExchangeProvisionResult, error) {
var res ExchangeProvisionResult
now := time.Now().UTC()
usable := usableSerialDevices(devices)
err := db.WithTx(ctx, e.pool, func(q db.Querier) error {
// 1) 建/復用 agent。platform / agentVersion 目前 agent 上報 payload 未帶forward-compat
// 先傳空——GetOrCreateAgentTx 空值不覆寫既有。
ag, agErr := e.agents.GetOrCreateAgentTx(ctx, q, userID, "", "", "", now)
if agErr != nil {
return fmt.Errorf("exchange: get-or-create agent: %w", agErr)
}
res.AgentID = ag.ID
// 2) 建/復用該 agent 的 representative device一 agent 一 representative
rep, repErr := e.representativeForAgentTx(ctx, q, userID, ag.ID, now)
if repErr != nil {
return fmt.Errorf("exchange: representative device: %w", repErr)
}
res.DeviceID = rep.ID
// 3) 建綁 representative device 的 session token。
plaintext, info, createErr := e.sessionToken.CreateTx(ctx, q, userID, rep.ID, parentTokenHash, ttl)
if createErr != nil {
return fmt.Errorf("exchange: create session token: %w", createErr)
}
res.SessionPlaintext = plaintext
res.SessionInfo = info
// 4) 每顆可用序號 USB建/復用真 USB device掛同一 agent
usbIDs := make([]string, 0, len(usable))
for _, in := range usable {
usbDev, uErr := e.upsertUSBDeviceTx(ctx, q, userID, ag.ID, in, now)
if uErr != nil {
return fmt.Errorf("exchange: upsert usb device (serial=%s): %w", in.SerialNumber, uErr)
}
usbIDs = append(usbIDs, usbDev.ID)
}
res.USBDeviceIDs = usbIDs
return nil
})
if err != nil {
return ExchangeProvisionResult{}, err
}
return res, nil
}
// representativeForAgentTx 建/復用某 agent 的 representative devicetx 內)。
//
// 復用GetRepresentativeByAgentTx 命中):只更新 PairedAt不全欄覆寫Mi#2
// 新建is_representative=true、serial=NULL、掛 agent_id。
func (e *pgPairingExchanger) representativeForAgentTx(
ctx context.Context, q db.Querier, userID, agentID string, now time.Time,
) (*device.Device, error) {
existing, gErr := e.devices.GetRepresentativeByAgentTx(ctx, q, agentID)
switch {
case gErr == nil:
// 復用:只更新配對時間(保留既有欄位)。
existing.PairedAt = &now
existing.UpdatedAt = now
if saveErr := e.devices.SaveTx(ctx, q, existing); saveErr != nil {
return nil, fmt.Errorf("save representative: %w", saveErr)
}
return existing, nil
case errors.Is(gErr, device.ErrNotFound):
rep := &device.Device{
ID: uuid.NewString(),
OwnerUserID: userID,
Name: representativeDeviceName,
DeviceType: representativeDeviceType,
AgentID: agentID,
IsRepresentative: true,
// serial_number 留空 → NULLrepresentative 非真 USB不佔 partial unique
RemoteStatus: device.RemoteStatusOffline,
Status: device.USBStatusUnknown,
PairedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
if saveErr := e.devices.SaveTx(ctx, q, rep); saveErr != nil {
return nil, fmt.Errorf("save representative: %w", saveErr)
}
return rep, nil
default:
return nil, fmt.Errorf("get representative: %w", gErr)
}
}
// upsertUSBDeviceTx 建/復用一顆真 USB devicetx 內,掛指定 agent
//
// 復用GetBySerialTx 命中同 owner 同 serial 的未刪除 device只更新 PairedAt + agent 關聯
// + device type/name不全欄覆寫Mi#2R4 同序號重配 = 復用。新建is_representative=false、
// 填 serial、Name 由 DeviceType 衍生S-2
func (e *pgPairingExchanger) upsertUSBDeviceTx(
ctx context.Context, q db.Querier, userID, agentID string, in ExchangeDeviceInput, now time.Time,
) (*device.Device, error) {
existing, gErr := e.devices.GetBySerialTx(ctx, q, userID, in.SerialNumber)
switch {
case gErr == nil:
// 復用:更新配對時間 + agent 關聯 + 上報的 device type非空時Name 隨 type 衍生。
existing.PairedAt = &now
existing.UpdatedAt = now
existing.AgentID = agentID
if in.DeviceType != "" {
existing.DeviceType = in.DeviceType
existing.Name = deriveDeviceName(in.DeviceType, existing.SerialNumber)
}
if saveErr := e.devices.SaveTx(ctx, q, existing); saveErr != nil {
return nil, fmt.Errorf("save usb device: %w", saveErr)
}
return existing, nil
case errors.Is(gErr, device.ErrNotFound):
usbDev := &device.Device{
ID: uuid.NewString(),
OwnerUserID: userID,
Name: deriveDeviceName(in.DeviceType, in.SerialNumber),
DeviceType: in.DeviceType,
SerialNumber: in.SerialNumber,
AgentID: agentID,
IsRepresentative: false,
RemoteStatus: device.RemoteStatusOffline,
Status: device.USBStatusUnknown,
PairedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
if saveErr := e.devices.SaveTx(ctx, q, usbDev); saveErr != nil {
return nil, fmt.Errorf("save usb device: %w", saveErr)
}
return usbDev, nil
default:
return nil, fmt.Errorf("get usb device by serial: %w", gErr)
}
}
// ── in-memory 後端 ────────────────────────────────────────────────────────────
// memSessionTokenCreator 是 in-memory store「建 session token」的能力
// (由 auth.InMemorySessionTokenStore 透過 SessionTokenStore interface 滿足)。
type memSessionTokenCreator interface {
Create(ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration) (string, *auth.SessionToken, error)
}
// memDeviceRepo 是 in-memory device store 供 exchange 用的能力(由 *device.InMemoryRepository 滿足)。
//
// 除 Repository 的 Save/GetBySerial 外,額外需要 GetRepresentativeByAgentTxconcrete method
// 傳 nil Querierin-memory 無交易)。
type memDeviceRepo interface {
Save(ctx context.Context, d *device.Device) error
GetBySerial(ctx context.Context, ownerUserID, serial string) (*device.Device, error)
GetRepresentativeByAgentTx(ctx context.Context, q db.Querier, agentID string) (*device.Device, error)
}
// memAgentRepo 是 in-memory agent store 供 exchange 用的能力(由 *agent.InMemoryRepository 滿足)。
type memAgentRepo interface {
GetOrCreateAgentTx(ctx context.Context, q db.Querier, ownerUserID, name, platform, agentVersion string, pairedAt time.Time) (*agent.Agent, error)
}
// memPairingExchanger 依序(非交易)完成 A' exchange 重塑。
//
// in-memory 為單機 local-dev fallback無跨 store 交易需求;依序執行已能保證行為一致。
// 行為與 pgPairingExchanger 對齊:建/復用 agent → representative device綁 session token
// → N 顆真 USB device。
type memPairingExchanger struct {
agents memAgentRepo
devices memDeviceRepo
sessionToken memSessionTokenCreator
}
// NewInMemoryPairingExchanger 建立 in-memory 後端的 exchange 協調者。
func NewInMemoryPairingExchanger(
agents memAgentRepo,
devices memDeviceRepo,
sessionToken memSessionTokenCreator,
) PairingExchanger {
return &memPairingExchanger{
agents: agents,
devices: devices,
sessionToken: sessionToken,
}
}
// Provisionin-memory建/復用 agent → representative device綁 session token→ N USB。
// 行為與 pgPairingExchanger.Provision 對齊(無交易,依序執行)。
func (e *memPairingExchanger) Provision(
ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput,
) (ExchangeProvisionResult, error) {
var res ExchangeProvisionResult
now := time.Now().UTC()
usable := usableSerialDevices(devices)
// 1) 建/復用 agent。
ag, agErr := e.agents.GetOrCreateAgentTx(ctx, nil, userID, "", "", "", now)
if agErr != nil {
return ExchangeProvisionResult{}, fmt.Errorf("exchange: get-or-create agent: %w", agErr)
}
res.AgentID = ag.ID
// 2) 建/復用 representative device。
rep, repErr := e.representativeForAgent(ctx, userID, ag.ID, now)
if repErr != nil {
return ExchangeProvisionResult{}, fmt.Errorf("exchange: representative device: %w", repErr)
}
res.DeviceID = rep.ID
// 3) 建綁 representative device 的 session token。
plaintext, info, err := e.sessionToken.Create(ctx, userID, rep.ID, parentTokenHash, ttl)
if err != nil {
return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err)
}
res.SessionPlaintext = plaintext
res.SessionInfo = info
// 4) 每顆可用序號 USB。
usbIDs := make([]string, 0, len(usable))
for _, in := range usable {
usbDev, uErr := e.upsertUSBDevice(ctx, userID, ag.ID, in, now)
if uErr != nil {
return ExchangeProvisionResult{}, fmt.Errorf("exchange: upsert usb device (serial=%s): %w", in.SerialNumber, uErr)
}
usbIDs = append(usbIDs, usbDev.ID)
}
res.USBDeviceIDs = usbIDs
return res, nil
}
// representativeForAgent 建/復用某 agent 的 representative devicein-memory對齊 pg 版)。
func (e *memPairingExchanger) representativeForAgent(
ctx context.Context, userID, agentID string, now time.Time,
) (*device.Device, error) {
existing, gErr := e.devices.GetRepresentativeByAgentTx(ctx, nil, agentID)
switch {
case gErr == nil:
existing.PairedAt = &now
existing.UpdatedAt = now
if saveErr := e.devices.Save(ctx, existing); saveErr != nil {
return nil, fmt.Errorf("save representative: %w", saveErr)
}
return existing, nil
case errors.Is(gErr, device.ErrNotFound):
rep := &device.Device{
ID: uuid.NewString(),
OwnerUserID: userID,
Name: representativeDeviceName,
DeviceType: representativeDeviceType,
AgentID: agentID,
IsRepresentative: true,
RemoteStatus: device.RemoteStatusOffline,
Status: device.USBStatusUnknown,
PairedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
if saveErr := e.devices.Save(ctx, rep); saveErr != nil {
return nil, fmt.Errorf("save representative: %w", saveErr)
}
return rep, nil
default:
return nil, fmt.Errorf("get representative: %w", gErr)
}
}
// upsertUSBDevice 建/復用一顆真 USB devicein-memory對齊 pg 版)。
func (e *memPairingExchanger) upsertUSBDevice(
ctx context.Context, userID, agentID string, in ExchangeDeviceInput, now time.Time,
) (*device.Device, error) {
existing, gErr := e.devices.GetBySerial(ctx, userID, in.SerialNumber)
switch {
case gErr == nil:
existing.PairedAt = &now
existing.UpdatedAt = now
existing.AgentID = agentID
if in.DeviceType != "" {
existing.DeviceType = in.DeviceType
existing.Name = deriveDeviceName(in.DeviceType, existing.SerialNumber)
}
if saveErr := e.devices.Save(ctx, existing); saveErr != nil {
return nil, fmt.Errorf("save usb device: %w", saveErr)
}
return existing, nil
case errors.Is(gErr, device.ErrNotFound):
usbDev := &device.Device{
ID: uuid.NewString(),
OwnerUserID: userID,
Name: deriveDeviceName(in.DeviceType, in.SerialNumber),
DeviceType: in.DeviceType,
SerialNumber: in.SerialNumber,
AgentID: agentID,
IsRepresentative: false,
RemoteStatus: device.RemoteStatusOffline,
Status: device.USBStatusUnknown,
PairedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
if saveErr := e.devices.Save(ctx, usbDev); saveErr != nil {
return nil, fmt.Errorf("save usb device: %w", saveErr)
}
return usbDev, nil
default:
return nil, fmt.Errorf("get usb device by serial: %w", gErr)
}
}