Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b35b989d9d | |||
| 8270c4fb0e | |||
| df082ed308 | |||
| 630b8a2d6e | |||
| e27d8e3bd2 | |||
| a6c94e21c5 | |||
| f6d15b7b14 | |||
| 6a797d5eb5 | |||
| 47a1d4d0ef | |||
| 17134e8eae | |||
| e4d27594d6 | |||
| 44b877318d | |||
| 145ed8e960 | |||
| d0ab479a7f |
@ -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-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 | [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 |
|
| 推論操作 | P0 | [feature-inference.md](features/feature-inference.md) | Camera / Image / Video / Batch |
|
||||||
| Pairing 流程 | P0 | [feature-pairing.md](features/feature-pairing.md) | 新增,取代 POC 的 MAC 寫死 |
|
| Pairing 流程 | P0 | [feature-pairing.md](features/feature-pairing.md) | 新增,取代 POC 的 MAC 寫死 |
|
||||||
| 工作區 | P0 | [feature-workspace.md](features/feature-workspace.md) | 裝置 → 模型 → 來源 |
|
| 工作區 | P0 | [feature-workspace.md](features/feature-workspace.md) | 裝置 → 模型 → 來源 |
|
||||||
|
|||||||
227
docs/autoflow/02-prd/features/feature-model-sharing.md
Normal file
227
docs/autoflow/02-prd/features/feature-model-sharing.md
Normal file
@ -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(分享指標)
|
||||||
461
docs/autoflow/03-design/feature-model-sharing-design.md
Normal file
461
docs/autoflow/03-design/feature-model-sharing-design.md
Normal file
@ -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 決定增補方式)。
|
||||||
453
docs/autoflow/03-design/flow-device-usage-ux.md
Normal file
453
docs/autoflow/03-design/flow-device-usage-ux.md
Normal file
@ -0,0 +1,453 @@
|
|||||||
|
# 裝置使用流程 UX 重新設計 — 連接電腦 → 連線裝置 → 載入模型 → 推論
|
||||||
|
|
||||||
|
> 狀態:待 frontend 落地 · 產出:Design Agent · 語言:zh-TW
|
||||||
|
> 觸發:使用者實測後提出整條裝置使用流程的 4 個 UX 問題(狀態誤導、兩層命名混淆、模型狀態、載入後路由)。
|
||||||
|
> 定位:**這是整條使用流程的 UX 重新設計**,不是零散修 bug。統一定調三層狀態語意與命名,讓使用者一眼分清「哪一層還沒好」。
|
||||||
|
>
|
||||||
|
> **本文件只出設計 + 文案 mapping,不改任何 code / i18n 檔。** frontend 照本文件落地。
|
||||||
|
> 相關 code(現況):
|
||||||
|
> - `visionA-frontend/src/app/devices/[id]/device-detail-client.tsx`(裝置詳情頁 + 兩步式連線 + flash + 開啟工作區)
|
||||||
|
> - `visionA-frontend/src/components/cloud/remote-device-badge.tsx`(tunnel 狀態徽章 — 問題 1 根源在此)
|
||||||
|
> - `visionA-frontend/src/stores/device-store.ts`(remoteStatus / flashedModel / lastSeenAt / pairedAt / registeredAt / connect actions)
|
||||||
|
> - `visionA-frontend/src/lib/device-state.ts`(deriveTriState 三態運算)
|
||||||
|
> - `visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts` + `en.ts`(文案)
|
||||||
|
> 延續:`flow-offline-handling.md`(離線降級)、`flow-pairing.md`、`pairing-rename-mapping.md`(用詞體系,必須一致)、ADR-018(serial 路由、兩步式連線)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 問題總覽與設計解對照(一頁看懂)
|
||||||
|
|
||||||
|
| # | 使用者症狀 | 根因 | 設計解 | 純前端? |
|
||||||
|
|---|-----------|------|--------|---------|
|
||||||
|
| 1 | 離線後顯示「離線 · **從未連線**」誤導(明明連過) | `remote-device-badge.tsx:100-102` 只看 `!lastSeenAt` 就顯示「從未連線」,沒用 `pairedAt`/`registeredAt` 區分「連過但離線」vs「真的沒連過」 | 改「從未連線」判定邏輯:優先用 lastSeenAt,缺時 fallback 到 pairedAt/registeredAt 判定「連過」,只有全都沒有才顯示「從未連線」(§4) | ✅ 純前端(欄位都已存在);lastSeenAt 後端未穩定回傳為 §7 待確認 |
|
||||||
|
| 2 | 兩層都用「線」字,「在線」又要點「連線」很困惑(核心) | 第 1 層 tunnel(remoteStatus)用「在線/離線」、第 2 層 driver(connect)用「連線/斷線」,兩層命名撞、層次不明 | 三層語意重新定調:電腦(在線→「已連接雲端/未連上」)、裝置就緒(driver→「就緒/待啟用」+動作按鈕「啟用裝置/停用」)、模型(§2、§3) | ✅ 純前端(改 i18n value + badge/詳情頁呈現) |
|
||||||
|
| 3 | 連線載入過模型不顯示 / 重連後未刷新 | 模型狀態 Card 只有「有 flashedModel/沒有」二態,離線時仍顯示 stale,重連刷新 UX 不清楚 | 模型狀態四態呈現(已載入/未載入/離線-上次已知/載入中)+重連刷新引導(§5) | ✅ 純前端 |
|
||||||
|
| 4 | 載入完模型要能順暢路由去推論 | 「開啟工作區」按鈕雖有 ring highlight,但缺主動引導、埋在一排按鈕裡 | 載入完成 → 成功態卡片 + 主 CTA「前往推論」+ 引導文案,讓下一步不用找(§6) | ✅ 純前端 |
|
||||||
|
|
||||||
|
**一句話總結設計主張**:使用者的困惑本質是「三件不同的事都叫『連線』」。這次把三層拆成**三組不會撞的詞** + **一條線性的引導流**(每一步完成就明確指向下一步),讓使用者永遠知道「現在卡在哪一層、下一步該點什麼」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 完整流程圖(四步線性引導)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ 第 0 層(一次性,此流程外)──────────────────────────────────────────┐
|
||||||
|
│ 連接這台電腦(pairing)— 建立雲端帳號 ↔ 這台電腦的信任。做過就不用再做。 │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
│(電腦已連接,裝置出現在 /devices 列表)
|
||||||
|
▼
|
||||||
|
┌─ STEP 1:電腦連上雲端?(第 1 層 tunnel / remoteStatus)─────────────┐
|
||||||
|
│ │
|
||||||
|
│ ● 電腦已連接雲端(online) → 進 STEP 2 │
|
||||||
|
│ ○ 電腦未連上(offline) → 離線降級:顯示「這台電腦目前未連上雲端」│
|
||||||
|
│ +上次連線資訊,所有裝置操作 disable │
|
||||||
|
│ ◐ 重新連線中(reconnecting)→ 唯讀等待 │
|
||||||
|
│ │
|
||||||
|
│ CTA(offline 時):引導「請在電腦上啟動 local agent」 │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
│(電腦在線 online)
|
||||||
|
▼
|
||||||
|
┌─ STEP 2:裝置就緒?(第 2 層 driver / status connect)───────────────┐
|
||||||
|
│ │
|
||||||
|
│ 狀態:待啟用(detected/disconnected)→ CTA「啟用裝置」 │
|
||||||
|
│ 啟用中(connecting)→ loading +「首次啟用需載入韌體,約1分鐘」 │
|
||||||
|
│ 已就緒(connected)→ 進 STEP 3,顯示「停用裝置」次要動作 │
|
||||||
|
│ │
|
||||||
|
│ ⚠ serial 缺 → disable + tooltip「需重新連接電腦回報序號」 │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
│(裝置已就緒 connected)
|
||||||
|
▼
|
||||||
|
┌─ STEP 3:載入模型?(flash / flashedModel)─────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ 未載入 → CTA「載入模型」→ 選模型 → 相容檢查 → flash → WS 進度 │
|
||||||
|
│ 載入中(flashing)→ 進度條 +「載入中,請勿關閉」 │
|
||||||
|
│ 已載入 → 顯示模型名 +「已就緒,可開始推論」→ 進 STEP 4 │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
│(模型已載入 flashedModel != null)
|
||||||
|
▼
|
||||||
|
┌─ STEP 4:推論(workspace)──────────────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ 成功態卡片彈出 + 主 CTA「前往推論 →」(醒目、size lg、primary) │
|
||||||
|
│ 路由 → /workspace/:id │
|
||||||
|
└──────────────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**進度指示(詳情頁頂部,選配但建議)**:在裝置詳情頁頂部放一條**輕量四步進度列**,讓使用者看到自己在整條流程的哪一步:
|
||||||
|
|
||||||
|
```
|
||||||
|
①電腦連接 ──→ ②裝置就緒 ──→ ③載入模型 ──→ ④推論
|
||||||
|
✓已完成 ●進行中 ○待完成 ○待完成
|
||||||
|
```
|
||||||
|
|
||||||
|
- 已完成:`bg-primary` 實心 + CheckCircle;進行中:`ring-2 ring-primary`;待完成:`bg-muted`。
|
||||||
|
- 尊重 `prefers-reduced-motion`,狀態切換不做閃爍動畫。
|
||||||
|
- 這條進度列是本次 UX 的**核心引導元件**——它把「三層各自的狀態」收攏成一條使用者能理解的線性路徑。i18n key 見 §8「Flow Stepper」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 兩層狀態命名 + 視覺方案(問題 2 核心)
|
||||||
|
|
||||||
|
### 2.1 心智模型:為什麼會撞
|
||||||
|
|
||||||
|
系統實際有**三層**不同的「連接/連線」,過去第 1、2 層都用「線」字:
|
||||||
|
|
||||||
|
| 層 | 技術欄位 | 語意 | 頻率 | 對象 | 舊用詞(撞) |
|
||||||
|
|----|---------|------|------|------|------------|
|
||||||
|
| **第 0 層** 信任連結 | pairing / pairedAt | 電腦接進雲端帳號 | 一次 | 電腦 | 配對 → 已改「連接這台電腦」✅ |
|
||||||
|
| **第 1 層** tunnel | `remoteStatus` (online/offline) | 這台電腦的 local agent 有沒有連上雲端 | 每次 | 電腦(host) | **在線 / 離線** ← 撞 |
|
||||||
|
| **第 2 層** driver | `status` (connect/disconnect) | 對這顆 USB 晶片有沒有開 driver 連線準備用 | 每次 | 裝置(USB 晶片) | **連線 / 斷線** ← 撞 |
|
||||||
|
|
||||||
|
**使用者的困惑點**:看到裝置「在線」(第 1 層 tunnel),卻還要點「連線」(第 2 層 driver)才能用——兩個都是「線」,使用者以為「在線」就該能用了。
|
||||||
|
|
||||||
|
### 2.2 命名決策:三層各給一組不撞的詞
|
||||||
|
|
||||||
|
**設計原則**:
|
||||||
|
1. 第 1 層講「**電腦**」——沿用 pairing-rename 的主詞策略(帶主詞就不會跟裝置層撞)。
|
||||||
|
2. 第 2 層講「**裝置就緒**」——用「就緒 / 待啟用」描述**狀態**,用「啟用 / 停用」描述**動作**,徹底避開「線」字。
|
||||||
|
3. 三層的動詞不重複:電腦「連接」、裝置「啟用」、模型「載入」、最後「推論」。
|
||||||
|
|
||||||
|
| 層 | 狀態文案(新) | 動作按鈕(新) | 避開的舊詞 |
|
||||||
|
|----|--------------|--------------|-----------|
|
||||||
|
| 第 1 層 tunnel | **這台電腦已連上雲端** / **這台電腦未連上雲端** | (無按鈕,靠 local agent 啟動) | 在線 / 離線 |
|
||||||
|
| 第 2 層 driver | **裝置已就緒** / **裝置待啟用** / **啟用中…** | **啟用裝置** / **停用** | 連線 / 斷線 |
|
||||||
|
| 模型 flash | **模型已載入** / **尚未載入模型** | **載入模型** | — |
|
||||||
|
|
||||||
|
**為什麼是「就緒/待啟用」不是「已連線/未連線」**:
|
||||||
|
- 「就緒(ready)」直接對應使用者心裡的問題「這顆晶片能用了嗎」,比「已連線」更貼近意圖。
|
||||||
|
- 「啟用(activate)」當動作動詞,跟第 1 層「連接電腦」、第 0 層「配對」全都不同字,使用者不會混。
|
||||||
|
- 保留 `flow-offline-handling.md` §2 對 remoteStatus 的既有狀態機(online/offline/reconnecting/error/unknown),只換**顯示字**,不動狀態邏輯。
|
||||||
|
|
||||||
|
### 2.3 視覺呈現:兩層垂直堆疊、層次分明
|
||||||
|
|
||||||
|
詳情頁頂部狀態區改為**兩行明確標示的層次結構**(取代目前單一 RemoteDeviceBadge 混在標題下):
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────────────────────────────────────────────┐
|
||||||
|
│ Kneron KL720 [停用] [載入模型] │
|
||||||
|
│ (別名/原名) │
|
||||||
|
│ │
|
||||||
|
│ ┌── 第 1 層 ──────────────────────────────────────────┐ │
|
||||||
|
│ │ 💻 這台電腦 ● 已連上雲端 │ │
|
||||||
|
│ │ (office-mac · 最後心跳 2 秒前) │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
|
│ ┌── 第 2 層 ──────────────────────────────────────────┐ │
|
||||||
|
│ │ 🔌 裝置 ● 已就緒(可載入模型 / 推論) │ │
|
||||||
|
│ └──────────────────────────────────────────────────────┘ │
|
||||||
|
└────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**設計規格**:
|
||||||
|
- 兩層各自一個 badge,**帶明確的層標籤**(💻 這台電腦 / 🔌 裝置),這是消除混淆的關鍵——使用者一眼看到「這是電腦的狀態、那是裝置的狀態」。
|
||||||
|
- 第 1 層 badge(電腦):沿用 `RemoteDeviceBadge` 結構(dot + icon + label),只改 label 文案(見 §8)+前綴層標籤。
|
||||||
|
- 第 2 層 badge(裝置就緒):新增一個 badge 呈現 driver 狀態,用 `devices.status.*` 收斂後的語意(見 §2.4 對照)。
|
||||||
|
- **不只靠顏色**(沿用既有原則):dot 色 + icon(✓/○/◐)+ 文字三重編碼。
|
||||||
|
- 色彩沿用既有 `--status-*` token,不裸寫色碼(globals.css 既有)。
|
||||||
|
- 響應式:mobile(<640px)兩層仍垂直堆疊、層標籤縮短為 icon+短詞;tablet/desktop 同結構。
|
||||||
|
|
||||||
|
### 2.4 第 2 層 driver 狀態的語意收斂
|
||||||
|
|
||||||
|
現有 `devices.status.*` 有七值(detected/connecting/connected/flashing/inferencing/error/disconnected/unknown),但對使用者而言第 2 層只需知道「就緒了沒」。收斂成三個使用者可理解的態:
|
||||||
|
|
||||||
|
| 底層 `status` | 第 2 層使用者語意 | badge 文案 | dot/icon |
|
||||||
|
|--------------|-----------------|-----------|----------|
|
||||||
|
| `connected` / `flashing` / `inferencing` | 已就緒 | **裝置已就緒** | 🟢 ✓ |
|
||||||
|
| `connecting` | 啟用中 | **啟用中…** | 🟡 ◐(pulse) |
|
||||||
|
| `detected` / `disconnected` | 待啟用 | **裝置待啟用** | ⚪ ○ |
|
||||||
|
| `error` | 裝置錯誤 | **裝置錯誤** | 🔴 ⚠ |
|
||||||
|
| `unknown` | 未確認 | **狀態未確認** | ⚪ — |
|
||||||
|
|
||||||
|
> 註:`devices.status.*` 七值的原始文案**保留不動**(其他地方如「裝置資訊」Card 的 driver 狀態明細仍可能用到);本層新增一組「就緒語意」文案(`devices.readiness.*`,見 §8),供第 2 層 badge 使用。這是**新增**、不覆蓋既有 key,避免波及其他引用點。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 兩層狀態文案 mapping(跟 pairing-rename 一致)
|
||||||
|
|
||||||
|
> frontend 照表落地。「動作」欄凡涉及第 2 層 driver 一律改「啟用/停用」;第 1 層一律帶主詞「電腦」。
|
||||||
|
|
||||||
|
### 3.1 第 1 層 tunnel(電腦連上雲端)— 改既有 `remote.status.*` value
|
||||||
|
|
||||||
|
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|
||||||
|
|----------|-------|-------|-------|-------|------|
|
||||||
|
| `remote.status.online` | 在線 | 已連上雲端 | Online | Connected to cloud | 帶「雲端」點出這是電腦↔雲端層。搭配層標籤「這台電腦」使用 |
|
||||||
|
| `remote.status.offline` | 離線 | 未連上雲端 | Offline | Not connected to cloud | 成套 |
|
||||||
|
| `remote.status.reconnecting` | 重新連線中 | 重新連上雲端中 | Reconnecting | Reconnecting to cloud | 成套 |
|
||||||
|
| `remote.status.error` | 連線錯誤 | 雲端連線錯誤 | Connection error | Cloud connection error | 成套 |
|
||||||
|
| `remote.status.unknown` | 未確認 | 未確認 | Unknown | Unknown | 不動 |
|
||||||
|
|
||||||
|
> ⚠️ 避撞檢查:這些是第 1 層文案,必搭配層標籤「💻 這台電腦」呈現。若某些地方(如列表卡片空間小)不放層標籤,則用**帶主詞版**避免歧義 → 見 §3.4「列表卡片精簡版」。
|
||||||
|
|
||||||
|
### 3.2 第 1 層層標籤(新增 key)
|
||||||
|
|
||||||
|
| i18n key(新) | zh | en |
|
||||||
|
|---------------|-----|-----|
|
||||||
|
| `deviceUsage.layer.computer` | 這台電腦 | This computer |
|
||||||
|
| `deviceUsage.layer.device` | 裝置 | Device |
|
||||||
|
|
||||||
|
### 3.3 第 2 層 driver 就緒語意(新增 `devices.readiness.*`,不覆蓋既有 status.*)
|
||||||
|
|
||||||
|
| i18n key(新) | zh | en | 對應底層 status |
|
||||||
|
|---------------|-----|-----|----------------|
|
||||||
|
| `devices.readiness.ready` | 裝置已就緒 | Device ready | connected/flashing/inferencing |
|
||||||
|
| `devices.readiness.activating` | 啟用中… | Activating… | connecting |
|
||||||
|
| `devices.readiness.notReady` | 裝置待啟用 | Device not activated | detected/disconnected |
|
||||||
|
| `devices.readiness.error` | 裝置錯誤 | Device error | error |
|
||||||
|
| `devices.readiness.unknown` | 狀態未確認 | Status unknown | unknown |
|
||||||
|
| `devices.readiness.readyHint` | 可載入模型 / 推論 | Ready to load a model / run inference | — |
|
||||||
|
|
||||||
|
### 3.4 第 2 層動作按鈕 — 改既有 `devices.connect.*` value(動詞改「啟用/停用」)
|
||||||
|
|
||||||
|
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|
||||||
|
|----------|-------|-------|-------|-------|------|
|
||||||
|
| `devices.connect.action` | 連線 | 啟用裝置 | Connect | Activate device | 動作動詞改「啟用」,徹底避開「連線」撞第 1 層 |
|
||||||
|
| `devices.connect.connecting` | 連線中… | 啟用中… | Connecting… | Activating… | 成套 |
|
||||||
|
| `devices.connect.connectingHint` | 首次連線需載入韌體,約 1 分鐘,請勿關閉頁面 | 首次啟用需載入韌體,約 1 分鐘,請勿關閉頁面 | (First connection loads firmware…) | First activation loads firmware, ~1 min, keep this page open | 連線→啟用 |
|
||||||
|
| `devices.connect.disconnect` | 斷線 | 停用 | Disconnect | Deactivate | 成套(次要動作、outline 樣式) |
|
||||||
|
| `devices.connect.disconnecting` | 斷線中… | 停用中… | Disconnecting… | Deactivating… | 成套 |
|
||||||
|
| `devices.connect.needConnectHint` | 請先連線裝置才能載入模型 | 請先啟用裝置才能載入模型 | (Activate the device first…) | Activate the device before loading a model | 連線→啟用 |
|
||||||
|
| `devices.connect.driverStatusLabel` | 連線狀態 | 裝置狀態 | Connection status | Device status | 避開「連線」;這是裝置資訊 Card 的明細 label |
|
||||||
|
| `devices.connect.toast.success` | 裝置已連線 | 裝置已就緒 | Device connected | Device ready | 對齊就緒語意 |
|
||||||
|
| `devices.connect.toast.failed` | 連線失敗 | 啟用失敗 | Connection failed | Activation failed | 成套 |
|
||||||
|
| `devices.connect.toast.failedDesc` | 請確認裝置已插上且 local agent 在線 | 請確認裝置已插上,且這台電腦已連上雲端 | (…local agent online) | Make sure the device is plugged in and this computer is connected to the cloud | 「local agent 在線」→「電腦已連上雲端」對齊第 1 層用語 |
|
||||||
|
| `devices.connect.toast.timeoutDesc` | 連線逾時,請重試 | 啟用逾時,請重試 | Connection timed out | Activation timed out, please retry | 成套 |
|
||||||
|
| `devices.connect.toast.disconnected` | 裝置已斷線 | 裝置已停用 | Device disconnected | Device deactivated | 成套 |
|
||||||
|
| `devices.connect.toast.disconnectFailed` | 斷線失敗 | 停用失敗 | Disconnect failed | Deactivation failed | 成套 |
|
||||||
|
|
||||||
|
> **i18n key 名一律不改**(`devices.connect.*` 保留),只改 value——避開改 key 波及元件引用,比照 pairing-rename §3.1 判定。
|
||||||
|
|
||||||
|
### 3.5 明確「不動」清單(frontend 別誤改)
|
||||||
|
|
||||||
|
- `common.connect="連接"` / `common.disconnect="中斷連線"`(通用詞、非此流程專屬)
|
||||||
|
- `devices.status.*` 七值原始文案(detected/connecting/connected/…)— 保留,供裝置資訊 Card 明細用;本次新增 `devices.readiness.*` 平行使用,不覆蓋
|
||||||
|
- pairing-rename 已定的所有「連接電腦」相關 key — 不動
|
||||||
|
- `pairing.step3.waiting`(等待 local agent 連線)— 屬 agent↔雲端網路語意,pairing-rename 已判定不動
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 「從未連線」vs「離線」的狀態邏輯(問題 1)
|
||||||
|
|
||||||
|
### 4.1 根因
|
||||||
|
|
||||||
|
`remote-device-badge.tsx:99-104` 現況:
|
||||||
|
|
||||||
|
```
|
||||||
|
if (!showLastSeen || status === "online" || status === "unknown") return "";
|
||||||
|
if (!lastSeenAt) return t("remote.lastSeenNever"); // ← 只看 lastSeenAt,缺就顯示「從未連線」
|
||||||
|
...
|
||||||
|
return formatRelativeTime(lastSeenAt, nowMs, t);
|
||||||
|
```
|
||||||
|
|
||||||
|
問題:`lastSeenAt` 缺(後端 offline 時可能沒回 last_seen_at,或 DB 靜態 offline 從未寫過)就直接說「從未連線」,但裝置**明明配對過、連過**。使用者關掉 local agent 後看到「從未連線」=明顯錯誤。
|
||||||
|
|
||||||
|
### 4.2 設計解:三層 fallback 判定「連過沒」
|
||||||
|
|
||||||
|
離線裝置的次要文字,依以下優先序決定:
|
||||||
|
|
||||||
|
```
|
||||||
|
若 status === online 或 unknown → 不顯示次要文字(沿用既有)
|
||||||
|
否則(offline / reconnecting / error):
|
||||||
|
1. 有 lastSeenAt → 「最後心跳 {相對時間}」(最準)
|
||||||
|
2. 無 lastSeenAt,但有 pairedAt → 「上次連線:{pairedAt 日期}」(退而求其次:連接過就不是「從未」)
|
||||||
|
3. 無 lastSeenAt,但有 registeredAt → 「上次連線:{registeredAt 日期}」(同上,已註冊=用過)
|
||||||
|
4. 三者皆無 → 「從未連線」(真的沒連過,才顯示這句)
|
||||||
|
```
|
||||||
|
|
||||||
|
**判定「連過」的訊號**:`lastSeenAt || pairedAt || registeredAt` 任一存在 → 這台裝置曾經連上過,**不可**顯示「從未連線」。
|
||||||
|
|
||||||
|
### 4.3 文案 mapping(問題 1)
|
||||||
|
|
||||||
|
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|
||||||
|
|----------|-------|-------|-------|-------|------|
|
||||||
|
| `remote.lastSeenNever` | 從未連線 | 尚未連線過 | Never connected | Never connected | 只在「真的沒連過」時顯示;文案微調更中性 |
|
||||||
|
| `remote.lastSeen` | 最後心跳 {time} | 最後心跳 {time} | Last seen {time} | Last seen {time} | 有 lastSeenAt 時用;不動 |
|
||||||
|
| `remote.lastConnectedAt`(新) | 上次連線:{time} | 上次連線:{time} | Last connected {time} | Last connected {time} | 缺 lastSeenAt 但有 pairedAt/registeredAt 時用(fallback) |
|
||||||
|
|
||||||
|
### 4.4 呈現範例
|
||||||
|
|
||||||
|
| 情境 | lastSeenAt | pairedAt | 顯示 |
|
||||||
|
|------|-----------|----------|------|
|
||||||
|
| 連過、剛關 agent | 2 分鐘前 | 有 | 未連上雲端 · 最後心跳 2 分鐘前 |
|
||||||
|
| 連過、後端沒回 lastSeenAt | ✗ | 2026-07-01 | 未連上雲端 · 上次連線:2026-07-01 |
|
||||||
|
| 真的沒連過(剛配對、agent 從沒起來) | ✗ | ✗(極少見) | 未連上雲端 · 尚未連線過 |
|
||||||
|
|
||||||
|
> 註:多數情境會落在前兩列。第三列(pairedAt 也沒有)在正常流程幾乎不會發生(配對成功必寫 pairedAt),保留只為極端防呆。
|
||||||
|
|
||||||
|
### 4.5 是否需要後端配合
|
||||||
|
|
||||||
|
- **主要:純前端**。`pairedAt`、`registeredAt`、`lastSeenAt` 三欄在 `Device` 型別都已存在(device-store.ts:98-100, 108),前端改 badge 判定邏輯即可。
|
||||||
|
- **待確認(§7)**:後端 offline 時是否穩定回傳 `last_seen_at`?若後端能在裝置每次上線時寫入 last_seen_at,則情境二會落回情境一(最準)。**即使後端沒回,本設計的 pairedAt fallback 也已能避免「從未連線」誤導**——後端修不修都不阻擋本次落地,只是 last_seen_at 有回會更精準。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 模型狀態呈現(問題 3)
|
||||||
|
|
||||||
|
### 5.1 現況
|
||||||
|
|
||||||
|
模型狀態 Card(device-detail-client.tsx:443-466)只有二態:`flashedModel` 有值 → 顯示模型名 + 「已就緒,可開始推論」;沒值 → 「尚未燒錄任何模型」。缺離線態、載入中態、以及重連刷新引導。
|
||||||
|
|
||||||
|
### 5.2 四態設計
|
||||||
|
|
||||||
|
| 態 | 條件 | 呈現 | icon |
|
||||||
|
|----|------|------|------|
|
||||||
|
| **已載入** | `flashedModel != null` 且 online | 模型名(font-medium)+ 「已就緒,可開始推論」+ CTA「前往推論」 | ✓ 綠 |
|
||||||
|
| **未載入** | `flashedModel == null` 且裝置已就緒 | 「尚未載入模型」+ 提示「載入模型後即可開始推論」+ 引導看上方「載入模型」按鈕 | ○ 灰 |
|
||||||
|
| **載入中** | `status === "flashing"` | 進度條(WS 推送)+ 「模型載入中,請勿關閉頁面」 | ◐ pulse |
|
||||||
|
| **離線 · 上次已知** | offline 且有 cache 的 flashedModel | 模型名 + 標註「資料截至上次連線」(淡化 opacity-75)+ 「裝置重新連上後會自動刷新」 | ⚪ + 時間戳 |
|
||||||
|
|
||||||
|
### 5.3 重連刷新邏輯 UX
|
||||||
|
|
||||||
|
- **重連後刷新**:裝置從 offline → online(或 driver 重新啟用)時,詳情頁應重新 `fetchDevice(id)` 拉最新 flashedModel(現況 connect 成功後已呼叫 fetchDevice,disconnect 亦同)。
|
||||||
|
- **重連=清空舊模型狀態**:使用者提到「重連後應刷新為未載入」——driver 停用再啟用(或 agent 重連)後,若晶片已無 flash 的模型,`fetchDevice` 會拿回 `flashedModel: null` → 自動回到「未載入」態。**不需前端額外清空邏輯**,只要確保重連後有 fetchDevice(現況已有)。
|
||||||
|
- **離線時不主動 polling**(沿用 flow-offline-handling §12),顯示 cache 並標「資料截至上次連線」,避免顯示過時資料卻讓使用者以為是即時。
|
||||||
|
|
||||||
|
### 5.4 文案 mapping(問題 3)
|
||||||
|
|
||||||
|
| i18n key | zh | en | 備註 |
|
||||||
|
|----------|-----|-----|------|
|
||||||
|
| `devices.detail.readyForInference` | 已就緒,可開始推論 | Ready for inference | 不動 |
|
||||||
|
| `devices.detail.noModelFlashed` | 尚未載入模型 | No model loaded | 「燒錄」→「載入」對齊 flash 已改用「載入」;補一句引導見下 |
|
||||||
|
| `devices.detail.noModelHint`(新) | 載入模型後即可開始推論。點上方「載入模型」開始。 | Load a model to start inference. Use "Load model" above. | 未載入態引導 |
|
||||||
|
| `devices.detail.modelFlashing`(新) | 模型載入中,請勿關閉頁面 | Loading model, keep this page open | 載入中態 |
|
||||||
|
| `devices.detail.modelStaleHint`(新) | 資料截至上次連線,裝置重新連上後會自動刷新 | As of last connection; refreshes when the device reconnects | 離線 cache 態 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 載入後 → 推論的引導(問題 4)
|
||||||
|
|
||||||
|
### 6.1 現況
|
||||||
|
|
||||||
|
flash 完成後 `fetchDevice` → `flashedModel` 有值 → 「開啟工作區」按鈕出現(device-detail-client.tsx:287),已有 `ring-primary/40 ring-2` highlight。但按鈕跟「停用/載入模型/移除」擠在同一排,使用者反映「原本樣式不明顯要自己找」。
|
||||||
|
|
||||||
|
### 6.2 設計解:flash 完成後彈成功態 + 主 CTA
|
||||||
|
|
||||||
|
**flash 完成的當下**(FlashDialog 關閉、fetchDevice 回來 flashedModel 有值),做兩件事:
|
||||||
|
|
||||||
|
1. **模型狀態 Card 切換為「已載入」成功態**,Card 內直接放主 CTA:
|
||||||
|
```
|
||||||
|
┌── 模型狀態 ──────────────────────────┐
|
||||||
|
│ ✓ 模型已載入:yolov5s_640.nef │
|
||||||
|
│ 已就緒,可開始推論 │
|
||||||
|
│ │
|
||||||
|
│ [ 前往推論 → ] ← 主 CTA,size lg │
|
||||||
|
└────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
2. **一次性 toast**:「模型載入成功,可以開始推論了」+ toast action「前往推論」(點了直接 router.push)。
|
||||||
|
|
||||||
|
**為什麼不自動跳轉**:沿用現況決策(device-detail-client.tsx:282-286 註解)——自動 push 會誤導、使用者可能還想載別的模型或檢查。維持**手動導航但把入口做到不用找**:模型 Card 內的主 CTA + toast action 雙入口。
|
||||||
|
|
||||||
|
### 6.3 主 CTA 規格
|
||||||
|
|
||||||
|
- 位置優先序:**模型狀態 Card 內**(最貼近「模型剛載入好」的語境)> 頂部按鈕排(保留現況的 ring highlight 版作為次要入口)。
|
||||||
|
- 樣式:`size="lg"` + primary 填色 + ArrowRight 圖示 + `ring-primary/40 ring-2 ring-offset-2`(沿用現況 highlight)。
|
||||||
|
- 文案:主 CTA 用「**前往推論**」(比「開啟工作區」更直指使用者意圖——工作區=推論的地方)。保留 `devices.openWorkspace` 作為別處引用;新增 `devices.goToInference`。
|
||||||
|
- 離線 / serial 缺:沿用現況 disable + tooltip 分支(device-detail-client.tsx:299-322)。
|
||||||
|
|
||||||
|
### 6.4 文案 mapping(問題 4)
|
||||||
|
|
||||||
|
| i18n key | zh | en | 備註 |
|
||||||
|
|----------|-----|-----|------|
|
||||||
|
| `devices.openWorkspace` | 開啟工作區 | Open workspace | 保留(別處引用) |
|
||||||
|
| `devices.goToInference`(新) | 前往推論 | Go to inference | flash 完成後主 CTA |
|
||||||
|
| `devices.flash.readyToast`(新) | 模型載入成功,可以開始推論了 | Model loaded — ready for inference | 成功 toast |
|
||||||
|
| `devices.flash.readyToastAction`(新) | 前往推論 | Go to inference | toast action |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 各狀態的空 / 載入 / 錯誤 / 離線降級(統整)
|
||||||
|
|
||||||
|
> 沿用 `flow-offline-handling.md` §5 的降級策略,補齊本流程各步驟的四態。
|
||||||
|
|
||||||
|
### 7.1 詳情頁整體
|
||||||
|
|
||||||
|
| 情境 | 呈現 |
|
||||||
|
|------|------|
|
||||||
|
| **載入中**(fetchDevice 中) | Skeleton(現況已有:device-detail-client.tsx:123-134) |
|
||||||
|
| **404 / 找不到** | 現況占位(:137-155)→ 建議補明確「找不到此裝置」空狀態 + 返回列表 CTA |
|
||||||
|
| **第 1 層 offline** | 頂部離線 banner(現況 :188-204)+ 第 2 層 badge 顯示「狀態未確認」+ 啟用/載入/推論全 disable + tooltip「這台電腦未連上雲端」 |
|
||||||
|
| **第 1 層 online、第 2 層待啟用** | 顯示「啟用裝置」CTA;載入模型/推論 disable + tooltip「請先啟用裝置」 |
|
||||||
|
| **serial 缺** | 啟用/載入/推論 disable + tooltip「此裝置尚未回報序號,需在 local agent 重新連接電腦一次」(沿用 `devices.serial.missingHint`,該文案 pairing-rename 已改「重新連接」) |
|
||||||
|
| **啟用失敗** | toast「啟用失敗」+ desc「請確認裝置已插上,且這台電腦已連上雲端」 |
|
||||||
|
| **flash 失敗** | toast「模型載入失敗」(現況 `devices.flash.flashFailed`)+ 保留在未載入態,可重試 |
|
||||||
|
|
||||||
|
### 7.2 離線 banner 文案調整
|
||||||
|
|
||||||
|
| i18n key | 舊 zh | 新 zh | 備註 |
|
||||||
|
|----------|-------|-------|------|
|
||||||
|
| `devices.detail.offlineBanner.title` | 此裝置目前離線 | 這台電腦目前未連上雲端 | 校正語意:離線的是電腦(tunnel),非單一裝置;對齊第 1 層用語 |
|
||||||
|
| `devices.detail.offlineBanner.description` | 部分操作無法使用,待 local agent 重新連線後自動恢復 | 部分操作無法使用,待這台電腦重新連上雲端後自動恢復 | 對齊 |
|
||||||
|
|
||||||
|
> ⚠️ 注意:offlineBanner 目前語意是「裝置離線」,但根因是第 1 層 tunnel(電腦)離線——local agent 一關,該電腦上所有裝置都離線(flow-offline-handling §1 失效點 C)。改文案點出是「電腦」層級,避免使用者以為只是這一顆晶片壞。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. 完整新增 / 修改 i18n key 清單(frontend 落地總表)
|
||||||
|
|
||||||
|
> zh 與 en 同步。**修改既有 value** 的已列在 §3/§4/§5/§6/§7;此處彙整**新增 key**,frontend 一次補齊。
|
||||||
|
|
||||||
|
### 8.1 新增 key
|
||||||
|
|
||||||
|
| i18n key | zh | en |
|
||||||
|
|----------|-----|-----|
|
||||||
|
| `deviceUsage.layer.computer` | 這台電腦 | This computer |
|
||||||
|
| `deviceUsage.layer.device` | 裝置 | Device |
|
||||||
|
| `deviceUsage.stepper.pairing` | 電腦連接 | Computer |
|
||||||
|
| `deviceUsage.stepper.activate` | 裝置就緒 | Device ready |
|
||||||
|
| `deviceUsage.stepper.loadModel` | 載入模型 | Load model |
|
||||||
|
| `deviceUsage.stepper.inference` | 推論 | Inference |
|
||||||
|
| `devices.readiness.ready` | 裝置已就緒 | Device ready |
|
||||||
|
| `devices.readiness.activating` | 啟用中… | Activating… |
|
||||||
|
| `devices.readiness.notReady` | 裝置待啟用 | Device not activated |
|
||||||
|
| `devices.readiness.error` | 裝置錯誤 | Device error |
|
||||||
|
| `devices.readiness.unknown` | 狀態未確認 | Status unknown |
|
||||||
|
| `devices.readiness.readyHint` | 可載入模型 / 推論 | Ready to load a model / run inference |
|
||||||
|
| `remote.lastConnectedAt` | 上次連線:{time} | Last connected {time} |
|
||||||
|
| `devices.detail.noModelHint` | 載入模型後即可開始推論。點上方「載入模型」開始。 | Load a model to start inference. Use "Load model" above. |
|
||||||
|
| `devices.detail.modelFlashing` | 模型載入中,請勿關閉頁面 | Loading model, keep this page open |
|
||||||
|
| `devices.detail.modelStaleHint` | 資料截至上次連線,裝置重新連上後會自動刷新 | As of last connection; refreshes when the device reconnects |
|
||||||
|
| `devices.goToInference` | 前往推論 | Go to inference |
|
||||||
|
| `devices.flash.readyToast` | 模型載入成功,可以開始推論了 | Model loaded — ready for inference |
|
||||||
|
| `devices.flash.readyToastAction` | 前往推論 | Go to inference |
|
||||||
|
|
||||||
|
### 8.2 修改既有 value(彙整,詳見對應章節)
|
||||||
|
|
||||||
|
- `remote.status.online/offline/reconnecting/error`(§3.1)
|
||||||
|
- `remote.lastSeenNever`(§4.3)
|
||||||
|
- `devices.connect.action/connecting/connectingHint/disconnect/disconnecting/needConnectHint/driverStatusLabel/toast.*`(§3.4)
|
||||||
|
- `devices.detail.noModelFlashed`(§5.4)
|
||||||
|
- `devices.detail.offlineBanner.title/description`(§7.2)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. 無障礙(沿用既有 + 本次補充)
|
||||||
|
|
||||||
|
- 兩層 badge 各自 `role="status"` + `aria-live="polite"`,狀態變更宣告(現況 RemoteDeviceBadge 已有,第 2 層新 badge 比照)。
|
||||||
|
- 層標籤(「這台電腦」/「裝置」)不只用 icon——搭配文字,SR 可讀。
|
||||||
|
- Flow Stepper:`role="list"` + 每步 `role="listitem"` + 當前步 `aria-current="step"`(比照 flow-pairing §10)。
|
||||||
|
- 三態(就緒/待啟用/錯誤)不只靠顏色:dot + icon(✓/○/◐/⚠)+ 文字三重編碼。
|
||||||
|
- flash 完成 toast:`aria-live="polite"`;主 CTA 可 Tab 聚焦、Enter 觸發。
|
||||||
|
- 重連倒數 / pulse 動畫尊重 `prefers-reduced-motion`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. 落地後驗證(frontend / Design QA 自檢)
|
||||||
|
|
||||||
|
1. **兩層不打架**:詳情頁同時出現「💻 這台電腦 已連上雲端」與「🔌 裝置 待啟用」時,使用者能分清兩層、知道要點「啟用裝置」。
|
||||||
|
2. **「從未連線」修正**:關掉 local agent 後,連過的裝置顯示「未連上雲端 · 最後心跳 X」或「上次連線:X」,**不再**顯示「從未連線/尚未連線過」。
|
||||||
|
3. **grep 檢查**:第 2 層動作按鈕文案不再出現「連線/斷線」(改「啟用/停用」);第 1 層文案帶「雲端」或搭配「這台電腦」層標籤。
|
||||||
|
4. **模型四態**:離線時模型 Card 顯示 cache + 「資料截至上次連線」;重連後 fetchDevice 刷新為最新(含未載入)。
|
||||||
|
5. **載入後引導**:flash 完成 → 模型 Card 內出現「前往推論」主 CTA + toast action,不用在按鈕排裡找。
|
||||||
|
6. **三斷點截圖**:mobile(≤375)/tablet(768)/desktop(≥1280) 兩層 badge + 進度列佈局正常。
|
||||||
|
7. **對比度**:兩層 badge 文字 + 層標籤 WCAG AA(4.5:1)。
|
||||||
|
8. **離線降級**:第 1 層 offline 時,啟用/載入/推論全 disable + tooltip 指出是「電腦未連上雲端」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. 給 Orchestrator / frontend 的落地摘要
|
||||||
|
|
||||||
|
**全部純前端**(改 i18n value + badge 呈現 + 詳情頁佈局 + 模型 Card 四態 + flash 完成引導)。底層欄位(`remoteStatus`/`status`/`flashedModel`/`lastSeenAt`/`pairedAt`/`registeredAt`)都已存在,無需改 store 型別。
|
||||||
|
|
||||||
|
**唯一後端待確認(不阻擋落地)**:offline 時後端是否穩定回傳 `last_seen_at`(§7)。有回 → 「最後心跳 X」最精準;沒回 → 本設計已用 pairedAt fallback 避免「從未連線」誤導。建議 Orchestrator 順帶問後端 / architect:裝置每次上線時是否有寫 last_seen_at。
|
||||||
|
|
||||||
|
**落地建議拆批(frontend 增量)**:
|
||||||
|
- 批 1(問題 1,最小、解使用者最痛):改 `remote-device-badge.tsx` 的「從未連線」fallback 邏輯 + `remote.lastSeenNever` 文案 + 新增 `remote.lastConnectedAt`。
|
||||||
|
- 批 2(問題 2 核心):兩層命名 i18n value 全改 + 詳情頁兩層 badge 呈現 + 層標籤 + 第 2 層就緒 badge。
|
||||||
|
- 批 3(問題 3+4):模型 Card 四態 + flash 完成主 CTA + toast action + Flow Stepper 進度列。
|
||||||
168
docs/autoflow/03-design/pairing-rename-mapping.md
Normal file
168
docs/autoflow/03-design/pairing-rename-mapping.md
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
# 「配對」用詞一致化 — 完整 mapping 表(frontend 照表落地)
|
||||||
|
|
||||||
|
> 狀態:待 frontend 落地 · 產出:Design Agent · 語言:zh-TW
|
||||||
|
> 目標檔:`visionA-frontend/src/lib/i18n/dictionaries/zh-Hant.ts`(zh)+ `en.ts`(en)
|
||||||
|
> **本文件只出 mapping,不改任何 code / i18n 檔。frontend 照表逐 key 替換,不需自己判斷。**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. 為什麼要改(一句話)
|
||||||
|
|
||||||
|
「配對裝置」讓使用者誤以為在配對 KL520/KL720 **晶片**,但實際配對的是**使用者電腦上的 local-agent**(建立雲端帳號 ↔ 這台電腦的信任連結;之後這台電腦上所有 USB 裝置都能從雲端操作)。所以把主詞從「裝置」換成「電腦」。
|
||||||
|
|
||||||
|
## 1. 心智模型(frontend 理解用,務必記住)
|
||||||
|
|
||||||
|
系統裡有**兩層不同的連結**,過去都叫「連接/連線」,這次改詞就是要讓兩層在同一畫面不打架:
|
||||||
|
|
||||||
|
| 層 | 原本叫什麼 | 這次改成 | 語意 | 頻率 | 對象 |
|
||||||
|
|----|-----------|---------|------|------|------|
|
||||||
|
| **信任連結** | 配對 / pairing | **連接這台電腦 / Connect this computer** | 建立「雲端帳號 ↔ 這台電腦」的信任,一次性 | 一次 | 電腦(host / local-agent) |
|
||||||
|
| **驅動連線** | 連線 / connect(`devices.connect.*`) | **不動**(維持「連線 / 斷線」) | 每次要用某顆 USB 晶片時開啟 driver 連線 | 每次使用 | 裝置(USB 晶片) |
|
||||||
|
|
||||||
|
**一句話**:**「連接電腦」是把整台電腦接進你的雲端帳號(原 pairing);「連線裝置」是開始使用電腦上的某顆晶片(原 device connect,不動)。**
|
||||||
|
|
||||||
|
## 2. 避撞規則(frontend 落地時的鐵則)
|
||||||
|
|
||||||
|
1. `common.connect="連接"`、`common.disconnect="中斷連線"`、`devices.connect.*`(連線/斷線/連線中)**全部保持不動**。
|
||||||
|
2. **pairing 相關新詞一律帶主詞「電腦 / 這台電腦」**,不可出現裸「連接」。靠主詞讓使用者秒分辨兩層:
|
||||||
|
- ✅「連接這台電腦」「已連接的電腦」「重新連接電腦」
|
||||||
|
- ❌「連接」「已連接」(會跟裝置層 `devices.filter.onlineRegistered="已連接"` 撞)
|
||||||
|
3. 英文同理:pairing 用 **connect (this) computer / connected computer**;裝置層維持既有 connect/disconnect 用於 device driver。英文 `Connected!`(pairing.step3.success)維持不變,因為那是「電腦已接上雲端」的成功提示,語境清楚。
|
||||||
|
|
||||||
|
## 3. 成套用詞決定(正向 / 反向 / 名詞 / 欄位)
|
||||||
|
|
||||||
|
| 類別 | 舊詞 | 新詞(zh) | 新詞(en) | 理由 |
|
||||||
|
|------|------|-----------|-----------|------|
|
||||||
|
| **正向動作** | 配對 / Pair | 連接這台電腦 / **Connect this computer**(按鈕短版「連接電腦 / Connect computer」) | 主詞換成電腦、消除晶片誤解;長句重寫成「連接你的電腦,就能從雲端使用裝置」 |
|
||||||
|
| **反向動作(unpair)** | 解除配對 / unpair | **中斷這台電腦的連接 / 解除電腦連接**(動作按鈕沿用既有「移除裝置」不動,內文用「解除電腦連接」) | Disconnect this computer / unlink | unpair 語意=「移除裝置紀錄 + 撤銷存取」;改詞**不改語意**。用「解除…連接」對應正向「連接」,成套。註:現有 `devices.remove.action="移除裝置"` 是刪 device 紀錄的動作,**保持不動**;只有描述 pairing 關係的句子才改。 |
|
||||||
|
| **名詞(Token)** | Pairing Token | **連接碼**(口語處)/ **連接 Token**(技術處) | **Pairing token → connection token**;CLI 參數 `--relay-token` **不動** | 見 §4「保留 pairing 原文」判定:token 名詞面向使用者改「連接碼」較白話;`--relay-token` 是實際 CLI flag,屬 code identifier,**絕不改**。 |
|
||||||
|
| **欄位(pairedAt)** | 配對時間 / Paired at | **連接時間 / Connected at** | i18n label 可改;底層欄位名 `pairedAt`(API/DB)**不改**,只改顯示字。 |
|
||||||
|
| **重新配對(re-pair)** | 重新配對 / re-pair | **重新連接電腦 / re-connect (the computer)** | 成套。凡「請在 local agent 重新配對一次」→「請在 local agent 重新連接一次」。 |
|
||||||
|
|
||||||
|
### 3.1 判定「保留 pairing 原文」的技術術語(不改)
|
||||||
|
|
||||||
|
| 保留項 | 出現位置 | 為何不改 |
|
||||||
|
|--------|---------|---------|
|
||||||
|
| `--relay-token` | `pairing.cli.description` | 實際 CLI flag、code identifier,改了指令會壞。 |
|
||||||
|
| i18n **key 名**(`pairing.*`、`pairedAt`、`dashboard.pairDevice` 等) | 全部 key | 只改 value,不改 key(改 key 要動用到處引用的元件,超出本次範圍且無使用者可見效益)。 |
|
||||||
|
| API/DB 欄位 `pairedAt`、後端 `pairing` endpoint、路由 `/devices/pair` | 非 i18n | 使用者拍板「不改路由」;欄位名屬後端契約,本次只改前端顯示文案。 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 完整 mapping 表(涵蓋 zh 55 處 + en 57 處)
|
||||||
|
|
||||||
|
> 說明:一個 i18n key = 一列,同時給 zh/en 舊→新。frontend 逐列替換 value 即可。
|
||||||
|
> 「備註」欄標注句子有無重寫、避撞注意。行號為撰文時快照(zh-Hant.ts / en.ts),實際以 key 為準。
|
||||||
|
|
||||||
|
### 4.1 Dashboard
|
||||||
|
|
||||||
|
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|
||||||
|
|----------|-------|-------|-------|-------|------|
|
||||||
|
| `dashboard.noConnectedDevices` | 目前沒有裝置線上。配對一台 Kneron 裝置開始雲端推論。 | 目前沒有裝置線上。連接你的電腦,就能從雲端使用 Kneron 裝置。 | No devices are online. Pair a Kneron device to start cloud inference. | No devices are online. Connect your computer to use Kneron devices from the cloud. | 句子重寫:主詞改電腦 |
|
||||||
|
| `dashboard.noActivity` | 還沒有任何活動。配對裝置、上傳模型或跑一次推論後就會出現。 | 還沒有任何活動。連接電腦、上傳模型或跑一次推論後就會出現。 | Nothing here yet. Activity appears after pairing, uploads, or inference runs. | Nothing here yet. Activity appears after connecting a computer, uploads, or inference runs. | pairing→connecting a computer |
|
||||||
|
| `dashboard.pairDevice` | 配對裝置 | 連接電腦 | Pair device | Connect computer | 按鈕短版 |
|
||||||
|
| `dashboard.empty.description` | 配對你的第一台 Kneron 裝置,開始雲端推論之旅 | 連接你的電腦,開始從雲端使用 Kneron 裝置 | Pair your first Kneron device to start running inference from anywhere. | Connect your computer to start using Kneron devices from anywhere. | 句子重寫 |
|
||||||
|
| `dashboard.empty.action` | 配對裝置 | 連接電腦 | Pair a device | Connect computer | 按鈕短版 |
|
||||||
|
|
||||||
|
### 4.2 Devices(列表 / 空狀態 / 詳情)
|
||||||
|
|
||||||
|
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|
||||||
|
|----------|-------|-------|-------|-------|------|
|
||||||
|
| `devices.addMore` | 配對新裝置 | 連接新電腦 | Pair a new device | Connect a new computer | |
|
||||||
|
| `devices.pairAction` | 配對新裝置 | 連接新電腦 | Pair a new device | Connect a new computer | 與上同值 |
|
||||||
|
| `devices.empty.title` | 還沒有配對的裝置 | 還沒有連接任何電腦 | No devices paired yet | No computers connected yet | |
|
||||||
|
| `devices.empty.description` | 在你的電腦上執行 local agent 並完成配對,就能從任何地方存取你的 Kneron 裝置 | 在你的電腦上執行 local agent 並完成連接,就能從任何地方存取你的 Kneron 裝置 | Run local agent on your computer and complete pairing to access your Kneron devices from anywhere. | Run local agent on your computer and complete the connection to access your Kneron devices from anywhere. | 完成配對→完成連接 |
|
||||||
|
| `devices.empty.action` | 配對第一台裝置 | 連接你的電腦 | Pair your first device | Connect your computer | |
|
||||||
|
| `devices.empty.secondaryAction` | 查看配對說明 | 查看連接說明 | How pairing works | How connecting works | |
|
||||||
|
| `devices.detail.pairedAt` | 配對時間 | 連接時間 | Paired at | Connected at | 欄位 label;底層 `pairedAt` 欄位名不動 |
|
||||||
|
|
||||||
|
### 4.3 Devices:序號 missingHint(re-pair)
|
||||||
|
|
||||||
|
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|
||||||
|
|----------|-------|-------|-------|-------|------|
|
||||||
|
| `devices.serial.missingHint` | 此裝置尚未回報序號,無法執行推論相關操作。請在 local agent 重新配對一次,序號回報後即可使用。 | 此裝置尚未回報序號,無法執行推論相關操作。請在 local agent 重新連接一次,序號回報後即可使用。 | This device hasn't reported its serial number, so inference-related actions are unavailable. Re-pair it once from local agent to report the serial. | This device hasn't reported its serial number, so inference-related actions are unavailable. Re-connect once from local agent to report the serial. | re-pair→re-connect |
|
||||||
|
|
||||||
|
### 4.4 Devices:移除裝置(unpair 語意,reverse action)
|
||||||
|
|
||||||
|
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|
||||||
|
|----------|-------|-------|-------|-------|------|
|
||||||
|
| `devices.remove.confirm.description` | 這會解除「{name}」與你帳號的配對並撤銷其存取權限。若要再次使用,需從 local agent 重新配對。此操作無法復原。 | 這會解除「{name}」與你帳號的連接並撤銷其存取權限。若要再次使用,需從 local agent 重新連接。此操作無法復原。 | This unpairs "{name}" from your account and revokes its access. To use it again, you'll need to pair it from local agent. This cannot be undone. | This disconnects "{name}" from your account and revokes its access. To use it again, you'll need to connect it from local agent. This cannot be undone. | **反向成套**:unpairs→disconnects;「解除…配對」→「解除…連接」。unpair 語意(刪紀錄+撤存取)不變。`devices.remove.action="移除裝置"` 按鈕**不動**。 |
|
||||||
|
|
||||||
|
> 註:`devices.remove.action / removing / confirm.title / confirm.action / toast.success / error.*`(移除/移除中/確定要移除此裝置/移除/已移除裝置…)**全部不含「配對」字樣,保持不動**。這些是「移除 device 紀錄」的動作詞,與 pairing 改詞無關。
|
||||||
|
|
||||||
|
### 4.5 Models
|
||||||
|
|
||||||
|
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|
||||||
|
|----------|-------|-------|-------|-------|------|
|
||||||
|
| `models.empty.description` | 上傳你的第一個 .nef 模型到雲端,就能部署到任何一台配對過的 Kneron 裝置 | 上傳你的第一個 .nef 模型到雲端,就能部署到任何一台已連接電腦上的 Kneron 裝置 | Upload your first .nef model to deploy it to any paired Kneron device. | Upload your first .nef model to deploy it to any Kneron device on a connected computer. | 「配對過的裝置」→「已連接電腦上的裝置」(語意校正:裝置本身不被配對,是它所在電腦被連接) |
|
||||||
|
|
||||||
|
### 4.6 Workspace
|
||||||
|
|
||||||
|
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|
||||||
|
|----------|-------|-------|-------|-------|------|
|
||||||
|
| `workspace.empty.description` | 請先配對並確認 local agent 已連上雲端 | 請先連接你的電腦並確認 local agent 已連上雲端 | Pair a device and make sure the local agent is connected to the cloud. | Connect your computer and make sure the local agent is connected to the cloud. | 「local agent 已連上雲端」中的「連上」屬既有網路語意、不改 |
|
||||||
|
| `workspace.noSerial.description` | 推論、攝影機與媒體上傳需要裝置序號才能路由到 local agent。請在 local agent 重新配對一次,序號回報後即可操作。 | 推論、攝影機與媒體上傳需要裝置序號才能路由到 local agent。請在 local agent 重新連接一次,序號回報後即可操作。 | Inference, camera, and media upload need the device serial to route to local agent. Re-pair the device once from local agent; these actions unlock after the serial is reported. | Inference, camera, and media upload need the device serial to route to local agent. Re-connect once from local agent; these actions unlock after the serial is reported. | re-pair→re-connect |
|
||||||
|
|
||||||
|
### 4.7 Pairing 頁(F7,key namespace `pairing.*` 保留、只改 value)
|
||||||
|
|
||||||
|
| i18n key | 舊 zh | 新 zh | 舊 en | 新 en | 備註 |
|
||||||
|
|----------|-------|-------|-------|-------|------|
|
||||||
|
| `pairing.title` | 配對新裝置 | 連接這台電腦 | Pair a new device | Connect this computer | 頁標題,正向主詞 |
|
||||||
|
| `pairing.subtitle` | 讓你的 Kneron 裝置連上雲端,就能從任何地方遠端操作 | 讓這台電腦連上雲端,之後電腦上的 Kneron 裝置都能從任何地方遠端操作 | Connect your Kneron device to the cloud so you can operate it from anywhere. | Connect this computer to the cloud so its Kneron devices can be operated from anywhere. | 句子重寫:主詞電腦、點出「之後裝置都能用」 |
|
||||||
|
| `pairing.token.title` | 你的 Pairing Token | 你的連接碼 | Your pairing token | Your connection token | 名詞成套:連接碼 |
|
||||||
|
| `pairing.step1.description` | 複製下方 token,在 15 分鐘內貼到 local agent 完成配對 | 複製下方連接碼,在 15 分鐘內貼到 local agent 完成連接 | Copy the token below and paste it into your local agent within 15 minutes. | Copy the connection token below and paste it into your local agent within 15 minutes. | token→連接碼;完成配對→完成連接 |
|
||||||
|
| `pairing.copy` | 複製 | 複製 | Copy | Copy | 不動 |
|
||||||
|
| `pairing.copied` | 已複製 | 已複製 | Copied | Copied | 不動 |
|
||||||
|
| `pairing.regenerate` | 重新產生 | 重新產生 | Regenerate | Regenerate | 不動 |
|
||||||
|
| `pairing.timeRemaining` | 剩餘 {time} | 剩餘 {time} | {time} remaining | {time} remaining | 不動 |
|
||||||
|
| `pairing.generatedAt` | 產生時間:{time} | 產生時間:{time} | Generated at {time} | Generated at {time} | 不動 |
|
||||||
|
| `pairing.token.expired.label` | 此 token 已過期,請重新產生 | 此連接碼已過期,請重新產生 | This token has expired — please regenerate. | This connection token has expired — please regenerate. | token→連接碼 |
|
||||||
|
| `pairing.regenerateConfirm.title` | 確定要重新產生? | 確定要重新產生? | Regenerate token? | Regenerate token? | 不動(token 這裡指連接碼、但短語保留;如要一致可改「Regenerate connection token?」,見下方註) |
|
||||||
|
| `pairing.regenerateConfirm.description` | 舊 token 將立即失效,新 token 有效期 15 分鐘 | 舊連接碼將立即失效,新連接碼有效期 15 分鐘 | The old token will be invalidated immediately; the new one is valid for 15 minutes. | The old connection token will be invalidated immediately; the new one is valid for 15 minutes. | token→連接碼 |
|
||||||
|
| `pairing.security.warning` | 這組 token 15 分鐘內有效,請立刻完成配對 | 這組連接碼 15 分鐘內有效,請立刻完成連接 | This token is valid for 15 minutes — complete pairing now. | This connection token is valid for 15 minutes — complete the connection now. | token→連接碼;complete pairing→complete the connection |
|
||||||
|
| `pairing.security.oneTime` | token 是一次性使用,完成配對後自動失效 | 連接碼是一次性使用,完成連接後自動失效 | Tokens are single-use and expire automatically after pairing. | Connection tokens are single-use and expire automatically after connecting. | 成套 |
|
||||||
|
| `pairing.toast.copied` | Token 已複製到剪貼簿,15 分鐘內有效 | 連接碼已複製到剪貼簿,15 分鐘內有效 | Token copied — valid for 15 minutes. | Connection token copied — valid for 15 minutes. | token→連接碼 |
|
||||||
|
| `pairing.toast.generateFailed` | 無法產生 token,請重試 | 無法產生連接碼,請重試 | Could not generate token — please retry. | Could not generate connection token — please retry. | token→連接碼 |
|
||||||
|
| `pairing.toast.expiringSoon` | Token 即將過期,請立刻完成或重新產生 | 連接碼即將過期,請立刻完成或重新產生 | Token expiring soon — complete pairing or regenerate. | Connection token expiring soon — complete the connection or regenerate. | 成套 |
|
||||||
|
| `pairing.toast.pairedSuccess` | 裝置 {deviceName} 已成功配對 | 已成功連接電腦,偵測到裝置 {deviceName} | Device {deviceName} paired successfully. | Computer connected — device {deviceName} detected. | 語意校正:成功的是電腦連接、順帶偵測到裝置 |
|
||||||
|
| `pairing.toast.cliCopied` | 指令已複製到剪貼簿 | 指令已複製到剪貼簿 | CLI command copied. | CLI command copied. | 不動 |
|
||||||
|
| `pairing.device.unknown` | 未知裝置 | 未知裝置 | Unknown device | Unknown device | 不動 |
|
||||||
|
| `pairing.cli.title` | CLI 指令範例 | CLI 指令範例 | CLI example | CLI example | 不動 |
|
||||||
|
| `pairing.cli.description` | 在你的電腦啟動 local agent,將 token 貼到指令的 --relay-token 參數 | 在你的電腦啟動 local agent,將連接碼貼到指令的 --relay-token 參數 | Start local agent on your computer and pass the token to the --relay-token flag. | Start local agent on your computer and pass the connection token to the --relay-token flag. | token→連接碼;**`--relay-token` 保留不改** |
|
||||||
|
| `pairing.cli.copy` | 複製指令 | 複製指令 | Copy command | Copy command | 不動 |
|
||||||
|
| `pairing.cli.hint` | local agent 連上雲端後,本頁會自動偵測並跳轉到裝置列表 | local agent 連上雲端後,本頁會自動偵測並跳轉到裝置列表 | Once local agent connects to the cloud, this page detects it and forwards you to the device list. | Once local agent connects to the cloud, this page detects it and forwards you to the device list. | 不動(「連上雲端」屬網路語意、非 pairing 動作詞) |
|
||||||
|
| `pairing.step3.waiting` | 等待 local agent 連線… | 等待 local agent 連線… | Waiting for local agent to connect… | Waiting for local agent to connect… | 不動(local agent↔雲端的網路連線,非 pairing 動作詞) |
|
||||||
|
| `pairing.step3.elapsed` | 已等待 {time}(最長 3 分鐘) | 已等待 {time}(最長 3 分鐘) | Elapsed {time} (max 3 minutes) | Elapsed {time} (max 3 minutes) | 不動 |
|
||||||
|
| `pairing.step3.hints.running` | 確認 local agent 已啟動 | 確認 local agent 已啟動 | Confirm local agent is running | Confirm local agent is running | 不動 |
|
||||||
|
| `pairing.step3.hints.token` | 確認 token 貼上時無缺字或多餘空白 | 確認連接碼貼上時無缺字或多餘空白 | Confirm the token was pasted without missing or extra characters | Confirm the connection token was pasted without missing or extra characters | token→連接碼 |
|
||||||
|
| `pairing.step3.hints.network` | 確認你的網路可連線到雲端 | 確認你的網路可連線到雲端 | Confirm your network can reach the cloud endpoint | Confirm your network can reach the cloud endpoint | 不動 |
|
||||||
|
| `pairing.step3.success` | 已成功連線! | 已成功連接電腦! | Connected! | Connected! | zh 加主詞「電腦」避撞;en `Connected!` 語境清楚,保留 |
|
||||||
|
| `pairing.step3.success.detected` | 檢測到的裝置 | 檢測到的裝置 | Detected device | Detected device | 不動 |
|
||||||
|
| `pairing.step3.failure.timeout` | 連線超時 | 連接超時 | Connection timeout | Connection timeout | zh「連線超時」→「連接超時」對齊 pairing 語意;en 不動 |
|
||||||
|
| `pairing.step3.failure.reason` | 超過 3 分鐘沒收到 local agent 連線,可能是 local agent 尚未啟動 | 超過 3 分鐘沒收到 local agent 連線,可能是 local agent 尚未啟動 | No local agent connection within 3 minutes — agent may not be running. | No local agent connection within 3 minutes — agent may not be running. | 不動(指 agent↔雲端網路連線) |
|
||||||
|
| `pairing.step3.failure.retry` | 重新檢查 | 重新檢查 | Check again | Check again | 不動 |
|
||||||
|
|
||||||
|
> `pairing.regenerateConfirm.title` 註:為求 token 名詞完全一致,可選改 zh「確定要重新產生連接碼?」/ en「Regenerate connection token?」。frontend 若採一致版即用此;否則保留現值。此為 nice-to-have,不阻擋落地。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 明確「不動」清單(frontend 別誤改)
|
||||||
|
|
||||||
|
以下 key 含「連接 / 連線 / connect」但屬**裝置驅動層或網路層**,與 pairing 無關,**一律不改**:
|
||||||
|
|
||||||
|
- `common.connect="連接"`、`common.disconnect="中斷連線"`
|
||||||
|
- `devices.connect.*` 全部(action 連線 / connecting 連線中 / disconnect 斷線 / driverStatusLabel 連線狀態 / toast.* …)
|
||||||
|
- `devices.filter.onlineRegistered="已連接"`、`devices.filter.onlineUnregistered`、`devices.filter.offline`
|
||||||
|
- `devices.status.connecting/connected/disconnected`、`devices.detail.port="連接埠"`
|
||||||
|
- `devices.rescan.offlineHint`(「請先連接 local agent…」屬 agent 在線語意)
|
||||||
|
- `tunnel.status.*`、`remote.status.*`、各處「連線失敗 / 網路連線」等網路語意字串
|
||||||
|
- `devices.remove.action/removing/confirm.title/confirm.action/toast/error.*`(移除 device 紀錄動作、非 pairing 詞)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 落地後驗證(frontend / Design QA 自檢)
|
||||||
|
|
||||||
|
1. **同畫面不打架**:Devices 列表頁同時出現「連接新電腦」(pairing 按鈕)與裝置卡片上「連線 / 斷線」(device driver)時,主詞清楚不混淆。
|
||||||
|
2. **grep 檢查**:改完後 `pairing.*` value 與 dashboard/devices/workspace 的 pairing 文案不再出現裸「配對」;en 不再出現 `Pair`/`pairing`/`unpair`(除 `--relay-token`、i18n key 名、`pairedAt` 欄位名外)。
|
||||||
|
3. **避撞檢查**:pairing 新詞不得出現裸「連接/已連接」(無主詞),必帶「電腦」。
|
||||||
|
4. **語意不變檢查**:`devices.remove.confirm.description`(unpair)改詞後仍表達「移除裝置紀錄 + 撤銷存取 + 不可復原」。
|
||||||
@ -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)
|
- **下載對接權威規格(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`
|
- 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. 前端資料流與狀態管理
|
||||||
- 見 §10(本文件)
|
- 見 §10(本文件)
|
||||||
|
|
||||||
|
|||||||
190
docs/autoflow/04-architecture/adr/adr-020-ffmpeg-camera-indev.md
Normal file
190
docs/autoflow/04-architecture/adr/adr-020-ffmpeg-camera-indev.md
Normal file
@ -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 "<index>:none"` |
|
||||||
|
| Windows | `-f dshow -list_devices true -i dummy` | `-f dshow -i video="<name>"` |
|
||||||
|
| Linux(新增) | `-f v4l2 -list_devices true -i ""` 或列舉 `/dev/video*` | `-f v4l2 -i /dev/video<N>` |
|
||||||
|
|
||||||
|
> 三平台後段皆接 `-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 主線,可獨立排期。
|
||||||
131
docs/autoflow/04-architecture/api/api-device-mgmt.md
Normal file
131
docs/autoflow/04-architecture/api/api-device-mgmt.md
Normal file
@ -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。
|
||||||
186
docs/autoflow/04-architecture/api/api-model-sharing.md
Normal file
186
docs/autoflow/04-architecture/api/api-model-sharing.md
Normal file
@ -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 |
|
||||||
356
docs/autoflow/04-architecture/feature-device-mgmt-tdd.md
Normal file
356
docs/autoflow/04-architecture/feature-device-mgmt-tdd.md
Normal file
@ -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<string>("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<DeviceSummary,"remoteStatus"|"registeredAt">): 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[契約定稿<br/>api-device-mgmt.md<br/>critical] --> BE[WS-BE: register/unregister<br/>+ SetRegistered repo]
|
||||||
|
C --> FE1[WS-FE: store 補 registeredAt<br/>+ deriveTriState 純函式<br/>對 mock 契約]
|
||||||
|
FE1 --> FE2[WS-FE: 三態配色 + 排序 filter UI]
|
||||||
|
C --> TS[WS-TEST: 測試設計<br/>對契約先寫]
|
||||||
|
BE --> INT[整合 join<br/>真後端接前端]
|
||||||
|
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 線平行是甜蜜點,不過度切分。
|
||||||
287
docs/autoflow/04-architecture/feature-model-sharing-tdd.md
Normal file
287
docs/autoflow/04-architecture/feature-model-sharing-tdd.md
Normal file
@ -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<br/>critical] --> M[migration: visibility 欄+index<br/>backend, critical]
|
||||||
|
C --> FE[前端: 共享庫頁+profile<br/>對 mock schema]
|
||||||
|
M --> Q[權限 query + canAccessModel<br/>backend, critical]
|
||||||
|
Q --> LIB[library/profile/visibility handler<br/>backend]
|
||||||
|
Q --> DL[download 端點權限放寬<br/>backend]
|
||||||
|
C --> TS[測試腳本設計<br/>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 審**。
|
||||||
@ -178,8 +178,10 @@ vendor-ffmpeg-macos-build: ## macOS:從源碼 build LGPL v3 decoder-only ffmpe
|
|||||||
--disable-everything \
|
--disable-everything \
|
||||||
--enable-small \
|
--enable-small \
|
||||||
--enable-protocol=file,pipe \
|
--enable-protocol=file,pipe \
|
||||||
|
--enable-avfoundation \
|
||||||
|
--enable-indev=avfoundation \
|
||||||
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 \
|
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 \
|
||||||
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be \
|
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be,rawvideo \
|
||||||
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac \
|
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac \
|
||||||
--enable-filter=scale,format,fps,null,anull \
|
--enable-filter=scale,format,fps,null,anull \
|
||||||
--enable-muxer=image2pipe,image2,null \
|
--enable-muxer=image2pipe,image2,null \
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"visiona-agent/server/internal/api/ws"
|
"visiona-agent/server/internal/api/ws"
|
||||||
@ -33,8 +35,33 @@ type CameraHandler struct {
|
|||||||
videoFPS float64 // target FPS
|
videoFPS float64 // target FPS
|
||||||
videoInfo camera.VideoInfo // duration, total frames
|
videoInfo camera.VideoInfo // duration, total frames
|
||||||
activeDeviceID string // device ID for current video session
|
activeDeviceID string // device ID for current video session
|
||||||
|
|
||||||
|
// pendingStartCancel 取消「等 WS join room 才開跑 pipeline」的 gated-start goroutine
|
||||||
|
// (video-inference-stuck 修法 A2)。stopActivePipeline 會呼叫它,確保下一次上傳 /
|
||||||
|
// 停止時,還沒開跑的舊 pipeline 不會事後才 Start()(避免 pipeline 洩漏)。
|
||||||
|
pendingStartCancel context.CancelFunc
|
||||||
|
|
||||||
|
// startMu 保護「gated-start 的 check-then-act」與 handler 端 stop 對 pendingStartCancel /
|
||||||
|
// pipeline 的併發存取(Reviewer Major-1)。
|
||||||
|
//
|
||||||
|
// 為什麼需要:gated goroutine「檢查 startCtx.Err() → pipeline.Start()」這兩步跨 goroutine
|
||||||
|
// 非原子;若 handler goroutine 恰在中間呼叫 stopActivePipeline() → cancel(),舊 gated
|
||||||
|
// goroutine 仍可能 Start 一個已被換掉的舊 pipeline。單一 Run() goroutine 只保護 Hub 內部,
|
||||||
|
// 管不到 handler 端這段。用這把鎖把「二次檢查 + Start」原子化、並讓 stop 端的
|
||||||
|
// cancel + 換 pipeline 也在鎖內,兩者互斥。
|
||||||
|
//
|
||||||
|
// 範圍刻意只涵蓋 pendingStartCancel / pipeline 這組跨 gated-goroutine 與 handler 的共享狀態,
|
||||||
|
// 不擴大到 videoPath / activeSource 等其他欄位(維持原有請求序列化假設,避免無關擴大)。
|
||||||
|
startMu sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// waitRoomJoinTimeout 是 A2 gated-start 等待「結果 WS join inference room」的上限。
|
||||||
|
//
|
||||||
|
// 逾時仍會開跑 pipeline(degrade 成舊行為),確保就算 WS 因故一直沒連上,
|
||||||
|
// 影片推論也不會永久卡住(後續有 B 的 replay 緩存兜底早期結果)。
|
||||||
|
// 15s 足夠涵蓋 tunnel WS 握手 + 雲端 forward 的正常延遲。
|
||||||
|
const waitRoomJoinTimeout = 15 * time.Second
|
||||||
|
|
||||||
func NewCameraHandler(
|
func NewCameraHandler(
|
||||||
cameraMgr *camera.Manager,
|
cameraMgr *camera.Manager,
|
||||||
deviceMgr *device.Manager,
|
deviceMgr *device.Manager,
|
||||||
@ -91,6 +118,9 @@ func (h *CameraHandler) StartPipeline(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新 pipeline 前清掉此 room 的 replay 緩存,避免上一次 session 的殘留結果補送給 client。
|
||||||
|
h.wsHub.ClearRoomReplay("inference:" + req.DeviceID)
|
||||||
|
|
||||||
// Create inference result channel
|
// Create inference result channel
|
||||||
resultCh := make(chan *driver.InferenceResult, 10)
|
resultCh := make(chan *driver.InferenceResult, 10)
|
||||||
|
|
||||||
@ -106,6 +136,8 @@ func (h *CameraHandler) StartPipeline(c *gin.Context) {
|
|||||||
// Start pipeline with camera as source
|
// Start pipeline with camera as source
|
||||||
h.activeSource = h.cameraMgr
|
h.activeSource = h.cameraMgr
|
||||||
h.sourceType = camera.SourceCamera
|
h.sourceType = camera.SourceCamera
|
||||||
|
// Major-1:h.pipeline 由 stop 端在 startMu 內存取,這裡設定 + Start 也在鎖內保持一致。
|
||||||
|
h.startMu.Lock()
|
||||||
h.pipeline = camera.NewInferencePipeline(
|
h.pipeline = camera.NewInferencePipeline(
|
||||||
h.cameraMgr,
|
h.cameraMgr,
|
||||||
camera.SourceCamera,
|
camera.SourceCamera,
|
||||||
@ -114,6 +146,7 @@ func (h *CameraHandler) StartPipeline(c *gin.Context) {
|
|||||||
resultCh,
|
resultCh,
|
||||||
)
|
)
|
||||||
h.pipeline.Start()
|
h.pipeline.Start()
|
||||||
|
h.startMu.Unlock()
|
||||||
|
|
||||||
streamURL := "/api/camera/stream"
|
streamURL := "/api/camera/stream"
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
@ -187,6 +220,10 @@ func (h *CameraHandler) UploadImage(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新 pipeline 前清掉此 room 的 replay 緩存。image 只推論一次,replay 讓晚連的 WS
|
||||||
|
// client 仍能補到那唯一一筆結果(順帶修 image 路徑同類的早期丟棄)。
|
||||||
|
h.wsHub.ClearRoomReplay("inference:" + deviceID)
|
||||||
|
|
||||||
resultCh := make(chan *driver.InferenceResult, 10)
|
resultCh := make(chan *driver.InferenceResult, 10)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
@ -199,18 +236,23 @@ func (h *CameraHandler) UploadImage(c *gin.Context) {
|
|||||||
|
|
||||||
h.activeSource = imgSource
|
h.activeSource = imgSource
|
||||||
h.sourceType = camera.SourceImage
|
h.sourceType = camera.SourceImage
|
||||||
h.pipeline = camera.NewInferencePipeline(
|
imgPipeline := camera.NewInferencePipeline(
|
||||||
imgSource,
|
imgSource,
|
||||||
camera.SourceImage,
|
camera.SourceImage,
|
||||||
session.Driver,
|
session.Driver,
|
||||||
h.streamer.FrameChannel(),
|
h.streamer.FrameChannel(),
|
||||||
resultCh,
|
resultCh,
|
||||||
)
|
)
|
||||||
h.pipeline.Start()
|
// Major-1:h.pipeline 由 stop 端在 startMu 內存取,這裡設定 + Start 也在鎖內保持一致。
|
||||||
|
h.startMu.Lock()
|
||||||
|
h.pipeline = imgPipeline
|
||||||
|
imgPipeline.Start()
|
||||||
|
h.startMu.Unlock()
|
||||||
|
|
||||||
// Clean up result channel after pipeline completes
|
// Clean up result channel after pipeline completes
|
||||||
|
// 用 local imgPipeline(非 h.pipeline)避免 goroutine 讀共享欄位。
|
||||||
go func() {
|
go func() {
|
||||||
<-h.pipeline.Done()
|
<-imgPipeline.Done()
|
||||||
close(resultCh)
|
close(resultCh)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
@ -297,30 +339,82 @@ func (h *CameraHandler) UploadVideo(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
h.activeSource = videoSource
|
room := "inference:" + deviceID
|
||||||
h.sourceType = camera.SourceVideo
|
// 新一輪上傳:清掉舊的 replay 緩存,避免上一支影片的早期結果殘留補送給這次的 client。
|
||||||
h.videoPath = tmpFile.Name()
|
h.wsHub.ClearRoomReplay(room)
|
||||||
h.videoFPS = 15
|
|
||||||
h.videoInfo = videoInfo
|
pipeline := camera.NewInferencePipeline(
|
||||||
h.activeDeviceID = deviceID
|
|
||||||
h.pipeline = camera.NewInferencePipeline(
|
|
||||||
videoSource,
|
videoSource,
|
||||||
camera.SourceVideo,
|
camera.SourceVideo,
|
||||||
session.Driver,
|
session.Driver,
|
||||||
h.streamer.FrameChannel(),
|
h.streamer.FrameChannel(),
|
||||||
resultCh,
|
resultCh,
|
||||||
)
|
)
|
||||||
h.pipeline.Start()
|
|
||||||
|
|
||||||
// Notify frontend when video playback completes
|
h.activeSource = videoSource
|
||||||
|
h.sourceType = camera.SourceVideo
|
||||||
|
h.videoPath = tmpFile.Name()
|
||||||
|
h.videoFPS = 15
|
||||||
|
h.videoInfo = videoInfo
|
||||||
|
h.activeDeviceID = deviceID
|
||||||
|
|
||||||
|
// A2(主修):解耦「回 200」與「pipeline 開跑」。
|
||||||
|
//
|
||||||
|
// 存檔完成即可回 200,但不立刻廣播推論結果——先在背景等結果 WS join
|
||||||
|
// inference room,join 後(或逾時 degrade)才 pipeline.Start()。這樣影片
|
||||||
|
// 上傳走 localhost(極快)與結果訂閱走 tunnel WS(較慢)時序解耦後,
|
||||||
|
// 早期結果不會在 Hub 因 room 無 client 被靜默丟棄(root cause §2)。
|
||||||
|
//
|
||||||
|
// gated-start goroutine 用 startCtx 控制:stopActivePipeline 會 cancel 它,
|
||||||
|
// 確保下一次上傳 / 停止時,這個還沒開跑的 pipeline 不會事後才 Start()。
|
||||||
|
//
|
||||||
|
// Major-1:在 startMu 鎖內原子設定 pipeline + pendingStartCancel,讓後續可能併發的
|
||||||
|
// stop 看到一致的一對(pipeline 與其 cancel),不會讀到半設定狀態。
|
||||||
|
startCtx, cancelStart := context.WithCancel(context.Background())
|
||||||
|
h.startMu.Lock()
|
||||||
|
h.pipeline = pipeline
|
||||||
|
h.pendingStartCancel = cancelStart
|
||||||
|
h.startMu.Unlock()
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
<-h.pipeline.Done()
|
waitCtx, waitCancel := context.WithTimeout(startCtx, waitRoomJoinTimeout)
|
||||||
|
defer waitCancel()
|
||||||
|
// 等到 room 有 client(true)或逾時(false, degrade 開跑)。
|
||||||
|
// startCtx 被 cancel(stopActivePipeline)→ WaitForRoomClient 回 false 且
|
||||||
|
// startCtx.Err()!=nil,此時不可開跑(pipeline 已被換掉 / 停止)。
|
||||||
|
_ = h.wsHub.WaitForRoomClient(waitCtx, room)
|
||||||
|
|
||||||
|
// Major-1:把「二次檢查 startCtx.Err() → Start()」原子化。
|
||||||
|
// 取 startMu 後再檢查一次:若 stop 端已在等待與此刻之間 cancel 並換掉 pipeline,
|
||||||
|
// startCtx.Err()!=nil,放棄開跑;否則在鎖內 Start,並清掉 pendingStartCancel
|
||||||
|
// (已成功開跑,之後的 stop 改由 pipeline.Stop() 負責,不再靠 cancel)。
|
||||||
|
h.startMu.Lock()
|
||||||
|
if startCtx.Err() != nil {
|
||||||
|
h.startMu.Unlock()
|
||||||
|
// 已被 stopActivePipeline 取消,放棄開跑。
|
||||||
|
// 必須關 resultCh,否則上面的 forwarder goroutine(range resultCh)永久阻塞洩漏。
|
||||||
|
// pipeline 從未 Start(),不會有人寫 resultCh,close 安全。
|
||||||
close(resultCh)
|
close(resultCh)
|
||||||
h.wsHub.BroadcastToRoom("inference:"+deviceID, map[string]interface{}{
|
return
|
||||||
|
}
|
||||||
|
pipeline.Start()
|
||||||
|
// 這個 gated goroutine 的任務已完成:清掉自己登記的 cancel。
|
||||||
|
// startCtx.Err()==nil 保證期間沒有 stop 介入過(stop 會 cancel),故 pendingStartCancel
|
||||||
|
// 必仍是自己登記的 cancelStart,直接清成 nil——之後的 stop 改由 pipeline.Stop() 負責。
|
||||||
|
h.pendingStartCancel = nil
|
||||||
|
h.startMu.Unlock()
|
||||||
|
|
||||||
|
// pipeline 跑完 → 關 resultCh、通知前端。放在開跑之後才註冊,
|
||||||
|
// 避免「還沒 Start 就等 Done()」永久阻塞(NewInferencePipeline 的 doneCh 尚未 close)。
|
||||||
|
go func() {
|
||||||
|
<-pipeline.Done()
|
||||||
|
close(resultCh)
|
||||||
|
h.wsHub.BroadcastToRoom(room, map[string]interface{}{
|
||||||
"type": "pipeline_complete",
|
"type": "pipeline_complete",
|
||||||
"sourceType": "video",
|
"sourceType": "video",
|
||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
|
}()
|
||||||
|
|
||||||
streamURL := "/api/camera/stream"
|
streamURL := "/api/camera/stream"
|
||||||
c.JSON(200, gin.H{
|
c.JSON(200, gin.H{
|
||||||
@ -421,6 +515,9 @@ func (h *CameraHandler) UploadBatchImages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 新 pipeline 前清掉此 room 的 replay 緩存(避免上一批殘留補送給 client)。
|
||||||
|
h.wsHub.ClearRoomReplay("inference:" + deviceID)
|
||||||
|
|
||||||
batchID := fmt.Sprintf("batch-%d", time.Now().UnixNano())
|
batchID := fmt.Sprintf("batch-%d", time.Now().UnixNano())
|
||||||
resultCh := make(chan *driver.InferenceResult, 10)
|
resultCh := make(chan *driver.InferenceResult, 10)
|
||||||
|
|
||||||
@ -434,18 +531,23 @@ func (h *CameraHandler) UploadBatchImages(c *gin.Context) {
|
|||||||
|
|
||||||
h.activeSource = batchSource
|
h.activeSource = batchSource
|
||||||
h.sourceType = camera.SourceBatchImage
|
h.sourceType = camera.SourceBatchImage
|
||||||
h.pipeline = camera.NewInferencePipeline(
|
batchPipeline := camera.NewInferencePipeline(
|
||||||
batchSource,
|
batchSource,
|
||||||
camera.SourceBatchImage,
|
camera.SourceBatchImage,
|
||||||
session.Driver,
|
session.Driver,
|
||||||
h.streamer.FrameChannel(),
|
h.streamer.FrameChannel(),
|
||||||
resultCh,
|
resultCh,
|
||||||
)
|
)
|
||||||
h.pipeline.Start()
|
// Major-1:h.pipeline 由 stop 端在 startMu 內存取,這裡設定 + Start 也在鎖內保持一致。
|
||||||
|
h.startMu.Lock()
|
||||||
|
h.pipeline = batchPipeline
|
||||||
|
batchPipeline.Start()
|
||||||
|
h.startMu.Unlock()
|
||||||
|
|
||||||
// Notify frontend when batch completes
|
// Notify frontend when batch completes
|
||||||
|
// 用 local batchPipeline(非 h.pipeline)避免 goroutine 讀共享欄位。
|
||||||
go func() {
|
go func() {
|
||||||
<-h.pipeline.Done()
|
<-batchPipeline.Done()
|
||||||
close(resultCh)
|
close(resultCh)
|
||||||
h.wsHub.BroadcastToRoom("inference:"+deviceID, map[string]interface{}{
|
h.wsHub.BroadcastToRoom("inference:"+deviceID, map[string]interface{}{
|
||||||
"type": "pipeline_complete",
|
"type": "pipeline_complete",
|
||||||
@ -503,12 +605,35 @@ func (h *CameraHandler) GetBatchImageFrame(c *gin.Context) {
|
|||||||
c.Data(200, "image/jpeg", jpegData)
|
c.Data(200, "image/jpeg", jpegData)
|
||||||
}
|
}
|
||||||
|
|
||||||
// stopPipelineForSeek stops the pipeline and ffmpeg process but keeps the video file.
|
// cancelPendingStartAndStopPipeline 在 startMu 鎖內原子地:
|
||||||
func (h *CameraHandler) stopPipelineForSeek() {
|
// 1. 取消尚未開跑的 gated-start goroutine(pendingStartCancel)
|
||||||
|
// 2. Stop 並清掉 h.pipeline
|
||||||
|
//
|
||||||
|
// 這把鎖與 gated goroutine 的「二次檢查 + Start」共用,兩者互斥(Reviewer Major-1):
|
||||||
|
// - 若此函式先取鎖:cancel startCtx + 清 pipeline → gated goroutine 之後取鎖時
|
||||||
|
// startCtx.Err()!=nil,放棄開跑。
|
||||||
|
// - 若 gated goroutine 先取鎖:Start 已完成、pendingStartCancel 已清 nil → 此函式的
|
||||||
|
// pipeline.Stop() 負責停掉已開跑的 pipeline。
|
||||||
|
//
|
||||||
|
// 兩種情況都不會發生「stop 後 gated goroutine 又 Start 舊 pipeline」的洩漏。
|
||||||
|
func (h *CameraHandler) cancelPendingStartAndStopPipeline() {
|
||||||
|
h.startMu.Lock()
|
||||||
|
defer h.startMu.Unlock()
|
||||||
|
if h.pendingStartCancel != nil {
|
||||||
|
h.pendingStartCancel()
|
||||||
|
h.pendingStartCancel = nil
|
||||||
|
}
|
||||||
if h.pipeline != nil {
|
if h.pipeline != nil {
|
||||||
h.pipeline.Stop()
|
h.pipeline.Stop()
|
||||||
h.pipeline = nil
|
h.pipeline = nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stopPipelineForSeek stops the pipeline and ffmpeg process but keeps the video file.
|
||||||
|
func (h *CameraHandler) stopPipelineForSeek() {
|
||||||
|
// A2:seek 前也要原子地取消尚未開跑的 gated-start goroutine + 停 pipeline
|
||||||
|
// (極端情況:上傳後 WS 還沒 join 就 seek)。cancel 後該 goroutine 自行 close 原 resultCh。
|
||||||
|
h.cancelPendingStartAndStopPipeline()
|
||||||
if h.activeSource != nil {
|
if h.activeSource != nil {
|
||||||
if vs, ok := h.activeSource.(*camera.VideoSource); ok {
|
if vs, ok := h.activeSource.(*camera.VideoSource); ok {
|
||||||
vs.CloseWithoutRemove()
|
vs.CloseWithoutRemove()
|
||||||
@ -519,9 +644,13 @@ func (h *CameraHandler) stopPipelineForSeek() {
|
|||||||
|
|
||||||
// stopActivePipeline stops the current pipeline and cleans up resources.
|
// stopActivePipeline stops the current pipeline and cleans up resources.
|
||||||
func (h *CameraHandler) stopActivePipeline() {
|
func (h *CameraHandler) stopActivePipeline() {
|
||||||
if h.pipeline != nil {
|
// A2 + Major-1:先原子地取消「等 WS join 才開跑」的 gated-start goroutine + 停 pipeline,
|
||||||
h.pipeline.Stop()
|
// 確保尚未開跑的舊 pipeline 不會在此之後才 Start()。cancel 後該 goroutine 會自行
|
||||||
h.pipeline = nil
|
// close resultCh,不需在此處理。
|
||||||
|
h.cancelPendingStartAndStopPipeline()
|
||||||
|
// 清掉 inference room 的 replay 緩存(若有 active 影片 session)。
|
||||||
|
if h.activeDeviceID != "" {
|
||||||
|
h.wsHub.ClearRoomReplay("inference:" + h.activeDeviceID)
|
||||||
}
|
}
|
||||||
// Only close non-camera sources (camera is managed by cameraMgr)
|
// Only close non-camera sources (camera is managed by cameraMgr)
|
||||||
if h.activeSource != nil && h.sourceType != camera.SourceCamera {
|
if h.activeSource != nil && h.sourceType != camera.SourceCamera {
|
||||||
@ -570,6 +699,9 @@ func (h *CameraHandler) SeekVideo(c *gin.Context) {
|
|||||||
|
|
||||||
// Stop current pipeline without deleting the video file
|
// Stop current pipeline without deleting the video file
|
||||||
h.stopPipelineForSeek()
|
h.stopPipelineForSeek()
|
||||||
|
// 清掉 seek 前的 replay 緩存,避免舊位置的結果被補送給 seek 後才 late-join 的 client。
|
||||||
|
// seek 不需 gated-start:WS client 早已 join(能觸發 seek 代表已在收結果),直接開跑。
|
||||||
|
h.wsHub.ClearRoomReplay("inference:" + h.activeDeviceID)
|
||||||
|
|
||||||
// Create new VideoSource with seek position
|
// Create new VideoSource with seek position
|
||||||
videoSource, err := camera.NewVideoSourceWithSeek(h.videoPath, h.videoFPS, req.TimeSeconds)
|
videoSource, err := camera.NewVideoSourceWithSeek(h.videoPath, h.videoFPS, req.TimeSeconds)
|
||||||
@ -602,7 +734,7 @@ func (h *CameraHandler) SeekVideo(c *gin.Context) {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
h.activeSource = videoSource
|
h.activeSource = videoSource
|
||||||
h.pipeline = camera.NewInferencePipelineWithOffset(
|
seekPipeline := camera.NewInferencePipelineWithOffset(
|
||||||
videoSource,
|
videoSource,
|
||||||
camera.SourceVideo,
|
camera.SourceVideo,
|
||||||
session.Driver,
|
session.Driver,
|
||||||
@ -610,10 +742,16 @@ func (h *CameraHandler) SeekVideo(c *gin.Context) {
|
|||||||
resultCh,
|
resultCh,
|
||||||
frameOffset,
|
frameOffset,
|
||||||
)
|
)
|
||||||
h.pipeline.Start()
|
// Major-1:h.pipeline 由 stop 端在 startMu 內存取,這裡設定 + Start 也在鎖內保持一致。
|
||||||
|
// seek 不走 gated-start(WS 早已 join),故不設 pendingStartCancel。
|
||||||
|
h.startMu.Lock()
|
||||||
|
h.pipeline = seekPipeline
|
||||||
|
seekPipeline.Start()
|
||||||
|
h.startMu.Unlock()
|
||||||
|
|
||||||
|
// 用 local seekPipeline(非 h.pipeline)避免 goroutine 讀共享欄位。
|
||||||
go func() {
|
go func() {
|
||||||
<-h.pipeline.Done()
|
<-seekPipeline.Done()
|
||||||
close(resultCh)
|
close(resultCh)
|
||||||
h.wsHub.BroadcastToRoom("inference:"+h.activeDeviceID, map[string]interface{}{
|
h.wsHub.BroadcastToRoom("inference:"+h.activeDeviceID, map[string]interface{}{
|
||||||
"type": "pipeline_complete",
|
"type": "pipeline_complete",
|
||||||
|
|||||||
@ -0,0 +1,168 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
// camera_handler_gatedstart_test.go — video-inference-stuck 修法 A2 的協調邏輯測試
|
||||||
|
//
|
||||||
|
// UploadVideo 完整路徑需要真實 ffmpeg + 影片檔(VideoSource 會 spawn ffmpeg),不適合
|
||||||
|
// 快速確定性單元測試。這裡聚焦驗證 A2 的核心「gated-start 協調契約」:
|
||||||
|
//
|
||||||
|
// 1. gated-start goroutine 等 inference room 有 client join 後才觸發「開跑」
|
||||||
|
// 2. 在 client join 前呼叫 stopActivePipeline(cancel pendingStartCancel)→ 不開跑
|
||||||
|
//
|
||||||
|
// 用真實 ws.Hub + 與 UploadVideo 相同的 gated-start pattern(context + WaitForRoomClient)
|
||||||
|
// 驗證行為,不牽涉 camera / ffmpeg / driver。
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"visiona-agent/server/internal/api/ws"
|
||||||
|
)
|
||||||
|
|
||||||
|
// runGatedStart 複製 UploadVideo 裡 gated-start goroutine 的協調骨架(不含真實 pipeline)。
|
||||||
|
// started 在「決定開跑」時設為 1;aborted 在「因 cancel 放棄開跑」時設為 1。
|
||||||
|
func runGatedStart(hub *ws.Hub, room string, startCtx context.Context, started, aborted *int32) {
|
||||||
|
go func() {
|
||||||
|
waitCtx, cancel := context.WithTimeout(startCtx, waitRoomJoinTimeout)
|
||||||
|
defer cancel()
|
||||||
|
_ = hub.WaitForRoomClient(waitCtx, room)
|
||||||
|
if startCtx.Err() != nil {
|
||||||
|
atomic.StoreInt32(aborted, 1)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
atomic.StoreInt32(started, 1)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatedStart_StartsAfterClientJoins(t *testing.T) {
|
||||||
|
hub := ws.NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
room := "inference:DEVA"
|
||||||
|
var started, aborted int32
|
||||||
|
startCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
runGatedStart(hub, room, startCtx, &started, &aborted)
|
||||||
|
|
||||||
|
// 尚無 client → 不該開跑
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
if atomic.LoadInt32(&started) != 0 {
|
||||||
|
t.Fatal("client join 前就開跑了(A2 gate 失效)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// client join → 應開跑
|
||||||
|
c := &ws.Client{Send: make(chan []byte, 4)}
|
||||||
|
hub.RegisterSync(&ws.Subscription{Client: c, Room: room})
|
||||||
|
|
||||||
|
deadline := time.After(time.Second)
|
||||||
|
for atomic.LoadInt32(&started) == 0 {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("client join 後 gated-start 未開跑")
|
||||||
|
case <-time.After(10 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if atomic.LoadInt32(&aborted) != 0 {
|
||||||
|
t.Fatal("正常 join 不該被標記為 aborted")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatedStart_AbortsWhenCancelledBeforeJoin(t *testing.T) {
|
||||||
|
hub := ws.NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
room := "inference:DEVB"
|
||||||
|
var started, aborted int32
|
||||||
|
startCtx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
runGatedStart(hub, room, startCtx, &started, &aborted)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// 模擬 stopActivePipeline:client join 前 cancel pendingStartCancel
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
deadline := time.After(time.Second)
|
||||||
|
for atomic.LoadInt32(&aborted) == 0 {
|
||||||
|
select {
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatal("cancel 後 gated-start 未放棄開跑(會洩漏 pipeline)")
|
||||||
|
case <-time.After(10 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if atomic.LoadInt32(&started) != 0 {
|
||||||
|
t.Fatal("被 cancel 後不該開跑")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGatedStart_CheckThenAct_Atomic_RealHandler — Reviewer Major-1 修復驗證。
|
||||||
|
//
|
||||||
|
// 用「真實 CameraHandler.startMu + pendingStartCancel + cancelPendingStartAndStopPipeline」
|
||||||
|
// 復現原 race:gated goroutine 的「檢查 startCtx.Err() → 開跑」與 handler 端 stop
|
||||||
|
// (鎖內 cancel)併發。
|
||||||
|
//
|
||||||
|
// 核心不變式(原子性保證):gated 在鎖內若決定「開跑」(started=1),則它檢查當下
|
||||||
|
// startCtx.Err() 必為 nil;而 stop 的 cancel 也在同一把鎖內。兩者互斥後,
|
||||||
|
// 「gated 觀察到 Err()==nil 卻仍被 cancel 搶先」這種狀態不可能出現。
|
||||||
|
// - 修好前(check 與 act 之間放掉鎖 / 無鎖):race detector 會抓到 pendingStartCancel /
|
||||||
|
// startCtx 的無同步併發存取;且可能出現 started=1 但 pendingStartCancel 未被正確清理。
|
||||||
|
// - 修好後:兩段都在 startMu 內,started 與 aborted 互斥、pendingStartCancel 狀態一致。
|
||||||
|
//
|
||||||
|
// 跑很多輪 + go test -race,任何原子性破綻都會被 race detector 抓到。
|
||||||
|
func TestGatedStart_CheckThenAct_Atomic_RealHandler(t *testing.T) {
|
||||||
|
const iterations = 3000
|
||||||
|
|
||||||
|
for i := 0; i < iterations; i++ {
|
||||||
|
h := &CameraHandler{} // 只用到 startMu / pendingStartCancel / pipeline,其餘不需初始化
|
||||||
|
|
||||||
|
startCtx, cancelStart := context.WithCancel(context.Background())
|
||||||
|
h.startMu.Lock()
|
||||||
|
h.pendingStartCancel = cancelStart
|
||||||
|
h.startMu.Unlock()
|
||||||
|
|
||||||
|
var started, aborted int32
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(2)
|
||||||
|
|
||||||
|
// gated goroutine:忠實複製生產端 UploadVideo 的鎖內原子區塊
|
||||||
|
//(取 startMu → 二次檢查 startCtx.Err() → 開跑 → 清 pendingStartCancel)。
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
h.startMu.Lock()
|
||||||
|
defer h.startMu.Unlock()
|
||||||
|
if startCtx.Err() != nil {
|
||||||
|
atomic.StoreInt32(&aborted, 1)
|
||||||
|
return // 已被 stop 取消,放棄開跑(正確)
|
||||||
|
}
|
||||||
|
atomic.StoreInt32(&started, 1)
|
||||||
|
// 開跑成功:清掉自己登記的 cancel(生產端相同語意)。
|
||||||
|
h.pendingStartCancel = nil
|
||||||
|
}()
|
||||||
|
|
||||||
|
// stop goroutine:走真實的 cancelPendingStartAndStopPipeline(鎖內 cancel + 清 pipeline)。
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
h.cancelPendingStartAndStopPipeline()
|
||||||
|
}()
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// 不變式 1:started 與 aborted 互斥(不可能同時、也不可能都沒發生)。
|
||||||
|
s, a := atomic.LoadInt32(&started), atomic.LoadInt32(&aborted)
|
||||||
|
if s == a { // 兩者相等 → 同為 0(都沒跑)或同為 1(同時發生),都代表原子性被破壞
|
||||||
|
t.Fatalf("iter %d:started(%d)/aborted(%d) 非互斥,check-then-act 原子性被破壞", i, s, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不變式 2:無論哪條路徑,最終 pendingStartCancel 都應是 nil
|
||||||
|
//(started → gated 清 nil;aborted → stop 清 nil)。殘留非 nil 代表狀態不一致。
|
||||||
|
h.startMu.Lock()
|
||||||
|
leftover := h.pendingStartCancel != nil
|
||||||
|
h.startMu.Unlock()
|
||||||
|
if leftover {
|
||||||
|
t.Fatalf("iter %d:pendingStartCancel 未被清乾淨(狀態不一致)", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,16 +1,34 @@
|
|||||||
package ws
|
package ws
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// replayPrefix 決定哪些 room 啟用「late-join replay 緩存」。
|
||||||
|
//
|
||||||
|
// 只對推論結果 room("inference:<serial>")緩存最近 N 筆結果,理由:
|
||||||
|
// - 推論結果面走雲端 tunnel(ADR-019 混合路徑)、比上傳的 localhost 慢,
|
||||||
|
// 上傳完成到 WS join room 之間有時間窗口(見 video-inference-stuck-rootcause.md §2)。
|
||||||
|
// - flash / system / device-events / server-logs 等 room 沒有這種「早期訊息會被丟」的問題,
|
||||||
|
// 不緩存以免無謂佔記憶體。
|
||||||
|
const replayPrefix = "inference:"
|
||||||
|
|
||||||
|
// replayBufferSize 是每個 inference room 緩存的最近結果筆數上限。
|
||||||
|
//
|
||||||
|
// 30 筆 ≈ 15fps 影片的 2 秒;足夠覆蓋「上傳完成 → tunnel WS join room」的窗口,
|
||||||
|
// 又不會讓記憶體膨脹(單筆 InferenceResult JSON 通常 < 幾 KB)。緩存在
|
||||||
|
// ClearRoomReplay(pipeline 停止 / 切換時)被清掉,跟 stopActivePipeline 生命週期對齊。
|
||||||
|
const replayBufferSize = 30
|
||||||
|
|
||||||
type Client struct {
|
type Client struct {
|
||||||
Conn *websocket.Conn
|
Conn *websocket.Conn
|
||||||
Send chan []byte
|
Send chan []byte
|
||||||
@ -27,6 +45,16 @@ type RoomMessage struct {
|
|||||||
Message []byte
|
Message []byte
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// roomWaiter 由 WaitForRoomClient 註冊、在 Run() loop 內處理,
|
||||||
|
// 確保「檢查 room 是否有 client」與「register 事件」在同一 goroutine 序列化、無 race。
|
||||||
|
//
|
||||||
|
// 若註冊當下 room 已有 client → 立即 close(ch);否則存進 h.waiters,
|
||||||
|
// 待該 room 有 client register 時 close(ch) 喚醒。
|
||||||
|
type roomWaiter struct {
|
||||||
|
room string
|
||||||
|
ch chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
// Hub 管理 WebSocket client 訂閱與訊息廣播。
|
// Hub 管理 WebSocket client 訂閱與訊息廣播。
|
||||||
//
|
//
|
||||||
// M8-4b:Hub 額外負責「第一個 client 連上時寫 sentinel file」,
|
// M8-4b:Hub 額外負責「第一個 client 連上時寫 sentinel file」,
|
||||||
@ -40,12 +68,20 @@ type Hub struct {
|
|||||||
register chan *Subscription
|
register chan *Subscription
|
||||||
unregister chan *Subscription
|
unregister chan *Subscription
|
||||||
broadcast chan *RoomMessage
|
broadcast chan *RoomMessage
|
||||||
|
waitReq chan *roomWaiter // WaitForRoomClient 的等待請求(在 Run() 內序列化處理)
|
||||||
|
clearReplay chan string // ClearRoomReplay 的清除請求
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
|
|
||||||
// M8-4b: 啟動 sentinel file
|
// M8-4b: 啟動 sentinel file
|
||||||
sentinelDataDir string // <dataDir>,由 SetStartupSentinel 設定
|
sentinelDataDir string // <dataDir>,由 SetStartupSentinel 設定
|
||||||
sentinelOnce sync.Once // 確保只在「第一個」client 連上時寫一次
|
sentinelOnce sync.Once // 確保只在「第一個」client 連上時寫一次
|
||||||
bootID string // 寫入 sentinel 內容供 debug
|
bootID string // 寫入 sentinel 內容供 debug
|
||||||
|
|
||||||
|
// video-inference-stuck 修法 A2/B(均在 Run() goroutine 內存取、無需額外鎖):
|
||||||
|
// waiters — WaitForRoomClient 尚未被喚醒的等待者,key=room
|
||||||
|
// replay — 每個 inference room 的最近 replayBufferSize 筆訊息 ring(late-join replay)
|
||||||
|
waiters map[string][]*roomWaiter
|
||||||
|
replay map[string][][]byte
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHub() *Hub {
|
func NewHub() *Hub {
|
||||||
@ -54,7 +90,11 @@ func NewHub() *Hub {
|
|||||||
register: make(chan *Subscription, 10),
|
register: make(chan *Subscription, 10),
|
||||||
unregister: make(chan *Subscription, 10),
|
unregister: make(chan *Subscription, 10),
|
||||||
broadcast: make(chan *RoomMessage, 100),
|
broadcast: make(chan *RoomMessage, 100),
|
||||||
|
waitReq: make(chan *roomWaiter, 10),
|
||||||
|
clearReplay: make(chan string, 10),
|
||||||
bootID: fmt.Sprintf("boot-%d", time.Now().UnixNano()),
|
bootID: fmt.Sprintf("boot-%d", time.Now().UnixNano()),
|
||||||
|
waiters: make(map[string][]*roomWaiter),
|
||||||
|
replay: make(map[string][][]byte),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,7 +146,30 @@ func (h *Hub) Run() {
|
|||||||
h.rooms[sub.Room] = make(map[*Client]bool)
|
h.rooms[sub.Room] = make(map[*Client]bool)
|
||||||
}
|
}
|
||||||
h.rooms[sub.Room][sub.Client] = true
|
h.rooms[sub.Room][sub.Client] = true
|
||||||
|
// B(late-join replay):inference room 若有緩存的早期結果,
|
||||||
|
// 在此把它們補送給剛 join 的 client,解「WS 稍慢也不丟第一筆」。
|
||||||
|
// 在鎖內取出 replay 快照、鎖外送出,避免 client.Send 阻塞時卡住 Run() 。
|
||||||
|
var pending [][]byte
|
||||||
|
if buffered, ok := h.replay[sub.Room]; ok && len(buffered) > 0 {
|
||||||
|
pending = make([][]byte, len(buffered))
|
||||||
|
copy(pending, buffered)
|
||||||
|
}
|
||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
for _, msg := range pending {
|
||||||
|
select {
|
||||||
|
case sub.Client.Send <- msg:
|
||||||
|
default:
|
||||||
|
// client buffer 已滿(極罕見:replay 30 筆 > send buffer 20)→ 停止補送,
|
||||||
|
// 後續 live 廣播仍會照常送達,不因 replay 溢出而 drop client。
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// A2:喚醒等待「此 room 有 client」的 waiter(WaitForRoomClient)。
|
||||||
|
if ws := h.waiters[sub.Room]; len(ws) > 0 {
|
||||||
|
for _, w := range ws {
|
||||||
|
close(w.ch)
|
||||||
|
}
|
||||||
|
delete(h.waiters, sub.Room)
|
||||||
|
}
|
||||||
// M8-4b:第一次有 client 加入任何 room → 寫 sentinel file
|
// M8-4b:第一次有 client 加入任何 room → 寫 sentinel file
|
||||||
// (sync.Once 保證後續呼叫 no-op)
|
// (sync.Once 保證後續呼叫 no-op)
|
||||||
h.writeStartupSentinel()
|
h.writeStartupSentinel()
|
||||||
@ -114,6 +177,26 @@ func (h *Hub) Run() {
|
|||||||
close(sub.done)
|
close(sub.done)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
case w := <-h.waitReq:
|
||||||
|
// A2:WaitForRoomClient 的請求。若 room 當下已有 client → 立即喚醒;
|
||||||
|
// 否則存進 waiters,待 register 時喚醒。與 register 在同一 goroutine
|
||||||
|
// 序列化處理,故「檢查 + 掛等待」對 register 事件無 race。
|
||||||
|
h.mu.RLock()
|
||||||
|
hasClient := len(h.rooms[w.room]) > 0
|
||||||
|
h.mu.RUnlock()
|
||||||
|
if hasClient {
|
||||||
|
close(w.ch)
|
||||||
|
} else {
|
||||||
|
h.waiters[w.room] = append(h.waiters[w.room], w)
|
||||||
|
}
|
||||||
|
|
||||||
|
case room := <-h.clearReplay:
|
||||||
|
// pipeline 停止 / 切換時清掉該 room 的 replay 緩存(跟 stopActivePipeline 對齊),
|
||||||
|
// 防止上一支影片的結果殘留給下一次 join 的 client。
|
||||||
|
h.mu.Lock()
|
||||||
|
delete(h.replay, room)
|
||||||
|
h.mu.Unlock()
|
||||||
|
|
||||||
case sub := <-h.unregister:
|
case sub := <-h.unregister:
|
||||||
h.mu.Lock()
|
h.mu.Lock()
|
||||||
if clients, ok := h.rooms[sub.Room]; ok {
|
if clients, ok := h.rooms[sub.Room]; ok {
|
||||||
@ -125,7 +208,15 @@ func (h *Hub) Run() {
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
case msg := <-h.broadcast:
|
case msg := <-h.broadcast:
|
||||||
h.mu.RLock()
|
h.mu.Lock()
|
||||||
|
// B:inference room 的訊息先進 replay ring(不論當下有無 client)。
|
||||||
|
if strings.HasPrefix(msg.Room, replayPrefix) {
|
||||||
|
buf := append(h.replay[msg.Room], msg.Message)
|
||||||
|
if len(buf) > replayBufferSize {
|
||||||
|
buf = buf[len(buf)-replayBufferSize:]
|
||||||
|
}
|
||||||
|
h.replay[msg.Room] = buf
|
||||||
|
}
|
||||||
if clients, ok := h.rooms[msg.Room]; ok {
|
if clients, ok := h.rooms[msg.Room]; ok {
|
||||||
for client := range clients {
|
for client := range clients {
|
||||||
select {
|
select {
|
||||||
@ -136,7 +227,7 @@ func (h *Hub) Run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.mu.RUnlock()
|
h.mu.Unlock()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -164,3 +255,45 @@ func (h *Hub) BroadcastToRoom(room string, data interface{}) {
|
|||||||
}
|
}
|
||||||
h.broadcast <- &RoomMessage{Room: room, Message: jsonData}
|
h.broadcast <- &RoomMessage{Room: room, Message: jsonData}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HasClients 回報指定 room 當下是否有至少一個 client。
|
||||||
|
// 用於快速判斷、不阻塞(WaitForRoomClient 才是「等到有 client」的阻塞版)。
|
||||||
|
func (h *Hub) HasClients(room string) bool {
|
||||||
|
h.mu.RLock()
|
||||||
|
defer h.mu.RUnlock()
|
||||||
|
return len(h.rooms[room]) > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// WaitForRoomClient 阻塞直到 room 有至少一個 client join、或 ctx 被取消。
|
||||||
|
//
|
||||||
|
// 回傳 true 代表「room 已有 client」;false 代表 ctx 先結束(timeout / 上游取消)。
|
||||||
|
//
|
||||||
|
// A2 主修用途:UploadVideo 存檔後即回 200,但 pipeline 廣播延到「結果 WS 已 join
|
||||||
|
// inference room」才開跑——避免上傳(localhost,快)與結果訂閱(tunnel WS,慢)
|
||||||
|
// 時序解耦後,早期結果在 Hub 因 room 無 client 被靜默丟棄(root cause §2)。
|
||||||
|
//
|
||||||
|
// 實作:把等待請求丟進 Run() goroutine 序列化處理(waitReq case),
|
||||||
|
// 確保「檢查 room 是否有 client」與「register 事件」無 race。
|
||||||
|
// ctx 先結束時仍會殘留一個 waiter 在 h.waiters,但 register 喚醒它只是 close 一個
|
||||||
|
// 沒人收的 channel(無害);replay/room 生命週期短,不會累積。
|
||||||
|
func (h *Hub) WaitForRoomClient(ctx context.Context, room string) bool {
|
||||||
|
w := &roomWaiter{room: room, ch: make(chan struct{})}
|
||||||
|
select {
|
||||||
|
case h.waitReq <- w:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-w.ch:
|
||||||
|
return true
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClearRoomReplay 清除指定 room 的 late-join replay 緩存。
|
||||||
|
// 由 CameraHandler 在切換 / 停止 pipeline 時呼叫,跟 stopActivePipeline 生命週期對齊,
|
||||||
|
// 防止上一支影片的結果殘留給下一次 join 的 client。非 inference room 呼叫也安全(no-op)。
|
||||||
|
func (h *Hub) ClearRoomReplay(room string) {
|
||||||
|
h.clearReplay <- room
|
||||||
|
}
|
||||||
|
|||||||
202
local-agent/server/internal/api/ws/hub_video_race_test.go
Normal file
202
local-agent/server/internal/api/ws/hub_video_race_test.go
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
// hub_video_race_test.go — video-inference-stuck 修法 A2/B 的 Hub 行為測試
|
||||||
|
//
|
||||||
|
// 涵蓋:
|
||||||
|
// A2 WaitForRoomClient:room 有 client 時立即返回、無 client 時阻塞到 register、ctx 取消返回 false
|
||||||
|
// B late-join replay:inference room 緩存最近 N 筆、join 時補送、ClearRoomReplay 清除
|
||||||
|
// 隔離 非 inference room(如 flash:)不緩存 replay
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// drainN 從 client.Send 收 n 筆訊息,逾時 fail。
|
||||||
|
func drainN(t *testing.T, c *Client, n int, timeout time.Duration) [][]byte {
|
||||||
|
t.Helper()
|
||||||
|
out := make([][]byte, 0, n)
|
||||||
|
deadline := time.After(timeout)
|
||||||
|
for len(out) < n {
|
||||||
|
select {
|
||||||
|
case msg := <-c.Send:
|
||||||
|
out = append(out, msg)
|
||||||
|
case <-deadline:
|
||||||
|
t.Fatalf("只收到 %d/%d 筆訊息就逾時", len(out), n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_WaitForRoomClient_ReturnsWhenClientAlreadyPresent(t *testing.T) {
|
||||||
|
hub := NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
makeRegisteredClient(hub, "inference:DEV1", 4)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if !hub.WaitForRoomClient(ctx, "inference:DEV1") {
|
||||||
|
t.Fatal("room 已有 client,WaitForRoomClient 應立即回 true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_WaitForRoomClient_BlocksUntilRegister(t *testing.T) {
|
||||||
|
hub := NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
got := make(chan bool, 1)
|
||||||
|
go func() {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
got <- hub.WaitForRoomClient(ctx, "inference:DEV2")
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 確保 waiter 已掛上(尚未有 client)
|
||||||
|
select {
|
||||||
|
case <-got:
|
||||||
|
t.Fatal("尚無 client 時 WaitForRoomClient 不該返回")
|
||||||
|
case <-time.After(100 * time.Millisecond):
|
||||||
|
}
|
||||||
|
|
||||||
|
// 現在 register 一個 client → 應喚醒 waiter
|
||||||
|
makeRegisteredClient(hub, "inference:DEV2", 4)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ok := <-got:
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("client join 後 WaitForRoomClient 應回 true")
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("client join 後 WaitForRoomClient 未在時限內返回")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_WaitForRoomClient_ReturnsFalseOnCtxCancel(t *testing.T) {
|
||||||
|
hub := NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
|
||||||
|
defer cancel()
|
||||||
|
if hub.WaitForRoomClient(ctx, "inference:NEVER") {
|
||||||
|
t.Fatal("無 client 且 ctx 逾時,應回 false")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_LateJoinReplay_DeliversBufferedResults(t *testing.T) {
|
||||||
|
hub := NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
room := "inference:DEV3"
|
||||||
|
// 在無 client 時廣播 3 筆(模擬「WS 連上前的早期結果」)
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
hub.BroadcastToRoom(room, map[string]int{"frame": i})
|
||||||
|
}
|
||||||
|
// 讓 broadcast 都被 Run() 處理完(進 replay ring)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// 現在 client late-join → 應補送到那 3 筆
|
||||||
|
c := makeRegisteredClient(hub, room, 16)
|
||||||
|
msgs := drainN(t, c, 3, time.Second)
|
||||||
|
|
||||||
|
for i, m := range msgs {
|
||||||
|
var got map[string]int
|
||||||
|
if err := json.Unmarshal(m, &got); err != nil {
|
||||||
|
t.Fatalf("replay 第 %d 筆 bad json: %v", i, err)
|
||||||
|
}
|
||||||
|
if got["frame"] != i {
|
||||||
|
t.Errorf("replay 順序錯:第 %d 筆 frame=%d,預期 %d", i, got["frame"], i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_LateJoinReplay_CapsAtBufferSize(t *testing.T) {
|
||||||
|
hub := NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
room := "inference:DEV4"
|
||||||
|
total := replayBufferSize + 10
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
hub.BroadcastToRoom(room, map[string]int{"frame": i})
|
||||||
|
}
|
||||||
|
time.Sleep(80 * time.Millisecond)
|
||||||
|
|
||||||
|
c := makeRegisteredClient(hub, room, replayBufferSize+8)
|
||||||
|
msgs := drainN(t, c, replayBufferSize, time.Second)
|
||||||
|
|
||||||
|
// 應只保留最後 replayBufferSize 筆,第一筆 frame 應為 total-replayBufferSize
|
||||||
|
var first map[string]int
|
||||||
|
if err := json.Unmarshal(msgs[0], &first); err != nil {
|
||||||
|
t.Fatalf("bad json: %v", err)
|
||||||
|
}
|
||||||
|
if first["frame"] != total-replayBufferSize {
|
||||||
|
t.Errorf("ring 未正確截斷:首筆 frame=%d,預期 %d", first["frame"], total-replayBufferSize)
|
||||||
|
}
|
||||||
|
// 不應再有第 replayBufferSize+1 筆
|
||||||
|
select {
|
||||||
|
case extra := <-c.Send:
|
||||||
|
t.Errorf("replay 超出 buffer 上限,仍收到多餘訊息: %s", extra)
|
||||||
|
case <-time.After(150 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_ClearRoomReplay_DropsBuffer(t *testing.T) {
|
||||||
|
hub := NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
room := "inference:DEV5"
|
||||||
|
hub.BroadcastToRoom(room, map[string]int{"frame": 0})
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
hub.ClearRoomReplay(room)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
// clear 之後 late-join 不該收到任何 replay
|
||||||
|
c := makeRegisteredClient(hub, room, 4)
|
||||||
|
select {
|
||||||
|
case msg := <-c.Send:
|
||||||
|
t.Errorf("ClearRoomReplay 後仍補送 replay: %s", msg)
|
||||||
|
case <-time.After(150 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHub_NonInferenceRoom_NoReplay(t *testing.T) {
|
||||||
|
hub := NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
room := "flash:DEV6" // 非 inference 前綴 → 不緩存
|
||||||
|
hub.BroadcastToRoom(room, map[string]string{"type": "progress"})
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
c := makeRegisteredClient(hub, room, 4)
|
||||||
|
select {
|
||||||
|
case msg := <-c.Send:
|
||||||
|
t.Errorf("非 inference room 不應緩存 replay,卻補送: %s", msg)
|
||||||
|
case <-time.After(150 * time.Millisecond):
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHub_ReplayAndLiveBroadcast_Ordering:late-join client 先收 replay、再收後續 live 訊息。
|
||||||
|
func TestHub_ReplayAndLiveBroadcast_Ordering(t *testing.T) {
|
||||||
|
hub := NewHub()
|
||||||
|
go hub.Run()
|
||||||
|
|
||||||
|
room := "inference:DEV7"
|
||||||
|
hub.BroadcastToRoom(room, map[string]int{"frame": 0}) // 早期(進 replay)
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
c := makeRegisteredClient(hub, room, 8)
|
||||||
|
// join 後再來一筆 live
|
||||||
|
hub.BroadcastToRoom(room, map[string]int{"frame": 1})
|
||||||
|
|
||||||
|
msgs := drainN(t, c, 2, time.Second)
|
||||||
|
for i, m := range msgs {
|
||||||
|
var got map[string]int
|
||||||
|
_ = json.Unmarshal(m, &got)
|
||||||
|
if got["frame"] != i {
|
||||||
|
t.Errorf("順序錯:第 %d 筆 frame=%d,預期 %d(replay 應在 live 之前)", i, got["frame"], i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,24 +2,64 @@ package camera
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"bytes"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"runtime"
|
"runtime"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// stderrTailBytes 是保留的 ffmpeg stderr 尾端大小上限。
|
||||||
|
// ffmpeg 開攝影機失敗(avfoundation not authorized / device busy 等)的關鍵訊息
|
||||||
|
// 都在 stderr 末尾,保留尾端即可診斷;限制大小避免長時間執行累積無界記憶體。
|
||||||
|
const stderrTailBytes = 8 * 1024
|
||||||
|
|
||||||
// FFmpegCamera captures webcam frames using ffmpeg subprocess.
|
// FFmpegCamera captures webcam frames using ffmpeg subprocess.
|
||||||
// Supports macOS (AVFoundation) and Windows (DirectShow).
|
// Supports macOS (AVFoundation), Windows (DirectShow) and Linux (Video4Linux2).
|
||||||
// ffmpeg outputs a continuous MJPEG stream to stdout which is parsed
|
// ffmpeg outputs a continuous MJPEG stream to stdout which is parsed
|
||||||
// by scanning for JPEG SOI (0xFFD8) and EOI (0xFFD9) markers.
|
// by scanning for JPEG SOI (0xFFD8) and EOI (0xFFD9) markers.
|
||||||
type FFmpegCamera struct {
|
type FFmpegCamera struct {
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
stdout io.ReadCloser
|
stdout io.ReadCloser
|
||||||
|
stderrBuf *ringBuffer // 保留 ffmpeg stderr 尾端,供失敗診斷(原本 =nil 直接丟棄)
|
||||||
latestFrame []byte
|
latestFrame []byte
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
done chan struct{}
|
done chan struct{}
|
||||||
err error
|
err error
|
||||||
|
|
||||||
|
// firstFrame 在第一張完整 JPEG frame 抵達時 close 一次,讓 WaitForFirstFrame 得知
|
||||||
|
// 攝影機真的開起來了(cmd.Start() 成功只代表 ffmpeg 進程 fork 成功,不代表拿到 camera)。
|
||||||
|
firstFrame chan struct{}
|
||||||
|
firstFrameOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// ringBuffer 保留寫入資料的最後 max bytes(thread-safe),用來留住 ffmpeg stderr 尾端。
|
||||||
|
type ringBuffer struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
buf []byte
|
||||||
|
max int
|
||||||
|
}
|
||||||
|
|
||||||
|
func newRingBuffer(max int) *ringBuffer {
|
||||||
|
return &ringBuffer{max: max}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ringBuffer) Write(p []byte) (int, error) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.buf = append(r.buf, p...)
|
||||||
|
if len(r.buf) > r.max {
|
||||||
|
r.buf = r.buf[len(r.buf)-r.max:]
|
||||||
|
}
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *ringBuffer) String() string {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
return string(bytes.TrimSpace(append([]byte(nil), r.buf...)))
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFFmpegCamera starts an ffmpeg process to capture from the given camera.
|
// NewFFmpegCamera starts an ffmpeg process to capture from the given camera.
|
||||||
@ -33,16 +73,23 @@ func NewFFmpegCamera(cameraIndex, width, height, framerate int) (*FFmpegCamera,
|
|||||||
// NewFFmpegCameraWithName starts ffmpeg with explicit camera name (needed for Windows dshow).
|
// NewFFmpegCameraWithName starts ffmpeg with explicit camera name (needed for Windows dshow).
|
||||||
func NewFFmpegCameraWithName(cameraIndex int, cameraName string, width, height, framerate int) (*FFmpegCamera, error) {
|
func NewFFmpegCameraWithName(cameraIndex int, cameraName string, width, height, framerate int) (*FFmpegCamera, error) {
|
||||||
args := buildCaptureArgs(cameraIndex, cameraName, width, height, framerate)
|
args := buildCaptureArgs(cameraIndex, cameraName, width, height, framerate)
|
||||||
|
|
||||||
cmd := exec.Command("ffmpeg", args...)
|
cmd := exec.Command("ffmpeg", args...)
|
||||||
|
return newFFmpegCameraFromCmd(cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newFFmpegCameraFromCmd wires up stdout/stderr and starts the given ffmpeg-like
|
||||||
|
// command. Extracted so tests can substitute a fake command (e.g. a shell script
|
||||||
|
// that simulates early-exit or no-frame) to exercise WaitForFirstFrame.
|
||||||
|
func newFFmpegCameraFromCmd(cmd *exec.Cmd) (*FFmpegCamera, error) {
|
||||||
stdout, err := cmd.StdoutPipe()
|
stdout, err := cmd.StdoutPipe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
|
return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Suppress ffmpeg's stderr banner/logs
|
// 保留 ffmpeg stderr 尾端而不是丟棄(原本 cmd.Stderr = nil 讓 avfoundation
|
||||||
cmd.Stderr = nil
|
// 權限被拒 / 裝置忙碌等錯誤全數消失、極難 debug)。ringBuffer 只留末端、有界。
|
||||||
|
stderrBuf := newRingBuffer(stderrTailBytes)
|
||||||
|
cmd.Stderr = stderrBuf
|
||||||
|
|
||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
|
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
|
||||||
@ -51,7 +98,9 @@ func NewFFmpegCameraWithName(cameraIndex int, cameraName string, width, height,
|
|||||||
cam := &FFmpegCamera{
|
cam := &FFmpegCamera{
|
||||||
cmd: cmd,
|
cmd: cmd,
|
||||||
stdout: stdout,
|
stdout: stdout,
|
||||||
|
stderrBuf: stderrBuf,
|
||||||
done: make(chan struct{}),
|
done: make(chan struct{}),
|
||||||
|
firstFrame: make(chan struct{}),
|
||||||
}
|
}
|
||||||
|
|
||||||
go cam.readLoop()
|
go cam.readLoop()
|
||||||
@ -59,12 +108,92 @@ func NewFFmpegCameraWithName(cameraIndex int, cameraName string, width, height,
|
|||||||
return cam, nil
|
return cam, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WaitForFirstFrame 等到攝影機真的產出第一張 frame 才回 nil;否則回明確錯誤。
|
||||||
|
//
|
||||||
|
// 存在原因:cmd.Start() 只代表 ffmpeg 進程 fork 成功,avfoundation 抓不到 camera
|
||||||
|
// (權限被 TCC 靜默拒絕 / 裝置忙碌)是在進程啟動「之後」才失敗、ffmpeg 隨即 exit。
|
||||||
|
// 呼叫端(manager.Open → handler)改在 Start 後呼叫此函式,把「真的拿到攝影機」
|
||||||
|
// 這件事納入成功判斷,避免 HTTP 200 假成功。
|
||||||
|
//
|
||||||
|
// 三種結束情況:
|
||||||
|
// 1. 收到第一張 frame → nil
|
||||||
|
// 2. ffmpeg 提早結束(readLoop 讀到 EOF,設 c.err)→ 回含 stderr 尾端的錯誤
|
||||||
|
// 3. 逾時 → 回逾時錯誤(附 stderr 尾端,可能含權限 / 裝置訊息)
|
||||||
|
func (c *FFmpegCamera) WaitForFirstFrame(timeout time.Duration) error {
|
||||||
|
timer := time.NewTimer(timeout)
|
||||||
|
defer timer.Stop()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-c.firstFrame:
|
||||||
|
return nil
|
||||||
|
case <-c.done:
|
||||||
|
// ffmpeg 已結束卻沒送出任何 frame → 開攝影機失敗。
|
||||||
|
c.mu.Lock()
|
||||||
|
streamErr := c.err
|
||||||
|
c.mu.Unlock()
|
||||||
|
// 有可能 done 與 firstFrame 幾乎同時(極少見):done 後再確認一次是否其實已有 frame。
|
||||||
|
select {
|
||||||
|
case <-c.firstFrame:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
return fmt.Errorf("camera did not start: ffmpeg exited before producing a frame: %w%s",
|
||||||
|
streamErr, c.stderrTailSuffix())
|
||||||
|
case <-timer.C:
|
||||||
|
return fmt.Errorf("camera did not start: timed out after %s waiting for first frame%s",
|
||||||
|
timeout, c.stderrTailSuffix())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// stderrTailSuffix 回傳可附加到錯誤訊息的 ffmpeg stderr 尾端(若有)。
|
||||||
|
func (c *FFmpegCamera) stderrTailSuffix() string {
|
||||||
|
if c.stderrBuf == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
tail := c.stderrBuf.String()
|
||||||
|
if tail == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return " (ffmpeg: " + tail + ")"
|
||||||
|
}
|
||||||
|
|
||||||
|
// StderrTail 回傳目前保留的 ffmpeg stderr 尾端(供上層 log 診斷)。
|
||||||
|
func (c *FFmpegCamera) StderrTail() string {
|
||||||
|
if c.stderrBuf == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return c.stderrBuf.String()
|
||||||
|
}
|
||||||
|
|
||||||
// buildCaptureArgs returns the ffmpeg arguments for the current OS.
|
// buildCaptureArgs returns the ffmpeg arguments for the current OS.
|
||||||
func buildCaptureArgs(cameraIndex int, cameraName string, width, height, framerate int) []string {
|
func buildCaptureArgs(cameraIndex int, cameraName string, width, height, framerate int) []string {
|
||||||
|
return buildCaptureArgsForOS(runtime.GOOS, cameraIndex, cameraName, width, height, framerate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildCaptureArgsForOS 依指定 goos 組 ffmpeg capture args,把平台判斷抽成參數以便
|
||||||
|
// table test 三平台輸出(不用 mock runtime.GOOS)。三平台各用不同 input device(indev):
|
||||||
|
// - macOS → avfoundation(-i "<index>:none")
|
||||||
|
// - Windows → dshow(-i video="<name>")
|
||||||
|
// - Linux → v4l2(-i /dev/video<N>)
|
||||||
|
//
|
||||||
|
// 三平台後段皆接 -f image2pipe -vcodec mjpeg -q:v 5 -an -,MJPEG pipe 架構共用。
|
||||||
|
// 對照見 ADR-020 §2.2 三平台 capture args 對照表。
|
||||||
|
func buildCaptureArgsForOS(goos string, cameraIndex int, cameraName string, width, height, framerate int) []string {
|
||||||
videoSize := fmt.Sprintf("%dx%d", width, height)
|
videoSize := fmt.Sprintf("%dx%d", width, height)
|
||||||
fps := fmt.Sprintf("%d", framerate)
|
fps := fmt.Sprintf("%d", framerate)
|
||||||
|
|
||||||
switch runtime.GOOS {
|
// captureTail 是三平台共用的輸出段(把 raw frame 轉成 stdout 上的 MJPEG stream)。
|
||||||
|
captureTail := func(args []string) []string {
|
||||||
|
return append(args,
|
||||||
|
"-f", "image2pipe",
|
||||||
|
"-vcodec", "mjpeg",
|
||||||
|
"-q:v", "5",
|
||||||
|
"-an",
|
||||||
|
"-",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch goos {
|
||||||
case "windows":
|
case "windows":
|
||||||
// DirectShow on Windows: -f dshow -i video="Camera Name"
|
// DirectShow on Windows: -f dshow -i video="Camera Name"
|
||||||
inputName := cameraName
|
inputName := cameraName
|
||||||
@ -77,30 +206,50 @@ func buildCaptureArgs(cameraIndex int, cameraName string, width, height, framera
|
|||||||
inputName = "Integrated Camera"
|
inputName = "Integrated Camera"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return []string{
|
return captureTail([]string{
|
||||||
"-f", "dshow",
|
"-f", "dshow",
|
||||||
"-framerate", fps,
|
"-framerate", fps,
|
||||||
"-video_size", videoSize,
|
"-video_size", videoSize,
|
||||||
"-i", fmt.Sprintf("video=%s", inputName),
|
"-i", fmt.Sprintf("video=%s", inputName),
|
||||||
"-f", "image2pipe",
|
})
|
||||||
"-vcodec", "mjpeg",
|
case "linux":
|
||||||
"-q:v", "5",
|
// Video4Linux2 on Linux: -f v4l2 -i /dev/video<N>
|
||||||
"-an",
|
// v4l2 以裝置節點路徑(非 index)指定攝影機;cameraIndex 對應 /dev/video<N>,
|
||||||
"-",
|
// 這是 Linux 攝影機的慣例(/dev/video0 = 第一支)。cameraName 在 Linux 未使用。
|
||||||
}
|
return captureTail([]string{
|
||||||
|
"-f", "v4l2",
|
||||||
|
"-framerate", fps,
|
||||||
|
"-video_size", videoSize,
|
||||||
|
"-i", fmt.Sprintf("/dev/video%d", cameraIndex),
|
||||||
|
})
|
||||||
|
case "darwin":
|
||||||
|
// AVFoundation on macOS: -f avfoundation -pixel_format uyvy422 -i "index:none"
|
||||||
|
//
|
||||||
|
// 必須明確指定攝影機支援的 input pixel format。攝影機只支援
|
||||||
|
// uyvy422/yuyv422/nv12/0rgb/bgr0;不指定時 avfoundation 會嘗試 yuv420p
|
||||||
|
// → 協商失敗(Input/output error)。指定 uyvy422(攝影機原生格式)→ 攝影機成功打開。
|
||||||
|
//
|
||||||
|
// 位置關鍵:-pixel_format 是 input 選項,必須放在 -i 之前(跟 -framerate/
|
||||||
|
// -video_size 同段)。放到 -i 之後會被當成 output 轉碼目標、不解決 input 協商。
|
||||||
|
// 對照見根因文件 .autoflow/05-implementation/camera-pixel-format-rootcause.md。
|
||||||
|
return captureTail([]string{
|
||||||
|
"-f", "avfoundation",
|
||||||
|
"-pixel_format", "uyvy422",
|
||||||
|
"-framerate", fps,
|
||||||
|
"-video_size", videoSize,
|
||||||
|
"-i", fmt.Sprintf("%d:none", cameraIndex),
|
||||||
|
})
|
||||||
default:
|
default:
|
||||||
// AVFoundation on macOS: -f avfoundation -i "index:none"
|
// 未知平台:明確落到 macOS 的 avfoundation 是錯的(原本 default 就是這個 bug)。
|
||||||
return []string{
|
// 保留 avfoundation 作為最後手段,但這只是為了讓非三大平台不至於 build 失敗;
|
||||||
|
// 實務上未知平台的 camera 抓取本就不支援,會在 ffmpeg 層以「Unknown input format」失敗。
|
||||||
|
// 三大目標平台(darwin/windows/linux)都有明確 case、不會落到這裡。
|
||||||
|
return captureTail([]string{
|
||||||
"-f", "avfoundation",
|
"-f", "avfoundation",
|
||||||
"-framerate", fps,
|
"-framerate", fps,
|
||||||
"-video_size", videoSize,
|
"-video_size", videoSize,
|
||||||
"-i", fmt.Sprintf("%d:none", cameraIndex),
|
"-i", fmt.Sprintf("%d:none", cameraIndex),
|
||||||
"-f", "image2pipe",
|
})
|
||||||
"-vcodec", "mjpeg",
|
|
||||||
"-q:v", "5",
|
|
||||||
"-an",
|
|
||||||
"-",
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -154,6 +303,9 @@ func (c *FFmpegCamera) readLoop() {
|
|||||||
c.latestFrame = frame
|
c.latestFrame = frame
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
|
|
||||||
|
// 通知 WaitForFirstFrame:攝影機真的產出 frame 了(只觸發一次)。
|
||||||
|
c.firstFrameOnce.Do(func() { close(c.firstFrame) })
|
||||||
|
|
||||||
inFrame = false
|
inFrame = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
92
local-agent/server/internal/camera/ffmpeg_camera_test.go
Normal file
92
local-agent/server/internal/camera/ffmpeg_camera_test.go
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
package camera
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 這些測試用 shell 腳本假扮 ffmpeg,驗證 WaitForFirstFrame 的三種結束路徑,
|
||||||
|
// 不依賴真實攝影機(CI / 無 camera 環境也能跑)。
|
||||||
|
|
||||||
|
// TestWaitForFirstFrame_EarlyExit 模擬 ffmpeg 啟動即失敗(如 avfoundation 權限被拒):
|
||||||
|
// 進程 fork 成功但隨即 exit、不產出任何 frame。WaitForFirstFrame 應回錯誤,
|
||||||
|
// 且錯誤訊息帶上 stderr 尾端(診斷用)。
|
||||||
|
func TestWaitForFirstFrame_EarlyExit(t *testing.T) {
|
||||||
|
// 寫一行錯誤到 stderr 後立刻 exit 1,stdout 沒有任何 JPEG。
|
||||||
|
cmd := exec.Command("sh", "-c", "echo 'avfoundation: not authorized to capture video' 1>&2; exit 1")
|
||||||
|
cam, err := newFFmpegCameraFromCmd(cmd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newFFmpegCameraFromCmd failed: %v", err)
|
||||||
|
}
|
||||||
|
defer cam.Close()
|
||||||
|
|
||||||
|
if err := cam.WaitForFirstFrame(3 * time.Second); err == nil {
|
||||||
|
t.Fatal("expected error when ffmpeg exits before producing a frame, got nil")
|
||||||
|
} else if !strings.Contains(err.Error(), "not authorized") {
|
||||||
|
t.Errorf("expected stderr tail in error, got: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWaitForFirstFrame_Timeout 模擬 ffmpeg 啟動後長時間不產出 frame(進程還活著,
|
||||||
|
// 但 avfoundation 卡住 / 沒資料)。WaitForFirstFrame 應在 timeout 後回逾時錯誤。
|
||||||
|
func TestWaitForFirstFrame_Timeout(t *testing.T) {
|
||||||
|
// 進程存活 10s、不輸出任何 JPEG 到 stdout。
|
||||||
|
cmd := exec.Command("sh", "-c", "sleep 10")
|
||||||
|
cam, err := newFFmpegCameraFromCmd(cmd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newFFmpegCameraFromCmd failed: %v", err)
|
||||||
|
}
|
||||||
|
defer cam.Close()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
if err := cam.WaitForFirstFrame(300 * time.Millisecond); err == nil {
|
||||||
|
t.Fatal("expected timeout error when no frame is produced, got nil")
|
||||||
|
} else if !strings.Contains(err.Error(), "timed out") {
|
||||||
|
t.Errorf("expected timeout error, got: %v", err)
|
||||||
|
}
|
||||||
|
if elapsed := time.Since(start); elapsed > 3*time.Second {
|
||||||
|
t.Errorf("WaitForFirstFrame took too long (%s), expected ~timeout", elapsed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWaitForFirstFrame_Success 模擬 ffmpeg 正常產出一張最小 JPEG(SOI+EOI):
|
||||||
|
// WaitForFirstFrame 應回 nil,且 ReadFrame 拿得到該 frame。
|
||||||
|
func TestWaitForFirstFrame_Success(t *testing.T) {
|
||||||
|
// printf 出最小合法 JPEG 標記:FF D8 ... FF D9,然後 sleep 保持進程存活。
|
||||||
|
cmd := exec.Command("sh", "-c", `printf '\xFF\xD8\x00\xFF\xD9'; sleep 2`)
|
||||||
|
cam, err := newFFmpegCameraFromCmd(cmd)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newFFmpegCameraFromCmd failed: %v", err)
|
||||||
|
}
|
||||||
|
defer cam.Close()
|
||||||
|
|
||||||
|
if err := cam.WaitForFirstFrame(3 * time.Second); err != nil {
|
||||||
|
t.Fatalf("expected success when a frame is produced, got: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
frame, err := cam.ReadFrame()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ReadFrame after first frame failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(frame) < 4 || frame[0] != 0xFF || frame[1] != 0xD8 {
|
||||||
|
t.Errorf("expected a JPEG frame starting with FFD8, got % x", frame)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRingBuffer_KeepsTail 驗證 ringBuffer 只保留尾端、不無界成長。
|
||||||
|
func TestRingBuffer_KeepsTail(t *testing.T) {
|
||||||
|
rb := newRingBuffer(8)
|
||||||
|
if _, err := rb.Write([]byte("0123456789ABCDEF")); err != nil {
|
||||||
|
t.Fatalf("write failed: %v", err)
|
||||||
|
}
|
||||||
|
got := rb.String()
|
||||||
|
if got != "9ABCDEF" && got != "89ABCDEF" {
|
||||||
|
// TrimSpace 不影響此輸入;預期保留最後 8 bytes "9ABCDEF" 前含 '8'
|
||||||
|
t.Logf("tail = %q", got)
|
||||||
|
}
|
||||||
|
if len(got) > 8 {
|
||||||
|
t.Errorf("ringBuffer exceeded max: len=%d content=%q", len(got), got)
|
||||||
|
}
|
||||||
|
}
|
||||||
219
local-agent/server/internal/camera/ffmpeg_capture_args_test.go
Normal file
219
local-agent/server/internal/camera/ffmpeg_capture_args_test.go
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
package camera
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBuildCaptureArgsForOS 驗證三平台各自用正確的 ffmpeg input device(indev):
|
||||||
|
// macOS→avfoundation、Windows→dshow、Linux→v4l2。這是 ADR-020 的核心修復:
|
||||||
|
// 原本 Linux 會誤落 default 分支用 avfoundation → 必壞。
|
||||||
|
func TestBuildCaptureArgsForOS(t *testing.T) {
|
||||||
|
const (
|
||||||
|
width = 640
|
||||||
|
height = 480
|
||||||
|
fps = 30
|
||||||
|
index = 0
|
||||||
|
)
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
goos string
|
||||||
|
cameraName string
|
||||||
|
// wantInputFlag 是預期的 "-f <indev>" 值
|
||||||
|
wantIndev string
|
||||||
|
// wantInputArg 是 "-i" 後面的值
|
||||||
|
wantInputArg string
|
||||||
|
// wantPixelFormat 若非空,斷言 "-pixel_format <值>" 存在且位於 -i 之前(input 選項)。
|
||||||
|
wantPixelFormat string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "macOS uses avfoundation with index:none and uyvy422 input pixel format",
|
||||||
|
goos: "darwin",
|
||||||
|
wantIndev: "avfoundation",
|
||||||
|
wantInputArg: "0:none",
|
||||||
|
wantPixelFormat: "uyvy422",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Windows uses dshow with video=name",
|
||||||
|
goos: "windows",
|
||||||
|
cameraName: "Integrated Camera",
|
||||||
|
wantIndev: "dshow",
|
||||||
|
wantInputArg: "video=Integrated Camera",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Linux uses v4l2 with /dev/video path",
|
||||||
|
goos: "linux",
|
||||||
|
wantIndev: "v4l2",
|
||||||
|
wantInputArg: "/dev/video0",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
args := buildCaptureArgsForOS(tt.goos, index, tt.cameraName, width, height, fps)
|
||||||
|
|
||||||
|
gotIndev := valueAfterFlag(args, "-f") // 第一個 -f 是 input format
|
||||||
|
if gotIndev != tt.wantIndev {
|
||||||
|
t.Errorf("indev = %q, want %q\nargs: %v", gotIndev, tt.wantIndev, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
gotInput := valueAfterFlag(args, "-i")
|
||||||
|
if gotInput != tt.wantInputArg {
|
||||||
|
t.Errorf("input = %q, want %q\nargs: %v", gotInput, tt.wantInputArg, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// macOS avfoundation 必須帶 -pixel_format uyvy422,且它是 input 選項、
|
||||||
|
// 必須位於 -i 之前(放到 -i 之後會被當 output 轉碼目標、不解決 input 協商)。
|
||||||
|
if tt.wantPixelFormat != "" {
|
||||||
|
gotPixFmt := valueAfterFlag(args, "-pixel_format")
|
||||||
|
if gotPixFmt != tt.wantPixelFormat {
|
||||||
|
t.Errorf("pixel_format = %q, want %q\nargs: %v", gotPixFmt, tt.wantPixelFormat, args)
|
||||||
|
}
|
||||||
|
pixIdx := indexOfFlag(args, "-pixel_format")
|
||||||
|
iIdx := indexOfFlag(args, "-i")
|
||||||
|
if pixIdx < 0 || iIdx < 0 || pixIdx > iIdx {
|
||||||
|
t.Errorf("-pixel_format (idx %d) must appear before -i (idx %d)\nargs: %v", pixIdx, iIdx, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 三平台後段皆須為 image2pipe / mjpeg,MJPEG pipe 架構共用。
|
||||||
|
if !containsSeq(args, "-f", "image2pipe") {
|
||||||
|
t.Errorf("missing image2pipe output, args: %v", args)
|
||||||
|
}
|
||||||
|
if !containsSeq(args, "-vcodec", "mjpeg") {
|
||||||
|
t.Errorf("missing mjpeg vcodec, args: %v", args)
|
||||||
|
}
|
||||||
|
if args[len(args)-1] != "-" {
|
||||||
|
t.Errorf("last arg should be stdout '-', got %q", args[len(args)-1])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildCaptureArgsForOS_LinuxIndexToDevicePath 驗證 Linux 的 cameraIndex 正確
|
||||||
|
// 對應到 /dev/video<N> 節點路徑。
|
||||||
|
func TestBuildCaptureArgsForOS_LinuxIndexToDevicePath(t *testing.T) {
|
||||||
|
for _, idx := range []int{0, 1, 2, 10} {
|
||||||
|
args := buildCaptureArgsForOS("linux", idx, "", 640, 480, 30)
|
||||||
|
want := "/dev/video" + itoa(idx)
|
||||||
|
if got := valueAfterFlag(args, "-i"); got != want {
|
||||||
|
t.Errorf("index %d → input %q, want %q", idx, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildCaptureArgsForOS_LinuxNotAVFoundation 是 ADR-020 的回歸鎖:Linux 絕不能
|
||||||
|
// 用 avfoundation(macOS 專用)。若未來有人把 Linux case 拿掉、讓它落回 default,
|
||||||
|
// 這個測試會抓到。
|
||||||
|
func TestBuildCaptureArgsForOS_LinuxNotAVFoundation(t *testing.T) {
|
||||||
|
args := buildCaptureArgsForOS("linux", 0, "", 640, 480, 30)
|
||||||
|
joined := strings.Join(args, " ")
|
||||||
|
if strings.Contains(joined, "avfoundation") {
|
||||||
|
t.Fatalf("Linux must NOT use avfoundation (macOS-only), args: %v", args)
|
||||||
|
}
|
||||||
|
if !strings.Contains(joined, "v4l2") {
|
||||||
|
t.Fatalf("Linux must use v4l2, args: %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseV4L2Devices 驗證 /dev/video* 路徑清單解析成 CameraInfo,且依 index 數值
|
||||||
|
// 排序(video10 排在 video2 之後、而非字典序)。
|
||||||
|
func TestParseV4L2Devices(t *testing.T) {
|
||||||
|
paths := []string{
|
||||||
|
"/dev/video10",
|
||||||
|
"/dev/video2",
|
||||||
|
"/dev/video0",
|
||||||
|
"/dev/video-not-a-number", // 應被略過
|
||||||
|
"/dev/videoX", // 應被略過
|
||||||
|
}
|
||||||
|
|
||||||
|
got := parseV4L2Devices(paths)
|
||||||
|
|
||||||
|
wantIndexes := []int{0, 2, 10}
|
||||||
|
if len(got) != len(wantIndexes) {
|
||||||
|
t.Fatalf("got %d devices, want %d: %+v", len(got), len(wantIndexes), got)
|
||||||
|
}
|
||||||
|
for i, want := range wantIndexes {
|
||||||
|
if got[i].Index != want {
|
||||||
|
t.Errorf("device[%d].Index = %d, want %d", i, got[i].Index, want)
|
||||||
|
}
|
||||||
|
if got[i].Name != "/dev/video"+itoa(want) {
|
||||||
|
t.Errorf("device[%d].Name = %q, want /dev/video%d", i, got[i].Name, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestParseV4L2Devices_Empty 驗證無裝置時回 nil/空。
|
||||||
|
func TestParseV4L2Devices_Empty(t *testing.T) {
|
||||||
|
if got := parseV4L2Devices(nil); len(got) != 0 {
|
||||||
|
t.Errorf("expected no devices, got %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestV4L2Index 驗證從路徑取 index。
|
||||||
|
func TestV4L2Index(t *testing.T) {
|
||||||
|
cases := map[string]int{
|
||||||
|
"/dev/video0": 0,
|
||||||
|
"/dev/video12": 12,
|
||||||
|
"/dev/video": -1,
|
||||||
|
"/dev/videoab": -1,
|
||||||
|
"videoX": -1,
|
||||||
|
}
|
||||||
|
for path, want := range cases {
|
||||||
|
if got := v4l2Index(path); got != want {
|
||||||
|
t.Errorf("v4l2Index(%q) = %d, want %d", path, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- helpers ---
|
||||||
|
|
||||||
|
// valueAfterFlag 回傳 args 中第一個等於 flag 的元素的下一個值。
|
||||||
|
func valueAfterFlag(args []string, flag string) string {
|
||||||
|
for i := 0; i < len(args)-1; i++ {
|
||||||
|
if args[i] == flag {
|
||||||
|
return args[i+1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// indexOfFlag 回傳 args 中第一個等於 flag 的元素的索引;不存在回 -1。
|
||||||
|
func indexOfFlag(args []string, flag string) int {
|
||||||
|
for i, a := range args {
|
||||||
|
if a == flag {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsSeq 判斷 args 是否包含連續的 a b 兩個元素。
|
||||||
|
func containsSeq(args []string, a, b string) bool {
|
||||||
|
for i := 0; i < len(args)-1; i++ {
|
||||||
|
if args[i] == a && args[i+1] == b {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func itoa(n int) string {
|
||||||
|
if n == 0 {
|
||||||
|
return "0"
|
||||||
|
}
|
||||||
|
neg := n < 0
|
||||||
|
if neg {
|
||||||
|
n = -n
|
||||||
|
}
|
||||||
|
var buf []byte
|
||||||
|
for n > 0 {
|
||||||
|
buf = append([]byte{byte('0' + n%10)}, buf...)
|
||||||
|
n /= 10
|
||||||
|
}
|
||||||
|
if neg {
|
||||||
|
buf = append([]byte{'-'}, buf...)
|
||||||
|
}
|
||||||
|
return string(buf)
|
||||||
|
}
|
||||||
@ -3,8 +3,10 @@ package camera
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
"regexp"
|
"regexp"
|
||||||
"runtime"
|
"runtime"
|
||||||
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
@ -19,7 +21,14 @@ func DetectFFmpeg() bool {
|
|||||||
// Automatically selects the correct capture framework for the current OS:
|
// Automatically selects the correct capture framework for the current OS:
|
||||||
// - macOS: AVFoundation
|
// - macOS: AVFoundation
|
||||||
// - Windows: DirectShow (dshow)
|
// - Windows: DirectShow (dshow)
|
||||||
|
// - Linux: Video4Linux2 (v4l2, enumerated from /dev/video*)
|
||||||
func ListFFmpegDevices() []CameraInfo {
|
func ListFFmpegDevices() []CameraInfo {
|
||||||
|
// Linux 走 /dev/video* 列舉、不依賴 ffmpeg binary(-list_devices 在部分 v4l2 build
|
||||||
|
// 不穩定),故不要求 DetectFFmpeg 也能列出裝置節點;實際抓 frame 時才需要 ffmpeg。
|
||||||
|
if runtime.GOOS == "linux" {
|
||||||
|
return listV4L2Devices()
|
||||||
|
}
|
||||||
|
|
||||||
if !DetectFFmpeg() {
|
if !DetectFFmpeg() {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@ -132,3 +141,66 @@ func parseDShowOutput(output string) []CameraInfo {
|
|||||||
|
|
||||||
return cameras
|
return cameras
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Linux (Video4Linux2) ---
|
||||||
|
|
||||||
|
// v4l2DeviceGlob 是列舉 v4l2 攝影機節點的 glob pattern。抽成變數讓測試可覆寫成
|
||||||
|
// 假的目錄,不依賴實機 /dev。
|
||||||
|
var v4l2DeviceGlob = "/dev/video*"
|
||||||
|
|
||||||
|
// v4l2DeviceIndexRe 從 /dev/video<N> 取出 index N。
|
||||||
|
var v4l2DeviceIndexRe = regexp.MustCompile(`video(\d+)$`)
|
||||||
|
|
||||||
|
// listV4L2Devices 列舉 Linux 上的 v4l2 攝影機節點(/dev/video*)。
|
||||||
|
//
|
||||||
|
// 為何用 glob 而非 ffmpeg -list_devices:v4l2 indev 的 -list_devices 支援度依 ffmpeg
|
||||||
|
// 版本而異、部分 build 不輸出可解析清單;直接列舉 /dev/video* 節點是最穩定的跨版本做法。
|
||||||
|
//
|
||||||
|
// 注意:/dev/video* 也包含非攝影機的 V4L2 節點(如 metadata / output device),這裡先
|
||||||
|
// 全數列出、由使用者選擇;抓 frame 失敗會在 ffmpeg 層以明確錯誤浮現(camera 修 bug 後不再吞錯)。
|
||||||
|
func listV4L2Devices() []CameraInfo {
|
||||||
|
matches, err := filepath.Glob(v4l2DeviceGlob)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return parseV4L2Devices(matches)
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseV4L2Devices 把 /dev/video* 路徑清單轉成 CameraInfo(依 index 排序)。
|
||||||
|
// 抽出來讓測試可直接餵路徑清單、不碰檔案系統。
|
||||||
|
func parseV4L2Devices(paths []string) []CameraInfo {
|
||||||
|
// 依裝置 index 數值排序(Glob 回傳為字典序,video10 會排在 video2 前,需正規化)。
|
||||||
|
sorted := append([]string(nil), paths...)
|
||||||
|
sort.Slice(sorted, func(i, j int) bool {
|
||||||
|
return v4l2Index(sorted[i]) < v4l2Index(sorted[j])
|
||||||
|
})
|
||||||
|
|
||||||
|
var cameras []CameraInfo
|
||||||
|
for _, path := range sorted {
|
||||||
|
idx := v4l2Index(path)
|
||||||
|
if idx < 0 {
|
||||||
|
continue // 不是 /dev/video<N> 形式、略過
|
||||||
|
}
|
||||||
|
cameras = append(cameras, CameraInfo{
|
||||||
|
ID: fmt.Sprintf("cam-%d", idx),
|
||||||
|
Name: path, // Linux 以裝置節點路徑作為名稱(buildCaptureArgs 用 index 組回路徑)
|
||||||
|
Index: idx,
|
||||||
|
Width: 640,
|
||||||
|
Height: 480,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return cameras
|
||||||
|
}
|
||||||
|
|
||||||
|
// v4l2Index 從 /dev/video<N> 取出 N;非該形式回 -1。
|
||||||
|
func v4l2Index(path string) int {
|
||||||
|
m := v4l2DeviceIndexRe.FindStringSubmatch(path)
|
||||||
|
if len(m) != 2 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
n, err := strconv.Atoi(m[1])
|
||||||
|
if err != nil {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|||||||
@ -3,8 +3,22 @@ package camera
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// firstFrameTimeout 是 Open() 等待攝影機產出第一張 frame 的上限。
|
||||||
|
//
|
||||||
|
// 為何 25s(不是更短):首次開攝影機時 macOS 的 TCC 攝影機授權對話框是「同步阻擋」的,
|
||||||
|
// 逾時從進入 WaitForFirstFrame 就起算、會把使用者在彈窗前猶豫/反應的時間也算進去。
|
||||||
|
// 若太短(如 8s),首次點「開始推論」極可能在使用者還沒按下「允許」前就逾時失敗、
|
||||||
|
// 要按第二次才成功——首次體驗變成非預期失敗。25s 足夠涵蓋 TCC 授權彈窗的使用者反應時間。
|
||||||
|
//
|
||||||
|
// 為何拉長不會拖慢正常情境:授權彈窗期間 ffmpeg 進程仍存活、不會早退;且一拿到第一張
|
||||||
|
// frame 就立即返回,已授權情境仍是秒開,不會真的等滿 25s。只有「真的開不了」(權限被拒
|
||||||
|
// 後 ffmpeg exit / 裝置忙碌)時才會等到逾時——ffmpeg 早退會由 done 分支提前回錯誤,
|
||||||
|
// 真正等滿 25s 的僅剩「進程活著但持續不出 frame」的少數情況。
|
||||||
|
const firstFrameTimeout = 25 * time.Second
|
||||||
|
|
||||||
type CameraInfo struct {
|
type CameraInfo struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@ -51,9 +65,23 @@ func (m *Manager) Open(index, width, height int) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to open camera (index=%d): %w", index, err)
|
return fmt.Errorf("failed to open camera (index=%d): %w", index, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// cmd.Start() 成功 ≠ 攝影機真的開起來。等第一張 frame 才算成功;否則清掉 ffmpeg
|
||||||
|
// 進程並回錯誤(含 ffmpeg stderr 尾端),讓 handler 回非 200、前端看到真實失敗,
|
||||||
|
// 而不是「200 假成功、畫面永遠空白」。
|
||||||
|
if err := cam.WaitForFirstFrame(firstFrameTimeout); err != nil {
|
||||||
|
if tail := cam.StderrTail(); tail != "" {
|
||||||
|
fmt.Printf("[ERROR] Camera open failed (index=%d): %v\n[ffmpeg stderr]\n%s\n", index, err, tail)
|
||||||
|
} else {
|
||||||
|
fmt.Printf("[ERROR] Camera open failed (index=%d): %v\n", index, err)
|
||||||
|
}
|
||||||
|
_ = cam.Close()
|
||||||
|
return fmt.Errorf("failed to open camera (index=%d): %w", index, err)
|
||||||
|
}
|
||||||
|
|
||||||
m.ffmpegCam = cam
|
m.ffmpegCam = cam
|
||||||
m.isOpen = true
|
m.isOpen = true
|
||||||
fmt.Printf("[INFO] Opened real camera (index=%d) via ffmpeg\n", index)
|
fmt.Printf("[INFO] Opened real camera (index=%d) via ffmpeg (first frame received)\n", index)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,11 +2,19 @@ package camera
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"visiona-agent/server/internal/driver"
|
"visiona-agent/server/internal/driver"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// maxConsecutiveReadErrors 是 camera 模式下連續讀 frame 失敗的容忍上限。
|
||||||
|
//
|
||||||
|
// 原本讀失敗只 sleep 100ms 後無限重試、完全靜默——攝影機中途斷線 / 從未出 frame 時
|
||||||
|
// 前端只會看到永遠空白、後端也沒任何跡象。改成連續失敗超過上限就 log + 結束 pipeline,
|
||||||
|
// 讓失敗看得見。100ms * 50 ≈ 5s,足夠容忍偶發抖動,又不會無限卡住。
|
||||||
|
const maxConsecutiveReadErrors = 50
|
||||||
|
|
||||||
// SourceType identifies the kind of frame source used in the pipeline.
|
// SourceType identifies the kind of frame source used in the pipeline.
|
||||||
type SourceType string
|
type SourceType string
|
||||||
|
|
||||||
@ -90,6 +98,7 @@ func (p *InferencePipeline) run(ctx context.Context) {
|
|||||||
targetInterval := time.Second / 15 // 15 FPS
|
targetInterval := time.Second / 15 // 15 FPS
|
||||||
inferenceRan := false // for image mode: only run inference once
|
inferenceRan := false // for image mode: only run inference once
|
||||||
frameIndex := 0 // video frame counter
|
frameIndex := 0 // video frame counter
|
||||||
|
consecutiveReadErrors := 0 // camera 模式:連續讀 frame 失敗計數
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
@ -118,9 +127,22 @@ func (p *InferencePipeline) run(ctx context.Context) {
|
|||||||
} else {
|
} else {
|
||||||
jpegFrame, readErr = p.source.ReadFrame()
|
jpegFrame, readErr = p.source.ReadFrame()
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
|
// camera 模式:不再無限靜默重試。連續失敗超過上限就 log + 結束,
|
||||||
|
// 避免攝影機從未出 frame / 中途斷線時前端永遠空白、後端毫無跡象。
|
||||||
|
// 非 camera 來源(理論上不會走到這,image/batch 有各自路徑)維持原重試行為。
|
||||||
|
if p.sourceType == SourceCamera {
|
||||||
|
consecutiveReadErrors++
|
||||||
|
if consecutiveReadErrors >= maxConsecutiveReadErrors {
|
||||||
|
fmt.Printf("[ERROR] camera pipeline aborted after %d consecutive read errors: %v\n",
|
||||||
|
consecutiveReadErrors, readErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
time.Sleep(100 * time.Millisecond)
|
time.Sleep(100 * time.Millisecond)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// 成功讀到 frame,重置連續失敗計數。
|
||||||
|
consecutiveReadErrors = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
// Send to MJPEG stream
|
// Send to MJPEG stream
|
||||||
|
|||||||
@ -35,12 +35,18 @@ case "$MODE" in
|
|||||||
CLOUD_API_URL="https://$HOST"
|
CLOUD_API_URL="https://$HOST"
|
||||||
RELAY_URL="wss://$HOST/tunnel/connect"
|
RELAY_URL="wss://$HOST/tunnel/connect"
|
||||||
WS_SCHEME="https"
|
WS_SCHEME="https"
|
||||||
|
# ADR-019 §2.5 CORS 白名單(stage 雙入口):影片分頁 localhost 直連時,
|
||||||
|
# 瀏覽器可能從公網 HTTPS 或內網純 HTTP 任一入口開啟,兩者都要放行。
|
||||||
|
CLOUD_ORIGINS="https://stage-9527.innovedus.com:9527,http://192.168.0.130:9527"
|
||||||
;;
|
;;
|
||||||
internal)
|
internal)
|
||||||
HOST="192.168.0.130:9527"
|
HOST="192.168.0.130:9527"
|
||||||
CLOUD_API_URL="http://$HOST"
|
CLOUD_API_URL="http://$HOST"
|
||||||
RELAY_URL="ws://$HOST/tunnel/connect"
|
RELAY_URL="ws://$HOST/tunnel/connect"
|
||||||
WS_SCHEME="http"
|
WS_SCHEME="http"
|
||||||
|
# ADR-019 §2.5 CORS 白名單:internal 走內網純 HTTP 入口;一併帶公網入口
|
||||||
|
# 以便同一台 agent 兩種路徑都能開影片分頁(多帶白名單不會放寬安全性)。
|
||||||
|
CLOUD_ORIGINS="http://192.168.0.130:9527,https://stage-9527.innovedus.com:9527"
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
echo "用法:$0 [public|internal]" >&2
|
echo "用法:$0 [public|internal]" >&2
|
||||||
@ -105,6 +111,16 @@ echo ""
|
|||||||
export VISIONA_CLOUD_API_URL="$CLOUD_API_URL"
|
export VISIONA_CLOUD_API_URL="$CLOUD_API_URL"
|
||||||
export VISIONA_RELAY_URL="$RELAY_URL"
|
export VISIONA_RELAY_URL="$RELAY_URL"
|
||||||
|
|
||||||
|
# ADR-019 localhost 直連的 CORS 白名單(server 讀 middleware.go:46 os.Getenv)。
|
||||||
|
# 漏設會導致影片分頁的 preflight(OPTIONS)被 local-agent 回 403 → 前端 port 探測
|
||||||
|
# 掃不到同機 agent → 影片分頁顯示 LOCAL_AGENT_NOT_FOUND(「需在同一台電腦操作」)。
|
||||||
|
# 格式:逗號分隔的完整 origin(scheme+host+port 一字不差、結尾無斜線),與
|
||||||
|
# middleware.go 的「完整 origin 精確比對」相容。
|
||||||
|
# 尊重外部覆寫(與上方優先級 env > 內建預設一致):若使用者已設則沿用其值。
|
||||||
|
export VISIONA_CLOUD_ORIGINS="${VISIONA_CLOUD_ORIGINS:-$CLOUD_ORIGINS}"
|
||||||
|
echo " CORS 白名單(VISIONA_CLOUD_ORIGINS):$VISIONA_CLOUD_ORIGINS"
|
||||||
|
echo ""
|
||||||
|
|
||||||
# public 模式:stage 用自簽憑證,agent 需明確 opt-in 跳過 TLS 驗證
|
# public 模式:stage 用自簽憑證,agent 需明確 opt-in 跳過 TLS 驗證
|
||||||
# (pairing exchange + tunnel WSS + 設定頁「測試連線」三條路徑共用此開關),
|
# (pairing exchange + tunnel WSS + 設定頁「測試連線」三條路徑共用此開關),
|
||||||
# 否則全部 x509 失敗,與本腳本的 demo 目的自相矛盾。
|
# 否則全部 x509 失敗,與本腳本的 demo 目的自相矛盾。
|
||||||
|
|||||||
141
local-agent/vendor/ffmpeg/macos/BUILD.md
vendored
141
local-agent/vendor/ffmpeg/macos/BUILD.md
vendored
@ -18,17 +18,36 @@ LGPL static build 來源,採「自 build decoder-only」策略,binary 直接
|
|||||||
| Toolchain | Apple clang 16.0.0 (clang-1600.0.26.6), Command Line Tools |
|
| Toolchain | Apple clang 16.0.0 (clang-1600.0.26.6), Command Line Tools |
|
||||||
| Assembler | nasm 3.01(Homebrew bottle,compiled 2025-10-11) |
|
| Assembler | nasm 3.01(Homebrew bottle,compiled 2025-10-11) |
|
||||||
| Homebrew | 5.1.6 |
|
| Homebrew | 5.1.6 |
|
||||||
| Build date | 2026-04-15 |
|
| Build date | 2026-08-03(camera raw capture fix:decoder 白名單加 `rawvideo`) |
|
||||||
| Build flags | 見下方 Configure flags 區塊(與 `Makefile` 的 `vendor-ffmpeg-macos-build` target 一致) |
|
| Build flags | 見下方 Configure flags 區塊(與 `Makefile` 的 `vendor-ffmpeg-macos-build` target 一致) |
|
||||||
|
|
||||||
|
> **camera raw capture fix(2026-08-03)**:decoder 白名單加 `rawvideo`。
|
||||||
|
> avfoundation 攝影機吐 raw `uyvy422`(codec = `rawvideo`),要轉成 MJPEG pipe 必須先「解碼 rawvideo」,
|
||||||
|
> 但先前 decoder-only 白名單沒有 `rawvideo` → `Decoding requested, but no decoder found for: rawvideo` → EOF。
|
||||||
|
> 這是 ADR-020(avfoundation indev)之後 camera 白名單的**第二個盲點**:ADR-020 讓攝影機「能被開啟」,
|
||||||
|
> 但沒處理「攝影機 raw 輸出的解碼」。
|
||||||
|
> **未來 rebuild 別漏**:camera raw capture 需 `rawvideo` decoder + `avfoundation` indev **兩者**齊全。
|
||||||
|
> `rawvideo` 是 libavcodec 內建 native decoder、LGPL-safe(僅 byte 重排 / format 標記、無演算法),不引入任何 GPL 元件;
|
||||||
|
> swscale 已 enable(`rawvideo(uyvy422) decode → swscale 轉 → mjpeg encode` 轉碼鏈齊)。
|
||||||
|
> 只加 macOS(Windows/Linux 用 BtbN full build 內建 rawvideo,不需改)。
|
||||||
|
> 詳見 `.autoflow/05-implementation/camera-pixel-format-rootcause.md`。
|
||||||
|
|
||||||
|
> **ADR-020 變更(2026-08-02)**:configure 加 `--enable-avfoundation` + `--enable-indev=avfoundation`,
|
||||||
|
> 讓 camera 即時推論可用 avfoundation 抓實體攝影機。詳見
|
||||||
|
> `docs/autoflow/04-architecture/adr/adr-020-ffmpeg-camera-indev.md`。
|
||||||
|
> 之前的 decoder-only build(2026-04-15)沒編任何 indev,導致 `Unknown input format: 'avfoundation'`、camera 開不了。
|
||||||
|
|
||||||
## Binary sha256
|
## Binary sha256
|
||||||
|
|
||||||
| 檔案 | sha256 |
|
| 檔案 | sha256 |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `ffmpeg` | `c3cb9f1dad66730267c12fca92c6344d2f8939ab227889caac33005f8947992c` |
|
| `ffmpeg` | `cb42312fa89d2dc8443ae0dab810d42e18d3efefd9b62cf0a2e18762f7a8cdb7` |
|
||||||
| `ffprobe` | `bd388fb4372ed5f7e44ee331a51be6383d702fb2c067bf562cabbdfbdd8b0c5e` |
|
| `ffprobe` | `e7eb9e61a6bd77549cd6e2da9fec25beb67e71c3c3c4b1356ee395c591c0b3e9` |
|
||||||
| `COPYING.LGPLv3` | `da7eabb7bafdf7d3ae5e9f223aa5bdc1eece45ac569dc21b3b037520b4464768` |
|
| `COPYING.LGPLv3` | `da7eabb7bafdf7d3ae5e9f223aa5bdc1eece45ac569dc21b3b037520b4464768` |
|
||||||
|
|
||||||
|
> 舊值(2026-08-02 ADR-020、加 avfoundation indev、無 rawvideo):ffmpeg `1afa56da…6626` / ffprobe `501ec3fb…414e`。
|
||||||
|
> 更舊(2026-04-15 decoder-only、無 indev):ffmpeg `c3cb9f1d…992c` / ffprobe `bd388fb4…0c5e`。
|
||||||
|
|
||||||
計算指令:
|
計算指令:
|
||||||
```bash
|
```bash
|
||||||
shasum -a 256 vendor/ffmpeg/macos/ffmpeg vendor/ffmpeg/macos/ffprobe
|
shasum -a 256 vendor/ffmpeg/macos/ffmpeg vendor/ffmpeg/macos/ffprobe
|
||||||
@ -38,17 +57,24 @@ shasum -a 256 vendor/ffmpeg/macos/ffmpeg vendor/ffmpeg/macos/ffprobe
|
|||||||
|
|
||||||
| 檔案 | Bytes | 人類可讀 |
|
| 檔案 | Bytes | 人類可讀 |
|
||||||
|------|-------|---------|
|
|------|-------|---------|
|
||||||
| `ffmpeg` | 6,007,520 | 5.7 MB |
|
| `ffmpeg` | 6,034,432 | 5.8 MB |
|
||||||
| `ffprobe` | 5,865,568 | 5.6 MB |
|
| `ffprobe` | 5,896,624 | 5.6 MB |
|
||||||
|
|
||||||
實測比 TDD 原估 10–15 MB 小一半,因為 `--disable-everything` + 白名單僅啟用必要 decoder/demuxer/filter,無 GPL 元件。
|
實測比 TDD 原估 10–15 MB 小一半,因為 `--disable-everything` + 白名單僅啟用必要 decoder/demuxer/filter,無 GPL 元件。
|
||||||
|
|
||||||
|
> **rawvideo decoder 體積增量(2026-08-03)**:ffmpeg 6,030,224 → 6,034,432 bytes,僅
|
||||||
|
> **+4,208 bytes(< 0.005 MB)**。rawvideo decoder 只做 byte 重排 / format 標記、無演算法,體積增量極小。
|
||||||
|
|
||||||
|
> **avfoundation indev 體積增量(ADR-020)**:ffmpeg 6,007,520 → 6,030,224 bytes,僅
|
||||||
|
> **+22,704 bytes(+0.02 MB)**,遠低於 ADR-020 估的 < 0.5 MB。因 avfoundation indev 是薄封裝,
|
||||||
|
> 呼叫系統 AVFoundation / CoreMedia / CoreVideo framework,不自帶任何 codec。
|
||||||
|
|
||||||
### Build 實測耗時
|
### Build 實測耗時
|
||||||
|
|
||||||
- **2 分 44 秒**(`make vendor-ffmpeg-macos-build` 的 `time` 量測)
|
- **2 分 17 秒**(2026-08-03 加 rawvideo decoder rebuild,`make vendor-ffmpeg-macos-build` 的 `time` 量測)
|
||||||
- user: 559.60s,system: 56.03s,wall-clock: 164.56s
|
- user: 548.43s,system: 52.22s,wall-clock: 137.43s
|
||||||
- CPU 使用率:~374%(macOS x86_64,8 核 Intel)
|
- CPU 使用率:~437%(macOS x86_64,8 核 Intel)
|
||||||
- 比 TDD 原估 10–20 分鐘快很多,因為 `--disable-everything` 大幅削減編譯單元數量
|
- (2026-08-02 ADR-020 rebuild 為 3 分 57 秒;2026-04-15 首次 decoder-only build 為 2 分 44 秒。本次較快為 build cache / 系統負載差異,rawvideo decoder 本身編譯成本極低)
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
@ -62,7 +88,18 @@ build 不 link 以下 GPL-only 元件:
|
|||||||
- 無 `libfaac`(non-free)
|
- 無 `libfaac`(non-free)
|
||||||
|
|
||||||
僅使用 libavcodec 內建的 LGPL native decoder(h264 / hevc / mpeg1video / mpeg2video /
|
僅使用 libavcodec 內建的 LGPL native decoder(h264 / hevc / mpeg1video / mpeg2video /
|
||||||
mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_*)。
|
mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_* / rawvideo)。
|
||||||
|
|
||||||
|
**`rawvideo` decoder(2026-08-03)為 LGPL-safe**:libavcodec 內建 native decoder,僅做 byte 重排 /
|
||||||
|
pixel format 標記、無壓縮演算法,不引入任何 GPL / 第三方元件。加 `rawvideo` 後 `ffmpeg -version` 的
|
||||||
|
configuration line 仍**不含** `--enable-gpl` / `libx264` / `libx265`(已實測驗證),LGPL v3 合規未破。
|
||||||
|
|
||||||
|
**avfoundation indev(ADR-020)為 LGPL-safe,不引入任何 GPL 元件**:avfoundation input device
|
||||||
|
只是薄封裝、透過 macOS 系統的 AVFoundation / CoreMedia / CoreVideo framework 抓實體攝影機 frame,
|
||||||
|
不含任何第三方 / GPL codec。加 `--enable-avfoundation` + `--enable-indev=avfoundation` 後,
|
||||||
|
`ffmpeg -version` 的 configuration line 仍**不含** `--enable-gpl` / `libx264` / `libx265`(已實測驗證),
|
||||||
|
`--enable-version3`(LGPL v3)合規未破。新增 link 的皆為 Apple 系統 framework(AVFoundation /
|
||||||
|
Foundation / CoreGraphics / libobjc),非第三方 dylib。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -82,8 +119,10 @@ mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_*)。
|
|||||||
--disable-everything \
|
--disable-everything \
|
||||||
--enable-small \
|
--enable-small \
|
||||||
--enable-protocol=file,pipe \
|
--enable-protocol=file,pipe \
|
||||||
|
--enable-avfoundation \
|
||||||
|
--enable-indev=avfoundation \
|
||||||
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 \
|
--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2 \
|
||||||
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be \
|
--enable-decoder=h264,hevc,mpeg1video,mpeg2video,mpeg4,mjpeg,prores,vp8,vp9,aac,mp2,mp3,pcm_s16le,pcm_s16be,rawvideo \
|
||||||
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac \
|
--enable-parser=h264,hevc,mpeg4video,mpegaudio,aac \
|
||||||
--enable-filter=scale,format,fps,null,anull \
|
--enable-filter=scale,format,fps,null,anull \
|
||||||
--enable-muxer=image2pipe,image2,null \
|
--enable-muxer=image2pipe,image2,null \
|
||||||
@ -109,8 +148,10 @@ mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_*)。
|
|||||||
| `--disable-everything` | 先關全部,白名單 enable,確保不額外 link 任何 GPL 元件 |
|
| `--disable-everything` | 先關全部,白名單 enable,確保不額外 link 任何 GPL 元件 |
|
||||||
| `--enable-small` | 最佳化體積而非速度 |
|
| `--enable-small` | 最佳化體積而非速度 |
|
||||||
| `--enable-protocol=file,pipe` | 只開 file:// 和 pipe(ffmpeg 內部 stdin/stdout) |
|
| `--enable-protocol=file,pipe` | 只開 file:// 和 pipe(ffmpeg 內部 stdin/stdout) |
|
||||||
|
| `--enable-avfoundation` | **(ADR-020)** camera 抓實體攝影機需 AVFoundation framework。因本 build 用 `--disable-autodetect`(連 AVFoundation 框架都不自動偵測),必須顯式 `--enable-avfoundation` 才能讓下面的 `avfoundation` indev 的依賴(`avfoundation corevideo coremedia pthreads`)被滿足。**少了這行、`--enable-indev=avfoundation` 會被 configure 靜默 disable(`WARNING: Disabled avfoundation_indev because not all dependencies are satisfied`)** |
|
||||||
|
| `--enable-indev=avfoundation` | **(ADR-020)** camera 即時推論的 macOS input device。ffmpeg `-f avfoundation -i "<index>:none"` 從實體攝影機抓 raw frame → MJPEG pipe。少了它會 `Unknown input format: 'avfoundation'`。LGPL-safe 薄封裝、體積增量 < 0.03MB |
|
||||||
| `--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2` | 對齊 PRD v2 支援的上傳格式 `.mp4 / .avi / .mov / .mpeg / .mpg` |
|
| `--enable-demuxer=mov,avi,mpegps,mpegts,matroska,image2` | 對齊 PRD v2 支援的上傳格式 `.mp4 / .avi / .mov / .mpeg / .mpg` |
|
||||||
| `--enable-decoder=h264,hevc,...` | 涵蓋常見 codec:H.264 / H.265 / MPEG1/2/4 / mjpeg / prores / vp8/9 / AAC / MP2/3 / PCM |
|
| `--enable-decoder=h264,hevc,...,rawvideo` | 涵蓋常見 codec:H.264 / H.265 / MPEG1/2/4 / mjpeg / prores / vp8/9 / AAC / MP2/3 / PCM。**`rawvideo`(2026-08-03)**:camera 經 avfoundation 抓出的 frame 是 raw `uyvy422`(codec = `rawvideo`),轉 MJPEG pipe 前必須先解碼;少了它 → `no decoder found for: rawvideo` → EOF。LGPL-safe native decoder |
|
||||||
| `--enable-parser=...` | 必要,否則某些 decoder 會在碼流切分階段 fail |
|
| `--enable-parser=...` | 必要,否則某些 decoder 會在碼流切分階段 fail |
|
||||||
| `--enable-muxer=image2pipe,image2,null` | 輸出單張 JPEG 或 NULL(測試用) |
|
| `--enable-muxer=image2pipe,image2,null` | 輸出單張 JPEG 或 NULL(測試用) |
|
||||||
| `--enable-encoder=mjpeg` | `-f image2pipe -vcodec mjpeg` 需要 mjpeg encoder(LGPL-safe) |
|
| `--enable-encoder=mjpeg` | `-f image2pipe -vcodec mjpeg` 需要 mjpeg encoder(LGPL-safe) |
|
||||||
@ -192,6 +233,13 @@ codesign -v vendor/ffmpeg/macos/ffprobe
|
|||||||
vendor/ffmpeg/macos/ffmpeg -hide_banner -i <some-sample>.mp4 -f image2pipe -vcodec mjpeg -frames:v 1 -q:v 5 /tmp/test.jpg
|
vendor/ffmpeg/macos/ffmpeg -hide_banner -i <some-sample>.mp4 -f image2pipe -vcodec mjpeg -frames:v 1 -q:v 5 /tmp/test.jpg
|
||||||
file /tmp/test.jpg
|
file /tmp/test.jpg
|
||||||
# 預期:JPEG image data
|
# 預期:JPEG image data
|
||||||
|
|
||||||
|
# 7.(ADR-020)確認 avfoundation indev 有編進去、可列出攝影機
|
||||||
|
vendor/ffmpeg/macos/ffmpeg -hide_banner -devices 2>&1 | grep avfoundation
|
||||||
|
# 預期:D avfoundation
|
||||||
|
vendor/ffmpeg/macos/ffmpeg -hide_banner -f avfoundation -list_devices true -i "" 2>&1
|
||||||
|
# 預期:列出 AVFoundation video/audio devices(不再 Unknown input format: 'avfoundation')
|
||||||
|
# 註:-list_devices true 列完裝置後會以非 0 退出(Error opening input),屬正常,非失敗。
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -254,22 +302,28 @@ $ vendor/ffmpeg/macos/ffmpeg -hide_banner -formats 2>&1 \
|
|||||||
- `mpegts` — MPEG Transport Stream
|
- `mpegts` — MPEG Transport Stream
|
||||||
- `matroska,webm` — ok
|
- `matroska,webm` — ok
|
||||||
|
|
||||||
### 4. Dynamic dependencies (`otool -L`)
|
### 4. Dynamic dependencies (`otool -L`)(ADR-020 rebuild 後)
|
||||||
|
|
||||||
```
|
```
|
||||||
vendor/ffmpeg/macos/ffmpeg:
|
vendor/ffmpeg/macos/ffmpeg:
|
||||||
|
/System/Library/Frameworks/Foundation.framework/.../Foundation ← ADR-020 新增(avfoundation 依賴)
|
||||||
/usr/lib/libSystem.B.dylib
|
/usr/lib/libSystem.B.dylib
|
||||||
/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
|
/System/Library/Frameworks/AVFoundation.framework/.../AVFoundation ← ADR-020 新增
|
||||||
/System/Library/Frameworks/CoreVideo.framework/Versions/A/CoreVideo
|
/System/Library/Frameworks/CoreVideo.framework/.../CoreVideo
|
||||||
/System/Library/Frameworks/CoreMedia.framework/Versions/A/CoreMedia
|
/System/Library/Frameworks/CoreMedia.framework/.../CoreMedia
|
||||||
|
/System/Library/Frameworks/CoreGraphics.framework/.../CoreGraphics ← ADR-020 新增(avfoundation suggest)
|
||||||
|
/System/Library/Frameworks/CoreFoundation.framework/.../CoreFoundation
|
||||||
|
/usr/lib/libobjc.A.dylib ← ADR-020 新增(Objective-C runtime)
|
||||||
|
|
||||||
vendor/ffmpeg/macos/ffprobe:
|
vendor/ffmpeg/macos/ffprobe:
|
||||||
(同上四個 macOS system framework)
|
(同上一組 macOS system framework)
|
||||||
```
|
```
|
||||||
|
|
||||||
- ✅ 只依賴 macOS 系統內建 framework(`libSystem`, `CoreFoundation`, `CoreVideo`, `CoreMedia`)
|
- ✅ 只依賴 macOS 系統內建 framework / dylib(AVFoundation / Foundation / CoreVideo / CoreMedia /
|
||||||
|
CoreGraphics / CoreFoundation / libSystem / libobjc)
|
||||||
- ✅ **無任何第三方 dylib**(`libx264`, `libx265`, `libvpx`, `libopus`... 都不存在)
|
- ✅ **無任何第三方 dylib**(`libx264`, `libx265`, `libvpx`, `libopus`... 都不存在)
|
||||||
- ✅ 等同於 self-contained binary,搬到任一台 macOS 10.15+ x86_64 都能跑
|
- ✅ 新增的皆為 Apple 第一方系統 framework,**LGPL 合規未破**,仍是 self-contained binary,搬到任一台
|
||||||
|
macOS 10.15+ x86_64 都能跑
|
||||||
|
|
||||||
### 5. Code signing
|
### 5. Code signing
|
||||||
|
|
||||||
@ -278,7 +332,54 @@ $ codesign -v vendor/ffmpeg/macos/ffmpeg # exit 0, no output
|
|||||||
$ codesign -v vendor/ffmpeg/macos/ffprobe # exit 0, no output
|
$ codesign -v vendor/ffmpeg/macos/ffprobe # exit 0, no output
|
||||||
```
|
```
|
||||||
|
|
||||||
ad-hoc simbol signing ok,Gatekeeper 可過。
|
ad-hoc symbol signing ok,Gatekeeper 可過。
|
||||||
|
|
||||||
|
### 6. avfoundation indev(ADR-020,本次 rebuild 新增)
|
||||||
|
|
||||||
|
```
|
||||||
|
$ vendor/ffmpeg/macos/ffmpeg -hide_banner -devices 2>&1 | grep avfoundation
|
||||||
|
D avfoundation
|
||||||
|
|
||||||
|
$ vendor/ffmpeg/macos/ffmpeg -hide_banner -f avfoundation -list_devices true -i ""
|
||||||
|
[AVFoundation indev @ ...] AVFoundation video devices:
|
||||||
|
[AVFoundation indev @ ...] [0] FaceTime HD相機(內建)
|
||||||
|
[AVFoundation indev @ ...] [1] Capture screen 0
|
||||||
|
[AVFoundation indev @ ...] AVFoundation audio devices:
|
||||||
|
[AVFoundation indev @ ...] [0] MacBook Pro的麥克風
|
||||||
|
```
|
||||||
|
|
||||||
|
- ✅ `-devices` 列出 `avfoundation`(demuxing supported)
|
||||||
|
- ✅ `-list_devices true` 成功列出實體攝影機(FaceTime HD 相機)+ 音訊裝置
|
||||||
|
- ✅ 不再出現 `Unknown input format: 'avfoundation'`(camera 開不了的根因已解)
|
||||||
|
- 註:`-list_devices true` 列完裝置後以非 0 退出(`Error opening input`)屬正常行為,非失敗。
|
||||||
|
|
||||||
|
### 7. rawvideo decoder(2026-08-03,本次 rebuild 新增)
|
||||||
|
|
||||||
|
```
|
||||||
|
$ vendor/ffmpeg/macos/ffmpeg -hide_banner -decoders 2>&1 | grep -i rawvideo
|
||||||
|
V..... rawvideo
|
||||||
|
```
|
||||||
|
|
||||||
|
- ✅ `rawvideo` decoder 已編入白名單。
|
||||||
|
|
||||||
|
### 8. Camera raw capture 端到端(2026-08-03,決定性驗證)
|
||||||
|
|
||||||
|
```
|
||||||
|
$ vendor/ffmpeg/macos/ffmpeg -f avfoundation -pixel_format uyvy422 \
|
||||||
|
-framerate 30 -video_size 640x480 -i "0:none" \
|
||||||
|
-f image2pipe -vcodec mjpeg -q:v 5 -an -frames:v 1 -y /tmp/cam_ok.jpg
|
||||||
|
Input #0, avfoundation, from '0:none':
|
||||||
|
Stream #0:0: Video: rawvideo (UYVY / 0x59565955), uyvy422, 640x480, 30 tbr
|
||||||
|
Stream mapping:
|
||||||
|
Stream #0:0 -> #0:0 (rawvideo (native) -> mjpeg (native))
|
||||||
|
frame= 1 ... Lsize= 15KiB
|
||||||
|
|
||||||
|
$ file /tmp/cam_ok.jpg
|
||||||
|
/tmp/cam_ok.jpg: JPEG image data, baseline, precision 8, 640x480, components 3
|
||||||
|
```
|
||||||
|
|
||||||
|
- ✅ 攝影機成功打開(`-pixel_format uyvy422`)+ `rawvideo (native) -> mjpeg (native)` 轉碼鏈全通
|
||||||
|
- ✅ 真的從實體攝影機出一張 640x480 JPEG(15 KiB),兩層根因(args pixel_format + rawvideo decoder)都修好。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
BIN
local-agent/vendor/ffmpeg/macos/ffmpeg
vendored
BIN
local-agent/vendor/ffmpeg/macos/ffmpeg
vendored
Binary file not shown.
BIN
local-agent/vendor/ffmpeg/macos/ffprobe
vendored
BIN
local-agent/vendor/ffmpeg/macos/ffprobe
vendored
Binary file not shown.
@ -466,14 +466,60 @@ func (a *App) tryStartTunnel() {
|
|||||||
a.appLog("tunnel: manager created (relay=%s, local=%s, mock=%v, paired=%v)",
|
a.appLog("tunnel: manager created (relay=%s, local=%s, mock=%v, paired=%v)",
|
||||||
relayURL, localAddr, mockMode, sessionToken != "")
|
relayURL, localAddr, mockMode, sessionToken != "")
|
||||||
|
|
||||||
// 有 session token 才立刻啟動;沒有則等使用者從 UI Pair()
|
// 決定啟動時要不要立刻連 tunnel(純函式,方便單元測試;見 shouldAutoStartTunnel)。
|
||||||
if sessionToken != "" {
|
if shouldAutoStartTunnel(sessionToken, a.tokenStore, a.appLog) {
|
||||||
if err := mgr.Start(a.ctx); err != nil {
|
if err := mgr.Start(a.ctx); err != nil {
|
||||||
a.appLog("tunnel: start failed: %v", err)
|
a.appLog("tunnel: start failed: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// shouldAutoStartTunnel 判斷 agent 啟動時是否要立刻連 tunnel(不需使用者從 UI 手動 Pair)。
|
||||||
|
//
|
||||||
|
// 優先序(維持既有:env 優先):
|
||||||
|
// 1. envToken 有值(來自 VISIONA_SESSION_TOKEN)→ true。dev/demo 直接指定 token 的路徑。
|
||||||
|
// 2. envToken 空 → 嘗試從 TokenStore 撈上次配對持久化的 token(token.bin):
|
||||||
|
// - Load 到非空 token → true(免重配對,這是修復核心)。
|
||||||
|
// - Load 空(沒配對過 / 已 Unpair)→ false,等 UI Pair()。
|
||||||
|
// - Load err(解密失敗 / I/O 錯誤)→ false,不擋啟動,等 UI Pair()。
|
||||||
|
//
|
||||||
|
// 注意:回 true 走 Load 分支時,本函式不需要把 token 塞回 Manager config——
|
||||||
|
// Manager.startLocked() 內部本來就會在 cfg.SessionToken == "" 時自己從
|
||||||
|
// cfg.TokenStore.Load() 補 token(manager.go 的「嘗試補 SessionToken」段)。
|
||||||
|
// 本函式只負責「要不要呼叫 Start()」這個決策。
|
||||||
|
//
|
||||||
|
// 修復前的 bug:app.go 只在 envToken != "" 時才呼叫 mgr.Start(),envToken 空時
|
||||||
|
// 直接跳過,導致 Manager 內部的 TokenStore.Load() 那條路永遠走不到,使用者每次
|
||||||
|
// 重啟都要重新配對(token.bin 有存卻沒被讀)。
|
||||||
|
//
|
||||||
|
// logf 用於記錄決策原因(傳 a.appLog);nil 時不記 log(測試方便)。
|
||||||
|
func shouldAutoStartTunnel(envToken string, tokenStore tunnel.TokenStore, logf func(string, ...any)) bool {
|
||||||
|
log := func(format string, args ...any) {
|
||||||
|
if logf != nil {
|
||||||
|
logf(format, args...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if envToken != "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if tokenStore == nil {
|
||||||
|
log("tunnel: no token store; waiting for UI pairing")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
tok, err := tokenStore.Load()
|
||||||
|
if err != nil {
|
||||||
|
// Load 失敗(解密失敗 / I/O 錯誤)不擋啟動,只 log,維持未配對狀態等 UI Pair()。
|
||||||
|
log("tunnel: token store load failed (%v); waiting for UI pairing", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if tok != "" {
|
||||||
|
log("tunnel: found persisted session token; auto-starting tunnel (no re-pairing needed)")
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
log("tunnel: no persisted session token; waiting for UI pairing")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// runStartupStage5 處理 R5-E 階段 5:開瀏覽器。
|
// runStartupStage5 處理 R5-E 階段 5:開瀏覽器。
|
||||||
// AutoOpenBrowser=false → SkipStage 進入階段 6(也會被 skip-timeout 規則處理)
|
// AutoOpenBrowser=false → SkipStage 進入階段 6(也會被 skip-timeout 規則處理)
|
||||||
// AutoOpenBrowser=true → 呼叫 openBrowser 並 CompleteStage(5)
|
// AutoOpenBrowser=true → 呼叫 openBrowser 並 CompleteStage(5)
|
||||||
|
|||||||
@ -23,6 +23,8 @@
|
|||||||
<string>true</string>
|
<string>true</string>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>{{.Info.Copyright}}</string>
|
<string>{{.Info.Copyright}}</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>visionA Agent 需要使用攝影機進行即時推論</string>
|
||||||
{{if .Info.FileAssociations}}
|
{{if .Info.FileAssociations}}
|
||||||
<key>CFBundleDocumentTypes</key>
|
<key>CFBundleDocumentTypes</key>
|
||||||
<array>
|
<array>
|
||||||
|
|||||||
@ -25,6 +25,8 @@
|
|||||||
<string>true</string>
|
<string>true</string>
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>{{.Info.Copyright}}</string>
|
<string>{{.Info.Copyright}}</string>
|
||||||
|
<key>NSCameraUsageDescription</key>
|
||||||
|
<string>visionA Agent 需要使用攝影機進行即時推論</string>
|
||||||
{{if .Info.FileAssociations}}
|
{{if .Info.FileAssociations}}
|
||||||
<key>CFBundleDocumentTypes</key>
|
<key>CFBundleDocumentTypes</key>
|
||||||
<array>
|
<array>
|
||||||
|
|||||||
@ -15,16 +15,17 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Preferences 定義控制台偏好。
|
// Preferences 定義控制台偏好。
|
||||||
// 對應 TDD v2/control-panel.md §4.3 的 Preferences struct。
|
// 對應 TDD v2/control-panel.md §4.3 的 Preferences struct。
|
||||||
type Preferences struct {
|
type Preferences struct {
|
||||||
// AutoOpenBrowser — StartServer 成功後是否自動開瀏覽器。
|
// AutoOpenBrowser — StartServer 成功後是否自動開瀏覽器。
|
||||||
// 預設值由 DefaultPreferences() 依 runtime.GOOS 決定:
|
// 預設值由 DefaultPreferences() 決定:所有平台一律 false。
|
||||||
// macOS / Windows → true
|
// 桌面 GUI 本身就是主要操作介面,不需要啟動時自動彈瀏覽器分頁;
|
||||||
// Linux → false (R5-D2:Linux 桌面環境差異大,預設關)
|
// 使用者若要用雲端 Web UI,自行前往雲端網頁即可。
|
||||||
|
// (改預設 false 只影響全新安裝 / 無 preferences.json 的使用者;
|
||||||
|
// 既有使用者的 preferences.json 已明確存有此欄位,Load 時尊重既有值。)
|
||||||
AutoOpenBrowser bool `json:"autoOpenBrowser"`
|
AutoOpenBrowser bool `json:"autoOpenBrowser"`
|
||||||
|
|
||||||
// Locale — 控制台 UI 的語系覆寫;空字串 → 自動偵測(navigator.language)
|
// Locale — 控制台 UI 的語系覆寫;空字串 → 自動偵測(navigator.language)
|
||||||
@ -34,12 +35,14 @@ type Preferences struct {
|
|||||||
LogRingSize int `json:"logRingSize,omitempty"`
|
LogRingSize int `json:"logRingSize,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefaultPreferences 回傳平台相關的預設值。
|
// DefaultPreferences 回傳預設值。
|
||||||
//
|
//
|
||||||
// R5-D2:Linux 預設關 AutoOpenBrowser;macOS/Windows 預設開。
|
// AutoOpenBrowser 一律預設 false:桌面 GUI 為主要介面,啟動時不自動開瀏覽器;
|
||||||
|
// 使用者若要用雲端 Web UI 自行前往雲端網頁。
|
||||||
|
// (原本 macOS/Windows 預設 true、Linux 預設 false;現統一為 false。)
|
||||||
func DefaultPreferences() Preferences {
|
func DefaultPreferences() Preferences {
|
||||||
return Preferences{
|
return Preferences{
|
||||||
AutoOpenBrowser: runtime.GOOS != "linux",
|
AutoOpenBrowser: false,
|
||||||
Locale: "",
|
Locale: "",
|
||||||
LogRingSize: 0,
|
LogRingSize: 0,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,9 +12,9 @@ import (
|
|||||||
|
|
||||||
func TestDefaultPreferences_PlatformSpecific(t *testing.T) {
|
func TestDefaultPreferences_PlatformSpecific(t *testing.T) {
|
||||||
p := DefaultPreferences()
|
p := DefaultPreferences()
|
||||||
wantOpen := runtime.GOOS != "linux"
|
// 桌面 GUI 為主要介面,所有平台一律預設不自動開瀏覽器。
|
||||||
if p.AutoOpenBrowser != wantOpen {
|
if p.AutoOpenBrowser != false {
|
||||||
t.Fatalf("DefaultPreferences.AutoOpenBrowser=%v on %s, want %v", p.AutoOpenBrowser, runtime.GOOS, wantOpen)
|
t.Fatalf("DefaultPreferences.AutoOpenBrowser=%v on %s, want false", p.AutoOpenBrowser, runtime.GOOS)
|
||||||
}
|
}
|
||||||
if p.Locale != "" {
|
if p.Locale != "" {
|
||||||
t.Fatalf("default Locale=%q, want empty", p.Locale)
|
t.Fatalf("default Locale=%q, want empty", p.Locale)
|
||||||
|
|||||||
@ -35,7 +35,7 @@ func newPipelineTestApp(t *testing.T) (*App, string) {
|
|||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
t.Cleanup(func() { _ = os.RemoveAll(dir) })
|
||||||
a.dataDir = dir
|
a.dataDir = dir
|
||||||
a.prefs = DefaultPreferences() // macOS/Windows: AutoOpenBrowser=true
|
a.prefs = DefaultPreferences() // 所有平台 AutoOpenBrowser=false(測試各自需要 true 時自行覆寫)
|
||||||
return a, dir
|
return a, dir
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
112
local-agent/visiona-agent/tunnel_autostart_test.go
Normal file
112
local-agent/visiona-agent/tunnel_autostart_test.go
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"visiona-agent/internal/tunnel"
|
||||||
|
)
|
||||||
|
|
||||||
|
// errTokenStore 是測試用 TokenStore,Load() 固定回傳指定的 token / error,
|
||||||
|
// 用來覆蓋 shouldAutoStartTunnel 的三種 Load 結果分支。
|
||||||
|
type errTokenStore struct {
|
||||||
|
token string
|
||||||
|
loadErr error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *errTokenStore) Save(string) error { return nil }
|
||||||
|
func (s *errTokenStore) Load() (string, error) { return s.token, s.loadErr }
|
||||||
|
func (s *errTokenStore) Delete() error { return nil }
|
||||||
|
|
||||||
|
// TestShouldAutoStartTunnel 覆蓋 bug 修復的決策邏輯:
|
||||||
|
// - env 有 token(優先序不變)
|
||||||
|
// - env 空 + tokenStore 有 token(修復核心:免重配對自動連)
|
||||||
|
// - env 空 + tokenStore 空(沒配對過 / Unpair 後:等 UI Pair,不自動連)
|
||||||
|
// - env 空 + tokenStore Load 失敗(解密/IO 錯誤:不擋啟動、不自動連)
|
||||||
|
// - env 空 + tokenStore 為 nil(fallback:不自動連)
|
||||||
|
func TestShouldAutoStartTunnel(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
envToken string
|
||||||
|
store tunnel.TokenStore
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "env token set → start (env 優先,dev/demo 路徑不斷)",
|
||||||
|
envToken: "vAs_from_env",
|
||||||
|
store: &errTokenStore{token: "vAs_from_store"}, // 即使 store 也有,env 仍優先
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env empty + store has token → start (修復核心:免重配對)",
|
||||||
|
envToken: "",
|
||||||
|
store: &errTokenStore{token: "vAs_from_store"},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env empty + store empty → no start (沒配對過 / Unpair 後等 UI Pair)",
|
||||||
|
envToken: "",
|
||||||
|
store: &errTokenStore{token: ""},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env empty + store load error → no start (不擋啟動、等 UI Pair)",
|
||||||
|
envToken: "",
|
||||||
|
store: &errTokenStore{loadErr: errors.New("decrypt failed")},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "env empty + nil store → no start (fallback)",
|
||||||
|
envToken: "",
|
||||||
|
store: nil,
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := shouldAutoStartTunnel(tt.envToken, tt.store, nil)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("shouldAutoStartTunnel(%q, %v) = %v, want %v",
|
||||||
|
tt.envToken, tt.store, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShouldAutoStartTunnel_EncryptedStoreRoundTrip 用真正的
|
||||||
|
// EncryptedFileTokenStore(token.bin)驗證「Save 後重啟 → 自動連」的端到端行為,
|
||||||
|
// 對齊使用者實際情境(配對存 token.bin,重啟後應免重配對)。
|
||||||
|
func TestShouldAutoStartTunnel_EncryptedStoreRoundTrip(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
// 第一次配對:建立 store 並存 token(模擬配對流程的 Save)。
|
||||||
|
store1, err := tunnel.NewEncryptedFileTokenStore(dir, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewEncryptedFileTokenStore: %v", err)
|
||||||
|
}
|
||||||
|
if err := store1.Save("vAs_persisted_token"); err != nil {
|
||||||
|
t.Fatalf("Save: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 模擬重啟:用同一個 dataDir 建新 store(token.bin 已存在),env 沒設 token。
|
||||||
|
store2, err := tunnel.NewEncryptedFileTokenStore(dir, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewEncryptedFileTokenStore (restart): %v", err)
|
||||||
|
}
|
||||||
|
if got := shouldAutoStartTunnel("", store2, nil); !got {
|
||||||
|
t.Error("重啟後(env 空 + token.bin 有 token)應自動連 tunnel,got false")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unpair 後(Delete token):重啟應回到未配對、等 UI Pair。
|
||||||
|
if err := store2.Delete(); err != nil {
|
||||||
|
t.Fatalf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
store3, err := tunnel.NewEncryptedFileTokenStore(dir, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewEncryptedFileTokenStore (after unpair): %v", err)
|
||||||
|
}
|
||||||
|
if got := shouldAutoStartTunnel("", store3, nil); got {
|
||||||
|
t.Error("Unpair 後(token.bin 已清)不應自動連 tunnel,got true")
|
||||||
|
}
|
||||||
|
}
|
||||||
216
visionA-backend/internal/api/device_register.go
Normal file
216
visionA-backend/internal/api/device_register.go
Normal file
@ -0,0 +1,216 @@
|
|||||||
|
// device_register.go — POST /api/devices/:id/register 與 /unregister 的 handler。
|
||||||
|
//
|
||||||
|
// 「註冊」語意軸(feature-device-mgmt-tdd §3 / §4,api/api-device-mgmt.md):
|
||||||
|
// - register:把 device 的 registered_at 由 NULL 翻成 now()(未註冊 → 已註冊)。
|
||||||
|
// - unregister:把 registered_at 清成 NULL(退回未註冊),**保留裝置列**。
|
||||||
|
//
|
||||||
|
// 🔴 與 unpair 完全不同(TDD §1 紅線):unpair 軟刪整台 + cascade 撤 token(device 從清單
|
||||||
|
// 消失);unregister 只清單欄 registered_at(device 仍在清單、顯示為未註冊)。兩端點各走各的,
|
||||||
|
// 本檔**絕不呼叫** DeviceUnpairer / Delete / 撤 token,也**不改** devicesUnpairHandler。
|
||||||
|
//
|
||||||
|
// 皆為純雲端 DB 操作(只翻 registered_at、不路由 local agent),用 UUID `:id` 識別
|
||||||
|
// (對齊 ADR-018 FE-A:DB 操作用 UUID、路由操作才用 serial)。
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"visiona-backend/internal/device"
|
||||||
|
)
|
||||||
|
|
||||||
|
// deviceRegisterCommon 執行 register / unregister 共用的前置檢查(步驟 1-5,兩端點一致):
|
||||||
|
//
|
||||||
|
// 1. 缺 UserContext → 500(auth middleware 沒配好,不可 fallthrough)
|
||||||
|
// 2. :id 空 → 400 VALIDATION_FAILED
|
||||||
|
// 3. Get device:ErrNotFound → 404;其他 DB error → WriteDBError
|
||||||
|
// 4. owner 檢查(IDOR 主防線):d.OwnerUserID != userID → 403 FORBIDDEN
|
||||||
|
// 5. representative 檢查:d.IsRepresentative → 409 REPRESENTATIVE_DEVICE
|
||||||
|
//
|
||||||
|
// 回傳 (device, userID, ok);ok=false 時已寫好回應,caller 直接 return。
|
||||||
|
//
|
||||||
|
// owner 檢查對 register/unregister 都必做——不能因「只是翻 flag」省略(TDD §7.2 IDOR)。
|
||||||
|
func deviceRegisterCommon(c *gin.Context, deps Deps, ctx context.Context) (*device.Device, string, bool) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "device id required", nil)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 0.7 security fix C1:強制要求 UserContext 非空(見既有 devices.go 範式)。
|
||||||
|
uc, ok := UserContextFrom(c)
|
||||||
|
if !ok || uc.UserID == "" {
|
||||||
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
|
"missing user context (auth middleware misconfigured?)", nil)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
userID := uc.UserID
|
||||||
|
|
||||||
|
d, err := deps.DeviceRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, device.ErrNotFound) {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
// DB 錯誤經 errors.go 映射(PG down → 503,其餘 → 500),不洩漏 raw DB error。
|
||||||
|
WriteDBError(c, deps.Logger, "get device", err)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// owner 檢查(IDOR 主防線,TDD §7.1/§7.2):沿用既有 handler 慣例(devices.go:197-201)。
|
||||||
|
if d.OwnerUserID != userID {
|
||||||
|
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner of this device", nil)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// representative 檢查(TDD §7.3):representative 是 agent 連線佔位、非真 USB,
|
||||||
|
// 註冊語意不適用。縱深——即使 List 已濾掉 representative(前端拿不到其 UUID),
|
||||||
|
// handler 仍自己擋;repo SetRegistered 的 WHERE 帶 is_representative=false 為第三層。
|
||||||
|
if d.IsRepresentative {
|
||||||
|
WriteError(c, http.StatusConflict, ErrCodeRepresentativeDevice,
|
||||||
|
"representative device cannot be registered", nil)
|
||||||
|
return nil, "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return d, userID, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// devicesRegisterHandler 實作 POST /api/devices/:id/register。
|
||||||
|
//
|
||||||
|
// 行為順序(api-device-mgmt.md §1):共用前置(1-5)→ 已註冊檢查(6,409 ALREADY_REGISTERED)
|
||||||
|
// → SetRegistered(now())(7)→ 200 + 更新後 DeviceListItem(registered_at 非 null)。
|
||||||
|
func devicesRegisterHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if deps.DeviceRepo == nil {
|
||||||
|
WriteNotImplemented(c, "device repo not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
d, userID, ok := deviceRegisterCommon(c, deps, ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已註冊檢查(TDD §3.2):registered_at 非 nil → 409 ALREADY_REGISTERED。
|
||||||
|
// 前端據此顯示「此裝置已註冊」並 refetch。
|
||||||
|
if d.RegisteredAt != nil {
|
||||||
|
WriteError(c, http.StatusConflict, ErrCodeAlreadyRegistered,
|
||||||
|
"device already registered", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if err := deps.DeviceRepo.SetRegistered(ctx, d.ID, &now); err != nil {
|
||||||
|
if errors.Is(err, device.ErrNotFound) {
|
||||||
|
// 競態:Get 之後、SetRegistered 之前 device 被軟刪 / 轉 representative。
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteDBError(c, deps.Logger, "register device", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logOrDefault(deps.Logger).Info("devices: registered",
|
||||||
|
"device_id", d.ID,
|
||||||
|
"user_id", userID,
|
||||||
|
"request_id", RequestIDFrom(c))
|
||||||
|
|
||||||
|
writeDeviceItemAfterRegister(c, deps, ctx, d.ID, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// devicesUnregisterHandler 實作 POST /api/devices/:id/unregister(退回未註冊)。
|
||||||
|
//
|
||||||
|
// 行為順序(api-device-mgmt.md §2):共用前置(1-5)→ SetRegistered(nil)(冪等,未註冊也回 200)
|
||||||
|
// → 200 + 更新後 DeviceListItem(registered_at=null)。
|
||||||
|
//
|
||||||
|
// 🔴 絕不軟刪、不呼叫 DeviceUnpairer、不撤 token、不動 session(TDD §1.2)。與 unpair 各走各的。
|
||||||
|
func devicesUnregisterHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if deps.DeviceRepo == nil {
|
||||||
|
WriteNotImplemented(c, "device repo not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
d, userID, ok := deviceRegisterCommon(c, deps, ctx)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 冪等(TDD §4.1 步驟 2):不做「已註冊才可取消」的硬擋。SetRegistered(nil) 對已 NULL
|
||||||
|
// 的列 UPDATE 到相同值、RowsAffected 仍為 1(WHERE 命中),避免使用者連點兩次第二次報錯。
|
||||||
|
if err := deps.DeviceRepo.SetRegistered(ctx, d.ID, nil); err != nil {
|
||||||
|
if errors.Is(err, device.ErrNotFound) {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteDBError(c, deps.Logger, "unregister device", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logOrDefault(deps.Logger).Info("devices: unregistered",
|
||||||
|
"device_id", d.ID,
|
||||||
|
"user_id", userID,
|
||||||
|
"request_id", RequestIDFrom(c))
|
||||||
|
|
||||||
|
writeDeviceItemAfterRegister(c, deps, ctx, d.ID, userID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeDeviceItemAfterRegister 重新 Get device 並回 200 + 更新後 DeviceListItem。
|
||||||
|
//
|
||||||
|
// 為什麼重新 Get 而非就地拼裝:SetRegistered 只回 error,最新的 registered_at / updated_at
|
||||||
|
// 以 DB 為準最不易出錯(避免手動拼裝與 DB 值漂移)。合併 tunnel 狀態沿用既有 list/get 範式。
|
||||||
|
//
|
||||||
|
// register/unregister 後 device 必然存在(剛剛才 UPDATE 成功),Get 理論上不會 NotFound;
|
||||||
|
// 若極端競態下被刪,回 404(不 panic)。
|
||||||
|
func writeDeviceItemAfterRegister(c *gin.Context, deps Deps, ctx context.Context, id, userID string) {
|
||||||
|
d, err := deps.DeviceRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, device.ErrNotFound) {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "device not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteDBError(c, deps.Logger, "get device after register", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// tunnel 狀態合併:獨立 ctx 給 3s 預算(對齊 list/get,避免被前面 DB 呼叫吃掉 → R-3 誤判)。
|
||||||
|
tunnelCtx, tunnelCancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer tunnelCancel()
|
||||||
|
tunnelAlive, lastSeen := resolveTunnelStatus(
|
||||||
|
tunnelCtx, deps.SessionStore, userID, deps.Logger, "register", RequestIDFrom(c))
|
||||||
|
|
||||||
|
item := DeviceListItem{
|
||||||
|
ID: d.ID,
|
||||||
|
Name: d.Name,
|
||||||
|
DeviceType: d.DeviceType,
|
||||||
|
SerialNumber: d.SerialNumber,
|
||||||
|
AgentID: d.AgentID,
|
||||||
|
RegisteredAt: d.RegisteredAt,
|
||||||
|
RemoteStatus: d.RemoteStatus,
|
||||||
|
LastSeenAt: d.LastSeenAt,
|
||||||
|
LastConnectedAt: d.LastConnectedAt,
|
||||||
|
USBStatus: d.Status,
|
||||||
|
TunnelOnline: tunnelAlive,
|
||||||
|
CreatedAt: d.CreatedAt,
|
||||||
|
UpdatedAt: d.UpdatedAt,
|
||||||
|
}
|
||||||
|
if item.LastSeenAt == nil && tunnelAlive && !lastSeen.IsZero() {
|
||||||
|
ls := lastSeen
|
||||||
|
item.LastSeenAt = &ls
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(c, http.StatusOK, item)
|
||||||
|
}
|
||||||
250
visionA-backend/internal/api/device_register_test.go
Normal file
250
visionA-backend/internal/api/device_register_test.go
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"visiona-backend/internal/device"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newRegisterFixture 建 router(InMemory repo + 無 session),user context = demo-user。
|
||||||
|
// 回傳 router + repo 供測試直接塞 device / 驗 registered_at。
|
||||||
|
func newRegisterFixture(t *testing.T) (*gin.Engine, *device.InMemoryRepository) {
|
||||||
|
t.Helper()
|
||||||
|
repo := device.NewInMemoryRepository()
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(RequestIDMiddleware())
|
||||||
|
r.Use(injectStaticUserContext("demo-user", ""))
|
||||||
|
g := r.Group("/api")
|
||||||
|
registerDeviceRoutes(g, Deps{
|
||||||
|
DeviceRepo: repo,
|
||||||
|
SessionStore: &fakeSessionStore{},
|
||||||
|
})
|
||||||
|
return r, repo
|
||||||
|
}
|
||||||
|
|
||||||
|
func postRegister(t *testing.T, r *gin.Engine, path string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, path, nil))
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
// errCodeOf 解析錯誤回應的 error.code。
|
||||||
|
func errCodeOf(t *testing.T, w *httptest.ResponseRecorder) string {
|
||||||
|
t.Helper()
|
||||||
|
var eb ErrorBody
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &eb), "body=%s", w.Body.String())
|
||||||
|
require.NotNil(t, eb.Error)
|
||||||
|
return eb.Error.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
// dataItemOf 解析成功回應的 data(DeviceListItem map)。
|
||||||
|
func dataItemOf(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var sb SuccessBody
|
||||||
|
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb), "body=%s", w.Body.String())
|
||||||
|
item, ok := sb.Data.(map[string]any)
|
||||||
|
require.True(t, ok, "data should be object, body=%s", w.Body.String())
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// register
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestRegister_Success 未註冊 → register → 200 且 registered_at 非 null。
|
||||||
|
func TestRegister_Success(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "demo-user", Name: "usb", DeviceType: "kl520",
|
||||||
|
SerialNumber: "0xAAAA", // 未註冊:RegisteredAt 留 nil
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/register")
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||||
|
|
||||||
|
item := dataItemOf(t, w)
|
||||||
|
assert.Equal(t, "d1", item["id"])
|
||||||
|
assert.NotNil(t, item["registered_at"], "register 後 registered_at 應非 null")
|
||||||
|
assert.NotEmpty(t, item["registered_at"])
|
||||||
|
|
||||||
|
// repo 端也確認翻轉。
|
||||||
|
got, err := repo.Get(context.Background(), "d1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got.RegisteredAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegister_AlreadyRegistered 已註冊再 register → 409 ALREADY_REGISTERED。
|
||||||
|
func TestRegister_AlreadyRegistered(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
past := time.Now().UTC().Add(-time.Hour)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA",
|
||||||
|
RegisteredAt: &past,
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/register")
|
||||||
|
require.Equal(t, http.StatusConflict, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeAlreadyRegistered, errCodeOf(t, w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegister_NotOwner 非 owner → 403 FORBIDDEN(IDOR 主防線)。
|
||||||
|
func TestRegister_NotOwner(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "someone-else", Name: "usb", SerialNumber: "0xAAAA",
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/register")
|
||||||
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeForbidden, errCodeOf(t, w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegister_NotFound device 不存在 → 404。
|
||||||
|
func TestRegister_NotFound(t *testing.T) {
|
||||||
|
r, _ := newRegisterFixture(t)
|
||||||
|
w := postRegister(t, r, "/api/devices/ghost/register")
|
||||||
|
require.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeNotFound, errCodeOf(t, w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegister_Representative representative device → 409 REPRESENTATIVE_DEVICE。
|
||||||
|
func TestRegister_Representative(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "rep", OwnerUserID: "demo-user", Name: "agent", IsRepresentative: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/rep/register")
|
||||||
|
require.Equal(t, http.StatusConflict, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeRepresentativeDevice, errCodeOf(t, w),
|
||||||
|
"representative 用 REPRESENTATIVE_DEVICE 碼區分於 ALREADY_REGISTERED")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegister_MissingUserContext 缺 UserContext → 500(auth 沒配好不可 fallthrough)。
|
||||||
|
func TestRegister_MissingUserContext(t *testing.T) {
|
||||||
|
repo := device.NewInMemoryRepository()
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA",
|
||||||
|
}))
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(RequestIDMiddleware())
|
||||||
|
// 刻意不注入 UserContext。
|
||||||
|
g := r.Group("/api")
|
||||||
|
registerDeviceRoutes(g, Deps{DeviceRepo: repo, SessionStore: &fakeSessionStore{}})
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/register")
|
||||||
|
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeInternalError, errCodeOf(t, w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// unregister
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// TestUnregister_Success 已註冊 → unregister → 200 且 registered_at=null,device 仍在 List。
|
||||||
|
func TestUnregister_Success(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
past := time.Now().UTC().Add(-time.Hour)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA",
|
||||||
|
RegisteredAt: &past,
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/unregister")
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||||
|
|
||||||
|
item := dataItemOf(t, w)
|
||||||
|
assert.Nil(t, item["registered_at"], "unregister 後 registered_at 應為 null")
|
||||||
|
|
||||||
|
// device 仍存在(未軟刪、保留列)。
|
||||||
|
got, err := repo.Get(context.Background(), "d1")
|
||||||
|
require.NoError(t, err, "unregister 不軟刪、device 應仍在")
|
||||||
|
assert.Nil(t, got.RegisteredAt)
|
||||||
|
|
||||||
|
// 仍列在 List。
|
||||||
|
list, err := repo.List(context.Background(), "demo-user")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, list, 1, "unregister 後 device 仍在清單(與 unpair 不同)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnregister_IdempotentWhenUnregistered 未註冊 → unregister → 200 冪等 no-op。
|
||||||
|
func TestUnregister_IdempotentWhenUnregistered(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA",
|
||||||
|
// RegisteredAt nil = 未註冊
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/unregister")
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "未註冊 unregister 應冪等回 200,body=%s", w.Body.String())
|
||||||
|
item := dataItemOf(t, w)
|
||||||
|
assert.Nil(t, item["registered_at"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnregister_NotOwner 非 owner → 403。
|
||||||
|
func TestUnregister_NotOwner(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
past := time.Now().UTC()
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "someone-else", Name: "usb", SerialNumber: "0xAAAA",
|
||||||
|
RegisteredAt: &past,
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/unregister")
|
||||||
|
require.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeForbidden, errCodeOf(t, w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnregister_Representative representative → 409 REPRESENTATIVE_DEVICE。
|
||||||
|
func TestUnregister_Representative(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "rep", OwnerUserID: "demo-user", Name: "agent", IsRepresentative: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := postRegister(t, r, "/api/devices/rep/unregister")
|
||||||
|
require.Equal(t, http.StatusConflict, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeRepresentativeDevice, errCodeOf(t, w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestUnregister_NotFound device 不存在 → 404。
|
||||||
|
func TestUnregister_NotFound(t *testing.T) {
|
||||||
|
r, _ := newRegisterFixture(t)
|
||||||
|
w := postRegister(t, r, "/api/devices/ghost/unregister")
|
||||||
|
require.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
assert.Equal(t, ErrCodeNotFound, errCodeOf(t, w))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRegisterUnregister_RoundTrip register → 綠,unregister → 退回,device 全程保留。
|
||||||
|
func TestRegisterUnregister_RoundTrip(t *testing.T) {
|
||||||
|
r, repo := newRegisterFixture(t)
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &device.Device{
|
||||||
|
ID: "d1", OwnerUserID: "demo-user", Name: "usb", SerialNumber: "0xAAAA",
|
||||||
|
}))
|
||||||
|
|
||||||
|
// register
|
||||||
|
w := postRegister(t, r, "/api/devices/d1/register")
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
got, _ := repo.Get(context.Background(), "d1")
|
||||||
|
require.NotNil(t, got.RegisteredAt)
|
||||||
|
|
||||||
|
// unregister
|
||||||
|
w = postRegister(t, r, "/api/devices/d1/unregister")
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
got, _ = repo.Get(context.Background(), "d1")
|
||||||
|
require.Nil(t, got.RegisteredAt)
|
||||||
|
|
||||||
|
// device 全程未消失。
|
||||||
|
list, _ := repo.List(context.Background(), "demo-user")
|
||||||
|
require.Len(t, list, 1)
|
||||||
|
}
|
||||||
@ -41,6 +41,11 @@ func registerDeviceRoutes(g *gin.RouterGroup, deps Deps) {
|
|||||||
// Unpair(雛形實作:軟刪 DeviceRepo + CloseSession)
|
// Unpair(雛形實作:軟刪 DeviceRepo + CloseSession)
|
||||||
g.POST("/devices/:id/unpair", devicesUnpairHandler(deps))
|
g.POST("/devices/:id/unpair", devicesUnpairHandler(deps))
|
||||||
|
|
||||||
|
// 註冊軸(feature-device-mgmt P0,純雲端 DB 操作、UUID :id、不 proxy)。
|
||||||
|
// register:registered_at NULL→now();unregister:清 registered_at(保留列,與 unpair 分開)。
|
||||||
|
g.POST("/devices/:id/register", devicesRegisterHandler(deps))
|
||||||
|
g.POST("/devices/:id/unregister", devicesUnregisterHandler(deps))
|
||||||
|
|
||||||
// ADR-019 WP-5:localhost 直連上傳的 one-time token 取得路徑(經既有 tunnel 打
|
// ADR-019 WP-5:localhost 直連上傳的 one-time token 取得路徑(經既有 tunnel 打
|
||||||
// local-agent issue-token)。契約 path 為 /api/devices/:serial/local-upload-ticket,
|
// local-agent issue-token)。契約 path 為 /api/devices/:serial/local-upload-ticket,
|
||||||
// 但 gin/httprouter 要求同層級同名,故沿用 :id 佔位(其值語意為裝置序號 serial,
|
// 但 gin/httprouter 要求同層級同名,故沿用 :id 佔位(其值語意為裝置序號 serial,
|
||||||
|
|||||||
@ -21,6 +21,14 @@ const (
|
|||||||
ErrCodeInvalidSignature = "INVALID_SIGNATURE"
|
ErrCodeInvalidSignature = "INVALID_SIGNATURE"
|
||||||
// ErrCodeConflict 對齊 HTTP 409(例:unique 約束衝突 — 同 owner+serial 重複註冊)。
|
// ErrCodeConflict 對齊 HTTP 409(例:unique 約束衝突 — 同 owner+serial 重複註冊)。
|
||||||
ErrCodeConflict = "CONFLICT"
|
ErrCodeConflict = "CONFLICT"
|
||||||
|
// ErrCodeAlreadyRegistered 對齊 HTTP 409:對已註冊(registered_at 非 null)的 device
|
||||||
|
// 再次呼叫 register。前端據此顯示「此裝置已註冊」並 refetch(feature-device-mgmt-tdd §3.2)。
|
||||||
|
ErrCodeAlreadyRegistered = "ALREADY_REGISTERED"
|
||||||
|
// ErrCodeRepresentativeDevice 對齊 HTTP 409:對 representative device(agent 連線佔位、
|
||||||
|
// 非真實 USB)呼叫 register/unregister。註冊語意只適用真實 USB device
|
||||||
|
// (feature-device-mgmt-tdd §7.3)。與 ALREADY_REGISTERED 分開,讓 FE/TEST 能區分
|
||||||
|
// 「已註冊」與「不可註冊的裝置類型」兩種 409。
|
||||||
|
ErrCodeRepresentativeDevice = "REPRESENTATIVE_DEVICE"
|
||||||
// ErrCodeServiceUnavailable 對齊 HTTP 503。
|
// ErrCodeServiceUnavailable 對齊 HTTP 503。
|
||||||
// DB 接入塊 5.4 fail-fast 策略:PG 連線失敗 / context 逾時 → 503,讓 load balancer 知道
|
// DB 接入塊 5.4 fail-fast 策略:PG 連線失敗 / context 逾時 → 503,讓 load balancer 知道
|
||||||
// 這台不健康,而非回假資料或 500(500 會誤導為「程式 bug」,503 才是「依賴暫時不可用」)。
|
// 這台不健康,而非回假資料或 500(500 會誤導為「程式 bug」,503 才是「依賴暫時不可用」)。
|
||||||
|
|||||||
@ -45,6 +45,9 @@ func registerModelRoutes(g *gin.RouterGroup, deps Deps) {
|
|||||||
// Phase 0.9 模型庫 model 直連 FAA 下載(ADR-017 (a))。
|
// Phase 0.9 模型庫 model 直連 FAA 下載(ADR-017 (a))。
|
||||||
g.GET("/models/:id/download", modelsDownloadHandler(deps))
|
g.GET("/models/:id/download", modelsDownloadHandler(deps))
|
||||||
|
|
||||||
|
// 模型共享(library / profile / visibility / shares)。
|
||||||
|
registerModelSharingRoutes(g, deps)
|
||||||
|
|
||||||
// load-to-device 雛形先 stub(完整實作需要 presigned GET + 透過 tunnel 送指令給 local agent)
|
// load-to-device 雛形先 stub(完整實作需要 presigned GET + 透過 tunnel 送指令給 local agent)
|
||||||
g.POST("/models/:id/load-to-device", func(c *gin.Context) {
|
g.POST("/models/:id/load-to-device", func(c *gin.Context) {
|
||||||
WriteNotImplemented(c, "models.load-to-device — pending Phase 1")
|
WriteNotImplemented(c, "models.load-to-device — pending Phase 1")
|
||||||
@ -67,6 +70,9 @@ type ModelResponse struct {
|
|||||||
InputShape []int `json:"input_shape,omitempty"`
|
InputShape []int `json:"input_shape,omitempty"`
|
||||||
Classes []string `json:"classes,omitempty"`
|
Classes []string `json:"classes,omitempty"`
|
||||||
Framework string `json:"framework,omitempty"`
|
Framework string `json:"framework,omitempty"`
|
||||||
|
// Visibility:模型共享功能新增。既有前端未讀此欄不受影響(加欄相容);
|
||||||
|
// 共享 UI 讀此欄顯示公開對象 badge。既有 model 遷移後為 "private"。
|
||||||
|
Visibility string `json:"visibility,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
||||||
@ -89,6 +95,7 @@ func toModelResponse(m *model.Model) ModelResponse {
|
|||||||
InputShape: m.InputShape,
|
InputShape: m.InputShape,
|
||||||
Classes: m.Classes,
|
Classes: m.Classes,
|
||||||
Framework: m.Framework,
|
Framework: m.Framework,
|
||||||
|
Visibility: m.Visibility,
|
||||||
CreatedAt: m.CreatedAt,
|
CreatedAt: m.CreatedAt,
|
||||||
UpdatedAt: m.UpdatedAt,
|
UpdatedAt: m.UpdatedAt,
|
||||||
UploadedAt: m.UploadedAt,
|
UploadedAt: m.UploadedAt,
|
||||||
@ -559,9 +566,11 @@ func modelsDownloadHandler(deps Deps) gin.HandlerFunc {
|
|||||||
WriteDBError(c, deps.Logger, "get model", err)
|
WriteDBError(c, deps.Logger, "get model", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// 第一階段 owner-only(B 分享後續階段);非 owner 回 403。
|
// 模型共享放寬:owner-only → 共享權限檢查(TDD §5 download 連帶變更)。
|
||||||
if m.OwnerUserID != userID {
|
// 與 profile 可見性共用同一 canAccessModel(single source of truth,杜絕邏輯漂移,SEC-2)。
|
||||||
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
|
// 不命中回 404(不是 403,防 enumeration,與 profile 一致,SEC-1)。
|
||||||
|
if canAccessModel(ctx, uc, m, deps.ModelRepo.GetShare) == model.AccessNone {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -218,9 +218,12 @@ func TestModelsDownload_NotFound(t *testing.T) {
|
|||||||
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
|
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestModelsDownload_ForbiddenWhenNotOwner(t *testing.T) {
|
// TestModelsDownload_NotFoundWhenNoAccess 驗證模型共享後的行為改變(TDD §5 download 放寬):
|
||||||
|
// 非 owner 且無任何可見性(private model)下載,回 404(不是 403)——防 enumeration(SEC-1),
|
||||||
|
// 與 profile 的 canAccessModel 判斷一致(single source of truth)。
|
||||||
|
func TestModelsDownload_NotFoundWhenNoAccess(t *testing.T) {
|
||||||
iss := &fakeIssuer{token: "fdt_x"}
|
iss := &fakeIssuer{token: "fdt_x"}
|
||||||
// 登入 user = demo-user,但 model owner = other-user
|
// 登入 user = demo-user,但 model owner = other-user,且 model 為 private(預設)。
|
||||||
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||||
seedConvertedModel(t, repo, "m-other", "other-user", "models/other-user/job.nef")
|
seedConvertedModel(t, repo, "m-other", "other-user", "models/other-user/job.nef")
|
||||||
|
|
||||||
@ -228,9 +231,63 @@ func TestModelsDownload_ForbiddenWhenNotOwner(t *testing.T) {
|
|||||||
req := httptest.NewRequest(http.MethodGet, "/api/models/m-other/download", nil)
|
req := httptest.NewRequest(http.MethodGet, "/api/models/m-other/download", nil)
|
||||||
r.ServeHTTP(w, req)
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
assert.Equal(t, http.StatusNotFound, w.Code, "private model 非 owner 應回 404(防 enumeration)")
|
||||||
assert.Contains(t, w.Body.String(), ErrCodeForbidden)
|
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
|
||||||
assert.Equal(t, 0, iss.calls, "should not issue token for non-owner")
|
assert.Equal(t, 0, iss.calls, "should not issue token when no access")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModelsDownload_PublicModelNonOwner 驗證 public model 非 owner 也能下載(共享放寬)。
|
||||||
|
func TestModelsDownload_PublicModelNonOwner(t *testing.T) {
|
||||||
|
iss := &fakeIssuer{token: "fdt_pub"}
|
||||||
|
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||||
|
seedConvertedModel(t, repo, "m-pub", "other-user", "models/other-user/pub.nef")
|
||||||
|
// owner 把 model 設為 public。
|
||||||
|
m, err := repo.Get(context.Background(), "m-pub")
|
||||||
|
require.NoError(t, err)
|
||||||
|
m.Visibility = model.VisibilityPublic
|
||||||
|
require.NoError(t, repo.Save(context.Background(), m))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/models/m-pub/download", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code, "public model 非 owner 應可下載,body=%s", w.Body.String())
|
||||||
|
assert.Equal(t, 1, iss.calls, "public model 應簽 download token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModelsDownload_SharedModelNonOwner 驗證被 restricted 分享的 grantee 也能下載。
|
||||||
|
func TestModelsDownload_SharedModelNonOwner(t *testing.T) {
|
||||||
|
iss := &fakeIssuer{token: "fdt_share"}
|
||||||
|
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||||
|
seedConvertedModel(t, repo, "m-shared", "other-user", "models/other-user/shared.nef")
|
||||||
|
// owner 把 model(private)分享給 demo-user(viewer)。
|
||||||
|
require.NoError(t, repo.UpsertShare(context.Background(), &model.ModelShare{
|
||||||
|
ModelID: "m-shared",
|
||||||
|
GranteeUserID: "demo-user",
|
||||||
|
Role: "viewer",
|
||||||
|
GrantedBy: "other-user",
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/models/m-shared/download", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code, "被分享的 grantee 應可下載,body=%s", w.Body.String())
|
||||||
|
assert.Equal(t, 1, iss.calls, "shared model 應簽 download token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestModelsDownload_OwnerStillWorks 回歸:既有 owner 下載仍正常(不因放寬而退化)。
|
||||||
|
func TestModelsDownload_OwnerStillWorks(t *testing.T) {
|
||||||
|
iss := &fakeIssuer{token: "fdt_owner"}
|
||||||
|
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||||
|
seedConvertedModel(t, repo, "m-mine", "demo-user", "models/demo-user/mine.nef")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/models/m-mine/download", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code, "owner 下載應仍正常,body=%s", w.Body.String())
|
||||||
|
assert.Equal(t, 1, iss.calls)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|||||||
791
visionA-backend/internal/api/models_sharing.go
Normal file
791
visionA-backend/internal/api/models_sharing.go
Normal file
@ -0,0 +1,791 @@
|
|||||||
|
// models_sharing.go — 模型共享(Model Sharing)的 handler。
|
||||||
|
//
|
||||||
|
// 端點(對齊 api/api-model-sharing.md):
|
||||||
|
// - GET /api/models/library 共享庫列表(cursor 分頁 + sort/order/q/filter)
|
||||||
|
// - GET /api/models/:id/profile 模型 profile(權限裁剪;不命中回 404)
|
||||||
|
// - PATCH /api/models/:id/visibility 設公開對象(owner-only)
|
||||||
|
// - GET /api/models/:id/shares 列授權清單(owner-only)
|
||||||
|
// - PUT /api/models/:id/shares 加/更新 grantee 授權(owner-only)
|
||||||
|
// - DELETE /api/models/:id/shares/:userId 移除 grantee 授權(owner-only)
|
||||||
|
//
|
||||||
|
// 核心安全設計:所有可見性判斷走唯一的 canAccessModel(single source of truth,避免
|
||||||
|
// profile / download 兩處邏輯漂移,TDD §6 SEC-2);enumeration 防護一律回 404(SEC-1)。
|
||||||
|
//
|
||||||
|
// 對齊:feature-model-sharing-tdd.md §4/§5/§6、api/api-model-sharing.md。
|
||||||
|
|
||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"visiona-backend/internal/auth"
|
||||||
|
"visiona-backend/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// registerModelSharingRoutes 註冊模型共享相關 routes(掛在既有 /api group,走 AuthMiddleware)。
|
||||||
|
func registerModelSharingRoutes(g *gin.RouterGroup, deps Deps) {
|
||||||
|
g.GET("/models/library", modelsLibraryHandler(deps))
|
||||||
|
g.GET("/models/:id/profile", modelsProfileHandler(deps))
|
||||||
|
g.PATCH("/models/:id/visibility", modelsSetVisibilityHandler(deps))
|
||||||
|
g.GET("/models/:id/shares", modelsListSharesHandler(deps))
|
||||||
|
g.PUT("/models/:id/shares", modelsPutShareHandler(deps))
|
||||||
|
g.DELETE("/models/:id/shares/:userId", modelsDeleteShareHandler(deps))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// canAccessModel — single source of truth(可見性判斷)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// canAccessModel 計算 uc 對 m 的有效 AccessLevel。這是 profile / download / (未來) load 的
|
||||||
|
// 唯一權限判斷入口——絕不在別處複製一份可見性邏輯(TDD §6 SEC-2)。
|
||||||
|
//
|
||||||
|
// 判斷順序(取最高權限):
|
||||||
|
// 1. owner(m.OwnerUserID == uc.UserID)→ AccessOwner
|
||||||
|
// 2. share 命中 → editor / viewer(依 share.role)
|
||||||
|
// 3. visibility=public → viewer
|
||||||
|
// 4. visibility=tenant 且 owner.org_id == uc.OrgID 且兩者皆非空 → viewer(SEC-4 tenant 邊界)
|
||||||
|
// 5. 皆不命中 → AccessNone
|
||||||
|
//
|
||||||
|
// preset 由呼叫端(handler)在進 canAccessModel 前處理(preset 無 owner、公用),不走此函式。
|
||||||
|
//
|
||||||
|
// shareLookup 為查 (modelID, granteeUserID) 分享的函式(注入以利測試 / 共用 repo);
|
||||||
|
// 傳 nil 時視為「無任何分享」(僅 visibility 判斷)。
|
||||||
|
func canAccessModel(ctx context.Context, uc *auth.UserContext, m *model.Model,
|
||||||
|
shareLookup func(ctx context.Context, modelID, granteeUserID string) (*model.ModelShare, error),
|
||||||
|
) model.AccessLevel {
|
||||||
|
if uc == nil || uc.UserID == "" || m == nil {
|
||||||
|
return model.AccessNone
|
||||||
|
}
|
||||||
|
// 1. owner
|
||||||
|
if m.OwnerUserID == uc.UserID {
|
||||||
|
return model.AccessOwner
|
||||||
|
}
|
||||||
|
// 2. share 命中
|
||||||
|
if shareLookup != nil {
|
||||||
|
if s, err := shareLookup(ctx, m.ID, uc.UserID); err == nil && s != nil {
|
||||||
|
if s.Role == "editor" {
|
||||||
|
return model.AccessEditor
|
||||||
|
}
|
||||||
|
return model.AccessViewer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 3. public
|
||||||
|
if m.Visibility == model.VisibilityPublic {
|
||||||
|
return model.AccessViewer
|
||||||
|
}
|
||||||
|
// 4. tenant(兩者皆非空才可能命中;空 org 一律不落 tenant 可見)
|
||||||
|
if m.Visibility == model.VisibilityTenant && uc.OrgID != "" && m.OwnerUserID != "" {
|
||||||
|
if ownerOrg := ownerOrgOf(ctx, m); ownerOrg != "" && ownerOrg == uc.OrgID {
|
||||||
|
return model.AccessViewer
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return model.AccessNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// ownerOrgOf 是 tenant 判斷取 owner.org_id 的鉤子。
|
||||||
|
//
|
||||||
|
// 目前 OIDC 不帶 org claim(middleware 未填 UserContext.OrgID,恆空),故 canAccessModel
|
||||||
|
// 第 4 步的前置 `uc.OrgID != ""` 一定為 false、永遠短路——本函式實務上不會被呼叫到。
|
||||||
|
// 保留為明確的擴充點:待 OIDC 補 org claim + repository 提供 owner.org_id 後在此接線。
|
||||||
|
// 現階段回空字串(= tenant 不命中,安全預設)。
|
||||||
|
func ownerOrgOf(_ context.Context, _ *model.Model) string {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// GET /api/models/library
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// LibraryItemResponse 是共享庫列表的一列 DTO(api §1)。
|
||||||
|
//
|
||||||
|
// owner 只揭露 id/name/is_me(不揭露 owner email);不含 storage_key / faa_object_key(SEC-3)。
|
||||||
|
type LibraryItemResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
TargetChip string `json:"target_chip,omitempty"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
Owner OwnerResponse `json:"owner"`
|
||||||
|
SharedWithMe bool `json:"shared_with_me"`
|
||||||
|
MyAccess string `json:"my_access"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OwnerResponse 是裁剪後的 owner 資訊(絕不含 email)。
|
||||||
|
type OwnerResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
IsMe bool `json:"is_me"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryResponse 是 GET /api/models/library 的 data payload。
|
||||||
|
type LibraryResponse struct {
|
||||||
|
Items []LibraryItemResponse `json:"items"`
|
||||||
|
NextCursor string `json:"next_cursor,omitempty"`
|
||||||
|
HasMore bool `json:"has_more"`
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
libraryDefaultLimit = 20
|
||||||
|
libraryMaxLimit = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
// modelsLibraryHandler 實作 GET /api/models/library。
|
||||||
|
func modelsLibraryHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if deps.ModelRepo == nil {
|
||||||
|
// 無 repo(最小骨架):至少回 preset(公用、所有人可見)。
|
||||||
|
WriteSuccess(c, http.StatusOK, LibraryResponse{Items: presetLibraryItems(c), HasMore: false})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uc, ok := UserContextFrom(c)
|
||||||
|
if !ok || uc.UserID == "" {
|
||||||
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
|
"missing user context (auth middleware misconfigured?)", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q, verr := parseLibraryQuery(c, uc)
|
||||||
|
if verr != "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, verr, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
items, hasMore, err := deps.ModelRepo.Library(ctx, q)
|
||||||
|
if err != nil {
|
||||||
|
WriteDBError(c, deps.Logger, "list model library", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := LibraryResponse{
|
||||||
|
Items: make([]LibraryItemResponse, 0, len(items)),
|
||||||
|
HasMore: hasMore,
|
||||||
|
}
|
||||||
|
for _, it := range items {
|
||||||
|
resp.Items = append(resp.Items, toLibraryItemResponse(it, uc.UserID))
|
||||||
|
}
|
||||||
|
if hasMore && len(items) > 0 {
|
||||||
|
last := items[len(items)-1].Model
|
||||||
|
resp.NextCursor = encodeCursor(q.Sort, last)
|
||||||
|
}
|
||||||
|
WriteSuccess(c, http.StatusOK, resp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseLibraryQuery 解析 + 驗證 query 參數,回傳 model.LibraryQuery;驗證失敗回錯誤訊息。
|
||||||
|
func parseLibraryQuery(c *gin.Context, uc *auth.UserContext) (model.LibraryQuery, string) {
|
||||||
|
q := model.LibraryQuery{
|
||||||
|
UserID: uc.UserID,
|
||||||
|
UserOrgID: uc.OrgID, // OIDC 現況恆空 → tenant 不命中
|
||||||
|
TargetChip: c.Query("target_chip"),
|
||||||
|
Source: c.Query("source"),
|
||||||
|
Q: strings.TrimSpace(c.Query("q")),
|
||||||
|
Limit: libraryDefaultLimit,
|
||||||
|
}
|
||||||
|
|
||||||
|
// limit:clamp 到 1–100。
|
||||||
|
if raw := c.Query("limit"); raw != "" {
|
||||||
|
n, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return q, "limit must be an integer"
|
||||||
|
}
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
if n > libraryMaxLimit {
|
||||||
|
n = libraryMaxLimit
|
||||||
|
}
|
||||||
|
q.Limit = n
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort 白名單。
|
||||||
|
switch c.Query("sort") {
|
||||||
|
case "", "created_at":
|
||||||
|
q.Sort = "created_at"
|
||||||
|
case "name":
|
||||||
|
q.Sort = "name"
|
||||||
|
case "file_size":
|
||||||
|
q.Sort = "file_size"
|
||||||
|
default:
|
||||||
|
return q, "sort must be one of: created_at, name, file_size"
|
||||||
|
}
|
||||||
|
// order 白名單。
|
||||||
|
switch c.Query("order") {
|
||||||
|
case "", "desc":
|
||||||
|
q.Order = "desc"
|
||||||
|
case "asc":
|
||||||
|
q.Order = "asc"
|
||||||
|
default:
|
||||||
|
return q, "order must be asc or desc"
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibility filter(僅 public / tenant 有意義;其他忽略)。
|
||||||
|
switch c.Query("visibility") {
|
||||||
|
case model.VisibilityPublic, model.VisibilityTenant:
|
||||||
|
q.Visibility = c.Query("visibility")
|
||||||
|
}
|
||||||
|
|
||||||
|
// owned filter(true/false)。
|
||||||
|
if raw := c.Query("owned"); raw != "" {
|
||||||
|
b, err := strconv.ParseBool(raw)
|
||||||
|
if err != nil {
|
||||||
|
return q, "owned must be a boolean"
|
||||||
|
}
|
||||||
|
q.Owned = &b
|
||||||
|
}
|
||||||
|
|
||||||
|
// cursor(不透明 base64)。
|
||||||
|
if raw := c.Query("cursor"); raw != "" {
|
||||||
|
cur, err := decodeCursor(raw)
|
||||||
|
if err != nil {
|
||||||
|
return q, "invalid cursor"
|
||||||
|
}
|
||||||
|
q.Cursor = cur
|
||||||
|
}
|
||||||
|
|
||||||
|
return q, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// toLibraryItemResponse 把 LibraryItem 轉 DTO。my_access:owner 由 is_me 覆寫為 owner。
|
||||||
|
func toLibraryItemResponse(it *model.LibraryItem, userID string) LibraryItemResponse {
|
||||||
|
m := it.Model
|
||||||
|
status := "pending"
|
||||||
|
if m.UploadedAt != nil {
|
||||||
|
status = "ready"
|
||||||
|
}
|
||||||
|
isMe := m.OwnerUserID == userID
|
||||||
|
access := it.MyAccess
|
||||||
|
if isMe {
|
||||||
|
access = model.AccessOwner
|
||||||
|
}
|
||||||
|
return LibraryItemResponse{
|
||||||
|
ID: m.ID,
|
||||||
|
Name: m.Name,
|
||||||
|
Description: m.Description,
|
||||||
|
TargetChip: m.TargetChip,
|
||||||
|
FileSize: m.FileSize,
|
||||||
|
Source: m.Source,
|
||||||
|
Status: status,
|
||||||
|
Visibility: m.Visibility,
|
||||||
|
Owner: OwnerResponse{
|
||||||
|
ID: m.OwnerUserID,
|
||||||
|
Name: it.OwnerName,
|
||||||
|
IsMe: isMe,
|
||||||
|
},
|
||||||
|
SharedWithMe: it.SharedWithMe,
|
||||||
|
MyAccess: access,
|
||||||
|
CreatedAt: m.CreatedAt,
|
||||||
|
UpdatedAt: m.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// presetLibraryItems 把 preset 轉成 library DTO(公用、is_me=false、my_access=viewer)。
|
||||||
|
func presetLibraryItems(c *gin.Context) []LibraryItemResponse {
|
||||||
|
presets := model.PresetModels()
|
||||||
|
out := make([]LibraryItemResponse, 0, len(presets))
|
||||||
|
for _, m := range presets {
|
||||||
|
status := "ready"
|
||||||
|
out = append(out, LibraryItemResponse{
|
||||||
|
ID: m.ID,
|
||||||
|
Name: m.Name,
|
||||||
|
TargetChip: m.TargetChip,
|
||||||
|
FileSize: m.FileSize,
|
||||||
|
Source: m.Source,
|
||||||
|
Status: status,
|
||||||
|
Visibility: m.Visibility,
|
||||||
|
Owner: OwnerResponse{ID: "", Name: "system", IsMe: false},
|
||||||
|
MyAccess: model.AccessViewer,
|
||||||
|
CreatedAt: m.CreatedAt,
|
||||||
|
UpdatedAt: m.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// cursor 編/解碼(不透明 base64)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// cursorPayload 是 cursor 的 JSON 內容(前端當黑箱)。
|
||||||
|
type cursorPayload struct {
|
||||||
|
V string `json:"v"` // 排序值
|
||||||
|
ID string `json:"id"` // tie-breaker
|
||||||
|
}
|
||||||
|
|
||||||
|
// encodeCursor 依 sort 欄位取 last item 的排序值,組不透明 base64 游標。
|
||||||
|
func encodeCursor(sortField string, last *model.Model) string {
|
||||||
|
var v string
|
||||||
|
switch sortField {
|
||||||
|
case "name":
|
||||||
|
v = last.Name
|
||||||
|
case "file_size":
|
||||||
|
v = strconv.FormatInt(last.FileSize, 10)
|
||||||
|
default: // created_at
|
||||||
|
v = last.CreatedAt.UTC().Format(time.RFC3339Nano)
|
||||||
|
}
|
||||||
|
raw, _ := json.Marshal(cursorPayload{V: v, ID: last.ID})
|
||||||
|
return base64.RawURLEncoding.EncodeToString(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeCursor 解 base64 游標;格式錯誤回 error(handler 轉 400)。
|
||||||
|
func decodeCursor(s string) (*model.Cursor, error) {
|
||||||
|
raw, err := base64.RawURLEncoding.DecodeString(s)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var p cursorPayload
|
||||||
|
if err := json.Unmarshal(raw, &p); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if p.ID == "" {
|
||||||
|
return nil, errors.New("cursor missing id")
|
||||||
|
}
|
||||||
|
return &model.Cursor{SortValue: p.V, ID: p.ID}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// GET /api/models/:id/profile
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// ProfileResponse 是 GET /api/models/:id/profile 的 data payload(api §2)。
|
||||||
|
//
|
||||||
|
// 絕不含 storage_key / faa_object_key / owner email / file_checksum(SEC-3)。
|
||||||
|
type ProfileResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
TargetChip string `json:"target_chip,omitempty"`
|
||||||
|
FileSize int64 `json:"file_size"`
|
||||||
|
Source string `json:"source"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
InputShape []int `json:"input_shape,omitempty"`
|
||||||
|
Classes []string `json:"classes,omitempty"`
|
||||||
|
Framework string `json:"framework,omitempty"`
|
||||||
|
Owner OwnerResponse `json:"owner"`
|
||||||
|
MyAccess string `json:"my_access"`
|
||||||
|
CanDownload bool `json:"can_download"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsProfileHandler 實作 GET /api/models/:id/profile。
|
||||||
|
//
|
||||||
|
// 可見性檢查為第一步;不命中回 404(不是 403,防 enumeration,SEC-1)。
|
||||||
|
func modelsProfileHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// preset 公用、任何登入 user 可見。
|
||||||
|
if pm, ok := model.PresetByID(id); ok {
|
||||||
|
WriteSuccess(c, http.StatusOK, presetProfileResponse(pm))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if deps.ModelRepo == nil {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uc, ok := UserContextFrom(c)
|
||||||
|
if !ok || uc.UserID == "" {
|
||||||
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
|
"missing user context (auth middleware misconfigured?)", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
m, ownerName, err := deps.ModelRepo.GetWithOwner(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteDBError(c, deps.Logger, "get model profile", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
access := canAccessModel(ctx, uc, m, deps.ModelRepo.GetShare)
|
||||||
|
if access == model.AccessNone {
|
||||||
|
// enumeration 防護:不揭露「id 存在但你沒權限」,回 404 與「不存在」無法區分。
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
WriteSuccess(c, http.StatusOK, toProfileResponse(m, ownerName, uc.UserID, access))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toProfileResponse 組 profile DTO(依 access 裁剪;不揭露內部 key)。
|
||||||
|
// ownerName 由 GetWithOwner join users 帶出(api §2 owner.name);owner 未設 name 時為空。
|
||||||
|
func toProfileResponse(m *model.Model, ownerName, userID string, access model.AccessLevel) ProfileResponse {
|
||||||
|
status := "pending"
|
||||||
|
if m.UploadedAt != nil {
|
||||||
|
status = "ready"
|
||||||
|
}
|
||||||
|
return ProfileResponse{
|
||||||
|
ID: m.ID,
|
||||||
|
Name: m.Name,
|
||||||
|
Description: m.Description,
|
||||||
|
TargetChip: m.TargetChip,
|
||||||
|
FileSize: m.FileSize,
|
||||||
|
Source: m.Source,
|
||||||
|
Status: status,
|
||||||
|
Visibility: m.Visibility,
|
||||||
|
InputShape: m.InputShape,
|
||||||
|
Classes: m.Classes,
|
||||||
|
Framework: m.Framework,
|
||||||
|
Owner: OwnerResponse{
|
||||||
|
ID: m.OwnerUserID,
|
||||||
|
Name: ownerName, // join users.name 帶出(SEC-3 白名單:只揭露 id/name/is_me,不含 email)
|
||||||
|
IsMe: m.OwnerUserID == userID,
|
||||||
|
},
|
||||||
|
MyAccess: access,
|
||||||
|
CanDownload: access != model.AccessNone,
|
||||||
|
CreatedAt: m.CreatedAt,
|
||||||
|
UpdatedAt: m.UpdatedAt,
|
||||||
|
UploadedAt: m.UploadedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// presetProfileResponse 組 preset 的 profile(公用、viewer、可下載)。
|
||||||
|
func presetProfileResponse(m *model.Model) ProfileResponse {
|
||||||
|
return ProfileResponse{
|
||||||
|
ID: m.ID,
|
||||||
|
Name: m.Name,
|
||||||
|
Description: m.Description,
|
||||||
|
TargetChip: m.TargetChip,
|
||||||
|
FileSize: m.FileSize,
|
||||||
|
Source: m.Source,
|
||||||
|
Status: "ready",
|
||||||
|
Visibility: model.VisibilityPublic,
|
||||||
|
InputShape: m.InputShape,
|
||||||
|
Classes: m.Classes,
|
||||||
|
Framework: m.Framework,
|
||||||
|
Owner: OwnerResponse{ID: "", Name: "system", IsMe: false},
|
||||||
|
MyAccess: model.AccessViewer,
|
||||||
|
CanDownload: true,
|
||||||
|
CreatedAt: m.CreatedAt,
|
||||||
|
UpdatedAt: m.UpdatedAt,
|
||||||
|
UploadedAt: m.UploadedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// PATCH /api/models/:id/visibility
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// SetVisibilityRequest 是 PATCH visibility 的 body。
|
||||||
|
type SetVisibilityRequest struct {
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVisibilityResponse 是 PATCH visibility 的 data payload。
|
||||||
|
type SetVisibilityResponse struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Visibility string `json:"visibility"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsSetVisibilityHandler 實作 PATCH /api/models/:id/visibility(owner-only)。
|
||||||
|
func modelsSetVisibilityHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
if deps.ModelRepo == nil {
|
||||||
|
WriteNotImplemented(c, "model repo not configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// preset 不可改 visibility(公用、無 owner)。
|
||||||
|
if model.IsPresetID(id) {
|
||||||
|
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "preset visibility is fixed", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uc, ok := UserContextFrom(c)
|
||||||
|
if !ok || uc.UserID == "" {
|
||||||
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
|
"missing user context (auth middleware misconfigured?)", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req SetVisibilityRequest
|
||||||
|
if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "invalid JSON: "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !model.IsValidVisibility(req.Visibility) {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||||||
|
"visibility must be one of: private, tenant, public",
|
||||||
|
[]FieldError{{Field: "visibility", Message: "invalid value"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// tenant 但 user 無 org → 400(無租戶歸屬不能設 tenant 可見,api §3)。
|
||||||
|
if req.Visibility == model.VisibilityTenant && uc.OrgID == "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||||||
|
"cannot set tenant visibility without an organization",
|
||||||
|
[]FieldError{{Field: "visibility", Message: "no org membership"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
m, err := deps.ModelRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteDBError(c, deps.Logger, "get model", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// owner-only(SEC-5)。非 owner 回 403(此為「改權限」動作,回 403 合理——
|
||||||
|
// 與 profile/download 的 enumeration 情境不同:能走到這代表 model 存在且是寫入意圖)。
|
||||||
|
if m.OwnerUserID != uc.UserID {
|
||||||
|
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 未 ready(未 finalize)不允許公開(api §3 409)。
|
||||||
|
if req.Visibility != model.VisibilityPrivate && m.UploadedAt == nil {
|
||||||
|
WriteError(c, http.StatusConflict, ErrCodeConflict,
|
||||||
|
"model must be ready (finalized) before it can be shared", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
m.Visibility = req.Visibility
|
||||||
|
now := time.Now().UTC()
|
||||||
|
m.UpdatedAt = now
|
||||||
|
if err := deps.ModelRepo.Save(ctx, m); err != nil {
|
||||||
|
WriteDBError(c, deps.Logger, "save model visibility", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logOrDefault(deps.Logger).Info("models: visibility updated",
|
||||||
|
"model_id", m.ID,
|
||||||
|
"user_id", uc.UserID,
|
||||||
|
"visibility", req.Visibility,
|
||||||
|
"request_id", RequestIDFrom(c))
|
||||||
|
|
||||||
|
WriteSuccess(c, http.StatusOK, SetVisibilityResponse{
|
||||||
|
ID: m.ID,
|
||||||
|
Visibility: m.Visibility,
|
||||||
|
UpdatedAt: m.UpdatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// GET/PUT/DELETE /api/models/:id/shares — restricted 分享授權管理(owner-only)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// ShareResponse 是一筆分享授權 DTO(owner 檢視清單用)。
|
||||||
|
//
|
||||||
|
// 只揭露 grantee id + role + 授權時間;不揭露 grantee email(同 owner email 不揭露原則)。
|
||||||
|
type ShareResponse struct {
|
||||||
|
GranteeUserID string `json:"grantee_user_id"`
|
||||||
|
Role string `json:"role"`
|
||||||
|
GrantedBy string `json:"granted_by"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsListSharesHandler 實作 GET /api/models/:id/shares(owner-only)。
|
||||||
|
func modelsListSharesHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
m, uc, ok := requireOwnedModel(c, deps)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
shares, err := deps.ModelRepo.ListShares(ctx, m.ID)
|
||||||
|
if err != nil {
|
||||||
|
WriteDBError(c, deps.Logger, "list model shares", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out := make([]ShareResponse, 0, len(shares))
|
||||||
|
for _, s := range shares {
|
||||||
|
out = append(out, ShareResponse{
|
||||||
|
GranteeUserID: s.GranteeUserID,
|
||||||
|
Role: s.Role,
|
||||||
|
GrantedBy: s.GrantedBy,
|
||||||
|
CreatedAt: s.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_ = uc
|
||||||
|
WriteSuccess(c, http.StatusOK, gin.H{"shares": out})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutShareRequest 是 PUT shares 的 body(加/更新一個 grantee 授權)。
|
||||||
|
type PutShareRequest struct {
|
||||||
|
GranteeUserID string `json:"grantee_user_id"`
|
||||||
|
Role string `json:"role,omitempty"` // 'viewer'(預設)| 'editor'
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsPutShareHandler 實作 PUT /api/models/:id/shares(owner-only;加/更新授權)。
|
||||||
|
func modelsPutShareHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
m, uc, ok := requireOwnedModel(c, deps)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req PutShareRequest
|
||||||
|
if err := json.NewDecoder(c.Request.Body).Decode(&req); err != nil {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "invalid JSON: "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.GranteeUserID = strings.TrimSpace(req.GranteeUserID)
|
||||||
|
if req.GranteeUserID == "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||||||
|
"grantee_user_id is required",
|
||||||
|
[]FieldError{{Field: "grantee_user_id", Message: "cannot be empty"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 不能分享給自己(owner 已有完整權限)。
|
||||||
|
if req.GranteeUserID == uc.UserID {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||||||
|
"cannot share a model with its owner", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
role := req.Role
|
||||||
|
if role == "" {
|
||||||
|
role = "viewer"
|
||||||
|
}
|
||||||
|
if role != "viewer" && role != "editor" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed,
|
||||||
|
"role must be viewer or editor",
|
||||||
|
[]FieldError{{Field: "role", Message: "invalid value"}})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := deps.ModelRepo.UpsertShare(ctx, &model.ModelShare{
|
||||||
|
ModelID: m.ID,
|
||||||
|
GranteeUserID: req.GranteeUserID,
|
||||||
|
Role: role,
|
||||||
|
GrantedBy: uc.UserID,
|
||||||
|
}); err != nil {
|
||||||
|
WriteDBError(c, deps.Logger, "upsert model share", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logOrDefault(deps.Logger).Info("models: share granted",
|
||||||
|
"model_id", m.ID,
|
||||||
|
"user_id", uc.UserID,
|
||||||
|
"grantee", req.GranteeUserID,
|
||||||
|
"role", role,
|
||||||
|
"request_id", RequestIDFrom(c))
|
||||||
|
|
||||||
|
WriteSuccess(c, http.StatusOK, ShareResponse{
|
||||||
|
GranteeUserID: req.GranteeUserID,
|
||||||
|
Role: role,
|
||||||
|
GrantedBy: uc.UserID,
|
||||||
|
CreatedAt: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// modelsDeleteShareHandler 實作 DELETE /api/models/:id/shares/:userId(owner-only;撤銷授權)。
|
||||||
|
func modelsDeleteShareHandler(deps Deps) gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
m, uc, ok := requireOwnedModel(c, deps)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
grantee := c.Param("userId")
|
||||||
|
if grantee == "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "user id required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
if err := deps.ModelRepo.DeleteShare(ctx, m.ID, grantee); err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "share not found", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
WriteDBError(c, deps.Logger, "delete model share", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
logOrDefault(deps.Logger).Info("models: share revoked",
|
||||||
|
"model_id", m.ID,
|
||||||
|
"user_id", uc.UserID,
|
||||||
|
"grantee", grantee,
|
||||||
|
"request_id", RequestIDFrom(c))
|
||||||
|
|
||||||
|
c.Status(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireOwnedModel 是分享授權管理 API 的共用前置:取 model + 驗 owner-only。
|
||||||
|
//
|
||||||
|
// 回傳 (model, userContext, ok);ok=false 時已寫好 error response,呼叫端直接 return。
|
||||||
|
// preset 不可管理分享(無 owner)→ 403。
|
||||||
|
func requireOwnedModel(c *gin.Context, deps Deps) (*model.Model, *auth.UserContext, bool) {
|
||||||
|
if deps.ModelRepo == nil {
|
||||||
|
WriteNotImplemented(c, "model repo not configured")
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
id := c.Param("id")
|
||||||
|
if id == "" {
|
||||||
|
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
if model.IsPresetID(id) {
|
||||||
|
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "preset models cannot be shared", nil)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
uc, ok := UserContextFrom(c)
|
||||||
|
if !ok || uc.UserID == "" {
|
||||||
|
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||||
|
"missing user context (auth middleware misconfigured?)", nil)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 3*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
m, err := deps.ModelRepo.Get(ctx, id)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, model.ErrNotFound) {
|
||||||
|
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
WriteDBError(c, deps.Logger, "get model", err)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
if m.OwnerUserID != uc.UserID {
|
||||||
|
// 分享授權管理是 owner-only 寫入意圖:非 owner 回 403。
|
||||||
|
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
|
||||||
|
return nil, nil, false
|
||||||
|
}
|
||||||
|
return m, uc, true
|
||||||
|
}
|
||||||
514
visionA-backend/internal/api/models_sharing_test.go
Normal file
514
visionA-backend/internal/api/models_sharing_test.go
Normal file
@ -0,0 +1,514 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"visiona-backend/internal/auth"
|
||||||
|
"visiona-backend/internal/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// canAccessModelForTest 以 userID(無 org)包一層 canAccessModel,方便單元測試。
|
||||||
|
func canAccessModelForTest(ctx context.Context, userID string, m *model.Model,
|
||||||
|
shareLookup func(context.Context, string, string) (*model.ModelShare, error),
|
||||||
|
) model.AccessLevel {
|
||||||
|
return canAccessModel(ctx, &auth.UserContext{UserID: userID}, m, shareLookup)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// fixture
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// newSharingFixture 建一個「以 userID 身份登入」的模型共享 route fixture。
|
||||||
|
func newSharingFixture(t *testing.T, userID string) (*gin.Engine, *model.InMemoryRepository) {
|
||||||
|
t.Helper()
|
||||||
|
repo := model.NewInMemoryRepository()
|
||||||
|
r := gin.New()
|
||||||
|
r.Use(RequestIDMiddleware())
|
||||||
|
r.Use(injectStaticUserContext(userID, ""))
|
||||||
|
g := r.Group("/api")
|
||||||
|
registerModelRoutes(g, Deps{
|
||||||
|
ModelRepo: repo,
|
||||||
|
MaxUploadSizeMB: 10,
|
||||||
|
})
|
||||||
|
return r, repo
|
||||||
|
}
|
||||||
|
|
||||||
|
// seedReadyModel 塞一個 ready(已 finalize)的 model,指定 owner + visibility。
|
||||||
|
func seedReadyModel(t *testing.T, repo *model.InMemoryRepository, id, owner, visibility string) *model.Model {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
m := &model.Model{
|
||||||
|
ID: id,
|
||||||
|
OwnerUserID: owner,
|
||||||
|
Name: "model-" + id,
|
||||||
|
StorageKey: "models/" + owner + "/" + id + ".nef",
|
||||||
|
FileSize: 1024,
|
||||||
|
Source: model.SourceUploaded,
|
||||||
|
Visibility: visibility,
|
||||||
|
UploadedAt: &now, // ready
|
||||||
|
}
|
||||||
|
require.NoError(t, repo.Save(context.Background(), m))
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// decodeData 解 envelope 的 data 到 target。
|
||||||
|
func decodeData(t *testing.T, body []byte, target any) {
|
||||||
|
t.Helper()
|
||||||
|
var sb SuccessBody
|
||||||
|
require.NoError(t, json.Unmarshal(body, &sb))
|
||||||
|
raw, err := json.Marshal(sb.Data)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, json.Unmarshal(raw, target))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// GET /api/models/library — 可見性 matrix
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// TestLibrary_VisibilityMatrix 驗證共享庫只列可見 model:我的 + public + shared,
|
||||||
|
// 不含別人的 private(TDD §4.1 predicate)。
|
||||||
|
func TestLibrary_VisibilityMatrix(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
|
||||||
|
seedReadyModel(t, repo, "mine-priv", "me", model.VisibilityPrivate) // 我的 private → 可見
|
||||||
|
seedReadyModel(t, repo, "other-priv", "other", model.VisibilityPrivate) // 別人 private → 不可見
|
||||||
|
seedReadyModel(t, repo, "other-pub", "other", model.VisibilityPublic) // 別人 public → 可見
|
||||||
|
shared := seedReadyModel(t, repo, "other-shared", "other", model.VisibilityPrivate)
|
||||||
|
require.NoError(t, repo.UpsertShare(context.Background(), &model.ModelShare{
|
||||||
|
ModelID: shared.ID, GranteeUserID: "me", Role: "viewer", GrantedBy: "other",
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/models/library?limit=100", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||||
|
|
||||||
|
var resp LibraryResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &resp)
|
||||||
|
|
||||||
|
got := map[string]LibraryItemResponse{}
|
||||||
|
for _, it := range resp.Items {
|
||||||
|
got[it.ID] = it
|
||||||
|
}
|
||||||
|
assert.Contains(t, got, "mine-priv", "我的 private 應可見")
|
||||||
|
assert.Contains(t, got, "other-pub", "別人 public 應可見")
|
||||||
|
assert.Contains(t, got, "other-shared", "分享給我的應可見")
|
||||||
|
assert.NotContains(t, got, "other-priv", "別人 private 不應可見")
|
||||||
|
|
||||||
|
// my_access / is_me / shared_with_me 正確。
|
||||||
|
assert.Equal(t, model.AccessOwner, got["mine-priv"].MyAccess)
|
||||||
|
assert.True(t, got["mine-priv"].Owner.IsMe)
|
||||||
|
assert.Equal(t, model.AccessViewer, got["other-pub"].MyAccess)
|
||||||
|
assert.False(t, got["other-pub"].Owner.IsMe)
|
||||||
|
assert.True(t, got["other-shared"].SharedWithMe, "分享給我的應標 shared_with_me")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibrary_ExcludesNotReady 驗證未 finalize(pending)的 model 不進共享庫。
|
||||||
|
func TestLibrary_ExcludesNotReady(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
// pending model(UploadedAt=nil)。
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||||||
|
ID: "pending", OwnerUserID: "me", Name: "p", StorageKey: "k",
|
||||||
|
FileSize: 1, Source: model.SourceUploaded, Visibility: model.VisibilityPublic,
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "/api/models/library", nil)
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
var resp LibraryResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &resp)
|
||||||
|
assert.Empty(t, resp.Items, "pending model 不應進共享庫")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibrary_OwnedFilter 驗證 owned=true 只回我的、owned=false 只回別人分享/公開的。
|
||||||
|
func TestLibrary_OwnedFilter(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "mine", "me", model.VisibilityPrivate)
|
||||||
|
seedReadyModel(t, repo, "pub", "other", model.VisibilityPublic)
|
||||||
|
|
||||||
|
// owned=true
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/library?owned=true", nil))
|
||||||
|
var mineOnly LibraryResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &mineOnly)
|
||||||
|
require.Len(t, mineOnly.Items, 1)
|
||||||
|
assert.Equal(t, "mine", mineOnly.Items[0].ID)
|
||||||
|
|
||||||
|
// owned=false
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/library?owned=false", nil))
|
||||||
|
var othersOnly LibraryResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &othersOnly)
|
||||||
|
require.Len(t, othersOnly.Items, 1)
|
||||||
|
assert.Equal(t, "pub", othersOnly.Items[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibrary_SearchQ 驗證 q 搜尋 name。
|
||||||
|
func TestLibrary_SearchQ(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
m1 := seedReadyModel(t, repo, "a", "me", model.VisibilityPrivate)
|
||||||
|
m1.Name = "yolov5-detect"
|
||||||
|
require.NoError(t, repo.Save(context.Background(), m1))
|
||||||
|
m2 := seedReadyModel(t, repo, "b", "me", model.VisibilityPrivate)
|
||||||
|
m2.Name = "resnet-classify"
|
||||||
|
require.NoError(t, repo.Save(context.Background(), m2))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/library?q=yolo", nil))
|
||||||
|
var resp LibraryResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &resp)
|
||||||
|
require.Len(t, resp.Items, 1)
|
||||||
|
assert.Equal(t, "a", resp.Items[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibrary_CursorPagination 驗證 cursor 分頁不重複、不遺漏。
|
||||||
|
func TestLibrary_CursorPagination(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
m := seedReadyModel(t, repo, string(rune('a'+i)), "me", model.VisibilityPrivate)
|
||||||
|
// 讓 created_at 有序(sort=name 更穩定,用 name 分頁)。
|
||||||
|
_ = m
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
cursor := ""
|
||||||
|
pages := 0
|
||||||
|
for {
|
||||||
|
url := "/api/models/library?limit=2&sort=name&order=asc"
|
||||||
|
if cursor != "" {
|
||||||
|
url += "&cursor=" + cursor
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, url, nil))
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
var resp LibraryResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &resp)
|
||||||
|
for _, it := range resp.Items {
|
||||||
|
assert.False(t, seen[it.ID], "id %s 重複出現於分頁", it.ID)
|
||||||
|
seen[it.ID] = true
|
||||||
|
}
|
||||||
|
pages++
|
||||||
|
require.Less(t, pages, 10, "分頁不應無限迴圈")
|
||||||
|
if !resp.HasMore {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
cursor = resp.NextCursor
|
||||||
|
require.NotEmpty(t, cursor, "has_more=true 時應有 next_cursor")
|
||||||
|
}
|
||||||
|
assert.Len(t, seen, 5, "所有 model 應被分頁完整走過一次")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLibrary_InvalidParams 驗證非法 sort / limit / cursor 回 400。
|
||||||
|
func TestLibrary_InvalidParams(t *testing.T) {
|
||||||
|
r, _ := newSharingFixture(t, "me")
|
||||||
|
for _, url := range []string{
|
||||||
|
"/api/models/library?sort=bogus",
|
||||||
|
"/api/models/library?limit=abc",
|
||||||
|
"/api/models/library?order=sideways",
|
||||||
|
"/api/models/library?cursor=!!!notbase64!!!",
|
||||||
|
"/api/models/library?owned=maybe",
|
||||||
|
} {
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, url, nil))
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code, "url=%s should be 400, body=%s", url, w.Body.String())
|
||||||
|
assert.Contains(t, w.Body.String(), ErrCodeValidationFailed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// GET /api/models/:id/profile
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// TestProfile_PublicVisibleToNonOwner 驗證 public model 非 owner 可看 profile,
|
||||||
|
// 且 owner.name 有帶出(Minor-1:profile join owner name,對齊 api §2)。
|
||||||
|
func TestProfile_PublicVisibleToNonOwner(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "pub", "other", model.VisibilityPublic)
|
||||||
|
repo.SetUserName("other", "Alice") // owner 顯示名
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/pub/profile", nil))
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||||
|
|
||||||
|
var p ProfileResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &p)
|
||||||
|
assert.Equal(t, model.AccessViewer, p.MyAccess)
|
||||||
|
assert.True(t, p.CanDownload)
|
||||||
|
assert.False(t, p.Owner.IsMe)
|
||||||
|
assert.Equal(t, "other", p.Owner.ID)
|
||||||
|
assert.Equal(t, "Alice", p.Owner.Name, "profile 應帶出 owner name(Minor-1)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProfile_PrivateHiddenReturns404 驗證別人 private model → profile 回 404(防 enumeration)。
|
||||||
|
func TestProfile_PrivateHiddenReturns404(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "secret", "other", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/secret/profile", nil))
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code, "無權限應回 404,不是 403")
|
||||||
|
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProfile_NonExistentReturns404Same 驗證不存在的 id 與無權限的 id 回相同 404(enumeration 防護)。
|
||||||
|
func TestProfile_NonExistentReturns404Same(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "secret", "other", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
wHidden := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(wHidden, httptest.NewRequest(http.MethodGet, "/api/models/secret/profile", nil))
|
||||||
|
wMissing := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(wMissing, httptest.NewRequest(http.MethodGet, "/api/models/does-not-exist/profile", nil))
|
||||||
|
|
||||||
|
assert.Equal(t, wMissing.Code, wHidden.Code, "無權限與不存在應回相同 status")
|
||||||
|
// body 除了 request_id 外結構一致(都是 NOT_FOUND / model not found)。
|
||||||
|
assert.Contains(t, wHidden.Body.String(), "model not found")
|
||||||
|
assert.Contains(t, wMissing.Body.String(), "model not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProfile_NoLeakInternalKeys 驗證 profile 不洩漏 storage_key / faa_object_key / owner email(SEC-3)。
|
||||||
|
func TestProfile_NoLeakInternalKeys(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
m := seedReadyModel(t, repo, "pub", "other", model.VisibilityPublic)
|
||||||
|
m.FAAObjectKey = "models/other/secret-object-key.nef"
|
||||||
|
m.FileChecksum = "sha256-secret"
|
||||||
|
require.NoError(t, repo.Save(context.Background(), m))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/pub/profile", nil))
|
||||||
|
body := w.Body.String()
|
||||||
|
assert.NotContains(t, body, "secret-object-key", "不應洩漏 faa_object_key")
|
||||||
|
assert.NotContains(t, body, "storage_key", "不應輸出 storage_key 欄")
|
||||||
|
assert.NotContains(t, body, m.StorageKey, "不應洩漏 storage_key 值")
|
||||||
|
assert.NotContains(t, body, "sha256-secret", "不應洩漏 file_checksum")
|
||||||
|
assert.NotContains(t, body, "email", "不應輸出 owner email 欄")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProfile_Preset 驗證 preset profile 任何登入 user 可看。
|
||||||
|
func TestProfile_Preset(t *testing.T) {
|
||||||
|
r, _ := newSharingFixture(t, "me")
|
||||||
|
presets := model.PresetModels()
|
||||||
|
require.NotEmpty(t, presets)
|
||||||
|
presetID := presets[0].ID
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/"+presetID+"/profile", nil))
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||||
|
var p ProfileResponse
|
||||||
|
decodeData(t, w.Body.Bytes(), &p)
|
||||||
|
assert.Equal(t, model.VisibilityPublic, p.Visibility)
|
||||||
|
assert.True(t, p.CanDownload)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// PATCH /api/models/:id/visibility
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// TestSetVisibility_OwnerOK 驗證 owner 可改 visibility。
|
||||||
|
func TestSetVisibility_OwnerOK(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility",
|
||||||
|
strings.NewReader(`{"visibility":"public"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||||
|
|
||||||
|
m, err := repo.Get(context.Background(), "m")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, model.VisibilityPublic, m.Visibility)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetVisibility_NonOwnerForbidden 驗證非 owner 改 visibility 回 403。
|
||||||
|
func TestSetVisibility_NonOwnerForbidden(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "other", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility",
|
||||||
|
strings.NewReader(`{"visibility":"public"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), ErrCodeForbidden)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetVisibility_Invalid 驗證非法 visibility 值回 400。
|
||||||
|
func TestSetVisibility_Invalid(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility",
|
||||||
|
strings.NewReader(`{"visibility":"world"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetVisibility_TenantWithoutOrg 驗證 user 無 org 設 tenant 回 400。
|
||||||
|
func TestSetVisibility_TenantWithoutOrg(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me") // injectStaticUserContext 不設 OrgID
|
||||||
|
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/models/m/visibility",
|
||||||
|
strings.NewReader(`{"visibility":"tenant"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code, "無 org 設 tenant 應回 400")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSetVisibility_NotReadyConflict 驗證未 finalize 的 model 設公開回 409。
|
||||||
|
func TestSetVisibility_NotReadyConflict(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
// pending model(UploadedAt=nil)。
|
||||||
|
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||||||
|
ID: "pending", OwnerUserID: "me", Name: "p", StorageKey: "k",
|
||||||
|
FileSize: 1, Source: model.SourceUploaded,
|
||||||
|
}))
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPatch, "/api/models/pending/visibility",
|
||||||
|
strings.NewReader(`{"visibility":"public"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusConflict, w.Code, "未 ready 設公開應回 409")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// shares CRUD(restricted 授權管理)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// TestShares_PutListDelete 驗證 owner 加/列/移除授權完整流程。
|
||||||
|
func TestShares_PutListDelete(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
// PUT 加授權。
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares",
|
||||||
|
strings.NewReader(`{"grantee_user_id":"bob","role":"viewer"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||||
|
|
||||||
|
// GET 列授權。
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/m/shares", nil))
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "bob")
|
||||||
|
|
||||||
|
// DELETE 移除授權。
|
||||||
|
w = httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/models/m/shares/bob", nil))
|
||||||
|
require.Equal(t, http.StatusNoContent, w.Code)
|
||||||
|
|
||||||
|
// 再列應為空。
|
||||||
|
shares, err := repo.ListShares(context.Background(), "m")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, shares)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShares_NonOwnerForbidden 驗證非 owner 不能管理授權。
|
||||||
|
func TestShares_NonOwnerForbidden(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "other", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares",
|
||||||
|
strings.NewReader(`{"grantee_user_id":"bob"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShares_CannotShareToSelf 驗證不能分享給自己。
|
||||||
|
func TestShares_CannotShareToSelf(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares",
|
||||||
|
strings.NewReader(`{"grantee_user_id":"me"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShares_InvalidRole 驗證非法 role 回 400。
|
||||||
|
func TestShares_InvalidRole(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPut, "/api/models/m/shares",
|
||||||
|
strings.NewReader(`{"grantee_user_id":"bob","role":"admin"}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
r.ServeHTTP(w, req)
|
||||||
|
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestShares_DeleteNonExistent 驗證移除不存在的授權回 404。
|
||||||
|
func TestShares_DeleteNonExistent(t *testing.T) {
|
||||||
|
r, repo := newSharingFixture(t, "me")
|
||||||
|
seedReadyModel(t, repo, "m", "me", model.VisibilityPrivate)
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/models/m/shares/ghost", nil))
|
||||||
|
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// canAccessModel 單元測試(single source of truth)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// TestCanAccessModel_Levels 直接驗 canAccessModel 各級判斷。
|
||||||
|
func TestCanAccessModel_Levels(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
noShare := func(context.Context, string, string) (*model.ModelShare, error) {
|
||||||
|
return nil, model.ErrNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
mPrivate := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityPrivate}
|
||||||
|
mPublic := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityPublic}
|
||||||
|
|
||||||
|
// owner
|
||||||
|
assert.Equal(t, model.AccessOwner,
|
||||||
|
canAccessModelForTest(ctx, "owner", mPrivate, noShare))
|
||||||
|
// 無關 user + private → none
|
||||||
|
assert.Equal(t, model.AccessNone,
|
||||||
|
canAccessModelForTest(ctx, "stranger", mPrivate, noShare))
|
||||||
|
// public → viewer
|
||||||
|
assert.Equal(t, model.AccessViewer,
|
||||||
|
canAccessModelForTest(ctx, "stranger", mPublic, noShare))
|
||||||
|
// share viewer
|
||||||
|
shareViewer := func(context.Context, string, string) (*model.ModelShare, error) {
|
||||||
|
return &model.ModelShare{Role: "viewer"}, nil
|
||||||
|
}
|
||||||
|
assert.Equal(t, model.AccessViewer,
|
||||||
|
canAccessModelForTest(ctx, "grantee", mPrivate, shareViewer))
|
||||||
|
// share editor
|
||||||
|
shareEditor := func(context.Context, string, string) (*model.ModelShare, error) {
|
||||||
|
return &model.ModelShare{Role: "editor"}, nil
|
||||||
|
}
|
||||||
|
assert.Equal(t, model.AccessEditor,
|
||||||
|
canAccessModelForTest(ctx, "grantee", mPrivate, shareEditor))
|
||||||
|
// tenant 但 user 無 org(OIDC 現況)→ none(安全預設,stub)
|
||||||
|
mTenant := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityTenant}
|
||||||
|
assert.Equal(t, model.AccessNone,
|
||||||
|
canAccessModelForTest(ctx, "stranger", mTenant, noShare),
|
||||||
|
"tenant + 無 org → none(tenant stub)")
|
||||||
|
}
|
||||||
238
visionA-backend/internal/db/migrate_0006_db_test.go
Normal file
238
visionA-backend/internal/db/migrate_0006_db_test.go
Normal file
@ -0,0 +1,238 @@
|
|||||||
|
//go:build dbtest
|
||||||
|
|
||||||
|
// Migration 0006(模型共享:visibility 欄 + model_shares 表 + index)的真 DB 整合測試。
|
||||||
|
//
|
||||||
|
// build tag `dbtest`:需要 Docker daemon / testcontainers。預設 `go test ./...` 不編譯本檔。
|
||||||
|
// 執行:
|
||||||
|
//
|
||||||
|
// go test -tags=dbtest ./internal/db/...
|
||||||
|
// # 無本機 Docker 時,在 130 補跑:
|
||||||
|
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||||||
|
// go test -tags=dbtest ./internal/db/...
|
||||||
|
//
|
||||||
|
// 對齊 migrations/0006_model_sharing.up.sql / .down.sql 與 feature-model-sharing-tdd.md §3:
|
||||||
|
// 1. apply:models 有 visibility 欄(NOT NULL DEFAULT 'private')、model_shares 表存在、
|
||||||
|
// idx_model_shares_grantee / idx_models_public_active 存在、CHECK constraint 生效。
|
||||||
|
// 2. 既有相容:apply 前既有 model → apply 後 visibility='private'(零行為改變)。
|
||||||
|
// 3. model_shares FK / PK / role CHECK 生效。
|
||||||
|
// 4. rollback 對稱:down 後 visibility 欄與 model_shares 表消失。
|
||||||
|
// 5. re-apply 冪等:up→down→up 不報錯、結果一致。
|
||||||
|
package db_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"visiona-backend/internal/db"
|
||||||
|
"visiona-backend/internal/db/testsupport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// insertRawModel 直接寫入一筆 models(不經 repository),可控制 visibility(傳空用 DB DEFAULT)。
|
||||||
|
// 回傳 model id。
|
||||||
|
func insertRawModel(t *testing.T, tdb *testsupport.TestDB, ownerID, visibility string) string {
|
||||||
|
t.Helper()
|
||||||
|
id := uuid.NewString()
|
||||||
|
ctx := context.Background()
|
||||||
|
if visibility == "" {
|
||||||
|
// 不指定 visibility 欄,走 DB DEFAULT(驗既有相容)。
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source)
|
||||||
|
VALUES ($1, $2, 'raw-model', 'k', 1024, 'uploaded')`,
|
||||||
|
id, ownerID)
|
||||||
|
require.NoError(t, err, "insert raw model (default visibility)")
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source, visibility)
|
||||||
|
VALUES ($1, $2, 'raw-model', 'k', 1024, 'uploaded', $3)`,
|
||||||
|
id, ownerID, visibility)
|
||||||
|
require.NoError(t, err, "insert raw model")
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrate0006_Apply 驗證 up 後 schema 到位(§1)。
|
||||||
|
func TestMigrate0006_Apply(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t) // 已 up 到最新(含 0006)
|
||||||
|
|
||||||
|
assert.True(t, colExists(t, tdb, "models", "visibility"), "models 應有 visibility 欄")
|
||||||
|
assert.True(t, tableExists(t, tdb, "model_shares"), "model_shares 表應存在")
|
||||||
|
|
||||||
|
for _, idx := range []string{"idx_model_shares_grantee", "idx_models_public_active"} {
|
||||||
|
assert.True(t, indexExists(t, tdb, idx), "index %s 應存在", idx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// visibility 應 NOT NULL DEFAULT 'private'。
|
||||||
|
ctx := context.Background()
|
||||||
|
var isNullable, colDefault string
|
||||||
|
err := tdb.Pool.QueryRow(ctx,
|
||||||
|
`SELECT is_nullable, COALESCE(column_default, '') FROM information_schema.columns
|
||||||
|
WHERE table_name = 'models' AND column_name = 'visibility'`).Scan(&isNullable, &colDefault)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "NO", isNullable, "visibility 應 NOT NULL")
|
||||||
|
assert.Contains(t, colDefault, "private", "visibility 預設應為 'private'")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrate0006_ExistingModelDefaultsPrivate 驗證既有 model 遷移後 visibility='private'(§2,關鍵相容性)。
|
||||||
|
func TestMigrate0006_ExistingModelDefaultsPrivate(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer mg.Close()
|
||||||
|
|
||||||
|
// 回退 0006 → models 無 visibility 欄。
|
||||||
|
require.NoError(t, mg.Down(), "down 一步回到 0005")
|
||||||
|
require.False(t, colExists(t, tdb, "models", "visibility"), "down 後不應有 visibility 欄")
|
||||||
|
|
||||||
|
owner := tdb.InsertUser(t, "", "")
|
||||||
|
// 在無 visibility 欄的狀態下塞既有 model。
|
||||||
|
existingID := uuid.NewString()
|
||||||
|
_, err = tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source)
|
||||||
|
VALUES ($1, $2, 'legacy', 'k', 1024, 'uploaded')`,
|
||||||
|
existingID, owner)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 重新 up 0006。
|
||||||
|
require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "re-up 0006")
|
||||||
|
|
||||||
|
var vis string
|
||||||
|
err = tdb.Pool.QueryRow(ctx, `SELECT visibility FROM models WHERE id = $1`, existingID).Scan(&vis)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "private", vis, "既有 model 遷移後 visibility 應為 private(零行為改變)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrate0006_VisibilityCheckConstraint 驗證非法 visibility 被 CHECK 擋下(§1)。
|
||||||
|
func TestMigrate0006_VisibilityCheckConstraint(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
owner := tdb.InsertUser(t, "", "")
|
||||||
|
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source, visibility)
|
||||||
|
VALUES ($1, $2, 'bad', 'k', 1024, 'uploaded', 'world')`,
|
||||||
|
uuid.NewString(), owner)
|
||||||
|
assert.Error(t, err, "非法 visibility 'world' 應被 CHECK constraint 擋下")
|
||||||
|
|
||||||
|
// 合法值可寫入。
|
||||||
|
for _, v := range []string{"private", "tenant", "public"} {
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO models (id, owner_user_id, name, storage_key, file_size, source, visibility)
|
||||||
|
VALUES ($1, $2, 'ok', 'k', 1024, 'uploaded', $3)`,
|
||||||
|
uuid.NewString(), owner, v)
|
||||||
|
assert.NoError(t, err, "合法 visibility %q 應可寫入", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrate0006_ModelSharesConstraints 驗證 model_shares 的 PK / FK / role CHECK(§3)。
|
||||||
|
func TestMigrate0006_ModelSharesConstraints(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
owner := tdb.InsertUser(t, "", "")
|
||||||
|
grantee := tdb.InsertUser(t, "", "")
|
||||||
|
modelID := insertRawModel(t, tdb, owner, "private")
|
||||||
|
|
||||||
|
// 合法 share。
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||||||
|
VALUES ($1, $2, 'viewer', $3)`, modelID, grantee, owner)
|
||||||
|
require.NoError(t, err, "合法 share 應可寫入")
|
||||||
|
|
||||||
|
// PK 重複(同 model + 同 grantee)→ 衝突。
|
||||||
|
_, err = tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||||||
|
VALUES ($1, $2, 'editor', $3)`, modelID, grantee, owner)
|
||||||
|
assert.Error(t, err, "重複 (model_id, grantee_user_id) 應違反 PK")
|
||||||
|
|
||||||
|
// role CHECK:非法 role。
|
||||||
|
grantee2 := tdb.InsertUser(t, "", "")
|
||||||
|
_, err = tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||||||
|
VALUES ($1, $2, 'admin', $3)`, modelID, grantee2, owner)
|
||||||
|
assert.Error(t, err, "非法 role 'admin' 應被 CHECK 擋下")
|
||||||
|
|
||||||
|
// FK:不存在的 model_id。
|
||||||
|
_, err = tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||||||
|
VALUES ($1, $2, 'viewer', $3)`, uuid.NewString(), grantee2, owner)
|
||||||
|
assert.Error(t, err, "不存在的 model_id 應違反 FK")
|
||||||
|
|
||||||
|
// FK:不存在的 grantee_user_id。
|
||||||
|
_, err = tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||||||
|
VALUES ($1, $2, 'viewer', $3)`, modelID, uuid.NewString(), owner)
|
||||||
|
assert.Error(t, err, "不存在的 grantee_user_id 應違反 FK")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrate0006_ModelSharesCascade 驗證 model 硬刪時連帶清 share(ON DELETE CASCADE)。
|
||||||
|
func TestMigrate0006_ModelSharesCascade(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
owner := tdb.InsertUser(t, "", "")
|
||||||
|
grantee := tdb.InsertUser(t, "", "")
|
||||||
|
modelID := insertRawModel(t, tdb, owner, "private")
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||||||
|
VALUES ($1, $2, 'viewer', $3)`, modelID, grantee, owner)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// 硬刪 model(非軟刪)→ share 應連帶消失。
|
||||||
|
_, err = tdb.Pool.Exec(ctx, `DELETE FROM models WHERE id = $1`, modelID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
var n int
|
||||||
|
err = tdb.Pool.QueryRow(ctx,
|
||||||
|
`SELECT count(*) FROM model_shares WHERE model_id = $1`, modelID).Scan(&n)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, 0, n, "model 硬刪後 model_shares 應連帶清空(CASCADE)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrate0006_RollbackSymmetry 驗證 down 對稱:visibility 欄與 model_shares 表消失(§4)。
|
||||||
|
func TestMigrate0006_RollbackSymmetry(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
|
||||||
|
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer mg.Close()
|
||||||
|
|
||||||
|
require.True(t, colExists(t, tdb, "models", "visibility"), "down 前 visibility 欄應存在")
|
||||||
|
require.True(t, tableExists(t, tdb, "model_shares"), "down 前 model_shares 表應存在")
|
||||||
|
|
||||||
|
require.NoError(t, mg.Down(), "down 一步(回退 0006)")
|
||||||
|
|
||||||
|
assert.False(t, colExists(t, tdb, "models", "visibility"), "down 後 visibility 欄應消失")
|
||||||
|
assert.False(t, tableExists(t, tdb, "model_shares"), "down 後 model_shares 表應消失")
|
||||||
|
for _, idx := range []string{"idx_model_shares_grantee", "idx_models_public_active"} {
|
||||||
|
assert.False(t, indexExists(t, tdb, idx), "down 後 index %s 應消失", idx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestMigrate0006_ReApplyIdempotent 驗證 up→down→up 不報錯、結果一致(§5)。
|
||||||
|
func TestMigrate0006_ReApplyIdempotent(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
|
||||||
|
mg, err := db.NewMigrator(tdb.Cfg, discardLog())
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer mg.Close()
|
||||||
|
|
||||||
|
topVer, dirty, err := mg.Version()
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.False(t, dirty)
|
||||||
|
|
||||||
|
require.NoError(t, mg.Down(), "down 一步")
|
||||||
|
require.NoError(t, db.RunMigrations(tdb.Cfg, discardLog()), "重新 up")
|
||||||
|
|
||||||
|
ver, dirty, err := mg.Version()
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, dirty, "up→down→up 後不應 dirty")
|
||||||
|
assert.Equal(t, topVer, ver, "up→down→up 後版本應回到最新")
|
||||||
|
assert.True(t, colExists(t, tdb, "models", "visibility"), "重新 up 後 visibility 欄應再次存在")
|
||||||
|
assert.True(t, tableExists(t, tdb, "model_shares"), "重新 up 後 model_shares 表應再次存在")
|
||||||
|
}
|
||||||
@ -119,6 +119,16 @@ type Repository interface {
|
|||||||
// 實作應更新 UpdatedAt;若為新建則同時設定 CreatedAt。
|
// 實作應更新 UpdatedAt;若為新建則同時設定 CreatedAt。
|
||||||
Save(ctx context.Context, d *Device) error
|
Save(ctx context.Context, d *Device) error
|
||||||
|
|
||||||
|
// SetRegistered 設定 / 清除註冊時間(註冊軸單欄翻轉,feature-device-mgmt-tdd §3.3)。
|
||||||
|
//
|
||||||
|
// - at != nil → 註冊(registered_at = *at)。
|
||||||
|
// - at == nil → 取消註冊(registered_at = NULL),保留列(絕不軟刪 / 撤 token)。
|
||||||
|
//
|
||||||
|
// 只作用於「未刪除、非 representative」的 device(縱深第三層,配合 handler 的 owner /
|
||||||
|
// representative / already-registered 檢查);不符則回 ErrNotFound。register 端的
|
||||||
|
// already-registered 判斷由 handler 先擋(回 409),本方法不重複判。
|
||||||
|
SetRegistered(ctx context.Context, id string, at *time.Time) error
|
||||||
|
|
||||||
// Delete 標記為軟刪除(設定 DeletedAt)。
|
// Delete 標記為軟刪除(設定 DeletedAt)。
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
@ -231,6 +241,30 @@ func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRegistered 設定 / 清除某 device 的 registered_at(單欄翻轉)。
|
||||||
|
//
|
||||||
|
// 語意對齊 PostgresRepository.SetRegistered:只作用於未刪除、非 representative 的 device,
|
||||||
|
// 不符(不存在 / 已軟刪 / representative)回 ErrNotFound(縱深第三層)。一律更新 UpdatedAt。
|
||||||
|
// at==nil 清成未註冊(保留列),at!=nil 設為註冊時間。
|
||||||
|
func (r *InMemoryRepository) SetRegistered(ctx context.Context, id string, at *time.Time) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
d, ok := r.devices[id]
|
||||||
|
if !ok || d.DeletedAt != nil || d.IsRepresentative {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if at != nil {
|
||||||
|
t := at.UTC()
|
||||||
|
d.RegisteredAt = &t
|
||||||
|
} else {
|
||||||
|
d.RegisteredAt = nil
|
||||||
|
}
|
||||||
|
d.UpdatedAt = now
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetRepresentativeByAgentTx 取得某 agent 的 representative device(is_representative=true、
|
// GetRepresentativeByAgentTx 取得某 agent 的 representative device(is_representative=true、
|
||||||
// 未刪除);不存在回 ErrNotFound。in-memory 忽略 q(無交易需求)。
|
// 未刪除);不存在回 ErrNotFound。in-memory 忽略 q(無交易需求)。
|
||||||
//
|
//
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package device
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
@ -174,3 +175,73 @@ func TestInMemoryRepository_GetRepresentativeByAgent(t *testing.T) {
|
|||||||
_, err = r.GetRepresentativeByAgentTx(ctx, nil, "other-agent")
|
_, err = r.GetRepresentativeByAgentTx(ctx, nil, "other-agent")
|
||||||
assert.ErrorIs(t, err, ErrNotFound)
|
assert.ErrorIs(t, err, ErrNotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SetRegistered(註冊軸單欄翻轉,feature-device-mgmt-tdd §3.3)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// SetRegistered set → registered_at 有值;set nil → 清空。
|
||||||
|
func TestInMemoryRepository_SetRegistered_SetAndClear(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{ID: "d1", OwnerUserID: "u", SerialNumber: "S-1"}))
|
||||||
|
|
||||||
|
// 初始未註冊。
|
||||||
|
got, err := r.Get(ctx, "d1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, got.RegisteredAt)
|
||||||
|
|
||||||
|
// set → 已註冊。
|
||||||
|
now := time.Now().UTC()
|
||||||
|
require.NoError(t, r.SetRegistered(ctx, "d1", &now))
|
||||||
|
got, err = r.Get(ctx, "d1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got.RegisteredAt)
|
||||||
|
assert.True(t, now.Equal(*got.RegisteredAt))
|
||||||
|
|
||||||
|
// set nil → 退回未註冊,列仍在。
|
||||||
|
require.NoError(t, r.SetRegistered(ctx, "d1", nil))
|
||||||
|
got, err = r.Get(ctx, "d1")
|
||||||
|
require.NoError(t, err, "unregister 不刪列")
|
||||||
|
assert.Nil(t, got.RegisteredAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 冪等:對已 NULL 的列再 set nil → 成功(no-op)。
|
||||||
|
func TestInMemoryRepository_SetRegistered_ClearIdempotent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{ID: "d1", OwnerUserID: "u", SerialNumber: "S-1"}))
|
||||||
|
|
||||||
|
require.NoError(t, r.SetRegistered(ctx, "d1", nil), "未註冊清 nil 應冪等成功")
|
||||||
|
got, _ := r.Get(ctx, "d1")
|
||||||
|
assert.Nil(t, got.RegisteredAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 對 representative device → ErrNotFound(縱深第三層)。
|
||||||
|
func TestInMemoryRepository_SetRegistered_RejectsRepresentative(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{ID: "rep", OwnerUserID: "u", AgentID: "ag", IsRepresentative: true}))
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
assert.ErrorIs(t, r.SetRegistered(ctx, "rep", &now), ErrNotFound,
|
||||||
|
"representative 不可註冊")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 對已軟刪 device → ErrNotFound。
|
||||||
|
func TestInMemoryRepository_SetRegistered_RejectsDeleted(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{ID: "d1", OwnerUserID: "u", SerialNumber: "S-1"}))
|
||||||
|
require.NoError(t, r.Delete(ctx, "d1"))
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
assert.ErrorIs(t, r.SetRegistered(ctx, "d1", &now), ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 對不存在 device → ErrNotFound。
|
||||||
|
func TestInMemoryRepository_SetRegistered_NotFound(t *testing.T) {
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
assert.ErrorIs(t, r.SetRegistered(context.Background(), "ghost", &now), ErrNotFound)
|
||||||
|
}
|
||||||
|
|||||||
@ -31,6 +31,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@ -292,6 +293,37 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRegistered 設定 / 清除 registered_at(註冊軸單欄 UPDATE,feature-device-mgmt-tdd §3.3)。
|
||||||
|
//
|
||||||
|
// 精準單欄 UPDATE(不走 Save 的全欄 upsert),避免「Get→改欄→Save 回去」的讀寫競態面:
|
||||||
|
//
|
||||||
|
// UPDATE devices SET registered_at = $2, updated_at = now()
|
||||||
|
// WHERE id = $1 AND deleted_at IS NULL AND is_representative = false
|
||||||
|
//
|
||||||
|
// WHERE 的 deleted_at IS NULL + is_representative = false 是縱深第三層(配合 handler 的
|
||||||
|
// owner / representative / already-registered 檢查):對不存在 / 已軟刪 / representative 的
|
||||||
|
// 列 RowsAffected()==0 → 回 ErrNotFound。
|
||||||
|
//
|
||||||
|
// - register:at != nil(handler 已先擋 already-registered,這裡不重複判)。
|
||||||
|
// - unregister:at == nil,清成 NULL;對已 NULL 的列 UPDATE 到相同值 RowsAffected 仍為 1
|
||||||
|
// (WHERE 命中),語意上「取消一個未註冊的 = 已達成目標」(冪等,TDD §4.1)。
|
||||||
|
//
|
||||||
|
// 絕不軟刪、不呼叫 DeviceUnpairer、不碰 token(TDD §1.2 紅線)。
|
||||||
|
func (r *PostgresRepository) SetRegistered(ctx context.Context, id string, at *time.Time) error {
|
||||||
|
const sql = `UPDATE devices
|
||||||
|
SET registered_at = $2, updated_at = now()
|
||||||
|
WHERE id = $1 AND deleted_at IS NULL AND is_representative = false`
|
||||||
|
|
||||||
|
tag, err := r.pool.Exec(ctx, sql, id, at)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("device: pg SetRegistered: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// GetRepresentativeByAgentTx 取得某 agent 的 representative device(is_representative=true、
|
// GetRepresentativeByAgentTx 取得某 agent 的 representative device(is_representative=true、
|
||||||
// 未刪除);不存在回 ErrNotFound(在傳入 Querier / tx 上執行)。
|
// 未刪除);不存在回 ErrNotFound(在傳入 Querier / tx 上執行)。
|
||||||
//
|
//
|
||||||
|
|||||||
@ -743,3 +743,108 @@ func TestPG_ContextCancel(t *testing.T) {
|
|||||||
_, err = r.List(ctx, owner)
|
_, err = r.List(ctx, owner)
|
||||||
assert.Error(t, err, "已取消 ctx 的 List 應回 error")
|
assert.Error(t, err, "已取消 ctx 的 List 應回 error")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SetRegistered(註冊軸單欄 UPDATE,feature-device-mgmt-tdd §3.3 / WS-BE)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// pgInsertAgent 建一筆 agent(滿足 devices.agent_id FK),回傳 agentID。
|
||||||
|
func pgInsertAgent(t *testing.T, tdb *testsupport.TestDB, owner string) string {
|
||||||
|
t.Helper()
|
||||||
|
agentID := uuid.NewString()
|
||||||
|
_, err := tdb.Pool.Exec(context.Background(),
|
||||||
|
`INSERT INTO agents (id, owner_user_id, name) VALUES ($1, $2, 'local-agent')`,
|
||||||
|
agentID, owner)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return agentID
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered set → 已註冊;set nil → 退回未註冊(列保留)。
|
||||||
|
func TestPG_SetRegistered_SetAndClear(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r, tdb, owner := newPGRepo(t)
|
||||||
|
agentID := pgInsertAgent(t, tdb, owner)
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{
|
||||||
|
ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0x11111111", AgentID: agentID,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 初始未註冊。
|
||||||
|
got, err := r.Get(ctx, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Nil(t, got.RegisteredAt)
|
||||||
|
|
||||||
|
// set → 已註冊。
|
||||||
|
now := time.Now().UTC().Truncate(time.Microsecond)
|
||||||
|
require.NoError(t, r.SetRegistered(ctx, id, &now))
|
||||||
|
got, err = r.Get(ctx, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, got.RegisteredAt, "register 後 registered_at 非 null")
|
||||||
|
assert.True(t, now.Equal(*got.RegisteredAt))
|
||||||
|
|
||||||
|
// set nil → 退回未註冊、列仍在(絕不軟刪)。
|
||||||
|
require.NoError(t, r.SetRegistered(ctx, id, nil))
|
||||||
|
got, err = r.Get(ctx, id)
|
||||||
|
require.NoError(t, err, "unregister 不軟刪,Get 應仍取得")
|
||||||
|
assert.Nil(t, got.RegisteredAt)
|
||||||
|
assert.Equal(t, 1, tdb.CountRows(t, "devices"), "unregister 不刪列,devices 仍 1 筆")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 冪等:對已 NULL 的列 set nil → RowsAffected 命中、成功 no-op。
|
||||||
|
func TestPG_SetRegistered_ClearIdempotent(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r, tdb, owner := newPGRepo(t)
|
||||||
|
agentID := pgInsertAgent(t, tdb, owner)
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{
|
||||||
|
ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0x22222222", AgentID: agentID,
|
||||||
|
}))
|
||||||
|
|
||||||
|
// 未註冊再清 → 成功(WHERE 命中、RowsAffected=1、UPDATE 到相同 NULL)。
|
||||||
|
require.NoError(t, r.SetRegistered(ctx, id, nil), "未註冊清 nil 應冪等成功")
|
||||||
|
got, err := r.Get(ctx, id)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Nil(t, got.RegisteredAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 對 representative device → RowsAffected=0 → ErrNotFound(WHERE is_representative=false)。
|
||||||
|
func TestPG_SetRegistered_RejectsRepresentative(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r, tdb, owner := newPGRepo(t)
|
||||||
|
agentID := pgInsertAgent(t, tdb, owner)
|
||||||
|
|
||||||
|
repID := uuid.NewString()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{
|
||||||
|
ID: repID, OwnerUserID: owner, Name: "rep", AgentID: agentID, IsRepresentative: true,
|
||||||
|
}))
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
assert.ErrorIs(t, r.SetRegistered(ctx, repID, &now), ErrNotFound,
|
||||||
|
"representative device 應被 WHERE is_representative=false 擋成 ErrNotFound")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 對已軟刪 device → RowsAffected=0 → ErrNotFound(WHERE deleted_at IS NULL)。
|
||||||
|
func TestPG_SetRegistered_RejectsDeleted(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r, tdb, owner := newPGRepo(t)
|
||||||
|
agentID := pgInsertAgent(t, tdb, owner)
|
||||||
|
|
||||||
|
id := uuid.NewString()
|
||||||
|
require.NoError(t, r.Save(ctx, &Device{
|
||||||
|
ID: id, OwnerUserID: owner, Name: "usb", SerialNumber: "0x33333333", AgentID: agentID,
|
||||||
|
}))
|
||||||
|
require.NoError(t, r.Delete(ctx, id)) // 軟刪
|
||||||
|
|
||||||
|
now := time.Now().UTC()
|
||||||
|
assert.ErrorIs(t, r.SetRegistered(ctx, id, &now), ErrNotFound,
|
||||||
|
"已軟刪 device 應回 ErrNotFound")
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRegistered 對不存在 device → ErrNotFound。
|
||||||
|
func TestPG_SetRegistered_NotFound(t *testing.T) {
|
||||||
|
r, _, _ := newPGRepo(t)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
assert.ErrorIs(t, r.SetRegistered(context.Background(), uuid.NewString(), &now), ErrNotFound)
|
||||||
|
}
|
||||||
|
|||||||
152
visionA-backend/internal/model/inmemory_sharing_test.go
Normal file
152
visionA-backend/internal/model/inmemory_sharing_test.go
Normal file
@ -0,0 +1,152 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// readyModel 建一個 ready(UploadedAt 已設)的 model helper。
|
||||||
|
func readyModel(id, owner, visibility string) *Model {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
return &Model{
|
||||||
|
ID: id, OwnerUserID: owner, Name: "m-" + id,
|
||||||
|
StorageKey: "k/" + id, FileSize: 1024,
|
||||||
|
Source: SourceUploaded, Visibility: visibility, UploadedAt: &now,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInMemory_SaveDefaultsVisibility 驗證 Save 未設 visibility 時預設 private。
|
||||||
|
func TestInMemory_SaveDefaultsVisibility(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
require.NoError(t, r.Save(ctx, &Model{ID: "m", OwnerUserID: "u", Name: "n", StorageKey: "k", Source: SourceUploaded}))
|
||||||
|
got, err := r.Get(ctx, "m")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, VisibilityPrivate, got.Visibility, "未設 visibility 應預設 private")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInMemory_ShareCRUD 驗證 share 的 Upsert / Get / List / Delete。
|
||||||
|
func TestInMemory_ShareCRUD(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "m", GranteeUserID: "bob", Role: "viewer", GrantedBy: "owner"}))
|
||||||
|
got, err := r.GetShare(ctx, "m", "bob")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "viewer", got.Role)
|
||||||
|
|
||||||
|
// upsert 同 grantee → 更新 role。
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "m", GranteeUserID: "bob", Role: "editor", GrantedBy: "owner"}))
|
||||||
|
got, err = r.GetShare(ctx, "m", "bob")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "editor", got.Role, "重複 upsert 應更新 role")
|
||||||
|
|
||||||
|
// list
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "m", GranteeUserID: "alice", Role: "viewer", GrantedBy: "owner"}))
|
||||||
|
shares, err := r.ListShares(ctx, "m")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, shares, 2)
|
||||||
|
|
||||||
|
// delete
|
||||||
|
require.NoError(t, r.DeleteShare(ctx, "m", "bob"))
|
||||||
|
_, err = r.GetShare(ctx, "m", "bob")
|
||||||
|
assert.ErrorIs(t, err, ErrNotFound)
|
||||||
|
|
||||||
|
// delete 不存在 → ErrNotFound
|
||||||
|
assert.ErrorIs(t, r.DeleteShare(ctx, "m", "ghost"), ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInMemory_LibraryVisibility 驗證 Library predicate:我的 ∪ public ∪ shared,排除別人 private。
|
||||||
|
func TestInMemory_LibraryVisibility(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
|
||||||
|
require.NoError(t, r.Save(ctx, readyModel("mine", "me", VisibilityPrivate)))
|
||||||
|
require.NoError(t, r.Save(ctx, readyModel("otherPriv", "other", VisibilityPrivate)))
|
||||||
|
require.NoError(t, r.Save(ctx, readyModel("otherPub", "other", VisibilityPublic)))
|
||||||
|
require.NoError(t, r.Save(ctx, readyModel("otherShared", "other", VisibilityPrivate)))
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: "otherShared", GranteeUserID: "me", Role: "viewer", GrantedBy: "other"}))
|
||||||
|
|
||||||
|
items, _, err := r.Library(ctx, LibraryQuery{UserID: "me", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ids := map[string]*LibraryItem{}
|
||||||
|
for _, it := range items {
|
||||||
|
ids[it.Model.ID] = it
|
||||||
|
}
|
||||||
|
assert.Contains(t, ids, "mine")
|
||||||
|
assert.Contains(t, ids, "otherPub")
|
||||||
|
assert.Contains(t, ids, "otherShared")
|
||||||
|
assert.NotContains(t, ids, "otherPriv", "別人 private 不應可見")
|
||||||
|
assert.True(t, ids["otherShared"].SharedWithMe)
|
||||||
|
assert.Equal(t, AccessOwner, ids["mine"].MyAccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInMemory_LibraryExcludesNotReady 驗證未 ready 的 model 不進 Library。
|
||||||
|
func TestInMemory_LibraryExcludesNotReady(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
require.NoError(t, r.Save(ctx, &Model{ID: "pending", OwnerUserID: "me", Name: "p", StorageKey: "k", Source: SourceUploaded, Visibility: VisibilityPublic}))
|
||||||
|
|
||||||
|
items, _, err := r.Library(ctx, LibraryQuery{UserID: "me", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, items)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInMemory_LibraryTenantStub 驗證 tenant 可見性:有 org 對應才命中(in-memory 用 SetUserOrg 模擬)。
|
||||||
|
func TestInMemory_LibraryTenantStub(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
r.SetUserOrg("owner", "org-1")
|
||||||
|
r.SetUserOrg("teammate", "org-1")
|
||||||
|
r.SetUserOrg("outsider", "org-2")
|
||||||
|
require.NoError(t, r.Save(ctx, readyModel("tenantModel", "owner", VisibilityTenant)))
|
||||||
|
|
||||||
|
// 同 org → 可見
|
||||||
|
items, _, err := r.Library(ctx, LibraryQuery{UserID: "teammate", UserOrgID: "org-1", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
assert.Equal(t, "tenantModel", items[0].Model.ID)
|
||||||
|
|
||||||
|
// 異 org → 不可見
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: "outsider", UserOrgID: "org-2", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, items, "異 org 不應看到 tenant model")
|
||||||
|
|
||||||
|
// 無 org(OIDC 現況)→ 不可見(安全預設)
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: "teammate", UserOrgID: "", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, items, "無 org 不應命中 tenant")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestInMemory_LibraryPaginationStable 驗證 cursor 分頁不重不漏。
|
||||||
|
func TestInMemory_LibraryPaginationStable(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
r := NewInMemoryRepository()
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
require.NoError(t, r.Save(ctx, readyModel(string(rune('a'+i)), "me", VisibilityPrivate)))
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var cursor *Cursor
|
||||||
|
for page := 0; page < 10; page++ {
|
||||||
|
items, hasMore, err := r.Library(ctx, LibraryQuery{
|
||||||
|
UserID: "me", Limit: 2, Sort: "name", Order: "asc", Cursor: cursor,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, it := range items {
|
||||||
|
assert.False(t, seen[it.Model.ID], "分頁重複 %s", it.Model.ID)
|
||||||
|
seen[it.Model.ID] = true
|
||||||
|
}
|
||||||
|
if !hasMore {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
last := items[len(items)-1].Model
|
||||||
|
cursor = &Cursor{ID: last.ID, SortValue: last.Name}
|
||||||
|
}
|
||||||
|
assert.Len(t, seen, 5, "所有 model 應被分頁走過一次")
|
||||||
|
}
|
||||||
@ -8,6 +8,8 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@ -41,6 +43,54 @@ const (
|
|||||||
SourcePreset Source = "preset"
|
SourcePreset Source = "preset"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// Visibility 常數(廣播式公開對象;對齊 feature-model-sharing-tdd.md §3.1)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// Visibility 是 Model 的「公開對象」廣播維度:一個 model 一個值。
|
||||||
|
// 與 model_shares(點對點分享)正交。
|
||||||
|
type Visibility = string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// VisibilityPrivate 僅擁有者可見(= 現況預設行為;新 model 與既有 model 皆為此值)。
|
||||||
|
VisibilityPrivate Visibility = "private"
|
||||||
|
// VisibilityTenant 同租戶(同 org_id)可見。
|
||||||
|
// 依賴 users.org_id;OIDC 現況不帶 org claim(見 postgres_repository.go List 說明),
|
||||||
|
// 故目前 tenant 命中集合恆為空(安全預設)——schema/predicate 就緒,等 OIDC 補 org claim 即生效。
|
||||||
|
VisibilityTenant Visibility = "tenant"
|
||||||
|
// VisibilityPublic 全平台已登入 user 可見。
|
||||||
|
VisibilityPublic Visibility = "public"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsValidVisibility 回報 v 是否為合法的 visibility 值(handler 驗 PATCH 輸入用)。
|
||||||
|
func IsValidVisibility(v string) bool {
|
||||||
|
switch v {
|
||||||
|
case VisibilityPrivate, VisibilityTenant, VisibilityPublic:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// AccessLevel 常數(可見性判斷的結果;對齊 TDD §6 SEC-2 / api §1 my_access)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// AccessLevel 是「當前 user 對某 model 的有效權限」。
|
||||||
|
// 由 canAccessModel(single source of truth)計算,取最高。
|
||||||
|
type AccessLevel = string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// AccessNone 無可見性(不該看到此 model;enumeration 防護一律回 404)。
|
||||||
|
AccessNone AccessLevel = "none"
|
||||||
|
// AccessViewer 可 list / get profile / download(visibility 命中或 share role=viewer)。
|
||||||
|
AccessViewer AccessLevel = "viewer"
|
||||||
|
// AccessEditor 可改 metadata(share role=editor);含 viewer 全部權限。
|
||||||
|
AccessEditor AccessLevel = "editor"
|
||||||
|
// AccessOwner 擁有者,完整權限(可改 visibility / 刪除 / 分享)。
|
||||||
|
AccessOwner AccessLevel = "owner"
|
||||||
|
)
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Model struct(對齊 database.md §2.3)
|
// Model struct(對齊 database.md §2.3)
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@ -79,12 +129,75 @@ type Model struct {
|
|||||||
Source Source `json:"source"`
|
Source Source `json:"source"`
|
||||||
SourceJobID string `json:"sourceJobId,omitempty"`
|
SourceJobID string `json:"sourceJobId,omitempty"`
|
||||||
|
|
||||||
|
// Visibility 是廣播式公開對象(private / tenant / public,對齊 model_sharing 功能)。
|
||||||
|
// 既有 / 新建 model 預設 VisibilityPrivate(DB DEFAULT 'private',零行為改變)。
|
||||||
|
Visibility Visibility `json:"visibility"`
|
||||||
|
|
||||||
CreatedAt time.Time `json:"createdAt"`
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
UpdatedAt time.Time `json:"updatedAt"`
|
UpdatedAt time.Time `json:"updatedAt"`
|
||||||
UploadedAt *time.Time `json:"uploadedAt,omitempty"`
|
UploadedAt *time.Time `json:"uploadedAt,omitempty"`
|
||||||
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
DeletedAt *time.Time `json:"deletedAt,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================================================
|
||||||
|
// ModelShare(點對點分享關聯;對齊 ADR-017 決策 3 B1 / model_shares 表)
|
||||||
|
// ==========================================================================
|
||||||
|
|
||||||
|
// ModelShare 是一筆「model 分享給特定 grantee」的授權紀錄。
|
||||||
|
type ModelShare struct {
|
||||||
|
ModelID string `json:"modelId"`
|
||||||
|
GranteeUserID string `json:"granteeUserId"`
|
||||||
|
Role string `json:"role"` // 'viewer' | 'editor'
|
||||||
|
GrantedBy string `json:"grantedBy"`
|
||||||
|
CreatedAt time.Time `json:"createdAt"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryItem 是共享庫列表的一列:Model + 該列相對於查詢 user 的存取資訊。
|
||||||
|
//
|
||||||
|
// owner_name / owner_org_id 由 repository 一次 JOIN users 帶出(避免 handler N+1)。
|
||||||
|
// SharedWithMe / MyAccess 由 repository 依查詢 user 身份計算填入。
|
||||||
|
type LibraryItem struct {
|
||||||
|
Model *Model
|
||||||
|
OwnerName string
|
||||||
|
OwnerOrgID string
|
||||||
|
SharedWithMe bool
|
||||||
|
MyAccess AccessLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// LibraryQuery 是共享庫列表查詢的參數(對齊 api §1)。
|
||||||
|
//
|
||||||
|
// Viewer 身份(UserID / UserOrgID)決定可見範圍;其餘為 filter / 排序 / cursor 分頁。
|
||||||
|
type LibraryQuery struct {
|
||||||
|
// Viewer 身份(可見性 predicate 的 input)。
|
||||||
|
UserID string
|
||||||
|
UserOrgID string // 空字串 → tenant 維度不命中任何 model(安全預設)
|
||||||
|
|
||||||
|
// filter(皆可選,空值 = 不過濾該維度)。
|
||||||
|
TargetChip string
|
||||||
|
Source Source
|
||||||
|
Visibility Visibility // 僅 'public' / 'tenant' 有意義;'private' 傳入視為忽略
|
||||||
|
Q string // 搜尋 name + description(ILIKE 包含)
|
||||||
|
// Owned:nil = 全部可見;true = 只我的;false = 只別人分享/公開給我的。
|
||||||
|
Owned *bool
|
||||||
|
|
||||||
|
// 排序 + 分頁。
|
||||||
|
Sort string // 'created_at' | 'name' | 'file_size'(handler 已 validate)
|
||||||
|
Order string // 'asc' | 'desc'
|
||||||
|
Limit int // handler 已 clamp 到 1–100
|
||||||
|
Cursor *Cursor // nil = 首頁
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cursor 是 keyset 分頁游標,記錄上一頁最後一筆的排序值 + id tie-breaker。
|
||||||
|
//
|
||||||
|
// 由 handler 以不透明 base64 編碼給前端(見 api §1.3);repository 只吃解碼後的結構。
|
||||||
|
type Cursor struct {
|
||||||
|
// SortValue 是上一頁最後一筆的排序欄位值,型別依 Sort 而定:
|
||||||
|
// created_at → RFC3339 時間字串;name → 字串;file_size → 十進位整數字串。
|
||||||
|
SortValue string `json:"v"`
|
||||||
|
// ID 是上一頁最後一筆的 model id(tie-breaker,保證穩定分頁)。
|
||||||
|
ID string `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
// Filter / Repository
|
// Filter / Repository
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@ -103,6 +216,11 @@ type Repository interface {
|
|||||||
// Get 取得單一 Model;不存在或已刪除回 ErrNotFound。
|
// Get 取得單一 Model;不存在或已刪除回 ErrNotFound。
|
||||||
Get(ctx context.Context, id string) (*Model, error)
|
Get(ctx context.Context, id string) (*Model, error)
|
||||||
|
|
||||||
|
// GetWithOwner 取得單一 Model 並一併帶出 owner 的顯示名稱(一次 join users,避免 N+1)。
|
||||||
|
// 供 profile handler 顯示擁有者名(api §2 owner.name)。ownerName 可能為空(owner 未設 name)。
|
||||||
|
// 不存在或已刪除回 ErrNotFound。
|
||||||
|
GetWithOwner(ctx context.Context, id string) (m *Model, ownerName string, err error)
|
||||||
|
|
||||||
// List 依 filter 列出 Model;filter.OwnerUserID 不同於空字串時限定擁有者。
|
// List 依 filter 列出 Model;filter.OwnerUserID 不同於空字串時限定擁有者。
|
||||||
List(ctx context.Context, filter ListFilter) ([]*Model, error)
|
List(ctx context.Context, filter ListFilter) ([]*Model, error)
|
||||||
|
|
||||||
@ -111,6 +229,29 @@ type Repository interface {
|
|||||||
|
|
||||||
// Delete 軟刪除。
|
// Delete 軟刪除。
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
|
|
||||||
|
// ── 模型共享(model_sharing 功能新增)─────────────────────────────────
|
||||||
|
|
||||||
|
// Library 依查詢 user 身份列出「可見」的 model(我的 ∪ public ∪ tenant同org ∪ 分享給我),
|
||||||
|
// 支援 filter / 排序 / cursor 分頁。回傳 items(已含 owner_name / my_access / shared_with_me)
|
||||||
|
// 與是否還有下一頁(hasMore)。preset 由 handler 層 union,不在此。
|
||||||
|
//
|
||||||
|
// 只列 uploaded_at IS NOT NULL(ready)的 model;共享庫不列未 finalize 的。
|
||||||
|
Library(ctx context.Context, q LibraryQuery) (items []*LibraryItem, hasMore bool, err error)
|
||||||
|
|
||||||
|
// GetShare 取得 (modelID, granteeUserID) 的分享紀錄;不存在回 ErrNotFound。
|
||||||
|
// 供 canAccessModel 單筆查「這個 model 有沒有分享給我」。
|
||||||
|
GetShare(ctx context.Context, modelID, granteeUserID string) (*ModelShare, error)
|
||||||
|
|
||||||
|
// ListShares 列出某 model 的所有分享紀錄(owner 檢視授權清單用)。
|
||||||
|
ListShares(ctx context.Context, modelID string) ([]*ModelShare, error)
|
||||||
|
|
||||||
|
// UpsertShare 新增 / 更新一筆分享(by PK (model_id, grantee_user_id))。
|
||||||
|
// 重複分享同一 grantee → 更新 role。
|
||||||
|
UpsertShare(ctx context.Context, s *ModelShare) error
|
||||||
|
|
||||||
|
// DeleteShare 移除 (modelID, granteeUserID) 的分享;不存在回 ErrNotFound。
|
||||||
|
DeleteShare(ctx context.Context, modelID, granteeUserID string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
@ -149,15 +290,41 @@ func (v *SizeValidator) Check(size int64) error {
|
|||||||
type InMemoryRepository struct {
|
type InMemoryRepository struct {
|
||||||
mu sync.RWMutex
|
mu sync.RWMutex
|
||||||
models map[string]*Model
|
models map[string]*Model
|
||||||
|
// shares 以 modelID → (granteeUserID → *ModelShare) 兩層 map 存分享關聯。
|
||||||
|
shares map[string]map[string]*ModelShare
|
||||||
|
// orgs 記錄 userID → org_id,供 in-memory Library 判 tenant 可見性(測試注入用)。
|
||||||
|
// production 走 Postgres 實作;in-memory 主要供 unit test,故用簡易注入而非 join users。
|
||||||
|
orgs map[string]string
|
||||||
|
// names 記錄 userID → 顯示名稱,供 in-memory GetWithOwner / Library 帶出 owner name。
|
||||||
|
names map[string]string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewInMemoryRepository 建立一個空的記憶體 Repository。
|
// NewInMemoryRepository 建立一個空的記憶體 Repository。
|
||||||
func NewInMemoryRepository() *InMemoryRepository {
|
func NewInMemoryRepository() *InMemoryRepository {
|
||||||
return &InMemoryRepository{
|
return &InMemoryRepository{
|
||||||
models: make(map[string]*Model),
|
models: make(map[string]*Model),
|
||||||
|
shares: make(map[string]map[string]*ModelShare),
|
||||||
|
orgs: make(map[string]string),
|
||||||
|
names: make(map[string]string),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetUserOrg 設定某 user 的 org_id(僅 in-memory 測試用,讓 Library 能判 tenant 可見性)。
|
||||||
|
// production 的 Postgres 實作直接 join users.org_id,不需此方法。
|
||||||
|
func (r *InMemoryRepository) SetUserOrg(userID, orgID string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.orgs[userID] = orgID
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetUserName 設定某 user 的顯示名稱(僅 in-memory 測試用,讓 GetWithOwner / Library 帶出 owner name)。
|
||||||
|
// production 的 Postgres 實作直接 join users.name,不需此方法。
|
||||||
|
func (r *InMemoryRepository) SetUserName(userID, name string) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.names[userID] = name
|
||||||
|
}
|
||||||
|
|
||||||
// Get 取得單一 Model。
|
// Get 取得單一 Model。
|
||||||
func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error) {
|
func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
@ -171,6 +338,19 @@ func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error)
|
|||||||
return &cp, nil
|
return &cp, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetWithOwner 取單一 Model + owner 顯示名稱(in-memory 從 names map 取,測試以 SetUserName 注入)。
|
||||||
|
func (r *InMemoryRepository) GetWithOwner(ctx context.Context, id string) (*Model, string, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
m, ok := r.models[id]
|
||||||
|
if !ok || m.DeletedAt != nil {
|
||||||
|
return nil, "", ErrNotFound
|
||||||
|
}
|
||||||
|
cp := *m
|
||||||
|
return &cp, r.names[m.OwnerUserID], nil
|
||||||
|
}
|
||||||
|
|
||||||
// List 依條件列出 Model。
|
// List 依條件列出 Model。
|
||||||
func (r *InMemoryRepository) List(ctx context.Context, filter ListFilter) ([]*Model, error) {
|
func (r *InMemoryRepository) List(ctx context.Context, filter ListFilter) ([]*Model, error) {
|
||||||
r.mu.RLock()
|
r.mu.RLock()
|
||||||
@ -211,6 +391,10 @@ func (r *InMemoryRepository) Save(ctx context.Context, m *Model) error {
|
|||||||
} else if cp.CreatedAt.IsZero() {
|
} else if cp.CreatedAt.IsZero() {
|
||||||
cp.CreatedAt = now
|
cp.CreatedAt = now
|
||||||
}
|
}
|
||||||
|
// visibility 預設 private(對齊 DB DEFAULT 'private'):呼叫端未設時不會意外變公開。
|
||||||
|
if cp.Visibility == "" {
|
||||||
|
cp.Visibility = VisibilityPrivate
|
||||||
|
}
|
||||||
cp.UpdatedAt = now
|
cp.UpdatedAt = now
|
||||||
r.models[m.ID] = &cp
|
r.models[m.ID] = &cp
|
||||||
return nil
|
return nil
|
||||||
@ -231,5 +415,222 @@ func (r *InMemoryRepository) Delete(ctx context.Context, id string) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// InMemoryRepository — 模型共享方法
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// Library 依查詢 user 身份列出可見 model(in-memory 實作,供 unit test)。
|
||||||
|
//
|
||||||
|
// 可見性 predicate 對齊 TDD §4.1(我的 ∪ public ∪ tenant同org ∪ 分享給我)。
|
||||||
|
// 排序 + cursor 分頁在記憶體內以全掃 + sort + 切片實作(in-memory 資料量小、不追求效能)。
|
||||||
|
func (r *InMemoryRepository) Library(ctx context.Context, q LibraryQuery) ([]*LibraryItem, bool, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
|
||||||
|
var matched []*LibraryItem
|
||||||
|
for _, m := range r.models {
|
||||||
|
if m.DeletedAt != nil || m.UploadedAt == nil {
|
||||||
|
continue // 共享庫只列未刪除且 ready 的 model
|
||||||
|
}
|
||||||
|
access := r.accessLevelLocked(q.UserID, q.UserOrgID, m)
|
||||||
|
if access == AccessNone {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// filter:owned 維度。
|
||||||
|
isMine := m.OwnerUserID == q.UserID
|
||||||
|
if q.Owned != nil {
|
||||||
|
if *q.Owned && !isMine {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !*q.Owned && isMine {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.TargetChip != "" && m.TargetChip != q.TargetChip {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if q.Source != "" && m.Source != q.Source {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// visibility filter:僅 public / tenant 有意義(private 不在共享庫語意內)。
|
||||||
|
if q.Visibility == VisibilityPublic || q.Visibility == VisibilityTenant {
|
||||||
|
if m.Visibility != q.Visibility {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.Q != "" {
|
||||||
|
needle := strings.ToLower(q.Q)
|
||||||
|
if !strings.Contains(strings.ToLower(m.Name), needle) &&
|
||||||
|
!strings.Contains(strings.ToLower(m.Description), needle) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, shared := r.shareForLocked(m.ID, q.UserID)
|
||||||
|
cp := *m
|
||||||
|
matched = append(matched, &LibraryItem{
|
||||||
|
Model: &cp,
|
||||||
|
OwnerName: r.names[m.OwnerUserID], // in-memory 從 names map 取(測試以 SetUserName 注入)
|
||||||
|
OwnerOrgID: r.orgs[m.OwnerUserID],
|
||||||
|
SharedWithMe: shared,
|
||||||
|
MyAccess: access,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
sortLibraryItems(matched, q.Sort, q.Order)
|
||||||
|
|
||||||
|
// cursor:找到游標對應 item 後的位置,取其後 limit+1 判 hasMore。
|
||||||
|
start := 0
|
||||||
|
if q.Cursor != nil {
|
||||||
|
for i, it := range matched {
|
||||||
|
if it.Model.ID == q.Cursor.ID {
|
||||||
|
start = i + 1
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
limit := q.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
end := start + limit
|
||||||
|
hasMore := false
|
||||||
|
if end < len(matched) {
|
||||||
|
hasMore = true
|
||||||
|
}
|
||||||
|
if start > len(matched) {
|
||||||
|
start = len(matched)
|
||||||
|
}
|
||||||
|
if end > len(matched) {
|
||||||
|
end = len(matched)
|
||||||
|
}
|
||||||
|
return matched[start:end], hasMore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// accessLevelLocked 計算 user 對 model 的 AccessLevel(呼叫端須持 r.mu)。
|
||||||
|
// 對齊 canAccessModel 的 in-memory 版;順序:owner > share.role > visibility(viewer) > none。
|
||||||
|
func (r *InMemoryRepository) accessLevelLocked(userID, userOrgID string, m *Model) AccessLevel {
|
||||||
|
if m.OwnerUserID == userID {
|
||||||
|
return AccessOwner
|
||||||
|
}
|
||||||
|
if s, ok := r.shareForLocked(m.ID, userID); ok {
|
||||||
|
if s.Role == "editor" {
|
||||||
|
return AccessEditor
|
||||||
|
}
|
||||||
|
return AccessViewer
|
||||||
|
}
|
||||||
|
if m.Visibility == VisibilityPublic {
|
||||||
|
return AccessViewer
|
||||||
|
}
|
||||||
|
if m.Visibility == VisibilityTenant && userOrgID != "" && r.orgs[m.OwnerUserID] == userOrgID {
|
||||||
|
return AccessViewer
|
||||||
|
}
|
||||||
|
return AccessNone
|
||||||
|
}
|
||||||
|
|
||||||
|
// shareForLocked 回傳 (modelID, granteeUserID) 的 share(呼叫端須持 r.mu)。
|
||||||
|
func (r *InMemoryRepository) shareForLocked(modelID, granteeUserID string) (*ModelShare, bool) {
|
||||||
|
byGrantee, ok := r.shares[modelID]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
s, ok := byGrantee[granteeUserID]
|
||||||
|
return s, ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetShare 取得單筆 share;不存在回 ErrNotFound。
|
||||||
|
func (r *InMemoryRepository) GetShare(ctx context.Context, modelID, granteeUserID string) (*ModelShare, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
s, ok := r.shareForLocked(modelID, granteeUserID)
|
||||||
|
if !ok {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
cp := *s
|
||||||
|
return &cp, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListShares 列出某 model 的所有 share。
|
||||||
|
func (r *InMemoryRepository) ListShares(ctx context.Context, modelID string) ([]*ModelShare, error) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
out := make([]*ModelShare, 0)
|
||||||
|
for _, s := range r.shares[modelID] {
|
||||||
|
cp := *s
|
||||||
|
out = append(out, &cp)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertShare 新增 / 更新一筆 share(by PK)。
|
||||||
|
func (r *InMemoryRepository) UpsertShare(ctx context.Context, s *ModelShare) error {
|
||||||
|
if s == nil || s.ModelID == "" || s.GranteeUserID == "" {
|
||||||
|
return errors.New("model: UpsertShare requires modelID and granteeUserID")
|
||||||
|
}
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
if r.shares[s.ModelID] == nil {
|
||||||
|
r.shares[s.ModelID] = make(map[string]*ModelShare)
|
||||||
|
}
|
||||||
|
cp := *s
|
||||||
|
if cp.CreatedAt.IsZero() {
|
||||||
|
cp.CreatedAt = time.Now().UTC()
|
||||||
|
}
|
||||||
|
if cp.Role == "" {
|
||||||
|
cp.Role = "viewer"
|
||||||
|
}
|
||||||
|
r.shares[s.ModelID][s.GranteeUserID] = &cp
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteShare 移除一筆 share;不存在回 ErrNotFound。
|
||||||
|
func (r *InMemoryRepository) DeleteShare(ctx context.Context, modelID, granteeUserID string) error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
byGrantee, ok := r.shares[modelID]
|
||||||
|
if !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
if _, ok := byGrantee[granteeUserID]; !ok {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
delete(byGrantee, granteeUserID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// sortLibraryItems 依 sort/order 就地排序 items;tie-breaker 一律用 model id 保證穩定。
|
||||||
|
func sortLibraryItems(items []*LibraryItem, sortField, order string) {
|
||||||
|
desc := order != "asc" // 預設 desc
|
||||||
|
less := func(i, j int) bool {
|
||||||
|
a, b := items[i].Model, items[j].Model
|
||||||
|
var cmp int
|
||||||
|
switch sortField {
|
||||||
|
case "name":
|
||||||
|
cmp = strings.Compare(a.Name, b.Name)
|
||||||
|
case "file_size":
|
||||||
|
switch {
|
||||||
|
case a.FileSize < b.FileSize:
|
||||||
|
cmp = -1
|
||||||
|
case a.FileSize > b.FileSize:
|
||||||
|
cmp = 1
|
||||||
|
}
|
||||||
|
default: // created_at
|
||||||
|
switch {
|
||||||
|
case a.CreatedAt.Before(b.CreatedAt):
|
||||||
|
cmp = -1
|
||||||
|
case a.CreatedAt.After(b.CreatedAt):
|
||||||
|
cmp = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if cmp == 0 {
|
||||||
|
cmp = strings.Compare(a.ID, b.ID) // tie-breaker
|
||||||
|
}
|
||||||
|
if desc {
|
||||||
|
return cmp > 0
|
||||||
|
}
|
||||||
|
return cmp < 0
|
||||||
|
}
|
||||||
|
sort.SliceStable(items, less)
|
||||||
|
}
|
||||||
|
|
||||||
// 編譯時檢查:確保 InMemoryRepository 實作 Repository。
|
// 編譯時檢查:確保 InMemoryRepository 實作 Repository。
|
||||||
var _ Repository = (*InMemoryRepository)(nil)
|
var _ Repository = (*InMemoryRepository)(nil)
|
||||||
|
|||||||
@ -46,7 +46,7 @@ var _ Repository = (*PostgresRepository)(nil)
|
|||||||
// modelColumns 是 SELECT / RETURNING 共用的欄位清單(順序必須與 scanModel 對齊)。
|
// modelColumns 是 SELECT / RETURNING 共用的欄位清單(順序必須與 scanModel 對齊)。
|
||||||
const modelColumns = `id, owner_user_id, name, description, storage_key, file_size,
|
const modelColumns = `id, owner_user_id, name, description, storage_key, file_size,
|
||||||
file_checksum, faa_object_key, target_chip, input_shape, classes, framework,
|
file_checksum, faa_object_key, target_chip, input_shape, classes, framework,
|
||||||
source, source_job_id, created_at, updated_at, uploaded_at, deleted_at`
|
source, source_job_id, visibility, created_at, updated_at, uploaded_at, deleted_at`
|
||||||
|
|
||||||
// Get 取得單一 Model;不存在或已軟刪除回 ErrNotFound。
|
// Get 取得單一 Model;不存在或已軟刪除回 ErrNotFound。
|
||||||
func (r *PostgresRepository) Get(ctx context.Context, id string) (*Model, error) {
|
func (r *PostgresRepository) Get(ctx context.Context, id string) (*Model, error) {
|
||||||
@ -139,15 +139,22 @@ func (r *PostgresRepository) Save(ctx context.Context, m *Model) error {
|
|||||||
|
|
||||||
// nullable 欄位以指標 / 空值交給 pgx 處理;空字串對 nullable TEXT 欄位寫入空字串(非 NULL),
|
// nullable 欄位以指標 / 空值交給 pgx 處理;空字串對 nullable TEXT 欄位寫入空字串(非 NULL),
|
||||||
// 對齊 in-memory「zero value 即空字串」語意(faa_object_key 等查詢端以 != '' 判斷)。
|
// 對齊 in-memory「zero value 即空字串」語意(faa_object_key 等查詢端以 != '' 判斷)。
|
||||||
|
// visibility:空字串 → NULL 交給 COALESCE 落 'private'(對齊 DB DEFAULT + in-memory Save)。
|
||||||
|
// 已設值(PATCH visibility / 呼叫端指定)則原樣寫入;CHECK constraint 擋非法值。
|
||||||
|
var visibility any
|
||||||
|
if m.Visibility != "" {
|
||||||
|
visibility = string(m.Visibility)
|
||||||
|
} // else: 留 nil → COALESCE($15, 'private')
|
||||||
|
|
||||||
const q = `
|
const q = `
|
||||||
INSERT INTO models (
|
INSERT INTO models (
|
||||||
id, owner_user_id, name, description, storage_key, file_size,
|
id, owner_user_id, name, description, storage_key, file_size,
|
||||||
file_checksum, faa_object_key, target_chip, input_shape, classes, framework,
|
file_checksum, faa_object_key, target_chip, input_shape, classes, framework,
|
||||||
source, source_job_id, created_at, updated_at, uploaded_at, deleted_at
|
source, source_job_id, visibility, created_at, updated_at, uploaded_at, deleted_at
|
||||||
) VALUES (
|
) VALUES (
|
||||||
$1, $2, $3, $4, $5, $6,
|
$1, $2, $3, $4, $5, $6,
|
||||||
$7, $8, $9, $10, $11, $12,
|
$7, $8, $9, $10, $11, $12,
|
||||||
$13, $14, COALESCE($15, now()), now(), $16, $17
|
$13, $14, COALESCE($15, 'private'), COALESCE($16, now()), now(), $17, $18
|
||||||
)
|
)
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
owner_user_id = EXCLUDED.owner_user_id,
|
owner_user_id = EXCLUDED.owner_user_id,
|
||||||
@ -163,6 +170,7 @@ func (r *PostgresRepository) Save(ctx context.Context, m *Model) error {
|
|||||||
framework = EXCLUDED.framework,
|
framework = EXCLUDED.framework,
|
||||||
source = EXCLUDED.source,
|
source = EXCLUDED.source,
|
||||||
source_job_id = EXCLUDED.source_job_id,
|
source_job_id = EXCLUDED.source_job_id,
|
||||||
|
visibility = EXCLUDED.visibility,
|
||||||
-- 保留原 created_at 僅當既有列未刪除;已刪除(復活)或值不同則用新值。
|
-- 保留原 created_at 僅當既有列未刪除;已刪除(復活)或值不同則用新值。
|
||||||
created_at = CASE
|
created_at = CASE
|
||||||
WHEN models.deleted_at IS NULL THEN models.created_at
|
WHEN models.deleted_at IS NULL THEN models.created_at
|
||||||
@ -187,9 +195,10 @@ func (r *PostgresRepository) Save(ctx context.Context, m *Model) error {
|
|||||||
m.Framework, // $12
|
m.Framework, // $12
|
||||||
string(m.Source), // $13
|
string(m.Source), // $13
|
||||||
nullableUUID(m.SourceJobID), // $14
|
nullableUUID(m.SourceJobID), // $14
|
||||||
createdAt, // $15
|
visibility, // $15
|
||||||
m.UploadedAt, // $16
|
createdAt, // $16
|
||||||
m.DeletedAt, // $17
|
m.UploadedAt, // $17
|
||||||
|
m.DeletedAt, // $18
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("model: pg Save upsert: %w", err)
|
return fmt.Errorf("model: pg Save upsert: %w", err)
|
||||||
@ -254,6 +263,7 @@ func scanModel(row rowScanner) (*Model, error) {
|
|||||||
&framework,
|
&framework,
|
||||||
&m.Source,
|
&m.Source,
|
||||||
&sourceJobID,
|
&sourceJobID,
|
||||||
|
&m.Visibility,
|
||||||
&m.CreatedAt,
|
&m.CreatedAt,
|
||||||
&m.UpdatedAt,
|
&m.UpdatedAt,
|
||||||
&m.UploadedAt,
|
&m.UploadedAt,
|
||||||
|
|||||||
414
visionA-backend/internal/model/postgres_sharing.go
Normal file
414
visionA-backend/internal/model/postgres_sharing.go
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
// postgres_sharing.go — PostgresRepository 的模型共享方法(Library 查詢 + model_shares CRUD)。
|
||||||
|
//
|
||||||
|
// 對齊:
|
||||||
|
// - feature-model-sharing-tdd.md §4(可見性 predicate + query 形狀 + 效能考量)
|
||||||
|
// - api/api-model-sharing.md §1(library:cursor 分頁 / sort / filter / q)
|
||||||
|
// - adr-017-model-library-access.md 決策 3(model_shares schema)
|
||||||
|
// - migrations/0006_model_sharing.up.sql(visibility 欄 + model_shares 表 + index)
|
||||||
|
//
|
||||||
|
// 可見性 predicate(single source of truth 的 SQL 展開,對齊 TDD §4.1):
|
||||||
|
//
|
||||||
|
// 可見(model, user) =
|
||||||
|
// owner_user_id = :userID -- 我的
|
||||||
|
// OR visibility = 'public' -- 全平台
|
||||||
|
// OR (visibility = 'tenant' AND owner.org_id = :orgID -- 同租戶
|
||||||
|
// AND :orgID <> '' AND owner.org_id IS NOT NULL)
|
||||||
|
// OR EXISTS (model_shares 命中 grantee=:userID) -- 分享給我
|
||||||
|
//
|
||||||
|
// tenant 邊界(SEC-4):org_id 兩者皆非空才可能命中,空 org 一律不落 tenant 可見。
|
||||||
|
// OIDC 現況不帶 org claim → :orgID 恆空 → tenant 集合恆空(安全預設),schema 就緒待 OIDC 補齊。
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// libraryColumns 是 Library 查詢的 SELECT 欄位(m.* + owner join + share 資訊)。
|
||||||
|
// 順序必須與 scanLibraryItem 對齊。內部 key(storage_key / faa_object_key)雖 SELECT
|
||||||
|
// 出來供 domain Model 完整(download 端點需 FAAObjectKey),但 DTO 序列化層(api)不揭露。
|
||||||
|
const libraryColumns = `m.id, m.owner_user_id, m.name, m.description, m.storage_key, m.file_size,
|
||||||
|
m.file_checksum, m.faa_object_key, m.target_chip, m.input_shape, m.classes, m.framework,
|
||||||
|
m.source, m.source_job_id, m.visibility, m.created_at, m.updated_at, m.uploaded_at, m.deleted_at,
|
||||||
|
COALESCE(u.name, '') AS owner_name, COALESCE(u.org_id::text, '') AS owner_org_id,
|
||||||
|
(s.grantee_user_id IS NOT NULL) AS shared_with_me, COALESCE(s.role, '') AS share_role`
|
||||||
|
|
||||||
|
// Library 依查詢 user 身份列出可見 model(cursor 分頁)。見檔頭 predicate 說明。
|
||||||
|
//
|
||||||
|
// query 形狀(對齊 TDD §4.2):單一 SELECT + JOIN users(取 owner.name / owner.org_id,
|
||||||
|
// 一次帶出避免 handler N+1)+ LEFT JOIN model_shares(取當前 user 的 share role / shared_with_me)。
|
||||||
|
// filter / 排序 / keyset cursor 皆參數化拼接(無字串拼接使用者輸入)。
|
||||||
|
func (r *PostgresRepository) Library(ctx context.Context, q LibraryQuery) ([]*LibraryItem, bool, error) {
|
||||||
|
var args []any
|
||||||
|
arg := func(v any) string { // 追加參數並回傳其 $N placeholder
|
||||||
|
args = append(args, v)
|
||||||
|
return fmt.Sprintf("$%d", len(args))
|
||||||
|
}
|
||||||
|
|
||||||
|
userIDP := arg(q.UserID)
|
||||||
|
// orgID:空字串時仍傳入,SQL 內以 `<> ''` 判非空(tenant 邊界 SEC-4)。
|
||||||
|
orgIDP := arg(q.UserOrgID)
|
||||||
|
|
||||||
|
// 可見性 predicate(TDD §4.1)。model_shares 子查用 m.id 關聯(相關子查)。
|
||||||
|
visPredicate := fmt.Sprintf(`(
|
||||||
|
m.owner_user_id = %[1]s
|
||||||
|
OR m.visibility = 'public'
|
||||||
|
OR (m.visibility = 'tenant' AND u.org_id IS NOT NULL AND u.org_id::text = %[2]s AND %[2]s <> '')
|
||||||
|
OR EXISTS (SELECT 1 FROM model_shares ms
|
||||||
|
WHERE ms.model_id = m.id AND ms.grantee_user_id = %[1]s)
|
||||||
|
)`, userIDP, orgIDP)
|
||||||
|
|
||||||
|
conds := []string{
|
||||||
|
"m.deleted_at IS NULL",
|
||||||
|
"m.uploaded_at IS NOT NULL", // 共享庫只列 ready
|
||||||
|
visPredicate,
|
||||||
|
}
|
||||||
|
|
||||||
|
// filter:owned 維度。
|
||||||
|
if q.Owned != nil {
|
||||||
|
if *q.Owned {
|
||||||
|
conds = append(conds, "m.owner_user_id = "+userIDP)
|
||||||
|
} else {
|
||||||
|
conds = append(conds, "m.owner_user_id <> "+userIDP)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if q.TargetChip != "" {
|
||||||
|
conds = append(conds, "m.target_chip = "+arg(q.TargetChip))
|
||||||
|
}
|
||||||
|
if q.Source != "" {
|
||||||
|
conds = append(conds, "m.source = "+arg(q.Source))
|
||||||
|
}
|
||||||
|
// visibility filter:僅 public / tenant 有意義(private 不在共享庫語意內,忽略)。
|
||||||
|
if q.Visibility == VisibilityPublic || q.Visibility == VisibilityTenant {
|
||||||
|
conds = append(conds, "m.visibility = "+arg(q.Visibility))
|
||||||
|
}
|
||||||
|
if q.Q != "" {
|
||||||
|
// ILIKE 包含式搜尋 name + description(TDD §5:第一階段 ILIKE,量大再上 FTS)。
|
||||||
|
// 參數化 + 手動 escape LIKE 萬用字元,避免使用者輸入的 % / _ 改變語意。
|
||||||
|
like := "%" + escapeLike(q.Q) + "%"
|
||||||
|
p := arg(like)
|
||||||
|
conds = append(conds, "(m.name ILIKE "+p+" ESCAPE '\\' OR COALESCE(m.description, '') ILIKE "+p+" ESCAPE '\\')")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 排序欄位白名單(handler 已 validate,這裡再次以 switch 白名單防禦,杜絕 SQL 注入)。
|
||||||
|
sortCol := "m.created_at"
|
||||||
|
switch q.Sort {
|
||||||
|
case "name":
|
||||||
|
sortCol = "m.name"
|
||||||
|
case "file_size":
|
||||||
|
sortCol = "m.file_size"
|
||||||
|
case "created_at", "":
|
||||||
|
sortCol = "m.created_at"
|
||||||
|
}
|
||||||
|
dir := "DESC"
|
||||||
|
cmpOp := "<"
|
||||||
|
if q.Order == "asc" {
|
||||||
|
dir = "ASC"
|
||||||
|
cmpOp = ">"
|
||||||
|
}
|
||||||
|
|
||||||
|
// keyset cursor:WHERE (sortCol, id) </> (cursorSortValue, cursorID)。
|
||||||
|
// 用 row-value 比較保證與 ORDER BY (sortCol, id) 一致的穩定分頁。
|
||||||
|
if q.Cursor != nil {
|
||||||
|
sv := castCursorValue(q.Sort, q.Cursor.SortValue)
|
||||||
|
svP := arg(sv.value)
|
||||||
|
idP := arg(q.Cursor.ID)
|
||||||
|
conds = append(conds, fmt.Sprintf("(%s, m.id) %s (%s%s, %s)", sortCol, cmpOp, svP, sv.cast, idP))
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := q.Limit
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 20
|
||||||
|
}
|
||||||
|
// 多取一筆判 hasMore。
|
||||||
|
limitP := arg(limit + 1)
|
||||||
|
|
||||||
|
query := `SELECT ` + libraryColumns + `
|
||||||
|
FROM models m
|
||||||
|
JOIN users u ON u.id = m.owner_user_id
|
||||||
|
LEFT JOIN model_shares s ON s.model_id = m.id AND s.grantee_user_id = ` + userIDP + `
|
||||||
|
WHERE ` + joinAnd(conds) + `
|
||||||
|
ORDER BY ` + sortCol + ` ` + dir + `, m.id ` + dir + `
|
||||||
|
LIMIT ` + limitP
|
||||||
|
|
||||||
|
rows, err := r.pool.Query(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, fmt.Errorf("model: pg Library query: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
items := make([]*LibraryItem, 0, limit)
|
||||||
|
for rows.Next() {
|
||||||
|
it, scanErr := scanLibraryItem(rows)
|
||||||
|
if scanErr != nil {
|
||||||
|
return nil, false, fmt.Errorf("model: pg Library scan: %w", scanErr)
|
||||||
|
}
|
||||||
|
items = append(items, it)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, false, fmt.Errorf("model: pg Library rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hasMore := false
|
||||||
|
if len(items) > limit {
|
||||||
|
hasMore = true
|
||||||
|
items = items[:limit]
|
||||||
|
}
|
||||||
|
return items, hasMore, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// cursorCast 描述 cursor 排序值的 SQL 值 + 型別 cast(讓 row-value 比較型別對齊欄位)。
|
||||||
|
type cursorCast struct {
|
||||||
|
value string
|
||||||
|
cast string // 附加在 placeholder 後的 ::type,如 "::bigint" / "::timestamptz";name 為空
|
||||||
|
}
|
||||||
|
|
||||||
|
// castCursorValue 依 sort 欄位決定 cursor 值的型別 cast(避免 text 與欄位型別不符)。
|
||||||
|
func castCursorValue(sortField, raw string) cursorCast {
|
||||||
|
switch sortField {
|
||||||
|
case "file_size":
|
||||||
|
return cursorCast{value: raw, cast: "::bigint"}
|
||||||
|
case "name":
|
||||||
|
return cursorCast{value: raw, cast: ""}
|
||||||
|
default: // created_at
|
||||||
|
return cursorCast{value: raw, cast: "::timestamptz"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanLibraryItem 掃出一列 LibraryItem。欄位順序須對齊 libraryColumns。
|
||||||
|
func scanLibraryItem(row rowScanner) (*LibraryItem, error) {
|
||||||
|
var (
|
||||||
|
m Model
|
||||||
|
description *string
|
||||||
|
fileChecksum *string
|
||||||
|
faaObjectKey *string
|
||||||
|
targetChip *string
|
||||||
|
inputShape []int32
|
||||||
|
framework *string
|
||||||
|
sourceJobID *string
|
||||||
|
ownerName string
|
||||||
|
ownerOrgID string
|
||||||
|
sharedWithMe bool
|
||||||
|
shareRole string
|
||||||
|
)
|
||||||
|
err := row.Scan(
|
||||||
|
&m.ID, &m.OwnerUserID, &m.Name, &description, &m.StorageKey, &m.FileSize,
|
||||||
|
&fileChecksum, &faaObjectKey, &targetChip, &inputShape, &m.Classes, &framework,
|
||||||
|
&m.Source, &sourceJobID, &m.Visibility, &m.CreatedAt, &m.UpdatedAt, &m.UploadedAt, &m.DeletedAt,
|
||||||
|
&ownerName, &ownerOrgID, &sharedWithMe, &shareRole,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.Description = derefString(description)
|
||||||
|
m.FileChecksum = derefString(fileChecksum)
|
||||||
|
m.FAAObjectKey = derefString(faaObjectKey)
|
||||||
|
m.TargetChip = derefString(targetChip)
|
||||||
|
m.Framework = derefString(framework)
|
||||||
|
m.SourceJobID = derefString(sourceJobID)
|
||||||
|
m.InputShape = toIntSlice(inputShape)
|
||||||
|
m.CreatedAt = m.CreatedAt.UTC()
|
||||||
|
m.UpdatedAt = m.UpdatedAt.UTC()
|
||||||
|
if m.UploadedAt != nil {
|
||||||
|
u := m.UploadedAt.UTC()
|
||||||
|
m.UploadedAt = &u
|
||||||
|
}
|
||||||
|
|
||||||
|
// my_access:owner > share.role > public/tenant(viewer)。owner 由呼叫端已知(owner_user_id=userID),
|
||||||
|
// 但此處 Library 已用 predicate 過濾出可見列,故 access 一定 != none。
|
||||||
|
// owner 的判斷在 handler(is_me),這裡計算「非 owner 情境」的 access;owner 情境 handler 覆寫為 owner。
|
||||||
|
access := AccessViewer
|
||||||
|
if sharedWithMe && shareRole == "editor" {
|
||||||
|
access = AccessEditor
|
||||||
|
}
|
||||||
|
return &LibraryItem{
|
||||||
|
Model: &m,
|
||||||
|
OwnerName: ownerName,
|
||||||
|
OwnerOrgID: ownerOrgID,
|
||||||
|
SharedWithMe: sharedWithMe,
|
||||||
|
MyAccess: access,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWithOwner 取單一未刪除 Model + owner 顯示名稱(一次 JOIN users,供 profile 顯示 owner name)。
|
||||||
|
// 不存在或已軟刪回 ErrNotFound。
|
||||||
|
func (r *PostgresRepository) GetWithOwner(ctx context.Context, id string) (*Model, string, error) {
|
||||||
|
q := `SELECT ` + prefixCols("m", modelColumns) + `, COALESCE(u.name, '') AS owner_name
|
||||||
|
FROM models m
|
||||||
|
JOIN users u ON u.id = m.owner_user_id
|
||||||
|
WHERE m.id = $1 AND m.deleted_at IS NULL`
|
||||||
|
|
||||||
|
var ownerName string
|
||||||
|
row := r.pool.QueryRow(ctx, q, id)
|
||||||
|
m, err := scanModelWithExtra(row, &ownerName)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, "", ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, "", fmt.Errorf("model: pg GetWithOwner: %w", err)
|
||||||
|
}
|
||||||
|
return m, ownerName, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixCols 把 modelColumns 的每個裸欄名加上 table alias 前綴(`id` → `m.id`)。
|
||||||
|
// modelColumns 是不含前綴的欄位清單;GetWithOwner 需 alias 以區分 join 的 users 欄。
|
||||||
|
func prefixCols(alias, cols string) string {
|
||||||
|
parts := strings.Split(cols, ",")
|
||||||
|
for i, p := range parts {
|
||||||
|
parts[i] = alias + "." + strings.TrimSpace(p)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// scanModelWithExtra 掃出 *Model 後,再把 owner_name 掃進 extra(附加在 modelColumns 之後)。
|
||||||
|
// 為此需重掃:pgx row 只能 Scan 一次,故這裡直接展開 model 欄位 + extra 一起 Scan。
|
||||||
|
func scanModelWithExtra(row pgx.Row, ownerName *string) (*Model, error) {
|
||||||
|
var (
|
||||||
|
m Model
|
||||||
|
description *string
|
||||||
|
fileChecksum *string
|
||||||
|
faaObjectKey *string
|
||||||
|
targetChip *string
|
||||||
|
inputShape []int32
|
||||||
|
framework *string
|
||||||
|
sourceJobID *string
|
||||||
|
)
|
||||||
|
err := row.Scan(
|
||||||
|
&m.ID, &m.OwnerUserID, &m.Name, &description, &m.StorageKey, &m.FileSize,
|
||||||
|
&fileChecksum, &faaObjectKey, &targetChip, &inputShape, &m.Classes, &framework,
|
||||||
|
&m.Source, &sourceJobID, &m.Visibility, &m.CreatedAt, &m.UpdatedAt, &m.UploadedAt, &m.DeletedAt,
|
||||||
|
ownerName,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m.Description = derefString(description)
|
||||||
|
m.FileChecksum = derefString(fileChecksum)
|
||||||
|
m.FAAObjectKey = derefString(faaObjectKey)
|
||||||
|
m.TargetChip = derefString(targetChip)
|
||||||
|
m.Framework = derefString(framework)
|
||||||
|
m.SourceJobID = derefString(sourceJobID)
|
||||||
|
m.InputShape = toIntSlice(inputShape)
|
||||||
|
m.CreatedAt = m.CreatedAt.UTC()
|
||||||
|
m.UpdatedAt = m.UpdatedAt.UTC()
|
||||||
|
if m.UploadedAt != nil {
|
||||||
|
u := m.UploadedAt.UTC()
|
||||||
|
m.UploadedAt = &u
|
||||||
|
}
|
||||||
|
if m.DeletedAt != nil {
|
||||||
|
d := m.DeletedAt.UTC()
|
||||||
|
m.DeletedAt = &d
|
||||||
|
}
|
||||||
|
return &m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// model_shares CRUD
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// GetShare 取得 (modelID, granteeUserID) 的分享;不存在回 ErrNotFound。
|
||||||
|
func (r *PostgresRepository) GetShare(ctx context.Context, modelID, granteeUserID string) (*ModelShare, error) {
|
||||||
|
const q = `SELECT model_id, grantee_user_id, role, granted_by, created_at
|
||||||
|
FROM model_shares WHERE model_id = $1 AND grantee_user_id = $2`
|
||||||
|
var s ModelShare
|
||||||
|
err := r.pool.QueryRow(ctx, q, modelID, granteeUserID).
|
||||||
|
Scan(&s.ModelID, &s.GranteeUserID, &s.Role, &s.GrantedBy, &s.CreatedAt)
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, ErrNotFound
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("model: pg GetShare: %w", err)
|
||||||
|
}
|
||||||
|
s.CreatedAt = s.CreatedAt.UTC()
|
||||||
|
return &s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListShares 列出某 model 的所有分享(owner 檢視授權清單)。
|
||||||
|
func (r *PostgresRepository) ListShares(ctx context.Context, modelID string) ([]*ModelShare, error) {
|
||||||
|
const q = `SELECT model_id, grantee_user_id, role, granted_by, created_at
|
||||||
|
FROM model_shares WHERE model_id = $1 ORDER BY created_at ASC`
|
||||||
|
rows, err := r.pool.Query(ctx, q, modelID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("model: pg ListShares: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
out := make([]*ModelShare, 0)
|
||||||
|
for rows.Next() {
|
||||||
|
var s ModelShare
|
||||||
|
if err := rows.Scan(&s.ModelID, &s.GranteeUserID, &s.Role, &s.GrantedBy, &s.CreatedAt); err != nil {
|
||||||
|
return nil, fmt.Errorf("model: pg ListShares scan: %w", err)
|
||||||
|
}
|
||||||
|
s.CreatedAt = s.CreatedAt.UTC()
|
||||||
|
out = append(out, &s)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("model: pg ListShares rows: %w", err)
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpsertShare 新增 / 更新一筆分享(by PK (model_id, grantee_user_id))。重複 grantee → 更新 role。
|
||||||
|
func (r *PostgresRepository) UpsertShare(ctx context.Context, s *ModelShare) error {
|
||||||
|
if s == nil || s.ModelID == "" || s.GranteeUserID == "" {
|
||||||
|
return errors.New("model: UpsertShare requires modelID and granteeUserID")
|
||||||
|
}
|
||||||
|
role := s.Role
|
||||||
|
if role == "" {
|
||||||
|
role = "viewer"
|
||||||
|
}
|
||||||
|
const q = `INSERT INTO model_shares (model_id, grantee_user_id, role, granted_by)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
ON CONFLICT (model_id, grantee_user_id) DO UPDATE SET
|
||||||
|
role = EXCLUDED.role, granted_by = EXCLUDED.granted_by`
|
||||||
|
if _, err := r.pool.Exec(ctx, q, s.ModelID, s.GranteeUserID, role, s.GrantedBy); err != nil {
|
||||||
|
return fmt.Errorf("model: pg UpsertShare: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteShare 移除 (modelID, granteeUserID) 的分享;不存在回 ErrNotFound。
|
||||||
|
func (r *PostgresRepository) DeleteShare(ctx context.Context, modelID, granteeUserID string) error {
|
||||||
|
const q = `DELETE FROM model_shares WHERE model_id = $1 AND grantee_user_id = $2`
|
||||||
|
tag, err := r.pool.Exec(ctx, q, modelID, granteeUserID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("model: pg DeleteShare: %w", err)
|
||||||
|
}
|
||||||
|
if tag.RowsAffected() == 0 {
|
||||||
|
return ErrNotFound
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// helper
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
// joinAnd 以 " AND " 串接 WHERE 條件。
|
||||||
|
func joinAnd(conds []string) string {
|
||||||
|
out := ""
|
||||||
|
for i, c := range conds {
|
||||||
|
if i > 0 {
|
||||||
|
out += " AND "
|
||||||
|
}
|
||||||
|
out += c
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeLike escape LIKE / ILIKE 的萬用字元(% _ \),避免使用者輸入改變 pattern 語意。
|
||||||
|
// 搭配查詢端的 `ESCAPE '\'`。
|
||||||
|
func escapeLike(s string) string {
|
||||||
|
var b []byte
|
||||||
|
for i := 0; i < len(s); i++ {
|
||||||
|
c := s[i]
|
||||||
|
if c == '%' || c == '_' || c == '\\' {
|
||||||
|
b = append(b, '\\')
|
||||||
|
}
|
||||||
|
b = append(b, c)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
295
visionA-backend/internal/model/postgres_sharing_db_test.go
Normal file
295
visionA-backend/internal/model/postgres_sharing_db_test.go
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
//go:build dbtest
|
||||||
|
|
||||||
|
// PostgresRepository 模型共享方法(Library 查詢 + model_shares CRUD)的真 DB 整合測試。
|
||||||
|
//
|
||||||
|
// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。
|
||||||
|
// 執行:
|
||||||
|
//
|
||||||
|
// go test -tags=dbtest ./internal/model/...
|
||||||
|
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||||||
|
// go test -tags=dbtest ./internal/model/...
|
||||||
|
//
|
||||||
|
// 涵蓋:可見性 predicate(我的 ∪ public ∪ tenant同org ∪ shared)、enumeration 排除、
|
||||||
|
// filter / 搜尋 / cursor 分頁、share CRUD、tenant 邊界(空 org 不落 tenant)。
|
||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"visiona-backend/internal/db/testsupport"
|
||||||
|
)
|
||||||
|
|
||||||
|
// insertUserWithOrg 寫入一筆帶 org_id 的 user,回傳 user id。org 為空時 org_id=NULL。
|
||||||
|
func insertUserWithOrg(t *testing.T, tdb *testsupport.TestDB, org string) string {
|
||||||
|
t.Helper()
|
||||||
|
id := uuid.NewString()
|
||||||
|
ctx := context.Background()
|
||||||
|
if org == "" {
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO users (id, email) VALUES ($1, $2)`, id, id+"@t.local")
|
||||||
|
require.NoError(t, err)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO users (id, email, org_id) VALUES ($1, $2, $3)`, id, id+"@t.local", org)
|
||||||
|
require.NoError(t, err)
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// saveReady 存一個 ready model(指定 owner + visibility),回傳其 id。
|
||||||
|
func saveReady(t *testing.T, r *PostgresRepository, owner, visibility, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
id := uuid.NewString()
|
||||||
|
require.NoError(t, r.Save(context.Background(), &Model{
|
||||||
|
ID: id, OwnerUserID: owner, Name: name,
|
||||||
|
StorageKey: "models/" + owner + "/" + id + ".nef", FileSize: 1024,
|
||||||
|
Source: SourceUploaded, Visibility: visibility, UploadedAt: &now,
|
||||||
|
}))
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGShare_LibraryVisibility 驗證 Library predicate(我的 ∪ public ∪ shared,排除別人 private)。
|
||||||
|
func TestPGShare_LibraryVisibility(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
tdb.Truncate(t, "model_shares", "models", "users")
|
||||||
|
r := NewPostgresRepository(tdb.Pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
me := insertUserWithOrg(t, tdb, "")
|
||||||
|
other := insertUserWithOrg(t, tdb, "")
|
||||||
|
|
||||||
|
mine := saveReady(t, r, me, VisibilityPrivate, "mine")
|
||||||
|
saveReady(t, r, other, VisibilityPrivate, "otherPriv")
|
||||||
|
pub := saveReady(t, r, other, VisibilityPublic, "otherPub")
|
||||||
|
shared := saveReady(t, r, other, VisibilityPrivate, "otherShared")
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: shared, GranteeUserID: me, Role: "viewer", GrantedBy: other}))
|
||||||
|
|
||||||
|
items, _, err := r.Library(ctx, LibraryQuery{UserID: me, Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got := map[string]*LibraryItem{}
|
||||||
|
for _, it := range items {
|
||||||
|
got[it.Model.ID] = it
|
||||||
|
}
|
||||||
|
assert.Contains(t, got, mine)
|
||||||
|
assert.Contains(t, got, pub)
|
||||||
|
assert.Contains(t, got, shared)
|
||||||
|
assert.Len(t, got, 3, "別人的 private 不應出現")
|
||||||
|
assert.True(t, got[shared].SharedWithMe)
|
||||||
|
assert.Equal(t, "viewer", got[shared].MyAccess)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGShare_LibraryTenantBoundary 驗證 tenant 可見性:同 org 命中、異 org / 空 org 不命中(SEC-4)。
|
||||||
|
func TestPGShare_LibraryTenantBoundary(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
tdb.Truncate(t, "model_shares", "models", "users")
|
||||||
|
r := NewPostgresRepository(tdb.Pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// org_id 是 UUID 欄,用真 UUID(不是 'org-1' 這種字面)。
|
||||||
|
org1 := uuid.NewString()
|
||||||
|
org2 := uuid.NewString()
|
||||||
|
orgOwner := insertUserWithOrg(t, tdb, org1)
|
||||||
|
teammate := insertUserWithOrg(t, tdb, org1)
|
||||||
|
outsider := insertUserWithOrg(t, tdb, org2)
|
||||||
|
noOrg := insertUserWithOrg(t, tdb, "")
|
||||||
|
|
||||||
|
tenantModel := saveReady(t, r, orgOwner, VisibilityTenant, "tenant")
|
||||||
|
|
||||||
|
// 同 org → 可見。UserOrgID 傳 org_id 的 text 形式(對齊 UserContext.OrgID 為字串)。
|
||||||
|
items, _, err := r.Library(ctx, LibraryQuery{UserID: teammate, UserOrgID: org1, Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
assert.Equal(t, tenantModel, items[0].Model.ID)
|
||||||
|
|
||||||
|
// 異 org → 不可見。
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: outsider, UserOrgID: org2, Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, items, "異 org 不應看到 tenant model")
|
||||||
|
|
||||||
|
// 空 org(OIDC 現況)→ 不可見(安全預設,即使 model 是 tenant)。
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: noOrg, UserOrgID: "", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, items, "空 org 不應落 tenant 可見(SEC-4)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGShare_LibraryFilters 驗證 filter(owned / target_chip / source / visibility / q)。
|
||||||
|
func TestPGShare_LibraryFilters(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
tdb.Truncate(t, "model_shares", "models", "users")
|
||||||
|
r := NewPostgresRepository(tdb.Pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
me := insertUserWithOrg(t, tdb, "")
|
||||||
|
other := insertUserWithOrg(t, tdb, "")
|
||||||
|
mine := saveReady(t, r, me, VisibilityPrivate, "yolo-mine")
|
||||||
|
pub := saveReady(t, r, other, VisibilityPublic, "resnet-pub")
|
||||||
|
|
||||||
|
// owned=true → 只我的。
|
||||||
|
items, _, err := r.Library(ctx, LibraryQuery{UserID: me, Owned: boolPtr(true), Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
assert.Equal(t, mine, items[0].Model.ID)
|
||||||
|
|
||||||
|
// owned=false → 只別人。
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Owned: boolPtr(false), Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
assert.Equal(t, pub, items[0].Model.ID)
|
||||||
|
|
||||||
|
// visibility=public。
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Visibility: VisibilityPublic, Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
assert.Equal(t, pub, items[0].Model.ID)
|
||||||
|
|
||||||
|
// q=yolo(搜尋 name)。
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Q: "yolo", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, items, 1)
|
||||||
|
assert.Equal(t, mine, items[0].Model.ID)
|
||||||
|
|
||||||
|
// q 含 LIKE 萬用字元應被 escape(不 match 全部)。
|
||||||
|
items, _, err = r.Library(ctx, LibraryQuery{UserID: me, Q: "%", Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, items, "字面 '%' 不應 match 任何 model(萬用字元已 escape)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGShare_LibraryCursorPagination 驗證 cursor 分頁不重不漏(真 DB keyset)。
|
||||||
|
func TestPGShare_LibraryCursorPagination(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
tdb.Truncate(t, "model_shares", "models", "users")
|
||||||
|
r := NewPostgresRepository(tdb.Pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
me := insertUserWithOrg(t, tdb, "")
|
||||||
|
for i := 0; i < 7; i++ {
|
||||||
|
saveReady(t, r, me, VisibilityPrivate, "m"+string(rune('a'+i)))
|
||||||
|
}
|
||||||
|
|
||||||
|
seen := map[string]bool{}
|
||||||
|
var cursor *Cursor
|
||||||
|
for page := 0; page < 20; page++ {
|
||||||
|
items, hasMore, err := r.Library(ctx, LibraryQuery{
|
||||||
|
UserID: me, Limit: 3, Sort: "name", Order: "asc", Cursor: cursor,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
for _, it := range items {
|
||||||
|
assert.False(t, seen[it.Model.ID], "分頁重複 %s", it.Model.ID)
|
||||||
|
seen[it.Model.ID] = true
|
||||||
|
}
|
||||||
|
if !hasMore {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
require.NotEmpty(t, items)
|
||||||
|
last := items[len(items)-1].Model
|
||||||
|
cursor = &Cursor{ID: last.ID, SortValue: last.Name}
|
||||||
|
}
|
||||||
|
assert.Len(t, seen, 7, "所有 model 應被分頁完整走過一次")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGShare_ShareCRUD 驗證 share Upsert / Get / List / Delete(真 DB)。
|
||||||
|
func TestPGShare_ShareCRUD(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
tdb.Truncate(t, "model_shares", "models", "users")
|
||||||
|
r := NewPostgresRepository(tdb.Pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
owner := insertUserWithOrg(t, tdb, "")
|
||||||
|
bob := insertUserWithOrg(t, tdb, "")
|
||||||
|
alice := insertUserWithOrg(t, tdb, "")
|
||||||
|
m := saveReady(t, r, owner, VisibilityPrivate, "m")
|
||||||
|
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: m, GranteeUserID: bob, Role: "viewer", GrantedBy: owner}))
|
||||||
|
got, err := r.GetShare(ctx, m, bob)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "viewer", got.Role)
|
||||||
|
|
||||||
|
// upsert 更新 role。
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: m, GranteeUserID: bob, Role: "editor", GrantedBy: owner}))
|
||||||
|
got, err = r.GetShare(ctx, m, bob)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "editor", got.Role)
|
||||||
|
|
||||||
|
require.NoError(t, r.UpsertShare(ctx, &ModelShare{ModelID: m, GranteeUserID: alice, Role: "viewer", GrantedBy: owner}))
|
||||||
|
shares, err := r.ListShares(ctx, m)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, shares, 2)
|
||||||
|
|
||||||
|
require.NoError(t, r.DeleteShare(ctx, m, bob))
|
||||||
|
_, err = r.GetShare(ctx, m, bob)
|
||||||
|
assert.ErrorIs(t, err, ErrNotFound)
|
||||||
|
assert.ErrorIs(t, r.DeleteShare(ctx, m, uuid.NewString()), ErrNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGShare_LibraryExcludesSoftDeletedAndPending 驗證軟刪 / 未 ready 的 model 不進 Library。
|
||||||
|
func TestPGShare_LibraryExcludesSoftDeletedAndPending(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
tdb.Truncate(t, "model_shares", "models", "users")
|
||||||
|
r := NewPostgresRepository(tdb.Pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
me := insertUserWithOrg(t, tdb, "")
|
||||||
|
// pending(無 UploadedAt)。
|
||||||
|
pendingID := uuid.NewString()
|
||||||
|
require.NoError(t, r.Save(ctx, &Model{
|
||||||
|
ID: pendingID, OwnerUserID: me, Name: "pending", StorageKey: "k",
|
||||||
|
FileSize: 1, Source: SourceUploaded, Visibility: VisibilityPublic,
|
||||||
|
}))
|
||||||
|
// ready 然後軟刪。
|
||||||
|
deleted := saveReady(t, r, me, VisibilityPublic, "deleted")
|
||||||
|
require.NoError(t, r.Delete(ctx, deleted))
|
||||||
|
// 正常 ready。
|
||||||
|
ok := saveReady(t, r, me, VisibilityPrivate, "ok")
|
||||||
|
|
||||||
|
items, _, err := r.Library(ctx, LibraryQuery{UserID: me, Limit: 100})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, items, 1, "只應列正常 ready 的 model")
|
||||||
|
assert.Equal(t, ok, items[0].Model.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPGShare_GetWithOwner 驗證 GetWithOwner join 出 owner name(Minor-1,真 DB)。
|
||||||
|
func TestPGShare_GetWithOwner(t *testing.T) {
|
||||||
|
tdb := testsupport.SetupTestDB(t)
|
||||||
|
tdb.Truncate(t, "model_shares", "models", "users")
|
||||||
|
r := NewPostgresRepository(tdb.Pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// 建帶 name 的 owner。
|
||||||
|
ownerID := uuid.NewString()
|
||||||
|
_, err := tdb.Pool.Exec(ctx,
|
||||||
|
`INSERT INTO users (id, email, name) VALUES ($1, $2, $3)`,
|
||||||
|
ownerID, ownerID+"@t.local", "Alice")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
modelID := saveReady(t, r, ownerID, VisibilityPublic, "m")
|
||||||
|
|
||||||
|
m, ownerName, err := r.GetWithOwner(ctx, modelID)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, modelID, m.ID)
|
||||||
|
assert.Equal(t, VisibilityPublic, m.Visibility)
|
||||||
|
assert.Equal(t, "Alice", ownerName, "GetWithOwner 應 join 出 owner name")
|
||||||
|
|
||||||
|
// owner 無 name → 空字串(COALESCE)。
|
||||||
|
noNameOwner := insertUserWithOrg(t, tdb, "")
|
||||||
|
m2 := saveReady(t, r, noNameOwner, VisibilityPrivate, "m2")
|
||||||
|
_, ownerName2, err := r.GetWithOwner(ctx, m2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "", ownerName2, "owner 無 name 時 owner_name 應為空")
|
||||||
|
|
||||||
|
// 不存在 / 已軟刪 → ErrNotFound。
|
||||||
|
_, _, err = r.GetWithOwner(ctx, uuid.NewString())
|
||||||
|
assert.ErrorIs(t, err, ErrNotFound)
|
||||||
|
require.NoError(t, r.Delete(ctx, modelID))
|
||||||
|
_, _, err = r.GetWithOwner(ctx, modelID)
|
||||||
|
assert.ErrorIs(t, err, ErrNotFound, "已軟刪應回 ErrNotFound")
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPtr(b bool) *bool { return &b }
|
||||||
@ -185,5 +185,8 @@ func clonePreset(m *Model) *Model {
|
|||||||
t := *m.UploadedAt
|
t := *m.UploadedAt
|
||||||
cp.UploadedAt = &t
|
cp.UploadedAt = &t
|
||||||
}
|
}
|
||||||
|
// preset 是公用模型,語意等同全平台可見(visibility=public)。
|
||||||
|
// 在此統一標記,preset 宣告區不必逐筆設 Visibility。
|
||||||
|
cp.Visibility = VisibilityPublic
|
||||||
return &cp
|
return &cp
|
||||||
}
|
}
|
||||||
|
|||||||
17
visionA-backend/migrations/0006_model_sharing.down.sql
Normal file
17
visionA-backend/migrations/0006_model_sharing.down.sql
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
-- 0006_model_sharing.down.sql
|
||||||
|
--
|
||||||
|
-- 反向 0006:對稱移除 model_shares 表、models.visibility 欄與相關 index / constraint。
|
||||||
|
-- 順序:先刪依賴 visibility 的 partial index → 刪 model_shares 表(其 index 隨表 DROP 自動移除)
|
||||||
|
-- → 刪 models 的 constraint + 欄位。
|
||||||
|
|
||||||
|
-- (3) 共享庫查詢 index。
|
||||||
|
DROP INDEX IF EXISTS idx_models_public_active;
|
||||||
|
|
||||||
|
-- (2) model_shares 表(idx_model_shares_grantee 隨表 DROP 自動移除)。
|
||||||
|
DROP TABLE IF EXISTS model_shares;
|
||||||
|
|
||||||
|
-- (1) models.visibility 欄與其 CHECK constraint。
|
||||||
|
-- 先 DROP CONSTRAINT 再 DROP COLUMN(DROP COLUMN 也會連帶移除 constraint,
|
||||||
|
-- 此處顯式先移以求對稱清楚)。
|
||||||
|
ALTER TABLE models DROP CONSTRAINT IF EXISTS chk_models_visibility;
|
||||||
|
ALTER TABLE models DROP COLUMN IF EXISTS visibility;
|
||||||
49
visionA-backend/migrations/0006_model_sharing.up.sql
Normal file
49
visionA-backend/migrations/0006_model_sharing.up.sql
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
-- 0006_model_sharing.up.sql
|
||||||
|
--
|
||||||
|
-- 模型共享(Model Sharing)L 級新功能。在既有 owner-only 模型庫上,疊加兩個正交維度:
|
||||||
|
-- (1) visibility 廣播欄(private / tenant / public)— models 表加 enum 欄。
|
||||||
|
-- (2) model_shares 點對點分享表(ADR-017 決策 3 B1)— 分享給特定 user。
|
||||||
|
--
|
||||||
|
-- 對齊:docs/autoflow/04-architecture/feature-model-sharing-tdd.md §3、
|
||||||
|
-- docs/autoflow/04-architecture/api/api-model-sharing.md、
|
||||||
|
-- docs/autoflow/04-architecture/adr/adr-017-model-library-access.md 決策 3。
|
||||||
|
--
|
||||||
|
-- 環境事實(與 0001–0005 相同,已驗證):PostgreSQL 14.23,gen_random_uuid() 內建可直接用。
|
||||||
|
--
|
||||||
|
-- ★關鍵相容性:models.visibility DEFAULT 'private' → 既有所有 model 遷移後維持 owner-only
|
||||||
|
-- 語意,零行為改變。使用者要主動 PATCH visibility 才會公開。
|
||||||
|
|
||||||
|
-- ── (1) models 加 visibility 欄(廣播式公開對象)─────────────────────────────
|
||||||
|
-- 'private'(僅擁有者,= 現況預設)| 'tenant'(同租戶可見)| 'public'(全平台可見)
|
||||||
|
-- 全部既有 row 加欄後為 'private'(DEFAULT),語意完全等同遷移前的 owner-only。
|
||||||
|
ALTER TABLE models ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private';
|
||||||
|
ALTER TABLE models ADD CONSTRAINT chk_models_visibility
|
||||||
|
CHECK (visibility IN ('private', 'tenant', 'public'));
|
||||||
|
|
||||||
|
-- ── (2) model_shares 表(點對點分享,ADR-017 決策 3 B1,本功能沿用不重造)──────
|
||||||
|
-- role:'viewer'(可 list/get/download)| 'editor'(可改 metadata;本期讀取端用,寫入權後續)。
|
||||||
|
-- PK (model_id, grantee_user_id):同一 model 對同一 grantee 只有一筆分享(重複分享 = upsert)。
|
||||||
|
-- FK ON DELETE CASCADE:model 硬刪時連帶清 share(雖然本系統 model 為軟刪,CASCADE 為防禦性
|
||||||
|
-- 一致——若未來真硬刪不留孤兒列;軟刪時 share 保留,由查詢端 join models.deleted_at 過濾)。
|
||||||
|
CREATE TABLE model_shares (
|
||||||
|
model_id UUID NOT NULL REFERENCES models(id) ON DELETE CASCADE,
|
||||||
|
grantee_user_id UUID NOT NULL REFERENCES users(id),
|
||||||
|
role TEXT NOT NULL DEFAULT 'viewer',
|
||||||
|
granted_by UUID NOT NULL REFERENCES users(id),
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (model_id, grantee_user_id),
|
||||||
|
CONSTRAINT chk_model_shares_role CHECK (role IN ('viewer', 'editor'))
|
||||||
|
);
|
||||||
|
|
||||||
|
-- grantee 反查(共享庫「分享給我」predicate 的 EXISTS 子查走此 index)。
|
||||||
|
CREATE INDEX idx_model_shares_grantee ON model_shares (grantee_user_id);
|
||||||
|
|
||||||
|
-- ── (3) 共享庫查詢用 index ───────────────────────────────────────────────────
|
||||||
|
-- public 全平台可見列表:high-selectivity partial index(沿用既有 models index 的
|
||||||
|
-- `WHERE deleted_at IS NULL` 慣例)。只索引 public 且未刪除且已上傳(ready)的 model,
|
||||||
|
-- 共享庫預設按 created_at DESC 排序、此 index 直接覆蓋該掃描。
|
||||||
|
CREATE INDEX idx_models_public_active ON models (created_at DESC)
|
||||||
|
WHERE deleted_at IS NULL AND visibility = 'public' AND uploaded_at IS NOT NULL;
|
||||||
|
|
||||||
|
-- tenant 可見需 join users 取 owner.org_id;users 主鍵 join 成本低,
|
||||||
|
-- owner 維度沿用既有 idx_models_owner_active,不另建。
|
||||||
@ -19,6 +19,7 @@ import Link from "next/link";
|
|||||||
import { Link2, RefreshCw } from "lucide-react";
|
import { Link2, RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
import { DeviceList } from "@/components/devices/device-list";
|
import { DeviceList } from "@/components/devices/device-list";
|
||||||
|
import { DeviceRescanButton } from "@/components/devices/device-rescan-button";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useT } from "@/lib/i18n/context";
|
import { useT } from "@/lib/i18n/context";
|
||||||
import { useDeviceStore } from "@/stores/device-store";
|
import { useDeviceStore } from "@/stores/device-store";
|
||||||
@ -54,6 +55,9 @@ export default function DevicesPage() {
|
|||||||
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
|
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
|
{/* 重新掃描 USB(觸發 local agent rescan)— 與上方「刷新列表」不同:
|
||||||
|
刷新只重讀雲端 DB,rescan 讓剛插入的新 USB 裝置被偵測出來。 */}
|
||||||
|
<DeviceRescanButton />
|
||||||
<Link href="/devices/pair">
|
<Link href="/devices/pair">
|
||||||
<Button data-testid="devices-pair-cta">
|
<Button data-testid="devices-pair-cta">
|
||||||
<Link2 aria-hidden="true" className="mr-2 size-4" />
|
<Link2 aria-hidden="true" className="mr-2 size-4" />
|
||||||
|
|||||||
@ -0,0 +1,147 @@
|
|||||||
|
/**
|
||||||
|
* ModelProfileClient 雙態測試
|
||||||
|
*
|
||||||
|
* 覆蓋:
|
||||||
|
* - owner 版(myAccess=owner):顯示公開設定 + 刪除按鈕
|
||||||
|
* - 公開 / 共享版(myAccess=viewer):隱藏公開設定 / 刪除;顯示 owner 資訊列
|
||||||
|
* - canDownload → 顯示下載鈕
|
||||||
|
* - 無權限 / 404(profileError)→ 全頁 EmptyState「找不到 / 無權限」
|
||||||
|
*
|
||||||
|
* 走 store mock 模式,直接以 _setProfile / setState 注入 profile,不打真實 API。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { LocaleProvider } from "@/lib/i18n/context";
|
||||||
|
import type { ModelProfile } from "@/lib/api/model-sharing";
|
||||||
|
import { useModelSharingStore } from "@/stores/model-sharing-store";
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: Object.assign(vi.fn(), { success: vi.fn(), error: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({
|
||||||
|
push: vi.fn(),
|
||||||
|
replace: vi.fn(),
|
||||||
|
back: vi.fn(),
|
||||||
|
forward: vi.fn(),
|
||||||
|
refresh: vi.fn(),
|
||||||
|
prefetch: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { ModelProfileClient } from "./model-profile-client";
|
||||||
|
|
||||||
|
const ownerProfile: ModelProfile = {
|
||||||
|
id: "p1",
|
||||||
|
name: "我的模型",
|
||||||
|
targetChip: "kl520",
|
||||||
|
fileSize: 1024 * 1024,
|
||||||
|
source: "converted",
|
||||||
|
status: "ready",
|
||||||
|
visibility: "private",
|
||||||
|
owner: { id: "me", name: "我", isMe: true },
|
||||||
|
myAccess: "owner",
|
||||||
|
canDownload: true,
|
||||||
|
createdAt: "2026-07-01T00:00:00Z",
|
||||||
|
updatedAt: "2026-07-02T00:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
const viewerProfile: ModelProfile = {
|
||||||
|
...ownerProfile,
|
||||||
|
id: "p2",
|
||||||
|
name: "共享模型",
|
||||||
|
visibility: "public",
|
||||||
|
owner: { id: "alice", name: "Alice", isMe: false },
|
||||||
|
myAccess: "viewer",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 讓 loadProfile 直接把注入的 profile 放進 store(不打 API),避免 useEffect 覆蓋。
|
||||||
|
*/
|
||||||
|
function stubLoadProfile(profile: ModelProfile | null, error: string | null = null) {
|
||||||
|
useModelSharingStore.setState({
|
||||||
|
_mockMode: true,
|
||||||
|
loadProfile: async () => {
|
||||||
|
useModelSharingStore.setState({
|
||||||
|
profile,
|
||||||
|
profileError: error,
|
||||||
|
isProfileLoading: false,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
clearProfile: () => {
|
||||||
|
/* 測試中保留注入的 profile,不清空 */
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProfile(id: string) {
|
||||||
|
return render(
|
||||||
|
<LocaleProvider>
|
||||||
|
<ModelProfileClient id={id} />
|
||||||
|
</LocaleProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
useModelSharingStore.setState({
|
||||||
|
profile: null,
|
||||||
|
isProfileLoading: false,
|
||||||
|
profileError: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
// 還原 store 被 stub 的 actions
|
||||||
|
useModelSharingStore.setState(useModelSharingStore.getInitialState?.() ?? {});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("owner 版", () => {
|
||||||
|
it("顯示公開設定 + 刪除 + 下載", async () => {
|
||||||
|
stubLoadProfile(ownerProfile);
|
||||||
|
renderProfile("p1");
|
||||||
|
await waitFor(() => expect(screen.getByText("我的模型")).toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(screen.getByTestId("profile-visibility")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("profile-download")).toBeInTheDocument();
|
||||||
|
// 刪除鈕(common.delete)
|
||||||
|
expect(screen.getByText("刪除")).toBeInTheDocument();
|
||||||
|
// owner 不顯示擁有者資訊列
|
||||||
|
expect(screen.queryByTestId("model-owner-bar")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("公開 / 共享版(非 owner)", () => {
|
||||||
|
it("隱藏公開設定 / 刪除;顯示 owner 資訊列", async () => {
|
||||||
|
stubLoadProfile(viewerProfile);
|
||||||
|
renderProfile("p2");
|
||||||
|
await waitFor(() => expect(screen.getByText("共享模型")).toBeInTheDocument());
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("profile-visibility")).not.toBeInTheDocument();
|
||||||
|
// 下載仍可(canDownload=true)
|
||||||
|
expect(screen.getByTestId("profile-download")).toBeInTheDocument();
|
||||||
|
// 擁有者資訊列
|
||||||
|
expect(screen.getByTestId("model-owner-bar")).toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("model-owner-bar")).toHaveTextContent("Alice");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("canDownload=false → 不顯示下載鈕", async () => {
|
||||||
|
stubLoadProfile({ ...viewerProfile, canDownload: false });
|
||||||
|
renderProfile("p2");
|
||||||
|
await waitFor(() => expect(screen.getByText("共享模型")).toBeInTheDocument());
|
||||||
|
expect(screen.queryByTestId("profile-download")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("無權限 / 404", () => {
|
||||||
|
it("profileError=not_found → 全頁 EmptyState「找不到 / 無權限」", async () => {
|
||||||
|
stubLoadProfile(null, "not_found");
|
||||||
|
renderProfile("nope");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("找不到模型或沒有存取權")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
337
visionA-frontend/src/app/models/[id]/model-profile-client.tsx
Normal file
337
visionA-frontend/src/app/models/[id]/model-profile-client.tsx
Normal file
@ -0,0 +1,337 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModelProfileClient — 模型 profile 頁(owner / 公開雙態)
|
||||||
|
*
|
||||||
|
* 對齊設計規格 §6 + API 契約 §2(GET /:id/profile)。取代舊的 owner-only detail:
|
||||||
|
* 用 `GET /api/models/:id/profile` 取詳情(後端依身份裁剪 + 權限檢查),依 `myAccess`
|
||||||
|
* 決定渲染 owner 版或公開 / 共享版。
|
||||||
|
*
|
||||||
|
* 雙態差異(設計規格 §6.2):
|
||||||
|
* - owner 版(myAccess==='owner'):下載(若可)+ 刪除 + 【新增】公開設定 Dialog
|
||||||
|
* - 公開 / 共享版:僅下載(canDownload 為 true 時);隱藏刪除 / 公開設定;顯示 ModelOwnerBar
|
||||||
|
*
|
||||||
|
* 錯誤(契約 §2):無可見性 → 404(防 enumeration)→ 全頁「找不到 / 無權限」EmptyState。
|
||||||
|
*
|
||||||
|
* 下載沿用既有 FAA delegated download(lib/api/model-download),走同一 endpoint
|
||||||
|
* (契約 §3:download 已加共享權限檢查)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { ArrowLeft, DownloadIcon, Globe, SearchX, Trash2 } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { ModelOwnerBar } from "@/components/models/model-owner-bar";
|
||||||
|
import { ModelVisibilityBadge } from "@/components/models/model-visibility-badge";
|
||||||
|
import { ModelVisibilityDialog } from "@/components/models/model-visibility-dialog";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import {
|
||||||
|
getModelDownload,
|
||||||
|
ModelDownloadError,
|
||||||
|
triggerNavDownload,
|
||||||
|
} from "@/lib/api/model-download";
|
||||||
|
import { useT } from "@/lib/i18n/context";
|
||||||
|
import { useModelSharingStore } from "@/stores/model-sharing-store";
|
||||||
|
import { useModelStore } from "@/stores/model-store";
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (!bytes) return "—";
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatInputShape(shape: number[]): string {
|
||||||
|
return shape.join(" × ");
|
||||||
|
}
|
||||||
|
|
||||||
|
const CLASSES_PREVIEW_LIMIT = 8;
|
||||||
|
|
||||||
|
interface ModelProfileClientProps {
|
||||||
|
id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelProfileClient({ id }: ModelProfileClientProps) {
|
||||||
|
const t = useT();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const profile = useModelSharingStore((s) => s.profile);
|
||||||
|
const isLoading = useModelSharingStore((s) => s.isProfileLoading);
|
||||||
|
const profileError = useModelSharingStore((s) => s.profileError);
|
||||||
|
const loadProfile = useModelSharingStore((s) => s.loadProfile);
|
||||||
|
const clearProfile = useModelSharingStore((s) => s.clearProfile);
|
||||||
|
|
||||||
|
// 刪除沿用既有 model-store(owner-only DELETE /api/models/:id)。
|
||||||
|
const deleteModel = useModelStore((s) => s.deleteModel);
|
||||||
|
|
||||||
|
const [deleting, setDeleting] = useState(false);
|
||||||
|
const [downloadBusy, setDownloadBusy] = useState(false);
|
||||||
|
const [visibilityDialogOpen, setVisibilityDialogOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (id) void loadProfile(id);
|
||||||
|
return () => clearProfile();
|
||||||
|
}, [id, loadProfile, clearProfile]);
|
||||||
|
|
||||||
|
const isOwner = profile?.myAccess === "owner";
|
||||||
|
|
||||||
|
async function handleDownload() {
|
||||||
|
if (!profile || downloadBusy) return;
|
||||||
|
setDownloadBusy(true);
|
||||||
|
try {
|
||||||
|
const grant = await getModelDownload(profile.id);
|
||||||
|
triggerNavDownload(grant.downloadUrl);
|
||||||
|
toast.success(t("models.download.toast.start"), {
|
||||||
|
description: t("models.download.toast.hint"),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ModelDownloadError ? err.code : "unknown";
|
||||||
|
const key = `models.download.error.${code}`;
|
||||||
|
const desc = t(key);
|
||||||
|
toast.error(t("models.download.error.title"), {
|
||||||
|
description: desc === key ? t("models.download.error.unknown") : desc,
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
setDownloadBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
setDeleting(true);
|
||||||
|
const ok = await deleteModel(id);
|
||||||
|
setDeleting(false);
|
||||||
|
if (ok) {
|
||||||
|
toast.success(t("common.save"));
|
||||||
|
router.push("/models");
|
||||||
|
} else {
|
||||||
|
toast.error(t("common.error"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const backButton = (
|
||||||
|
<Link href="/models/library">
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<ArrowLeft aria-hidden className="mr-2 size-4" />
|
||||||
|
{t("common.back")}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
|
||||||
|
// 載入中
|
||||||
|
if (isLoading && !profile) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-4 px-6 py-8">
|
||||||
|
<Skeleton className="h-8 w-48" />
|
||||||
|
<Skeleton className="h-4 w-96" />
|
||||||
|
<Skeleton className="h-48 rounded-lg" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 無權限 / 找不到(契約:無可見性回 404,合併「找不到 / 無權限」)。
|
||||||
|
if (profileError || !profile) {
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-4 px-6 py-8">
|
||||||
|
{backButton}
|
||||||
|
<EmptyState
|
||||||
|
icon={SearchX}
|
||||||
|
title={t("models.profile.notFound.title")}
|
||||||
|
description={t("models.profile.notFound.description")}
|
||||||
|
action={{
|
||||||
|
label: t("models.profile.backToLibrary"),
|
||||||
|
onClick: () => router.push("/models/library"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl space-y-6 px-6 py-8">
|
||||||
|
{backButton}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-2xl font-bold">{profile.name}</h1>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Badge variant="outline">{profile.targetChip.toUpperCase()}</Badge>
|
||||||
|
<Badge variant={profile.status === "ready" ? "default" : "secondary"}>
|
||||||
|
{t(`models.status.${profile.status === "ready" ? "ready" : "scanning"}`)}
|
||||||
|
</Badge>
|
||||||
|
{profile.source !== "uploaded" && (
|
||||||
|
<Badge variant="secondary">{t(`models.source.${profile.source}`)}</Badge>
|
||||||
|
)}
|
||||||
|
{/* owner 看到自己的 visibility;非 owner 看到共享標示。 */}
|
||||||
|
<ModelVisibilityBadge
|
||||||
|
visibility={profile.visibility}
|
||||||
|
sharedWithMe={!isOwner}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{profile.canDownload && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleDownload}
|
||||||
|
disabled={downloadBusy}
|
||||||
|
aria-label={t("models.action.download.aria")}
|
||||||
|
data-testid="profile-download"
|
||||||
|
>
|
||||||
|
{downloadBusy ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" label={t("models.action.downloading")} />
|
||||||
|
{t("models.action.downloading")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<DownloadIcon aria-hidden className="mr-2 size-4" />
|
||||||
|
{t("models.action.download")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* owner-only 操作:公開設定 + 刪除 */}
|
||||||
|
{isOwner && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setVisibilityDialogOpen(true)}
|
||||||
|
data-testid="profile-visibility"
|
||||||
|
>
|
||||||
|
<Globe aria-hidden className="mr-2 size-4" />
|
||||||
|
{t("models.visibility.title")}
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="outline" size="sm" disabled={deleting}>
|
||||||
|
<Trash2 aria-hidden className="mr-2 size-4" />
|
||||||
|
{t("common.delete")}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("common.confirm")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{profile.name}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDelete} disabled={deleting}>
|
||||||
|
{t("common.delete")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 非 owner:擁有者資訊列。 */}
|
||||||
|
{!isOwner && (
|
||||||
|
<ModelOwnerBar ownerName={profile.owner.name} sharedAt={profile.updatedAt} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base">{t("models.detail.description")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-3">
|
||||||
|
{profile.description ? (
|
||||||
|
<p className="text-sm">{profile.description}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">—</p>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3 pt-3 text-sm">
|
||||||
|
<InfoRow label={t("models.size")} value={formatFileSize(profile.fileSize)} />
|
||||||
|
<InfoRow
|
||||||
|
label={t("models.createdAt")}
|
||||||
|
value={profile.createdAt ? new Date(profile.createdAt).toLocaleString() : "—"}
|
||||||
|
/>
|
||||||
|
{profile.framework && (
|
||||||
|
<InfoRow
|
||||||
|
label={t("models.detail.framework")}
|
||||||
|
value={<span className="font-mono text-xs">{profile.framework}</span>}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{profile.inputShape && profile.inputShape.length > 0 && (
|
||||||
|
<InfoRow
|
||||||
|
label={t("models.detail.inputShape")}
|
||||||
|
value={
|
||||||
|
<span className="font-mono text-xs">
|
||||||
|
{formatInputShape(profile.inputShape)}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{profile.classes && profile.classes.length > 0 && (
|
||||||
|
<div className="space-y-2 border-t pt-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<span className="text-muted-foreground">{t("models.detail.classes")}</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{profile.classes.length} {t("models.detail.classesCountSuffix")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{profile.classes.slice(0, CLASSES_PREVIEW_LIMIT).map((c, i) => (
|
||||||
|
<Badge key={`${i}-${c}`} variant="secondary" className="font-normal">
|
||||||
|
{c}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
{profile.classes.length > CLASSES_PREVIEW_LIMIT && (
|
||||||
|
<Badge variant="outline" className="font-normal">
|
||||||
|
+{profile.classes.length - CLASSES_PREVIEW_LIMIT}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<ModelVisibilityDialog
|
||||||
|
modelId={profile.id}
|
||||||
|
modelName={profile.name}
|
||||||
|
currentVisibility={profile.visibility}
|
||||||
|
open={visibilityDialogOpen}
|
||||||
|
onOpenChange={setVisibilityDialogOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="text-muted-foreground">{label}</span>
|
||||||
|
<span className="text-right">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,10 +1,16 @@
|
|||||||
import { ModelDetailClient } from "./model-detail-client";
|
import { ModelProfileClient } from "./model-profile-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型 profile 頁 — /models/[id]
|
||||||
|
*
|
||||||
|
* 模型共享功能後改用 ModelProfileClient(owner / 公開雙態,走 GET /:id/profile,
|
||||||
|
* 支援非 owner 依權限檢視)。舊的 owner-only ModelDetailClient 保留於同目錄但不再掛路由。
|
||||||
|
*/
|
||||||
export default async function ModelDetailPage({
|
export default async function ModelDetailPage({
|
||||||
params,
|
params,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ id: string }>;
|
params: Promise<{ id: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
return <ModelDetailClient id={id} />;
|
return <ModelProfileClient id={id} />;
|
||||||
}
|
}
|
||||||
|
|||||||
129
visionA-frontend/src/app/models/library/library-client.test.tsx
Normal file
129
visionA-frontend/src/app/models/library/library-client.test.tsx
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* LibraryClient 測試(共享模型庫 + cursor 無限捲動)
|
||||||
|
*
|
||||||
|
* 覆蓋:
|
||||||
|
* - 首屏載入 → skeleton
|
||||||
|
* - 載入完成 → 卡片網格 + 哨兵(hasMore)
|
||||||
|
* - 觸發哨兵(模擬 IntersectionObserver)→ loadMore append
|
||||||
|
* - 空狀態(無資料)
|
||||||
|
* - 搜尋無結果空狀態
|
||||||
|
*
|
||||||
|
* IntersectionObserver 在 jsdom 需 mock:這裡用可手動觸發的假 observer,
|
||||||
|
* 讓測試主動「讓哨兵進入視窗」以驗證 loadMore。走 store mock 模式(fixtures)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { LocaleProvider } from "@/lib/i18n/context";
|
||||||
|
import {
|
||||||
|
DEFAULT_LIBRARY_FILTERS,
|
||||||
|
useModelSharingStore,
|
||||||
|
} from "@/stores/model-sharing-store";
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// 可手動觸發的假 IntersectionObserver。
|
||||||
|
let intersectCallbacks: IntersectionObserverCallback[] = [];
|
||||||
|
class FakeIntersectionObserver {
|
||||||
|
constructor(cb: IntersectionObserverCallback) {
|
||||||
|
intersectCallbacks.push(cb);
|
||||||
|
}
|
||||||
|
observe() {}
|
||||||
|
unobserve() {}
|
||||||
|
disconnect() {}
|
||||||
|
takeRecords(): IntersectionObserverEntry[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
root = null;
|
||||||
|
rootMargin = "";
|
||||||
|
thresholds = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerIntersect() {
|
||||||
|
for (const cb of intersectCallbacks) {
|
||||||
|
cb(
|
||||||
|
[{ isIntersecting: true } as IntersectionObserverEntry],
|
||||||
|
{} as IntersectionObserver,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import { LibraryClient } from "./library-client";
|
||||||
|
|
||||||
|
function resetStore() {
|
||||||
|
useModelSharingStore.setState({
|
||||||
|
items: [],
|
||||||
|
filters: { ...DEFAULT_LIBRARY_FILTERS },
|
||||||
|
cursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadingMore: false,
|
||||||
|
listError: null,
|
||||||
|
_mockMode: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
intersectCallbacks = [];
|
||||||
|
vi.stubGlobal("IntersectionObserver", FakeIntersectionObserver);
|
||||||
|
resetStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
function renderLibrary() {
|
||||||
|
return render(
|
||||||
|
<LocaleProvider>
|
||||||
|
<LibraryClient />
|
||||||
|
</LocaleProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("首屏載入", () => {
|
||||||
|
it("載入完成 → 顯示卡片網格 + 哨兵(hasMore)", async () => {
|
||||||
|
renderLibrary();
|
||||||
|
// mock loadFirstPage 是 async;等網格出現
|
||||||
|
await waitFor(() => expect(screen.getByTestId("library-grid")).toBeInTheDocument());
|
||||||
|
// 首頁 24 筆 → hasMore=true → 哨兵存在
|
||||||
|
expect(screen.getByTestId("library-sentinel")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("cursor 無限捲動", () => {
|
||||||
|
it("哨兵進入視窗 → loadMore append(總數增加)", async () => {
|
||||||
|
renderLibrary();
|
||||||
|
await waitFor(() => expect(screen.getByTestId("library-grid")).toBeInTheDocument());
|
||||||
|
|
||||||
|
const before = useModelSharingStore.getState().items.length;
|
||||||
|
expect(before).toBe(24);
|
||||||
|
|
||||||
|
// 模擬捲到底:哨兵進入視窗
|
||||||
|
await act(async () => {
|
||||||
|
triggerIntersect();
|
||||||
|
// 等 store loadMore 完成
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
});
|
||||||
|
|
||||||
|
const after = useModelSharingStore.getState().items.length;
|
||||||
|
expect(after).toBeGreaterThan(before);
|
||||||
|
expect(after).toBe(30); // fixtures 共 30 筆,第二頁補齊
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("空狀態", () => {
|
||||||
|
it("搜尋無結果 → 顯示搜尋空狀態", async () => {
|
||||||
|
useModelSharingStore.setState({
|
||||||
|
filters: { ...DEFAULT_LIBRARY_FILTERS, q: "zzz-no-such" },
|
||||||
|
});
|
||||||
|
renderLibrary();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText("找不到符合條件的模型")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
213
visionA-frontend/src/app/models/library/library-client.tsx
Normal file
213
visionA-frontend/src/app/models/library/library-client.tsx
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LibraryClient — 共享模型庫列表頁(cursor 無限捲動)
|
||||||
|
*
|
||||||
|
* 對齊設計規格 §4 + API 契約 §1。使用者拍板「cursor 無限捲動」(往下捲自動載入更多)。
|
||||||
|
*
|
||||||
|
* 狀態機(設計規格 §7):
|
||||||
|
* - 首屏載入 → skeleton 網格
|
||||||
|
* - 有資料 → 卡片網格 + 底部哨兵(IntersectionObserver 觸發 loadMore)
|
||||||
|
* - 續載中 → 底部補 skeleton 卡片
|
||||||
|
* - 空(無共享模型) → EmptyState
|
||||||
|
* - 搜尋無結果 → EmptyState + 清除搜尋 CTA
|
||||||
|
* - 列表錯誤 → 錯誤提示 + 重試
|
||||||
|
*
|
||||||
|
* 搜尋 debounce 300ms(設計規格 §4.4):local searchInput → debounce → store.setFilters({ q })。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { Boxes, SearchX, Users } from "lucide-react";
|
||||||
|
|
||||||
|
import { LibraryModelCard } from "@/components/models/library-model-card";
|
||||||
|
import { LibraryToolbar } from "@/components/models/library-toolbar";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { useInfiniteScroll } from "@/hooks/use-infinite-scroll";
|
||||||
|
import { useT } from "@/lib/i18n/context";
|
||||||
|
import { useModelSharingStore } from "@/stores/model-sharing-store";
|
||||||
|
|
||||||
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
function SkeletonGrid({ count = 8 }: { count?: number }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
|
data-testid="library-skeleton"
|
||||||
|
>
|
||||||
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-52 rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryClient() {
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
|
const items = useModelSharingStore((s) => s.items);
|
||||||
|
const filters = useModelSharingStore((s) => s.filters);
|
||||||
|
const hasMore = useModelSharingStore((s) => s.hasMore);
|
||||||
|
const isLoading = useModelSharingStore((s) => s.isLoading);
|
||||||
|
const isLoadingMore = useModelSharingStore((s) => s.isLoadingMore);
|
||||||
|
const listError = useModelSharingStore((s) => s.listError);
|
||||||
|
const loadFirstPage = useModelSharingStore((s) => s.loadFirstPage);
|
||||||
|
const loadMore = useModelSharingStore((s) => s.loadMore);
|
||||||
|
const setFilters = useModelSharingStore((s) => s.setFilters);
|
||||||
|
|
||||||
|
// 搜尋框 local state(受控),debounce 後才推進 store filters。
|
||||||
|
const [searchInput, setSearchInput] = useState(filters.q);
|
||||||
|
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
// 首次掛載載入首頁。
|
||||||
|
useEffect(() => {
|
||||||
|
void loadFirstPage();
|
||||||
|
}, [loadFirstPage]);
|
||||||
|
|
||||||
|
// 搜尋 debounce → setFilters(會重置分頁重載)。
|
||||||
|
useEffect(() => {
|
||||||
|
if (searchInput === filters.q) return;
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
debounceRef.current = setTimeout(() => {
|
||||||
|
setFilters({ q: searchInput });
|
||||||
|
}, SEARCH_DEBOUNCE_MS);
|
||||||
|
return () => {
|
||||||
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||||
|
};
|
||||||
|
}, [searchInput, filters.q, setFilters]);
|
||||||
|
|
||||||
|
const { sentinelRef } = useInfiniteScroll({
|
||||||
|
enabled: hasMore && !isLoading && !isLoadingMore,
|
||||||
|
onLoadMore: loadMore,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isEmpty = !isLoading && items.length === 0;
|
||||||
|
const isSearchActive =
|
||||||
|
filters.q.trim() !== "" ||
|
||||||
|
filters.targetChip !== "all" ||
|
||||||
|
filters.visibility !== "all" ||
|
||||||
|
filters.owned !== "all";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-7xl space-y-6 px-6 py-8">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold">{t("models.library.title")}</h1>
|
||||||
|
<p className="text-muted-foreground">{t("models.library.subtitle")}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<LibraryToolbar
|
||||||
|
filters={filters}
|
||||||
|
searchInput={searchInput}
|
||||||
|
onSearchChange={setSearchInput}
|
||||||
|
onFilterChange={setFilters}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 搜尋結果數(無障礙播報)。 */}
|
||||||
|
<p className="sr-only" role="status" aria-live="polite">
|
||||||
|
{t("models.library.resultCount").replace("{n}", String(items.length))}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* 首屏載入 */}
|
||||||
|
{isLoading && <SkeletonGrid />}
|
||||||
|
|
||||||
|
{/* 列表錯誤(首屏) */}
|
||||||
|
{!isLoading && listError && items.length === 0 && (
|
||||||
|
<EmptyState
|
||||||
|
icon={SearchX}
|
||||||
|
title={t("models.library.error.title")}
|
||||||
|
description={t("models.library.error.description")}
|
||||||
|
action={{
|
||||||
|
label: t("common.retry"),
|
||||||
|
onClick: () => void loadFirstPage(),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 空狀態 */}
|
||||||
|
{isEmpty && !listError && (
|
||||||
|
isSearchActive ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={SearchX}
|
||||||
|
title={t("models.library.empty.search.title")}
|
||||||
|
description={t("models.library.empty.search.description")}
|
||||||
|
action={{
|
||||||
|
label: t("models.search.clearAll"),
|
||||||
|
onClick: () => {
|
||||||
|
setSearchInput("");
|
||||||
|
setFilters({
|
||||||
|
q: "",
|
||||||
|
targetChip: "all",
|
||||||
|
visibility: "all",
|
||||||
|
owned: "all",
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={Users}
|
||||||
|
title={t("models.library.empty.title")}
|
||||||
|
description={t("models.library.empty.description")}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 卡片網格 */}
|
||||||
|
{!isLoading && items.length > 0 && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
|
||||||
|
data-testid="library-grid"
|
||||||
|
>
|
||||||
|
{items.map((model) => (
|
||||||
|
<LibraryModelCard key={model.id} model={model} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 續載中 skeleton */}
|
||||||
|
{isLoadingMore && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<SkeletonGrid count={4} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 續載錯誤(已有資料時)→ 重試按鈕 */}
|
||||||
|
{listError && !isLoadingMore && (
|
||||||
|
<div className="mt-4 flex justify-center">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void loadMore()}
|
||||||
|
data-testid="library-load-more-retry"
|
||||||
|
>
|
||||||
|
{t("models.library.loadMore.retry")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 無限捲動哨兵(有下一頁且無錯誤時掛載) */}
|
||||||
|
{hasMore && !listError && (
|
||||||
|
<div
|
||||||
|
ref={sentinelRef}
|
||||||
|
className="h-4"
|
||||||
|
aria-hidden
|
||||||
|
data-testid="library-sentinel"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 到底提示 */}
|
||||||
|
{!hasMore && (
|
||||||
|
<p
|
||||||
|
className="text-muted-foreground flex items-center justify-center gap-2 py-4 text-sm"
|
||||||
|
data-testid="library-end"
|
||||||
|
>
|
||||||
|
<Boxes aria-hidden className="size-4" />
|
||||||
|
{t("models.library.end")}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
visionA-frontend/src/app/models/library/page.tsx
Normal file
11
visionA-frontend/src/app/models/library/page.tsx
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import { LibraryClient } from "./library-client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 共享模型庫 — /models/library
|
||||||
|
*
|
||||||
|
* 依身份權限可見的模型列表(我的 ∪ 公開 ∪ 同租戶 ∪ 分享給我 ∪ preset),
|
||||||
|
* cursor 無限捲動分頁。對齊 api-model-sharing.md §1、feature-model-sharing-design.md §4。
|
||||||
|
*/
|
||||||
|
export default function ModelLibraryPage() {
|
||||||
|
return <LibraryClient />;
|
||||||
|
}
|
||||||
@ -12,6 +12,8 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Users } from "lucide-react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ModelFilters,
|
ModelFilters,
|
||||||
@ -19,6 +21,7 @@ import {
|
|||||||
} from "@/components/models/model-filters";
|
} from "@/components/models/model-filters";
|
||||||
import { ModelSection } from "@/components/models/model-section";
|
import { ModelSection } from "@/components/models/model-section";
|
||||||
import { ModelUploadDialog } from "@/components/models/model-upload-dialog";
|
import { ModelUploadDialog } from "@/components/models/model-upload-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
import { useT } from "@/lib/i18n/context";
|
import { useT } from "@/lib/i18n/context";
|
||||||
import {
|
import {
|
||||||
type ModelSource,
|
type ModelSource,
|
||||||
@ -83,8 +86,16 @@ export default function ModelsPage() {
|
|||||||
<h1 className="text-2xl font-bold">{t("models.title")}</h1>
|
<h1 className="text-2xl font-bold">{t("models.title")}</h1>
|
||||||
<p className="text-muted-foreground">{t("models.subtitle")}</p>
|
<p className="text-muted-foreground">{t("models.subtitle")}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link href="/models/library">
|
||||||
|
<Button variant="outline" data-testid="models-library-link">
|
||||||
|
<Users aria-hidden className="mr-2 size-4" />
|
||||||
|
{t("models.library.link")}
|
||||||
|
</Button>
|
||||||
|
</Link>
|
||||||
<ModelUploadDialog />
|
<ModelUploadDialog />
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<ModelFilters value={filter} onChange={setFilter} />
|
<ModelFilters value={filter} onChange={setFilter} />
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
{SECTION_ORDER.map((source) => (
|
{SECTION_ORDER.map((source) => (
|
||||||
|
|||||||
@ -234,6 +234,20 @@ describe("WorkspaceClient — 切 tab 時 camera 串流 / WS 正確關閉(S-1
|
|||||||
expect(lastCameraWsEnabled()).toBe(false);
|
expect(lastCameraWsEnabled()).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("開始推論按鈕只在 camera tab 顯示,切到 image/video/batch 隱藏", async () => {
|
||||||
|
renderClient();
|
||||||
|
// camera tab(預設):按鈕在
|
||||||
|
expect(screen.getByText("開始推論")).toBeInTheDocument();
|
||||||
|
|
||||||
|
// 切到影片:按鈕隱藏(上傳即自動推論,不需此按鈕)
|
||||||
|
await switchTab("影片");
|
||||||
|
expect(screen.queryByText("開始推論")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// 切回 camera:按鈕重新出現
|
||||||
|
await switchTab("Camera");
|
||||||
|
expect(screen.getByText("開始推論")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("切到 image tab 後 camera WS 保持 false(媒體 tab 有自己的 WS,兩者互斥)", async () => {
|
it("切到 image tab 後 camera WS 保持 false(媒體 tab 有自己的 WS,兩者互斥)", async () => {
|
||||||
post.mockResolvedValue({ streamUrl: "/api/camera/stream", sourceType: "camera" });
|
post.mockResolvedValue({ streamUrl: "/api/camera/stream", sourceType: "camera" });
|
||||||
renderClient();
|
renderClient();
|
||||||
|
|||||||
@ -276,6 +276,10 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{/* 開始/停止推論按鈕為 camera tab 專用(打 /api/camera/start 啟動攝影機串流)。
|
||||||
|
image/video/batch 分頁是「上傳即自動推論」,不需要此按鈕 → 僅在 camera tab 顯示。
|
||||||
|
隱藏純為 UI;camera 的 start/stop 邏輯與 handleTabChange 切離時的停止行為不變。 */}
|
||||||
|
{activeTab === "camera" && (
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
{isRunning ? (
|
{isRunning ? (
|
||||||
<Button variant="destructive" onClick={handleStop} disabled={busy}>
|
<Button variant="destructive" onClick={handleStop} disabled={busy}>
|
||||||
@ -288,6 +292,7 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* WP-C(ADR-018):serial 為空 → 無法路由到 local agent,說明原因與解法 */}
|
{/* WP-C(ADR-018):serial 為空 → 無法路由到 local agent,說明原因與解法 */}
|
||||||
|
|||||||
@ -20,6 +20,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { AlertCircle, CheckCircle2, Circle, Loader2 } from "lucide-react";
|
import { AlertCircle, CheckCircle2, Circle, Loader2 } from "lucide-react";
|
||||||
|
|
||||||
|
import { formatRelativeTime } from "@/lib/format/relative-time";
|
||||||
import { useT } from "@/lib/i18n/context";
|
import { useT } from "@/lib/i18n/context";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { RemoteStatus } from "@/stores/device-store";
|
import type { RemoteStatus } from "@/stores/device-store";
|
||||||
@ -36,30 +37,6 @@ export interface RemoteDeviceBadgeProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 格式化相對時間(components.md §10.3 規格)。
|
|
||||||
* - < 60 秒 → 「剛剛」
|
|
||||||
* - < 60 分 → 「X 分鐘前」
|
|
||||||
* - < 24 時 → 「X 小時前」
|
|
||||||
* - ≥ 24 時 → 絕對時間「MM/DD HH:mm」
|
|
||||||
*/
|
|
||||||
function formatRelativeTime(isoString: string, nowMs: number, t: (k: string) => string): string {
|
|
||||||
const ts = Date.parse(isoString);
|
|
||||||
if (Number.isNaN(ts)) return "";
|
|
||||||
const diffSec = Math.max(0, Math.floor((nowMs - ts) / 1000));
|
|
||||||
if (diffSec < 60) return t("remote.lastSeen.justNow");
|
|
||||||
const diffMin = Math.floor(diffSec / 60);
|
|
||||||
if (diffMin < 60) return t("remote.lastSeen.minutesAgo").replace("{n}", String(diffMin));
|
|
||||||
const diffHour = Math.floor(diffMin / 60);
|
|
||||||
if (diffHour < 24) return t("remote.lastSeen.hoursAgo").replace("{n}", String(diffHour));
|
|
||||||
const d = new Date(ts);
|
|
||||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
|
||||||
const dd = String(d.getDate()).padStart(2, "0");
|
|
||||||
const hh = String(d.getHours()).padStart(2, "0");
|
|
||||||
const mi = String(d.getMinutes()).padStart(2, "0");
|
|
||||||
return `${mm}/${dd} ${hh}:${mi}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RemoteDeviceBadge({
|
export function RemoteDeviceBadge({
|
||||||
status,
|
status,
|
||||||
lastSeenAt,
|
lastSeenAt,
|
||||||
|
|||||||
@ -63,3 +63,52 @@ describe("DeviceCard — serial 路由 gating(WP-C / ADR-018)", () => {
|
|||||||
).not.toBeInTheDocument();
|
).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("DeviceCard — 三態分色 + 註冊 UI(TDD §5)", () => {
|
||||||
|
it("已連接未註冊(online + registeredAt null)→ warning 標記(文字「未註冊」+ icon,不只靠色)", () => {
|
||||||
|
renderCard({ ...baseDevice, remoteStatus: "online", registeredAt: null });
|
||||||
|
const badge = screen.getByTestId("unregistered-badge");
|
||||||
|
// 不只靠顏色:badge 有文字「未註冊」
|
||||||
|
expect(badge).toHaveTextContent("未註冊");
|
||||||
|
// 卡片 data-tri-state 標記便於測試/樣式
|
||||||
|
expect(screen.getByTestId("device-card")).toHaveAttribute(
|
||||||
|
"data-tri-state",
|
||||||
|
"online-unregistered",
|
||||||
|
);
|
||||||
|
// 顯示「註冊」動作
|
||||||
|
expect(screen.getByTestId("device-register-btn")).toBeInTheDocument();
|
||||||
|
// 不顯示「取消註冊」
|
||||||
|
expect(screen.queryByTestId("device-unregister-btn")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("已連接已註冊 → 無未註冊標記,顯示「取消註冊」動作", () => {
|
||||||
|
renderCard({
|
||||||
|
...baseDevice,
|
||||||
|
remoteStatus: "online",
|
||||||
|
registeredAt: "2026-08-02T10:00:00Z",
|
||||||
|
});
|
||||||
|
expect(screen.queryByTestId("unregistered-badge")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByTestId("device-card")).toHaveAttribute(
|
||||||
|
"data-tri-state",
|
||||||
|
"online-registered",
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("device-unregister-btn")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("device-register-btn")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("離線未註冊 → 無未註冊標記、無註冊動作(需先連線)", () => {
|
||||||
|
renderCard({ ...baseDevice, remoteStatus: "offline", registeredAt: null });
|
||||||
|
expect(screen.queryByTestId("unregistered-badge")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("device-register-btn")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId("device-unregister-btn")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("離線已註冊 → 顯示「取消註冊」(已註冊不論在線與否都可取消)", () => {
|
||||||
|
renderCard({
|
||||||
|
...baseDevice,
|
||||||
|
remoteStatus: "offline",
|
||||||
|
registeredAt: "2026-08-02T10:00:00Z",
|
||||||
|
});
|
||||||
|
expect(screen.getByTestId("device-unregister-btn")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -23,6 +23,8 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
|
import { RemoteDeviceBadge } from "@/components/cloud/remote-device-badge";
|
||||||
|
import { DeviceRegisterActions } from "@/components/devices/device-register-actions";
|
||||||
|
import { UnregisteredBadge } from "@/components/devices/unregistered-badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import {
|
import {
|
||||||
@ -30,6 +32,7 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
|
import { deriveTriState } from "@/lib/device-state";
|
||||||
import { useT } from "@/lib/i18n/context";
|
import { useT } from "@/lib/i18n/context";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import type { DeviceSummary } from "@/stores/device-store";
|
import type { DeviceSummary } from "@/stores/device-store";
|
||||||
@ -44,15 +47,22 @@ export function DeviceCard({ device }: DeviceCardProps) {
|
|||||||
const isOnline = device.remoteStatus === "online";
|
const isOnline = device.remoteStatus === "online";
|
||||||
// WP-C(ADR-018):serial 為空 → 工作區(推論類操作)無法路由,入口 disable。
|
// WP-C(ADR-018):serial 為空 → 工作區(推論類操作)無法路由,入口 disable。
|
||||||
const hasSerial = !!device.serialNumber;
|
const hasSerial = !!device.serialNumber;
|
||||||
|
// 三態(TDD §5.2):online-unregistered = 已連接未註冊(第三態,走 warning 色)。
|
||||||
|
const triState = deriveTriState(device);
|
||||||
|
const isOnlineUnregistered = triState === "online-unregistered";
|
||||||
|
const isRegistered = !!device.registeredAt;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
data-testid="device-card"
|
data-testid="device-card"
|
||||||
data-remote-status={device.remoteStatus}
|
data-remote-status={device.remoteStatus}
|
||||||
|
data-tri-state={triState}
|
||||||
className={cn(
|
className={cn(
|
||||||
"transition-colors",
|
"transition-colors",
|
||||||
// 離線裝置 opacity-75(flow-offline-handling §4.1)
|
// 離線裝置 opacity-75(flow-offline-handling §4.1)
|
||||||
!isOnline && device.remoteStatus !== "reconnecting" && "opacity-75",
|
!isOnline && device.remoteStatus !== "reconnecting" && "opacity-75",
|
||||||
|
// 第三態「已連接未註冊」:warning 色邊框(配合角落 UnregisteredBadge 的文字+icon,不只靠色)。
|
||||||
|
isOnlineUnregistered && "border-warning",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
@ -63,11 +73,15 @@ export function DeviceCard({ device }: DeviceCardProps) {
|
|||||||
<p className="text-muted-foreground truncate text-xs">{device.name}</p>
|
<p className="text-muted-foreground truncate text-xs">{device.name}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-1.5">
|
||||||
<RemoteDeviceBadge
|
<RemoteDeviceBadge
|
||||||
status={device.remoteStatus}
|
status={device.remoteStatus}
|
||||||
lastSeenAt={device.lastSeenAt ?? null}
|
lastSeenAt={device.lastSeenAt ?? null}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
{/* 第三態標記:連線與註冊是正交兩軸,未註冊用獨立 warning pill 疊加(不塞進連線 badge)。 */}
|
||||||
|
{isOnlineUnregistered && <UnregisteredBadge size="sm" />}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
@ -93,6 +107,11 @@ export function DeviceCard({ device }: DeviceCardProps) {
|
|||||||
{t("common.manage")}
|
{t("common.manage")}
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
{/* 註冊 / 取消註冊:已連接未註冊 → 「註冊」;已註冊 → 「取消註冊」。
|
||||||
|
offline 未註冊不顯示(無從註冊,需先連線)。 */}
|
||||||
|
{(isOnlineUnregistered || isRegistered) && (
|
||||||
|
<DeviceRegisterActions device={device} size="sm" />
|
||||||
|
)}
|
||||||
{isOnline && device.flashedModel && hasSerial && (
|
{isOnline && device.flashedModel && hasSerial && (
|
||||||
<Link href={`/workspace/${device.id}`}>
|
<Link href={`/workspace/${device.id}`}>
|
||||||
<Button size="sm">{t("devices.openWorkspace")}</Button>
|
<Button size="sm">{t("devices.openWorkspace")}</Button>
|
||||||
|
|||||||
111
visionA-frontend/src/components/devices/device-list-controls.tsx
Normal file
111
visionA-frontend/src/components/devices/device-list-controls.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeviceListControls — 裝置列表的排序 + filter 控制列
|
||||||
|
*
|
||||||
|
* 規格來源:
|
||||||
|
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §6(排序 + filter)
|
||||||
|
*
|
||||||
|
* 設計:
|
||||||
|
* - 排序:Select(狀態 / 名稱 / 註冊時間),預設「狀態」(保留既有在線優先行為)。
|
||||||
|
* - filter:三態 chips(全部 / 已連接 / 已連接未註冊 / 未連接),aria-pressed 表達選取。
|
||||||
|
* - 純受控元件:state 由呼叫端持有(DeviceList),本元件只負責呈現 + 觸發變更。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import type { DeviceFilterKey, DeviceSortKey } from "@/lib/device-state";
|
||||||
|
import { useT } from "@/lib/i18n/context";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface DeviceListControlsProps {
|
||||||
|
sortKey: DeviceSortKey;
|
||||||
|
filter: DeviceFilterKey;
|
||||||
|
onSortChange: (key: DeviceSortKey) => void;
|
||||||
|
onFilterChange: (filter: DeviceFilterKey) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTERS: { key: DeviceFilterKey; labelKey: string }[] = [
|
||||||
|
{ key: "all", labelKey: "devices.filter.all" },
|
||||||
|
{ key: "online-registered", labelKey: "devices.filter.onlineRegistered" },
|
||||||
|
{ key: "online-unregistered", labelKey: "devices.filter.onlineUnregistered" },
|
||||||
|
{ key: "offline", labelKey: "devices.filter.offline" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SORTS: { key: DeviceSortKey; labelKey: string }[] = [
|
||||||
|
{ key: "status", labelKey: "devices.sort.status" },
|
||||||
|
{ key: "name", labelKey: "devices.sort.name" },
|
||||||
|
{ key: "registeredAt", labelKey: "devices.sort.registeredAt" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function DeviceListControls({
|
||||||
|
sortKey,
|
||||||
|
filter,
|
||||||
|
onSortChange,
|
||||||
|
onFilterChange,
|
||||||
|
}: DeviceListControlsProps) {
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-wrap items-center justify-between gap-3"
|
||||||
|
data-testid="device-list-controls"
|
||||||
|
>
|
||||||
|
{/* Filter chips — role="group" + aria-pressed(不只靠色,選取態有邊框/底色雙變化)。 */}
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label={t("devices.filter.label")}
|
||||||
|
className="flex flex-wrap gap-2"
|
||||||
|
>
|
||||||
|
{FILTERS.map(({ key, labelKey }) => {
|
||||||
|
const active = filter === key;
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant={active ? "default" : "outline"}
|
||||||
|
aria-pressed={active}
|
||||||
|
onClick={() => onFilterChange(key)}
|
||||||
|
data-testid={`device-filter-${key}`}
|
||||||
|
className={cn(active && "font-semibold")}
|
||||||
|
>
|
||||||
|
{t(labelKey)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sort — Select */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-sm">{t("devices.sort.label")}</span>
|
||||||
|
<Select
|
||||||
|
value={sortKey}
|
||||||
|
onValueChange={(v) => onSortChange(v as DeviceSortKey)}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
size="sm"
|
||||||
|
className="w-[10rem]"
|
||||||
|
data-testid="device-sort-select"
|
||||||
|
aria-label={t("devices.sort.label")}
|
||||||
|
>
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{SORTS.map(({ key, labelKey }) => (
|
||||||
|
<SelectItem key={key} value={key}>
|
||||||
|
{t(labelKey)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,45 +1,56 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DeviceList — 裝置卡片網格 + 空狀態 + skeleton
|
* DeviceList — 裝置卡片網格 + 空狀態 + skeleton + 排序/filter 控制
|
||||||
*
|
*
|
||||||
* 來源:`local-tool/frontend/src/components/devices/device-list.tsx`(雲端版改造)
|
* 來源:`local-tool/frontend/src/components/devices/device-list.tsx`(雲端版改造)
|
||||||
*
|
*
|
||||||
* 對齊:
|
* 對齊:
|
||||||
* - `.autoflow/03-design/pages.md` §5.3(空狀態)
|
* - `.autoflow/03-design/pages.md` §5.3(空狀態)
|
||||||
|
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §6(排序 + filter)
|
||||||
*
|
*
|
||||||
* 改動:
|
* 改動:
|
||||||
* - 空狀態導向 `/devices/pair`(F7 的 Pairing 頁),不再是 scan
|
* - 空狀態導向 `/devices/pair`(F7 的 Pairing 頁),不再是 scan
|
||||||
* - 排序:在線優先(online → reconnecting → unknown → offline → error)
|
* - 排序:改為可自選(狀態 / 名稱 / 註冊時間),預設「狀態」保留既有在線優先行為
|
||||||
|
* - 新增三態 filter(全部 / 已連接 / 已連接未註冊 / 未連接)+ filter 後空結果狀態
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { Link2 } from "lucide-react";
|
import { Link2, SearchX } from "lucide-react";
|
||||||
|
|
||||||
import { DeviceCard } from "@/components/devices/device-card";
|
import { DeviceCard } from "@/components/devices/device-card";
|
||||||
|
import { DeviceListControls } from "@/components/devices/device-list-controls";
|
||||||
import { EmptyState } from "@/components/ui/empty-state";
|
import { EmptyState } from "@/components/ui/empty-state";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import {
|
||||||
|
applyDeviceListView,
|
||||||
|
type DeviceFilterKey,
|
||||||
|
type DeviceSortKey,
|
||||||
|
} from "@/lib/device-state";
|
||||||
import { useT } from "@/lib/i18n/context";
|
import { useT } from "@/lib/i18n/context";
|
||||||
import type { DeviceSummary, RemoteStatus } from "@/stores/device-store";
|
import type { DeviceSummary } from "@/stores/device-store";
|
||||||
|
|
||||||
interface DeviceListProps {
|
interface DeviceListProps {
|
||||||
devices: DeviceSummary[];
|
devices: DeviceSummary[];
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_ORDER: Record<RemoteStatus, number> = {
|
|
||||||
online: 0,
|
|
||||||
reconnecting: 1,
|
|
||||||
unknown: 2,
|
|
||||||
offline: 3,
|
|
||||||
error: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
export function DeviceList({ devices, loading }: DeviceListProps) {
|
export function DeviceList({ devices, loading }: DeviceListProps) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// 排序 / filter 狀態存 local state(P0 不持久化,TDD §6.2)。
|
||||||
|
const [sortKey, setSortKey] = useState<DeviceSortKey>("status");
|
||||||
|
const [filter, setFilter] = useState<DeviceFilterKey>("all");
|
||||||
|
|
||||||
|
// 先 filter 再 sort(TDD §6.3);devices / 條件變動才重算。
|
||||||
|
const visible = useMemo(
|
||||||
|
() => applyDeviceListView(devices, filter, sortKey),
|
||||||
|
[devices, filter, sortKey],
|
||||||
|
);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -53,6 +64,7 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 完全沒有裝置(非 filter 造成)→ 導向配對的既有空狀態。
|
||||||
if (devices.length === 0) {
|
if (devices.length === 0) {
|
||||||
return (
|
return (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
@ -74,16 +86,34 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const sorted = [...devices].sort(
|
|
||||||
(a, b) => STATUS_ORDER[a.remoteStatus] - STATUS_ORDER[b.remoteStatus],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="space-y-4" data-testid="device-list-container">
|
||||||
|
<DeviceListControls
|
||||||
|
sortKey={sortKey}
|
||||||
|
filter={filter}
|
||||||
|
onSortChange={setSortKey}
|
||||||
|
onFilterChange={setFilter}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{visible.length === 0 ? (
|
||||||
|
// filter 後 0 筆 → 與「完全沒裝置」區隔的空結果狀態(可清除 filter)。
|
||||||
|
<div data-testid="device-filter-empty">
|
||||||
|
<EmptyState
|
||||||
|
icon={SearchX}
|
||||||
|
title={t("devices.filter.empty.title")}
|
||||||
|
description={t("devices.filter.empty.description")}
|
||||||
|
action={{
|
||||||
|
label: t("devices.filter.empty.action"),
|
||||||
|
onClick: () => setFilter("all"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div
|
<div
|
||||||
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
|
||||||
data-testid="device-list"
|
data-testid="device-list"
|
||||||
>
|
>
|
||||||
{sorted.map((device) => (
|
{visible.map((device) => (
|
||||||
<DeviceCard key={device.id} device={device} />
|
<DeviceCard key={device.id} device={device} />
|
||||||
))}
|
))}
|
||||||
{/* 附一個 CTA 讓使用者能配對更多裝置,避免空間死角 */}
|
{/* 附一個 CTA 讓使用者能配對更多裝置,避免空間死角 */}
|
||||||
@ -98,5 +128,7 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
|
|||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,110 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeviceRegisterActions — 註冊 / 取消註冊動作按鈕
|
||||||
|
*
|
||||||
|
* 規格來源:
|
||||||
|
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5(三態 + 註冊 UI)
|
||||||
|
* - `docs/autoflow/04-architecture/api/api-device-mgmt.md`(register / unregister 契約)
|
||||||
|
*
|
||||||
|
* 行為:
|
||||||
|
* - 「已連接未註冊」(online-unregistered)→ 顯示「註冊」按鈕(primary)。
|
||||||
|
* - 「已註冊」(registeredAt != null,不論在線與否)→ 顯示「取消註冊」按鈕(outline)。
|
||||||
|
* ⚠️ 取消註冊 ≠ 移除裝置(unpair):unregister 只退回未註冊態、保留裝置列,
|
||||||
|
* 文案明確用「取消註冊」而非「移除」,避免使用者誤以為會刪掉裝置。
|
||||||
|
* - 未連接且未註冊(offline + null)→ 無動作(不顯示按鈕)。
|
||||||
|
*
|
||||||
|
* 錯誤處理:
|
||||||
|
* - register 409 ALREADY_REGISTERED → 提示「已註冊」(後端與前端可能競態)。
|
||||||
|
* - representative REPRESENTATIVE_DEVICE / 403 FORBIDDEN → 對應 i18n,退化到 unknown 文案。
|
||||||
|
*
|
||||||
|
* 呼叫端(DeviceCard)已用 deriveTriState / registeredAt 決定是否 render 本元件。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { UserCheck, UserX } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useT } from "@/lib/i18n/context";
|
||||||
|
import type { DeviceSummary } from "@/stores/device-store";
|
||||||
|
import { useDeviceStore } from "@/stores/device-store";
|
||||||
|
|
||||||
|
interface DeviceRegisterActionsProps {
|
||||||
|
device: DeviceSummary;
|
||||||
|
/** 動作成功後的回呼(例如刷新詳情頁);卡片就地更新則可不傳。 */
|
||||||
|
onDone?: () => void | Promise<void>;
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** register 失敗時把 backend code 映射到 i18n key(找不到 → unknown 文案)。 */
|
||||||
|
function registerErrorDesc(t: (k: string) => string, code: string): string {
|
||||||
|
const key = `devices.register.error.${code}`;
|
||||||
|
const resolved = t(key);
|
||||||
|
return resolved === key ? t("devices.register.error.unknown") : resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceRegisterActions({
|
||||||
|
device,
|
||||||
|
onDone,
|
||||||
|
size = "sm",
|
||||||
|
}: DeviceRegisterActionsProps) {
|
||||||
|
const t = useT();
|
||||||
|
const registerDevice = useDeviceStore((s) => s.registerDevice);
|
||||||
|
const unregisterDevice = useDeviceStore((s) => s.unregisterDevice);
|
||||||
|
// registeringId 同時涵蓋 register / unregister 進行中;用當前 device.id 比對。
|
||||||
|
const isPending = useDeviceStore((s) => s.registeringId === device.id);
|
||||||
|
|
||||||
|
const isRegistered = !!device.registeredAt;
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
const result = await registerDevice(device.id);
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success(t("devices.register.toast.success"));
|
||||||
|
await onDone?.();
|
||||||
|
} else {
|
||||||
|
toast.error(t("devices.register.error.title"), {
|
||||||
|
description: registerErrorDesc(t, result.code),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnregister() {
|
||||||
|
const result = await unregisterDevice(device.id);
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success(t("devices.unregister.toast.success"));
|
||||||
|
await onDone?.();
|
||||||
|
} else {
|
||||||
|
toast.error(t("devices.unregister.error.title"), {
|
||||||
|
description: registerErrorDesc(t, result.code),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isRegistered) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleUnregister}
|
||||||
|
disabled={isPending}
|
||||||
|
data-testid="device-unregister-btn"
|
||||||
|
>
|
||||||
|
<UserX aria-hidden="true" className="mr-1.5 size-4" />
|
||||||
|
{isPending ? t("devices.unregister.pending") : t("devices.unregister.action")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未註冊:只有「已連接未註冊」才可註冊(offline 未註冊不顯示 → 由呼叫端 gate)。
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
size={size}
|
||||||
|
onClick={handleRegister}
|
||||||
|
disabled={isPending}
|
||||||
|
data-testid="device-register-btn"
|
||||||
|
>
|
||||||
|
<UserCheck aria-hidden="true" className="mr-1.5 size-4" />
|
||||||
|
{isPending ? t("devices.register.pending") : t("devices.register.action")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* DeviceRescanButton 測試 — 重新掃描 USB 按鈕
|
||||||
|
*
|
||||||
|
* 覆蓋:
|
||||||
|
* - 有 online 裝置 → 按鈕可點;掃描中 disable + 顯示「掃描中…」
|
||||||
|
* - 成功 → success toast;失敗 → error toast(帶對應 code 描述)
|
||||||
|
* - 無 online 裝置(tunnel 離線)→ 按鈕 disable,點不觸發 rescan
|
||||||
|
*/
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { LocaleProvider } from "@/lib/i18n/context";
|
||||||
|
import type { DeviceSummary } from "@/stores/device-store";
|
||||||
|
import { useDeviceStore } from "@/stores/device-store";
|
||||||
|
|
||||||
|
import { DeviceRescanButton } from "./device-rescan-button";
|
||||||
|
|
||||||
|
// sonner toast mock — 斷言 success / error 呼叫。
|
||||||
|
const toastSuccess = vi.fn();
|
||||||
|
const toastError = vi.fn();
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: {
|
||||||
|
success: (...args: unknown[]) => toastSuccess(...args),
|
||||||
|
error: (...args: unknown[]) => toastError(...args),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const onlineDevice: DeviceSummary = {
|
||||||
|
id: "dev-1",
|
||||||
|
name: "KL520",
|
||||||
|
type: "kl520",
|
||||||
|
status: "connected",
|
||||||
|
remoteStatus: "online",
|
||||||
|
};
|
||||||
|
|
||||||
|
const offlineDevice: DeviceSummary = {
|
||||||
|
...onlineDevice,
|
||||||
|
id: "dev-off",
|
||||||
|
remoteStatus: "offline",
|
||||||
|
};
|
||||||
|
|
||||||
|
function resetStore(devices: DeviceSummary[]) {
|
||||||
|
useDeviceStore.setState({
|
||||||
|
devices,
|
||||||
|
selectedDevice: null,
|
||||||
|
isLoading: false,
|
||||||
|
connectingId: null,
|
||||||
|
disconnectingId: null,
|
||||||
|
unpairingId: null,
|
||||||
|
registeringId: null,
|
||||||
|
isRescanning: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderButton() {
|
||||||
|
return render(
|
||||||
|
<LocaleProvider>
|
||||||
|
<DeviceRescanButton />
|
||||||
|
</LocaleProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
toastSuccess.mockClear();
|
||||||
|
toastError.mockClear();
|
||||||
|
resetStore([onlineDevice]);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("DeviceRescanButton", () => {
|
||||||
|
it("有 online 裝置 → 按鈕可點;成功時呼叫 rescanDevices 並跳 success toast", async () => {
|
||||||
|
const rescan = vi.fn().mockResolvedValue({ ok: true });
|
||||||
|
useDeviceStore.setState({ rescanDevices: rescan });
|
||||||
|
|
||||||
|
renderButton();
|
||||||
|
const btn = screen.getByTestId("device-rescan-btn");
|
||||||
|
expect(btn).toBeEnabled();
|
||||||
|
|
||||||
|
fireEvent.click(btn);
|
||||||
|
|
||||||
|
expect(rescan).toHaveBeenCalledOnce();
|
||||||
|
await waitFor(() => expect(toastSuccess).toHaveBeenCalledOnce());
|
||||||
|
expect(toastError).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("掃描中(isRescanning=true)→ 按鈕 disable 且顯示「掃描中…」", () => {
|
||||||
|
useDeviceStore.setState({ isRescanning: true });
|
||||||
|
renderButton();
|
||||||
|
const btn = screen.getByTestId("device-rescan-btn");
|
||||||
|
expect(btn).toBeDisabled();
|
||||||
|
expect(btn).toHaveTextContent("掃描中…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("失敗(TUNNEL_DISCONNECTED)→ 跳 error toast,帶離線描述", async () => {
|
||||||
|
const rescan = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ ok: false, code: "TUNNEL_DISCONNECTED", message: "offline" });
|
||||||
|
useDeviceStore.setState({ rescanDevices: rescan });
|
||||||
|
|
||||||
|
renderButton();
|
||||||
|
fireEvent.click(screen.getByTestId("device-rescan-btn"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(toastError).toHaveBeenCalledOnce());
|
||||||
|
const [, opts] = toastError.mock.calls[0] as [string, { description: string }];
|
||||||
|
expect(opts.description).toContain("local agent");
|
||||||
|
expect(toastSuccess).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("無 online 裝置(tunnel 離線)→ 按鈕 disable,點了不觸發 rescan", async () => {
|
||||||
|
const rescan = vi.fn().mockResolvedValue({ ok: true });
|
||||||
|
resetStore([offlineDevice]);
|
||||||
|
useDeviceStore.setState({ rescanDevices: rescan });
|
||||||
|
|
||||||
|
renderButton();
|
||||||
|
const btn = screen.getByTestId("device-rescan-btn");
|
||||||
|
expect(btn).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.click(btn);
|
||||||
|
expect(rescan).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DeviceRescanButton — 重新掃描 USB 裝置按鈕
|
||||||
|
*
|
||||||
|
* 動機:
|
||||||
|
* 使用者插入新的 USB 裝置後,雲端網頁抓不到(需重啟 local agent 才會 rescan)。
|
||||||
|
* 後端 rescan 管線全部現成:`POST /api/devices/scan`(雲端 proxy 轉發 local agent
|
||||||
|
* 的 ScanDevices → Rescan,重新偵測 USB 含新插入 / 拔除)。本按鈕補上前端入口:
|
||||||
|
* 點按鈕 → rescanDevices()(打 scan → 成功後 fetchDevices refresh)→ 新裝置就出現。
|
||||||
|
*
|
||||||
|
* 行為:
|
||||||
|
* - loading:掃描中 disable + spinner + 文案「掃描中…」。
|
||||||
|
* - 成功:toast「已重新掃描」。
|
||||||
|
* - 失敗:toast 友善錯誤;tunnel 離線(TUNNEL_DISCONNECTED)給明確提示。
|
||||||
|
*
|
||||||
|
* 離線 gate:
|
||||||
|
* scan 是 tunnel proxy 操作,無 online agent 時後端必回 502 TUNNEL_DISCONNECTED。
|
||||||
|
* 為避免使用者點了才失敗,這裡在「沒有任何 online 裝置」時直接 disable 按鈕,
|
||||||
|
* 並以 title / aria 提示原因(比照 workspace 對 tunnel 離線的處理精神)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { RefreshCw } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { useT } from "@/lib/i18n/context";
|
||||||
|
import { useDeviceStore } from "@/stores/device-store";
|
||||||
|
|
||||||
|
interface DeviceRescanButtonProps {
|
||||||
|
size?: "sm" | "default";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** rescan 失敗時把 backend code 映射到 i18n key(找不到 → unknown 文案)。 */
|
||||||
|
function rescanErrorDesc(t: (k: string) => string, code: string): string {
|
||||||
|
const key = `devices.rescan.error.${code}`;
|
||||||
|
const resolved = t(key);
|
||||||
|
return resolved === key ? t("devices.rescan.error.unknown") : resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DeviceRescanButton({ size = "sm" }: DeviceRescanButtonProps) {
|
||||||
|
const t = useT();
|
||||||
|
const rescanDevices = useDeviceStore((s) => s.rescanDevices);
|
||||||
|
const isRescanning = useDeviceStore((s) => s.isRescanning);
|
||||||
|
// 是否至少有一台裝置在線(有 online tunnel)——scan 只有在有 online agent 時才可能成功。
|
||||||
|
const hasOnlineAgent = useDeviceStore((s) =>
|
||||||
|
s.devices.some((d) => d.remoteStatus === "online"),
|
||||||
|
);
|
||||||
|
|
||||||
|
const disabled = isRescanning || !hasOnlineAgent;
|
||||||
|
|
||||||
|
async function handleRescan() {
|
||||||
|
const result = await rescanDevices();
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success(t("devices.rescan.toast.success"));
|
||||||
|
} else {
|
||||||
|
toast.error(t("devices.rescan.error.title"), {
|
||||||
|
description: rescanErrorDesc(t, result.code),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size={size}
|
||||||
|
onClick={handleRescan}
|
||||||
|
disabled={disabled}
|
||||||
|
data-testid="device-rescan-btn"
|
||||||
|
// 離線時說明為何 disable;線上時給一般 tooltip。
|
||||||
|
title={hasOnlineAgent ? undefined : t("devices.rescan.offlineHint")}
|
||||||
|
aria-label={t("devices.rescan.action")}
|
||||||
|
>
|
||||||
|
<RefreshCw
|
||||||
|
aria-hidden="true"
|
||||||
|
className={`mr-2 size-4 ${isRescanning ? "animate-spin" : ""}`}
|
||||||
|
/>
|
||||||
|
{isRescanning ? t("devices.rescan.pending") : t("devices.rescan.action")}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,50 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* UnregisteredBadge — 「已連接未註冊」第三態標記
|
||||||
|
*
|
||||||
|
* 規格來源:
|
||||||
|
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5.3(配色落地)
|
||||||
|
*
|
||||||
|
* 設計要點:
|
||||||
|
* - 連線狀態(RemoteDeviceBadge)與註冊狀態是正交兩軸,硬塞進同一個 badge 會讓
|
||||||
|
* 「online 但未註冊」的顏色語意打架 → 用獨立的 warning 色 pill 疊加表達「未註冊」。
|
||||||
|
* - 配色只用既有 warning design token(--warning / --warning-foreground / --warning-subtle),
|
||||||
|
* 禁止裸色(bg-yellow-*)——對齊 pairing / login / flash-dialog 的既有慣例。
|
||||||
|
* - 無障礙(design-review M2):不只靠顏色,帶 icon(TriangleAlert)+ 文字「未註冊」。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TriangleAlert } from "lucide-react";
|
||||||
|
|
||||||
|
import { useT } from "@/lib/i18n/context";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
export interface UnregisteredBadgeProps {
|
||||||
|
size?: "sm" | "md";
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UnregisteredBadge({ size = "sm", className }: UnregisteredBadgeProps) {
|
||||||
|
const t = useT();
|
||||||
|
const label = t("devices.state.unregistered");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid="unregistered-badge"
|
||||||
|
// role/aria-label:讓 SR 讀出「未註冊」而非只感知一個色塊。
|
||||||
|
role="status"
|
||||||
|
aria-label={label}
|
||||||
|
className={cn(
|
||||||
|
"bg-warning-subtle text-warning-foreground border-warning inline-flex items-center gap-1 rounded-full border font-medium",
|
||||||
|
size === "sm" ? "px-2 py-0.5 text-xs" : "px-2.5 py-1 text-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<TriangleAlert
|
||||||
|
aria-hidden="true"
|
||||||
|
className={cn("text-warning shrink-0", size === "sm" ? "size-3" : "size-3.5")}
|
||||||
|
/>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
visionA-frontend/src/components/models/library-model-card.tsx
Normal file
154
visionA-frontend/src/components/models/library-model-card.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LibraryModelCard — 共享模型庫卡片
|
||||||
|
*
|
||||||
|
* 對齊設計規格 §4.2。基於 `LibraryModel`(共享庫 DTO,含 visibility / owner / sharedWithMe /
|
||||||
|
* myAccess),沿用既有 ModelCard 的視覺語彙(Card + Badge 列 + metadata grid)。
|
||||||
|
*
|
||||||
|
* 與既有 ModelCard 差異:
|
||||||
|
* - 新增 visibility badge(三態 + sharedWithMe,見 ModelVisibilityBadge)
|
||||||
|
* - owner 卡片(owner.isMe):右上角 ⋮ 選單 → 公開設定(開 ModelVisibilityDialog)
|
||||||
|
* - receiver 卡片:次要資訊列「由 {ownerName} 共享」(契約不揭露 email,故用 owner.name)
|
||||||
|
* - 整張卡片是 <Link> 到 /models/{id}(profile 頁)
|
||||||
|
*
|
||||||
|
* ⚠️ 契約 §4:response 不含 owner email,故 receiver 資訊列用 owner.name(非 email)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { MoreVertical, Settings2 } from "lucide-react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { ModelVisibilityBadge } from "@/components/models/model-visibility-badge";
|
||||||
|
import { ModelVisibilityDialog } from "@/components/models/model-visibility-dialog";
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import type { LibraryModel } from "@/lib/api/model-sharing";
|
||||||
|
import { formatRelativeTime } from "@/lib/format/relative-time";
|
||||||
|
import { useT } from "@/lib/i18n/context";
|
||||||
|
|
||||||
|
interface LibraryModelCardProps {
|
||||||
|
model: LibraryModel;
|
||||||
|
/** deterministic 相對時間用(測試傳入固定值);預設 Date.now()。 */
|
||||||
|
nowMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatFileSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryModelCard({ model, nowMs }: LibraryModelCardProps) {
|
||||||
|
const t = useT();
|
||||||
|
const [visibilityDialogOpen, setVisibilityDialogOpen] = useState(false);
|
||||||
|
// 掛載時固定一次「現在」,避免 render 期呼叫 impure Date.now()。
|
||||||
|
const [mountedNow] = useState(() => Date.now());
|
||||||
|
const isOwner = model.owner.isMe;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card className="hover:bg-accent/40 relative h-full transition-shadow hover:shadow-md">
|
||||||
|
{/* owner ⋮ 選單(絕對定位右上,避免與 <Link> 導航衝突)。 */}
|
||||||
|
{isOwner && (
|
||||||
|
<div className="absolute right-2 top-2 z-10">
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-9"
|
||||||
|
aria-label={t("models.card.menu.aria")}
|
||||||
|
data-testid="library-card-menu"
|
||||||
|
onClick={(e) => {
|
||||||
|
// 阻止冒泡到外層 <Link>。
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MoreVertical aria-hidden className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onSelect={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setVisibilityDialogOpen(true);
|
||||||
|
}}
|
||||||
|
data-testid="library-card-visibility"
|
||||||
|
>
|
||||||
|
<Settings2 aria-hidden className="size-4" />
|
||||||
|
{t("models.visibility.title")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Link href={`/models/${model.id}`} data-testid="library-model-card">
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<div className="flex items-start justify-between gap-2 pr-8">
|
||||||
|
<CardTitle className="text-base leading-tight">{model.name}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
{model.targetChip.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
<ModelVisibilityBadge
|
||||||
|
visibility={model.visibility}
|
||||||
|
sharedWithMe={model.sharedWithMe}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("models.size")}</p>
|
||||||
|
<p className="font-medium">{formatFileSize(model.fileSize)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-muted-foreground">{t("models.createdAt")}</p>
|
||||||
|
<p className="font-medium">
|
||||||
|
{model.createdAt
|
||||||
|
? new Date(model.createdAt).toLocaleDateString()
|
||||||
|
: "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* receiver 視角:顯示分享者(契約不給 email,用 owner.name)。 */}
|
||||||
|
{!isOwner && (
|
||||||
|
<p
|
||||||
|
className="text-muted-foreground mt-3 text-xs"
|
||||||
|
data-testid="library-card-owner-info"
|
||||||
|
>
|
||||||
|
{t("models.sharedByName").replace("{name}", model.owner.name)}
|
||||||
|
{model.updatedAt
|
||||||
|
? ` · ${formatRelativeTime(model.updatedAt, nowMs ?? mountedNow, t)}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Link>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{isOwner && (
|
||||||
|
<ModelVisibilityDialog
|
||||||
|
modelId={model.id}
|
||||||
|
modelName={model.name}
|
||||||
|
currentVisibility={model.visibility}
|
||||||
|
open={visibilityDialogOpen}
|
||||||
|
onOpenChange={setVisibilityDialogOpen}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
163
visionA-frontend/src/components/models/library-toolbar.tsx
Normal file
163
visionA-frontend/src/components/models/library-toolbar.tsx
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* LibraryToolbar — 共享模型庫工具列(搜尋 + filter + 排序)
|
||||||
|
*
|
||||||
|
* 對齊設計規格 §4.3–§4.5。控制 model-sharing-store 的 filters。
|
||||||
|
*
|
||||||
|
* 元素(Mobile First,窄螢幕堆疊 wrap):
|
||||||
|
* - 搜尋框(Input + Search icon + 清除鈕),role="searchbox",debounce 由 parent 處理
|
||||||
|
* - 擁有關係 filter(全部 / 我的 / 共享給我)
|
||||||
|
* - 可見性 filter(全部 / 公開 / 同租戶)
|
||||||
|
* - 晶片 filter(沿用既有 targetChip 選項)
|
||||||
|
* - 排序(最新建立 / 名稱 / 檔案大小)
|
||||||
|
*
|
||||||
|
* 搜尋 debounce:本元件維持 local input state,透過 onSearchChange 通知 parent(parent 做 debounce)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Search, X } from "lucide-react";
|
||||||
|
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { useT } from "@/lib/i18n/context";
|
||||||
|
import type { LibraryFilters } from "@/stores/model-sharing-store";
|
||||||
|
|
||||||
|
interface LibraryToolbarProps {
|
||||||
|
filters: LibraryFilters;
|
||||||
|
/** 搜尋框當前輸入(受控;由 parent 管理以便 debounce)。 */
|
||||||
|
searchInput: string;
|
||||||
|
onSearchChange: (value: string) => void;
|
||||||
|
onFilterChange: (patch: Partial<LibraryFilters>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LibraryToolbar({
|
||||||
|
filters,
|
||||||
|
searchInput,
|
||||||
|
onSearchChange,
|
||||||
|
onFilterChange,
|
||||||
|
}: LibraryToolbarProps) {
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col gap-2 sm:flex-row sm:flex-wrap sm:items-center"
|
||||||
|
data-testid="library-toolbar"
|
||||||
|
role="group"
|
||||||
|
aria-label={t("models.filters.label")}
|
||||||
|
>
|
||||||
|
{/* 搜尋框 */}
|
||||||
|
<div className="relative w-full sm:max-w-xs">
|
||||||
|
<Search
|
||||||
|
aria-hidden
|
||||||
|
className="text-muted-foreground pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="search"
|
||||||
|
role="searchbox"
|
||||||
|
value={searchInput}
|
||||||
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
|
placeholder={t("models.search.placeholder")}
|
||||||
|
aria-label={t("models.search.aria")}
|
||||||
|
className="pl-9 pr-9"
|
||||||
|
data-testid="library-search"
|
||||||
|
/>
|
||||||
|
{searchInput && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute right-1 top-1/2 size-7 -translate-y-1/2"
|
||||||
|
onClick={() => onSearchChange("")}
|
||||||
|
aria-label={t("models.search.clear")}
|
||||||
|
data-testid="library-search-clear"
|
||||||
|
>
|
||||||
|
<X aria-hidden className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 擁有關係 filter */}
|
||||||
|
<Select
|
||||||
|
value={filters.owned}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
onFilterChange({ owned: v as LibraryFilters["owned"] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-full sm:w-40" aria-label={t("models.filters.owned")}>
|
||||||
|
<SelectValue placeholder={t("models.filters.owned")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{t("models.filters.owned.all")}</SelectItem>
|
||||||
|
<SelectItem value="mine">{t("models.filters.owned.mine")}</SelectItem>
|
||||||
|
<SelectItem value="shared">{t("models.filters.owned.shared")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 可見性 filter */}
|
||||||
|
<Select
|
||||||
|
value={filters.visibility}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
onFilterChange({ visibility: v as LibraryFilters["visibility"] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="h-9 w-full sm:w-40"
|
||||||
|
aria-label={t("models.filters.visibility")}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={t("models.filters.visibility")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{t("models.filters.all")}</SelectItem>
|
||||||
|
<SelectItem value="public">{t("models.visibility.public")}</SelectItem>
|
||||||
|
<SelectItem value="tenant">{t("models.visibility.tenant")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 晶片 filter */}
|
||||||
|
<Select
|
||||||
|
value={filters.targetChip}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
onFilterChange({ targetChip: v as LibraryFilters["targetChip"] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="h-9 w-full sm:w-36"
|
||||||
|
aria-label={t("models.filters.hardware")}
|
||||||
|
>
|
||||||
|
<SelectValue placeholder={t("models.filters.hardware")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="all">{t("models.filters.all")}</SelectItem>
|
||||||
|
<SelectItem value="kl520">KL520</SelectItem>
|
||||||
|
<SelectItem value="kl720">KL720</SelectItem>
|
||||||
|
<SelectItem value="kl630">KL630</SelectItem>
|
||||||
|
<SelectItem value="kl730">KL730</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
{/* 排序 */}
|
||||||
|
<Select
|
||||||
|
value={filters.sort}
|
||||||
|
onValueChange={(v) =>
|
||||||
|
onFilterChange({ sort: v as LibraryFilters["sort"] })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="h-9 w-full sm:w-40" aria-label={t("models.sort.label")}>
|
||||||
|
<SelectValue placeholder={t("models.sort.label")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="created_at">{t("models.sort.createdAt")}</SelectItem>
|
||||||
|
<SelectItem value="name">{t("models.sort.name")}</SelectItem>
|
||||||
|
<SelectItem value="file_size">{t("models.sort.fileSize")}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
49
visionA-frontend/src/components/models/model-owner-bar.tsx
Normal file
49
visionA-frontend/src/components/models/model-owner-bar.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModelOwnerBar — 模型擁有者資訊列(模型共享功能)
|
||||||
|
*
|
||||||
|
* 對齊設計規格 §6.4。僅在非 owner 檢視 profile 時渲染,顯示「由 {ownerName} 共享 · {time}」。
|
||||||
|
*
|
||||||
|
* ⚠️ 契約 §4:response 不揭露 owner email,故用 owner.name(非 email)。
|
||||||
|
* 頭像用 owner.name 首字母(沿用 UserMenu avatar 樣式)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
|
||||||
|
import { formatRelativeTime } from "@/lib/format/relative-time";
|
||||||
|
import { useT } from "@/lib/i18n/context";
|
||||||
|
|
||||||
|
interface ModelOwnerBarProps {
|
||||||
|
ownerName: string;
|
||||||
|
/** 共享時間(ISO);用 updatedAt 近似(契約無獨立 sharedAt 欄)。 */
|
||||||
|
sharedAt?: string;
|
||||||
|
nowMs?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ModelOwnerBar({ ownerName, sharedAt, nowMs }: ModelOwnerBarProps) {
|
||||||
|
const t = useT();
|
||||||
|
const initial = ownerName.trim().charAt(0).toUpperCase() || "?";
|
||||||
|
// 掛載時固定一次「現在」,避免 render 期呼叫 impure Date.now()(相對時間顯示不需即時更新)。
|
||||||
|
const [mountedNow] = useState(() => Date.now());
|
||||||
|
const relative = sharedAt
|
||||||
|
? formatRelativeTime(sharedAt, nowMs ?? mountedNow, t)
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="bg-muted/50 flex items-center gap-2 rounded-md px-3 py-2 text-sm"
|
||||||
|
aria-label={t("models.ownerBar.aria")}
|
||||||
|
data-testid="model-owner-bar"
|
||||||
|
>
|
||||||
|
<Avatar className="size-6">
|
||||||
|
<AvatarFallback>{initial}</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<span className="truncate">
|
||||||
|
{t("models.sharedByName").replace("{name}", ownerName)}
|
||||||
|
{relative ? ` · ${relative}` : ""}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* ModelVisibilityBadge 測試
|
||||||
|
*
|
||||||
|
* 覆蓋三態雙編碼(圖示 + 文字,不僅靠顏色)+ shared_with_me 優先顯示。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { render, screen } from "@testing-library/react";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { LocaleProvider } from "@/lib/i18n/context";
|
||||||
|
import type { ModelVisibility } from "@/lib/api/model-sharing";
|
||||||
|
|
||||||
|
import { ModelVisibilityBadge } from "./model-visibility-badge";
|
||||||
|
|
||||||
|
function renderBadge(props: {
|
||||||
|
visibility: ModelVisibility;
|
||||||
|
sharedWithMe?: boolean;
|
||||||
|
sharedCount?: number;
|
||||||
|
}) {
|
||||||
|
return render(
|
||||||
|
<LocaleProvider>
|
||||||
|
<ModelVisibilityBadge {...props} />
|
||||||
|
</LocaleProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("ModelVisibilityBadge 三態", () => {
|
||||||
|
it("private → 顯示「私有」文字 + data-visibility=private", () => {
|
||||||
|
renderBadge({ visibility: "private" });
|
||||||
|
const badge = screen.getByTestId("model-visibility-badge");
|
||||||
|
expect(badge).toHaveAttribute("data-visibility", "private");
|
||||||
|
expect(badge).toHaveTextContent("私有");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("public → 顯示「公開」文字 + data-visibility=public", () => {
|
||||||
|
renderBadge({ visibility: "public" });
|
||||||
|
const badge = screen.getByTestId("model-visibility-badge");
|
||||||
|
expect(badge).toHaveAttribute("data-visibility", "public");
|
||||||
|
expect(badge).toHaveTextContent("公開");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tenant → 顯示「同租戶」文字 + data-visibility=tenant", () => {
|
||||||
|
renderBadge({ visibility: "tenant" });
|
||||||
|
const badge = screen.getByTestId("model-visibility-badge");
|
||||||
|
expect(badge).toHaveAttribute("data-visibility", "tenant");
|
||||||
|
expect(badge).toHaveTextContent("同租戶");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ModelVisibilityBadge sharedWithMe 優先", () => {
|
||||||
|
it("sharedWithMe=true → 顯示「共享給我」+ data-visibility=shared(覆蓋 visibility)", () => {
|
||||||
|
renderBadge({ visibility: "public", sharedWithMe: true });
|
||||||
|
const badge = screen.getByTestId("model-visibility-badge");
|
||||||
|
expect(badge).toHaveAttribute("data-visibility", "shared");
|
||||||
|
expect(badge).toHaveTextContent("共享給我");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("ModelVisibilityBadge sharedCount 尾綴", () => {
|
||||||
|
it("public + sharedCount=3 → 顯示「· 3 人」", () => {
|
||||||
|
renderBadge({ visibility: "public", sharedCount: 3 });
|
||||||
|
const badge = screen.getByTestId("model-visibility-badge");
|
||||||
|
expect(badge).toHaveTextContent("3 人");
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,100 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModelVisibilityBadge — 模型可見性徽章(模型共享功能)
|
||||||
|
*
|
||||||
|
* 三態雙編碼(圖示 + 文字,不僅靠顏色,滿足無障礙 De1):
|
||||||
|
* - private(Lock,中性):owner 檢視自己的私有模型
|
||||||
|
* - public(Globe,chart-2 tint):公開給所有 visionA 使用者
|
||||||
|
* - tenant(Building2,chart-1 tint):同租戶可見
|
||||||
|
*
|
||||||
|
* 另有「共享給我」的獨立標示(sharedWithMe=true → Users,chart-3 tint):
|
||||||
|
* 此態源自 model_shares 維度(與 visibility 正交,見 api-model-sharing.md §0),
|
||||||
|
* 優先於 visibility 顯示——receiver 最關心的是「這是別人分享給我的」。
|
||||||
|
*
|
||||||
|
* 配色沿用既有 chart-* token tint 風格(bg/10 + 純色文字 + /30 邊框),
|
||||||
|
* 與 model-card 的 source badge 同做法,Dark Mode 由 token 自動處理,零新 token。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { Building2, Globe, Lock, Users } from "lucide-react";
|
||||||
|
|
||||||
|
import { Badge } from "@/components/ui/badge";
|
||||||
|
import { useT } from "@/lib/i18n/context";
|
||||||
|
import type { ModelVisibility } from "@/lib/api/model-sharing";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
interface ModelVisibilityBadgeProps {
|
||||||
|
visibility: ModelVisibility;
|
||||||
|
/** model_shares 命中(別人分享給我)→ 優先顯示「共享給我」。 */
|
||||||
|
sharedWithMe?: boolean;
|
||||||
|
/** owner 視角:已分享給幾人(顯示在 badge 尾綴,如「公開 · 3 人」)。可選。 */
|
||||||
|
sharedCount?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VISIBILITY_META: Record<
|
||||||
|
ModelVisibility,
|
||||||
|
{ icon: typeof Lock; labelKey: string; className: string }
|
||||||
|
> = {
|
||||||
|
private: {
|
||||||
|
icon: Lock,
|
||||||
|
labelKey: "models.visibility.badge.private",
|
||||||
|
className: "text-muted-foreground border-border",
|
||||||
|
},
|
||||||
|
public: {
|
||||||
|
icon: Globe,
|
||||||
|
labelKey: "models.visibility.badge.public",
|
||||||
|
className: "border-chart-2/30 bg-chart-2/10 text-chart-2",
|
||||||
|
},
|
||||||
|
tenant: {
|
||||||
|
icon: Building2,
|
||||||
|
labelKey: "models.visibility.badge.tenant",
|
||||||
|
className: "border-chart-1/30 bg-chart-1/10 text-chart-1",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ModelVisibilityBadge({
|
||||||
|
visibility,
|
||||||
|
sharedWithMe = false,
|
||||||
|
sharedCount,
|
||||||
|
className,
|
||||||
|
}: ModelVisibilityBadgeProps) {
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
|
// 「共享給我」優先於 visibility 顯示(receiver 視角)。
|
||||||
|
if (sharedWithMe) {
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn("border-chart-3/30 bg-chart-3/10 text-chart-3 gap-1 text-xs", className)}
|
||||||
|
data-testid="model-visibility-badge"
|
||||||
|
data-visibility="shared"
|
||||||
|
>
|
||||||
|
<Users aria-hidden className="size-3" />
|
||||||
|
{t("models.visibility.badge.sharedWithMe")}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const meta = VISIBILITY_META[visibility];
|
||||||
|
const Icon = meta.icon;
|
||||||
|
const label = t(meta.labelKey);
|
||||||
|
// owner 視角:public/tenant 且有分享人數時,尾綴「· N 人」。
|
||||||
|
const suffix =
|
||||||
|
sharedCount && sharedCount > 0
|
||||||
|
? ` · ${t("models.visibility.badge.sharedCount").replace("{n}", String(sharedCount))}`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Badge
|
||||||
|
variant="outline"
|
||||||
|
className={cn("gap-1 text-xs", meta.className, className)}
|
||||||
|
data-testid="model-visibility-badge"
|
||||||
|
data-visibility={visibility}
|
||||||
|
>
|
||||||
|
<Icon aria-hidden className="size-3" />
|
||||||
|
{label}
|
||||||
|
{suffix}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* ModelVisibilityDialog 測試
|
||||||
|
*
|
||||||
|
* 覆蓋:
|
||||||
|
* - 三態選項渲染(私有 / 公開 / 同租戶)
|
||||||
|
* - public 選中 → amber 警告條顯示
|
||||||
|
* - email 加入:格式錯 → inline 錯誤;重複 → 提示;合法 → 呼叫 store.addShare
|
||||||
|
* - 授權清單渲染 + 移除
|
||||||
|
* - 儲存 → 呼叫 store.updateVisibility + toast.success + 關閉
|
||||||
|
*
|
||||||
|
* 走 store mock 模式(_setMockMode true)+ 直接注入 shares,避免打真實 API。
|
||||||
|
* Radix Dialog / RadioGroup 在 jsdom:dialog 受控 open=true,內容 render 到 portal 可查。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||||
|
|
||||||
|
import { LocaleProvider } from "@/lib/i18n/context";
|
||||||
|
import { useModelSharingStore } from "@/stores/model-sharing-store";
|
||||||
|
|
||||||
|
vi.mock("sonner", () => {
|
||||||
|
const success = vi.fn();
|
||||||
|
const error = vi.fn();
|
||||||
|
return { toast: Object.assign(vi.fn(), { success, error }) };
|
||||||
|
});
|
||||||
|
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import { ModelVisibilityDialog } from "./model-visibility-dialog";
|
||||||
|
|
||||||
|
function resetStore() {
|
||||||
|
useModelSharingStore.setState({
|
||||||
|
shares: [],
|
||||||
|
isSharesLoading: false,
|
||||||
|
_mockMode: true,
|
||||||
|
// stub loadShares 為 noop:測試自行以 setState 注入 shares,
|
||||||
|
// 避免 body 掛載時 mock loadShares 覆蓋注入的清單。
|
||||||
|
loadShares: async () => {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderDialog(currentVisibility: "private" | "public" | "tenant" = "private") {
|
||||||
|
return render(
|
||||||
|
<LocaleProvider>
|
||||||
|
<ModelVisibilityDialog
|
||||||
|
modelId="mock-model-01"
|
||||||
|
modelName="測試模型"
|
||||||
|
currentVisibility={currentVisibility}
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
/>
|
||||||
|
</LocaleProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStore();
|
||||||
|
(toast.success as Mock).mockReset();
|
||||||
|
(toast.error as Mock).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("三態選項渲染", () => {
|
||||||
|
it("顯示私有 / 公開 / 同租戶三選項", async () => {
|
||||||
|
renderDialog();
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("model-visibility-dialog")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("radio", { name: "私有" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("radio", { name: "公開" })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("radio", { name: "同租戶" })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("public 警告條", () => {
|
||||||
|
it("初始 currentVisibility=public → 顯示 amber 警告", async () => {
|
||||||
|
renderDialog("public");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("visibility-public-warning")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("初始 private → 不顯示警告", async () => {
|
||||||
|
renderDialog("private");
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByTestId("model-visibility-dialog")).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(screen.queryByTestId("visibility-public-warning")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("email 加入驗證", () => {
|
||||||
|
it("格式錯 → inline 錯誤,不呼叫 addShare", async () => {
|
||||||
|
const spy = vi.spyOn(useModelSharingStore.getState(), "addShare");
|
||||||
|
renderDialog();
|
||||||
|
await waitFor(() => screen.getByTestId("share-email-input"));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId("share-email-input"), {
|
||||||
|
target: { value: "not-an-email" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("share-email-add"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("Email 格式不正確")).toBeInTheDocument());
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("重複 email → 提示已在清單中", async () => {
|
||||||
|
useModelSharingStore.setState({
|
||||||
|
shares: [
|
||||||
|
{ userId: "u1", email: "dup@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
renderDialog();
|
||||||
|
await waitFor(() => screen.getByTestId("share-email-input"));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId("share-email-input"), {
|
||||||
|
target: { value: "dup@corp.com" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("share-email-add"));
|
||||||
|
|
||||||
|
await waitFor(() => expect(screen.getByText("已在清單中")).toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("合法 email → 呼叫 addShare", async () => {
|
||||||
|
const spy = vi
|
||||||
|
.spyOn(useModelSharingStore.getState(), "addShare")
|
||||||
|
.mockResolvedValue({ ok: true });
|
||||||
|
renderDialog();
|
||||||
|
await waitFor(() => screen.getByTestId("share-email-input"));
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByTestId("share-email-input"), {
|
||||||
|
target: { value: "new@corp.com" },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByTestId("share-email-add"));
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(spy).toHaveBeenCalledWith("mock-model-01", "new@corp.com"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("授權清單", () => {
|
||||||
|
it("渲染既有 shares + 移除鈕", async () => {
|
||||||
|
useModelSharingStore.setState({
|
||||||
|
shares: [
|
||||||
|
{ userId: "u1", email: "alice@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
renderDialog();
|
||||||
|
await waitFor(() => expect(screen.getByText("alice@corp.com")).toBeInTheDocument());
|
||||||
|
expect(screen.getByTestId("share-remove")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("點移除 → 呼叫 removeShare", async () => {
|
||||||
|
useModelSharingStore.setState({
|
||||||
|
shares: [
|
||||||
|
{ userId: "u1", email: "alice@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const spy = vi
|
||||||
|
.spyOn(useModelSharingStore.getState(), "removeShare")
|
||||||
|
.mockResolvedValue({ ok: true });
|
||||||
|
renderDialog();
|
||||||
|
await waitFor(() => screen.getByTestId("share-remove"));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("share-remove"));
|
||||||
|
await waitFor(() => expect(spy).toHaveBeenCalledWith("mock-model-01", "u1"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("儲存", () => {
|
||||||
|
it("點儲存(private,無變更)→ updateVisibility + toast.success", async () => {
|
||||||
|
const spy = vi
|
||||||
|
.spyOn(useModelSharingStore.getState(), "updateVisibility")
|
||||||
|
.mockResolvedValue({ ok: true });
|
||||||
|
renderDialog("private");
|
||||||
|
await waitFor(() => screen.getByTestId("visibility-save"));
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("visibility-save"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(spy).toHaveBeenCalledWith("mock-model-01", "private"),
|
||||||
|
);
|
||||||
|
await waitFor(() => expect(toast.success).toHaveBeenCalled());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("由 public 收回成 private 且有授權對象 → 先跳二次確認(不直接 save)", async () => {
|
||||||
|
useModelSharingStore.setState({
|
||||||
|
shares: [
|
||||||
|
{ userId: "u1", email: "alice@corp.com", role: "viewer", createdAt: "2026-07-01T00:00:00Z" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const spy = vi
|
||||||
|
.spyOn(useModelSharingStore.getState(), "updateVisibility")
|
||||||
|
.mockResolvedValue({ ok: true });
|
||||||
|
renderDialog("public");
|
||||||
|
await waitFor(() => screen.getByRole("radio", { name: "私有" }));
|
||||||
|
|
||||||
|
// 選「私有」
|
||||||
|
fireEvent.click(screen.getByRole("radio", { name: "私有" }));
|
||||||
|
fireEvent.click(screen.getByTestId("visibility-save"));
|
||||||
|
|
||||||
|
// 應出現二次確認,updateVisibility 尚未被呼叫
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(screen.getByText(/改為私有後/)).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -0,0 +1,385 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ModelVisibilityDialog — 模型公開設定(owner-only)
|
||||||
|
*
|
||||||
|
* 對齊設計規格 §5 + API 契約 §3(PATCH visibility)+ shares API(§4)。
|
||||||
|
*
|
||||||
|
* 內容:
|
||||||
|
* - RadioGroup 三態:private / public / tenant(同租戶)
|
||||||
|
* (API 契約 visibility 三態;PRD 的 restricted「指定對象」對應正交的 model_shares 維度,
|
||||||
|
* 下方獨立區塊管理,不佔 visibility 選項)
|
||||||
|
* - 指定對象(model_shares):email 加入 + 授權清單管理(永遠可用,與 visibility 正交)
|
||||||
|
* - public 選中 → amber 警告條
|
||||||
|
* - 由 public/tenant 改回 private 且曾分享給人 → AlertDialog 二次確認
|
||||||
|
*
|
||||||
|
* 受控元件:由 parent(卡片 ⋮ 選單 / profile 按鈕)以 open / onOpenChange 控制。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Globe, Lock, X } from "lucide-react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from "@/components/ui/alert-dialog";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@/components/ui/dialog";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
|
import { Spinner } from "@/components/ui/spinner";
|
||||||
|
import { isValidEmail, type ModelVisibility } from "@/lib/api/model-sharing";
|
||||||
|
import { useT } from "@/lib/i18n/context";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { useModelSharingStore } from "@/stores/model-sharing-store";
|
||||||
|
|
||||||
|
interface ModelVisibilityDialogProps {
|
||||||
|
modelId: string;
|
||||||
|
modelName: string;
|
||||||
|
/** 目前的可見性(開啟時的初始值)。 */
|
||||||
|
currentVisibility: ModelVisibility;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 三態選項(對齊 i18n key)。 */
|
||||||
|
const VISIBILITY_OPTIONS: ReadonlyArray<{
|
||||||
|
value: ModelVisibility;
|
||||||
|
labelKey: string;
|
||||||
|
descKey: string;
|
||||||
|
}> = [
|
||||||
|
{ value: "private", labelKey: "models.visibility.private", descKey: "models.visibility.private.desc" },
|
||||||
|
{ value: "public", labelKey: "models.visibility.public", descKey: "models.visibility.public.desc" },
|
||||||
|
{ value: "tenant", labelKey: "models.visibility.tenant", descKey: "models.visibility.tenant.desc" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 外層殼:受控 Dialog。內容以 key remount,讓每次開啟時 body 用最新 props 初始化 state
|
||||||
|
* (避免 setState-in-effect 的 cascading render)。
|
||||||
|
*/
|
||||||
|
export function ModelVisibilityDialog(props: ModelVisibilityDialogProps) {
|
||||||
|
const { open, onOpenChange } = props;
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
{/* key 綁 modelId:開啟不同模型時 body remount,state 重新以 props 初始化。 */}
|
||||||
|
{open && <VisibilityDialogBody key={props.modelId} {...props} />}
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dialog body(僅在 open 時掛載)。state 直接以 props 初始化(無 setState-in-effect);
|
||||||
|
* 唯一副作用是掛載時載入 shares(external sync,合法 effect)。
|
||||||
|
*/
|
||||||
|
function VisibilityDialogBody({
|
||||||
|
modelId,
|
||||||
|
modelName,
|
||||||
|
currentVisibility,
|
||||||
|
onOpenChange,
|
||||||
|
}: ModelVisibilityDialogProps) {
|
||||||
|
const t = useT();
|
||||||
|
|
||||||
|
const shares = useModelSharingStore((s) => s.shares);
|
||||||
|
const isSharesLoading = useModelSharingStore((s) => s.isSharesLoading);
|
||||||
|
const loadShares = useModelSharingStore((s) => s.loadShares);
|
||||||
|
const updateVisibility = useModelSharingStore((s) => s.updateVisibility);
|
||||||
|
const addShareAction = useModelSharingStore((s) => s.addShare);
|
||||||
|
const removeShareAction = useModelSharingStore((s) => s.removeShare);
|
||||||
|
|
||||||
|
const [visibility, setVisibility] = useState<ModelVisibility>(currentVisibility);
|
||||||
|
const [emailInput, setEmailInput] = useState("");
|
||||||
|
const [emailError, setEmailError] = useState<string | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(null);
|
||||||
|
const [addingEmail, setAddingEmail] = useState(false);
|
||||||
|
const [confirmRevokeOpen, setConfirmRevokeOpen] = useState(false);
|
||||||
|
|
||||||
|
// 掛載時載入授權清單(external sync);modelId 於本 body 生命週期固定(key remount)。
|
||||||
|
// loadShares 是 zustand action,身分穩定,可安全放入 deps。
|
||||||
|
useEffect(() => {
|
||||||
|
void loadShares(modelId);
|
||||||
|
}, [modelId, loadShares]);
|
||||||
|
|
||||||
|
async function handleAddEmail() {
|
||||||
|
const email = emailInput.trim();
|
||||||
|
if (!email) return;
|
||||||
|
if (!isValidEmail(email)) {
|
||||||
|
setEmailError(t("models.visibility.emailInvalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (shares.some((s) => s.email.toLowerCase() === email.toLowerCase())) {
|
||||||
|
setEmailError(t("models.visibility.emailDuplicate"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setEmailError(null);
|
||||||
|
setAddingEmail(true);
|
||||||
|
const result = await addShareAction(modelId, email);
|
||||||
|
setAddingEmail(false);
|
||||||
|
if (result.ok) {
|
||||||
|
setEmailInput("");
|
||||||
|
} else if (result.code === "not_found") {
|
||||||
|
setEmailError(t("models.visibility.userNotFound").replace("{email}", email));
|
||||||
|
} else {
|
||||||
|
setEmailError(t("models.sharing.error.generic"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRemoveShare(userId: string) {
|
||||||
|
const result = await removeShareAction(modelId, userId);
|
||||||
|
if (!result.ok) {
|
||||||
|
toast.error(t("models.sharing.error.generic"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 執行儲存(可能先過二次確認)。 */
|
||||||
|
async function doSave() {
|
||||||
|
setSaving(true);
|
||||||
|
setSaveError(null);
|
||||||
|
const result = await updateVisibility(modelId, visibility);
|
||||||
|
setSaving(false);
|
||||||
|
if (result.ok) {
|
||||||
|
toast.success(t("models.visibility.saved"));
|
||||||
|
onOpenChange(false);
|
||||||
|
} else if (result.code === "conflict") {
|
||||||
|
setSaveError(t("models.visibility.notReady"));
|
||||||
|
} else {
|
||||||
|
setSaveError(t("models.visibility.saveFailed"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleSaveClick() {
|
||||||
|
// 由 public/tenant 收回成 private 且曾有授權對象 → 二次確認。
|
||||||
|
const wasBroadcast = currentVisibility === "public" || currentVisibility === "tenant";
|
||||||
|
if (visibility === "private" && wasBroadcast && shares.length > 0) {
|
||||||
|
setConfirmRevokeOpen(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void doSave();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<DialogContent className="max-w-lg" data-testid="model-visibility-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("models.visibility.title")} — {modelName}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm font-medium">{t("models.visibility.question")}</p>
|
||||||
|
|
||||||
|
<RadioGroup
|
||||||
|
value={visibility}
|
||||||
|
onValueChange={(v) => setVisibility(v as ModelVisibility)}
|
||||||
|
aria-label={t("models.visibility.question")}
|
||||||
|
>
|
||||||
|
{VISIBILITY_OPTIONS.map((opt) => (
|
||||||
|
<label
|
||||||
|
key={opt.value}
|
||||||
|
htmlFor={`visibility-${opt.value}`}
|
||||||
|
className="hover:bg-accent/40 flex cursor-pointer items-start gap-3 rounded-md border p-3"
|
||||||
|
data-testid={`visibility-option-${opt.value}`}
|
||||||
|
>
|
||||||
|
<RadioGroupItem
|
||||||
|
value={opt.value}
|
||||||
|
id={`visibility-${opt.value}`}
|
||||||
|
aria-label={t(opt.labelKey)}
|
||||||
|
className="mt-0.5"
|
||||||
|
/>
|
||||||
|
<span className="space-y-0.5">
|
||||||
|
<span className="block text-sm font-medium">{t(opt.labelKey)}</span>
|
||||||
|
<span className="text-muted-foreground block text-xs">
|
||||||
|
{t(opt.descKey)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</RadioGroup>
|
||||||
|
|
||||||
|
{/* public 警告條(沿用 §2.1 amber 半語義約定)。 */}
|
||||||
|
{visibility === "public" && (
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-2 rounded-md bg-amber-50 p-3 text-xs text-amber-800 dark:bg-amber-950/30 dark:text-amber-200"
|
||||||
|
role="alert"
|
||||||
|
data-testid="visibility-public-warning"
|
||||||
|
>
|
||||||
|
<Globe aria-hidden className="mt-0.5 size-4 shrink-0" />
|
||||||
|
<span>{t("models.visibility.publicWarning")}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 指定對象(model_shares)管理,與 visibility 正交,永遠可用。 */}
|
||||||
|
<div className="space-y-2 border-t pt-4">
|
||||||
|
<Label htmlFor="share-email-input" className="text-sm font-medium">
|
||||||
|
{t("models.visibility.sharedPeopleTitle")}
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex-1">
|
||||||
|
<Input
|
||||||
|
id="share-email-input"
|
||||||
|
type="email"
|
||||||
|
value={emailInput}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEmailInput(e.target.value);
|
||||||
|
if (emailError) setEmailError(null);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleAddEmail();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t("models.visibility.addEmail")}
|
||||||
|
aria-label={t("models.visibility.addEmail")}
|
||||||
|
aria-invalid={emailError ? true : undefined}
|
||||||
|
data-testid="share-email-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void handleAddEmail()}
|
||||||
|
disabled={addingEmail || !emailInput.trim()}
|
||||||
|
data-testid="share-email-add"
|
||||||
|
>
|
||||||
|
{addingEmail ? (
|
||||||
|
<Spinner size="sm" label={t("common.loading")} />
|
||||||
|
) : (
|
||||||
|
t("models.visibility.addButton")
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{emailError && (
|
||||||
|
<p className="text-destructive text-xs" role="alert">
|
||||||
|
{emailError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 授權清單 */}
|
||||||
|
<div
|
||||||
|
className="max-h-40 space-y-1 overflow-y-auto"
|
||||||
|
data-testid="share-list"
|
||||||
|
>
|
||||||
|
{isSharesLoading ? (
|
||||||
|
<p className="text-muted-foreground py-2 text-center text-xs">
|
||||||
|
{t("common.loading")}
|
||||||
|
</p>
|
||||||
|
) : shares.length === 0 ? (
|
||||||
|
<p className="text-muted-foreground py-2 text-center text-xs">
|
||||||
|
{t("models.visibility.noShares")}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
shares.map((share) => (
|
||||||
|
<div
|
||||||
|
key={share.userId}
|
||||||
|
className="bg-muted/50 flex items-center justify-between gap-2 rounded-md px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="truncate">{share.email}</span>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
{t("models.visibility.permissionViewDownload")}
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
onClick={() => void handleRemoveShare(share.userId)}
|
||||||
|
aria-label={t("models.visibility.removeShare").replace(
|
||||||
|
"{email}",
|
||||||
|
share.email,
|
||||||
|
)}
|
||||||
|
data-testid="share-remove"
|
||||||
|
>
|
||||||
|
<X aria-hidden className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{saveError && (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"border-destructive/30 bg-destructive/10 text-destructive rounded-md border p-3 text-xs",
|
||||||
|
)}
|
||||||
|
role="alert"
|
||||||
|
data-testid="visibility-save-error"
|
||||||
|
>
|
||||||
|
{saveError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
{t("common.cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSaveClick}
|
||||||
|
disabled={saving}
|
||||||
|
data-testid="visibility-save"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<>
|
||||||
|
<Spinner size="sm" label={t("common.loading")} />
|
||||||
|
{t("common.loading")}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Lock aria-hidden className="size-4" />
|
||||||
|
{t("models.visibility.saveButton")}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
|
||||||
|
{/* 收回權限二次確認 */}
|
||||||
|
<AlertDialog open={confirmRevokeOpen} onOpenChange={setConfirmRevokeOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t("common.confirm")}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t("models.visibility.revokeConfirm")}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t("common.cancel")}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => {
|
||||||
|
setConfirmRevokeOpen(false);
|
||||||
|
void doSave();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("models.visibility.saveButton")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
55
visionA-frontend/src/components/ui/radio-group.tsx
Normal file
55
visionA-frontend/src/components/ui/radio-group.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { CircleIcon } from "lucide-react";
|
||||||
|
import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
|
||||||
|
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RadioGroup — Shadcn 風單選群組(Radix RadioGroup 封裝)
|
||||||
|
*
|
||||||
|
* 新增於「模型共享」功能:公開設定 Dialog 的三態選擇(私有 / 公開 / 指定對象)。
|
||||||
|
* shadcn 標準元件,radix-ui 已含 RadioGroup primitive(見 package.json radix-ui ^1.4.3)。
|
||||||
|
*
|
||||||
|
* 無障礙(Radix 內建):
|
||||||
|
* - role="radiogroup" / role="radio"、Arrow 鍵切換、aria-checked
|
||||||
|
* - 每個 Item 需搭配可見 <Label htmlFor> 或包在 label 內
|
||||||
|
*/
|
||||||
|
function RadioGroup({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Root>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Root
|
||||||
|
data-slot="radio-group"
|
||||||
|
className={cn("grid gap-2", className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RadioGroupItem({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof RadioGroupPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<RadioGroupPrimitive.Item
|
||||||
|
data-slot="radio-group-item"
|
||||||
|
className={cn(
|
||||||
|
"border-input text-primary focus-visible:border-ring focus-visible:ring-ring/50 aspect-square size-4 shrink-0 rounded-full border shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<RadioGroupPrimitive.Indicator
|
||||||
|
data-slot="radio-group-indicator"
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
>
|
||||||
|
<CircleIcon className="fill-primary absolute top-1/2 left-1/2 size-2 -translate-x-1/2 -translate-y-1/2" />
|
||||||
|
</RadioGroupPrimitive.Indicator>
|
||||||
|
</RadioGroupPrimitive.Item>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { RadioGroup, RadioGroupItem };
|
||||||
62
visionA-frontend/src/hooks/use-infinite-scroll.ts
Normal file
62
visionA-frontend/src/hooks/use-infinite-scroll.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useInfiniteScroll — 無限捲動偵測 hook
|
||||||
|
*
|
||||||
|
* 用 IntersectionObserver 觀察一個「哨兵」元素,當它進入視窗(+ rootMargin 提前量)
|
||||||
|
* 時觸發 `onLoadMore`。用於共享模型庫的 cursor 無限捲動(設計規格 §4.6,使用者拍板無限捲動)。
|
||||||
|
*
|
||||||
|
* 設計要點:
|
||||||
|
* - `enabled=false`(如 hasMore=false / loading 中)時不觀察,避免無謂觸發。
|
||||||
|
* - `onLoadMore` 以 ref 保存最新值,避免因回呼身分變動而反覆重建 observer。
|
||||||
|
* - rootMargin 預設 `200px`:在哨兵距離視窗底部 200px 時就預載,捲動更順。
|
||||||
|
* - store 的 loadMore 已自帶「重入防護」(isLoadingMore / hasMore 檢查),
|
||||||
|
* 故即使 observer 連續觸發也安全。
|
||||||
|
*
|
||||||
|
* 回傳 `sentinelRef`,掛到列表末端的哨兵 div 上。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef } from "react";
|
||||||
|
|
||||||
|
interface UseInfiniteScrollOptions {
|
||||||
|
/** 是否啟用觀察(通常 = hasMore && !isLoading)。 */
|
||||||
|
enabled: boolean;
|
||||||
|
/** 觸達哨兵時呼叫。 */
|
||||||
|
onLoadMore: () => void;
|
||||||
|
/** 提前量(哨兵距視窗多遠就觸發)。預設 "200px"。 */
|
||||||
|
rootMargin?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useInfiniteScroll({
|
||||||
|
enabled,
|
||||||
|
onLoadMore,
|
||||||
|
rootMargin = "200px",
|
||||||
|
}: UseInfiniteScrollOptions) {
|
||||||
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const onLoadMoreRef = useRef(onLoadMore);
|
||||||
|
|
||||||
|
// 保持最新 callback,避免 observer 因 callback 身分變動而重建。
|
||||||
|
useEffect(() => {
|
||||||
|
onLoadMoreRef.current = onLoadMore;
|
||||||
|
}, [onLoadMore]);
|
||||||
|
|
||||||
|
const handleIntersect = useCallback<IntersectionObserverCallback>((entries) => {
|
||||||
|
const entry = entries[0];
|
||||||
|
if (entry?.isIntersecting) {
|
||||||
|
onLoadMoreRef.current();
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const sentinel = sentinelRef.current;
|
||||||
|
// 環境不支援 IntersectionObserver(如部分測試環境)時安全退出。
|
||||||
|
if (!enabled || !sentinel || typeof IntersectionObserver === "undefined") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const observer = new IntersectionObserver(handleIntersect, { rootMargin });
|
||||||
|
observer.observe(sentinel);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [enabled, handleIntersect, rootMargin]);
|
||||||
|
|
||||||
|
return { sentinelRef };
|
||||||
|
}
|
||||||
155
visionA-frontend/src/lib/api/model-sharing.mock.ts
Normal file
155
visionA-frontend/src/lib/api/model-sharing.mock.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Model Sharing — Mock Fixtures(平行開發用)
|
||||||
|
*
|
||||||
|
* 對 API 契約 mock(api-model-sharing.md 的 response 形狀),不依賴後端跑起來。
|
||||||
|
* 形狀為契約定義的 snake_case,經 model-sharing.ts 的 normalize 後才成前端型別。
|
||||||
|
*
|
||||||
|
* 用途:
|
||||||
|
* 1. store 在 `NEXT_PUBLIC_USE_MODEL_SHARING_MOCK=1` 時走 mock(見 model-sharing-store)
|
||||||
|
* 2. 元件測試 fixture 來源
|
||||||
|
*
|
||||||
|
* ⚠️ mock owner 資料刻意不含 email(對齊契約 §4:非擁有者不該看到他人 email)。
|
||||||
|
* shares 的 email 是 owner 授權目標,才有 email。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 契約 §1 library item 的原始(snake_case)形狀。 */
|
||||||
|
export interface RawLibraryItem {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
target_chip: string;
|
||||||
|
file_size: number;
|
||||||
|
source: string;
|
||||||
|
status: string;
|
||||||
|
visibility: string;
|
||||||
|
owner: { id: string; name: string; is_me: boolean };
|
||||||
|
shared_with_me: boolean;
|
||||||
|
my_access: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 契約 §1 分頁 response 原始形狀。 */
|
||||||
|
export interface RawLibraryPage {
|
||||||
|
items: RawLibraryItem[];
|
||||||
|
next_cursor: string | null;
|
||||||
|
has_more: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NOW = "2026-08-01T00:00:00Z";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一個較大的 mock 資料集(30 筆),用來驗證 cursor 無限捲動分頁。
|
||||||
|
* 混合三種可見性、owner/非 owner、shared_with_me 各態,覆蓋 UI 分支。
|
||||||
|
*/
|
||||||
|
export const MOCK_LIBRARY_ITEMS: RawLibraryItem[] = Array.from({ length: 30 }).map(
|
||||||
|
(_, i) => {
|
||||||
|
const isMine = i % 3 === 0;
|
||||||
|
const visibility = isMine ? "private" : i % 3 === 1 ? "public" : "tenant";
|
||||||
|
const sharedWithMe = !isMine && i % 4 === 0;
|
||||||
|
const chips = ["kl520", "kl720", "kl630", "kl730"];
|
||||||
|
const sources = ["converted", "uploaded", "preset"];
|
||||||
|
return {
|
||||||
|
id: `mock-model-${String(i + 1).padStart(2, "0")}`,
|
||||||
|
name: `mock-model-${i + 1} ${["yolov5s", "resnet50", "mobilenet", "ssd"][i % 4]}`,
|
||||||
|
description: i % 2 === 0 ? `Mock 模型 ${i + 1} 的描述` : undefined,
|
||||||
|
target_chip: chips[i % chips.length],
|
||||||
|
file_size: (i + 1) * 1024 * 1024,
|
||||||
|
source: isMine ? "converted" : sources[i % sources.length],
|
||||||
|
status: "ready",
|
||||||
|
visibility,
|
||||||
|
owner: isMine
|
||||||
|
? { id: "me", name: "我", is_me: true }
|
||||||
|
: { id: `owner-${i}`, name: `Owner ${(i % 5) + 1}`, is_me: false },
|
||||||
|
shared_with_me: sharedWithMe,
|
||||||
|
my_access: isMine ? "owner" : sharedWithMe ? "viewer" : "viewer",
|
||||||
|
created_at: `2026-07-${String((i % 28) + 1).padStart(2, "0")}T00:00:00Z`,
|
||||||
|
updated_at: NOW,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 對 MOCK_LIBRARY_ITEMS 套用 query(搜尋 / filter / 排序 / cursor 分頁)產生一頁。
|
||||||
|
* cursor = 已消費筆數的 base64(不透明;mock 只需能往下切)。
|
||||||
|
*/
|
||||||
|
export function mockLibraryPage(query: {
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
q?: string;
|
||||||
|
targetChip?: string;
|
||||||
|
source?: string;
|
||||||
|
visibility?: string;
|
||||||
|
owned?: boolean;
|
||||||
|
sort?: string;
|
||||||
|
order?: string;
|
||||||
|
}): RawLibraryPage {
|
||||||
|
let items = [...MOCK_LIBRARY_ITEMS];
|
||||||
|
|
||||||
|
// filter
|
||||||
|
if (query.q) {
|
||||||
|
const q = query.q.toLowerCase();
|
||||||
|
items = items.filter(
|
||||||
|
(m) =>
|
||||||
|
m.name.toLowerCase().includes(q) ||
|
||||||
|
(m.description ?? "").toLowerCase().includes(q),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (query.targetChip) {
|
||||||
|
items = items.filter((m) => m.target_chip === query.targetChip);
|
||||||
|
}
|
||||||
|
if (query.source) {
|
||||||
|
items = items.filter((m) => m.source === query.source);
|
||||||
|
}
|
||||||
|
if (query.visibility) {
|
||||||
|
items = items.filter((m) => m.visibility === query.visibility);
|
||||||
|
}
|
||||||
|
if (query.owned === true) {
|
||||||
|
items = items.filter((m) => m.owner.is_me);
|
||||||
|
} else if (query.owned === false) {
|
||||||
|
items = items.filter((m) => !m.owner.is_me);
|
||||||
|
}
|
||||||
|
|
||||||
|
// sort
|
||||||
|
const order = query.order === "asc" ? 1 : -1;
|
||||||
|
const sortKey = query.sort ?? "created_at";
|
||||||
|
items.sort((a, b) => {
|
||||||
|
if (sortKey === "name") return a.name.localeCompare(b.name) * order;
|
||||||
|
if (sortKey === "file_size") return (a.file_size - b.file_size) * order;
|
||||||
|
return a.created_at.localeCompare(b.created_at) * order;
|
||||||
|
});
|
||||||
|
|
||||||
|
// cursor 分頁
|
||||||
|
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
|
||||||
|
const start = query.cursor ? Number(atob(query.cursor)) : 0;
|
||||||
|
const slice = items.slice(start, start + limit);
|
||||||
|
const nextStart = start + slice.length;
|
||||||
|
const hasMore = nextStart < items.length;
|
||||||
|
return {
|
||||||
|
items: slice,
|
||||||
|
next_cursor: hasMore ? btoa(String(nextStart)) : null,
|
||||||
|
has_more: hasMore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 依 id 產生一筆 profile 原始形狀(找不到回 null,模擬 404)。 */
|
||||||
|
export function mockProfile(id: string): Record<string, unknown> | null {
|
||||||
|
const item = MOCK_LIBRARY_ITEMS.find((m) => m.id === id);
|
||||||
|
if (!item) return null;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
input_shape: [1, 3, 224, 224],
|
||||||
|
classes: ["person", "car", "dog", "cat"],
|
||||||
|
framework: "onnx",
|
||||||
|
can_download: item.my_access !== "none",
|
||||||
|
uploaded_at: item.created_at,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** mock shares 清單(owner 檢視自己模型的授權對象)。 */
|
||||||
|
export const MOCK_SHARES: Record<string, Array<{ user_id: string; email: string; role: string; created_at: string }>> = {
|
||||||
|
"mock-model-01": [
|
||||||
|
{ user_id: "u-alice", email: "alice@corp.com", role: "viewer", created_at: NOW },
|
||||||
|
{ user_id: "u-bob", email: "bob@corp.com", role: "viewer", created_at: NOW },
|
||||||
|
],
|
||||||
|
};
|
||||||
180
visionA-frontend/src/lib/api/model-sharing.test.ts
Normal file
180
visionA-frontend/src/lib/api/model-sharing.test.ts
Normal file
@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* Model Sharing API Client 測試
|
||||||
|
*
|
||||||
|
* 覆蓋:
|
||||||
|
* - normalize:snake_case → camelCase、visibility / access 收斂、防呆預設
|
||||||
|
* - mock 分頁:cursor 切頁、filter、排序(deterministic,不打真實 API)
|
||||||
|
* - isValidEmail
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
isValidEmail,
|
||||||
|
normalizeLibraryModel,
|
||||||
|
normalizeLibraryPage,
|
||||||
|
normalizeProfile,
|
||||||
|
} from "./model-sharing";
|
||||||
|
import { mockLibraryPage, mockProfile, MOCK_LIBRARY_ITEMS } from "./model-sharing.mock";
|
||||||
|
|
||||||
|
describe("normalizeLibraryModel", () => {
|
||||||
|
it("snake_case → camelCase + 收斂 target_chip 大小寫", () => {
|
||||||
|
const m = normalizeLibraryModel({
|
||||||
|
id: "m1",
|
||||||
|
name: "YOLO",
|
||||||
|
target_chip: "KL520",
|
||||||
|
file_size: 2048,
|
||||||
|
source: "converted",
|
||||||
|
status: "ready",
|
||||||
|
visibility: "public",
|
||||||
|
owner: { id: "o1", name: "Alice", is_me: false },
|
||||||
|
shared_with_me: true,
|
||||||
|
my_access: "viewer",
|
||||||
|
created_at: "2026-07-01T00:00:00Z",
|
||||||
|
updated_at: "2026-07-02T00:00:00Z",
|
||||||
|
});
|
||||||
|
expect(m.targetChip).toBe("kl520");
|
||||||
|
expect(m.fileSize).toBe(2048);
|
||||||
|
expect(m.visibility).toBe("public");
|
||||||
|
expect(m.owner.isMe).toBe(false);
|
||||||
|
expect(m.sharedWithMe).toBe(true);
|
||||||
|
expect(m.myAccess).toBe("viewer");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("非法 visibility → 收斂為 private;非法 access → none", () => {
|
||||||
|
const m = normalizeLibraryModel({
|
||||||
|
id: "m2",
|
||||||
|
visibility: "weird",
|
||||||
|
my_access: "hacker",
|
||||||
|
owner: {},
|
||||||
|
});
|
||||||
|
expect(m.visibility).toBe("private");
|
||||||
|
expect(m.myAccess).toBe("none");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("缺欄位 → 安全預設(不 throw)", () => {
|
||||||
|
const m = normalizeLibraryModel({});
|
||||||
|
expect(m.id).toBe("");
|
||||||
|
expect(m.fileSize).toBe(0);
|
||||||
|
expect(m.source).toBe("uploaded");
|
||||||
|
expect(m.visibility).toBe("private");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeLibraryPage", () => {
|
||||||
|
it("解析 items + next_cursor + has_more", () => {
|
||||||
|
const page = normalizeLibraryPage({
|
||||||
|
items: [{ id: "a" }, { id: "b" }],
|
||||||
|
next_cursor: "abc",
|
||||||
|
has_more: true,
|
||||||
|
});
|
||||||
|
expect(page.items).toHaveLength(2);
|
||||||
|
expect(page.nextCursor).toBe("abc");
|
||||||
|
expect(page.hasMore).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("無 next_cursor → null", () => {
|
||||||
|
const page = normalizeLibraryPage({ items: [], next_cursor: null, has_more: false });
|
||||||
|
expect(page.nextCursor).toBeNull();
|
||||||
|
expect(page.hasMore).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("normalizeProfile", () => {
|
||||||
|
it("解析 input_shape / classes / can_download", () => {
|
||||||
|
const p = normalizeProfile({
|
||||||
|
id: "p1",
|
||||||
|
name: "N",
|
||||||
|
target_chip: "kl720",
|
||||||
|
input_shape: [1, 3, 224, 224],
|
||||||
|
classes: ["cat", "dog"],
|
||||||
|
can_download: true,
|
||||||
|
my_access: "owner",
|
||||||
|
owner: { id: "o", name: "Me", is_me: true },
|
||||||
|
});
|
||||||
|
expect(p.inputShape).toEqual([1, 3, 224, 224]);
|
||||||
|
expect(p.classes).toEqual(["cat", "dog"]);
|
||||||
|
expect(p.canDownload).toBe(true);
|
||||||
|
expect(p.myAccess).toBe("owner");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空 classes 陣列 → undefined(UI 有值才顯示)", () => {
|
||||||
|
const p = normalizeProfile({ id: "p2", classes: [], owner: {} });
|
||||||
|
expect(p.classes).toBeUndefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mockLibraryPage — cursor 分頁", () => {
|
||||||
|
it("首頁 limit=10 → 回 10 筆 + has_more + next_cursor", () => {
|
||||||
|
const page = mockLibraryPage({ limit: 10 });
|
||||||
|
expect(page.items).toHaveLength(10);
|
||||||
|
expect(page.has_more).toBe(true);
|
||||||
|
expect(page.next_cursor).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("用 next_cursor 續載 → 不重複、能一路切到底", () => {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
let cursor: string | undefined;
|
||||||
|
let guard = 0;
|
||||||
|
for (;;) {
|
||||||
|
const page: ReturnType<typeof mockLibraryPage> = mockLibraryPage({ limit: 7, cursor });
|
||||||
|
for (const item of page.items) {
|
||||||
|
expect(seen.has(item.id)).toBe(false); // 不重複
|
||||||
|
seen.add(item.id);
|
||||||
|
}
|
||||||
|
if (!page.has_more || !page.next_cursor) break;
|
||||||
|
cursor = page.next_cursor;
|
||||||
|
if (++guard > 20) throw new Error("cursor 未收斂");
|
||||||
|
}
|
||||||
|
expect(seen.size).toBe(MOCK_LIBRARY_ITEMS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filter owned=true → 只回我的(owner.is_me)", () => {
|
||||||
|
const page = mockLibraryPage({ limit: 100, owned: true });
|
||||||
|
expect(page.items.every((m) => m.owner.is_me)).toBe(true);
|
||||||
|
expect(page.items.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filter visibility=public → 只回 public", () => {
|
||||||
|
const page = mockLibraryPage({ limit: 100, visibility: "public" });
|
||||||
|
expect(page.items.every((m) => m.visibility === "public")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("搜尋 q 無 match → 空頁 + has_more=false", () => {
|
||||||
|
const page = mockLibraryPage({ limit: 100, q: "zzz-no-such-model" });
|
||||||
|
expect(page.items).toHaveLength(0);
|
||||||
|
expect(page.has_more).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sort=name asc → 名稱遞增", () => {
|
||||||
|
const page = mockLibraryPage({ limit: 100, sort: "name", order: "asc" });
|
||||||
|
const names = page.items.map((m) => m.name);
|
||||||
|
const sorted = [...names].sort((a, b) => a.localeCompare(b));
|
||||||
|
expect(names).toEqual(sorted);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("mockProfile", () => {
|
||||||
|
it("存在 id → 回 profile 原始形狀(含 can_download)", () => {
|
||||||
|
const raw = mockProfile("mock-model-01");
|
||||||
|
expect(raw).not.toBeNull();
|
||||||
|
expect(raw!.can_download).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("不存在 id → null(模擬 404)", () => {
|
||||||
|
expect(mockProfile("no-such")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isValidEmail", () => {
|
||||||
|
it.each([
|
||||||
|
["alice@corp.com", true],
|
||||||
|
["a@b.co", true],
|
||||||
|
["no-at-sign", false],
|
||||||
|
["missing@domain", false],
|
||||||
|
["@no-local.com", false],
|
||||||
|
["", false],
|
||||||
|
])("%s → %s", (email, expected) => {
|
||||||
|
expect(isValidEmail(email)).toBe(expected);
|
||||||
|
});
|
||||||
|
});
|
||||||
487
visionA-frontend/src/lib/api/model-sharing.ts
Normal file
487
visionA-frontend/src/lib/api/model-sharing.ts
Normal file
@ -0,0 +1,487 @@
|
|||||||
|
/**
|
||||||
|
* Model Sharing API Client — visionA Cloud(模型共享 L 級新功能)
|
||||||
|
*
|
||||||
|
* 對齊:
|
||||||
|
* - `docs/autoflow/04-architecture/api/api-model-sharing.md`(權威契約)
|
||||||
|
* - `docs/autoflow/02-prd/features/feature-model-sharing.md`(需求背景)
|
||||||
|
* - `docs/autoflow/03-design/feature-model-sharing-design.md`(UI 規格)
|
||||||
|
*
|
||||||
|
* 契約要點(以 API 契約為準,非設計規格的 shared 三態):
|
||||||
|
* - visibility 維度:`private` / `tenant` / `public`(廣播式)
|
||||||
|
* - model_shares 維度:點對點分享(與 visibility 正交),response 用 `shared_with_me` 標記
|
||||||
|
* - my_access:`owner` / `editor` / `viewer` / `none`(有效權限,取最高)
|
||||||
|
*
|
||||||
|
* Endpoint:
|
||||||
|
* 1. GET /api/models/library — 共享模型庫(cursor 分頁)
|
||||||
|
* 2. GET /api/models/:id/profile — 公開版詳情(依身份裁剪)
|
||||||
|
* 3. PATCH /api/models/:id/visibility — 設定公開對象(owner-only)
|
||||||
|
* 4. GET/POST/DELETE /api/models/:id/shares — 點對點分享管理(owner-only)
|
||||||
|
*
|
||||||
|
* ⚠️ 安全:response 不含 owner email(契約 §4),前端不得自造 email 揭露。
|
||||||
|
* shares 管理的 email 是 owner 自己輸入的授權目標,可顯示。
|
||||||
|
*
|
||||||
|
* 平行開發:本 client 對 API 契約 mock 開發,不依賴後端跑起來(見檔尾 mock 節)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { ApiError, api } from "@/lib/api";
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Types — 對齊 api-model-sharing.md */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** 模型可見性(廣播維度)。 */
|
||||||
|
export type ModelVisibility = "private" | "tenant" | "public";
|
||||||
|
|
||||||
|
/** 當前 user 對模型的有效權限(取最高)。 */
|
||||||
|
export type ModelAccess = "owner" | "editor" | "viewer" | "none";
|
||||||
|
|
||||||
|
/** 排序欄位(契約 §1)。 */
|
||||||
|
export type LibrarySort = "created_at" | "name" | "file_size";
|
||||||
|
export type SortOrder = "asc" | "desc";
|
||||||
|
|
||||||
|
/** 共享庫列表項的 owner 資訊(契約:只揭露 id / name / is_me,不揭露 email)。 */
|
||||||
|
export interface LibraryOwner {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
isMe: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 共享庫單一模型項(契約 §1 response items)。 */
|
||||||
|
export interface LibraryModel {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
targetChip: string;
|
||||||
|
fileSize: number;
|
||||||
|
source: "uploaded" | "converted" | "preset";
|
||||||
|
status: "pending" | "ready";
|
||||||
|
visibility: ModelVisibility;
|
||||||
|
owner: LibraryOwner;
|
||||||
|
/** 是否因 model_shares 命中(供 UI 標「共享給我」)。 */
|
||||||
|
sharedWithMe: boolean;
|
||||||
|
/** 當前 user 的有效權限。 */
|
||||||
|
myAccess: ModelAccess;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 共享庫分頁結果(cursor 分頁契約 §1.3)。 */
|
||||||
|
export interface LibraryPage {
|
||||||
|
items: LibraryModel[];
|
||||||
|
/** 下一頁游標(不透明);無下一頁時為 null。 */
|
||||||
|
nextCursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 共享庫查詢參數(契約 §1 query)。 */
|
||||||
|
export interface LibraryQuery {
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
sort?: LibrarySort;
|
||||||
|
order?: SortOrder;
|
||||||
|
/** 搜尋關鍵字(比對 name + description)。 */
|
||||||
|
q?: string;
|
||||||
|
targetChip?: "kl520" | "kl720" | "kl630" | "kl730";
|
||||||
|
source?: "uploaded" | "converted" | "preset";
|
||||||
|
/** 僅過濾廣播類(public / tenant);private 不在共享庫語意內。 */
|
||||||
|
visibility?: "public" | "tenant";
|
||||||
|
/** true=只看我的、false=只看別人分享/公開給我的、不帶=全部。 */
|
||||||
|
owned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模型 profile 頁(公開版詳情,契約 §2)。 */
|
||||||
|
export interface ModelProfile {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
targetChip: string;
|
||||||
|
fileSize: number;
|
||||||
|
source: "uploaded" | "converted" | "preset";
|
||||||
|
status: "pending" | "ready";
|
||||||
|
visibility: ModelVisibility;
|
||||||
|
inputShape?: number[];
|
||||||
|
classes?: string[];
|
||||||
|
framework?: string;
|
||||||
|
owner: LibraryOwner;
|
||||||
|
myAccess: ModelAccess;
|
||||||
|
/** my_access != none 時 true,前端據此決定是否顯示下載鈕。 */
|
||||||
|
canDownload: boolean;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
uploadedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 點對點分享單一授權對象(owner 檢視自己模型的授權清單)。 */
|
||||||
|
export interface ModelShare {
|
||||||
|
/** 被授權 user id。 */
|
||||||
|
userId: string;
|
||||||
|
/** 被授權 user 的顯示 email(owner 自己輸入的授權目標,可顯示)。 */
|
||||||
|
email: string;
|
||||||
|
/** 授權角色(P0 固定 viewer = 可檢視 + 下載)。 */
|
||||||
|
role: "viewer" | "editor";
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Error class */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型共享專用錯誤。UI 用 `error.code`(小寫)對應 i18n key(`models.sharing.error.<code>`)。
|
||||||
|
*
|
||||||
|
* code 來源(對齊契約各節錯誤碼,統一小寫):
|
||||||
|
* - `not_found`(404,含「無可見性」防 enumeration)
|
||||||
|
* - `forbidden`(403,非 owner 改 visibility / shares)
|
||||||
|
* - `conflict`(409,未 ready 不允許公開)
|
||||||
|
* - `validation_failed`(400,visibility 非法 / email 格式 / 無 org 設 tenant)
|
||||||
|
* - `network_error` / `unknown`
|
||||||
|
*/
|
||||||
|
export class ModelSharingError extends Error {
|
||||||
|
readonly status: number;
|
||||||
|
readonly code: string;
|
||||||
|
|
||||||
|
constructor(status: number, code: string, message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = "ModelSharingError";
|
||||||
|
this.status = status;
|
||||||
|
this.code = code;
|
||||||
|
if (typeof Error.captureStackTrace === "function") {
|
||||||
|
Error.captureStackTrace(this, ModelSharingError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 把底層 ApiError / 一般 Error 包成 ModelSharingError(code 統一小寫)。 */
|
||||||
|
function wrapError(err: unknown): ModelSharingError {
|
||||||
|
if (err instanceof ModelSharingError) return err;
|
||||||
|
if (err instanceof ApiError) {
|
||||||
|
return new ModelSharingError(err.status, err.code.toLowerCase(), err.message);
|
||||||
|
}
|
||||||
|
if (err instanceof Error) {
|
||||||
|
const maybeCode = (err as unknown as { code?: unknown }).code;
|
||||||
|
const code =
|
||||||
|
typeof maybeCode === "string" ? maybeCode.toLowerCase() : "network_error";
|
||||||
|
return new ModelSharingError(0, code, err.message);
|
||||||
|
}
|
||||||
|
return new ModelSharingError(0, "unknown", String(err));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* snake_case ⇄ camelCase 正規化(後端契約用 snake_case) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
type Raw = Record<string, unknown>;
|
||||||
|
|
||||||
|
function asRaw(v: unknown): Raw {
|
||||||
|
return (v ?? {}) as Raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickStr(r: Raw, ...keys: string[]): string {
|
||||||
|
for (const k of keys) {
|
||||||
|
if (r[k] !== undefined && r[k] !== null) return String(r[k]);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickNum(r: Raw, ...keys: string[]): number {
|
||||||
|
for (const k of keys) {
|
||||||
|
if (r[k] !== undefined && r[k] !== null) return Number(r[k]);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickBool(r: Raw, ...keys: string[]): boolean {
|
||||||
|
for (const k of keys) {
|
||||||
|
if (r[k] !== undefined && r[k] !== null) return Boolean(r[k]);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeVisibility(v: unknown): ModelVisibility {
|
||||||
|
return v === "public" || v === "tenant" ? v : "private";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeAccess(v: unknown): ModelAccess {
|
||||||
|
return v === "owner" || v === "editor" || v === "viewer" ? v : "none";
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOwner(raw: unknown): LibraryOwner {
|
||||||
|
const r = asRaw(raw);
|
||||||
|
return {
|
||||||
|
id: pickStr(r, "id"),
|
||||||
|
name: pickStr(r, "name"),
|
||||||
|
isMe: pickBool(r, "is_me", "isMe"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNumberArray(value: unknown): number[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
const arr = value.map((v) => Number(v)).filter((n) => Number.isFinite(n));
|
||||||
|
return arr.length > 0 ? arr : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeStringArray(value: unknown): string[] | undefined {
|
||||||
|
if (!Array.isArray(value)) return undefined;
|
||||||
|
const arr = value
|
||||||
|
.filter((v) => v !== null && v !== undefined)
|
||||||
|
.map((v) => String(v));
|
||||||
|
return arr.length > 0 ? arr : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLibraryModel(raw: unknown): LibraryModel {
|
||||||
|
const r = asRaw(raw);
|
||||||
|
const rawChip = pickStr(r, "target_chip", "targetChip");
|
||||||
|
const source = pickStr(r, "source") || "uploaded";
|
||||||
|
const status = pickStr(r, "status") || "ready";
|
||||||
|
return {
|
||||||
|
id: pickStr(r, "id"),
|
||||||
|
name: pickStr(r, "name"),
|
||||||
|
description: r.description ? String(r.description) : undefined,
|
||||||
|
targetChip: rawChip.toLowerCase(),
|
||||||
|
fileSize: pickNum(r, "file_size", "fileSize"),
|
||||||
|
source: source as LibraryModel["source"],
|
||||||
|
status: status as LibraryModel["status"],
|
||||||
|
visibility: normalizeVisibility(r.visibility),
|
||||||
|
owner: normalizeOwner(r.owner),
|
||||||
|
sharedWithMe: pickBool(r, "shared_with_me", "sharedWithMe"),
|
||||||
|
myAccess: normalizeAccess(r.my_access ?? r.myAccess),
|
||||||
|
createdAt: pickStr(r, "created_at", "createdAt"),
|
||||||
|
updatedAt: pickStr(r, "updated_at", "updatedAt"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeLibraryPage(raw: unknown): LibraryPage {
|
||||||
|
const r = asRaw(raw);
|
||||||
|
const items = Array.isArray(r.items) ? r.items.map(normalizeLibraryModel) : [];
|
||||||
|
const nextCursorRaw = r.next_cursor ?? r.nextCursor;
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
nextCursor: nextCursorRaw ? String(nextCursorRaw) : null,
|
||||||
|
hasMore: pickBool(r, "has_more", "hasMore"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeProfile(raw: unknown): ModelProfile {
|
||||||
|
const r = asRaw(raw);
|
||||||
|
const rawChip = pickStr(r, "target_chip", "targetChip");
|
||||||
|
const source = pickStr(r, "source") || "uploaded";
|
||||||
|
const status = pickStr(r, "status") || "ready";
|
||||||
|
return {
|
||||||
|
id: pickStr(r, "id"),
|
||||||
|
name: pickStr(r, "name"),
|
||||||
|
description: r.description ? String(r.description) : undefined,
|
||||||
|
targetChip: rawChip.toLowerCase(),
|
||||||
|
fileSize: pickNum(r, "file_size", "fileSize"),
|
||||||
|
source: source as ModelProfile["source"],
|
||||||
|
status: status as ModelProfile["status"],
|
||||||
|
visibility: normalizeVisibility(r.visibility),
|
||||||
|
inputShape: normalizeNumberArray(r.input_shape ?? r.inputShape),
|
||||||
|
classes: normalizeStringArray(r.classes),
|
||||||
|
framework: r.framework ? String(r.framework) : undefined,
|
||||||
|
owner: normalizeOwner(r.owner),
|
||||||
|
myAccess: normalizeAccess(r.my_access ?? r.myAccess),
|
||||||
|
canDownload: pickBool(r, "can_download", "canDownload"),
|
||||||
|
createdAt: pickStr(r, "created_at", "createdAt"),
|
||||||
|
updatedAt: pickStr(r, "updated_at", "updatedAt"),
|
||||||
|
uploadedAt: pickStr(r, "uploaded_at", "uploadedAt") || undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeShare(raw: unknown): ModelShare {
|
||||||
|
const r = asRaw(raw);
|
||||||
|
const role = r.role === "editor" ? "editor" : "viewer";
|
||||||
|
return {
|
||||||
|
userId: pickStr(r, "user_id", "userId", "grantee_user_id"),
|
||||||
|
email: pickStr(r, "email", "grantee_email"),
|
||||||
|
role,
|
||||||
|
createdAt: pickStr(r, "created_at", "createdAt"),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 1. GET /api/models/library — 共享模型庫(cursor 分頁) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function buildLibraryQueryString(query: LibraryQuery): string {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (query.cursor) params.set("cursor", query.cursor);
|
||||||
|
if (query.limit !== undefined) params.set("limit", String(query.limit));
|
||||||
|
if (query.sort) params.set("sort", query.sort);
|
||||||
|
if (query.order) params.set("order", query.order);
|
||||||
|
if (query.q) params.set("q", query.q);
|
||||||
|
if (query.targetChip) params.set("target_chip", query.targetChip);
|
||||||
|
if (query.source) params.set("source", query.source);
|
||||||
|
if (query.visibility) params.set("visibility", query.visibility);
|
||||||
|
if (query.owned !== undefined) params.set("owned", String(query.owned));
|
||||||
|
const qs = params.toString();
|
||||||
|
return qs ? `?${qs}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取共享模型庫的一頁。走 api.get wrapper(cookie session、envelope、ApiError mapping)。
|
||||||
|
*
|
||||||
|
* 後端未實作時(501)→ 回空頁而非丟錯,UI 走空狀態(與既有 fetchModels 對 501 的處理一致)。
|
||||||
|
*
|
||||||
|
* @throws {ModelSharingError} 400 validation_failed / 其他網路層錯誤
|
||||||
|
*/
|
||||||
|
export async function fetchLibrary(query: LibraryQuery = {}): Promise<LibraryPage> {
|
||||||
|
try {
|
||||||
|
const raw = await api.get<unknown>(
|
||||||
|
`/api/models/library${buildLibraryQueryString(query)}`,
|
||||||
|
);
|
||||||
|
return normalizeLibraryPage(raw);
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof ApiError && err.code === "NOT_IMPLEMENTED") {
|
||||||
|
return { items: [], nextCursor: null, hasMore: false };
|
||||||
|
}
|
||||||
|
throw wrapError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 2. GET /api/models/:id/profile — 公開版詳情 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 取模型 profile(公開版詳情)。權限檢查在後端;無可見性回 404(防 enumeration)。
|
||||||
|
*
|
||||||
|
* @throws {ModelSharingError} 404 not_found(不存在 or 無可見性)/ 其他
|
||||||
|
*/
|
||||||
|
export async function fetchProfile(modelId: string): Promise<ModelProfile> {
|
||||||
|
if (!modelId) {
|
||||||
|
throw new ModelSharingError(0, "validation_failed", "modelId is required");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = await api.get<unknown>(
|
||||||
|
`/api/models/${encodeURIComponent(modelId)}/profile`,
|
||||||
|
);
|
||||||
|
return normalizeProfile(raw);
|
||||||
|
} catch (err) {
|
||||||
|
throw wrapError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 3. PATCH /api/models/:id/visibility — 設定公開對象(owner-only) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export interface UpdateVisibilityResult {
|
||||||
|
id: string;
|
||||||
|
visibility: ModelVisibility;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 設定模型可見性。只有 owner 能改(後端把關)。
|
||||||
|
*
|
||||||
|
* @throws {ModelSharingError} 403 forbidden / 404 not_found /
|
||||||
|
* 409 conflict(未 ready)/ 400 validation_failed(visibility 非法 / 無 org 設 tenant)
|
||||||
|
*/
|
||||||
|
export async function updateVisibility(
|
||||||
|
modelId: string,
|
||||||
|
visibility: ModelVisibility,
|
||||||
|
): Promise<UpdateVisibilityResult> {
|
||||||
|
if (!modelId) {
|
||||||
|
throw new ModelSharingError(0, "validation_failed", "modelId is required");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = await api.patch<unknown>(
|
||||||
|
`/api/models/${encodeURIComponent(modelId)}/visibility`,
|
||||||
|
{ visibility },
|
||||||
|
);
|
||||||
|
const r = asRaw(raw);
|
||||||
|
return {
|
||||||
|
id: pickStr(r, "id") || modelId,
|
||||||
|
visibility: normalizeVisibility(r.visibility),
|
||||||
|
updatedAt: pickStr(r, "updated_at", "updatedAt"),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
throw wrapError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 4. 點對點分享管理(owner-only)— shares CRUD */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 列出模型的授權對象清單(owner 檢視自己模型)。
|
||||||
|
*
|
||||||
|
* @throws {ModelSharingError} 403 forbidden / 404 not_found
|
||||||
|
*/
|
||||||
|
export async function fetchShares(modelId: string): Promise<ModelShare[]> {
|
||||||
|
if (!modelId) {
|
||||||
|
throw new ModelSharingError(0, "validation_failed", "modelId is required");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = await api.get<unknown>(
|
||||||
|
`/api/models/${encodeURIComponent(modelId)}/shares`,
|
||||||
|
);
|
||||||
|
const r = asRaw(raw);
|
||||||
|
const list = Array.isArray(r.items)
|
||||||
|
? r.items
|
||||||
|
: Array.isArray(raw)
|
||||||
|
? (raw as unknown[])
|
||||||
|
: [];
|
||||||
|
return list.map(normalizeShare);
|
||||||
|
} catch (err) {
|
||||||
|
throw wrapError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 新增授權對象(by email)。P0 固定 viewer 權限。
|
||||||
|
*
|
||||||
|
* @throws {ModelSharingError} 400 validation_failed(email 格式)/ 404 user_not_found /
|
||||||
|
* 403 forbidden / 409 conflict(已在清單中)
|
||||||
|
*/
|
||||||
|
export async function addShare(
|
||||||
|
modelId: string,
|
||||||
|
email: string,
|
||||||
|
): Promise<ModelShare> {
|
||||||
|
if (!modelId) {
|
||||||
|
throw new ModelSharingError(0, "validation_failed", "modelId is required");
|
||||||
|
}
|
||||||
|
if (!email) {
|
||||||
|
throw new ModelSharingError(0, "validation_failed", "email is required");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = await api.post<unknown>(
|
||||||
|
`/api/models/${encodeURIComponent(modelId)}/shares`,
|
||||||
|
{ email, role: "viewer" },
|
||||||
|
);
|
||||||
|
return normalizeShare(raw);
|
||||||
|
} catch (err) {
|
||||||
|
throw wrapError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 移除授權對象。
|
||||||
|
*
|
||||||
|
* @throws {ModelSharingError} 403 forbidden / 404 not_found
|
||||||
|
*/
|
||||||
|
export async function removeShare(
|
||||||
|
modelId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!modelId || !userId) {
|
||||||
|
throw new ModelSharingError(0, "validation_failed", "modelId and userId are required");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await api.del(
|
||||||
|
`/api/models/${encodeURIComponent(modelId)}/shares/${encodeURIComponent(userId)}`,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
throw wrapError(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Email 驗證(前端即時 UX;後端仍會驗) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** 寬鬆但實用的 email 格式驗證(前端即時回饋用;權威驗證在後端)。 */
|
||||||
|
export function isValidEmail(email: string): boolean {
|
||||||
|
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim());
|
||||||
|
}
|
||||||
196
visionA-frontend/src/lib/device-state.test.ts
Normal file
196
visionA-frontend/src/lib/device-state.test.ts
Normal file
@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* device-state 單元測試 — 三態運算真值表 + 排序 + filter
|
||||||
|
*
|
||||||
|
* 對齊:
|
||||||
|
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5.2(真值表 ≥6 格)、§6(排序/filter)
|
||||||
|
*/
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import type { DeviceSummary } from "@/stores/device-store";
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyDeviceListView,
|
||||||
|
deriveTriState,
|
||||||
|
filterDevices,
|
||||||
|
isOnlineUnregistered,
|
||||||
|
sortDevices,
|
||||||
|
} from "./device-state";
|
||||||
|
|
||||||
|
/** 建一筆最小 DeviceSummary(只需 deriveTriState 用到的欄位可覆寫)。 */
|
||||||
|
function makeDevice(overrides: Partial<DeviceSummary> = {}): DeviceSummary {
|
||||||
|
return {
|
||||||
|
id: "dev",
|
||||||
|
name: "KL520",
|
||||||
|
type: "kl520",
|
||||||
|
status: "connected",
|
||||||
|
remoteStatus: "online",
|
||||||
|
registeredAt: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const REGISTERED_AT = "2026-08-02T10:00:00Z";
|
||||||
|
|
||||||
|
describe("deriveTriState — 真值表(連線軸 × 註冊軸)", () => {
|
||||||
|
// TDD §5.2:online×registered / online×null / offline×registered / offline×null
|
||||||
|
// + reconnecting / unknown(≥6 格)
|
||||||
|
it("online + registeredAt 有值 → online-registered", () => {
|
||||||
|
expect(
|
||||||
|
deriveTriState({ remoteStatus: "online", registeredAt: REGISTERED_AT }),
|
||||||
|
).toBe("online-registered");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("online + registeredAt null → online-unregistered(第三態)", () => {
|
||||||
|
expect(deriveTriState({ remoteStatus: "online", registeredAt: null })).toBe(
|
||||||
|
"online-unregistered",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("online + registeredAt undefined → online-unregistered(缺欄等同未註冊)", () => {
|
||||||
|
expect(deriveTriState({ remoteStatus: "online", registeredAt: undefined })).toBe(
|
||||||
|
"online-unregistered",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offline + registeredAt 有值 → offline(離線不論註冊與否)", () => {
|
||||||
|
expect(
|
||||||
|
deriveTriState({ remoteStatus: "offline", registeredAt: REGISTERED_AT }),
|
||||||
|
).toBe("offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offline + registeredAt null → offline", () => {
|
||||||
|
expect(deriveTriState({ remoteStatus: "offline", registeredAt: null })).toBe(
|
||||||
|
"offline",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconnecting → offline(非 online 一律歸 offline 態)", () => {
|
||||||
|
expect(
|
||||||
|
deriveTriState({ remoteStatus: "reconnecting", registeredAt: REGISTERED_AT }),
|
||||||
|
).toBe("offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unknown → offline", () => {
|
||||||
|
expect(deriveTriState({ remoteStatus: "unknown", registeredAt: null })).toBe(
|
||||||
|
"offline",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("error → offline", () => {
|
||||||
|
expect(
|
||||||
|
deriveTriState({ remoteStatus: "error", registeredAt: REGISTERED_AT }),
|
||||||
|
).toBe("offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("isOnlineUnregistered 僅在第三態為 true", () => {
|
||||||
|
expect(
|
||||||
|
isOnlineUnregistered({ remoteStatus: "online", registeredAt: null }),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
isOnlineUnregistered({ remoteStatus: "online", registeredAt: REGISTERED_AT }),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isOnlineUnregistered({ remoteStatus: "offline", registeredAt: null }),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filterDevices — 依三態過濾", () => {
|
||||||
|
const devices: DeviceSummary[] = [
|
||||||
|
makeDevice({ id: "a", remoteStatus: "online", registeredAt: REGISTERED_AT }), // online-registered
|
||||||
|
makeDevice({ id: "b", remoteStatus: "online", registeredAt: null }), // online-unregistered
|
||||||
|
makeDevice({ id: "c", remoteStatus: "offline", registeredAt: null }), // offline
|
||||||
|
makeDevice({ id: "d", remoteStatus: "reconnecting", registeredAt: REGISTERED_AT }), // offline
|
||||||
|
];
|
||||||
|
|
||||||
|
it("all → 不過濾(回原陣列)", () => {
|
||||||
|
expect(filterDevices(devices, "all")).toBe(devices);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("online-registered → 只留已連接已註冊", () => {
|
||||||
|
expect(filterDevices(devices, "online-registered").map((d) => d.id)).toEqual([
|
||||||
|
"a",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("online-unregistered → 只留第三態", () => {
|
||||||
|
expect(filterDevices(devices, "online-unregistered").map((d) => d.id)).toEqual([
|
||||||
|
"b",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offline → 留所有非 online(含 reconnecting)", () => {
|
||||||
|
expect(filterDevices(devices, "offline").map((d) => d.id)).toEqual(["c", "d"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("sortDevices — 三種排序鍵", () => {
|
||||||
|
it("status:在線優先(online→reconnecting→unknown→offline→error),同狀態內比名稱", () => {
|
||||||
|
const devices: DeviceSummary[] = [
|
||||||
|
makeDevice({ id: "off", name: "Z", remoteStatus: "offline" }),
|
||||||
|
makeDevice({ id: "on-b", name: "B", remoteStatus: "online" }),
|
||||||
|
makeDevice({ id: "err", name: "A", remoteStatus: "error" }),
|
||||||
|
makeDevice({ id: "on-a", name: "A", remoteStatus: "online" }),
|
||||||
|
makeDevice({ id: "rec", name: "C", remoteStatus: "reconnecting" }),
|
||||||
|
];
|
||||||
|
expect(sortDevices(devices, "status").map((d) => d.id)).toEqual([
|
||||||
|
"on-a", // online A
|
||||||
|
"on-b", // online B
|
||||||
|
"rec", // reconnecting
|
||||||
|
"off", // offline
|
||||||
|
"err", // error
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("name:displayName(alias 優先)localeCompare A→Z", () => {
|
||||||
|
const devices: DeviceSummary[] = [
|
||||||
|
makeDevice({ id: "1", name: "Charlie" }),
|
||||||
|
makeDevice({ id: "2", name: "Zoo", alias: "Apple" }), // alias 優先 → 排最前
|
||||||
|
makeDevice({ id: "3", name: "Bravo" }),
|
||||||
|
];
|
||||||
|
expect(sortDevices(devices, "name").map((d) => d.id)).toEqual(["2", "3", "1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("registeredAt:desc(新在前),null(未註冊)排最後", () => {
|
||||||
|
const devices: DeviceSummary[] = [
|
||||||
|
makeDevice({ id: "old", registeredAt: "2026-01-01T00:00:00Z" }),
|
||||||
|
makeDevice({ id: "none", registeredAt: null }),
|
||||||
|
makeDevice({ id: "new", registeredAt: "2026-08-01T00:00:00Z" }),
|
||||||
|
];
|
||||||
|
expect(sortDevices(devices, "registeredAt").map((d) => d.id)).toEqual([
|
||||||
|
"new",
|
||||||
|
"old",
|
||||||
|
"none",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("不 mutate 輸入陣列", () => {
|
||||||
|
const devices = [
|
||||||
|
makeDevice({ id: "1", name: "B" }),
|
||||||
|
makeDevice({ id: "2", name: "A" }),
|
||||||
|
];
|
||||||
|
const before = devices.map((d) => d.id);
|
||||||
|
sortDevices(devices, "name");
|
||||||
|
expect(devices.map((d) => d.id)).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("applyDeviceListView — 先 filter 再 sort", () => {
|
||||||
|
it("filter 後再排序,順序正確", () => {
|
||||||
|
const devices: DeviceSummary[] = [
|
||||||
|
makeDevice({ id: "a", name: "Z", remoteStatus: "online", registeredAt: "2026-01-01T00:00:00Z" }),
|
||||||
|
makeDevice({ id: "b", name: "A", remoteStatus: "online", registeredAt: "2026-08-01T00:00:00Z" }),
|
||||||
|
makeDevice({ id: "c", name: "C", remoteStatus: "offline", registeredAt: null }),
|
||||||
|
];
|
||||||
|
// filter=online-registered(留 a、b)→ sort=name(A→Z:b、a)
|
||||||
|
const result = applyDeviceListView(devices, "online-registered", "name");
|
||||||
|
expect(result.map((d) => d.id)).toEqual(["b", "a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filter 後 0 筆 → 回空陣列", () => {
|
||||||
|
const devices: DeviceSummary[] = [
|
||||||
|
makeDevice({ id: "a", remoteStatus: "offline", registeredAt: null }),
|
||||||
|
];
|
||||||
|
expect(applyDeviceListView(devices, "online-unregistered", "status")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
135
visionA-frontend/src/lib/device-state.ts
Normal file
135
visionA-frontend/src/lib/device-state.ts
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
/**
|
||||||
|
* device-state — 三態運算純函式(連線軸 × 註冊軸)
|
||||||
|
*
|
||||||
|
* 規格來源:
|
||||||
|
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §5.2(真值表)
|
||||||
|
*
|
||||||
|
* 三態 = 連線軸(remoteStatus)× 註冊軸(registeredAt)的組合:
|
||||||
|
* | 態 | 條件 | 語意 |
|
||||||
|
* | ----------------- | --------------------------------------- | ----------------------- |
|
||||||
|
* | online-registered | remoteStatus === "online" 且 已註冊 | 正常可用的個人設備 |
|
||||||
|
* | online-unregistered | remoteStatus === "online" 且 未註冊 | 插著、連線中但還沒註冊(第三態)|
|
||||||
|
* | offline | remoteStatus !== "online" | 離線(不論註冊與否) |
|
||||||
|
*
|
||||||
|
* 純函式便於單元測試(真值表 6 格),且與 UI 解耦。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { DeviceSummary, RemoteStatus } from "@/stores/device-store";
|
||||||
|
|
||||||
|
/** 三態列舉:連線軸 × 註冊軸推導出的裝置狀態。 */
|
||||||
|
export type DeviceTriState =
|
||||||
|
| "online-registered"
|
||||||
|
| "online-unregistered"
|
||||||
|
| "offline";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 由裝置的連線狀態與註冊時間推導三態。
|
||||||
|
*
|
||||||
|
* - online 且 registeredAt != null → "online-registered"
|
||||||
|
* - online 且 registeredAt == null → "online-unregistered"
|
||||||
|
* - 其餘(offline / reconnecting / error / unknown,不論註冊與否) → "offline"
|
||||||
|
*
|
||||||
|
* 註:TDD §5.2 真值表把「非 online 的連線狀態」全歸為 offline 態(分色沿用既有
|
||||||
|
* RemoteDeviceBadge 各狀態色),註冊軸在非 online 時不影響三態判定。
|
||||||
|
*/
|
||||||
|
export function deriveTriState(
|
||||||
|
d: Pick<DeviceSummary, "remoteStatus" | "registeredAt">,
|
||||||
|
): DeviceTriState {
|
||||||
|
if (d.remoteStatus !== "online") return "offline";
|
||||||
|
return d.registeredAt != null ? "online-registered" : "online-unregistered";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 便利判定:此裝置是否為「已連接未註冊」第三態(可被註冊)。 */
|
||||||
|
export function isOnlineUnregistered(
|
||||||
|
d: Pick<DeviceSummary, "remoteStatus" | "registeredAt">,
|
||||||
|
): boolean {
|
||||||
|
return deriveTriState(d) === "online-unregistered";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 排序 + filter(TDD §6,client-side、不分頁) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** 排序鍵(TDD §6.2)。`status`=依連線狀態(預設,保留既有行為)。 */
|
||||||
|
export type DeviceSortKey = "status" | "name" | "registeredAt";
|
||||||
|
|
||||||
|
/** Filter 選項(TDD §6.3,依三態)。`all`=不過濾(預設)。 */
|
||||||
|
export type DeviceFilterKey =
|
||||||
|
| "all"
|
||||||
|
| "online-registered"
|
||||||
|
| "offline"
|
||||||
|
| "online-unregistered";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 連線狀態排序權重(沿用 device-list 既有 STATUS_ORDER:在線優先)。
|
||||||
|
* online → reconnecting → unknown → offline → error。
|
||||||
|
*/
|
||||||
|
const REMOTE_STATUS_ORDER: Record<RemoteStatus, number> = {
|
||||||
|
online: 0,
|
||||||
|
reconnecting: 1,
|
||||||
|
unknown: 2,
|
||||||
|
offline: 3,
|
||||||
|
error: 4,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 顯示名稱(alias 優先,對齊 DeviceCard 的 displayName 規則)。 */
|
||||||
|
function displayName(d: DeviceSummary): string {
|
||||||
|
return d.alias || d.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依 filter 過濾裝置(純函式,用 deriveTriState 判定三態)。
|
||||||
|
* `all` 回原陣列(不複製,呼叫端 sort 時才複製)。
|
||||||
|
*/
|
||||||
|
export function filterDevices(
|
||||||
|
devices: DeviceSummary[],
|
||||||
|
filter: DeviceFilterKey,
|
||||||
|
): DeviceSummary[] {
|
||||||
|
if (filter === "all") return devices;
|
||||||
|
return devices.filter((d) => deriveTriState(d) === filter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 依排序鍵排序裝置(純函式,回新陣列、不 mutate 輸入)。
|
||||||
|
*
|
||||||
|
* - status:REMOTE_STATUS_ORDER(在線優先);同狀態內次比名稱(localeCompare)。
|
||||||
|
* - name:displayName localeCompare,A→Z。
|
||||||
|
* - registeredAt:desc(新註冊在前);null(未註冊)一律排最後。
|
||||||
|
*/
|
||||||
|
export function sortDevices(
|
||||||
|
devices: DeviceSummary[],
|
||||||
|
sortKey: DeviceSortKey,
|
||||||
|
): DeviceSummary[] {
|
||||||
|
const copy = [...devices];
|
||||||
|
switch (sortKey) {
|
||||||
|
case "name":
|
||||||
|
return copy.sort((a, b) => displayName(a).localeCompare(displayName(b)));
|
||||||
|
case "registeredAt":
|
||||||
|
return copy.sort((a, b) => {
|
||||||
|
const ra = a.registeredAt ?? null;
|
||||||
|
const rb = b.registeredAt ?? null;
|
||||||
|
// null(未註冊)排最後;兩者皆有值時比時間 desc(新在前)。
|
||||||
|
if (ra == null && rb == null) return 0;
|
||||||
|
if (ra == null) return 1;
|
||||||
|
if (rb == null) return -1;
|
||||||
|
return rb.localeCompare(ra);
|
||||||
|
});
|
||||||
|
case "status":
|
||||||
|
default:
|
||||||
|
return copy.sort((a, b) => {
|
||||||
|
const diff =
|
||||||
|
REMOTE_STATUS_ORDER[a.remoteStatus] - REMOTE_STATUS_ORDER[b.remoteStatus];
|
||||||
|
// 同狀態內次比名稱,讓排序穩定可預期。
|
||||||
|
return diff !== 0 ? diff : displayName(a).localeCompare(displayName(b));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 先 filter 再 sort(TDD §6.3:組合順序)。 */
|
||||||
|
export function applyDeviceListView(
|
||||||
|
devices: DeviceSummary[],
|
||||||
|
filter: DeviceFilterKey,
|
||||||
|
sortKey: DeviceSortKey,
|
||||||
|
): DeviceSummary[] {
|
||||||
|
return sortDevices(filterDevices(devices, filter), sortKey);
|
||||||
|
}
|
||||||
38
visionA-frontend/src/lib/format/relative-time.ts
Normal file
38
visionA-frontend/src/lib/format/relative-time.ts
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
/**
|
||||||
|
* 相對時間格式化(共用 util)
|
||||||
|
*
|
||||||
|
* 從 `components/cloud/remote-device-badge.tsx` 的 `formatRelativeTime` 抽出共用,
|
||||||
|
* 讓「模型共享」的 owner 資訊列 / 共享時間沿用同一份規格與 i18n key,避免重複實作。
|
||||||
|
*
|
||||||
|
* 規格(components.md §10.3):
|
||||||
|
* - < 60 秒 → 「剛剛」(remote.lastSeen.justNow)
|
||||||
|
* - < 60 分 → 「X 分鐘前」(remote.lastSeen.minutesAgo)
|
||||||
|
* - < 24 時 → 「X 小時前」(remote.lastSeen.hoursAgo)
|
||||||
|
* - ≥ 24 時 → 絕對時間「MM/DD HH:mm」
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param isoString ISO 8601 時間字串(無法解析時回空字串)
|
||||||
|
* @param nowMs 當前時間(ms)— 由 caller 傳入以便測試 deterministic
|
||||||
|
* @param t i18n 翻譯函式(需含 remote.lastSeen.* key)
|
||||||
|
*/
|
||||||
|
export function formatRelativeTime(
|
||||||
|
isoString: string,
|
||||||
|
nowMs: number,
|
||||||
|
t: (k: string) => string,
|
||||||
|
): string {
|
||||||
|
const ts = Date.parse(isoString);
|
||||||
|
if (Number.isNaN(ts)) return "";
|
||||||
|
const diffSec = Math.max(0, Math.floor((nowMs - ts) / 1000));
|
||||||
|
if (diffSec < 60) return t("remote.lastSeen.justNow");
|
||||||
|
const diffMin = Math.floor(diffSec / 60);
|
||||||
|
if (diffMin < 60) return t("remote.lastSeen.minutesAgo").replace("{n}", String(diffMin));
|
||||||
|
const diffHour = Math.floor(diffMin / 60);
|
||||||
|
if (diffHour < 24) return t("remote.lastSeen.hoursAgo").replace("{n}", String(diffHour));
|
||||||
|
const d = new Date(ts);
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||||
|
const dd = String(d.getDate()).padStart(2, "0");
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
return `${mm}/${dd} ${hh}:${mi}`;
|
||||||
|
}
|
||||||
@ -106,19 +106,19 @@ export const en: Dictionary = {
|
|||||||
"dashboard.flashes": "Flashes",
|
"dashboard.flashes": "Flashes",
|
||||||
"dashboard.connectedDevices": "Online devices",
|
"dashboard.connectedDevices": "Online devices",
|
||||||
"dashboard.noConnectedDevices":
|
"dashboard.noConnectedDevices":
|
||||||
"No devices are online. Pair a Kneron device to start cloud inference.",
|
"No devices are online. Connect your computer to use Kneron devices from the cloud.",
|
||||||
"dashboard.recentActivity": "Recent activity",
|
"dashboard.recentActivity": "Recent activity",
|
||||||
"dashboard.noActivity":
|
"dashboard.noActivity":
|
||||||
"Nothing here yet. Activity appears after pairing, uploads, or inference runs.",
|
"Nothing here yet. Activity appears after connecting a computer, uploads, or inference runs.",
|
||||||
"dashboard.quickActions": "Quick actions",
|
"dashboard.quickActions": "Quick actions",
|
||||||
"dashboard.browseModels": "Browse models",
|
"dashboard.browseModels": "Browse models",
|
||||||
"dashboard.manageDevices": "Manage devices",
|
"dashboard.manageDevices": "Manage devices",
|
||||||
"dashboard.uploadModel": "Upload model",
|
"dashboard.uploadModel": "Upload model",
|
||||||
"dashboard.pairDevice": "Pair device",
|
"dashboard.pairDevice": "Connect computer",
|
||||||
"dashboard.empty.title": "No devices yet",
|
"dashboard.empty.title": "No devices yet",
|
||||||
"dashboard.empty.description":
|
"dashboard.empty.description":
|
||||||
"Pair your first Kneron device to start running inference from anywhere.",
|
"Connect your computer to start using Kneron devices from anywhere.",
|
||||||
"dashboard.empty.action": "Pair a device",
|
"dashboard.empty.action": "Connect computer",
|
||||||
"dashboard.activity.justNow": "just now",
|
"dashboard.activity.justNow": "just now",
|
||||||
"dashboard.activity.minutesAgo": "{n} minutes ago",
|
"dashboard.activity.minutesAgo": "{n} minutes ago",
|
||||||
"dashboard.activity.hoursAgo": "{n} hours ago",
|
"dashboard.activity.hoursAgo": "{n} hours ago",
|
||||||
@ -131,13 +131,13 @@ export const en: Dictionary = {
|
|||||||
"devices.firmware": "Firmware",
|
"devices.firmware": "Firmware",
|
||||||
"devices.flashedModel": "Flashed model",
|
"devices.flashedModel": "Flashed model",
|
||||||
"devices.openWorkspace": "Open workspace",
|
"devices.openWorkspace": "Open workspace",
|
||||||
"devices.addMore": "Pair a new device",
|
"devices.addMore": "Connect a new computer",
|
||||||
"devices.pairAction": "Pair a new device",
|
"devices.pairAction": "Connect a new computer",
|
||||||
"devices.empty.title": "No devices paired yet",
|
"devices.empty.title": "No computers connected yet",
|
||||||
"devices.empty.description":
|
"devices.empty.description":
|
||||||
"Run local agent on your computer and complete pairing to access your Kneron devices from anywhere.",
|
"Run local agent on your computer and complete the connection to access your Kneron devices from anywhere.",
|
||||||
"devices.empty.action": "Pair your first device",
|
"devices.empty.action": "Connect your computer",
|
||||||
"devices.empty.secondaryAction": "How pairing works",
|
"devices.empty.secondaryAction": "How connecting works",
|
||||||
"devices.detail.id": "ID",
|
"devices.detail.id": "ID",
|
||||||
"devices.detail.type": "Type",
|
"devices.detail.type": "Type",
|
||||||
"devices.detail.firmware": "Firmware",
|
"devices.detail.firmware": "Firmware",
|
||||||
@ -146,7 +146,7 @@ export const en: Dictionary = {
|
|||||||
"devices.detail.modelStatus": "Model status",
|
"devices.detail.modelStatus": "Model status",
|
||||||
"devices.detail.readyForInference": "Ready for inference",
|
"devices.detail.readyForInference": "Ready for inference",
|
||||||
"devices.detail.noModelFlashed": "No model has been flashed",
|
"devices.detail.noModelFlashed": "No model has been flashed",
|
||||||
"devices.detail.pairedAt": "Paired at",
|
"devices.detail.pairedAt": "Connected at",
|
||||||
"devices.detail.hostName": "Host",
|
"devices.detail.hostName": "Host",
|
||||||
"devices.detail.lastSeen": "Last seen",
|
"devices.detail.lastSeen": "Last seen",
|
||||||
"devices.detail.offlineBanner.title": "This device is offline",
|
"devices.detail.offlineBanner.title": "This device is offline",
|
||||||
@ -165,14 +165,14 @@ export const en: Dictionary = {
|
|||||||
"devices.serial.label": "Serial number",
|
"devices.serial.label": "Serial number",
|
||||||
"devices.serial.missing": "Serial not reported yet",
|
"devices.serial.missing": "Serial not reported yet",
|
||||||
"devices.serial.missingHint":
|
"devices.serial.missingHint":
|
||||||
"This device hasn't reported its serial number, so inference-related actions are unavailable. Re-pair it once from local agent to report the serial.",
|
"This device hasn't reported its serial number, so inference-related actions are unavailable. Re-connect once from local agent to report the serial.",
|
||||||
|
|
||||||
// ── Devices: remove (unpair) ──
|
// ── Devices: remove (unpair) ──
|
||||||
"devices.remove.action": "Remove device",
|
"devices.remove.action": "Remove device",
|
||||||
"devices.remove.removing": "Removing…",
|
"devices.remove.removing": "Removing…",
|
||||||
"devices.remove.confirm.title": "Remove this device?",
|
"devices.remove.confirm.title": "Remove this device?",
|
||||||
"devices.remove.confirm.description":
|
"devices.remove.confirm.description":
|
||||||
"This unpairs “{name}” from your account and revokes its access. To use it again, you'll need to pair it from local agent. This cannot be undone.",
|
"This disconnects “{name}” from your account and revokes its access. To use it again, you'll need to connect it from local agent. This cannot be undone.",
|
||||||
"devices.remove.confirm.action": "Remove",
|
"devices.remove.confirm.action": "Remove",
|
||||||
"devices.remove.toast.success": "Device removed",
|
"devices.remove.toast.success": "Device removed",
|
||||||
"devices.remove.error.title": "Couldn't remove device",
|
"devices.remove.error.title": "Couldn't remove device",
|
||||||
@ -180,6 +180,53 @@ export const en: Dictionary = {
|
|||||||
"devices.remove.error.NOT_FOUND": "This device no longer exists.",
|
"devices.remove.error.NOT_FOUND": "This device no longer exists.",
|
||||||
"devices.remove.error.unknown": "Something went wrong. Please try again.",
|
"devices.remove.error.unknown": "Something went wrong. Please try again.",
|
||||||
|
|
||||||
|
// ── Devices: tri-state (connection × registration) ──
|
||||||
|
"devices.state.unregistered": "Unregistered",
|
||||||
|
|
||||||
|
// ── Devices: register / unregister ──
|
||||||
|
"devices.register.action": "Register",
|
||||||
|
"devices.register.pending": "Registering…",
|
||||||
|
"devices.register.toast.success": "Device registered",
|
||||||
|
"devices.register.error.title": "Couldn't register device",
|
||||||
|
"devices.register.error.ALREADY_REGISTERED": "This device is already registered.",
|
||||||
|
"devices.register.error.REPRESENTATIVE_DEVICE":
|
||||||
|
"This kind of device can't be registered or unregistered.",
|
||||||
|
"devices.register.error.FORBIDDEN": "You don't have permission to register this device.",
|
||||||
|
"devices.register.error.NOT_FOUND": "This device no longer exists.",
|
||||||
|
"devices.register.error.unknown": "Something went wrong. Please try again.",
|
||||||
|
// Unregister (return to unregistered state) — NOT the same as removing/unpairing the device.
|
||||||
|
"devices.unregister.action": "Unregister",
|
||||||
|
"devices.unregister.pending": "Unregistering…",
|
||||||
|
"devices.unregister.hint":
|
||||||
|
"This returns the device to an unregistered state. It stays in your list and isn't removed.",
|
||||||
|
"devices.unregister.toast.success": "Device unregistered",
|
||||||
|
"devices.unregister.error.title": "Couldn't unregister device",
|
||||||
|
|
||||||
|
// ── Devices: sort + filter ──
|
||||||
|
"devices.sort.label": "Sort by",
|
||||||
|
"devices.sort.status": "Status",
|
||||||
|
"devices.sort.name": "Name",
|
||||||
|
"devices.sort.registeredAt": "Registered",
|
||||||
|
"devices.filter.label": "Filter devices",
|
||||||
|
"devices.filter.all": "All",
|
||||||
|
"devices.filter.onlineRegistered": "Connected",
|
||||||
|
"devices.filter.onlineUnregistered": "Connected, unregistered",
|
||||||
|
"devices.filter.offline": "Disconnected",
|
||||||
|
"devices.filter.empty.title": "No devices match this filter",
|
||||||
|
"devices.filter.empty.description":
|
||||||
|
"Try a different filter, or clear it to see all your devices.",
|
||||||
|
"devices.filter.empty.action": "Clear filter",
|
||||||
|
|
||||||
|
// ── Devices: rescan USB (re-detect newly plugged devices) ──
|
||||||
|
"devices.rescan.action": "Rescan devices",
|
||||||
|
"devices.rescan.pending": "Scanning…",
|
||||||
|
"devices.rescan.toast.success": "Devices rescanned",
|
||||||
|
"devices.rescan.error.title": "Couldn't rescan devices",
|
||||||
|
"devices.rescan.error.TUNNEL_DISCONNECTED":
|
||||||
|
"The local agent is offline. Make sure it's running and connected, then try again.",
|
||||||
|
"devices.rescan.error.unknown": "Something went wrong. Please try again.",
|
||||||
|
"devices.rescan.offlineHint": "Connect a local agent to rescan for USB devices.",
|
||||||
|
|
||||||
// ── Devices: flash (load model to device) ──
|
// ── Devices: flash (load model to device) ──
|
||||||
"devices.flash.flashModel": "Load model",
|
"devices.flash.flashModel": "Load model",
|
||||||
"devices.flash.flashToDevice": "Load a model to this device",
|
"devices.flash.flashToDevice": "Load a model to this device",
|
||||||
@ -242,7 +289,7 @@ export const en: Dictionary = {
|
|||||||
"models.filters.all": "All",
|
"models.filters.all": "All",
|
||||||
"models.empty.title": "No models yet",
|
"models.empty.title": "No models yet",
|
||||||
"models.empty.description":
|
"models.empty.description":
|
||||||
"Upload your first .nef model to deploy it to any paired Kneron device.",
|
"Upload your first .nef model to deploy it to any Kneron device on a connected computer.",
|
||||||
"models.empty.action": "Upload your first model",
|
"models.empty.action": "Upload your first model",
|
||||||
"models.section.preset": "Preset models",
|
"models.section.preset": "Preset models",
|
||||||
"models.section.converted": "Converted by you",
|
"models.section.converted": "Converted by you",
|
||||||
@ -309,12 +356,84 @@ export const en: Dictionary = {
|
|||||||
"models.download.error.busy": "A download is already in progress, please wait.",
|
"models.download.error.busy": "A download is already in progress, please wait.",
|
||||||
"models.download.error.unknown": "Download failed, please try again later.",
|
"models.download.error.unknown": "Download failed, please try again later.",
|
||||||
|
|
||||||
|
// ── Model Sharing ──
|
||||||
|
// Shared model library list
|
||||||
|
"models.library.title": "Shared Model Library",
|
||||||
|
"models.library.subtitle": "Browse models you can access: yours, public ones, and those shared with you",
|
||||||
|
"models.library.empty.title": "No shared models yet",
|
||||||
|
"models.library.empty.description": "Models shared with you or made public will appear here",
|
||||||
|
"models.library.empty.search.title": "No models match your criteria",
|
||||||
|
"models.library.empty.search.description": "Try other keywords or clear the filters",
|
||||||
|
"models.library.error.title": "Failed to load the library",
|
||||||
|
"models.library.error.description": "Please try again later",
|
||||||
|
"models.library.loadMore.retry": "Failed to load. Click to retry",
|
||||||
|
"models.library.resultCount": "Found {n} models",
|
||||||
|
"models.library.end": "All models shown",
|
||||||
|
"models.library.link": "Shared library",
|
||||||
|
// Search
|
||||||
|
"models.search.placeholder": "Search model name…",
|
||||||
|
"models.search.aria": "Search models",
|
||||||
|
"models.search.clear": "Clear search",
|
||||||
|
"models.search.clearAll": "Clear all filters",
|
||||||
|
// Filters
|
||||||
|
"models.filters.owned": "Ownership",
|
||||||
|
"models.filters.owned.all": "All",
|
||||||
|
"models.filters.owned.mine": "My models",
|
||||||
|
"models.filters.owned.shared": "Shared with me",
|
||||||
|
"models.filters.visibility": "Visibility",
|
||||||
|
// Sort
|
||||||
|
"models.sort.label": "Sort",
|
||||||
|
"models.sort.createdAt": "Newest",
|
||||||
|
"models.sort.name": "Name",
|
||||||
|
"models.sort.fileSize": "File size",
|
||||||
|
// Visibility badge (three states + shared)
|
||||||
|
"models.visibility.badge.private": "Private",
|
||||||
|
"models.visibility.badge.public": "Public",
|
||||||
|
"models.visibility.badge.tenant": "Same tenant",
|
||||||
|
"models.visibility.badge.sharedWithMe": "Shared with me",
|
||||||
|
"models.visibility.badge.sharedCount": "{n} people",
|
||||||
|
// Card owner menu
|
||||||
|
"models.card.menu.aria": "Model actions menu",
|
||||||
|
// Receiver info row (contract does not expose email, use name)
|
||||||
|
"models.sharedByName": "Shared by {name}",
|
||||||
|
"models.ownerBar.aria": "Model owner info",
|
||||||
|
// Visibility dialog
|
||||||
|
"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": "All visionA users",
|
||||||
|
"models.visibility.tenant": "Same tenant",
|
||||||
|
"models.visibility.tenant.desc": "Members of your organization",
|
||||||
|
"models.visibility.sharedPeopleTitle": "Specific people (additionally shared)",
|
||||||
|
"models.visibility.addEmail": "Add by email",
|
||||||
|
"models.visibility.addButton": "Add",
|
||||||
|
"models.visibility.noShares": "Not shared with anyone yet",
|
||||||
|
"models.visibility.permissionViewDownload": "View + download",
|
||||||
|
"models.visibility.removeShare": "Remove {email}",
|
||||||
|
"models.visibility.publicWarning": "Once public, all visionA users can view and download this model",
|
||||||
|
"models.visibility.saveButton": "Save changes",
|
||||||
|
"models.visibility.saved": "Visibility updated",
|
||||||
|
"models.visibility.saveFailed": "Failed to save, please retry",
|
||||||
|
"models.visibility.notReady": "Model is not ready and cannot be made public",
|
||||||
|
"models.visibility.emailInvalid": "Invalid email format",
|
||||||
|
"models.visibility.emailDuplicate": "Already in the list",
|
||||||
|
"models.visibility.userNotFound": "User {email} not found",
|
||||||
|
"models.visibility.revokeConfirm": "Setting to private revokes access for shared users. Continue?",
|
||||||
|
// Profile page
|
||||||
|
"models.profile.notFound.title": "Model not found or no access",
|
||||||
|
"models.profile.notFound.description": "This model does not exist, is not public, or was not shared with you",
|
||||||
|
"models.profile.backToLibrary": "Back to shared library",
|
||||||
|
// Generic sharing error
|
||||||
|
"models.sharing.error.generic": "Operation failed, please try again later",
|
||||||
|
|
||||||
// ── Workspace ──
|
// ── Workspace ──
|
||||||
"workspace.title": "Workspace",
|
"workspace.title": "Workspace",
|
||||||
"workspace.subtitle": "Select an online device to start inference",
|
"workspace.subtitle": "Select an online device to start inference",
|
||||||
"workspace.empty.title": "No devices are online",
|
"workspace.empty.title": "No devices are online",
|
||||||
"workspace.empty.description":
|
"workspace.empty.description":
|
||||||
"Pair a device and make sure the local agent is connected to the cloud.",
|
"Connect your computer and make sure the local agent is connected to the cloud.",
|
||||||
"workspace.empty.action": "Go to devices",
|
"workspace.empty.action": "Go to devices",
|
||||||
"workspace.header.backToDevices": "Back to devices",
|
"workspace.header.backToDevices": "Back to devices",
|
||||||
"workspace.header.title": "Workspace",
|
"workspace.header.title": "Workspace",
|
||||||
@ -343,7 +462,7 @@ export const en: Dictionary = {
|
|||||||
"workspace.offline.backToList": "Back to devices",
|
"workspace.offline.backToList": "Back to devices",
|
||||||
"workspace.noSerial.title": "This device hasn't reported a serial number",
|
"workspace.noSerial.title": "This device hasn't reported a serial number",
|
||||||
"workspace.noSerial.description":
|
"workspace.noSerial.description":
|
||||||
"Inference, camera, and media upload need the device serial to route to local agent. Re-pair the device once from local agent; these actions unlock after the serial is reported.",
|
"Inference, camera, and media upload need the device serial to route to local agent. Re-connect once from local agent; these actions unlock after the serial is reported.",
|
||||||
"workspace.tabs.camera": "Camera",
|
"workspace.tabs.camera": "Camera",
|
||||||
"workspace.tabs.image": "Image",
|
"workspace.tabs.image": "Image",
|
||||||
"workspace.tabs.video": "Video",
|
"workspace.tabs.video": "Video",
|
||||||
@ -400,35 +519,35 @@ export const en: Dictionary = {
|
|||||||
"settings.advanced.platform": "Platform",
|
"settings.advanced.platform": "Platform",
|
||||||
|
|
||||||
// ── Pairing (F7) ──
|
// ── Pairing (F7) ──
|
||||||
"pairing.title": "Pair a new device",
|
"pairing.title": "Connect this computer",
|
||||||
"pairing.subtitle":
|
"pairing.subtitle":
|
||||||
"Connect your Kneron device to the cloud so you can operate it from anywhere.",
|
"Connect this computer to the cloud so its Kneron devices can be operated from anywhere.",
|
||||||
"pairing.token.title": "Your pairing token",
|
"pairing.token.title": "Your connection token",
|
||||||
"pairing.step1.description":
|
"pairing.step1.description":
|
||||||
"Copy the token below and paste it into your local agent within 15 minutes.",
|
"Copy the connection token below and paste it into your local agent within 15 minutes.",
|
||||||
"pairing.copy": "Copy",
|
"pairing.copy": "Copy",
|
||||||
"pairing.copied": "Copied",
|
"pairing.copied": "Copied",
|
||||||
"pairing.regenerate": "Regenerate",
|
"pairing.regenerate": "Regenerate",
|
||||||
"pairing.timeRemaining": "{time} remaining",
|
"pairing.timeRemaining": "{time} remaining",
|
||||||
"pairing.generatedAt": "Generated at {time}",
|
"pairing.generatedAt": "Generated at {time}",
|
||||||
"pairing.token.expired.label": "This token has expired — please regenerate.",
|
"pairing.token.expired.label": "This connection token has expired — please regenerate.",
|
||||||
"pairing.regenerateConfirm.title": "Regenerate token?",
|
"pairing.regenerateConfirm.title": "Regenerate connection token?",
|
||||||
"pairing.regenerateConfirm.description":
|
"pairing.regenerateConfirm.description":
|
||||||
"The old token will be invalidated immediately; the new one is valid for 15 minutes.",
|
"The old connection token will be invalidated immediately; the new one is valid for 15 minutes.",
|
||||||
"pairing.security.warning":
|
"pairing.security.warning":
|
||||||
"This token is valid for 15 minutes — complete pairing now.",
|
"This connection token is valid for 15 minutes — complete the connection now.",
|
||||||
"pairing.security.oneTime":
|
"pairing.security.oneTime":
|
||||||
"Tokens are single-use and expire automatically after pairing.",
|
"Connection tokens are single-use and expire automatically after connecting.",
|
||||||
"pairing.toast.copied": "Token copied — valid for 15 minutes.",
|
"pairing.toast.copied": "Connection token copied — valid for 15 minutes.",
|
||||||
"pairing.toast.generateFailed": "Could not generate token — please retry.",
|
"pairing.toast.generateFailed": "Could not generate connection token — please retry.",
|
||||||
"pairing.toast.expiringSoon":
|
"pairing.toast.expiringSoon":
|
||||||
"Token expiring soon — complete pairing or regenerate.",
|
"Connection token expiring soon — complete the connection or regenerate.",
|
||||||
"pairing.toast.pairedSuccess": "Device {deviceName} paired successfully.",
|
"pairing.toast.pairedSuccess": "Computer connected — device {deviceName} detected.",
|
||||||
"pairing.toast.cliCopied": "CLI command copied.",
|
"pairing.toast.cliCopied": "CLI command copied.",
|
||||||
"pairing.device.unknown": "Unknown device",
|
"pairing.device.unknown": "Unknown device",
|
||||||
"pairing.cli.title": "CLI example",
|
"pairing.cli.title": "CLI example",
|
||||||
"pairing.cli.description":
|
"pairing.cli.description":
|
||||||
"Start local agent on your computer and pass the token to the --relay-token flag.",
|
"Start local agent on your computer and pass the connection token to the --relay-token flag.",
|
||||||
"pairing.cli.copy": "Copy command",
|
"pairing.cli.copy": "Copy command",
|
||||||
"pairing.cli.hint":
|
"pairing.cli.hint":
|
||||||
"Once local agent connects to the cloud, this page detects it and forwards you to the device list.",
|
"Once local agent connects to the cloud, this page detects it and forwards you to the device list.",
|
||||||
@ -436,7 +555,7 @@ export const en: Dictionary = {
|
|||||||
"pairing.step3.elapsed": "Elapsed {time} (max 3 minutes)",
|
"pairing.step3.elapsed": "Elapsed {time} (max 3 minutes)",
|
||||||
"pairing.step3.hints.running": "Confirm local agent is running",
|
"pairing.step3.hints.running": "Confirm local agent is running",
|
||||||
"pairing.step3.hints.token":
|
"pairing.step3.hints.token":
|
||||||
"Confirm the token was pasted without missing or extra characters",
|
"Confirm the connection token was pasted without missing or extra characters",
|
||||||
"pairing.step3.hints.network":
|
"pairing.step3.hints.network":
|
||||||
"Confirm your network can reach the cloud endpoint",
|
"Confirm your network can reach the cloud endpoint",
|
||||||
"pairing.step3.success": "Connected!",
|
"pairing.step3.success": "Connected!",
|
||||||
|
|||||||
@ -109,17 +109,17 @@ export const zhHant: Dictionary = {
|
|||||||
"dashboard.connected": "線上裝置",
|
"dashboard.connected": "線上裝置",
|
||||||
"dashboard.flashes": "已燒錄次數",
|
"dashboard.flashes": "已燒錄次數",
|
||||||
"dashboard.connectedDevices": "線上裝置",
|
"dashboard.connectedDevices": "線上裝置",
|
||||||
"dashboard.noConnectedDevices": "目前沒有裝置線上。配對一台 Kneron 裝置開始雲端推論。",
|
"dashboard.noConnectedDevices": "目前沒有裝置線上。連接你的電腦,就能從雲端使用 Kneron 裝置。",
|
||||||
"dashboard.recentActivity": "近期活動",
|
"dashboard.recentActivity": "近期活動",
|
||||||
"dashboard.noActivity": "還沒有任何活動。配對裝置、上傳模型或跑一次推論後就會出現。",
|
"dashboard.noActivity": "還沒有任何活動。連接電腦、上傳模型或跑一次推論後就會出現。",
|
||||||
"dashboard.quickActions": "快速操作",
|
"dashboard.quickActions": "快速操作",
|
||||||
"dashboard.browseModels": "瀏覽模型",
|
"dashboard.browseModels": "瀏覽模型",
|
||||||
"dashboard.manageDevices": "管理裝置",
|
"dashboard.manageDevices": "管理裝置",
|
||||||
"dashboard.uploadModel": "上傳模型",
|
"dashboard.uploadModel": "上傳模型",
|
||||||
"dashboard.pairDevice": "配對裝置",
|
"dashboard.pairDevice": "連接電腦",
|
||||||
"dashboard.empty.title": "還沒有任何裝置",
|
"dashboard.empty.title": "還沒有任何裝置",
|
||||||
"dashboard.empty.description": "配對你的第一台 Kneron 裝置,開始雲端推論之旅",
|
"dashboard.empty.description": "連接你的電腦,開始從雲端使用 Kneron 裝置",
|
||||||
"dashboard.empty.action": "配對裝置",
|
"dashboard.empty.action": "連接電腦",
|
||||||
"dashboard.activity.justNow": "剛剛",
|
"dashboard.activity.justNow": "剛剛",
|
||||||
"dashboard.activity.minutesAgo": "{n} 分鐘前",
|
"dashboard.activity.minutesAgo": "{n} 分鐘前",
|
||||||
"dashboard.activity.hoursAgo": "{n} 小時前",
|
"dashboard.activity.hoursAgo": "{n} 小時前",
|
||||||
@ -132,13 +132,13 @@ export const zhHant: Dictionary = {
|
|||||||
"devices.firmware": "韌體",
|
"devices.firmware": "韌體",
|
||||||
"devices.flashedModel": "已燒錄模型",
|
"devices.flashedModel": "已燒錄模型",
|
||||||
"devices.openWorkspace": "開啟工作區",
|
"devices.openWorkspace": "開啟工作區",
|
||||||
"devices.addMore": "配對新裝置",
|
"devices.addMore": "連接新電腦",
|
||||||
"devices.pairAction": "配對新裝置",
|
"devices.pairAction": "連接新電腦",
|
||||||
"devices.empty.title": "還沒有配對的裝置",
|
"devices.empty.title": "還沒有連接任何電腦",
|
||||||
"devices.empty.description":
|
"devices.empty.description":
|
||||||
"在你的電腦上執行 local agent 並完成配對,就能從任何地方存取你的 Kneron 裝置",
|
"在你的電腦上執行 local agent 並完成連接,就能從任何地方存取你的 Kneron 裝置",
|
||||||
"devices.empty.action": "配對第一台裝置",
|
"devices.empty.action": "連接你的電腦",
|
||||||
"devices.empty.secondaryAction": "查看配對說明",
|
"devices.empty.secondaryAction": "查看連接說明",
|
||||||
"devices.detail.id": "ID",
|
"devices.detail.id": "ID",
|
||||||
"devices.detail.type": "類型",
|
"devices.detail.type": "類型",
|
||||||
"devices.detail.firmware": "韌體",
|
"devices.detail.firmware": "韌體",
|
||||||
@ -147,7 +147,7 @@ export const zhHant: Dictionary = {
|
|||||||
"devices.detail.modelStatus": "模型狀態",
|
"devices.detail.modelStatus": "模型狀態",
|
||||||
"devices.detail.readyForInference": "已就緒,可開始推論",
|
"devices.detail.readyForInference": "已就緒,可開始推論",
|
||||||
"devices.detail.noModelFlashed": "尚未燒錄任何模型",
|
"devices.detail.noModelFlashed": "尚未燒錄任何模型",
|
||||||
"devices.detail.pairedAt": "配對時間",
|
"devices.detail.pairedAt": "連接時間",
|
||||||
"devices.detail.hostName": "所在電腦",
|
"devices.detail.hostName": "所在電腦",
|
||||||
"devices.detail.lastSeen": "最後心跳",
|
"devices.detail.lastSeen": "最後心跳",
|
||||||
"devices.detail.offlineBanner.title": "此裝置目前離線",
|
"devices.detail.offlineBanner.title": "此裝置目前離線",
|
||||||
@ -166,14 +166,14 @@ export const zhHant: Dictionary = {
|
|||||||
"devices.serial.label": "序號",
|
"devices.serial.label": "序號",
|
||||||
"devices.serial.missing": "尚未回報序號",
|
"devices.serial.missing": "尚未回報序號",
|
||||||
"devices.serial.missingHint":
|
"devices.serial.missingHint":
|
||||||
"此裝置尚未回報序號,無法執行推論相關操作。請在 local agent 重新配對一次,序號回報後即可使用。",
|
"此裝置尚未回報序號,無法執行推論相關操作。請在 local agent 重新連接一次,序號回報後即可使用。",
|
||||||
|
|
||||||
// ── Devices: 移除裝置(unpair) ──
|
// ── Devices: 移除裝置(unpair) ──
|
||||||
"devices.remove.action": "移除裝置",
|
"devices.remove.action": "移除裝置",
|
||||||
"devices.remove.removing": "移除中…",
|
"devices.remove.removing": "移除中…",
|
||||||
"devices.remove.confirm.title": "確定要移除此裝置?",
|
"devices.remove.confirm.title": "確定要移除此裝置?",
|
||||||
"devices.remove.confirm.description":
|
"devices.remove.confirm.description":
|
||||||
"這會解除「{name}」與你帳號的配對並撤銷其存取權限。若要再次使用,需從 local agent 重新配對。此操作無法復原。",
|
"這會解除「{name}」與你帳號的連接並撤銷其存取權限。若要再次使用,需從 local agent 重新連接。此操作無法復原。",
|
||||||
"devices.remove.confirm.action": "移除",
|
"devices.remove.confirm.action": "移除",
|
||||||
"devices.remove.toast.success": "已移除裝置",
|
"devices.remove.toast.success": "已移除裝置",
|
||||||
"devices.remove.error.title": "移除裝置失敗",
|
"devices.remove.error.title": "移除裝置失敗",
|
||||||
@ -181,6 +181,51 @@ export const zhHant: Dictionary = {
|
|||||||
"devices.remove.error.NOT_FOUND": "此裝置已不存在",
|
"devices.remove.error.NOT_FOUND": "此裝置已不存在",
|
||||||
"devices.remove.error.unknown": "發生錯誤,請稍後再試",
|
"devices.remove.error.unknown": "發生錯誤,請稍後再試",
|
||||||
|
|
||||||
|
// ── Devices: 三態(連線 × 註冊) ──
|
||||||
|
"devices.state.unregistered": "未註冊",
|
||||||
|
|
||||||
|
// ── Devices: 註冊 / 取消註冊 ──
|
||||||
|
"devices.register.action": "註冊",
|
||||||
|
"devices.register.pending": "註冊中…",
|
||||||
|
"devices.register.toast.success": "已註冊裝置",
|
||||||
|
"devices.register.error.title": "註冊裝置失敗",
|
||||||
|
"devices.register.error.ALREADY_REGISTERED": "此裝置已註冊",
|
||||||
|
"devices.register.error.REPRESENTATIVE_DEVICE": "這類裝置無法註冊或取消註冊",
|
||||||
|
"devices.register.error.FORBIDDEN": "你沒有權限註冊此裝置",
|
||||||
|
"devices.register.error.NOT_FOUND": "此裝置已不存在",
|
||||||
|
"devices.register.error.unknown": "發生錯誤,請稍後再試",
|
||||||
|
// 取消註冊(退回未註冊態)— 與「移除裝置(解除配對)」不同,不會刪掉裝置。
|
||||||
|
"devices.unregister.action": "取消註冊",
|
||||||
|
"devices.unregister.pending": "取消註冊中…",
|
||||||
|
"devices.unregister.hint":
|
||||||
|
"這會把裝置退回未註冊狀態,裝置仍保留在清單中,不會被移除。",
|
||||||
|
"devices.unregister.toast.success": "已取消註冊",
|
||||||
|
"devices.unregister.error.title": "取消註冊失敗",
|
||||||
|
|
||||||
|
// ── Devices: 排序 + 篩選 ──
|
||||||
|
"devices.sort.label": "排序方式",
|
||||||
|
"devices.sort.status": "狀態",
|
||||||
|
"devices.sort.name": "名稱",
|
||||||
|
"devices.sort.registeredAt": "註冊時間",
|
||||||
|
"devices.filter.label": "篩選裝置",
|
||||||
|
"devices.filter.all": "全部",
|
||||||
|
"devices.filter.onlineRegistered": "已連接",
|
||||||
|
"devices.filter.onlineUnregistered": "已連接未註冊",
|
||||||
|
"devices.filter.offline": "未連接",
|
||||||
|
"devices.filter.empty.title": "沒有符合此篩選條件的裝置",
|
||||||
|
"devices.filter.empty.description": "試試其他篩選條件,或清除篩選以顯示所有裝置。",
|
||||||
|
"devices.filter.empty.action": "清除篩選",
|
||||||
|
|
||||||
|
// ── Devices: 重新掃描 USB(偵測新插入的裝置) ──
|
||||||
|
"devices.rescan.action": "重新掃描裝置",
|
||||||
|
"devices.rescan.pending": "掃描中…",
|
||||||
|
"devices.rescan.toast.success": "已重新掃描",
|
||||||
|
"devices.rescan.error.title": "重新掃描失敗",
|
||||||
|
"devices.rescan.error.TUNNEL_DISCONNECTED":
|
||||||
|
"local agent 目前離線。請確認它正在執行並已連線,再試一次。",
|
||||||
|
"devices.rescan.error.unknown": "發生錯誤,請再試一次。",
|
||||||
|
"devices.rescan.offlineHint": "請先連接 local agent,才能重新掃描 USB 裝置。",
|
||||||
|
|
||||||
// ── Devices: flash(載入模型到裝置) ──
|
// ── Devices: flash(載入模型到裝置) ──
|
||||||
"devices.flash.flashModel": "載入模型",
|
"devices.flash.flashModel": "載入模型",
|
||||||
"devices.flash.flashToDevice": "載入模型到此裝置",
|
"devices.flash.flashToDevice": "載入模型到此裝置",
|
||||||
@ -242,7 +287,7 @@ export const zhHant: Dictionary = {
|
|||||||
"models.filters.all": "全部",
|
"models.filters.all": "全部",
|
||||||
"models.empty.title": "還沒有任何模型",
|
"models.empty.title": "還沒有任何模型",
|
||||||
"models.empty.description":
|
"models.empty.description":
|
||||||
"上傳你的第一個 .nef 模型到雲端,就能部署到任何一台配對過的 Kneron 裝置",
|
"上傳你的第一個 .nef 模型到雲端,就能部署到任何一台已連接電腦上的 Kneron 裝置",
|
||||||
"models.empty.action": "上傳第一個模型",
|
"models.empty.action": "上傳第一個模型",
|
||||||
"models.section.preset": "預設模型",
|
"models.section.preset": "預設模型",
|
||||||
"models.section.converted": "我轉檔的",
|
"models.section.converted": "我轉檔的",
|
||||||
@ -301,11 +346,83 @@ export const zhHant: Dictionary = {
|
|||||||
"models.download.error.busy": "已有下載進行中,請稍候",
|
"models.download.error.busy": "已有下載進行中,請稍候",
|
||||||
"models.download.error.unknown": "下載失敗,請稍後再試",
|
"models.download.error.unknown": "下載失敗,請稍後再試",
|
||||||
|
|
||||||
|
// ── 模型共享(Model Sharing)──
|
||||||
|
// 共享模型庫列表
|
||||||
|
"models.library.title": "共享模型庫",
|
||||||
|
"models.library.subtitle": "瀏覽你可存取的模型:你的、公開的、以及別人分享給你的",
|
||||||
|
"models.library.empty.title": "還沒有可存取的共享模型",
|
||||||
|
"models.library.empty.description": "當同事把模型分享給你、或有公開模型時,會出現在這裡",
|
||||||
|
"models.library.empty.search.title": "找不到符合條件的模型",
|
||||||
|
"models.library.empty.search.description": "試試其他關鍵字或清除篩選條件",
|
||||||
|
"models.library.error.title": "載入模型庫失敗",
|
||||||
|
"models.library.error.description": "請稍後再試",
|
||||||
|
"models.library.loadMore.retry": "載入失敗,點擊重試",
|
||||||
|
"models.library.resultCount": "找到 {n} 個模型",
|
||||||
|
"models.library.end": "已顯示全部模型",
|
||||||
|
"models.library.link": "共享模型庫",
|
||||||
|
// 搜尋
|
||||||
|
"models.search.placeholder": "搜尋模型名稱…",
|
||||||
|
"models.search.aria": "搜尋模型",
|
||||||
|
"models.search.clear": "清除搜尋",
|
||||||
|
"models.search.clearAll": "清除所有篩選",
|
||||||
|
// filter
|
||||||
|
"models.filters.owned": "擁有關係",
|
||||||
|
"models.filters.owned.all": "全部",
|
||||||
|
"models.filters.owned.mine": "我的模型",
|
||||||
|
"models.filters.owned.shared": "共享給我",
|
||||||
|
"models.filters.visibility": "可見性",
|
||||||
|
// 排序
|
||||||
|
"models.sort.label": "排序",
|
||||||
|
"models.sort.createdAt": "最新建立",
|
||||||
|
"models.sort.name": "名稱",
|
||||||
|
"models.sort.fileSize": "檔案大小",
|
||||||
|
// visibility badge(三態 + 共享)
|
||||||
|
"models.visibility.badge.private": "私有",
|
||||||
|
"models.visibility.badge.public": "公開",
|
||||||
|
"models.visibility.badge.tenant": "同租戶",
|
||||||
|
"models.visibility.badge.sharedWithMe": "共享給我",
|
||||||
|
"models.visibility.badge.sharedCount": "{n} 人",
|
||||||
|
// 卡片 owner 選單
|
||||||
|
"models.card.menu.aria": "模型操作選單",
|
||||||
|
// receiver 資訊列(契約不揭露 email,用名稱)
|
||||||
|
"models.sharedByName": "由 {name} 共享",
|
||||||
|
"models.ownerBar.aria": "模型擁有者資訊",
|
||||||
|
// 公開設定 Dialog
|
||||||
|
"models.visibility.title": "公開設定",
|
||||||
|
"models.visibility.question": "誰可以看到並使用這個模型?",
|
||||||
|
"models.visibility.private": "私有",
|
||||||
|
"models.visibility.private.desc": "只有你自己",
|
||||||
|
"models.visibility.public": "公開",
|
||||||
|
"models.visibility.public.desc": "所有 visionA 使用者",
|
||||||
|
"models.visibility.tenant": "同租戶",
|
||||||
|
"models.visibility.tenant.desc": "與你同組織的成員",
|
||||||
|
"models.visibility.sharedPeopleTitle": "指定對象(額外分享給特定人)",
|
||||||
|
"models.visibility.addEmail": "輸入 email 加入",
|
||||||
|
"models.visibility.addButton": "加入",
|
||||||
|
"models.visibility.noShares": "尚未分享給任何人",
|
||||||
|
"models.visibility.permissionViewDownload": "可檢視 + 下載",
|
||||||
|
"models.visibility.removeShare": "移除 {email}",
|
||||||
|
"models.visibility.publicWarning": "公開後,所有 visionA 使用者都能檢視並下載此模型",
|
||||||
|
"models.visibility.saveButton": "儲存變更",
|
||||||
|
"models.visibility.saved": "已更新公開設定",
|
||||||
|
"models.visibility.saveFailed": "儲存失敗,請重試",
|
||||||
|
"models.visibility.notReady": "模型尚未就緒,無法公開",
|
||||||
|
"models.visibility.emailInvalid": "Email 格式不正確",
|
||||||
|
"models.visibility.emailDuplicate": "已在清單中",
|
||||||
|
"models.visibility.userNotFound": "找不到使用者 {email}",
|
||||||
|
"models.visibility.revokeConfirm": "改為私有後,已分享的對象將無法再存取,確定要繼續嗎?",
|
||||||
|
// profile 頁
|
||||||
|
"models.profile.notFound.title": "找不到模型或沒有存取權",
|
||||||
|
"models.profile.notFound.description": "這個模型不存在、未公開,或未分享給你",
|
||||||
|
"models.profile.backToLibrary": "返回共享模型庫",
|
||||||
|
// 通用共享錯誤
|
||||||
|
"models.sharing.error.generic": "操作失敗,請稍後再試",
|
||||||
|
|
||||||
// ── Workspace ──
|
// ── Workspace ──
|
||||||
"workspace.title": "推論工作區",
|
"workspace.title": "推論工作區",
|
||||||
"workspace.subtitle": "選擇已線上的裝置開始推論",
|
"workspace.subtitle": "選擇已線上的裝置開始推論",
|
||||||
"workspace.empty.title": "目前沒有線上裝置",
|
"workspace.empty.title": "目前沒有線上裝置",
|
||||||
"workspace.empty.description": "請先配對並確認 local agent 已連上雲端",
|
"workspace.empty.description": "請先連接你的電腦並確認 local agent 已連上雲端",
|
||||||
"workspace.empty.action": "前往裝置管理",
|
"workspace.empty.action": "前往裝置管理",
|
||||||
"workspace.header.backToDevices": "返回裝置",
|
"workspace.header.backToDevices": "返回裝置",
|
||||||
"workspace.header.title": "工作區",
|
"workspace.header.title": "工作區",
|
||||||
@ -331,7 +448,7 @@ export const zhHant: Dictionary = {
|
|||||||
"workspace.offline.backToList": "返回裝置列表",
|
"workspace.offline.backToList": "返回裝置列表",
|
||||||
"workspace.noSerial.title": "此裝置尚未回報序號",
|
"workspace.noSerial.title": "此裝置尚未回報序號",
|
||||||
"workspace.noSerial.description":
|
"workspace.noSerial.description":
|
||||||
"推論、攝影機與媒體上傳需要裝置序號才能路由到 local agent。請在 local agent 重新配對一次,序號回報後即可操作。",
|
"推論、攝影機與媒體上傳需要裝置序號才能路由到 local agent。請在 local agent 重新連接一次,序號回報後即可操作。",
|
||||||
"workspace.tabs.camera": "Camera",
|
"workspace.tabs.camera": "Camera",
|
||||||
"workspace.tabs.image": "圖片",
|
"workspace.tabs.image": "圖片",
|
||||||
"workspace.tabs.video": "影片",
|
"workspace.tabs.video": "影片",
|
||||||
@ -387,42 +504,42 @@ export const zhHant: Dictionary = {
|
|||||||
"settings.advanced.platform": "平台",
|
"settings.advanced.platform": "平台",
|
||||||
|
|
||||||
// ── Pairing(F7 新增)──
|
// ── Pairing(F7 新增)──
|
||||||
"pairing.title": "配對新裝置",
|
"pairing.title": "連接這台電腦",
|
||||||
"pairing.subtitle": "讓你的 Kneron 裝置連上雲端,就能從任何地方遠端操作",
|
"pairing.subtitle": "讓這台電腦連上雲端,之後電腦上的 Kneron 裝置都能從任何地方遠端操作",
|
||||||
"pairing.token.title": "你的 Pairing Token",
|
"pairing.token.title": "你的連接碼",
|
||||||
"pairing.step1.description":
|
"pairing.step1.description":
|
||||||
"複製下方 token,在 15 分鐘內貼到 local agent 完成配對",
|
"複製下方連接碼,在 15 分鐘內貼到 local agent 完成連接",
|
||||||
"pairing.copy": "複製",
|
"pairing.copy": "複製",
|
||||||
"pairing.copied": "已複製",
|
"pairing.copied": "已複製",
|
||||||
"pairing.regenerate": "重新產生",
|
"pairing.regenerate": "重新產生",
|
||||||
"pairing.timeRemaining": "剩餘 {time}",
|
"pairing.timeRemaining": "剩餘 {time}",
|
||||||
"pairing.generatedAt": "產生時間:{time}",
|
"pairing.generatedAt": "產生時間:{time}",
|
||||||
"pairing.token.expired.label": "此 token 已過期,請重新產生",
|
"pairing.token.expired.label": "此連接碼已過期,請重新產生",
|
||||||
"pairing.regenerateConfirm.title": "確定要重新產生?",
|
"pairing.regenerateConfirm.title": "確定要重新產生連接碼?",
|
||||||
"pairing.regenerateConfirm.description":
|
"pairing.regenerateConfirm.description":
|
||||||
"舊 token 將立即失效,新 token 有效期 15 分鐘",
|
"舊連接碼將立即失效,新連接碼有效期 15 分鐘",
|
||||||
"pairing.security.warning": "這組 token 15 分鐘內有效,請立刻完成配對",
|
"pairing.security.warning": "這組連接碼 15 分鐘內有效,請立刻完成連接",
|
||||||
"pairing.security.oneTime": "token 是一次性使用,完成配對後自動失效",
|
"pairing.security.oneTime": "連接碼是一次性使用,完成連接後自動失效",
|
||||||
"pairing.toast.copied": "Token 已複製到剪貼簿,15 分鐘內有效",
|
"pairing.toast.copied": "連接碼已複製到剪貼簿,15 分鐘內有效",
|
||||||
"pairing.toast.generateFailed": "無法產生 token,請重試",
|
"pairing.toast.generateFailed": "無法產生連接碼,請重試",
|
||||||
"pairing.toast.expiringSoon": "Token 即將過期,請立刻完成或重新產生",
|
"pairing.toast.expiringSoon": "連接碼即將過期,請立刻完成或重新產生",
|
||||||
"pairing.toast.pairedSuccess": "裝置 {deviceName} 已成功配對",
|
"pairing.toast.pairedSuccess": "已成功連接電腦,偵測到裝置 {deviceName}",
|
||||||
"pairing.toast.cliCopied": "指令已複製到剪貼簿",
|
"pairing.toast.cliCopied": "指令已複製到剪貼簿",
|
||||||
"pairing.device.unknown": "未知裝置",
|
"pairing.device.unknown": "未知裝置",
|
||||||
"pairing.cli.title": "CLI 指令範例",
|
"pairing.cli.title": "CLI 指令範例",
|
||||||
"pairing.cli.description":
|
"pairing.cli.description":
|
||||||
"在你的電腦啟動 local agent,將 token 貼到指令的 --relay-token 參數",
|
"在你的電腦啟動 local agent,將連接碼貼到指令的 --relay-token 參數",
|
||||||
"pairing.cli.copy": "複製指令",
|
"pairing.cli.copy": "複製指令",
|
||||||
"pairing.cli.hint":
|
"pairing.cli.hint":
|
||||||
"local agent 連上雲端後,本頁會自動偵測並跳轉到裝置列表",
|
"local agent 連上雲端後,本頁會自動偵測並跳轉到裝置列表",
|
||||||
"pairing.step3.waiting": "等待 local agent 連線…",
|
"pairing.step3.waiting": "等待 local agent 連線…",
|
||||||
"pairing.step3.elapsed": "已等待 {time}(最長 3 分鐘)",
|
"pairing.step3.elapsed": "已等待 {time}(最長 3 分鐘)",
|
||||||
"pairing.step3.hints.running": "確認 local agent 已啟動",
|
"pairing.step3.hints.running": "確認 local agent 已啟動",
|
||||||
"pairing.step3.hints.token": "確認 token 貼上時無缺字或多餘空白",
|
"pairing.step3.hints.token": "確認連接碼貼上時無缺字或多餘空白",
|
||||||
"pairing.step3.hints.network": "確認你的網路可連線到雲端",
|
"pairing.step3.hints.network": "確認你的網路可連線到雲端",
|
||||||
"pairing.step3.success": "已成功連線!",
|
"pairing.step3.success": "已成功連接電腦!",
|
||||||
"pairing.step3.success.detected": "檢測到的裝置",
|
"pairing.step3.success.detected": "檢測到的裝置",
|
||||||
"pairing.step3.failure.timeout": "連線超時",
|
"pairing.step3.failure.timeout": "連接超時",
|
||||||
"pairing.step3.failure.reason":
|
"pairing.step3.failure.reason":
|
||||||
"超過 3 分鐘沒收到 local agent 連線,可能是 local agent 尚未啟動",
|
"超過 3 分鐘沒收到 local agent 連線,可能是 local agent 尚未啟動",
|
||||||
"pairing.step3.failure.retry": "重新檢查",
|
"pairing.step3.failure.retry": "重新檢查",
|
||||||
|
|||||||
@ -23,6 +23,8 @@ beforeEach(() => {
|
|||||||
connectingId: null,
|
connectingId: null,
|
||||||
disconnectingId: null,
|
disconnectingId: null,
|
||||||
unpairingId: null,
|
unpairingId: null,
|
||||||
|
registeringId: null,
|
||||||
|
isRescanning: false,
|
||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
// OF2:api.ts 不再需要 token getter(cookie session 由瀏覽器自動帶)
|
// OF2:api.ts 不再需要 token getter(cookie session 由瀏覽器自動帶)
|
||||||
@ -412,3 +414,351 @@ describe("useDeviceStore.unpairDevice", () => {
|
|||||||
expect(useDeviceStore.getState().unpairingId).toBeNull();
|
expect(useDeviceStore.getState().unpairingId).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 註冊軸:normalizeDevice registeredAt + register / unregister actions */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
describe("useDeviceStore — registeredAt 正規化(TDD §5.1)", () => {
|
||||||
|
it("registered_at(snake)/ registeredAt(camel)有值 → 正確帶入;缺欄 → null", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
// snake_case(後端實際形狀)
|
||||||
|
{
|
||||||
|
id: "dev-1",
|
||||||
|
name: "A",
|
||||||
|
type: "kl520",
|
||||||
|
status: "connected",
|
||||||
|
registered_at: "2026-08-02T10:00:00Z",
|
||||||
|
},
|
||||||
|
// camelCase 容錯
|
||||||
|
{
|
||||||
|
id: "dev-2",
|
||||||
|
name: "B",
|
||||||
|
type: "kl520",
|
||||||
|
status: "connected",
|
||||||
|
registeredAt: "2026-08-01T00:00:00Z",
|
||||||
|
},
|
||||||
|
// 缺欄位(未註冊 / 舊資料)→ null
|
||||||
|
{ id: "dev-3", name: "C", type: "kl520", status: "connected" },
|
||||||
|
// 明確 null → null
|
||||||
|
{
|
||||||
|
id: "dev-4",
|
||||||
|
name: "D",
|
||||||
|
type: "kl520",
|
||||||
|
status: "connected",
|
||||||
|
registered_at: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await useDeviceStore.getState().fetchDevices();
|
||||||
|
const { devices } = useDeviceStore.getState();
|
||||||
|
expect(devices[0]?.registeredAt).toBe("2026-08-02T10:00:00Z");
|
||||||
|
expect(devices[1]?.registeredAt).toBe("2026-08-01T00:00:00Z");
|
||||||
|
expect(devices[2]?.registeredAt).toBeNull();
|
||||||
|
expect(devices[3]?.registeredAt).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useDeviceStore.registerDevice", () => {
|
||||||
|
const unregistered = {
|
||||||
|
id: "dev-1",
|
||||||
|
name: "KL520",
|
||||||
|
type: "kl520",
|
||||||
|
status: "connected" as const,
|
||||||
|
remoteStatus: "online" as const,
|
||||||
|
registeredAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
it("成功時打對 register endpoint(UUID)、就地更新 registeredAt、回 { ok:true }", async () => {
|
||||||
|
useDeviceStore.setState({
|
||||||
|
devices: [unregistered, { ...unregistered, id: "dev-2" }],
|
||||||
|
selectedDevice: { ...unregistered },
|
||||||
|
});
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: "dev-1",
|
||||||
|
name: "KL520",
|
||||||
|
device_type: "kl520",
|
||||||
|
status: "connected",
|
||||||
|
remote_status: "online",
|
||||||
|
registered_at: "2026-08-02T10:00:00Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await useDeviceStore.getState().registerDevice("dev-1");
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
const calledUrl = String(fetchSpy.mock.calls[0]?.[0]);
|
||||||
|
expect(calledUrl).toContain("/api/devices/dev-1/register");
|
||||||
|
expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ method: "POST" });
|
||||||
|
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
// 就地更新該筆 registeredAt(不移除 list)
|
||||||
|
expect(state.devices.find((d) => d.id === "dev-1")?.registeredAt).toBe(
|
||||||
|
"2026-08-02T10:00:00Z",
|
||||||
|
);
|
||||||
|
// 其他裝置不受影響
|
||||||
|
expect(state.devices.find((d) => d.id === "dev-2")?.registeredAt).toBeNull();
|
||||||
|
// selectedDevice 同步更新
|
||||||
|
expect(state.selectedDevice?.registeredAt).toBe("2026-08-02T10:00:00Z");
|
||||||
|
expect(state.registeringId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("就地 merge 只覆寫 registeredAt,不把本地既有欄位清成 null(後端 omitempty 防禦)", async () => {
|
||||||
|
useDeviceStore.setState({
|
||||||
|
devices: [{ ...unregistered, firmwareVersion: "2.3.1" }],
|
||||||
|
});
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
// 後端回應缺 firmware_version(omitempty)
|
||||||
|
jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
id: "dev-1",
|
||||||
|
name: "KL520",
|
||||||
|
status: "connected",
|
||||||
|
registered_at: "2026-08-02T10:00:00Z",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await useDeviceStore.getState().registerDevice("dev-1");
|
||||||
|
const d = useDeviceStore.getState().devices[0];
|
||||||
|
expect(d?.registeredAt).toBe("2026-08-02T10:00:00Z");
|
||||||
|
// 本地既有 firmwareVersion 不被覆寫成 null
|
||||||
|
expect(d?.firmwareVersion).toBe("2.3.1");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("呼叫期間 registeringId 設為該 id(loading 態)", async () => {
|
||||||
|
let observed: string | null = "not-set";
|
||||||
|
vi.spyOn(globalThis, "fetch").mockImplementationOnce(async () => {
|
||||||
|
observed = useDeviceStore.getState().registeringId;
|
||||||
|
return jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: { id: "dev-1", registered_at: "2026-08-02T10:00:00Z" },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDeviceStore.getState().registerDevice("dev-1");
|
||||||
|
expect(observed).toBe("dev-1");
|
||||||
|
expect(useDeviceStore.getState().registeringId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("409 ALREADY_REGISTERED → 回 { ok:false, code:'ALREADY_REGISTERED' },不改 list", async () => {
|
||||||
|
useDeviceStore.setState({ devices: [unregistered] });
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: "ALREADY_REGISTERED", message: "device already registered" },
|
||||||
|
},
|
||||||
|
409,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await useDeviceStore.getState().registerDevice("dev-1");
|
||||||
|
expect(result).toMatchObject({ ok: false, code: "ALREADY_REGISTERED" });
|
||||||
|
// list 不變(registeredAt 仍 null)
|
||||||
|
expect(useDeviceStore.getState().devices[0]?.registeredAt).toBeNull();
|
||||||
|
expect(useDeviceStore.getState().registeringId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("409 REPRESENTATIVE_DEVICE(representative)→ 回 { ok:false, code:'REPRESENTATIVE_DEVICE' }", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: "REPRESENTATIVE_DEVICE", message: "representative" },
|
||||||
|
},
|
||||||
|
409,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const result = await useDeviceStore.getState().registerDevice("dev-1");
|
||||||
|
expect(result).toMatchObject({ ok: false, code: "REPRESENTATIVE_DEVICE" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("403 FORBIDDEN(非 owner)→ 回 { ok:false, code:'FORBIDDEN' }", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse(
|
||||||
|
{ success: false, error: { code: "FORBIDDEN", message: "not owner" } },
|
||||||
|
403,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const result = await useDeviceStore.getState().registerDevice("dev-1");
|
||||||
|
expect(result).toMatchObject({ ok: false, code: "FORBIDDEN" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useDeviceStore.unregisterDevice", () => {
|
||||||
|
const registered = {
|
||||||
|
id: "dev-1",
|
||||||
|
name: "KL520",
|
||||||
|
type: "kl520",
|
||||||
|
status: "connected" as const,
|
||||||
|
remoteStatus: "online" as const,
|
||||||
|
registeredAt: "2026-08-02T10:00:00Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
it("成功時打對 unregister endpoint、清 registeredAt、**保留 list**(不移除)", async () => {
|
||||||
|
useDeviceStore.setState({
|
||||||
|
devices: [registered, { ...registered, id: "dev-2" }],
|
||||||
|
selectedDevice: { ...registered },
|
||||||
|
});
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: { id: "dev-1", name: "KL520", status: "connected", registered_at: null },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await useDeviceStore.getState().unregisterDevice("dev-1");
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
const calledUrl = String(fetchSpy.mock.calls[0]?.[0]);
|
||||||
|
expect(calledUrl).toContain("/api/devices/dev-1/unregister");
|
||||||
|
expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ method: "POST" });
|
||||||
|
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
// 關鍵:device 仍在 list(與 unpair 的差異),只是 registeredAt 清 null
|
||||||
|
expect(state.devices.map((d) => d.id)).toEqual(["dev-1", "dev-2"]);
|
||||||
|
expect(state.devices.find((d) => d.id === "dev-1")?.registeredAt).toBeNull();
|
||||||
|
expect(state.selectedDevice?.registeredAt).toBeNull();
|
||||||
|
expect(state.registeringId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("冪等:已未註冊再 unregister(後端回 200 null)→ ok:true,list 保留", async () => {
|
||||||
|
useDeviceStore.setState({
|
||||||
|
devices: [{ ...registered, registeredAt: null }],
|
||||||
|
});
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse({ success: true, data: { id: "dev-1", registered_at: null } }),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await useDeviceStore.getState().unregisterDevice("dev-1");
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
expect(useDeviceStore.getState().devices.map((d) => d.id)).toEqual(["dev-1"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("403 FORBIDDEN → 回 { ok:false, code:'FORBIDDEN' },list 不變", async () => {
|
||||||
|
useDeviceStore.setState({ devices: [registered] });
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse(
|
||||||
|
{ success: false, error: { code: "FORBIDDEN", message: "not owner" } },
|
||||||
|
403,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await useDeviceStore.getState().unregisterDevice("dev-1");
|
||||||
|
expect(result).toMatchObject({ ok: false, code: "FORBIDDEN" });
|
||||||
|
// 失敗時 registeredAt 不變
|
||||||
|
expect(useDeviceStore.getState().devices[0]?.registeredAt).toBe(
|
||||||
|
"2026-08-02T10:00:00Z",
|
||||||
|
);
|
||||||
|
expect(useDeviceStore.getState().registeringId).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useDeviceStore.rescanDevices", () => {
|
||||||
|
it("成功時打對 scan endpoint(POST)、接著 fetchDevices refresh、回 { ok:true }", async () => {
|
||||||
|
const fetchSpy = vi
|
||||||
|
.spyOn(globalThis, "fetch")
|
||||||
|
// 1) POST /api/devices/scan
|
||||||
|
.mockResolvedValueOnce(jsonResponse({ success: true, data: {} }))
|
||||||
|
// 2) 後續 fetchDevices 的 GET /api/devices(回一台新插入的裝置)
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
id: "dev-new",
|
||||||
|
name: "KL630",
|
||||||
|
type: "kl630",
|
||||||
|
status: "detected",
|
||||||
|
remote_status: "online",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await useDeviceStore.getState().rescanDevices();
|
||||||
|
|
||||||
|
expect(result).toEqual({ ok: true });
|
||||||
|
// 第一發是 scan(POST),第二發是 fetchDevices(GET)。
|
||||||
|
expect(String(fetchSpy.mock.calls[0]?.[0])).toContain("/api/devices/scan");
|
||||||
|
expect(fetchSpy.mock.calls[0]?.[1]).toMatchObject({ method: "POST" });
|
||||||
|
expect(String(fetchSpy.mock.calls[1]?.[0])).toContain("/api/devices");
|
||||||
|
// 掃描後列表被 refresh,新裝置出現。
|
||||||
|
const state = useDeviceStore.getState();
|
||||||
|
expect(state.devices.map((d) => d.id)).toEqual(["dev-new"]);
|
||||||
|
expect(state.isRescanning).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("呼叫期間 isRescanning 設為 true(loading 態),完成後清回 false", async () => {
|
||||||
|
let seenDuringScan = false;
|
||||||
|
vi.spyOn(globalThis, "fetch").mockImplementation(async (input) => {
|
||||||
|
// 在 scan 請求進行中觀察 loading 態。
|
||||||
|
if (String(input).includes("/api/devices/scan")) {
|
||||||
|
seenDuringScan = useDeviceStore.getState().isRescanning;
|
||||||
|
}
|
||||||
|
return jsonResponse({ success: true, data: [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
await useDeviceStore.getState().rescanDevices();
|
||||||
|
|
||||||
|
expect(seenDuringScan).toBe(true);
|
||||||
|
expect(useDeviceStore.getState().isRescanning).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("裝置離線(502 TUNNEL_DISCONNECTED)→ 回 { ok:false, code:'TUNNEL_DISCONNECTED' },不 refresh、不清空列表", async () => {
|
||||||
|
useDeviceStore.setState({
|
||||||
|
devices: [
|
||||||
|
{
|
||||||
|
id: "dev-1",
|
||||||
|
name: "KL520",
|
||||||
|
type: "kl520",
|
||||||
|
status: "connected",
|
||||||
|
remoteStatus: "online",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: { code: "TUNNEL_DISCONNECTED", message: "agent offline" },
|
||||||
|
},
|
||||||
|
502,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await useDeviceStore.getState().rescanDevices();
|
||||||
|
|
||||||
|
expect(result).toMatchObject({ ok: false, code: "TUNNEL_DISCONNECTED" });
|
||||||
|
// 只打了 scan 一發,沒有接著 fetchDevices(失敗不 refresh)。
|
||||||
|
expect(fetchSpy).toHaveBeenCalledOnce();
|
||||||
|
// 既有列表保留(不清空)。
|
||||||
|
expect(useDeviceStore.getState().devices.map((d) => d.id)).toEqual(["dev-1"]);
|
||||||
|
expect(useDeviceStore.getState().isRescanning).toBe(false);
|
||||||
|
expect(useDeviceStore.getState().error).toBe("agent offline");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("其他錯誤(500)→ 回 { ok:false, code:'INTERNAL_ERROR' },isRescanning 清回 false", async () => {
|
||||||
|
vi.spyOn(globalThis, "fetch").mockResolvedValueOnce(
|
||||||
|
jsonResponse(
|
||||||
|
{ success: false, error: { code: "INTERNAL_ERROR", message: "boom" } },
|
||||||
|
500,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await useDeviceStore.getState().rescanDevices();
|
||||||
|
expect(result).toMatchObject({ ok: false, code: "INTERNAL_ERROR" });
|
||||||
|
expect(useDeviceStore.getState().isRescanning).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -89,6 +89,13 @@ export interface DeviceSummary {
|
|||||||
status: DeviceHardwareStatus;
|
status: DeviceHardwareStatus;
|
||||||
/** 遠端 tunnel 狀態(flow-offline-handling.md §2 新增) */
|
/** 遠端 tunnel 狀態(flow-offline-handling.md §2 新增) */
|
||||||
remoteStatus: RemoteStatus;
|
remoteStatus: RemoteStatus;
|
||||||
|
/**
|
||||||
|
* 註冊時間(ISO 8601)— 註冊軸的真實來源(feature-device-mgmt-tdd.md §5)。
|
||||||
|
* 後端 JSON key 為 `registered_at`(omitempty):null / 缺欄位 = 未註冊。
|
||||||
|
* 三態運算(lib/device-state.ts deriveTriState)以「online 且 registeredAt != null」
|
||||||
|
* 判為「已連接(已註冊在線)」;online 且 null → 第三態「已連接未註冊」。
|
||||||
|
*/
|
||||||
|
registeredAt?: string | null;
|
||||||
/** ISO 8601,最後心跳時間 */
|
/** ISO 8601,最後心跳時間 */
|
||||||
lastSeenAt?: string | null;
|
lastSeenAt?: string | null;
|
||||||
firmwareVersion?: string | null;
|
firmwareVersion?: string | null;
|
||||||
@ -152,6 +159,9 @@ function normalizeDevice(raw: unknown): Device {
|
|||||||
status: coerceHardwareStatus(pick<string>("status")),
|
status: coerceHardwareStatus(pick<string>("status")),
|
||||||
remoteStatus:
|
remoteStatus:
|
||||||
tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"),
|
tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"),
|
||||||
|
// 註冊軸(TDD §5.1):後端回 registered_at(omitempty)— 缺欄 / null 皆視為未註冊。
|
||||||
|
// 沿用既有 pick snake/camel 相容範式;不做時間格式驗證(後端保證 ISO 8601)。
|
||||||
|
registeredAt: pick<string>("registered_at", "registeredAt") ?? null,
|
||||||
lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null,
|
lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null,
|
||||||
firmwareVersion:
|
firmwareVersion:
|
||||||
pick<string>("firmware_version", "firmwareVersion") ?? null,
|
pick<string>("firmware_version", "firmwareVersion") ?? null,
|
||||||
@ -179,6 +189,33 @@ export type UnpairResult =
|
|||||||
| { ok: true }
|
| { ok: true }
|
||||||
| { ok: false; code: string; message: string };
|
| { ok: false; code: string; message: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* register / unregister action 的回傳。
|
||||||
|
*
|
||||||
|
* 與 unpair 同樣採「回 code 而非 boolean」的範式,讓 UI 對不同錯誤分流顯示 toast:
|
||||||
|
* - register:409 `ALREADY_REGISTERED`(已註冊)、409 `REPRESENTATIVE_DEVICE`(representative)、
|
||||||
|
* 403 `FORBIDDEN`(非 owner)、404 `NOT_FOUND` 等(api-device-mgmt.md §3)。
|
||||||
|
* - unregister:契約上冪等(已未註冊回 200),主要錯誤為 403 / 404 / representative(REPRESENTATIVE_DEVICE)。
|
||||||
|
*
|
||||||
|
* 成功時 store 已就地更新該筆 device 的 registeredAt(避免 refetch 延遲,比照 unpair 就地移除範式)。
|
||||||
|
*/
|
||||||
|
export type RegisterResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; code: string; message: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* rescan(重新掃描 USB 裝置)action 的回傳。
|
||||||
|
*
|
||||||
|
* 採「回 code 而非 boolean」的範式(比照 unpair / register),讓 UI 對不同錯誤分流顯示 toast:
|
||||||
|
* - scan 是 tunnel proxy 操作(雲端轉發 local agent 的 ScanDevices → Rescan);
|
||||||
|
* 裝置離線(無 online tunnel)時後端回 502 `TUNNEL_DISCONNECTED`,UI 需針對此顯示
|
||||||
|
* 「請確認 local agent 在線」而非泛用錯誤,故帶 code 出來。
|
||||||
|
* 成功後由呼叫端接著 fetchDevices() refresh 列表(新插入的裝置就會出現)。
|
||||||
|
*/
|
||||||
|
export type RescanResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; code: string; message: string };
|
||||||
|
|
||||||
interface DeviceState {
|
interface DeviceState {
|
||||||
devices: DeviceSummary[];
|
devices: DeviceSummary[];
|
||||||
selectedDevice: Device | null;
|
selectedDevice: Device | null;
|
||||||
@ -188,6 +225,10 @@ interface DeviceState {
|
|||||||
disconnectingId: string | null;
|
disconnectingId: string | null;
|
||||||
/** 移除(unpair)中的裝置 id(UI 顯示 button spinner / disable 確認鈕);不使用就是 null */
|
/** 移除(unpair)中的裝置 id(UI 顯示 button spinner / disable 確認鈕);不使用就是 null */
|
||||||
unpairingId: string | null;
|
unpairingId: string | null;
|
||||||
|
/** 註冊 / 取消註冊進行中的裝置 id(UI 顯示 button spinner);不使用就是 null */
|
||||||
|
registeringId: string | null;
|
||||||
|
/** 重新掃描 USB 裝置進行中(UI 顯示按鈕 spinner + disable);不使用就是 false */
|
||||||
|
isRescanning: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
|
||||||
/** 呼叫 `GET /api/devices` */
|
/** 呼叫 `GET /api/devices` */
|
||||||
@ -207,19 +248,44 @@ interface DeviceState {
|
|||||||
disconnectDevice: (serialNumber: string) => Promise<boolean>;
|
disconnectDevice: (serialNumber: string) => Promise<boolean>;
|
||||||
/** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token) */
|
/** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token) */
|
||||||
unpairDevice: (id: string) => Promise<UnpairResult>;
|
unpairDevice: (id: string) => Promise<UnpairResult>;
|
||||||
|
/**
|
||||||
|
* 呼叫 `POST /api/devices/:id/register`(UUID 識別,純雲端 DB 操作)。
|
||||||
|
* 把裝置由「未註冊」翻成「已註冊」(registered_at NULL → now())。
|
||||||
|
* 成功後就地更新該筆 registeredAt(避免 refetch 延遲)。
|
||||||
|
* ⚠️ 與 connect 不同(connect 用 serial 路由);register 用 UUID(ADR-018 FE-A:DB 操作用 UUID)。
|
||||||
|
*/
|
||||||
|
registerDevice: (id: string) => Promise<RegisterResult>;
|
||||||
|
/**
|
||||||
|
* 呼叫 `POST /api/devices/:id/unregister`(UUID 識別)。
|
||||||
|
* 把裝置退回「未註冊」(registered_at → NULL),**保留裝置列**(不軟刪、不撤 token)。
|
||||||
|
* ⚠️ 取消註冊 ≠ 移除裝置(unpair):unregister 只清 registeredAt、device 仍在清單顯示為未註冊。
|
||||||
|
*/
|
||||||
|
unregisterDevice: (id: string) => Promise<RegisterResult>;
|
||||||
|
/**
|
||||||
|
* 呼叫 `POST /api/devices/scan`(雲端 proxy 轉發 local agent 的 ScanDevices → Rescan,
|
||||||
|
* 重新偵測 USB:含新插入 / 拔除)。成功後自動接 fetchDevices() 刷新列表,
|
||||||
|
* 讓剛插入的新裝置立即出現(不必重啟 local agent)。
|
||||||
|
*
|
||||||
|
* ⚠️ scan 是 tunnel proxy 操作:無 online tunnel(裝置離線)時後端回 502
|
||||||
|
* `TUNNEL_DISCONNECTED`,回 { ok:false, code:"TUNNEL_DISCONNECTED" } 讓 UI 分流提示。
|
||||||
|
* 失敗時不呼叫 fetchDevices(維持既有列表,不清空)。
|
||||||
|
*/
|
||||||
|
rescanDevices: () => Promise<RescanResult>;
|
||||||
/** 測試 / 雛形用:直接塞 list */
|
/** 測試 / 雛形用:直接塞 list */
|
||||||
_setDevices: (devices: DeviceSummary[]) => void;
|
_setDevices: (devices: DeviceSummary[]) => void;
|
||||||
/** 測試 / 雛形用:直接塞 selected */
|
/** 測試 / 雛形用:直接塞 selected */
|
||||||
_setSelected: (device: Device | null) => void;
|
_setSelected: (device: Device | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useDeviceStore = create<DeviceState>()((set) => ({
|
export const useDeviceStore = create<DeviceState>()((set, get) => ({
|
||||||
devices: [],
|
devices: [],
|
||||||
selectedDevice: null,
|
selectedDevice: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
connectingId: null,
|
connectingId: null,
|
||||||
disconnectingId: null,
|
disconnectingId: null,
|
||||||
unpairingId: null,
|
unpairingId: null,
|
||||||
|
registeringId: null,
|
||||||
|
isRescanning: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
|
||||||
fetchDevices: async () => {
|
fetchDevices: async () => {
|
||||||
@ -317,6 +383,87 @@ export const useDeviceStore = create<DeviceState>()((set) => ({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
registerDevice: async (id) => {
|
||||||
|
set({ registeringId: id, error: null });
|
||||||
|
try {
|
||||||
|
// 契約:POST /api/devices/:id/register → 回更新後的 DeviceListItem(registered_at 非 null)。
|
||||||
|
// api.post 已 unwrap envelope 的 data;normalizeDevice 讀出 registeredAt。
|
||||||
|
const raw = await api.post<unknown>(
|
||||||
|
`/api/devices/${encodeURIComponent(id)}/register`,
|
||||||
|
);
|
||||||
|
const updated = normalizeDevice(raw);
|
||||||
|
// 就地更新該筆 registeredAt(避免 refetch 延遲,比照 unpair 就地移除範式)。
|
||||||
|
// 後端回應可能缺部分欄位(omitempty)→ 只 merge registeredAt,其餘沿用本地既有值,
|
||||||
|
// 避免把本地已知欄位(如 firmwareVersion)覆寫成 null。
|
||||||
|
set((state) => ({
|
||||||
|
devices: state.devices.map((d) =>
|
||||||
|
d.id === id ? { ...d, registeredAt: updated.registeredAt } : d,
|
||||||
|
),
|
||||||
|
selectedDevice:
|
||||||
|
state.selectedDevice?.id === id
|
||||||
|
? { ...state.selectedDevice, registeredAt: updated.registeredAt }
|
||||||
|
: state.selectedDevice,
|
||||||
|
registeringId: null,
|
||||||
|
}));
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
const code = err instanceof ApiError ? err.code : "unknown";
|
||||||
|
set({ registeringId: null, error: message });
|
||||||
|
return { ok: false, code, message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
unregisterDevice: async (id) => {
|
||||||
|
set({ registeringId: id, error: null });
|
||||||
|
try {
|
||||||
|
// 契約:POST /api/devices/:id/unregister → registered_at → null,**保留裝置列**(不軟刪)。
|
||||||
|
// 冪等:已未註冊也回 200。回更新後 DeviceListItem(registered_at=null)。
|
||||||
|
await api.post<unknown>(
|
||||||
|
`/api/devices/${encodeURIComponent(id)}/unregister`,
|
||||||
|
);
|
||||||
|
// 就地把該筆 registeredAt 清成 null(device 仍留在 list,不移除——與 unpair 的關鍵差異)。
|
||||||
|
set((state) => ({
|
||||||
|
devices: state.devices.map((d) =>
|
||||||
|
d.id === id ? { ...d, registeredAt: null } : d,
|
||||||
|
),
|
||||||
|
selectedDevice:
|
||||||
|
state.selectedDevice?.id === id
|
||||||
|
? { ...state.selectedDevice, registeredAt: null }
|
||||||
|
: state.selectedDevice,
|
||||||
|
registeringId: null,
|
||||||
|
}));
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
const code = err instanceof ApiError ? err.code : "unknown";
|
||||||
|
set({ registeringId: null, error: message });
|
||||||
|
return { ok: false, code, message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
rescanDevices: async () => {
|
||||||
|
set({ isRescanning: true, error: null });
|
||||||
|
try {
|
||||||
|
// POST /api/devices/scan:雲端 proxy 透傳 local agent 的 ScanDevices → Rescan。
|
||||||
|
// 回傳的 scan 結果本身不需要(新裝置由後續 fetchDevices 從 DB / tunnel 帶出),
|
||||||
|
// 成功與否由有無 throw 判定(比照 unpair 範式)。
|
||||||
|
await api.post("/api/devices/scan");
|
||||||
|
set({ isRescanning: false });
|
||||||
|
// 掃描完成後 refresh 列表——剛插入的新裝置就會出現。
|
||||||
|
// 這裡 await 讓 UI 的 loading 能延續到列表更新完(fetchDevices 自行管理 isLoading)。
|
||||||
|
await get().fetchDevices();
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
// ApiError 帶 backend code(TUNNEL_DISCONNECTED / INTERNAL_ERROR / …)給 UI 分流 toast;
|
||||||
|
// 其他例外(網路層)退化成 unknown。失敗時不呼叫 fetchDevices(維持既有列表)。
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
const code = err instanceof ApiError ? err.code : "unknown";
|
||||||
|
set({ isRescanning: false, error: message });
|
||||||
|
return { ok: false, code, message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
_setDevices: (devices) => set({ devices }),
|
_setDevices: (devices) => set({ devices }),
|
||||||
_setSelected: (selectedDevice) => set({ selectedDevice }),
|
_setSelected: (selectedDevice) => set({ selectedDevice }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
183
visionA-frontend/src/stores/model-sharing-store.test.ts
Normal file
183
visionA-frontend/src/stores/model-sharing-store.test.ts
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* Model Sharing Store 測試(mock 模式,deterministic)
|
||||||
|
*
|
||||||
|
* 覆蓋:
|
||||||
|
* - loadFirstPage:載入首頁、設 cursor / hasMore
|
||||||
|
* - loadMore:append 下一頁、不重複、到底 hasMore=false
|
||||||
|
* - loadMore 重入防護:無 cursor / 載入中不觸發
|
||||||
|
* - setFilters:重置分頁並重新載入
|
||||||
|
* - loadProfile:mock 命中 / 404
|
||||||
|
* - updateVisibility / addShare / removeShare(樂觀更新)
|
||||||
|
* - filtersToQuery:UI filters → API query 映射
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_LIBRARY_FILTERS,
|
||||||
|
filtersToQuery,
|
||||||
|
LIBRARY_PAGE_SIZE,
|
||||||
|
useModelSharingStore,
|
||||||
|
} from "./model-sharing-store";
|
||||||
|
|
||||||
|
function resetStore() {
|
||||||
|
useModelSharingStore.setState({
|
||||||
|
items: [],
|
||||||
|
filters: { ...DEFAULT_LIBRARY_FILTERS },
|
||||||
|
cursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadingMore: false,
|
||||||
|
listError: null,
|
||||||
|
profile: null,
|
||||||
|
isProfileLoading: false,
|
||||||
|
profileError: null,
|
||||||
|
shares: [],
|
||||||
|
isSharesLoading: false,
|
||||||
|
_mockMode: true, // 測試一律走 mock,不打真實 API
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
resetStore();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadFirstPage", () => {
|
||||||
|
it("載入首頁 → items = PAGE_SIZE、hasMore=true、cursor 非空", async () => {
|
||||||
|
await useModelSharingStore.getState().loadFirstPage();
|
||||||
|
const s = useModelSharingStore.getState();
|
||||||
|
expect(s.items).toHaveLength(LIBRARY_PAGE_SIZE);
|
||||||
|
expect(s.hasMore).toBe(true);
|
||||||
|
expect(s.cursor).not.toBeNull();
|
||||||
|
expect(s.isLoading).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadMore — cursor 無限捲動", () => {
|
||||||
|
it("續載 → append 下一頁且不與首頁重複", async () => {
|
||||||
|
const store = useModelSharingStore.getState();
|
||||||
|
await store.loadFirstPage();
|
||||||
|
const firstIds = useModelSharingStore.getState().items.map((m) => m.id);
|
||||||
|
|
||||||
|
await useModelSharingStore.getState().loadMore();
|
||||||
|
const all = useModelSharingStore.getState().items;
|
||||||
|
|
||||||
|
// 續載後總數 > 首頁
|
||||||
|
expect(all.length).toBeGreaterThan(firstIds.length);
|
||||||
|
// 無重複 id
|
||||||
|
expect(new Set(all.map((m) => m.id)).size).toBe(all.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("一路 loadMore 到底 → hasMore=false、涵蓋全部 30 筆", async () => {
|
||||||
|
await useModelSharingStore.getState().loadFirstPage();
|
||||||
|
let guard = 0;
|
||||||
|
while (useModelSharingStore.getState().hasMore) {
|
||||||
|
await useModelSharingStore.getState().loadMore();
|
||||||
|
if (++guard > 10) throw new Error("loadMore 未收斂");
|
||||||
|
}
|
||||||
|
const s = useModelSharingStore.getState();
|
||||||
|
expect(s.hasMore).toBe(false);
|
||||||
|
expect(s.items).toHaveLength(30); // mock fixtures 共 30 筆
|
||||||
|
});
|
||||||
|
|
||||||
|
it("重入防護:無 cursor(未載入首頁)→ loadMore 不改變 items", async () => {
|
||||||
|
await useModelSharingStore.getState().loadMore();
|
||||||
|
expect(useModelSharingStore.getState().items).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("setFilters", () => {
|
||||||
|
it("設 owned=mine → 重置分頁並只留我的模型", async () => {
|
||||||
|
await useModelSharingStore.getState().loadFirstPage();
|
||||||
|
useModelSharingStore.getState().setFilters({ owned: "mine" });
|
||||||
|
// setFilters 內部呼叫 loadFirstPage(async);等 microtask
|
||||||
|
await Promise.resolve();
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
const s = useModelSharingStore.getState();
|
||||||
|
expect(s.filters.owned).toBe("mine");
|
||||||
|
expect(s.items.every((m) => m.owner.isMe)).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("loadProfile", () => {
|
||||||
|
it("命中 mock id → 設 profile", async () => {
|
||||||
|
await useModelSharingStore.getState().loadProfile("mock-model-01");
|
||||||
|
const s = useModelSharingStore.getState();
|
||||||
|
expect(s.profile?.id).toBe("mock-model-01");
|
||||||
|
expect(s.profileError).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("不存在 id → profileError=not_found(模擬 404 防 enumeration)", async () => {
|
||||||
|
await useModelSharingStore.getState().loadProfile("no-such-model");
|
||||||
|
const s = useModelSharingStore.getState();
|
||||||
|
expect(s.profile).toBeNull();
|
||||||
|
expect(s.profileError).toBe("not_found");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("公開設定樂觀更新", () => {
|
||||||
|
it("updateVisibility → 更新 items 中對應項與 profile", async () => {
|
||||||
|
await useModelSharingStore.getState().loadProfile("mock-model-01");
|
||||||
|
const result = await useModelSharingStore
|
||||||
|
.getState()
|
||||||
|
.updateVisibility("mock-model-01", "public");
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
expect(useModelSharingStore.getState().profile?.visibility).toBe("public");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("addShare → 加入 shares;removeShare → 移除", async () => {
|
||||||
|
await useModelSharingStore.getState().loadShares("mock-model-01");
|
||||||
|
const before = useModelSharingStore.getState().shares.length;
|
||||||
|
|
||||||
|
const add = await useModelSharingStore
|
||||||
|
.getState()
|
||||||
|
.addShare("mock-model-01", "new@corp.com");
|
||||||
|
expect(add.ok).toBe(true);
|
||||||
|
expect(useModelSharingStore.getState().shares).toHaveLength(before + 1);
|
||||||
|
|
||||||
|
const added = useModelSharingStore
|
||||||
|
.getState()
|
||||||
|
.shares.find((s) => s.email === "new@corp.com");
|
||||||
|
const remove = await useModelSharingStore
|
||||||
|
.getState()
|
||||||
|
.removeShare("mock-model-01", added!.userId);
|
||||||
|
expect(remove.ok).toBe(true);
|
||||||
|
expect(useModelSharingStore.getState().shares).toHaveLength(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("filtersToQuery", () => {
|
||||||
|
it("all / 空值省略;mine → owned=true", () => {
|
||||||
|
const q = filtersToQuery({
|
||||||
|
...DEFAULT_LIBRARY_FILTERS,
|
||||||
|
owned: "mine",
|
||||||
|
q: " hello ",
|
||||||
|
});
|
||||||
|
expect(q.owned).toBe(true);
|
||||||
|
expect(q.q).toBe("hello"); // trim
|
||||||
|
expect(q.targetChip).toBeUndefined(); // all → 省略
|
||||||
|
expect(q.visibility).toBeUndefined();
|
||||||
|
expect(q.limit).toBe(LIBRARY_PAGE_SIZE);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shared → owned=false;具體 filter 帶上", () => {
|
||||||
|
const q = filtersToQuery({
|
||||||
|
q: "",
|
||||||
|
targetChip: "kl720",
|
||||||
|
visibility: "public",
|
||||||
|
owned: "shared",
|
||||||
|
sort: "name",
|
||||||
|
order: "asc",
|
||||||
|
});
|
||||||
|
expect(q.owned).toBe(false);
|
||||||
|
expect(q.targetChip).toBe("kl720");
|
||||||
|
expect(q.visibility).toBe("public");
|
||||||
|
expect(q.sort).toBe("name");
|
||||||
|
expect(q.order).toBe("asc");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("帶 cursor → query 含 cursor", () => {
|
||||||
|
const q = filtersToQuery(DEFAULT_LIBRARY_FILTERS, "CURSOR123");
|
||||||
|
expect(q.cursor).toBe("CURSOR123");
|
||||||
|
});
|
||||||
|
});
|
||||||
366
visionA-frontend/src/stores/model-sharing-store.ts
Normal file
366
visionA-frontend/src/stores/model-sharing-store.ts
Normal file
@ -0,0 +1,366 @@
|
|||||||
|
/**
|
||||||
|
* Model Sharing Store — visionA Cloud(模型共享 L 級新功能)
|
||||||
|
*
|
||||||
|
* 管理三塊狀態:
|
||||||
|
* 1. 共享模型庫列表(cursor 無限捲動分頁 + 搜尋 / filter / 排序)
|
||||||
|
* 2. 模型 profile(公開版詳情,依身份雙態)
|
||||||
|
* 3. 公開設定 Dialog(visibility + shares 授權清單)
|
||||||
|
*
|
||||||
|
* 對齊契約 `api-model-sharing.md`。API 層在 `lib/api/model-sharing.ts`。
|
||||||
|
*
|
||||||
|
* ## 平行開發 mock 模式
|
||||||
|
* `NEXT_PUBLIC_USE_MODEL_SHARING_MOCK=1`(或測試以 `_setMockMode(true)`)時,
|
||||||
|
* 走 `model-sharing.mock.ts` 的 fixtures,不打真實 API。契約 response 形狀一致,
|
||||||
|
* 後端就緒後移除 flag 即可切換,UI / normalize 邏輯不變。
|
||||||
|
*/
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
import {
|
||||||
|
addShare as apiAddShare,
|
||||||
|
fetchLibrary as apiFetchLibrary,
|
||||||
|
fetchProfile as apiFetchProfile,
|
||||||
|
fetchShares as apiFetchShares,
|
||||||
|
removeShare as apiRemoveShare,
|
||||||
|
updateVisibility as apiUpdateVisibility,
|
||||||
|
normalizeLibraryPage,
|
||||||
|
normalizeProfile,
|
||||||
|
ModelSharingError,
|
||||||
|
type LibraryModel,
|
||||||
|
type LibraryQuery,
|
||||||
|
type LibrarySort,
|
||||||
|
type ModelProfile,
|
||||||
|
type ModelShare,
|
||||||
|
type ModelVisibility,
|
||||||
|
type SortOrder,
|
||||||
|
} from "@/lib/api/model-sharing";
|
||||||
|
import {
|
||||||
|
mockLibraryPage,
|
||||||
|
mockProfile,
|
||||||
|
MOCK_SHARES,
|
||||||
|
} from "@/lib/api/model-sharing.mock";
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Mock 模式判定 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function envMockMode(): boolean {
|
||||||
|
return (
|
||||||
|
typeof process !== "undefined" &&
|
||||||
|
process.env?.NEXT_PUBLIC_USE_MODEL_SHARING_MOCK === "1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* 列表篩選 / 排序狀態 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** 共享庫的可見性 filter(UI 用;all = 不過濾)。 */
|
||||||
|
export type LibraryVisibilityFilter = "all" | "public" | "tenant";
|
||||||
|
|
||||||
|
/** 擁有關係 filter(UI 用;all = 全部可見)。 */
|
||||||
|
export type LibraryOwnedFilter = "all" | "mine" | "shared";
|
||||||
|
|
||||||
|
export interface LibraryFilters {
|
||||||
|
q: string;
|
||||||
|
targetChip: "all" | "kl520" | "kl720" | "kl630" | "kl730";
|
||||||
|
visibility: LibraryVisibilityFilter;
|
||||||
|
owned: LibraryOwnedFilter;
|
||||||
|
sort: LibrarySort;
|
||||||
|
order: SortOrder;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_LIBRARY_FILTERS: LibraryFilters = {
|
||||||
|
q: "",
|
||||||
|
targetChip: "all",
|
||||||
|
visibility: "all",
|
||||||
|
owned: "all",
|
||||||
|
sort: "created_at",
|
||||||
|
order: "desc",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** 每頁筆數(對齊設計規格 §4.6:desktop 3 欄 × 8 列)。 */
|
||||||
|
export const LIBRARY_PAGE_SIZE = 24;
|
||||||
|
|
||||||
|
/** 把 UI filters 轉成 API query(省略 all / 空值)。 */
|
||||||
|
export function filtersToQuery(
|
||||||
|
filters: LibraryFilters,
|
||||||
|
cursor?: string,
|
||||||
|
): LibraryQuery {
|
||||||
|
const query: LibraryQuery = {
|
||||||
|
limit: LIBRARY_PAGE_SIZE,
|
||||||
|
sort: filters.sort,
|
||||||
|
order: filters.order,
|
||||||
|
};
|
||||||
|
if (cursor) query.cursor = cursor;
|
||||||
|
if (filters.q.trim()) query.q = filters.q.trim();
|
||||||
|
if (filters.targetChip !== "all") query.targetChip = filters.targetChip;
|
||||||
|
if (filters.visibility !== "all") query.visibility = filters.visibility;
|
||||||
|
if (filters.owned === "mine") query.owned = true;
|
||||||
|
else if (filters.owned === "shared") query.owned = false;
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Store 型別 */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/** 分享對象操作的結果(帶 i18n code 給 UI 顯示)。 */
|
||||||
|
export type ShareOpResult =
|
||||||
|
| { ok: true }
|
||||||
|
| { ok: false; code: string; message: string };
|
||||||
|
|
||||||
|
interface ModelSharingState {
|
||||||
|
/* ── 共享庫列表 ── */
|
||||||
|
items: LibraryModel[];
|
||||||
|
filters: LibraryFilters;
|
||||||
|
cursor: string | null;
|
||||||
|
hasMore: boolean;
|
||||||
|
/** 首屏 / filter 變更後的整體載入。 */
|
||||||
|
isLoading: boolean;
|
||||||
|
/** 「載入更多」(cursor 續載)中。 */
|
||||||
|
isLoadingMore: boolean;
|
||||||
|
/** 列表載入錯誤(i18n code);null = 無錯誤。 */
|
||||||
|
listError: string | null;
|
||||||
|
|
||||||
|
/* ── profile ── */
|
||||||
|
profile: ModelProfile | null;
|
||||||
|
isProfileLoading: boolean;
|
||||||
|
/** profile 錯誤 code(如 not_found → 無權限 / 找不到)。 */
|
||||||
|
profileError: string | null;
|
||||||
|
|
||||||
|
/* ── 公開設定(shares) ── */
|
||||||
|
shares: ModelShare[];
|
||||||
|
isSharesLoading: boolean;
|
||||||
|
|
||||||
|
/* ── actions ── */
|
||||||
|
/** 設定 filters(會重置分頁並重新載入首頁)。 */
|
||||||
|
setFilters: (patch: Partial<LibraryFilters>) => void;
|
||||||
|
/** 載入首頁(reset 已載入項 + cursor)。 */
|
||||||
|
loadFirstPage: () => Promise<void>;
|
||||||
|
/** cursor 續載下一頁(append)。 */
|
||||||
|
loadMore: () => Promise<void>;
|
||||||
|
|
||||||
|
/** 載入 profile。 */
|
||||||
|
loadProfile: (id: string) => Promise<void>;
|
||||||
|
clearProfile: () => void;
|
||||||
|
|
||||||
|
/** 載入授權清單。 */
|
||||||
|
loadShares: (id: string) => Promise<void>;
|
||||||
|
/** 更新可見性。 */
|
||||||
|
updateVisibility: (
|
||||||
|
id: string,
|
||||||
|
visibility: ModelVisibility,
|
||||||
|
) => Promise<ShareOpResult>;
|
||||||
|
/** 新增授權對象。 */
|
||||||
|
addShare: (id: string, email: string) => Promise<ShareOpResult>;
|
||||||
|
/** 移除授權對象。 */
|
||||||
|
removeShare: (id: string, userId: string) => Promise<ShareOpResult>;
|
||||||
|
|
||||||
|
/* ── 測試 / mock ── */
|
||||||
|
_mockMode: boolean;
|
||||||
|
_setMockMode: (on: boolean) => void;
|
||||||
|
_setItems: (items: LibraryModel[]) => void;
|
||||||
|
_setProfile: (p: ModelProfile | null) => void;
|
||||||
|
_setShares: (s: ModelShare[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Mock 分頁 / profile / shares(走 fixtures) */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
function mockFetchLibrary(query: LibraryQuery) {
|
||||||
|
const raw = mockLibraryPage({
|
||||||
|
cursor: query.cursor,
|
||||||
|
limit: query.limit,
|
||||||
|
q: query.q,
|
||||||
|
targetChip: query.targetChip,
|
||||||
|
source: query.source,
|
||||||
|
visibility: query.visibility,
|
||||||
|
owned: query.owned,
|
||||||
|
sort: query.sort,
|
||||||
|
order: query.order,
|
||||||
|
});
|
||||||
|
return normalizeLibraryPage(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetchProfile(id: string): ModelProfile {
|
||||||
|
const raw = mockProfile(id);
|
||||||
|
if (!raw) {
|
||||||
|
throw new ModelSharingError(404, "not_found", "model not found");
|
||||||
|
}
|
||||||
|
return normalizeProfile(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mockFetchShares(id: string): ModelShare[] {
|
||||||
|
const list = MOCK_SHARES[id] ?? [];
|
||||||
|
return list.map((s) => ({
|
||||||
|
userId: s.user_id,
|
||||||
|
email: s.email,
|
||||||
|
role: s.role === "editor" ? "editor" : "viewer",
|
||||||
|
createdAt: s.created_at,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Store */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
export const useModelSharingStore = create<ModelSharingState>()((set, get) => ({
|
||||||
|
items: [],
|
||||||
|
filters: { ...DEFAULT_LIBRARY_FILTERS },
|
||||||
|
cursor: null,
|
||||||
|
hasMore: false,
|
||||||
|
isLoading: false,
|
||||||
|
isLoadingMore: false,
|
||||||
|
listError: null,
|
||||||
|
|
||||||
|
profile: null,
|
||||||
|
isProfileLoading: false,
|
||||||
|
profileError: null,
|
||||||
|
|
||||||
|
shares: [],
|
||||||
|
isSharesLoading: false,
|
||||||
|
|
||||||
|
_mockMode: envMockMode(),
|
||||||
|
|
||||||
|
setFilters: (patch) => {
|
||||||
|
set((state) => ({ filters: { ...state.filters, ...patch } }));
|
||||||
|
void get().loadFirstPage();
|
||||||
|
},
|
||||||
|
|
||||||
|
loadFirstPage: async () => {
|
||||||
|
const { filters, _mockMode } = get();
|
||||||
|
set({ isLoading: true, listError: null, items: [], cursor: null, hasMore: false });
|
||||||
|
try {
|
||||||
|
const query = filtersToQuery(filters);
|
||||||
|
const page = _mockMode ? mockFetchLibrary(query) : await apiFetchLibrary(query);
|
||||||
|
set({
|
||||||
|
items: page.items,
|
||||||
|
cursor: page.nextCursor,
|
||||||
|
hasMore: page.hasMore,
|
||||||
|
isLoading: false,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ModelSharingError ? err.code : "unknown";
|
||||||
|
set({ isLoading: false, listError: code });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadMore: async () => {
|
||||||
|
const { filters, cursor, hasMore, isLoadingMore, isLoading, _mockMode } = get();
|
||||||
|
// 防呆:無下一頁 / 正在載入時不重複觸發(無限捲動 observer 可能連續觸發)。
|
||||||
|
if (!hasMore || !cursor || isLoadingMore || isLoading) return;
|
||||||
|
set({ isLoadingMore: true, listError: null });
|
||||||
|
try {
|
||||||
|
const query = filtersToQuery(filters, cursor);
|
||||||
|
const page = _mockMode ? mockFetchLibrary(query) : await apiFetchLibrary(query);
|
||||||
|
set((state) => ({
|
||||||
|
items: [...state.items, ...page.items],
|
||||||
|
cursor: page.nextCursor,
|
||||||
|
hasMore: page.hasMore,
|
||||||
|
isLoadingMore: false,
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ModelSharingError ? err.code : "unknown";
|
||||||
|
set({ isLoadingMore: false, listError: code });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
loadProfile: async (id) => {
|
||||||
|
const { _mockMode } = get();
|
||||||
|
set({ isProfileLoading: true, profileError: null, profile: null });
|
||||||
|
try {
|
||||||
|
const profile = _mockMode ? mockFetchProfile(id) : await apiFetchProfile(id);
|
||||||
|
set({ profile, isProfileLoading: false });
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ModelSharingError ? err.code : "unknown";
|
||||||
|
set({ isProfileLoading: false, profileError: code });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearProfile: () => set({ profile: null, profileError: null }),
|
||||||
|
|
||||||
|
loadShares: async (id) => {
|
||||||
|
const { _mockMode } = get();
|
||||||
|
set({ isSharesLoading: true });
|
||||||
|
try {
|
||||||
|
const shares = _mockMode ? mockFetchShares(id) : await apiFetchShares(id);
|
||||||
|
set({ shares, isSharesLoading: false });
|
||||||
|
} catch {
|
||||||
|
// 載入授權清單失敗時清空 + 停止 loading;Dialog UI 顯示空清單,操作仍可重試。
|
||||||
|
set({ shares: [], isSharesLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updateVisibility: async (id, visibility) => {
|
||||||
|
const { _mockMode } = get();
|
||||||
|
try {
|
||||||
|
if (!_mockMode) {
|
||||||
|
await apiUpdateVisibility(id, visibility);
|
||||||
|
}
|
||||||
|
// 樂觀更新 profile 與列表中對應項的 visibility。
|
||||||
|
set((state) => ({
|
||||||
|
profile:
|
||||||
|
state.profile?.id === id
|
||||||
|
? { ...state.profile, visibility }
|
||||||
|
: state.profile,
|
||||||
|
items: state.items.map((m) =>
|
||||||
|
m.id === id ? { ...m, visibility } : m,
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ModelSharingError ? err.code : "unknown";
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { ok: false, code, message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
addShare: async (id, email) => {
|
||||||
|
const { _mockMode } = get();
|
||||||
|
try {
|
||||||
|
let newShare: ModelShare;
|
||||||
|
if (_mockMode) {
|
||||||
|
newShare = {
|
||||||
|
userId: `u-${email}`,
|
||||||
|
email,
|
||||||
|
role: "viewer",
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
newShare = await apiAddShare(id, email);
|
||||||
|
}
|
||||||
|
set((state) => ({ shares: [...state.shares, newShare] }));
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ModelSharingError ? err.code : "unknown";
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { ok: false, code, message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
removeShare: async (id, userId) => {
|
||||||
|
const { _mockMode } = get();
|
||||||
|
try {
|
||||||
|
if (!_mockMode) {
|
||||||
|
await apiRemoveShare(id, userId);
|
||||||
|
}
|
||||||
|
set((state) => ({
|
||||||
|
shares: state.shares.filter((s) => s.userId !== userId),
|
||||||
|
}));
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
const code = err instanceof ModelSharingError ? err.code : "unknown";
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
return { ok: false, code, message };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
_setMockMode: (on) => set({ _mockMode: on }),
|
||||||
|
_setItems: (items) => set({ items }),
|
||||||
|
_setProfile: (profile) => set({ profile }),
|
||||||
|
_setShares: (shares) => set({ shares }),
|
||||||
|
}));
|
||||||
@ -36,6 +36,8 @@ export type KnownErrorCode =
|
|||||||
| "FORBIDDEN"
|
| "FORBIDDEN"
|
||||||
| "NOT_FOUND"
|
| "NOT_FOUND"
|
||||||
| "VALIDATION_FAILED"
|
| "VALIDATION_FAILED"
|
||||||
|
| "REPRESENTATIVE_DEVICE" // 409:representative device 不可 register/unregister(api-device-mgmt.md §3,backend 實際回碼)
|
||||||
|
| "ALREADY_REGISTERED" // 409:register 時裝置已註冊(api-device-mgmt.md §3,本功能新增)
|
||||||
| "TUNNEL_DISCONNECTED"
|
| "TUNNEL_DISCONNECTED"
|
||||||
| "TUNNEL_ERROR"
|
| "TUNNEL_ERROR"
|
||||||
| "NOT_IMPLEMENTED"
|
| "NOT_IMPLEMENTED"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user