DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。 OIDC / pairing FK 修復(接 DB 上線必要): - 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback 驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID) - pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token; 自建 device 空 serial 寫 NULL(避免撞 partial unique) - device.SaveTx / session.CreateTx 新增 tx-aware 版本 B4 model metadata: - 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model: converter_client → flow → adapter → model.Model → PG → ModelResponse DTO - input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性 - 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md nginx healthz(部署層): - 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB - 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因) storage error 統一映射(不洩漏 storage 後端細節)。 測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、 internal/db(redis)、relay/session 弱處,含 testcontainers integration。 DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
226 lines
7.1 KiB
Go
226 lines
7.1 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"
|
||
)
|
||
|
||
// ==========================================================================
|
||
// 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)。
|
||
type Device struct {
|
||
ID string `json:"id"`
|
||
OwnerUserID string `json:"ownerUserId"`
|
||
Name string `json:"name"`
|
||
DeviceType string `json:"deviceType"`
|
||
SerialNumber string `json:"serialNumber,omitempty"`
|
||
|
||
// 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
|
||
|
||
// 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) 查詢。
|
||
func (r *InMemoryRepository) GetBySerial(ctx context.Context, ownerUserID, serial string) (*Device, error) {
|
||
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 的所有未刪除 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 {
|
||
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
|
||
}
|
||
|
||
// 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)
|