visionA/visionA-backend/internal/api/pairing_exchange.go
jim800121chen cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。

OIDC / pairing FK 修復(接 DB 上線必要):
- 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback
  驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID)
- pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token;
  自建 device 空 serial 寫 NULL(避免撞 partial unique)
- device.SaveTx / session.CreateTx 新增 tx-aware 版本

B4 model metadata:
- 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model:
  converter_client → flow → adapter → model.Model → PG → ModelResponse DTO
- input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性
- 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md

nginx healthz(部署層):
- 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB
- 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因)

storage error 統一映射(不洩漏 storage 後端細節)。

測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、
internal/db(redis)、relay/session 弱處,含 testcontainers integration。
DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-21 06:36:35 +08:00

211 lines
8.8 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"
"fmt"
"log/slog"
"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 // 本次自建的 device id
SessionPlaintext string // 新 session token 原文caller 只此一次能拿到)
SessionInfo *auth.SessionToken // session token 儲存層表示(含 ExpiresAt
}
// 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
Provision(ctx context.Context, userID, parentTokenHash string, ttl time.Duration) (ExchangeProvisionResult, error)
}
// ── Postgres 後端 ─────────────────────────────────────────────────────────────
// pgDeviceSaver 是 device 在 tx 內 upsert 的能力(由 device.PostgresRepository 滿足)。
type pgDeviceSaver interface {
SaveTx(ctx context.Context, q db.Querier, d *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 在單一交易內:自建 device → 建綁該 device 的 session token。
//
// 任一步失敗整筆 rollbackdevice 不會「已建但沒 token」殘留在 DB
func (e *pgPairingExchanger) Provision(
ctx context.Context, userID, parentTokenHash string, ttl time.Duration,
) (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,
}
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)
if createErr != nil {
return fmt.Errorf("exchange: create session token: %w", createErr)
}
res.DeviceID = deviceID
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 自建 device 後建綁該 device 的 session token依序非交易
func (e *memPairingExchanger) Provision(
ctx context.Context, userID, parentTokenHash string, ttl time.Duration,
) (ExchangeProvisionResult, error) {
deviceID := uuid.NewString()
now := time.Now().UTC()
dev := &device.Device{
ID: deviceID,
OwnerUserID: userID,
Name: defaultPairedDeviceName,
DeviceType: defaultPairedDeviceType,
RemoteStatus: device.RemoteStatusOffline,
Status: device.USBStatusUnknown,
PairedAt: &now,
CreatedAt: now,
UpdatedAt: now,
}
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)
if err != nil {
return ExchangeProvisionResult{}, fmt.Errorf("exchange: create session token: %w", err)
}
return ExchangeProvisionResult{
DeviceID: deviceID,
SessionPlaintext: plaintext,
SessionInfo: info,
}, nil
}