visionA/visionA-backend/internal/api/pairing_exchange.go
jim800121chen b9ee184586 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>
2026-07-10 09:31:51 +08:00

299 lines
13 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// pairing_exchange.go — pairing exchange 自建 device 的協調者DB-on FK 收尾,問題 #2
//
// 背景:
//
// session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從頭到尾沒有任何
// production 路徑會建 devicegrep 確認 device.Save 只在 seed / test 被呼叫。exchange 時
// info.DeviceID 必為空 → DB-on 下 session token INSERT 因 device_id 空字串 cast UUID 失敗。
// in-memory 模式因為不檢查 FK 而藏住此問題。
//
// 修法使用者拍板exchange 時雲端自建 device不動 local-tool
//
// exchange 驗完 pairing token 後、建 session token 之前,雲端自建一筆 device 代表「這台配對
// 進來的 local agent」owner = pairing token 綁的 user這個 user 已透過 OIDC callback
// provision 進 users 表 —— 見問題 #1。然後用這個 device_id 建 session token。
//
// 為什麼抽成 coordinator比照 unpair.go 的 DeviceUnpairer
// - 讓 handlerpairing.go 的 exchange維持薄。
// - Postgres 後端用 db.WithTx 把「建 device + 建 session token」包成單一交易——任一步失敗
// 整筆 rollback杜絕「device 建了但 session token 沒建成」的中間態database.md §6 一致性精神)。
// - in-memory 後端依序執行(無交易),行為一致。
// - main.go 依 dbPool 是否非 nil 擇一注入 Deps.PairingExchanger。為 nil 時 exchange handler
// fallback 到「不自建 device、直接用 info.DeviceID可能為空建 session token」的舊行為
// (與 DB-off 雛形相容in-memory store 不檢查 FK空 deviceID 可接受)。
//
// 冪等pairing token 是一次性MarkUsed 後 Validate 回 ErrTokenUsed故同一 token 不會被
// exchange 兩次成功。每次成功 exchange 自建一筆新 device新 UUID是正確語意——不同次配對
// 視為不同 agent 連線。重試exchange 後 MarkUsed 失敗被 abort時 session token 已 revoke、
// device 已建但無 token 指向它(孤兒 device無安全風險僅一筆閒置紀錄雛形可接受
package api
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"visiona-backend/internal/auth"
"visiona-backend/internal/db"
"visiona-backend/internal/device"
)
// defaultPairedDeviceName / defaultPairedDeviceType 是 exchange 自建 device 的預設值。
//
// 雛形agent 端 exchange request 只傳 pairing_token、不帶裝置資訊不動 local-tool
// 故 Name / DeviceType 在雲端用預設值。Phase 1 若 agent 帶上 serial / device_type 可改填真值。
const (
defaultPairedDeviceName = "local-tool (paired)"
defaultPairedDeviceType = "local-agent"
)
// ExchangeProvisionResult 回報 exchange 自建 device + 建 session token 的結果。
type ExchangeProvisionResult struct {
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 依序)操作。
//
// Provision 語意:成功回 ExchangeProvisionResult任一步失敗回 errorhandler 經 errors.go
// 映射成 5xx不洩漏 raw error
type PairingExchanger interface {
// Provision 自建一筆 deviceowner = userID並建一筆綁該 device 的 session token。
//
// parentTokenHash 為來源 pairing token 的 hash稽核鏈寫進 session_tokens.parent_token_hash
//
// 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 + 依 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 滿足)。
type pgSessionTokenCreator interface {
CreateTx(ctx context.Context, q db.Querier, userID, deviceID, parentTokenHash string, ttl time.Duration) (string, *auth.SessionToken, error)
}
// pgPairingExchanger 用單一 pgx 交易完成「自建 device + 建 session token」。
type pgPairingExchanger struct {
pool *pgxpool.Pool
devices pgDeviceSaver
sessionToken pgSessionTokenCreator
log *slog.Logger
}
// NewPostgresPairingExchanger 建立 Postgres 後端的 exchange 協調者。
func NewPostgresPairingExchanger(
pool *pgxpool.Pool,
devices pgDeviceSaver,
sessionToken pgSessionTokenCreator,
log *slog.Logger,
) PairingExchanger {
return &pgPairingExchanger{
pool: pool,
devices: devices,
sessionToken: sessionToken,
log: logOrDefault(log),
}
}
// 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, devices []ExchangeDeviceInput,
) (ExchangeProvisionResult, error) {
var res ExchangeProvisionResult
now := time.Now().UTC()
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, dev.ID, parentTokenHash, ttl)
if createErr != nil {
return fmt.Errorf("exchange: create session token: %w", createErr)
}
res.DeviceID = dev.ID
res.SessionPlaintext = plaintext
res.SessionInfo = info
return nil
})
if err != nil {
return ExchangeProvisionResult{}, err
}
return res, nil
}
// ── in-memory 後端 ────────────────────────────────────────────────────────────
// memSessionTokenCreator 是 in-memory store「建 session token」的能力
// (由 auth.InMemorySessionTokenStore 透過 SessionTokenStore interface 滿足)。
type memSessionTokenCreator interface {
Create(ctx context.Context, userID, deviceID, parentTokenHash string, ttl time.Duration) (string, *auth.SessionToken, error)
}
// memPairingExchanger 依序(非交易)完成自建 device + 建 session token。
//
// in-memory 為單機 local-dev fallback無跨 store 交易需求;依序執行已能保證行為一致。
type memPairingExchanger struct {
devices device.Repository
sessionToken memSessionTokenCreator
}
// NewInMemoryPairingExchanger 建立 in-memory 後端的 exchange 協調者。
func NewInMemoryPairingExchanger(
devices device.Repository,
sessionToken memSessionTokenCreator,
) PairingExchanger {
return &memPairingExchanger{
devices: devices,
sessionToken: sessionToken,
}
}
// Provision 自建(或依 serial 復用device 後建綁該 device 的 session token
// 依序非交易。serial 處理邏輯與 pgPairingExchanger 對齊WP-0
func (e *memPairingExchanger) Provision(
ctx context.Context, userID, parentTokenHash string, ttl time.Duration, devices []ExchangeDeviceInput,
) (ExchangeProvisionResult, error) {
now := time.Now().UTC()
dev := &device.Device{
ID: uuid.NewString(),
OwnerUserID: userID,
Name: defaultPairedDeviceName,
DeviceType: defaultPairedDeviceType,
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:
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, dev.ID, parentTokenHash, ttl)
if err != nil {
return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err)
}
return ExchangeProvisionResult{
DeviceID: dev.ID,
SessionPlaintext: plaintext,
SessionInfo: info,
}, nil
}