jim800121chen 59c57fa481 feat(device): WP-B repository 接 agents 模型 + exchange 重塑(A' 走向第二階段 Go 層)
- 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>
2026-07-16 11:40:17 +08:00

168 lines
6.0 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.

// Package agent 定義 Agent domain model 與 Repository 介面。
//
// 背景ADR-018 走向 A' / migration 0005WP-B B2
//
// 一個 agent = 一條已配對的 tunnel 連線(一台跑 local-agent 的機器)。一個 agent 底下可掛
// 多顆實體 USB deviceagents 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 PKDEFAULT gen_random_uuid()
// - owner_user_id UUID NOT NULL REFERENCES users(id)
// - name TEXT NOT NULL DEFAULT 'local-agent'
// - platform / agent_version TEXTnullableagent 上報,可空)
// - last_paired_at TIMESTAMPTZnullable
// - created_at / updated_at NOT NULL DEFAULT now()
// - deleted_at TIMESTAMPTZnullablesoft delete
//
// 兩個實作對齊(沿用專案慣例,比照 user / device package
// - InMemoryRepositorylocal-dev fallback / 單元測試(不檢查 FK
// - PostgresRepositoryDB-onpostgres_repository.go
//
// main.go 依 dbPool 是否非 nil 擇一注入。
package agent
import (
"context"
"errors"
"sync"
"time"
"github.com/google/uuid"
"visiona-backend/internal/db"
)
// newAgentID 產生一個新 agent idin-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」一台機器一條 tunnelexchange 用它判斷是否已有
// agent 可復用。若未來支援「一 owner 多機器多 agent」此方法需擴充識別鍵如 machine id
GetByOwnerTx(ctx context.Context, q db.Querier, ownerUserID string) (*Agent, error)
// GetOrCreateAgentTx 取得該 owner 的 agent不存在則建立一筆。
//
// 在傳入的 Querierpool 或 tx上執行供 exchange 與 device / session token 建立在
// 同一交易內(整筆原子)。回傳的 Agent 一定非 nil復用既有或新建並更新 last_paired_at。
//
// name / platform / agentVersion 為 agent 上報值(可空);新建時填入,復用時更新非空值 +
// last_paired_attx 內只更新這幾欄,避免全欄覆寫造成 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 的第一個未刪除 agentin-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 的 agentin-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)