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>
637 lines
22 KiB
Go
637 lines
22 KiB
Go
// 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)
|