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>
41 KiB
Design Doc — Kneron Model Converter 對外 API(L 級新功能)
作者:Architect Agent
狀態:Draft(三方交叉審閱前)
最後更新:2026-04-25
範圍:Phase 1(對外 API、OAuth2、File Access Agent 整合、promote)+ Phase 2 規格預留
變更歷程
| 日期 | 變更 | 作者 |
|---|---|---|
| 2026-04-25 | 初版 Draft | Architect Agent |
| 2026-04-25 | 原始模型上傳路徑改為 visionA-backend multipart 直接上傳 Converter;移除 File Access Agent 的 GET/HEAD S2S 需求(R1 / TBD-1 / §5.5 / ADR-002 input 部分);user_id 改放 multipart 欄位 | Architect Agent |
0. 文件導讀
本 Design Doc 聚焦「系統層級架構決策」。若你是工程師要開始寫程式,請看 TDD.md(或本專案若拆分為 TDD-*.md)。
對應文件:
- 產品需求:
../02-prd/PRD.md(§1.2、§4.3、§4.4、§5.5、§5.6、§14、§15) - 使用者流程:
../03-design/user-flow-cross-system.md - 設計審閱:
../03-design/design-review.md - 專案健檢:
../00-onboarding/health-check.md
1. 背景與範圍 (Context and Scope)
1.1 背景
Kneron Model Converter(下稱 Converter)目前是一個「只有 Web UI 的內部工具」,支援 AI 工程師以圖形化介面執行 ONNX → BIE → NEF 三階段模型轉檔。本次 L 級新功能將其擴展為「對外提供 OAuth2 保護 REST API 的服務」,讓 Innovedus 生態中的其他服務(首個消費者為 VisionA)能以程式化方式整合轉檔能力。
關鍵生態組成:
- Converter(本專案):Node.js + Python Worker,部署在靠近 NAS 網段的位置
- visionA-backend:Go 服務,Converter API 的消費者(Persona C)
- Member Center:OAuth2 / OIDC authorization server(C# / OpenIddict)
- File Access Agent:tenant 邊界內檔案閘道(C# / ASP.NET Core),駐守 NAS 側,單一 tenant per instance
1.2 系統定位(新架構)
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):
- 取 token(visionA-backend → Member Center)
visionA-backend 以
client_credentials取得aud=kneron_converter_api的 access token(scope=converter:job.write)。 - 建 job(visionA-backend → Converter,multipart/form-data)
visionA-backend 以
POST /api/v1/jobs直接把原始模型 multipart 上傳到 Converter;Converter 驗 OAuth token 後把檔案寫入 Converter Bucket(jobs/{job_id}/input/{filename}),流程與既有 Web UIPOST /jobsmultipart 上傳一致(multer.memoryStorage(),fileSize: 500MB)。 - 轉檔(Worker pool 處理,順序固定 onnx → bie → nef) Workers 從 Converter Bucket 讀檔、處理、寫回結果檔。Phase 1 Converter 完全不從 File Access Agent 讀任何東西。
- polling 進度(visionA-backend → Converter)
每 2-5 秒
GET /api/v1/jobs/:id。 - promote 到 NAS(visionA-backend → Converter → File Access Agent)
使用者在 VisionA 前端按「加進模型庫」時,visionA-backend 呼叫
POST /api/v1/jobs/:id/promote,Converter 以自己的 OAuth client 身分取files:upload.writetoken,把 Converter Bucket 中的結果檔 PUT 到 File Access Agent。
2. 目標與非目標 (Goals and Non-Goals)
Goals(Phase 1 必達)
- 對外 API 以 OAuth2 Bearer 驗證,對齊 Innovedus Member Center(JWKS 驗簽)
- Converter 同時具備 Resource Server(驗他人 token)與 OAuth Client(取自己 token)雙重身分
- 提供四個對外端點:
POST /api/v1/jobs、GET /api/v1/jobs、GET /api/v1/jobs/:id、POST /api/v1/jobs/:id/promote - 同使用者同時一個轉檔限制(以
user_id為界,不是client_id) - Recovery 支援(
GET /api/v1/jobs?user_id=...&status=in_progress) - 既有
/jobs/*舊路徑保留不動,Web UI 零影響 - 部署分流:公網只開
/api/v1/*,/jobs/*只在內部網段可達 - OpenAPI 3.0 規格產出,供下游整合
- API SLA 可觀測(p95、錯誤率、token failure rate)
Non-Goals(Phase 1 明確不做)
- 使用者直連下載(delegated download)— 阻塞於 Member Center endpoint,延至 Phase 2
- Webhook / SSE 對外推送(polling 已足夠,見 ADR-004)
- Job 取消 / 重試(非本次範圍,API 僅保留路徑)
- Job 持久化 / 跨 Crash recovery(維持「Crash 即 Reset」哲學)
- Web UI 改走新 OAuth 流程(本次 Phase 1 不動,見 ADR-006)
- 單階段轉換 API 後端對齊(既有 backlog,與本次獨立)
- 使用者層級 ACL(Converter 不管,責任邊界在 visionA-backend)
- 跨 tenant 隔離的複雜授權模型(本次設計為 single-tenant per Converter deployment,見 §5.3)
3. 架構設計 (The Actual Design)
3.1 架構模式選擇
- 選擇:維持現有 單體 Task Scheduler(Node.js Express)+ Worker Pool(Python) 架構。對外 API 以新增路由群的方式加入,不另開新服務。
- 理由:
- Phase 1 範圍聚焦「多一層 auth + 多一組 API 端點 + promote 時對 File Access Agent 一次寫入」,不足以撐起新服務的運維複雜度。
- 既有 Crash 即 Reset 哲學對單體有利:Scheduler stateless,重啟 = 復原。
- 新舊路徑共用同一份 Redis job record,不需跨服務同步。
- 取捨:
- 代價:Scheduler 單體變胖(預估 +600 行)。可接受,因為 API 介面屬於 I/O 密集,不是 CPU 密集,Node.js 單 process 足以負擔。
- 若未來 QPS 需求爆增(例如 > 500 RPS),可把 auth middleware 與 OAuth client 抽出為獨立 sidecar,但 Phase 1 不做。
3.2 分階段架構演進
Phase 1 架構(本次)
見 §1.2 的圖。重點變化:
| 元件 | 是否新增 / 修改 |
|---|---|
| Nginx | 新增 public vhost(/api/v1/*)與 internal vhost(/jobs/*)的分流設定(見 §7) |
| Task Scheduler | 新增 auth middleware、OAuth client、新 /api/v1/* 路由群、user 索引、promote 實作 |
| Redis | 新增 user:{user_id}:jobs Set 索引、job record 新增欄位 |
| Workers | 不需要大改(Phase 1 保持從 Converter Bucket 讀寫,見 §3.4 關鍵設計決定) |
| MinIO(Converter Bucket) | 不變 |
| File Access Agent | 不在本專案部署範圍(由 Innovedus 生態團隊部署) |
| Member Center | 不在本專案部署範圍 |
月度基礎設施成本預估(本專案側):Phase 1 沒有新增基礎設施,與現況相同。跨團隊依賴的 Member Center / File Access Agent 成本由對方團隊吸收。
Phase 2 架構(預留)
Phase 2 對 Converter 本體無架構變更。Delegated download 的流程完全發生在「visionA-backend ↔ Member Center ↔ 使用者瀏覽器 ↔ File Access Agent」,不經 Converter。
唯一可能的 Converter 變化:Phase 2 上線後可考慮讓 Converter 在 promote 完成時回傳更多資訊(例如 download_hint_object_key)方便 visionA-backend 直接拿來換 delegated token,但這是 nice-to-have,Phase 1 API 契約已足夠支撐。
3.3 技術選型(Technology Radar)
| 層級 | 技術選擇 | 狀態 | 選型理由 | 退出成本 |
|---|---|---|---|---|
| Auth 驗 JWT | jose(npm) |
Adopt | 零依賴純 JS,支援 JWKS remote + cache,主流專案廣泛採用 | 低(抽 1 個 middleware 即可換 jsonwebtoken + jwks-rsa) |
| Auth 取 token | 自寫輕量 HTTP client(node-fetch 或 Node 18 原生 fetch)+ in-memory cache |
Adopt | client_credentials 只是一個 HTTP POST,不需引入 openid-client 這種大套件 |
低 |
| HTTP client 對 File Access Agent | Node 18 原生 fetch + stream | Adopt | 支援大檔 stream,無需額外 deps | 低 |
| Rate Limit | express-rate-limit(既有)+ per-client_id key 擴展 |
Adopt | 既有套件擴展即可 | 低 |
| OpenAPI 產出 | 手寫 YAML + @redocly/cli 或 swagger-ui-express 提供 /openapi.json 檢視 |
Adopt | Phase 1 手寫可控,避免 code-first 產出不穩定 | 低 |
| Redis 索引 | Redis Set(user:{user_id}:jobs)+ 原有 job:{id} |
Adopt | Phase 1 量級不足以需要 PostgreSQL;維持 stateless 設計一致 | 中(未來要遷 PG 需雙寫) |
| 觀測工具 | 結構化 log(JSON)+ Nginx access log | Trial | Phase 1 先不引入 Prometheus;留待 Phase 2 | — |
3.4 關鍵設計決定:原始模型的上傳路徑
背景:
- visionA-backend 需要把使用者上傳的原始模型交給 Converter 轉檔。
- 原本考慮過「檔案先上傳 File Access Agent,Converter 再從 File Access Agent 拉」的方案,但發現:
- 原始模型在轉檔成功、使用者按「加進模型庫」前不屬於 NAS 模型庫,沒必要先進 File Access Agent。
- File Access Agent 的
GET /files/{objectKey}只接受 delegated download token,Converter 以 S2S JWT 無法下載(除非對方擴充 API,這會是額外的跨團隊阻塞)。 - 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 讀任何東西。
具體流程:
POST /api/v1/jobs(multipart/form-data)進來時,Scheduler 驗 OAuth token、以multer.memoryStorage()接收檔案(modelrequired ≤500MB、ref_images[]optional maxCount 100)。- Scheduler 檢查
user_id是否已有 in-progress job;若無則把 buffer 寫入 Converter Bucket(jobs/{job_id}/input/{filename}、jobs/{job_id}/ref_images/*)。 - 建 job record、enqueue 到第一階段。
- Worker 從 Converter Bucket 讀 input(和既有 MinIO 模式完全一致),產出結果寫回 Converter Bucket。
promote時 Scheduler 以自己的 OAuth client 身分取files:upload.writetoken,從 Converter Bucket 讀結果、PUT 到 File Access Agent。
為什麼這樣選:
- Worker 程式零改動(現有
STORAGE_BACKEND=minio模式直接沿用) - 對外 API 上傳路徑與既有 Web UI 程式幾乎 100% 共享(
multer中介層、500MB 限制、儲存路徑約定) - Phase 1 Converter 只需要「當 OAuth client 打 File Access Agent」的單一場景(promote 寫入),不需要
files:download.read/files:metadata.read - 避免阻塞於 File Access Agent GET 授權模型的擴充(原 ADR-002 的待確認項已解除)
代價:
POST /api/v1/jobs的 p95 會受 multipart 上傳大小影響(500MB 在一般網路環境約 5-30s),需調整 SLA(見 §6)。- 大檔 multipart 會暫時佔用 Scheduler 記憶體(
multer.memoryStorage()),與既有 Web UI 一致的風險模型。 - visionA-backend 必須自行處理上傳超時與重傳(和一般檔案上傳 API 行為一致)。
替代方案(見 ADR-002):
- A. 檔案先進 File Access Agent,Converter 再拉 — 需要跨團隊擴充 File Access Agent GET S2S 授權,Phase 1 被阻塞
- B. 檔案流經 visionA-backend 兩次上傳(VisionA 前端 → backend → Converter)— visionA-backend 要扛兩次大檔流量,浪費頻寬
- C. 使用者瀏覽器 direct-to-Converter presigned URL — 沒有對應基礎設施,Phase 1 不做
3.5 API 設計概覽
- API 風格:REST + JSON
- Base Path:
/api/v1/* - 認證:所有端點(除
/health)都要Authorization: Bearer <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 個 job),Redis 足以承擔。未來若需要歷史任務持久化、跨 Crash recovery,再評估 PG。
資料流(Phase 1)
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.1、Design 4.1.2 |
POST /api/v1/jobs p95 |
回應時間 95 百分位(含 multipart 上傳到 Converter Bucket,與檔案大小相關) | < 5s(200MB 檔案) | 依 PRD §9.2.1 的頻寬假設調整 |
POST /api/v1/jobs/:id/promote p95 |
回應時間 95 百分位 | < 3s | PRD §9.2.1、Design 4.1.3 |
| Token 驗證失敗率 | 401 / 總請求 | < 1%(排除正常過期) | PRD §9.2.1 |
關於 POST /api/v1/jobs p95:因為改為 multipart 直接上傳,延遲主要受檔案大小與 visionA-backend 到 Converter 的網路頻寬影響。200MB @ 50MB/s ≈ 4s,500MB @ 50MB/s ≈ 10s。若觀測到頻繁超 SLA,Phase 2 可考慮拆為「建 job」+「上傳 chunk」兩個端點。
4.2 容錯設計
| 失敗情境 | 設計應對 |
|---|---|
| Member Center JWKS 不可達 | JWKS 本地 cache(TTL 10 min,stale-while-revalidate 24h),短時離線仍可驗 token |
| Member Center token endpoint 不可達(取 Converter 自己的 token) | Token cache(有效期內不重取);過期後重試 3 次,失敗則 promote 回 503 + auth_service_unavailable,由 visionA-backend 重試 |
| multipart 上傳失敗 / 超過 500MB | POST /api/v1/jobs 回 400 validation_error 或 413 file_too_large;Redis 不建 job record(避免殘留) |
| File Access Agent promote 失敗 | Converter Bucket 檔案保留 7 天,使用者可重試 promote;API 回 502 |
| Worker Crash | 既有 Crash 即 Reset 機制:Worker 重啟後繼續 consume Redis Stream,in-progress 的 job 若在 Redis 中會被接手(前提:Redis 沒死) |
| Redis Crash | 符合「Crash 即 Reset」設計哲學,所有 job 遺失,使用者重送 |
| Scheduler Crash | 同上,重啟後繼續服務;進行中的 Worker 下階段 done 事件會找不到 job record 而被忽略(既有行為) |
4.3 災難復原
維持既有 Crash 即 Reset:RPO = 無保證(Redis 重啟即清空)、RTO < 30s(Docker restart)。本次不新增 DR 機制。
5. 安全架構 (Security)
5.1 威脅模型(STRIDE 摘要)
| 威脅 | 風險 | 防護 |
|---|---|---|
| Spoofing(偽造 visionA-backend) | 中 | OAuth2 Bearer JWT(Member Center 簽發),JWKS 驗簽 |
| Spoofing(偽造 user_id) | 低(接受) | 信任 visionA-backend 的 user_id;Converter 不做 user ACL(PRD §5.6 明確) |
| Tampering(改動 job record) | 低 | Redis 在內部網段,無外部存取;Job record 僅 Scheduler 可寫 |
| Repudiation(否認呼叫) | 中 | Log client_id + request_id + user_id,保留 30 天 |
| Info Disclosure(跨 client 看別人的 job) | 中 | Job 查詢過濾:只回 created_by_client_id 吻合的 job(見 §5.3) |
| DoS | 中 | Rate Limit per client_id;檔案大小上限 500MB(既有) |
| Elevation of Privilege | 低 | scope 檢查嚴格:converter:job.read vs converter:job.write |
5.2 安全邊界
5.2.1 端點 auth 要求
| 端點 | 需要 auth? | 需要的 scope |
|---|---|---|
GET /health |
❌ 不需要 | — |
POST /api/v1/jobs |
✅ | converter:job.write |
GET /api/v1/jobs |
✅ | converter:job.read |
GET /api/v1/jobs/:id |
✅ | converter:job.read |
POST /api/v1/jobs/:id/promote |
✅ | converter:job.write |
舊 /jobs/*(Web UI 走的路徑) |
❌ 不加 OAuth | — |
/jobs/*/events(SSE) |
❌ 不加 OAuth | — |
5.2.2 Token 驗證清單
每個 /api/v1/* 請求的 middleware 必須檢查:
Authorization: Bearer <JWT>header 存在- JWT 格式合法
- JWT 簽章(用 Member Center JWKS 驗)
iss== 設定的 Member Center issueraud包含kneron_converter_api(可能是 string 或 array)exp未過期(含 clock skew ±60s)scope包含該端點要求的 scope(空白分隔字串)client_idclaim 存在(記錄用)- (可選)
tenant_idclaim(見 §5.3)
失敗處理:
- 1-5 失敗 → 401
invalid_token - 6 失敗 → 401
token_expired - 7 失敗 → 403
insufficient_scope+details.required_scope - 8 失敗 → 401
invalid_token(缺必要 claim)
5.2.3 user_id 的邊界(方式 A:multipart 欄位)
再次強調:user_id 不是授權邊界。 Converter 的設計如下:
| 操作 | 處理方式 |
|---|---|
POST /api/v1/jobs 建 job |
信任 multipart field 中的 user_id,寫入 job record |
GET /api/v1/jobs/:id 查 job |
回傳 job record(不比對呼叫者 user_id) |
GET /api/v1/jobs?user_id=X 查列表 |
以 query 中的 user_id 過濾(呼叫者可以查任意 user 的 job) |
POST /api/v1/jobs/:id/promote |
不需要 user_id(只檢查 job 狀態) |
為什麼這樣設計:
- 服務消費者(例如 visionA-backend)本來就需要看自己所有 user 的 job(例如 admin 監控面板)
- 授權邊界在
client_id層:只要 client 有converter:job.readscope 就能查任何 job - 防止跨 client 串連(見 §5.3)
5.2.4 跨 client 隔離(重要)
風險:若未來有 client A 和 client B 都接 Converter,client B 不應看到 client A 建的 job。
設計:Job record 記錄 created_by_client_id,查詢時預設只回相同 client_id 的 job。
| 查詢 | 預設行為 |
|---|---|
GET /api/v1/jobs |
只回 created_by_client_id == token.client_id 的 job |
GET /api/v1/jobs/:id |
若 created_by_client_id != token.client_id,回 404(不是 403,避免資訊洩露) |
POST /api/v1/jobs/:id/promote |
同上 |
Phase 1 的第一個 client 是 visionA-backend,此規則對它無感。未來加新 client 時,自動獲得隔離。
例外:若需要「管理員 client」能跨 client 查詢(例如監控用),可設定特殊 scope converter:admin.read,Phase 1 不實作,但保留擴展空間。
5.3 Tenant 策略(回應 PM 疑問 A.1.3)
決定:Phase 1 採 single-tenant per Converter deployment,Converter 不自行管理 tenant 隔離。
具體作法:
- Converter 在設定檔中記錄
EXPECTED_TENANT_ID(從環境變數CONVERTER_TENANT_ID)。 - 驗 token 時若 JWT 有
tenant_idclaim,則檢查等於EXPECTED_TENANT_ID,不等則 403tenant_mismatch。 - 若 JWT 沒有
tenant_idclaim,依 Member Center owner 的決定行事(Phase 1 初期可能沒有 tenant claim,可先 warn log,不擋)。 - Job record 記錄
tenant_id(方便未來 log 與審計)。 - 所有 Converter 打 File Access Agent 的請求自然帶著 Converter 自己的
tenant_id(來自 token),File Access Agent 的INSTANCE_TENANT_ID必須與之吻合。
未來(多租戶)的擴展路徑:
- 若要 Converter 一份程式碼支援多個 tenant,需要新增「根據 token 的 tenant_id 路由到對應的 File Access Agent instance」邏輯。Phase 1 不做。
5.4 舊 /jobs/* 路徑的保護(部署分流)
Web UI 走的 /jobs/* 路徑不加 OAuth。若公網可達,會被繞過對外 API 的 scope 檢查直接打 Scheduler。
解決:部署層級分流(見 §7)。Nginx 分兩個 vhost:
- public vhost(443 對公網):只 proxy
/api/v1/*到 Scheduler,其他路徑一律 404 - internal vhost(僅內部網段可達):proxy
/jobs/*、/health、/queues/stats、/jobs/*/events到 Scheduler
這樣 Web UI(部署在內部網段 / 跳板後面)正常運作,對外 API 僅暴露 /api/v1/*。
採納 Design Review §2.3 建議的方案 B。
6. 效能工程 (Performance)
6.1 延遲預算(POST /api/v1/jobs)
| 階段 | 預算 |
|---|---|
| Nginx ingress(含 multipart 前置處理) | 10ms |
| JWT 驗證(JWKS cache hit) | 5ms |
| multipart 接收(multer memory,與檔案大小相關) | 200MB @ 50MB/s ≈ 4s |
| multipart validation(欄位、mimetype、副檔名) | 20ms |
| Redis 查 active_job | 10ms |
| MinIO PutObject(寫入 Converter Bucket,buffer 已在記憶體) | 200MB @ 200MB/s ≈ 1s |
| Redis 寫 job record + 索引 | 20ms |
| Enqueue 到 Redis Stream | 10ms |
| 總預算(200MB 檔案) | ~5s(p95) |
| 總預算(500MB 檔案) | ~12s(p95) |
若檔案很大:p95 會超過 5s SLA。考慮未來:
- 改為 chunked upload(前端把檔案切塊,多個 PUT /api/v1/jobs/:id/chunks)— Phase 2 可選
- 改為 async 上傳模式(先回 202 + job_id,背景接收剩餘 chunks)— Phase 2 可選
6.2 Token cache 策略
| Cache | TTL | 退出條件 |
|---|---|---|
| JWKS(驗 JWT 用) | 10 min(主動 refresh) | 遇到未知 kid 時強制 refresh 一次 |
| Converter 自己的 access token | expires_in - 60s(快到期才 refresh) |
遇到 401 時強制 refresh 一次 |
6.3 Rate Limit 策略
| 範圍 | 限制 | 動機 |
|---|---|---|
| 全局(IP) | 200 req / 15min(既有) | 維持既有防護 |
Per client_id |
300 req / 5min | 防止單一 client 暴力 polling,但容許正常 2-5s polling(5min 可 60-150 次已足夠) |
7. 部署架構
7.1 Nginx 雙 vhost 分流
Phase 1 採一份 Nginx process、兩個 server block 的設計(方案 B):
┌─────────────────────────────────────────────────────────────┐
│ Nginx(單一 process) │
│ │
│ ┌────────────────────────┐ ┌────────────────────────────┐ │
│ │ server { │ │ server { │ │
│ │ listen 443 ssl; │ │ listen 10.0.0.1:80; │ │
│ │ server_name │ │ server_name │ │
│ │ converter....com; │ │ converter-internal...; │ │
│ │ │ │ │ │
│ │ location /api/v1/ {} │ │ location /jobs {} │ │
│ │ location = /health {} │ │ location /queues/stats {} │ │
│ │ location / { │ │ location / { │ │
│ │ return 404; │ │ proxy_pass web:3000; │ │
│ │ } │ │ } │ │
│ │ } │ │ } │ │
│ │ (public vhost) │ │ (internal vhost, 內網 IP) │ │
│ └───────────┬─────────────┘ └────────────┬────────────────┘ │
└──────────────┼──────────────────────────────┼───────────────────┘
│ │
▼ ▼
┌──────────────────────────────────────────────────┐
│ Task Scheduler (:4000) │
│ - /api/v1/* (OAuth 保護,僅 public vhost 轉入)│
│ - /jobs/* (無 auth,僅 internal vhost 轉入) │
│ - /jobs/*/events(SSE) │
│ - /health, /queues/stats │
└──────────────────────────────────────────────────┘
▲
│ (僅 internal vhost 流入)
│
Web UI / 內部工具(內網)
實務上可以兩種實作方式:
- A. 兩份 Nginx instance(一個 public、一個 internal,各自獨立 process)
- B. 一份 Nginx process、兩個
serverblock,根據listen的 interface(public IP vs internal IP)做分流
Phase 1 採 B(設定簡單、資源省、單一 reload 管理)。上方 ASCII 圖即為方案 B 的實際結構;對應完整 Nginx config 詳見 TDD.md §7.1。
7.2 docker-compose 變化
本次不在 docker-compose.yml 新增 File Access Agent / Member Center(由對方團隊部署,Converter 只是 client)。
新增環境變數(詳見 TDD.md §9):
MC_ISSUER,MC_JWKS_URL,MC_TOKEN_URLKNERON_CONVERTER_CLIENT_ID,KNERON_CONVERTER_CLIENT_SECRETKNERON_CONVERTER_AUDIENCE(接收端)FILE_ACCESS_AGENT_BASE_URL,FILE_ACCESS_AGENT_AUDIENCECONVERTER_TENANT_IDCONVERTER_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。
理由:
- 使用者已決定 Converter 對齊 Innovedus 生態(progress.md)。
- OAuth2 是業界標準,visionA-backend 本來就要接 Member Center(Phase 1 規劃)。
- 自建 API Key 等於要自己管 secret rotation、scope、audit,重複發明輪子。
- Member Center 已提供 JWKS、token endpoint、client management,Converter 只要實作 resource server + client 兩個 OAuth2 角色即可。
代價:跨團隊依賴(註冊 audience、client、scope),若 Member Center owner 不配合會阻塞。已列入 progress.md 風險。
替代方案:
- A. 自建 API Key:簡單但不符合生態標準,未來遷移成本高
- B. mTLS:運維成本高,對 Node.js 生態不夠友善
ADR-002:promote 結果檔採「做法 2」— Converter 自己推到 File Access Agent
狀態:Accepted
背景:轉檔完的結果要搬回 File Access Agent(promote)。有三種搬檔做法:
| 做法 | 說明 | 優劣 |
|---|---|---|
| 1 | 結果檔流經 visionA-backend(Converter → visionA-backend → File Access Agent) | 浪費頻寬、visionA-backend 要扛大檔 |
| 2 | Converter 自己 PUT File Access Agent(files:upload.write) |
檔案只在 NAS 側流動,單次寫入 |
| 3 | File Access Agent 主動拉 Converter Bucket | File Access Agent 沒這功能,不能為了我們改 |
決定:採做法 2。
理由:
- 省流量:Converter 和 File Access Agent 都在 NAS 側,直連 HTTP。
- visionA-backend 職責單純(只管 orchestration + 原始檔 multipart 轉送,不碰結果檔)。
- 符合 PRD US-13 的明確要求。
- File Access Agent 的
PUT /files/{key}已明確支援 S2S JWT +files:upload.writescope(對方現有 API 已實作,無跨團隊阻塞)。
範圍澄清(2026-04-25 更新):
本 ADR 只針對結果檔 promote 的搬檔路徑。原始模型不會進 File Access Agent(visionA-backend 直接 multipart 上傳 Converter,見 §3.4),Phase 1 Converter 完全不需要 files:download.read / files:metadata.read scope。
代價:
- Converter 需要取自己的 service token(OAuth client 邏輯)。Token 可 cache、失敗可 retry。
- Converter 需要設定
KNERON_CONVERTER_CLIENT_ID+CLIENT_SECRET(secret 管理責任)。
替代方案(已排除):
- 備案 A:讓 visionA-backend 自己從 Converter 下載結果、再上傳到 File Access Agent — 浪費頻寬 × 2,不合理
- 備案 B:File Access Agent 主動拉 — 對方沒這個介面,不做
ADR-003:user_id 以 multipart 欄位傳遞(方式 A),不放 token claim、不放自訂 header
狀態:Accepted(已由使用者決策)
背景:需要記錄 job 是誰的(VisionA 使用者 ID)。有三種方式:
| 方式 | 做法 | 優劣 |
|---|---|---|
| A | 放 multipart 欄位 user_id,Converter 信任 |
與 model_id / version / platform 等其他業務欄位用同一條路徑,和既有 Web UI 的 POST /jobs 對齊 |
| B | Member Center 簽 token 時把 user_id 塞 claim |
看起來像「user 的 token」,但 client_credentials 本質是 S2S,不該綁 user |
| C | 自訂 X-User-Id header |
跟一般業務欄位(model_id 等)放不同位置,增加心智負擔 |
決定:採方式 A(multipart 欄位)。
理由:
- client_credentials 是服務對服務的 token,沒有「user」概念,不應把 user_id 放 claim
POST /api/v1/jobs本身就是 multipart,多一個user_id欄位最自然,和model_id、version、platform等業務欄位放一起- 與既有 Web UI
POST /jobs的 multipart 欄位路徑一致,程式碼可共用 validation - Converter 不做 user 層 ACL(PRD §5.6 明確),user_id 只用於業務邏輯(同使用者限制、查詢過濾)
- 避免跟 Member Center 要客製化 claim,維持標準 OAuth2
代價:Converter 完全信任 visionA-backend 送的 user_id。若 visionA-backend 被入侵或 bug 亂送,可能造成「A 使用者看到 B 的 job」。
- 緩解:授權責任邊界在 visionA-backend,這是合理的 trust boundary。Converter 的日誌會記錄 user_id 變更頻率,可做異常監測(PRD §12.2 已列)。
ADR-004:Polling 而非 Webhook
狀態:Accepted(PM 已決策)
背景:轉檔時間長(可能 30s-數分鐘),visionA-backend 需要知道何時完成。
決定:Phase 1 只提供 polling(GET /api/v1/jobs/:id),不實作 Webhook。
理由:
- 下游是另一個 backend 服務,polling 對它是標準做法
- Webhook 需要處理:retry、簽章、對方 endpoint 驗證、重放防護,surface area 太大
- Phase 1 先快速上線驗證產品價值,Webhook 可以 Phase 3 再考慮
代價:進度延遲 = polling 間隔。API 文件建議 2-5s 間隔,對 UX 足夠。
ADR-005:Phase 1 使用者下載延至 Phase 2
狀態:Accepted(已由使用者決策)
背景:Member Center 的 POST /file-access/download-tokens 尚未實作,該 endpoint 是使用者直連下載的前提。
決定:Phase 1 完全不做使用者下載功能,延至 Phase 2。
理由:
- 阻塞於外部依賴(Member Center owner 的時程)
- Phase 1 先讓「上傳 → 轉檔 → 搬進模型庫」閉環可以跑
- UX 缺口由 VisionA 產品團隊用 messaging 策略處理(Design 議題 #1)
代價:VisionA 使用者暫時沒有下載能力。VisionA 產品團隊需有 fallback(Design Review §6 議題 #1)。
ADR-006:Phase 1 Web UI 不改,維持既有 multipart 路徑
狀態:Accepted(PM 已決策)
背景:Web UI 目前走 POST /jobs(multipart)、GET /jobs/:id/events(SSE)、GET /jobs/:id/download/...。
決定:全部保留不動,Web UI 與對外 API 兩套並存。
理由:
- Web UI 是內部工具,persona 與 API 消費者不同
- 給 Web UI 加 OAuth 等於加登入流程,UX 倒退(Design Review §2.3)
- 降低本次 L 級的範圍,避免同時改兩套
代價:Web UI 的 /jobs/* 路徑若曝露公網會繞過 OAuth。
- 緩解:部署層分流(§5.4、§7),public Nginx 只 proxy
/api/v1/*。
9. 回應 PRD 附錄 A 疑問清單
| # | PM 疑問 | 架構決策 |
|---|---|---|
| A.1.1 | Web UI 要不要也改走 OAuth | 不改,見 ADR-006。以 Nginx 分流保護。未來若要改,屬於獨立的 M/L 級任務 |
| A.1.2 | 同使用者限制的範圍 | 整個 Converter 服務共用 user_id 空間。現階段第一個 client 是 visionA-backend,未來加 client 時若真的有 user_id 衝突風險,再考慮用 (client_id, user_id) 複合鍵(Phase 1 不做) |
| A.1.3 | tenant_id 策略 | single-tenant per Converter deployment。見 §5.3。Job record 會記錄 tenant_id 方便 audit |
| A.1.4 | Phase 2 fallback | 本架構文件不決定此議題(屬 VisionA 產品團隊決策)。我們的架構不阻擋 VisionA 選任一方案 |
| A.1.5 | API 採用度 baseline | 非架構議題。建議 Phase 1 上線後跑 1 個月 beta 再訂 SLA 目標 |
| A.1.6 | 既有 [推測] 標記清理 |
非本次範圍 |
| A.2.1 | Member Center owner 協調 | 阻塞項,必須在 kickoff 前解決。建議的 naming:kneron_converter_api audience、kneron_converter client、scopes 見 TDD §8(Phase 1 Converter 只需 files:upload.write 一個 scope 打 FAA) |
| A.2.2 | File Access Agent deployment / tenant_id | 中度依賴(只 promote 時用)。Converter 在 setup 時需要 FILE_ACCESS_AGENT_BASE_URL + 確認 tenant_id 吻合;FAA 現有 PUT /files/{key} 已支援 S2S JWT,無需擴充 |
| A.2.3 | VisionA Phase 1 OAuth 整合時程 | 需雙方 kickoff 對齊 |
| A.2.4 | Member Center download-tokens 實作時程 | Phase 2 啟動觸發條件 |
| A.3.1 | scope 命名 | 建議採 converter:job.write、converter:job.read,格式對齊 File Access Agent 的 files:*.* 慣例 |
| A.3.2 | Effort 估算 | 見 TDD §12(按 T1-T8 拆分,預估 4-5 人週) |
| A.3.3 | OpenAPI 維護策略 | 手寫 YAML(Phase 1),手動與實作同步。未來再評估自動生成 |
| A.3.4 | user_id 索引的 Redis 策略 | 新增 user:{user_id}:jobs Set + user:{user_id}:active_job String。避免 KEYS * 全掃(Design 4.1.2 建議已採納) |
10. 回應 Design Review 7 條建議
| # | Design 建議 | 是否採納 | 說明 |
|---|---|---|---|
| 1 | Response schema(stage_timings、stage_progress、expires_at、結構化 error) |
✅ 全採納 | 見 TDD §1 |
| 2 | 錯誤碼結構化({error: {code, message, details}},409 帶 active_job 詳情) |
✅ 全採納 | 見 TDD §1.5 |
| 3 | Polling 效能(p95 < 200ms,ETag 支援) | ✅ 採納 | 見 §6.1、TDD §1.3(ETag) |
| 4 | 部署隔離(避免 /jobs/* 公網曝光) |
✅ 採納方案 B(Nginx 分流) | 見 §7 |
| 5 | 預留擴展(metadata、ETA、DELETE 路徑、progress 顆粒度) | ✅ 大部分採納 | API 留 metadata: {}、保留 DELETE 路徑可回 501 Not Implemented(Phase 2 再啟用) |
| 6 | promote 同步(p95 < 3s) | ✅ 採納 | 見 §6.1。超過 10s timeout 的 async 模式 Phase 1 不做 |
| 7 | Rate limit per client_id | ✅ 採納 | 見 §6.3 |
11. 風險與待確認事項(給使用者決策)
註:2026-04-25 變更後,原 R1(File Access Agent GET S2S 授權問題)已移除(見 §0 變更歷程),現存 R1-R6 為原 R2-R6 的內容沿用原敘述挪上一格後重編;R7 為本次針對 multer memoryStorage 大檔並發 OOM 新增的風險。
| # | 風險 / 議題 | 影響 | 行動 |
|---|---|---|---|
| R1 | Member Center client / audience / scope 註冊時程 | 高 | Orchestrator 協助排跨團隊會議 |
| R2 | Member Center 是否支援 tenant_id claim?格式? |
中 | 待確認;Phase 1 可先不擋 tenant(warn log),等 Member Center 定案 |
| R3 | File Access Agent 的 object_key 命名約定與 VisionA 對齊(僅 promote 需要) |
中 | 見 TDD §6.1 建議,需 VisionA 確認 |
| R4 | JWKS cache stale 時 Member Center 更新 key 的同步策略 | 低 | 10 min TTL + 遇到未知 kid 強制 refresh 應足夠 |
| R5 | 大檔(> 200MB)multipart 上傳會超過 p95 SLA | 中 | Phase 1 接受(SLA 已調整為 5s @ 200MB、12s @ 500MB);若觀測到頻繁超 SLA,Phase 2 引入 chunked upload |
| R6 | docker-compose 本地開發時如何測 OAuth | 中 | 見 TDD §11(建議本地跑 Member Center docker-compose,或以 mock server) |
| R7 | Scheduler 同時承接多個 500MB multipart 上傳會吃光記憶體(multer.memoryStorage()) |
中 | Phase 1 依賴 user_has_active_job 鎖避免同 user 併發;若跨 user 併發成為瓶頸,改 multer.diskStorage() 或 streaming 上傳 |
12. Phase 1 / Phase 2 切分(架構層)
Phase 1 必做
- auth middleware + scope 檢查
- OAuth client + token cache
- 新路由群
/api/v1/*(POST jobs、GET jobs、GET jobs/:id、POST promote) - Redis 資料模型擴充(user_id、tenant_id、索引)
- 同使用者一個轉檔限制
- Recovery 查詢
- 部署分流(Nginx 雙 vhost)
- OpenAPI 文件
Phase 2 預留(不改契約)
POST /api/v1/jobs/:id/download-tokens(等 Member Center 補完)DELETE /api/v1/jobs/:id(回 501 Phase 1 → 實作 Phase 2/3)POST /api/v1/jobs/:id/webhooks(預留結構,可回 501)- Async promote 模式(若觀測到 p95 > SLA)
阻塞條件
- Phase 1 阻塞:見 R1(Member Center 註冊),必須在 kickoff 前解除。File Access Agent 側 Phase 1 只需
PUT /files/{key}S2S 支援(對方現有 API 已支援,無阻塞) - Phase 2 阻塞:Member Center
POST /file-access/download-tokens實作
觸發條件
- Phase 1:三方交叉審閱通過 + 使用者審核 Design Doc + TDD + R1 解除
- Phase 2:Member Center endpoint 實作完成並提供測試環境
13. 後續步驟
- 本 Design Doc 送 PM / Design 交叉審閱
- 使用者審核最終版
- 跨團隊協調 R1(Member Center 註冊 audience / client / scope)
- 工程師依
TDD.md的任務清單(T1-T8)增量式開發 - 第二階段:Reviewer 每個任務把關
附註:本 Design Doc 約 710 行,已超過建議拆分門檻(500 行)。本次更新聚焦內容修正,暫不拆分;下輪更新建議拆分為 design-doc.md(索引)+ 子模組。