# 技術設計文件(TDD)— 模型共享(Model Sharing) > 狀態:Draft(待 PM / Design 三方互審) > 作者:Architect Agent > 日期:2026-08-02 > 範圍:L 級新功能。在既有「模型庫」(owner-only + preset)上,新增「公開對象(visibility)」+「共享模型庫(依身份權限可見)」兩個維度。 > 對齊:`api/api-model-sharing.md`(API 規格)、ADR-017 決策 3(`model_shares` 點對點分享)、`database.md §2.3`(models schema)。 > > **不動 production code、不寫 migration 實檔**——本檔只給設計方向,migration DDL / handler 由 backend agent 落地。 --- ## 1. 系統概述與定位 ### 1.1 功能拆解(對齊使用者截圖) | 大項 | 細項 | 對應本 TDD | |------|------|-----------| | 模型公開設定 | 設定公開對象 | §3 資料模型(visibility 欄)+ API §3 PATCH visibility | | 共享模型庫 | 依身份權限可見列表 / 分頁 / 排序 filter / 搜尋 | §4 權限查詢 + API §1 `GET /api/models/library` | | 共享模型庫 | 模型 profile 頁 | §5 + API §2 `GET /api/models/:id/profile` | ### 1.2 兩個正交維度(核心設計判斷) 現況只有 **owner-only**(`models.owner_user_id` + 403 非 owner)+ **preset**(7 個公用模型、不在 DB)。本功能加兩個**正交維度**: | 維度 | 語意 | 資料落點 | 來源 | |------|------|---------|------| | **visibility(廣播)** | 「對一群人公開」:private / tenant / public | `models.visibility` 欄(本 TDD 新增) | 本功能 | | **model_shares(點對點)** | 「分享給特定某人」by user | `model_shares` 表 | ADR-017 決策 3 已定義,本功能**沿用不重造** | 兩者是「或」的關係:一個 user 能看到 model ⟺ 是我的 **OR** visibility 命中我的身份 **OR** 被 share 給我 **OR** preset。這是共享模型庫列表的核心 predicate(§4)。 > **為何不把 visibility 塞進 model_shares**:model_shares 是 per-(model, user) 列舉,表達「public/tenant」要對每個 user 塞一列、不可行。visibility 是「對一整群」的廣播屬性,天然是 model 上的 enum 欄,查詢時展開成 OR 條件。兩者資料結構本質不同、故分開。 --- ## 2. 「依身份權限」的基礎:既有 identity 模型盤點 設計前先確認「身份」有什麼可用(避免腦補一套不存在的 RBAC): | 事實 | source | 對本功能的意義 | |------|--------|--------------| | `users.roles TEXT[] NOT NULL DEFAULT '{}'` | migration 0001 + `user.User.Roles` | 有 role 欄位,但目前 OIDC provision 只寫最小欄位、role 多為空。**本功能第一階段不依賴 role 做可見性**(避免建在空資料上),只用 owner / org / share / visibility。role-based 可見性列為後續。`[需 PM 確認]` | | `users.org_id UUID`(migration 0001 有欄);但 `user.User` domain **未含 OrgID**、`UserContext.OrgID` 有欄位但 OIDC 未必填 | migration 0001 line、`auth.UserContext.OrgID`、`user.User`(無 OrgID) | **`tenant` visibility 依賴 org_id**。第一階段需確認 OIDC 是否帶 org claim;若無,`tenant` 可見性暫時等同「沒有人」(安全預設)。`[需 PM 確認]` org_id 來源。 | | `UserContext{ UserID, Email, Roles, OrgID }` 已是 handler 可信賴身份 | `auth.auth.go` | 可見性查詢的 input = `UserContext`。handler 從它取 userID + orgId。 | | 無 tenant / group / workspace 表 | grep migrations | **本功能不新增 workspace 表**(ADR-017 決策 3 B2 workspace 留後續)。tenant = org_id 直接比對,不建關聯表。 | **設計結論**:第一階段「身份」= `userID` + `org_id`。可見性維度 = private / tenant(org) / public + 點對點 share。**不建 RBAC engine、不建 workspace 表**(effort scaling:與現況落差最小、避免建在空 role 資料上)。 --- ## 3. 資料模型變更(給 migration 設計方向,不寫實檔) ### 3.1 `models` 表加 `visibility` 欄 ``` -- 方向(DDL 由 backend agent 落地 migration 0006 或後續編號) ALTER TABLE models ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'; -- 'private' | 'tenant' | 'public' -- 既有資料預設 'private':不破壞現況(既有 model 維持只有 owner 看得到)。★關鍵相容性 ALTER TABLE models ADD CONSTRAINT chk_models_visibility CHECK (visibility IN ('private','tenant','public')); ``` **為何用 enum 欄而非獨立 sharing 表**: - 「公開對象」是 model 的**單值屬性**(一個 model 一個 visibility),不是 1-N 關聯 → enum 欄最自然、查詢無需 join。 - 若未來要「同時公開給多個特定 group」才需要關聯表;第一階段 private/tenant/public 三選一,enum 足夠(YAGNI)。 **既有資料預設值(關鍵相容性)**:`DEFAULT 'private'` → 既有所有 model 遷移後維持 owner-only 語意,**零行為改變**。使用者要主動 PATCH 才會公開。 ### 3.2 `model_shares` 表(沿用 ADR-017 決策 3,本功能不重造) ADR-017 決策 3 已定義(此處只引用、確認欄位對齊): ``` CREATE TABLE model_shares ( model_id UUID NOT NULL REFERENCES models(id), grantee_user_id UUID NOT NULL REFERENCES users(id), role TEXT NOT NULL, -- 'viewer' | 'editor' granted_by UUID NOT NULL REFERENCES users(id), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), PRIMARY KEY (model_id, grantee_user_id) ); CREATE INDEX ON model_shares (grantee_user_id); ``` > 本功能第一階段的**寫入 model_shares 的 API(分享給特定人)不在使用者截圖範圍內**(截圖是「公開對象」+「共享庫瀏覽」)。model_shares 在本功能中主要作為**讀取端**(共享庫 predicate 的一部分),確保未來點對點分享上線時共享庫已支援。`[需 PM 確認]` 本期是否需要「分享給特定人」的寫入 UI,或只做 visibility 廣播。 ### 3.3 index 設計(效能,見 §4) ``` -- 共享庫「public 全平台可見」列表:high-selectivity 掃描 CREATE INDEX idx_models_public_active ON models (visibility, created_at DESC) WHERE deleted_at IS NULL AND visibility = 'public' AND uploaded_at IS NOT NULL; -- tenant 可見:需 join users 取 owner.org_id,故 index 在 owner_user_id(既有 idx_models_owner_active 已可用) -- 搜尋 q:見 §5 效能考量(第一階段 ILIKE + trigram,量大再上 FTS) ``` **沿用 ADR-018 慣例**:partial index `WHERE deleted_at IS NULL`(既有 models index 全用此模式)、`gen_random_uuid()`(PG14 內建)、migration 需有對應 `*_test.go` 驗 up/down 對稱(130 testcontainers,避免假綠——見 memory「DB 接入 dbtest 陷阱」)。 --- ## 4. 權限可見性查詢設計(核心,效能重點) ### 4.1 可見性 predicate 「共享模型庫列表」= 一個 query 過濾出當前 user 可見的所有 model: ``` 可見(model, user) = model.owner_user_id = :userID -- 我的 OR model.visibility = 'public' -- 全平台 OR (model.visibility = 'tenant' AND owner.org_id = :userOrgID AND :userOrgID IS NOT NULL) -- 同租戶 OR EXISTS (SELECT 1 FROM model_shares s -- 分享給我 WHERE s.model_id = model.id AND s.grantee_user_id = :userID) -- preset 模型不在 DB,由 handler 層 union(沿用既有 modelsListHandler 做法) ``` ### 4.2 query 形狀(給 backend,方向非最終 SQL) 第一階段建議 **單一 query + LEFT JOIN users(取 owner.org_id / owner.name)** : ```sql SELECT m.*, u.name AS owner_name, u.org_id AS owner_org_id FROM models m JOIN users u ON u.id = m.owner_user_id WHERE m.deleted_at IS NULL AND m.uploaded_at IS NOT NULL -- 只列 ready AND ( m.owner_user_id = $userID OR m.visibility = 'public' OR (m.visibility = 'tenant' AND u.org_id = $userOrgID AND $userOrgID <> '') OR EXISTS (SELECT 1 FROM model_shares s WHERE s.model_id = m.id AND s.grantee_user_id = $userID) ) -- + filter(target_chip / source / q)+ sort + cursor(見 §5) ``` ### 4.3 效能考量 | 考量 | 對策 | |------|------| | **OR 條件讓 planner 難用 index** | public 是最常見的大集合、用 `idx_models_public_active`(§3.3)覆蓋。owner 用既有 `idx_models_owner_active`。PG 對多 OR 可能走 BitmapOr——`[需 backend 驗]` EXPLAIN,量大時考慮拆 UNION(owner 子查 ∪ public 子查 ∪ shared 子查)避免全表掃。 | | **model_shares EXISTS 子查** | `idx model_shares (grantee_user_id)` 已在 ADR-017 定義,子查走此 index。 | | **tenant join users** | owner.org_id 比對需 join;users 主鍵 join 成本低。 | | **N+1 owner name** | 用 JOIN 一次帶出 owner_name,不在 handler loop 查。 | | **共享庫規模** | 全平台 public model 數量可能大 → **必須分頁 + index**,不可一次撈全部(現況 owner-only list 無此問題)。 | --- ## 5. API 規格摘要(詳見 `api/api-model-sharing.md`) | 端點 | 用途 | 權限 | |------|------|------| | `GET /api/models/library` | 共享庫列表(分頁 cursor + limit + sort + order + q + filter) | 登入即可,回傳依身份裁剪可見範圍 | | `GET /api/models/:id/profile` | 模型 profile(公開版詳情) | 可見性檢查,不命中回 404 | | `PATCH /api/models/:id/visibility` | 設公開對象 | owner-only | ### 5.1 分頁契約(cursor vs offset — 決策) **採 cursor-based(不透明 base64 游標),不用 offset**: - 理由:共享庫是「跨擁有者、可能很大、常按 created_at 排序」的 feed 型列表;offset 在深分頁效能差且有「插入導致跳頁/重複」問題。 - cursor 編碼 `(sort_value, id)` tie-breaker,base64 不透明(前端當黑箱)。 - `has_more` + `next_cursor` 回傳(見 API §1)。 - **與既有 `GET /api/models` 不一致是可接受的**:既有是「我的模型」小列表、無分頁需求;共享庫是新端點、獨立契約。`[需 Design 確認]` 前端是否用無限捲動(cursor 適合)或頁碼(offng 需 offset,但不建議)。 ### 5.2 既有端點的連帶變更(相容性關鍵) | 既有端點 | 變更 | 相容性 | |---------|------|--------| | `GET /api/models` | **不變**(維持「我的 + preset」語意,owner dashboard 用) | 零改變 | | `GET /api/models/:id`(get 詳情) | **不變**(維持 owner-only 403) | 零改變;profile 是**新端點**、不動這個 | | `GET /api/models/:id/download` | **owner-only → 共享權限檢查**(有可見性 + can_download 才放行) | ⚠️ 行為改變、涉及 auth。與 profile 可見性**共用同一判斷函式**(single source of truth)。`[需 security 審]` | | `POST /api/models/init` / `finalize` / `DELETE` | **不變**(新 model 預設 private,見 §3.1) | 零改變 | | `toModelResponse` DTO | 加 `visibility` 欄(omitempty 或固定輸出,`[需 Design 確認]`) | 加欄不破壞既有前端(snake/camel 雙吃 + 未知欄忽略) | --- ## 6. 安全性設計(多處 `[需 security 審]`) | # | 風險 | 對策 | 需 security 審 | |---|------|------|:---:| | SEC-1 | **IDOR / enumeration**:非 owner 猜 model id 看不該看的 profile/download | 可見性檢查在 handler 第一步;不命中回 **404 而非 403**(不揭露 id 存在性) | ✅ | | SEC-2 | **下載端點權限繞過**:download 從 owner-only 放寬後放行過頭 | profile 可見性 + download 授權**共用同一 `canAccessModel(uc, model) → accessLevel` 函式**,杜絕兩處邏輯漂移 | ✅ | | SEC-3 | **資訊洩漏**:profile 回傳 owner email / storage_key / faa_object_key | DTO 白名單;內部 key `json:"-"`(沿用 `FAAObjectKey` 既有做法);owner 只揭露 id/name | ✅ | | SEC-4 | **tenant 邊界誤放**:org_id 為空時誤判「同租戶」 | tenant 命中要求 `owner.org_id == user.org_id` 且**兩者皆非空**;空 org 一律不落 tenant 可見 | ✅ | | SEC-5 | **越權改 visibility**:非 owner 改他人 model 公開對象 | PATCH visibility 強制 `owner_user_id == userID`;editor 能否改 `[需 PM 確認]` | ✅ | | SEC-6 | **公開後可下載即等於檔案外流** | visibility=public 的 download 仍走 ADR-017 (a) FAA delegated token(短 TTL + boundary),非直接曝露 storage | 併 ADR-017 R4 | > **建議**:本功能的權限模型(可見性判斷 + download 放寬 + enumeration 防護)**整體送 security agent 審一輪**。這是「auth 的複雜點」——放寬既有 owner-only 邊界,任何判斷漏洞都是資料外洩。 --- ## 7. 對既有功能的相容性總結 | 面向 | 影響 | 結論 | |------|------|------| | 既有 model 資料 | `visibility DEFAULT 'private'` | 零行為改變,既有 model 維持 owner-only | | 既有 `GET /api/models` | 不動 | 相容 | | 既有上傳/下載/刪除 | 上傳/刪除不動;download 加共享權限檢查 | download 需回歸測試(既有 owner 下載仍 work)| | 前端 model-store | DTO 加 `visibility` 欄;snake/camel 雙吃 + 未知欄忽略 | 加欄相容;共享庫是新頁面/新 store slice | | migration | 新增 `visibility` 欄 + index(+ model_shares 若尚未建) | 需 up/down 對稱 test(130 testcontainers,避免假綠)| --- ## 8. 並行化工作流計畫(Parallelization Plan) L 級、跨 backend + frontend + testing,適用本節。**contract-first**:§8.1 契約定死後三條 work-stream 可平行。 ### 8.1 模組間契約(single source of truth) | 契約項 | 定義處 | 鎖定內容 | |--------|--------|---------| | API schema(library / profile / visibility) | `api/api-model-sharing.md` | request/response 欄位、分頁 cursor 形狀、錯誤碼 | | visibility enum | 本檔 §3.1 | `private`/`tenant`/`public`(三方一致,前後端不各自定義字串)| | accessLevel enum | 本檔 §6 SEC-2 | `owner`/`editor`/`viewer`/`none`(`can_download = access != none`)| | 可見性 predicate | 本檔 §4.1 | 唯一真實來源,backend 與 testing 都對這份 | | DTO 欄位(owner 裁剪、內部 key 不揭露) | api §1/§2 | `owner{id,name,is_me}`、不含 email/storage_key/faa_object_key | ### 8.2 依賴圖 + 關鍵路徑 ```mermaid graph LR C[契約定稿 §8.1
critical] --> M[migration: visibility 欄+index
backend, critical] C --> FE[前端: 共享庫頁+profile
對 mock schema] M --> Q[權限 query + canAccessModel
backend, critical] Q --> LIB[library/profile/visibility handler
backend] Q --> DL[download 端點權限放寬
backend] C --> TS[測試腳本設計
testing 對契約] LIB --> INT[整合 join] DL --> INT FE --> INT INT --> SEC[security 審 + E2E join] TS --> SEC ``` **關鍵路徑(critical path)**:`契約定稿 → migration → 權限 query + canAccessModel → handler → 整合 → security審/E2E`。這條無法平行、決定總工期。前端可對 mock schema 平行、不在關鍵路徑上。 ### 8.3 work-stream 清單 + 同步點 | Work-stream | 派給 | 可與誰平行 | 阻擋於(前置) | 同步點(join) | |-------------|------|-----------|---------------|----------------| | WS-1 migration + 權限 query + canAccessModel + 三個 handler + download 放寬 | backend | WS-2, WS-3 | 契約定稿 | 整合 join | | WS-2 共享庫列表頁 + profile 頁(對 mock) | frontend | WS-1, WS-3 | 契約定稿 | 整合 join | | WS-3 測試腳本設計(權限 matrix + 分頁 + enumeration) | testing | WS-1, WS-2 | 契約定稿 | E2E join | | WS-4 security 審(權限模型 / IDOR / 邊界) | security | — | WS-1 整合後 | security join | > backend 的權限 query 與 canAccessModel 在關鍵路徑、內部**串行**(query → handler → download),不硬拆。coding 本質難平行的部分誠實標串行。 ### 8.4 任務卡(Anthropic 四要素,摘要) **WS-1 backend**: - Objective:落地 visibility 欄 migration + 可見性 query + `canAccessModel(uc, model)→accessLevel` 單一函式 + library/profile/visibility 三 handler + download 端點改用 canAccessModel。 - Output:可運行 API + 單元測試(權限 matrix)+ migration up/down test(130 testcontainers)。行為符合 §4.1 predicate + api 契約。 - 來源指引:本 TDD §3/§4/§6 + `api/api-model-sharing.md` + ADR-017 決策 3(model_shares)。 - 邊界:**只做 model sharing。不碰前端、不改 model_shares schema(ADR-017 已定)、不動既有 `GET /api/models` 與 `/:id` 語意。visibility/accessLevel 字串照契約、不自造。download 放寬必須用 canAccessModel、不得複製一份可見性邏輯。** **WS-2 frontend**: - Objective:共享庫列表頁(分頁/排序/filter/搜尋)+ profile 頁 + visibility 設定 UI。 - Output:頁面 + 元件測試,對 §8.1 契約的 mock。 - 邊界:**只做 UI + api client。不碰 backend。visibility enum / 錯誤碼 / 分頁 cursor 當黑箱照契約。不揭露/不依賴 storage_key/faa_object_key(DTO 本來就沒有)。** **WS-3 testing**: - Objective:權限可見性 matrix(owner/tenant同org/tenant異org/public/shared/無關 × library/profile/download)+ enumeration(猜 id 回 404)+ 分頁 + 既有 owner download 回歸。 - 邊界:**對契約與 §4.1 predicate 設計,不改 production code。** --- ## 9. 待三方確認清單(彙整,交互審用) | # | 項目 | 待誰 | 出處 | |---|------|------|------| | P1 | org_id 來源(OIDC 是否帶 org claim)+ tenant=org 語意是否正確;跨 org 可能性 | PM | §2 | | P2 | 本期是否要「分享給特定人」寫入 UI,或只做 visibility 廣播 | PM | §3.2 | | P3 | editor 能否改 visibility / 未 ready 能否公開 / checksum 對非 owner 是否公開 | PM | api §3/§5、§6 SEC-5 | | P4 | 第一階段是否需要 role-based 可見性(現況 role 資料多為空) | PM | §2 | | D1 | 共享庫 UI 入口(與「我的模型」分頁 or 合併 tab)、無限捲動 vs 頁碼 | Design | §5.1、api §0 | | D2 | `shared_with_me` / visibility badge 標示方式、profile 頁欄位與版面 | Design | api §1/§2 | | SEC1 | 整體權限模型送審:IDOR/404 策略、download 放寬、tenant 邊界、owner email 不揭露 | security | §6 全節 | --- ## 10. 一句話總結 在既有 owner-only 模型庫上,加一個 **model 上的 `visibility` enum 欄(private/tenant/public,預設 private 保證零相容性衝擊)** 作為「公開對象」廣播維度,與 ADR-017 決策 3 的 `model_shares`(點對點分享)正交;共享模型庫用**單一可見性 predicate**(我的 ∪ public ∪ 同 org tenant ∪ share ∪ preset)+ cursor 分頁 + 對應 index 查詢;profile/download 的權限用**同一 `canAccessModel` 函式**杜絕邏輯漂移,enumeration 一律回 404——整個放寬 owner-only 邊界的權限模型建議**整體送 security 審**。