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

276 lines
9.3 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 device 定義 Device domain model 與 Repository 介面。
//
// 對齊 database.md §2.2(雙狀態模型 — Minor-3與 §3Repository interface
// 雛形以 InMemoryRepository 實作Phase 1 新增 PostgresRepository 取代。
package device
import (
"context"
"errors"
"sync"
"time"
"visiona-backend/internal/db"
)
// ==========================================================================
// Errors
// ==========================================================================
var (
// ErrNotFound 表示指定 ID 的 Device 不存在。
ErrNotFound = errors.New("device: not found")
)
// ==========================================================================
// Remote / USB 狀態常數(對齊 database.md §2.2
// ==========================================================================
// RemoteStatus 是雲端對 tunnel 連線的觀察值。
type RemoteStatus = string
const (
// RemoteStatusOnline 表示 tunnel 有效、雲端可達。
RemoteStatusOnline RemoteStatus = "online"
// RemoteStatusOffline 表示 tunnel 斷線或從未連上。
RemoteStatusOffline RemoteStatus = "offline"
// RemoteStatusReconnecting 表示 tunnel 短暫斷線、local agent 重連中。
RemoteStatusReconnecting RemoteStatus = "reconnecting"
// RemoteStatusError 表示 tunnel 發生未預期錯誤yamux 異常等)。
RemoteStatusError RemoteStatus = "error"
)
// USBStatus 是 local agent 從 Kneron SDK 讀到的 USB 狀態。
type USBStatus = string
const (
// USBStatusOnline USB 插著且可用。
USBStatusOnline USBStatus = "online"
// USBStatusOffline USB 拔掉了。
USBStatusOffline USBStatus = "offline"
// USBStatusUnknown 尚未回報 / 初始狀態。
USBStatusUnknown USBStatus = "unknown"
)
// ==========================================================================
// Device struct
// ==========================================================================
// Device 對應 database.md §2.2 的 Device 實體。
//
// 雙狀態說明Minor-3
// - StatusUSB-levellocal agent 觀察到的 USB 連接狀態
// - RemoteStatustunnel-level雲端觀察到的 tunnel 連線狀態
//
// 前端優先顯示 RemoteStatus次要顯示 Status見 TDD §10.5.1)。
//
// A' 模型欄位ADR-018 / migration 0005WP-B B1 加入):
// - AgentID所屬 agent一條 tunnel 連線。NULL=遷移前舊資料 / 尚未歸入 agent。
// - AgentLocalDeviceIDlocal agent 端合成 id如 kl520-0路由/除錯輔助。
// - RegisteredAt註冊軸NULL=未註冊)。連線軸 × 註冊軸 → 前端三色WP-F
// - IsRepresentativetrue=agent 佔位/代表 device非真 USB綁 session_tokens
// false=真實 USB device。List/Get 只列 false 者WP-B B4
type Device struct {
ID string `json:"id"`
OwnerUserID string `json:"ownerUserId"`
Name string `json:"name"`
DeviceType string `json:"deviceType"`
SerialNumber string `json:"serialNumber,omitempty"`
// A' 模型agents 掛載 + 註冊軸 + representative 區分migration 0005
AgentID string `json:"agentId,omitempty"`
AgentLocalDeviceID string `json:"agentLocalDeviceId,omitempty"`
RegisteredAt *time.Time `json:"registeredAt,omitempty"`
IsRepresentative bool `json:"isRepresentative"`
// tunnel-level 狀態
RemoteStatus RemoteStatus `json:"remoteStatus"`
LastSeenAt *time.Time `json:"lastSeenAt,omitempty"`
LastConnectedAt *time.Time `json:"lastConnectedAt,omitempty"`
// USB-level 狀態
Status USBStatus `json:"status"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
PairedAt *time.Time `json:"pairedAt,omitempty"`
DeletedAt *time.Time `json:"deletedAt,omitempty"`
}
// ==========================================================================
// Repository interface
// ==========================================================================
// Repository 是 Device 持久層介面。
//
// 所有查詢方法**必須略過 DeletedAt != nil 的紀錄**soft delete
// Phase 1 的 PostgresRepository 會加上 `WHERE deleted_at IS NULL`。
type Repository interface {
// Get 取得單一 device不存在或已軟刪除回 ErrNotFound。
Get(ctx context.Context, id string) (*Device, error)
// GetBySerial 以 (ownerUserID, serialNumber) 查詢(避免同 user 重複註冊同 serial
GetBySerial(ctx context.Context, ownerUserID, serial string) (*Device, error)
// List 列出某 user 的所有未刪除device。
List(ctx context.Context, ownerUserID string) ([]*Device, error)
// Save 新增或更新一筆 deviceupsert 語意by ID
// 實作應更新 UpdatedAt若為新建則同時設定 CreatedAt。
Save(ctx context.Context, d *Device) error
// Delete 標記為軟刪除(設定 DeletedAt
Delete(ctx context.Context, id string) error
}
// ==========================================================================
// InMemoryRepository
// ==========================================================================
// InMemoryRepository 是 Phase 0 雛形的記憶體實作。
type InMemoryRepository struct {
mu sync.RWMutex
devices map[string]*Device
}
// NewInMemoryRepository 建立一個空的記憶體 Repository。
func NewInMemoryRepository() *InMemoryRepository {
return &InMemoryRepository{
devices: make(map[string]*Device),
}
}
// Get 取得單一 device。
func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Device, error) {
r.mu.RLock()
defer r.mu.RUnlock()
d, ok := r.devices[id]
if !ok || d.DeletedAt != nil {
return nil, ErrNotFound
}
cp := *d
return &cp, nil
}
// GetBySerial 以 (owner, serial) 查詢。
//
// 空 serial 分支WP-0 Mi#4對齊 PostgresRepository.GetBySerialTx以序號查詢對「空序號」
// 無業務意義,直接回 ErrNotFound不再用空字串比對撈到 representative / 佔位 device
func (r *InMemoryRepository) GetBySerial(ctx context.Context, ownerUserID, serial string) (*Device, error) {
if serial == "" {
return nil, ErrNotFound
}
r.mu.RLock()
defer r.mu.RUnlock()
for _, d := range r.devices {
if d.DeletedAt != nil {
continue
}
if d.OwnerUserID == ownerUserID && d.SerialNumber == serial {
cp := *d
return &cp, nil
}
}
return nil, ErrNotFound
}
// List 列出某 user 的所有未刪除、非 representative 的 deviceWP-B B4
//
// is_representative=false filter 對齊 PostgresRepository.Listrepresentative device
// agent 連線佔位)不出現在使用者裝置清單,只列真實 USB device。
func (r *InMemoryRepository) List(ctx context.Context, ownerUserID string) ([]*Device, error) {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]*Device, 0)
for _, d := range r.devices {
if d.DeletedAt != nil || d.IsRepresentative {
continue
}
if d.OwnerUserID == ownerUserID {
cp := *d
out = append(out, &cp)
}
}
return out, nil
}
// Save 新增或更新 deviceupsert by ID
//
// remote_status / status 補預設值offline / unknown與 PostgresRepository.Save 一致:
// devices 表的這兩個欄位是 NOT NULL DEFAULT 'offline' / 'unknown'PG Save 對空值補預設後寫入。
// in-memory 在此同樣補預設,避免「同一筆空狀態 device 經 PG 讀出 offline/unknown、
// 經 in-memory 讀出空字串」的隱性落差(前端顯示 RemoteStatus見 api/devices.go
func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error {
if d == nil || d.ID == "" {
return errors.New("device: Save requires non-nil device with ID")
}
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now().UTC()
// Copy 避免外部後續修改影響 store
cp := *d
// 補預設值,對齊 PG NOT NULL DEFAULT 欄位語意(見上方說明)。
if cp.RemoteStatus == "" {
cp.RemoteStatus = RemoteStatusOffline
}
if cp.Status == "" {
cp.Status = USBStatusUnknown
}
if existing, ok := r.devices[d.ID]; ok && existing.DeletedAt == nil {
cp.CreatedAt = existing.CreatedAt // 保留原始 CreatedAt
} else if cp.CreatedAt.IsZero() {
cp.CreatedAt = now
}
cp.UpdatedAt = now
r.devices[d.ID] = &cp
return nil
}
// GetRepresentativeByAgentTx 取得某 agent 的 representative deviceis_representative=true、
// 未刪除);不存在回 ErrNotFound。in-memory 忽略 q無交易需求
//
// 語意對齊 PostgresRepository一 agent 一顆 representative多筆殘留時取 CreatedAt 最早者
// 保決定性map 迭代順序不定,故顯式挑最早)。
func (r *InMemoryRepository) GetRepresentativeByAgentTx(_ context.Context, _ db.Querier, agentID string) (*Device, error) {
r.mu.RLock()
defer r.mu.RUnlock()
var found *Device
for _, d := range r.devices {
if d.DeletedAt != nil || !d.IsRepresentative || d.AgentID != agentID {
continue
}
if found == nil || d.CreatedAt.Before(found.CreatedAt) {
found = d
}
}
if found == nil {
return nil, ErrNotFound
}
cp := *found
return &cp, nil
}
// Delete 標記 device 為軟刪除。
func (r *InMemoryRepository) Delete(ctx context.Context, id string) error {
r.mu.Lock()
defer r.mu.Unlock()
d, ok := r.devices[id]
if !ok || d.DeletedAt != nil {
return ErrNotFound
}
now := time.Now().UTC()
d.DeletedAt = &now
d.UpdatedAt = now
return nil
}
// 編譯時檢查:確保 InMemoryRepository 實作 Repository。
var _ Repository = (*InMemoryRepository)(nil)