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