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>
211 lines
8.8 KiB
Go
211 lines
8.8 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"
|
||
"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;任一步失敗回 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)。
|
||
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。
|
||
//
|
||
// 任一步失敗整筆 rollback(device 不會「已建但沒 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
|
||
}
|