# ADR-014:visionA 端轉檔功能架構(Phase 0.8) ## 狀態 Accepted — 2026-04-30 / **§2 download flow 部分 supersede — 2026-05-16(ADR-016)** > **2026-05-16 更新**:§2「Download — FAA delegated token(browser 直連 / v1.1 後 server-side proxy)」整段被 [ADR-016](./adr-016-download-via-converter.md) 部分 supersede。原因:對 MC source 完整驗證後發現「MC issue + validate delegated download token」endpoint **從未存在**——本 §2 從 2026-05-02 寫定起即為 broken design,只是因從未 e2e 跑通 visionA → FAA download 而未被發現。 > > **新設計**:visionA download 改走 converter 新增的 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉(jimchen 可單方控制兩端、不必動 MC / FAA / warrenchen)。詳見 ADR-016。 > > **本 ADR 仍有效的段落**:§1(upload streaming proxy)、§3(半自動分流的「加到模型庫」原則,但 server-side pull 的 FAA 部分改走 converter)、§4(模組劃分)、§5(service token cache 僅 converter 部分已被 ADR-015 §1 supersede;FAA 部分被 ADR-016 supersede)、§6(user_id trust boundary,**核心原則完全不變**)、§7(FAA / MC 相關 row 被 ADR-016 supersede;converter row 維持)、§8(active job 衝突處理不變)。 ## 上位 / 同層 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=` 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) > ⚠️ **2026-05-16:本節整段被 [ADR-016](./adr-016-download-via-converter.md) supersede**。 > > 致命發現(2026-05-16): > 1. MC source 沒有 `POST /file-access/download-tokens` endpoint(無法 issue delegated token) > 2. MC source 沒有 `IDelegatedDownloadTokenValidator` 對應的 introspection endpoint(即使有 token 也無法 validate) > 3. FAA `GET /files/{key}` 強制只接 delegated token、不接 service token(visionA 即使能拿 service token 也打不進去) > > 本節下方描述的「server-side 302 redirect」/「跟 MC 換 delegated token」/「FAA 跟 MC validate」整條鏈從 2026-05-02 起即為 broken design、從未 e2e 跑通。 > > **新設計(ADR-016)**:visionA download 改走 converter 新增的 `GET /api/v1/jobs/{id}/result` + visionA stream 中轉。不再有 visionA → MC、visionA → FAA 任何 server-to-server 路徑。 > > 本節以下內容**僅作歷史保留**、實作以 ADR-016 為準。 ``` 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= ↓ browser 自動 follow redirect ↓ Browser ──直連 FAA──► GET /files/{key}?access_token= ``` - 同 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:用 `下載` 或 `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=` | 進 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 | | 2026-05-16 | 1.2 | §2 download flow 整段標 supersede by [ADR-016](./adr-016-download-via-converter.md):致命發現 MC source 沒有 issue / validate delegated download token endpoint、§2 從 2026-05-02 起即為 broken design。新設計 visionA download 改走 converter `GET /api/v1/jobs/{id}/result` + visionA stream 中轉。其他段落(§1 upload streaming proxy / §3 半自動分流 / §4 模組劃分 / §6 user_id trust boundary / §8 active job 衝突處理)維持有效;§5 / §7 中 FAA / MC 相關部分連帶 supersede。 |