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) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-05-01 10:59:21 +08:00
parent 7404ca9bc8
commit cff9236699
6 changed files with 4410 additions and 0 deletions

1134
docs/autoflow/02-prd/PRD.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,460 @@
# Design Review — API 對外開放 L 級功能
> **審查範圍**2026-04-25 的 L 級新功能「開放 Kneron Model Converter 轉檔能力為對外 REST API 供 VisionA 使用」。
>
> **審查者**Design AgentAutoflow
>
> **審查時機**:三方聯合討論階段(與 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-backendpolling 對它們而言是標準做法
- 不做 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 | 使用者轉完沒 promote7 天後檔案消失 | 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 直接上傳到 ConverterConverter 不再從 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 前端能做出更好的 UXAPI 要預留以下能力(即使 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.1visionA-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例如 10sAPI 回 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 之類的自訂 IDvisionA 端的 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 工程師<br/>(既有 Web UI 使用者)"]
VisionAUser["VisionA 終端使用者<br/>(新的 間接受益者)"]
end
subgraph InnovedusEcosystem["Innovedus 生態"]
WebUI["Kneron Converter Web UI<br/>(保留不動)"]
VisionAFrontend["VisionA 前端<br/>(另一個團隊實作)"]
VisionABackend["visionA-backend<br/>Persona C"]
MemberCenter["Member Center"]
FileAccessAgent["File Access Agent"]
end
subgraph Converter["Kneron Model Converter本專案"]
OldAPI["舊 API<br/>POST /jobs<br/>GET /jobs/:id<br/>GET /jobs/:id/events (SSE)<br/>不加 OAuth"]
NewAPI["新 API<br/>POST /api/v1/jobs<br/>GET /api/v1/jobs/:id<br/>POST /api/v1/jobs/:id/promote<br/>OAuth2 Bearer"]
end
KneronUser -->|multipart 上傳 / SSE 看進度| WebUI
WebUI -->|保持既有呼叫| OldAPI
VisionAUser -->|平台內操作| VisionAFrontend
VisionAFrontend -->|VisionA 自家協議| VisionABackend
VisionABackend -->|取 service token| MemberCenter
VisionABackend -->|Bearer token + multipart<br/>(含原始模型 + 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 Agent2026-04-25 更新)
- **✅ 同意** polling 模式不做 Webhook
- **✅ 同意** Web UI 不改Phase 1 保留既有路徑和 UX
- **✅ 同意** 搬檔做法 2Converter 自己 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 需求)

View File

@ -0,0 +1,353 @@
# 跨系統使用者流程圖 — VisionA 終端使用者視角
> **視角**VisionA 終端使用者Edge AI 應用開發者)在 VisionA 平台內完成模型轉檔的完整體驗。
>
> **重點**從使用者能「感知到什麼」切入而非技術細節。Token 種類、錯誤情境等只標示關鍵分支,詳細 API 規格見 `04-architecture/TDD.md`
>
> **涵蓋情境**
> - 情境 A首次上傳模型並轉檔主要 Happy Path
> - 情境 B離開頁面後回來看未完成的 jobRecovery
> - 情境 CPhase 2 使用者下載模型(阻塞中 — 僅供未來參考)
>
> **服務標記慣例**
> - 🧑 = VisionA 終端使用者(人類)
> - 🖥️ = VisionA 前端(瀏覽器)
> - ⚙️ = visionA-backendGo 服務)
> - 🔐 = Member CenterAuth 中心)
> - 📦 = File Access Agent檔案閘道
> - 🏭 = Kneron Converter API本專案
> - 🗄️ = Converter BucketMinIO 暫存)
> - 💾 = 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 / Converterpromote 時才會再呼叫 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: 階段 2visionA-backend 呼叫 Converter 建 jobmultipart 同時帶原始模型)
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<br/>Authorization: Bearer token<br/>Content-Type: multipart/form-data<br/>files: model (必填, ≤500MB), ref_images[] (optional, maxCount 100)<br/>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?<br/>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: 走情境 BRecovery
else 沒有 in-progress job
CV->>CB: 寫入原始模型 (jobs/{job_id}/input/{filename})<br/>(含 ref_images[] 如有)
CV->>CV: 建 job 記錄,塞進 Redis<br/>Enqueue 到 onnx-worker
CV-->>BE: 201 Created + {job_id, status: created}
BE-->>FE: 201 + job_id
end
Note over FE,CV: 階段 3Polling 進度(每 2-5 秒一次)
loop 直到 status=completed 或 failed
FE->>BE: GET /api/jobs/{job_id}/status
BE->>CV: GET /api/v1/jobs/{job_id}<br/>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<br/>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: 顯示「轉檔完成!」<br/>+「加進我的模型庫」按鈕<br/>+「檔案將於 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<br/>Authorization: Bearer token<br/>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}<br/>Authorization: Bearer token<br/>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 tokenConverter 收檔後暫存在自己的 Bucket |
| 進度條跳動 | visionA-backend 每 2-5 秒 polling Converter拿到新進度就更新 UI |
| 「ONNX 優化 → BIE 量化 → NEF 編譯」階段切換 | Converter 的 Task Scheduler 在 Redis 中推進 job stageWorker 消耗 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 過期)<br/>scope=converter:job.read
MC-->>BE: service token
BE->>CV: GET /api/v1/jobs?user_id=...&status=in_progress<br/>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: 顯示「你有一個轉檔進行中<br/>模型 XYZ - BIE 階段 60%<br/>[繼續追蹤] [放著不管]」
U->>FE: 按「繼續追蹤」
FE->>U: 進入進度頁,開始 polling<br/>(同情境 A 階段 4
else 沒有 in-progress job
BE-->>FE: 空陣列
FE->>U: 正常顯示模型庫頁,不打擾使用者
end
Note over CV: ⚠️ 特別情境Converter 在使用者離開期間 Crash
Note over CV: Redis 被重置 → in-progress job 消失<br/>符合「Crash 即 Reset」設計
Note over U: 使用者回來看不到 job<br/>需要重新上傳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可選功能|
---
## 情境 CPhase 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<br/>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=<delegated_token>
FA->>MC: POST /validate-delegated-token<br/>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 tokenaud=kneron_converter_api, scope=converter:job.write / converter:job.read| visionA-backend | Member Centerclient_credentials grant| Converter APIJWKS 驗簽)| visionA-backend 呼叫 Converter API建 job / 查詢 / promote|
| Converter service tokenaud=file_access_api, scope=files:upload.write| Converter API | Member Centerclient_credentials grant| File Access Agent | Converter 在 promote 階段 PUT 結果檔到 NAS。**Phase 1 不再需要 `files:download.read` / `files:metadata.read`**,因為原始模型已改由 visionA-backend multipart 直接上傳到 ConverterConverter 不再從 FAA 拉檔 |
| Delegated download tokenPhase 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 本身不依賴 FAAFAA 錯誤集中在 promote|
---
## 結語
這份跨系統流程圖揭露的關鍵 UX 訊息:
1. **使用者只看到「在 VisionA 平台內按幾下就轉完」**背後有多個服務協作visionA-backend / Member Center / Converter / File Access Agent— 這是架構要盡力維持的體驗。值得注意的是 Phase 1 **建 job 階段只涉及 3 方**visionA-backend / Member Center / ConverterFile 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 拉檔等待」

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,724 @@
# Design Doc — Kneron Model Converter 對外 APIL 級新功能)
## 作者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 serverC# / 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<br/>(Go, Phase 1)"]
MC["Member Center<br/>(OAuth2 + JWKS)"]
end
subgraph NAS["NAS 側(內部網段)"]
subgraph ConverterNode["Converter 部署節點"]
Nginx["Nginx<br/>public + internal vhost"]
Scheduler["Task Scheduler<br/>(Node.js Express)"]
Redis["Redis<br/>(job state + user index)"]
Workers["Workers<br/>(onnx / bie / nef)"]
ConvBucket["Converter Bucket<br/>(MinIO, 7d lifecycle)"]
end
FAA["File Access Agent<br/>(C# ASP.NET Core)"]
NasBucket["NAS Bucket<br/>(模型庫長期儲存)"]
FAA --- NasBucket
end
VisionAFE -->|HTTPS| VisionABE
VisionABE -->|1. token<br/>(client_credentials)| MC
VisionABE -->|2. POST /api/v1/jobs<br/>multipart: model + user_id<br/>(aud=kneron_converter_api)| Nginx
Nginx -->|public vhost| Scheduler
Scheduler -->|驗 token<br/>(JWKS)| MC
Scheduler -->|multer memory<br/>寫入 input| ConvBucket
Scheduler -->|取 token<br/>(client_credentials,<br/>僅 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 結果檔<br/>(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 tokenscope=`converter:job.write`)。
2. **建 job**visionA-backend → Convertermultipart/form-data
visionA-backend 以 `POST /api/v1/jobs` 直接把原始模型 multipart 上傳到 ConverterConverter 驗 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)
### GoalsPhase 1 必達)
- [ ] 對外 API 以 OAuth2 Bearer 驗證,對齊 Innovedus Member CenterJWKS 驗簽)
- [ ] 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-GoalsPhase 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與本次獨立
- [ ] 使用者層級 ACLConverter 不管,責任邊界在 visionA-backend
- [ ] 跨 tenant 隔離的複雜授權模型(本次設計為 single-tenant per Converter deployment見 §5.3
---
## 3. 架構設計 (The Actual Design)
### 3.1 架構模式選擇
- **選擇**:維持現有 **單體 Task SchedulerNode.js Express+ Worker PoolPython** 架構。對外 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 關鍵設計決定) |
| MinIOConverter 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-havePhase 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 需雙寫) |
| 觀測工具 | 結構化 logJSON+ Nginx access log | Trial | Phase 1 先不引入 Prometheus留待 Phase 2 | — |
### 3.4 關鍵設計決定:原始模型的上傳路徑
**背景**
- visionA-backend 需要把使用者上傳的原始模型交給 Converter 轉檔。
- 原本考慮過「檔案先上傳 File Access AgentConverter 再從 File Access Agent 拉」的方案,但發現:
1. 原始模型在轉檔成功、使用者按「加進模型庫」前**不屬於 NAS 模型庫**,沒必要先進 File Access Agent。
2. File Access Agent 的 `GET /files/{objectKey}` 只接受 delegated download tokenConverter 以 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 AgentConverter 再拉 — 需要跨團隊擴充 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 <JWT>`
- **錯誤格式**:統一 `{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 jobRedis 足以承擔未來若需要歷史任務持久化 Crash recovery再評估 PG
#### 資料流Phase 1
```mermaid
flowchart LR
BE[visionA-backend] -->|1. POST /api/v1/jobs<br/>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<br/>(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.1Design 4.1.2 |
| `POST /api/v1/jobs` p95 | 回應時間 95 百分位(含 multipart 上傳到 Converter Bucket與檔案大小相關| < 5s200MB 檔案| PRD §9.2.1 的頻寬假設調整 |
| `POST /api/v1/jobs/:id/promote` p95 | 回應時間 95 百分位 | < 3s | PRD §9.2.1Design 4.1.3 |
| Token 驗證失敗率 | 401 / 總請求 | < 1%排除正常過期| PRD §9.2.1 |
**關於 `POST /api/v1/jobs` p95**:因為改為 multipart 直接上傳,延遲主要受檔案大小與 visionA-backend 到 Converter 的網路頻寬影響。200MB @ 50MB/s ≈ 4s500MB @ 50MB/s ≈ 10s。若觀測到頻繁超 SLAPhase 2 可考慮拆為「建 job」+「上傳 chunk」兩個端點。
### 4.2 容錯設計
| 失敗情境 | 設計應對 |
|---------|---------|
| Member Center JWKS 不可達 | JWKS 本地 cacheTTL 10 minstale-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 天,使用者可重試 promoteAPI 回 502 |
| Worker Crash | 既有 Crash 即 Reset 機制Worker 重啟後繼續 consume Redis Streamin-progress 的 job 若在 Redis 中會被接手前提Redis 沒死)|
| Redis Crash | 符合「Crash 即 Reset」設計哲學所有 job 遺失,使用者重送 |
| Scheduler Crash | 同上,重啟後繼續服務;進行中的 Worker 下階段 done 事件會找不到 job record 而被忽略(既有行為)|
### 4.3 災難復原
維持既有 **Crash 即 Reset**RPO = 無保證Redis 重啟即清空、RTO < 30sDocker restart)。本次不新增 DR 機制
---
## 5. 安全架構 (Security)
### 5.1 威脅模型STRIDE 摘要)
| 威脅 | 風險 | 防護 |
|------|------|------|
| Spoofing偽造 visionA-backend| 中 | OAuth2 Bearer JWTMember Center 簽發JWKS 驗簽 |
| Spoofing偽造 user_id| 低(接受)| 信任 visionA-backend 的 user_idConverter 不做 user ACLPRD §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 <JWT>` 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 的邊界(方式 Amultipart 欄位)
**再次強調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 都接 Converterclient 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`(來自 tokenFile 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 Bucketbuffer 已在記憶體)| 200MB @ 200MB/s ≈ 1s |
| Redis 寫 job record + 索引 | 20ms |
| Enqueue 到 Redis Stream | 10ms |
| **總預算200MB 檔案)** | **~5sp95** |
| **總預算500MB 檔案)** | **~12sp95** |
**若檔案很大**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 polling5min 可 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/*/eventsSSE
│ - /health, /queues/stats │
└──────────────────────────────────────────────────┘
│ (僅 internal vhost 流入)
Web UI / 內部工具(內網)
```
實務上可以兩種實作方式:
- **A. 兩份 Nginx instance**(一個 public、一個 internal各自獨立 process
- **B. 一份 Nginx process、兩個 `server` block**,根據 `listen` 的 interfacepublic 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 CenterPhase 1 規劃)。
3. 自建 API Key 等於要自己管 secret rotation、scope、audit重複發明輪子。
4. Member Center 已提供 JWKS、token endpoint、client managementConverter 只要實作 resource server + client 兩個 OAuth2 角色即可。
**代價**:跨團隊依賴(註冊 audience、client、scope若 Member Center owner 不配合會阻塞。已列入 progress.md 風險。
**替代方案**
- A. 自建 API Key簡單但不符合生態標準未來遷移成本高
- B. mTLS運維成本高對 Node.js 生態不夠友善
---
### ADR-002promote 結果檔採「做法 2」— Converter 自己推到 File Access Agent
**狀態**Accepted
**背景**:轉檔完的結果要搬回 File Access Agent`promote`)。有三種搬檔做法:
| 做法 | 說明 | 優劣 |
|------|------|------|
| 1 | 結果檔流經 visionA-backendConverter → 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 AgentvisionA-backend 直接 multipart 上傳 Converter見 §3.4Phase 1 Converter 完全不需要 `files:download.read` / `files:metadata.read` scope。
**代價**
- Converter 需要取自己的 service tokenOAuth client 邏輯。Token 可 cache、失敗可 retry。
- Converter 需要設定 `KNERON_CONVERTER_CLIENT_ID` + `CLIENT_SECRET`secret 管理責任)。
**替代方案**(已排除):
- 備案 A讓 visionA-backend 自己從 Converter 下載結果、再上傳到 File Access Agent — 浪費頻寬 × 2不合理
- 備案 BFile Access Agent 主動拉 — 對方沒這個介面,不做
---
### ADR-003user_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 等)放不同位置,增加心智負擔 |
**決定**:採方式 Amultipart 欄位)。
**理由**
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 層 ACLPRD §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-004Polling 而非 Webhook
**狀態**AcceptedPM 已決策)
**背景**:轉檔時間長(可能 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-005Phase 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 產品團隊需有 fallbackDesign Review §6 議題 #1)。
---
### ADR-006Phase 1 Web UI 不改,維持既有 multipart 路徑
**狀態**AcceptedPM 已決策)
**背景**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、§7public 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 §8Phase 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 < 200msETag 支援| 採納 | §6.1TDD §1.3ETag|
| 4 | 部署隔離(避免 `/jobs/*` 公網曝光)| ✅ 採納方案 BNginx 分流) | 見 §7 |
| 5 | 預留擴展metadata、ETA、DELETE 路徑、progress 顆粒度)| ✅ 大部分採納 | API 留 `metadata: {}`、保留 `DELETE` 路徑可回 501 Not ImplementedPhase 2 再啟用)|
| 6 | promote 同步p95 < 3s| 採納 | §6.1超過 10s timeout async 模式 Phase 1 不做 |
| 7 | Rate limit per client_id | ✅ 採納 | 見 §6.3 |
---
## 11. 風險與待確認事項(給使用者決策)
> 註2026-04-25 變更後,原 R1File 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 可先不擋 tenantwarn 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 | 大檔(> 200MBmultipart 上傳會超過 p95 SLA | 中 | Phase 1 接受SLA 已調整為 5s @ 200MB、12s @ 500MB若觀測到頻繁超 SLAPhase 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 阻塞**:見 R1Member 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 2Member Center endpoint 實作完成並提供測試環境
---
## 13. 後續步驟
1. 本 Design Doc 送 PM / Design 交叉審閱
2. 使用者審核最終版
3. 跨團隊協調 R1Member Center 註冊 audience / client / scope
4. 工程師依 `TDD.md` 的任務清單T1-T8增量式開發
5. 第二階段Reviewer 每個任務把關
---
**附註**:本 Design Doc 約 710 行已超過建議拆分門檻500 行)。本次更新聚焦內容修正,暫不拆分;下輪更新建議拆分為 `design-doc.md`(索引)+ 子模組。

View File

@ -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: <file>
│ │ ...
│ │
│ │ Converter 端:
│ │ - 用 token 驗 clientOK
│ │ - 信任 user_id 是「真正提交的 user」
│ │ - 不再驗證 user_id 與 token 的關係
```
#### Trust assumptionPhase 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 | 冒充任何 useruser_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-facingcompromise 機率低
2. Phase 1 重點是把核心 pipeline 跑通,安全強化排在 Phase 2
3. 引入 HMAC / OBO 會增加 visionA 端的整合工作量,目前未取得對方確認
#### MitigationsPhase 1 已採用)
| Mitigation | 說明 |
|-----------|------|
| **per-client_id rate limiter300 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 候補方案
##### 方案 1HMAC-signed user_id推薦短期
visionA-backend 用共享 secret HMAC 簽 user_idConverter 驗簽:
```
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: "<hex>" else:
x_user_id_ts: "<unix>" accept user_id
```
**優點**:實作簡單,雙方只需要共享 secret + 規範 hash。
**缺點**:仍是 symmetric secret有外洩風險不會解決「visionA 自己被 compromise」的場景attacker 也能簽)。
##### 方案 2OBO Token / Token Exchange業界標準推薦中期
visionA-backend 為每個 user 取 user-context token例如 OBO flow / Token Exchange RFC 8693Converter 從 JWT claims 取 `user_id`
```
visionA-backend Member Center Converter
POST /token ──────────────→ grant_type=token-exchange
subject_token=<user-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 <new token>
Converter 從 token claims
取 user_id不是 multipart
```
**優點**
- 完全消除 trust boundary 問題user_id 來自 Member Center 簽過的 JWT
- 業界標準,跨 vendor 適用
- 自動 audit chainactor + subject 雙重身份)
**缺點**
- visionA / Member Center 都需要實作 Token Exchange 流程
- 性能:每次 POST /jobs 多一次 Token Exchange round-trip可 cache 緩解)
##### 方案 3Audit 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._-]+$` regexSec M1 強化)+ 額外 `..` 拒絕 |
| **version 嚴格白名單** | `src/routes/v1/validators/createJob.js` | `^[A-Za-z0-9._-]+$` regexSec 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 | 預設 500MBmulter LIMIT_FILE_SIZE → 413|
| **ref_image per-file 大小** | `validateCreateJobRequest` | 10MBSec 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 失敗 → 直接回 502Redis 完全乾淨
- Lua conflict / throw → cleanup MinIOfire-and-forget靠 7d lifecycle 兜底)
- enqueue 失敗 → 補償 release Redis + cleanup MinIOSec 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 M5mount-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 PinSec 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/PS256Sec 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— 通常不視為敏感
- IPlog 仍記,但 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 secretprod 用全新生成的 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 修復 |