feat(device): WP-0 序號串通 + serial 路由(ADR-018 走向 A' 第一階段)

- local-agent/server:detector 保留 kn_number(parseScanDevices 可測化、
  合成 ID 語意不動)+ DeviceInfo.SerialNumber + Manager serialToLocalID
  反查表 + GetDevice 雙查(sessions 先、serial 後,純加法)
- visiona-agent:pairing exchange 帶本地裝置清單(DeviceLister 失敗不中斷
  配對、timeout 2s、omitempty 舊版相容)
- visionA-backend:exchange 收 devices —— R1 取第一顆可用序號、R2 假序號
  0x00000000 寫 NULL、R4 同 owner 同序號復用既有 device_id(防 23505)、
  pg+mem 兩實作對齊
- 五個 proxy 操作(flash/inference/camera/connect/disconnect)收斂於
  GetDevice 單一入口,serial 路由一處涵蓋
- docs:api-spec.md §2 增補 POST /api/pairing/exchange(schema + R1/R2/R4)
- 測試:行為級四環節鏈 + dbtest 130 實跑 6/6 + DBOn 回歸 4/4;
  三 module build/vet/test 全綠
- review:通過 0C/0M/6Mi/5Sug(.autoflow/05-implementation/review/
  wp0-serial-routing-review.md);Minor #1 Rescan stale session 掛 WP-C 前置

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-07-10 09:31:51 +08:00
parent 3d30fdc580
commit b9ee184586
13 changed files with 990 additions and 78 deletions

View File

@ -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-0ADR-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:

View File

@ -10,9 +10,26 @@ 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
// 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
@ -23,6 +40,7 @@ func NewManager(registry *DriverRegistry, scriptPath string) *Manager {
return &Manager{
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
}
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 {

View File

@ -12,8 +12,16 @@ type testDriver struct {
}
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) 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")
}
}

View File

@ -20,6 +20,12 @@ type DeviceInfo struct {
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"`

View File

@ -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,6 +241,11 @@ 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]
@ -232,6 +255,7 @@ func DetectDevices(scriptPath string) []driver.DeviceInfo {
Name: fmt.Sprintf("Kneron %s #%d", chip, idx),
Type: devType,
Port: port,
SerialNumber: serial,
VendorID: KneronVendorID,
Status: driver.StatusDetected,
FirmwareVer: fw,

View File

@ -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)
}
}

View File

@ -189,6 +189,7 @@ func NewApp() *App {
// startup 由 Wails 在 app 啟動時呼叫。
//
// M8-4b整合 6 階段啟動 pipeline。流程
//
// stage 1init Wails console→ seedUserDataDir 完成後 CompleteStage(1)
// stage 2Python runtime
// stage 3spawn server ├─ 由 startServerV2 內部 hook
@ -413,6 +414,11 @@ func (a *App) tryStartTunnel() {
exchanger := tunnel.NewHTTPPairingExchanger(cloudAPIURL)
exchanger.MockMode = mockMode
exchanger.InsecureSkipTLSVerify = insecureSkipTLSVerify
// WP-0ADR-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 {

View File

@ -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-0ADR-018 序號地基)新增的 optional 欄位exchange 前向本地
// server 撈 `GET /api/devices` 取得實體 USB 清單(含 Kneron kn_number 序號),
// 讓雲端把序號填進 devices.serial_numberserial 路由的資料來源)。
// `omitempty` 保證舊行為相容撈不到清單local server 未起 / timeout / 0 裝置)
// 時不送此欄位,雲端走現行「自建 serial=NULL device」路徑。
type exchangeRequest struct {
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 的成功 envelopeapi/errors.go WriteSuccess
//
// {
@ -200,6 +230,14 @@ type HTTPPairingExchanger struct {
// 僅在 Client == nil走預設 client 建構)時生效;若呼叫者注入自訂 Client
// 以該 Client 自帶的 Transport 為準。
InsecureSkipTLSVerify bool
// DeviceLister 供 exchange 前撈本地 USB 清單(含序號)塞進 payload。
// nil 或回傳失敗 → payload 不帶 devicesexchange 照常進行,見 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 走注入的 Logfapp.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 建立一個向本地 server127.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 等):

View File

@ -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-0ADR-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("撈清單失敗應留 logno 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 的 envelopesuccess + 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 emptyserialNumber 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 URLhttp://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
}

View File

@ -336,8 +336,14 @@ func registerPairingPublicRoutes(r gin.IRouter, deps Deps) {
}
// PairingExchangeRequest 是 POST /api/pairing/exchange 的 request body。
//
// Devices 為 WP-0ADR-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"`
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,

View File

@ -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 裝置會撞同一個值,不可當唯一鍵 / 路由鍵——
// 視同「無序號」寫 NULLADR-018 §2.2 / task-1 mapping R2
const fakeSerialNumber = "0x00000000"
// firstUsableSerialDevice 從 agent 上報清單挑第一顆「序號可用」的裝置。
//
// WP-0 最小落地task-1 mapping R1exchange 仍只落一筆 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 自建一筆 deviceowner = 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,23 +149,28 @@ func NewPostgresPairingExchanger(
}
}
// Provision 在單一交易內:自建 device → 建綁該 device 的 session token。
// Provision 在單一交易內:自建(或依 serial 復用)device → 建綁該 device 的 session token。
//
// 任一步失敗整筆 rollbackdevice 不會「已建但沒 token」殘留在 DB
//
// WP-0 序號地基ADR-018agent 上報清單有可用序號時,先 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,
ID: uuid.NewString(),
OwnerUserID: userID,
Name: defaultPairedDeviceName,
DeviceType: defaultPairedDeviceType,
// serial_number 留空agent 未帶SaveTx 把空 serial 寫成 SQL NULL
// serial_number 預設留空agent 未帶SaveTx 把空 serial 寫成 SQL NULL
// 故同 owner 多次 exchange 各建一筆 serial=NULL 的 distinct device不撞
// partial unique uq_devices_owner_serial_active每個 NULL 互不相等)。
RemoteStatus: device.RemoteStatusOffline,
@ -130,15 +179,36 @@ func (e *pgPairingExchanger) Provision(
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

View File

@ -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-0ADR-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_active23505——復用既有 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")
}

View File

@ -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-0ADR-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)
}