B 設備管理(feature-device-mgmt-tdd): - POST /api/devices/:id/register + /unregister(owner 檢查 + representative 擋 + 已註冊擋 + SetRegistered 單欄翻轉,不碰 unpair 軟刪) - error codes ALREADY_REGISTERED / REPRESENTATIVE_DEVICE(409) - 不需 migration(registered_at 欄/index/讀寫已在 0005) C 模型共享(feature-model-sharing-tdd,security 深審 APPROVE): - migration 0006:models.visibility enum DEFAULT 'private'(零行為改變)+ model_shares 表 - canAccessModel single source(owner ∪ share ∪ public ∪ tenant):profile + download 共用 - GET /library(cursor keyset)/ GET /:id/profile(404 防列舉、GetWithOwner join name 不洩 email) / PATCH /:id/visibility(owner-only)/ shares CRUD / download 放寬 - tenant 因 OIDC 無 org claim 留 stub(恆空、安全預設;補 org claim 需重送 security 深審) reviewer 通過(B 三條紅線 / C security APPROVE 無 C/M)。130 dbtest 全綠、gosec 新檔 0。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
310 lines
11 KiB
Go
310 lines
11 KiB
Go
// 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)
|