依 autoflow-agent workspace v2 設計把 PRD / 設計 / 架構 / 交付類 共享文件從個人層 .autoflow/(ignored)搬到 docs/autoflow/(進 git), 讓團隊可共享產品與架構文件,個人層只留 progress / review / testing 等 per-branch 筆記。 - 02-prd/ 21 個檔(PRD、features、market-analysis 等) - 03-design/ 18 個檔(design-spec、wireframes、flows 等) - 04-architecture/ 31 個檔(TDD、design-doc、ADR×14、API 規格等) - 07-delivery/ 3 個檔(project-summary、phase-0.6-handover、stage-deployment-setup) 合計 73 檔。原檔已從 .autoflow/ 移除(migration 工具執行 git mv, 但因 .autoflow/ 在 .gitignore 中、git 將此操作視為新增、無 rename history)。
233 lines
16 KiB
Markdown
233 lines
16 KiB
Markdown
# ADR-014:visionA 端轉檔功能架構(Phase 0.8)
|
||
|
||
## 狀態
|
||
Accepted — 2026-04-30
|
||
|
||
## 上位 / 同層 ADR
|
||
- 沿用:[ADR-006](./adr-006-no-redis-in-prototype.md)(in-memory state)、[ADR-010](./adr-010-oidc-bff.md)(OIDC BFF + confidential client)、[ADR-011](./adr-011-supersede-adr-005.md)(OIDC 取代 StaticAuth)、[ADR-013](./adr-013-public-client.md)(user OIDC client 為 public + PKCE-only;service client 仍為 confidential)
|
||
- 並存:本 ADR 規範「visionA-backend 同時當 multipart streaming proxy(upload)+ delegated download token broker(download)」
|
||
|
||
## 背景 (Context)
|
||
|
||
Phase 0.8 要把 kneron_model_converter(以下簡稱 converter)整合進 visionA Cloud。雙方為各自獨立部署的後端:
|
||
|
||
- **converter** 仍在公司內網 `192.168.0.130`:`POST /api/v1/jobs`(multipart, 500MB cap)/ `GET /api/v1/jobs/{id}` poll / `POST /api/v1/jobs/{id}/promote` 推 NEF 到 File Access Agent (FAA)
|
||
- **visionA-backend** 將部署到 AWS(stage 已上 `https://stage-9527.innovedus.com:9527/`)
|
||
- **FAA** 是 ASP.NET Core stateless 服務,存放 NEF;支援 `GET /files/{key}?access_token=<delegated>` browser 直連
|
||
- **Innovedus Member Center (MC)** 是 OAuth/OIDC IdP,同時負責簽 service-to-service token 與 delegated download token
|
||
|
||
整合上必須回答兩個問題:
|
||
|
||
1. **Upload(轉檔 input)** 怎麼進 converter?browser 直連 vs visionA backend 中轉?
|
||
2. **Download(轉檔結果)** 怎麼出 FAA?browser 直連 vs visionA backend 中轉?
|
||
|
||
並存的設計約束:
|
||
|
||
- visionA-backend 是 user 身份 / OIDC sub 注入 converter `user_id` 表單欄位的**唯一可信任點**(converter 完全信任 caller 帶來的 user_id,見 converter openapi.yaml `## user_id 與 trust boundary`)
|
||
- converter 一個 `user_id` 同時間只能有 1 個 active job(`409 user_has_active_job`)
|
||
- FAA delegated download token TTL 短(5–15 分鐘),可給 browser 直連
|
||
- Member Center service client(`23605e14a2c64660abd97e29963d8d58`)已配置,需 4 個 scope:`converter:job.write/read`、`files:download.read/delegate`
|
||
- `internal/config.OIDCConfig.ServiceClientID/Secret` 鉤子在 ADR-013 / Phase 0.7 已預埋但未啟用(A1 階段)
|
||
|
||
Phase 0.8 MVP 範圍:上傳 → 轉檔 → 半自動處理(user 完成後選「加到模型庫」or「下載」)。**Non-Goals**:歷史 / 取消 / SSE 進度推送 / 同 user 多個 active job / 多 chip 同時轉。
|
||
|
||
## 決策 (Decision)
|
||
|
||
採 **Upload 走 visionA backend streaming proxy + Download 走 FAA delegated token(browser 直連)** 的非對稱設計,並把 visionA-backend 同時當 **multipart streaming proxy** + **delegated download token broker**。
|
||
|
||
### 1. Upload — 一次性 → visionA backend 中轉
|
||
|
||
```
|
||
Browser ──multipart──► visionA backend ──multipart streaming──► converter
|
||
(io.Pipe + multipart.Reader/Writer)
|
||
```
|
||
|
||
- 每個檔案只上傳「一次」,跨 internet 一次成本可接受(500MB × 1 次 vs 500MB × N 次下載)
|
||
- 用 `io.Pipe` + goroutine:一邊讀 client、一邊寫 converter — **不暫存 disk、不 buffer 全 RAM**
|
||
- visionA-backend 在這條路徑做的事:
|
||
1. 從 cookie session 取 `user_id`(OIDC sub),灌進 converter request 的 `user_id` 表單欄位
|
||
2. 跟 MC 取 service token(scope `converter:job.write`),帶在 `Authorization: Bearer`
|
||
3. 透傳 model file + ref_images[] + 其他 form fields(target_chip / 各 enable_* flag)
|
||
4. converter response 整形後回 frontend(不直接洩 converter response shape)
|
||
- converter **零修改** — 沿用既有 `POST /api/v1/jobs` multipart endpoint
|
||
|
||
### 2. Download — 多次性 → FAA delegated token(server-side 302 redirect → browser 直連 FAA)
|
||
|
||
```
|
||
Browser ──GET /api/conversion/{job_id}/download──► visionA backend
|
||
↓
|
||
ownership 檢查
|
||
↓
|
||
MC POST /file-access/download-tokens
|
||
↓
|
||
Browser ◄─── HTTP 302 Found, Location: https://faa/files/{key}?access_token=<delegated>
|
||
↓
|
||
browser 自動 follow redirect
|
||
↓
|
||
Browser ──直連 FAA──► GET /files/{key}?access_token=<delegated>
|
||
```
|
||
|
||
- 同 NEF 可能被同一 user 多次下載到不同 device,N 次跨 internet 流量燒不起
|
||
- FAA 收到 token 後線上跟 MC validate(FAA 自己跟 MC 對打,visionA-backend 不參與)
|
||
- visionA-backend 在這條路徑做的事(單一 GET endpoint 內完成):
|
||
1. 既有 OIDC AuthMiddleware 驗 cookie session 拿 user_id
|
||
2. 確認該 user 對該 job 有權(從 visionA 內部記錄查 ownership,**禁止讓 client 直接傳 object_key**)
|
||
3. server-to-server 跟 MC 換 delegated token(scope `files:download.delegate`)
|
||
4. 組 download URL 後直接 `c.Redirect(http.StatusFound, downloadURL)` — 把 token 放在 Location header
|
||
- visionA-frontend 不需處理 token:用 `<a href="/api/conversion/{job_id}/download" download>下載</a>` 或 `window.location.href = '/api/conversion/{job_id}/download'` 即可,browser 自動 follow 302
|
||
- **Pattern 對齊**:仿 FAA TestSite `DownloadFileDirect` action(`FileAccessAgent.TestSite/Controllers/HomeController.cs:255-282`)— 也是 server 端組 URL 後 `return Redirect(directUrl)`,token 不過 frontend JS
|
||
|
||
**為什麼 302 redirect 比「frontend 拿 token + navigation」更安全**
|
||
|
||
| 面向 | 方案 X(frontend 拿 token JSON)| 方案 ✓(server 302 redirect)|
|
||
|------|-----|-----|
|
||
| Token 在 fetch response body | ✗ 在(JS 看得到、可能進 console.log / Sentry / 第三方分析)| ✓ 不在(沒有 JSON response)|
|
||
| Token 在 URL bar | ✗ 在(`window.location.href = url` 之後 URL bar 會短暫顯示)| △ 短暫(302 的 final URL 仍會出現,但 browser navigation 完成後通常立即被 FAA download 流程取代;且 navigation 期間 history entry 可被 `Cache-Control: no-store` + 短 TTL 緩解)|
|
||
| Token 在 localStorage / sessionStorage | △ 視 frontend 實作(容易誤存)| ✓ 結構性不可能(沒入口)|
|
||
| 受 frontend XSS 影響 | ✗ XSS 可竊取 token | ✓ XSS 看不到(302 在 fetch 場景會自動 follow,response body 為 FAA 內容;但 anchor / navigation 場景 JS 完全看不到)|
|
||
| 需要 FAA CORS 設定 | ✗ 需要(fetch / XHR 受 CORS 限制) | ✓ 不需要(CORS 只管 JS fetch / XHR;server-side 302 + browser navigation 走「navigation request」,完全不適用 CORS)|
|
||
| 跟 visionA OIDC cookie session 整合 | △ 額外 endpoint + JSON 流程 | ✓ 自然整合(GET endpoint 走既有 AuthMiddleware)|
|
||
| Frontend 程式碼複雜度 | 中(fetch → 取 url → navigation) | 低(一個 anchor tag / 一行 navigation)|
|
||
|
||
**Token 仍需 4 個 scope**:visionA-backend 為了跟 MC 換 delegated token,service token 仍需 `files:download.delegate` scope(沒變)。302 redirect 是「換到 token 後怎麼把它送進 browser」的差異,不影響 token issuance 路徑。
|
||
|
||
### 3. 半自動 — converter 完成後使用者選擇路徑
|
||
|
||
job `completed` 後 frontend 詢問 user:
|
||
|
||
| 動作 | 路徑 | 說明 |
|
||
|------|------|------|
|
||
| 「加到模型庫」 | visionA backend 跟 FAA pull NEF(server-to-server,scope `files:download.read`)→ 走既有 `/api/models/init` + `/api/models/finalize` 三段式 upload flow → 寫進 visionA `model.Model.Source="converted"` + `SourceJobID=<converter-job-id>` | 進 visionA storage 給後續 device load 用,這次走 backend 因為終點是 visionA storage |
|
||
| 「下載」 | 上述 §2 流程 | browser 直連 FAA |
|
||
|
||
兩者都先呼叫 converter `POST /api/v1/jobs/{id}/promote`,promote response 含 `target_object_key`。
|
||
|
||
### 4. 模組劃分 — 新增 `internal/conversion/`
|
||
|
||
不擴 `model.Model` schema(`Source` / `SourceJobID` 欄位 ADR-005 / database.md 已預埋)。新增獨立 package:
|
||
|
||
```
|
||
internal/conversion/
|
||
├── conversion.go # 對外 interface (Service)
|
||
├── converter_client.go # converter scheduler API client
|
||
├── faa_client.go # FAA API client(delegated token + server-to-server pull)
|
||
├── mc_token_client.go # MC client_credentials grant + token cache
|
||
└── flow.go # 整體 flow 協調(init / poll / promote / pull / persist)
|
||
```
|
||
|
||
`internal/conversion/` 依賴 `internal/model.Repository`(沿用既有 `/api/models/init+finalize` 邏輯,不繞過)。
|
||
|
||
### 5. Service token cache — 仿 converter scheduler 模式
|
||
|
||
- visionA backend 啟動時不主動取(lazy),第一次需要時才打 MC `POST {issuer}/oauth/token` (`grant_type=client_credentials`)
|
||
- token cache(記憶體 + `sync.RWMutex`),`exp - 15s` 重取
|
||
- token request 失敗:4xx 不重試(log + 5xx response 給 client);5xx 指數退避 max 2 次
|
||
- visionA-backend 預設 service-to-service token 共用(converter:job.write / read / files:download.read / delegate 同一 client + 同一個 cache)— MC 端發單一 token 含所有 4 個 scope
|
||
|
||
### 6. user_id 注入 + trust boundary
|
||
|
||
- **visionA backend 是唯一灌 user_id 的點**:從 cookie session 拿 OIDC sub → POST /jobs 時帶 user_id
|
||
- converter 信任 visionA backend 帶來的 user_id(converter 端的 trust boundary 設計詳見 converter openapi.yaml)
|
||
- visionA-backend 必須確保:
|
||
1. 任何呼叫 converter 的 endpoint 一律先過 OIDC AuthMiddleware(既有)
|
||
2. job_id → user_id 的 mapping 記在 visionA 內部(in-memory 或之後 DB),每次 status / promote / download token 操作前 ownership 檢查
|
||
3. **絕不接受 client 直接傳 user_id / object_key** — 一律從 session 反查
|
||
|
||
### 7. 失敗模式 retry 矩陣
|
||
|
||
| 操作 | 重試策略 | 失敗回 frontend |
|
||
|------|---------|---------------|
|
||
| `POST /api/v1/jobs`(init) | 4xx 不重試;5xx / network 退避 max 2 次 | 4xx 透傳 converter error code;5xx 一律 `502 converter_unavailable` |
|
||
| `GET /api/v1/jobs/{id}`(poll) | 5xx / network 退避 max 3 次;各次 2s 內 timeout | 持續失敗 → frontend 視為 stuck,提示重試 |
|
||
| `POST /promote` | 5xx / network 退避 max 2 次 | 失敗回 `502 promote_failed`,job 留在 completed 狀態,user 可重試 |
|
||
| FAA pull(加到模型庫)| 5xx / network 退避 max 2 次 | 失敗回 frontend `502 faa_unavailable`,model record 不寫入 |
|
||
| MC token endpoint | 4xx fatal;5xx 退避 max 2 次 | 失敗回 frontend `503 idp_unavailable` |
|
||
| MC delegated token | 4xx 透傳;5xx 退避 max 2 次 | 失敗回 frontend `502 download_token_failed` |
|
||
|
||
### 8. 同 user active job 衝突(409)
|
||
|
||
converter 回 `409 user_has_active_job` → visionA-backend 透傳 `409 active_job_exists` + 既有 job 詳情給 frontend,由 frontend 提示「你已有進行中的轉檔任務」。
|
||
|
||
## 考慮過的替代方案
|
||
|
||
### 方案 A:Upload 也走 browser 直連(converter 開放 CORS + 公網)
|
||
|
||
| 評估 | 內容 |
|
||
|------|------|
|
||
| 優點 | visionA-backend 不需處理 500MB streaming,省記憶體與頻寬 |
|
||
| 缺點 | (1) converter 必須開公網或開 CORS,安全表面變大;(2) user_id trust boundary 失守(browser 自己灌 user_id 等於沒驗);(3) converter 要新增 OIDC delegated upload token 機制(converter 團隊額外工作量) |
|
||
| 排除原因 | **user_id 信任邊界守不住**;converter 端要新增工作量。Upload 一次性,跨 internet 成本可接受 |
|
||
|
||
### 方案 B:Download 也走 visionA backend 中轉
|
||
|
||
| 評估 | 內容 |
|
||
|------|------|
|
||
| 優點 | visionA-backend 看得到所有下載流量、易做 audit |
|
||
| 缺點 | (1) 跨 internet 流量 N 倍(同 NEF 多次下載);(2) visionA-backend 變成 streaming bottleneck;(3) FAA delegated token 機制(已實作)白做 |
|
||
| 排除原因 | **流量成本**;FAA 已具備 delegated token,不用浪費 |
|
||
|
||
### 方案 C:Upload + Download 都走 backend 中轉(對稱設計)
|
||
|
||
| 評估 | 內容 |
|
||
|------|------|
|
||
| 排除原因 | 同方案 B 的流量成本問題 |
|
||
|
||
### 方案 D:擴 `model.Model` schema 加轉檔狀態
|
||
|
||
| 評估 | 內容 |
|
||
|------|------|
|
||
| 排除原因 | (1) 違反 SRP — model 應該只代表「已就緒可載入 device 的模型」;(2) job 狀態屬於 conversion 領域,不該污染 model 領域;(3) `model.Model.Source="converted" + SourceJobID` 已足夠表達來源關聯 |
|
||
|
||
## 後果 (Consequences)
|
||
|
||
### 正面影響
|
||
|
||
- **converter 零修改**:沿用既有 multipart endpoint
|
||
- **user_id 信任邊界乾淨**:visionA-backend 是唯一灌入點,從 OIDC cookie session 拿,不可被偽造
|
||
- **流量成本最佳**:upload 1× / download N× 的不對稱反映物流現實
|
||
- **Service token cache 可重用**:之後接 MC 其他 API(user 組織查詢 / push 通知)零成本擴展
|
||
- **不破壞既有 model store**:沿用 `/api/models/init+finalize`,conversion 只是「來源不同」
|
||
|
||
### 負面影響(接受的取捨)
|
||
|
||
- **visionA-backend 多一塊 streaming proxy 責任**:要寫好 `io.Pipe` + multipart streaming + context cancellation;錯誤處理複雜
|
||
- **跨網路依賴增加**:visionA-backend 失能 → 轉檔功能整個壞;MC 失能 → token 無法簽,轉檔不可用
|
||
- **MVP 不做進度推送**:user upload 完看 converter polling status,沒 SSE → UX 較粗(PRD Phase 0.8 接受)
|
||
- **Service token 集中失敗**:所有 4 個 scope 共用一個 cache,token 失效會同時影響轉檔與下載(MVP 階段可接受;後續可拆 cache)
|
||
- **取消 job 不做**:user 一旦 init 就要等到 converter 自己跑完或 timeout(converter 端 expires_at 7 天)
|
||
|
||
### 風險
|
||
|
||
| 風險 | 緩解 |
|
||
|------|------|
|
||
| visionA-backend 處理 500MB upload 時記憶體爆 | 嚴格 streaming(io.Pipe,不暫存);上線前壓測 1 個 + 2 個併發 upload;若有問題降到 200MB cap |
|
||
| Service token endpoint 被打爆(過度頻繁取 token)| token cache 確保 exp - 15s 內只取一次;log 記每次 cache miss |
|
||
| FAA CORS 還沒加 | **不再阻擋**:採用 server-side 302 redirect 後,browser navigation 不適用 CORS。Phase 1+ 若要改 fetch + Blob + a.click() 才需要 CORS(例如要顯示下載進度條) |
|
||
| MC `usage=webhook_outbound` 命名不對(同 ADR-010)| 不影響 visionA 程式碼;MC 改 `web_app` 後只需改 admin 註冊欄位 |
|
||
| converter 在 visionA 上 AWS 後不可達(網路) | Phase 0.8 範圍:visionA stage 仍可走 VPN 到 192.168.0.130;prod 上線前需 converter 也上 AWS 或開 VPN tunnel — 列入 prod 上線 blocker |
|
||
| 同 user 多 tab 各 init 一個 job → converter 409 | frontend 在 init 前先打 visionA backend 查當前 user 有無 active job;backend 直接拒絕第二次 init(不打到 converter)|
|
||
|
||
## 合規性
|
||
|
||
- [x] 與 PM Agent 確認:對齊 PRD Phase 0.8 範圍(半自動 / 模型 ≤500MB / ref_images ≤100×10MB / 同 user 1 active job / 不做歷史/取消/SSE/多 chip)
|
||
- [x] 與 Architect 確認:模組切分(`internal/conversion/`)、不擴 model schema、沿用 `/api/models/init+finalize`
|
||
- [x] 使用者裁決:upload 走 backend、download 走 delegated、半自動分流、不擴 schema
|
||
- [ ] DevOps 待確認:visionA stage → 192.168.0.130 的網路可達性(VPN / 直通)
|
||
- [x] FAA CORS:Phase 0.8 採 server-side 302 redirect,**不需要** CORS 設定(仿 FAA TestSite `DownloadFileDirect` pattern)
|
||
- [ ] MC 待確認:service client `23605e14a2c64660abd97e29963d8d58` 已授權 4 個 scope
|
||
|
||
## 相關文件
|
||
|
||
- 上位:`prd.md`(Phase 0.8 轉檔功能 PRD,PM 領地)
|
||
- 同層:`adr-006-no-redis-in-prototype.md`(in-memory token cache 沿用)、`adr-010-oidc-bff.md`(OIDC BFF)、`adr-011-supersede-adr-005.md`、`adr-013-public-client.md`(service client 仍為 confidential)
|
||
- 詳細實作:`conversion.md`(本 ADR 實作 spec)、`api/api-conversion.md`(對 frontend 的 API 規格)
|
||
- 安全:`security.md` §service-to-service token 流程(本次新增)
|
||
- 跨團隊整合:`/Users/jimchen/kneron_model_converter/docs/TODO-visionA-integration.md`
|
||
|
||
## 版本記錄
|
||
|
||
| 日期 | 版本 | 變更 |
|
||
|------|------|------|
|
||
| 2026-04-30 | 1.0 | 初版 — Phase 0.8 轉檔整合架構決策 |
|
||
| 2026-04-30 | 1.1 | Download flow 改為 server-side HTTP 302 redirect(仿 FAA TestSite `DownloadFileDirect`),token 不過 frontend JS、不需 FAA CORS |
|