jim800121chen 47a1d4d0ef feat(backend): 設備註冊 + 模型共享 backend(B 設備管理 + C 模型共享)
B 設備管理(feature-device-mgmt-tdd):
- POST /api/devices/:id/register + /unregister(owner 檢查 + representative 擋
  + 已註冊擋 + SetRegistered 單欄翻轉,不碰 unpair 軟刪)
- error codes ALREADY_REGISTERED / REPRESENTATIVE_DEVICE(409)
- 不需 migration(registered_at 欄/index/讀寫已在 0005)

C 模型共享(feature-model-sharing-tdd,security 深審 APPROVE):
- migration 0006:models.visibility enum DEFAULT 'private'(零行為改變)+ model_shares 表
- canAccessModel single source(owner ∪ share ∪ public ∪ tenant):profile + download 共用
- GET /library(cursor keyset)/ GET /:id/profile(404 防列舉、GetWithOwner join name 不洩 email)
  / PATCH /:id/visibility(owner-only)/ shares CRUD / download 放寬
- tenant 因 OIDC 無 org claim 留 stub(恆空、安全預設;補 org claim 需重送 security 深審)

reviewer 通過(B 三條紅線 / C security APPROVE 無 C/M)。130 dbtest 全綠、gosec 新檔 0。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 16:29:50 +08:00

637 lines
22 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"
"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_idOIDC 現況不帶 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 的有效權限」。
// 由 canAccessModelsingle source of truth計算取最高。
type AccessLevel = string
const (
// AccessNone 無可見性(不該看到此 modelenumeration 防護一律回 404
AccessNone AccessLevel = "none"
// AccessViewer 可 list / get profile / downloadvisibility 命中或 share role=viewer
AccessViewer AccessLevel = "viewer"
// AccessEditor 可改 metadatashare 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 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"`
// Visibility 是廣播式公開對象private / tenant / public對齊 model_sharing 功能)。
// 既有 / 新建 model 預設 VisibilityPrivateDB 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 + descriptionILIKE 包含)
// Ownednil = 全部可見true = 只我的false = 只別人分享/公開給我的。
Owned *bool
// 排序 + 分頁。
Sort string // 'created_at' | 'name' | 'file_size'handler 已 validate
Order string // 'asc' | 'desc'
Limit int // handler 已 clamp 到 1100
Cursor *Cursor // nil = 首頁
}
// Cursor 是 keyset 分頁游標,記錄上一頁最後一筆的排序值 + id tie-breaker。
//
// 由 handler 以不透明 base64 編碼給前端(見 api §1.3repository 只吃解碼後的結構。
type Cursor struct {
// SortValue 是上一頁最後一筆的排序欄位值,型別依 Sort 而定:
// created_at → RFC3339 時間字串name → 字串file_size → 十進位整數字串。
SortValue string `json:"v"`
// ID 是上一頁最後一筆的 model idtie-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 列出 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
// ── 模型共享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 NULLready的 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 檢查 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
// 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 新增或更新 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
}
// 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 身份列出可見 modelin-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
}
// filterowned 維度。
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 新增 / 更新一筆 shareby 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 就地排序 itemstie-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)