jim800121chen cff9236699 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>
2026-05-01 10:59:21 +08:00

725 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 rotationscopeaudit重複發明輪子
4. Member Center 已提供 JWKStoken endpointclient managementConverter 只要實作 resource server + client 兩個 OAuth2 角色即可
**代價**跨團隊依賴註冊 audienceclientscope 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 boundaryConverter 的日誌會記錄 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.3Job 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` clientscopes 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 | 預留擴展metadataETADELETE 路徑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`(索引)+ 子模組。