jim800121chen 22f0837ba8 feat(visionA-backend): Phase 0 → 0.7 雲端後端(雙 binary + OIDC BFF + stage 部署)
從 edge-ai-platform POC 轉為正式產品的雲端後端,含以下整合階段:

- Phase 0:雛形骨架 — `cmd/api-server` (REST :3721) + `cmd/remote-proxy`
  (tunnel :3800 / internal :3801) 雙 binary 共用 internal/,沿用 POC 的
  WebSocket+yamux tunnel 協定但解耦 relay 與 API
- Phase 0.6:OIDC BFF 接 Innovedus Member Center
  - internal/oidc package(coreos/go-oidc + PKCE S256 + state + nonce)
  - internal/usersession package(HMAC-SHA256 cookie + RotateSessionID
    防 session fixation, OWASP ASVS V3.2.1)
  - 4 個 OIDC handler(/api/auth/login|callback|me|logout)+ AuthMiddleware
  - 完全拔除 StaticAuthProvider,OIDC 是唯一認證路徑
  - 9 個 ADR(含 ADR-010 BFF / ADR-011 取代 static auth /
    ADR-012 pending session shared cookie / ADR-013 PKCE-only public client)
- Phase 0.7:A1 改造 + security audit 修復
  - OIDC ClientSecret 變選填,支援 stage MC 的 public PKCE-only client
    (AuthStyleInParams 強制 token endpoint 不送 client_secret)
  - 預留 ServiceClient* 欄位給未來 client_credentials grant
  - 移除 13+ 處 resolveUserID(uc, StaticUserID) fallback 改 strict mode
    (Audit C1:multi-tenant 隔離破口)
  - Pairing exchange MarkUsed 失敗 abort + revoke session token(Audit M3)
  - 新增 all_endpoints_require_auth_test 整合測試(51 endpoint × 401)

驗證:go test -race -count=3 ./... 17 packages 全綠 / go vet 0 warning

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:21:20 +08:00

214 lines
6.5 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
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
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)