// Package device 定義 Device domain model 與 Repository 介面。 // // 對齊 database.md §2.2(雙狀態模型 — Minor-3)與 §3(Repository 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): // - Status(USB-level):local agent 觀察到的 USB 連接狀態 // - RemoteStatus(tunnel-level):雲端觀察到的 tunnel 連線狀態 // // 前端優先顯示 RemoteStatus,次要顯示 Status(見 TDD §10.5.1)。 // // A' 模型欄位(ADR-018 / migration 0005,WP-B B1 加入): // - AgentID:所屬 agent(一條 tunnel 連線)。NULL=遷移前舊資料 / 尚未歸入 agent。 // - AgentLocalDeviceID:local agent 端合成 id(如 kl520-0),路由/除錯輔助。 // - RegisteredAt:註冊軸(NULL=未註冊)。連線軸 × 註冊軸 → 前端三色(WP-F)。 // - IsRepresentative:true=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 新增或更新一筆 device(upsert 語意,by ID)。 // 實作應更新 UpdatedAt;若為新建則同時設定 CreatedAt。 Save(ctx context.Context, d *Device) error // SetRegistered 設定 / 清除註冊時間(註冊軸單欄翻轉,feature-device-mgmt-tdd §3.3)。 // // - at != nil → 註冊(registered_at = *at)。 // - at == nil → 取消註冊(registered_at = NULL),保留列(絕不軟刪 / 撤 token)。 // // 只作用於「未刪除、非 representative」的 device(縱深第三層,配合 handler 的 owner / // representative / already-registered 檢查);不符則回 ErrNotFound。register 端的 // already-registered 判斷由 handler 先擋(回 409),本方法不重複判。 SetRegistered(ctx context.Context, id string, at *time.Time) 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 的 device(WP-B B4)。 // // is_representative=false filter 對齊 PostgresRepository.List:representative 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 新增或更新 device(upsert 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 } // SetRegistered 設定 / 清除某 device 的 registered_at(單欄翻轉)。 // // 語意對齊 PostgresRepository.SetRegistered:只作用於未刪除、非 representative 的 device, // 不符(不存在 / 已軟刪 / representative)回 ErrNotFound(縱深第三層)。一律更新 UpdatedAt。 // at==nil 清成未註冊(保留列),at!=nil 設為註冊時間。 func (r *InMemoryRepository) SetRegistered(ctx context.Context, id string, at *time.Time) error { r.mu.Lock() defer r.mu.Unlock() d, ok := r.devices[id] if !ok || d.DeletedAt != nil || d.IsRepresentative { return ErrNotFound } now := time.Now().UTC() if at != nil { t := at.UTC() d.RegisteredAt = &t } else { d.RegisteredAt = nil } d.UpdatedAt = now return nil } // GetRepresentativeByAgentTx 取得某 agent 的 representative device(is_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)