Compare commits

..

14 Commits

Author SHA1 Message Date
b35b989d9d docs(design): 裝置使用流程 UX 重新設計(連接電腦→啟用裝置→載入模型→推論)
解使用者實測 4 問題:從未連線誤導、兩層狀態命名混淆(動詞不重複三層:連接
電腦/啟用裝置/載入模型/推論)、模型狀態四態、載入後推論引導。全純前端、落地
拆 3 批。待補:多電腦分組(design 進行中)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-05 00:27:29 +08:00
8270c4fb0e fix(local-agent): AutoOpenBrowser 預設改 false,啟動不自動開瀏覽器
使用者:local-agent 啟動不需自動開網頁、桌面 GUI 為主。DefaultPreferences
的 AutoOpenBrowser 從 runtime.GOOS!="linux"(mac/win=true)改為所有平台 false。
只影響全新安裝(既有 preferences.json 有明確 autoOpenBrowser 值、Unmarshal
保留,尊重既有設定)。false 分支既有邏輯(SkipStage5 / 不檢查 timeout)未動。
codebase 本就無桌面「Open in Browser」按鈕(DEPRECATED、雲端 UI 遠端設計),
改預設沒弄丟任何手動能力。

reviewer 通過(0C/0M)。build/vet/test 全綠。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 12:35:10 +08:00
df082ed308 docs(design): 配對→連接電腦 用詞 mapping 表
41 key 舊→新對照(zh+en)、心智模型(連接電腦 vs 連線裝置)、避撞規則、
保留項清單。frontend 照此落地。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 09:41:18 +08:00
630b8a2d6e feat(frontend): 裝置頁重新掃描按鈕 + 配對→「連接這台電腦」用詞
1. 重新掃描裝置按鈕:打現成 POST /api/devices/scan(proxy→local-agent
   Rescan)→ fetchDevices refresh。插新 USB 不用重啟 agent。離線 gate +
   loading + 失敗不清空列表。

2. 配對→連接電腦 全站用詞(i18n zh+en 各 41 value):「配對裝置」讓人誤以為
   配對 KL520/KL720 晶片,實際是配對電腦上的 local-agent → 改「連接這台電腦」。
   避撞既有裝置 connect/連線(新詞帶「電腦」主詞)。解除連接/連接碼/連接時間/
   重新連接成套。「配對過的裝置」→「已連接電腦上的裝置」語意校正。key 名/
   pairedAt 欄位/路由 /devices/pair 保留。

reviewer 通過(rescan 0C/0M、用詞 0C/0M 41/41 對齊避撞守住)。tsc/eslint/build
綠 + 39 i18n parity/store test。mapping: docs/autoflow/03-design/pairing-rename-mapping.md

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 09:41:18 +08:00
e27d8e3bd2 fix(local-agent): camera pixel format + rawvideo decoder(實測出畫面)
avfoundation 修好後實測發現兩層問題(camera 三層根因的後兩層):
- 改動1(args):darwin buildCaptureArgs 加 -pixel_format uyvy422(放 -i 前)。
  攝影機只支援 uyvy422/yuyv422/nv12(非 yuv420p),不指定→協商失敗 I/O error。
- 改動2(ffmpeg):Makefile decoder 白名單加 rawvideo。攝影機吐 raw uyvy422
  codec=rawvideo,轉 mjpeg 前要解,精簡 build 漏了→ no decoder found→EOF。

本機真攝影機端到端實測:/tmp/cam_ok.jpg = JPEG 640x480(rawvideo native→
mjpeg native 全鏈通、真的出畫面)。reviewer 通過(0C/0M)。只影響 camera
(video/image/batch 解既有壓縮檔、不碰 rawvideo/avfoundation)。只改 macOS
(Windows/Linux full build 內建 rawvideo;pixel_format 待實機 follow-up)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 00:53:35 +08:00
a6c94e21c5 fix(local-agent): 重啟自動從 token.bin 讀 token,免每次重配對
app.go 只在 env VISIONA_SESSION_TOKEN 有值時才啟動 tunnel、env 空就等 UI Pair,
從沒觸發 token.bin 的載入 → 每次重啟都要重配對。(Manager.startLocked 本來就會
從 config.TokenStore.Load,只是沒被呼叫。)

修法:抽 shouldAutoStartTunnel — env 有 token 用 env(優先序不變);env 空則
tokenStore.Load(),有值就自動 Start(免重配對)、空/err/nil store 等 UI Pair 不擋啟動。
Unpair→Delete token.bin→重啟 Load 回空→不自動連(round-trip test 覆蓋)。

reviewer 通過(0C/0M/1m 雙Load技術債)。build/vet/test 全過 + 5 分支 + Unpair round-trip 測試。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 18:54:09 +08:00
f6d15b7b14 docs(arch): B 設備管理 + C 模型共享 + camera ADR-020 規劃文件
- feature-device-mgmt-tdd.md + api-device-mgmt.md(B 設備管理 TDD)
- feature-model-sharing-tdd.md + api-model-sharing.md + PRD feature + 設計規格(C 模型共享三方規劃)
- adr-020-ffmpeg-camera-indev.md(camera 三平台 indev)
- PRD.md / TDD.md 索引增補

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 16:31:38 +08:00
6a797d5eb5 feat(frontend): 設備管理三態/註冊 + 模型共享庫/公開設定(B + C)
B 設備管理:device-store 補 registeredAt + register/unregister actions;
deriveTriState 三態(已連接/未連接/已連接未註冊);三態 badge(warning token
+ icon + 文字不只色);排序/filter chips;註冊 UI(明確區分取消註冊≠移除)。

C 模型共享:/models/library cursor 無限捲動 + 搜尋/filter/排序;visibility badge
三態;公開設定 Dialog(RadioGroup + shares 管理 + public 警告 + 二次確認);
profile 頁 owner/公開雙態;radio-group 新元件;owner 用 name 不洩 email。

共用檔 types/api.ts(B error codes)+ i18n en/zh(devices.* B / models.* C)。
零新 Design Token。reviewer B(0C/0M) + C(0C/0M、設計12/12 API8/8) 通過。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 16:30:22 +08:00
47a1d4d0ef feat(backend): 設備註冊 + 模型共享 backend(B 設備管理 + C 模型共享)
B 設備管理(feature-device-mgmt-tdd):
- POST /api/devices/:id/register + /unregister(owner 檢查 + representative 擋
  + 已註冊擋 + SetRegistered 單欄翻轉,不碰 unpair 軟刪)
- error codes ALREADY_REGISTERED / REPRESENTATIVE_DEVICE(409)
- 不需 migration(registered_at 欄/index/讀寫已在 0005)

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

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

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 16:29:50 +08:00
17134e8eae feat(local-agent): camera 三平台 input device(ADR-020 WP-1/2)
camera 即時推論開不了根因:vendor decoder-only ffmpeg --disable-everything
沒 enable 任何 input device → macOS avfoundation 認不得。

- WP-1 macOS:ffmpeg rebuild 加 --enable-avfoundation + --enable-indev=avfoundation
  (--disable-autodetect 會靜默 disable、兩行要一起帶)。-list_devices 列出相機、
  +22KB、LGPL-safe。sha 已核對。
- WP-2:buildCaptureArgs 補 Linux v4l2 分支(原誤落 avfoundation default 必壞)+
  ListFFmpegDevices Linux glob /dev/video*。四路明確 case + 回歸鎖。

reviewer WP-1(0C/0M/0m) + WP-2(0C/0M/2m) 通過。Windows dshow / Linux v4l2 實機驗待機器。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 16:28:51 +08:00
e4d27594d6 fix(local-agent): 修 camera 即時推論開不了(macOS 攝影機權限 + 假成功)
三層疊加根因:
1. 主根因:.app 缺 NSCameraUsageDescription → macOS TCC 靜默拒絕、不彈授權
   視窗、綠燈不亮、ffmpeg avfoundation 抓不到 camera。
2. ffmpeg cmd.Start() 只要 fork 成功就回 nil → HTTP 200 假成功(攝影機沒真開)。
3. cmd.Stderr=nil 吞掉 ffmpeg 錯誤 + pipeline 靜默重試 → 極難查。

修法:
- Info.plist + Info.dev.plist 加 NSCameraUsageDescription(wails build template,
  ad-hoc 簽名下只需 usage description,刻意不加 hardened runtime/entitlement
  避免 TCC 直接拒絕)
- ffmpeg WaitForFirstFrame:收到首張完整 JPEG frame 才回成功;早退/逾時回明確
  錯誤 → camera/start 真的回非 200、前端看到真實失敗。timeout 25s(涵蓋首次
  TCC 授權彈窗的使用者反應時間;已授權情境仍秒開)
- stderr 導到有界 ringBuffer + log;pipeline camera 模式連續失敗 50 次結束

reviewer 通過(0C/0M/2m)。只動 camera 鏈路(影片/圖片/批次/tunnel 上傳不受影響)。
build/vet/test/gosec 過、4 新 camera 測試。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 03:48:30 +08:00
44b877318d fix(frontend): 「開始推論」按鈕只在 camera 分頁顯示
「開始/停止推論」按鈕是 Camera 即時攝影機專用(打 /api/camera/start),
但原本渲染在所有分頁共用頂部。影片/圖片/批次是「上傳即自動推論」
(media-tab useInferenceStream=uploaded),不需此按鈕,顯示會誤導使用者
以為要先按開始推論。

用 activeTab === "camera" 條件渲染,非 camera 分頁隱藏。純顯示層改動,
handleStart/handleStop/isRunning/disabled 邏輯完全未動;與 handleTabChange
「切走即停」語意一致(切回 camera 時 isRunning 已 false、無殘留)。

reviewer 通過(0C/0M/0m)。tsc/eslint/build/14 test 綠 + 新增 1 測試驗
「按鈕只在 camera 顯示、切走隱藏、切回重現」。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 03:38:09 +08:00
145ed8e960 fix(local-agent): start-agent.sh 補設 VISIONA_CLOUD_ORIGINS(ADR-019 localhost 直連 CORS)
start-agent.sh 漏設 VISIONA_CLOUD_ORIGINS → local-agent CORS 白名單為空 →
影片分頁從瀏覽器打 local-agent 的 preflight 被 403 → 前端判 LOCAL_AGENT_NOT_FOUND
(顯示「需同一台電腦操作」)。

依 MODE 補設雙入口白名單(public: 公網 https + 內網 http;internal 同);
用 ${VISIONA_CLOUD_ORIGINS:-預設} 尊重外部覆寫;啟動 log 印出白名單。
origin 字串與 middleware.go 完整精確比對相容。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 03:16:05 +08:00
d0ab479a7f fix(local-agent): 影片上傳後推論卡「等待第一筆結果」— WS join 才開播 + late-join replay
時序競態:UploadVideo 回 200 即刻 pipeline.Start() 廣播推論結果,但瀏覽器
200 後才連結果 WS。WP-4 上傳改 localhost 直連(極快)、結果 WS 仍走慢 tunnel
→ 窗口放大;Hub 對無 client 的 room 廣播靜默丟棄 → 早期結果全丟、第一筆永遠等不到。

修法(A2 主修 + B 保險,結果面維持走 tunnel、不動 ADR-019 混合路徑):
- A2:UploadVideo 存檔即回 200,但 pipeline 建好不 Start;背景 gated-start
  等 WS join inference:<serial> room 後才 Start;15s 逾時降級照舊開跑(不永久卡)
- B:inference room 緩存最近 30 筆,client join 時先 replay 再收 live
  (順帶修 image 模式晚連 WS 丟結果的同類 bug)
- CameraHandler 加 startMu:gated goroutine 的 check-then-act(二次檢查 startCtx
  → Start)與 stop 的 cancel+Stop 原子化,消滅「stop 後 gated 又 Start 舊 pipeline」race

reviewer 通過(Major-1 修復複審 )。14 test -race 全過(含 3000 輪併發 atomicity
測試)、gosec 改的檔 0 新 finding。serial room key 兩端同形已查證排除。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-02 03:16:05 +08:00
95 changed files with 13066 additions and 262 deletions

View File

@ -95,6 +95,7 @@ visionA Cloud 是把 edge-ai-platform POC 升格的正式雲端產品,定位
|------|--------|------|------|
| 裝置管理 | P0 | [feature-device-management.md](features/feature-device-management.md) | 搬自 local-tool走 remote-proxy |
| 模型管理 | P0 | [feature-model-management.md](features/feature-model-management.md) | 7 預設 + 上傳,走 S3 介面 |
| 模型共享 | P0/P1 | [feature-model-sharing.md](features/feature-model-sharing.md) | L 級新功能:在模型管理上加「可見性/權限」維度(私有/指定使用者/組織/公開),含共享模型庫(分頁/排序/filter/搜尋、公開設定、profile 頁;三方討論中 |
| 推論操作 | P0 | [feature-inference.md](features/feature-inference.md) | Camera / Image / Video / Batch |
| Pairing 流程 | P0 | [feature-pairing.md](features/feature-pairing.md) | 新增,取代 POC 的 MAC 寫死 |
| 工作區 | P0 | [feature-workspace.md](features/feature-workspace.md) | 裝置 → 模型 → 來源 |

View File

@ -0,0 +1,227 @@
# Feature模型共享Model Sharing— L 級新功能
> 父文件:[PRD.md](../PRD.md) | 相依既有功能:[模型管理](feature-model-management.md)、[會員系統](feature-auth.md)
> 對應 User StoriesUS-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 |
| 使用者 / 權限 | OIDCMember Center`sub`=userIDusers 表有 `org_id`(nullable, 未用)、`roles TEXT[]`(未用) | 「公開對象」要對齊這些既有欄位(同租戶 / 群組概念) |
| 下載 | handler 註解已寫明「第一階段 owner-only**B 分享後續階段**)」 | 本功能正是被預留的「B 分享階段」 |
**一句話**:既有系統已經為「分享」預留了骨架(`org_id``roles`、download handler 的註解),本功能把這個骨架填血。
---
## 1. 功能概述與價值主張
**使用者痛點**(反推自 Persona見 user-research.md
- **阿哲FAE**:轉檔 / 調校好一個客戶專用模型後,想給同組 FAE 或客戶直接用,現在只能「下載 → 私訊傳檔 → 對方重新上傳」,模型檔散落、版本混亂。
- **SarahSI**:在多個客戶現場佈署,想把一套驗證過的模型推給整個團隊 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** |
**P0MVP先做 `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 到 0005models 表 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-31P0**:作為模型擁有者,我要**在模型 profile 頁設定它的公開對象(私有 / 指定使用者)**,這樣我就能控制誰能用我的模型。
- **US-32P0**:作為模型擁有者,我要**把模型分享給指定的一位或多位使用者(用 email 或名稱搜尋)**,這樣特定同事 / 客戶就能直接使用。
- **US-33P0**:作為模型擁有者,我要**看到目前這個模型已分享給哪些人,並能移除某個人的存取權**,這樣我能隨時收回授權。
- **US-34P1**:作為模型擁有者,我要**把模型設為「同組織可見」或「全公開」**,這樣整個團隊 / 社群不用我逐一授權。
### 3.2 共享模型庫(被分享者 / 探索者視角)
- **US-35P0**:作為使用者,我要**在模型庫看到「共享給我」的模型(別人授權給我的)**,並清楚看到它是誰分享的、我的權限是什麼。
- **US-36P0**:作為使用者,我要**能對共享模型庫做分頁瀏覽**,這樣模型多時不會一次載入全部、頁面不卡。
- **US-37P0**:作為使用者,我要**對共享模型庫做排序(上傳時間 / 名稱 / 檔案大小)**,這樣我能快速找到最新或最相關的模型。
- **US-38P0**:作為使用者,我要**對共享模型庫做 filter依硬體晶片 / 來源 / 可見性 / 分享者)**,這樣我能縮小範圍。
- **US-39P0**:作為使用者,我要**用關鍵字搜尋模型(名稱 / 描述)**,這樣我能直接找到目標模型。
- **US-40P0**:作為使用者,我要**進入任一可見模型的 profile 頁看到完整資訊metadata、支援硬體、擁有者、分享者、我的權限、下載 / 載入按鈕)**,這樣我在使用前能確認它是我要的。
### 3.3 依身份權限的可見性(貫穿所有 story 的規則)
**共享模型庫「我能看到的模型」= 聯集**
```
我擁有的owner
明確分享給我的restricted grantUS-32
我所屬 org 的 organization 模型P1US-34
全公開模型P1US-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.1Reach 為 Phase 相對估值、Impact 對北極星 WAD。Effort 全部標「待 Architect 核對」——尤其 US-32 / US-35 的授權表 + 聯集查詢是技術重點。
### P0 / P1 切分總結
**P0MVP本次做**
- 可見性兩級:`private`(預設)+ `restricted`(指定 user 分享)
- 公開設定 UI在 profile 頁)+ 授權清單管理(新增 / 移除)
- 共享模型庫:「共享給我」維度 + 分頁 + 排序 + filter + 搜尋
- 模型 profile 頁(擴充既有 `/models/[id]`,加分享資訊與權限顯示)
**P1後續**
- `organization`(同租戶可見)— 依賴租戶 / org 指派機制成熟
- `public`(全公開)— 需內容治理(濫用回報、審核)
- 二次分享、edit 權限、分享通知email / in-app
---
## 5. 與既有模型功能的關係(擴充 vs 全新)
| 元件 / 檔案 | 現況 | 本功能 | 類型 |
|------------|------|--------|------|
| `Model` domainmodel.go | 無 visibility | 加 `Visibility` 欄位 + 可能的 share 關聯 | **擴充** |
| `models`migration 0001 | owner-scoped index | 新 migration加 visibility 欄 + `model_shares` 表 + 新 index | **擴充 + 新表** |
| `Repository.List(ListFilter)` | 只 owner filter | 擴充 filtergrantee / 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`(現為未寫入 stubP1 前需先有租戶指派機制 | §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.mdUS-31~US-40 併入主表、success-metrics.md分享指標

View 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 StoryDesign 視角)
> **作為** 一個 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 ← 預設,僅「我的」模型
│ └─ 依共享關係分區(新增):我的 / 公開 / 共享給我的
├── 搜尋框(新增,跨當前檢視)
├── 篩選(擴充 ModelFilterstargetChip + 共享狀態)
├── 排序(新增:名稱 / 建立時間 / 共享時間)
├── 分頁 / 載入更多(新增)
└── /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 二選一)
**方案 P1cursor / 無限捲動友善)→「載入更多」按鈕**Phase 0 建議,實作簡單、無頁碼狀態同步問題):
```
┌────────────────────────────────────────────┐
│ [卡片] [卡片] [卡片] [卡片] │
│ [卡片] [卡片] [卡片] [卡片] │
│ │
│ [ 載入更多 (顯示 24 / 87) ] │ ← Button variant=outlineloading 時 Spinner
└────────────────────────────────────────────┘
```
**方案 P2offset / 傳統頁碼)→ 底部 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版型**
```
┌──────────────────────────────────────┐
│ ← 返回 │
│ │
│ 🔒 │
│ 沒有權限檢視此模型 │
│ 這個模型未公開或未共享給你 │
│ │
│ [ 返回模型庫 ] │
└──────────────────────────────────────┘
```
> 🔷 Architect403 vs 404 的取捨——為避免「模型是否存在」的資訊洩漏,建議私有模型對無權限者回 **404**當作不存在UI 走「找不到模型」而非「無權限」。**請 Architect 確認採 403 揭露存在 or 404 隱藏存在**UI 兩版文案我都備。
---
## 8. 響應式(沿用既有斷點)
沿用 `pages.md` §11 的斷點與 `/models` 既有規則Mobile 單欄 / Tablet 2 欄 / Desktop 34 欄)。本功能新增元素的響應式:
| 元素 | Mobile (<640) | Tablet (6401024) | 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 AAdesign-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 決定增補方式)。

View 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-018serial 路由、兩步式連線)。
---
## 0. 問題總覽與設計解對照(一頁看懂)
| # | 使用者症狀 | 根因 | 設計解 | 純前端? |
|---|-----------|------|--------|---------|
| 1 | 離線後顯示「離線 · **從未連線**」誤導(明明連過) | `remote-device-badge.tsx:100-102` 只看 `!lastSeenAt` 就顯示「從未連線」,沒用 `pairedAt`/`registeredAt` 區分「連過但離線」vs「真的沒連過」 | 改「從未連線」判定邏輯:優先用 lastSeenAt缺時 fallback 到 pairedAt/registeredAt 判定「連過」只有全都沒有才顯示「從未連線」§4 | ✅ 純前端欄位都已存在lastSeenAt 後端未穩定回傳為 §7 待確認 |
| 2 | 兩層都用「線」字,「在線」又要點「連線」很困惑(核心) | 第 1 層 tunnelremoteStatus用「在線/離線」、第 2 層 driverconnect用「連線/斷線」,兩層命名撞、層次不明 | 三層語意重新定調:電腦(在線→「已連接雲端/未連上」、裝置就緒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→ 唯讀等待 │
│ │
│ CTAoffline 時):引導「請在電腦上啟動 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 現況
模型狀態 Carddevice-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 成功後已呼叫 fetchDevicedisconnect 亦同)。
- **重連=清空舊模型狀態**使用者提到「重連後應刷新為未載入」——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 │
│ 已就緒,可開始推論 │
│ │
│ [ 前往推論 → ] ← 主 CTAsize 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 AA4.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 進度列。

View 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序號 missingHintre-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 頁F7key 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改詞後仍表達「移除裝置紀錄 + 撤銷存取 + 不可復原」。

View File

@ -62,6 +62,18 @@
- **下載對接權威規格v1.3query-string token + redirect、推翻舊「fetch + Bearer」、解 CORS 405** → [`adr/adr-017-model-library-access.md` §11](adr/adr-017-model-library-access.md#11-v13瀏覽器下載改-query-string-token--redirect推翻決策-2--104-的fetch--bearer)
- CORS 405 根因定位(個人層)→ `.autoflow/04-architecture/download-cors-405-diagnosis.md`
### 9.6 模型共享Model Sharing — 公開對象 + 共享模型庫)
- **TDD**(資料模型 visibility 欄 / 權限可見性 query / 相容性 / 並行化計畫) → [`feature-model-sharing-tdd.md`](feature-model-sharing-tdd.md)
- **API 規格**`GET /api/models/library` 共享庫列表 / `GET /api/models/:id/profile` / `PATCH /api/models/:id/visibility` → [`api/api-model-sharing.md`](api/api-model-sharing.md)
- 沿用 ADR-017 決策 3 的 `model_shares`(點對點分享);本功能新增 `models.visibility` enumprivate/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本文件

View File

@ -0,0 +1,190 @@
# ADR-020: vendor ffmpeg 加回 camera input deviceindev— 三平台 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 §2decoder-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 deviceindev**
### 1.2 根因(已 100% 確認,非推測)
camera 抓實體攝影機是 **Go 端 `os/exec` 起 ffmpeg subprocess**(非 Python指令形如 `ffmpeg -f avfoundation -i "0:none" ... -f image2pipe -vcodec mjpeg -`ffmpeg 把攝影機輸出成連續 MJPEG stream 到 stdoutGo 端掃 JPEG SOI/EOI marker 切 frame。
問題在 vendor 的 macOS ffmpeg 是「decoder-only」自 buildv2 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)
採**方案 Arebuild / 驗證三平台 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 對照:
| 平台 | 列裝置 | 抓 framecapture 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-onlyLGPL 乾淨;架構不動 | 需 rebuild macOS binary + 補 Linux code 分支 | — |
| B. 換完整版 ffmpeg | 省事、一次到位所有格式 | 體積爆增4070MB vs 現 5.7MB);推翻 v2 TDD §2 decoder-onlymacOS 無現成 LGPL static 完整 build正是當初自 build 的理由) | 唯一好處「省事」在 A 只加一個 flag 前提下不成立 |
| C. camera 改用平台原生 APIAVFoundation / Media Foundation / V4L2 ioctl + cgo | 不依賴 ffmpeg subprocess | 三平台各寫一套原生 + cgo複雜度爆炸 = 重寫 camera 子系統 | 解「build flag 少一行」不該砍掉可用的抓取架構 |
| D. macOS 專用第二顆含 avfoundation 的 ffmpeg | 主 binary 維持純解碼 | 多一顆 binary + 兩套 build 維護 | A 加一個 flag 就能讓同一顆 binary 兼顧indev 增量 < 0.5MBD 無意義 |
---
## 4. 後果 (Consequences)
### 4.1 打破的既有前提
本 ADR **打破 v2 TDD §2 decoder-only「不含 indev」的前提**。原決策的假設是「local-tool 只處理本地檔案解碼」,但 camera 即時推論需要從實體裝置抓 frameindev該假設對 camera 情境不成立。本 ADR 確立新契約:
> **camera indev 是 ffmpeg build 白名單的必要一部分。** 未來升級 ffmpeg 版本 rebuild 時,三平台的 indevavfoundation / 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 v3rebuild 後仍須通過既有驗證(`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.5MBLGPL v3 合規未破
- [ ] **v2 TDD ffmpeg 章節交叉引用**增補不覆蓋註記「camera indev 為 build 白名單必要部分、三平台 indev 對照見 ADR-020」
- [ ] 與 Tech Lead / 使用者確認
- [ ] 成本影響已評估(體積增量微、無新增基礎設施成本)
---
## 6. WP 拆解(供 Orchestrator 排期)
| WP | 內容 | 派誰 | 需實機 | 前置 | 估時 |
|----|------|------|--------|------|------|
| WP-1 | macOSconfigure 加 `--enable-indev=avfoundation`、rebuild、重算 sha256、更新 BUILD.md、commit binary | devopsbuild/vendor| macOS使用者有 | — | 11.5h(含 ~3 分 rebuild |
| WP-2 | `buildCaptureArgs` / `ListFFmpegDevices` 補 Linux v4l2 分支(三平台 capture args 對照) | backendGo camera code | 否可先寫、Linux 驗證階段測) | — | 11.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 | 11.5h |
| WP-5 | v2 TDD ffmpeg 章節交叉引用增補ADR-020 轉 Accepted | architect | 否(待 WP-1~4 完成) | WP-1~4 | 0.5h |
> **平行 / 接力**WP-1macOS ffmpeg與 WP-2Linux code可**平行**互不相依、不同檔。WP-3Windows 驗獨立可平行。WP-4Linux 驗)**接力 WP-2**。WP-5 收尾,等前四個。
> **關鍵路徑**WP-2 → WP-4 → WP-5Linux 驗需先有 code 分支)。
> **使用者只有 macOS**WP-1 可立即做並驗證WP-3 / WP-4 的實機驗證需 Windows / Linux 機器,標為「待實機」,先寫好 code / build 再擇機驗。
---
## 7. 等級與後續
- **等級**M 級(跨多檔 + 跨三平台 + 動 vendor build 策略,但非新 user story / 新架構)。
- **後續**ADR 定稿後由 Orchestrator 派 devopsffmpeg rebuild / 驗證)+ backendLinux camera code 分支落地。camera 不阻擋 B+C 主線,可獨立排期。

View 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-ADB 操作用 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 撤 tokendevice 從清單消失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 + DeviceListItemregistered_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 | 非 ownerIDOR | 既有 |
| `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 範式)
```
// 皆用 UUIDDB 操作);不帶 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。

View 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 sessionBFF`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 | 每頁筆數1100超出 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 200envelope `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 200envelope `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 內部 keyADR-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 200envelope `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 |

View 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 列 | 對 tokenpairing/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-Aregister 是純雲端 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 是全欄 upsertregister 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_testtestcontainers 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並列 registerDeviceRoutesUUID 識別。
- **Request body**:無。
- **行為**(與 register 對稱、但更寬鬆):
1. userID / `:id` / Get / owner / representative 檢查同 §3.1 步驟 1-5。
2. **不做「已註冊才可取消」的硬擋**:採冪等——若 `registered_at` 已是 NULL未註冊unregister 回 200no-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 8601nil=未註冊)。
- `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-161Light/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
新增 keyzh-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 || namelocaleCompareA→Z |
| 註冊時間 | `registeredAt` desc新註冊在前null未註冊排最後 |
- 排序狀態存元件 local state`useState`即可不需持久化P0。若要記住偏好可存 localStorage可選、非必要
### 6.3 Filter依三態
提供 filterchips / 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 → 500auth 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前端拿不到其 UUIDhandler 仍要自己擋(攻擊者可能猜 UUID 或從其他管道拿到。repo 的 `SetRegistered` 的 WHERE 也帶 `is_representative = false`§3.3),三層防護。
### 7.4 其他
- register/unregister 是狀態變更操作,走既有 auth middlewareJWT/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-memoryset→get 回值、set nil→清空、representative 拒絕ErrNotFound、已刪除拒絕。
- postgres db_testtestcontainers本機無 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-firstsingle 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` omitemptysnake_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-BEregister/unregister handler + route + SetRegistered repoin-mem+pg+ handler test | backend | WS-FE, WS-TEST | 契約定稿 | 整合 join |
| WS-FEstore 補 registeredAt + normalize + deriveTriState + 三態配色 + 排序/filter UI + i18n | frontend | WS-BE, WS-TEST | 契約定稿 | 整合 join |
| WS-TESTbackend 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 keyzh/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.18.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-BEbackend+ WS-FEfrontend+ WS-TESTtesting三線並行——契約已定、各自可對 mock/stub 開工。
3. 各線完成 → Reviewer 審 → 收斂到**整合 join**(真後端接前端)。
4. **E2E join** + unpair 回歸全綠 → P0 收尾。
5. effort scaling 判斷本任務中等2 個清楚分離模組 BE/FE + 測試3 線平行是甜蜜點,不過度切分。

View 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)
)
-- + filtertarget_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量大時考慮拆 UNIONowner 子查 public 子查 shared 子查)避免全表掃。 |
| **model_shares EXISTS 子查** | `idx model_shares (grantee_user_id)` 已在 ADR-017 定義,子查走此 index。 |
| **tenant join users** | owner.org_id 比對需 joinusers 主鍵 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-breakerbase64 不透明(前端當黑箱)。
- `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 對稱 test130 testcontainers避免假綠|
---
## 8. 並行化工作流計畫Parallelization Plan
L 級、跨 backend + frontend + testing適用本節。**contract-first**§8.1 契約定死後三條 work-stream 可平行。
### 8.1 模組間契約single source of truth
| 契約項 | 定義處 | 鎖定內容 |
|--------|--------|---------|
| API schemalibrary / 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 test130 testcontainers。行為符合 §4.1 predicate + api 契約。
- 來源指引:本 TDD §3/§4/§6 + `api/api-model-sharing.md` + ADR-017 決策 3model_shares
- 邊界:**只做 model sharing。不碰前端、不改 model_shares schemaADR-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_keyDTO 本來就沒有)。**
**WS-3 testing**
- Objective權限可見性 matrixowner/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 審**。

View File

@ -178,8 +178,10 @@ vendor-ffmpeg-macos-build: ## macOS從源碼 build LGPL v3 decoder-only ffmpe
--disable-everything \
--enable-small \
--enable-protocol=file,pipe \
--enable-avfoundation \
--enable-indev=avfoundation \
--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-filter=scale,format,fps,null,anull \
--enable-muxer=image2pipe,image2,null \

View File

@ -1,12 +1,14 @@
package handlers
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
"visiona-agent/server/internal/api/ws"
@ -33,8 +35,33 @@ type CameraHandler struct {
videoFPS float64 // target FPS
videoInfo camera.VideoInfo // duration, total frames
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」的上限。
//
// 逾時仍會開跑 pipelinedegrade 成舊行為),確保就算 WS 因故一直沒連上,
// 影片推論也不會永久卡住(後續有 B 的 replay 緩存兜底早期結果)。
// 15s 足夠涵蓋 tunnel WS 握手 + 雲端 forward 的正常延遲。
const waitRoomJoinTimeout = 15 * time.Second
func NewCameraHandler(
cameraMgr *camera.Manager,
deviceMgr *device.Manager,
@ -91,6 +118,9 @@ func (h *CameraHandler) StartPipeline(c *gin.Context) {
return
}
// 新 pipeline 前清掉此 room 的 replay 緩存,避免上一次 session 的殘留結果補送給 client。
h.wsHub.ClearRoomReplay("inference:" + req.DeviceID)
// Create inference result channel
resultCh := make(chan *driver.InferenceResult, 10)
@ -106,6 +136,8 @@ func (h *CameraHandler) StartPipeline(c *gin.Context) {
// Start pipeline with camera as source
h.activeSource = h.cameraMgr
h.sourceType = camera.SourceCamera
// Major-1h.pipeline 由 stop 端在 startMu 內存取,這裡設定 + Start 也在鎖內保持一致。
h.startMu.Lock()
h.pipeline = camera.NewInferencePipeline(
h.cameraMgr,
camera.SourceCamera,
@ -114,6 +146,7 @@ func (h *CameraHandler) StartPipeline(c *gin.Context) {
resultCh,
)
h.pipeline.Start()
h.startMu.Unlock()
streamURL := "/api/camera/stream"
c.JSON(200, gin.H{
@ -187,6 +220,10 @@ func (h *CameraHandler) UploadImage(c *gin.Context) {
return
}
// 新 pipeline 前清掉此 room 的 replay 緩存。image 只推論一次replay 讓晚連的 WS
// client 仍能補到那唯一一筆結果(順帶修 image 路徑同類的早期丟棄)。
h.wsHub.ClearRoomReplay("inference:" + deviceID)
resultCh := make(chan *driver.InferenceResult, 10)
go func() {
@ -199,18 +236,23 @@ func (h *CameraHandler) UploadImage(c *gin.Context) {
h.activeSource = imgSource
h.sourceType = camera.SourceImage
h.pipeline = camera.NewInferencePipeline(
imgPipeline := camera.NewInferencePipeline(
imgSource,
camera.SourceImage,
session.Driver,
h.streamer.FrameChannel(),
resultCh,
)
h.pipeline.Start()
// Major-1h.pipeline 由 stop 端在 startMu 內存取,這裡設定 + Start 也在鎖內保持一致。
h.startMu.Lock()
h.pipeline = imgPipeline
imgPipeline.Start()
h.startMu.Unlock()
// Clean up result channel after pipeline completes
// 用 local imgPipeline非 h.pipeline避免 goroutine 讀共享欄位。
go func() {
<-h.pipeline.Done()
<-imgPipeline.Done()
close(resultCh)
}()
@ -297,29 +339,81 @@ func (h *CameraHandler) UploadVideo(c *gin.Context) {
}
}()
h.activeSource = videoSource
h.sourceType = camera.SourceVideo
h.videoPath = tmpFile.Name()
h.videoFPS = 15
h.videoInfo = videoInfo
h.activeDeviceID = deviceID
h.pipeline = camera.NewInferencePipeline(
room := "inference:" + deviceID
// 新一輪上傳:清掉舊的 replay 緩存,避免上一支影片的早期結果殘留補送給這次的 client。
h.wsHub.ClearRoomReplay(room)
pipeline := camera.NewInferencePipeline(
videoSource,
camera.SourceVideo,
session.Driver,
h.streamer.FrameChannel(),
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 roomjoin 後(或逾時 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() {
<-h.pipeline.Done()
close(resultCh)
h.wsHub.BroadcastToRoom("inference:"+deviceID, map[string]interface{}{
"type": "pipeline_complete",
"sourceType": "video",
})
waitCtx, waitCancel := context.WithTimeout(startCtx, waitRoomJoinTimeout)
defer waitCancel()
// 等到 room 有 clienttrue或逾時false, degrade 開跑)。
// startCtx 被 cancelstopActivePipeline→ 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 goroutinerange resultCh永久阻塞洩漏。
// pipeline 從未 Start(),不會有人寫 resultChclose 安全。
close(resultCh)
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",
"sourceType": "video",
})
}()
}()
streamURL := "/api/camera/stream"
@ -421,6 +515,9 @@ func (h *CameraHandler) UploadBatchImages(c *gin.Context) {
return
}
// 新 pipeline 前清掉此 room 的 replay 緩存(避免上一批殘留補送給 client
h.wsHub.ClearRoomReplay("inference:" + deviceID)
batchID := fmt.Sprintf("batch-%d", time.Now().UnixNano())
resultCh := make(chan *driver.InferenceResult, 10)
@ -434,18 +531,23 @@ func (h *CameraHandler) UploadBatchImages(c *gin.Context) {
h.activeSource = batchSource
h.sourceType = camera.SourceBatchImage
h.pipeline = camera.NewInferencePipeline(
batchPipeline := camera.NewInferencePipeline(
batchSource,
camera.SourceBatchImage,
session.Driver,
h.streamer.FrameChannel(),
resultCh,
)
h.pipeline.Start()
// Major-1h.pipeline 由 stop 端在 startMu 內存取,這裡設定 + Start 也在鎖內保持一致。
h.startMu.Lock()
h.pipeline = batchPipeline
batchPipeline.Start()
h.startMu.Unlock()
// Notify frontend when batch completes
// 用 local batchPipeline非 h.pipeline避免 goroutine 讀共享欄位。
go func() {
<-h.pipeline.Done()
<-batchPipeline.Done()
close(resultCh)
h.wsHub.BroadcastToRoom("inference:"+deviceID, map[string]interface{}{
"type": "pipeline_complete",
@ -503,12 +605,35 @@ func (h *CameraHandler) GetBatchImageFrame(c *gin.Context) {
c.Data(200, "image/jpeg", jpegData)
}
// stopPipelineForSeek stops the pipeline and ffmpeg process but keeps the video file.
func (h *CameraHandler) stopPipelineForSeek() {
// cancelPendingStartAndStopPipeline 在 startMu 鎖內原子地:
// 1. 取消尚未開跑的 gated-start goroutinependingStartCancel
// 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 {
h.pipeline.Stop()
h.pipeline = nil
}
}
// stopPipelineForSeek stops the pipeline and ffmpeg process but keeps the video file.
func (h *CameraHandler) stopPipelineForSeek() {
// A2seek 前也要原子地取消尚未開跑的 gated-start goroutine + 停 pipeline
// (極端情況:上傳後 WS 還沒 join 就 seek。cancel 後該 goroutine 自行 close 原 resultCh。
h.cancelPendingStartAndStopPipeline()
if h.activeSource != nil {
if vs, ok := h.activeSource.(*camera.VideoSource); ok {
vs.CloseWithoutRemove()
@ -519,9 +644,13 @@ func (h *CameraHandler) stopPipelineForSeek() {
// stopActivePipeline stops the current pipeline and cleans up resources.
func (h *CameraHandler) stopActivePipeline() {
if h.pipeline != nil {
h.pipeline.Stop()
h.pipeline = nil
// A2 + Major-1先原子地取消「等 WS join 才開跑」的 gated-start goroutine + 停 pipeline
// 確保尚未開跑的舊 pipeline 不會在此之後才 Start()。cancel 後該 goroutine 會自行
// 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)
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
h.stopPipelineForSeek()
// 清掉 seek 前的 replay 緩存,避免舊位置的結果被補送給 seek 後才 late-join 的 client。
// seek 不需 gated-startWS client 早已 join能觸發 seek 代表已在收結果),直接開跑。
h.wsHub.ClearRoomReplay("inference:" + h.activeDeviceID)
// Create new VideoSource with seek position
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.pipeline = camera.NewInferencePipelineWithOffset(
seekPipeline := camera.NewInferencePipelineWithOffset(
videoSource,
camera.SourceVideo,
session.Driver,
@ -610,10 +742,16 @@ func (h *CameraHandler) SeekVideo(c *gin.Context) {
resultCh,
frameOffset,
)
h.pipeline.Start()
// Major-1h.pipeline 由 stop 端在 startMu 內存取,這裡設定 + Start 也在鎖內保持一致。
// seek 不走 gated-startWS 早已 join故不設 pendingStartCancel。
h.startMu.Lock()
h.pipeline = seekPipeline
seekPipeline.Start()
h.startMu.Unlock()
// 用 local seekPipeline非 h.pipeline避免 goroutine 讀共享欄位。
go func() {
<-h.pipeline.Done()
<-seekPipeline.Done()
close(resultCh)
h.wsHub.BroadcastToRoom("inference:"+h.activeDeviceID, map[string]interface{}{
"type": "pipeline_complete",

View File

@ -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 前呼叫 stopActivePipelinecancel pendingStartCancel→ 不開跑
//
// 用真實 ws.Hub + 與 UploadVideo 相同的 gated-start patterncontext + WaitForRoomClient
// 驗證行為,不牽涉 camera / ffmpeg / driver。
import (
"context"
"sync"
"sync/atomic"
"testing"
"time"
"visiona-agent/server/internal/api/ws"
)
// runGatedStart 複製 UploadVideo 裡 gated-start goroutine 的協調骨架(不含真實 pipeline
// started 在「決定開跑」時設為 1aborted 在「因 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)
// 模擬 stopActivePipelineclient 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」
// 復現原 racegated 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()
// 不變式 1started 與 aborted 互斥(不可能同時、也不可能都沒發生)。
s, a := atomic.LoadInt32(&started), atomic.LoadInt32(&aborted)
if s == a { // 兩者相等 → 同為 0都沒跑或同為 1同時發生都代表原子性被破壞
t.Fatalf("iter %dstarted(%d)/aborted(%d) 非互斥check-then-act 原子性被破壞", i, s, a)
}
// 不變式 2無論哪條路徑最終 pendingStartCancel 都應是 nil
//started → gated 清 nilaborted → stop 清 nil。殘留非 nil 代表狀態不一致。
h.startMu.Lock()
leftover := h.pendingStartCancel != nil
h.startMu.Unlock()
if leftover {
t.Fatalf("iter %dpendingStartCancel 未被清乾淨(狀態不一致)", i)
}
}
}

View File

@ -1,16 +1,34 @@
package ws
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
// replayPrefix 決定哪些 room 啟用「late-join replay 緩存」。
//
// 只對推論結果 room"inference:<serial>")緩存最近 N 筆結果,理由:
// - 推論結果面走雲端 tunnelADR-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。緩存在
// ClearRoomReplaypipeline 停止 / 切換時)被清掉,跟 stopActivePipeline 生命週期對齊。
const replayBufferSize = 30
type Client struct {
Conn *websocket.Conn
Send chan []byte
@ -27,6 +45,16 @@ type RoomMessage struct {
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 訂閱與訊息廣播。
//
// M8-4bHub 額外負責「第一個 client 連上時寫 sentinel file」
@ -36,25 +64,37 @@ type RoomMessage struct {
// dataDir 由 main.go 在初始化 Hub 後透過 SetStartupSentinel(dataDir) 注入。
// 若 dataDir 為空sentinel 寫入會被跳過(單元測試或缺少資料目錄時的安全行為)。
type Hub struct {
rooms map[string]map[*Client]bool
register chan *Subscription
unregister chan *Subscription
broadcast chan *RoomMessage
mu sync.RWMutex
rooms map[string]map[*Client]bool
register chan *Subscription
unregister chan *Subscription
broadcast chan *RoomMessage
waitReq chan *roomWaiter // WaitForRoomClient 的等待請求(在 Run() 內序列化處理)
clearReplay chan string // ClearRoomReplay 的清除請求
mu sync.RWMutex
// M8-4b: 啟動 sentinel file
sentinelDataDir string // <dataDir>,由 SetStartupSentinel 設定
sentinelOnce sync.Once // 確保只在「第一個」client 連上時寫一次
bootID string // 寫入 sentinel 內容供 debug
// video-inference-stuck 修法 A2/B均在 Run() goroutine 內存取、無需額外鎖):
// waiters — WaitForRoomClient 尚未被喚醒的等待者key=room
// replay — 每個 inference room 的最近 replayBufferSize 筆訊息 ringlate-join replay
waiters map[string][]*roomWaiter
replay map[string][][]byte
}
func NewHub() *Hub {
return &Hub{
rooms: make(map[string]map[*Client]bool),
register: make(chan *Subscription, 10),
unregister: make(chan *Subscription, 10),
broadcast: make(chan *RoomMessage, 100),
bootID: fmt.Sprintf("boot-%d", time.Now().UnixNano()),
rooms: make(map[string]map[*Client]bool),
register: make(chan *Subscription, 10),
unregister: make(chan *Subscription, 10),
broadcast: make(chan *RoomMessage, 100),
waitReq: make(chan *roomWaiter, 10),
clearReplay: make(chan string, 10),
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][sub.Client] = true
// Blate-join replayinference 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()
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」的 waiterWaitForRoomClient
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
// sync.Once 保證後續呼叫 no-op
h.writeStartupSentinel()
@ -114,6 +177,26 @@ func (h *Hub) Run() {
close(sub.done)
}
case w := <-h.waitReq:
// A2WaitForRoomClient 的請求。若 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:
h.mu.Lock()
if clients, ok := h.rooms[sub.Room]; ok {
@ -125,7 +208,15 @@ func (h *Hub) Run() {
h.mu.Unlock()
case msg := <-h.broadcast:
h.mu.RLock()
h.mu.Lock()
// Binference 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 {
for client := range clients {
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}
}
// 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
}

View File

@ -0,0 +1,202 @@
package ws
// hub_video_race_test.go — video-inference-stuck 修法 A2/B 的 Hub 行為測試
//
// 涵蓋:
// A2 WaitForRoomClientroom 有 client 時立即返回、無 client 時阻塞到 register、ctx 取消返回 false
// B late-join replayinference 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 已有 clientWaitForRoomClient 應立即回 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_Orderinglate-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預期 %dreplay 應在 live 之前)", i, got["frame"], i)
}
}
}

View File

@ -2,24 +2,64 @@ package camera
import (
"bufio"
"bytes"
"fmt"
"io"
"os/exec"
"runtime"
"sync"
"time"
)
// stderrTailBytes 是保留的 ffmpeg stderr 尾端大小上限。
// ffmpeg 開攝影機失敗avfoundation not authorized / device busy 等)的關鍵訊息
// 都在 stderr 末尾,保留尾端即可診斷;限制大小避免長時間執行累積無界記憶體。
const stderrTailBytes = 8 * 1024
// 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
// by scanning for JPEG SOI (0xFFD8) and EOI (0xFFD9) markers.
type FFmpegCamera struct {
cmd *exec.Cmd
stdout io.ReadCloser
stderrBuf *ringBuffer // 保留 ffmpeg stderr 尾端,供失敗診斷(原本 =nil 直接丟棄)
latestFrame []byte
mu sync.Mutex
done chan struct{}
err error
// firstFrame 在第一張完整 JPEG frame 抵達時 close 一次,讓 WaitForFirstFrame 得知
// 攝影機真的開起來了cmd.Start() 成功只代表 ffmpeg 進程 fork 成功,不代表拿到 camera
firstFrame chan struct{}
firstFrameOnce sync.Once
}
// ringBuffer 保留寫入資料的最後 max bytesthread-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.
@ -33,25 +73,34 @@ func NewFFmpegCamera(cameraIndex, width, height, framerate int) (*FFmpegCamera,
// NewFFmpegCameraWithName starts ffmpeg with explicit camera name (needed for Windows dshow).
func NewFFmpegCameraWithName(cameraIndex int, cameraName string, width, height, framerate int) (*FFmpegCamera, error) {
args := buildCaptureArgs(cameraIndex, cameraName, width, height, framerate)
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()
if err != nil {
return nil, fmt.Errorf("failed to get stdout pipe: %w", err)
}
// Suppress ffmpeg's stderr banner/logs
cmd.Stderr = nil
// 保留 ffmpeg stderr 尾端而不是丟棄(原本 cmd.Stderr = nil 讓 avfoundation
// 權限被拒 / 裝置忙碌等錯誤全數消失、極難 debug。ringBuffer 只留末端、有界。
stderrBuf := newRingBuffer(stderrTailBytes)
cmd.Stderr = stderrBuf
if err := cmd.Start(); err != nil {
return nil, fmt.Errorf("failed to start ffmpeg: %w", err)
}
cam := &FFmpegCamera{
cmd: cmd,
stdout: stdout,
done: make(chan struct{}),
cmd: cmd,
stdout: stdout,
stderrBuf: stderrBuf,
done: make(chan struct{}),
firstFrame: make(chan struct{}),
}
go cam.readLoop()
@ -59,12 +108,92 @@ func NewFFmpegCameraWithName(cameraIndex int, cameraName string, width, height,
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.
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 deviceindev
// - 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)
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":
// DirectShow on Windows: -f dshow -i video="Camera Name"
inputName := cameraName
@ -77,30 +206,50 @@ func buildCaptureArgs(cameraIndex int, cameraName string, width, height, framera
inputName = "Integrated Camera"
}
}
return []string{
return captureTail([]string{
"-f", "dshow",
"-framerate", fps,
"-video_size", videoSize,
"-i", fmt.Sprintf("video=%s", inputName),
"-f", "image2pipe",
"-vcodec", "mjpeg",
"-q:v", "5",
"-an",
"-",
}
})
case "linux":
// Video4Linux2 on Linux: -f v4l2 -i /dev/video<N>
// 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:
// AVFoundation on macOS: -f avfoundation -i "index:none"
return []string{
// 未知平台:明確落到 macOS 的 avfoundation 是錯的(原本 default 就是這個 bug
// 保留 avfoundation 作為最後手段,但這只是為了讓非三大平台不至於 build 失敗;
// 實務上未知平台的 camera 抓取本就不支援,會在 ffmpeg 層以「Unknown input format」失敗。
// 三大目標平台darwin/windows/linux都有明確 case、不會落到這裡。
return captureTail([]string{
"-f", "avfoundation",
"-framerate", fps,
"-video_size", videoSize,
"-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.mu.Unlock()
// 通知 WaitForFirstFrame攝影機真的產出 frame 了(只觸發一次)。
c.firstFrameOnce.Do(func() { close(c.firstFrame) })
inFrame = false
}
}

View 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 1stdout 沒有任何 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 正常產出一張最小 JPEGSOI+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)
}
}

View File

@ -0,0 +1,219 @@
package camera
import (
"strings"
"testing"
)
// TestBuildCaptureArgsForOS 驗證三平台各自用正確的 ffmpeg input deviceindev
// 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 / mjpegMJPEG 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 絕不能
// 用 avfoundationmacOS 專用)。若未來有人把 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)
}

View File

@ -3,8 +3,10 @@ package camera
import (
"fmt"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"sort"
"strconv"
"strings"
)
@ -17,9 +19,16 @@ func DetectFFmpeg() bool {
// ListFFmpegDevices detects available video devices using ffmpeg.
// Automatically selects the correct capture framework for the current OS:
// - macOS: AVFoundation
// - macOS: AVFoundation
// - Windows: DirectShow (dshow)
// - Linux: Video4Linux2 (v4l2, enumerated from /dev/video*)
func ListFFmpegDevices() []CameraInfo {
// Linux 走 /dev/video* 列舉、不依賴 ffmpeg binary-list_devices 在部分 v4l2 build
// 不穩定),故不要求 DetectFFmpeg 也能列出裝置節點;實際抓 frame 時才需要 ffmpeg。
if runtime.GOOS == "linux" {
return listV4L2Devices()
}
if !DetectFFmpeg() {
return nil
}
@ -132,3 +141,66 @@ func parseDShowOutput(output string) []CameraInfo {
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_devicesv4l2 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
}

View File

@ -3,8 +3,22 @@ package camera
import (
"fmt"
"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 {
ID string `json:"id"`
Name string `json:"name"`
@ -51,9 +65,23 @@ func (m *Manager) Open(index, width, height int) error {
if err != nil {
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.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
}

View File

@ -2,11 +2,19 @@ package camera
import (
"context"
"fmt"
"time"
"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.
type SourceType string
@ -90,6 +98,7 @@ func (p *InferencePipeline) run(ctx context.Context) {
targetInterval := time.Second / 15 // 15 FPS
inferenceRan := false // for image mode: only run inference once
frameIndex := 0 // video frame counter
consecutiveReadErrors := 0 // camera 模式:連續讀 frame 失敗計數
for {
select {
@ -118,9 +127,22 @@ func (p *InferencePipeline) run(ctx context.Context) {
} else {
jpegFrame, readErr = p.source.ReadFrame()
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)
continue
}
// 成功讀到 frame重置連續失敗計數。
consecutiveReadErrors = 0
}
// Send to MJPEG stream

View File

@ -35,12 +35,18 @@ case "$MODE" in
CLOUD_API_URL="https://$HOST"
RELAY_URL="wss://$HOST/tunnel/connect"
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)
HOST="192.168.0.130:9527"
CLOUD_API_URL="http://$HOST"
RELAY_URL="ws://$HOST/tunnel/connect"
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
@ -105,6 +111,16 @@ echo ""
export VISIONA_CLOUD_API_URL="$CLOUD_API_URL"
export VISIONA_RELAY_URL="$RELAY_URL"
# ADR-019 localhost 直連的 CORS 白名單server 讀 middleware.go:46 os.Getenv
# 漏設會導致影片分頁的 preflightOPTIONS被 local-agent 回 403 → 前端 port 探測
# 掃不到同機 agent → 影片分頁顯示 LOCAL_AGENT_NOT_FOUND「需在同一台電腦操作」
# 格式:逗號分隔的完整 originscheme+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 驗證
# pairing exchange + tunnel WSS + 設定頁「測試連線」三條路徑共用此開關),
# 否則全部 x509 失敗,與本腳本的 demo 目的自相矛盾。

View File

@ -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 |
| Assembler | nasm 3.01Homebrew bottlecompiled 2025-10-11 |
| Homebrew | 5.1.6 |
| Build date | 2026-04-15 |
| Build date | 2026-08-03camera raw capture fixdecoder 白名單加 `rawvideo` |
| Build flags | 見下方 Configure flags 區塊(與 `Makefile``vendor-ffmpeg-macos-build` target 一致) |
> **camera raw capture fix2026-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-020avfoundation 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` 轉碼鏈齊)。
> 只加 macOSWindows/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 build2026-04-15沒編任何 indev導致 `Unknown input format: 'avfoundation'`、camera 開不了。
## Binary sha256
| 檔案 | sha256 |
|------|--------|
| `ffmpeg` | `c3cb9f1dad66730267c12fca92c6344d2f8939ab227889caac33005f8947992c` |
| `ffprobe` | `bd388fb4372ed5f7e44ee331a51be6383d702fb2c067bf562cabbdfbdd8b0c5e` |
| `ffmpeg` | `cb42312fa89d2dc8443ae0dab810d42e18d3efefd9b62cf0a2e18762f7a8cdb7` |
| `ffprobe` | `e7eb9e61a6bd77549cd6e2da9fec25beb67e71c3c3c4b1356ee395c591c0b3e9` |
| `COPYING.LGPLv3` | `da7eabb7bafdf7d3ae5e9f223aa5bdc1eece45ac569dc21b3b037520b4464768` |
> 舊值2026-08-02 ADR-020、加 avfoundation indev、無 rawvideoffmpeg `1afa56da…6626` / ffprobe `501ec3fb…414e`
> 更舊2026-04-15 decoder-only、無 indevffmpeg `c3cb9f1d…992c` / ffprobe `bd388fb4…0c5e`
計算指令:
```bash
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 | 人類可讀 |
|------|-------|---------|
| `ffmpeg` | 6,007,520 | 5.7 MB |
| `ffprobe` | 5,865,568 | 5.6 MB |
| `ffmpeg` | 6,034,432 | 5.8 MB |
| `ffprobe` | 5,896,624 | 5.6 MB |
實測比 TDD 原估 1015 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 實測耗時
- **2 分 44 秒**`make vendor-ffmpeg-macos-build``time` 量測)
- user: 559.60ssystem: 56.03swall-clock: 164.56s
- CPU 使用率:~374%macOS x86_648 核 Intel
- 比 TDD 原估 1020 分鐘快很多,因為 `--disable-everything` 大幅削減編譯單元數量
- **2 分 17 秒**2026-08-03 加 rawvideo decoder rebuild`make vendor-ffmpeg-macos-build``time` 量測)
- user: 548.43ssystem: 52.22swall-clock: 137.43s
- CPU 使用率:~437%macOS x86_648 核 Intel
- 2026-08-02 ADR-020 rebuild 為 3 分 57 秒2026-04-15 首次 decoder-only build 為 2 分 44 秒。本次較快為 build cache / 系統負載差異rawvideo decoder 本身編譯成本極低)
## License
@ -62,7 +88,18 @@ build 不 link 以下 GPL-only 元件:
- 無 `libfaac`non-free
僅使用 libavcodec 內建的 LGPL native decoderh264 / hevc / mpeg1video / mpeg2video /
mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_*)。
mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_* / rawvideo
**`rawvideo` decoder2026-08-03為 LGPL-safe**libavcodec 內建 native decoder僅做 byte 重排 /
pixel format 標記、無壓縮演算法,不引入任何 GPL / 第三方元件。加 `rawvideo``ffmpeg -version`
configuration line 仍**不含** `--enable-gpl` / `libx264` / `libx265`已實測驗證LGPL v3 合規未破。
**avfoundation indevADR-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 系統 frameworkAVFoundation /
Foundation / CoreGraphics / libobjc非第三方 dylib。
---
@ -82,8 +119,10 @@ mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_*)。
--disable-everything \
--enable-small \
--enable-protocol=file,pipe \
--enable-avfoundation \
--enable-indev=avfoundation \
--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-filter=scale,format,fps,null,anull \
--enable-muxer=image2pipe,image2,null \
@ -109,8 +148,10 @@ mpeg4 / mjpeg / prores / vp8 / vp9 / aac / mp2 / mp3 / pcm_*)。
| `--disable-everything` | 先關全部,白名單 enable確保不額外 link 任何 GPL 元件 |
| `--enable-small` | 最佳化體積而非速度 |
| `--enable-protocol=file,pipe` | 只開 file:// 和 pipeffmpeg 內部 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-decoder=h264,hevc,...` | 涵蓋常見 codecH.264 / H.265 / MPEG1/2/4 / mjpeg / prores / vp8/9 / AAC / MP2/3 / PCM |
| `--enable-decoder=h264,hevc,...,rawvideo` | 涵蓋常見 codecH.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-muxer=image2pipe,image2,null` | 輸出單張 JPEG 或 NULL測試用 |
| `--enable-encoder=mjpeg` | `-f image2pipe -vcodec mjpeg` 需要 mjpeg encoderLGPL-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
file /tmp/test.jpg
# 預期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
- `matroska,webm` — ok
### 4. Dynamic dependencies (`otool -L`)
### 4. Dynamic dependencies (`otool -L`)ADR-020 rebuild 後)
```
vendor/ffmpeg/macos/ffmpeg:
/System/Library/Frameworks/Foundation.framework/.../Foundation ← ADR-020 新增avfoundation 依賴)
/usr/lib/libSystem.B.dylib
/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation
/System/Library/Frameworks/CoreVideo.framework/Versions/A/CoreVideo
/System/Library/Frameworks/CoreMedia.framework/Versions/A/CoreMedia
/System/Library/Frameworks/AVFoundation.framework/.../AVFoundation ← ADR-020 新增
/System/Library/Frameworks/CoreVideo.framework/.../CoreVideo
/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:
(同上四個 macOS system framework
(同上一組 macOS system framework
```
- ✅ 只依賴 macOS 系統內建 framework`libSystem`, `CoreFoundation`, `CoreVideo`, `CoreMedia`
- ✅ 只依賴 macOS 系統內建 framework / dylibAVFoundation / Foundation / CoreVideo / CoreMedia /
CoreGraphics / CoreFoundation / libSystem / libobjc
- ✅ **無任何第三方 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
@ -278,7 +332,54 @@ $ codesign -v vendor/ffmpeg/macos/ffmpeg # exit 0, no output
$ codesign -v vendor/ffmpeg/macos/ffprobe # exit 0, no output
```
ad-hoc simbol signing okGatekeeper 可過。
ad-hoc symbol signing okGatekeeper 可過。
### 6. avfoundation indevADR-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 decoder2026-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 JPEG15 KiB兩層根因args pixel_format + rawvideo decoder都修好。
---

Binary file not shown.

Binary file not shown.

View File

@ -466,14 +466,60 @@ func (a *App) tryStartTunnel() {
a.appLog("tunnel: manager created (relay=%s, local=%s, mock=%v, paired=%v)",
relayURL, localAddr, mockMode, sessionToken != "")
// 有 session token 才立刻啟動;沒有則等使用者從 UI Pair()
if sessionToken != "" {
// 決定啟動時要不要立刻連 tunnel純函式方便單元測試見 shouldAutoStartTunnel
if shouldAutoStartTunnel(sessionToken, a.tokenStore, a.appLog) {
if err := mgr.Start(a.ctx); err != nil {
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 撈上次配對持久化的 tokentoken.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() 補 tokenmanager.go 的「嘗試補 SessionToken」段
// 本函式只負責「要不要呼叫 Start()」這個決策。
//
// 修復前的 bugapp.go 只在 envToken != "" 時才呼叫 mgr.Start()envToken 空時
// 直接跳過,導致 Manager 內部的 TokenStore.Load() 那條路永遠走不到,使用者每次
// 重啟都要重新配對token.bin 有存卻沒被讀)。
//
// logf 用於記錄決策原因(傳 a.appLognil 時不記 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開瀏覽器。
// AutoOpenBrowser=false → SkipStage 進入階段 6也會被 skip-timeout 規則處理)
// AutoOpenBrowser=true → 呼叫 openBrowser 並 CompleteStage(5)

View File

@ -23,6 +23,8 @@
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
<key>NSCameraUsageDescription</key>
<string>visionA Agent 需要使用攝影機進行即時推論</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>

View File

@ -25,6 +25,8 @@
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>{{.Info.Copyright}}</string>
<key>NSCameraUsageDescription</key>
<string>visionA Agent 需要使用攝影機進行即時推論</string>
{{if .Info.FileAssociations}}
<key>CFBundleDocumentTypes</key>
<array>

View File

@ -15,16 +15,17 @@ import (
"fmt"
"os"
"path/filepath"
"runtime"
)
// Preferences 定義控制台偏好。
// 對應 TDD v2/control-panel.md §4.3 的 Preferences struct。
type Preferences struct {
// AutoOpenBrowser — StartServer 成功後是否自動開瀏覽器。
// 預設值由 DefaultPreferences() 依 runtime.GOOS 決定:
// macOS / Windows → true
// Linux → false R5-D2Linux 桌面環境差異大,預設關)
// 預設值由 DefaultPreferences() 決定:所有平台一律 false。
// 桌面 GUI 本身就是主要操作介面,不需要啟動時自動彈瀏覽器分頁;
// 使用者若要用雲端 Web UI自行前往雲端網頁即可。
// (改預設 false 只影響全新安裝 / 無 preferences.json 的使用者;
// 既有使用者的 preferences.json 已明確存有此欄位Load 時尊重既有值。)
AutoOpenBrowser bool `json:"autoOpenBrowser"`
// Locale — 控制台 UI 的語系覆寫;空字串 → 自動偵測navigator.language
@ -34,12 +35,14 @@ type Preferences struct {
LogRingSize int `json:"logRingSize,omitempty"`
}
// DefaultPreferences 回傳平台相關的預設值。
// DefaultPreferences 回傳預設值。
//
// R5-D2Linux 預設關 AutoOpenBrowsermacOS/Windows 預設開。
// AutoOpenBrowser 一律預設 false桌面 GUI 為主要介面,啟動時不自動開瀏覽器;
// 使用者若要用雲端 Web UI 自行前往雲端網頁。
// (原本 macOS/Windows 預設 true、Linux 預設 false現統一為 false。
func DefaultPreferences() Preferences {
return Preferences{
AutoOpenBrowser: runtime.GOOS != "linux",
AutoOpenBrowser: false,
Locale: "",
LogRingSize: 0,
}

View File

@ -12,9 +12,9 @@ import (
func TestDefaultPreferences_PlatformSpecific(t *testing.T) {
p := DefaultPreferences()
wantOpen := runtime.GOOS != "linux"
if p.AutoOpenBrowser != wantOpen {
t.Fatalf("DefaultPreferences.AutoOpenBrowser=%v on %s, want %v", p.AutoOpenBrowser, runtime.GOOS, wantOpen)
// 桌面 GUI 為主要介面,所有平台一律預設不自動開瀏覽器。
if p.AutoOpenBrowser != false {
t.Fatalf("DefaultPreferences.AutoOpenBrowser=%v on %s, want false", p.AutoOpenBrowser, runtime.GOOS)
}
if p.Locale != "" {
t.Fatalf("default Locale=%q, want empty", p.Locale)

View File

@ -35,7 +35,7 @@ func newPipelineTestApp(t *testing.T) (*App, string) {
}
t.Cleanup(func() { _ = os.RemoveAll(dir) })
a.dataDir = dir
a.prefs = DefaultPreferences() // macOS/Windows: AutoOpenBrowser=true
a.prefs = DefaultPreferences() // 所有平台 AutoOpenBrowser=false測試各自需要 true 時自行覆寫)
return a, dir
}

View File

@ -0,0 +1,112 @@
package main
import (
"errors"
"testing"
"visiona-agent/internal/tunnel"
)
// errTokenStore 是測試用 TokenStoreLoad() 固定回傳指定的 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 為 nilfallback不自動連
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 用真正的
// EncryptedFileTokenStoretoken.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 建新 storetoken.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應自動連 tunnelgot 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 已清)不應自動連 tunnelgot true")
}
}

View File

@ -0,0 +1,216 @@
// device_register.go — POST /api/devices/:id/register 與 /unregister 的 handler。
//
// 「註冊」語意軸feature-device-mgmt-tdd §3 / §4api/api-device-mgmt.md
// - register把 device 的 registered_at 由 NULL 翻成 now()(未註冊 → 已註冊)。
// - unregister把 registered_at 清成 NULL退回未註冊**保留裝置列**。
//
// 🔴 與 unpair 完全不同TDD §1 紅線unpair 軟刪整台 + cascade 撤 tokendevice 從清單
// 消失unregister 只清單欄 registered_atdevice 仍在清單、顯示為未註冊)。兩端點各走各的,
// 本檔**絕不呼叫** DeviceUnpairer / Delete / 撤 token也**不改** devicesUnpairHandler。
//
// 皆為純雲端 DB 操作(只翻 registered_at、不路由 local agent用 UUID `:id` 識別
// (對齊 ADR-018 FE-ADB 操作用 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 → 500auth middleware 沒配好,不可 fallthrough
// 2. :id 空 → 400 VALIDATION_FAILED
// 3. Get deviceErrNotFound → 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.3representative 是 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→ 已註冊檢查6409 ALREADY_REGISTERED
// → SetRegistered(now())7→ 200 + 更新後 DeviceListItemregistered_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.2registered_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 + 更新後 DeviceListItemregistered_at=null
//
// 🔴 絕不軟刪、不呼叫 DeviceUnpairer、不撤 token、不動 sessionTDD §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 仍為 1WHERE 命中),避免使用者連點兩次第二次報錯。
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)
}

View 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 建 routerInMemory repo + 無 sessionuser 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 解析成功回應的 dataDeviceListItem 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 FORBIDDENIDOR 主防線)。
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 → 500auth 沒配好不可 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=nulldevice 仍在 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 應冪等回 200body=%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)
}

View File

@ -41,6 +41,11 @@ func registerDeviceRoutes(g *gin.RouterGroup, deps Deps) {
// Unpair雛形實作軟刪 DeviceRepo + CloseSession
g.POST("/devices/:id/unpair", devicesUnpairHandler(deps))
// 註冊軸feature-device-mgmt P0純雲端 DB 操作、UUID :id、不 proxy
// registerregistered_at NULL→now()unregister清 registered_at保留列與 unpair 分開)。
g.POST("/devices/:id/register", devicesRegisterHandler(deps))
g.POST("/devices/:id/unregister", devicesUnregisterHandler(deps))
// ADR-019 WP-5localhost 直連上傳的 one-time token 取得路徑(經既有 tunnel 打
// local-agent issue-token。契約 path 為 /api/devices/:serial/local-upload-ticket
// 但 gin/httprouter 要求同層級同名,故沿用 :id 佔位(其值語意為裝置序號 serial

View File

@ -21,6 +21,14 @@ const (
ErrCodeInvalidSignature = "INVALID_SIGNATURE"
// ErrCodeConflict 對齊 HTTP 409unique 約束衝突 — 同 owner+serial 重複註冊)。
ErrCodeConflict = "CONFLICT"
// ErrCodeAlreadyRegistered 對齊 HTTP 409對已註冊registered_at 非 null的 device
// 再次呼叫 register。前端據此顯示「此裝置已註冊」並 refetchfeature-device-mgmt-tdd §3.2)。
ErrCodeAlreadyRegistered = "ALREADY_REGISTERED"
// ErrCodeRepresentativeDevice 對齊 HTTP 409對 representative deviceagent 連線佔位、
// 非真實 USB呼叫 register/unregister。註冊語意只適用真實 USB device
// feature-device-mgmt-tdd §7.3)。與 ALREADY_REGISTERED 分開,讓 FE/TEST 能區分
// 「已註冊」與「不可註冊的裝置類型」兩種 409。
ErrCodeRepresentativeDevice = "REPRESENTATIVE_DEVICE"
// ErrCodeServiceUnavailable 對齊 HTTP 503。
// DB 接入塊 5.4 fail-fast 策略PG 連線失敗 / context 逾時 → 503讓 load balancer 知道
// 這台不健康,而非回假資料或 500500 會誤導為「程式 bug」503 才是「依賴暫時不可用」)。

View File

@ -45,6 +45,9 @@ func registerModelRoutes(g *gin.RouterGroup, deps Deps) {
// Phase 0.9 模型庫 model 直連 FAA 下載ADR-017 (a))。
g.GET("/models/:id/download", modelsDownloadHandler(deps))
// 模型共享library / profile / visibility / shares
registerModelSharingRoutes(g, deps)
// load-to-device 雛形先 stub完整實作需要 presigned GET + 透過 tunnel 送指令給 local agent
g.POST("/models/:id/load-to-device", func(c *gin.Context) {
WriteNotImplemented(c, "models.load-to-device — pending Phase 1")
@ -64,9 +67,12 @@ type ModelResponse struct {
// snake_case 對齊本 DTO 既有慣例target_chip / file_size / created_at
// 前端 model-store normalize 雙吃 snake/camelinput_shape(snake) 在其讀取範圍內。
// omitempty上傳類 / 舊 model 無 metadata 時不輸出,不破壞既有回應結構。
InputShape []int `json:"input_shape,omitempty"`
Classes []string `json:"classes,omitempty"`
Framework string `json:"framework,omitempty"`
InputShape []int `json:"input_shape,omitempty"`
Classes []string `json:"classes,omitempty"`
Framework string `json:"framework,omitempty"`
// Visibility模型共享功能新增。既有前端未讀此欄不受影響加欄相容
// 共享 UI 讀此欄顯示公開對象 badge。既有 model 遷移後為 "private"。
Visibility string `json:"visibility,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
UploadedAt *time.Time `json:"uploaded_at,omitempty"`
@ -89,6 +95,7 @@ func toModelResponse(m *model.Model) ModelResponse {
InputShape: m.InputShape,
Classes: m.Classes,
Framework: m.Framework,
Visibility: m.Visibility,
CreatedAt: m.CreatedAt,
UpdatedAt: m.UpdatedAt,
UploadedAt: m.UploadedAt,
@ -559,9 +566,11 @@ func modelsDownloadHandler(deps Deps) gin.HandlerFunc {
WriteDBError(c, deps.Logger, "get model", err)
return
}
// 第一階段 owner-onlyB 分享後續階段);非 owner 回 403。
if m.OwnerUserID != userID {
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
// 模型共享放寬owner-only → 共享權限檢查TDD §5 download 連帶變更)。
// 與 profile 可見性共用同一 canAccessModelsingle source of truth杜絕邏輯漂移SEC-2
// 不命中回 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
}

View File

@ -218,9 +218,12 @@ func TestModelsDownload_NotFound(t *testing.T) {
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
}
func TestModelsDownload_ForbiddenWhenNotOwner(t *testing.T) {
// TestModelsDownload_NotFoundWhenNoAccess 驗證模型共享後的行為改變TDD §5 download 放寬):
// 非 owner 且無任何可見性private model下載回 404不是 403——防 enumerationSEC-1
// 與 profile 的 canAccessModel 判斷一致single source of truth
func TestModelsDownload_NotFoundWhenNoAccess(t *testing.T) {
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")
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)
r.ServeHTTP(w, req)
assert.Equal(t, http.StatusForbidden, w.Code)
assert.Contains(t, w.Body.String(), ErrCodeForbidden)
assert.Equal(t, 0, iss.calls, "should not issue token for non-owner")
assert.Equal(t, http.StatusNotFound, w.Code, "private model 非 owner 應回 404防 enumeration")
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
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 把 modelprivate分享給 demo-userviewer
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)
}
// ==========================================================================

View 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
//
// 核心安全設計:所有可見性判斷走唯一的 canAccessModelsingle source of truth避免
// profile / download 兩處邏輯漂移TDD §6 SEC-2enumeration 防護一律回 404SEC-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. ownerm.OwnerUserID == uc.UserID→ AccessOwner
// 2. share 命中 → editor / viewer依 share.role
// 3. visibility=public → viewer
// 4. visibility=tenant 且 owner.org_id == uc.OrgID 且兩者皆非空 → viewerSEC-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 claimmiddleware 未填 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 是共享庫列表的一列 DTOapi §1
//
// owner 只揭露 id/name/is_me不揭露 owner email不含 storage_key / faa_object_keySEC-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,
}
// limitclamp 到 1100。
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 filtertrue/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_accessowner 由 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 游標;格式錯誤回 errorhandler 轉 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 payloadapi §2
//
// 絕不含 storage_key / faa_object_key / owner email / file_checksumSEC-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防 enumerationSEC-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.nameowner 未設 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/visibilityowner-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-onlySEC-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 是一筆分享授權 DTOowner 檢視清單用)。
//
// 只揭露 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/sharesowner-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/sharesowner-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/:userIdowner-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
}

View 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
// 不含別人的 privateTDD §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 驗證未 finalizepending的 model 不進共享庫。
func TestLibrary_ExcludesNotReady(t *testing.T) {
r, repo := newSharingFixture(t, "me")
// pending modelUploadedAt=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-1profile 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 nameMinor-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 回相同 404enumeration 防護)。
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 emailSEC-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 modelUploadedAt=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 CRUDrestricted 授權管理)
// ==========================================================================
// 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 無 orgOIDC 現況)→ none安全預設stub
mTenant := &model.Model{ID: "m", OwnerUserID: "owner", Visibility: model.VisibilityTenant}
assert.Equal(t, model.AccessNone,
canAccessModelForTest(ctx, "stranger", mTenant, noShare),
"tenant + 無 org → nonetenant stub")
}

View 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. applymodels 有 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 硬刪時連帶清 shareON 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 表應再次存在")
}

View File

@ -119,6 +119,16 @@ type Repository interface {
// 實作應更新 UpdatedAt若為新建則同時設定 CreatedAt。
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(ctx context.Context, id string) error
}
@ -231,6 +241,30 @@ func (r *InMemoryRepository) Save(ctx context.Context, d *Device) error {
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 deviceis_representative=true、
// 未刪除);不存在回 ErrNotFound。in-memory 忽略 q無交易需求
//

View File

@ -3,6 +3,7 @@ package device
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@ -174,3 +175,73 @@ func TestInMemoryRepository_GetRepresentativeByAgent(t *testing.T) {
_, err = r.GetRepresentativeByAgentTx(ctx, nil, "other-agent")
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)
}

View File

@ -31,6 +31,7 @@ import (
"context"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
@ -292,6 +293,37 @@ func (r *PostgresRepository) SaveTx(ctx context.Context, q db.Querier, d *Device
return nil
}
// SetRegistered 設定 / 清除 registered_at註冊軸單欄 UPDATEfeature-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。
//
// - registerat != nilhandler 已先擋 already-registered這裡不重複判
// - unregisterat == nil清成 NULL對已 NULL 的列 UPDATE 到相同值 RowsAffected 仍為 1
// WHERE 命中),語意上「取消一個未註冊的 = 已達成目標」冪等TDD §4.1)。
//
// 絕不軟刪、不呼叫 DeviceUnpairer、不碰 tokenTDD §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 deviceis_representative=true、
// 未刪除);不存在回 ErrNotFound在傳入 Querier / tx 上執行)。
//

View File

@ -743,3 +743,108 @@ func TestPG_ContextCancel(t *testing.T) {
_, err = r.List(ctx, owner)
assert.Error(t, err, "已取消 ctx 的 List 應回 error")
}
// ---------------------------------------------------------------------------
// SetRegistered註冊軸單欄 UPDATEfeature-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 → ErrNotFoundWHERE 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 → ErrNotFoundWHERE 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)
}

View File

@ -0,0 +1,152 @@
package model
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// readyModel 建一個 readyUploadedAt 已設)的 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")
// 無 orgOIDC 現況)→ 不可見(安全預設)
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 應被分頁走過一次")
}

View File

@ -8,6 +8,8 @@ import (
"context"
"errors"
"fmt"
"sort"
"strings"
"sync"
"time"
)
@ -41,6 +43,54 @@ const (
SourcePreset Source = "preset"
)
// ==========================================================================
// Visibility 常數(廣播式公開對象;對齊 feature-model-sharing-tdd.md §3.1
// ==========================================================================
// Visibility 是 Model 的「公開對象」廣播維度:一個 model 一個值。
// 與 model_shares點對點分享正交。
type Visibility = string
const (
// VisibilityPrivate 僅擁有者可見(= 現況預設行為;新 model 與既有 model 皆為此值)。
VisibilityPrivate Visibility = "private"
// VisibilityTenant 同租戶(同 org_id可見。
// 依賴 users.org_idOIDC 現況不帶 org claim見 postgres_repository.go List 說明),
// 故目前 tenant 命中集合恆為空安全預設——schema/predicate 就緒,等 OIDC 補 org claim 即生效。
VisibilityTenant Visibility = "tenant"
// VisibilityPublic 全平台已登入 user 可見。
VisibilityPublic Visibility = "public"
)
// IsValidVisibility 回報 v 是否為合法的 visibility 值handler 驗 PATCH 輸入用)。
func IsValidVisibility(v string) bool {
switch v {
case VisibilityPrivate, VisibilityTenant, VisibilityPublic:
return true
default:
return false
}
}
// ==========================================================================
// AccessLevel 常數(可見性判斷的結果;對齊 TDD §6 SEC-2 / api §1 my_access
// ==========================================================================
// AccessLevel 是「當前 user 對某 model 的有效權限」。
// 由 canAccessModelsingle source of truth計算取最高。
type AccessLevel = string
const (
// AccessNone 無可見性(不該看到此 modelenumeration 防護一律回 404
AccessNone AccessLevel = "none"
// AccessViewer 可 list / get profile / downloadvisibility 命中或 share role=viewer
AccessViewer AccessLevel = "viewer"
// AccessEditor 可改 metadatashare role=editor含 viewer 全部權限。
AccessEditor AccessLevel = "editor"
// AccessOwner 擁有者,完整權限(可改 visibility / 刪除 / 分享)。
AccessOwner AccessLevel = "owner"
)
// ==========================================================================
// Model struct對齊 database.md §2.3
// ==========================================================================
@ -79,12 +129,75 @@ type Model struct {
Source Source `json:"source"`
SourceJobID string `json:"sourceJobId,omitempty"`
// Visibility 是廣播式公開對象private / tenant / public對齊 model_sharing 功能)。
// 既有 / 新建 model 預設 VisibilityPrivateDB DEFAULT 'private',零行為改變)。
Visibility Visibility `json:"visibility"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
UploadedAt *time.Time `json:"uploadedAt,omitempty"`
DeletedAt *time.Time `json:"deletedAt,omitempty"`
}
// ==========================================================================
// ModelShare點對點分享關聯對齊 ADR-017 決策 3 B1 / model_shares 表)
// ==========================================================================
// ModelShare 是一筆「model 分享給特定 grantee」的授權紀錄。
type ModelShare struct {
ModelID string `json:"modelId"`
GranteeUserID string `json:"granteeUserId"`
Role string `json:"role"` // 'viewer' | 'editor'
GrantedBy string `json:"grantedBy"`
CreatedAt time.Time `json:"createdAt"`
}
// LibraryItem 是共享庫列表的一列Model + 該列相對於查詢 user 的存取資訊。
//
// owner_name / owner_org_id 由 repository 一次 JOIN users 帶出(避免 handler N+1
// SharedWithMe / MyAccess 由 repository 依查詢 user 身份計算填入。
type LibraryItem struct {
Model *Model
OwnerName string
OwnerOrgID string
SharedWithMe bool
MyAccess AccessLevel
}
// LibraryQuery 是共享庫列表查詢的參數(對齊 api §1
//
// Viewer 身份UserID / UserOrgID決定可見範圍其餘為 filter / 排序 / cursor 分頁。
type LibraryQuery struct {
// Viewer 身份(可見性 predicate 的 input
UserID string
UserOrgID string // 空字串 → tenant 維度不命中任何 model安全預設
// filter皆可選空值 = 不過濾該維度)。
TargetChip string
Source Source
Visibility Visibility // 僅 'public' / 'tenant' 有意義;'private' 傳入視為忽略
Q string // 搜尋 name + descriptionILIKE 包含)
// Ownednil = 全部可見true = 只我的false = 只別人分享/公開給我的。
Owned *bool
// 排序 + 分頁。
Sort string // 'created_at' | 'name' | 'file_size'handler 已 validate
Order string // 'asc' | 'desc'
Limit int // handler 已 clamp 到 1100
Cursor *Cursor // nil = 首頁
}
// Cursor 是 keyset 分頁游標,記錄上一頁最後一筆的排序值 + id tie-breaker。
//
// 由 handler 以不透明 base64 編碼給前端(見 api §1.3repository 只吃解碼後的結構。
type Cursor struct {
// SortValue 是上一頁最後一筆的排序欄位值,型別依 Sort 而定:
// created_at → RFC3339 時間字串name → 字串file_size → 十進位整數字串。
SortValue string `json:"v"`
// ID 是上一頁最後一筆的 model idtie-breaker保證穩定分頁
ID string `json:"id"`
}
// ==========================================================================
// Filter / Repository
// ==========================================================================
@ -103,6 +216,11 @@ type Repository interface {
// Get 取得單一 Model不存在或已刪除回 ErrNotFound。
Get(ctx context.Context, id string) (*Model, error)
// GetWithOwner 取得單一 Model 並一併帶出 owner 的顯示名稱(一次 join users避免 N+1
// 供 profile handler 顯示擁有者名api §2 owner.name。ownerName 可能為空owner 未設 name
// 不存在或已刪除回 ErrNotFound。
GetWithOwner(ctx context.Context, id string) (m *Model, ownerName string, err error)
// List 依 filter 列出 Modelfilter.OwnerUserID 不同於空字串時限定擁有者。
List(ctx context.Context, filter ListFilter) ([]*Model, error)
@ -111,6 +229,29 @@ type Repository interface {
// Delete 軟刪除。
Delete(ctx context.Context, id string) error
// ── 模型共享model_sharing 功能新增)─────────────────────────────────
// Library 依查詢 user 身份列出「可見」的 model我的 public tenant同org 分享給我),
// 支援 filter / 排序 / cursor 分頁。回傳 items已含 owner_name / my_access / shared_with_me
// 與是否還有下一頁hasMore。preset 由 handler 層 union不在此。
//
// 只列 uploaded_at IS NOT NULLready的 model共享庫不列未 finalize 的。
Library(ctx context.Context, q LibraryQuery) (items []*LibraryItem, hasMore bool, err error)
// GetShare 取得 (modelID, granteeUserID) 的分享紀錄;不存在回 ErrNotFound。
// 供 canAccessModel 單筆查「這個 model 有沒有分享給我」。
GetShare(ctx context.Context, modelID, granteeUserID string) (*ModelShare, error)
// ListShares 列出某 model 的所有分享紀錄owner 檢視授權清單用)。
ListShares(ctx context.Context, modelID string) ([]*ModelShare, error)
// UpsertShare 新增 / 更新一筆分享by PK (model_id, grantee_user_id))。
// 重複分享同一 grantee → 更新 role。
UpsertShare(ctx context.Context, s *ModelShare) error
// DeleteShare 移除 (modelID, granteeUserID) 的分享;不存在回 ErrNotFound。
DeleteShare(ctx context.Context, modelID, granteeUserID string) error
}
// ==========================================================================
@ -149,15 +290,41 @@ func (v *SizeValidator) Check(size int64) error {
type InMemoryRepository struct {
mu sync.RWMutex
models map[string]*Model
// shares 以 modelID → (granteeUserID → *ModelShare) 兩層 map 存分享關聯。
shares map[string]map[string]*ModelShare
// orgs 記錄 userID → org_id供 in-memory Library 判 tenant 可見性(測試注入用)。
// production 走 Postgres 實作in-memory 主要供 unit test故用簡易注入而非 join users。
orgs map[string]string
// names 記錄 userID → 顯示名稱,供 in-memory GetWithOwner / Library 帶出 owner name。
names map[string]string
}
// NewInMemoryRepository 建立一個空的記憶體 Repository。
func NewInMemoryRepository() *InMemoryRepository {
return &InMemoryRepository{
models: make(map[string]*Model),
shares: make(map[string]map[string]*ModelShare),
orgs: make(map[string]string),
names: make(map[string]string),
}
}
// SetUserOrg 設定某 user 的 org_id僅 in-memory 測試用,讓 Library 能判 tenant 可見性)。
// production 的 Postgres 實作直接 join users.org_id不需此方法。
func (r *InMemoryRepository) SetUserOrg(userID, orgID string) {
r.mu.Lock()
defer r.mu.Unlock()
r.orgs[userID] = orgID
}
// SetUserName 設定某 user 的顯示名稱(僅 in-memory 測試用,讓 GetWithOwner / Library 帶出 owner name
// production 的 Postgres 實作直接 join users.name不需此方法。
func (r *InMemoryRepository) SetUserName(userID, name string) {
r.mu.Lock()
defer r.mu.Unlock()
r.names[userID] = name
}
// Get 取得單一 Model。
func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error) {
r.mu.RLock()
@ -171,6 +338,19 @@ func (r *InMemoryRepository) Get(ctx context.Context, id string) (*Model, error)
return &cp, nil
}
// GetWithOwner 取單一 Model + owner 顯示名稱in-memory 從 names map 取,測試以 SetUserName 注入)。
func (r *InMemoryRepository) GetWithOwner(ctx context.Context, id string) (*Model, string, error) {
r.mu.RLock()
defer r.mu.RUnlock()
m, ok := r.models[id]
if !ok || m.DeletedAt != nil {
return nil, "", ErrNotFound
}
cp := *m
return &cp, r.names[m.OwnerUserID], nil
}
// List 依條件列出 Model。
func (r *InMemoryRepository) List(ctx context.Context, filter ListFilter) ([]*Model, error) {
r.mu.RLock()
@ -211,6 +391,10 @@ func (r *InMemoryRepository) Save(ctx context.Context, m *Model) error {
} else if cp.CreatedAt.IsZero() {
cp.CreatedAt = now
}
// visibility 預設 private對齊 DB DEFAULT 'private'):呼叫端未設時不會意外變公開。
if cp.Visibility == "" {
cp.Visibility = VisibilityPrivate
}
cp.UpdatedAt = now
r.models[m.ID] = &cp
return nil
@ -231,5 +415,222 @@ func (r *InMemoryRepository) Delete(ctx context.Context, id string) error {
return nil
}
// ---------------------------------------------------------------------------
// InMemoryRepository — 模型共享方法
// ---------------------------------------------------------------------------
// Library 依查詢 user 身份列出可見 modelin-memory 實作,供 unit test
//
// 可見性 predicate 對齊 TDD §4.1(我的 public tenant同org 分享給我)。
// 排序 + cursor 分頁在記憶體內以全掃 + sort + 切片實作in-memory 資料量小、不追求效能)。
func (r *InMemoryRepository) Library(ctx context.Context, q LibraryQuery) ([]*LibraryItem, bool, error) {
r.mu.RLock()
defer r.mu.RUnlock()
var matched []*LibraryItem
for _, m := range r.models {
if m.DeletedAt != nil || m.UploadedAt == nil {
continue // 共享庫只列未刪除且 ready 的 model
}
access := r.accessLevelLocked(q.UserID, q.UserOrgID, m)
if access == AccessNone {
continue
}
// filterowned 維度。
isMine := m.OwnerUserID == q.UserID
if q.Owned != nil {
if *q.Owned && !isMine {
continue
}
if !*q.Owned && isMine {
continue
}
}
if q.TargetChip != "" && m.TargetChip != q.TargetChip {
continue
}
if q.Source != "" && m.Source != q.Source {
continue
}
// visibility filter僅 public / tenant 有意義private 不在共享庫語意內)。
if q.Visibility == VisibilityPublic || q.Visibility == VisibilityTenant {
if m.Visibility != q.Visibility {
continue
}
}
if q.Q != "" {
needle := strings.ToLower(q.Q)
if !strings.Contains(strings.ToLower(m.Name), needle) &&
!strings.Contains(strings.ToLower(m.Description), needle) {
continue
}
}
_, shared := r.shareForLocked(m.ID, q.UserID)
cp := *m
matched = append(matched, &LibraryItem{
Model: &cp,
OwnerName: r.names[m.OwnerUserID], // in-memory 從 names map 取(測試以 SetUserName 注入)
OwnerOrgID: r.orgs[m.OwnerUserID],
SharedWithMe: shared,
MyAccess: access,
})
}
sortLibraryItems(matched, q.Sort, q.Order)
// cursor找到游標對應 item 後的位置,取其後 limit+1 判 hasMore。
start := 0
if q.Cursor != nil {
for i, it := range matched {
if it.Model.ID == q.Cursor.ID {
start = i + 1
break
}
}
}
limit := q.Limit
if limit <= 0 {
limit = 20
}
end := start + limit
hasMore := false
if end < len(matched) {
hasMore = true
}
if start > len(matched) {
start = len(matched)
}
if end > len(matched) {
end = len(matched)
}
return matched[start:end], hasMore, nil
}
// accessLevelLocked 計算 user 對 model 的 AccessLevel呼叫端須持 r.mu
// 對齊 canAccessModel 的 in-memory 版順序owner > share.role > visibility(viewer) > none。
func (r *InMemoryRepository) accessLevelLocked(userID, userOrgID string, m *Model) AccessLevel {
if m.OwnerUserID == userID {
return AccessOwner
}
if s, ok := r.shareForLocked(m.ID, userID); ok {
if s.Role == "editor" {
return AccessEditor
}
return AccessViewer
}
if m.Visibility == VisibilityPublic {
return AccessViewer
}
if m.Visibility == VisibilityTenant && userOrgID != "" && r.orgs[m.OwnerUserID] == userOrgID {
return AccessViewer
}
return AccessNone
}
// shareForLocked 回傳 (modelID, granteeUserID) 的 share呼叫端須持 r.mu
func (r *InMemoryRepository) shareForLocked(modelID, granteeUserID string) (*ModelShare, bool) {
byGrantee, ok := r.shares[modelID]
if !ok {
return nil, false
}
s, ok := byGrantee[granteeUserID]
return s, ok
}
// GetShare 取得單筆 share不存在回 ErrNotFound。
func (r *InMemoryRepository) GetShare(ctx context.Context, modelID, granteeUserID string) (*ModelShare, error) {
r.mu.RLock()
defer r.mu.RUnlock()
s, ok := r.shareForLocked(modelID, granteeUserID)
if !ok {
return nil, ErrNotFound
}
cp := *s
return &cp, nil
}
// ListShares 列出某 model 的所有 share。
func (r *InMemoryRepository) ListShares(ctx context.Context, modelID string) ([]*ModelShare, error) {
r.mu.RLock()
defer r.mu.RUnlock()
out := make([]*ModelShare, 0)
for _, s := range r.shares[modelID] {
cp := *s
out = append(out, &cp)
}
return out, nil
}
// UpsertShare 新增 / 更新一筆 shareby PK
func (r *InMemoryRepository) UpsertShare(ctx context.Context, s *ModelShare) error {
if s == nil || s.ModelID == "" || s.GranteeUserID == "" {
return errors.New("model: UpsertShare requires modelID and granteeUserID")
}
r.mu.Lock()
defer r.mu.Unlock()
if r.shares[s.ModelID] == nil {
r.shares[s.ModelID] = make(map[string]*ModelShare)
}
cp := *s
if cp.CreatedAt.IsZero() {
cp.CreatedAt = time.Now().UTC()
}
if cp.Role == "" {
cp.Role = "viewer"
}
r.shares[s.ModelID][s.GranteeUserID] = &cp
return nil
}
// DeleteShare 移除一筆 share不存在回 ErrNotFound。
func (r *InMemoryRepository) DeleteShare(ctx context.Context, modelID, granteeUserID string) error {
r.mu.Lock()
defer r.mu.Unlock()
byGrantee, ok := r.shares[modelID]
if !ok {
return ErrNotFound
}
if _, ok := byGrantee[granteeUserID]; !ok {
return ErrNotFound
}
delete(byGrantee, granteeUserID)
return nil
}
// sortLibraryItems 依 sort/order 就地排序 itemstie-breaker 一律用 model id 保證穩定。
func sortLibraryItems(items []*LibraryItem, sortField, order string) {
desc := order != "asc" // 預設 desc
less := func(i, j int) bool {
a, b := items[i].Model, items[j].Model
var cmp int
switch sortField {
case "name":
cmp = strings.Compare(a.Name, b.Name)
case "file_size":
switch {
case a.FileSize < b.FileSize:
cmp = -1
case a.FileSize > b.FileSize:
cmp = 1
}
default: // created_at
switch {
case a.CreatedAt.Before(b.CreatedAt):
cmp = -1
case a.CreatedAt.After(b.CreatedAt):
cmp = 1
}
}
if cmp == 0 {
cmp = strings.Compare(a.ID, b.ID) // tie-breaker
}
if desc {
return cmp > 0
}
return cmp < 0
}
sort.SliceStable(items, less)
}
// 編譯時檢查:確保 InMemoryRepository 實作 Repository。
var _ Repository = (*InMemoryRepository)(nil)

View File

@ -46,7 +46,7 @@ var _ Repository = (*PostgresRepository)(nil)
// modelColumns 是 SELECT / RETURNING 共用的欄位清單(順序必須與 scanModel 對齊)。
const modelColumns = `id, owner_user_id, name, description, storage_key, file_size,
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。
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
// 對齊 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 = `
INSERT INTO models (
id, owner_user_id, name, description, storage_key, file_size,
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 (
$1, $2, $3, $4, $5, $6,
$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
owner_user_id = EXCLUDED.owner_user_id,
@ -163,6 +170,7 @@ func (r *PostgresRepository) Save(ctx context.Context, m *Model) error {
framework = EXCLUDED.framework,
source = EXCLUDED.source,
source_job_id = EXCLUDED.source_job_id,
visibility = EXCLUDED.visibility,
-- 保留原 created_at 僅當既有列未刪除已刪除復活或值不同則用新值
created_at = CASE
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
string(m.Source), // $13
nullableUUID(m.SourceJobID), // $14
createdAt, // $15
m.UploadedAt, // $16
m.DeletedAt, // $17
visibility, // $15
createdAt, // $16
m.UploadedAt, // $17
m.DeletedAt, // $18
)
if err != nil {
return fmt.Errorf("model: pg Save upsert: %w", err)
@ -254,6 +263,7 @@ func scanModel(row rowScanner) (*Model, error) {
&framework,
&m.Source,
&sourceJobID,
&m.Visibility,
&m.CreatedAt,
&m.UpdatedAt,
&m.UploadedAt,

View 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 §1librarycursor 分頁 / sort / filter / q
// - adr-017-model-library-access.md 決策 3model_shares schema
// - migrations/0006_model_sharing.up.sqlvisibility 欄 + model_shares 表 + index
//
// 可見性 predicatesingle 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-4org_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 對齊。內部 keystorage_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 身份列出可見 modelcursor 分頁)。見檔頭 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)
// 可見性 predicateTDD §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,
}
// filterowned 維度。
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 + descriptionTDD §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 cursorWHERE (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_accessowner > share.role > public/tenant(viewer)。owner 由呼叫端已知owner_user_id=userID
// 但此處 Library 已用 predicate 過濾出可見列,故 access 一定 != none。
// owner 的判斷在 handleris_me這裡計算「非 owner 情境」的 accessowner 情境 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)
}

View 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")
// 空 orgOIDC 現況)→ 不可見(安全預設,即使 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 驗證 filterowned / 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 nameMinor-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 }

View File

@ -185,5 +185,8 @@ func clonePreset(m *Model) *Model {
t := *m.UploadedAt
cp.UploadedAt = &t
}
// preset 是公用模型語意等同全平台可見visibility=public
// 在此統一標記preset 宣告區不必逐筆設 Visibility。
cp.Visibility = VisibilityPublic
return &cp
}

View 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 COLUMNDROP COLUMN 也會連帶移除 constraint
-- 此處顯式先移以求對稱清楚)。
ALTER TABLE models DROP CONSTRAINT IF EXISTS chk_models_visibility;
ALTER TABLE models DROP COLUMN IF EXISTS visibility;

View File

@ -0,0 +1,49 @@
-- 0006_model_sharing.up.sql
--
-- 模型共享Model SharingL 級新功能。在既有 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。
--
-- 環境事實(與 00010005 相同已驗證PostgreSQL 14.23gen_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 CASCADEmodel 硬刪時連帶清 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_idusers 主鍵 join 成本低,
-- owner 維度沿用既有 idx_models_owner_active不另建。

View File

@ -19,6 +19,7 @@ import Link from "next/link";
import { Link2, RefreshCw } from "lucide-react";
import { DeviceList } from "@/components/devices/device-list";
import { DeviceRescanButton } from "@/components/devices/device-rescan-button";
import { Button } from "@/components/ui/button";
import { useT } from "@/lib/i18n/context";
import { useDeviceStore } from "@/stores/device-store";
@ -54,6 +55,9 @@ export default function DevicesPage() {
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
/>
</Button>
{/* USB local agent rescan
DBrescan USB */}
<DeviceRescanButton />
<Link href="/devices/pair">
<Button data-testid="devices-pair-cta">
<Link2 aria-hidden="true" className="mr-2 size-4" />

View File

@ -0,0 +1,147 @@
/**
* ModelProfileClient
*
*
* - owner myAccess=owner +
* - / myAccess=viewer / owner
* - canDownload
* - / 404profileError 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(),
);
});
});

View File

@ -0,0 +1,337 @@
"use client";
/**
* ModelProfileClient profile owner /
*
* §6 + API §2GET /: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 downloadlib/api/model-download endpoint
* §3download
*/
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-storeowner-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>
);
}

View File

@ -1,10 +1,16 @@
import { ModelDetailClient } from "./model-detail-client";
import { ModelProfileClient } from "./model-profile-client";
/**
* profile /models/[id]
*
* ModelProfileClientowner / GET /:id/profile
* owner owner-only ModelDetailClient
*/
export default async function ModelDetailPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = await params;
return <ModelDetailClient id={id} />;
return <ModelProfileClient id={id} />;
}

View 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(),
);
});
});

View File

@ -0,0 +1,213 @@
"use client";
/**
* LibraryClient cursor
*
* §4 + API §1使cursor
*
* §7
* - skeleton
* - + IntersectionObserver loadMore
* - skeleton
* - EmptyState
* - EmptyState + CTA
* - +
*
* debounce 300ms §4.4local 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>
);
}

View File

@ -0,0 +1,11 @@
import { LibraryClient } from "./library-client";
/**
* /models/library
*
* preset
* cursor api-model-sharing.md §1feature-model-sharing-design.md §4
*/
export default function ModelLibraryPage() {
return <LibraryClient />;
}

View File

@ -12,6 +12,8 @@
*/
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { Users } from "lucide-react";
import {
ModelFilters,
@ -19,6 +21,7 @@ import {
} from "@/components/models/model-filters";
import { ModelSection } from "@/components/models/model-section";
import { ModelUploadDialog } from "@/components/models/model-upload-dialog";
import { Button } from "@/components/ui/button";
import { useT } from "@/lib/i18n/context";
import {
type ModelSource,
@ -83,7 +86,15 @@ export default function ModelsPage() {
<h1 className="text-2xl font-bold">{t("models.title")}</h1>
<p className="text-muted-foreground">{t("models.subtitle")}</p>
</div>
<ModelUploadDialog />
<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 />
</div>
</div>
<ModelFilters value={filter} onChange={setFilter} />
<div className="space-y-8">

View File

@ -234,6 +234,20 @@ describe("WorkspaceClient — 切 tab 時 camera 串流 / WS 正確關閉S-1
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 () => {
post.mockResolvedValue({ streamUrl: "/api/camera/stream", sourceType: "camera" });
renderClient();

View File

@ -276,18 +276,23 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
/>
)}
</div>
<div className="flex gap-2">
{isRunning ? (
<Button variant="destructive" onClick={handleStop} disabled={busy}>
{t("workspace.inference.stop")}
</Button>
) : (
// WP-Cserial 為空無法路由 → disable原因見下方 no-serial banner
<Button onClick={handleStart} disabled={busy || !isOnline || !hasSerial}>
{t("workspace.inference.start")}
</Button>
)}
</div>
{/* / camera tab /api/camera/start
image/video/batch camera tab
UIcamera start/stop handleTabChange */}
{activeTab === "camera" && (
<div className="flex gap-2">
{isRunning ? (
<Button variant="destructive" onClick={handleStop} disabled={busy}>
{t("workspace.inference.stop")}
</Button>
) : (
// WP-Cserial 為空無法路由 → disable原因見下方 no-serial banner
<Button onClick={handleStart} disabled={busy || !isOnline || !hasSerial}>
{t("workspace.inference.start")}
</Button>
)}
</div>
)}
</div>
{/* WP-CADR-018serial 為空 → 無法路由到 local agent說明原因與解法 */}

View File

@ -20,6 +20,7 @@
import { useEffect, useState } from "react";
import { AlertCircle, CheckCircle2, Circle, Loader2 } from "lucide-react";
import { formatRelativeTime } from "@/lib/format/relative-time";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { RemoteStatus } from "@/stores/device-store";
@ -36,30 +37,6 @@ export interface RemoteDeviceBadgeProps {
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({
status,
lastSeenAt,

View File

@ -63,3 +63,52 @@ describe("DeviceCard — serial 路由 gatingWP-C / ADR-018", () => {
).not.toBeInTheDocument();
});
});
describe("DeviceCard — 三態分色 + 註冊 UITDD §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();
});
});

View File

@ -23,6 +23,8 @@
import Link from "next/link";
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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
@ -30,6 +32,7 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { deriveTriState } from "@/lib/device-state";
import { useT } from "@/lib/i18n/context";
import { cn } from "@/lib/utils";
import type { DeviceSummary } from "@/stores/device-store";
@ -44,15 +47,22 @@ export function DeviceCard({ device }: DeviceCardProps) {
const isOnline = device.remoteStatus === "online";
// WP-CADR-018serial 為空 → 工作區(推論類操作)無法路由,入口 disable。
const hasSerial = !!device.serialNumber;
// 三態TDD §5.2online-unregistered = 已連接未註冊(第三態,走 warning 色)。
const triState = deriveTriState(device);
const isOnlineUnregistered = triState === "online-unregistered";
const isRegistered = !!device.registeredAt;
return (
<Card
data-testid="device-card"
data-remote-status={device.remoteStatus}
data-tri-state={triState}
className={cn(
"transition-colors",
// 離線裝置 opacity-75flow-offline-handling §4.1
!isOnline && device.remoteStatus !== "reconnecting" && "opacity-75",
// 第三態「已連接未註冊」warning 色邊框(配合角落 UnregisteredBadge 的文字+icon不只靠色
isOnlineUnregistered && "border-warning",
)}
>
<CardHeader className="pb-3">
@ -63,11 +73,15 @@ export function DeviceCard({ device }: DeviceCardProps) {
<p className="text-muted-foreground truncate text-xs">{device.name}</p>
)}
</div>
<RemoteDeviceBadge
status={device.remoteStatus}
lastSeenAt={device.lastSeenAt ?? null}
size="sm"
/>
<div className="flex shrink-0 flex-col items-end gap-1.5">
<RemoteDeviceBadge
status={device.remoteStatus}
lastSeenAt={device.lastSeenAt ?? null}
size="sm"
/>
{/* 第三態標記:連線與註冊是正交兩軸,未註冊用獨立 warning pill 疊加(不塞進連線 badge。 */}
{isOnlineUnregistered && <UnregisteredBadge size="sm" />}
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
@ -93,6 +107,11 @@ export function DeviceCard({ device }: DeviceCardProps) {
{t("common.manage")}
</Button>
</Link>
{/* 註冊 /
offline */}
{(isOnlineUnregistered || isRegistered) && (
<DeviceRegisterActions device={device} size="sm" />
)}
{isOnline && device.flashedModel && hasSerial && (
<Link href={`/workspace/${device.id}`}>
<Button size="sm">{t("devices.openWorkspace")}</Button>

View 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>
);
}

View File

@ -1,45 +1,56 @@
"use client";
/**
* DeviceList + + skeleton
* DeviceList + + skeleton + /filter
*
* `local-tool/frontend/src/components/devices/device-list.tsx`
*
*
* - `.autoflow/03-design/pages.md` §5.3
* - `docs/autoflow/04-architecture/feature-device-mgmt-tdd.md` §6 + filter
*
*
* - `/devices/pair`F7 Pairing scan
* - online reconnecting unknown offline error
* - / /
* - filter / / / + filter
*/
import { useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Link2 } from "lucide-react";
import { Link2, SearchX } from "lucide-react";
import { DeviceCard } from "@/components/devices/device-card";
import { DeviceListControls } from "@/components/devices/device-list-controls";
import { EmptyState } from "@/components/ui/empty-state";
import { Skeleton } from "@/components/ui/skeleton";
import {
applyDeviceListView,
type DeviceFilterKey,
type DeviceSortKey,
} from "@/lib/device-state";
import { useT } from "@/lib/i18n/context";
import type { DeviceSummary, RemoteStatus } from "@/stores/device-store";
import type { DeviceSummary } from "@/stores/device-store";
interface DeviceListProps {
devices: DeviceSummary[];
loading?: boolean;
}
const STATUS_ORDER: Record<RemoteStatus, number> = {
online: 0,
reconnecting: 1,
unknown: 2,
offline: 3,
error: 4,
};
export function DeviceList({ devices, loading }: DeviceListProps) {
const t = useT();
const router = useRouter();
// 排序 / filter 狀態存 local stateP0 不持久化TDD §6.2)。
const [sortKey, setSortKey] = useState<DeviceSortKey>("status");
const [filter, setFilter] = useState<DeviceFilterKey>("all");
// 先 filter 再 sortTDD §6.3devices / 條件變動才重算。
const visible = useMemo(
() => applyDeviceListView(devices, filter, sortKey),
[devices, filter, sortKey],
);
if (loading) {
return (
<div
@ -53,6 +64,7 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
);
}
// 完全沒有裝置(非 filter 造成)→ 導向配對的既有空狀態。
if (devices.length === 0) {
return (
<EmptyState
@ -74,29 +86,49 @@ export function DeviceList({ devices, loading }: DeviceListProps) {
);
}
const sorted = [...devices].sort(
(a, b) => STATUS_ORDER[a.remoteStatus] - STATUS_ORDER[b.remoteStatus],
);
return (
<div
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
data-testid="device-list"
>
{sorted.map((device) => (
<DeviceCard key={device.id} device={device} />
))}
{/* 附一個 CTA 讓使用者能配對更多裝置,避免空間死角 */}
<Link
href="/devices/pair"
data-testid="pair-new-device-cta"
className="border-border hover:border-primary hover:bg-accent focus-visible:ring-ring flex min-h-[12rem] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<Link2 aria-hidden="true" className="text-muted-foreground size-6" />
<span className="text-muted-foreground text-sm font-medium">
{t("devices.addMore")}
</span>
</Link>
<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
className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"
data-testid="device-list"
>
{visible.map((device) => (
<DeviceCard key={device.id} device={device} />
))}
{/* 附一個 CTA 讓使用者能配對更多裝置,避免空間死角 */}
<Link
href="/devices/pair"
data-testid="pair-new-device-cta"
className="border-border hover:border-primary hover:bg-accent focus-visible:ring-ring flex min-h-[12rem] flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed p-6 text-center transition-colors focus-visible:ring-2 focus-visible:outline-none"
>
<Link2 aria-hidden="true" className="text-muted-foreground size-6" />
<span className="text-muted-foreground text-sm font-medium">
{t("devices.addMore")}
</span>
</Link>
</div>
)}
</div>
);
}

View File

@ -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
* unpairunregister 退
* 使
* - 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>
);
}

View File

@ -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();
});
});

View File

@ -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>
);
}

View File

@ -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 iconTriangleAlert+
*/
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>
);
}

View 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
*
* §4response 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}
/>
)}
</>
);
}

View 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 parentparent 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>
);
}

View File

@ -0,0 +1,49 @@
"use client";
/**
* ModelOwnerBar
*
* §6.4 owner profile {ownerName} · {time}
*
* §4response 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>
);
}

View File

@ -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 人");
});
});

View File

@ -0,0 +1,100 @@
"use client";
/**
* ModelVisibilityBadge
*
* + 滿 De1
* - privateLockowner
* - publicGlobechart-2 tint visionA 使
* - tenantBuilding2chart-1 tint
*
* sharedWithMe=true Userschart-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>
);
}

View File

@ -0,0 +1,211 @@
/**
* ModelVisibilityDialog
*
*
* - / /
* - public amber
* - email inline store.addShare
* - +
* - store.updateVisibility + toast.success +
*
* store mock _setMockMode true+ shares API
* Radix Dialog / RadioGroup jsdomdialog 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();
});
});

View File

@ -0,0 +1,385 @@
"use client";
/**
* ModelVisibilityDialog owner-only
*
* §5 + API §3PATCH visibility+ shares API§4
*
*
* - RadioGroup private / public / tenant
* API visibility PRD restricted model_shares
* visibility
* - model_sharesemail + 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 remountstate 重新以 props 初始化。 */}
{open && <VisibilityDialogBody key={props.modelId} {...props} />}
</Dialog>
);
}
/**
* Dialog body open state props setState-in-effect
* sharesexternal 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 syncmodelId 於本 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>
</>
);
}

View 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 };

View 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 };
}

View File

@ -0,0 +1,155 @@
/**
* Model Sharing Mock Fixtures
*
* API mockapi-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/ ownershared_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 = base64mock
*/
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 },
],
};

View File

@ -0,0 +1,180 @@
/**
* Model Sharing API Client
*
*
* - normalizesnake_case camelCasevisibility / access
* - mock cursor filterdeterministic 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 陣列 → undefinedUI 有值才顯示)", () => {
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);
});
});

View 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 / tenantprivate 不在共享庫語意內。 */
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 的顯示 emailowner 自己輸入的授權目標,可顯示)。 */
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`400visibility / 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 包成 ModelSharingErrorcode 統一小寫)。 */
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 wrappercookie sessionenvelopeApiError 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_failedvisibility / 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 emailP0 viewer
*
* @throws {ModelSharingError} 400 validation_failedemail / 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());
}

View 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.2online×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("namedisplayNamealias 優先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("registeredAtdesc新在前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=nameA→Zb、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([]);
});
});

View 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";
}
/* -------------------------------------------------------------------------- */
/* 排序 + filterTDD §6client-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
*
* - statusREMOTE_STATUS_ORDERlocaleCompare
* - namedisplayName localeCompareAZ
* - registeredAtdescnull
*/
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 再 sortTDD §6.3:組合順序)。 */
export function applyDeviceListView(
devices: DeviceSummary[],
filter: DeviceFilterKey,
sortKey: DeviceSortKey,
): DeviceSummary[] {
return sortDevices(filterDevices(devices, filter), sortKey);
}

View 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}`;
}

View File

@ -106,19 +106,19 @@ export const en: Dictionary = {
"dashboard.flashes": "Flashes",
"dashboard.connectedDevices": "Online devices",
"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.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.browseModels": "Browse models",
"dashboard.manageDevices": "Manage devices",
"dashboard.uploadModel": "Upload model",
"dashboard.pairDevice": "Pair device",
"dashboard.pairDevice": "Connect computer",
"dashboard.empty.title": "No devices yet",
"dashboard.empty.description":
"Pair your first Kneron device to start running inference from anywhere.",
"dashboard.empty.action": "Pair a device",
"Connect your computer to start using Kneron devices from anywhere.",
"dashboard.empty.action": "Connect computer",
"dashboard.activity.justNow": "just now",
"dashboard.activity.minutesAgo": "{n} minutes ago",
"dashboard.activity.hoursAgo": "{n} hours ago",
@ -131,13 +131,13 @@ export const en: Dictionary = {
"devices.firmware": "Firmware",
"devices.flashedModel": "Flashed model",
"devices.openWorkspace": "Open workspace",
"devices.addMore": "Pair a new device",
"devices.pairAction": "Pair a new device",
"devices.empty.title": "No devices paired yet",
"devices.addMore": "Connect a new computer",
"devices.pairAction": "Connect a new computer",
"devices.empty.title": "No computers connected yet",
"devices.empty.description":
"Run local agent on your computer and complete pairing to access your Kneron devices from anywhere.",
"devices.empty.action": "Pair your first device",
"devices.empty.secondaryAction": "How pairing works",
"Run local agent on your computer and complete the connection to access your Kneron devices from anywhere.",
"devices.empty.action": "Connect your computer",
"devices.empty.secondaryAction": "How connecting works",
"devices.detail.id": "ID",
"devices.detail.type": "Type",
"devices.detail.firmware": "Firmware",
@ -146,7 +146,7 @@ export const en: Dictionary = {
"devices.detail.modelStatus": "Model status",
"devices.detail.readyForInference": "Ready for inference",
"devices.detail.noModelFlashed": "No model has been flashed",
"devices.detail.pairedAt": "Paired at",
"devices.detail.pairedAt": "Connected at",
"devices.detail.hostName": "Host",
"devices.detail.lastSeen": "Last seen",
"devices.detail.offlineBanner.title": "This device is offline",
@ -165,14 +165,14 @@ export const en: Dictionary = {
"devices.serial.label": "Serial number",
"devices.serial.missing": "Serial not reported yet",
"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.action": "Remove device",
"devices.remove.removing": "Removing…",
"devices.remove.confirm.title": "Remove this device?",
"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.toast.success": "Device removed",
"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.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.flashModel": "Load model",
"devices.flash.flashToDevice": "Load a model to this device",
@ -242,7 +289,7 @@ export const en: Dictionary = {
"models.filters.all": "All",
"models.empty.title": "No models yet",
"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.section.preset": "Preset models",
"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.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.title": "Workspace",
"workspace.subtitle": "Select an online device to start inference",
"workspace.empty.title": "No devices are online",
"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.header.backToDevices": "Back to devices",
"workspace.header.title": "Workspace",
@ -343,7 +462,7 @@ export const en: Dictionary = {
"workspace.offline.backToList": "Back to devices",
"workspace.noSerial.title": "This device hasn't reported a serial number",
"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.image": "Image",
"workspace.tabs.video": "Video",
@ -400,35 +519,35 @@ export const en: Dictionary = {
"settings.advanced.platform": "Platform",
// ── Pairing (F7) ──
"pairing.title": "Pair a new device",
"pairing.title": "Connect this computer",
"pairing.subtitle":
"Connect your Kneron device to the cloud so you can operate it from anywhere.",
"pairing.token.title": "Your pairing token",
"Connect this computer to the cloud so its Kneron devices can be operated from anywhere.",
"pairing.token.title": "Your connection token",
"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.copied": "Copied",
"pairing.regenerate": "Regenerate",
"pairing.timeRemaining": "{time} remaining",
"pairing.generatedAt": "Generated at {time}",
"pairing.token.expired.label": "This token has expired — please regenerate.",
"pairing.regenerateConfirm.title": "Regenerate token?",
"pairing.token.expired.label": "This connection token has expired — please regenerate.",
"pairing.regenerateConfirm.title": "Regenerate connection token?",
"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":
"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":
"Tokens are single-use and expire automatically after pairing.",
"pairing.toast.copied": "Token copied — valid for 15 minutes.",
"pairing.toast.generateFailed": "Could not generate token — please retry.",
"Connection tokens are single-use and expire automatically after connecting.",
"pairing.toast.copied": "Connection token copied — valid for 15 minutes.",
"pairing.toast.generateFailed": "Could not generate connection token — please retry.",
"pairing.toast.expiringSoon":
"Token expiring soon — complete pairing or regenerate.",
"pairing.toast.pairedSuccess": "Device {deviceName} paired successfully.",
"Connection token expiring soon — complete the connection or regenerate.",
"pairing.toast.pairedSuccess": "Computer connected — device {deviceName} detected.",
"pairing.toast.cliCopied": "CLI command copied.",
"pairing.device.unknown": "Unknown device",
"pairing.cli.title": "CLI example",
"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.hint":
"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.hints.running": "Confirm local agent is running",
"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":
"Confirm your network can reach the cloud endpoint",
"pairing.step3.success": "Connected!",

View File

@ -109,17 +109,17 @@ export const zhHant: Dictionary = {
"dashboard.connected": "線上裝置",
"dashboard.flashes": "已燒錄次數",
"dashboard.connectedDevices": "線上裝置",
"dashboard.noConnectedDevices": "目前沒有裝置線上。配對一台 Kneron 裝置開始雲端推論。",
"dashboard.noConnectedDevices": "目前沒有裝置線上。連接你的電腦,就能從雲端使用 Kneron 裝置。",
"dashboard.recentActivity": "近期活動",
"dashboard.noActivity": "還沒有任何活動。配對裝置、上傳模型或跑一次推論後就會出現。",
"dashboard.noActivity": "還沒有任何活動。連接電腦、上傳模型或跑一次推論後就會出現。",
"dashboard.quickActions": "快速操作",
"dashboard.browseModels": "瀏覽模型",
"dashboard.manageDevices": "管理裝置",
"dashboard.uploadModel": "上傳模型",
"dashboard.pairDevice": "配對裝置",
"dashboard.pairDevice": "連接電腦",
"dashboard.empty.title": "還沒有任何裝置",
"dashboard.empty.description": "配對你的第一台 Kneron 裝置,開始雲端推論之旅",
"dashboard.empty.action": "配對裝置",
"dashboard.empty.description": "連接你的電腦,開始從雲端使用 Kneron 裝置",
"dashboard.empty.action": "連接電腦",
"dashboard.activity.justNow": "剛剛",
"dashboard.activity.minutesAgo": "{n} 分鐘前",
"dashboard.activity.hoursAgo": "{n} 小時前",
@ -132,13 +132,13 @@ export const zhHant: Dictionary = {
"devices.firmware": "韌體",
"devices.flashedModel": "已燒錄模型",
"devices.openWorkspace": "開啟工作區",
"devices.addMore": "配對新裝置",
"devices.pairAction": "配對新裝置",
"devices.empty.title": "還沒有配對的裝置",
"devices.addMore": "連接新電腦",
"devices.pairAction": "連接新電腦",
"devices.empty.title": "還沒有連接任何電腦",
"devices.empty.description":
"在你的電腦上執行 local agent 並完成配對,就能從任何地方存取你的 Kneron 裝置",
"devices.empty.action": "配對第一台裝置",
"devices.empty.secondaryAction": "查看配對說明",
"在你的電腦上執行 local agent 並完成連接,就能從任何地方存取你的 Kneron 裝置",
"devices.empty.action": "連接你的電腦",
"devices.empty.secondaryAction": "查看連接說明",
"devices.detail.id": "ID",
"devices.detail.type": "類型",
"devices.detail.firmware": "韌體",
@ -147,7 +147,7 @@ export const zhHant: Dictionary = {
"devices.detail.modelStatus": "模型狀態",
"devices.detail.readyForInference": "已就緒,可開始推論",
"devices.detail.noModelFlashed": "尚未燒錄任何模型",
"devices.detail.pairedAt": "配對時間",
"devices.detail.pairedAt": "連接時間",
"devices.detail.hostName": "所在電腦",
"devices.detail.lastSeen": "最後心跳",
"devices.detail.offlineBanner.title": "此裝置目前離線",
@ -166,14 +166,14 @@ export const zhHant: Dictionary = {
"devices.serial.label": "序號",
"devices.serial.missing": "尚未回報序號",
"devices.serial.missingHint":
"此裝置尚未回報序號,無法執行推論相關操作。請在 local agent 重新配對一次,序號回報後即可使用。",
"此裝置尚未回報序號,無法執行推論相關操作。請在 local agent 重新連接一次,序號回報後即可使用。",
// ── Devices: 移除裝置unpair ──
"devices.remove.action": "移除裝置",
"devices.remove.removing": "移除中…",
"devices.remove.confirm.title": "確定要移除此裝置?",
"devices.remove.confirm.description":
"這會解除「{name}」與你帳號的配對並撤銷其存取權限。若要再次使用,需從 local agent 重新配對。此操作無法復原。",
"這會解除「{name}」與你帳號的連接並撤銷其存取權限。若要再次使用,需從 local agent 重新連接。此操作無法復原。",
"devices.remove.confirm.action": "移除",
"devices.remove.toast.success": "已移除裝置",
"devices.remove.error.title": "移除裝置失敗",
@ -181,6 +181,51 @@ export const zhHant: Dictionary = {
"devices.remove.error.NOT_FOUND": "此裝置已不存在",
"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.flashModel": "載入模型",
"devices.flash.flashToDevice": "載入模型到此裝置",
@ -242,7 +287,7 @@ export const zhHant: Dictionary = {
"models.filters.all": "全部",
"models.empty.title": "還沒有任何模型",
"models.empty.description":
"上傳你的第一個 .nef 模型到雲端,就能部署到任何一台配對過的 Kneron 裝置",
"上傳你的第一個 .nef 模型到雲端,就能部署到任何一台已連接電腦上的 Kneron 裝置",
"models.empty.action": "上傳第一個模型",
"models.section.preset": "預設模型",
"models.section.converted": "我轉檔的",
@ -301,11 +346,83 @@ export const zhHant: Dictionary = {
"models.download.error.busy": "已有下載進行中,請稍候",
"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.title": "推論工作區",
"workspace.subtitle": "選擇已線上的裝置開始推論",
"workspace.empty.title": "目前沒有線上裝置",
"workspace.empty.description": "請先配對並確認 local agent 已連上雲端",
"workspace.empty.description": "請先連接你的電腦並確認 local agent 已連上雲端",
"workspace.empty.action": "前往裝置管理",
"workspace.header.backToDevices": "返回裝置",
"workspace.header.title": "工作區",
@ -331,7 +448,7 @@ export const zhHant: Dictionary = {
"workspace.offline.backToList": "返回裝置列表",
"workspace.noSerial.title": "此裝置尚未回報序號",
"workspace.noSerial.description":
"推論、攝影機與媒體上傳需要裝置序號才能路由到 local agent。請在 local agent 重新配對一次,序號回報後即可操作。",
"推論、攝影機與媒體上傳需要裝置序號才能路由到 local agent。請在 local agent 重新連接一次,序號回報後即可操作。",
"workspace.tabs.camera": "Camera",
"workspace.tabs.image": "圖片",
"workspace.tabs.video": "影片",
@ -387,42 +504,42 @@ export const zhHant: Dictionary = {
"settings.advanced.platform": "平台",
// ── PairingF7 新增)──
"pairing.title": "配對新裝置",
"pairing.subtitle": "讓你的 Kneron 裝置連上雲端,就能從任何地方遠端操作",
"pairing.token.title": "你的 Pairing Token",
"pairing.title": "連接這台電腦",
"pairing.subtitle": "讓這台電腦連上雲端,之後電腦上的 Kneron 裝置都能從任何地方遠端操作",
"pairing.token.title": "你的連接碼",
"pairing.step1.description":
"複製下方 token在 15 分鐘內貼到 local agent 完成配對",
"複製下方連接碼,在 15 分鐘內貼到 local agent 完成連接",
"pairing.copy": "複製",
"pairing.copied": "已複製",
"pairing.regenerate": "重新產生",
"pairing.timeRemaining": "剩餘 {time}",
"pairing.generatedAt": "產生時間:{time}",
"pairing.token.expired.label": "此 token 已過期,請重新產生",
"pairing.regenerateConfirm.title": "確定要重新產生",
"pairing.token.expired.label": "此連接碼已過期,請重新產生",
"pairing.regenerateConfirm.title": "確定要重新產生連接碼",
"pairing.regenerateConfirm.description":
"舊 token 將立即失效,新 token 有效期 15 分鐘",
"pairing.security.warning": "這組 token 15 分鐘內有效,請立刻完成配對",
"pairing.security.oneTime": "token 是一次性使用,完成配對後自動失效",
"pairing.toast.copied": "Token 已複製到剪貼簿15 分鐘內有效",
"pairing.toast.generateFailed": "無法產生 token,請重試",
"pairing.toast.expiringSoon": "Token 即將過期,請立刻完成或重新產生",
"pairing.toast.pairedSuccess": "裝置 {deviceName} 已成功配對",
"舊連接碼將立即失效,新連接碼有效期 15 分鐘",
"pairing.security.warning": "這組連接碼 15 分鐘內有效,請立刻完成連接",
"pairing.security.oneTime": "連接碼是一次性使用,完成連接後自動失效",
"pairing.toast.copied": "連接碼已複製到剪貼簿15 分鐘內有效",
"pairing.toast.generateFailed": "無法產生連接碼,請重試",
"pairing.toast.expiringSoon": "連接碼即將過期,請立刻完成或重新產生",
"pairing.toast.pairedSuccess": "已成功連接電腦,偵測到裝置 {deviceName}",
"pairing.toast.cliCopied": "指令已複製到剪貼簿",
"pairing.device.unknown": "未知裝置",
"pairing.cli.title": "CLI 指令範例",
"pairing.cli.description":
"在你的電腦啟動 local agent token 貼到指令的 --relay-token 參數",
"在你的電腦啟動 local agent連接碼貼到指令的 --relay-token 參數",
"pairing.cli.copy": "複製指令",
"pairing.cli.hint":
"local agent 連上雲端後,本頁會自動偵測並跳轉到裝置列表",
"pairing.step3.waiting": "等待 local agent 連線…",
"pairing.step3.elapsed": "已等待 {time}(最長 3 分鐘)",
"pairing.step3.hints.running": "確認 local agent 已啟動",
"pairing.step3.hints.token": "確認 token 貼上時無缺字或多餘空白",
"pairing.step3.hints.token": "確認連接碼貼上時無缺字或多餘空白",
"pairing.step3.hints.network": "確認你的網路可連線到雲端",
"pairing.step3.success": "已成功連",
"pairing.step3.success": "已成功連接電腦",
"pairing.step3.success.detected": "檢測到的裝置",
"pairing.step3.failure.timeout": "連超時",
"pairing.step3.failure.timeout": "連超時",
"pairing.step3.failure.reason":
"超過 3 分鐘沒收到 local agent 連線,可能是 local agent 尚未啟動",
"pairing.step3.failure.retry": "重新檢查",

View File

@ -23,6 +23,8 @@ beforeEach(() => {
connectingId: null,
disconnectingId: null,
unpairingId: null,
registeringId: null,
isRescanning: false,
error: null,
});
// OF2api.ts 不再需要 token gettercookie session 由瀏覽器自動帶)
@ -412,3 +414,351 @@ describe("useDeviceStore.unpairDevice", () => {
expect(useDeviceStore.getState().unpairingId).toBeNull();
});
});
/* -------------------------------------------------------------------------- */
/* 註冊軸normalizeDevice registeredAt + register / unregister actions */
/* -------------------------------------------------------------------------- */
describe("useDeviceStore — registeredAt 正規化TDD §5.1", () => {
it("registered_atsnake/ registeredAtcamel有值 → 正確帶入;缺欄 → 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 endpointUUID、就地更新 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_versionomitempty
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 設為該 idloading 態)", 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_DEVICErepresentative→ 回 { 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:truelist 保留", 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 endpointPOST、接著 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 });
// 第一發是 scanPOST第二發是 fetchDevicesGET
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 設為 trueloading 態),完成後清回 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);
});
});

View File

@ -89,6 +89,13 @@ export interface DeviceSummary {
status: DeviceHardwareStatus;
/** 遠端 tunnel 狀態flow-offline-handling.md §2 新增) */
remoteStatus: RemoteStatus;
/**
* ISO 8601 feature-device-mgmt-tdd.md §5
* JSON key `registered_at`omitemptynull / =
* lib/device-state.ts deriveTriStateonline registeredAt != null
* online null
*/
registeredAt?: string | null;
/** ISO 8601最後心跳時間 */
lastSeenAt?: string | null;
firmwareVersion?: string | null;
@ -152,6 +159,9 @@ function normalizeDevice(raw: unknown): Device {
status: coerceHardwareStatus(pick<string>("status")),
remoteStatus:
tunnelOnline === true ? "online" : (rawRemoteStatus ?? "unknown"),
// 註冊軸TDD §5.1):後端回 registered_atomitempty— 缺欄 / null 皆視為未註冊。
// 沿用既有 pick snake/camel 相容範式;不做時間格式驗證(後端保證 ISO 8601
registeredAt: pick<string>("registered_at", "registeredAt") ?? null,
lastSeenAt: pick<string>("last_seen_at", "lastSeenAt") ?? null,
firmwareVersion:
pick<string>("firmware_version", "firmwareVersion") ?? null,
@ -179,6 +189,33 @@ export type UnpairResult =
| { ok: true }
| { ok: false; code: string; message: string };
/**
* register / unregister action
*
* unpair code boolean UI toast
* - register409 `ALREADY_REGISTERED`409 `REPRESENTATIVE_DEVICE`representative
* 403 `FORBIDDEN` owner404 `NOT_FOUND` api-device-mgmt.md §3
* - unregister 200 403 / 404 / representativeREPRESENTATIVE_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 {
devices: DeviceSummary[];
selectedDevice: Device | null;
@ -188,6 +225,10 @@ interface DeviceState {
disconnectingId: string | null;
/** 移除unpair中的裝置 idUI 顯示 button spinner / disable 確認鈕);不使用就是 null */
unpairingId: string | null;
/** 註冊 / 取消註冊進行中的裝置 idUI 顯示 button spinner不使用就是 null */
registeringId: string | null;
/** 重新掃描 USB 裝置進行中UI 顯示按鈕 spinner + disable不使用就是 false */
isRescanning: boolean;
error: string | null;
/** 呼叫 `GET /api/devices` */
@ -207,19 +248,44 @@ interface DeviceState {
disconnectDevice: (serialNumber: string) => Promise<boolean>;
/** 呼叫 `POST /api/devices/:id/unpair`(軟刪裝置 + cascade 撤銷 pairing/session token */
unpairDevice: (id: string) => Promise<UnpairResult>;
/**
* `POST /api/devices/:id/register`UUID DB
* registered_at NULL now()
* registeredAt refetch
* connect connect serial register UUIDADR-018 FE-ADB UUID
*/
registerDevice: (id: string) => Promise<RegisterResult>;
/**
* `POST /api/devices/:id/unregister`UUID
* 退registered_at NULL**** token
* unpairunregister registeredAtdevice
*/
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 */
_setDevices: (devices: DeviceSummary[]) => void;
/** 測試 / 雛形用:直接塞 selected */
_setSelected: (device: Device | null) => void;
}
export const useDeviceStore = create<DeviceState>()((set) => ({
export const useDeviceStore = create<DeviceState>()((set, get) => ({
devices: [],
selectedDevice: null,
isLoading: false,
connectingId: null,
disconnectingId: null,
unpairingId: null,
registeringId: null,
isRescanning: false,
error: null,
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 → 回更新後的 DeviceListItemregistered_at 非 null
// api.post 已 unwrap envelope 的 datanormalizeDevice 讀出 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。回更新後 DeviceListItemregistered_at=null
await api.post<unknown>(
`/api/devices/${encodeURIComponent(id)}/unregister`,
);
// 就地把該筆 registeredAt 清成 nulldevice 仍留在 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 codeTUNNEL_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 }),
_setSelected: (selectedDevice) => set({ selectedDevice }),
}));

View File

@ -0,0 +1,183 @@
/**
* Model Sharing Store mock deterministic
*
*
* - loadFirstPage cursor / hasMore
* - loadMoreappend hasMore=false
* - loadMore cursor /
* - setFilters
* - loadProfilemock / 404
* - updateVisibility / addShare / removeShare
* - filtersToQueryUI 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 內部呼叫 loadFirstPageasync等 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 → 加入 sharesremoveShare → 移除", 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");
});
});

View File

@ -0,0 +1,366 @@
/**
* Model Sharing Store visionA Cloud L
*
*
* 1. cursor + / filter /
* 2. profile
* 3. Dialogvisibility + 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"
);
}
/* -------------------------------------------------------------------------- */
/* 列表篩選 / 排序狀態 */
/* -------------------------------------------------------------------------- */
/** 共享庫的可見性 filterUI 用all = 不過濾)。 */
export type LibraryVisibilityFilter = "all" | "public" | "tenant";
/** 擁有關係 filterUI 用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.6desktop 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 codenull = 無錯誤。 */
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 {
// 載入授權清單失敗時清空 + 停止 loadingDialog 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 }),
}));

View File

@ -36,6 +36,8 @@ export type KnownErrorCode =
| "FORBIDDEN"
| "NOT_FOUND"
| "VALIDATION_FAILED"
| "REPRESENTATIVE_DEVICE" // 409representative device 不可 register/unregisterapi-device-mgmt.md §3backend 實際回碼)
| "ALREADY_REGISTERED" // 409register 時裝置已註冊api-device-mgmt.md §3本功能新增
| "TUNNEL_DISCONNECTED"
| "TUNNEL_ERROR"
| "NOT_IMPLEMENTED"