從 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>
224 lines
6.4 KiB
Go
224 lines
6.4 KiB
Go
// Package model 定義 Model domain(KL 推論模型檔)與 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 列出 Model;filter.OwnerUserID 不同於空字串時限定擁有者。
|
||
List(ctx context.Context, filter ListFilter) ([]*Model, error)
|
||
|
||
// Save 新增或更新 Model(upsert 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 檢查 size(bytes)是否超過上限,超過回 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 新增或更新 Model(upsert 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)
|