- 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>
534 lines
23 KiB
Go
534 lines
23 KiB
Go
// pairing_exchange.go — pairing exchange 建 agent + representative device + N 顆真 USB device
|
||
// 的協調者(A' 模型;ADR-018 / migration 0005,WP-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 device(is_representative=true、serial=NULL),
|
||
// session_tokens.device_id 綁它(維持現行「一條 tunnel 綁一個 device_id」的物理語意,
|
||
// 但改綁「agent 代表 device」而非隨機佔位——session_tokens schema 一字不動)。
|
||
// 3. 對 agent 上報清單的每顆可用序號 USB,建/復用一筆真 USB device(is_representative=false、
|
||
// agent_id=同一 agent、填 serial)——R1 完整 N 顆(非只第一顆)。
|
||
//
|
||
// 為什麼抽成 coordinator(比照 unpair.go 的 DeviceUnpairer):
|
||
// - 讓 handler(pairing.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 device(A' 語意
|
||
// 「一 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 衍生(見 deriveDeviceName,S-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 ids(is_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)。
|
||
//
|
||
// Firmware(S-1 forward-compat):agent 上報韌體版本字串,目前 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-compat:WP-B 尚未消費(見上方說明)
|
||
}
|
||
|
||
// fakeSerialNumber 是 agent 端 pyusb fallback(無 Kneron SDK,如 macOS 缺 dylib)
|
||
// 寫死上報的假序號。多顆無 SDK 裝置會撞同一個值,不可當唯一鍵 / 路由鍵——
|
||
// 視同「無序號」寫 NULL(ADR-018 §2.2 / task-1 mapping R2)。
|
||
const fakeSerialNumber = "0x00000000"
|
||
|
||
// serialPattern 是 Kneron kn_number 的合法格式白名單(Mi#5):0x 前綴 + 8 位十六進位。
|
||
//
|
||
// 為什麼加白名單:serial 是跨層路由鍵(雲端 device ↔ local agent sessions),亂格式序號
|
||
// 會污染路由與去重。不符此格式者視同「無序號」(寫 NULL、不進去重、不路由),與假序號同處理。
|
||
// 攻擊面受限(需持有效一次性 pairing token 才能觸發 exchange),reviewer 已判不升級 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;任一步失敗回 error(handler 經 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)。
|
||
// - SaveTx:upsert device(representative / 真 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 的 agent(GetOrCreateAgentTx,advisory lock 序列化同 owner)。
|
||
// 2. 建/復用該 agent 的 representative device(is_representative=true、serial=NULL)。
|
||
// 3. 建綁 representative device 的 session token(session_tokens.device_id = representative.id)。
|
||
// 4. 對每顆可用序號 USB,建/復用一筆真 USB device(is_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 device(tx 內)。
|
||
//
|
||
// 復用(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 留空 → NULL(representative 非真 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 device(tx 內,掛指定 agent)。
|
||
//
|
||
// 復用(GetBySerialTx 命中同 owner 同 serial 的未刪除 device):只更新 PairedAt + agent 關聯
|
||
// + device type/name(不全欄覆寫,Mi#2;R4 同序號重配 = 復用)。新建: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 外,額外需要 GetRepresentativeByAgentTx(concrete method)。
|
||
// 傳 nil Querier(in-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,
|
||
}
|
||
}
|
||
|
||
// Provision(in-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 device(in-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 device(in-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)
|
||
}
|
||
}
|