jim800121chen cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
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>
2026-06-21 06:36:35 +08:00

226 lines
7.1 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"
)
// ==========================================================================
// 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)。
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 新增或更新一筆 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) 查詢。
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 新增或更新 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
}
// 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)