From cff9236699e8f4f0b6f98e0de7a17029604d6509 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Fri, 1 May 2026 10:59:21 +0800 Subject: [PATCH] docs: migrate Autoflow shared documents to docs/autoflow/ Move PRD, design specs, architecture docs, and TDD from .autoflow/ (personal/per-branch layer) to docs/autoflow/ (shared layer that goes into git) per the new Autoflow workspace layout. Files moved: - 02-prd/PRD.md - 03-design/design-review.md - 03-design/user-flow-cross-system.md - 04-architecture/TDD.md - 04-architecture/design-doc.md - 04-architecture/security.md The originals were never tracked, so git mv reduced to a filesystem rename with no history to preserve. .autoflow/ remains for personal notes (progress.md, review reports, testing logs). Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/autoflow/02-prd/PRD.md | 1134 ++++++++++++++ docs/autoflow/03-design/design-review.md | 460 ++++++ .../03-design/user-flow-cross-system.md | 353 +++++ docs/autoflow/04-architecture/TDD.md | 1392 +++++++++++++++++ docs/autoflow/04-architecture/design-doc.md | 724 +++++++++ docs/autoflow/04-architecture/security.md | 347 ++++ 6 files changed, 4410 insertions(+) create mode 100644 docs/autoflow/02-prd/PRD.md create mode 100644 docs/autoflow/03-design/design-review.md create mode 100644 docs/autoflow/03-design/user-flow-cross-system.md create mode 100644 docs/autoflow/04-architecture/TDD.md create mode 100644 docs/autoflow/04-architecture/design-doc.md create mode 100644 docs/autoflow/04-architecture/security.md diff --git a/docs/autoflow/02-prd/PRD.md b/docs/autoflow/02-prd/PRD.md new file mode 100644 index 0000000..975f1ba --- /dev/null +++ b/docs/autoflow/02-prd/PRD.md @@ -0,0 +1,1134 @@ +# Kneron Model Converter - 產品需求文件 (PRD) + +> **文件性質**:此 PRD 為從既有程式碼反向整理,尚待產品負責人確認。 +> 標記 `[推測]` 的部分為從程式碼推斷但無法確認的內容,請使用者審閱時特別留意。 +> +> **最後更新**:2026-04-25(原始模型上傳路徑變更:visionA-backend 直接 multipart 上傳到 Converter,不經過 File Access Agent) +> +> ## 變更歷程 +> +> ### 2026-04-25(第二次更新):原始模型上傳路徑變更 +> +> - **變更內容**:原始模型的上傳路徑由「visionA-backend → File Access Agent(使用者模型庫)→ Converter 用 S2S 從 File Access Agent 拉下來」改為「visionA-backend 直接把原始模型以 multipart 上傳到 Converter(跟現有 Web UI 一樣)」。 +> - **變更原因**:先前 PRD 基於誤解,把使用者模型庫設計為「原始模型的來源」;使用者已澄清,正確設計是「模型庫只在使用者按『加進模型庫』後才存入轉檔結果」。原始模型上傳是從 visionA-backend 直接推給 Converter。 +> - **影響章節**: +> - §4.3(F-28 刪除、F-23 描述重寫) +> - §4.4(US-08 前置條件與驗收標準重寫) +> - §5.5(POST /api/v1/jobs 描述改為 multipart) +> - §5.6(OAuth2 Client 讀檔相關 scope 移除) +> - §9.2.1(POST /jobs 的 p95 目標調整,標註大檔上傳時間不計入) +> - §10(路線圖移除「從 File Access Agent 取原始模型」) +> - §14(Phase 1 對 File Access Agent 只剩 PUT(promote),不需要 GET、HEAD) +> - §15(Phase 1 Scope 表移除「從 File Access Agent 取原始模型」) +> - §12.2(大檔 promote 風險表述維持;新增 multipart 上傳相關風險) +> - 附錄 A(補充本次變更造成的新問題) +> - **不影響章節**: +> - 既有 Persona A / B 定義 +> - 既有 US-01 ~ US-07 +> - 既有 `[推測]` 標記 +> - US-13(promote 流程) +> - US-14(Phase 2 delegated download) +> - 既有「Crash 即 Reset」設計哲學 +> +> ### 2026-04-25(首次更新):新增 L 級功能(對外 API) +> +> 將 Kneron Model Converter 從「僅有 Web UI 的內部工具」擴展為「對外提供 OAuth2 保護 REST API 的服務」,讓 Innovedus 生態中的其他服務(首個消費者為 VisionA)能以程式化方式呼叫轉檔能力。 +> +> 新增章節: +> - §1.2 新增 **Persona C — 服務消費者(visionA-backend)** +> - §4.3 **API 消費者功能總覽**(Phase 1 / Phase 2 切分) +> - §4.4 **新增的 User Stories(US-08 ~ US-14)** +> - §5.5 **新增 API 端點預告(對外 API / v1)** +> - §8.1 **RICE 新增項目:API 對外開放** +> - §9.1 **新增成功指標(API SLA、採用度、錯誤率)** +> - §14 **相依服務(新增章節)** +> - §15 **Phase 1 / Phase 2 Scope 切分(新增章節)** +> +> 保留不變: +> - 既有 Persona A / B、既有 User Stories US-01 ~ US-07 +> - 既有 Web UI 行為(Phase 1 不改 Web UI 流程) +> - 既有「Crash 即 Reset」設計哲學 +> - 既有 `[推測]` 標記(本次不清除,留待使用者決定) + +--- + +## 1. 產品策略 + +### 1.1 產品願景 + +**一句話描述**:讓 AI 工程師能透過 Web 介面,輕鬆地將 AI 模型轉換為可部署到 Kneron AI 晶片的格式,並透過任務佇列機制解決高併發下的效能瓶頸。 + +**背景問題**:Kneron Toolchain 的模型轉換流程(ONNX → BIE → NEF)在單一 VM / 單一 Toolchain instance 運行時,高併發場景下會造成 CPU 爆滿與 Crash。現有的轉換方式需要工程師手動操作 CLI 或 Python API,缺乏視覺化介面與任務管理能力。 + +**解決方案**:建構一個 Web-based 的模型轉換服務,採用「Crash 即 Reset」的設計哲學,將轉換流程拆分為可水平擴充的 Worker Pool,由 Task Scheduler 統一派工與狀態管理,提供即時任務追蹤與結果下載。 + +### 1.2 目標用戶(Persona) + +#### Persona A:AI 應用工程師 — 小明 + +- **角色**:AI 應用開發工程師,負責將訓練好的模型部署到 Kneron 晶片 +- **背景**:熟悉 Python、ONNX 模型格式,但不一定熟悉 Kneron Toolchain 的底層細節 +- **目標**:快速將 ONNX/TFLite 模型轉換為 NEF 格式,部署到 Kneron KDP 系列晶片 +- **痛點**: + - 手動使用 Toolchain CLI 操作繁瑣,容易出錯 + - 多人同時轉換時系統容易當機 + - 轉換過程耗時,無法即時掌握進度 +- **技術素養**:高 +- **一句話描述**:「我希望能一鍵上傳模型就自動完成轉換,這樣我就可以專注在模型調校而非部署工具的操作」 + +#### Persona B:[推測] 團隊主管 / 技術經理 — 大華 + +- **角色**:管理多位 AI 工程師的技術主管 +- **目標**:提供團隊穩定的模型轉換服務,減少因工具問題導致的開發阻塞 +- **痛點**: + - 單人佔用 Toolchain 時其他人無法使用 + - Crash 後需要 IT 介入重啟 + - 缺乏任務可見性,無法掌握團隊的轉換排隊情況 +- **技術素養**:高 +- **一句話描述**:「我希望團隊能同時使用轉換服務不互相干擾,這樣就不會因為工具問題拖延專案進度」 + +#### Persona C:服務消費者 — visionA-backend(非人類 Persona) + +> **注意:這不是一個「使用者」,而是一個「服務」。** Persona C 代表所有以程式化方式呼叫 Kneron Model Converter API 的下游服務。首個消費者為 Innovedus 旗下的 **VisionA**(`visionA-backend`,Go 服務,路徑:`~/visionA/visionA-backend/`)。未來可擴展至 Innovedus 生態的其他 vertical 應用。 + +- **角色**:Innovedus 生態中需要「模型轉檔」能力的下游後端服務 +- **當前代表**:visionA-backend(Go / Gin),目前處於 Phase 0 雛形,近期進入 Phase 1 將導入 OAuth2/OIDC +- **目標**: + - 讓 VisionA 的終端使用者(Edge AI 應用開發者)能在 VisionA 平台內直接觸發模型轉檔,無需離開平台 + - 把轉檔結果自動納入使用者的 VisionA 模型庫(長期儲存於 NAS) + - 不需要自己重新實作 Kneron Toolchain 的封裝 +- **痛點**: + - 目前 Kneron Model Converter 只有 Web UI,無法以程式化方式整合 + - 沒有標準的 auth 機制,無法對齊 Innovedus 統一的 Member Center OAuth2 體系 + - 沒有檔案搬運 API,下游服務無法把轉檔結果搬到自己的長期儲存 + - 缺乏「同使用者同時一個轉檔」的保護,容易讓使用者搞混 + - 沒有 job recovery 能力,使用者離開頁面後回來看不到未完成的任務 +- **技術素養**:不適用(服務對服務) +- **關鍵特性**: + - 以 OAuth2 `client_credentials` 取得 service token 呼叫 Converter API + - 在 request 中帶 `user_id`(VisionA 端的使用者 ID,Converter 信任不驗證;僅用於業務邏輯,非授權邊界);建 job 時 `user_id` 作為 multipart field,查詢 API 作為 query string + - **直接將原始模型以 multipart 上傳給 Converter**(不經過任何中間檔案庫) + - 透過 polling(非 webhook)取得轉檔進度 + - 轉檔完成後呼叫 `promote` API 將結果搬進模型庫 +- **一句話描述**:「作為服務,我希望能用標準的 OAuth2 service token 呼叫 Converter API,讓我的使用者能在我的平台內完成模型轉檔,同時不用自己處理 Kneron Toolchain 的複雜度」 + +#### 間接使用者:VisionA 終端使用者 + +雖然他們不直接接觸 Converter API,但他們是最終受益者: +- **角色**:VisionA 平台上的 Edge AI 應用開發者 +- **感受到的能力**:在 VisionA 模型庫內可以「把模型送去轉檔」、「看進度」、「把結果加進模型庫」 +- **他們不會知道**:背後是另一個服務(Kneron Model Converter)在處理,也不會直接和它溝通 +- **關鍵體驗要求**: + - 同時只能有一個轉檔進行中(避免搞混) + - 離開頁面再回來能看到未完成的任務 + - 轉檔失敗時能看到具體原因 + +### 1.3 核心問題與價值主張 + +| 核心問題 | 價值主張 | +|---------|---------| +| 單一 Toolchain instance 無法承受高併發 | Worker Pool 水平擴展,多任務平行處理 | +| 模型轉換操作繁瑣(CLI / Python API) | Web UI 提供視覺化操作介面 | +| 轉換進度不透明 | SSE 即時推送任務狀態 | +| Crash 後恢復困難 | 「Crash 即 Reset」哲學,無狀態設計,重啟即恢復服務 | +| 多人共用時互相干擾 | 任務佇列隔離,每個 Worker 獨立處理 | + +### 1.4 OKR [推測] + +**Objective:提供穩定、高效的 AI 模型轉換服務** + +- KR1:系統可同時處理 N 個轉換任務(透過 Worker 水平擴展) +- KR2:單一任務的轉換成功率 > 95%(排除模型本身問題) +- KR3:系統 Crash 後 < 30 秒恢復服務能力 + +### 1.5 北極星指標與指標體系 [推測] + +``` +北極星指標:每日成功轉換任務數 + ├── 驅動指標:任務提交數、轉換成功率、平均轉換時間 + │ ├── 輸入指標:Web UI 使用率、API 呼叫次數 + │ ├── 輸入指標:各階段(ONNX/BIE/NEF)失敗率 + │ └── 輸入指標:Worker 利用率 + └── 護欄指標:Worker Crash 率、Redis 記憶體使用量、佇列堆積長度 +``` + +--- + +## 2. 市場分析 [推測] + +### 2.1 市場定位 + +此產品為 **內部工具 / 企業內部服務**,主要服務 Kneron 內部或其客戶的 AI 工程團隊。非面向公開市場的 SaaS 產品。 + +### 2.2 競品對照 + +| 方案 | 類型 | 優勢 | 劣勢 | 與本產品差異 | +|------|------|------|------|------------| +| Kneron Toolchain CLI | 原生 CLI 工具 | 功能完整、官方支援 | 無 GUI、不支援併發、操作門檻高 | 本產品是其 Web 化包裝 | +| Kneron Toolchain Web GUI(原版) | 官方 Web GUI | 有介面 | 單一 instance、Crash 問題 | 本產品解決了其效能與穩定性瓶頸 | +| Edge Impulse | 雲端 ML 平台 | 完整 MLOps 流程 | 不支援 Kneron 特定格式 | 本產品專注 Kneron 晶片生態 | +| TensorFlow Lite Converter | Google 官方工具 | 生態完整 | 只支援 TFLite 格式 | 本產品支援 Kneron 特有的 BIE/NEF 格式 | + +### 2.3 差異化策略 + +- **唯一性**:唯一支援 Kneron KDP 系列晶片完整轉換流程(ONNX → BIE → NEF)的 Web 服務 +- **護城河**:與 Kneron Toolchain 深度整合,封裝其 Python API +- **關鍵優勢**:Worker Pool 架構解決了原生 Toolchain 的併發瓶頸 + +--- + +## 3. 用戶研究 + +### 3.1 用戶旅程地圖 + +| 階段 | 用戶行為 | 想法/感受 | 痛點 | 機會點 | +|------|----------|----------|------|--------| +| 模型準備 | 訓練好模型,匯出 ONNX/TFLite | 「模型訓練完成,該部署了」 | 需要確認模型格式相容性 | [推測] 提供模型格式驗證 | +| 上傳模型 | 透過 Web UI 上傳模型檔 + 參考圖片 | 「介面還算直覺」 | 大檔案上傳可能較慢(上限 500MB) | 上傳進度顯示 | +| 設定參數 | 選擇平台、填寫 Model ID / Version | 「這些參數是什麼意思?」 | 參數含義不夠直覺 | [推測] 提供參數說明 / 預設值 | +| 等待轉換 | 觀察 SSE 即時進度更新 | 「大概還要多久?」 | 只有階段進度,無時間預估 | [推測] 加入預估剩餘時間 | +| 下載結果 | 下載 BIE/NEF 產出檔 | 「轉換完成,可以部署了」 | - | - | +| 失敗處理 | 看到失敗訊息,需要重新提交 | 「為什麼失敗?該怎麼修?」 | 錯誤訊息可能不夠友善 | [推測] 提供更詳細的錯誤診斷 | + +--- + +## 4. 功能需求 + +### 4.1 功能總覽與狀態 + +| # | 功能 | 狀態 | 說明 | +|---|------|------|------| +| F-01 | 模型檔案上傳 | 已實作 | 支援 ONNX、TFLite 格式,上限 500MB | +| F-02 | 參考圖片上傳 | 已實作 | BIE 量化用的參考圖片(最多 100 張) | +| F-03 | 完整轉換流程(ONNX → BIE → NEF) | 已實作 | 固定三階段序列化流程 | +| F-04 | 單階段轉換(ONNX 優化) | UI 已實作,後端部分實作 | Web UI 有獨立 ONNX 表單,但後端 API 僅有完整流程端點 | +| F-05 | 單階段轉換(BIE 量化) | UI 已實作,後端部分實作 | 同上 | +| F-06 | 單階段轉換(NEF 編譯) | UI 已實作,後端部分實作 | 同上 | +| F-07 | 任務狀態即時更新(SSE) | 已實作 | Server-Sent Events + Polling 備援 | +| F-08 | 任務狀態查詢(REST API) | 已實作 | GET /jobs/:jobId | +| F-09 | 任務列表查詢 | 已實作 | GET /jobs | +| F-10 | 結果檔案下載 | 已實作 | GET /jobs/:jobId/download/:filename | +| F-11 | Worker 水平擴展 | 已實作 | docker-compose --scale | +| F-12 | Stub Worker 開發模式 | 已實作 | WORKER_MODE=stub 模擬處理 | +| F-13 | IP 評估(可選) | 已實作 | enable_evaluate 旗標 | +| F-14 | 浮點 E2E 模擬 | 未完成 | enable_sim_fp 旗標已定義,邏輯未接線 | +| F-15 | 定點 E2E 模擬 | 未完成 | enable_sim_fixed 旗標已定義,邏輯未接線 | +| F-16 | 硬體 E2E 模擬 | 未完成 | enable_sim_hw 旗標已定義,邏輯未接線 | +| F-17 | 健康檢查 API | 已實作 | GET /health | +| F-18 | [推測] 任務取消 | 未實作 | 目前無法中途取消進行中的任務 | +| F-19 | [推測] 任務重試 | 未實作 | 符合「Crash 即 Reset」哲學,失敗需手動重送 | +| F-20 | [推測] 歷史任務持久化 | 未實作 | Redis 重啟即清空,無持久化 | + +### 4.2 User Stories + +#### US-01:上傳模型並執行完整轉換 + +> **作為** AI 應用工程師, +> **我希望** 上傳 ONNX/TFLite 模型檔和參考圖片,設定目標平台後一鍵啟動轉換, +> **以便** 自動完成 ONNX 優化 → BIE 量化 → NEF 編譯的完整流程,取得可部署到 Kneron 晶片的 NEF 檔。 + +**驗收標準**: +- 支援上傳 .onnx 和 .tflite 格式檔案 +- 必填參數:model_id、version、platform +- 支援平台:KDP520、KDP720、KDP530、KDP630、KDP730 +- 上傳成功後自動進入 ONNX → BIE → NEF 流程 +- 狀態:**已實作** + +#### US-02:即時追蹤轉換進度 + +> **作為** AI 應用工程師, +> **我希望** 在提交任務後能即時看到目前處於哪個階段(ONNX/BIE/NEF)以及進度百分比, +> **以便** 掌握任務狀態,不需要反覆手動刷新。 + +**驗收標準**: +- 透過 SSE 即時推送狀態更新 +- SSE 斷線時自動降級為每 3 秒 Polling +- 顯示當前階段名稱與進度百分比 +- 任務完成或失敗時顯示對應通知 +- 狀態:**已實作** + +#### US-03:下載轉換結果 + +> **作為** AI 應用工程師, +> **我希望** 轉換完成後能直接從 Web UI 下載產出的 NEF 檔(或中間產物 BIE), +> **以便** 取得可部署的模型檔案。 + +**驗收標準**: +- 任務 COMPLETED 後提供下載按鈕 +- 支援下載 out.onnx、out.bie、out.nef +- 狀態:**已實作** + +#### US-04:選擇單階段處理 + +> **作為** AI 應用工程師, +> **我希望** 能選擇只執行某一個階段(例如只做 ONNX 優化,或只做 BIE 量化), +> **以便** 在除錯時逐步確認每個階段的輸出正確性。 + +**驗收標準**: +- Web UI 提供四種工作流程選擇:ONNX 優化 / BIE 量化 / NEF 編譯 / 完整流程 +- 各流程有獨立的參數表單 +- 狀態:**UI 已實作,後端 API 部分實作**(Web UI 的前端表單呼叫的 API 端點如 `/api/onnx/upload`、`/api/bie/process` 尚未在 Task Scheduler 中實作,後端目前僅支援 POST /jobs 建立完整流程任務) + +#### US-05:水平擴展 Worker + +> **作為** 系統管理員, +> **我希望** 能透過簡單的指令增加 Worker 數量, +> **以便** 在任務量大時提升系統處理能力。 + +**驗收標準**: +- 透過 `docker-compose up --scale bie-worker=N` 即可擴展 +- 新 Worker 自動加入 Consumer Group 分配任務 +- 不需修改任何設定檔或重啟其他服務 +- 狀態:**已實作** + +#### US-06:[推測] 查看失敗原因 + +> **作為** AI 應用工程師, +> **我希望** 任務失敗時能看到具體的錯誤原因與失敗階段, +> **以便** 判斷是模型問題還是系統問題,並採取對應措施。 + +**驗收標準**: +- 失敗任務顯示失敗的階段(ONNX/BIE/NEF) +- 顯示錯誤原因文字 +- 狀態:**已實作**(job record 中有 error.step 和 error.reason) + +#### US-07:開發與測試用 Stub 模式 + +> **作為** 開發人員, +> **我希望** 能在不安裝 Kneron Toolchain 的情況下啟動整套系統進行開發測試, +> **以便** 快速迭代 Scheduler、Web UI 和佇列邏輯。 + +**驗收標準**: +- 設定 WORKER_MODE=stub 即可啟用 +- Stub Worker 模擬處理時間(2-3 秒)並產生假輸出檔 +- 完整流程可走通,SSE 通知正常運作 +- 狀態:**已實作** + +--- + +### 4.3 API 消費者功能總覽(新增,2026-04-25) + +本節說明本次 L 級更新的新功能範圍。所有新功能都是針對 **Persona C(服務消費者)** 設計,並以「讓 VisionA 終端使用者獲得完整模型轉檔體驗」為最終目標。 + +| # | 功能 | 狀態 | Phase | 說明 | +|---|------|------|-------|------| +| F-21 | OAuth2 Resource Server(驗 visionA-backend 的 service token) | 未實作 | Phase 1 | 以 Member Center JWKS 驗簽 JWT,檢查 `aud=kneron_converter_api` 與 scope | +| F-22 | OAuth2 Client(Converter 自己取 service token) | 未實作 | Phase 1 | 用 `client_credentials` 向 Member Center 取 token,供 PUT 檔案到 File Access Agent(promote 時使用) | +| F-23 | 對外 API: 建立轉檔 job(`POST /api/v1/jobs`) | 未實作 | Phase 1 | **multipart/form-data** 格式(跟現有 Web UI 一致),visionA-backend 直接把原始模型上傳到 Converter,body 帶 `user_id` + `model`(原始模型檔)+ `ref_images[]`(可選)+ 參數 | +| F-24 | 對外 API: 查詢 job 進度(`GET /api/v1/jobs/:id`) | 未實作 | Phase 1 | Polling 模式,回傳 status、stage、progress、error | +| F-25 | 對外 API: 查詢 job 列表(`GET /api/v1/jobs?user_id=...&status=...`) | 未實作 | Phase 1 | 支援 recovery 場景(使用者離開頁面回來) | +| F-26 | 對外 API: 搬檔到模型庫(`POST /api/v1/jobs/:id/promote`) | 未實作 | Phase 1 | Converter 自己用 service token PUT 結果檔到 File Access Agent | +| F-27 | 同使用者同時一個轉檔限制 | 未實作 | Phase 1 | 建 job 時檢查 `user_id` 是否有 in-progress job,有則拒絕 | +| F-29 | OpenAPI 規格(對外 API 契約) | 未實作 | Phase 1 | 產出 OpenAPI 3.0 spec,方便下游服務整合與測試 | +| F-30 | 使用者下載模型(delegated token 直連 File Access Agent) | 未實作 | **Phase 2** | 阻塞:Member Center `POST /file-access/download-tokens` 尚未實作 | + +> **註(2026-04-25 變更)**:原 F-28「從 File Access Agent 取 input 模型」已刪除。Phase 1 的原始模型上傳改為由 visionA-backend 直接 multipart 上傳到 Converter(見 F-23),不再經過 File Access Agent。 + +**Phase 1 / Phase 2 切分原因**:Phase 2 的「使用者下載」依賴 Member Center 的 delegated token endpoint,但該 endpoint 目前尚未實作。為避免阻塞 Phase 1 整體上線,Phase 1 先讓 VisionA 能完成「上傳 → 轉檔 → 搬進模型庫」閉環,下載則等 Member Center 補完後再做。詳見 §15。 + +### 4.4 新增 User Stories(2026-04-25) + +#### US-08:visionA-backend 以 service token 建立轉檔 job + +> **作為** visionA-backend(服務消費者), +> **我希望** 用 OAuth2 service token 呼叫 Converter API 建立轉檔 job, +> **以便** 讓我的使用者能在 VisionA 平台內觸發模型轉檔,而不用跳到另一個系統。 + +**前置條件**: +- visionA-backend 已取得終端使用者要轉檔的原始模型檔案(由 VisionA 自己的上傳流程收到,保存在 visionA-backend 自己可存取的位置) +- visionA-backend 已向 Member Center 取得 service token(`aud=kneron_converter_api`, scope=`converter:job.write`) + +**驗收標準**: +- `POST /api/v1/jobs`,Content-Type: `multipart/form-data`,Header: `Authorization: Bearer ` +- **multipart files**: + - `model`(必填,原始模型檔,支援 `.onnx` / `.tflite`,單檔 ≤ 500MB) + - `ref_images[]`(可選,BIE 量化用參考圖片,最多 100 個) +- **multipart fields**: + - `user_id`(必填,VisionA 使用者 ID,字串) + - `model_id`(必填,Integer,1–65535) + - `version`(必填,字串,如 `"0001"`) + - `platform`(必填,字串,`"520"` / `"720"` / `"530"` / `"630"` / `"730"`) + - `enable_evaluate`(可選,Boolean,預設 false) + - `enable_sim_fp`(可選,Boolean,預設 false,未接線) + - `enable_sim_fixed`(可選,Boolean,預設 false,未接線) + - `enable_sim_hw`(可選,Boolean,預設 false,未接線) +- Converter 驗證 token 簽章、`aud`、`scope`,失敗則回 `401` / `403` +- 驗證通過後,Converter 把原始模型存到 Converter Bucket(例如 `jobs/{job_id}/input/{filename}`),由既有 Worker 流程處理(與現有 Web UI 走 `POST /jobs` 的儲存路徑一致) +- 建 job 成功回 `201 Created`,body 含 `job_id`、`status=created` +- 若該 `user_id` 已有進行中 job(`status ∈ {created, running}`),回 `409 Conflict`,body 含現有 job_id(詳見 US-11) +- 若 multipart 欄位缺漏、檔案超過 500MB、模型格式非 `.onnx` / `.tflite`、platform 不支援,回 `400 Bad Request` +- **設計備註**:此路徑刻意與既有 Web UI 的 `POST /jobs`(Phase 1 內部路徑)走同一份 multipart 上傳行為,以最大化重用既有上傳處理邏輯(multer memory storage、檔案大小限制、Worker 派工)。差異僅在:`/api/v1/jobs` 多驗 OAuth token、必填 `user_id`、多檢查 US-11 的同使用者同時限制。 +- 狀態:**未實作(Phase 1 必做)** + +#### US-09:追蹤轉檔進度(polling) + +> **作為** VisionA 終端使用者(透過 visionA-backend), +> **我希望** 在觸發轉檔後能看到目前進度(哪個階段、百分比), +> **以便** 知道還要多久、是否成功。 + +**驗收標準**: +- visionA-backend 以固定間隔呼叫 `GET /api/v1/jobs/:id`(建議 2-5 秒一次) +- 回傳 body 含:`status`(created / running / completed / failed)、`stage`(onnx / bie / nef)、`progress`(0-100)、`error`(失敗時)、`result_object_keys`(完成時,各輸出檔在 Converter MinIO 的暫存 key) +- visionA-backend 將此資訊轉化為 VisionA UI 要顯示的進度條 +- **不做** Webhook / SSE 對外推送(Phase 1 範圍內,polling 已足夠) +- 若 `job_id` 不存在或不屬於呼叫者的 tenant,回 `404` +- 狀態:**未實作(Phase 1 必做)** + +#### US-10:轉檔完成後加進模型庫 + +> **作為** VisionA 終端使用者(透過 visionA-backend), +> **我希望** 轉檔完成後可以選擇「加進我的模型庫」, +> **以便** 結果被長期保存到 NAS,而不是留在 Converter 的暫存區等 7 天後被清掉。 + +**驗收標準**: +- visionA-backend 呼叫 `POST /api/v1/jobs/:id/promote`,body 帶目標 `object_key`(VisionA 端決定命名,例如 `visionA/models/{user_id}/{model_id}/v{version}/out.nef`) +- Converter 自己用 service token(scope=`files:upload.write`)PUT 檔案到 File Access Agent +- 檔案流只在 NAS 側流動(Converter → File Access Agent → NAS Bucket),不透過 visionA-backend 中轉 +- 成功回 `200 OK`,body 含已搬檔的 object_key 列表 +- 若 job 尚未 completed 或已 failed,回 `409 Conflict` +- 若 promote 過程失敗(File Access Agent 不可用、auth 問題等),回 `502 Bad Gateway`,並可重試 +- Converter Bucket 的暫存檔仍保留直到 7 天 lifecycle 到期(不立即刪除,以利失敗重試) +- 狀態:**未實作(Phase 1 必做)** + +#### US-11:同使用者同時一個轉檔 + +> **作為** VisionA 終端使用者(透過 visionA-backend), +> **我希望** 系統限制同一個使用者同時只能進行一個轉檔, +> **以便** 我不會被多個同時進行的轉檔搞混,也避免意外消耗 Worker 資源。 + +**驗收標準**: +- Converter 在建 job 時檢查該 `user_id` 是否已有 `status ∈ {created, running}` 的 job +- 若有,回 `409 Conflict`,body 範例:`{"error":"user_has_active_job","active_job_id":"..."}` +- 限制作用於 `user_id`(VisionA 使用者),**不是** `client_id`(同一個 VisionA backend 服務可以同時有多個不同使用者的轉檔) +- 此為「產品面軟限制」,非「授權邊界」:Converter 只是信任 visionA-backend 傳來的 user_id +- 狀態:**未實作(Phase 1 必做)** + +#### US-12:離開頁面 recovery + +> **作為** VisionA 終端使用者(透過 visionA-backend), +> **我希望** 離開轉檔頁面再回來時,能看到我之前未完成的 job, +> **以便** 不用重新送一次或擔心轉檔結果遺失。 + +**驗收標準**: +- visionA-backend 進入轉檔頁面時呼叫 `GET /api/v1/jobs?user_id=...&status=in_progress`(`in_progress` 為 `created` + `running` 的總稱) +- Converter 回傳該使用者所有進行中的 job 清單(Phase 1 同時至多 1 個,但 API 設計為 list 以保彈性) +- visionA-backend 根據清單決定是否顯示「有未完成的轉檔,是否繼續追蹤?」的 UI +- 同時支援 `status=completed` / `status=failed`(近期完成)、`limit`、`offset` 參數 +- **注意**:Converter 本身仍遵循「Crash 即 Reset」哲學,Redis 重啟後 job 會消失。recovery 僅在 Converter 正常運行期間有效,不保證跨 Crash 可恢復 +- 狀態:**未實作(Phase 1 必做)** + +#### US-13:Converter 自己搬檔避免大檔跨網段傳輸 + +> **作為** visionA-backend, +> **我希望** 呼叫 Converter 搬檔時,Converter 能自己把檔案推到 File Access Agent, +> **以便** 避免大檔(可能數百 MB)跨 AWS ↔ NAS 來回傳輸(Converter → visionA-backend → File Access Agent 會慢且貴)。 + +**驗收標準**: +- `POST /api/v1/jobs/:id/promote` 的實作中,Converter 直接從自己的 MinIO 讀檔,然後 PUT 到 File Access Agent +- visionA-backend 不接觸檔案實體,只接收 promote API 的成功/失敗結果 +- Converter 以自己的 OAuth2 `client_credentials` 向 Member Center 取得 `files:upload.write` scope 的 token(non-user-bound service token) +- File Access Agent 以 Member Center JWKS 驗該 token,檢查 `aud=file_access_api`、`scope=files:upload.write`、`tenant_id` 吻合 +- 狀態:**未實作(Phase 1 必做)** + +#### US-14:【Phase 2】使用者下載模型走 delegated token + +> **作為** VisionA 終端使用者, +> **我希望** 從 VisionA 模型庫下載已搬進 NAS 的模型檔時,瀏覽器能直連 File Access Agent(帶短效 delegated token), +> **以便** 大檔不必經過 visionA-backend 中轉,下載快速且省流量。 + +**前置條件(阻塞)**: +- Member Center 已實作 `POST /file-access/download-tokens`(目前狀態:**未實作**) + +**驗收標準(Phase 2)**: +- visionA-backend 呼叫 Member Center 的 `POST /file-access/download-tokens`(scope=`files:download.delegate`),傳入 `tenant_id`、`user_id`、`object_key` +- Member Center 回傳短效 opaque token(建議 `exp <= 5 分鐘`) +- visionA-backend 把 token 回給瀏覽器,瀏覽器直連 `GET https:///files/{object_key}?token=` +- File Access Agent 呼叫 Member Center validation endpoint 線上驗 token,驗過才回檔案 +- **此 User Story 不涉及 Converter API 的修改**,只是 VisionA ↔ Member Center ↔ File Access Agent 的串接 +- 狀態:**未實作,Phase 2(等 Member Center 補完後啟動)** + +**阻塞觸發條件**: +- Member Center owner 確認 `POST /file-access/download-tokens` 的實作時程,並提供測試環境 +- 或者 VisionA 團隊決定自己暫時 proxy 下載(折衷方案,需另議) + +--- + +## 5. 系統架構摘要(從程式碼確認) + +### 5.1 服務元件 + +| 元件 | 技術 | 職責 | +|------|------|------| +| Web UI | Vue 3 + Vite + Element Plus | 使用者操作介面 | +| Nginx | Alpine | 反向代理、靜態檔案服務、SSE 轉發 | +| Task Scheduler | Node.js + Express | REST API、Job 狀態管理、Queue 派工、SSE 推送 | +| Redis | Redis 7 Alpine(不開 persistence) | Job 狀態儲存、任務佇列(Redis Stream) | +| ONNX Worker | Python | ONNX 優化 / TFLite 轉換 | +| BIE Worker | Python | 量化分析 | +| NEF Worker | Python | NEF 編譯 | +| Shared Volume | Docker named volume | Worker 間的檔案交換 | + +### 5.2 API 端點(已實作) + +| 方法 | 路徑 | 說明 | +|------|------|------| +| GET | /health | 健康檢查 | +| POST | /jobs | 建立新任務(上傳模型 + 參考圖片 + 參數) | +| GET | /jobs | 列出所有任務 | +| GET | /jobs/:jobId | 查詢單一任務狀態 | +| GET | /jobs/:jobId/events | SSE 事件串流 | +| GET | /jobs/:jobId/download/:filename | 下載結果檔案 | + +### 5.3 任務參數 + +| 參數 | 必填 | 類型 | 說明 | +|------|------|------|------| +| model(檔案) | 是 | File | ONNX 或 TFLite 模型檔 | +| ref_images(檔案) | 否 | File[] | 量化用參考圖片(最多 100 張) | +| model_id | 是 | Integer | 模型 ID(1-65535) | +| version | 是 | String | 模型版本(如 "0001") | +| platform | 是 | String | 目標平台("520"/"720"/"530"/"630"/"730") | +| enable_evaluate | 否 | Boolean | 是否執行 IP 評估(預設 false) | +| enable_sim_fp | 否 | Boolean | 是否執行浮點 E2E 模擬(預設 false,未接線) | +| enable_sim_fixed | 否 | Boolean | 是否執行定點 E2E 模擬(預設 false,未接線) | +| enable_sim_hw | 否 | Boolean | 是否執行硬體 E2E 模擬(預設 false,未接線) | + +### 5.4 任務狀態機 + +``` +建立 → ONNX → BIE → NEF → COMPLETED + ↓ ↓ ↓ + FAILED FAILED FAILED +``` + +- 流程固定:ONNX → BIE → NEF,不支援跳過或只跑部分(後端限制) +- 任何階段失敗即整體 FAILED,不自動重試 + +### 5.5 對外 API 端點預告(新增,2026-04-25,待 Architect 產出詳細規格) + +本節只列出 **PRD 層級** 的功能與驗收要點,詳細 API 規格(request / response schema、錯誤碼、範例)由 Architect Agent 在 `04-architecture/TDD.md` 或 `04-architecture/api/api-external.md` 中定義。 + +**Base Path**:`/api/v1`(與既有內部 API 區隔,讓 Web UI 繼續用舊的 `/jobs`,對外走新版) + +**Auth**:所有端點都需要 `Authorization: Bearer `,token 需滿足: +- 簽發者:Innovedus Member Center(以 JWKS 驗簽) +- `aud=kneron_converter_api` +- 對應 scope(下表列出) + +| 方法 | 路徑 | Content-Type | 用途 | 必要 scope | 對應 US | +|------|------|-------------|------|-----------|---------| +| POST | `/api/v1/jobs` | `multipart/form-data` | 建立轉檔 job(直接上傳原始模型) | `converter:job.write` | US-08, US-11 | +| GET | `/api/v1/jobs/:id` | — | 查詢單一 job(polling) | `converter:job.read` | US-09 | +| GET | `/api/v1/jobs` | — | 查詢 job 列表(recovery) | `converter:job.read` | US-12 | +| POST | `/api/v1/jobs/:id/promote` | `application/json` | 搬檔到模型庫 | `converter:job.write` | US-10, US-13 | + +> **註**:`POST /api/v1/jobs` 採 multipart/form-data 是刻意選擇,與既有 Web UI `POST /jobs` 一致,讓 visionA-backend 直接把原始模型推給 Converter。**不使用**「body 帶 object_key,Converter 再去 File Access Agent 拉檔」的設計(詳見 §4.4 US-08、§14.2)。 + +**Phase 1 明確不做的端點**: +- Webhook 回呼(不做,polling 已足夠) +- Cancel job(延後到未來,既有專案本來就沒有) +- Delegated download 相關(Phase 2) + +**scope 命名**:上述命名為 PM 建議,Architect 需與 Member Center owner 對齊最終名稱。若 Member Center 已有命名慣例則採用其慣例。 + +### 5.6 Auth / 身分識別模型(新增,2026-04-25) + +本節定義 Converter 在新 API 中的雙重身分與 user_id 傳遞策略。 + +**Converter 作為 OAuth2 Resource Server**: +- 驗證 visionA-backend(或其他未來消費者)送來的 service token +- 驗證項目:JWT 簽章(Member Center JWKS)、`iss`、`aud=kneron_converter_api`、`exp`、必要 `scope` +- **不驗證使用者身分**:token 是 service-to-service(`client_credentials`),claim 裡沒有 `user_id` + +**Converter 作為 OAuth2 Client**: +- 使用自己的 `client_id=kneron_converter`(需在 Member Center 註冊) +- 以 `client_credentials` grant 取得 token +- 取得後用於: + - PUT 結果檔到 File Access Agent(scope=`files:upload.write`,promote 時使用,見 US-10 / US-13) +- **Phase 1 Converter 完全不從 File Access Agent「讀」任何東西**(原始模型改由 visionA-backend 直接 multipart 上傳,見 US-08)。因此 Phase 1 **不需要** `files:download.read` 或 `files:metadata.read` 等讀取類 scope。 + +**user_id 傳遞策略(方式 A:request payload)**: +- visionA-backend 在對外 API 中以 **request payload**(非 JWT claim,非 HTTP header)傳遞 `user_id`: + - `POST /api/v1/jobs`:`user_id` 作為 **multipart form field** + - `POST /api/v1/jobs/:id/promote`:`user_id` 作為 JSON body 欄位(若 promote API body 需要;若不需要,由 `:id` 反查即可) + - `GET /api/v1/jobs?user_id=...`(list)與 `GET /api/v1/jobs/:id`(single,Converter 會在內部比對 job 的 user_id):`user_id` 作為 **query string** +- Converter **信任** visionA-backend 送的 user_id,**不驗證** +- user_id 的用途: + - 業務邏輯:同使用者同時一個轉檔的限制(US-11) + - recovery 的 job 列表過濾(US-12) +- user_id **不是授權邊界**:Converter 不負責判斷「某個 user 能不能做某件事」,授權邊界由 visionA-backend 自己管 +- 選擇方式 A 而非「把 user_id 放 token claim」的原因:visionA-backend 是 service-to-service token(非 user-bound),硬塞 user_id 進 claim 會破壞 OAuth2 semantics +- 選擇方式 A 而非「自訂 header(如 X-User-Id)」的原因:delegation model 跟 payload 一起走、API 審計可讀性較好,且 multipart 本來就要帶其他欄位,user_id 放在一起語意一致 + +**重要:不做的事**: +- Converter **不**呼叫 Member Center 驗證 user_id 是否存在(相信 visionA-backend) +- Converter **不**在 job 上做 user 層級的 ACL(例如「user A 不能看到 user B 的 job」),這由 visionA-backend 自己管,Converter 只是按 user_id 過濾查詢結果 + +--- + +## 6. 非功能需求 + +### 6.1 效能需求 + +| 項目 | 需求 | 現況 | +|------|------|------| +| 併發任務數 | 取決於 Worker 數量(每 Worker 一次處理一個任務) | 已實作(水平擴展) | +| 檔案上傳上限 | 500 MB | 已實作 | +| API Rate Limit | 每 15 分鐘 200 次請求 | 已實作 | +| SSE 心跳 | 每 15 秒 | 已實作 | + +### 6.2 安全性需求 + +| 項目 | 需求 | 現況 | +|------|------|------| +| HTTP 安全標頭 | X-Frame-Options, X-Content-Type-Options, X-XSS-Protection | 已實作(Nginx + Helmet) | +| 容器安全 | 非 root 用戶運行 | Task Scheduler 已實作 | +| API Key 認證 | [推測] 應有 API Key 或其他認證機制 | 未實作(開發環境硬編碼) | +| Redis 認證 | Redis 應有密碼保護 | 未實作 | +| HTTPS | 生產環境需 TLS | 未實作 | +| 輸入驗證 | 檔案類型/大小驗證 | 部分實作(大小有限制,類型由前端限制) | + +### 6.3 可用性需求 + +| 項目 | 需求 | 現況 | +|------|------|------| +| Crash Recovery | 服務重啟後自動恢復可用 | 已實作(Crash 即 Reset) | +| 容器自動重啟 | 服務異常時自動重啟 | 已實作(restart: unless-stopped) | +| 健康檢查 | Redis 連線狀態監控 | 已實作(GET /health) | +| [推測] 監控告警 | 系統指標監控與告警 | 未實作 | +| [推測] 日誌收集 | 集中式日誌管理 | 未實作(僅 console 輸出) | + +### 6.4 可擴展性 + +| 項目 | 需求 | 現況 | +|------|------|------| +| Worker 水平擴展 | 動態增減 Worker 數量 | 已實作 | +| [推測] 跨主機部署 | 多台主機分散 Worker | 未實作(目前用 Shared Volume,未來規劃 MinIO) | +| [推測] Scheduler HA | 多個 Scheduler instance | 明確列為 Non-goal | + +--- + +## 7. 系統限制與假設 + +### 7.1 設計決策(Non-goals,從設計文件確認) + +以下為刻意的設計取捨,**不是缺陷**: + +1. **不做任務持久化** — Redis 重啟即清空所有任務 +2. **不做 Crash 後恢復** — 進行中的任務在 Crash 後消失 +3. **不做 exactly-once / at-least-once 保證** — 任務可能因 Crash 而遺失 +4. **不做 Scheduler HA** — 單一 Scheduler instance +5. **不做自動重試** — 失敗即 FAILED,由使用者手動重送 +6. **轉換流程固定** — 必須走完 ONNX → BIE → NEF,不支援跳過 + +### 7.2 技術假設 + +1. 所有 Worker 需掛載相同的 Docker Volume 才能存取共享檔案 +2. Worker 需要 Kneron Toolchain Python 環境(或使用 Stub 模式) +3. Redis 不開啟 persistence,符合「Crash 即 Reset」哲學 +4. [推測] 目標部署環境為 Linux Docker 環境 + +### 7.3 已知限制 + +1. 前端 UI 的單階段 API 端點(`/api/onnx/upload`、`/api/bie/process` 等)在後端尚未實作,前後端 API 契約不一致 +2. Simulator 功能(浮點/定點/硬體模擬)旗標已定義但邏輯未接線 +3. 任務列表使用 `redis.keys('job:*')` 掃描,任務量大時效能可能有問題 [推測] +4. 檔案上傳使用 memory storage(`multer.memoryStorage()`),大檔案可能導致記憶體壓力 + +--- + +## 8. 功能優先排序(RICE) + +> 以下 RICE 評分為從程式碼推斷的優先級建議,Reach 與 Confidence 為推測值,Effort 需 Architect 確認。 + +### 8.1 RICE 評分表(含 2026-04-25 新增項目) + +| # | 功能 | Reach | Impact | Confidence | Effort (人週) | RICE 分數 | 階段 | +|---|------|-------|--------|------------|--------------|----------|------| +| **F-21~29** | **API 對外開放 Phase 1(OAuth2 + 對外 API + promote + recovery)** | **100%** | **3** | **70%** | **4** | **53** | **本次 Phase 1** | +| F-30 | Phase 2 使用者下載(delegated token) | 100% | 2 | 40% | 1 | 80 | Phase 2(阻塞中)| +| F-14~16 | E2E 模擬(浮點/定點/硬體) | 80% | 2 | 60% | 3 | 32 | Phase 2 | +| F-04~06 | 單階段轉換 API(後端) | 70% | 2 | 80% | 1.5 | 75 | Phase 2 | +| - | 前後端 API 契約對齊 | 100% | 3 | 90% | 1 | 270 | 急迫 | +| - | Redis 認證 + HTTPS | 100% | 2 | 90% | 1 | 180 | Phase 1 | +| F-18 | 任務取消 [推測] | 50% | 1 | 50% | 2 | 13 | Phase 3 | +| F-20 | 歷史任務持久化 [推測] | 60% | 1.5 | 40% | 3 | 12 | Phase 3 | +| - | 跨主機部署(MinIO) | 30% | 2 | 70% | 4 | 11 | Later | +| - | 監控與告警 | 80% | 1.5 | 70% | 2 | 42 | Phase 2 | +| - | 錯誤訊息改善 [推測] | 80% | 1 | 60% | 0.5 | 96 | Phase 1 | +| - | 轉換時間預估 [推測] | 70% | 0.5 | 40% | 1 | 14 | Phase 3 | + +### 8.2 API 對外開放(Phase 1)RICE 推論說明 + +- **Reach = 100%**:100% 指「所有未來要整合 Converter 的下游服務」都需要此能力,不是終端使用者數。Phase 1 首個消費者是 visionA-backend,但架構設計為可擴展(未來 Innovedus 其他 vertical)。 +- **Impact = 3(最高)**:沒有 API,VisionA 就無法整合,整個合作關係無法成立。屬於「關鍵路徑」。 +- **Confidence = 70%**:OAuth2 / File Access Agent(僅 upload 方向)/ Member Center 三邊整合有不確定性(特別是 scope 命名、client 註冊流程、tenant_id 策略),實作細節待與兩個服務 owner 對齊。 +- **Effort = 4 人週**:涵蓋 OAuth middleware、client 實作、multipart 上傳整合對外 API、promote API、user_id 相關業務邏輯、recovery API、OpenAPI 規格產出、整合測試。待 Architect Agent 在 TDD 中精確估算(**2026-04-25 變更後,Phase 1 不含「從 File Access Agent 取原始模型」的整合工作,Architect 可視情況調低 Effort**)。 +- **RICE = 53**:分數看起來不高是因為 Effort 較大,但此功能屬於「解鎖型」——不做就無法開始整個商業合作,性質上是 P0,RICE 分數在此僅作排序參考,不應作為 go/no-go 的唯一依據。 + +### 8.3 商業效益(預期) + +| 指標 | Phase 1 上線前 | Phase 1 上線後(預期) | 備註 | +|------|---------------|---------------------|------| +| 下游服務整合可能性 | 0(無 API)| 1+(VisionA)| 未來可擴展 | +| VisionA 使用者轉檔路徑 | N/A(無法使用 Converter)| 平台內一站完成 | 降低切換成本 | +| 手動操作步驟(從模型到 NAS)| 5+ 步(上傳 Web UI → 轉檔 → 下載 → 上傳到 VisionA → 搬到 NAS)| 2 步(在 VisionA 選模型 → 按轉檔)| 使用者體驗大幅改善 | +| 服務耦合度 | 緊耦合(各服務自己做)| 鬆耦合(經 OAuth2 契約)| 可維護性 | + +**注意**:上述「預期」為 PM 推論,實際採用率與整合價值需 Phase 1 上線後用 §9 的成功指標追蹤。 + +### RICE 公式 +RICE = (Reach x Impact x Confidence) / Effort + +--- + +## 9. 成功指標(KPI) + +> 9.1 既有指標 [推測] 為建議性質,需與產品負責人確認是否適用。 +> 9.2 新增指標(2026-04-25)專門針對 API 對外開放功能。 + +### 9.1 既有成功指標 [推測] + +| 類別 | 指標 | 建議目標值 | 追蹤方式 | +|------|------|-----------|---------| +| 穩定性 | 系統 uptime | > 99%(工作時段) | 健康檢查 API 監控 | +| 效率 | 轉換成功率 | > 95% | Job COMPLETED / 總 Job | +| 效率 | 平均轉換時間 | 視模型大小而定 | Job created_at → completed_at | +| 容量 | 每日處理任務數 | 視團隊規模而定 | Job 計數 | +| 體驗 | 任務佇列等待時間 | < 5 分鐘 | 入 Queue 到 Worker 開始處理 | +| 可用性 | Crash 後恢復時間 | < 30 秒 | Docker restart 時間 | + +### 9.2 API 對外開放 成功指標(新增,2026-04-25) + +#### 9.2.1 API SLA(品質指標) + +| 指標 | 目標值 | 護欄(不能低於) | 追蹤方式 | +|------|--------|---------------|---------| +| API 可用率(`/api/v1/*` 非 5xx 的比率) | > 99.5%(工作時段) | 99% | Nginx access log + 監控 | +| API p95 回應時間(GET /jobs/:id) | < 200ms | 500ms | APM / 自建時間戳 | +| API p95 server-side 處理時間(POST /jobs)| < 500ms(**不含 multipart 上傳時間**,計算從 Converter 收到整份 body 後的處理時間) | 2s | APM(server-side 埋點,從 body 完整接收到回 201 的時間)| +| 端到端回應時間(POST /jobs,含上傳)| 取決於檔案大小與網路;**大檔上傳時間不計入 API p95 SLA** | — | 由 visionA-backend 側觀測,作為 UX 指標而非服務 SLA | +| API p95 回應時間(POST /jobs/:id/promote) | < 3s(含 PUT 到 File Access Agent) | 10s | APM | +| OAuth token 驗證失敗率(401 / 403) | < 1%(排除 token 過期正常情況) | 5% | Nginx log 分類統計 | + +> **註(2026-04-25 變更)**:原 `POST /jobs` p95 目標「< 2s(含從 File Access Agent 下載原始模型)」已調整。現行設計為 visionA-backend 直接 multipart 上傳原始模型,因此 API SLA 分兩層: +> - **server-side 處理時間**:Converter 收完 multipart body 後的處理邏輯(驗 token、存檔到 Bucket、建 job record、派工到 Queue)— 這是 Converter 可控的 SLA。 +> - **端到端上傳時間**:包含大檔上傳網路傳輸時間(可能數百 MB),受限於 visionA-backend ↔ Converter 網路頻寬與檔案大小,不納入 API p95 SLA,改由 visionA-backend 側追蹤作為使用者體驗指標。 + +#### 9.2.2 API 採用度指標(成長指標) + +| 指標 | 目標(Phase 1 上線後 3 個月)| 追蹤方式 | +|------|----------------------------|---------| +| 每月透過 API 建立的 job 數 | 由 visionA 的使用量決定,設 baseline | job 來源標記(API vs Web UI)| +| API job 占總 job 的比例 | > 30%(Phase 1)→ > 60%(6 個月)| 同上 | +| promote 成功率(搬進模型庫)| > 90% | `promote` API 成功/總呼叫 | +| 離開頁面 recovery 使用率(有 in-progress 時回來查看)| 不設硬目標,但要能觀測 | `GET /api/v1/jobs?status=in_progress` 呼叫次數 | +| 同使用者 409 Conflict 比率(US-11 觸發)| < 5%(表示大多數使用者理解限制)| Conflict 回應計數 / 總 POST /jobs | + +#### 9.2.3 錯誤率護欄 + +| 指標 | 護欄(不能超過) | 觸發動作 | +|------|----------------|---------| +| Auth failure rate | 5% | 檢查 Member Center JWKS 是否可達、scope 設定 | +| Promote 失敗率(5xx 類)| 5% | 檢查 File Access Agent 連線、Converter service token 有效性 | +| File Access Agent 連線失敗率 | 2% | 跨服務網路問題警報 | +| Converter 自己的 service token 取用失敗率 | 1% | Member Center 連線問題 | + +#### 9.2.4 使用者體驗指標(透過 visionA 間接觀察) + +| 指標 | 目標 | 追蹤方式 | +|------|------|---------| +| 同使用者同時一個轉檔的限制被使用者理解率(不會重複嘗試)| 高 | 連續同 user_id 在短時間內的 409 次數 | +| recovery 成功率(使用者離開頁面回來能看到 job)| > 95%(Converter 正常運行期間)| visionA 側埋點 | + +**追蹤工具建議(待 Architect 決定)**: +- API layer 埋點:Nginx access log + 自訂結構化 log +- 指標聚合:Prometheus + Grafana(與既有監控對齊,如有) +- 失敗類別拆分:在 Scheduler 層記錄 error code(auth_failed / promote_failed / input_fetch_failed 等) + +**注意**:上述目標值為 PM 提案,具體數字需 Phase 1 上線前與 SRE / 監控負責人對齊。沒有歷史資料的指標(例如 API p95)需在 Phase 1 beta 期間收集 baseline 後再訂正式 SLA。 + +--- + +## 10. 策略性路線圖 + +### Now(當前 — 已實作 / 急需修復) + +- [x] 完整轉換流程(ONNX → BIE → NEF) +- [x] Web UI 基本功能 +- [x] SSE 即時狀態推送 +- [x] Worker 水平擴展 +- [x] Stub Worker 開發模式 +- [x] Docker Compose 一鍵啟動 +- [ ] **前後端 API 契約對齊**(Web UI 的單階段端點在後端未實作) + +### Next(Phase 1 — 對外 API + 穩定化與安全性) + +本次 L 級新功能主要工作: + +- [ ] **API 對外開放 Phase 1(本次重點)** + - [ ] OAuth2 Resource Server(以 Member Center JWKS 驗 service token) + - [ ] OAuth2 Client(Converter 自己取 service token,promote 時 PUT 到 File Access Agent) + - [ ] 對外 API 路徑:`POST /api/v1/jobs`(multipart)、`GET /api/v1/jobs/:id`、`GET /api/v1/jobs`、`POST /api/v1/jobs/:id/promote` + - [ ] user_id 業務邏輯(同使用者同時一個轉檔、recovery) + - [ ] multipart 上傳流程(visionA-backend → Converter,與現有 Web UI 路徑重用儲存邏輯) + - [ ] 搬結果檔到 File Access Agent(promote) + - [ ] OpenAPI 3.0 規格產出與測試 +- [ ] 安全性強化:Redis 認證、HTTPS(原 API Key 由 OAuth2 取代) +- [ ] 錯誤訊息改善(更友善的錯誤描述) +- [ ] 單階段轉換 API(後端實作 /api/onnx/process、/api/bie/process、/api/nef/process)— 延後至下一波 +- [ ] 輸入驗證強化(後端檔案類型驗證) + +### Later(Phase 2 — 功能擴展) + +- [ ] **使用者下載模型走 delegated token**(本次 Phase 2,阻塞於 Member Center) +- [ ] E2E 模擬功能接線(浮點/定點/硬體) +- [ ] 監控與告警(系統指標、Worker 狀態、API SLA) +- [ ] 日誌收集與集中管理 +- [ ] [推測] 批次轉換(多個模型一次提交) +- [ ] Web UI 是否也改走新 OAuth 流程(待決) + +### Future(Phase 3 — 進階功能) + +- [ ] [推測] 任務取消功能 +- [ ] [推測] 歷史任務持久化(可選開啟) +- [ ] 跨主機部署(MinIO 替換 Shared Volume) +- [ ] [推測] 轉換時間預估 +- [ ] [推測] 使用者認證與權限管理 + +--- + +## 11. 支援平台清單(從程式碼確認) + +| 平台代號 | 平台名稱 | 備註 | +|---------|---------|------| +| 520 | KDP520 | - | +| 720 | KDP720 | - | +| 530 | KDP530 | - | +| 630 | KDP630 | - | +| 730 | KDP730 | - | + +--- + +## 12. 風險與緩解 + +### 12.1 既有風險(延續) + +| 風險 | 可能性 | 影響 | 緩解措施 | +|------|--------|------|----------| +| Redis Crash 導致所有任務遺失 | 中 | 中 | 符合設計哲學,使用者重送即可;未來可考慮 optional persistence | +| 大檔案上傳耗盡 Node.js 記憶體 | 中 | 高 | 改用 disk storage(multer.diskStorage)替代 memory storage | +| Worker 處理時間過長佔用資源 | 中 | 中 | [推測] 加入 timeout 機制 | +| 前後端 API 不一致造成功能無法使用 | 高 | 高 | 優先對齊前後端 API 契約 | +| 無認證機制導致未授權存取 | 中 | 高 | Phase 1 以 OAuth2 取代硬編碼 | +| Redis keys 掃描在大量任務時效能劣化 | 低 | 中 | [推測] 改用 Redis Sorted Set 管理任務索引 | + +### 12.2 本次 L 級新功能風險(新增,2026-04-25) + +| 風險 | 可能性 | 影響 | 緩解措施 | +|------|--------|------|----------| +| Member Center `POST /file-access/download-tokens` 長期不實作,Phase 2 無法啟動 | 中 | 中 | Phase 1 可獨立上線(不依賴此 endpoint);定期與 Member Center owner 同步;必要時 VisionA 做 proxy 下載作為 fallback | +| File Access Agent 部署位置 / tenant_id 未定,promote 整合測試被阻塞 | 中 | 中 | 盡早與 File Access Agent owner 對齊部署計畫;若必要 Converter 開發環境先接 Stub 做 promote 契約驗證(讀檔已不再依賴 File Access Agent)| +| Member Center OAuth client / audience 註冊延遲 | 中 | 高 | 盡早申請;開發初期可用 local Member Center 測試 | +| scope 命名與 Member Center 既有命名慣例衝突 | 低 | 中 | Architect 在 TDD 提案後,走跨團隊 review | +| visionA-backend 傳來的 user_id 被偽造(VisionA 被入侵或誤用)| 低 | 高 | 接受此風險:Converter 不做 user 層 ACL,責任邊界在 visionA-backend。可加日誌記錄 user_id 變更頻率作為監測 | +| 大檔 promote 時 File Access Agent 不可用 | 中 | 中 | Converter Bucket 7 天 lifecycle 保留,使用者可重試 promote;API 回清楚的 502 | +| **大檔 multipart 上傳失敗或 timeout**(visionA-backend ↔ Converter 之間) | 中 | 中 | 沿用既有 Web UI 的 500MB 上限;若未來需更大檔,與 Architect 討論改用 disk storage 或 chunked upload;API 回清楚的 `413 Payload Too Large` / `408 Request Timeout`;由 visionA-backend 做重試邏輯(Converter 無 resume upload 能力)| +| **multipart 上傳占用記憶體**(既有 `multer.memoryStorage()` 問題)| 中 | 高 | 此為既有既有風險(見 §12.1),L 級新功能後因 API 消費者數量增加可能加劇。建議 Architect 評估是否 Phase 1 同步改 `multer.diskStorage()`;若不改,需明確在 Capacity Planning 中計算記憶體上限 | +| Converter 的 service token 過期未正確刷新 | 低 | 中 | 實作 token cache + 主動 refresh(`exp-60s`)| +| 同使用者同時一個轉檔對「急用者」造成不便 | 中 | 低 | 以 409 + 現有 job_id 回應,讓 VisionA 引導使用者「繼續追蹤」而非重送 | +| 對外 API 穩定性承諾(OpenAPI)修改困難 | 低 | 中 | Phase 1 採保守設計,預留未來擴充欄位;API 走 `/api/v1` 版本化 | +| OAuth middleware 阻塞既有 Web UI 走的 `/jobs` 路徑 | 低 | 高 | API 分兩套:Web UI 維持原路徑無 OAuth,對外 API 走 `/api/v1/*` 新路徑 | + +--- + +## 14. 相依服務(新增,2026-04-25) + +本次 L 級新功能使 Kneron Model Converter 從「獨立工具」變為「依賴兩個外部服務 + 被一個外部服務呼叫」的生態成員。本章節說明三個相依關係。 + +### 14.1 Member Center(身分與權限中心) + +- **專案位置**:`/tmp/member_center/`(Innovedus 旗下,C# .NET + OpenIddict + PostgreSQL) +- **負責範圍**:OAuth2 / OIDC authorization server、JWKS 發布、token 簽發、delegated token 簽發與驗證 +- **Converter 對它的依賴**: + - **驗 token(作為 Resource Server)**:從 `GET /.well-known/openid-configuration` 與 JWKS 端點拉取公鑰,驗證 visionA-backend 送來的 service token + - **取 token(作為 Client)**:以 `client_credentials` 向 `POST /oauth/token` 取得 Converter 自己的 service token +- **目前狀態**: + - 核心 OAuth2 / OIDC 已完成(見 `/tmp/member_center/docs/DESIGN.md §7.1`) + - `POST /file-access/download-tokens`(Phase 2 需要)**尚未實作** +- **本次 Converter 需要 Member Center 做的事**(需跨團隊協調): + - 註冊一個 resource audience:`kneron_converter_api`(暫定名) + - 註冊一個 OAuth client:`kneron_converter`(暫定名,Converter 自己使用) + - 在 visionA-backend 的 OAuth client 設定中加入 `kneron_converter_api` 的 scope 授權(例如 `converter:job.write`, `converter:job.read`) + - 在 Converter 的 client 設定中加入 `file_access_api` 相關 scope 授權:**Phase 1 僅需 `files:upload.write`**(promote 時 PUT 檔案用);**不需要 `files:download.read` 或 `files:metadata.read`**(2026-04-25 變更後,Phase 1 Converter 完全不從 File Access Agent 讀取任何東西) +- **風險**:任何註冊、scope 命名、client_secret 發放都需要 Member Center owner 配合 + +### 14.2 File Access Agent(檔案閘道) + +- **專案位置**:`/tmp/file_access_agent/`(Innovedus 旗下,C# / ASP.NET Core) +- **負責範圍**: + - 代表「tenant 邊界內的所有檔案」的統一存取閘道 + - 所有檔案進出都 proxy 經過 File Access Agent(**無 presigned URL 模式**) + - 單一 tenant per instance(啟動時設定 `INSTANCE_TENANT_ID`) + - 驗 upload 的 JWT(向 Member Center JWKS) + - 驗 download 的 delegated token(向 Member Center 線上 validation endpoint) +- **Converter 對它的依賴(Phase 1 簡化版,2026-04-25 變更)**: + - **僅 PUT 結果到模型庫**:promote 時 Converter PUT 結果檔到 File Access Agent(帶 service token,scope=`files:upload.write`) + - **Phase 1 Converter 完全不從 File Access Agent「讀」任何東西**(原始模型改由 visionA-backend 直接 multipart 上傳到 Converter) + - Phase 1 **不需要** GET / HEAD / metadata 類的呼叫 +- **目前狀態**: + - 核心功能完成(見 `/tmp/file_access_agent/docs/API.md`):upload (PUT)、download (GET with delegated token)、metadata、delete + - MinioFileStorage、MemberCenterDelegatedDownloadTokenValidator 已實作(約 1865 行 C#) +- **本次 Converter 需要 File Access Agent 做的事(Phase 1)**: + - 確認 deployment:哪個 instance 服務哪個 tenant、Converter 要連哪個 URL + - 約定 objectKey 命名(建議:`kneron-converter/{user_id}/{model_id}/v{version}/{filename}` 或類似結構,需與 VisionA 對齊) + - 只使用 PUT 端點(upload 類),不使用 GET / HEAD / metadata +- **設計選擇(已確認)**:沒有 presigned URL 模式 — 所有 **promote 流量** 都經過 File Access Agent,Converter 接受此設計,不爭取改變 +- **Phase 2 可能新增的依賴**:無(Phase 2 的 delegated download 不經過 Converter,見 US-14) + +### 14.3 visionA-backend(服務消費者) + +- **專案位置**:`~/visionA/visionA-backend/`(Innovedus 旗下,Go + Gin) +- **負責範圍**: + - VisionA 平台的後端,管理其使用者、模型庫、應用設定 + - Phase 0 雛形階段,近期進 Phase 1 導入 OAuth2/OIDC 與 JWT 認證 +- **Converter 對它的關係**: + - 是 Converter 的 **第一個 API 消費者(Persona C 實例)** + - visionA-backend 會以 `client_credentials` 向 Member Center 取 service token(`aud=kneron_converter_api`),然後呼叫 Converter API +- **職責邊界(明確區分)**: + - **visionA-backend 的責任**:使用者認證、使用者 ACL(哪個使用者能看哪些模型)、模型庫 UX、將終端使用者的操作轉換為對 Converter API 的呼叫、處理 polling 與 UI 進度顯示、決定 promote 的目標 objectKey + - **Converter 的責任**:Kneron Toolchain 轉檔執行、job 狀態管理、以自己的 service token 讀寫 File Access Agent、同使用者同時一個轉檔的限制、job 查詢過濾 + - **不能混淆的邊界**:Converter **不**驗 user_id 真偽、**不**做使用者層級 ACL、**不**管 VisionA 的模型庫命名規則 +- **目前狀態**:Phase 0 雛形(static auth,無 DB,LocalFS)。Phase 1 將導入 JWT/OIDC 與真實使用者系統 +- **協作時間表假設**:VisionA 進 Phase 1(OAuth2 完成)的時間需與 Converter Phase 1 的整合測試時程對齊 + +### 14.4 相依關係圖(PRD 層級,2026-04-25 更新) + +``` + [VisionA 終端使用者] + │ + │ (web UI 操作) + ▼ + ┌──────────────────┐ + │ visionA-backend │ ← Persona C(Converter API 消費者) + └───┬──────┬───┬───┘ + │ │ │ + service token │ │ │ ① multipart 上傳原始模型 + (client_cred.) │ │ │ (POST /api/v1/jobs, Phase 1) + │ │ │ + ▼ │ │ delegated token(Phase 2) + ┌────────────────┐ │ + │ Member Center │ │ + │ (OAuth2, JWKS, │ │ + │ delegated tok.)│ │ + └────┬──────┬────┘ │ + │ │ │ + 驗 token │ │ 驗 │ + 取 token │ │ │ + ▼ ▼ ▼ + ┌────────────────────────┐ + │ Converter (本專案) │ + │ - 收 multipart 上傳 │ + │ - 驗 OAuth token │ + │ - Worker 轉檔流程 │ + │ - Bucket 暫存 (7天) │ + └──────────────┬─────────┘ + │ + │ ② promote 時 PUT 結果檔 + │ (Phase 1, scope=files:upload.write) + ▼ + ┌──────────────────┐ + │ File Access Agent │ ← Phase 1 只收寫入 + │ (檔案閘道) │ (不處理 Converter 的讀檔) + │ 無 presigned URL │ + └─────────┬────────┘ + │ + │ NAS(長期儲存) + ▼ + [模型庫] +``` + +**Phase 1 檔案流(已確認)**: +1. 原始模型:`visionA-backend ──multipart──> Converter`(不經 File Access Agent) +2. 轉檔結果(promote 時):`Converter ──PUT──> File Access Agent ──> NAS` +3. 使用者下載結果(Phase 1 UX 由 visionA-backend 決定,可選方案:VisionA proxy、VisionA 存自己的 copy、或等 Phase 2) + +**Phase 2 新增的檔案流(阻塞於 Member Center)**: +4. 使用者下載:`瀏覽器 ──GET (delegated token)──> File Access Agent`(Converter 不參與) + +--- + +## 15. Phase 1 / Phase 2 Scope 切分(新增,2026-04-25) + +本章節明確界定本次 L 級功能的交付範圍,避免在實作時範圍蔓延或誤砍。 + +### 15.1 Phase 1(本次必做) + +**目標**:讓 visionA-backend 能完成「上傳 → 轉檔 → 搬進模型庫」閉環,不需要使用者直接下載能力。 + +**必須交付**: + +| 項目 | 說明 | 對應 US / F | +|------|------|------------| +| OAuth2 Resource Server | 驗 visionA-backend 的 service token(JWKS 驗簽、aud、scope) | F-21, US-08 | +| OAuth2 Client | Converter 自己取 service token(僅用於 promote 時呼叫 File Access Agent 的 PUT) | F-22, US-13 | +| `POST /api/v1/jobs`(multipart) | 建 job,multipart files:`model` + `ref_images[]`;multipart fields:`user_id` + `model_id` + `version` + `platform` + 旗標 | F-23, US-08 | +| `GET /api/v1/jobs/:id` | Polling 進度 | F-24, US-09 | +| `GET /api/v1/jobs?user_id=...&status=...` | Recovery 的 job 列表查詢 | F-25, US-12 | +| `POST /api/v1/jobs/:id/promote` | 搬結果到 File Access Agent(Converter 自己推) | F-26, US-10, US-13 | +| 同使用者同時一個轉檔限制 | 建 job 時檢查 user_id 有無 in-progress job | F-27, US-11 | +| OpenAPI 3.0 規格 | 對外 API 契約,visionA-backend 整合用 | F-29 | + +> **註(2026-04-25 變更)**:原 Phase 1 Scope 中的「從 File Access Agent 取原始模型(F-28)」已移除。Phase 1 原始模型來源改為「visionA-backend 直接 multipart 上傳到 Converter」(見 F-23 / US-08)。 + +**明確不做**(Phase 1 不在範圍): + +- ❌ 使用者下載模型的 delegated token 流程(移至 Phase 2) +- ❌ Web UI 改走新 OAuth 流程(維持既有 multipart 路徑,另外評估) +- ❌ Webhook / SSE 對外推送(polling 已足夠) +- ❌ Job 取消功能(既有專案本來就沒有,不在本次新增) +- ❌ Job 持久化 / Crash 恢復(維持「Crash 即 Reset」哲學) +- ❌ 單階段轉換 API(F-04~06)— 這是既有 backlog 項目,和本次 L 級功能獨立 + +**Phase 1 上線完成的判定標準**: + +1. visionA-backend 能成功以 service token 呼叫所有四個對外 API +2. 一個完整 E2E 流程跑通:VisionA 使用者上傳模型 → File Access Agent → 觸發轉檔 → polling 進度 → 完成後 promote 到模型庫 +3. 同使用者同時轉檔限制生效(回 409) +4. Recovery 能查到 in-progress job +5. OpenAPI 規格通過 visionA-backend 整合測試 +6. API SLA 基本指標可觀測(可用率、p95 回應時間) + +### 15.2 Phase 2(延後做) + +**目標**:讓 VisionA 終端使用者能直接從瀏覽器下載 NAS 裡的模型檔,不經 visionA-backend 中轉。 + +**必須交付**: + +| 項目 | 說明 | 對應 US / F | +|------|------|------------| +| visionA-backend 呼叫 Member Center 取 delegated token | scope=`files:download.delegate`,帶 tenant_id + user_id + object_key | F-30, US-14 | +| 瀏覽器直連 File Access Agent 下載 | `GET /files/{object_key}?token=` | US-14 | +| Converter 無變更 | Phase 2 對 Converter 本身無直接改動 | — | + +**阻塞條件(必須先解除才能啟動 Phase 2)**: + +1. Member Center 的 `POST /file-access/download-tokens` 已實作並部署(目前:**未實作**) +2. Member Center owner 提供測試環境與 scope 授權 +3. File Access Agent 的 `MemberCenterDelegatedDownloadTokenValidator` 已通過整合測試(目前:程式碼已有,待驗證) + +**觸發 Phase 2 的條件**: + +- Member Center owner 確認時程,或 +- VisionA 業務需求強烈要求,此時可考慮折衷方案(VisionA 自己做 proxy 下載),但這是另一個討論 + +**Phase 2 的 US-14 風險**: +- 若 Member Center 長期不實作,VisionA 的終端使用者要下載自己的模型就只能「從 VisionA 下載介面(經 visionA-backend proxy)」或「連 Kneron Converter 舊的 Web UI(此時兩邊資料不同步,會混亂)」。需提前與 VisionA 產品團隊溝通 UX 取捨。 + +### 15.3 Phase 1 / Phase 2 決策原則 + +- **Phase 1 必須能獨立上線**:不能因為 Phase 2 阻塞就拖累 Phase 1。 +- **Phase 1 / Phase 2 之間不會互相破壞**:Phase 2 只新增功能,不修改 Phase 1 的 API 契約。 +- **阻塞透明化**:Phase 2 的阻塞原因(Member Center endpoint 未實作)必須明確記錄在 PRD,讓使用者知道是上游依賴不是本專案問題。 + +--- + +## 16. 附錄 + +> 註:本章原為 §13,因新增 §14(相依服務)與 §15(Phase 切分)而重編號為 §16。章節內容不變。 + +### 16.1 技術堆疊完整列表 + +| 層級 | 技術 | 版本 | +|------|------|------| +| 前端框架 | Vue 3 + Vite | Vue 3.3.4, Vite 4.4.5 | +| UI 元件庫 | Element Plus | - | +| 狀態管理 | Pinia | - | +| 後端(調度) | Node.js + Express | Node 18 | +| Worker 框架 | Python(同步) | Python 3.9 | +| 佇列 / 狀態 | Redis Stream | Redis 7 Alpine | +| 模型處理 | ONNX 1.14.1, TensorFlow 2.16.2 | - | +| 容器化 | Docker + docker-compose | - | +| 反向代理 | Nginx | Alpine | +| 測試框架 | pytest + pytest-asyncio | - | + +### 16.2 後端介面層設計 + +程式碼採用 **Protocol + Factory 模式**,將 Kneron Toolchain 的呼叫封裝為可替換的後端介面: + +- `QuantizationBackend`(`services/backends/quantization.py`)— 量化分析 +- `CompilerBackend`(`services/backends/compiler.py`)— NEF 編譯 +- `EvaluatorBackend`(`services/backends/evaluator.py`)— IP 評估 +- `SimulatorBackend`(`services/backends/simulator.py`)— E2E 模擬(未完成) + +此設計使得未來可以替換為不同版本的 Toolchain 後端,或在測試時注入 Mock。 + +### 16.3 程式碼品質觀察 + +- 模組化設計良好,Worker / Consumer / Backend 職責清晰 +- 測試覆蓋:6 個測試檔涵蓋 ONNX/BIE/NEF 核心與 E2E 流程 +- 使用 Helmet、Rate Limiting、Compression 等 Express 最佳實踐 +- Graceful shutdown 處理(SIGTERM / SIGINT) + +--- + +## 附錄 A:本次更新的 PM 疑問清單(2026-04-25) + +撰寫本次 PRD 更新時遇到以下疑問,列出給 Orchestrator / 使用者裁決。**這些問題不阻塞三方交叉審閱,但需在使用者最終審核 PRD 前釐清**。 + +### A.1 需使用者決定的產品問題 + +1. **Phase 1 的 Web UI 要不要也改走 OAuth 流程?** + - 傾向不改(維持既有 multipart 行為),未來評估 + - 若決定改,則 Phase 1 範圍會擴大(升級為 L+ 或拆兩個 L) +2. **同使用者同時一個轉檔**限制範圍確認 + - 是否真的是「整個 Converter 服務」共用的 user_id 空間?還是不同 `client_id`(例如未來有 visionA-backend 以外的消費者)各自獨立? + - 目前 PRD 以「整個服務內 user_id 唯一」設計(預期未來不會出現跨 client 的 user_id 衝突) +3. **tenant_id 策略** + - Member Center 與 File Access Agent 都是多租戶設計,但 PRD 目前未處理「Converter 內部怎麼標記 tenant」 + - 待 Architect Agent 評估:Converter 是否要在 job record 上記錄 tenant_id?還是只信任 File Access Agent 的 single-tenant-per-instance 保證? +4. **Phase 2 的 fallback 策略** + - 若 Member Center 長期不實作 delegated token,VisionA 要不要做 proxy 下載作為折衷? + - 還是完全等 Member Center? +5. **API 採用度的 baseline 如何取得?** + - §9.2.2 列的目標值(例如「每月 X 個 job」)目前沒有歷史資料 + - 建議:Phase 1 上線後跑 1 個月 beta 再訂正式目標 +6. **既有 PRD 中的 `[推測]` 標記要不要在此次順便確認?** + - 本次 PM 暫不動既有 `[推測]`,保留給使用者決定 + - 建議在使用者最終審核本版 PRD 時一併確認 + +### A.2 需跨團隊協調的問題(給 Orchestrator 記到 progress.md 未解決問題) + +1. **Member Center owner 協調**:註冊 `kneron_converter_api` audience、`kneron_converter` client、設定 scope 授權 +2. **File Access Agent owner 協調**:確認 Converter 要連的 instance URL、tenant_id、objectKey 命名約定 +3. **VisionA 團隊協調**:確認 VisionA 的 Phase 1 OAuth 整合時程,對齊 Converter 整合測試時機 +4. **Member Center `/file-access/download-tokens` 實作時程**:影響 Phase 2 啟動 + +### A.3 給 Architect Agent 的待確認項 + +1. **scope 命名**:`converter:job.write` / `converter:job.read` 是否符合 Member Center 慣例?由 Architect 在 TDD 提案後跨團隊 review +2. **Effort 估算**:RICE 表中 Phase 1 Effort = 4 人週 為 PM 概估,需 Architect 在 TDD 後精確。**注意:2026-04-25 變更後,Phase 1 移除「從 File Access Agent 取原始模型」這條路徑,相對減少 OAuth Client 的讀檔 scope 與整合測試複雜度,Architect 可重新估 Effort** +3. **OpenAPI 規格的維護策略**:自動生成還是手寫?版本變更流程? +4. **user_id 在 job record 的索引設計**:Redis 現在用 `redis.keys('job:*')` 掃描,加 user_id 過濾後效能策略? +5. **(新增,2026-04-25)multipart 上傳的記憶體策略**:既有 Web UI 用 `multer.memoryStorage()`,對外 API 接受相同路徑後,同時多個 visionA-backend 呼叫可能放大記憶體問題。Architect 需評估 Phase 1 是否同步改 `diskStorage`,或是否需 Capacity Planning 加上明確的上限(例如同時 multipart 上傳數 × 500MB ≤ Node.js heap) +6. **(新增,2026-04-25)`POST /api/v1/jobs` 與既有 `POST /jobs` 的路徑實作關係**:兩者行為近似,Architect 需決定是「共享同一個 handler,前面加 OAuth middleware + user_id 驗證分支」還是「完全獨立兩個 handler」。這會影響未來維護性 + +### A.4 給 Design Agent 的待確認項 + +1. 本次 Phase 1 主要影響「API 層 + visionA-backend 串接」,**Kneron Converter 自己的 Web UI 預期不改** +2. Design Agent 需確認:既有 Web UI 的「上傳模型」流程是否在 Phase 1 仍維持 multipart?若維持,則 Web UI 不受影響 +3. visionA-backend 端的 UI 設計(進度條、recovery 提示、promote 按鈕等)**不在 Kneron Converter 的 Design Agent 範圍內**,由 VisionA 團隊負責 + +### A.5 本次變更(2026-04-25 第二次更新)新產生的 Open Questions + +1. **VisionA 終端使用者如何下載轉檔結果(Phase 1 期間)**: + - 由於 Converter 不再從 File Access Agent「讀」原始模型,而使用者要看 / 下載 **轉檔結果**(BIE、NEF)的流程在 Phase 1 就尚未有 end-to-end 設計 + - 選項:(a) 使用者先等到 promote 完成後從 VisionA UI 下載(VisionA 自己 proxy),(b) Phase 1 只讓 VisionA 平台顯示「已加進模型庫」,不提供下載鍵,等 Phase 2 delegated token 再開放,(c) VisionA 先不呼叫 promote,從 Converter 的 `/jobs/:jobId/download/:filename` 既有路徑下載(此路徑目前無 OAuth) + - 需 PM + VisionA 產品團隊對齊 +2. **visionA-backend 在上傳前是否也存一份原始模型到自己**: + - 由於 Phase 1 原始模型不進 File Access Agent,若轉檔失敗或使用者要重試,visionA-backend 是否需要自己暫存一份? + - 這是 visionA-backend 內部設計問題,不影響 Converter API 契約,但會影響 VisionA 使用者 UX(失敗後能不能一鍵重試) +3. **既有 `/jobs/:jobId/download/:filename` 端點的對外曝光問題**: + - Phase 1 visionA-backend 若走 promote 路徑,下載由 VisionA ↔ File Access Agent 處理,Converter 的下載端點不需對外 + - 但若 VisionA 決定「先不 promote、直接從 Converter 拉結果」,此端點就需要加 OAuth 保護 + - 需在 TDD 中決定:既有下載端點是否要區分「Web UI 內部用」vs「對外 API /api/v1 用」兩套 diff --git a/docs/autoflow/03-design/design-review.md b/docs/autoflow/03-design/design-review.md new file mode 100644 index 0000000..8b9f6e1 --- /dev/null +++ b/docs/autoflow/03-design/design-review.md @@ -0,0 +1,460 @@ +# Design Review — API 對外開放 L 級功能 + +> **審查範圍**:2026-04-25 的 L 級新功能「開放 Kneron Model Converter 轉檔能力為對外 REST API 供 VisionA 使用」。 +> +> **審查者**:Design Agent(Autoflow) +> +> **審查時機**:三方聯合討論階段(與 PM Agent / Architect Agent 同步作業) +> +> **特別聲明**:這次任務的直接產出物是 **後端 API 介面**,不是新 UI 畫面。本次 Review 的焦點為: +> 1. 對既有 Web UI 使用者體驗的間接影響 +> 2. API 設計對下游消費者(visionA-backend)能做出什麼樣的終端 UX 的限制 +> 3. 從 UX 觀點給 PM / Architect 的建議 +> +> **不在本次範圍**:新 UI 畫面設計、Wireframe、Prototype、Design Tokens 調整。這些本次都不需要做。 + +## 變更歷程 + +| 日期 | 變更內容 | +|------|---------| +| 2026-04-25 | 原始模型上傳路徑改為 visionA-backend → Converter multipart 直連(不經過 File Access Agent)。同步更新:(1)錯誤情境表移除「原始模型在 FAA 找不到 `input_object_not_found`」,改為 multipart 相關錯誤 `invalid_multipart` / `file_too_large`;(2)相依圖中 Converter 對 FAA 的依賴只保留 promote PUT,移除讀原始模型的箭頭;(3)衝突 response 範例保持不變(不受上傳路徑影響)。| + +--- + +## 1. 摘要 + +### 1.1 整體評估結論 + +**Phase 1 對既有 Web UI 使用者體驗的影響:幾乎無感(✅ 優秀)** + +- 既有 Web UI 走的 API 路徑(`/jobs`、`/jobs/:id`、`/jobs/:id/events`、`/jobs/:id/download/:filename`)**不改動** +- 對外 API 走全新路徑 `/api/v1/*`,兩套並存 +- 既有 Web UI 使用者繼續以 multipart 上傳、SSE 追蹤、GET 下載,**流程零改動** + +**主要 UX 風險:集中在「我們給 visionA 前端團隊的操作彈性」上(⚠️ 要注意)** + +我們不實作 VisionA 的 UI,但 **我們的 API 設計會限制他們能做出什麼樣的體驗**。如果 API 設計得太死板,visionA 前端團隊就算想做更好的 UX 也做不到。本次 Review 會針對這點給 Architect 一份「API 設計的 UX 彈性需求」清單。 + +### 1.2 Phase 1 vs Phase 2 對使用者體驗的差異 + +| 情境 | Phase 1 體驗 | Phase 2 體驗 | UX 差距 | +|------|------------|------------|---------| +| VisionA 使用者上傳模型並轉檔 | ✅ 在 VisionA 平台內完成 | ✅ 同 Phase 1 | 無差 | +| VisionA 使用者把轉檔結果加進模型庫 | ✅ 按一下按鈕即完成 | ✅ 同 Phase 1 | 無差 | +| VisionA 使用者下載已轉檔的模型 | ⚠️ **阻塞 / 折衷方案** — VisionA 前端目前能做的:(a) 顯示「下載功能即將上線」占位;(b) 從 VisionA backend proxy 下載(違反原架構);(c) 導向舊的 Kneron Converter Web UI 下載(會混亂) | ✅ 瀏覽器直連 File Access Agent,速度快、省流量 | **中等** — 這是 UX 上的明顯缺口,必須在 Phase 1 上線時和 VisionA 產品團隊協調 messaging | + +**Design Agent 的強烈建議**:Phase 1 上線時必須有「使用者下載 messaging 策略」,不能讓使用者上傳完轉檔後發現東西下載不了(UX 上的死胡同)。**這是需要使用者決策的議題 #1,詳見第 6 節。** + +--- + +## 2. 既有 Web UI 影響分析 + +### 2.1 既有 Web UI 使用者旅程是否改變? + +**結論:Phase 1 完全不改,Web UI 使用者無感。** + +| 旅程階段 | 既有行為(會繼續保留)| 是否受影響 | +|---------|----------------------|----------| +| 進入 Web UI 主頁 | 直接打開網頁,不需登入([推測] 現況)| ❌ 不受影響 | +| 上傳模型(multipart)| 瀏覽器表單 → `POST /jobs` with FormData | ❌ 不受影響 | +| 追蹤進度(SSE)| 瀏覽器開 SSE 連線 `GET /jobs/:id/events`,自動 fallback 到每 3 秒 polling | ❌ 不受影響 | +| 下載結果 | 直接點 `GET /jobs/:id/download/:filename` | ❌ 不受影響 | +| 任務列表 | `GET /jobs` 列出全部 job | ❌ 不受影響(但查不到 VisionA 使用者透過新 API 建立的 job — **見 2.3**)| + +**核心設計選擇(已由 PM 決策)**: +- 既有 `/jobs` 路徑保留,**不**加 OAuth 驗證 +- 對外 API 走新路徑 `/api/v1/*`,**獨立**加 OAuth middleware +- 兩套 API 並存,互不影響 + +✅ **Design Agent 同意此決策**,理由: +1. 避免破壞現有使用者(零 regression 風險) +2. 讓 Web UI 和對外 API 的演進節奏脫鉤(Web UI 是內部工具,對外 API 需要穩定性承諾) +3. 未來若要合併,可在 Phase 3 評估,不用現在壓時程決定 + +### 2.2 既有 Web UI 的 API 呼叫路徑是否受影響? + +**結論:不受影響。** + +既有 Web UI 呼叫的所有端點(在 `apps/task-scheduler/server.js` 中驗證過): + +``` +POST /jobs (multipart 上傳) +GET /jobs (列表) +GET /jobs/:jobId (查詢單一 job) +GET /jobs/:jobId/events (SSE) +GET /jobs/:jobId/download/:filename (下載) +GET /health +``` + +這些端點 Phase 1 **保留原樣**。對外 API 是新增,不是取代。 + +### 2.3 既有 Web UI 要不要也改走新 OAuth 流程? + +**Design Agent 建議:Phase 1 不改,維持現況。** 但有一個 UX 面的警示要給使用者知道。 + +#### 建議不改的理由(UX 觀點) + +1. **內部工具的 UX 定位**:既有 Web UI 的 persona 是 **Kneron 內部 AI 工程師**,在可信網段內使用,不需要 OAuth 這層摩擦 +2. **加上 OAuth 等於要加登入流程**:會多出「登入頁 → 選 tenant → 授權」等步驟,對內部工具體驗是倒退 +3. **風險可控**:Web UI 目前應該是在內網 / VPN 後面運行,不直接暴露公網(這需要由 Architect 與 DevOps 確認,見下方警示) + +#### ⚠️ 警示 — 需要使用者與 Architect 釐清 + +**如果 Web UI 和對外 API 部署在同一個 Task Scheduler instance 上**(例如同一個 Express app 綁兩套 route),那麼: + +- 對外 API 的 public endpoint(`/api/v1/*`)會和 Web UI 用的 `/jobs` **共用同一個 TCP port / Nginx vhost** +- 若未來公開 `/api/v1/*` 到公網,`/jobs` 路徑也會曝光 +- 有心人可以直接打 `POST /jobs`(不需 OAuth)繞過對外 API 的 rate limit / scope 檢查 + +**緩解方案(建議交給 Architect 評估)**: +- 方案 A:**部署層級隔離** — Web UI 的 `/jobs` 只綁 internal network interface,`/api/v1/*` 綁 public interface +- 方案 B:**Nginx 路由控制** — 同一個 Task Scheduler,但 public Nginx 只 proxy `/api/v1/*`,internal Nginx 才 proxy `/jobs` +- 方案 C:**在 `/jobs` 加基本 IP allowlist**(內網 CIDR) + +### 2.4 既有 SSE (`/jobs/:id/events`) 機制在新架構下是否保留? + +**結論:保留,但只給 Web UI 用。對外 API 走 polling,不對外開放 SSE。** + +#### Design Agent 的觀察 + +1. **Web UI 繼續用 SSE**:既有 Web UI 的即時性體驗(看到 stage 切換、進度百分比即時跳動)是 UX 亮點,不能砍 +2. **對外 API 不做 SSE**(PM 已決策):polling 已足夠,理由充分: + - 下游消費者是另一個 backend 服務(visionA-backend),polling 對它們而言是標準做法 + - 不做 Webhook / SSE 能簡化 Phase 1 的 surface area + - visionA-backend 自己再決定要不要把進度以 SSE / WebSocket 推給 VisionA 前端 +3. **但要確保 SSE endpoint 不意外被對外 API 消費者呼叫**(沒意義且資源浪費): + - `/jobs/:id/events` 保留在非 OAuth 路徑下,visionA-backend 沒有理由呼叫它 + - 若要防禦,可以在 Nginx 層封堵 public access 到 `/jobs/*/events` + +#### UX 風險監測 + +visionA-backend 的 polling 策略會直接影響 VisionA 使用者看到的進度「順暢度」。建議 API 規格中明確給出指引(見第 4 節「給 Architect 的建議」): +- 建議的 polling 間隔(2-5 秒) +- 鼓勵 `stage_changed` 時立即 poll(讓 VisionA 能快速反應階段切換) +- 回應中的 `etag` / `updated_at` 讓消費者知道何時真的有變 + +--- + +## 3. 新呼叫方(visionA-backend)間接影響分析 + +### 3.1 間接 UX 影響總覽 + +雖然我們不設計 VisionA 的 UI,但以下 API 設計決策會 **直接限制** visionA 前端能做出的體驗: + +| Converter API 設計點 | 對 VisionA 終端使用者 UX 的影響 | Design Agent 建議 | +|--------------------|----------------------------|------------------| +| 同使用者同時一個轉檔限制(US-11)| 使用者想同時轉兩個不同模型會被擋 | 回 409 時附清晰的 `active_job_id` + 人類可讀的訊息,讓 VisionA 能做「你有一個轉檔進行中,要切過去看嗎?」的 UX | +| Polling 模式(不做 Webhook)| 進度更新延遲 = polling 間隔 | API 文件明示建議 polling 間隔;回應要快(p95 < 200ms)避免 VisionA 排隊等待 | +| `POST /api/v1/jobs` 採 multipart/form-data(原始模型直接上傳)| 大檔上傳期間使用者需要「可取消 / 可重試」的體感 | API 文件建議 visionA-backend 在上傳到 Converter 時使用支援 progress event 的 HTTP client(原生 `net/http` + `io.TeeReader` 或等效),並在 visionA 前端以 progress bar 呈現上傳百分比;建議 Architect 在 TDD 中明確:收檔失敗 / 網路中斷時回 4xx/5xx 足以讓 visionA-backend 判斷是否重試(不要只 reset connection)| +| 原始模型檔案大小上限 500MB | 超過上限的使用者會被擋在建 job 前 | API 在 413 `file_too_large` 回應中附 `details.limit_bytes` / `details.actual_bytes`,讓 visionA 前端能顯示具體原因;VisionA 前端可在上傳前做 client-side 大小檢查提前攔截 | +| promote 需要另一個 API 呼叫 | 使用者要按兩次(轉完 + 加進模型庫)| **建議 visionA 前端把兩步驟做成一次操作**(使用者按一次「轉檔並加進模型庫」,VisionA backend 內部自動 chain),但這是 VisionA 側的決定 | +| Converter Bucket 7 天 lifecycle | 使用者轉完沒 promote,7 天後檔案消失 | API 回應中要暴露 `expires_at`,讓 VisionA 能在 UI 顯示「檔案將於 X 天後自動清除」| +| Recovery API 是 list 模式 | 使用者離開後回來 → VisionA 前端要決定是否自動跳轉到 job 頁 | API 要回足夠多資訊(job_id、stage、progress、created_at),讓 VisionA 前端做智慧決策 | +| Phase 1 沒有 delegated download | 使用者在 VisionA 模型庫看到模型,但不能下載 | **嚴重 UX 缺口,見第 6 節議題 #1** | + +### 3.2 錯誤情境的 UX 投射 + +visionA 前端能把 Converter 回的錯誤轉成什麼樣的訊息,完全取決於我們回了什麼。以下是 Design Agent 建議 API 要清楚區分的錯誤類型: + +| 情境 | HTTP 狀態 | 錯誤碼建議 | VisionA 能做出的 UX | +|------|---------|----------|------------------| +| token 無效 / 過期 | 401 | `unauthorized` | 背景 refresh token 重試 | +| token 有效但 scope 不足 | 403 | `insufficient_scope` + `required_scope` | 提示 admin 去 Member Center 補授權 | +| 已有 in-progress job | 409 | `user_has_active_job` + `active_job_id` | 「你有一個轉檔進行中,要切過去看嗎?」 | +| job 狀態不對(promote 時 job 還沒 completed)| 409 | `job_not_ready_for_promote` + `current_status` | 「轉檔還沒完成,請等進度條到 100% 再加進模型庫」 | +| multipart body 格式錯 / `model` 檔案欄位缺失 / 必填 field 缺失(例如 `user_id`、`model_id`)| 400 | `invalid_multipart` + `details.missing_field` 或 `details.reason` | 「上傳失敗,請確認檔案格式與欄位」+ 具體指出缺哪個欄位 | +| 原始模型超過 500MB 上限 | 413 | `file_too_large` + `details.limit_bytes` + `details.actual_bytes` | 「檔案過大(限制 500MB),請確認上傳的模型大小」| +| 轉檔失敗(模型本身的問題)| — | `job.error.reason` 要人類可讀 | 「在 BIE 量化階段失敗:[具體原因],建議檢查參考圖片是否足夠」 | +| File Access Agent 不可用(promote 時)| 502 | `file_gateway_unavailable` | 「模型庫服務暫時不可用,我們會自動重試」+ 顯示 retry 按鈕 | +| Member Center JWKS 取用失敗 | 503 | `auth_service_unavailable` | 系統層錯誤,顯示 maintenance banner | + +> **備註**:Phase 1 的原始模型改由 visionA-backend 以 multipart 直接上傳到 Converter,Converter 不再從 File Access Agent 拉原始模型,因此原本的 `input_object_not_found`(422)錯誤碼在 Phase 1 **不會出現**。取而代之的是上傳階段的 `invalid_multipart`(400)與 `file_too_large`(413)。File Access Agent 相關錯誤在 Phase 1 只會發生在 promote 階段。 + +### 3.3 API 設計上要給 visionA 留下哪些彈性? + +為了讓 visionA 前端能做出更好的 UX,API 要預留以下能力(即使 Phase 1 可以先不實作,但 schema 要設計得可擴展): + +#### 必要(Phase 1) +1. **狀態細節**:不只 `status`,要有 `stage` + `progress`(0-100) +2. **錯誤碼結構化**:`error.code` + `error.message` + `error.details`(可擴展) +3. **時間戳完整**:`created_at`、`updated_at`、各階段的 `stage_started_at` / `stage_completed_at`(用於 VisionA 做階段耗時顯示) +4. **保留欄位**:`metadata: {}`(讓未來能加欄位不破壞 API 契約) +5. **job_id 可識別**:使用者若要截圖 / 反映問題,能清楚引用 job_id + +#### 建議(未必 Phase 1,但設計時要預留) +6. **ETA 欄位**:`estimated_completion_at`(Phase 1 可回 null,未來再實作) +7. **取消能力**:即使 Phase 1 不實作,API 路徑 `DELETE /api/v1/jobs/:id` 要預留(避免 Phase 2 需要時得改契約) +8. **Webhook 註冊**:同上,即使 Phase 1 不實作,資料模型要考量未來可擴展 +9. **progress 的顆粒度**:每個 stage 內部的 progress(例如「BIE 階段 45%」而不只是「整體 33%」) + +--- + +## 4. 建議與風險 + +### 4.1 給 Architect 的 API 設計建議(UX 觀點) + +以下建議從「下游消費者能做出什麼樣的 UX」角度出發,請 Architect 在 TDD 中採納或說明取捨。 + +#### 4.1.1 Response Schema 建議 + +**`GET /api/v1/jobs/:id` 回應範例(建議)**: + +```json +{ + "job_id": "uuid-v4", + "user_id": "visionA-user-id", + "status": "running", // created / running / completed / failed + "stage": "bie", // onnx / bie / nef / null + "progress": 45, // 0-100(整體) + "stage_progress": 60, // 0-100(當前階段內)—— 建議有 + "created_at": "2026-04-25T12:00:00Z", + "updated_at": "2026-04-25T12:05:30Z", + "stage_timings": { // 建議有,讓 VisionA 能顯示階段耗時 + "onnx": {"started_at": "...", "completed_at": "..."}, + "bie": {"started_at": "...", "completed_at": null}, + "nef": null + }, + "estimated_completion_at": null, // Phase 1 可為 null + "result_object_keys": null, // completed 時才有 + "expires_at": "2026-05-02T12:00:00Z", // Converter Bucket 7 天後過期 + "error": null, // 失敗時結構化錯誤 + "metadata": {} +} +``` + +**`POST /api/v1/jobs` 衝突回應(建議)**: + +```json +{ + "error": { + "code": "user_has_active_job", + "message": "使用者目前已有進行中的轉檔任務", + "details": { + "active_job_id": "uuid-v4", + "active_job_status": "running", + "active_job_stage": "bie", + "active_job_progress": 45 + } + } +} +``` + +這讓 visionA 前端能直接顯示「你有一個轉檔進行中(BIE 階段 45%),要切過去看嗎?」而不用再多打一次 API 查詳情。 + +#### 4.1.2 Polling 效能考量 + +- **p95 < 200ms**(已在 PRD §9.2.1):visionA-backend 可能每 2-5 秒 polling 一次,如果 API 慢 visionA 的 UI 會跟著卡 +- **建議加 `ETag` / `If-None-Match` 支援**:visionA-backend 可以在 304 時跳過資料傳輸,省流量 +- **避免無必要的 DB 查詢**:`GET /api/v1/jobs/:id` 應該只讀 Redis,不做任何外部 HTTP 呼叫(否則 polling × N 個使用者會變擴增 load) + +#### 4.1.3 promote API 的 UX 考量 + +**建議 `POST /api/v1/jobs/:id/promote` 是同步呼叫並回等候 result**: +- 好處:visionA 前端可以直接顯示 loading → 成功 / 失敗 +- 風險:PUT 到 File Access Agent 若慢(大檔),API 會 block 幾秒(p95 < 3s 已在 SLA) +- 備案:若 PUT 超過某個 timeout(例如 10s),API 回 202 + 新的 `promote_job_id`,visionA polling 查 promote 進度 +- Architect 決策:Phase 1 建議先做同步版本(簡單),失敗率觀察後再決定是否需要 async 模式 + +#### 4.1.4 其他 + +- **版本化**:`/api/v1/` 是對的,建議 OpenAPI spec 明確標注「breaking change 會走 `/api/v2/`」 +- **error response 統一格式**:所有 4xx/5xx 都用同一個 `{error: {code, message, details}}` 結構,避免 visionA 要寫多套 parser +- **job_id 格式固定**:建議用 UUID v4,不要用 snowflake 之類的自訂 ID(visionA 端的 log 比較好看) + +### 4.2 給 PM 的需求補強建議 + +PM 的 PRD 已經相當完整,以下是 Design Agent 從 UX 觀點發現可以補強的地方: + +#### 4.2.1 建議新增:使用者下載 messaging 策略(§15.1 或 §12.2) + +Phase 1 上線時 VisionA 使用者會遇到「我的模型在模型庫裡但下不下來」的情境。建議在 PRD 中增加一段: + +> **Phase 1 使用者下載缺口的 UX 處理方案(待確認)**: +> Phase 1 上線時,delegated download 尚未可用。VisionA 需在其 UI 中明確告知使用者「下載功能於 Phase 2 上線」,或採 fallback proxy 方案。此取捨由 VisionA 產品團隊主導,Kneron Converter 不需額外做事,但雙方需在 Phase 1 上線 kickoff 前對齊 messaging。 + +#### 4.2.2 建議新增:VisionA 前端 UX 對我們 API 的期望(§4.4 或新增 §4.5) + +目前 PRD §4.4 的 User Story 是從「visionA-backend」視角寫的。建議補一組 **從 VisionA 終端使用者視角** 的 UX acceptance criteria,例如: + +- 「轉檔成功後,使用者能在 5 秒內看到模型出現在自己的模型庫」 +- 「同使用者同時轉檔限制觸發時,使用者能在 2 秒內看到『你有一個轉檔進行中』的提示,並能一鍵跳過去」 +- 「轉檔失敗時,使用者能看到具體原因(不只是 generic error)」 + +這些是 UX 標準,不是技術標準,寫進 PRD 可以讓 Architect 在設計 API 時有依據。 + +#### 4.2.3 建議補強:`[推測]` 標記的清理策略 + +PRD 中保留了既有的 `[推測]` 標記,但 §1.2 新增的 Persona C 和 §4.3~4.4 的 User Stories 沒有 `[推測]` 標記(因為是基於本次討論確認的)。建議使用者審閱時: +- 新增章節(§1.2 Persona C、§4.3~4.4、§14、§15):請使用者確認後正式定案 +- 舊章節的 `[推測]` 標記:獨立一個工作項,逐條請使用者確認或刪除 + +這不是本次 L 級範圍,但建議 PM 在下一版 PRD 中處理。 + +### 4.3 給 Architect 的一般性 UX 風險提醒 + +#### 4.3.1 Polling 間隔太短可能讓 VisionA UI 卡頓? + +**風險等級:低** +- visionA-backend 是 polling 方(不是 visionA 前端直接 polling Converter),所以 Converter 端壓力可控 +- 但若有很多 VisionA 使用者同時轉檔,visionA-backend 可能每秒對 Converter 打幾十次 `GET /jobs/:id` +- **建議**:Architect 在 TDD 中明確給出 rate limit 策略(對 visionA-backend 的 client_credentials token 設 rate limit),並在 API 文件中建議 polling 間隔 + +#### 4.3.2 同時一個轉檔的限制可能引起使用者困惑 + +**風險等級:中** +- 「我剛剛明明建立了一個 job,為什麼再按一次就 409」可能讓使用者以為系統壞了 +- **建議**:429 或 409 的 response body 要帶完整的 active job 資訊(見 4.1.1),讓 visionA 前端能做出「你有 X 在轉中,要看嗎?」的友善提示 +- **護欄指標**:PRD §9.2.2 已經設計了「同使用者 409 比率 < 5%」的指標追蹤,Design Agent 同意這個目標 + +#### 4.3.3 Converter Bucket 7 天 lifecycle 和使用者期待不符 + +**風險等級:中** +- 使用者可能誤以為「轉完就在那裡了」,7 天後突然不見會很驚訝 +- **建議**: + 1. API 回應明確標注 `expires_at`(見 4.1.1) + 2. visionA 前端在轉檔結果頁顯示「請於 X 天內加進模型庫,否則檔案將被自動清除」 + 3. `POST /promote` 應該是非常顯眼的 primary action(但這是 VisionA 的 UI 決定) + +#### 4.3.4 使用者中途關掉頁面再回來(Recovery)是否會遺失 SSE? + +**風險等級:低(已由 PM 決策接受)** +- PRD 已明確 Phase 1 對外 API 是 polling 模式,visionA-backend 重新進入頁面時會呼叫 `GET /api/v1/jobs?user_id=...&status=in_progress` +- Converter 本身的 Redis 狀態在這段時間是持續更新的,所以 recovery 不會因為瀏覽器關掉而遺失資料 +- **風險**:若 Converter 在使用者離開期間 Crash(符合設計哲學),使用者回來會看不到任何 job + - PRD 已在 US-12 明示這個限制(「Crash 即 Reset」不保證跨 Crash recovery) + - Design Agent 接受這個設計取捨,但建議 API 回應中區分「沒有 in-progress job」vs「job 已不存在(可能因為 Crash / 過期)」讓 visionA 能給使用者不同提示 + +### 4.4 潛在 UX 風險清單(彙整) + +| # | 風險 | 嚴重度 | 建議處理 | +|---|------|--------|---------| +| 1 | Phase 1 使用者下載功能缺口 | **高** | 需 VisionA 產品團隊協調 messaging 策略(見議題 #1) | +| 2 | Web UI `/jobs` 路徑在公網意外曝露 | 中 | 部署層或 Nginx 層隔離(見 2.3 警示) | +| 3 | 7 天 lifecycle 讓使用者驚訝 | 中 | API 要暴露 `expires_at`,visionA UI 要顯示 | +| 4 | 同使用者 409 的使用者困惑 | 中 | 回應要帶完整 active job 資訊 | +| 5 | 錯誤訊息不夠人類可讀 | 中 | 結構化 error code + 可讀 message | +| 6 | Polling 效能對 visionA 的影響 | 低 | API p95 < 200ms + 建議 ETag | +| 7 | Crash 後 job 消失 | 低(已接受)| API 能區分「無 job」vs「job 不存在」 | +| 8 | 轉檔階段耗時資訊不足,使用者無法預期 | 低 | 回應中帶 `stage_timings`,未來可做 ETA | + +--- + +## 5. 其他觀察 + +### 5.1 既有 Web UI 的非本次議題(延伸觀察,不在本次範圍) + +在審查過程中 Design Agent 觀察到既有 Web UI 有以下 UX 債,但 **不屬於本次 L 級範圍**,僅作記錄供未來討論: + +1. **前後端 API 契約不一致**(已在 PRD §7.3 列出):Web UI 的單階段表單呼叫的 `/api/onnx/upload` 等端點後端沒實作。UX 影響:使用者按了按鈕會失敗 +2. **無時間預估**:使用者只能看到「BIE 階段 45%」,不知道總共還要多久 +3. **無批次上傳**:一次只能轉一個模型 +4. **錯誤訊息品質**:`job.error.reason` 欄位存在但文字品質未知 +5. **無登入系統**:內部工具現況,但若未來要加 OAuth 要從頭設計登入 UX + +### 5.2 Mermaid 相依圖(從 UX 視角) + +```mermaid +flowchart LR + subgraph UsersWorld["使用者世界"] + KneronUser["Kneron 內部 AI 工程師
(既有 Web UI 使用者)"] + VisionAUser["VisionA 終端使用者
(新的 間接受益者)"] + end + + subgraph InnovedusEcosystem["Innovedus 生態"] + WebUI["Kneron Converter Web UI
(保留不動)"] + VisionAFrontend["VisionA 前端
(另一個團隊實作)"] + VisionABackend["visionA-backend
(Persona C)"] + MemberCenter["Member Center"] + FileAccessAgent["File Access Agent"] + end + + subgraph Converter["Kneron Model Converter(本專案)"] + OldAPI["舊 API
POST /jobs
GET /jobs/:id
GET /jobs/:id/events (SSE)
不加 OAuth"] + NewAPI["新 API
POST /api/v1/jobs
GET /api/v1/jobs/:id
POST /api/v1/jobs/:id/promote
OAuth2 Bearer"] + end + + KneronUser -->|multipart 上傳 / SSE 看進度| WebUI + WebUI -->|保持既有呼叫| OldAPI + + VisionAUser -->|平台內操作| VisionAFrontend + VisionAFrontend -->|VisionA 自家協議| VisionABackend + VisionABackend -->|取 service token| MemberCenter + VisionABackend -->|Bearer token + multipart
(含原始模型 + ref_images)| NewAPI + + NewAPI -.->|promote PUT 結果檔| FileAccessAgent + NewAPI -.->|驗 token / 取 token| MemberCenter + + classDef newPath fill:#e1f5ff,stroke:#0288d1,stroke-width:2px + classDef oldPath fill:#fff9c4,stroke:#fbc02d,stroke-width:2px + class NewAPI,VisionAUser,VisionAFrontend,VisionABackend,MemberCenter,FileAccessAgent newPath + class OldAPI,KneronUser,WebUI oldPath +``` + +**圖例**:黃色 = Phase 1 保留不動的既有路徑;藍色 = Phase 1 新增 / 整合的部分。 + +--- + +## 6. 需要使用者決策的 UX 議題 + +### 議題 #1(最重要):Phase 1 使用者下載的 messaging 策略 + +**背景**:Phase 1 上線後,VisionA 使用者能完成「上傳 → 轉檔 → 加進模型庫」,但 **不能下載** 已搬進模型庫的模型檔。這是因為 Phase 2 的 delegated download 阻塞於 Member Center 未實作 `POST /file-access/download-tokens`。 + +**為什麼這是重要 UX 議題**: +- 使用者的心智模型會是「我的模型在我的模型庫裡 → 我當然可以下載它」 +- 上線時如果下載按鈕不能按、或按了沒反應,會是明顯的 UX 死胡同 +- 使用者可能繞道去舊 Kneron Web UI 下載,但那邊資料和 VisionA 模型庫不同步(混亂) + +**可選方案**: + +| 方案 | 做法 | UX 優劣 | 工程成本 | +|------|------|--------|---------| +| A | 隱藏下載按鈕,標示「下載功能 Phase 2 上線」| UX 誠實,但缺口明顯 | 低(VisionA 前端) | +| B | VisionA backend 做 proxy 下載(VisionA backend 從 File Access Agent 拉檔再回給瀏覽器)| 暫時堪用,但違反原架構(大檔過 VisionA backend)| 中(VisionA backend) | +| C | 使用者點下載 → 跳轉到舊 Kneron Converter Web UI 下載 | 技術最簡單,但資料不同步,使用者會混亂 | 低(導流)| +| D | 等 Member Center 實作完才上線 Phase 1 | UX 完整,但時程被別人卡住 | 阻塞 | + +**Design Agent 傾向方案 A**(隱藏 + messaging),但這需要 **使用者 + VisionA 產品團隊** 一起決定,不是 Converter 單方能決定。 + +**建議 Orchestrator 協調**:Phase 1 kickoff 前召一次 VisionA 產品團隊 + Kneron Converter PM + Member Center owner 的跨團隊會議,明確 messaging 策略與時程。 + +### 議題 #2(次要):既有 Web UI 的公開曝光風險 + +**背景**:見第 2.3 節。若未來 `/api/v1/*` 對外公開而 `/jobs` 沒做任何隔離,可能被繞過。 + +**需要使用者確認**: +1. 本專案部署目標是什麼?(純內網 / VPN 後面 / 公網) +2. Web UI 是否會和對外 API 在同一個 public entry 後面? + +這個議題交由 Architect Agent 在 TDD 中提出具體部署策略,使用者確認即可。Design Agent 只提出警示。 + +### 議題 #3(低優先):既有 PRD 的 `[推測]` 標記清理 + +見 4.2.3。建議在本次 L 級之後單獨開一個工作項處理。不阻塞 Phase 1。 + +--- + +## 7. 結論 + +### 7.1 對三方聯合討論的 Design 立場 + +- **✅ 同意** PM 的 Phase 1 / Phase 2 切分(§15) +- **✅ 同意** user_id 以 multipart form field 帶入(方式 A),而非放 token claim +- **✅ 同意** 原始模型採 multipart 直連上傳(visionA-backend → Converter),不經過 File Access Agent(2026-04-25 更新) +- **✅ 同意** polling 模式不做 Webhook +- **✅ 同意** Web UI 不改(Phase 1 保留既有路徑和 UX) +- **✅ 同意** 搬檔做法 2(Converter 自己 PUT 到 File Access Agent,僅限 promote 階段) +- **⚠️ 要求補強**:Phase 1 使用者下載 messaging 策略(議題 #1) +- **⚠️ 要求補強**:API response schema 要符合第 4.1 節的 UX 期望(錯誤結構化、stage_timings、expires_at 等) +- **⚠️ 新增 UX 關切**:multipart 上傳的 progress bar 呈現與 `invalid_multipart` / `file_too_large` 錯誤碼細節結構化(見 3.1、3.2) + +### 7.2 Design Agent 對其他兩方文件的審閱(待三方產出後執行) + +- 審閱 PRD 時的重點:Persona C 是否清楚描述服務對服務的互動?終端使用者的 UX 期望是否納入 acceptance criteria? +- 審閱 TDD 時的重點:API response schema 是否提供足夠彈性讓 visionA 做好 UX?錯誤碼是否結構化?polling 效能是否達 SLA? + +### 7.3 本次 Design Review 的直接產出 + +1. 本份 `design-review.md` +2. `user-flow-cross-system.md`(跨系統使用者流程圖) +3. **不產出** Wireframe、Prototype、Design Tokens(本次無新 UI 需求) diff --git a/docs/autoflow/03-design/user-flow-cross-system.md b/docs/autoflow/03-design/user-flow-cross-system.md new file mode 100644 index 0000000..b1f6930 --- /dev/null +++ b/docs/autoflow/03-design/user-flow-cross-system.md @@ -0,0 +1,353 @@ +# 跨系統使用者流程圖 — VisionA 終端使用者視角 + +> **視角**:VisionA 終端使用者(Edge AI 應用開發者)在 VisionA 平台內完成模型轉檔的完整體驗。 +> +> **重點**:從使用者能「感知到什麼」切入,而非技術細節。Token 種類、錯誤情境等只標示關鍵分支,詳細 API 規格見 `04-architecture/TDD.md`。 +> +> **涵蓋情境**: +> - 情境 A:首次上傳模型並轉檔(主要 Happy Path) +> - 情境 B:離開頁面後回來看未完成的 job(Recovery) +> - 情境 C:Phase 2 使用者下載模型(阻塞中 — 僅供未來參考) +> +> **服務標記慣例**: +> - 🧑 = VisionA 終端使用者(人類) +> - 🖥️ = VisionA 前端(瀏覽器) +> - ⚙️ = visionA-backend(Go 服務) +> - 🔐 = Member Center(Auth 中心) +> - 📦 = File Access Agent(檔案閘道) +> - 🏭 = Kneron Converter API(本專案) +> - 🗄️ = Converter Bucket(MinIO 暫存) +> - 💾 = NAS Bucket(模型庫長期儲存) + +## 變更歷程 + +| 日期 | 變更內容 | +|------|---------| +| 2026-04-25 | 原始模型上傳路徑改為 visionA-backend → Converter multipart 直連(不經過 File Access Agent)。`POST /api/v1/jobs` 改為 multipart/form-data,移除 `input_object_key` 欄位,`user_id` 改以 multipart field 帶入。Phase 1 Converter 不再從 File Access Agent 讀取原始模型,只在 promote 階段 PUT 結果檔。階段編號:原「階段 2(上傳 FAA)+ 階段 3(建 job)」合併為新「階段 2(建 job + 上傳)」,後續階段編號往前挪一格。| + +--- + +## 情境 A:使用者上傳新模型並轉檔(Happy Path) + +### A.1 使用者視角的流程摘要(非技術) + +``` +🧑 使用者在 VisionA 平台 + │ + ├─ 1. 在「模型庫」按「新增模型」→ 選本機 ONNX 檔 + │ + ├─ 2. 填寫 model_id / version / 選擇 Kneron 晶片平台(520/720/...) + │ + ├─ 3. 按「上傳並轉檔」 + │ ↓ + │ [系統顯示進度條:ONNX 優化 0% → 33% → BIE 量化 33% → 66% → NEF 編譯 66% → 100%] + │ + ├─ 4. 轉檔完成!顯示成功訊息 + 「加進我的模型庫」按鈕 + │ + └─ 5. 按「加進我的模型庫」→ 短暫 loading → 顯示「已加入模型庫」 + ↓ + 使用者在模型庫看到新的已轉檔模型 +``` + +**使用者感知到的總體體驗**:從選檔到加進模型庫,一條流水線,使用者不需要知道背後有多個服務在協作(建 job 時涉及 visionA-backend / Member Center / Converter,promote 時才會再呼叫 File Access Agent)。 + +--- + +### A.2 完整跨系統流程圖(Mermaid Sequence) + +```mermaid +sequenceDiagram + autonumber + participant U as 🧑 使用者 + participant FE as 🖥️ VisionA 前端 + participant BE as ⚙️ visionA-backend + participant MC as 🔐 Member Center + participant FA as 📦 File Access Agent + participant CV as 🏭 Converter API + participant CB as 🗄️ Converter Bucket + participant NB as 💾 NAS Bucket + + Note over U,FE: 階段 1:選檔 + 填表 + U->>FE: 選本機 ONNX 檔 + U->>FE: 填 model_id / version / platform + U->>FE: 按「上傳並轉檔」 + FE->>BE: 送出 (multipart: 檔案 + 參數) + + Note over BE,CB: 階段 2:visionA-backend 呼叫 Converter 建 job(multipart 同時帶原始模型) + BE->>MC: POST /oauth/token (client_credentials, scope=converter:job.write) + MC-->>BE: service token (aud=kneron_converter_api) + BE->>CV: POST /api/v1/jobs
Authorization: Bearer token
Content-Type: multipart/form-data
files: model (必填, ≤500MB), ref_images[] (optional, maxCount 100)
fields: user_id, model_id, version, platform, enable_evaluate, enable_sim_fp, enable_sim_fixed, enable_sim_hw + CV->>MC: 驗 token (JWKS) + MC-->>CV: 驗證通過 + CV->>CV: 檢查:user_id 是否有 in-progress job?
(US-11 同使用者一個轉檔限制) + + alt 有 in-progress job + CV-->>BE: 409 Conflict + {code: user_has_active_job, active_job_id, active_job_status, active_job_stage} + BE-->>FE: 409 + active job 詳情 + FE-->>U: 顯示「你有一個轉檔進行中,要切過去看嗎?」 + Note over U: 走情境 B(Recovery) + else 沒有 in-progress job + CV->>CB: 寫入原始模型 (jobs/{job_id}/input/{filename})
(含 ref_images[] 如有) + CV->>CV: 建 job 記錄,塞進 Redis
Enqueue 到 onnx-worker + CV-->>BE: 201 Created + {job_id, status: created} + BE-->>FE: 201 + job_id + end + + Note over FE,CV: 階段 3:Polling 進度(每 2-5 秒一次) + loop 直到 status=completed 或 failed + FE->>BE: GET /api/jobs/{job_id}/status + BE->>CV: GET /api/v1/jobs/{job_id}
Authorization: Bearer token + CV-->>BE: {status, stage, progress, stage_timings, expires_at, ...} + BE-->>FE: 轉譯後的進度 JSON + FE->>U: 更新進度條 + 階段顯示 + end + + Note over CV: Worker 背景處理:ONNX → BIE → NEF
(Crash 即 Reset 設計,不保證跨 Crash 存活) + CV->>CB: 儲存各階段結果(out.onnx / out.bie / out.nef) + + Note over FE,U: 階段 4:轉檔完成,顯示「加進模型庫」按鈕 + CV-->>BE: {status: completed, result_object_keys: [...], expires_at: "2026-05-02"} + BE-->>FE: 已完成 + result 資訊 + FE->>U: 顯示「轉檔完成!」
+「加進我的模型庫」按鈕
+「檔案將於 7 天後自動清除」提示 + + Note over U,NB: 階段 5:使用者按「加進模型庫」→ promote + U->>FE: 按「加進我的模型庫」 + FE->>BE: POST /api/models/{job_id}/add-to-library + BE->>CV: POST /api/v1/jobs/{job_id}/promote
Authorization: Bearer token
Body: {target_object_key: "visionA/models/{user_id}/{model_id}/v{version}/out.nef", ...} + CV->>MC: 取 files:upload.write token(若 cache 過期) + MC-->>CV: service token + CV->>CB: 讀結果檔 + CV->>FA: PUT /files/{target_object_key}
Authorization: Bearer token
Body: out.nef + FA->>MC: 驗 token + MC-->>FA: 驗證通過 + FA->>NB: 儲存到 NAS(使用者的模型庫區) + FA-->>CV: 200 OK + CV-->>BE: 200 OK + promoted_object_keys + BE-->>FE: 成功 + FE->>U: 顯示「已加入模型庫」 + Note over U: 使用者在模型庫看到新模型 +``` + +### A.3 使用者感知到什麼 vs 背後發生什麼 + +| 使用者看到 / 做的事 | 背後發生的事(簡述)| +|------------------|------------------| +| 選檔並按「上傳並轉檔」 | visionA-backend 以 multipart/form-data 直接把原始模型上傳到 Converter(經 Member Center auth 取 `converter:job.write` service token),Converter 收檔後暫存在自己的 Bucket | +| 進度條跳動 | visionA-backend 每 2-5 秒 polling Converter,拿到新進度就更新 UI | +| 「ONNX 優化 → BIE 量化 → NEF 編譯」階段切換 | Converter 的 Task Scheduler 在 Redis 中推進 job stage,Worker 消耗 stage queue | +| 「檔案將於 7 天後自動清除」 | Converter Bucket 有 7 天 lifecycle,促使使用者做 promote | +| 按「加進我的模型庫」 | visionA-backend 呼叫 Converter `/promote`,Converter 自己把檔案 PUT 到 File Access Agent,不經 visionA-backend(省流量)| +| 模型出現在使用者的模型庫 | NAS Bucket 已經收到檔案(在 VisionA 使用者模型庫的 objectKey path 下)| + +### A.4 關鍵時間點與可能的等待體驗 + +| 時間點 | 使用者感受 | 影響 UX 的因素 | +|-------|---------|--------------| +| 按「上傳並轉檔」後 | Loading / 上傳進度條 | 單一 multipart 上傳從 visionA-backend 到 Converter 的時間,**與檔案尺寸相關**(上限 500MB)。**建議 UI 顯示 upload progress bar**:multipart 上傳可追蹤 byte 進度(XHR upload progress event 或等效機制),不再像舊設計有「上傳 FAA + 拉檔入 Bucket」兩段式等待 | +| 進度條階段切換 | 期待每階段的時間均等,但實際上 NEF 通常最久 | 建議 UI 顯示各階段預估時間(可從 `stage_timings` 歷史推算)| +| 轉檔完成 → 按「加進模型庫」 | 短暫 loading | API p95 < 3s,使用者體感應該 OK | +| 轉檔失敗 | 希望看到具體原因 | 錯誤碼要結構化,`error.details.stage` + 人類可讀的 `error.details.reason` | + +--- + +## 情境 B:使用者離開頁面後回來(Recovery) + +### B.1 使用者視角 + +``` +🧑 使用者在轉檔進行中... + │ + ├─ 按上一頁 / 關閉瀏覽器 / 切到其他 app + │ [Converter 背景繼續轉檔,不受影響] + │ + ├─ 15 分鐘後回來 VisionA,進入「模型庫」或「轉檔中心」 + │ + ├─ 系統自動偵測到「你有一個轉檔進行中」 + │ └─ 顯示:「模型 XYZ 正在轉檔(BIE 階段 60%),要繼續追蹤嗎?」 + │ + └─ 使用者按「繼續追蹤」→ 回到進度頁,正常 polling 進度 +``` + +### B.2 跨系統流程(Mermaid Sequence) + +```mermaid +sequenceDiagram + autonumber + participant U as 🧑 使用者 + participant FE as 🖥️ VisionA 前端 + participant BE as ⚙️ visionA-backend + participant MC as 🔐 Member Center + participant CV as 🏭 Converter API + + Note over U,FE: 使用者重新打開 VisionA 模型庫頁 + U->>FE: 進入頁面 + FE->>BE: GET /api/models/in-progress?user_id=... + BE->>MC: 取 service token(若 cache 過期)
scope=converter:job.read + MC-->>BE: service token + BE->>CV: GET /api/v1/jobs?user_id=...&status=in_progress
Authorization: Bearer token + CV->>MC: 驗 token + MC-->>CV: 驗證通過 + CV->>CV: 從 Redis 查 user_id 對應的所有 in-progress job + CV-->>BE: [{job_id, status, stage, progress, created_at}, ...] + + alt 有 in-progress job + BE-->>FE: job 列表 + FE->>U: 顯示「你有一個轉檔進行中
模型 XYZ - BIE 階段 60%
[繼續追蹤] [放著不管]」 + U->>FE: 按「繼續追蹤」 + FE->>U: 進入進度頁,開始 polling
(同情境 A 階段 4) + else 沒有 in-progress job + BE-->>FE: 空陣列 + FE->>U: 正常顯示模型庫頁,不打擾使用者 + end + + Note over CV: ⚠️ 特別情境:Converter 在使用者離開期間 Crash + Note over CV: Redis 被重置 → in-progress job 消失
(符合「Crash 即 Reset」設計) + Note over U: 使用者回來看不到 job
需要重新上傳(US-12 已明示此限制) +``` + +### B.3 Recovery 的 UX 考量 + +| 考量點 | 處理建議 | +|-------|---------| +| 使用者不記得自己有 job 在跑 | 進入頁面時主動查詢並顯示提示 | +| 使用者有多個裝置(手機+電腦)都登入 | 每個裝置都會看到相同的 in-progress job(因為是 server-side 查詢)| +| Converter Crash 導致 job 消失 | VisionA 前端顯示「上次的轉檔記錄已遺失,建議重新上傳」(需 visionA-backend 決定是否保留本地 cache)| +| 回來時 job 已完成 | 可查 `GET /api/v1/jobs?user_id=...&status=completed` 看最近完成的 job(可選功能)| + +--- + +## 情境 C:Phase 2 — 使用者直接下載模型庫檔案(阻塞中) + +> ⚠️ **重要提示**:本情境的 `POST /file-access/download-tokens` endpoint 在 Member Center **尚未實作**,Phase 1 **不做此情境**。 +> +> 以下流程圖僅供未來 Phase 2 啟動時參考。Phase 1 上線時使用者如何處理下載需求,見 `design-review.md` 第 6 節「議題 #1」。 + +### C.1 Phase 2 目標使用者體驗 + +``` +🧑 使用者在 VisionA 模型庫 + │ + ├─ 看到已轉檔的模型 + │ + ├─ 按「下載 .nef 檔」 + │ + └─ 瀏覽器直接下載(快速,因為直連 File Access Agent,不經 visionA-backend) +``` + +### C.2 Phase 2 跨系統流程(Mermaid Sequence,尚未實作) + +```mermaid +sequenceDiagram + autonumber + participant U as 🧑 使用者 + participant FE as 🖥️ VisionA 前端 + participant BE as ⚙️ visionA-backend + participant MC as 🔐 Member Center + participant FA as 📦 File Access Agent + participant NB as 💾 NAS Bucket + + Note over U,FE: 使用者按下載按鈕 + U->>FE: 按「下載 .nef」 + FE->>BE: POST /api/models/{model_id}/download-token + + Note over BE,MC: ⚠️ Phase 2 阻塞中:Member Center 尚未實作此 endpoint + BE->>MC: POST /file-access/download-tokens
Body: {tenant_id, user_id, object_key, scope: "files:download.delegate"} + MC-->>BE: {delegated_token (opaque, exp <= 5min), expires_at} + BE-->>FE: delegated_token + File Access Agent 的 download URL + + Note over FE,FA: 瀏覽器直接下載(不經 visionA-backend,省流量) + FE->>FA: GET /files/{object_key}?token= + FA->>MC: POST /validate-delegated-token
Body: {token} + MC-->>FA: {valid: true, user_id, object_key, tenant_id} + FA->>NB: 讀檔 + FA-->>FE: 檔案(stream 下載) + FE->>U: 瀏覽器儲存檔案 +``` + +### C.3 Phase 2 體驗 vs Phase 1 折衷方案的 UX 落差 + +| 項目 | Phase 1 折衷方案(任一)| Phase 2 完整方案 | +|------|----------------------|----------------| +| 下載速度 | 慢(大檔走 visionA-backend proxy)或無法下載(隱藏按鈕)| 快(瀏覽器直連 File Access Agent)| +| 對 visionA-backend 的負擔 | 高(若 proxy)| 零 | +| 檔案流量路徑 | 使用者 → visionA-backend → File Access Agent → 使用者 | 使用者 ↔ File Access Agent(直連)| +| 使用者 UX | 有 spinner,可能 timeout | 瀏覽器原生下載體驗 | +| 安全性 | 同 Phase 2(都走 Member Center auth)| 同 | + +--- + +## 綜合:三個情境的狀態機 + +```mermaid +stateDiagram-v2 + [*] --> 選擇模型: 使用者進入 VisionA + + 選擇模型 --> 上傳中: 選檔 + 填表 + 提交 + 上傳中 --> 建 job: multipart 直接上傳到 Converter(含原始模型 + ref_images) + + 建 job --> 有衝突: 檢查 user_id 已有 in-progress + 有衝突 --> 繼續追蹤舊 job: 使用者選擇查看既有 job + + 建 job --> 轉檔中: 無衝突,Converter 收檔並建立 job + 轉檔中 --> ONNX 階段 + ONNX 階段 --> BIE 階段: 完成 + BIE 階段 --> NEF 階段: 完成 + NEF 階段 --> 轉檔完成: 完成 + + ONNX 階段 --> 轉檔失敗: 失敗 + BIE 階段 --> 轉檔失敗: 失敗 + NEF 階段 --> 轉檔失敗: 失敗 + + 轉檔中 --> 使用者離開: 使用者關頁面 + 使用者離開 --> 繼續追蹤舊 job: 使用者回來(走情境 B Recovery) + 使用者離開 --> Job 消失: Converter Crash(符合設計哲學) + Job 消失 --> [*] + + 轉檔完成 --> 已 promote: 使用者按「加進模型庫」(Converter PUT 到 File Access Agent) + 已 promote --> 下載: 使用者要用模型 + + 下載 --> Phase 1 折衷: Phase 1(下載功能缺口) + 下載 --> Phase 2 直連: Phase 2(等 Member Center 補完) + + 轉檔失敗 --> [*]: 使用者看錯誤訊息 + Phase 1 折衷 --> [*] + Phase 2 直連 --> [*] + 繼續追蹤舊 job --> 轉檔中 +``` + +--- + +## 附錄:Token 類型快速對照 + +| Token | 誰持有 | 誰簽發 | 誰驗 | 用途 | +|-------|-------|-------|------|------| +| visionA-backend service token(aud=kneron_converter_api, scope=converter:job.write / converter:job.read)| visionA-backend | Member Center(client_credentials grant)| Converter API(JWKS 驗簽)| visionA-backend 呼叫 Converter API(建 job / 查詢 / promote)| +| Converter service token(aud=file_access_api, scope=files:upload.write)| Converter API | Member Center(client_credentials grant)| File Access Agent | Converter 在 promote 階段 PUT 結果檔到 NAS。**Phase 1 不再需要 `files:download.read` / `files:metadata.read`**,因為原始模型已改由 visionA-backend multipart 直接上傳到 Converter,Converter 不再從 FAA 拉檔 | +| Delegated download token(Phase 2)| 瀏覽器 | Member Center(代 user 簽)| File Access Agent(線上驗)| 使用者瀏覽器直連 File Access Agent 下載 | + +--- + +## 附錄:錯誤情境快速對照 + +| 使用者看到的訊息 | 技術原因 | 對應 HTTP 狀態 + error code | +|---------------|---------|--------------------------| +| 「你有一個轉檔進行中,要切過去看嗎?」| 同使用者同時一個轉檔限制觸發 | `POST /api/v1/jobs` 回 409 `user_has_active_job` | +| 「轉檔失敗:BIE 量化階段 — [具體原因]」| Worker 內部失敗 | Job 的 `status=failed`, `error.details.stage=bie`, `error.details.reason=...` | +| 「上傳失敗,請確認檔案格式與欄位」| multipart body 格式錯 / `model` 欄位缺失 / 必填 field 缺失(例如 `user_id`、`model_id`)| `POST /api/v1/jobs` 回 400 `invalid_multipart` | +| 「檔案過大,請確認模型不超過 500MB」| 上傳檔案超過 Converter 的大小上限 | `POST /api/v1/jobs` 回 413 `file_too_large` | +| 「模型庫服務暫時不可用,請稍後再試」| promote 時 File Access Agent 不可用 | `POST /promote` 回 502 `file_gateway_unavailable` | +| 「轉檔還沒完成,請等進度條到 100% 再加進模型庫」| promote 時 job 還沒 `completed` | `POST /promote` 回 409 `job_not_ready_for_promote` | +| 「你的登入已過期,請重新登入」| visionA-backend 的 user session 過期(visionA 自己的 auth,不是 Converter 的問題)| visionA-backend 自己處理 | +| 「系統錯誤,請聯絡客服」| Member Center 或 File Access Agent 完全無法連線 | `POST /api/v1/jobs` 回 503 `auth_service_unavailable` 或類似(Phase 1 建 job 本身不依賴 FAA,FAA 錯誤集中在 promote)| + +--- + +## 結語 + +這份跨系統流程圖揭露的關鍵 UX 訊息: + +1. **使用者只看到「在 VisionA 平台內按幾下就轉完」**,背後有多個服務協作(visionA-backend / Member Center / Converter / File Access Agent)— 這是架構要盡力維持的體驗。值得注意的是 Phase 1 **建 job 階段只涉及 3 方**(visionA-backend / Member Center / Converter),File Access Agent 僅在 promote 階段參與,簡化了上傳時的故障面 +2. **Phase 1 的 UX 閉環卡在「下載」**,需要 VisionA 產品團隊協調 messaging(見 `design-review.md` 議題 #1) +3. **Recovery 體驗是 VisionA 的 UX 優勢**,但受限於「Crash 即 Reset」設計,不保證跨 Crash 存活 — 要誠實告知使用者 +4. **錯誤訊息的人類可讀性直接決定使用者對系統的信任**,Architect 在 TDD 中要重視 error code 的結構化設計 +5. **multipart 直連上傳的 UX 優勢**:相較於舊設計「visionA-backend → FAA → Converter 拉檔」兩段式,新設計從使用者按下「上傳並轉檔」到 Converter 建 job 成功,只有一次網路傳輸;上傳時間可用 progress bar 精準呈現,不再有中間「看不見的 Converter 拉檔等待」 diff --git a/docs/autoflow/04-architecture/TDD.md b/docs/autoflow/04-architecture/TDD.md new file mode 100644 index 0000000..223c621 --- /dev/null +++ b/docs/autoflow/04-architecture/TDD.md @@ -0,0 +1,1392 @@ +# TDD — Kneron Model Converter 對外 API(Phase 1) + +## 作者:Architect Agent +## 狀態:Draft(三方交叉審閱前) +## 最後更新:2026-04-25 +## 配套文件: +- `design-doc.md`(架構決策) +- `../02-prd/PRD.md`(需求) +- `../03-design/design-review.md`(UX 回饋) + +本 TDD 聚焦 Phase 1 實作細節。所有決策背後的「為什麼」請參考 `design-doc.md`。 + +## 變更歷程 + +| 日期 | 變更 | 作者 | +|------|------|------| +| 2026-04-25 | 初版 Draft 1.0 | Architect Agent | +| 2026-04-25 | 原始模型上傳路徑改為 visionA-backend multipart 直接上傳 Converter;POST /api/v1/jobs 改 multipart/form-data;移除 FAA `getFile()` / `headFile()` / `files:download.read` / `files:metadata.read` 相關內容;TBD-1、input_object_key、input_not_found 相關內容同步移除 | Architect Agent | + +--- + +## 1. API 規格(Phase 1 必做) + +### 1.1 通用約定 + +- **Base URL**:`https:///api/v1`(public vhost,僅此路徑對外) +- **Content-Type**: + - `POST /api/v1/jobs`:`multipart/form-data`(與既有 Web UI `POST /jobs` 一致) + - 其他端點(GET / POST `/promote`):request 為 `application/json; charset=utf-8` + - **所有 response**:`application/json; charset=utf-8` +- **時間格式**:ISO 8601 UTC(例:`2026-04-25T12:00:00Z`) +- **ID 格式**:`job_id` 採 UUIDv4(字串) +- **認證**:`Authorization: Bearer `(除 `/health` 外全部必要) +- **Request ID**:若 client 傳 `X-Request-Id`,回應帶同一值;未傳則 server 產 UUIDv4。所有 log 必須記錄。 +- **速率限制**:per `client_id` 300 req / 5min(header 回 `X-RateLimit-Limit`、`X-RateLimit-Remaining`、`X-RateLimit-Reset`) + +### 1.2 統一錯誤格式 + +所有 4xx / 5xx 回應: + +```json +{ + "error": { + "code": "string_code", + "message": "human readable message (zh-TW)", + "details": { /* 可選,結構視 code 而定 */ }, + "request_id": "uuid-v4" + } +} +``` + +### 1.3 端點清單 + +| 方法 | 路徑 | 說明 | 需 scope | +|------|------|------|---------| +| GET | `/health` | 健康檢查 | — | +| POST | `/api/v1/jobs` | 建立轉檔 job | `converter:job.write` | +| GET | `/api/v1/jobs` | 列出 job(過濾條件)| `converter:job.read` | +| GET | `/api/v1/jobs/:id` | 單一 job 狀態 | `converter:job.read` | +| POST | `/api/v1/jobs/:id/promote` | 搬檔到 File Access Agent | `converter:job.write` | + +**Phase 2 預留(Phase 1 回 501 Not Implemented)**: +| 方法 | 路徑 | 說明 | +|------|------|------| +| POST | `/api/v1/jobs/:id/download-tokens` | 換 delegated download token(待 Member Center)| +| DELETE | `/api/v1/jobs/:id` | 取消 job | + +### 1.4 端點詳細規格 + +#### 1.4.1 `GET /health`(不需 auth) + +**Response 200**: +```json +{ + "service": "kneron-converter-api", + "status": "healthy", + "version": "1.0.0", + "timestamp": "2026-04-25T12:00:00Z", + "dependencies": { + "redis": "connected", + "member_center": "reachable", + "file_access_agent": "reachable" + } +} +``` + +**Response 503**(任一依賴失敗): +```json +{ + "service": "kneron-converter-api", + "status": "unhealthy", + "dependencies": { + "redis": "disconnected", + "member_center": "reachable", + "file_access_agent": "reachable" + } +} +``` + +說明:Member Center / File Access Agent 的可達性檢查可用背景 cache(每 30s 檢查一次),避免 `/health` 自己變慢。 + +--- + +#### 1.4.2 `POST /api/v1/jobs` + +**Request**: +```http +POST /api/v1/jobs +Authorization: Bearer +Content-Type: multipart/form-data; boundary=----WebKitFormBoundary... +X-Request-Id: (optional) + +------WebKitFormBoundary... +Content-Disposition: form-data; name="model"; filename="model.onnx" +Content-Type: application/octet-stream + + +------WebKitFormBoundary... +Content-Disposition: form-data; name="ref_images[]"; filename="img_0.jpg" +Content-Type: image/jpeg + + +------WebKitFormBoundary... +Content-Disposition: form-data; name="user_id" + +visionA-user-12345 +------WebKitFormBoundary... +Content-Disposition: form-data; name="model_id" + +1001 +------WebKitFormBoundary... +Content-Disposition: form-data; name="version" + +0001 +------WebKitFormBoundary... +Content-Disposition: form-data; name="platform" + +520 +------WebKitFormBoundary... +Content-Disposition: form-data; name="enable_evaluate" + +false +------WebKitFormBoundary...-- +``` + +**Multer 設定**: +- `multer.memoryStorage()`(與既有 Web UI `POST /jobs` 一致) +- `limits.fileSize`: 500MB(`model` 單檔上限) +- `fields`: `model`(1 個 file)、`ref_images[]`(`maxCount: 100`) + +**欄位定義**: + +| 欄位 | 類型 | 位置 | 必填 | 驗證 | +|------|------|------|------|------| +| `model` | file | multipart file | ✅ | 副檔名 ∈ {`.onnx`, `.pt`, `.pth`, `.tflite`, `.h5`, `.pb`};大小 ≤ 500MB | +| `ref_images[]` | file[] | multipart file | ❌ | `image/*`;最多 100 張;與既有 Web UI 規則一致 | +| `user_id` | string | multipart field | ✅ | 1-128 字元,不含 `/`、`\`、`..`,VisionA 端決定格式 | +| `model_id` | string → int | multipart field | ✅ | 轉 int 後 1 ≤ x ≤ 65535 | +| `version` | string | multipart field | ✅ | 1-32 字元,建議數字字串 | +| `platform` | string | multipart field | ✅ | enum: `520`, `720`, `530`, `630`, `730` | +| `enable_evaluate` | string `'true'` / `'false'` | multipart field | ❌ | 預設 `'false'` | +| `enable_sim_fp` | string `'true'` / `'false'` | multipart field | ❌ | 預設 `'false'` | +| `enable_sim_fixed` | string `'true'` / `'false'` | multipart field | ❌ | 預設 `'false'` | +| `enable_sim_hw` | string `'true'` / `'false'` | multipart field | ❌ | 預設 `'false'` | +| `metadata` | string(JSON)| multipart field | ❌ | 若傳入,需為合法 JSON 物件字串;未來擴展用 | + +**注意事項**: +- multipart 中所有 field value 都是字串,server 端需將 `'true'` / `'false'` → boolean,`model_id` → integer。 +- 與既有 Web UI `POST /jobs` multipart 欄位完全對齊,`user_id` 是對外 API 新增的欄位(Web UI 不需要)。 +- Validation 順序:先驗 OAuth token、再驗 multipart(避免未驗證就吃大檔)。實作上建議把 `requireAuth` middleware 放在 `multer` middleware 之前,這樣無效 token 會在 multer 開始 parse 前就被拒。 + +**Response 201 Created**: +```json +{ + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "created", + "stage": "onnx", + "progress": 0, + "created_at": "2026-04-25T12:00:00Z", + "expires_at": "2026-05-02T12:00:00Z", + "user_id": "visionA-user-12345" +} +``` + +**錯誤回應**: + +| 狀態 | error.code | 情境 | +|------|-----------|------| +| 400 | `validation_error` | 欄位缺漏或格式錯誤(`details.field` 列出問題欄位)| +| 400 | `invalid_multipart` | multipart parse 失敗、缺必要 file / field、副檔名不符 | +| 401 | `invalid_token` | JWT 無效 / 過期 / 缺 claim | +| 403 | `insufficient_scope` | token 缺 `converter:job.write`(`details.required_scope`)| +| 403 | `tenant_mismatch` | token 的 `tenant_id` 與 Converter 設定不符 | +| 409 | `user_has_active_job` | user_id 已有進行中 job(詳見 §1.5)| +| 413 | `file_too_large` | 上傳檔案超過 500MB(由 multer `LIMIT_FILE_SIZE` 觸發)| +| 500 | `misconfiguration` | `STORAGE_BACKEND !== 'minio'` 等 | +| 500 | `internal_error` | 其他 | + +--- + +#### 1.4.3 `GET /api/v1/jobs/:id` + +**Request**: +```http +GET /api/v1/jobs/550e8400-e29b-41d4-a716-446655440000 +Authorization: Bearer +If-None-Match: "etag-value" (optional) +``` + +**Response 200 OK**: +```json +{ + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "user_id": "visionA-user-12345", + "status": "running", + "stage": "bie", + "progress": 45, + "stage_progress": 60, + "created_at": "2026-04-25T12:00:00Z", + "updated_at": "2026-04-25T12:05:30Z", + "expires_at": "2026-05-02T12:00:00Z", + "stage_timings": { + "onnx": { "started_at": "2026-04-25T12:00:05Z", "completed_at": "2026-04-25T12:02:10Z" }, + "bie": { "started_at": "2026-04-25T12:02:15Z", "completed_at": null }, + "nef": null + }, + "input": { + "filename": "model.onnx", + "object_key": "jobs/550e8400-e29b-41d4-a716-446655440000/input/model.onnx", + "size_bytes": 204800000, + "ref_images_count": 0 + }, + "result_object_keys": null, + "error": null, + "parameters": { + "model_id": 1001, + "version": "0001", + "platform": "520", + "enable_evaluate": false, + "enable_sim_fp": false, + "enable_sim_fixed": false, + "enable_sim_hw": false + }, + "metadata": { + "source": "visionA-web", + "tags": ["experiment-001"] + }, + "estimated_completion_at": null +} +``` + +**狀態機**(`status` 欄位): + +- `created` — 剛建立,等第一階段開工 +- `running` — 正在某個 stage(`stage` 欄位有值) +- `completed` — 全部完成(`result_object_keys` 有值,`stage=null`) +- `failed` — 失敗(`error` 有值) + +**完成時的 `result_object_keys`**(在 Converter Bucket 的 key): +```json +"result_object_keys": { + "onnx": "jobs/{job_id}/output/out.onnx", + "bie": "jobs/{job_id}/output/out.bie", + "nef": "jobs/{job_id}/output/out.nef" +} +``` + +**失敗時的 `error`**: +```json +"error": { + "stage": "bie", + "code": "quantization_failed", + "message": "參考圖片不足或格式不符,BIE 量化階段失敗", + "details": { "raw": "..." } +} +``` + +**Response 304 Not Modified**:若 `If-None-Match` 吻合當前 ETag(ETag 建議為 `updated_at` 的 hash)。 + +**錯誤回應**: + +| 狀態 | error.code | 情境 | +|------|-----------|------| +| 401/403 | 同上 | — | +| 404 | `job_not_found` | job 不存在,或不屬於呼叫 client_id(避免資訊洩露)| + +--- + +#### 1.4.4 `GET /api/v1/jobs`(列表 / Recovery) + +**Query 參數**: + +| 參數 | 類型 | 必填 | 說明 | +|------|------|------|------| +| `user_id` | string | ❌ | 過濾 user_id(Recovery 必用)| +| `status` | string | ❌ | `in_progress`(= `created` ∪ `running`), `completed`, `failed`, `all`(預設 `all`)| +| `limit` | int | ❌ | 預設 20,上限 100 | +| `offset` | int | ❌ | 預設 0 | +| `created_after` | ISO 8601 | ❌ | 過濾 `created_at >= created_after` | + +**Response 200**: +```json +{ + "total": 2, + "limit": 20, + "offset": 0, + "items": [ + { /* 同 GET /jobs/:id 格式,但 items 為精簡版:可省 stage_timings.details、metadata */ } + ] +} +``` + +**實作注意**:以 `user:{user_id}:jobs` Set 為索引,避免全掃 `KEYS job:*`(採納 Design 4.1.2 建議)。 + +--- + +#### 1.4.5 `POST /api/v1/jobs/:id/promote` + +**Request**: +```http +POST /api/v1/jobs/550e8400-.../promote +Authorization: Bearer +Content-Type: application/json + +{ + "targets": [ + { + "source": "nef", + "target_object_key": "visionA/models/user-12345/model-1001/v0001/out.nef" + }, + { + "source": "bie", + "target_object_key": "visionA/models/user-12345/model-1001/v0001/out.bie" + } + ] +} +``` + +**欄位定義**: + +| 欄位 | 類型 | 必填 | 說明 | +|------|------|------|------| +| `targets` | array | ✅ | 要 promote 的檔案清單(至少 1 個)| +| `targets[].source` | string | ✅ | enum: `onnx`, `bie`, `nef` — 對應 job 輸出檔 | +| `targets[].target_object_key` | string | ✅ | File Access Agent 的目標 key(VisionA 決定命名)| + +**Response 200 OK**: +```json +{ + "job_id": "550e8400-...", + "promoted": [ + { + "source": "nef", + "target_object_key": "visionA/models/user-12345/model-1001/v0001/out.nef", + "size_bytes": 10485760, + "file_access_agent_etag": "abc123", + "promoted_at": "2026-04-25T12:30:00Z" + }, + { + "source": "bie", + "target_object_key": "visionA/models/user-12345/model-1001/v0001/out.bie", + "size_bytes": 5242880, + "file_access_agent_etag": "def456", + "promoted_at": "2026-04-25T12:30:02Z" + } + ] +} +``` + +**錯誤回應**: + +| 狀態 | error.code | 情境 | +|------|-----------|------| +| 400 | `validation_error` | targets 格式錯、source 非合法 stage | +| 404 | `job_not_found` | 同上 | +| 409 | `job_not_ready_for_promote` | `status != completed`(`details.current_status`)| +| 409 | `source_not_available` | job 沒產這個 stage 的結果(例如只跑 onnx 但要 promote nef)| +| 502 | `file_gateway_unavailable` | File Access Agent PUT 失敗 | +| 503 | `auth_service_unavailable` | 取 Converter 自己 token 失敗 | + +**重試語意**:`promote` 是冪等的(同樣 target_object_key PUT 兩次結果一樣,File Access Agent 會覆蓋)。Converter Bucket 檔案在 7 天內保留,允許重試。 + +### 1.5 重要錯誤 payload 範例 + +#### `user_has_active_job`(採納 Design 建議) + +```json +{ + "error": { + "code": "user_has_active_job", + "message": "使用者目前已有進行中的轉檔任務", + "details": { + "active_job_id": "550e8400-...", + "active_job_status": "running", + "active_job_stage": "bie", + "active_job_progress": 45, + "active_job_created_at": "2026-04-25T12:00:00Z" + }, + "request_id": "req-uuid" + } +} +``` + +#### `insufficient_scope` + +```json +{ + "error": { + "code": "insufficient_scope", + "message": "token 缺少必要權限", + "details": { + "required_scope": "converter:job.write", + "provided_scopes": ["converter:job.read"] + }, + "request_id": "req-uuid" + } +} +``` + +--- + +## 2. Task Scheduler 改造 + +### 2.1 目錄結構建議 + +``` +apps/task-scheduler/ +├── server.js ← 既有,只作為 entry(初始化 + mount routes) +├── src/ +│ ├── config.js ← 新:集中讀取所有 env(fail fast) +│ ├── redis.js ← 新:Redis client + helper +│ ├── auth/ +│ │ ├── jwks.js ← 新:JWKS cache + JWT 驗證 +│ │ ├── middleware.js ← 新:Express middleware(驗 token + scope) +│ │ └── oauthClient.js ← 新:Converter 作為 OAuth client(token cache) +│ ├── fileAccessAgent/ +│ │ ├── client.js ← 新:File Access Agent HTTP client(僅 PUT,promote 用) +│ │ └── errors.js ← 新:錯誤翻譯 +│ ├── routes/ +│ │ ├── legacy.js ← 既有路由(/jobs, /jobs/:id, /jobs/:id/events, ...) +│ │ └── v1/ +│ │ ├── index.js ← mount 新路由 +│ │ ├── jobs.js ← POST/GET /api/v1/jobs, GET /:id +│ │ └── promote.js ← POST /api/v1/jobs/:id/promote +│ ├── services/ +│ │ ├── jobService.js ← 新:封裝 job CRUD、user 索引、active job 檢查 +│ │ └── doneListener.js ← 既有 listenDoneQueue 抽成 module +│ ├── middleware/ +│ │ ├── errorHandler.js ← 新:統一錯誤格式 +│ │ └── requestId.js ← 新:X-Request-Id +│ └── utils/ +│ └── logger.js ← 新:結構化 log +├── package.json +└── Dockerfile +``` + +**實作原則**:保守重構,既有功能不改語意,只「移動 + 抽象」。 + +### 2.2 auth middleware(T1) + +```javascript +// src/auth/middleware.js 骨架 + +const { verifyJwt, InsufficientScopeError } = require('./jwks'); +const config = require('../config'); + +function requireAuth(requiredScope) { + return async (req, res, next) => { + try { + const authHeader = req.headers.authorization || ''; + const match = authHeader.match(/^Bearer\s+(.+)$/); + if (!match) { + return sendError(res, 401, 'invalid_token', 'Missing bearer token', req); + } + + const token = match[1]; + const claims = await verifyJwt(token, { + issuer: config.memberCenter.issuer, + audience: config.converter.audience, + clockSkew: 60, + }); + + // scope 檢查 + const scopes = (claims.scope || '').split(' ').filter(Boolean); + if (!scopes.includes(requiredScope)) { + return sendError(res, 403, 'insufficient_scope', 'Missing required scope', req, { + required_scope: requiredScope, + provided_scopes: scopes, + }); + } + + // tenant 檢查(可選) + if (config.converter.tenantId && claims.tenant_id) { + if (claims.tenant_id !== config.converter.tenantId) { + return sendError(res, 403, 'tenant_mismatch', 'Tenant mismatch', req); + } + } + + // 記錄 claim 到 req 供下游使用 + req.auth = { + clientId: claims.client_id || claims.sub, + tenantId: claims.tenant_id || null, + scopes, + tokenClaims: claims, + }; + + next(); + } catch (err) { + // 具體錯誤類型處理 + if (err.code === 'ERR_JWT_EXPIRED') { + return sendError(res, 401, 'token_expired', 'Token expired', req); + } + if (err.code === 'ERR_JWKS_NO_MATCHING_KEY') { + return sendError(res, 401, 'invalid_token', 'Signature verification failed', req); + } + return sendError(res, 401, 'invalid_token', 'Token verification failed', req); + } + }; +} +``` + +### 2.3 JWKS cache(T1) + +採用 `jose` npm 套件的 `createRemoteJWKSet`,內建 TTL cache 與 stale-while-revalidate。 + +```javascript +// src/auth/jwks.js + +const { createRemoteJWKSet, jwtVerify } = require('jose'); +const config = require('../config'); + +const jwks = createRemoteJWKSet(new URL(config.memberCenter.jwksUrl), { + cacheMaxAge: 10 * 60 * 1000, // 10 min + cooldownDuration: 30 * 1000, // 30s 內不重複 refresh +}); + +async function verifyJwt(token, { issuer, audience, clockSkew }) { + const { payload } = await jwtVerify(token, jwks, { + issuer, + audience, + clockTolerance: clockSkew, + }); + return payload; +} + +module.exports = { verifyJwt }; +``` + +### 2.4 OAuth client(T2) + +```javascript +// src/auth/oauthClient.js + +const config = require('../config'); + +class OAuthClient { + constructor() { + this._cache = new Map(); // scope-key -> { token, expiresAt } + } + + async getToken(scope) { + const key = scope; + const cached = this._cache.get(key); + if (cached && cached.expiresAt - 60000 > Date.now()) { + return cached.token; + } + + const params = new URLSearchParams({ + grant_type: 'client_credentials', + client_id: config.converter.clientId, + client_secret: config.converter.clientSecret, + scope, + audience: config.fileAccessAgent.audience, + }); + + const res = await fetch(config.memberCenter.tokenUrl, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: params.toString(), + }); + if (!res.ok) { + throw new Error(`token endpoint ${res.status}`); + } + const data = await res.json(); + const entry = { + token: data.access_token, + expiresAt: Date.now() + (data.expires_in || 3600) * 1000, + }; + this._cache.set(key, entry); + return entry.token; + } + + invalidate(scope) { + this._cache.delete(scope); + } +} + +module.exports = new OAuthClient(); +``` + +**錯誤處理**:呼叫端 catch 到失敗時回 503 `auth_service_unavailable`。 + +### 2.5 File Access Agent client(T6) + +Phase 1 Converter 只在 `promote` 階段呼叫 File Access Agent(寫入結果檔),**不需要 HEAD / GET**。 + +```javascript +// src/fileAccessAgent/client.js + +const config = require('../config'); +const oauthClient = require('../auth/oauthClient'); + +async function putFile(objectKey, stream, { contentType, contentLength }) { + const token = await oauthClient.getToken('files:upload.write'); + const res = await fetch( + `${config.fileAccessAgent.baseUrl}/files/${encodeURI(objectKey)}`, + { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': contentType, + 'Content-Length': String(contentLength), + }, + body: stream, + duplex: 'half', // Node 18 stream body 需要 + } + ); + if (!res.ok) throw new FAAError(res.status, await res.text()); + return await res.json(); +} + +module.exports = { putFile }; +``` + +**大檔 stream 處理(promote 用)**:從 MinIO `GetObjectCommand` 的 Body(stream)直接 pipe 到 fetch PUT body,確保不把整個結果檔載入記憶體。`POST /api/v1/jobs/:id/promote` 流程: + +``` +MinIO GetObjectCommand.Body (stream) + ↓ pipe +fetch PUT body (stream, duplex: 'half') + ↓ +File Access Agent +``` + +### 2.6 新路由群(T3) + +```javascript +// src/routes/v1/index.js + +const express = require('express'); +const jobsRouter = require('./jobs'); +const promoteRouter = require('./promote'); +const { requireAuth } = require('../../auth/middleware'); +const { apiV1RateLimit } = require('../../middleware/rateLimit'); + +const router = express.Router(); + +router.use(apiV1RateLimit); + +router.post('/jobs', requireAuth('converter:job.write'), jobsRouter.create); +router.get('/jobs', requireAuth('converter:job.read'), jobsRouter.list); +router.get('/jobs/:id', requireAuth('converter:job.read'), jobsRouter.get); +router.post('/jobs/:id/promote', requireAuth('converter:job.write'), promoteRouter.promote); + +// Phase 2 預留 +router.post('/jobs/:id/download-tokens', requireAuth('converter:job.read'), (req, res) => { + res.status(501).json({ + error: { code: 'not_implemented', message: 'Phase 2 功能,待 Member Center 補完', request_id: req.requestId }, + }); +}); +router.delete('/jobs/:id', requireAuth('converter:job.write'), (req, res) => { + res.status(501).json({ + error: { code: 'not_implemented', message: '尚未實作', request_id: req.requestId }, + }); +}); + +module.exports = router; +``` + +### 2.7 Redis 資料模型改造 + +#### 2.7.1 Job record(JSON,key = `job:{id}`)新增欄位 + +```jsonc +{ + // 既有欄位 + "job_id": "uuid", + "created_at": "...", + "updated_at": "...", + "status": "ONNX | BIE | NEF | COMPLETED | FAILED", // 注意:舊 Web UI 仍用大寫狀態 + "stage": "onnx | bie | nef | null", + "progress": 0, + "parameters": { /* model_id, version, platform, options */ }, + "output": { "bie_path": null, "nef_path": null }, + "error": null, + + // 新增欄位(Phase 1) + "origin": "api | web", // 來自新 API 或舊 Web UI + "user_id": "visionA-user-12345", + "tenant_id": "uuid-or-null", + "created_by_client_id": "kneron_converter_client_abc", + "input": { + "filename": "model.onnx", // multipart 原始檔名 + "object_key": "jobs/{job_id}/input/model.onnx", // Converter Bucket 內的 key + "size_bytes": 204800000, + "ref_images_count": 0 + }, + "stage_timings": { + "onnx": { "started_at": "...", "completed_at": "..." }, + "bie": { "started_at": "...", "completed_at": null }, + "nef": null + }, + "stage_progress": 0, // 0-100,當前 stage 內進度(Worker 推上來) + "expires_at": "2026-05-02T12:00:00Z", + "metadata": {} +} +``` + +**關於 `status` 大小寫**:既有 Web UI 會讀大寫(`ONNX`, `COMPLETED` 等)。新 API 對外回傳時需要**映射為小寫語意化狀態**(`created`, `running`, `completed`, `failed`)。映射表: + +| 內部 status | 對外 `status` + `stage` | +|------------|----------------------| +| `ONNX` | `running` + stage=`onnx` | +| `BIE` | `running` + stage=`bie` | +| `NEF` | `running` + stage=`nef` | +| `COMPLETED` | `completed` + stage=`null` | +| `FAILED` | `failed` + stage=<失敗時的 stage> | + +**注意**:既有 Scheduler `advanceJob` 把初始狀態設 `ONNX`,不區分「created」。新 API 建 job 後、onnx worker 接到前,依然是 `ONNX`。此時對外狀態應回 `created`(stage=onnx 但 stage_timings.onnx.started_at 為 null)。**實作上以 `stage_timings.onnx.started_at == null` 判斷是 `created` 還是 `running`。** + +#### 2.7.2 User 索引(新) + +| Key | 類型 | 用途 | TTL | +|-----|------|------|-----| +| `user:{user_id}:jobs` | Set | 該 user 所有 job_id(不分狀態) | 每次寫入時 `EXPIRE 7d` | +| `user:{user_id}:active_job` | String | 當前 in-progress job_id(= `created` 或 `running`)| 隨 job 結束刪除 | + +**寫入時機**(原子性用 MULTI 包): + +``` +建立 job: + MULTI + SET job:{id} {...} + SADD user:{user_id}:jobs {id} + EXPIRE user:{user_id}:jobs 604800 + SETNX user:{user_id}:active_job {id} # NX 是同使用者鎖的關鍵 + EXEC + + 若 SETNX 回 0 → 衝突,回滾(DEL job:{id}、SREM user:{user_id}:jobs {id}),回 409 + 若 SETNX 回 1 → 成功 + +完成 / 失敗時: + MULTI + SET job:{id} {...} + DEL user:{user_id}:active_job + EXEC + + 僅在 active_job 的 value 等於當前 job_id 時才 DEL(用 WATCH 或 Lua script 確保) +``` + +**Lua script(建議)**:確保「檢查 + 設 active + 寫 job」的原子性。 + +```lua +-- claim_active_job.lua +-- KEYS[1] = user:{user_id}:active_job +-- KEYS[2] = job:{job_id} +-- KEYS[3] = user:{user_id}:jobs +-- ARGV[1] = job_id +-- ARGV[2] = job_json +-- ARGV[3] = ttl_seconds + +if redis.call('EXISTS', KEYS[1]) == 1 then + return {'conflict', redis.call('GET', KEYS[1])} +end +redis.call('SET', KEYS[1], ARGV[1]) +redis.call('SET', KEYS[2], ARGV[2]) +redis.call('SADD', KEYS[3], ARGV[1]) +redis.call('EXPIRE', KEYS[3], tonumber(ARGV[3])) +return {'ok'} +``` + +#### 2.7.3 避免 `KEYS *` 的實作 + +**錯誤做法**(既有 code 有用,但新 API 不用): +```javascript +const keys = await redis.keys('job:*'); // O(N) 阻塞 Redis +``` + +**新 API 列表查詢**: +```javascript +async function listJobsByUser(userId, { status, limit, offset }) { + const ids = await redis.smembers(`user:${userId}:jobs`); + const pipeline = redis.pipeline(); + for (const id of ids) pipeline.get(`job:${id}`); + const results = await pipeline.exec(); + let jobs = results.map(([err, raw]) => JSON.parse(raw)).filter(Boolean); + // status 過濾 + if (status === 'in_progress') { + jobs = jobs.filter(j => ['created', 'running'].includes(mapStatus(j))); + } else if (status && status !== 'all') { + jobs = jobs.filter(j => mapStatus(j) === status); + } + // 排序、分頁 + jobs.sort((a, b) => new Date(b.created_at) - new Date(a.created_at)); + return { total: jobs.length, items: jobs.slice(offset, offset + limit) }; +} +``` + +### 2.8 POST /api/v1/jobs 流程(T4) + +``` +1. requireAuth('converter:job.write') — middleware 驗 token(放在 multer 之前,避免未驗證就吃大檔) +2. multer 中介層處理 multipart(memoryStorage,fileSize=500MB): + - req.files.model[0](required) + - req.files['ref_images[]'] / req.files.ref_images(optional, maxCount=100) + - req.body.user_id / model_id / version / platform / enable_* + ├── LIMIT_FILE_SIZE → 413 file_too_large + ├── multer 其他錯誤 → 400 invalid_multipart + └── ok → 繼續 +3. 驗證 fields(joi / zod / 手寫): + - user_id, model_id, version, platform 必填 + - enable_* 轉 boolean + - model 檔副檔名白名單 + ├── 失敗 → 400 validation_error(details.field) +4. 檢查 STORAGE_BACKEND === 'minio' + ├── 否 → 500 misconfiguration +5. 生成 job_id(UUIDv4) +6. 嘗試 claim_active_job Lua script(見 §2.7.2) + ├── conflict → 回 409 user_has_active_job + 當前 active job 詳情 + └── ok → 繼續 +7. 同步寫入 MinIO(Converter Bucket): + - jobs/{job_id}/input/{sanitized_model_filename} ← req.files.model[0].buffer + - jobs/{job_id}/ref_images/{index}_{sanitized_filename} ← 每個 ref_image.buffer + - 失敗 → 回滾(DEL job:{id}, DEL user:{user_id}:active_job, SREM user:{user_id}:jobs {id}),回 502 `storage_unavailable` +8. 更新 job record(補 input.object_key、size_bytes、ref_images_count、stage_timings.onnx.started_at=now) +9. enqueueStage('onnx', job) +10. 回 201 + { job_id, status: 'created', ... } +``` + +**關鍵**: +- Auth middleware 必須在 multer 之前,避免未驗證就 parse 500MB 大檔 +- 第 7 步若任一檔案寫 MinIO 失敗必須回滾,避免 Redis 有 job 但 MinIO 沒檔 +- `claim_active_job` 之後才寫 MinIO,避免拿到鎖但 MinIO 失敗時還要補回滾 MinIO(順序:驗證 → 鎖 → 寫檔 → enqueue) + +**time complexity**:SLA p95 < 5s(200MB @ 50MB/s ≈ 4s multipart + 1s MinIO write)。500MB 檔案 ~12s(見 design-doc §6.1)。 + +### 2.9 GET /api/v1/jobs/:id 流程(T5) + +``` +1. requireAuth('converter:job.read') +2. 讀 job:{id} +3. 若不存在,回 404 job_not_found +4. 若 job.created_by_client_id !== req.auth.clientId → 回 404(不洩露) +5. 計算 ETag = hash(job.updated_at),若 If-None-Match 吻合 → 304 +6. 映射內部 status → 對外 status + stage +7. 回 200 + 序列化 response +``` + +### 2.10 promote 流程(T6) + +``` +1. requireAuth('converter:job.write') +2. 驗 body(targets 格式) +3. 讀 job:{id}(+ client 隔離檢查) +4. 若 status != 'completed' → 409 job_not_ready_for_promote +5. 對每個 target: + a. 從 Converter Bucket 讀結果檔(stream) + b. faa.putFile(target.target_object_key, stream, ...) + c. 記錄 promoted_at / etag / size +6. 全部成功 → 回 200 + promoted[] +7. 部分失敗 → 回 502,details 標注哪些成功 / 失敗 +``` + +**冪等性**:promote 是冪等的(File Access Agent PUT 會覆蓋),可以重試。 + +### 2.11 Done listener 的改造 + +既有 `listenDoneQueue` 收到 worker done 事件時呼叫 `advanceJob`。新改動: + +- `advanceJob` 在 status 變化時同步更新 `stage_timings` +- 完成時自動 `DEL user:{user_id}:active_job`(Lua script 保證原子性) +- 失敗時同上 + +### 2.12 /health 升級 + +既有 `/health` 只檢查 Redis。新版加上: +- Member Center reachability(`GET /.well-known/openid-configuration`,背景 30s 一次,cache 結果) +- File Access Agent reachability(`GET /health`,同上) +- 回應 503 if 任一 critical dependency 異常 + +--- + +## 3. Worker 改造 + +**Phase 1 決定:Worker 不大改。** + +既有 `services/workers/s3_storage.py` 已支援從 MinIO 讀寫。Worker 只要看到 input 在 `jobs/{job_id}/input/` 路徑就開工,不需要知道 File Access Agent 的存在。 + +唯一需要改動的: + +1. **stage_progress 回報**(可選):Worker 處理過程中若能回報階段內進度(例如 30%、60%),可透過一個新的 Redis Stream `queue:progress` 推給 Scheduler。Phase 1 可先全回 0 或 100,後續增強。 +2. **`stage_timings` 的 started_at**:Worker 接到任務時用既有 done event 前,先寫一個 `stage_started` event。或者更簡單的做法:Scheduler 在 `enqueueStage` 時寫 `stage_timings.{stage}.started_at = now`。**建議採後者**,Worker 不動。 + +--- + +## 4. 資料模型與索引 + +### 4.1 為什麼不用 PostgreSQL + +- Phase 1 的資料模式簡單:job 是 state machine,user index 是 key-value +- 既有哲學是「Crash 即 Reset」,PG 會引入反向的持久化語意,反而變複雜 +- Redis Set 做 user 索引足以應付預期量(per user < 10 jobs / 7 天) +- 未來若要跨 Crash recovery / 多 instance HA,再評估 PG + +### 4.2 Redis 記憶體預估 + +- 每個 job record 約 2-4 KB(含 stage_timings 等) +- 每個 user index Set 每個元素 < 40 bytes +- 1000 並發 user × 10 jobs = 10k job record ≈ 40 MB(Redis 輕鬆) +- Converter Bucket lifecycle 7 天,Redis 也跟著 TTL 7 天,記憶體上限可控 + +--- + +## 5. OAuth 整合細節 + +### 5.1 token 驗證(resource server 身分) + +| Claim | 檢查 | +|-------|------| +| `iss` | 等於 `MC_ISSUER` | +| `aud` | 包含 `KNERON_CONVERTER_AUDIENCE`(支援 array 或 string)| +| `exp` | 未過期(含 60s clock skew)| +| `nbf` | 若有,已到 | +| `scope` | 空白分隔,包含 endpoint 要求的 scope | +| `client_id` | 必須有(記錄用)| +| `tenant_id` | 若有,等於 `CONVERTER_TENANT_ID`(Phase 1 可先 warn-only)| + +**JWKS 快取**:`jose.createRemoteJWKSet` 內建,TTL 10min,30s cooldown。 + +### 5.2 Converter 當 OAuth Client + +- `client_credentials` grant +- Phase 1 只需要一個 scope:`files:upload.write`(`aud=file_access_api`),僅 `promote` 時呼叫 +- Cache key = scope(未來擴充時若新增 scope,自動 per-scope cache) +- expires_in - 60s 時主動 refresh +- 失敗時 catch,轉 503 `auth_service_unavailable` + +### 5.3 Member Center 離線的影響 + +| 場景 | 影響 | 緩解 | +|------|------|------| +| JWKS fetch 失敗 | 新 kid 無法驗證 | cache 內還有舊 kid 的 key,舊 token 可過;新 token 會失敗 | +| token endpoint 失敗 | Converter 無法取新 token 打 File Access Agent(僅 promote 用)| cache 內 token 有效期內無影響;過期後 promote 會失敗 → 503。`POST /api/v1/jobs` 建 job 不受影響(只驗他人 token,不取自己 token)| +| discovery 失敗 | health check 標示 unhealthy | K8s / Docker 重啟不解決,需人工介入 | + +--- + +## 6. File Access Agent 整合 + +### 6.1 Object key 命名約定(建議) + +| 用途 | 建議命名 | 說明 | +|------|---------|------| +| promote 結果到模型庫(File Access Agent)| `visionA/models/{user_id}/{model_id}/v{version}/{filename}` | VisionA 決定 target_object_key(Converter 不強制命名規則)| +| Converter Bucket 內部(原始模型 input)| `jobs/{job_id}/input/{filename}` | Converter 自己管,multipart 上傳後寫入 | +| Converter Bucket 內部(參考圖片)| `jobs/{job_id}/ref_images/{index}_{filename}` | Converter 自己管 | +| Converter Bucket 內部(結果檔)| `jobs/{job_id}/output/{filename}` | Converter 自己管 | + +**約定**: +- `target_object_key`(promote 目標)的命名規則由 VisionA 定義,Converter 只做基本 sanity check(不能有 `..`、反斜線)。 +- Converter Bucket 內部 object key 由 Converter 控制,外部看不到也不需對齊。 +- Phase 1 不涉及 File Access Agent 上原始模型的 object key,該情境已不存在(原始模型直接 multipart 到 Converter)。 + +### 6.2 HTTP headers 一覽 + +| Request | Headers | +|---------|---------| +| PUT /files/{key}(promote 用)| `Authorization: Bearer `, `Content-Type`, `Content-Length` | + +**注意**:Phase 1 Converter 只對 File Access Agent 發 `PUT` 請求(promote 結果檔),不需要 HEAD / GET。 + +### 6.3 失敗重試策略(僅 PUT /files/{key}) + +| 錯誤 | Converter 行為 | +|------|--------------| +| 4xx(client error)| 不重試,直接回對應的 4xx 給 visionA-backend(例如 target_object_key 不合法)| +| 401(token 失效)| 強制 `oauthClient.invalidate('files:upload.write')`,重取 token 重試一次;仍失敗 → 503 `auth_service_unavailable` | +| 5xx(server error)| 重試最多 2 次(exponential backoff 500ms / 2000ms);全失敗 → 502 `file_gateway_unavailable` | +| network timeout | 同 5xx | + +### 6.4 Timeout + +- PUT /files/{key}:依檔案大小動態,預設 300s(500MB @ 最壞 5MB/s);由 `PROMOTE_TIMEOUT_MS` env 控制 + +### 6.5 大檔 stream + +- 使用 Node 18 原生 `fetch` + `body: ReadableStream` +- `duplex: 'half'` 旗標必要(Node 18.17+) +- 從 MinIO GetObjectCommand 的 Body(stream)直接 pipe 到 fetch PUT body +- 不做記憶體緩衝 + +--- + +## 7. 部署架構 + +### 7.1 Nginx 設定(雙 vhost) + +```nginx +# /etc/nginx/conf.d/converter.conf + +# Upstream +upstream scheduler_upstream { + server scheduler:4000; + keepalive 32; +} + +# Public vhost(對公網,端口 443) +server { + listen 443 ssl http2; + server_name converter.innovedus.com; + + ssl_certificate /etc/nginx/certs/fullchain.pem; + ssl_certificate_key /etc/nginx/certs/privkey.pem; + + # 只 proxy /api/v1/* + location /api/v1/ { + proxy_pass http://scheduler_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_request_buffering off; # 大檔 stream + proxy_read_timeout 300s; + client_max_body_size 600M; # 容許略大於 500MB 的 multipart 上限(POST /api/v1/jobs 原始模型上傳) + } + + # /health 可公開 + location = /health { + proxy_pass http://scheduler_upstream; + } + + # 其他路徑 404 + location / { + return 404 '{"error":{"code":"not_found","message":"Not found"}}'; + default_type application/json; + } +} + +# Internal vhost(僅內網 bind,端口 80 綁內部 interface) +server { + listen 10.0.0.1:80; # 內部 IP,不對外 + server_name converter-internal.innovedus.com; + + # Web UI / 舊工具走的路徑 + location /jobs { + proxy_pass http://scheduler_upstream; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_buffering off; # SSE 需要 + } + + location /queues/stats { + proxy_pass http://scheduler_upstream; + } + + # Web UI 靜態資源 + location / { + proxy_pass http://web:3000; + } +} +``` + +### 7.2 docker-compose.yml 變更 + +```yaml +services: + scheduler: + environment: + # 既有 + - PORT=4000 + - REDIS_URL=redis://redis:6379 + - STORAGE_BACKEND=minio + # ... MinIO 相關 + # 新增(Phase 1) + - MC_ISSUER=${MC_ISSUER} + - MC_JWKS_URL=${MC_JWKS_URL} + - MC_TOKEN_URL=${MC_TOKEN_URL} + - KNERON_CONVERTER_AUDIENCE=${KNERON_CONVERTER_AUDIENCE:-kneron_converter_api} + - KNERON_CONVERTER_CLIENT_ID=${KNERON_CONVERTER_CLIENT_ID} + - KNERON_CONVERTER_CLIENT_SECRET=${KNERON_CONVERTER_CLIENT_SECRET} + - FILE_ACCESS_AGENT_BASE_URL=${FILE_ACCESS_AGENT_BASE_URL} + - FILE_ACCESS_AGENT_AUDIENCE=${FILE_ACCESS_AGENT_AUDIENCE:-file_access_api} + - CONVERTER_TENANT_ID=${CONVERTER_TENANT_ID:-} + - CONVERTER_SCOPE_WRITE=${CONVERTER_SCOPE_WRITE:-converter:job.write} + - CONVERTER_SCOPE_READ=${CONVERTER_SCOPE_READ:-converter:job.read} + - API_V1_RATE_LIMIT_WINDOW_MS=${API_V1_RATE_LIMIT_WINDOW_MS:-300000} + - API_V1_RATE_LIMIT_MAX=${API_V1_RATE_LIMIT_MAX:-300} + - NODE_ENV=${NODE_ENV:-development} +``` + +### 7.3 `.env.example` 新增 + +```bash +# === OAuth (Member Center) === +MC_ISSUER=https://auth.innovedus.com +MC_JWKS_URL=https://auth.innovedus.com/.well-known/jwks +MC_TOKEN_URL=https://auth.innovedus.com/oauth/token + +# === Converter identity (Resource Server) === +KNERON_CONVERTER_AUDIENCE=kneron_converter_api + +# === Converter identity (OAuth Client,呼叫 File Access Agent 用) === +KNERON_CONVERTER_CLIENT_ID=kneron_converter +KNERON_CONVERTER_CLIENT_SECRET=change-me +CONVERTER_TENANT_ID= + +# === File Access Agent === +FILE_ACCESS_AGENT_BASE_URL=https://files.nas.internal +FILE_ACCESS_AGENT_AUDIENCE=file_access_api + +# === Scope 命名(可配置以防 Member Center owner 要求不同名稱)=== +CONVERTER_SCOPE_WRITE=converter:job.write +CONVERTER_SCOPE_READ=converter:job.read + +# === Rate Limit === +API_V1_RATE_LIMIT_WINDOW_MS=300000 +API_V1_RATE_LIMIT_MAX=300 +``` + +--- + +## 8. Scope 設計總表(給跨團隊對齊用) + +### 8.1 Converter 作為 Resource Server(接收端) + +| Scope | 用途 | 被誰取 | +|-------|------|--------| +| `converter:job.write` | 建 job、promote | visionA-backend | +| `converter:job.read` | 查 job | visionA-backend | +| (未來)`converter:admin.read` | 跨 client 查 job | 內部監控用 | + +### 8.2 Converter 作為 OAuth Client(發起端) + +| Scope | 用途 | 在哪裡用 | +|-------|------|---------| +| `files:upload.write` | PUT File Access Agent | promote 結果檔到 NAS 模型庫 | + +**Phase 1 僅需上述一個 scope。** Converter 完全不從 File Access Agent 讀取任何東西(原始模型已改為 visionA-backend 直接 multipart 上傳 Converter),因此不需要 `files:download.read` / `files:metadata.read`。 + +### 8.3 Member Center 需要做的事(跨團隊協調,對應 progress.md 未解決問題) + +1. 新增 resource audience `kneron_converter_api` +2. 新增 OAuth client `kneron_converter`(供 Converter 自己用,grant=client_credentials) +3. 為 visionA-backend 的 client 加上 `converter:job.write`、`converter:job.read` scope 授權 +4. 為 `kneron_converter` client 加上 `files:upload.write` scope 授權(**僅此一個,用於 promote**) +5. 確認 `tenant_id` claim 是否在 S2S token 中可用 +6. (Phase 2)實作 `POST /file-access/download-tokens` + +--- + +## 9. 配置管理(完整環境變數清單) + +| 變數 | 必填 | 預設 | 說明 | +|------|------|------|------| +| `PORT` | ❌ | `4000` | Scheduler listen port | +| `NODE_ENV` | ❌ | `development` | Node 環境 | +| `REDIS_URL` | ✅ | `redis://redis:6379` | Redis 連線 | +| `JOB_DATA_DIR` | ❌ | `/data/jobs` | 舊 local 模式路徑 | +| `FRONTEND_URL` | ❌ | `http://localhost:3000` | CORS | +| `STORAGE_BACKEND` | ❌ | `local` | `local` / `minio` | +| `MINIO_*` | 依 STORAGE_BACKEND | — | 既有 MinIO 參數 | +| **新增(Phase 1)**| | | | +| `MC_ISSUER` | ✅ | — | Member Center issuer URL | +| `MC_JWKS_URL` | ✅ | — | JWKS endpoint | +| `MC_TOKEN_URL` | ✅ | — | token endpoint | +| `KNERON_CONVERTER_AUDIENCE` | ✅ | `kneron_converter_api` | 接受的 aud | +| `KNERON_CONVERTER_CLIENT_ID` | ✅ | — | Converter 作為 client | +| `KNERON_CONVERTER_CLIENT_SECRET` | ✅ | — | 嚴禁進 Git | +| `FILE_ACCESS_AGENT_BASE_URL` | ✅ | — | File Access Agent URL(僅 promote 使用)| +| `FILE_ACCESS_AGENT_AUDIENCE` | ✅ | `file_access_api` | File Access Agent 的 aud(僅 promote 使用)| +| `CONVERTER_TENANT_ID` | ❌ | `""` | 若空則不做 tenant 檢查 | +| `CONVERTER_SCOPE_WRITE` | ❌ | `converter:job.write` | 可覆寫 | +| `CONVERTER_SCOPE_READ` | ❌ | `converter:job.read` | 可覆寫 | +| `API_V1_RATE_LIMIT_WINDOW_MS` | ❌ | `300000` | 5 min | +| `API_V1_RATE_LIMIT_MAX` | ❌ | `300` | 每 client_id | +| `MULTIPART_MODEL_MAX_BYTES` | ❌ | `524288000` | `POST /api/v1/jobs` 模型檔大小上限(500MB,可覆寫)| +| `MULTIPART_REF_IMAGES_MAX_COUNT` | ❌ | `100` | `POST /api/v1/jobs` ref_images 數量上限 | +| `PROMOTE_TIMEOUT_MS` | ❌ | `300000` | promote 單檔 timeout | + +**Secret 管理**:`KNERON_CONVERTER_CLIENT_SECRET` 禁止進 Git。dev 用 `.env`,prod 建議由 Docker secrets / K8s secrets 注入。 + +--- + +## 10. 向後相容與遷移 + +### 10.1 既有路徑行為(不變) + +| 路徑 | Phase 1 行為 | +|------|------------| +| `POST /jobs` (multipart) | **不變**,繼續接收 Web UI 上傳 | +| `GET /jobs/:id` | **不變**,`origin=web` 的 job 不過濾,`origin=api` 的 job 也看得到(內部 vhost 無授權,看不到差別)| +| `GET /jobs/:id/events` (SSE) | **不變**,Web UI 繼續用 | +| `GET /jobs/:id/download/:filename` | **不變**,Web UI 下載結果 | +| `GET /jobs` | **不變**,列全部 | +| `GET /health`, `GET /queues/stats` | **不變** | + +### 10.2 Web UI 何時遷移 + +**非本次範圍**。未來若決定把 Web UI 也納入 OAuth,屬於獨立的 L 級任務,需要設計 Member Center 登入流程、token refresh 等 UX 細節。 + +### 10.3 `STORAGE_BACKEND=local` 模式 + +既有 local 模式(Shared Volume)保留運作。新 API 要求 `STORAGE_BACKEND=minio`,因為: +- 從 multipart 收到的 buffer 要寫到某個 bucket 供 Worker 讀取 +- Shared Volume 路徑跨 container 複雜,未來跨主機部署也不適合 + +**實作檢查**:`POST /api/v1/jobs` 啟動時檢查 `STORAGE_BACKEND === 'minio'`,若非則 500 `misconfiguration`。 + +--- + +## 11. 測試策略 + +### 11.1 Unit test(Jest / Mocha) + +- `auth/jwks.js`:mock JWKS 回應,測過期、簽章錯、aud 錯、scope 不足 +- `auth/oauthClient.js`:mock token endpoint,測 cache 命中、過期重取、失敗處理 +- `fileAccessAgent/client.js`:mock fetch,測 PUT 5xx 重試、401 invalidate 重試、timeout +- `services/jobService.js`:測 claim_active_job 的並發(模擬兩個 user_id 相同同時建 job) +- `routes/v1/jobs.js` multipart validation:mock `multer`,測超過 500MB、缺 `model`、model_id 非數字、platform 不在 enum、user_id 含 `/` +- Response schema 映射(內部 status → 對外 status + stage) + +### 11.2 Integration test + +- **Member Center mock**:用 `wiremock` 或手寫 Express mock 模擬 JWKS + token endpoint +- **File Access Agent mock**:模擬 PUT 的成功 / 失敗回應(promote 用) +- **Redis**:用真 Redis(docker-compose test 環境) +- **multipart 上傳**:用 `supertest` + `attach('model', buffer, 'model.onnx')` 測試真實 multipart 流程(小檔、中檔、邊界檔 499MB / 501MB) + +### 11.3 E2E test(黑箱) + +- 需真 Member Center + File Access Agent 測試環境(Phase 1 kickoff 前準備) +- 測試案例: + 1. 完整流程:multipart 上傳 → polling → promote 成功 + 2. 409 測試:同 user 連續建 job + 3. 權限測試:invalid token / 缺 scope / 錯 aud + 4. 錯誤路徑:上傳超過 500MB → 413、缺 `model` file → 400、promote File Access Agent 500 → 502 + 5. 多檔案大小測試:小檔(1MB)、中檔(50MB)、大檔(200MB、500MB)分別驗證 p95 + +### 11.4 負載測試 + +- `POST /api/v1/jobs` 不需高 QPS(實際使用量一個 user 分鐘級),但需驗證大檔 multipart 不會 OOM(測試 10 個 user 同時上傳 200MB) +- `GET /api/v1/jobs/:id` 是熱點(polling),測每秒 100 req per Scheduler instance +- p95 < 200ms 驗證(GET),p95 < 5s / 12s 驗證(POST 200MB / 500MB) + +--- + +## 12. 實作任務拆分(按 Autoflow 增量式開發規範) + +每個任務 = 一個可獨立 review 的單位。Reviewer 會逐個審查。 + +| # | 任務 | 依賴 | 可並行? | 預估 | 驗收標準 | +|---|------|------|---------|------|---------| +| T1 | auth middleware + JWKS 驗證 | — | — | 3d | unit test 全過,能在空 route 上驗 mock token | +| T2 | Converter OAuth client(client_credentials + cache)| — | ✅ 與 T1 平行 | 2d | unit test 過,能對 mock token endpoint 取到並 cache | +| T3 | 新 `/api/v1/*` 路由骨架 + 錯誤格式統一 + request_id middleware | T1 | — | 2d | 所有新端點可通,回 501 是正常路徑 | +| T4 | POST /api/v1/jobs(multer 接收 multipart、寫 MinIO、active job 鎖、enqueue)| T1, T3 | — | 3d | 能建 job、409 正常、413 正常、回滾正常、大檔不 OOM | +| T5 | GET /api/v1/jobs + GET /api/v1/jobs/:id(含 ETag、client 隔離、user 索引)| T1, T3, T4 | ✅ 與 T6 平行 | 3d | Recovery 查詢正確、ETag 304 可用 | +| T6 | POST /api/v1/jobs/:id/promote(含 stream PUT、重試、FAA client)| T1, T2, T3 | ✅ 與 T5 平行 | 4d | 促進成功、冪等、失敗可重試 | +| T7 | 部署分流(Nginx 雙 vhost 設定 + docker-compose 更新)| — | ✅ 與 T1-T6 平行 | 1d | 內網可達 `/jobs`,公網只可達 `/api/v1/*` | +| T8 | OpenAPI 3.0 spec(手寫)+ 錯誤碼完整文件 | T3-T6 | — | 2d | spec lint 過,visionA-backend 能直接 import | + +**預估總工時**:3-4 人週(單人序列執行),若 2 人並行可壓到 2 週。對齊 PRD RICE Effort=4 的估算(較原估算略減,因為 T4 不再需要實作 FAA GET / HEAD 分支)。 + +**外部依賴觸發**: +- T1 需要 Member Center JWKS URL(可用 mock) +- T6 需要 File Access Agent 測試環境(或 mock PUT endpoint) +- T7 需要使用者確認部署拓撲 + +--- + +## 13. 未解決 / 待確認事項(TBD) + +| # | 項目 | 影響 | 待誰確認 | +|---|------|------|---------| +| TBD-1 | Member Center 的 `tenant_id` claim 是否出現在 client_credentials token | T1 設定 | Member Center owner | +| TBD-2 | `kneron_converter_api` audience / `kneron_converter` client / scope 的最終命名 | T1, T2 | Member Center owner | +| TBD-3 | File Access Agent 的 base URL(測試環境、prod 環境)與 tenant_id | T6 | File Access Agent owner | +| TBD-4 | Rate limit 的實際值(300 req / 5min 是估算,需觀測後校準)| 上線後調整 | 觀測資料 | +| TBD-5 | Nginx 雙 vhost 的具體 IP / hostname(依部署拓撲)| T7 | 使用者 / DevOps | +| TBD-6 | stage_progress 的顆粒度(Worker 是否有能力回報 stage 內 %)| P2 feature | Worker 開發團隊 | + +--- + +## 14. 附錄:Error code 完整表 + +| Code | HTTP | 說明 | +|------|------|------| +| `validation_error` | 400 | 欄位格式錯誤(multipart field 缺漏、model_id 非數字、platform 不在 enum 等)| +| `invalid_multipart` | 400 | multipart parse 失敗、缺必要 file、副檔名不符 | +| `invalid_token` | 401 | JWT 無效 / 簽章錯 / 缺 claim | +| `token_expired` | 401 | JWT 過期 | +| `insufficient_scope` | 403 | scope 不足 | +| `tenant_mismatch` | 403 | tenant_id 不符 | +| `job_not_found` | 404 | job 不存在或不屬於 client(避免資訊洩露)| +| `not_found` | 404 | 路徑不存在 | +| `user_has_active_job` | 409 | 同 user 已有 in-progress job | +| `job_not_ready_for_promote` | 409 | promote 時 job 非 completed | +| `source_not_available` | 409 | promote 的 source stage 沒產出 | +| `file_too_large` | 413 | multipart 上傳超過 500MB(由 multer `LIMIT_FILE_SIZE` 觸發)| +| `invalid_object_key` | 422 | target_object_key 格式不合法 | +| `misconfiguration` | 500 | 伺服器設定錯誤(例:STORAGE_BACKEND 錯)| +| `storage_unavailable` | 502 | MinIO 寫入失敗(`POST /api/v1/jobs` 寫 input 時)| +| `internal_error` | 500 | 其他未分類 | +| `not_implemented` | 501 | Phase 2 功能 | +| `file_gateway_unavailable` | 502 | File Access Agent 失敗(僅 promote 使用)| +| `auth_service_unavailable` | 503 | Member Center 取 token 失敗(僅 promote 使用)| +| `service_unavailable` | 503 | 其他依賴失敗 | + +--- + +## 15. 附錄:請求 / 回應速查 + +### 建 job(multipart) +```bash +curl -X POST https://converter.innovedus.com/api/v1/jobs \ + -H "Authorization: Bearer $TOKEN" \ + -F "model=@./model.onnx" \ + -F "user_id=u-12345" \ + -F "model_id=1001" \ + -F "version=0001" \ + -F "platform=520" \ + -F "enable_evaluate=false" \ + -F "enable_sim_fp=false" \ + -F "enable_sim_fixed=false" \ + -F "enable_sim_hw=false" +``` + +**含參考圖片**(可重複 `-F "ref_images[]=@..."`): +```bash +curl -X POST https://converter.innovedus.com/api/v1/jobs \ + -H "Authorization: Bearer $TOKEN" \ + -F "model=@./model.onnx" \ + -F "ref_images[]=@./img_0.jpg" \ + -F "ref_images[]=@./img_1.jpg" \ + -F "user_id=u-12345" \ + -F "model_id=1001" \ + -F "version=0001" \ + -F "platform=520" +``` + +### 查 job +```bash +curl -H "Authorization: Bearer $TOKEN" \ + https://converter.innovedus.com/api/v1/jobs/550e8400-... +``` + +### Recovery +```bash +curl -H "Authorization: Bearer $TOKEN" \ + 'https://converter.innovedus.com/api/v1/jobs?user_id=u-12345&status=in_progress' +``` + +### Promote +```bash +curl -X POST https://converter.innovedus.com/api/v1/jobs/550e8400-.../promote \ + -H "Authorization: Bearer $TOKEN" \ + -H "Content-Type: application/json" \ + -d '{ + "targets": [ + {"source": "nef", "target_object_key": "visionA/models/u-12345/m-1001/v0001/out.nef"} + ] + }' +``` + +--- + +## 16. 變更記錄 + +| 日期 | 版本 | 變更 | 作者 | +|------|------|------|------| +| 2026-04-25 | Draft 1.0 | 初版,Phase 1 完整規格 | Architect Agent | +| 2026-04-25 | Draft 1.1 | POST /api/v1/jobs 改 multipart/form-data;移除 FAA getFile/headFile 實作、`files:download.read`/`files:metadata.read` scope、`input_object_key` 欄位、`input_not_found` error code;新增 `invalid_multipart`/`file_too_large`/`storage_unavailable` error codes;TBD-1 刪除、TBD 重新編號;§2.5 File Access Agent client 僅保留 putFile;§2.8 POST jobs 流程改為 multer 接收→寫 MinIO;§6 FAA 整合精簡為僅 PUT | Architect Agent | + +--- + +**注意**:本 TDD 約 1390 行,已超過拆分門檻甚多。本次更新聚焦內容修正,暫不拆分;下輪更新強烈建議拆分為: +- `TDD.md`(索引) +- `TDD-api.md`(§1、§14、§15) +- `TDD-backend.md`(§2、§3、§4) +- `TDD-integration.md`(§5、§6) +- `TDD-infra.md`(§7、§9) +- `TDD-testing.md`(§11) diff --git a/docs/autoflow/04-architecture/design-doc.md b/docs/autoflow/04-architecture/design-doc.md new file mode 100644 index 0000000..30659c9 --- /dev/null +++ b/docs/autoflow/04-architecture/design-doc.md @@ -0,0 +1,724 @@ +# Design Doc — Kneron Model Converter 對外 API(L 級新功能) + +## 作者:Architect Agent +## 狀態:Draft(三方交叉審閱前) +## 最後更新:2026-04-25 +## 範圍:Phase 1(對外 API、OAuth2、File Access Agent 整合、promote)+ Phase 2 規格預留 + +## 變更歷程 + +| 日期 | 變更 | 作者 | +|------|------|------| +| 2026-04-25 | 初版 Draft | Architect Agent | +| 2026-04-25 | 原始模型上傳路徑改為 visionA-backend multipart 直接上傳 Converter;移除 File Access Agent 的 GET/HEAD S2S 需求(R1 / TBD-1 / §5.5 / ADR-002 input 部分);user_id 改放 multipart 欄位 | Architect Agent | + +--- + +## 0. 文件導讀 + +本 Design Doc 聚焦「系統層級架構決策」。若你是工程師要開始寫程式,請看 `TDD.md`(或本專案若拆分為 `TDD-*.md`)。 + +對應文件: +- 產品需求:`../02-prd/PRD.md`(§1.2、§4.3、§4.4、§5.5、§5.6、§14、§15) +- 使用者流程:`../03-design/user-flow-cross-system.md` +- 設計審閱:`../03-design/design-review.md` +- 專案健檢:`../00-onboarding/health-check.md` + +--- + +## 1. 背景與範圍 (Context and Scope) + +### 1.1 背景 + +Kneron Model Converter(下稱 Converter)目前是一個「只有 Web UI 的內部工具」,支援 AI 工程師以圖形化介面執行 ONNX → BIE → NEF 三階段模型轉檔。本次 L 級新功能將其擴展為「對外提供 OAuth2 保護 REST API 的服務」,讓 Innovedus 生態中的其他服務(首個消費者為 VisionA)能以程式化方式整合轉檔能力。 + +關鍵生態組成: +- **Converter(本專案)**:Node.js + Python Worker,部署在靠近 NAS 網段的位置 +- **visionA-backend**:Go 服務,Converter API 的消費者(Persona C) +- **Member Center**:OAuth2 / OIDC authorization server(C# / OpenIddict) +- **File Access Agent**:tenant 邊界內檔案閘道(C# / ASP.NET Core),駐守 NAS 側,單一 tenant per instance + +### 1.2 系統定位(新架構) + +```mermaid +flowchart TB + subgraph AWS["AWS 側"] + VisionAFE["VisionA 前端"] + VisionABE["visionA-backend
(Go, Phase 1)"] + MC["Member Center
(OAuth2 + JWKS)"] + end + + subgraph NAS["NAS 側(內部網段)"] + subgraph ConverterNode["Converter 部署節點"] + Nginx["Nginx
(public + internal vhost)"] + Scheduler["Task Scheduler
(Node.js Express)"] + Redis["Redis
(job state + user index)"] + Workers["Workers
(onnx / bie / nef)"] + ConvBucket["Converter Bucket
(MinIO, 7d lifecycle)"] + end + FAA["File Access Agent
(C# ASP.NET Core)"] + NasBucket["NAS Bucket
(模型庫長期儲存)"] + FAA --- NasBucket + end + + VisionAFE -->|HTTPS| VisionABE + VisionABE -->|1. token
(client_credentials)| MC + VisionABE -->|2. POST /api/v1/jobs
multipart: model + user_id
(aud=kneron_converter_api)| Nginx + Nginx -->|public vhost| Scheduler + Scheduler -->|驗 token
(JWKS)| MC + Scheduler -->|multer memory
寫入 input| ConvBucket + Scheduler -->|取 token
(client_credentials,
僅 promote 需要)| MC + Scheduler -->|put / get job state| Redis + Scheduler -->|enqueue stage| Redis + Workers -->|consume queue| Redis + Workers -->|read input / write output| ConvBucket + Scheduler -->|讀 MinIO 暫存| ConvBucket + Scheduler -->|promote: PUT 結果檔
(files:upload.write)| FAA + + classDef aws fill:#ffe0b2,stroke:#ef6c00 + classDef nas fill:#c8e6c9,stroke:#2e7d32 + classDef new fill:#bbdefb,stroke:#1565c0 + class AWS aws + class NAS nas + class Nginx,Scheduler new +``` + +**關鍵資料流(Happy Path)**: + +1. **取 token**(visionA-backend → Member Center) + visionA-backend 以 `client_credentials` 取得 `aud=kneron_converter_api` 的 access token(scope=`converter:job.write`)。 +2. **建 job**(visionA-backend → Converter,multipart/form-data) + visionA-backend 以 `POST /api/v1/jobs` 直接把原始模型 multipart 上傳到 Converter;Converter 驗 OAuth token 後把檔案寫入 Converter Bucket(`jobs/{job_id}/input/{filename}`),流程與既有 Web UI `POST /jobs` multipart 上傳一致(`multer.memoryStorage()`,`fileSize: 500MB`)。 +3. **轉檔**(Worker pool 處理,順序固定 onnx → bie → nef) + Workers 從 Converter Bucket 讀檔、處理、寫回結果檔。Phase 1 Converter **完全不從 File Access Agent 讀任何東西**。 +4. **polling 進度**(visionA-backend → Converter) + 每 2-5 秒 `GET /api/v1/jobs/:id`。 +5. **promote 到 NAS**(visionA-backend → Converter → File Access Agent) + 使用者在 VisionA 前端按「加進模型庫」時,visionA-backend 呼叫 `POST /api/v1/jobs/:id/promote`,Converter 以自己的 OAuth client 身分取 `files:upload.write` token,把 Converter Bucket 中的結果檔 PUT 到 File Access Agent。 + +--- + +## 2. 目標與非目標 (Goals and Non-Goals) + +### Goals(Phase 1 必達) + +- [ ] 對外 API 以 OAuth2 Bearer 驗證,對齊 Innovedus Member Center(JWKS 驗簽) +- [ ] Converter 同時具備 **Resource Server**(驗他人 token)與 **OAuth Client**(取自己 token)雙重身分 +- [ ] 提供四個對外端點:`POST /api/v1/jobs`、`GET /api/v1/jobs`、`GET /api/v1/jobs/:id`、`POST /api/v1/jobs/:id/promote` +- [ ] 同使用者同時一個轉檔限制(以 `user_id` 為界,不是 `client_id`) +- [ ] Recovery 支援(`GET /api/v1/jobs?user_id=...&status=in_progress`) +- [ ] 既有 `/jobs/*` 舊路徑保留不動,Web UI 零影響 +- [ ] 部署分流:公網只開 `/api/v1/*`,`/jobs/*` 只在內部網段可達 +- [ ] OpenAPI 3.0 規格產出,供下游整合 +- [ ] API SLA 可觀測(p95、錯誤率、token failure rate) + +### Non-Goals(Phase 1 明確不做) + +- [ ] 使用者直連下載(delegated download)— 阻塞於 Member Center endpoint,延至 Phase 2 +- [ ] Webhook / SSE 對外推送(polling 已足夠,見 ADR-004) +- [ ] Job 取消 / 重試(非本次範圍,API 僅保留路徑) +- [ ] Job 持久化 / 跨 Crash recovery(維持「Crash 即 Reset」哲學) +- [ ] Web UI 改走新 OAuth 流程(本次 Phase 1 不動,見 ADR-006) +- [ ] 單階段轉換 API 後端對齊(既有 backlog,與本次獨立) +- [ ] 使用者層級 ACL(Converter 不管,責任邊界在 visionA-backend) +- [ ] 跨 tenant 隔離的複雜授權模型(本次設計為 single-tenant per Converter deployment,見 §5.3) + +--- + +## 3. 架構設計 (The Actual Design) + +### 3.1 架構模式選擇 + +- **選擇**:維持現有 **單體 Task Scheduler(Node.js Express)+ Worker Pool(Python)** 架構。對外 API 以新增路由群的方式加入,不另開新服務。 +- **理由**: + 1. Phase 1 範圍聚焦「多一層 auth + 多一組 API 端點 + promote 時對 File Access Agent 一次寫入」,不足以撐起新服務的運維複雜度。 + 2. 既有 Crash 即 Reset 哲學對單體有利:Scheduler stateless,重啟 = 復原。 + 3. 新舊路徑共用同一份 Redis job record,不需跨服務同步。 +- **取捨**: + - 代價:Scheduler 單體變胖(預估 +600 行)。可接受,因為 API 介面屬於 I/O 密集,不是 CPU 密集,Node.js 單 process 足以負擔。 + - 若未來 QPS 需求爆增(例如 > 500 RPS),可把 auth middleware 與 OAuth client 抽出為獨立 sidecar,但 Phase 1 不做。 + +### 3.2 分階段架構演進 + +#### Phase 1 架構(本次) + +見 §1.2 的圖。重點變化: + +| 元件 | 是否新增 / 修改 | +|------|---------------| +| Nginx | 新增 public vhost(`/api/v1/*`)與 internal vhost(`/jobs/*`)的分流設定(見 §7) | +| Task Scheduler | 新增 auth middleware、OAuth client、新 `/api/v1/*` 路由群、user 索引、promote 實作 | +| Redis | 新增 `user:{user_id}:jobs` Set 索引、job record 新增欄位 | +| Workers | **不需要大改**(Phase 1 保持從 Converter Bucket 讀寫,見 §3.4 關鍵設計決定) | +| MinIO(Converter Bucket) | 不變 | +| File Access Agent | 不在本專案部署範圍(由 Innovedus 生態團隊部署)| +| Member Center | 不在本專案部署範圍 | + +**月度基礎設施成本預估**(本專案側):Phase 1 沒有新增基礎設施,與現況相同。跨團隊依賴的 Member Center / File Access Agent 成本由對方團隊吸收。 + +#### Phase 2 架構(預留) + +Phase 2 對 Converter 本體無架構變更。Delegated download 的流程完全發生在「visionA-backend ↔ Member Center ↔ 使用者瀏覽器 ↔ File Access Agent」,不經 Converter。 + +唯一可能的 Converter 變化:Phase 2 上線後可考慮讓 Converter 在 `promote` 完成時回傳更多資訊(例如 `download_hint_object_key`)方便 visionA-backend 直接拿來換 delegated token,但這是 nice-to-have,Phase 1 API 契約已足夠支撐。 + +### 3.3 技術選型(Technology Radar) + +| 層級 | 技術選擇 | 狀態 | 選型理由 | 退出成本 | +|------|---------|------|---------|---------| +| Auth 驗 JWT | `jose`(npm)| Adopt | 零依賴純 JS,支援 JWKS remote + cache,主流專案廣泛採用 | 低(抽 1 個 middleware 即可換 `jsonwebtoken` + `jwks-rsa`)| +| Auth 取 token | 自寫輕量 HTTP client(`node-fetch` 或 Node 18 原生 fetch)+ in-memory cache | Adopt | client_credentials 只是一個 HTTP POST,不需引入 `openid-client` 這種大套件 | 低 | +| HTTP client 對 File Access Agent | Node 18 原生 fetch + stream | Adopt | 支援大檔 stream,無需額外 deps | 低 | +| Rate Limit | `express-rate-limit`(既有)+ per-client_id key 擴展 | Adopt | 既有套件擴展即可 | 低 | +| OpenAPI 產出 | 手寫 YAML + `@redocly/cli` 或 `swagger-ui-express` 提供 `/openapi.json` 檢視 | Adopt | Phase 1 手寫可控,避免 code-first 產出不穩定 | 低 | +| Redis 索引 | Redis Set(`user:{user_id}:jobs`)+ 原有 `job:{id}` | Adopt | Phase 1 量級不足以需要 PostgreSQL;維持 stateless 設計一致 | 中(未來要遷 PG 需雙寫) | +| 觀測工具 | 結構化 log(JSON)+ Nginx access log | Trial | Phase 1 先不引入 Prometheus;留待 Phase 2 | — | + +### 3.4 關鍵設計決定:原始模型的上傳路徑 + +**背景**: +- visionA-backend 需要把使用者上傳的原始模型交給 Converter 轉檔。 +- 原本考慮過「檔案先上傳 File Access Agent,Converter 再從 File Access Agent 拉」的方案,但發現: + 1. 原始模型在轉檔成功、使用者按「加進模型庫」前**不屬於 NAS 模型庫**,沒必要先進 File Access Agent。 + 2. File Access Agent 的 `GET /files/{objectKey}` 只接受 delegated download token,Converter 以 S2S JWT 無法下載(除非對方擴充 API,這會是額外的跨團隊阻塞)。 + 3. Converter 既有 Web UI `POST /jobs` 已經是 multipart 上傳架構(`multer.memoryStorage()`, `fileSize: 500MB`),對外 API 直接沿用同一條路徑即可。 + +**決定**: +**Phase 1 採「visionA-backend 直接 multipart 上傳 Converter」的策略,與既有 Web UI 行為完全對齊。Converter Phase 1 完全不從 File Access Agent 讀任何東西。** + +具體流程: +1. `POST /api/v1/jobs`(multipart/form-data)進來時,Scheduler 驗 OAuth token、以 `multer.memoryStorage()` 接收檔案(`model` required ≤500MB、`ref_images[]` optional maxCount 100)。 +2. Scheduler 檢查 `user_id` 是否已有 in-progress job;若無則把 buffer 寫入 Converter Bucket(`jobs/{job_id}/input/{filename}`、`jobs/{job_id}/ref_images/*`)。 +3. 建 job record、enqueue 到第一階段。 +4. Worker 從 Converter Bucket 讀 input(**和既有 MinIO 模式完全一致**),產出結果寫回 Converter Bucket。 +5. `promote` 時 Scheduler 以自己的 OAuth client 身分取 `files:upload.write` token,從 Converter Bucket 讀結果、PUT 到 File Access Agent。 + +**為什麼這樣選**: +- Worker 程式零改動(現有 `STORAGE_BACKEND=minio` 模式直接沿用) +- 對外 API 上傳路徑與既有 Web UI 程式幾乎 100% 共享(`multer` 中介層、500MB 限制、儲存路徑約定) +- Phase 1 Converter 只需要「當 OAuth client 打 File Access Agent」的單一場景(promote 寫入),不需要 `files:download.read` / `files:metadata.read` +- 避免阻塞於 File Access Agent GET 授權模型的擴充(原 ADR-002 的待確認項已解除) + +**代價**: +- `POST /api/v1/jobs` 的 p95 會受 multipart 上傳大小影響(500MB 在一般網路環境約 5-30s),需調整 SLA(見 §6)。 +- 大檔 multipart 會暫時佔用 Scheduler 記憶體(`multer.memoryStorage()`),與既有 Web UI 一致的風險模型。 +- visionA-backend 必須自行處理上傳超時與重傳(和一般檔案上傳 API 行為一致)。 + +**替代方案**(見 ADR-002): +- A. 檔案先進 File Access Agent,Converter 再拉 — 需要跨團隊擴充 File Access Agent GET S2S 授權,Phase 1 被阻塞 +- B. 檔案流經 visionA-backend 兩次上傳(VisionA 前端 → backend → Converter)— visionA-backend 要扛兩次大檔流量,浪費頻寬 +- C. 使用者瀏覽器 direct-to-Converter presigned URL — 沒有對應基礎設施,Phase 1 不做 + +### 3.5 API 設計概覽 + +- **API 風格**:REST + JSON +- **Base Path**:`/api/v1/*` +- **認證**:所有端點(除 `/health`)都要 `Authorization: Bearer ` +- **錯誤格式**:統一 `{error: {code, message, details, request_id}}` +- **版本策略**:breaking change 走 `/api/v2/*`,小變更在 `/api/v1/*` 內向後相容新增欄位 +- **Rate Limit**:以 `client_id`(來自 token claim)為 key,預設 300 requests / 5 min(可調) +- **ETag 支援**:`GET /api/v1/jobs/:id` 支援 `If-None-Match`,304 Not Modified 省流量(採納 Design 建議) + +詳細 API spec 見 `TDD.md §1`。 + +### 3.6 資料架構 + +#### 核心資料(Redis) + +| Key | 類型 | 內容 | TTL | +|-----|------|------|-----| +| `job:{job_id}` | String (JSON) | Job 完整 record(新增 `user_id`、`tenant_id`、`created_by_client_id`、`metadata`、`stage_timings`、`expires_at` 欄位)| 7 天 | +| `user:{user_id}:jobs` | Set | 該 user 的 job_id 集合 | 隨最新 job 延長,建議 7 天 | +| `user:{user_id}:active_job` | String | 當前 in-progress job_id(存在即代表有 active)| 隨 job 完成時刪除 | +| `ratelimit:client:{client_id}` | 由 `express-rate-limit` 管理 | — | 5 min | + +不引入 PostgreSQL 的理由:Phase 1 資料量不大(單個 user 7 天內通常 < 10 個 job),Redis 足以承擔。未來若需要歷史任務持久化、跨 Crash recovery,再評估 PG。 + +#### 資料流(Phase 1) + +```mermaid +flowchart LR + BE[visionA-backend] -->|1. POST /api/v1/jobs
multipart: model + user_id| Sched[Scheduler] + Sched -->|2. 驗 token| MC[Member Center] + Sched -->|3. 檢查 user:active_job| Redis[(Redis)] + Sched -->|4. multer 寫入 input| CB[(Converter Bucket)] + Sched -->|5. 建 job + 索引| Redis + Sched -->|6. enqueue onnx| Redis + Workers[Workers] -->|consume| Redis + Workers -->|read/write| CB + Workers -->|done event| Redis + BE -->|7. poll GET /api/v1/jobs/:id| Sched + Sched -->|8. 讀 job| Redis + BE -->|9. POST /promote| Sched + Sched -->|10. 取 Converter token
(files:upload.write, cache)| MC + Sched -->|11. 讀結果| CB + Sched -->|12. PUT 結果| FAA[File Access Agent] +``` + +--- + +## 4. 可靠性設計 (Reliability) + +### 4.1 SLI / SLO + +| 服務 | SLI | SLO | 依據 | +|------|-----|-----|------| +| `/api/v1/*` 可用率 | 2xx+3xx 請求 / 總請求 | ≥ 99.5%(工作時段)| PRD §9.2.1 | +| `GET /api/v1/jobs/:id` p95 | 回應時間 95 百分位 | < 200ms | PRD §9.2.1、Design 4.1.2 | +| `POST /api/v1/jobs` p95 | 回應時間 95 百分位(含 multipart 上傳到 Converter Bucket,與檔案大小相關)| < 5s(200MB 檔案)| 依 PRD §9.2.1 的頻寬假設調整 | +| `POST /api/v1/jobs/:id/promote` p95 | 回應時間 95 百分位 | < 3s | PRD §9.2.1、Design 4.1.3 | +| Token 驗證失敗率 | 401 / 總請求 | < 1%(排除正常過期)| PRD §9.2.1 | + +**關於 `POST /api/v1/jobs` p95**:因為改為 multipart 直接上傳,延遲主要受檔案大小與 visionA-backend 到 Converter 的網路頻寬影響。200MB @ 50MB/s ≈ 4s,500MB @ 50MB/s ≈ 10s。若觀測到頻繁超 SLA,Phase 2 可考慮拆為「建 job」+「上傳 chunk」兩個端點。 + +### 4.2 容錯設計 + +| 失敗情境 | 設計應對 | +|---------|---------| +| Member Center JWKS 不可達 | JWKS 本地 cache(TTL 10 min,stale-while-revalidate 24h),短時離線仍可驗 token | +| Member Center token endpoint 不可達(取 Converter 自己的 token)| Token cache(有效期內不重取);過期後重試 3 次,失敗則 `promote` 回 503 + `auth_service_unavailable`,由 visionA-backend 重試 | +| multipart 上傳失敗 / 超過 500MB | `POST /api/v1/jobs` 回 400 `validation_error` 或 413 `file_too_large`;Redis 不建 job record(避免殘留)| +| File Access Agent promote 失敗 | Converter Bucket 檔案保留 7 天,使用者可重試 promote;API 回 502 | +| Worker Crash | 既有 Crash 即 Reset 機制:Worker 重啟後繼續 consume Redis Stream,in-progress 的 job 若在 Redis 中會被接手(前提:Redis 沒死)| +| Redis Crash | 符合「Crash 即 Reset」設計哲學,所有 job 遺失,使用者重送 | +| Scheduler Crash | 同上,重啟後繼續服務;進行中的 Worker 下階段 done 事件會找不到 job record 而被忽略(既有行為)| + +### 4.3 災難復原 + +維持既有 **Crash 即 Reset**:RPO = 無保證(Redis 重啟即清空)、RTO < 30s(Docker restart)。本次不新增 DR 機制。 + +--- + +## 5. 安全架構 (Security) + +### 5.1 威脅模型(STRIDE 摘要) + +| 威脅 | 風險 | 防護 | +|------|------|------| +| Spoofing(偽造 visionA-backend)| 中 | OAuth2 Bearer JWT(Member Center 簽發),JWKS 驗簽 | +| Spoofing(偽造 user_id)| 低(接受)| 信任 visionA-backend 的 user_id;Converter 不做 user ACL(PRD §5.6 明確)| +| Tampering(改動 job record)| 低 | Redis 在內部網段,無外部存取;Job record 僅 Scheduler 可寫 | +| Repudiation(否認呼叫)| 中 | Log `client_id` + `request_id` + `user_id`,保留 30 天 | +| Info Disclosure(跨 client 看別人的 job)| 中 | Job 查詢過濾:只回 `created_by_client_id` 吻合的 job(見 §5.3)| +| DoS | 中 | Rate Limit per `client_id`;檔案大小上限 500MB(既有)| +| Elevation of Privilege | 低 | scope 檢查嚴格:`converter:job.read` vs `converter:job.write` | + +### 5.2 安全邊界 + +#### 5.2.1 端點 auth 要求 + +| 端點 | 需要 auth? | 需要的 scope | +|------|-----------|-------------| +| `GET /health` | ❌ 不需要 | — | +| `POST /api/v1/jobs` | ✅ | `converter:job.write` | +| `GET /api/v1/jobs` | ✅ | `converter:job.read` | +| `GET /api/v1/jobs/:id` | ✅ | `converter:job.read` | +| `POST /api/v1/jobs/:id/promote` | ✅ | `converter:job.write` | +| 舊 `/jobs/*`(Web UI 走的路徑)| ❌ 不加 OAuth | — | +| `/jobs/*/events`(SSE)| ❌ 不加 OAuth | — | + +#### 5.2.2 Token 驗證清單 + +每個 `/api/v1/*` 請求的 middleware 必須檢查: + +1. `Authorization: Bearer ` header 存在 +2. JWT 格式合法 +3. JWT 簽章(用 Member Center JWKS 驗) +4. `iss` == 設定的 Member Center issuer +5. `aud` 包含 `kneron_converter_api`(可能是 string 或 array) +6. `exp` 未過期(含 clock skew ±60s) +7. `scope` 包含該端點要求的 scope(空白分隔字串) +8. `client_id` claim 存在(記錄用) +9. (可選)`tenant_id` claim(見 §5.3) + +**失敗處理**: +- 1-5 失敗 → 401 `invalid_token` +- 6 失敗 → 401 `token_expired` +- 7 失敗 → 403 `insufficient_scope` + `details.required_scope` +- 8 失敗 → 401 `invalid_token`(缺必要 claim) + +#### 5.2.3 user_id 的邊界(方式 A:multipart 欄位) + +**再次強調:user_id 不是授權邊界。** Converter 的設計如下: + +| 操作 | 處理方式 | +|------|---------| +| `POST /api/v1/jobs` 建 job | 信任 multipart field 中的 `user_id`,寫入 job record | +| `GET /api/v1/jobs/:id` 查 job | 回傳 job record(不比對呼叫者 user_id)| +| `GET /api/v1/jobs?user_id=X` 查列表 | 以 query 中的 `user_id` 過濾(**呼叫者可以查任意 user 的 job**)| +| `POST /api/v1/jobs/:id/promote` | 不需要 user_id(只檢查 job 狀態)| + +**為什麼這樣設計**: +- 服務消費者(例如 visionA-backend)本來就需要看自己所有 user 的 job(例如 admin 監控面板) +- 授權邊界在 `client_id` 層:只要 client 有 `converter:job.read` scope 就能查任何 job +- 防止跨 client 串連(見 §5.3) + +#### 5.2.4 跨 client 隔離(重要) + +**風險**:若未來有 client A 和 client B 都接 Converter,client B 不應看到 client A 建的 job。 + +**設計**:Job record 記錄 `created_by_client_id`,查詢時**預設**只回相同 client_id 的 job。 + +| 查詢 | 預設行為 | +|------|---------| +| `GET /api/v1/jobs` | 只回 `created_by_client_id == token.client_id` 的 job | +| `GET /api/v1/jobs/:id` | 若 `created_by_client_id != token.client_id`,回 404(**不是 403**,避免資訊洩露)| +| `POST /api/v1/jobs/:id/promote` | 同上 | + +Phase 1 的第一個 client 是 visionA-backend,此規則對它無感。未來加新 client 時,自動獲得隔離。 + +**例外**:若需要「管理員 client」能跨 client 查詢(例如監控用),可設定特殊 scope `converter:admin.read`,Phase 1 不實作,但保留擴展空間。 + +### 5.3 Tenant 策略(回應 PM 疑問 A.1.3) + +**決定**:Phase 1 採 **single-tenant per Converter deployment**,Converter 不自行管理 tenant 隔離。 + +具體作法: +- Converter 在設定檔中記錄 `EXPECTED_TENANT_ID`(從環境變數 `CONVERTER_TENANT_ID`)。 +- 驗 token 時若 JWT 有 `tenant_id` claim,則檢查等於 `EXPECTED_TENANT_ID`,不等則 403 `tenant_mismatch`。 +- 若 JWT 沒有 `tenant_id` claim,依 Member Center owner 的決定行事(Phase 1 初期可能沒有 tenant claim,可先 warn log,不擋)。 +- Job record 記錄 `tenant_id`(方便未來 log 與審計)。 +- 所有 Converter 打 File Access Agent 的請求自然帶著 Converter 自己的 `tenant_id`(來自 token),File Access Agent 的 `INSTANCE_TENANT_ID` 必須與之吻合。 + +**未來(多租戶)的擴展路徑**: +- 若要 Converter 一份程式碼支援多個 tenant,需要新增「根據 token 的 tenant_id 路由到對應的 File Access Agent instance」邏輯。Phase 1 不做。 + +### 5.4 舊 `/jobs/*` 路徑的保護(部署分流) + +Web UI 走的 `/jobs/*` 路徑**不加 OAuth**。若公網可達,會被繞過對外 API 的 scope 檢查直接打 Scheduler。 + +**解決**:**部署層級分流**(見 §7)。Nginx 分兩個 vhost: +- **public vhost**(443 對公網):只 proxy `/api/v1/*` 到 Scheduler,其他路徑一律 404 +- **internal vhost**(僅內部網段可達):proxy `/jobs/*`、`/health`、`/queues/stats`、`/jobs/*/events` 到 Scheduler + +這樣 Web UI(部署在內部網段 / 跳板後面)正常運作,對外 API 僅暴露 `/api/v1/*`。 + +採納 Design Review §2.3 建議的方案 B。 + +--- + +## 6. 效能工程 (Performance) + +### 6.1 延遲預算(`POST /api/v1/jobs`) + +| 階段 | 預算 | +|------|------| +| Nginx ingress(含 multipart 前置處理)| 10ms | +| JWT 驗證(JWKS cache hit)| 5ms | +| multipart 接收(multer memory,與檔案大小相關)| 200MB @ 50MB/s ≈ 4s | +| multipart validation(欄位、mimetype、副檔名)| 20ms | +| Redis 查 active_job | 10ms | +| MinIO PutObject(寫入 Converter Bucket,buffer 已在記憶體)| 200MB @ 200MB/s ≈ 1s | +| Redis 寫 job record + 索引 | 20ms | +| Enqueue 到 Redis Stream | 10ms | +| **總預算(200MB 檔案)** | **~5s(p95)** | +| **總預算(500MB 檔案)** | **~12s(p95)** | + +**若檔案很大**:p95 會超過 5s SLA。考慮未來: +- 改為 chunked upload(前端把檔案切塊,多個 PUT /api/v1/jobs/:id/chunks)— Phase 2 可選 +- 改為 async 上傳模式(先回 202 + job_id,背景接收剩餘 chunks)— Phase 2 可選 + +### 6.2 Token cache 策略 + +| Cache | TTL | 退出條件 | +|-------|-----|---------| +| JWKS(驗 JWT 用)| 10 min(主動 refresh)| 遇到未知 `kid` 時強制 refresh 一次 | +| Converter 自己的 access token | `expires_in - 60s`(快到期才 refresh)| 遇到 401 時強制 refresh 一次 | + +### 6.3 Rate Limit 策略 + +| 範圍 | 限制 | 動機 | +|------|------|------| +| 全局(IP)| 200 req / 15min(既有)| 維持既有防護 | +| Per `client_id` | 300 req / 5min | 防止單一 client 暴力 polling,但容許正常 2-5s polling(5min 可 60-150 次已足夠)| + +--- + +## 7. 部署架構 + +### 7.1 Nginx 雙 vhost 分流 + +Phase 1 採**一份 Nginx process、兩個 `server` block** 的設計(方案 B): + +``` + ┌─────────────────────────────────────────────────────────────┐ + │ Nginx(單一 process) │ + │ │ + │ ┌────────────────────────┐ ┌────────────────────────────┐ │ + │ │ server { │ │ server { │ │ + │ │ listen 443 ssl; │ │ listen 10.0.0.1:80; │ │ + │ │ server_name │ │ server_name │ │ + │ │ converter....com; │ │ converter-internal...; │ │ + │ │ │ │ │ │ + │ │ location /api/v1/ {} │ │ location /jobs {} │ │ + │ │ location = /health {} │ │ location /queues/stats {} │ │ + │ │ location / { │ │ location / { │ │ + │ │ return 404; │ │ proxy_pass web:3000; │ │ + │ │ } │ │ } │ │ + │ │ } │ │ } │ │ + │ │ (public vhost) │ │ (internal vhost, 內網 IP) │ │ + │ └───────────┬─────────────┘ └────────────┬────────────────┘ │ + └──────────────┼──────────────────────────────┼───────────────────┘ + │ │ + ▼ ▼ + ┌──────────────────────────────────────────────────┐ + │ Task Scheduler (:4000) │ + │ - /api/v1/* (OAuth 保護,僅 public vhost 轉入)│ + │ - /jobs/* (無 auth,僅 internal vhost 轉入) │ + │ - /jobs/*/events(SSE) │ + │ - /health, /queues/stats │ + └──────────────────────────────────────────────────┘ + ▲ + │ (僅 internal vhost 流入) + │ + Web UI / 內部工具(內網) +``` + +實務上可以兩種實作方式: +- **A. 兩份 Nginx instance**(一個 public、一個 internal,各自獨立 process) +- **B. 一份 Nginx process、兩個 `server` block**,根據 `listen` 的 interface(public IP vs internal IP)做分流 + +Phase 1 採 **B**(設定簡單、資源省、單一 reload 管理)。上方 ASCII 圖即為方案 B 的實際結構;對應完整 Nginx config 詳見 `TDD.md §7.1`。 + +### 7.2 docker-compose 變化 + +本次**不**在 docker-compose.yml 新增 File Access Agent / Member Center(由對方團隊部署,Converter 只是 client)。 + +新增環境變數(詳見 `TDD.md §9`): +- `MC_ISSUER`, `MC_JWKS_URL`, `MC_TOKEN_URL` +- `KNERON_CONVERTER_CLIENT_ID`, `KNERON_CONVERTER_CLIENT_SECRET` +- `KNERON_CONVERTER_AUDIENCE`(接收端) +- `FILE_ACCESS_AGENT_BASE_URL`, `FILE_ACCESS_AGENT_AUDIENCE` +- `CONVERTER_TENANT_ID` +- `CONVERTER_SCOPES_REQUIRED_WRITE`, `CONVERTER_SCOPES_REQUIRED_READ` + +--- + +## 8. ADR(架構決策紀錄) + +### ADR-001:對外 API 採 Member Center OAuth2,不自建 API Key + +**狀態**:Accepted +**背景**:對外 API 需要身分驗證機制。選項有(a)自建 API Key、(b)採 Innovedus Member Center OAuth2。 + +**決定**:採 **Member Center OAuth2**。 + +**理由**: +1. 使用者已決定 Converter 對齊 Innovedus 生態(progress.md)。 +2. OAuth2 是業界標準,visionA-backend 本來就要接 Member Center(Phase 1 規劃)。 +3. 自建 API Key 等於要自己管 secret rotation、scope、audit,重複發明輪子。 +4. Member Center 已提供 JWKS、token endpoint、client management,Converter 只要實作 resource server + client 兩個 OAuth2 角色即可。 + +**代價**:跨團隊依賴(註冊 audience、client、scope),若 Member Center owner 不配合會阻塞。已列入 progress.md 風險。 + +**替代方案**: +- A. 自建 API Key:簡單但不符合生態標準,未來遷移成本高 +- B. mTLS:運維成本高,對 Node.js 生態不夠友善 + +--- + +### ADR-002:promote 結果檔採「做法 2」— Converter 自己推到 File Access Agent + +**狀態**:Accepted + +**背景**:轉檔完的結果要搬回 File Access Agent(`promote`)。有三種搬檔做法: + +| 做法 | 說明 | 優劣 | +|------|------|------| +| 1 | 結果檔流經 visionA-backend(Converter → visionA-backend → File Access Agent)| 浪費頻寬、visionA-backend 要扛大檔 | +| 2 | Converter 自己 PUT File Access Agent(`files:upload.write`)| 檔案只在 NAS 側流動,單次寫入 | +| 3 | File Access Agent 主動拉 Converter Bucket | File Access Agent 沒這功能,不能為了我們改 | + +**決定**:採做法 2。 + +**理由**: +1. 省流量:Converter 和 File Access Agent 都在 NAS 側,直連 HTTP。 +2. visionA-backend 職責單純(只管 orchestration + 原始檔 multipart 轉送,不碰結果檔)。 +3. 符合 PRD US-13 的明確要求。 +4. File Access Agent 的 `PUT /files/{key}` 已明確支援 S2S JWT + `files:upload.write` scope(對方現有 API 已實作,無跨團隊阻塞)。 + +**範圍澄清(2026-04-25 更新)**: +本 ADR **只針對結果檔 promote 的搬檔路徑**。原始模型不會進 File Access Agent(visionA-backend 直接 multipart 上傳 Converter,見 §3.4),Phase 1 Converter 完全不需要 `files:download.read` / `files:metadata.read` scope。 + +**代價**: +- Converter 需要取自己的 service token(OAuth client 邏輯)。Token 可 cache、失敗可 retry。 +- Converter 需要設定 `KNERON_CONVERTER_CLIENT_ID` + `CLIENT_SECRET`(secret 管理責任)。 + +**替代方案**(已排除): +- 備案 A:讓 visionA-backend 自己從 Converter 下載結果、再上傳到 File Access Agent — 浪費頻寬 × 2,不合理 +- 備案 B:File Access Agent 主動拉 — 對方沒這個介面,不做 + +--- + +### ADR-003:user_id 以 multipart 欄位傳遞(方式 A),不放 token claim、不放自訂 header + +**狀態**:Accepted(已由使用者決策) + +**背景**:需要記錄 job 是誰的(VisionA 使用者 ID)。有三種方式: + +| 方式 | 做法 | 優劣 | +|------|------|------| +| A | 放 multipart 欄位 `user_id`,Converter 信任 | 與 model_id / version / platform 等其他業務欄位用同一條路徑,和既有 Web UI 的 `POST /jobs` 對齊 | +| B | Member Center 簽 token 時把 `user_id` 塞 claim | 看起來像「user 的 token」,但 client_credentials 本質是 S2S,不該綁 user | +| C | 自訂 `X-User-Id` header | 跟一般業務欄位(model_id 等)放不同位置,增加心智負擔 | + +**決定**:採方式 A(multipart 欄位)。 + +**理由**: +1. client_credentials 是服務對服務的 token,沒有「user」概念,不應把 user_id 放 claim +2. `POST /api/v1/jobs` 本身就是 multipart,多一個 `user_id` 欄位最自然,和 `model_id`、`version`、`platform` 等業務欄位放一起 +3. 與既有 Web UI `POST /jobs` 的 multipart 欄位路徑一致,程式碼可共用 validation +4. Converter 不做 user 層 ACL(PRD §5.6 明確),user_id 只用於業務邏輯(同使用者限制、查詢過濾) +5. 避免跟 Member Center 要客製化 claim,維持標準 OAuth2 + +**代價**:Converter 完全信任 visionA-backend 送的 user_id。若 visionA-backend 被入侵或 bug 亂送,可能造成「A 使用者看到 B 的 job」。 +- **緩解**:授權責任邊界在 visionA-backend,這是合理的 trust boundary。Converter 的日誌會記錄 user_id 變更頻率,可做異常監測(PRD §12.2 已列)。 + +--- + +### ADR-004:Polling 而非 Webhook + +**狀態**:Accepted(PM 已決策) + +**背景**:轉檔時間長(可能 30s-數分鐘),visionA-backend 需要知道何時完成。 + +**決定**:Phase 1 只提供 polling(`GET /api/v1/jobs/:id`),不實作 Webhook。 + +**理由**: +1. 下游是另一個 backend 服務,polling 對它是標準做法 +2. Webhook 需要處理:retry、簽章、對方 endpoint 驗證、重放防護,surface area 太大 +3. Phase 1 先快速上線驗證產品價值,Webhook 可以 Phase 3 再考慮 + +**代價**:進度延遲 = polling 間隔。API 文件建議 2-5s 間隔,對 UX 足夠。 + +--- + +### ADR-005:Phase 1 使用者下載延至 Phase 2 + +**狀態**:Accepted(已由使用者決策) + +**背景**:Member Center 的 `POST /file-access/download-tokens` 尚未實作,該 endpoint 是使用者直連下載的前提。 + +**決定**:Phase 1 **完全不做**使用者下載功能,延至 Phase 2。 + +**理由**: +1. 阻塞於外部依賴(Member Center owner 的時程) +2. Phase 1 先讓「上傳 → 轉檔 → 搬進模型庫」閉環可以跑 +3. UX 缺口由 VisionA 產品團隊用 messaging 策略處理(Design 議題 #1) + +**代價**:VisionA 使用者暫時沒有下載能力。VisionA 產品團隊需有 fallback(Design Review §6 議題 #1)。 + +--- + +### ADR-006:Phase 1 Web UI 不改,維持既有 multipart 路徑 + +**狀態**:Accepted(PM 已決策) + +**背景**:Web UI 目前走 `POST /jobs`(multipart)、`GET /jobs/:id/events`(SSE)、`GET /jobs/:id/download/...`。 + +**決定**:**全部保留不動**,Web UI 與對外 API 兩套並存。 + +**理由**: +1. Web UI 是內部工具,persona 與 API 消費者不同 +2. 給 Web UI 加 OAuth 等於加登入流程,UX 倒退(Design Review §2.3) +3. 降低本次 L 級的範圍,避免同時改兩套 + +**代價**:Web UI 的 `/jobs/*` 路徑若曝露公網會繞過 OAuth。 +- **緩解**:部署層分流(§5.4、§7),public Nginx 只 proxy `/api/v1/*`。 + +--- + +## 9. 回應 PRD 附錄 A 疑問清單 + +| # | PM 疑問 | 架構決策 | +|---|---------|---------| +| A.1.1 | Web UI 要不要也改走 OAuth | **不改**,見 ADR-006。以 Nginx 分流保護。未來若要改,屬於獨立的 M/L 級任務 | +| A.1.2 | 同使用者限制的範圍 | **整個 Converter 服務共用 user_id 空間**。現階段第一個 client 是 visionA-backend,未來加 client 時若真的有 user_id 衝突風險,再考慮用 `(client_id, user_id)` 複合鍵(Phase 1 不做)| +| A.1.3 | tenant_id 策略 | **single-tenant per Converter deployment**。見 §5.3。Job record 會記錄 tenant_id 方便 audit | +| A.1.4 | Phase 2 fallback | 本架構文件不決定此議題(屬 VisionA 產品團隊決策)。我們的架構不阻擋 VisionA 選任一方案 | +| A.1.5 | API 採用度 baseline | 非架構議題。建議 Phase 1 上線後跑 1 個月 beta 再訂 SLA 目標 | +| A.1.6 | 既有 `[推測]` 標記清理 | 非本次範圍 | +| A.2.1 | Member Center owner 協調 | **阻塞項**,必須在 kickoff 前解決。建議的 naming:`kneron_converter_api` audience、`kneron_converter` client、scopes 見 TDD §8(Phase 1 Converter 只需 `files:upload.write` 一個 scope 打 FAA)| +| A.2.2 | File Access Agent deployment / tenant_id | 中度依賴(只 promote 時用)。Converter 在 setup 時需要 FILE_ACCESS_AGENT_BASE_URL + 確認 tenant_id 吻合;FAA 現有 `PUT /files/{key}` 已支援 S2S JWT,無需擴充 | +| A.2.3 | VisionA Phase 1 OAuth 整合時程 | 需雙方 kickoff 對齊 | +| A.2.4 | Member Center download-tokens 實作時程 | Phase 2 啟動觸發條件 | +| A.3.1 | scope 命名 | 建議採 `converter:job.write`、`converter:job.read`,格式對齊 File Access Agent 的 `files:*.*` 慣例 | +| A.3.2 | Effort 估算 | 見 TDD §12(按 T1-T8 拆分,預估 4-5 人週)| +| A.3.3 | OpenAPI 維護策略 | **手寫 YAML**(Phase 1),手動與實作同步。未來再評估自動生成 | +| A.3.4 | user_id 索引的 Redis 策略 | **新增 `user:{user_id}:jobs` Set + `user:{user_id}:active_job` String**。避免 `KEYS *` 全掃(Design 4.1.2 建議已採納)| + +--- + +## 10. 回應 Design Review 7 條建議 + +| # | Design 建議 | 是否採納 | 說明 | +|---|-----------|---------|------| +| 1 | Response schema(`stage_timings`、`stage_progress`、`expires_at`、結構化 error)| ✅ 全採納 | 見 TDD §1 | +| 2 | 錯誤碼結構化(`{error: {code, message, details}}`,409 帶 active_job 詳情)| ✅ 全採納 | 見 TDD §1.5 | +| 3 | Polling 效能(p95 < 200ms,ETag 支援)| ✅ 採納 | 見 §6.1、TDD §1.3(ETag)| +| 4 | 部署隔離(避免 `/jobs/*` 公網曝光)| ✅ 採納方案 B(Nginx 分流) | 見 §7 | +| 5 | 預留擴展(metadata、ETA、DELETE 路徑、progress 顆粒度)| ✅ 大部分採納 | API 留 `metadata: {}`、保留 `DELETE` 路徑可回 501 Not Implemented(Phase 2 再啟用)| +| 6 | promote 同步(p95 < 3s)| ✅ 採納 | 見 §6.1。超過 10s timeout 的 async 模式 Phase 1 不做 | +| 7 | Rate limit per client_id | ✅ 採納 | 見 §6.3 | + +--- + +## 11. 風險與待確認事項(給使用者決策) + +> 註:2026-04-25 變更後,原 R1(File Access Agent GET S2S 授權問題)已移除(見 §0 變更歷程),現存 R1-R6 為原 R2-R6 的內容沿用原敘述挪上一格後重編;R7 為本次針對 multer memoryStorage 大檔並發 OOM 新增的風險。 + +| # | 風險 / 議題 | 影響 | 行動 | +|---|-----------|------|------| +| R1 | Member Center client / audience / scope 註冊時程 | 高 | Orchestrator 協助排跨團隊會議 | +| R2 | Member Center 是否支援 `tenant_id` claim?格式?| 中 | 待確認;Phase 1 可先不擋 tenant(warn log),等 Member Center 定案 | +| R3 | File Access Agent 的 `object_key` 命名約定與 VisionA 對齊(僅 promote 需要)| 中 | 見 TDD §6.1 建議,需 VisionA 確認 | +| R4 | JWKS cache stale 時 Member Center 更新 key 的同步策略 | 低 | 10 min TTL + 遇到未知 kid 強制 refresh 應足夠 | +| R5 | 大檔(> 200MB)multipart 上傳會超過 p95 SLA | 中 | Phase 1 接受(SLA 已調整為 5s @ 200MB、12s @ 500MB);若觀測到頻繁超 SLA,Phase 2 引入 chunked upload | +| R6 | docker-compose 本地開發時如何測 OAuth | 中 | 見 TDD §11(建議本地跑 Member Center docker-compose,或以 mock server)| +| R7 | Scheduler 同時承接多個 500MB multipart 上傳會吃光記憶體(`multer.memoryStorage()`)| 中 | Phase 1 依賴 `user_has_active_job` 鎖避免同 user 併發;若跨 user 併發成為瓶頸,改 `multer.diskStorage()` 或 streaming 上傳 | + +--- + +## 12. Phase 1 / Phase 2 切分(架構層) + +### Phase 1 必做 + +- auth middleware + scope 檢查 +- OAuth client + token cache +- 新路由群 `/api/v1/*`(POST jobs、GET jobs、GET jobs/:id、POST promote) +- Redis 資料模型擴充(user_id、tenant_id、索引) +- 同使用者一個轉檔限制 +- Recovery 查詢 +- 部署分流(Nginx 雙 vhost) +- OpenAPI 文件 + +### Phase 2 預留(不改契約) + +- `POST /api/v1/jobs/:id/download-tokens`(等 Member Center 補完) +- `DELETE /api/v1/jobs/:id`(回 501 Phase 1 → 實作 Phase 2/3) +- `POST /api/v1/jobs/:id/webhooks`(預留結構,可回 501) +- Async promote 模式(若觀測到 p95 > SLA) + +### 阻塞條件 + +- **Phase 1 阻塞**:見 R1(Member Center 註冊),必須在 kickoff 前解除。File Access Agent 側 Phase 1 只需 `PUT /files/{key}` S2S 支援(對方現有 API 已支援,無阻塞) +- **Phase 2 阻塞**:Member Center `POST /file-access/download-tokens` 實作 + +### 觸發條件 + +- Phase 1:三方交叉審閱通過 + 使用者審核 Design Doc + TDD + R1 解除 +- Phase 2:Member Center endpoint 實作完成並提供測試環境 + +--- + +## 13. 後續步驟 + +1. 本 Design Doc 送 PM / Design 交叉審閱 +2. 使用者審核最終版 +3. 跨團隊協調 R1(Member Center 註冊 audience / client / scope) +4. 工程師依 `TDD.md` 的任務清單(T1-T8)增量式開發 +5. 第二階段:Reviewer 每個任務把關 + +--- + +**附註**:本 Design Doc 約 710 行,已超過建議拆分門檻(500 行)。本次更新聚焦內容修正,暫不拆分;下輪更新建議拆分為 `design-doc.md`(索引)+ 子模組。 diff --git a/docs/autoflow/04-architecture/security.md b/docs/autoflow/04-architecture/security.md new file mode 100644 index 0000000..154237e --- /dev/null +++ b/docs/autoflow/04-architecture/security.md @@ -0,0 +1,347 @@ +# Security Notes — Phase 1 + +> 本文件記錄 Phase 1 已知的安全設計決策、被接受的風險、以及對應的 mitigation 與 Phase 2 改進候補方案。 +> +> **更新時機**:每次安全審查(Reviewer / Security Auditor)發現新風險或變更現有 trust assumption 時,必須更新此檔案。 + +## 索引 + +| Section | 內容 | +|---------|------| +| [Trust Boundary](#trust-boundary重要-design-risk) | user_id 來源信任問題(Phase 1 接受風險) | +| [Input Validation](#input-validation) | 已落實的輸入驗證機制 | +| [Storage Security](#storage-security) | MinIO object key 控制與 cleanup 策略 | +| [Auth Security](#auth-security) | JWT / JWKS 配置、algorithm pin | +| [Rate Limiting](#rate-limiting) | 雙層 rate limiter 設計 | +| [Logging](#logging) | 結構化 log 與敏感資料保護 | +| [Phase 2 候補方案清單](#phase-2-候補方案清單) | 已知待補強的設計 | + +--- + +## Trust Boundary(重要 design risk) + +### user_id 為 multipart field,受信任 visionA-backend 帶來 + +#### 設計 + +`POST /api/v1/jobs` 的 `user_id` 從 multipart form field 傳入,**不是**從 JWT claim derive。Converter 完全信任 visionA-backend 端把對的 `user_id` 傳進來。 + +``` +visionA-backend Converter + │ │ + ├── client_credentials ──────→│ (取得 access token) + │ │ + ├── POST /api/v1/jobs ────────→│ Form-Data: + │ Authorization: Bearer … │ user_id: "alice" ← visionA 端決定 + │ │ model: + │ │ ... + │ │ + │ │ Converter 端: + │ │ - 用 token 驗 client(OK) + │ │ - 信任 user_id 是「真正提交的 user」 + │ │ - 不再驗證 user_id 與 token 的關係 +``` + +#### Trust assumption(Phase 1) + +visionA-backend 端: + +1. **程式碼安全** — 無 XSS / SSRF / RCE 漏洞,user_id 來源可信 +2. **infra 安全** — network ACL、IP allow-list、TLS 確保只有 visionA-backend 能呼叫此 API +3. **credential 管理** — `client_secret` 不外洩、不放 git、不寫 log +4. **audit log 健全** — visionA 端能追溯「哪個真實用戶觸發了哪次轉檔」 + +#### Risk(被接受) + +visionA-backend **一旦被 compromise**,attacker 可用同一個合法 `client_credentials`: + +| 攻擊面 | 影響 | +|-------|------| +| 為任意 `user_id` 建 job | 冒充任何 user(user_id 完全由 attacker 控制)| +| 鎖定特定 user 7 天 | active_job conflict 機制被當武器(任意 user_id 一旦被鎖,正常請求也 409)| +| 偽造的 job 計入 victim user_id 的 history | `user:{victim}:jobs` Set 被汙染,未來查 history 看到不是自己的紀錄 | +| 累計 victim 的 job count(如有 quota / billing) | Phase 2 若引入 per-user quota / billing,會誤計到 victim 上 | + +#### Phase 1 決策(2026-04-25 使用者裁決) + +**接受此風險。** 理由: +1. visionA-backend 是內部受控系統(非 Internet-facing),compromise 機率低 +2. Phase 1 重點是把核心 pipeline 跑通,安全強化排在 Phase 2 +3. 引入 HMAC / OBO 會增加 visionA 端的整合工作量,目前未取得對方確認 + +#### Mitigations(Phase 1 已採用) + +| Mitigation | 說明 | +|-----------|------| +| **per-client_id rate limiter(300 req / 5min)** | 限制單一 client(即被 compromise 的 visionA-backend)的攻擊速度 | +| **input 完全由 server 控制 object_key** | `jobs/{server-生成 uuidv4}/input/{sanitize 後 filename}`,attacker 無法控制 prefix | +| **filename / user_id 嚴格 sanitize** | 阻擋 path traversal / Redis key injection / log injection / XSS / glob pattern | +| **structured audit log(含 client_id + user_id pair)** | 可從 log 反查「哪個 client 為哪個 user_id 建了 job」,發現 compromise 時加速 forensics | +| **active_job 7 天 TTL**(fail-safe)| 即便 worker 異常未清,TTL 也會自動 GC,避免 attacker 鎖死後永久不釋放 | + +#### Phase 2 候補方案 + +##### 方案 1:HMAC-signed user_id(推薦短期) + +visionA-backend 用共享 secret HMAC 簽 user_id,Converter 驗簽: + +``` +visionA-backend Converter + + hmac = HMAC-SHA256( 收到 multipart 後: + secret, recv_user_id, recv_hmac + user_id || timestamp) ↓ + if HMAC-SHA256(secret, recv_user_id || ts) != recv_hmac: + POST /api/v1/jobs ─────────→ return 401 invalid_hmac + Form: if abs(now - ts) > 60s: + user_id: "alice" return 401 hmac_expired + x_user_id_hmac: "" else: + x_user_id_ts: "" accept user_id +``` + +**優點**:實作簡單,雙方只需要共享 secret + 規範 hash。 +**缺點**:仍是 symmetric secret,有外洩風險;不會解決「visionA 自己被 compromise」的場景(attacker 也能簽)。 + +##### 方案 2:OBO Token / Token Exchange(業界標準,推薦中期) + +visionA-backend 為每個 user 取 user-context token(例如 OBO flow / Token Exchange RFC 8693),Converter 從 JWT claims 取 `user_id`: + +``` +visionA-backend Member Center Converter + + POST /token ──────────────→ grant_type=token-exchange + subject_token= audience=converter + subject_token_type=jwt ↓ + new token with claims: + sub: "alice" ← user 身份 + actor: { sub: "visionA-client" } ← 委託 client + ←─── new access token ───── + + POST /api/v1/jobs ─────────────────────────────────────────────────→ + Authorization: Bearer ↓ + Converter 從 token claims + 取 user_id(不是 multipart) +``` + +**優點**: +- 完全消除 trust boundary 問題(user_id 來自 Member Center 簽過的 JWT) +- 業界標準,跨 vendor 適用 +- 自動 audit chain(actor + subject 雙重身份) + +**缺點**: +- visionA / Member Center 都需要實作 Token Exchange 流程 +- 性能:每次 POST /jobs 多一次 Token Exchange round-trip(可 cache 緩解) + +##### 方案 3:Audit Anomaly Detection(補強) + +偵測同 `client_id` 短期內出現大量不同 `user_id` 的異常 pattern: + +``` +監控 metric: + unique_user_ids_per_client_per_5min{client_id="visionA-backend-client"} + +正常 pattern: + - 一個 client_id 5 分鐘內可能服務 5-50 個不同 user_id + +異常 pattern: + - 一個 client_id 5 分鐘內出現 500 個不同 user_id + - 一個 client_id 連續 1 小時內每 5 分鐘出現 < 1 秒的 burst(自動化攻擊) + +告警 → 人工介入 → 視情況 revoke client_credentials +``` + +**優點**:不需要改動 protocol,可獨立實作;對已 deployed 系統最容易加上。 +**缺點**:被動防禦(事後發現),無法即時阻擋。 + +--- + +## Input Validation + +### 已落實(Phase 1) + +| 項目 | 實作位置 | 機制 | +|------|---------|------| +| **filename sanitize** | `src/utils/sanitize.js` `sanitizeFilename` | NUL byte truncation / path.posix.basename / 控制字元 / 白名單字元 / 截長 200 / leading-dot 移除 | +| **user_id 嚴格白名單** | `src/utils/sanitize.js` `validateUserId` | `^[A-Za-z0-9._-]+$` regex(Sec M1 強化)+ 額外 `..` 拒絕 | +| **version 嚴格白名單** | `src/routes/v1/validators/createJob.js` | `^[A-Za-z0-9._-]+$` regex(Sec M3)| +| **model_id 數字範圍** | 同上 | `^\d+$` + 1 ≤ x ≤ 65535 | +| **platform enum** | 同上 | `{520, 720, 530, 630, 730}` | +| **enable_* boolean** | 同上 | 嚴格 `'true'` / `'false'` 字串 | +| **metadata JSON object** | 同上 | JSON.parse + 拒絕 array / null / primitive | +| **model 副檔名白名單** | 同上 | `{.onnx, .tflite}`(PRD F-01)| +| **model file 大小** | multer + handler | 預設 500MB(multer LIMIT_FILE_SIZE → 413)| +| **ref_image per-file 大小** | `validateCreateJobRequest` | 10MB(Sec C2 修正,避免 100 張 × 500MB = 50GB OOM)| +| **ref_image 張數** | multer fields config | maxCount=100 | + +### 攻擊向量驗證清單 + +| 攻擊向量 | 防禦點 | 測試 | +|---------|-------|------| +| Path traversal in filename | `sanitizeFilename` `path.posix.basename` + leading-dot strip | `sanitize.test.js` | +| NUL byte truncation | `sanitizeFilename` `split('\0')` | 同上 | +| Windows path / backslash | `sanitizeFilename` `replace(/\\/g, '/')` | 同上 | +| Redis key injection in user_id | `validateUserId` 拒絕 `:` | 同上 | +| XSS in user_id | `validateUserId` 嚴格白名單 | 同上(Sec M1)| +| XSS in version | `version` 嚴格白名單 | `createJob.validator.test.js`(Sec M3)| +| Unicode RTL override | 嚴格白名單拒絕非 ASCII | 同上 | +| Glob / shell metachar in user_id / version | 嚴格白名單拒絕 `*?[];&|$` 等 | 同上 | +| ref_image OOM (100 × 500MB) | per-file 10MB 上限 | `createJob.integration.test.js`(Sec C2)| +| log injection (CRLF) | 嚴格白名單拒絕 `\r\n` | 同上 | + +--- + +## Storage Security + +### MinIO object key 完全 server 控制 + +``` +inputObjectKey = `jobs/${jobId}/input/${safeFilename}` + ^uuidv4 ^server-controlled prefix ^sanitized +refImageKey = `jobs/${jobId}/ref_images/${index}_${safeFilename}` +``` + +attacker 無法控制: +- prefix `jobs/` — server hardcode +- jobId — server 用 `uuidv4()` 生成 +- ref_images index — server 用 `idx` 自增 + +attacker 部分控制(已 sanitize): +- safeFilename — 經 `sanitizeFilename` 處理(最壞情況產生合法的相對檔名) + +### M5 方案 A:先寫 MinIO 後 Lua claim + +避免「拿到 Lua claim 但 MinIO 失敗」需要 rollback Redis 的複雜度: +- MinIO 失敗 → 直接回 502,Redis 完全乾淨 +- Lua conflict / throw → cleanup MinIO(fire-and-forget,靠 7d lifecycle 兜底) +- enqueue 失敗 → 補償 release Redis + cleanup MinIO(Sec M2 + Reviewer Major-2 修正) + +### Sec M4:寫入放大 pre-check + +handler 在 `writeInputToMinIO` 之前先廉價 GET `user:{userId}:active_job`,若已存在直接回 409。 +避免 conflict request 還是上傳完 500MB 才被 Lua reject(節省頻寬與記憶體)。 + +> ⚠️ pre-check 與 Lua claim 之間仍有 race(兩個 request 同時通過 pre-check),最終 atomicity 仍由 Lua 保證;pre-check 純粹是「optimization」。 + +### Sec M5:mount-time STORAGE_BACKEND 檢查 + +`createJobsRouter` 在 mount 時就檢查 `STORAGE_BACKEND === 'minio'`: +- 不對 → **不掛 multer**,POST /api/v1/jobs 直接回 500 misconfiguration +- 不會吃 multipart body,避免 misconfig 也消耗 500MB 記憶體 +- GET / DELETE / download-tokens 不依賴 storage backend,仍正常掛 + +--- + +## Auth Security + +### JWT Algorithm Pin(Sec m3) + +`src/auth/jwks.js` 明確 pin 接受的 JWT signing algorithm: + +```js +const ALLOWED_JWT_ALGS = ['RS256', 'ES256', 'PS256']; +``` + +拒絕: +- `none`(jose 預設拒絕,但仍明確列出) +- `HS256` / `HS384` / `HS512`(HMAC,避免演算法混淆攻擊) + +### JWKS Cache + +- TTL 10 分鐘(`JWKS_CACHE_MAX_AGE_MS` env override) +- Cooldown 30 秒(避免 JWKS endpoint 失敗時 thundering herd) +- 模組層級 cache(同一個 jwksUrl 共用一個 RemoteJWKSet) + +### Token 驗證 + +| 檢查項 | jose 預設 | Converter 加碼 | +|-------|----------|----------------| +| signature | ✅ | — | +| exp | ✅ | clockTolerance 60s | +| nbf | ✅ | — | +| issuer | — | ✅(`MEMBER_CENTER_ISSUER`)| +| audience | — | ✅(`KNERON_CONVERTER_AUDIENCE`)| +| algorithm | 拒絕 none | ✅ pin to RS256/ES256/PS256(Sec m3)| + +--- + +## Rate Limiting + +### 雙層設計 + +``` +Request → IP-based limiter (200 req / 15min) ← app.js 全域 + → requireAuth (驗 token) + → per-client_id limiter (300 req / 5min) ← v1 jobs router + → multer / handler +``` + +| 層級 | 目的 | 範圍 | +|------|------|------| +| IP-based | 防匿名流量 / DDoS | 全 `/api` 前綴 | +| per-client_id | 合約上限 / 攻擊速度限制 | `/api/v1/jobs` 寫入端點 | + +### Phase 2 待補 + +- Memory store 警告:目前用 process-local memory,**多 instance 部署需改 Redis store** +- 目前對「未認證但合法路由」的 quota 計算可能誤殺 — 預設 1 個 visionA-backend IP 帶多 user 共用,需要監控 IP-based 是否誤殺 + +--- + +## Logging + +### 結構化 JSON + +所有 log 使用結構化 JSON 格式,必含: +- `timestamp`(ISO 8601) +- `level`(INFO / WARN / ERROR) +- `service`(`task-scheduler`) +- `request_id`(貫穿請求生命週期) +- `action`(`domain.action` 格式) + +### 敏感資料保護 + +**絕對不寫 log**(已逐條檢查): +- token / Authorization header +- file body / model 內容 +- MinIO secret / OAuth client_secret +- JWT payload 完整 dump(只記 `client_id` / `tenant_id` / `user_id`) + +**遮罩處理**(如有需要在 Phase 2 加): +- 原始 filename(已 sanitize)— 通常不視為敏感 +- IP(log 仍記,但 GDPR 場景可能需要遮罩) + +### Sec C1 暫緩 + +`.env` 一度被 commit 進 git history(健檢時發現),**已加入 `.gitignore` 但 history 仍可追溯**。 + +決策: +- **Phase 1 暫緩**(2026-04-25 使用者裁決) +- **Phase 1 ready 後**會做一次 git history rewrite + 強制 rotate 所有 secret +- 在那之前,所有 secret 都被視為「可能已外洩」,**dev 環境用 dummy secret,prod 用全新生成的 secret** + +--- + +## Phase 2 候補方案清單 + +### 已知待補強(依優先級) + +| # | 項目 | 優先級 | 預期任務 | +|---|------|-------|---------| +| 1 | **HMAC-signed user_id 或 OBO token**(解決 Trust Boundary)| HIGH | Phase 2 — auth 強化 | +| 2 | **Git history rewrite**(清掉 .env 洩漏)| HIGH | Phase 1 ready 收尾 | +| 3 | **MULTIPART_MODEL_MAX_BYTES env 串接**(目前寫死 500MB)| MEDIUM | T10 | +| 4 | **MAX_CONCURRENT_UPLOADS semaphore**(防多 user 並發 OOM)| MEDIUM | T10 | +| 5 | **Stream storage 評估**(取代 memoryStorage,根本解決 OOM)| MEDIUM | Phase 2 — infra | +| 6 | **Rate limiter Redis store**(多 instance 部署前提)| MEDIUM | Phase 2 — infra | +| 7 | **Audit anomaly detection**(user_id pattern 異常告警)| LOW | Phase 2 — observability | +| 8 | **Filename Unicode normalization**(極端 unicode bypass)| LOW | Phase 2 — security 細修 | +| 9 | **Metadata prototype pollution 防護**(白名單 keys)| LOW | Phase 2 — security 細修 | +| 10 | **Token revocation list / JWT blacklist**(無此需求現在)| LOW | Phase 2 — auth | + +--- + +## 變更歷史 + +| 日期 | 變更 | 觸發 | +|------|------|------| +| 2026-04-25 | 初版 | T5 Reviewer + Security Audit 修復 |