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

224 lines
6.4 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 model 定義 Model domainKL 推論模型檔)與 Repository 介面。
//
// 對齊 database.md §2.3。雛形以 InMemoryRepository 實作;
// Phase 1 以 PostgresRepository 取代(同 interface
package model
import (
"context"
"errors"
"fmt"
"sync"
"time"
)
// ==========================================================================
// Errors
// ==========================================================================
var (
// ErrNotFound 表示指定 ID 的 Model 不存在。
ErrNotFound = errors.New("model: not found")
// ErrFileTooLarge 表示上傳檔案超過配置的大小上限MB
// 由 service 層檢查並回傳Repository 層本身不驗。
ErrFileTooLarge = errors.New("model: file too large")
)
// ==========================================================================
// Source 常數
// ==========================================================================
// Source 描述 Model 的來源。
type Source = string
const (
// SourceUploaded 使用者直接上傳。
SourceUploaded Source = "uploaded"
// SourceConverted 透過 converter 產生。
SourceConverted Source = "converted"
// SourcePreset 系統預設模型。
SourcePreset Source = "preset"
)
// ==========================================================================
// Model struct對齊 database.md §2.3
// ==========================================================================
// Model 是 KL 推論用的模型檔(通常 .nef 格式)。
type Model struct {
ID string `json:"id"`
OwnerUserID string `json:"ownerUserId"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
// 檔案資訊
StorageKey string `json:"storageKey"`
FileSize int64 `json:"fileSize"`
FileChecksum string `json:"fileChecksum,omitempty"` // sha256 hex
// 模型 metadata可選
TargetChip string `json:"targetChip,omitempty"`
InputShape []int `json:"inputShape,omitempty"`
Classes []string `json:"classes,omitempty"`
Framework string `json:"framework,omitempty"`
// 來源
Source Source `json:"source"`
SourceJobID string `json:"sourceJobId,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
UploadedAt *time.Time `json:"uploadedAt,omitempty"`
DeletedAt *time.Time `json:"deletedAt,omitempty"`
}
// ==========================================================================
// Filter / Repository
// ==========================================================================
// ListFilter 提供 List 方法的可選篩選條件。
type ListFilter struct {
OwnerUserID string // 必填於一般業務查詢;空字串表示不過濾(僅供管理用)
TargetChip string // 可選
Source Source // 可選
}
// Repository 是 Model 持久層介面。
//
// 所有查詢必須略過 DeletedAt != nil 的紀錄。
type Repository interface {
// Get 取得單一 Model不存在或已刪除回 ErrNotFound。
Get(ctx context.Context, id string) (*Model, error)
// List 依 filter 列出 Modelfilter.OwnerUserID 不同於空字串時限定擁有者。
List(ctx context.Context, filter ListFilter) ([]*Model, error)
// Save 新增或更新 Modelupsert by ID
Save(ctx context.Context, m *Model) error
// Delete 軟刪除。
Delete(ctx context.Context, id string) error
}
// ==========================================================================
// SizeValidator — 依 Config.Model.MaxSizeMB 驗證檔案大小
// ==========================================================================
// SizeValidator 提供 Model 上傳大小上限檢查。
//
// 由 api handler / service 層呼叫Repository 不耦合此邏輯。
type SizeValidator struct {
MaxSizeMB int
}
// NewSizeValidator 建立檔案大小驗證器maxSizeMB <= 0 時視為無限制(不建議生產用)。
func NewSizeValidator(maxSizeMB int) *SizeValidator {
return &SizeValidator{MaxSizeMB: maxSizeMB}
}
// Check 檢查 sizebytes是否超過上限超過回 ErrFileTooLarge。
func (v *SizeValidator) Check(size int64) error {
if v.MaxSizeMB <= 0 {
return nil
}
limit := int64(v.MaxSizeMB) * 1024 * 1024
if size > limit {
return fmt.Errorf("%w: %d bytes exceeds %d MB limit", ErrFileTooLarge, size, v.MaxSizeMB)
}
return nil
}
// ==========================================================================
// InMemoryRepository
// ==========================================================================
// InMemoryRepository 是 Phase 0 的記憶體實作。
type InMemoryRepository struct {
mu sync.RWMutex
models map[string]*Model
}
// NewInMemoryRepository 建立一個空的記憶體 Repository。
func NewInMemoryRepository() *InMemoryRepository {
return &InMemoryRepository{
models: make(map[string]*Model),
}
}
// Get 取得單一 Model。
func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error) {
r.mu.RLock()
defer r.mu.RUnlock()
m, ok := r.models[id]
if !ok || m.DeletedAt != nil {
return nil, ErrNotFound
}
cp := *m
return &cp, nil
}
// List 依條件列出 Model。
func (r *InMemoryRepository) List(ctx context.Context, filter ListFilter) ([]*Model, error) {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]*Model, 0)
for _, m := range r.models {
if m.DeletedAt != nil {
continue
}
if filter.OwnerUserID != "" && m.OwnerUserID != filter.OwnerUserID {
continue
}
if filter.TargetChip != "" && m.TargetChip != filter.TargetChip {
continue
}
if filter.Source != "" && m.Source != filter.Source {
continue
}
cp := *m
out = append(out, &cp)
}
return out, nil
}
// Save 新增或更新 Modelupsert by ID
func (r *InMemoryRepository) Save(ctx context.Context, m *Model) error {
if m == nil || m.ID == "" {
return errors.New("model: Save requires non-nil model with ID")
}
r.mu.Lock()
defer r.mu.Unlock()
now := time.Now().UTC()
cp := *m
if existing, ok := r.models[m.ID]; ok && existing.DeletedAt == nil {
cp.CreatedAt = existing.CreatedAt
} else if cp.CreatedAt.IsZero() {
cp.CreatedAt = now
}
cp.UpdatedAt = now
r.models[m.ID] = &cp
return nil
}
// Delete 軟刪除。
func (r *InMemoryRepository) Delete(ctx context.Context, id string) error {
r.mu.Lock()
defer r.mu.Unlock()
m, ok := r.models[id]
if !ok || m.DeletedAt != nil {
return ErrNotFound
}
now := time.Now().UTC()
m.DeletedAt = &now
m.UpdatedAt = now
return nil
}
// 編譯時檢查:確保 InMemoryRepository 實作 Repository。
var _ Repository = (*InMemoryRepository)(nil)