// 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)