jim800121chen c63886a194 feat(visionA-backend): Phase 0.9 模型庫存取 — FAA delegated download token(B1+B2)
對齊 ADR-017 v1.2:模型庫下載走 visionA 簽 MC delegated token → Client 直連 FAA。

B2 — MC download token client(internal/fileaccess):
- DownloadTokenIssuer: GetServiceToken(打 MC /oauth/token,client_credentials +
  scope files:download.delegate,含 token cache)+ IssueDownloadToken(打 MC Issue 簽 fdt_)
- secret / service token / fdt token 三層全程用 hashShort 遮罩不 log
- FileAccessConfig + VISIONA_FILE_ACCESS_* env + main.go wire(Enabled() 才接)

B1 — object_key 斷層:
- model.Model 加 FAAObjectKey(json:"-" 不揭露前端)
- PromoteToModels 寫入(用 promote response TargetObjectKey = models/{userID}/{jobID}.nef)
- 三方對映天然一致(visionA Issue / FAA path / MC validate)
- 第一階段框死只 Source=converted 類 model,上傳類 download 回 501

download endpoint:
- GET /api/models/:id/download(owner-only)→ {download_url, token, expires_at}
- 前端帶 Authorization: Bearer 直連 FAA(不經 visionA、不經 AWS)
- 401/403/404/501/502 分明,502 對外 mask 不洩漏 MC 內部狀態

測試: 13 + 8 unit test(mock MC + fake issuer,httptest 驗真 HTTP);go build/vet/test 全綠。
Reviewer: 0 Critical / 0 Major / 3 Minor / 4 Suggestion,通過。

技術債(正式上線前): 第一階段 PoC 共用 FAA service client,MC 規範禁止 client 混用
usage、secret 不共用,須 MC 配發 visionA 專屬 usage=file_api client。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-07 04:06:09 +08:00

236 lines
7.3 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
// FAAObjectKey 是該 model 在 File Access Agent 上的 object keyADR-017 (a) B1
//
// 只有「轉檔→promote 進 FAA」類 modelSource=converted有值——promote 時由
// PromoteToModels 寫入(= converter promote 的 target_object_key命名 models/{userID}/{jobID}.nef
// 上傳類 modelSource=uploaded只在 visionA 自己 storage、不在 FAA此欄位留空。
//
// model download endpointGET /api/models/:id/download用此欄位非 StorageKey去 MC
// Issue download token + 組 FAA URL留空時回 501第一階段不支援上傳類 FAA 直連)。
//
// nullableDB 為 NULLdatabase.md §2.3 待補欄位見回報JSON `-` 完全不序列化到 API 回應,不向前端揭露 FAA 內部 object key。
FAAObjectKey string `json:"-"` // 不對前端揭露(內部 storage keyADR-017 決策 2 防曝露)
// 模型 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)