- Device struct 加 4 欄(agent_id/agent_local_device_id/registered_at/
is_representative)+ deviceColumns 13→17 + scanDevice/SaveTx 讀寫新欄
- 新增 internal/agent package(domain + interface + in-memory + PG repo):
GetOrCreateAgentTx/GetByOwnerTx,advisory lock 序列化同 owner get-or-create
- exchange 重塑:建/復用 agent → representative device(綁 session_tokens、
serial=NULL)→ loop 建 N 顆真 USB device(R1 完整 N 顆非只第一顆)
- List filter is_representative=false + DeviceListItem 回傳 agent_id/registered_at
- 併入 WP-0/0005 follow-up Minor:Mi#2 lost-update 收斂(tx 內查詢+局部更新)
/ Mi#3 過時註解 / Mi#4 空 serial 回 ErrNotFound / Mi#5 serial 白名單
^0x[0-9A-Fa-f]{8}$ + 去重 / S-1 firmware forward-compat / S-2 device Name 衍生
守 ADR-018 A'(一 owner N agents、session_tokens FK 物理不動、不加 owner
unique 為多機器留路)。Reviewer 通過(0C/0M)。5 套件 dbtest 130 全綠
(db 19/device 37/agent 13/api 172/cmd 60)、build/vet/test 綠。
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
168 lines
6.0 KiB
Go
168 lines
6.0 KiB
Go
// Package agent 定義 Agent domain model 與 Repository 介面。
|
||
//
|
||
// 背景(ADR-018 走向 A' / migration 0005,WP-B B2):
|
||
//
|
||
// 一個 agent = 一條已配對的 tunnel 連線(一台跑 local-agent 的機器)。一個 agent 底下可掛
|
||
// 多顆實體 USB device(agents 1 ─ N devices)。exchange 時建/復用該 owner 的 agent,再建
|
||
// representative device(綁 session_tokens)+ N 顆真 USB device(皆 agent_id=此 agent)。
|
||
//
|
||
// 對齊 migrations/0005_create_agents.up.sql 的 agents 表 schema:
|
||
// - id UUID PK(DEFAULT gen_random_uuid())
|
||
// - owner_user_id UUID NOT NULL REFERENCES users(id)
|
||
// - name TEXT NOT NULL DEFAULT 'local-agent'
|
||
// - platform / agent_version TEXT(nullable,agent 上報,可空)
|
||
// - last_paired_at TIMESTAMPTZ(nullable)
|
||
// - created_at / updated_at NOT NULL DEFAULT now()
|
||
// - deleted_at TIMESTAMPTZ(nullable,soft delete)
|
||
//
|
||
// 兩個實作對齊(沿用專案慣例,比照 user / device package):
|
||
// - InMemoryRepository:local-dev fallback / 單元測試(不檢查 FK)。
|
||
// - PostgresRepository:DB-on(postgres_repository.go)。
|
||
//
|
||
// main.go 依 dbPool 是否非 nil 擇一注入。
|
||
package agent
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/google/uuid"
|
||
|
||
"visiona-backend/internal/db"
|
||
)
|
||
|
||
// newAgentID 產生一個新 agent id(in-memory 用;PG 版由 DB gen_random_uuid() 產)。
|
||
func newAgentID() string { return uuid.NewString() }
|
||
|
||
// ErrNotFound 表示指定條件的 Agent 不存在(或已軟刪除)。
|
||
var ErrNotFound = errors.New("agent: not found")
|
||
|
||
// Agent 對應 migrations/0005 的 agents 表。
|
||
type Agent struct {
|
||
ID string `json:"id"`
|
||
OwnerUserID string `json:"ownerUserId"`
|
||
Name string `json:"name"`
|
||
Platform string `json:"platform,omitempty"`
|
||
AgentVersion string `json:"agentVersion,omitempty"`
|
||
LastPairedAt *time.Time `json:"lastPairedAt,omitempty"`
|
||
|
||
CreatedAt time.Time `json:"createdAt"`
|
||
UpdatedAt time.Time `json:"updatedAt"`
|
||
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
||
}
|
||
|
||
// Repository 是 Agent 持久層介面。
|
||
//
|
||
// 所有查詢方法必須略過 deleted_at IS NOT NULL 的紀錄(soft delete)。
|
||
type Repository interface {
|
||
// GetByOwnerTx 取得該 owner 的(第一個未刪除)agent;不存在回 ErrNotFound。
|
||
//
|
||
// 現階段語意「一 owner 一 agent」(一台機器一條 tunnel):exchange 用它判斷是否已有
|
||
// agent 可復用。若未來支援「一 owner 多機器多 agent」,此方法需擴充識別鍵(如 machine id)。
|
||
GetByOwnerTx(ctx context.Context, q db.Querier, ownerUserID string) (*Agent, error)
|
||
|
||
// GetOrCreateAgentTx 取得該 owner 的 agent,不存在則建立一筆。
|
||
//
|
||
// 在傳入的 Querier(pool 或 tx)上執行,供 exchange 與 device / session token 建立在
|
||
// 同一交易內(整筆原子)。回傳的 Agent 一定非 nil(復用既有或新建);並更新 last_paired_at。
|
||
//
|
||
// name / platform / agentVersion 為 agent 上報值(可空);新建時填入,復用時更新非空值 +
|
||
// last_paired_at(tx 內只更新這幾欄,避免全欄覆寫造成 lost-update)。
|
||
GetOrCreateAgentTx(ctx context.Context, q db.Querier, ownerUserID, name, platform, agentVersion string, pairedAt time.Time) (*Agent, error)
|
||
}
|
||
|
||
// ==========================================================================
|
||
// InMemoryRepository
|
||
// ==========================================================================
|
||
|
||
// InMemoryRepository 是 local-dev fallback / 單元測試用的記憶體實作。
|
||
type InMemoryRepository struct {
|
||
mu sync.RWMutex
|
||
agents map[string]*Agent // keyed by id
|
||
}
|
||
|
||
// NewInMemoryRepository 建立一個空的記憶體 Repository。
|
||
func NewInMemoryRepository() *InMemoryRepository {
|
||
return &InMemoryRepository{agents: make(map[string]*Agent)}
|
||
}
|
||
|
||
// GetByOwnerTx 找該 owner 的第一個未刪除 agent(in-memory 忽略 q)。
|
||
func (r *InMemoryRepository) GetByOwnerTx(_ context.Context, _ db.Querier, ownerUserID string) (*Agent, error) {
|
||
r.mu.RLock()
|
||
defer r.mu.RUnlock()
|
||
if a := r.findActiveByOwnerLocked(ownerUserID); a != nil {
|
||
cp := *a
|
||
return &cp, nil
|
||
}
|
||
return nil, ErrNotFound
|
||
}
|
||
|
||
// GetOrCreateAgentTx 復用或新建該 owner 的 agent(in-memory 忽略 q,無交易需求)。
|
||
//
|
||
// 語意對齊 Postgres 版:復用時只更新 last_paired_at + 非空的 name/platform/agentVersion,
|
||
// 不覆寫其他欄位(避免 lost-update)。
|
||
func (r *InMemoryRepository) GetOrCreateAgentTx(
|
||
_ context.Context, _ db.Querier, ownerUserID, name, platform, agentVersion string, pairedAt time.Time,
|
||
) (*Agent, error) {
|
||
if ownerUserID == "" {
|
||
return nil, errors.New("agent: GetOrCreateAgentTx requires ownerUserID")
|
||
}
|
||
r.mu.Lock()
|
||
defer r.mu.Unlock()
|
||
|
||
now := time.Now().UTC()
|
||
paired := pairedAt.UTC()
|
||
|
||
if a := r.findActiveByOwnerLocked(ownerUserID); a != nil {
|
||
// 復用:只更新 last_paired_at + 非空上報欄位(對齊 PG tx 內局部更新)。
|
||
a.LastPairedAt = &paired
|
||
a.UpdatedAt = now
|
||
if name != "" {
|
||
a.Name = name
|
||
}
|
||
if platform != "" {
|
||
a.Platform = platform
|
||
}
|
||
if agentVersion != "" {
|
||
a.AgentVersion = agentVersion
|
||
}
|
||
cp := *a
|
||
return &cp, nil
|
||
}
|
||
|
||
// 新建。
|
||
if name == "" {
|
||
name = "local-agent" // 對齊 agents.name DEFAULT
|
||
}
|
||
a := &Agent{
|
||
ID: newAgentID(),
|
||
OwnerUserID: ownerUserID,
|
||
Name: name,
|
||
Platform: platform,
|
||
AgentVersion: agentVersion,
|
||
LastPairedAt: &paired,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}
|
||
r.agents[a.ID] = a
|
||
cp := *a
|
||
return &cp, nil
|
||
}
|
||
|
||
// findActiveByOwnerLocked 找該 owner 的第一個未刪除 agent(呼叫端須持鎖)。
|
||
//
|
||
// map 迭代順序不定,但「一 owner 一 agent」下最多一筆 active,故無歧義。
|
||
func (r *InMemoryRepository) findActiveByOwnerLocked(ownerUserID string) *Agent {
|
||
for _, a := range r.agents {
|
||
if a.DeletedAt == nil && a.OwnerUserID == ownerUserID {
|
||
return a
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// 編譯時檢查:確保 InMemoryRepository 實作 Repository。
|
||
var _ Repository = (*InMemoryRepository)(nil)
|