- 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>
299 lines
13 KiB
Go
299 lines
13 KiB
Go
// pairing_exchange.go — pairing exchange 自建 device 的協調者(DB-on FK 收尾,問題 #2)。
|
||
//
|
||
// 背景:
|
||
//
|
||
// session_tokens.device_id 是 NOT NULL FK → devices(id),但雛形 pairing 流程從頭到尾沒有任何
|
||
// production 路徑會建 device(grep 確認 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):
|
||
// - 讓 handler(pairing.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 裝置會撞同一個值,不可當唯一鍵 / 路由鍵——
|
||
// 視同「無序號」寫 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 依序)操作。
|
||
//
|
||
// Provision 語意:成功回 ExchangeProvisionResult;任一步失敗回 error(handler 經 errors.go
|
||
// 映射成 5xx,不洩漏 raw error)。
|
||
type PairingExchanger interface {
|
||
// Provision 自建一筆 device(owner = 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。
|
||
//
|
||
// 任一步失敗整筆 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, 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
|
||
}
|