diff --git a/docs/autoflow/04-architecture/api/api-spec.md b/docs/autoflow/04-architecture/api/api-spec.md index 0bdcece..ee0e47d 100644 --- a/docs/autoflow/04-architecture/api/api-spec.md +++ b/docs/autoflow/04-architecture/api/api-spec.md @@ -47,6 +47,46 @@ } ``` +### POST `/api/pairing/exchange`(agent → 雲端;public、不走 AuthMiddleware) + +> 呼叫方是 **local agent**(不是瀏覽器前端):agent 拿 Pairing Token 換 Session Token 時本身還沒有登入身份,故此端點註冊在 engine 層級、不套 auth。行為主文件見 `../visiona-agent-tdd.md` §4.3 / §7.1;本節記錄 contract 形狀與 WP-0(ADR-018)擴充。 + +- **Request**(實作對齊 `visionA-backend/internal/api/pairing.go` `PairingExchangeRequest` 與 agent 端 `internal/tunnel/pairing.go` `exchangeRequest`): + ```json + { + "pairing_token": "vAc_<32 hex>", + "devices": [ + { + "serial_number": "0x1A2B3C4D", + "device_type": "KL520", + "firmware": "1.2.3" + } + ] + } + ``` + - `pairing_token`(**required**):一次性 Pairing Token。 + - `devices`(**optional**、WP-0 / migration 0004 新增):agent 在 exchange 前撈本地 `GET /api/devices` 上報的實體 USB 清單(含 Kneron kn_number 序號)。陣列因應「一 agent 多 USB」;`device_type` / `firmware` 為 optional 欄位(`omitempty`)。 +- **Response**(200,通用 envelope 的 `data`): + ```json + { + "session_token": "vAs_...", + "account": "demo@visionA.local", + "relay_url": "wss://relay.visionA.cloud", + "expires_at": "2026-07-21T00:00:00Z" + } + ``` +- **錯誤碼**(走 `error.code`,對齊 TDD §7.1 四種 case):`INVALID_PAIRING_TOKEN` / `PAIRING_TOKEN_EXPIRED` / `PAIRING_TOKEN_USED` / `PAIRING_TOKEN_REVOKED`。 + +**`devices` 欄位的雲端行為(WP-0 序號地基,決策見 `../adr/adr-018-agent-device-model.md`)**: + +| 規則 | 行為 | +|------|------| +| R1(最小落地) | exchange 仍只落**一筆** device 記錄:從上報清單挑**第一顆序號可用**的裝置,把序號填進 `devices.serial_number`(agent 有帶 `device_type` 時一併填入)。多顆 USB 的完整模型(一 agent N device)屬 WP-B / migration 0005 範疇 | +| R2(假序號) | 假序號 `0x00000000`(agent 端 pyusb fallback、無 Kneron SDK 時寫死上報)與空字串序號**視同無序號**、跳過不用 → `serial_number` 寫 NULL | +| R4(同序號重配) | 同 owner 已存在同 serial 的未刪除 device → **復用既有 `device_id`**(只更新 `paired_at` / `updated_at`),不新建,避免撞 partial unique index `uq_devices_owner_serial_active` | + +**向下相容**:`devices` 欄位缺省(舊 agent、local server 未起、撈不到清單、0 顆裝置)→ 行為與舊版**完全一致**:自建一筆 `serial_number = NULL` 的 device。序號是加值資訊,配對本身不因撈不到 USB 而失敗。 + ### GET `/api/pairing/status` - 查詢當前 user 的 tunnel 連線狀態 - Response: diff --git a/local-agent/server/internal/device/manager.go b/local-agent/server/internal/device/manager.go index 7a87f6d..4ceb000 100644 --- a/local-agent/server/internal/device/manager.go +++ b/local-agent/server/internal/device/manager.go @@ -10,21 +10,39 @@ import ( "visiona-agent/server/pkg/logger" ) +// fakeSerialNumber is the placeholder kn_number reported by the Python +// bridge pyusb fallback when the Kneron SDK is unavailable (e.g. macOS +// without the dylib). It is not a real hardware serial, so it must never be +// used as a routing key (multiple devices could collide on it). Treated the +// same as "no serial" (ADR-018 §2.2 / R2). +const fakeSerialNumber = "0x00000000" + type Manager struct { - registry *DriverRegistry - sessions map[string]*DeviceSession - eventBus chan DeviceEvent - scriptPath string - logBroadcaster *logger.Broadcaster - mu sync.RWMutex + registry *DriverRegistry + sessions map[string]*DeviceSession + // serialToLocalID maps a device's hardware serial (Kneron kn_number, + // e.g. "0x1A2B3C4D") to its local synthetic session key ("kl520-0"). + // + // Why it exists (ADR-018 serial routing): cloud-side requests arrive with + // an identifier that never matched the local sessions key (the cloud used + // its own DB UUID). The serial is the only identifier stable across both + // layers, so GetDevice does a dual lookup: sessions[id] first (backward + // compatible with local synthetic IDs), then serialToLocalID[id]. + // Empty and fake ("0x00000000") serials are never indexed. + serialToLocalID map[string]string + eventBus chan DeviceEvent + scriptPath string + logBroadcaster *logger.Broadcaster + mu sync.RWMutex } func NewManager(registry *DriverRegistry, scriptPath string) *Manager { return &Manager{ - registry: registry, - sessions: make(map[string]*DeviceSession), - eventBus: make(chan DeviceEvent, 100), - scriptPath: scriptPath, + registry: registry, + sessions: make(map[string]*DeviceSession), + serialToLocalID: make(map[string]string), + eventBus: make(chan DeviceEvent, 100), + scriptPath: scriptPath, } } @@ -60,6 +78,7 @@ func (m *Manager) Start() { m.sessions[info.ID] = NewSession(d) log.Printf("Registered Kneron device: %s (%s, type=%s)", info.Name, info.ID, info.Type) } + m.rebuildSerialIndexLocked() } // Rescan re-detects connected Kneron devices. New devices are registered, @@ -97,6 +116,8 @@ func (m *Manager) Rescan() []driver.DeviceInfo { } } + m.rebuildSerialIndexLocked() + // Return current list. devices := make([]driver.DeviceInfo, 0, len(m.sessions)) for _, s := range m.sessions { @@ -105,6 +126,31 @@ func (m *Manager) Rescan() []driver.DeviceInfo { return devices } +// rebuildSerialIndexLocked rebuilds serialToLocalID from the current +// sessions. Caller must hold m.mu (write lock). +// +// Rebuilding (instead of incremental add/delete) keeps the index trivially +// consistent with sessions across Start/Rescan, including device removal. +// Empty serials are skipped; the fake serial "0x00000000" is skipped because +// multiple SDK-less devices report the same value (routing would be +// ambiguous). Real kn_numbers are unique per physical dongle; should a +// duplicate ever appear it is logged and only one entry wins. +func (m *Manager) rebuildSerialIndexLocked() { + idx := make(map[string]string, len(m.sessions)) + for id, s := range m.sessions { + serial := s.Driver.Info().SerialNumber + if serial == "" || serial == fakeSerialNumber { + continue + } + if prev, dup := idx[serial]; dup { + log.Printf("WARNING: duplicate device serial %s (devices %s and %s); serial routing keeps one entry only", serial, prev, id) + continue + } + idx[serial] = id + } + m.serialToLocalID = idx +} + func (m *Manager) ListDevices() []driver.DeviceInfo { m.mu.RLock() defer m.mu.RUnlock() @@ -115,14 +161,26 @@ func (m *Manager) ListDevices() []driver.DeviceInfo { return devices } +// GetDevice resolves a device session by identifier with a dual lookup +// (ADR-018 serial routing, additive and backward compatible): +// +// 1. sessions[id] — the local synthetic key ("kl520-0"); every existing +// caller keeps working unchanged. +// 2. serialToLocalID[id] — the hardware serial (kn_number, "0x1A2B3C4D"); +// lets cloud-proxied requests (flash / inference / camera / connect / +// disconnect all converge here) address a physical dongle by serial. func (m *Manager) GetDevice(id string) (*DeviceSession, error) { m.mu.RLock() defer m.mu.RUnlock() - s, ok := m.sessions[id] - if !ok { - return nil, fmt.Errorf("device not found: %s", id) + if s, ok := m.sessions[id]; ok { + return s, nil } - return s, nil + if localID, ok := m.serialToLocalID[id]; ok { + if s, ok := m.sessions[localID]; ok { + return s, nil + } + } + return nil, fmt.Errorf("device not found: %s", id) } func (m *Manager) Connect(id string) error { diff --git a/local-agent/server/internal/device/manager_test.go b/local-agent/server/internal/device/manager_test.go index 08364b7..ca6eb9b 100644 --- a/local-agent/server/internal/device/manager_test.go +++ b/local-agent/server/internal/device/manager_test.go @@ -11,9 +11,17 @@ type testDriver struct { connected bool } -func (d *testDriver) Info() driver.DeviceInfo { return d.info } -func (d *testDriver) Connect() error { d.connected = true; d.info.Status = driver.StatusConnected; return nil } -func (d *testDriver) Disconnect() error { d.connected = false; d.info.Status = driver.StatusDisconnected; return nil } +func (d *testDriver) Info() driver.DeviceInfo { return d.info } +func (d *testDriver) Connect() error { + d.connected = true + d.info.Status = driver.StatusConnected + return nil +} +func (d *testDriver) Disconnect() error { + d.connected = false + d.info.Status = driver.StatusDisconnected + return nil +} func (d *testDriver) IsConnected() bool { return d.connected } func (d *testDriver) Flash(_ string, _ chan<- driver.FlashProgress) error { return nil } func (d *testDriver) StartInference() error { return nil } @@ -92,3 +100,114 @@ func TestManager_Connect(t *testing.T) { t.Error("Connect() did not connect device") } } + +// ========================================================================== +// Serial routing (ADR-018 WP-0): serialToLocalID + GetDevice dual lookup +// ========================================================================== + +// seedDevice registers a test driver session and rebuilds the serial index, +// mimicking what Start()/Rescan() do after registration. +func seedDevice(mgr *Manager, info driver.DeviceInfo) *testDriver { + td := &testDriver{info: info} + mgr.mu.Lock() + mgr.sessions[info.ID] = NewSession(td) + mgr.rebuildSerialIndexLocked() + mgr.mu.Unlock() + return td +} + +func TestManager_GetDevice_BySerial(t *testing.T) { + mgr := NewManager(NewRegistry(), "") + seedDevice(mgr, driver.DeviceInfo{ID: "kl520-0", SerialNumber: "0x1A2B3C4D"}) + + t.Run("serial hits the physical device", func(t *testing.T) { + s, err := mgr.GetDevice("0x1A2B3C4D") + if err != nil { + t.Fatalf("GetDevice(serial) error = %v", err) + } + if got := s.Driver.Info().ID; got != "kl520-0" { + t.Errorf("GetDevice(serial) resolved ID = %q, want kl520-0", got) + } + }) + + t.Run("local synthetic id still works (backward compat)", func(t *testing.T) { + s, err := mgr.GetDevice("kl520-0") + if err != nil { + t.Fatalf("GetDevice(localID) error = %v", err) + } + if s == nil { + t.Fatal("GetDevice(localID) returned nil session") + } + }) + + t.Run("unknown id (e.g. cloud UUID) still misses", func(t *testing.T) { + if _, err := mgr.GetDevice("d76718a9-cf15-4795-914e-df5ae46ee536"); err == nil { + t.Error("GetDevice(UUID) expected error, got nil") + } + }) +} + +func TestManager_GetDevice_MultipleSerials(t *testing.T) { + mgr := NewManager(NewRegistry(), "") + mgr.mu.Lock() + mgr.sessions["kl520-0"] = NewSession(&testDriver{info: driver.DeviceInfo{ID: "kl520-0", SerialNumber: "0xAAAA0001"}}) + mgr.sessions["kl720-0"] = NewSession(&testDriver{info: driver.DeviceInfo{ID: "kl720-0", SerialNumber: "0xBBBB0002"}}) + mgr.rebuildSerialIndexLocked() + mgr.mu.Unlock() + + s1, err := mgr.GetDevice("0xAAAA0001") + if err != nil { + t.Fatalf("GetDevice(0xAAAA0001) error = %v", err) + } + if got := s1.Driver.Info().ID; got != "kl520-0" { + t.Errorf("serial 0xAAAA0001 resolved to %q, want kl520-0", got) + } + + s2, err := mgr.GetDevice("0xBBBB0002") + if err != nil { + t.Fatalf("GetDevice(0xBBBB0002) error = %v", err) + } + if got := s2.Driver.Info().ID; got != "kl720-0" { + t.Errorf("serial 0xBBBB0002 resolved to %q, want kl720-0", got) + } +} + +func TestManager_SerialIndex_SkipsEmptyAndFakeSerial(t *testing.T) { + mgr := NewManager(NewRegistry(), "") + mgr.mu.Lock() + // Empty serial (bridge could not read kn_number). + mgr.sessions["kl520-0"] = NewSession(&testDriver{info: driver.DeviceInfo{ID: "kl520-0"}}) + // Fake serial from pyusb fallback (no SDK) — must not be routable. + mgr.sessions["kl720-0"] = NewSession(&testDriver{info: driver.DeviceInfo{ID: "kl720-0", SerialNumber: fakeSerialNumber}}) + mgr.rebuildSerialIndexLocked() + mgr.mu.Unlock() + + if _, err := mgr.GetDevice(""); err == nil { + t.Error("GetDevice(\"\") expected error (empty serial must not be indexed)") + } + if _, err := mgr.GetDevice(fakeSerialNumber); err == nil { + t.Errorf("GetDevice(%s) expected error (fake serial must not be routable)", fakeSerialNumber) + } + // Devices themselves remain reachable by local id. + if _, err := mgr.GetDevice("kl520-0"); err != nil { + t.Errorf("GetDevice(kl520-0) error = %v", err) + } + if _, err := mgr.GetDevice("kl720-0"); err != nil { + t.Errorf("GetDevice(kl720-0) error = %v", err) + } +} + +func TestManager_SerialIndex_RemovedDeviceUnroutable(t *testing.T) { + mgr := NewManager(NewRegistry(), "") + seedDevice(mgr, driver.DeviceInfo{ID: "kl520-0", SerialNumber: "0x1A2B3C4D"}) + + // Simulate Rescan removing the device: delete session + rebuild index. + mgr.mu.Lock() + delete(mgr.sessions, "kl520-0") + mgr.rebuildSerialIndexLocked() + mgr.mu.Unlock() + + if _, err := mgr.GetDevice("0x1A2B3C4D"); err == nil { + t.Error("GetDevice(serial) expected error after device removal") + } +} diff --git a/local-agent/server/internal/driver/interface.go b/local-agent/server/internal/driver/interface.go index 1a1ea9f..ae0cc2f 100644 --- a/local-agent/server/internal/driver/interface.go +++ b/local-agent/server/internal/driver/interface.go @@ -16,10 +16,16 @@ type DeviceDriver interface { } type DeviceInfo struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - Port string `json:"port"` + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Port string `json:"port"` + // SerialNumber is the Kneron kn_number reported by the Python bridge + // (formatted "0x%08X"). Empty when the bridge cannot read it (e.g. the + // pyusb fallback reports the fake value 0x00000000, which callers should + // treat as "no serial"). Used as the stable cross-layer routing key + // (cloud devices.serial_number <-> local sessions key), see ADR-018. + SerialNumber string `json:"serialNumber,omitempty"` VendorID uint16 `json:"vendorId,omitempty"` ProductID uint16 `json:"productId,omitempty"` Status DeviceStatus `json:"status"` diff --git a/local-agent/server/internal/driver/kneron/detector.go b/local-agent/server/internal/driver/kneron/detector.go index dc42c6a..9f5adb5 100644 --- a/local-agent/server/internal/driver/kneron/detector.go +++ b/local-agent/server/internal/driver/kneron/detector.go @@ -89,8 +89,8 @@ const KneronVendorID uint16 = 0x3231 // Known Kneron product IDs. const ( - ProductIDKL520 = "0x0100" - ProductIDKL720 = "0x0200" + ProductIDKL520 = "0x0100" + ProductIDKL720 = "0x0200" ProductIDKL720Alt = "0x0720" ) @@ -198,6 +198,24 @@ func DetectDevices(scriptPath string) []driver.DeviceInfo { return nil } + return parseScanDevices(devicesRaw) +} + +// parseScanDevices converts the raw `devices` array from the Python bridge +// scan response into driver.DeviceInfo entries. +// +// Extracted from DetectDevices so the parsing logic is unit-testable without +// spawning the Python bridge subprocess. +// +// Serial number (kn_number) handling: +// - The bridge reports kn_number formatted "0x%08X" (SDK scan branch). +// - A missing or non-string kn_number yields an empty SerialNumber; the +// device is still registered (serial is additive metadata — a device must +// never be dropped because its serial could not be read). +// - The synthetic ID ("kl520-0") stays untouched: it remains the local +// sessions routing key (flash/inference depend on it). The serial travels +// in the new SerialNumber field only (ADR-018 serial routing). +func parseScanDevices(devicesRaw []interface{}) []driver.DeviceInfo { // Track per-chip counters for naming (e.g. "KL520 #1", "KL720 #1"). chipCount := map[string]int{} @@ -223,18 +241,24 @@ func DetectDevices(scriptPath string) []driver.DeviceInfo { productID = p } + serial := "" + if s, ok := dev["kn_number"].(string); ok { + serial = strings.TrimSpace(s) + } + chip, devType := chipFromProductID(productID) chipCount[chip]++ idx := chipCount[chip] info := driver.DeviceInfo{ - ID: fmt.Sprintf("%s-%d", strings.ToLower(chip), idx-1), - Name: fmt.Sprintf("Kneron %s #%d", chip, idx), - Type: devType, - Port: port, - VendorID: KneronVendorID, - Status: driver.StatusDetected, - FirmwareVer: fw, + ID: fmt.Sprintf("%s-%d", strings.ToLower(chip), idx-1), + Name: fmt.Sprintf("Kneron %s #%d", chip, idx), + Type: devType, + Port: port, + SerialNumber: serial, + VendorID: KneronVendorID, + Status: driver.StatusDetected, + FirmwareVer: fw, } devices = append(devices, info) } diff --git a/local-agent/server/internal/driver/kneron/detector_test.go b/local-agent/server/internal/driver/kneron/detector_test.go new file mode 100644 index 0000000..a3bfa10 --- /dev/null +++ b/local-agent/server/internal/driver/kneron/detector_test.go @@ -0,0 +1,105 @@ +package kneron + +import ( + "testing" +) + +// TestParseScanDevices_WithKnNumber verifies the serial number (kn_number) +// reported by the Python bridge is preserved into DeviceInfo.SerialNumber +// while the synthetic local ID stays untouched (ADR-018 serial routing, WP-0). +func TestParseScanDevices_WithKnNumber(t *testing.T) { + raw := []interface{}{ + map[string]interface{}{ + "port": "1-2", + "firmware": "KDP", + "kn_number": "0x1A2B3C4D", + "product_id": "0x0100", + }, + map[string]interface{}{ + "port": "1-3", + "firmware": "KDP2", + "kn_number": "0x0E5F6071", + "product_id": "0x0720", + }, + } + + devices := parseScanDevices(raw) + if len(devices) != 2 { + t.Fatalf("parseScanDevices() = %d devices, want 2", len(devices)) + } + + if devices[0].ID != "kl520-0" { + t.Errorf("devices[0].ID = %q, want kl520-0 (synthetic ID must not change)", devices[0].ID) + } + if devices[0].SerialNumber != "0x1A2B3C4D" { + t.Errorf("devices[0].SerialNumber = %q, want 0x1A2B3C4D", devices[0].SerialNumber) + } + if devices[1].ID != "kl720-0" { + t.Errorf("devices[1].ID = %q, want kl720-0", devices[1].ID) + } + if devices[1].SerialNumber != "0x0E5F6071" { + t.Errorf("devices[1].SerialNumber = %q, want 0x0E5F6071", devices[1].SerialNumber) + } +} + +// TestParseScanDevices_MissingKnNumber verifies a payload without kn_number +// still yields the full device list (serial empty, no panic, no device drop). +func TestParseScanDevices_MissingKnNumber(t *testing.T) { + raw := []interface{}{ + map[string]interface{}{ + "port": "1-2", + "firmware": "KDP", + "product_id": "0x0100", + }, + } + + devices := parseScanDevices(raw) + if len(devices) != 1 { + t.Fatalf("parseScanDevices() = %d devices, want 1 (device must not be dropped)", len(devices)) + } + if devices[0].SerialNumber != "" { + t.Errorf("SerialNumber = %q, want empty when kn_number absent", devices[0].SerialNumber) + } + if devices[0].ID != "kl520-0" { + t.Errorf("ID = %q, want kl520-0", devices[0].ID) + } +} + +// TestParseScanDevices_NonStringKnNumber verifies a non-string kn_number +// (e.g. raw number instead of formatted hex string) does not panic and the +// device is still registered with an empty serial. +func TestParseScanDevices_NonStringKnNumber(t *testing.T) { + raw := []interface{}{ + map[string]interface{}{ + "port": "1-2", + "kn_number": float64(123456), // JSON number decodes to float64 + "product_id": "0x0200", + }, + } + + devices := parseScanDevices(raw) + if len(devices) != 1 { + t.Fatalf("parseScanDevices() = %d devices, want 1", len(devices)) + } + if devices[0].SerialNumber != "" { + t.Errorf("SerialNumber = %q, want empty for non-string kn_number", devices[0].SerialNumber) + } +} + +// TestParseScanDevices_WhitespaceTrimmed verifies kn_number whitespace is trimmed. +func TestParseScanDevices_WhitespaceTrimmed(t *testing.T) { + raw := []interface{}{ + map[string]interface{}{ + "kn_number": " 0x1A2B3C4D ", + "product_id": "0x0100", + }, + } + + devices := parseScanDevices(raw) + if len(devices) != 1 { + t.Fatalf("parseScanDevices() = %d devices, want 1", len(devices)) + } + if devices[0].SerialNumber != "0x1A2B3C4D" { + t.Errorf("SerialNumber = %q, want trimmed 0x1A2B3C4D", devices[0].SerialNumber) + } +} diff --git a/local-agent/visiona-agent/app.go b/local-agent/visiona-agent/app.go index 239e57c..3adca03 100644 --- a/local-agent/visiona-agent/app.go +++ b/local-agent/visiona-agent/app.go @@ -56,9 +56,9 @@ const ( // 乾淨 Windows 環境總和常常超過 60 秒,180 秒涵蓋 99% 情境。日常啟動 // server 幾百毫秒就能回應,放寬上限不影響正常情境;配合 pipeline pause // 機制也不會讓 soft/hard timeout 誤觸。 - healthCheckTimeout = 180 * time.Second - shutdownGracePeriod = 5 * time.Second - appName = "visiona-agent" + healthCheckTimeout = 180 * time.Second + shutdownGracePeriod = 5 * time.Second + appName = "visiona-agent" ) // PythonMode 決定 Python runtime 的選擇策略。 @@ -189,12 +189,13 @@ func NewApp() *App { // startup 由 Wails 在 app 啟動時呼叫。 // // M8-4b:整合 6 階段啟動 pipeline。流程: -// stage 1(init Wails console)→ seedUserDataDir 完成後 CompleteStage(1) -// stage 2(Python runtime) ┐ -// stage 3(spawn server) ├─ 由 startServerV2 內部 hook -// stage 4(device probe) ┘ -// stage 5(open browser) → ctrl.Start() return 後處理 -// stage 6(wait WebSocket) → watcher goroutine poll sentinel file +// +// stage 1(init Wails console)→ seedUserDataDir 完成後 CompleteStage(1) +// stage 2(Python runtime) ┐ +// stage 3(spawn server) ├─ 由 startServerV2 內部 hook +// stage 4(device probe) ┘ +// stage 5(open browser) → ctrl.Start() return 後處理 +// stage 6(wait WebSocket) → watcher goroutine poll sentinel file // // pipeline 失敗時不再呼叫 reportFatal(會直接結束程式),而是進 Error state, // 讓使用者看到 Wails 控制台的 Retry 按鈕。 @@ -413,6 +414,11 @@ func (a *App) tryStartTunnel() { exchanger := tunnel.NewHTTPPairingExchanger(cloudAPIURL) exchanger.MockMode = mockMode exchanger.InsecureSkipTLSVerify = insecureSkipTLSVerify + // WP-0(ADR-018 序號地基):exchange 前撈本地 /api/devices 把 USB 序號 + // (kn_number)塞進 payload,雲端據此填 devices.serial_number。 + // 撈失敗不會中斷配對(見 tunnel.DeviceLister 註解)。port 在上方已確認 > 0。 + exchanger.DeviceLister = tunnel.NewLocalDeviceLister(port) + exchanger.Logf = a.appLog // Startup log 明確標示當前模式;mock=true 時用 WARN 級字眼讓 dev 一眼看到。 if mockMode { diff --git a/local-agent/visiona-agent/internal/tunnel/pairing.go b/local-agent/visiona-agent/internal/tunnel/pairing.go index cba7033..441b786 100644 --- a/local-agent/visiona-agent/internal/tunnel/pairing.go +++ b/local-agent/visiona-agent/internal/tunnel/pairing.go @@ -21,6 +21,7 @@ import ( "errors" "fmt" "io" + "log" "net/http" "regexp" "strings" @@ -110,10 +111,39 @@ type ExchangeResult struct { } // exchangeRequest 對應 TDD §4.3 定義的 request body。 +// +// Devices 為 WP-0(ADR-018 序號地基)新增的 optional 欄位:exchange 前向本地 +// server 撈 `GET /api/devices` 取得實體 USB 清單(含 Kneron kn_number 序號), +// 讓雲端把序號填進 devices.serial_number(serial 路由的資料來源)。 +// `omitempty` 保證舊行為相容:撈不到清單(local server 未起 / timeout / 0 裝置) +// 時不送此欄位,雲端走現行「自建 serial=NULL device」路徑。 type exchangeRequest struct { - PairingToken string `json:"pairing_token"` + PairingToken string `json:"pairing_token"` + Devices []exchangeDevice `json:"devices,omitempty"` } +// exchangeDevice 是 exchange payload 中的單顆實體 USB 裝置。 +// 用陣列因應「一 agent 多 USB」(WP-0 只保證序號送達,多顆的完整模型是 WP-B)。 +type exchangeDevice struct { + SerialNumber string `json:"serial_number"` + DeviceType string `json:"device_type,omitempty"` + Firmware string `json:"firmware,omitempty"` +} + +// LocalDevice 是 DeviceLister 回傳的本地 USB 裝置摘要(exchange payload 的來源)。 +type LocalDevice struct { + SerialNumber string + DeviceType string + Firmware string +} + +// DeviceLister 回傳本地 server 目前偵測到的 USB 裝置清單。 +// +// 由 app.go 組裝時注入(NewLocalDeviceLister(port)),Exchanger 不自己猜 port。 +// 回傳 error 或 nil 清單都不會讓 exchange 失敗——序號是加值資訊,配對本身 +// 不能因為撈不到 USB 而中斷(fallback 到不帶 devices 的現行行為)。 +type DeviceLister func() ([]LocalDevice, error) + // exchangeResponse 對齊雲端 /api/pairing/exchange 的成功 envelope(api/errors.go WriteSuccess): // // { @@ -200,6 +230,14 @@ type HTTPPairingExchanger struct { // 僅在 Client == nil(走預設 client 建構)時生效;若呼叫者注入自訂 Client, // 以該 Client 自帶的 Transport 為準。 InsecureSkipTLSVerify bool + + // DeviceLister 供 exchange 前撈本地 USB 清單(含序號)塞進 payload。 + // nil 或回傳失敗 → payload 不帶 devices(exchange 照常進行,見 DeviceLister 註解)。 + DeviceLister DeviceLister + + // Logf 為 optional 的 log 函式(app.go 注入 appLog)。nil 時 fallback 到 + // 標準 log.Printf。撈裝置清單失敗屬「可恢復、不中斷」情況,必須留下紀錄。 + Logf func(format string, args ...interface{}) } // NewHTTPPairingExchanger 建立一個生產預設實例。 @@ -262,7 +300,10 @@ func (e *HTTPPairingExchanger) exchangeReal(pairingToken string) (ExchangeResult client = &c } - body, err := json.Marshal(exchangeRequest{PairingToken: pairingToken}) + body, err := json.Marshal(exchangeRequest{ + PairingToken: pairingToken, + Devices: e.collectLocalDevices(), + }) if err != nil { return ExchangeResult{}, err } @@ -310,6 +351,94 @@ func (e *HTTPPairingExchanger) exchangeReal(pairingToken string) (ExchangeResult } } +// collectLocalDevices 在 exchange 前撈本地 USB 清單、轉成 payload 形狀。 +// +// 不弄壞守則(device-serial-task1-mapping.md §2 段 4a):撈清單失敗(local +// server 沒起 / timeout / 0 裝置)**不可讓 exchange 失敗**——回 nil 讓 payload +// 省略 devices 欄位(`omitempty`),雲端走現行「serial=NULL」路徑。 +func (e *HTTPPairingExchanger) collectLocalDevices() []exchangeDevice { + if e.DeviceLister == nil { + return nil + } + devs, err := e.DeviceLister() + if err != nil { + e.logf("pairing: list local devices failed (exchange continues without serials): %v", err) + return nil + } + if len(devs) == 0 { + return nil + } + out := make([]exchangeDevice, 0, len(devs)) + for _, d := range devs { + out = append(out, exchangeDevice{ + SerialNumber: strings.TrimSpace(d.SerialNumber), + DeviceType: d.DeviceType, + Firmware: d.Firmware, + }) + } + return out +} + +// logf 走注入的 Logf(app.go 的 appLog);未注入時 fallback 標準 log。 +func (e *HTTPPairingExchanger) logf(format string, args ...interface{}) { + if e.Logf != nil { + e.Logf(format, args...) + return + } + log.Printf(format, args...) +} + +// localDeviceListTimeout 是撈本地 /api/devices 的 timeout。 +// 比照 server_control.go probe 的 2 秒——序號是加值資訊,不能拖慢配對。 +const localDeviceListTimeout = 2 * time.Second + +// localDevicesEnvelope 對齊 local server `GET /api/devices` 的回應 envelope: +// +// { "success": true, "data": { "devices": [ { "id", "serialNumber", "type", "firmwareVersion", ... } ] } } +// +// 只解需要的欄位(serialNumber / type / firmwareVersion)。 +type localDevicesEnvelope struct { + Success bool `json:"success"` + Data struct { + Devices []struct { + SerialNumber string `json:"serialNumber"` + Type string `json:"type"` + FirmwareVersion string `json:"firmwareVersion"` + } `json:"devices"` + } `json:"data"` +} + +// NewLocalDeviceLister 建立一個向本地 server(127.0.0.1:port)撈 +// `GET /api/devices` 的 DeviceLister。port 由 app.go 從 ServerController 取得 +// 後注入(Exchanger 不自己猜 port)。 +func NewLocalDeviceLister(port int) DeviceLister { + client := &http.Client{Timeout: localDeviceListTimeout} + url := fmt.Sprintf("http://127.0.0.1:%d/api/devices", port) + return func() ([]LocalDevice, error) { + resp, err := client.Get(url) + if err != nil { + return nil, fmt.Errorf("local device list: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("local device list: http %d", resp.StatusCode) + } + var env localDevicesEnvelope + if err := json.NewDecoder(resp.Body).Decode(&env); err != nil { + return nil, fmt.Errorf("local device list: decode: %w", err) + } + out := make([]LocalDevice, 0, len(env.Data.Devices)) + for _, d := range env.Data.Devices { + out = append(out, LocalDevice{ + SerialNumber: d.SerialNumber, + DeviceType: d.Type, + Firmware: d.FirmwareVersion, + }) + } + return out, nil + } +} + // mapExchangeErrorCode 把雲端錯誤 envelope 的 error.code 映射成 agent 內部 sentinel error。 // // code 為雲端的大寫常數(見 visionA-backend api.ErrCodeInvalidPairingToken 等): diff --git a/local-agent/visiona-agent/internal/tunnel/pairing_test.go b/local-agent/visiona-agent/internal/tunnel/pairing_test.go index 79c659a..42d8383 100644 --- a/local-agent/visiona-agent/internal/tunnel/pairing_test.go +++ b/local-agent/visiona-agent/internal/tunnel/pairing_test.go @@ -4,8 +4,11 @@ package tunnel import ( "encoding/json" "errors" + "io" "net/http" "net/http/httptest" + "net/url" + "strconv" "strings" "testing" ) @@ -374,3 +377,178 @@ func TestExchangeReal404HintsMockMode(t *testing.T) { t.Errorf("err = %v — should hint mock_mode or AB11", err) } } + +// ========================================================================== +// WP-0(ADR-018 序號地基):exchange payload 帶本地 USB 裝置清單 +// ========================================================================== + +// TestExchangeReal_PayloadIncludesDevices 驗證 DeviceLister 有清單時, +// exchange request body 帶 devices(含 serial_number),序號得以上雲。 +func TestExchangeReal_PayloadIncludesDevices(t *testing.T) { + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(exchangeResponse{ + Success: true, + Data: exchangeResponseData{SessionToken: "vAs_" + strings.Repeat("a", 64)}, + }) + })) + defer srv.Close() + + ex := NewHTTPPairingExchanger(srv.URL) + ex.DeviceLister = func() ([]LocalDevice, error) { + return []LocalDevice{ + {SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520", Firmware: "KDP"}, + {SerialNumber: " 0x0E5F6071 ", DeviceType: "kneron_kl720"}, + }, nil + } + + if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatalf("Exchange: %v", err) + } + + devices, ok := gotBody["devices"].([]interface{}) + if !ok { + t.Fatalf("request body 缺 devices 欄位:%v", gotBody) + } + if len(devices) != 2 { + t.Fatalf("devices 長度 = %d, want 2", len(devices)) + } + d0 := devices[0].(map[string]interface{}) + if d0["serial_number"] != "0x1A2B3C4D" { + t.Errorf("devices[0].serial_number = %v, want 0x1A2B3C4D", d0["serial_number"]) + } + if d0["device_type"] != "kneron_kl520" { + t.Errorf("devices[0].device_type = %v, want kneron_kl520", d0["device_type"]) + } + d1 := devices[1].(map[string]interface{}) + if d1["serial_number"] != "0x0E5F6071" { + t.Errorf("devices[1].serial_number = %v(應 trim 空白), want 0x0E5F6071", d1["serial_number"]) + } +} + +// TestExchangeReal_DeviceListerFails_ExchangeStillSucceeds 驗證不弄壞守則: +// 撈本地清單失敗時 exchange 照常成功(payload 無 devices),且失敗有留 log。 +func TestExchangeReal_DeviceListerFails_ExchangeStillSucceeds(t *testing.T) { + var gotBody map[string]interface{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewDecoder(r.Body).Decode(&gotBody) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(exchangeResponse{ + Success: true, + Data: exchangeResponseData{SessionToken: "vAs_" + strings.Repeat("b", 64)}, + }) + })) + defer srv.Close() + + var logged []string + ex := NewHTTPPairingExchanger(srv.URL) + ex.DeviceLister = func() ([]LocalDevice, error) { + return nil, errors.New("local server unreachable") + } + ex.Logf = func(format string, args ...interface{}) { + logged = append(logged, format) + } + + if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatalf("Exchange 不應因撈清單失敗而失敗:%v", err) + } + if _, has := gotBody["devices"]; has { + t.Errorf("撈清單失敗時 payload 不應帶 devices:%v", gotBody["devices"]) + } + if len(logged) == 0 { + t.Error("撈清單失敗應留 log(no silent failures)") + } +} + +// TestExchangeReal_NoDeviceLister_NoDevicesField 驗證未注入 DeviceLister(舊行為) +// 時 payload 完全不帶 devices 欄位(omitempty 相容性)。 +func TestExchangeReal_NoDeviceLister_NoDevicesField(t *testing.T) { + var rawBody []byte + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + buf := new(strings.Builder) + _, _ = io.Copy(buf, r.Body) + rawBody = []byte(buf.String()) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(exchangeResponse{ + Success: true, + Data: exchangeResponseData{SessionToken: "vAs_" + strings.Repeat("c", 64)}, + }) + })) + defer srv.Close() + + ex := NewHTTPPairingExchanger(srv.URL) + if _, err := ex.Exchange("vAc_0123456789abcdef0123456789abcdef"); err != nil { + t.Fatalf("Exchange: %v", err) + } + if strings.Contains(string(rawBody), "devices") { + t.Errorf("無 DeviceLister 時 payload 不應含 devices 欄位:%s", rawBody) + } +} + +// TestNewLocalDeviceLister_ParsesEnvelope 驗證 NewLocalDeviceLister 能解析 +// local server GET /api/devices 的 envelope(success + data.devices)。 +func TestNewLocalDeviceLister_ParsesEnvelope(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/devices" { + w.WriteHeader(404) + return + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "success": true, + "data": { + "devices": [ + {"id":"kl520-0","name":"Kneron KL520 #1","type":"kneron_kl520","serialNumber":"0x1A2B3C4D","firmwareVersion":"KDP","status":"detected"}, + {"id":"kl720-0","name":"Kneron KL720 #1","type":"kneron_kl720","status":"detected"} + ] + } + }`)) + })) + defer srv.Close() + + port := portFromTestServerURL(t, srv.URL) + lister := NewLocalDeviceLister(port) + devs, err := lister() + if err != nil { + t.Fatalf("lister: %v", err) + } + if len(devs) != 2 { + t.Fatalf("devices = %d, want 2", len(devs)) + } + if devs[0].SerialNumber != "0x1A2B3C4D" || devs[0].DeviceType != "kneron_kl520" || devs[0].Firmware != "KDP" { + t.Errorf("devs[0] = %+v", devs[0]) + } + if devs[1].SerialNumber != "" { + t.Errorf("devs[1].SerialNumber = %q, want empty(serialNumber omitempty)", devs[1].SerialNumber) + } +} + +// TestNewLocalDeviceLister_Unreachable 驗證 local server 不可達時回 error +// (由 collectLocalDevices 轉為「不帶 devices、exchange 照常」)。 +func TestNewLocalDeviceLister_Unreachable(t *testing.T) { + // 先開再關,拿一個幾乎必然沒人聽的 port。 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {})) + port := portFromTestServerURL(t, srv.URL) + srv.Close() + + lister := NewLocalDeviceLister(port) + if _, err := lister(); err == nil { + t.Error("lister 對不可達的 local server 應回 error") + } +} + +// portFromTestServerURL 從 httptest server URL(http://127.0.0.1:PORT)解出 port。 +func portFromTestServerURL(t *testing.T, rawURL string) int { + t.Helper() + u, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("parse url %q: %v", rawURL, err) + } + port, err := strconv.Atoi(u.Port()) + if err != nil { + t.Fatalf("parse port from %q: %v", rawURL, err) + } + return port +} diff --git a/visionA-backend/internal/api/pairing.go b/visionA-backend/internal/api/pairing.go index ef7c252..a24ade0 100644 --- a/visionA-backend/internal/api/pairing.go +++ b/visionA-backend/internal/api/pairing.go @@ -336,8 +336,14 @@ func registerPairingPublicRoutes(r gin.IRouter, deps Deps) { } // PairingExchangeRequest 是 POST /api/pairing/exchange 的 request body。 +// +// Devices 為 WP-0(ADR-018 序號地基)新增的 optional 欄位:agent 在 exchange 前 +// 撈本地 USB 清單(含 Kneron kn_number 序號)上報,雲端據此填 +// devices.serial_number。舊 agent 不送此欄位 → 行為與現行完全一致 +// (自建 serial=NULL device)。 type PairingExchangeRequest struct { - PairingToken string `json:"pairing_token" binding:"required"` + PairingToken string `json:"pairing_token" binding:"required"` + Devices []ExchangeDeviceInput `json:"devices,omitempty"` } // PairingExchangeResponse 是 POST /api/pairing/exchange 成功時的 data payload。 @@ -439,7 +445,7 @@ func pairingExchangeHandler(deps Deps) gin.HandlerFunc { deviceID string ) if deps.PairingExchanger != nil { - res, exErr := deps.PairingExchanger.Provision(ctx, info.UserID, info.TokenHash, auth.SessionTokenTTL) + res, exErr := deps.PairingExchanger.Provision(ctx, info.UserID, info.TokenHash, auth.SessionTokenTTL, req.Devices) if exErr != nil { logOrDefault(deps.Logger).Error("pairing exchange: provision device+session failed", "error", exErr, diff --git a/visionA-backend/internal/api/pairing_exchange.go b/visionA-backend/internal/api/pairing_exchange.go index b3d4b89..29841b9 100644 --- a/visionA-backend/internal/api/pairing_exchange.go +++ b/visionA-backend/internal/api/pairing_exchange.go @@ -30,8 +30,10 @@ package api import ( "context" + "errors" "fmt" "log/slog" + "strings" "time" "github.com/google/uuid" @@ -53,11 +55,41 @@ const ( // ExchangeProvisionResult 回報 exchange 自建 device + 建 session token 的結果。 type ExchangeProvisionResult struct { - DeviceID string // 本次自建的 device id + DeviceID string // 本次自建(或依 serial 復用)的 device id SessionPlaintext string // 新 session token 原文(caller 只此一次能拿到) SessionInfo *auth.SessionToken // session token 儲存層表示(含 ExpiresAt) } +// ExchangeDeviceInput 是 exchange payload 中 agent 上報的單顆實體 USB 裝置 +// (WP-0 / ADR-018 序號地基,對齊 agent 端 tunnel.exchangeDevice 的 JSON)。 +type ExchangeDeviceInput struct { + SerialNumber string `json:"serial_number"` + DeviceType string `json:"device_type,omitempty"` + Firmware string `json:"firmware,omitempty"` +} + +// fakeSerialNumber 是 agent 端 pyusb fallback(無 Kneron SDK,如 macOS 缺 dylib) +// 寫死上報的假序號。多顆無 SDK 裝置會撞同一個值,不可當唯一鍵 / 路由鍵—— +// 視同「無序號」寫 NULL(ADR-018 §2.2 / task-1 mapping R2)。 +const fakeSerialNumber = "0x00000000" + +// firstUsableSerialDevice 從 agent 上報清單挑第一顆「序號可用」的裝置。 +// +// WP-0 最小落地(task-1 mapping R1):exchange 仍只落一筆 device,多顆 USB 的 +// 完整模型(一 agent N device)是 WP-B / migration 0005 的範疇。這裡取第一顆 +// 有效序號填入;空序號與假序號(0x00000000)跳過。 +func firstUsableSerialDevice(devices []ExchangeDeviceInput) (ExchangeDeviceInput, bool) { + for _, d := range devices { + serial := strings.TrimSpace(d.SerialNumber) + if serial == "" || strings.EqualFold(serial, fakeSerialNumber) { + continue + } + d.SerialNumber = serial + return d, true + } + return ExchangeDeviceInput{}, false +} + // PairingExchanger 把「自建 device + 建 session token」包成一個原子(Postgres tx)或 // 一致(in-memory 依序)操作。 // @@ -67,14 +99,26 @@ type PairingExchanger interface { // Provision 自建一筆 device(owner = userID)並建一筆綁該 device 的 session token。 // // parentTokenHash 為來源 pairing token 的 hash(稽核鏈,寫進 session_tokens.parent_token_hash)。 - Provision(ctx context.Context, userID, parentTokenHash string, ttl time.Duration) (ExchangeProvisionResult, error) + // + // devices 為 agent 上報的實體 USB 清單(WP-0 序號地基,可為 nil = 舊 agent / + // 撈不到清單,行為與現行完全一致)。序號可用時: + // - 同 owner 已有同 serial 的未刪除 device → 復用既有 device_id(防 + // uq_devices_owner_serial_active 23505 炸裂;「同序號重配 = 復用」,R4)。 + // - 否則新建 device 並填 serial_number。 + Provision(ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput) (ExchangeProvisionResult, error) } // ── Postgres 後端 ───────────────────────────────────────────────────────────── -// pgDeviceSaver 是 device 在 tx 內 upsert 的能力(由 device.PostgresRepository 滿足)。 +// pgDeviceSaver 是 device 在 tx 內 upsert + 依 serial 查詢的能力 +// (由 device.PostgresRepository 滿足)。 +// +// GetBySerial 用於 WP-0 序號防炸:serial 有值時 exchange 先查同 owner 是否已有 +// 同 serial 的未刪除 device,有則復用、不再自建(避免撞 partial unique +// uq_devices_owner_serial_active → 23505 → exchange 500)。 type pgDeviceSaver interface { SaveTx(ctx context.Context, q db.Querier, d *device.Device) error + GetBySerial(ctx context.Context, ownerUserID, serial string) (*device.Device, error) } // pgSessionTokenCreator 是「在 tx 內建 session token」的能力(由 auth.PostgresSessionTokenStore 滿足)。 @@ -105,40 +149,66 @@ func NewPostgresPairingExchanger( } } -// Provision 在單一交易內:自建 device → 建綁該 device 的 session token。 +// Provision 在單一交易內:自建(或依 serial 復用)device → 建綁該 device 的 session token。 // // 任一步失敗整筆 rollback(device 不會「已建但沒 token」殘留在 DB)。 +// +// WP-0 序號地基(ADR-018):agent 上報清單有可用序號時,先 GetBySerial 查同 +// owner 是否已有同 serial 的未刪除 device——有則復用既有 device_id(同序號重配 +// = 復用,R4),沒有才新建並填 serial_number。已知限制:GetBySerial 走 pool +// (非 tx 內),與並發 exchange 之間有極小 race window;撞到時 SaveTx 會被 +// partial unique index 擋下(整筆 rollback、不產生重複 serial),同一顆 agent +// 的配對操作實務上是序列的,可接受。 func (e *pgPairingExchanger) Provision( - ctx context.Context, userID, parentTokenHash string, ttl time.Duration, + ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput, ) (ExchangeProvisionResult, error) { var res ExchangeProvisionResult - deviceID := uuid.NewString() now := time.Now().UTC() - err := db.WithTx(ctx, e.pool, func(q db.Querier) error { - dev := &device.Device{ - ID: deviceID, - OwnerUserID: userID, - Name: defaultPairedDeviceName, - DeviceType: defaultPairedDeviceType, - // serial_number 留空(agent 未帶):SaveTx 把空 serial 寫成 SQL NULL, - // 故同 owner 多次 exchange 各建一筆 serial=NULL 的 distinct device,不撞 - // partial unique uq_devices_owner_serial_active(每個 NULL 互不相等)。 - RemoteStatus: device.RemoteStatusOffline, - Status: device.USBStatusUnknown, - PairedAt: &now, - CreatedAt: now, - UpdatedAt: now, + dev := &device.Device{ + ID: uuid.NewString(), + OwnerUserID: userID, + Name: defaultPairedDeviceName, + DeviceType: defaultPairedDeviceType, + // serial_number 預設留空(agent 未帶):SaveTx 把空 serial 寫成 SQL NULL, + // 故同 owner 多次 exchange 各建一筆 serial=NULL 的 distinct device,不撞 + // partial unique uq_devices_owner_serial_active(每個 NULL 互不相等)。 + RemoteStatus: device.RemoteStatusOffline, + Status: device.USBStatusUnknown, + PairedAt: &now, + CreatedAt: now, + UpdatedAt: now, + } + + if input, ok := firstUsableSerialDevice(devices); ok { + existing, gErr := e.devices.GetBySerial(ctx, userID, input.SerialNumber) + switch { + case gErr == nil: + // 復用既有 device:保留既有欄位,只更新配對時間。 + dev = existing + dev.PairedAt = &now + dev.UpdatedAt = now + case errors.Is(gErr, device.ErrNotFound): + // 新建 device,填入序號(+ agent 上報的 device type 若有)。 + dev.SerialNumber = input.SerialNumber + if input.DeviceType != "" { + dev.DeviceType = input.DeviceType + } + default: + return ExchangeProvisionResult{}, fmt.Errorf("exchange: get device by serial: %w", gErr) } + } + + err := db.WithTx(ctx, e.pool, func(q db.Querier) error { if saveErr := e.devices.SaveTx(ctx, q, dev); saveErr != nil { return fmt.Errorf("exchange: save device: %w", saveErr) } - plaintext, info, createErr := e.sessionToken.CreateTx(ctx, q, userID, deviceID, parentTokenHash, ttl) + plaintext, info, createErr := e.sessionToken.CreateTx(ctx, q, userID, dev.ID, parentTokenHash, ttl) if createErr != nil { return fmt.Errorf("exchange: create session token: %w", createErr) } - res.DeviceID = deviceID + res.DeviceID = dev.ID res.SessionPlaintext = plaintext res.SessionInfo = info return nil @@ -176,15 +246,15 @@ func NewInMemoryPairingExchanger( } } -// Provision 自建 device 後建綁該 device 的 session token(依序,非交易)。 +// Provision 自建(或依 serial 復用)device 後建綁該 device 的 session token +// (依序,非交易)。serial 處理邏輯與 pgPairingExchanger 對齊(WP-0)。 func (e *memPairingExchanger) Provision( - ctx context.Context, userID, parentTokenHash string, ttl time.Duration, + ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput, ) (ExchangeProvisionResult, error) { - deviceID := uuid.NewString() now := time.Now().UTC() dev := &device.Device{ - ID: deviceID, + ID: uuid.NewString(), OwnerUserID: userID, Name: defaultPairedDeviceName, DeviceType: defaultPairedDeviceType, @@ -194,16 +264,34 @@ func (e *memPairingExchanger) Provision( CreatedAt: now, UpdatedAt: now, } + + if input, ok := firstUsableSerialDevice(devices); ok { + existing, gErr := e.devices.GetBySerial(ctx, userID, input.SerialNumber) + switch { + case gErr == nil: + dev = existing + dev.PairedAt = &now + dev.UpdatedAt = now + case errors.Is(gErr, device.ErrNotFound): + dev.SerialNumber = input.SerialNumber + if input.DeviceType != "" { + dev.DeviceType = input.DeviceType + } + default: + return ExchangeProvisionResult{}, fmt.Errorf("exchange: get device by serial: %w", gErr) + } + } + if err := e.devices.Save(ctx, dev); err != nil { return ExchangeProvisionResult{}, fmt.Errorf("exchange: save device: %w", err) } - plaintext, info, err := e.sessionToken.Create(ctx, userID, deviceID, parentTokenHash, ttl) + plaintext, info, err := e.sessionToken.Create(ctx, userID, dev.ID, parentTokenHash, ttl) if err != nil { return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err) } return ExchangeProvisionResult{ - DeviceID: deviceID, + DeviceID: dev.ID, SessionPlaintext: plaintext, SessionInfo: info, }, nil diff --git a/visionA-backend/internal/api/pairing_exchange_db_test.go b/visionA-backend/internal/api/pairing_exchange_db_test.go index e33285d..a86bea6 100644 --- a/visionA-backend/internal/api/pairing_exchange_db_test.go +++ b/visionA-backend/internal/api/pairing_exchange_db_test.go @@ -58,7 +58,7 @@ func TestPGExchange_ProvisionCreatesDeviceAndSession(t *testing.T) { tdb, exchanger, devRepo, sessions, owner := pgExchangeFixture(t) parentHash := auth.HashToken("vAc_" + uuid.NewString()[:32]) - res, err := exchanger.Provision(ctx, owner, parentHash, auth.SessionTokenTTL) + res, err := exchanger.Provision(ctx, owner, parentHash, auth.SessionTokenTTL, nil) require.NoError(t, err) require.NotEmpty(t, res.DeviceID, "應自建一筆 device") require.NotEmpty(t, res.SessionPlaintext, "應建一個 session token") @@ -94,7 +94,7 @@ func TestPGExchange_Provision_RollbackOnBadOwner(t *testing.T) { tdb, exchanger, _, _, _ := pgExchangeFixture(t) badOwner := uuid.NewString() // 不在 users 表 - _, err := exchanger.Provision(ctx, badOwner, "", auth.SessionTokenTTL) + _, err := exchanger.Provision(ctx, badOwner, "", auth.SessionTokenTTL, nil) require.Error(t, err, "owner 不存在 → device.owner_user_id FK violation") // device 不應殘留(整筆交易 rollback) @@ -108,11 +108,79 @@ func TestPGExchange_Provision_MultipleCreatesDistinctDevices(t *testing.T) { ctx := context.Background() _, exchanger, _, _, owner := pgExchangeFixture(t) - res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL) + res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, nil) require.NoError(t, err) - res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL) + res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, nil) require.NoError(t, err) assert.NotEqual(t, res1.DeviceID, res2.DeviceID, "兩次 exchange 應各自建不同 device") assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext, "兩次 session token 應不同") } + +// ========================================================================== +// WP-0(ADR-018 序號地基):exchange 收 agent 上報序號(真 DB) +// ========================================================================== + +// TestPGExchange_Provision_FillsSerialNumber 驗證序號真的寫進 devices.serial_number +// (非 NULL),且 device_type 取 agent 上報值。 +func TestPGExchange_Provision_FillsSerialNumber(t *testing.T) { + ctx := context.Background() + tdb, exchanger, devRepo, _, owner := pgExchangeFixture(t) + + res, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, + []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520"}}) + require.NoError(t, err) + + dev, err := devRepo.Get(ctx, res.DeviceID) + require.NoError(t, err) + assert.Equal(t, "0x1A2B3C4D", dev.SerialNumber) + assert.Equal(t, "kneron_kl520", dev.DeviceType) + + // 直接查 DB 確認 serial_number 非 NULL(不是空字串寫入) + var serialIsNull bool + require.NoError(t, tdb.Pool.QueryRow(ctx, + `SELECT serial_number IS NULL FROM devices WHERE id = $1`, res.DeviceID).Scan(&serialIsNull)) + assert.False(t, serialIsNull, "devices.serial_number 不應為 NULL") +} + +// TestPGExchange_Provision_SameSerialReusesDevice 驗證同序號重複 exchange 不撞 +// partial unique uq_devices_owner_serial_active(23505)——復用既有 device、 +// 不 500、devices 表不長出第二筆。 +func TestPGExchange_Provision_SameSerialReusesDevice(t *testing.T) { + ctx := context.Background() + tdb, exchanger, _, sessions, owner := pgExchangeFixture(t) + input := []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D"}} + + res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input) + require.NoError(t, err) + res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input) + require.NoError(t, err, "同序號重配不可撞 23505 炸 500(防炸守則)") + + assert.Equal(t, res1.DeviceID, res2.DeviceID, "同序號重配應復用既有 device") + assert.Equal(t, 1, tdb.CountRows(t, "devices"), "同序號重配不應多建 device") + + // 兩個 session token 都存在、都綁同一顆 device + tok2, err := sessions.Get(ctx, res2.SessionPlaintext) + require.NoError(t, err) + assert.Equal(t, res1.DeviceID, tok2.DeviceID) +} + +// TestPGExchange_Provision_FakeSerialWritesNull 驗證假序號 0x00000000 視同無序號 +// → serial_number 寫 NULL、不進復用分支(NULL 互不相等,各建 distinct device)。 +func TestPGExchange_Provision_FakeSerialWritesNull(t *testing.T) { + ctx := context.Background() + tdb, exchanger, _, _, owner := pgExchangeFixture(t) + input := []ExchangeDeviceInput{{SerialNumber: "0x00000000"}} + + res1, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input) + require.NoError(t, err) + res2, err := exchanger.Provision(ctx, owner, "", auth.SessionTokenTTL, input) + require.NoError(t, err, "假序號寫 NULL、NULL 互不相等 → 不撞 unique") + + assert.NotEqual(t, res1.DeviceID, res2.DeviceID) + + var serialIsNull bool + require.NoError(t, tdb.Pool.QueryRow(ctx, + `SELECT serial_number IS NULL FROM devices WHERE id = $1`, res1.DeviceID).Scan(&serialIsNull)) + assert.True(t, serialIsNull, "假序號應寫 NULL") +} diff --git a/visionA-backend/internal/api/pairing_exchange_test.go b/visionA-backend/internal/api/pairing_exchange_test.go index 094ef12..5c04c10 100644 --- a/visionA-backend/internal/api/pairing_exchange_test.go +++ b/visionA-backend/internal/api/pairing_exchange_test.go @@ -22,7 +22,7 @@ func TestMemExchange_ProvisionCreatesDeviceAndSession(t *testing.T) { sessions := auth.NewInMemorySessionTokenStore() exchanger := NewInMemoryPairingExchanger(devRepo, sessions) - res, err := exchanger.Provision(ctx, "owner-1", "parent-hash", auth.SessionTokenTTL) + res, err := exchanger.Provision(ctx, "owner-1", "parent-hash", auth.SessionTokenTTL, nil) require.NoError(t, err) require.NotEmpty(t, res.DeviceID) require.NotEmpty(t, res.SessionPlaintext) @@ -49,11 +49,96 @@ func TestMemExchange_Provision_DistinctDevices(t *testing.T) { exchanger := NewInMemoryPairingExchanger( device.NewInMemoryRepository(), auth.NewInMemorySessionTokenStore()) - res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL) + res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, nil) require.NoError(t, err) - res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL) + res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, nil) require.NoError(t, err) assert.NotEqual(t, res1.DeviceID, res2.DeviceID) assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext) } + +// ========================================================================== +// WP-0(ADR-018 序號地基):exchange 收 agent 上報序號 +// ========================================================================== + +// TestMemExchange_Provision_FillsSerialNumber 驗證 agent 上報序號時,自建 device +// 的 serial_number 有值(+ device_type 若有上報)。 +func TestMemExchange_Provision_FillsSerialNumber(t *testing.T) { + ctx := context.Background() + devRepo := device.NewInMemoryRepository() + exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore()) + + res, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, + []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D", DeviceType: "kneron_kl520", Firmware: "KDP"}}) + require.NoError(t, err) + + dev, err := devRepo.Get(ctx, res.DeviceID) + require.NoError(t, err) + assert.Equal(t, "0x1A2B3C4D", dev.SerialNumber, "serial_number 應填入 agent 上報值") + assert.Equal(t, "kneron_kl520", dev.DeviceType, "device_type 有上報時應覆蓋預設") +} + +// TestMemExchange_Provision_SameSerialReusesDevice 驗證同序號重複 exchange +// 復用既有 device(防唯一約束炸裂;「同序號重配 = 復用」,R4)——不新建、 +// 不報錯、session token 各自獨立。 +func TestMemExchange_Provision_SameSerialReusesDevice(t *testing.T) { + ctx := context.Background() + devRepo := device.NewInMemoryRepository() + exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore()) + input := []ExchangeDeviceInput{{SerialNumber: "0x1A2B3C4D"}} + + res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input) + require.NoError(t, err) + res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input) + require.NoError(t, err, "同序號重配不可失敗(防 23505 的行為對齊)") + + assert.Equal(t, res1.DeviceID, res2.DeviceID, "同序號重配應復用既有 device") + assert.NotEqual(t, res1.SessionPlaintext, res2.SessionPlaintext, "session token 應各自獨立") + + devices, err := devRepo.List(ctx, "owner-1") + require.NoError(t, err) + assert.Len(t, devices, 1, "同序號重配不應多建 device") +} + +// TestMemExchange_Provision_FakeSerialTreatedAsEmpty 驗證假序號 0x00000000 +// (macOS 無 SDK 的 pyusb fallback)視同無序號:serial 留空、行為與不帶序號一致 +// (每次 exchange 各建一筆 distinct device)。 +func TestMemExchange_Provision_FakeSerialTreatedAsEmpty(t *testing.T) { + ctx := context.Background() + devRepo := device.NewInMemoryRepository() + exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore()) + input := []ExchangeDeviceInput{{SerialNumber: "0x00000000"}} + + res1, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input) + require.NoError(t, err) + res2, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, input) + require.NoError(t, err) + + dev1, err := devRepo.Get(ctx, res1.DeviceID) + require.NoError(t, err) + assert.Empty(t, dev1.SerialNumber, "假序號應視同無序號(寫 NULL)") + assert.NotEqual(t, res1.DeviceID, res2.DeviceID, "假序號不進復用分支,各建 distinct device") +} + +// TestMemExchange_Provision_PicksFirstUsableSerial 驗證多顆上報時取第一顆 +// 可用序號(空序號 / 假序號跳過)——WP-0 最小落地(R1:多顆完整模型是 WP-B)。 +func TestMemExchange_Provision_PicksFirstUsableSerial(t *testing.T) { + ctx := context.Background() + devRepo := device.NewInMemoryRepository() + exchanger := NewInMemoryPairingExchanger(devRepo, auth.NewInMemorySessionTokenStore()) + + res, err := exchanger.Provision(ctx, "owner-1", "", auth.SessionTokenTTL, + []ExchangeDeviceInput{ + {SerialNumber: ""}, // 空序號跳過 + {SerialNumber: "0x00000000"}, // 假序號跳過 + {SerialNumber: " 0x0E5F6071 ", DeviceType: "kneron_kl720"}, + {SerialNumber: "0xFFFF0001"}, + }) + require.NoError(t, err) + + dev, err := devRepo.Get(ctx, res.DeviceID) + require.NoError(t, err) + assert.Equal(t, "0x0E5F6071", dev.SerialNumber, "應取第一顆可用序號(trim 空白)") + assert.Equal(t, "kneron_kl720", dev.DeviceType) +}