diff --git a/docs/autoflow/02-prd/PRD.md b/docs/autoflow/02-prd/PRD.md index 0780e0e..35cb526 100644 --- a/docs/autoflow/02-prd/PRD.md +++ b/docs/autoflow/02-prd/PRD.md @@ -95,6 +95,7 @@ visionA Cloud 是把 edge-ai-platform POC 升格的正式雲端產品,定位 |------|--------|------|------| | 裝置管理 | P0 | [feature-device-management.md](features/feature-device-management.md) | 搬自 local-tool,走 remote-proxy | | 模型管理 | P0 | [feature-model-management.md](features/feature-model-management.md) | 7 預設 + 上傳,走 S3 介面 | +| 模型共享 | P0/P1 | [feature-model-sharing.md](features/feature-model-sharing.md) | L 級新功能:在模型管理上加「可見性/權限」維度(私有/指定使用者/組織/公開),含共享模型庫(分頁/排序/filter/搜尋)、公開設定、profile 頁;三方討論中 | | 推論操作 | P0 | [feature-inference.md](features/feature-inference.md) | Camera / Image / Video / Batch | | Pairing 流程 | P0 | [feature-pairing.md](features/feature-pairing.md) | 新增,取代 POC 的 MAC 寫死 | | 工作區 | P0 | [feature-workspace.md](features/feature-workspace.md) | 裝置 → 模型 → 來源 | diff --git a/docs/autoflow/02-prd/features/feature-model-sharing.md b/docs/autoflow/02-prd/features/feature-model-sharing.md new file mode 100644 index 0000000..f53f202 --- /dev/null +++ b/docs/autoflow/02-prd/features/feature-model-sharing.md @@ -0,0 +1,227 @@ +# Feature:模型共享(Model Sharing)— L 級新功能 + +> 父文件:[PRD.md](../PRD.md) | 相依既有功能:[模型管理](feature-model-management.md)、[會員系統](feature-auth.md) +> 對應 User Stories:US-31 ~ US-40(本檔新增,接續 user-stories.md 既有 US-01~US-30) +> 狀態:三方聯合討論中(PM 產出草稿,待 Design / Architect 互審) +> 撰寫日期:2026-08-02 + +--- + +## 0. 這份 PRD 的定位(先講清楚,避免誤解範圍) + +**模型共享不是從零打造的功能,而是在既有「模型管理」功能上,疊加一個「可見性 / 權限」維度。** + +反推自既有 code 的現況(重要,這是設計基礎): + +| 面向 | 現況(已實作) | 本功能要加什麼 | +|------|---------------|---------------| +| 模型資料模型 | `Model` 有 `OwnerUserID`、`Source`(preset/uploaded/converted)、軟刪。**無 visibility 欄位** | 新增「可見性 / 公開對象」維度 | +| 存取控制 | 嚴格 owner-only(`List` 只 filter `OwnerUserID`;`Get`/`download` 非 owner 回 403)+ 系統 preset 公用 | 讓「非 owner 但被授權者」也能看到 / 下載 | +| 模型庫 UI(`/models`)| 按**來源**分三區(preset / converted / uploaded),只看自己的 | 新增「共享給我 / 我可見」的維度;分頁、排序、搜尋、filter | +| 使用者 / 權限 | OIDC(Member Center)`sub`=userID;users 表有 `org_id`(nullable, 未用)、`roles TEXT[]`(未用) | 「公開對象」要對齊這些既有欄位(同租戶 / 群組概念) | +| 下載 | handler 註解已寫明「第一階段 owner-only(**B 分享後續階段**)」 | 本功能正是被預留的「B 分享階段」 | + +**一句話**:既有系統已經為「分享」預留了骨架(`org_id`、`roles`、download handler 的註解),本功能把這個骨架填血。 + +--- + +## 1. 功能概述與價值主張 + +**使用者痛點**(反推自 Persona,見 user-research.md): + +- **阿哲(FAE)**:轉檔 / 調校好一個客戶專用模型後,想給同組 FAE 或客戶直接用,現在只能「下載 → 私訊傳檔 → 對方重新上傳」,模型檔散落、版本混亂。 +- **Sarah(SI)**:在多個客戶現場佈署,想把一套驗證過的模型推給整個團隊 workspace,現在每個工程師都要自己上傳一份。 +- **Mike(開發者)**:想引用別人分享的公開模型做 A/B test 基準,現在沒有「探索別人模型」的入口。 + +**核心價值主張**:「**上傳一次,授權共享,團隊即用**」——把模型從「我的私有檔案」升級為「可依身份權限流通的資產」。 + +**北極星指標關聯**:模型共享降低「取得可用模型」的摩擦 → 提升首次推論成功率與每週推論次數(WAD / 每週推論次數 per user,見 success-metrics.md)。 + +--- + +## 2. 公開對象(可見性)權限模型 — PM 提案 + +> ⚠️ **本節是全功能的核心設計決策,需 Architect 確認技術可行性、Design 確認 UI 複雜度。** + +### 2.1 設計原則 + +1. **對齊既有 auth 概念**,不自造平行權限系統。既有已有:`OwnerUserID`(擁有者)、`org_id`(租戶 / 組織,nullable)、`roles TEXT[]`。 +2. **preset 模型維持不變**:系統 7 預設模型永遠對所有人公開,不納入本功能的可見性控制。 +3. **owner 永遠有完整權限**:可設定 / 變更公開對象、可撤銷、可刪除。 +4. **漸進式**:Phase A 先做最有價值、技術最單純的層級;複雜的群組 / 租戶留 Phase B。 + +### 2.2 提案:可見性層級(Visibility Levels) + +在 `Model` 上新增 `Visibility` 欄位,四個層級由私到公: + +| 層級 | 值(提案) | 誰能看到 / 下載 | 對齊既有概念 | 建議 Phase | +|------|-----------|----------------|-------------|-----------| +| **私有** | `private` | 只有 owner(= 現況預設行為) | `OwnerUserID` | **P0(預設值)** | +| **指定使用者** | `restricted` | owner + 被明確授權的 user 清單 | 新增 share grant 關聯 | **P0** | +| **同租戶 / 組織** | `organization` | owner + 同 `org_id` 的所有 user | 既有 `users.org_id` | **P1**(依賴租戶功能成熟度) | +| **全公開** | `public` | 所有已登入 user | — | **P1** | + +**P0(MVP)先做 `private` + `restricted` 兩級**,理由: +- `private` 是現況行為,成本近乎 0(只是把隱含的「無 visibility = 私有」顯性化)。 +- `restricted`(指定 user 分享)是**痛點最直接的解**(阿哲要給特定同事 / 客戶),且不依賴「租戶 / 群組」這種尚未成熟的概念。 +- `organization` / `public` 依賴 `org_id` 租戶模型與內容治理(濫用、審核),成本與風險高,留 P1。 + +> **待 Architect 確認 A**:`restricted` 的授權清單,建議新增一張 `model_shares` 關聯表(`model_id` × `grantee_user_id` × `permission` × `granted_by` × `created_at`)。這是否與現有 DB migration 策略相容?(既有 migration 到 0005,models 表 index 全 owner-scoped,共享查詢需要新 index / 新表。) +> +> **待 Architect 確認 B**:`organization` 層級依賴 `users.org_id`,但該欄位目前是「nullable + 未寫入」的 stub。若 P1 要做,需先有「租戶 / org 指派」機制。此依賴請 Architect 評估。 +> +> **待 Design 確認 C**:四層級的 UI 呈現。P0 兩級相對單純(一個下拉 + 指定 user 的 email/名稱輸入);但「指定 user 清單」的 UX(怎麼搜尋 user、怎麼移除、怎麼顯示已授權清單)需要 Design 設計。 + +### 2.3 權限粒度(permission) + +P0 先做**唯讀分享**(被授權者可**檢視 + 下載 + 用於推論**,但不能改 / 刪 / 二次分享)。 + +| 權限 | P0 | 說明 | +|------|-----|------| +| view(看得到、看 profile) | ✅ | 出現在對方的「共享給我」列表 | +| download(下載 .nef) | ✅ | 對齊 download handler 的「B 分享階段」 | +| use(載入裝置推論) | ✅ | 分享的核心價值:對方能直接用 | +| edit / re-share / delete | ❌(P1+) | 只有 owner 能做 | + +> **待 Architect 確認 D**:`use`(載入裝置推論)牽涉 `load-to-device` 與 download token 簽發。現在 download handler 對非 owner 回 403,`restricted` 要放行「被授權者」——授權檢查要插在哪一層(middleware / handler / repo filter)? + +--- + +## 3. User Stories(接續既有 US 編號) + +> 格式對齊 user-stories.md。RICE 的 Effort 欄標「待 Architect 核對」,本檔先給 PM 相對估值。 + +### 3.1 模型公開設定(owner 視角) + +- **US-31(P0)**:作為模型擁有者,我要**在模型 profile 頁設定它的公開對象(私有 / 指定使用者)**,這樣我就能控制誰能用我的模型。 +- **US-32(P0)**:作為模型擁有者,我要**把模型分享給指定的一位或多位使用者(用 email 或名稱搜尋)**,這樣特定同事 / 客戶就能直接使用。 +- **US-33(P0)**:作為模型擁有者,我要**看到目前這個模型已分享給哪些人,並能移除某個人的存取權**,這樣我能隨時收回授權。 +- **US-34(P1)**:作為模型擁有者,我要**把模型設為「同組織可見」或「全公開」**,這樣整個團隊 / 社群不用我逐一授權。 + +### 3.2 共享模型庫(被分享者 / 探索者視角) + +- **US-35(P0)**:作為使用者,我要**在模型庫看到「共享給我」的模型(別人授權給我的)**,並清楚看到它是誰分享的、我的權限是什麼。 +- **US-36(P0)**:作為使用者,我要**能對共享模型庫做分頁瀏覽**,這樣模型多時不會一次載入全部、頁面不卡。 +- **US-37(P0)**:作為使用者,我要**對共享模型庫做排序(上傳時間 / 名稱 / 檔案大小)**,這樣我能快速找到最新或最相關的模型。 +- **US-38(P0)**:作為使用者,我要**對共享模型庫做 filter(依硬體晶片 / 來源 / 可見性 / 分享者)**,這樣我能縮小範圍。 +- **US-39(P0)**:作為使用者,我要**用關鍵字搜尋模型(名稱 / 描述)**,這樣我能直接找到目標模型。 +- **US-40(P0)**:作為使用者,我要**進入任一可見模型的 profile 頁,看到完整資訊(metadata、支援硬體、擁有者、分享者、我的權限、下載 / 載入按鈕)**,這樣我在使用前能確認它是我要的。 + +### 3.3 依身份權限的可見性(貫穿所有 story 的規則) + +**共享模型庫「我能看到的模型」= 聯集**: +``` +我擁有的(owner) +∪ 明確分享給我的(restricted grant,US-32) +∪ 我所屬 org 的 organization 模型(P1,US-34) +∪ 全公開模型(P1,US-34) +∪ 系統 preset(既有,永遠可見) +``` + +> **待 Architect 確認 E**:這個「聯集查詢」在現有 `Repository.List(ListFilter)` 介面下無法表達(現在只支援 owner filter)。需擴充 `ListFilter`(加 grantee / visibility / org 維度)或新增 method。請評估對現有 in-memory + postgres 兩個實作的衝擊。 + +--- + +## 4. RICE 排序 / 優先級 + +| # | Story | Reach | Impact | Conf. | Effort(待 Architect 核對) | RICE | Phase | +|---|-------|-------|--------|-------|------|------|-------| +| US-32 | 分享給指定使用者 | 70 | 3 | 80% | 2 | 84 | **P0** | +| US-35 | 「共享給我」列表 | 70 | 3 | 80% | 1.5 | 112 | **P0** | +| US-31 | 公開設定(私有/指定) | 70 | 2 | 80% | 1 | 112 | **P0** | +| US-40 | 模型 profile 頁 | 80 | 2 | 90% | 1.5 | 96 | **P0** | +| US-39 | 關鍵字搜尋 | 80 | 1 | 90% | 1 | 72 | **P0** | +| US-33 | 檢視 / 撤銷授權清單 | 60 | 2 | 80% | 1 | 96 | **P0** | +| US-38 | filter(晶片/來源/可見性/分享者)| 80 | 1 | 80% | 1 | 64 | **P0** | +| US-37 | 排序 | 80 | 1 | 90% | 0.5 | 144 | **P0** | +| US-36 | 分頁 | 80 | 1 | 90% | 0.8 | 90 | **P0** | +| US-34 | org 可見 / 全公開 | 50 | 2 | 60% | 3 | 20 | **P1** | + +> RICE 說明沿用 user-stories.md §5.1(Reach 為 Phase 相對估值、Impact 對北極星 WAD)。Effort 全部標「待 Architect 核對」——尤其 US-32 / US-35 的授權表 + 聯集查詢是技術重點。 + +### P0 / P1 切分總結 + +**P0(MVP,本次做)**: +- 可見性兩級:`private`(預設)+ `restricted`(指定 user 分享) +- 公開設定 UI(在 profile 頁)+ 授權清單管理(新增 / 移除) +- 共享模型庫:「共享給我」維度 + 分頁 + 排序 + filter + 搜尋 +- 模型 profile 頁(擴充既有 `/models/[id]`,加分享資訊與權限顯示) + +**P1(後續)**: +- `organization`(同租戶可見)— 依賴租戶 / org 指派機制成熟 +- `public`(全公開)— 需內容治理(濫用回報、審核) +- 二次分享、edit 權限、分享通知(email / in-app) + +--- + +## 5. 與既有模型功能的關係(擴充 vs 全新) + +| 元件 / 檔案 | 現況 | 本功能 | 類型 | +|------------|------|--------|------| +| `Model` domain(model.go) | 無 visibility | 加 `Visibility` 欄位 + 可能的 share 關聯 | **擴充** | +| `models` 表(migration 0001) | owner-scoped index | 新 migration:加 visibility 欄 + `model_shares` 表 + 新 index | **擴充 + 新表** | +| `Repository.List(ListFilter)` | 只 owner filter | 擴充 filter(grantee / visibility)或新 method | **擴充** | +| `GET /api/models`(models.go) | preset + 自己的 | 加「共享給我」聯集 + 分頁 / 排序 / 搜尋 query params | **擴充** | +| `GET /api/models/:id`、`download` | 非 owner 403 | 放行被授權者 | **擴充** | +| 新 API:設定可見性 / 管理授權 | — | `PUT /api/models/:id/visibility`、`POST/DELETE /api/models/:id/shares` | **全新** | +| `/models` 頁(page.tsx) | 按 source 三區 | 加「共享給我」維度 + 分頁 / 排序 / 搜尋列 | **擴充** | +| `ModelFilters`(model-filters.tsx) | 只 targetChip | 加來源 / 可見性 / 分享者 filter + 搜尋框 | **擴充** | +| `/models/[id]` profile 頁 | 基本資訊 + 下載 / 刪除 | 加:公開設定區、授權清單管理、分享者 / 我的權限顯示 | **擴充** | +| 「公開設定」互動 | — | 新 UI(可見性下拉 + user 搜尋授權) | **全新(Design 重點)** | + +**關鍵**:搜尋 / 排序 / 分頁在既有 code 是被標為「Phase 1 TODO」的(見 model-filters.tsx 註解、feature-model-management.md TODO-3)。本功能把它們正式做出來,且**同時服務「我的模型」與「共享模型」兩個維度**——不是只給共享用。 + +--- + +## 6. 成功指標 + +| 類別 | 指標 | Baseline | P0 目標 | 追蹤方式 | +|------|------|----------|---------|---------| +| 採用 | 建立過至少一次分享的 owner 比例 | N/A(新功能,baseline=0) | 內測 FAE 中 ≥ 40% | 埋點:`model_share_created`(待 Architect 確認埋點清單) | +| 採用 | 「共享給我」列表被點開的 WAU 比例 | N/A | ≥ 30% | 埋點:`shared_library_viewed` | +| 價值 | 用「別人分享的模型」跑過推論的 user 比例 | N/A | ≥ 25% | 埋點:`inference_with_shared_model` | +| 效率 | 共享模型庫首屏載入時間(含分頁) | N/A | P95 < 1.5s | 前端 perf(待 Architect / 非功能需求對齊 nonfunctional.md) | +| 效率 | 搜尋 → 找到目標模型的操作步數 | 現況需私訊傳檔(不可量測) | ≤ 3 步(搜尋 / filter → profile → 使用) | 可用性測試 | +| 護欄 | 未授權存取被正確拒絕率 | 現況 owner-only 403 | 100%(不可退化) | 安全測試(待 Security / Architect) | +| 護欄 | 既有「我的模型」列表載入時間不退化 | 現況 P95 | 不劣於現況 | 回歸 perf 測試 | + +> Baseline 多為 N/A:全新功能、無歷史數據。目標值為內測 FAE 群體的估算門檻,launch 後 2 週校正。護欄指標「未授權存取拒絕率」是**本功能最重要的把關**——分享功能最大的風險是權限漏洞導致模型外洩。 + +--- + +## 7. 待三方確認事項彙總(互審重點) + +### 給 Architect(技術可行性) + +| # | 事項 | 位置 | +|---|------|------| +| A | `model_shares` 授權關聯表設計是否相容既有 migration 策略(0001~0005 全 owner-scoped index) | §2.2 | +| B | `organization` 層級依賴 `users.org_id`(現為未寫入 stub),P1 前需先有租戶指派機制 | §2.2 | +| C | `use`(被授權者載入裝置推論)的 download token 簽發 + 授權檢查插入層級(middleware / handler / repo) | §2.3 | +| D | `Repository.List` 聯集查詢(owner ∪ granted ∪ org ∪ public)對 in-memory + postgres 兩實作的衝擊 | §3.3 | +| E | 分頁 / 排序 / 搜尋在 List 介面的表達(offset/cursor 分頁?搜尋用 LIKE 還是全文索引?) | §3.2 | +| F | 各 RICE Effort 欄核對(尤其 US-32 / US-35) | §4 | +| G | 成功指標的埋點事件清單可行性 | §6 | + +### 給 Design(體驗面) + +| # | 事項 | 位置 | +|---|------|------| +| C | 「指定 user 分享」的 UX:怎麼搜尋 user、加入 / 移除、已授權清單呈現 | §2.2 | +| — | 可見性層級的 UI(下拉?分段控制?如何讓 owner 一眼看懂「私有 vs 指定 vs 公開」的差異與風險) | §2.2 | +| — | 共享模型庫的資訊架構:「我的模型」與「共享給我」怎麼並存(分頁 tab?分區?既有已按 source 分三區,再加維度會不會過載) | §3.2 / §5 | +| — | profile 頁如何同時呈現「我是 owner(可管理)」vs「我是被授權者(唯讀)」兩種狀態 | US-40 | +| — | 分頁 / 排序 / 搜尋 / filter 控制列的整合設計(既有 filter 只有 targetChip 一個下拉,要擴成完整工具列) | §3.2 | + +### 給雙方(需一起拍板) + +- **P0 是否確認只做 `private` + `restricted` 兩級**?(PM 主張是,理由見 §2.2。若 Design / Architect 認為 org 層級成本可控且需求急,可討論上調。) +- **可見性預設值 = `private`**(不主動公開任何東西,安全優先)——三方確認無異議。 + +--- + +## 8. 連結 + +- 回:[PRD 索引](../PRD.md) +- 相依:[模型管理](feature-model-management.md)、[會員系統](feature-auth.md)、[介面契約](../interface-contracts.md) +- 待更新:user-stories.md(US-31~US-40 併入主表)、success-metrics.md(分享指標) diff --git a/docs/autoflow/03-design/feature-model-sharing-design.md b/docs/autoflow/03-design/feature-model-sharing-design.md new file mode 100644 index 0000000..66c111c --- /dev/null +++ b/docs/autoflow/03-design/feature-model-sharing-design.md @@ -0,0 +1,461 @@ +# 模型共享 設計規格 — visionA Cloud + +> **L 級新功能**「模型共享」的 Design 部分(三方聯合規劃:PM 寫 PRD / Architect 寫 TDD / Design 寫本檔)。 +> +> **核心定位**:本功能是**擴充既有模型庫 UI**,不是全新模組。100% 沿用既有 Design Tokens(`design-tokens.md`)、既有元件(`components.md`)、既有 `/models` 三區版型與 `ModelCard` / `ModelGrid` / `ModelSection` / `ModelFilters` / `ModelDetailClient`。本檔只定義「共享」這個維度帶來的新視覺 / 互動增量。 +> +> **不新增 Design Token**。狀態色沿用既有 `chart-*` token 與 §2.1 半語義約定。 +> +> 對應頁面:`/models`(列表擴充)+ `/models/[id]`(詳情頁擴充成 owner / 公開兩態)+ 新增「共享對象設定」互動(Dialog)+ 新增「共享模型庫」瀏覽維度。 +> +> 配套格式參考:`pages.md` §8、`flows/flow-model-upload.md`、`components.md`。 + +--- + +## 0. 名詞與待確認前提(先給 PM / Architect) + +本設計基於以下**假設**,凡標 🔶 者為「需 PM 確認需求細節」、標 🔷 者為「需 Architect 確認技術可行性」。設計會標出因假設不同而分歧的 UI 分支。 + +| # | 名詞 / 假設 | 說明 | 標記 | +|---|-----------|------|------| +| A1 | **公開對象(visibility)三態** | 假設分 `private`(僅自己)/ `public`(所有登入使用者可見)/ `shared`(指定對象)三種 | 🔶 PM:是否只做 private/public 兩態?「指定對象」Phase 0 是否需要? | +| A2 | **「指定對象」的粒度** | 假設可指定「個別使用者(by email)」。是否還有「群組 / 組織 / 團隊」概念? | 🔶 PM + 🔷 Architect:有無 org/team 資料模型? | +| A3 | **可見模型的三分類** | 列表要區分「我的(owner)/ 公開(public)/ 共享給我的(shared-with-me)」 | 🔶 PM:分類命名與是否需要「我公開出去的」獨立檢視 | +| A4 | **共享 = 唯讀** | 假設別人共享 / 公開給我的模型我**只能檢視 + 下載 + 燒錄**,不能改名 / 刪除 / 再共享 | 🔶 PM:可否「再共享」(re-share)?可否下載? | +| A5 | **profile 頁 = 擴充既有 detail** | 假設「模型 profile 頁」就是擴充現有 `/models/[id]`,依 viewer 身份切 owner 版 / 公開版,不另建路由 | 🔷 Architect:是否需要獨立 public 路由(如 `/m/[shareId]` 供分享連結)? | +| A6 | **分頁(pagination)** | 共享模型庫可能量大需分頁。假設用 offset/limit 或 cursor | 🔷 Architect:後端分頁機制(offset vs cursor)決定 UI(頁碼 vs 無限捲動 / 載入更多) | +| A7 | **搜尋範圍** | 假設搜尋 = 對「當前可見的所有模型」搜名稱(+ 選填分類 / 晶片) | 🔶 PM:搜尋要不要跨到「公開但未共享給我」的全庫探索? | +| A8 | **公開對象變更權限** | 假設只有 owner 能改自己模型的公開設定 | 🔷 Architect:權限在 API 層把關(前端只是 UI 便利) | + +> **設計原則**:以上假設我採「最完整但可降級」的畫法——先畫三態 visibility + 三分類列表,PM 若砍到兩態 / 兩分類,UI 直接移除對應分支即可,不需重畫。 + +--- + +## 1. User Story(Design 視角) + +> **作為** 一個 Kneron 開發者, +> **我想要** 把我的模型公開或共享給特定同事,並瀏覽別人共享給我的模型, +> **這樣** 團隊就能共用模型,不用每個人重複上傳 / 轉檔。 + +**體驗成功條件:** +- 使用者一眼能分辨列表中哪些是「我的 / 公開 / 共享給我的」(不只靠顏色,用 badge 文字 + 圖示雙編碼) +- 設定公開對象在 2 次點擊內可達(卡片選單 or 詳情頁按鈕 → Dialog) +- 共享模型庫量大時,瀏覽 / 搜尋 / 篩選 / 排序不卡頓、狀態清楚(載入 / 空 / 無結果 / 無權限四態齊備) +- 公開版 profile 頁不洩漏 owner 的私有操作(刪除 / 改公開設定按鈕對非 owner 隱藏) + +--- + +## 2. 範圍與 Phase 0 降級 + +| 項目 | Phase 0(本次) | Phase 1+ | +|------|----------------|---------| +| Visibility 態 | private / public / shared(🔶 待 PM 砍) | + 到期共享 / 密碼保護連結 | +| 指定對象粒度 | 個別使用者 by email(🔶) | + 群組 / 組織 | +| 列表分類 | 我的 / 公開 / 共享給我的 三分類(🔶) | + 我公開出去的獨立檢視 | +| 分頁 | 「載入更多」按鈕 or 頁碼(依 🔷 A6) | 無限捲動 + 虛擬列表 | +| 排序 | 名稱 / 建立時間 / 共享時間(3 選項) | + 熱門度 / 下載數 | +| 篩選 | 既有 targetChip + 新增「共享狀態」維度 | + 分類 / 標籤 / owner | +| 搜尋 | 名稱模糊搜(前端 or 後端依 🔷 A6) | 全文 + tag | +| profile 公開版 | owner 版 / 公開版兩態 | 分享連結 `/m/[shareId]` | +| 再共享 / 轉讓 | 不做(🔶 A4) | 視需求 | + +--- + +## 3. 資訊架構變更(IA) + +沿用既有 `/models` 與 `/models/[id]`,不新增頂層導航。共享是「模型」的一個屬性維度,落在既有模型庫內。 + +``` +/models (模型庫 — 擴充:新增「共享」瀏覽維度) +│ +├── 檢視模式切換(新增): +│ ┌─ 依來源分區(既有:preset / converted / uploaded) ← 預設,僅「我的」模型 +│ └─ 依共享關係分區(新增):我的 / 公開 / 共享給我的 +│ +├── 搜尋框(新增,跨當前檢視) +├── 篩選(擴充 ModelFilters:targetChip + 共享狀態) +├── 排序(新增:名稱 / 建立時間 / 共享時間) +├── 分頁 / 載入更多(新增) +│ +└── /models/[id] (模型 profile 頁 — 擴充成雙態) + ├── owner 版:既有全操作(改名 / 刪除 / 下載 / 燒錄 / 【新增】公開設定) + └── 公開 / 共享版:唯讀(檢視 / 下載 / 燒錄),隱藏 owner-only 操作 + + 新增「擁有者」資訊列(誰共享的 / 共享時間 / 公開對象徽章) +``` + +**🔶 待 PM 確認**:兩種檢視模式(來源分區 vs 共享關係分區)是否都要?還是共享關係分區直接取代來源分區?我建議**保留切換**(Tab 或 Segmented Control),因為「來源分區」對管理自己的模型仍有用,「共享關係分區」才是本功能重點。 + +--- + +## 4. 共享模型庫列表設計 + +### 4.1 檢視模式切換(新增元件 `ModelViewToggle`) + +在 `/models` 頁標題列下、`ModelFilters` 上方,新增一個 Segmented Control(用既有 `Tabs` 元件實作,`variant` 沿用): + +``` +┌─────────────────────────────────────────────────────────────┐ +│ 模型庫 [上傳模型] │ +│ 管理、公開與共享你的 Kneron 模型 │ +├─────────────────────────────────────────────────────────────┤ +│ ┌──────────────┬──────────────┐ │ +│ │ 依來源 ● │ 依共享關係 │ ← Tabs(既有元件) │ +│ └──────────────┴──────────────┘ │ +├─────────────────────────────────────────────────────────────┤ +│ 🔍 [搜尋模型名稱...........] [晶片 ▾] [共享狀態 ▾] [排序 ▾]│ ← 篩選列(擴充) +├─────────────────────────────────────────────────────────────┤ +│ (分區內容依所選檢視模式渲染) │ +└─────────────────────────────────────────────────────────────┘ +``` + +- **依來源**(既有預設,不變):preset / converted / uploaded 三區,僅顯示「我的」模型。 +- **依共享關係**(新增):三區 — + - 🟢 **我的**(owner):`chart-1` 色點呼應 + - 🔵 **公開**(public,含我公開的 + 別人公開的,🔶 A7 待確認範圍):`chart-2` + - 🟣 **共享給我的**(shared-with-me):`chart-3` + +> 沿用既有 `ModelSection` 的「色點 + 標題 + 計數」樣式,只是換分組維度。`SOURCE_DOT_CLASS` 邏輯延伸出一份 `SHARE_DOT_CLASS`,色 token 一致。 + +### 4.2 卡片上的共享標示(擴充 `ModelCard`) + +既有 `ModelCard` 已有 status badge + targetChip badge + source badge。**新增一個 visibility badge**,放在 badge 列,用**圖示 + 文字雙編碼**(不僅靠顏色,滿足無障礙 De1): + +| Visibility | 圖示(Lucide) | 文字 | 配色(沿用 token tint 風格) | 顯示條件 | +|-----------|--------------|------|---------------------------|---------| +| private | `Lock` | 私有 | `text-muted-foreground` + `border-border`(中性)| owner 檢視自己模型時 | +| public | `Globe` | 公開 | `border-chart-2/30 bg-chart-2/10 text-chart-2` | 任何人 | +| shared | `Users` | 已共享 / 共享給我 | `border-chart-3/30 bg-chart-3/10 text-chart-3` | owner 看到「已共享給 N 人」;receiver 看到「{owner} 共享」| + +**owner 額外資訊**:private/shared/public 卡片右上角,owner 檢視時可在 badge 顯示對象數,如「公開」「共享 · 3 人」。 + +**receiver 視角**:卡片新增一行**次要資訊**(`text-xs text-muted-foreground`):「由 {ownerEmail} 共享 · {relativeTime}」。沿用 `RemoteDeviceBadge` 的相對時間格式化 util(剛剛 / X 分鐘前 / 絕對時間)。 + +**卡片操作差異**: +- owner 卡片:既有下載按鈕(若 downloadable)+ **新增卡片右上角 `⋮` 選單**(Dropdown),內含「公開設定」「刪除」等(見 §5 觸發點)。 +- receiver 卡片:只保留「下載」(若 A4 允許)+ 點擊進 profile 頁。無 `⋮` 選單(無 owner 權限)。 + +> 🔷 **Architect 確認**:`ModelSummary` 需新增欄位 `visibility: 'private' | 'public' | 'shared'`、`ownerEmail?`、`sharedAt?`、`sharedCount?`(owner 視角)、`isOwner: boolean`。前端 UI 依這些欄位渲染,實際權限由 API 把關。 + +### 4.3 篩選擴充(擴充 `ModelFilters`) + +既有 `ModelFilters` 只有 targetChip。新增「共享狀態」維度(第二個 `Select`),僅在「依共享關係」檢視模式下顯示: + +```tsx +export interface ModelFilterValue { + targetChip: TargetChip | "all"; + visibility?: "all" | "private" | "public" | "shared"; // 新增,僅共享關係檢視用 +} +``` + +- targetChip 篩選跨所有分區作用(沿用既有行為)。 +- visibility 篩選:選「公開」時只顯示 public 區,其餘區收合為 0(沿用「區內為空仍顯示標題 + 精簡空狀態」的既有慣例)。 + +### 4.4 搜尋框(新增) + +- 位置:篩選列最左,`Input` + 前綴 `Search` icon(既有 `Input` 元件 h-9)。 +- 行為:🔷 A6 — + - 若後端分頁:搜尋走後端(debounce 300ms,送 query),配合分頁。 + - 若前端全量:即時前端 filter(無 debounce 需求)。 +- Placeholder:`搜尋模型名稱...` +- 清除:有輸入時右側顯示 `X` 清除鈕(`ghost` icon button)。 +- 無障礙:`role="searchbox"`、`aria-label="搜尋模型"`;搜尋無結果時 `aria-live="polite"` 播報結果數。 + +### 4.5 排序(新增 `ModelSortSelect`) + +`Select`(h-9),選項: + +| key | 文字 | 說明 | +|-----|------|------| +| `name-asc` | 名稱 A→Z | 預設(我的區)| +| `createdAt-desc` | 最新建立 | | +| `sharedAt-desc` | 最近共享 | 僅「共享給我的」區有意義;其他區隱藏此選項 | + +🔷 排序在後端還前端由 A6 決定。若後端分頁 → 排序參數送後端。 + +### 4.6 分頁(新增,依 🔷 A6 二選一) + +**方案 P1(cursor / 無限捲動友善)→「載入更多」按鈕**(Phase 0 建議,實作簡單、無頁碼狀態同步問題): + +``` +┌────────────────────────────────────────────┐ +│ [卡片] [卡片] [卡片] [卡片] │ +│ [卡片] [卡片] [卡片] [卡片] │ +│ │ +│ [ 載入更多 (顯示 24 / 87) ] │ ← Button variant=outline,loading 時 Spinner +└────────────────────────────────────────────┘ +``` + +**方案 P2(offset / 傳統頁碼)→ 底部 Pagination 元件**(若後端只支援 offset): + +``` + ‹ 上一頁 1 2 [3] 4 5 … 12 下一頁 › +``` + +> 🔷 **Architect 裁決 A6**:後端分頁是 cursor 還 offset?我**建議 P1「載入更多」**——對「瀏覽型」的共享庫體驗較順、行動裝置友善、無「換頁後捲動位置重置」問題。若 Architect 已定 offset,則走 P2。**兩案我都出規格,選一即可,不需重畫卡片。** + +**分頁 UX 細節**: +- 每頁 24 筆(3 欄 × 8 列 desktop;卡片沿用既有 `grid-cols-1 sm:2 lg:3 xl:4`)。 +- 載入更多 loading:按鈕內 `Spinner` + 文字「載入中」;同時底部補 4 個 `Skeleton` 卡片(沿用既有 skeleton 樣式)。 +- 搜尋 / 篩選 / 排序變更 → 重置分頁到第 1 頁 / 清空已載入。 + +--- + +## 5. 模型公開設定 UI(新增 `ModelVisibilityDialog`) + +### 5.1 觸發點(兩處,owner-only) + +1. **列表卡片** `⋮` 選單 →「公開設定」(§4.2 新增的 Dropdown)。 +2. **profile 頁**(owner 版)操作列 → 新增按鈕 `[公開設定]`(`variant=outline` + `Globe` icon),放在既有「下載 / 刪除」旁。 + +> 🔶 PM:觸發點以上兩處是否足夠?是否需要在上傳完成後直接引導設定公開對象?(我建議上傳 Dialog 完成後加一句「模型預設為私有,之後可在詳情頁設定公開」的提示,不強迫當下設定。) + +### 5.2 Dialog 版型(沿用既有 `Dialog`) + +``` +┌──────────────────────────────────────────────────┐ +│ 公開設定 — {模型名稱} [X] │ +├──────────────────────────────────────────────────┤ +│ 誰可以看到並使用這個模型? │ +│ │ +│ ○ 🔒 私有 只有你自己 │ +│ ○ 🌐 公開 所有 visionA 使用者 │ +│ ● 👥 指定對象 只有你選的人 │ ← RadioGroup +│ │ +│ ┌─ 指定對象(僅「指定對象」選中時展開) ──────────┐ │ +│ │ [輸入 email 加入........] [加入] │ │ +│ │ ┌──────────────────────────────────────────┐ │ │ +│ │ │ alice@corp.com 可檢視+下載 [✕] │ │ │ +│ │ │ bob@corp.com 可檢視+下載 [✕] │ │ │ +│ │ └──────────────────────────────────────────┘ │ │ +│ └────────────────────────────────────────────────┘ │ +│ │ +│ ⚠ 公開後,所有使用者都能下載此模型(若允許下載) │ ← public 選中時的提示 +├──────────────────────────────────────────────────┤ +│ [取消] [儲存變更] │ +└──────────────────────────────────────────────────┘ +``` + +**元件組成(全部沿用既有 shadcn 元件):** +- 三態選擇:`RadioGroup`(🔷 若 components.md 未列 RadioGroup,需 Architect / Frontend 確認 shadcn 是否已有,或用 `Select` 降級)。 +- 圖示:`Lock` / `Globe` / `Users`(與列表 badge 同圖示,維持一致性)。 +- 指定對象加入:`Input`(email)+ `Button`(加入)。email 格式即時驗證(失焦時),錯誤走既有 `aria-invalid` + `border-destructive` 樣式。 +- 對象清單:每列 = email + 權限標籤(Phase 0 固定「可檢視+下載」,🔶 A4)+ 移除鈕(`ghost` icon `X`)。 +- public 警告:選 public 時顯示 `bg-amber-50 dark:bg-amber-950/30` 提示條(沿用 §2.1 半語義約定)。 +- 底部:`[取消]`(`variant=outline`)+ `[儲存變更]`(`variant=default`,loading 時 Spinner)。 + +**🔶 待 PM 確認**: +- 「指定對象」若 Phase 0 不做,Dialog 降為 private / public 兩選(RadioGroup 兩項,移除展開區),版型不變。 +- 權限粒度:Phase 0 是否只有「可檢視+下載」單一權限?還是要分「僅檢視 / 檢視+下載 / 檢視+燒錄」? + +### 5.3 互動與回饋 + +| 動作 | 回饋 | +|------|------| +| 切換 private→public | 顯示 amber 警告條;儲存前不生效 | +| 加入 email(格式錯)| Input 紅框 + 下方「Email 格式不正確」| +| 加入 email(不存在的使用者)| 🔷 Architect:後端驗證,回錯時 toast「找不到使用者 {email}」| +| 加入重複 email | Input 提示「已在清單中」,不重複加入 | +| 儲存成功 | 關 Dialog + toast「已更新公開設定」+ 列表 / 卡片 badge 即時更新 | +| 儲存失敗 | Dialog 內 error banner「儲存失敗,請重試」,不關閉 | +| public→private(曾共享給人)| AlertDialog 二次確認「改為私有後,已共享的對象將無法再存取,確定?」| + +### 5.4 UX Writing(本功能新增文案) + +| key | 繁中 | English | +|-----|------|---------| +| `models.visibility.title` | 公開設定 | Visibility | +| `models.visibility.question` | 誰可以看到並使用這個模型? | Who can access this model? | +| `models.visibility.private` | 私有 | Private | +| `models.visibility.private.desc` | 只有你自己 | Only you | +| `models.visibility.public` | 公開 | Public | +| `models.visibility.public.desc` | 所有 visionA 使用者 | All visionA users | +| `models.visibility.shared` | 指定對象 | Specific people | +| `models.visibility.shared.desc` | 只有你選的人 | Only people you choose | +| `models.visibility.addEmail` | 輸入 email 加入 | Add by email | +| `models.visibility.publicWarning` | 公開後,所有使用者都能下載此模型 | Once public, anyone can download this model | +| `models.visibility.saved` | 已更新公開設定 | Visibility updated | +| `models.visibility.emailInvalid` | Email 格式不正確 | Invalid email format | +| `models.visibility.emailDuplicate` | 已在清單中 | Already in the list | +| `models.visibility.userNotFound` | 找不到使用者 {email} | User {email} not found | +| `models.visibility.revokeConfirm` | 改為私有後,已共享的對象將無法再存取,確定? | Setting to private revokes access for shared users. Continue? | +| `models.sharedBy` | 由 {email} 共享 | Shared by {email} | + +--- + +## 6. 模型 profile 頁設計(擴充 `/models/[id]`) + +### 6.1 設計取捨:擴充既有 detail,依身份切兩態 + +**決策:不另建路由**,沿用 `/models/[id]` + `ModelDetailClient`,依 `model.isOwner`(🔷 Architect 提供)分支渲染。理由: +- 既有 detail 頁已有完整資訊展示(描述 / 大小 / framework / inputShape / classes / checksum),共享 profile 需要的資訊 90% 重疊,重畫浪費。 +- 唯一差異是「操作區權限」與「擁有者資訊」,用條件渲染即可。 + +> 🔷 **Architect 確認 A5**:若未來要「分享連結給未登入者 / 站外」,才需獨立 public 路由 `/m/[shareId]`(免登入、SEO 友善)。Phase 0 假設僅站內登入使用者可見,沿用 `/models/[id]` 即可。**若需求包含站外分享連結,請 PM 明確,會影響是否要新路由。** + +### 6.2 兩態差異表 + +| 區塊 | owner 版(既有)| 公開 / 共享版(新增分支)| +|------|---------------|----------------------| +| 返回鈕 | `← 返回`(回 /models)| 同 | +| 標題 + badge 列 | name + targetChip + status + source | + **visibility badge**(公開 / 共享)| +| **擁有者資訊列**(新增)| 不顯示(自己就是 owner)| 顯示:`由 {ownerEmail} 共享 · {time}` + owner 頭像(首字母圓形,沿用 UserMenu avatar 樣式)| +| 操作列 | 下載 + 刪除 + **【新增】公開設定** | **僅**下載(若 A4 允許);隱藏刪除 / 公開設定 | +| 描述 / metadata 卡片 | 完整 | 完整(唯讀,相同)| +| classes 區塊 | 完整 | 完整(唯讀)| +| 燒錄至裝置(未來 F7+)| 列出自己在線裝置 | 同(共享模型也能燒錄到自己的裝置)| + +### 6.3 公開版 profile 版型 + +``` +┌──────────────────────────────────────────────────────┐ +│ ← 返回 │ +│ │ +│ ResNet50-KL720 👥 共享給我 │ ← 標題 + visibility badge +│ [KL720] [就緒] [轉檔] │ ← 既有 badge 列 +│ [⬇ 下載] │ ← 僅下載(無刪除/公開設定) +│ ┌──────────────────────────────────────────────────┐│ +│ │ 👤 由 alice@corp.com 共享 · 3 天前 ││ ← 擁有者資訊列(新增) +│ └──────────────────────────────────────────────────┘│ +│ │ +│ ┌─ 模型描述 ─────────────────────────────────────┐ │ ← 既有卡片,唯讀 +│ │ 一般物件偵測模型... │ │ +│ │ 大小: 24.3 MB 建立: 2026/07/20 │ │ +│ │ Framework: onnx Input: 1×3×224×224 │ │ +│ │ Classes (80): [person][car][dog] +77 │ │ +│ └────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────┘ +``` + +### 6.4 擁有者資訊列規格(新增小元件 `ModelOwnerBar`) + +- 容器:`flex items-center gap-2` + `bg-muted/50 rounded-md px-3 py-2 text-sm` +- 頭像:40px→改 24px(`h-6 w-6 rounded-full`)圓形,email 首字母,沿用 UserMenu avatar 配色 +- 文字:`由 {ownerEmail} 共享 · {relativeTime}`(相對時間 util 沿用 RemoteDeviceBadge) +- 僅在非 owner 檢視時渲染 +- 無障礙:`aria-label="模型擁有者資訊"` + +--- + +## 7. 各狀態設計(空 / 載入 / 無權限 / 無結果) + +沿用既有 `EmptyState` / `Skeleton` / toast,文案走本功能語氣。 + +| 狀態 | 觸發 | 呈現 | 文案 | +|------|------|------|------| +| **載入中** | 首次載入 / 換頁 / 搜尋 | 既有 `Skeleton` 卡片網格(8 個)| — | +| **空 · 共享給我的(無)** | 「共享給我的」區無資料 | `EmptyState` icon=`Users` | 標題「還沒有人共享模型給你」/ 描述「當同事把模型共享給你時,會出現在這裡」| +| **空 · 公開(無)** | 公開區無資料 | 精簡 inline 空狀態(沿用區內空狀態樣式)| 「目前沒有公開的模型」| +| **搜尋無結果** | 搜尋 query 無 match | `EmptyState` icon=`SearchX` + `[清除搜尋]` CTA | 標題「找不到符合『{query}』的模型」/ 描述「試試其他關鍵字或清除篩選」| +| **無權限** | 直接開別人私有模型的 `/models/[id]` | 全頁 `EmptyState` icon=`Lock` + `[返回模型庫]` | 標題「沒有權限檢視此模型」/ 描述「這個模型未公開或未共享給你」| +| **公開設定儲存失敗** | API 錯 | Dialog 內 error banner | 「儲存失敗,請重試」| +| **載入更多失敗** | 分頁 API 錯 | 「載入更多」按鈕轉為 `[重試]` + toast | 「載入失敗,點擊重試」| + +**無權限狀態(`/models/[id]` 403)版型:** +``` +┌──────────────────────────────────────┐ +│ ← 返回 │ +│ │ +│ 🔒 │ +│ 沒有權限檢視此模型 │ +│ 這個模型未公開或未共享給你 │ +│ │ +│ [ 返回模型庫 ] │ +└──────────────────────────────────────┘ +``` +> 🔷 Architect:403 vs 404 的取捨——為避免「模型是否存在」的資訊洩漏,建議私有模型對無權限者回 **404**(當作不存在),UI 走「找不到模型」而非「無權限」。**請 Architect 確認採 403 揭露存在 or 404 隱藏存在**,UI 兩版文案我都備。 + +--- + +## 8. 響應式(沿用既有斷點) + +沿用 `pages.md` §11 的斷點與 `/models` 既有規則(Mobile 單欄 / Tablet 2 欄 / Desktop 3–4 欄)。本功能新增元素的響應式: + +| 元素 | Mobile (<640) | Tablet (640–1024) | Desktop (≥1024) | +|------|--------------|-------------------|-----------------| +| 檢視模式 Tabs | 全寬 2 等分 | 同 | 靠左自然寬 | +| 篩選 / 搜尋 / 排序列 | **堆疊**(搜尋佔滿寬一行 + 篩選鈕 wrap 下一行)| 部分同行 wrap | 單行 | +| visibility badge | 顯示(badge 列 `flex-wrap` 既有)| 同 | 同 | +| 擁有者資訊列 | 全寬,email 過長 `truncate` | 同 | 同 | +| 公開設定 Dialog | `max-w-[calc(100vw-2rem)]`,對象清單可捲動 | `max-w-lg` | `max-w-lg` | +| 分頁「載入更多」| 全寬按鈕 | 置中 | 置中 | +| 頁碼(P2)| 精簡(僅 ‹ 頁 X/Y › )| 完整頁碼 | 完整頁碼 | + +--- + +## 9. 無障礙(沿用既有 + 本功能新增) + +- **visibility badge**:圖示(`aria-hidden`)+ 文字雙編碼,不僅靠顏色(De1)。SR 讀得到「公開」「已共享」文字。 +- **檢視模式 Tabs**:Radix Tabs 內建 `role="tablist"` + Arrow 鍵導航 + `aria-selected`。 +- **公開設定 RadioGroup**:Arrow 鍵切換選項、`aria-checked`、每選項有可見 label。 +- **email 加入**:`Input` `aria-label`;加入成功後焦點回 input 便於連續加入;對象清單移除鈕 `aria-label="移除 {email}"`。 +- **搜尋框**:`role="searchbox"`;無結果 `aria-live="polite"` 播報「找到 0 個模型」。 +- **載入更多**:`aria-label` 含當前 / 總數「載入更多,已顯示 24 之 87」。 +- **狀態改變**(儲存 / 共享):toast 走既有 Sonner(`aria-live` 內建)。 +- **觸控目標**:所有新增互動元素(`⋮` 選單鈕、移除鈕、Tab、清除鈕)≥ 44×44px(`⋮` 用 `icon` size = 36px 需注意,建議 profile / 卡片上用 `size=icon` 但外圍點擊區補到 44px,或用既有 button 慣例)。🔷 Frontend 實作注意。 +- **對比度**:所有新 badge 用既有 `chart-*` token tint 風格(`/10` 底 + 純色文字),與既有 source badge 同做法,既有已通過 WCAG AA。Dark Mode 由 token 自動處理。 + +> **Dark Mode**:本功能不新增顏色,全用既有 token(`chart-1/2/3`、`muted`、`amber-*` 半語義),Dark Mode 自動生效。無需獨立 Dark 版設計。(本任務 A 層 Dark Mode 截圖延到 B 層 / 實作期補,因為零新 token、純沿用。) + +--- + +## 10. 沿用 vs 新增總表 + +### 10.1 沿用既有(不改) +| 既有資產 | 用途 | +|---------|------| +| Design Tokens 全部 | 零新增 | +| `ModelGrid` | 卡片網格 + skeleton + 空狀態(分頁時擴充「載入更多」)| +| `ModelSection` | 分區色點 + 標題 + 計數(共享關係分區沿用)| +| `Card` / `Badge` / `Button` / `Dialog` / `AlertDialog` / `Select` / `Input` / `Tabs` / `EmptyState` / `Skeleton` / `Sonner` | 全部 | +| `RemoteDeviceBadge` 的相對時間 util | 共享時間 / owner 資訊列 | +| UserMenu avatar 樣式 | owner 頭像 | +| §2.1 amber 半語義 | public 警告條 | + +### 10.2 新增(Design 定義,Frontend 實作) +| 新增 | 類型 | 基底 | +|------|------|------| +| `ModelViewToggle` | 元件 | `Tabs` | +| visibility badge(含於 ModelCard 擴充)| 元件擴充 | `Badge` + Lucide `Lock`/`Globe`/`Users` | +| ModelCard `⋮` owner 選單 | 元件擴充 | `DropdownMenu`(🔷 確認 shadcn 已有)| +| `ModelFilters` 共享狀態維度 | 元件擴充 | `Select` | +| 搜尋框 | 元件擴充 | `Input` + `Search` icon | +| `ModelSortSelect` | 元件 | `Select` | +| 分頁「載入更多」or Pagination | 元件 | `Button` / 新 Pagination(🔷 A6 二選一)| +| `ModelVisibilityDialog` | 元件 | `Dialog` + `RadioGroup` + `Input` + `Button` | +| `ModelOwnerBar` | 元件 | `Card`/div + avatar | +| ModelDetailClient owner/公開雙態分支 | 頁面擴充 | 條件渲染 | +| 無權限 / 搜尋無結果 / 共享空狀態 | 狀態 | `EmptyState` | + +--- + +## 11. 給三方互審的重點清單 + +### 需 PM 確認需求細節 🔶 +1. **A1 Visibility 態數**:private/public/shared 三態 vs 只 private/public 兩態?「指定對象」Phase 0 要不要做? +2. **A2 指定對象粒度**:個別 email vs 群組/組織/團隊? +3. **A3 列表分類**:「我的 / 公開 / 共享給我的」三分類命名 OK 嗎?要不要「我公開出去的」獨立區? +4. **A4 共享權限**:receiver 能否下載?能否再共享(re-share)?權限是否分「僅檢視 / 檢視+下載 / 檢視+燒錄」? +5. **A7 搜尋範圍**:搜尋只搜「可見的」還是要能「探索全站公開庫」? +6. **檢視模式**:「依來源」與「依共享關係」兩種 Tab 都保留,還是共享關係取代來源? +7. **觸發引導**:上傳完成後要不要引導設定公開對象? + +### 需 Architect 確認可行性 🔷 +1. **A5 / 6.1 profile 路由**:沿用 `/models/[id]` 雙態 vs 需獨立 public 路由 `/m/[shareId]`(站外分享連結)? +2. **A6 分頁機制**:後端 cursor(→ P1 載入更多)還 offset(→ P2 頁碼)?搜尋 / 排序在前端還後端? +3. **`ModelSummary` 新欄位**:`visibility` / `ownerEmail` / `sharedAt` / `sharedCount` / `isOwner` 可否由 API 提供? +4. **7 無權限回應碼**:私有模型對無權限者回 403(揭露存在)還 404(隱藏存在)?影響 UI 文案。 +5. **RadioGroup / DropdownMenu**:shadcn 元件庫是否已含這兩個?未含則需先補 or 用 Select 降級。 +6. **email 對象驗證**:加入指定對象時,後端能否即時驗證使用者存在? + +--- + +## 12. Verification(本設計任務自檢) + +- **De-A1 三斷點**:§8 已定義 mobile/tablet/desktop 三斷點行為(純規格 + ASCII 版型;實際截圖於 prototype / 實作期補,本階段為規格文件無渲染產出)。 +- **De-A2 對比度**:零新 token,全沿用既有 `chart-*` tint + `muted` + `amber` 半語義,既有已過 WCAG AA(design-tokens.md 已確認)。 +- **De-A3 無 hardcode**:本檔所有色彩引用皆為 token 名(`chart-1/2/3`、`muted-foreground`、`amber-50` 半語義約定),無裸 hex / rgb。 +- **De-A4 三態覆蓋**:§7 已定義空 / 載入 / 無結果 / 無權限 / 失敗多態。 +- **De-A5 觸控目標**:§9 已標註新增互動元素 ≥ 44px 需求 + `⋮` 鈕注意事項。 +- **De-B1 互動五狀態 / De-B2 Dark Mode / De-B4 三方互審**:延到 milestone(互動狀態沿用既有元件既有態;Dark Mode 零新 token 自動生效;三方互審即本檔 §11 待 PM/Architect 回審)。 +- **No silent failures**:所有 🔶🔷 未定項已明確標「待 PM/Architect 確認」+ 對應假設,無「TBD 無負責人」。 +- **Doc 同步**:本檔為新增;pages.md / components.md 的增補建議見下方回報(本任務未直接改,避免撞名覆蓋,交 Orchestrator 決定增補方式)。 diff --git a/docs/autoflow/04-architecture/TDD.md b/docs/autoflow/04-architecture/TDD.md index e76ed75..4267c84 100644 --- a/docs/autoflow/04-architecture/TDD.md +++ b/docs/autoflow/04-architecture/TDD.md @@ -62,6 +62,18 @@ - **下載對接權威規格(v1.3:query-string token + redirect、推翻舊「fetch + Bearer」、解 CORS 405)** → [`adr/adr-017-model-library-access.md` §11](adr/adr-017-model-library-access.md#11-v13瀏覽器下載改-query-string-token--redirect推翻決策-2--104-的fetch--bearer) - CORS 405 根因定位(個人層)→ `.autoflow/04-architecture/download-cors-405-diagnosis.md` +### 9.6 模型共享(Model Sharing — 公開對象 + 共享模型庫) +- **TDD**(資料模型 visibility 欄 / 權限可見性 query / 相容性 / 並行化計畫) → [`feature-model-sharing-tdd.md`](feature-model-sharing-tdd.md) +- **API 規格**(`GET /api/models/library` 共享庫列表 / `GET /api/models/:id/profile` / `PATCH /api/models/:id/visibility`) → [`api/api-model-sharing.md`](api/api-model-sharing.md) +- 沿用 ADR-017 決策 3 的 `model_shares`(點對點分享);本功能新增 `models.visibility` enum(private/tenant/public)作正交的「廣播」維度 +- ⚠️ 放寬既有 owner-only 邊界(download 端點權限改動 + enumeration 防護)— 建議整體送 security agent 審 + +### 9.7 個人設備管理 — 設備註冊 / 取消註冊(P0) +- **TDD**(註冊 API / 取消註冊 vs unpair 區分 / 三態分色 / 排序 filter / 安全 / 並行化計畫) → [`feature-device-mgmt-tdd.md`](feature-device-mgmt-tdd.md) +- **API 規格**(`POST /api/devices/:id/register` / `POST /api/devices/:id/unregister`,UUID 識別) → [`api/api-device-mgmt.md`](api/api-device-mgmt.md) +- 沿用 ADR-018 走向 A' 模型(`registered_at` 註冊軸、欄位已存在 → **不需 migration**) +- ⚠️ **取消註冊 = 退回未註冊(清 registered_at、保留列),與 unpair(軟刪+撤 token)分開**;使用者 2026-08-02 重新拍板,覆蓋 ADR-018 §1.1 Q5「實體刪除」 + ### 10. 前端資料流與狀態管理 - 見 §10(本文件) diff --git a/docs/autoflow/04-architecture/adr/adr-020-ffmpeg-camera-indev.md b/docs/autoflow/04-architecture/adr/adr-020-ffmpeg-camera-indev.md new file mode 100644 index 0000000..f5867a1 --- /dev/null +++ b/docs/autoflow/04-architecture/adr/adr-020-ffmpeg-camera-indev.md @@ -0,0 +1,190 @@ +# ADR-020: vendor ffmpeg 加回 camera input device(indev)— 三平台 avfoundation / dshow / v4l2 + +## 狀態 +Proposed。 + +> 待「三平台 ffmpeg rebuild / 驗證 + `buildCaptureArgs` 補 Linux 分支 + 三平台實機驗(需實體攝影機)」全數完成後,轉 Accepted。 +> 使用者目前僅有 macOS 機器可測,Windows / Linux 標為「待實機驗證」。 + +## 日期 +2026-08-02 + +## 作者 +Architect Agent + +## 相關 +- 根因評估:`local-tool/.autoflow/05-implementation/camera-ffmpeg-avfoundation-eval.md` +- 打破的前提:v2 TDD §2(decoder-only ffmpeg 決策)、`vendor/ffmpeg/macos/BUILD.md` +- 相關實作:`local-tool/server/internal/camera/ffmpeg_camera.go`、`ffmpeg_detect.go` + +--- + +## 1. 背景與範圍 (Context) + +### 1.1 觸發問題 + +camera 即時推論在真機報錯(camera 修 bug 讓「吞錯誤」不再發生後,底層錯誤終於浮現): + +``` +failed to open camera (index=0): camera did not start: +ffmpeg exited before producing a frame: ffmpeg stream ended: EOF +... +Unknown input format: 'avfoundation' +Error opening input file 0:none +``` + +`Unknown input format: 'avfoundation'` = 這顆 ffmpeg binary **沒有編進 avfoundation input device(indev)**。 + +### 1.2 根因(已 100% 確認,非推測) + +camera 抓實體攝影機是 **Go 端 `os/exec` 起 ffmpeg subprocess**(非 Python),指令形如 `ffmpeg -f avfoundation -i "0:none" ... -f image2pipe -vcodec mjpeg -`,ffmpeg 把攝影機輸出成連續 MJPEG stream 到 stdout,Go 端掃 JPEG SOI/EOI marker 切 frame。 + +問題在 vendor 的 macOS ffmpeg 是「decoder-only」自 build(v2 TDD §2 決策、`vendor/ffmpeg/macos/BUILD.md`),configure flags 為縮體積 `--disable-everything`,白名單只 re-enable 了「解碼既有檔案」需要的元件: + +``` +--disable-everything +--enable-protocol=file,pipe +--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 +--enable-decoder=h264,hevc,...,mjpeg,... +--enable-parser=... --enable-filter=... --enable-muxer=image2pipe,image2,null +--enable-encoder=mjpeg +--disable-network +``` + +**白名單裡完全沒有任何 `--enable-indev=...`。** `--disable-everything` 一併關掉所有 input device,而白名單沒把 camera 需要的 indev 加回來 → `avfoundation` 認不得 → camera 開不了。 + +### 1.3 為什麼只有 camera 壞、影片 / 圖片 / 批次正常 + +| 功能 | 用到的 ffmpeg 能力 | 白名單有無 | 結果 | +|------|-------------------|-----------|------| +| 影片 / 圖片 / 批次推論 | demuxer + decoder(解碼**既有檔案**) | ✅ 有 | 正常 | +| camera 即時推論 | **indev**(從實體裝置抓 raw frame) | ❌ 沒有 | 壞 | + +這是純「build 白名單漏了 indev」的問題,**不是程式碼邏輯錯**。 + +### 1.4 跨平台現況(讀 code 確認) + +camera 三平台共用同一套 MJPEG pipe 抓取架構,但每平台用不同 indev: + +| 平台 | 抓取 indev | ffmpeg 來源 | ffmpeg indev 現況 | camera code 現況 | +|------|-----------|------------|------------------|-----------------| +| macOS | `avfoundation` | 自 build decoder-only | ❌ 缺 avfoundation(根因) | ✅ 完整 | +| Windows | `dshow` | BtbN n7.1 完整 LGPL build | ⚠️ 大概率已含 dshow(**待實機驗**) | ✅ 完整(dshow 路徑齊全) | +| Linux | `v4l2`(應為) | BtbN n7.1 完整 LGPL build | ⚠️ 大概率已含 v4l2(**待實機驗**) | ❌ 缺 Linux 分支 | + +**附帶 code bug(獨立於 ffmpeg)**:`buildCaptureArgs`(`ffmpeg_camera.go`)的 `switch runtime.GOOS` 只有 `windows` 與 `default`,`default` 分支用 avfoundation。**Linux 會誤落 `default` → 對 Linux 攝影機用 avfoundation → 必錯**。即使 macOS ffmpeg 修好,Linux camera 仍會壞。 + +--- + +## 2. 決策 (Decision) + +採**方案 A:rebuild / 驗證三平台 ffmpeg 加回對應 indev,並補 Linux camera code 分支**。 + +### 2.1 三平台 ffmpeg indev + +| 平台 | 動作 | 具體 | +|------|------|------| +| macOS | rebuild 加 indev | `vendor-ffmpeg-macos-build` 的 configure 加 `--enable-indev=avfoundation`;rebuild;重算 sha256;更新 `BUILD.md`;commit 新 binary | +| Windows | 驗證(大概率零改動) | 實機 `ffmpeg -f dshow -list_devices true -i dummy` 確認 BtbN build 已含 dshow;**若缺**才需換含 dshow 的 build | +| Linux | 驗證(大概率零改動) | 實機 `ffmpeg -devices` 確認 BtbN build 已含 v4l2;**若缺**才需換 build 或補 `--enable-indev=v4l2` | + +> BtbN 官方 LGPL 完整 build(`win64-lgpl` / `linux64-lgpl`)預設含 dshow / v4l2 indev,故 Windows / Linux **預期零 ffmpeg 改動、只需實機驗證**。macOS 是自 build 精簡版,是唯一確定要 rebuild 的。 + +### 2.2 補 Linux camera code 分支(獨立必補項) + +`buildCaptureArgs`(`ffmpeg_camera.go`)與 `ListFFmpegDevices`(`ffmpeg_detect.go`)補 Linux 分支,三平台 capture args 對照: + +| 平台 | 列裝置 | 抓 frame(capture args) | +|------|--------|-------------------------| +| macOS | `-f avfoundation -list_devices true -i ""` | `-f avfoundation -i ":none"` | +| Windows | `-f dshow -list_devices true -i dummy` | `-f dshow -i video=""` | +| Linux(新增) | `-f v4l2 -list_devices true -i ""` 或列舉 `/dev/video*` | `-f v4l2 -i /dev/video` | + +> 三平台後段皆接 `-f image2pipe -vcodec mjpeg -q:v 5 -an -`,MJPEG pipe 架構不動。 + +--- + +## 3. 考慮過的替代方案 (Alternatives Considered) + +| 方案 | 優點 | 缺點 | 排除原因 | +|------|------|------|---------| +| **A. rebuild 加 indev(採用)** | 成本最低(macOS 加一行 flag);不推翻 decoder-only;LGPL 乾淨;架構不動 | 需 rebuild macOS binary + 補 Linux code 分支 | — | +| B. 換完整版 ffmpeg | 省事、一次到位所有格式 | 體積爆增(40–70MB vs 現 5.7MB);推翻 v2 TDD §2 decoder-only;macOS 無現成 LGPL static 完整 build(正是當初自 build 的理由) | 唯一好處「省事」在 A 只加一個 flag 前提下不成立 | +| C. camera 改用平台原生 API(AVFoundation / Media Foundation / V4L2 ioctl + cgo) | 不依賴 ffmpeg subprocess | 三平台各寫一套原生 + cgo,複雜度爆炸 = 重寫 camera 子系統 | 解「build flag 少一行」不該砍掉可用的抓取架構 | +| D. macOS 專用第二顆含 avfoundation 的 ffmpeg | 主 binary 維持純解碼 | 多一顆 binary + 兩套 build 維護 | A 加一個 flag 就能讓同一顆 binary 兼顧(indev 增量 < 0.5MB),D 無意義 | + +--- + +## 4. 後果 (Consequences) + +### 4.1 打破的既有前提 + +本 ADR **打破 v2 TDD §2 decoder-only「不含 indev」的前提**。原決策的假設是「local-tool 只處理本地檔案解碼」,但 camera 即時推論需要從實體裝置抓 frame(indev),該假設對 camera 情境不成立。本 ADR 確立新契約: + +> **camera indev 是 ffmpeg build 白名單的必要一部分。** 未來升級 ffmpeg 版本 rebuild 時,三平台的 indev(avfoundation / dshow / v4l2)不得再遺漏。 + +### 4.2 體積影響 + +| 平台 | 增量 | 說明 | +|------|------|------| +| macOS | **< 0.5 MB**(現 5.7MB) | avfoundation indev 是薄封裝,呼叫系統 AVFoundation / CoreMedia framework(`otool -L` 已顯示 binary 已 link 這些 framework),不自帶 codec | +| Windows / Linux | 0(若零改動) | BtbN build 已含 indev,不重 build | + +### 4.3 LGPL 合規 + +- avfoundation / dshow / v4l2 **皆為 LGPL-safe indev,不引入任何 GPL 元件**。 +- macOS 維持 `--enable-version3`(LGPL v3),rebuild 後仍須通過既有驗證(`ffmpeg -version` 不含 `--enable-gpl` / `libx264` / `libx265`)。 +- Windows / Linux 沿用 BtbN LGPL build,合規不變。 + +### 4.4 三平台 indev 對照(契約,供未來 rebuild 參照) + +| 平台 | indev | ffmpeg 來源 | build 需求 | +|------|-------|------------|-----------| +| macOS | avfoundation | 自 build decoder-only | configure 白名單須含 `--enable-indev=avfoundation` | +| Windows | dshow | BtbN 完整 LGPL build | 沿用 upstream(含 dshow) | +| Linux | v4l2 | BtbN 完整 LGPL build | 沿用 upstream(含 v4l2) | + +### 4.5 風險 + +- 低。macOS 改的是 build flag + 補一個 OS 分支(Linux code),不動 MJPEG pipe 抓取架構。 +- 主要不確定性在「Windows / Linux BtbN build 是否已含 indev」,透過實機 `-list_devices` / `-devices` 驗證即可消除;若不含則退化為「換 build」(+1h)。 + +--- + +## 5. 合規性 Checklist + +實作與轉 Accepted 前須全數完成: + +- [ ] **macOS ffmpeg rebuild**:configure 加 `--enable-indev=avfoundation`、rebuild、重算 sha256、更新 `vendor/ffmpeg/macos/BUILD.md`(含新 configure line / sha256 / 大小)、commit 新 binary +- [ ] **macOS LGPL 驗證**:rebuild 後 `ffmpeg -version` 不含 `--enable-gpl` / `libx264` / `libx265`;`ffmpeg -devices` 列出 avfoundation +- [ ] **Windows dshow 驗證**:實機 `ffmpeg -f dshow -list_devices true -i dummy` 有列裝置(若缺 → 換含 dshow 的 build) +- [ ] **Linux v4l2 驗證**:實機 `ffmpeg -devices` 含 v4l2(若缺 → 換 build 或補 flag) +- [ ] **`buildCaptureArgs` 補 Linux 分支**:`ffmpeg_camera.go` + `ffmpeg_detect.go` 加 `case "linux": -f v4l2`(列裝置 + 抓 frame) +- [ ] **三平台實機驗**:各平台接實體攝影機,camera 即時推論從「開不了」變「出 frame」 +- [ ] **體積 / LGPL 確認**:macOS binary 增量 < 0.5MB、LGPL v3 合規未破 +- [ ] **v2 TDD ffmpeg 章節交叉引用**(增補不覆蓋):註記「camera indev 為 build 白名單必要部分、三平台 indev 對照見 ADR-020」 +- [ ] 與 Tech Lead / 使用者確認 +- [ ] 成本影響已評估(體積增量微、無新增基礎設施成本) + +--- + +## 6. WP 拆解(供 Orchestrator 排期) + +| WP | 內容 | 派誰 | 需實機 | 前置 | 估時 | +|----|------|------|--------|------|------| +| WP-1 | macOS:configure 加 `--enable-indev=avfoundation`、rebuild、重算 sha256、更新 BUILD.md、commit binary | devops(build/vendor)| macOS(使用者有) | — | 1–1.5h(含 ~3 分 rebuild) | +| WP-2 | `buildCaptureArgs` / `ListFFmpegDevices` 補 Linux v4l2 分支(三平台 capture args 對照) | backend(Go camera code) | 否(可先寫、Linux 驗證階段測) | — | 1–1.5h | +| WP-3 | Windows:實機 `-f dshow -list_devices` 驗證 dshow;若缺才換 build | devops | Windows(**待實機**) | — | 0.5h(順利)/ +1h(換 build) | +| WP-4 | Linux:實機驗證 v4l2 + camera 即時推論測試 | devops + backend | Linux(**待實機**) | WP-2 | 1–1.5h | +| WP-5 | v2 TDD ffmpeg 章節交叉引用(增補);ADR-020 轉 Accepted | architect | 否(待 WP-1~4 完成) | WP-1~4 | 0.5h | + +> **平行 / 接力**:WP-1(macOS ffmpeg)與 WP-2(Linux code)可**平行**(互不相依、不同檔)。WP-3(Windows 驗)獨立可平行。WP-4(Linux 驗)**接力 WP-2**。WP-5 收尾,等前四個。 +> **關鍵路徑**:WP-2 → WP-4 → WP-5(Linux 驗需先有 code 分支)。 +> **使用者只有 macOS**:WP-1 可立即做並驗證;WP-3 / WP-4 的實機驗證需 Windows / Linux 機器,標為「待實機」,先寫好 code / build 再擇機驗。 + +--- + +## 7. 等級與後續 + +- **等級**:M 級(跨多檔 + 跨三平台 + 動 vendor build 策略,但非新 user story / 新架構)。 +- **後續**:ADR 定稿後由 Orchestrator 派 devops(ffmpeg rebuild / 驗證)+ backend(Linux camera code 分支)落地。camera 不阻擋 B+C 主線,可獨立排期。 diff --git a/docs/autoflow/04-architecture/api/api-device-mgmt.md b/docs/autoflow/04-architecture/api/api-device-mgmt.md new file mode 100644 index 0000000..e6a2351 --- /dev/null +++ b/docs/autoflow/04-architecture/api/api-device-mgmt.md @@ -0,0 +1,131 @@ +# API 規格 — 設備註冊 / 取消註冊(個人設備管理 P0) + +- **上位**:[`../feature-device-mgmt-tdd.md`](../feature-device-mgmt-tdd.md)、[`api-spec.md`](api-spec.md) §3 Devices +- **狀態**:Draft(契約,contract-first single source of truth) +- **最後更新**:2026-08-02 + +> 本檔為 register / unregister 兩個新端點的權威契約。前端對此形狀寫 normalize、後端對此形狀保證回應。既有 `GET /api/devices`、`GET /api/devices/:id`、`POST /api/devices/:id/unpair` 見 `api-spec.md` §3、**本 P0 不改**。 + +--- + +## 通用 + +- **認證**:JWT/OIDC bearer(同既有 `/api/devices/*` route group)。 +- **識別值**:`:id` = device UUID(雲端 DB 主鍵)。register/unregister 是純雲端 DB 操作、不路由到 local agent,故用 UUID(對齊 ADR-018 FE-A:DB 操作用 UUID、路由操作才用 serial)。 +- **回應信封**:沿用既有 `{ "success": true, "data": ... }`(前端 api client 已 unwrap `data`)。錯誤為 `{ "success": false, "error": { "code", "message" } }`。 + +### DeviceListItem(回應 data 形狀,沿用既有,snake_case) + +register/unregister 成功皆回**更新後的單筆 DeviceListItem**(與 `GET /api/devices/:id` 同形狀): + +```json +{ + "id": "uuid", + "name": "KL520 #A1B2", + "device_type": "kl520", + "serial_number": "0x1234ABCD", + "agent_id": "uuid", + "registered_at": "2026-08-02T10:00:00Z", + "remote_status": "online", + "last_seen_at": "2026-08-02T10:00:00Z", + "last_connected_at": null, + "status": "connected", + "tunnel_online": true, + "created_at": "2026-08-01T00:00:00Z", + "updated_at": "2026-08-02T10:00:00Z" +} +``` + +- `registered_at`:register 後為 ISO 8601 時間;unregister 後為 `null`(omitempty → 欄位可能不出現,前端 normalize 皆視為 null=未註冊)。 + +--- + +## 1. `POST /api/devices/:id/register` — 註冊設備 + +把裝置從「未註冊」翻成「已註冊」(`registered_at` NULL → now())。 + +- **Request body**:無。 +- **Success**:`200 OK` + 更新後 DeviceListItem(`registered_at` 非 null)。 + +### 行為順序(後端) + +| 步驟 | 條件 | 回應 | +|------|------|------| +| 1 | 缺 UserContext | 500 `INTERNAL_ERROR` | +| 2 | `:id` 空 | 400 `VALIDATION_FAILED` | +| 3 | device 不存在 / 已軟刪 | 404 `NOT_FOUND` | +| 4 | `owner_user_id != caller` | 403 `FORBIDDEN`(IDOR 防護) | +| 5 | `is_representative == true` | 409 `CONFLICT`(代表 device 不可註冊) | +| 6 | `registered_at != null`(已註冊) | 409 `ALREADY_REGISTERED` | +| 7 | 正常 | 200 + DeviceListItem | + +### 範例 + +``` +POST /api/devices/7f3a.../register +→ 200 { "success": true, "data": { ...,"registered_at":"2026-08-02T10:00:00Z" } } + +(已註冊再打) +→ 409 { "success": false, "error": { "code":"ALREADY_REGISTERED","message":"device already registered" } } +``` + +--- + +## 2. `POST /api/devices/:id/unregister` — 取消註冊(退回未註冊) + +把裝置退回「未註冊」(`registered_at` → NULL),**保留裝置列**、不軟刪、不撤 token。 + +> ⚠️ **與 `unpair` 完全不同**:unpair 是軟刪整台 + cascade 撤 token(device 從清單消失);unregister 只清 `registered_at`(device 仍在清單、顯示為未註冊)。兩端點各走各的,不可合併。詳見 TDD §1。 + +- **Request body**:無。 +- **Success**:`200 OK` + 更新後 DeviceListItem(`registered_at` = null)。 + +### 行為順序(後端) + +| 步驟 | 條件 | 回應 | +|------|------|------| +| 1 | 缺 UserContext | 500 `INTERNAL_ERROR` | +| 2 | `:id` 空 | 400 `VALIDATION_FAILED` | +| 3 | device 不存在 / 已軟刪 | 404 `NOT_FOUND` | +| 4 | `owner_user_id != caller` | 403 `FORBIDDEN` | +| 5 | `is_representative == true` | 409 `CONFLICT` | +| 6 | 已是未註冊(`registered_at == null`) | **200**(冪等 no-op,非錯誤) | +| 7 | 正常 | 200 + DeviceListItem(registered_at=null) | + +### 範例 + +``` +POST /api/devices/7f3a.../unregister +→ 200 { "success": true, "data": { ...,"registered_at": null } } +(device 仍存在於 GET /api/devices) +``` + +--- + +## 3. 錯誤碼 + +| code | HTTP | 情境 | 新增? | +|------|------|------|-------| +| `VALIDATION_FAILED` | 400 | id 空 | 既有 | +| `FORBIDDEN` | 403 | 非 owner(IDOR) | 既有 | +| `NOT_FOUND` | 404 | device 不存在/軟刪 | 既有 | +| `CONFLICT` | 409 | representative device 不可註冊/取消 | 既有(或沿用既有 CONFLICT 碼) | +| `ALREADY_REGISTERED` | 409 | register 時已註冊 | **新增** | +| `INTERNAL_ERROR` | 500 | 缺 UserContext / DB 錯 | 既有(DB down 經 errors.go 映射 503) | + +- `ALREADY_REGISTERED` 為本功能新增錯誤碼,需在後端 errors 常數 + 前端 i18n(`devices.register.error.alreadyRegistered`)同步登記。 +- representative 衝突若不想新增碼,可沿用既有 `CONFLICT` + message 區分;由 backend agent 依既有錯誤碼慣例定,前端據 message/context 提示。 + +--- + +## 4. 前端呼叫(對齊 device-store.ts 範式) + +``` +// 皆用 UUID(DB 操作);不帶 serial。 +registerDevice(id): POST /api/devices/{id}/register → 更新 store 該筆 registeredAt +unregisterDevice(id): POST /api/devices/{id}/unregister → 清 store 該筆 registeredAt(保留列) +``` + +- 成功後就地更新 store 對應 device 的 `registeredAt`(避免 refetch 延遲,比照既有 unpairDevice 就地移除的範式),或 refetch 該筆。 +- 409 `ALREADY_REGISTERED` → toast「已註冊」+ refetch。 +- **不像 unpair 從 list 移除**——unregister 只改欄、device 留在 list。 diff --git a/docs/autoflow/04-architecture/api/api-model-sharing.md b/docs/autoflow/04-architecture/api/api-model-sharing.md new file mode 100644 index 0000000..162a330 --- /dev/null +++ b/docs/autoflow/04-architecture/api/api-model-sharing.md @@ -0,0 +1,186 @@ +# API Spec — 模型共享(Model Sharing) + +> 對齊 `feature-model-sharing-tdd.md`。base URL / envelope / 認證同 `api-spec.md`(§0)。 +> 通用回應:`{ "success": true, "data": {...} }` / `{ "success": false, "error": { "code", "message" } }`。 +> 認證:cookie session(BFF,同 `api-spec.md`),handler 從 `UserContext` 取 `userID` / `roles` / `orgId`。 +> +> **狀態**:Draft(待三方互審)。標 `[需 PM 確認]` / `[需 Design 確認]` / `[需 security 審]` 的點見各節。 + +--- + +## 0. 名詞與前提 + +| 名詞 | 定義 | +|------|------| +| **visibility** | 模型的「公開對象」維度(廣播式):`private`(僅擁有者)/ `tenant`(同租戶可見)/ `public`(全平台可見)。存在 `models.visibility` 欄。 | +| **model_shares** | 「點對點分享給特定人」維度(ADR-017 決策 3 已定義,本功能沿用、不重造)。與 visibility 正交。 | +| **共享模型庫** | 「依身份權限的可見模型列表」= 我的 ∪ 公開(public) ∪ 同租戶(tenant 且同 org) ∪ 分享給我(model_shares) ∪ preset。 | +| **tenant / org** | 沿用既有 `users.org_id`(migration 0001 已有欄位、domain 尚未使用,見 TDD §2)。`tenant` visibility = 同 `org_id` 可見。`[需 PM 確認]` org 邊界語意。 | + +> **與既有 `/api/models` 的分工**:既有 `GET /api/models`(`api-spec.md §4`)維持「**我的模型 + preset**」語意不變(owner dashboard 用),**不動**。共享模型庫是**新端點** `GET /api/models/library`(跨擁有者、依權限可見)。兩者刻意分開,避免破壞既有 owner dashboard 行為。`[需 Design 確認]` UI 是否分兩個入口。 + +--- + +## 1. GET `/api/models/library` — 共享模型庫列表(依身份權限可見) + +依當前 user 身份,回「可見模型」的分頁列表。可見範圍 = 我的 ∪ public ∪ (tenant 且同 org) ∪ 分享給我 ∪ preset。 + +### Query 參數 + +| 參數 | 型別 | 預設 | 說明 | +|------|------|------|------| +| `cursor` | string | — | 分頁游標(不透明 base64)。首頁不帶。見 §1.3 分頁契約。 | +| `limit` | int | 20 | 每頁筆數,1–100,超出 clamp。 | +| `sort` | enum | `created_at` | 排序欄位:`created_at` / `name` / `file_size`。 | +| `order` | enum | `desc` | `asc` / `desc`。 | +| `q` | string | — | 搜尋關鍵字(比對 `name` + `description`,ILIKE 前綴/包含,見 TDD §5 效能)。 | +| `target_chip` | enum | — | filter:`kl520`/`kl720`/`kl630`/`kl730`。 | +| `source` | enum | — | filter:`uploaded`/`converted`/`preset`。 | +| `visibility` | enum | — | filter:`public`/`tenant`(僅過濾廣播類;`private` 不在共享庫語意內、傳了視為忽略)。 | +| `owned` | bool | — | `true`=只看我的、`false`=只看別人分享/公開給我的;不帶=全部可見。 | + +### Response 200(envelope `data`) + +```json +{ + "items": [ + { + "id": "uuid", + "name": "yolov5s-kl520", + "description": "...", + "target_chip": "kl520", + "file_size": 10485760, + "source": "converted", + "status": "ready", + "visibility": "public", + "owner": { "id": "uuid", "name": "Alice", "is_me": false }, + "shared_with_me": false, + "my_access": "viewer", + "created_at": "2026-07-01T00:00:00Z", + "updated_at": "2026-07-02T00:00:00Z" + } + ], + "next_cursor": "eyJ...", + "has_more": true +} +``` + +**欄位說明(權限相關,重要)**: +- `owner`:只揭露 `id` / `name` / `is_me`(**不揭露 owner email**——非擁有者不該看到他人 email,見 §4 安全)。`[需 security 審]`。 +- `my_access`:當前 user 對此模型的有效權限(`owner`/`editor`/`viewer`)。由「owner_user_id 命中 / model_shares.role / visibility 落 viewer」計算,取最高。 +- `shared_with_me`:是否因 model_shares 命中(供 UI 標「已分享給我」)。`[需 Design 確認]` 標示方式。 +- `status`:沿用既有 DTO(`pending`/`ready` 由 `uploaded_at` 決定,見 `models.go toModelResponse`)。**共享庫只列 `ready`**(未 finalize 的不進共享庫,見 TDD §5)。 + +### 錯誤碼 + +| HTTP | code | 情境 | +|------|------|------| +| 400 | `VALIDATION_FAILED` | limit / cursor / sort 非法。 | +| 401 | (middleware) | 未登入。 | +| 500/503 | `INTERNAL_ERROR` | DB 錯誤(經 `WriteDBError` 映射,不洩漏 raw)。 | + +--- + +## 2. GET `/api/models/:id/profile` — 模型 profile 頁(公開版詳情) + +非擁有者也可看的模型詳情頁。**權限檢查為核心**:只有「對此 model 有可見性」的 user 能看,且回傳欄位依身份裁剪。 + +### 可見性判斷(handler 第一步) + +user 能看此 profile ⟺ 命中任一: +1. `owner_user_id == userID`(我的) +2. `visibility == public` +3. `visibility == tenant` 且 model.owner.org_id == user.org_id +4. `model_shares` 命中(grantee == userID) +5. preset 模型(公用) + +不命中 → **404**(不是 403):不揭露「這個 id 存在但你沒權限」,避免 enumeration 洩漏存在性。`[需 security 審]` 404 vs 403 策略。 + +### Response 200(envelope `data`) + +```json +{ + "id": "uuid", + "name": "...", + "description": "...", + "target_chip": "kl520", + "file_size": 10485760, + "source": "converted", + "status": "ready", + "visibility": "public", + "input_shape": [1, 3, 224, 224], + "classes": ["cat", "dog"], + "framework": "onnx", + "owner": { "id": "uuid", "name": "Alice", "is_me": false }, + "my_access": "viewer", + "can_download": true, + "created_at": "...", + "updated_at": "...", + "uploaded_at": "..." +} +``` + +**依身份裁剪的欄位(重要,`[需 security 審]`)**: +- **絕不揭露**(無論身份):`storage_key`、`faa_object_key`(FAA 內部 key,ADR-017 決策 2 防曝露)、owner email、`file_checksum`(`[需 PM 確認]` checksum 是否對非 owner 公開)。 +- `can_download`:`my_access != none` 時 true。前端據此決定是否顯示下載鈕。實際下載仍走既有 `GET /api/models/:id/download`(該端點需同步加共享權限檢查,見 §3)。 + +### 錯誤碼 + +| HTTP | code | 情境 | +|------|------|------| +| 404 | `NOT_FOUND` | model 不存在 **或** 無可見性(合併,防 enumeration)。 | + +--- + +## 3. PATCH `/api/models/:id/visibility` — 設定公開對象 + +擁有者設定模型的公開對象。**只有 owner(或 editor?`[需 PM 確認]`)能改**。 + +### Request + +```json +{ "visibility": "public" } +``` + +- `visibility`(required):`private` / `tenant` / `public`。 +- `tenant` 但 user 無 `org_id` → 400(無租戶歸屬不能設 tenant 可見)。`[需 PM 確認]` demo/無 org user 的行為。 + +### Response 200(envelope `data`) + +```json +{ "id": "uuid", "visibility": "public", "updated_at": "..." } +``` + +### 錯誤碼 + +| HTTP | code | 情境 | +|------|------|------| +| 400 | `VALIDATION_FAILED` | visibility 非法 / 設 tenant 但無 org。 | +| 403 | `FORBIDDEN` | 非 owner。 | +| 404 | `NOT_FOUND` | model 不存在。 | +| 409 | `CONFLICT` | model 未 `ready`(未 finalize 不允許公開,`[需 PM 確認]`)。 | + +> **既有 `GET /api/models/:id/download` 的連帶變更(見 TDD §5)**:目前是 owner-only(`m.OwnerUserID != userID → 403`)。共享後需改為「有可見性且 `can_download` → 放行」。此變更**涉及 auth 繞過風險**,`[需 security 審]`。 + +--- + +## 4. 安全考量摘要(詳見 TDD §6) + +| 風險 | 對策 | +|------|------| +| IDOR / enumeration(猜 id 看他人 model) | profile / download 的可見性檢查在 handler 第一步;不命中回 404(不回 403 揭露存在性)。`[需 security 審]` | +| 越權改 visibility | PATCH visibility 強制 owner 檢查。 | +| 資訊洩漏(email / storage_key / faa_object_key) | DTO 白名單序列化,內部 key 用 `json:"-"`(沿用 `model.Model.FAAObjectKey` 既有做法)。 | +| tenant 邊界誤放 | tenant 可見性需 `model.owner.org_id == user.org_id` 且兩者皆非空;空 org 不落 tenant 可見。 | +| 下載端點權限繞過 | `GET /api/models/:id/download` 的 owner-only 改共享權限檢查,須與 profile 可見性邏輯**共用同一函式**(single source of truth,避免兩處判斷不一致)。`[需 security 審]` | + +--- + +## 5. 待三方確認清單(本檔) + +| # | 項目 | 待誰 | +|---|------|------| +| S1 | org/tenant 邊界語意(org_id 是否等於租戶;跨 org 是否可能) | PM | +| S2 | editor 是否能改 visibility / 未 ready 能否公開 / checksum 是否對非 owner 公開 | PM | +| S3 | 共享庫 UI 入口(與既有「我的模型」分頁 or 合併 tab)、`shared_with_me` 標示、profile 頁欄位 | Design | +| S4 | 404 vs 403 防 enumeration、download 端點權限改動、owner email 不揭露 | security | diff --git a/docs/autoflow/04-architecture/feature-device-mgmt-tdd.md b/docs/autoflow/04-architecture/feature-device-mgmt-tdd.md new file mode 100644 index 0000000..0caa1f8 --- /dev/null +++ b/docs/autoflow/04-architecture/feature-device-mgmt-tdd.md @@ -0,0 +1,356 @@ +# 技術設計文件(TDD)— 個人設備管理 / 設備與註冊(P0) + +- **作者**:Architect Agent +- **狀態**:Draft(待實作 + 三方交叉審閱) +- **最後更新**:2026-08-02 +- **上位文件**:[`adr/adr-018-agent-device-model.md`](adr/adr-018-agent-device-model.md)(走向 A' 模型)、[`database.md`](database.md)、[`api/api-spec.md`](api/api-spec.md) +- **權威輸入**:`.autoflow/05-implementation/device-mgmt-gap-analysis.md`(缺口盤點) +- **讀者**:Backend / Frontend / Testing Agents +- **API 規格**:[`api/api-device-mgmt.md`](api/api-device-mgmt.md) + +--- + +## 0. 一句話與範圍 + +「取序號 → 上報 → 存 serial」地基(ADR-018 WP-0/WP-B)已完整落地,`registered_at` 欄可讀寫、List/Get API 已回傳,但**整條「註冊」語意軸沒接上**:沒有任何路徑能把 `registered_at` 由 NULL 翻成有值,前端 store 也把後端回的 `registered_at` 丟掉。本 TDD 補完 P0 範圍: + +| P0 細項 | 對應 work-stream | 一句話 | +|---------|-----------------|--------| +| 1. 註冊 API | WS-BE | `POST /api/devices/:id/register` → `registered_at` NULL→now() | +| 2. 已註冊檢查 | WS-BE | register 端點對「已註冊」回衝突(冪等取捨見 §3.2) | +| 3. 取消註冊(退回未註冊) | WS-BE | `POST /api/devices/:id/unregister` → 清 `registered_at`、**保留列**,與 unpair 分開 | +| 4. 三態分色 | WS-FE | 前端 store 補 `registeredAt` 欄 + 三態運算 + 配色 | +| 5. 排序 + filter | WS-FE | 排序切換(名稱/狀態/註冊時間)+ 依三態 filter + UI | +| 6. 資料模型 | WS-BE | 確認欄位現況、**判定不需 migration** | +| 7. 安全 | WS-BE | register/unregister 的 owner 檢查 + IDOR 防護 | + +**非目標(本 TDD 不做)**: +- 不改既有 `unpair`(devices.go:256-346 軟刪 + cascade)的任何行為。 +- 不做批次註冊 / 自動註冊(Q3 已定手動逐顆註冊)。 +- 不動 serial 路由 / agents 表 / session_tokens。 +- 不寫 production code、不寫 migration 實檔(本文件只給設計方向與契約)。 + +--- + +## 1. 關鍵決策:**取消註冊 ≠ unpair**(使用者已拍板,覆蓋 ADR-018 §1.1 Q5) + +> ⚠️ **這是本 TDD 最容易做壞的地方,工程師務必先讀懂再動手。** + +系統裡有**兩個語意不同、絕不可合併**的「移除類」動作: + +| 動作 | 端點 | 對 `registered_at` | 對 device 列 | 對 token(pairing/session) | 語意 | +|------|------|-------------------|-------------|---------------------------|------| +| **取消註冊**(本 TDD 新增) | `POST /api/devices/:id/unregister` | 清成 NULL | **保留**(列還在、仍在清單顯示為「未註冊」) | **不動** | 「這顆 USB 退回未註冊狀態,但我還看得到它」 | +| **unpair / 移除裝置**(既有,不動) | `POST /api/devices/:id/unpair` | 不特別處理(連列都軟刪了) | **軟刪**(`deleted_at=now()`、從清單消失) | **cascade 撤銷**(DeviceUnpairer) | 「把整顆已配對裝置移除、斷開連線」 | + +### 1.1 為什麼與 ADR-018 §1.1 不同(決策沿革) + +ADR-018 §1.1 記載的 Q5 是「取消註冊 = 真實體刪除 + 清 session_tokens FK」。**使用者在本次(2026-08-02)重新拍板為「取消註冊 = 退回未註冊、保留裝置列、不硬刪」**,理由:實體刪除會讓使用者「取消註冊後就再也看不到那顆已插著的 USB」,體驗不合理;而「退回未註冊」讓已連接的 USB 仍留在清單、可再次註冊,符合三態模型(未註冊態就是要能看到的第三態)。 + +- ADR-018 為不可變決策紀錄、其 §1.1 原文不改;本 TDD 於此明載新決策為準。**實作以本 TDD 為準。** +- 「真實體刪除 + 清 session_tokens」的語意,已由**既有 unpair** 覆蓋(雖是軟刪、但那是既有行為、本 TDD 不動)。取消註冊是全新的第三種較輕動作。 + +### 1.2 工程師落地時的三條紅線 + +1. **不要改 `devicesUnpairHandler`**(devices.go:256-346)。unregister 是**新 handler、新端點**,與 unpair 各走各的。 +2. **unregister 不呼叫 `DeviceUnpairer`、不呼叫 `Delete/DeleteTx`、不碰 token。** 它只做一件事:把該列 `registered_at` set NULL。 +3. **register / unregister 對 representative device 一律拒絕**(見 §7.3)——只有真實 USB(`is_representative=false`)能被註冊 / 取消註冊。 + +--- + +## 2. 現況地基盤點(實作前必讀,避免重造輪子) + +| 元件 | 現況 | 檔案:行 | 對本 TDD 的意義 | +|------|------|--------|----------------| +| `Device.RegisteredAt *time.Time` | 已存在(domain) | `device.go:83` | 直接用,不加欄 | +| DB 欄 `registered_at TIMESTAMPTZ`(nullable) | 已存在(0005) | `migrations/0005_create_agents.up.sql:38` | 不需 migration | +| index `idx_devices_registered` | 已存在(partial,未刪除) | 同上:44 | filter「未註冊」可用;本 P0 前端 client-side filter 為主,index 供未來後端 filter | +| `scanDevice` 讀 `registered_at` | 已接(nullable *time.Time) | `postgres_repository.go:391` | 讀取路徑完整 | +| `Save` upsert 寫 `registered_at` | 已接(`$15 = d.RegisteredAt`) | `postgres_repository.go:268,286` | **register 可直接走 Save/SaveTx**、不必新寫 SQL(但見 §3.3 建議加專用 UPDATE) | +| List/Get API 回 `registered_at` | 已接 | `devices.go:65,129,220` | 後端→前端資料已備妥 | +| `is_representative=false` filter | List 已濾(只列真 USB) | `device.go:190` / postgres List | register/unregister 端點另需在 handler 再擋一次 representative(縱深) | +| owner 檢查範式 | 既有 handler 皆用 `UserContextFrom` + `d.OwnerUserID != userID → 403` | `devices.go:197-201,299-302` | register/unregister 照抄這個範式 | +| 前端 `DeviceSummary.registeredAt` | **不存在**(資料到前端被丟) | `device-store.ts:74-96` | WS-FE 第一件事:補欄 + normalize | + +**結論**:後端 register/unregister 是「加 2 個 handler + 2 條 route + 1 個 repo 專用方法(可選)」;前端三態是「補 1 個欄位 + 三態運算 + 配色」。地基全在,只差翻轉開關與前端消費。 + +--- + +## 3. 註冊 API(細項 1 + 2 + 6)— WS-BE + +### 3.1 端點:`POST /api/devices/:id/register` + +- **路由註冊位置**:`registerDeviceRoutes`(devices.go:27-49),與既有 unpair 並列(都是純雲端 DB 操作、用 UUID `:id`、不 proxy)。 +- **識別值**:UUID(`:id`)。理由(對齊 ADR-018 FE-A):register 是純雲端 DB 操作(只翻 `registered_at`、不路由到 local agent),用主鍵 UUID 最自然;且序號可能為空的 device 也要能被查詢與擋掉。 +- **Request body**:無(空 body)。 +- **行為**: + 1. `UserContextFrom` 取 userID(缺 → 500,照既有範式 devices.go:95-100)。 + 2. `:id` 空 → 400 `VALIDATION_FAILED`。 + 3. `DeviceRepo.Get(ctx, id)`:`ErrNotFound` → 404;其他 DB error → `WriteDBError`。 + 4. **owner 檢查**:`d.OwnerUserID != userID` → 403 `FORBIDDEN`(§7)。 + 5. **representative 檢查**:`d.IsRepresentative == true` → 409 `CONFLICT`(representative 不是真 USB、不可註冊,§7.3)。 + 6. **已註冊檢查(細項 2)**:`d.RegisteredAt != nil` → 見 §3.2。 + 7. 翻轉:set `registered_at = now()`、回 200 + 更新後的 DeviceListItem(含 `registered_at`)。 + +### 3.2 已註冊檢查(細項 2)— 取「409 衝突」,理由如下 + +兩個方案: + +| 方案 | 行為 | 取捨 | +|------|------|------| +| **A. 409 CONFLICT(採用)** | 已註冊再 register → 回 409 `ALREADY_REGISTERED` | 語意明確、前端可提示「已註冊」;符合需求「避免重複註冊」的字面 | +| B. 冪等 200 | 已註冊再 register → 回 200(不改 registered_at) | 對 retry 友善,但掩蓋「使用者以為沒註冊卻已註冊」的狀態 | + +**採 A**:需求明確要「已註冊檢查(避免重複註冊)」,409 最貼合。前端收到 409 顯示 toast「此裝置已註冊」並 refetch。錯誤碼新增 `ALREADY_REGISTERED`(見 api 規格 §錯誤碼)。 + +> 註:`registered_at` 一旦設值,不因後續 exchange 覆寫——`upsertUSBDeviceTx` 復用分支(pairing_exchange.go:329-341)只更新 PairedAt/AgentID/type,**不碰 RegisteredAt**,故重配對不會清掉註冊態(現況已正確、不需改)。 + +### 3.3 Repo 落地建議:新增專用 `SetRegisteredAtTx`(優於走 Save) + +雖然 `Save`/`SaveTx` 已能寫 `registered_at`(upsert 全欄),但 register/unregister 建議**新增精準的單欄 UPDATE**,理由: + +- Save 是全欄 upsert,register handler 得先 Get 整個 device 再 Save 回去,有「讀到寫之間被其他請求改動」的競態面(雖 P0 單裝置風險低,但語意不乾淨)。 +- 專用 `UPDATE devices SET registered_at = $2, updated_at = now() WHERE id = $1 AND deleted_at IS NULL AND is_representative = false` 一句到位、天然只影響該欄、`RowsAffected()==0` 可轉 404/409 判定。 + +**建議簽名**(domain `Repository` interface 加一法、in-memory + postgres 各實作): + +``` +// SetRegistered 設定/清除註冊時間。at=nil 表示取消註冊(清 NULL)。 +// 只作用於未刪除、非 representative 的 device;不符則回 ErrNotFound。 +SetRegistered(ctx context.Context, id string, at *time.Time) error +``` + +- register:`SetRegistered(ctx, id, &now)`(handler 已先擋 already-registered,故這裡不再判)。 +- unregister:`SetRegistered(ctx, id, nil)`。 +- 若不想動 interface,退路是 handler 內 Get→改欄→Save(可接受但較不乾淨)。**推薦加 interface 方法**,並補 in-memory 對稱實作 + postgres db_test(testcontainers 130,見 §8)。 + +### 3.4 資料模型(細項 6):**不需 migration** + +- `registered_at` 欄、`idx_devices_registered` index、domain 欄位、scan/save 讀寫路徑**全部已存在**(§2 表)。register/unregister 純粹是「翻轉既有欄」+「加 handler」,**不新增欄、不改 schema、不寫 migration 檔**。 +- 唯一的 code 面新增是可選的 `SetRegistered` repo 方法(Go 層,非 schema)。 + +--- + +## 4. 取消註冊 API(細項 3)— WS-BE + +### 4.1 端點:`POST /api/devices/:id/unregister` + +- **路由**:同 register,並列 registerDeviceRoutes,UUID 識別。 +- **Request body**:無。 +- **行為**(與 register 對稱、但更寬鬆): + 1. userID / `:id` / Get / owner / representative 檢查同 §3.1 步驟 1-5。 + 2. **不做「已註冊才可取消」的硬擋**:採冪等——若 `registered_at` 已是 NULL(未註冊),unregister 回 200(no-op 成功),避免使用者連點兩次第二次報錯。(`SetRegistered(id, nil)` 對已 NULL 的列 UPDATE 到相同值、`RowsAffected` 仍為 1,因 WHERE 命中;語意上「取消一個未註冊的 = 已達成目標」。) + 3. `SetRegistered(ctx, id, nil)` → 清 `registered_at`。 + 4. 回 200 + 更新後 DeviceListItem(`registered_at` 為 null)。 +- **絕不做**:不軟刪、不呼叫 DeviceUnpairer、不撤 token、不動 session(§1.2 紅線)。 + +### 4.2 與 unpair 的並存驗證(testing 重點) + +- unregister 後:device 仍 `GET /api/devices` 列得到、`registered_at=null`、pairing/session token 不變、tunnel 狀態不變。 +- unpair 後:device 從 List 消失(軟刪)、token 被撤。 +- 兩端點互不影響:對同一 device 先 unregister 再 unpair 應正常(unregister 只清欄、unpair 照樣軟刪)。 + +--- + +## 5. 三態分色(細項 4)— WS-FE + +### 5.1 前端資料斷點修復(第一步,最關鍵) + +`device-store.ts` 的 `DeviceSummary`(74-96)**沒有 `registeredAt` 欄**,`normalizeDevice`(128-164)也沒接,後端回的 `registered_at` 在前端被丟棄。必須先補: + +- `DeviceSummary` 加 `registeredAt?: string | null;`(ISO 8601,nil=未註冊)。 +- `Device`(extends Summary)自動繼承。 +- `normalizeDevice` 加 `registeredAt: pick("registered_at", "registeredAt") ?? null,`(沿用既有 `pick` snake/camel 相容範式)。 + +### 5.2 三態運算規則(給前端明確定義) + +三態 = **連線軸(remoteStatus)× 註冊軸(registeredAt)** 的組合: + +| 態 | 條件 | 語意 | 配色 token | +|----|------|------|-----------| +| **已連接(已註冊在線)** | `remoteStatus === "online"` **且** `registeredAt != null` | 正常可用的個人設備 | `--status-online`(綠,既有) | +| **已連接未註冊** | `remoteStatus === "online"` **且** `registeredAt == null` | 插著、連線中但還沒註冊 → **第三態** | `--warning`(黃,既有 warning token 組) | +| **未連接** | `remoteStatus !== "online"`(offline/reconnecting/error/unknown) | 離線(不論註冊與否) | 沿用既有 RemoteDeviceBadge 各狀態色 | + +**運算實作建議**:在 `device-store.ts` 或一支 `lib/device-state.ts` 加純函式: + +``` +type DeviceTriState = "online-registered" | "online-unregistered" | "offline"; +function deriveTriState(d: Pick): DeviteTriState +// online + registeredAt != null → "online-registered" +// online + registeredAt == null → "online-unregistered" +// 其餘 → "offline" +``` + +- 純函式便於 testing 單測(真值表 6 格)。 + +### 5.3 配色落地(沿用既有 design tokens,不新增裸色) + +第三態「已連接未註冊」用**既有 warning token 組**(`--warning` / `--warning-foreground` / `--warning-subtle`,globals.css:159-161,Light/Dark 都有定義),與 pairing 頁 / login 頁 / flash-dialog 的警示 UI 同色系。**禁止**寫 `bg-yellow-*` 裸色(對齊 flash-progress.tsx:11 的既有慣例)。 + +改動點: +- `remote-device-badge.tsx`:目前 badge 只吃 `remoteStatus`(63-101)。**方案:不改 badge 的內部語意**(badge 仍表達連線狀態),而是在 `DeviceCard` 層疊加「未註冊」標記——online 且未註冊時,卡片額外顯示一個 warning 色的「未註冊」pill/badge(新增小元件或用既有 warning classes)。理由:連線狀態與註冊狀態是正交兩軸,硬塞進同一個 badge 會讓「online 但未註冊」的顏色語意打架。 +- `device-card.tsx`:`isOnline`(44)旁加 `isRegistered = !!device.registeredAt`;online && !registered → 卡片邊框 / 角落 pill 走 warning 色 + i18n 文案「未註冊」。 +- **不只靠顏色**(沿用 design-review M2 無障礙原則):第三態要有文字「未註冊」+ icon,不能只有黃色。 + +### 5.4 i18n + +新增 key(zh-Hant + en 同步,對齊 `dictionaries/`):`devices.state.unregistered`(「未註冊」/ "Unregistered")、`devices.register.action`(「註冊」)、`devices.unregister.action`(「取消註冊」)、`devices.register.error.alreadyRegistered`、`devices.register.confirm` 等(實際 key 由 frontend agent 補全、testing 驗證存在)。 + +--- + +## 6. 排序 + filter(細項 5)— WS-FE + +### 6.1 現況與範圍 + +- 現況:`device-list.tsx:31-37,77-79` 寫死一條「在線優先」排序,**無 filter、無切換 UI**。 +- **不需後端改動、不需分頁**:個人設備數量級小(一個使用者的 USB 通常 < 20),全部 client-side 排序 / 過濾即可。**不做 cursor/offset 分頁**(資料量不成立、徒增複雜度)。 + +### 6.2 排序(可自選鍵) + +提供排序切換(下拉或 segmented control),鍵: + +| 排序鍵 | 規則 | +|--------|------| +| 狀態(預設,保留既有行為) | 沿用 `STATUS_ORDER`(online→reconnecting→unknown→offline→error);同狀態內次比名稱 | +| 名稱 | `displayName`(alias || name)localeCompare,A→Z | +| 註冊時間 | `registeredAt` desc(新註冊在前);null(未註冊)排最後 | + +- 排序狀態存元件 local state(`useState`)即可;不需持久化(P0)。若要記住偏好可存 localStorage(可選、非必要)。 + +### 6.3 Filter(依三態) + +提供 filter(chips / checkbox group),選項: + +| filter | 條件(用 §5.2 deriveTriState) | +|--------|------------------------------| +| 全部(預設) | 不過濾 | +| 已連接 | triState === "online-registered" | +| 未連接 | triState === "offline" | +| 已連接未註冊 | triState === "online-unregistered" | + +- 排序與 filter 組合:先 filter 再 sort。 +- 空結果狀態:filter 後 0 筆 → 顯示「無符合條件的裝置」空狀態(與既有 EmptyState 區隔——這不是「完全沒裝置」)。 + +### 6.4 UI 落點 + +- 排序 + filter 控制列放 `devices/page.tsx` 或 `device-list.tsx` 頂部。 +- `device-list.tsx` 目前接 `devices` prop 後自己排序;改為接「已排序 / 已過濾的 devices」或把排序 filter state 提到 list 內。由 frontend agent 決定放置層級(保持既有 EmptyState / skeleton / pair CTA 行為不變)。 + +--- + +## 7. 安全(細項 7)— WS-BE + +### 7.1 Owner 權限檢查 + +register / unregister **都必須**照既有範式(devices.go:197-201):`UserContextFrom` 取 userID → `Get` device → `d.OwnerUserID != userID` → 403 `FORBIDDEN`。缺 UserContext → 500(auth middleware 沒配好,不可 fallthrough)。 + +### 7.2 IDOR 防護 + +- **威脅**:攻擊者用別人的 device UUID 打 `POST /api/devices/{別人的id}/register`(或 unregister),若無 owner 檢查就能改別人裝置的註冊態。 +- **防護**:§7.1 的 owner 檢查即 IDOR 防線——先 Get 拿到 device 的 `OwnerUserID`、與 caller userID 比對、不符回 403。**不可**因「反正只是翻個 flag」而省略。 +- **列舉防護取捨**:owner 不符回 403(既有 handler 慣例,如 devices.go:198)。這會洩漏「該 UUID 存在但不屬於你」vs「不存在(404)」的差異。既有 unpair/get 都用 403、本 TDD **沿用一致**(不特別改成 404 混淆),維持 codebase 一致性;若 security agent 要求統一改 404-混淆,另案處理、不在 P0 範圍。 + +### 7.3 Representative device 防護 + +- register/unregister 前檢查 `d.IsRepresentative`;為 true → 409 `CONFLICT`(representative 是 agent 連線佔位、非真 USB、註冊語意不適用)。 +- 縱深:即使 List 已濾掉 representative(前端拿不到其 UUID),handler 仍要自己擋(攻擊者可能猜 UUID 或從其他管道拿到)。repo 的 `SetRegistered` 的 WHERE 也帶 `is_representative = false`(§3.3),三層防護。 + +### 7.4 其他 + +- register/unregister 是狀態變更操作,走既有 auth middleware(JWT/OIDC,與 unpair 同一 route group)。 +- 無新增 PII、無新增對外資料揭露。 +- 建議整體(register/unregister/IDOR)納入 testing 的 API 安全測試;若 Orchestrator 判定需深度威脅建模,可送 security agent,但 P0 owner+representative+IDOR 三檢查已覆蓋主要面。 + +--- + +## 8. 測試策略(給 Testing Agent) + +### 8.1 Backend 單元 / handler 測試 +- register:未認證(500) / 空 id(400) / device 不存在(404) / 非 owner(403) / representative(409) / 已註冊(409) / 正常(200 且 registered_at 非 null)。 +- unregister:非 owner(403) / representative(409) / 已註冊→取消(200 且 registered_at=null) / 未註冊→取消(200 冪等 no-op)。 +- **並存回歸**:unregister 後 device 仍在 List、token 不變;unpair 行為完全不變(跑既有 unpair_test.go / unpair_db_test.go 確認全綠)。 + +### 8.2 Repo 測試(SetRegistered,若採 §3.3) +- in-memory:set→get 回值、set nil→清空、representative 拒絕(ErrNotFound)、已刪除拒絕。 +- postgres db_test:testcontainers(本機無 docker → `192.168.0.130`,見 ADR-018 §4.3 dbtest 陷阱);`go test -run xxx -list` 先確認 case 數再跑,避免假綠。UPDATE `RowsAffected` 對 representative / deleted 列為 0 → ErrNotFound。 + +### 8.3 Frontend 測試 +- `normalizeDevice`:`registered_at` 有值/缺值 → `registeredAt` 正確 / null。 +- `deriveTriState` 真值表:online×registered / online×null / offline×registered / offline×null / reconnecting / unknown(≥6 格)。 +- 三態配色:online 未註冊卡片有 warning 標記 + 「未註冊」文字(不只靠色)。 +- 排序:三種鍵各驗序;filter:四選項各驗集合 + 空結果狀態。 + +### 8.4 E2E(可選,P0 視情況) +- 註冊一顆在線未註冊 device → 卡片由黃轉綠 → 取消註冊 → 轉回黃 → device 仍在清單。 + +--- + +## 9. 並行化工作流計畫(Parallelization Plan) + +### 9.1 契約(contract-first,single source of truth) + +跨 BE/FE 必須一致的契約,全部定死於 [`api/api-device-mgmt.md`](api/api-device-mgmt.md): +- 端點:`POST /api/devices/:id/register`、`POST /api/devices/:id/unregister`(UUID 識別)。 +- Response:既有 `DeviceListItem` 形狀(含 `registered_at` omitempty,snake_case)。前端對這個形狀寫 normalize。 +- 錯誤碼:`ALREADY_REGISTERED`(409)、`CONFLICT`(409, representative)、`FORBIDDEN`(403)、`NOT_FOUND`(404)、`VALIDATION_FAILED`(400)。 +- 三態運算規則(§5.2 真值表)= 前後端共識,前端據此算色、後端據此保證 `registered_at` 語意。 + +### 9.2 依賴圖 + 關鍵路徑 + +```mermaid +graph LR + C[契約定稿
api-device-mgmt.md
critical] --> BE[WS-BE: register/unregister
+ SetRegistered repo] + C --> FE1[WS-FE: store 補 registeredAt
+ deriveTriState 純函式
對 mock 契約] + FE1 --> FE2[WS-FE: 三態配色 + 排序 filter UI] + C --> TS[WS-TEST: 測試設計
對契約先寫] + BE --> INT[整合 join
真後端接前端] + FE2 --> INT + TS --> INT + INT --> E2E[E2E join] +``` + +- **關鍵路徑**:`契約定稿 → WS-BE → 整合 join → E2E`。契約是所有線的前置、必須先鎖死。 +- WS-FE 可對著契約 mock 先開工(store 補欄 + deriveTriState + UI),不必等後端。 + +### 9.3 Work-stream 清單 + 同步點 + +| Work-stream | 派給 | 可與誰平行 | 阻擋於 | 同步點 | +|-------------|------|-----------|--------|--------| +| WS-BE:register/unregister handler + route + SetRegistered repo(in-mem+pg)+ handler test | backend | WS-FE, WS-TEST | 契約定稿 | 整合 join | +| WS-FE:store 補 registeredAt + normalize + deriveTriState + 三態配色 + 排序/filter UI + i18n | frontend | WS-BE, WS-TEST | 契約定稿 | 整合 join | +| WS-TEST:backend handler/repo 測試設計 + frontend 真值表/配色測試 + 並存回歸 + E2E 腳本 | testing | WS-BE, WS-FE | 契約定稿 | 整合 join / E2E join | + +**同步點**: +- **整合 join**:真後端接上前端,驗「註冊→卡片轉綠 / 取消→轉黃、device 保留」端到端。 +- **E2E join**:跑 §8.4,並確認 unpair 回歸全綠。 + +### 9.4 任務卡(Anthropic 四要素) + +**WS-BE** +- Objective:實作 register/unregister 兩端點 + (建議)`SetRegistered` repo 方法,符合 api-device-mgmt.md 契約。 +- Output:可運行 handler + route 註冊 + repo 方法(in-mem + pg)+ 對應 Go 測試(含 pg db_test 走 130 testcontainers)。 +- 來源指引:讀本 TDD §1/§3/§4/§7、`api/api-device-mgmt.md`、既有 `devices.go`(照 owner 檢查範式)、`unpair.go`(**只讀不改**、理解語意差異)、`postgres_repository.go`(Save/scan 範式)。 +- 邊界:**只加 register/unregister,絕不改 unpair / Delete / DeviceUnpairer / token / session**。錯誤碼照契約、不自創。representative 一律擋。不寫 migration(欄已存在)。 + +**WS-FE** +- Objective:補前端註冊軸消費——store 接 `registeredAt`、三態運算與配色、排序/filter UI、register/unregister 呼叫。 +- Output:`DeviceSummary.registeredAt` + normalize + `deriveTriState` 純函式 + DeviceCard 三態配色(warning token)+ device-list 排序/filter 控制 + store action `registerDevice`/`unregisterDevice` + i18n key(zh/en)。 +- 來源指引:讀本 TDD §5/§6、`api/api-device-mgmt.md`、既有 `device-store.ts`(pick 範式、unpairDevice action 範式)、`device-card.tsx` / `remote-device-badge.tsx` / `device-list.tsx`、`globals.css`(warning token)。 +- 邊界:識別值——register/unregister 用 **UUID**(DB 操作、對齊 FE-A);**不改** serial 路由類操作(connect/camera/media)。配色只用既有 design token、禁裸色。第三態不只靠顏色(加文字+icon)。不動 unpairDevice 行為。 + +**WS-TEST** +- Objective:對契約設計 backend + frontend 測試 + 並存回歸 + E2E 腳本。 +- Output:§8.1–8.4 測試清單落為可執行測試 / 腳本。 +- 來源指引:讀本 TDD §8、`api/api-device-mgmt.md`、既有 unpair 測試。 +- 邊界:只寫測試、不改 production code。特別覆蓋「unregister vs unpair 語意不混」與「unpair 回歸不破」。 + +--- + +## 10. 給 Orchestrator 的實作排程建議 + +1. **先鎖契約**:確認 `api/api-device-mgmt.md`(本 TDD 附)定稿。 +2. **同一輪平行 invoke**:WS-BE(backend)+ WS-FE(frontend)+ WS-TEST(testing)三線並行——契約已定、各自可對 mock/stub 開工。 +3. 各線完成 → Reviewer 審 → 收斂到**整合 join**(真後端接前端)。 +4. **E2E join** + unpair 回歸全綠 → P0 收尾。 +5. effort scaling 判斷:本任務中等(2 個清楚分離模組 BE/FE + 測試),3 線平行是甜蜜點,不過度切分。 diff --git a/docs/autoflow/04-architecture/feature-model-sharing-tdd.md b/docs/autoflow/04-architecture/feature-model-sharing-tdd.md new file mode 100644 index 0000000..37f8da3 --- /dev/null +++ b/docs/autoflow/04-architecture/feature-model-sharing-tdd.md @@ -0,0 +1,287 @@ +# 技術設計文件(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 審**。