// Package model 定義 Model domain(KL 推論模型檔)與 Repository 介面。 // // 對齊 database.md §2.3。雛形以 InMemoryRepository 實作; // Phase 1 以 PostgresRepository 取代(同 interface)。 package model import ( "context" "errors" "fmt" "sort" "strings" "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" ) // ========================================================================== // Visibility 常數(廣播式公開對象;對齊 feature-model-sharing-tdd.md §3.1) // ========================================================================== // Visibility 是 Model 的「公開對象」廣播維度:一個 model 一個值。 // 與 model_shares(點對點分享)正交。 type Visibility = string const ( // VisibilityPrivate 僅擁有者可見(= 現況預設行為;新 model 與既有 model 皆為此值)。 VisibilityPrivate Visibility = "private" // VisibilityTenant 同租戶(同 org_id)可見。 // 依賴 users.org_id;OIDC 現況不帶 org claim(見 postgres_repository.go List 說明), // 故目前 tenant 命中集合恆為空(安全預設)——schema/predicate 就緒,等 OIDC 補 org claim 即生效。 VisibilityTenant Visibility = "tenant" // VisibilityPublic 全平台已登入 user 可見。 VisibilityPublic Visibility = "public" ) // IsValidVisibility 回報 v 是否為合法的 visibility 值(handler 驗 PATCH 輸入用)。 func IsValidVisibility(v string) bool { switch v { case VisibilityPrivate, VisibilityTenant, VisibilityPublic: return true default: return false } } // ========================================================================== // AccessLevel 常數(可見性判斷的結果;對齊 TDD §6 SEC-2 / api §1 my_access) // ========================================================================== // AccessLevel 是「當前 user 對某 model 的有效權限」。 // 由 canAccessModel(single source of truth)計算,取最高。 type AccessLevel = string const ( // AccessNone 無可見性(不該看到此 model;enumeration 防護一律回 404)。 AccessNone AccessLevel = "none" // AccessViewer 可 list / get profile / download(visibility 命中或 share role=viewer)。 AccessViewer AccessLevel = "viewer" // AccessEditor 可改 metadata(share role=editor);含 viewer 全部權限。 AccessEditor AccessLevel = "editor" // AccessOwner 擁有者,完整權限(可改 visibility / 刪除 / 分享)。 AccessOwner AccessLevel = "owner" ) // ========================================================================== // 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"` // Visibility 是廣播式公開對象(private / tenant / public,對齊 model_sharing 功能)。 // 既有 / 新建 model 預設 VisibilityPrivate(DB DEFAULT 'private',零行為改變)。 Visibility Visibility `json:"visibility"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` UploadedAt *time.Time `json:"uploadedAt,omitempty"` DeletedAt *time.Time `json:"deletedAt,omitempty"` } // ========================================================================== // ModelShare(點對點分享關聯;對齊 ADR-017 決策 3 B1 / model_shares 表) // ========================================================================== // ModelShare 是一筆「model 分享給特定 grantee」的授權紀錄。 type ModelShare struct { ModelID string `json:"modelId"` GranteeUserID string `json:"granteeUserId"` Role string `json:"role"` // 'viewer' | 'editor' GrantedBy string `json:"grantedBy"` CreatedAt time.Time `json:"createdAt"` } // LibraryItem 是共享庫列表的一列:Model + 該列相對於查詢 user 的存取資訊。 // // owner_name / owner_org_id 由 repository 一次 JOIN users 帶出(避免 handler N+1)。 // SharedWithMe / MyAccess 由 repository 依查詢 user 身份計算填入。 type LibraryItem struct { Model *Model OwnerName string OwnerOrgID string SharedWithMe bool MyAccess AccessLevel } // LibraryQuery 是共享庫列表查詢的參數(對齊 api §1)。 // // Viewer 身份(UserID / UserOrgID)決定可見範圍;其餘為 filter / 排序 / cursor 分頁。 type LibraryQuery struct { // Viewer 身份(可見性 predicate 的 input)。 UserID string UserOrgID string // 空字串 → tenant 維度不命中任何 model(安全預設) // filter(皆可選,空值 = 不過濾該維度)。 TargetChip string Source Source Visibility Visibility // 僅 'public' / 'tenant' 有意義;'private' 傳入視為忽略 Q string // 搜尋 name + description(ILIKE 包含) // Owned:nil = 全部可見;true = 只我的;false = 只別人分享/公開給我的。 Owned *bool // 排序 + 分頁。 Sort string // 'created_at' | 'name' | 'file_size'(handler 已 validate) Order string // 'asc' | 'desc' Limit int // handler 已 clamp 到 1–100 Cursor *Cursor // nil = 首頁 } // Cursor 是 keyset 分頁游標,記錄上一頁最後一筆的排序值 + id tie-breaker。 // // 由 handler 以不透明 base64 編碼給前端(見 api §1.3);repository 只吃解碼後的結構。 type Cursor struct { // SortValue 是上一頁最後一筆的排序欄位值,型別依 Sort 而定: // created_at → RFC3339 時間字串;name → 字串;file_size → 十進位整數字串。 SortValue string `json:"v"` // ID 是上一頁最後一筆的 model id(tie-breaker,保證穩定分頁)。 ID string `json:"id"` } // ========================================================================== // 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) // GetWithOwner 取得單一 Model 並一併帶出 owner 的顯示名稱(一次 join users,避免 N+1)。 // 供 profile handler 顯示擁有者名(api §2 owner.name)。ownerName 可能為空(owner 未設 name)。 // 不存在或已刪除回 ErrNotFound。 GetWithOwner(ctx context.Context, id string) (m *Model, ownerName string, err 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 // ── 模型共享(model_sharing 功能新增)───────────────────────────────── // Library 依查詢 user 身份列出「可見」的 model(我的 ∪ public ∪ tenant同org ∪ 分享給我), // 支援 filter / 排序 / cursor 分頁。回傳 items(已含 owner_name / my_access / shared_with_me) // 與是否還有下一頁(hasMore)。preset 由 handler 層 union,不在此。 // // 只列 uploaded_at IS NOT NULL(ready)的 model;共享庫不列未 finalize 的。 Library(ctx context.Context, q LibraryQuery) (items []*LibraryItem, hasMore bool, err error) // GetShare 取得 (modelID, granteeUserID) 的分享紀錄;不存在回 ErrNotFound。 // 供 canAccessModel 單筆查「這個 model 有沒有分享給我」。 GetShare(ctx context.Context, modelID, granteeUserID string) (*ModelShare, error) // ListShares 列出某 model 的所有分享紀錄(owner 檢視授權清單用)。 ListShares(ctx context.Context, modelID string) ([]*ModelShare, error) // UpsertShare 新增 / 更新一筆分享(by PK (model_id, grantee_user_id))。 // 重複分享同一 grantee → 更新 role。 UpsertShare(ctx context.Context, s *ModelShare) error // DeleteShare 移除 (modelID, granteeUserID) 的分享;不存在回 ErrNotFound。 DeleteShare(ctx context.Context, modelID, granteeUserID 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 // shares 以 modelID → (granteeUserID → *ModelShare) 兩層 map 存分享關聯。 shares map[string]map[string]*ModelShare // orgs 記錄 userID → org_id,供 in-memory Library 判 tenant 可見性(測試注入用)。 // production 走 Postgres 實作;in-memory 主要供 unit test,故用簡易注入而非 join users。 orgs map[string]string // names 記錄 userID → 顯示名稱,供 in-memory GetWithOwner / Library 帶出 owner name。 names map[string]string } // NewInMemoryRepository 建立一個空的記憶體 Repository。 func NewInMemoryRepository() *InMemoryRepository { return &InMemoryRepository{ models: make(map[string]*Model), shares: make(map[string]map[string]*ModelShare), orgs: make(map[string]string), names: make(map[string]string), } } // SetUserOrg 設定某 user 的 org_id(僅 in-memory 測試用,讓 Library 能判 tenant 可見性)。 // production 的 Postgres 實作直接 join users.org_id,不需此方法。 func (r *InMemoryRepository) SetUserOrg(userID, orgID string) { r.mu.Lock() defer r.mu.Unlock() r.orgs[userID] = orgID } // SetUserName 設定某 user 的顯示名稱(僅 in-memory 測試用,讓 GetWithOwner / Library 帶出 owner name)。 // production 的 Postgres 實作直接 join users.name,不需此方法。 func (r *InMemoryRepository) SetUserName(userID, name string) { r.mu.Lock() defer r.mu.Unlock() r.names[userID] = name } // 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 } // GetWithOwner 取單一 Model + owner 顯示名稱(in-memory 從 names map 取,測試以 SetUserName 注入)。 func (r *InMemoryRepository) GetWithOwner(ctx context.Context, id string) (*Model, string, 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, r.names[m.OwnerUserID], 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 } // visibility 預設 private(對齊 DB DEFAULT 'private'):呼叫端未設時不會意外變公開。 if cp.Visibility == "" { cp.Visibility = VisibilityPrivate } 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 — 模型共享方法 // --------------------------------------------------------------------------- // Library 依查詢 user 身份列出可見 model(in-memory 實作,供 unit test)。 // // 可見性 predicate 對齊 TDD §4.1(我的 ∪ public ∪ tenant同org ∪ 分享給我)。 // 排序 + cursor 分頁在記憶體內以全掃 + sort + 切片實作(in-memory 資料量小、不追求效能)。 func (r *InMemoryRepository) Library(ctx context.Context, q LibraryQuery) ([]*LibraryItem, bool, error) { r.mu.RLock() defer r.mu.RUnlock() var matched []*LibraryItem for _, m := range r.models { if m.DeletedAt != nil || m.UploadedAt == nil { continue // 共享庫只列未刪除且 ready 的 model } access := r.accessLevelLocked(q.UserID, q.UserOrgID, m) if access == AccessNone { continue } // filter:owned 維度。 isMine := m.OwnerUserID == q.UserID if q.Owned != nil { if *q.Owned && !isMine { continue } if !*q.Owned && isMine { continue } } if q.TargetChip != "" && m.TargetChip != q.TargetChip { continue } if q.Source != "" && m.Source != q.Source { continue } // visibility filter:僅 public / tenant 有意義(private 不在共享庫語意內)。 if q.Visibility == VisibilityPublic || q.Visibility == VisibilityTenant { if m.Visibility != q.Visibility { continue } } if q.Q != "" { needle := strings.ToLower(q.Q) if !strings.Contains(strings.ToLower(m.Name), needle) && !strings.Contains(strings.ToLower(m.Description), needle) { continue } } _, shared := r.shareForLocked(m.ID, q.UserID) cp := *m matched = append(matched, &LibraryItem{ Model: &cp, OwnerName: r.names[m.OwnerUserID], // in-memory 從 names map 取(測試以 SetUserName 注入) OwnerOrgID: r.orgs[m.OwnerUserID], SharedWithMe: shared, MyAccess: access, }) } sortLibraryItems(matched, q.Sort, q.Order) // cursor:找到游標對應 item 後的位置,取其後 limit+1 判 hasMore。 start := 0 if q.Cursor != nil { for i, it := range matched { if it.Model.ID == q.Cursor.ID { start = i + 1 break } } } limit := q.Limit if limit <= 0 { limit = 20 } end := start + limit hasMore := false if end < len(matched) { hasMore = true } if start > len(matched) { start = len(matched) } if end > len(matched) { end = len(matched) } return matched[start:end], hasMore, nil } // accessLevelLocked 計算 user 對 model 的 AccessLevel(呼叫端須持 r.mu)。 // 對齊 canAccessModel 的 in-memory 版;順序:owner > share.role > visibility(viewer) > none。 func (r *InMemoryRepository) accessLevelLocked(userID, userOrgID string, m *Model) AccessLevel { if m.OwnerUserID == userID { return AccessOwner } if s, ok := r.shareForLocked(m.ID, userID); ok { if s.Role == "editor" { return AccessEditor } return AccessViewer } if m.Visibility == VisibilityPublic { return AccessViewer } if m.Visibility == VisibilityTenant && userOrgID != "" && r.orgs[m.OwnerUserID] == userOrgID { return AccessViewer } return AccessNone } // shareForLocked 回傳 (modelID, granteeUserID) 的 share(呼叫端須持 r.mu)。 func (r *InMemoryRepository) shareForLocked(modelID, granteeUserID string) (*ModelShare, bool) { byGrantee, ok := r.shares[modelID] if !ok { return nil, false } s, ok := byGrantee[granteeUserID] return s, ok } // GetShare 取得單筆 share;不存在回 ErrNotFound。 func (r *InMemoryRepository) GetShare(ctx context.Context, modelID, granteeUserID string) (*ModelShare, error) { r.mu.RLock() defer r.mu.RUnlock() s, ok := r.shareForLocked(modelID, granteeUserID) if !ok { return nil, ErrNotFound } cp := *s return &cp, nil } // ListShares 列出某 model 的所有 share。 func (r *InMemoryRepository) ListShares(ctx context.Context, modelID string) ([]*ModelShare, error) { r.mu.RLock() defer r.mu.RUnlock() out := make([]*ModelShare, 0) for _, s := range r.shares[modelID] { cp := *s out = append(out, &cp) } return out, nil } // UpsertShare 新增 / 更新一筆 share(by PK)。 func (r *InMemoryRepository) UpsertShare(ctx context.Context, s *ModelShare) error { if s == nil || s.ModelID == "" || s.GranteeUserID == "" { return errors.New("model: UpsertShare requires modelID and granteeUserID") } r.mu.Lock() defer r.mu.Unlock() if r.shares[s.ModelID] == nil { r.shares[s.ModelID] = make(map[string]*ModelShare) } cp := *s if cp.CreatedAt.IsZero() { cp.CreatedAt = time.Now().UTC() } if cp.Role == "" { cp.Role = "viewer" } r.shares[s.ModelID][s.GranteeUserID] = &cp return nil } // DeleteShare 移除一筆 share;不存在回 ErrNotFound。 func (r *InMemoryRepository) DeleteShare(ctx context.Context, modelID, granteeUserID string) error { r.mu.Lock() defer r.mu.Unlock() byGrantee, ok := r.shares[modelID] if !ok { return ErrNotFound } if _, ok := byGrantee[granteeUserID]; !ok { return ErrNotFound } delete(byGrantee, granteeUserID) return nil } // sortLibraryItems 依 sort/order 就地排序 items;tie-breaker 一律用 model id 保證穩定。 func sortLibraryItems(items []*LibraryItem, sortField, order string) { desc := order != "asc" // 預設 desc less := func(i, j int) bool { a, b := items[i].Model, items[j].Model var cmp int switch sortField { case "name": cmp = strings.Compare(a.Name, b.Name) case "file_size": switch { case a.FileSize < b.FileSize: cmp = -1 case a.FileSize > b.FileSize: cmp = 1 } default: // created_at switch { case a.CreatedAt.Before(b.CreatedAt): cmp = -1 case a.CreatedAt.After(b.CreatedAt): cmp = 1 } } if cmp == 0 { cmp = strings.Compare(a.ID, b.ID) // tie-breaker } if desc { return cmp > 0 } return cmp < 0 } sort.SliceStable(items, less) } // 編譯時檢查:確保 InMemoryRepository 實作 Repository。 var _ Repository = (*InMemoryRepository)(nil)