Compare commits
3 Commits
8c27da7cca
...
53e8ab4ae1
| Author | SHA1 | Date | |
|---|---|---|---|
| 53e8ab4ae1 | |||
| c63886a194 | |||
| 88a8ddbd82 |
@ -0,0 +1,491 @@
|
||||
# ADR-017: 模型庫存取架構(File Access Agent 重設計)
|
||||
|
||||
## 狀態
|
||||
Proposed(待使用者裁決)— **v1.2 修訂(2026-06-07):(a) 已用 stage 真實環境 + 真 secret + 真 user e2e 實測打通,跨團隊 blocking 歸零,剩餘全是 visionA 端純實作。** 保留 v1.0(推 (c))/ v1.1(改推 (a)、待跨團隊驗證)歷史。
|
||||
|
||||
## 日期
|
||||
2026-06-06(v1.0)/ 2026-06-06(v1.1 修訂)/ 2026-06-07(v1.2 修訂)
|
||||
|
||||
## 作者
|
||||
Architect Agent
|
||||
|
||||
---
|
||||
|
||||
## 0. v1.1 修訂註記(重要 — 推翻 v1.0 前提)
|
||||
|
||||
> **v1.0(同日稍早)推薦決策 1 = (c) visionA 自簽 token**,核心理由是「(a) 回到 MC token 那條鏈一年沒生出來、是 fictional(見 ADR-016 致命發現)」。
|
||||
>
|
||||
> **這個前提在 v1.1 被推翻。** 重新驗證 source 後發現:上週(ADR-016)判 (a) fictional 時**只 grep 了 MC master 分支**,沒看 develop。實際上:
|
||||
>
|
||||
> 1. **MC `develop` 分支已實作** `POST /file-access/download-tokens`(Issue)+ `POST /file-access/download-tokens/validate`(Validate)兩個 endpoint(commit `e77fdec`),master 尚未 merge。
|
||||
> 2. **FAA 端 `MemberCenterDelegatedDownloadTokenValidator.cs` 本來就是配套**:它打 MC validate 的 payload(`token/tenant_id/file_id/object_key/method`)與 response(`active/...`)形狀,**與 MC develop 的 validate endpoint 契約吻合**(見本 ADR §9 驗證證據)。
|
||||
> 3. 所以 ADR-014 那條 `visionA → MC → FAA` delegated token 鏈,在 **source level 是通的**,缺的只是「部署 + scope 註冊 + 跨 repo 設定」,不是「程式碼不存在」。
|
||||
>
|
||||
> **使用者原本就想 follow pptx 設計(走 MC token = (a))。** 既然 (a) 不再是「賭 MC 一年生不出來的 endpoint」、而是「已寫好、待部署」,本 ADR v1.1 **改推 (a)**,並把 (c) 降為「跨團隊部署若卡住的 fallback」。
|
||||
>
|
||||
> **但 v1.1 必須誠實標記**:source 就緒 ≠ 零風險。(a) 仍有**跨團隊部署依賴**(warrenchen 要 merge develop→master + 部署 MC stage + 設 FAA stage MemberCenterOptions + MC 端註冊 visionA service client 的 `files:download.read/delegate` scope)。這條鏈過去 5/9 就因「scope 沒在 MC 註冊」撞 `invalid_scope`——同類風險仍在。詳見 §4 決策 1(改寫)與 §9(可行性驗證)。
|
||||
>
|
||||
> **被 supersede 的內容**:v1.0 §4 決策 1 的「推薦 (c)」結論、決策 2 的「visionA 自簽 JWT」具體流程、§5 落地順序 P0、§6 Q1、§7 R1。原文保留於下方並標 ~~刪除線~~ / 修訂框,保留歷史脈絡。
|
||||
|
||||
---
|
||||
|
||||
## 0.1 v1.2 修訂註記(重要 — (a) 已 stage 真實環境 e2e 實證打通)
|
||||
|
||||
> **v1.1 時 (a) 還是「source 就緒、但跨團隊部署 + scope 註冊是未知風險」**(P0 標「warrenchen 主導、不可控」、Q1.5 標「待跟 warrenchen 確認」、R1 標「5/9 撞過 invalid_scope 的同類最高風險」)。
|
||||
>
|
||||
> **v1.2 的事實:MC stage 已部署、scope 機制已驗證、整條 (a) e2e 認證鏈用真 secret + 真 user_id 跑通了(HTTP 200 簽出 `fdt_` token、FAA 只因測試用假 object_key 回 404 file_not_found,即認證鏈全綠)。跨團隊 blocking 歸零,剩餘全是 visionA 端純實作。** 完整證據見**新增的 §10((a) 端到端 stage 實測證據)**。
|
||||
>
|
||||
> **v1.1 → v1.2 的核心轉變**:
|
||||
> 1. **MC 部署確認**:stage `POST /file-access/download-tokens` 與 `/validate` 回 401(非 404)→ endpoint **已部署**(推翻 v1.0/v1.1「只看 git master 判 fictional」,stage 實際版本已含 develop 功能)。
|
||||
> 2. **scope 機制已驗證**:MC 用 usage→scope 模型(`file_api` usage 自動帶 5 個 files scope),FAA 既有 service client `4242ba63...` 實測能拿 `files:download.delegate` token。R1 的「scope 沒在 MC 註冊」風險**已實證可繞過**(短期共用 FAA client、正式上線換 visionA 專屬)。
|
||||
> 3. **🏆 e2e 全綠**:使用者提供真實 OIDC user_id,整條 Issue→簽 fdt→FAA validate 鏈 stage 實測通過(B3 tenant 一致、B4 真實 user、FAA validate 鏈全部實證)。
|
||||
>
|
||||
> **被 v1.2 supersede 的內容**:v1.1 §4 決策 1 的「(a) 待跨團隊部署驗證」、§5 P0「跨團隊部署鏈(warrenchen 主導不可控)」、§6 Q1/Q1.5/Q9「待確認」狀態、§7 R1「最高風險」定性。原文保留並標 v1.2 更新框,保留歷史脈絡。
|
||||
>
|
||||
> **剩餘 visionA 端 blocking(v1.2 標清楚、為 backend agent 接下來的工作)**:B1 object_key 斷層(最關鍵,第一階段 (a) 只支援轉檔→promote 類 model)+ B2 visionA 加「打 MC /oauth/token + 打 MC Issue 簽 fdt」的 client code + .env 設定。詳見 §10.4。
|
||||
|
||||
---
|
||||
|
||||
## 1. 背景與範圍
|
||||
|
||||
visionA Cloud 的「模型庫」要處理的是**大檔案**(轉檔/訓練好的模型、APP 套件),這類資產的存取設計有兩個本質問題綁在一起:
|
||||
|
||||
1. **流量費用** — S3 容量便宜但流量貴,大檔反覆下載不該每次都經過 AWS egress。
|
||||
2. **權限保護** — Bucket 位址不能曝露;就算有人拿到連結也不能直接下載,每次存取都必須先做身份/權限查驗。
|
||||
|
||||
本 ADR 收斂三個面向,並把它們放進使用者目標架構(`模型庫存取架構.pptx`,引入 File Access Agent + NAS Bucket)的框架下一起決策,而不是各自打 patch:
|
||||
|
||||
- **B 模型存取權限/分享**:現況 owner-only → 目標 share/workspace/role。
|
||||
- **C 模型下載/檔案存取**:現況**沒有 model download endpoint**,要設計 pptx 的「Client 直連 FAA 下載」。
|
||||
- **D promote-to-models 修補**:stage 環境 promote 按鈕卡 OAuth 401,要收斂進新架構而非孤立修補。
|
||||
|
||||
### 1.1 pptx 目標 vs 現況的根本衝突(本 ADR 存在的理由)
|
||||
|
||||
pptx 的上傳/下載流程都假設一條 token 鏈:**「visionA 向 MC 取 Token → 帶 Token 給 FAA → FAA 向 MC 驗 Token」**。
|
||||
|
||||
~~但歷史盤點(見 §2)顯示:MC 沒有簽發 delegated download token 的 endpoint,也沒有 FAA 驗 token 所需的 introspection endpoint。ADR-014 §2 描述的這條鏈從 2026-05-02 起就是 fictional、從未 e2e 通過,上週 ADR-016 才改走「converter 中轉」繞開。~~
|
||||
|
||||
> **v1.1 修正**:上述「MC 沒有 endpoint」的判斷**只成立於 MC master 分支**。MC `develop` 分支(commit `e77fdec`)**已實作** Issue + Validate 兩個 endpoint,FAA 端 validator 本來就是配套設計。所以這條鏈在 source level 是通的、只差部署。詳見 §0 修訂註記與 §9 可行性驗證。**本 ADR 改在「使用者想 follow pptx(走 MC token)」+「MC develop 已就緒、待部署」的新事實下,決定推動 (a) 並列出跨團隊部署 checklist。**
|
||||
|
||||
---
|
||||
|
||||
## 2. 現況盤點
|
||||
|
||||
| 面向 | 現況事實 | Source |
|
||||
|------|---------|--------|
|
||||
| **A. FAA 端能力** | `GET /files/{key}`(下載)**沒掛 RequireAuthorization()**,改用 `IDelegatedDownloadTokenValidator.ValidateAsync()` 驗 delegated download token。`PUT`/`GET metadata`/`HEAD`/`DELETE` 走 JWT Bearer + `EnsureJwtScopeAndTenant`(scope: files:upload.write / files:metadata.read / files:delete)。即 FAA 是 **dual-auth**:download=delegated token、其他 op=service JWT。 | `file_access_agent/.../Program.cs:184-254` |
|
||||
| **A'. FAA download 原生支援** | FAA download 本來就是為「帶 delegated token 直接下載」設計 → pptx 的「Client 直連 FAA 下載」**FAA 端原生支援**,FAA 不是阻礙。 | 同上 |
|
||||
| **B-新事實. MC develop 已有 token 簽發** | MC `develop`(commit `e77fdec`)**已實作** `POST /file-access/download-tokens`(Issue, `[Authorize(Policy="FilesDownloadDelegate")]`,回 opaque `fdt_<base64url>` token + scope `files:download.read` + 自訂 TTL)。**master 尚未 merge**——上週只 grep master 才誤判不存在。 | MC develop `FileAccessController.cs`(Orchestrator 已驗)|
|
||||
| **B-新事實'. MC develop 已有 token 驗證** | MC develop **已實作** `POST /file-access/download-tokens/validate`(Validate, `[Authorize(Policy="FilesDownloadRead")]`,驗 TokenHash + RevokedAt + ExpiresAt + boundary(tenant/file/object_key/method),回 `{active, scope, expires_at}`)。FAA validator 的 payload/response 形狀**與此契約吻合**。 | MC develop / FAA validator(本 ADR §9)|
|
||||
| **B-結論修正. token 鏈 source 已就緒、待部署** | ADR-014 §2 的「visionA→MC→FAA delegated token 鏈」在 **source level 是通的**(MC develop + FAA validator 配套)。缺的是:MC merge develop→master + 部署 stage + 註冊 visionA service client 的 download scope + FAA stage `MemberCenterOptions` 設定。**不再是 fictional,而是「跨團隊部署待辦」。** | MC develop / FAA / ADR-014 |
|
||||
| **C. promote(converter→FAA)prod work** | converter promote → FAA PUT NEF 走 **OAuth client_credentials + scope `files:upload.write`**,**prod 已上線可用**。 | `apps/task-scheduler/.../fileAccessAgent/client.js` |
|
||||
| **C'. promote stage 卡 401** | stage 上 promote 按鈕卡 OAuth 401 → 研判是 stage converter 的 OAuth client 設定 / FAA accepted scope 問題(**設定問題,非設計問題**)。 | progress.md |
|
||||
| **C''. NEF 雙存** | converter MinIO 存 NEF,promote 後 NEF 同時在 converter MinIO(expires_at=7天)+ FAA。 | ADR-016 |
|
||||
| **D. visionA download 現況** | visionA → converter(init/poll/promote/result download)**全走 API key**(ADR-015 §1)。visionA backend 有 stream proxy 結構(`flow.go` DownloadStream:io.CopyN size cap + Content-Disposition + context cancellation),現況 stream 來源是 converter `/result`。 | ADR-015 / `internal/conversion/flow.go` |
|
||||
| **D'. 無 model download endpoint** | `internal/api/models.go`:/api/models/* 有 list/get/init/finalize/delete;**load-to-device 是 stub;無 model download endpoint**。 | `internal/api/models.go` |
|
||||
| **E. 資料模型現況** | DB `models` table 是 `owner_user_id UUID NOT NULL`(單一擁有者)+ `CREATE INDEX ON models (owner_user_id) WHERE deleted_at IS NULL`。`model.go` Model struct 只有 OwnerUserID,Repository.List 用 OwnerUserID 過濾,**無 share/workspace/role/ACL**。 | `internal/model/model.go` / `database.md:344-363` |
|
||||
|
||||
**現況一句話總結**:FAA 設計上 ready(download 收 delegated token、op 收 service JWT),converter→FAA 上傳 prod work;但「誰簽發/驗證 download token」這一環,MC 從未提供,visionA 也還沒自建,所以模型下載至今走不通、model download endpoint 根本還沒長出來。
|
||||
|
||||
---
|
||||
|
||||
## 3. 目標架構(pptx)解讀
|
||||
|
||||
### 3.1 核心設計
|
||||
|
||||
引入 **File Access Agent (FAA) + NAS Bucket(搭配 AWS S3)**。大檔存取統一收口到 FAA:
|
||||
|
||||
- Bucket 位址不曝露(Client 只跟 FAA 互動,不知道也碰不到底層 Bucket)。
|
||||
- 每次存取都過 FAA 的權限查驗。
|
||||
- 未來可在 FAA 層加密。
|
||||
- **省流量**:除了「上傳那一次」大檔流量不經 AWS;下載走 FAA(NAS/公司網路),不吃 AWS egress。
|
||||
- 轉檔/訓練好的模型可以**只傳網址**讓 FAA 自己去抓(converter→FAA 已經是這個模式的雛形)。
|
||||
|
||||
### 3.2 pptx 上傳流程
|
||||
|
||||
1. POST visionA endpoint
|
||||
2. visionA 向 MC 取 Token
|
||||
3. 檔案(連結) + Token 送 FAA
|
||||
4. FAA 向 MC 驗 Token
|
||||
5. FAA 回檔案 path 給 visionA
|
||||
|
||||
### 3.3 pptx 下載流程
|
||||
|
||||
1. visionA 驗存取權限
|
||||
2. visionA 向 MC 取 Token
|
||||
3. **Client 帶 Token 直接向 FAA 發 GET**
|
||||
4. FAA 向 MC 驗 Token
|
||||
5. Response 檔案到 Client(**不經 visionA、不經 AWS**)
|
||||
|
||||
### 3.4 pptx 灰色地帶(必須在 §4 解掉)
|
||||
|
||||
| # | 灰色地帶 | 問題 |
|
||||
|---|---------|------|
|
||||
| G1 | **「visionA 向 MC 取 Token」** | ~~MC 沒有簽發 download token 的 endpoint~~ **v1.1:MC develop 已有 Issue endpoint(事實 B-新事實)**。可做到,但 visionA 需「復活 MC service token client」+ MC 端註冊 download scope(見 §9 缺口)。 |
|
||||
| G2 | **「FAA 向 MC 驗 Token」** | ~~MC 沒有 introspection endpoint~~ **v1.1:MC develop 已有 Validate endpoint、FAA validator 本來就是配套(事實 B-新事實')**。可做到,但需 FAA stage 設好 `MemberCenterOptions`(見 §9 缺口)。 |
|
||||
| G3 | **Client 直連 FAA 的憑證怎麼到 Client 手上** | pptx 說「Client 帶 Token」,但沒講 token 範圍(單檔/全庫)、有效期、防盜用(被截走能不能重放)。 |
|
||||
| G4 | **Bucket 不曝露但 FAA 對外** | FAA 要對 Client(含桌面 local-tool / 瀏覽器)開放,CORS、FAA 對外網段、HA 都得想。 |
|
||||
| G5 | **pptx 自列待解** | ①HA(NAS/公司網路掛掉就全掛)②頻寬(下載走公司網路,先 router 限流,未來移轉便宜雲端空間)。 |
|
||||
|
||||
**G1 + G2 是本 ADR 決策 1/決策 2 的核心**:使用者已拍板「Client 直連 FAA」+「要驗權限」,但簽發/驗證 token 的 MC 能力不存在。所以必須決定 **delegated token 由誰簽、由誰驗**。
|
||||
|
||||
---
|
||||
|
||||
## 4. 關鍵決策點
|
||||
|
||||
### 決策 1:認證方向(download token 誰簽發、誰驗證)— **v1.2 改寫((a) 已 stage e2e 實證)**
|
||||
|
||||
> **v1.2 重大更新**:(a) 已用 stage 真實環境 + 真 secret + 真 user_id **e2e 實測打通**(HTTP 200 簽出 `fdt_` token、FAA validate 鏈全綠,僅因測試用假 object_key 回 404 file_not_found;見新增 §10)。**(a) 從 v1.1 的「已寫好、待跨團隊部署驗證」升級為「已實證可行、跨團隊 blocking 歸零」。** 剩餘全是 visionA 端純實作(object_key 斷層 + 打 MC Issue 的 client code,見 §10.4)。**(c) fallback 實測後已無需動用**——除非未來改用正式專屬 file_api client 時受阻,否則 (a) 直接落地。
|
||||
>
|
||||
> **v1.1 原文(保留供對照)**:v1.0 在「(a) 是 fictional」的錯誤前提下推 (c)。新事實(MC develop 已實作 Issue+Validate、FAA validator 配套,見 §0/§9)下,(a) 從「賭一年沒生出來的 endpoint」變成「已寫好、待跨團隊部署」。**使用者想 follow pptx(= (a))**,故 v1.1 改推 (a),(c) 降為 fallback。
|
||||
|
||||
pptx 預設 MC 簽發 + MC 驗證。以下三案(v1.1 重新評估):
|
||||
|
||||
| 維度 | (a) MC delegated token(pptx 原圖)★v1.1 改推 | (b) 延續 API key / pre-shared secret | (c) visionA 簽短期 token + FAA 本地驗(v1.0 原推,v1.1 降 fallback) |
|
||||
|------|---------------------------------------|------------------------------------|--------------------------------------------------------|
|
||||
| **做法** | visionA 打 MC `POST /file-access/download-tokens` 拿 opaque `fdt_` token → Client 帶 token 打 FAA `GET /files/{key}` → FAA `IDelegatedDownloadTokenValidator` 打 MC `validate` 驗 | Client 帶固定 pre-shared secret 直連 FAA;FAA 比對 secret | visionA 自簽短期 JWT(含 file key + exp + 權限 claim),FAA 用 visionA JWKS 本地驗簽 |
|
||||
| **source 現況(v1.1 新增)** | **MC develop + FAA validator 都已寫好**(commit `e77fdec`),缺部署/scope 註冊/FAA stage 設定 | 無現成 | **FAA validator 目前 impl 是「打 MC validate」、不是「本地驗 visionA JWKS」** — 要 (c) 反而得叫 warrenchen **改寫 FAA validator**(比 (a) 重!)|
|
||||
| **與 pptx 相容** | 完全相容(就是 pptx 原圖、使用者想要的) | 半相容 | 相容(token 由 visionA 取代 MC 角色) |
|
||||
| **對 MC / FAA team 依賴** | **中** — 不需「新寫」endpoint(已寫好),但需 warrenchen merge develop→master + 部署 MC stage + 設 FAA stage `MemberCenterOptions` + MC 註冊 visionA service client 的 download scope | 無 | **中-高(v1.1 重估)** — FAA 現成 validator 是打 MC 的;改成本地驗 visionA JWKS 要 warrenchen **改 validator impl**,這也是動 FAA、不比 (a) 輕 |
|
||||
| **與現況落差** | 中 — visionA 端要**復活 MC service token client**(ADR-016 剛砍掉 mc_token_client.go,要逆轉)+ 新增「打 MC Issue」邏輯 | 中 | 中 — visionA 要建簽發+JWKS;FAA 要改 validator |
|
||||
| **防盜用(被截走)** | 佳 — MC 簽 opaque token + 短 TTL(可自訂 `expires_in_seconds`)+ boundary(tenant/file/object_key/method) + 可 revoke(RevokedAt)| **差** — secret 外洩=全庫淪陷 | 佳 — 短 exp + 綁 file key + 綁 user |
|
||||
| **HA / 延遲** | 每次下載 FAA→MC 同步 validate,多一跳 + MC 成驗證單點(但 FAA validator 有 service token cache,token validate 是輕量呼叫)| 無外部依賴 | 無外部依賴,FAA 本地驗簽,延遲最低 |
|
||||
| **成本/工時** | 中 — visionA 復活 MC client(逆轉 ADR-016)+ 跨團隊部署協調(工期受 warrenchen 排程影響) | 低(但留安全債) | 中 — visionA 簽發+JWKS + **FAA validator 改寫(跨團隊)** |
|
||||
| **opaque token 限制(v1.1 新增)** | token 是 `fdt_<random>` 不是 JWT → FAA **無法本地驗簽**,每次必 call MC validate(這是 MC 設計,符合 FAA 現成 validator)| — | JWT 可本地驗(但 FAA 現成 validator 不是這樣做的)|
|
||||
|
||||
**v1.2 推薦:(a) MC delegated token(pptx 原圖)— 已 stage e2e 實證可行。** (c) 仍保留為 **fallback**,但實測後已無需動用(除非未來改正式專屬 client 受阻)。
|
||||
|
||||
**改推 (a) 的理由**:
|
||||
1. **(a) 不再是 fictional** — MC develop + FAA validator 是配套的、已寫好(§9 驗證)。v1.0 推 (c) 的唯一核心理由(「MC 那條鏈一年沒生出來」)已不成立。
|
||||
2. **(c) 在新事實下反而更重** — FAA 現成的 `MemberCenterDelegatedDownloadTokenValidator` 是「打 MC validate」的 impl。要走 (c) 必須請 warrenchen **改寫 validator 成本地驗 visionA JWKS**——這同樣是動 FAA、且是「改既有正確配套」,比「部署既有 (a)」更不划算。
|
||||
3. **符合使用者意圖** — 使用者原本就想 follow pptx 設計(走 MC token)。(a) = pptx 原圖。
|
||||
4. **FAA / MC 端零程式碼改動** — (a) 在 source level 已就緒,FAA / MC 不需寫新 code,只需「部署 + 設定 + scope 註冊」。
|
||||
5. **token 安全性最強** — MC 簽 opaque token,支援 boundary 檢查 + revoke + 自訂 TTL,比 visionA 自簽 JWT(無法 revoke)更完整。
|
||||
|
||||
> **(a) 的關鍵前置(跨團隊部署 checklist,非程式碼)**:
|
||||
> 1. **[MC / warrenchen]** merge `develop`(含 `e77fdec`+`5f32452`)→ `master` + 部署 MC stage。
|
||||
> 2. **[MC / warrenchen]** 在 MC 端把 visionA service client(`23605e14...`)的 `files:download.read` + `files:download.delegate` scope **註冊到 AuthResourceRegistry**(這是 5/9 撞 `invalid_scope` 的同類風險點——ADR-014 line 35 雖列了 4 scope,但「列在 visionA 文件」≠「在 MC 端註冊」)。
|
||||
> 3. **[FAA / warrenchen]** FAA stage 部署 + 設好 `MemberCenterOptions`(`BaseUrl` / `ClientId` / `ClientSecret` / `Scope` / `DownloadTokenValidationPath` 指向 MC develop 的 `/file-access/download-tokens/validate`)。
|
||||
> 4. **[visionA / jimchen]** **復活 MC service token client**(逆轉 ADR-016 §5 砍除的 mc_token_client.go)+ 新增「打 MC Issue endpoint 拿 fdt_ token」邏輯。
|
||||
> 5. **[三方]** 對齊 boundary:visionA Issue 時帶的 `object_key` 必須 = FAA `GET /files/{key}` 的 key = MC validate 的 `object_key`(見決策 2 與 §9 的 object_key 斷層分析)。
|
||||
>
|
||||
> **✅ v1.2 更新:此前置清單 step 1–3、5 已 stage 實測驗證完成。** MC stage 已部署(step 1,§10.1);scope 機制已驗證——FAA service client `4242ba63...` 實測能拿 `files:download.delegate` token、boundary(tenant/object_key/method)e2e 通過(step 2/5,§10.2–§10.3);FAA stage 已部署且 `MemberCenterOptions` 已設好(step 3,§10.3 FAA validate 鏈全綠)。**原「step 2 是最高風險、5/9 撞 invalid_scope」已實證可繞過**(短期共用 FAA client 拿 token,正式上線換 visionA 專屬 usage=file_api client,見 §7 R1 改寫)。**剩 step 4(visionA 復活 MC client + 打 Issue)= 唯一剩餘工作、純 visionA 端、自己可控。**
|
||||
|
||||
> **~~何時 fallback 回 (c)~~(v1.2:實測後已無需動用)**:v1.1 設想「若跨團隊部署卡住則 fallback (c)」。v1.2 跨團隊部署已 e2e 通、無卡點,**(c) 不再需要動用**。僅保留為「未來 visionA 改用正式專屬 file_api client、若 MC 端無法配發專屬 client 時」的理論退路。**(b) pre-shared secret 仍不採用**。
|
||||
|
||||
#### v1.0 原推薦((c))保留供對照
|
||||
|
||||
> ~~**推薦:(c) visionA 簽短期 download token + FAA 本地驗。** 理由:(1) 解除對 MC 的歷史依賴——MC 那兩個 endpoint 一年沒生出來;(2) FAA 本來就是 dual-auth、有 validator 抽象,換 impl 即可;(3) 滿足 pptx 兩大訴求;(4) 未來不排斥 (a)。~~
|
||||
>
|
||||
> v1.1 supersede 原因:理由 (1) 的前提「MC endpoint 一年沒生出來」是錯的(只看了 master);理由 (2) 「FAA 換 impl 即可」低估了——FAA 現成 validator 正是 (a) 要的、(c) 才需要改寫它。
|
||||
|
||||
---
|
||||
|
||||
### 決策 2:Client↔FAA 憑證傳遞與 Bucket 保護 — **v1.2 改寫(流程已 stage 實測確認)**
|
||||
|
||||
> **✅ v1.2 實測確認**:以下流程已用 stage 真實環境 + 真 secret + 真 OIDC user_id 跑通(見 §10.3 e2e)。token 確認為 MC 簽的 opaque `fdt_<base64url>`(120s、scope `files:download.read`、token_type `file_download`)。**FAA download endpoint 確切用法**(給 backend 實作):`GET {FAA}/files/{objectKey}`,token 放 **`Authorization: Bearer {fdt_token}`**(FAA `TryReadAccessToken` 只認 Authorization Bearer,**不認 query / 自訂 header**)。`objectKey` 必須與簽 token 時的 `object_key` **完全一致**(FAA validate boundary 檢查、不一致回 `object_key_mismatch`)。
|
||||
|
||||
採用決策 1**(a)** 後,下載流程具體化(token 由 **MC** 簽,不是 visionA 自簽):
|
||||
|
||||
1. Client(local-tool / browser)向 **visionA** 請求下載某 model(帶 visionA 使用者 session)。
|
||||
2. visionA **先驗存取權限**(owner / share / workspace / role,見決策 3)。
|
||||
3. 通過 → visionA **用 MC service token 打 MC `POST /file-access/download-tokens`**(Issue),帶 `tenant_id` / `user_id` / `file_id` / `object_key` / `method=GET` / `expires_in_seconds`,MC 回 opaque `fdt_<base64url>` token。
|
||||
4. visionA 回給 Client「FAA 下載 URL + `fdt_` token」。
|
||||
5. Client 帶 token 直接 `GET FAA /files/{object_key}`(**`Authorization: Bearer fdt_...`**——v1.2 實測:FAA 只認 Authorization Bearer、不認 `?access_token=` query;不經 visionA、不經 AWS)。
|
||||
6. FAA `MemberCenterDelegatedDownloadTokenValidator` **打 MC `POST /file-access/download-tokens/validate`**(帶 FAA 自己的 MC service token、`instanceOptions.TenantId` + 從 URL path 取的 `ObjectKey` + `Method=GET`)驗 token + boundary(tenant/object_key/method)→ MC 回 `{active:true}` → FAA 回檔。
|
||||
|
||||
> 注意:`fdt_` 是 **opaque random token、不是 JWT** → FAA **無法本地驗簽**,每次下載一定要 call MC validate(這是 MC 刻意設計:可即時 revoke)。延遲多一跳 FAA→MC,但 FAA validator 有 service token cache(見 FAA validator source line 74-129),validate 本身是輕量 POST。
|
||||
|
||||
**防盜用 / 不曝露設計**:
|
||||
|
||||
| 風險 | 對策 |
|
||||
|------|------|
|
||||
| token 被截走重放 | 短 TTL(visionA Issue 時帶 `expires_in_seconds`,建議 60–300s,待裁決 Q2);MC 端 boundary 綁 `object_key` + `method`(截走也只能下載那一個檔、那個 method);MC 端可 `RevokedAt` 即時撤銷 |
|
||||
| Bucket 位址曝露 | Client 只拿到 FAA URL(`/files/{object_key}`),object_key 是 FAA 內部 storage key、不是 S3/NAS 真實路徑 |
|
||||
| CORS(browser 直連 FAA) | FAA 需設允許 visionA 前端 origin 的 CORS(待裁決 Q3:是否需瀏覽器直連;只 local-tool 直連無 CORS 問題)|
|
||||
| FAA 對外網段 / HA | 見決策 5 |
|
||||
| token 簽發濫用 | visionA 端對「打 MC Issue」做 rate limit + audit log(誰、何時、要下載哪個 model)|
|
||||
|
||||
#### ⚠️ object_key 三方對映斷層(v1.1 新增 — (a) 最大實作風險)
|
||||
|
||||
(a) 要求 visionA Issue 時帶的 `object_key` = FAA `GET /files/{key}` 的 key = MC validate 的 `object_key`,三方必須一致。但驗證現有 source 發現**斷層**:
|
||||
|
||||
| key 空間 | 值 | source |
|
||||
|----------|-----|--------|
|
||||
| **visionA model storage key** | `models/{userID}/{modelID}.nef` | `internal/api/models.go:235`(visionA 自己的 S3/LocalFS key)|
|
||||
| **converter promote → FAA 的 object_key** | 由 `flow.go` 按命名規則組的 `TargetObjectKey`(FAA 內部 key) | `converter_client.go` PromoteReq.TargetObjectKey |
|
||||
| **FAA 實際存放的 object_key** | converter PUT 進去的 key | FAA `PUT /files/{objectKey}` |
|
||||
|
||||
**斷層本質**:
|
||||
- visionA model store 的檔案是走 `/api/models/init+finalize` 上傳到 **visionA 自己的 storage**(`models/{userID}/{modelID}.nef`),**從未經過 FAA**。
|
||||
- 只有「轉檔結果 NEF」會經 converter promote 進 FAA(用另一套 `TargetObjectKey`)。
|
||||
- **所以「model download (a) 直連 FAA」的前提——model 必須在 FAA 上、且 visionA 知道它的 FAA object_key——目前不成立**:
|
||||
- 上傳類 model(SourceUploaded)根本不在 FAA 上,無法走 (a) 直連 FAA。
|
||||
- 轉檔類 model(SourceConverted)若有 promote 進 FAA,visionA 需**記錄該 model 的 FAA object_key**(目前 `model.Model` struct 沒有 `faa_object_key` 欄位,只有 visionA 自己的 `StorageKey`)。
|
||||
|
||||
**(a) 要可行,必須補的 visionA 端工作(決策方向,細節由 backend agent 落地)**:
|
||||
1. `model.Model` 加 `FAAObjectKey`(nullable)欄位,記錄 model 在 FAA 上的 key(promote 時寫入)。
|
||||
2. 釐清「哪些 model 在 FAA 上」:只有經 converter promote 的才有 FAAObjectKey;純上傳的 model 要 (a) 直連 FAA 需先有「把上傳檔也 PUT 進 FAA」的路徑(目前沒有)。
|
||||
3. download flow 用 `FAAObjectKey`(不是 `StorageKey`)去 Issue MC token + 組 FAA URL。
|
||||
|
||||
> **這個斷層是 (a) 比 (c) 多出的實作成本**,且影響「上傳類 model 能不能走 (a)」——須在 Q1 裁決時一併考慮(見待裁決 Q7)。
|
||||
|
||||
#### v1.0 原決策 2(visionA 自簽 JWT)保留供對照
|
||||
|
||||
> ~~採用決策 1(c):visionA 簽發短期 JWT(claim: file_key/sub/exp/scope)、FAA 用 visionA JWKS 本地驗簽。~~ v1.1 改為 MC 簽 opaque `fdt_` token + FAA 打 MC validate。
|
||||
|
||||
---
|
||||
|
||||
### 決策 3:B — 權限/分享資料模型(owner-only → share/workspace/role)
|
||||
|
||||
現況 `models.owner_user_id NOT NULL` 單一擁有者(事實 E)。最小可行變更,**不一次做到完整 RBAC**,只加「能分享 + 能用 workspace 群組 + 三種 role」:
|
||||
|
||||
**方案比較(最小可行 vs 完整):**
|
||||
|
||||
| 維度 | (B1) 只加 model_shares(per-model 直接分享)★第一階段推薦 | (B2) 加 workspaces + memberships + model 歸屬 workspace | (B3) 完整 ABAC/RBAC policy engine |
|
||||
|------|--------------------------------------------------|----------------------------------------------|--------------------------------|
|
||||
| 變更面 | 加 1 張 `model_shares(model_id, grantee_user_id, role, ...)` | 加 `workspaces` / `workspace_members` / `models.workspace_id` | 引入 policy 表 + evaluator |
|
||||
| 滿足 pptx | 「分享給特定人」✓ | 「團隊/組織共用」✓ | 過度設計 |
|
||||
| 工時 | 小 | 中 | 大 |
|
||||
| 與現況落差 | 小(owner_user_id 保留,share 疊加) | 中 | 大 |
|
||||
|
||||
**推薦:分兩步 — 先 (B1),後 (B2);(B3) 不做。**
|
||||
|
||||
最小資料模型(對齊 `database.md §2.3` + `models` DDL,`owner_user_id` 保留不動):
|
||||
|
||||
```
|
||||
-- 第一階段 (B1):per-model 分享
|
||||
CREATE TABLE model_shares (
|
||||
model_id UUID NOT NULL REFERENCES models(id),
|
||||
grantee_user_id UUID NOT NULL REFERENCES users(id),
|
||||
role TEXT NOT NULL, -- 'viewer' | 'editor'(owner 仍記在 models.owner_user_id)
|
||||
granted_by UUID NOT NULL REFERENCES users(id),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
PRIMARY KEY (model_id, grantee_user_id)
|
||||
);
|
||||
CREATE INDEX ON model_shares (grantee_user_id);
|
||||
|
||||
-- 第二階段 (B2):workspace 群組共用(待第一階段穩定後)
|
||||
-- workspaces(id, owner_user_id, name, ...)
|
||||
-- workspace_members(workspace_id, user_id, role)
|
||||
-- models 加 nullable workspace_id(NULL=個人資產,非 NULL=workspace 資產)
|
||||
```
|
||||
|
||||
**role 語意(三種,最小集)**:
|
||||
- `owner`:`models.owner_user_id`,可刪除/改權限/分享。
|
||||
- `editor`:可更新 model metadata、可 promote/覆蓋。
|
||||
- `viewer`:只能 list/get/download。
|
||||
|
||||
**權限查驗點**:決策 2 第 2 步「visionA 先驗存取權限」就是查 `owner_user_id == user OR model_shares 命中 OR(B2 後)workspace_members 命中`,通過才簽 download token。`Repository.List` 要從「只用 owner_user_id 過濾」改成「owner ∪ shared ∪ workspace」。
|
||||
|
||||
> 此為**決策方向**,schema 細節由後續實作(backend agent)落地 migration,本 ADR 只定模型形狀。
|
||||
|
||||
---
|
||||
|
||||
### 決策 4:D — promote 收斂(stage OAuth 401)
|
||||
|
||||
事實 C/C':promote(converter→FAA PUT,OAuth client_credentials + scope `files:upload.write`)**prod 已 work**,只有 **stage 卡 401**,研判是**設定問題(stage OAuth client 設定 / FAA accepted scope),非設計問題**。
|
||||
|
||||
| 方案 | 說明 | 評估 |
|
||||
|------|------|------|
|
||||
| (D1) 修 stage OAuth 設定 ★推薦 | 對齊 stage converter 的 OAuth client_id/secret/scope 與 FAA 在 stage 接受的 scope(`files:upload.write`),讓 stage 跟 prod 一致 | promote 設計本身是對的(prod 已證明),不該為了 stage 設定問題去改架構 |
|
||||
| (D2) 改走新存取路徑 | 把 promote 也改成決策 1(c) 的 token 模式 | **不推薦** — promote 是 server-to-server(converter→FAA),用 service JWT/OAuth 才對;decision 1(c) 的短期 download token 是給 Client 下載用,兩者用途不同,不該混 |
|
||||
|
||||
**推薦:(D1) 修 stage 設定,不動 promote 架構。** promote(上傳/server-to-server)與 download(Client 直連)是兩條不同的 auth 路徑,**正好對應 FAA 的 dual-auth 設計**(op=service JWT、download=delegated token),不要強行統一。
|
||||
|
||||
> promote 走 OAuth service token、download 走 visionA 簽的 delegated token — 這個分離是刻意的,符合事實 A 的 FAA dual-auth。
|
||||
|
||||
---
|
||||
|
||||
### 決策 4.5:與 ADR-016 converter 中轉的關係(v1.1 新增 — 並存,非取代)
|
||||
|
||||
ADR-016 讓 **轉檔結果 NEF download** 走 `converter GET /api/v1/jobs/{id}/result` + visionA stream 中轉(visionA 不直接打 FAA)。本 ADR (a) 是 **model library download** 走 Client 直連 FAA。兩者**是兩條不同用途的路徑、並存**:
|
||||
|
||||
| 路徑 | 用途 | 觸發點 | 來源 | 認證 |
|
||||
|------|------|--------|------|------|
|
||||
| **ADR-016 converter 中轉** | 下載**轉檔結果 NEF**(job-scoped、剛轉完還沒進模型庫)| `GET /api/conversion/{job_id}/download` | converter MinIO(7 天 expires_at)| visionA→converter API key |
|
||||
| **ADR-017 (a) 直連 FAA** | 下載**模型庫 model**(持久資產、已在模型庫)| model download endpoint(待建)| FAA(持久)| MC `fdt_` token |
|
||||
|
||||
**為什麼並存而非取代**:
|
||||
- ADR-016 的來源是 **converter MinIO(7 天就 GC)**——只適合「剛轉完、demand-trigger download」的短期場景,**不適合模型庫**(模型庫是持久資產,7 天後 converter MinIO 沒了)。
|
||||
- ADR-017 (a) 的來源是 **FAA(持久)**——適合模型庫長期下載。
|
||||
- 兩條路徑的「檔案在哪」不同(converter MinIO vs FAA)、生命週期不同(7 天 vs 持久),故並存。
|
||||
|
||||
**但有交集需釐清(待裁決 Q8)**:
|
||||
- 「轉檔結果」可以「加到模型庫」(promote 進 FAA)。一旦加到模型庫,該 model 的 download 應走 (a)(FAA 持久),而非 ADR-016(converter MinIO 會過期)。
|
||||
- **建議分流**:model 在模型庫內(有 FAAObjectKey)→ 走 (a);job 結果還沒進模型庫 → 走 ADR-016。兩者不互相取代,依「是否已是模型庫資產」分流。
|
||||
- ADR-016 的 converter 中轉**保留不動**(轉檔結果 demand download 仍需要它);(a) 是**新增**模型庫的持久 download 路徑。
|
||||
|
||||
### 決策 5:HA / 頻寬(pptx 自列待解 G5)
|
||||
|
||||
| 問題 | 短期(本架構落地時) | 長期(標 Phase 後續,本 ADR 不深入) |
|
||||
|------|---------------------|----------------------------------|
|
||||
| HA(NAS / 公司網路掛掉全掛) | 接受單點風險 + 監控告警(FAA health check);關鍵 model 可在 converter MinIO 保留副本(事實 C'' NEF 雙存已是雛形)作為降級來源 | 移轉便宜雲端空間做 FAA 後端 / 多節點 FAA |
|
||||
| 頻寬(下載走公司網路) | router 限流(pptx 已提);visionA 端對 download token 簽發做 rate limit | 移轉便宜雲端空間,下載改走雲端 egress(屆時重評「省流量」前提) |
|
||||
|
||||
**標記為後續 Phase**,本 ADR 先讓核心存取路徑(決策 1–4)跑通;HA/頻寬不阻擋第一階段,但須在 progress 留風險登記。
|
||||
|
||||
---
|
||||
|
||||
## 5. 建議落地順序
|
||||
|
||||
依賴關係:~~決策 1(a) 的跨團隊部署是地基~~(**v1.2:跨團隊部署已 stage e2e 實測完成、不再是地基/不確定性**),C(下載)依賴 visionA 端實作;B(權限)是下載前的查驗點;D 與 B/C 無依賴可平行。
|
||||
|
||||
> **v1.2 改寫 P0**:v1.1 的 P0 是「MC merge+部署 + scope 註冊 + FAA stage 設定」這條跨團隊部署鏈、標為 (a) 最大不確定性。**v1.2:這條鏈已用 stage 真實環境 e2e 實測打通(§10)、跨團隊 blocking 歸零。** P0 縮減為「visionA 端實作」+「(正式上線前)請 MC 給 visionA 專屬 file_api client」。短期 PoC 直接共用 FAA service client `4242ba63...`(已實測可拿 token)。
|
||||
|
||||
| 階段 | 內容 | 依賴 | 可平行? |
|
||||
|------|------|------|---------|
|
||||
| **P0(跨團隊部署)✅ v1.2 已驗證完成** | ~~① MC merge+部署 stage ② scope 註冊 ③ FAA stage 設定~~ **三項已 stage e2e 實測通過(§10)**。剩餘 P0 = 僅「正式上線前請 MC 配發 visionA 專屬 usage=file_api client」(短期 PoC 共用 `4242ba63...`,非 blocking)| 無 | — |
|
||||
| **P0.5(visionA 端,現為起點)** | **B2**:復活 MC client(打 MC `/oauth/token` + 打 MC Issue 簽 fdt,逆轉 ADR-016 §5;比原版單純——token 由 MC 簽、不自簽)+ .env 設定(client `4242ba63...` / MC base url / FAA base url / tenant `732270c0...`)。**B1**:`model.Model` 加 `FAAObjectKey` 欄位(解 object_key 斷層,見決策 2 + §10.4)| 無(P0 已完成)| 與 P1a/P1b 平行 |
|
||||
| **P1a** | D — 修 stage OAuth 設定(決策 4 D1)。**v1.2 線索**:`4242ba63...` 有 `files:upload.write`、promote 卡的 401 很可能同類問題(converter 用的 client 沒 upload.write)→ 可一併驗證 | 無 | **與 P0.5/P1b 平行** |
|
||||
| **P1b** | B 第一階段 — model_shares 表 + 權限查驗(決策 3 B1)| 無 | **與 P1a 平行** |
|
||||
| **P2** | C — visionA 打 MC Issue + model download flow(決策 1a + 決策 2,FAA 用法見決策 2 v1.2 確認框);第一階段 (a) 只支援轉檔→promote 類 model(object_key 斷層,§10.4 B1)| P0.5 + P1b | 接 P0.5/P1b |
|
||||
| **P3** | B 第二階段 — workspaces/workspace_members(決策 3 B2)| P1b 穩定後 | 獨立 |
|
||||
| **P4(後續 Phase)** | HA / 頻寬(決策 5 長期方向)| 不阻擋前面 | 獨立 |
|
||||
|
||||
> **~~fallback 觸發點~~(v1.2:跨團隊部署已通、fallback 不再需要)**:v1.1 設想「P0 卡住則 fallback (c)」。v1.2 P0 已 stage e2e 通、無卡點,**(c) 不動用**。僅保留「正式上線改用 visionA 專屬 file_api client、若 MC 端無法配發時」的理論退路。
|
||||
|
||||
不寫 task 級細節;各階段的實作 task 拆分由 backend agent 在進開發時做。
|
||||
|
||||
---
|
||||
|
||||
## 6. 待使用者裁決清單
|
||||
|
||||
| # | 待裁決項 | 推薦選項 | 理由 |
|
||||
|---|---------|---------|------|
|
||||
| **Q1(v1.2:已確認走 (a))** | 決策 1 認證方向:**(a) MC delegated token** vs (c) visionA 自簽(fallback) | **✅ 已確認走 (a)** — stage e2e 實測打通(§10),(c) 無需動用 | MC stage 已部署 + scope 機制 + FAA validate 鏈全部 e2e 實證;符合使用者 follow pptx 意圖。(c) 僅留理論退路 |
|
||||
| **Q1.5(v1.2:已實測 P0 可行)** | (a) 的 P0 跨團隊前置 checklist 能否打通 | **✅ 已實測通過** — MC/FAA stage 已部署、scope 用 FAA client `4242ba63...` 實測可拿 token、e2e 全綠(§10)| 原「5/9 撞 invalid_scope」風險已實證可繞過(共用 FAA client)。跨團隊 blocking 歸零 |
|
||||
| **Q2** | download token 有效期(visionA Issue 時帶的 `expires_in_seconds`)| **60–300 秒**(傾向 120s)| 夠 Client 發起下載、又短到截走也很快失效 |
|
||||
| **Q3** | 是否需支援**瀏覽器**直連 FAA(牽涉 CORS),或只 local-tool 直連 | 先**只 local-tool**(無 CORS),瀏覽器需求出現再加 FAA CORS | 縮小第一階段範圍 |
|
||||
| **Q4** | B 權限模型做到哪 | **先 B1(model_shares),B2 排後續,B3 不做** | 最小可行先上 |
|
||||
| **Q5** | promote stage 401 修法 | **D1 修 stage 設定,不動 promote 架構** | prod 已證明設計對 |
|
||||
| **Q6** | HA/頻寬是否阻擋第一階段 | **不阻擋,標後續 Phase + 風險登記** | 先讓核心存取路徑跑通 |
|
||||
| **Q7(v1.1 新增)** | object_key 斷層:上傳類 model(不在 FAA 上)能否走 (a) 直連 FAA | **第一階段 (a) 只支援「轉檔→promote 進 FAA 的 model」(有 FAAObjectKey);純上傳 model 的 FAA download 排後續**(需先有「上傳檔也 PUT 進 FAA」的路徑)| 純上傳 model 目前只在 visionA 自己 storage、不在 FAA,無法直連;避免第一階段範圍爆炸 |
|
||||
| **Q8(v1.1 新增)** | model download 走 (a) 直連 FAA vs 沿用 ADR-016 converter 中轉的分流 | **已在模型庫(有 FAAObjectKey)→ (a);job 結果還沒進模型庫 → ADR-016**(兩者並存)| ADR-016 來源 converter MinIO 7 天就 GC、不適合模型庫持久資產;(a) 來源 FAA 持久 |
|
||||
| **Q9(v1.2:已同意復活 MC client)** | 是否接受「復活 MC service token client」(逆轉 ADR-016 §5 剛砍的 mc_token_client.go)| **✅ 已同意接受**((a) 必要成本;邏輯比原版單純:打 MC `/oauth/token` 拿 service token + 打 MC Issue 簽 fdt,token 由 MC 簽、不需自簽)| (a) 要 visionA 打 MC Issue,必須有 MC service token;使用者已同意 |
|
||||
| **Q10(v1.2 新增)** | 是否接受「短期共用 FAA service client `4242ba63...` 做 PoC、正式上線前換 visionA 專屬 usage=file_api client」| **接受(使用者已傾向)** — 短期解 blocking、正式上線換專屬降技術債 | `4242ba63...` 是現成 usage=file_api client(實測可拿 download.delegate token);共用違反 MC「secret 不共用」規範(技術債,見 R1),正式上線換專屬 client。詳見 §7 R1 + §10.2 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 後果
|
||||
|
||||
### 正面 — **v1.1 改寫**
|
||||
- **模型下載有可行路徑、且符合 pptx 原圖((a))**:不需 visionA 自建簽發 + JWKS,MC/FAA source 已就緒。
|
||||
- 滿足 pptx 兩大訴求:Bucket 不曝露 + 每次驗權限 + 下載不經 AWS。
|
||||
- **FAA/MC 端零程式碼改動**(只需部署 + 設定 + scope 註冊)——比 (c)「請 warrenchen 改寫 FAA validator」輕。
|
||||
- **token 安全性最強**:MC 簽 opaque `fdt_` token,支援 boundary + revoke + 自訂 TTL,比 visionA 自簽 JWT(無法 revoke)完整。
|
||||
- B 權限分階段,先解 owner-only 痛點,不過度設計。
|
||||
|
||||
### 負面(接受的取捨)— **v1.1 改寫**
|
||||
- **visionA 要復活 MC service token client**(逆轉 ADR-016 §5 剛砍的 mc_token_client.go)——但邏輯比原版單純(只需 service token cache + 打 Issue,不需自簽 delegated token)。
|
||||
- **(a) 依賴 MC 線上 validate**:每次 FAA download 多一跳 FAA→MC(FAA validator 有 token cache 緩解,但 MC 成驗證單點)。
|
||||
- **object_key 斷層成本**:`model.Model` 要加 `FAAObjectKey`、釐清「哪些 model 在 FAA 上」(見決策 2)。
|
||||
- **跨團隊部署不可控**:(a) 的 P0 依賴 warrenchen 排程(merge/部署/scope 註冊)。
|
||||
- HA/頻寬留後續 Phase,第一階段 NAS/公司網路是單點。
|
||||
|
||||
### 風險 — **v1.1 改寫**
|
||||
- **R1(v1.2 改寫——原「scope 註冊最高風險」已 e2e 實證解除,改為共用 client 的技術債)**:v1.1 的 R1(「scope 沒在 MC 註冊、5/9 撞 invalid_scope」)已用 stage e2e 實測解除(§10:MC scope 機制驗證通過、FAA client `4242ba63...` 實測可拿 `files:download.delegate` token)。**v1.2 新的 R1 = 短期共用 FAA service client `4242ba63...` 的技術債**:(i) 違反 MC source 明訂「OAuth client 禁止混用 usage、secret 不共用」;(ii) FAA 的 client secret 進 visionA `.env` → 擴大 secret 洩漏面(一份 secret 同時被 FAA 與 visionA 持有,任一邊洩漏波及兩個服務)。**緩解:正式上線前請 MC 配發 visionA 專屬 usage=file_api client(換掉 `4242ba63...`),把 secret 邊界收回 visionA 自己**(待裁決 Q10、合規清單追蹤)。實測共用實際上 e2e 可跑通是因 `4242ba63...` 本就是 usage=file_api client、自動帶 5 個 files scope。
|
||||
- **R2(v1.1 新增)**:object_key 三方斷層(決策 2)——上傳類 model 不在 FAA 上,第一階段 (a) 只能支援轉檔→promote 的 model。若使用者期待「所有 model 都能直連 FAA」,範圍大幅擴張。
|
||||
- **R3(v1.1 改寫)**:(a) 跨團隊部署任一卡住 → 整條 (a) 不通。緩解:保留 (c) 為 fallback;P0 設可接受期限,逾期 fallback。
|
||||
- **R4(opaque token 限制)**:`fdt_` 是 opaque、FAA 無法本地驗,每次 download 必 call MC validate。MC validate 慢/掛 → 所有 model download 失敗。緩解:FAA validator service token cache + MC validate SLA 監控。
|
||||
- **R5(pptx 矛盾,原 R3)**:pptx「只傳網址讓 FAA 去抓」上傳省流量,但來源(converter MinIO)也在公司網路,HA/頻寬風險與下載同源。
|
||||
- **R6(原 R4)**:NEF 在 converter MinIO(7天)+ FAA 雙存,長期雙存一致性與清理策略未定,須在 P2 釐清。
|
||||
- **R1'(僅 fallback 回 (c) 才適用)**:(c) 把 token 簽發收回 visionA,**JWKS 私鑰外洩 = 全模型庫可被簽 token 下載**,須 secret 管理 + key rotation。(a) 無此風險(token 由 MC 簽、可 revoke)。
|
||||
|
||||
---
|
||||
|
||||
## 8. 合規性 — **v1.2 改寫**
|
||||
- [x] **(a) 的 P0 跨團隊部署 checklist** — ✅ v1.2 已 stage e2e 實測完成(MC/FAA stage 已部署、scope 機制驗證、Issue→fdt→FAA validate 鏈全綠,§10),跨團隊 blocking 歸零
|
||||
- [ ] **(正式上線前)請 MC 配發 visionA 專屬 usage=file_api client** 換掉短期共用的 FAA client `4242ba63...`(R1 技術債、Q10)
|
||||
- [x] 與使用者確認 Q1 / Q1.5 / Q9 裁決 — ✅ 已確認走 (a) + 同意復活 MC client;新增 Q10(共用 client PoC)使用者已傾向接受
|
||||
- [ ] 與 backend agent 確認 object_key 斷層解法(`model.Model` 加 `FAAObjectKey`、上傳類 model 範圍)
|
||||
- [ ] 與 security agent 評估 R4(MC validate 單點 / SLA);若 fallback (c) 則評估 R1'(JWKS 私鑰)
|
||||
- [ ] 成本影響:第一階段無新雲端資源(沿用現有 FAA + NAS + MC),主要是 visionA 開發工時 + 跨團隊部署協調
|
||||
|
||||
---
|
||||
|
||||
## 9. (a) 端到端可行性驗證(v1.1 新增)
|
||||
|
||||
本節記錄改推 (a) 前所做的 source 驗證證據與三端缺口清單。
|
||||
|
||||
### 9.1 source 證據(已就緒的部分)
|
||||
|
||||
| 端 | 證據 | 結論 |
|
||||
|----|------|------|
|
||||
| **MC develop** | commit `e77fdec`(FileAccessDownloadToken entity + migration):`POST /file-access/download-tokens`(Issue, `[Authorize(Policy="FilesDownloadDelegate")]`,回 `fdt_<base64url>` + scope `files:download.read` + 自訂 `expires_in_seconds`,token hash 存 DB);`POST /file-access/download-tokens/validate`(Validate, `[Authorize(Policy="FilesDownloadRead")]`,驗 TokenHash + RevokedAt + ExpiresAt + boundary)。commit `5f32452` AuthResourceRegistry 做 audience/scope mapping。**master 尚未 merge。** | Issue + Validate **已實作**;token 為 opaque(非 JWT)→ 必 call MC validate |
|
||||
| **FAA(master,working tree 已驗)** | `MemberCenterDelegatedDownloadTokenValidator.cs`:`ValidateAsync` 用 `GetServiceAccessTokenAsync`(FAA 自己拿 MC service token,`client_credentials` + `_options.Scope`)→ `PostAsJsonAsync(_options.DownloadTokenValidationPath, payload)` 打 MC。payload = `{token, tenant_id, file_id, object_key, method}`、response = `{active, tenant_id, user_id, file_id, object_key, method, expires_at}`。`Program.cs:33` 註冊此 validator;`Program.cs:184` `GET /files/{**objectKey}` 用它驗 + boundary 檢查(tenant_mismatch / object_key_mismatch / method_mismatch)。 | FAA validator 的 payload/response 形狀**與 MC develop validate 契約吻合** → 配套設計,FAA 端**不需重寫** |
|
||||
| **visionA(已驗)** | `internal/conversion/`:mc_token_client.go **確實已不存在**(ADR-016 §5 砍除生效)。download 現走 converter `GetResult`(`converter_client.go:998`)+ API key。model 上傳走 `/api/models/init+finalize` 進 visionA 自己 storage(`models.go:235` `StorageKey=models/{userID}/{modelID}.nef`)。 | visionA 端要 (a) 須**復活 MC service token client** + 新增打 MC Issue 邏輯 |
|
||||
|
||||
### 9.2 三端缺口清單((a) 端到端「還缺什麼」)
|
||||
|
||||
| 端 | 缺口 | 誰做 | 風險 |
|
||||
|----|------|------|------|
|
||||
| **MC** | ① develop 尚未 merge master + 部署 stage | warrenchen | 排程不可控 |
|
||||
| **MC** | ② visionA service client(`23605e14...`)的 `files:download.read/delegate` scope **是否已在 MC AuthResourceRegistry 註冊**未確認(ADR-014 line 35 只證明「visionA 文件列了」,非「MC 端註冊了」);Issue 的 `[Authorize(Policy="FilesDownloadDelegate")]` 需 visionA token 帶對應 claim | warrenchen | **最高(5/9 撞過 invalid_scope)** |
|
||||
| **FAA** | ③ FAA stage 是否部署 + `MemberCenterOptions`(BaseUrl/ClientId/ClientSecret/Scope/DownloadTokenValidationPath)是否設好未確認;FAA 自己也要有 MC service token(scope 待確認)| warrenchen | 中 |
|
||||
| **visionA** | ④ 復活 MC service token client(逆轉 ADR-016 §5)+ 打 MC Issue | jimchen / backend agent | 低(自己可控)|
|
||||
| **visionA** | ⑤ object_key 斷層:`model.Model` 加 `FAAObjectKey`、釐清上傳類 model 不在 FAA 上的範圍(見決策 2)| jimchen / backend agent | 中(範圍影響)|
|
||||
| **三方** | ⑥ boundary 對齊:visionA Issue 帶的 object_key = FAA URL key = MC validate object_key(見決策 2 斷層分析)| 三方協調 | 中 |
|
||||
|
||||
### 9.3 驗證結論
|
||||
|
||||
- **source level:(a) 通**(MC develop Issue+Validate 已寫、FAA validator 配套、契約吻合)。**v1.0 判 fictional 是因只 grep master。**
|
||||
- **部署 level:(a) 未通**,缺 §9.2 六項,其中 ② scope 註冊是最高風險(warrenchen 端、5/9 同類事故)。
|
||||
- **visionA 端額外成本**:復活 MC service token client(逆轉 ADR-016)+ 解 object_key 斷層(加 FAAObjectKey)。
|
||||
- **(c) 在新事實下反而更重**:FAA 現成 validator 正是 (a) 要的;(c) 要請 warrenchen 改寫成本地驗 JWKS,是「改既有正確配套」。
|
||||
- **最終建議:推 (a),但 P0 step ②③ 須先跟 warrenchen 確認可行(Q1.5),逾期 fallback (c)。**
|
||||
|
||||
> **⚠️ v1.2 後記**:§9 是 v1.1 時的「source level 驗證 + 待跨團隊驗證」紀錄。**§9.2 六項缺口中的 ①②③(MC merge/部署、scope 註冊、FAA stage 設定)已於 v1.2 用 stage 真實環境 e2e 實測全部通過**(見 §10)。§9.3「部署 level 未通」結論已被 §10 推翻。④⑤⑥(visionA 端實作 + object_key 斷層 + boundary 對齊)轉為 §10.4 的 visionA 端 checklist。
|
||||
|
||||
---
|
||||
|
||||
## 10. (a) 端到端 stage 實測證據(v1.2 新增 — 本 ADR 最有價值的部分)
|
||||
|
||||
本節記錄 v1.1 → v1.2 之間,用 **stage 真實環境 + 真 secret + 真 OIDC user_id** 把整條 (a) 鏈路打通的實證。**這把 (a) 從「待驗證的可行性」升級為「已實證可行」。**
|
||||
|
||||
### 10.0 環境與憑證(使用者由 MC team 取得)
|
||||
|
||||
| 項目 | 值 |
|
||||
|------|-----|
|
||||
| MC stage Web | `https://stage-9527.innovedus.com:7880/` |
|
||||
| MC stage API | `https://stage-9527.innovedus.com:7850/` |
|
||||
| FAA stage | `https://stage-9527.innovedus.com:5081/` |
|
||||
| visionA Login client(web_login) | `b8093fea1a504a5d8f0e04bee9f78f2e`,callback `https://stage-9527.innovedus.com:9527/api/auth/callback`(**注意:MC team 訊息少寫 `/api`,以 visionA 現用含 `/api` 的為準、勿改**)|
|
||||
| visionA「Login 以外 API call」共用 client | `23605e14a2c64660abd97e29963d8d58`(client_credentials)|
|
||||
| **FAA service client(PoC 共用)** | `4242ba63099d4f318dd3f143d27ef4c5`,tenant `732270c0-449c-489c-bfad-321e9bf89b3d`,scopes `files:upload.write files:metadata.read files:delete files:download.delegate` |
|
||||
| 真實 OIDC user_id(從 `/api/auth/me`) | `b5332e51-c394-45c7-a28a-83e6da127ba9` |
|
||||
|
||||
### 10.1 MC 部署確認(推翻「只看 git master 判 fictional」)
|
||||
|
||||
- `POST /file-access/download-tokens`(Issue)與 `/validate` 在 stage 回 **401(非 404)→ endpoint 已部署**。對照亂打路徑回 404。
|
||||
- → **推翻 v1.0/v1.1「只看 git master 判 fictional」**:stage 實際版本已含 develop 的 download token 功能(即 §9.2 缺口 ①「develop 尚未 merge master」對 stage 不成立——stage 跑的版本已有此功能)。
|
||||
|
||||
### 10.2 scope 模型診斷(MC usage→scope 模型)
|
||||
|
||||
- MC discovery `scopes_supported` 含 `files:download.read/delegate/upload.write/metadata.read/delete`。
|
||||
- **MC 用 usage→scope 模型**(`AuthResourceRegistryService`):`file_api` 這個 **usage 類型**(不是現成 client)自動帶 5 個 files scope。`file_api` 強制 **confidential + client_credentials + 綁 tenant_id**。source 明訂「**OAuth client 禁止混用 usage、secret 不共用**」(→ R1 技術債的根據)。
|
||||
- **visionA 共用 client `23605e14...` 實測**:所有 files scope 全部 `not allowed`;`converter:job.*` 是 invalid(**MC 根本沒這 scope**,呼應 converter 已改 API key)。→ **共用 client 對 file_api 行不通**(規範禁止混用 usage)。
|
||||
- **FAA client `4242ba63...` 實測**:能拿 `files:download.delegate` token,`aud=file_access_api` → 它就是個 **usage=file_api 的 client**(所以 PoC 直接共用它可行)。
|
||||
|
||||
### 10.3 🏆 (a) 端到端 e2e 全綠(stage 真實環境、真 secret、真 user_id)
|
||||
|
||||
| Step | 動作 | 結果 |
|
||||
|------|------|------|
|
||||
| **Step 1** | `4242ba63...` 打 MC `/oauth/token`(client_credentials)拿 `files:download.delegate` token | ✅ 拿到 |
|
||||
| **Step 2** | 帶該 token + 真 user_id 打 MC `POST /file-access/download-tokens`(Issue)| ✅ **HTTP 200,簽出 `fdt_...` token**(token_type=`file_download`、120s、scope=`files:download.read`、tenant/user/object_key 正確回填)|
|
||||
| **Step 3** | 拿 `fdt_` token 以 `Authorization: Bearer` 打 FAA `GET /files/{objectKey}` | ✅ **HTTP 404 `file_not_found`**(= 認證 + MC validate 全過、FAA 去 bucket 找檔、**只因 `test/probe.nef` 是假 key 不存在**;換真實檔就回檔案內容)|
|
||||
|
||||
→ **B3(tenant 一致)、B4(user 是 MC 真實 user)、FAA validate 鏈,全部實證通過。** 404 是「檔案不存在」而非「認證失敗」——這正是認證鏈全綠的證明。
|
||||
|
||||
### 10.4 剩餘 visionA 端 blocking(backend agent 接下來的工作)
|
||||
|
||||
| # | 項目 | 說明 |
|
||||
|---|------|------|
|
||||
| **B1(最關鍵)** | **object_key 斷層** | visionA model `StorageKey=models/{userID}/{modelID}.nef`(visionA 自己 storage)≠ FAA object_key。**上傳類 model(SourceUploaded)根本不在 FAA → 第一階段 (a) 只支援轉檔→promote 類 model**。`model.Model` 要加 `FAAObjectKey` 欄位 + promote 時寫入。download flow 用 `FAAObjectKey`(非 `StorageKey`)組 FAA URL + Issue token |
|
||||
| **B2** | **visionA 加 MC client code** | 加「打 MC `/oauth/token` + 打 MC Issue 簽 fdt token」的 client code(**逆轉 ADR-016 砍除的 mc_token_client,但更單純:不自簽、token 由 MC 簽**)+ .env 設定(client `4242ba63...` / MC base url / FAA base url / tenant `732270c0...`)|
|
||||
| **D(連帶)** | **promote 401 可一併驗證** | `4242ba63...` 有 `files:upload.write` → promote 卡的 401 **很可能同類問題**(converter 用的 client 沒 `upload.write`)。標為「可一併驗證」 |
|
||||
|
||||
**FAA download endpoint 確切用法(給 backend 實作)**:
|
||||
- `GET {FAA}/files/{objectKey}`,token 放 `Authorization: Bearer {fdt_token}`(FAA `TryReadAccessToken` **只認 Authorization Bearer**,不認 query / 自訂 header)。
|
||||
- FAA 自己用 `IDelegatedDownloadTokenValidator`(=`MemberCenterDelegatedDownloadTokenValidator`)拿 token 打 MC validate,帶 `instanceOptions.TenantId` + `ObjectKey`(從 URL path 取)+ `Method=GET`。
|
||||
- `objectKey` 必須與簽 token 時的 `object_key` **完全一致**(FAA validate boundary 檢查、不一致回 `object_key_mismatch`)。
|
||||
@ -189,3 +189,46 @@ VISIONA_CONVERTER_API_KEY=
|
||||
|
||||
# 上傳模型檔大小上限(MB)— 與 converter 端 limit 對齊
|
||||
VISIONA_CONVERTER_MAX_MODEL_SIZE_MB=500
|
||||
|
||||
# ============================================================
|
||||
# Phase 0.9 — 模型庫 model 直連 FAA 下載(ADR-017 (a))
|
||||
# ============================================================
|
||||
# 對齊 docs/autoflow/04-architecture/adr/adr-017-model-library-access.md §10(stage e2e 實測藍本)。
|
||||
#
|
||||
# 下載鏈:visionA 用 service client 打 MC /oauth/token(scope files:download.delegate)
|
||||
# → 打 MC POST /file-access/download-tokens(Issue)簽 opaque fdt_ token
|
||||
# → 回給 Client「FAA 下載 URL + fdt token」,Client 帶 Authorization: Bearer fdt_...
|
||||
# 直接 GET {FAA}/files/{object_key}(不經 visionA、不經 AWS)。
|
||||
#
|
||||
# 啟用判定:MC_BASE_URL / SERVICE_CLIENT_ID / SERVICE_CLIENT_SECRET / TENANT_ID / FAA_BASE_URL
|
||||
# 全部非空才啟用;任一缺 → GET /api/models/:id/download 回 501。
|
||||
#
|
||||
# ⚠️⚠️ 技術債(ADR-017 §7 R1 / Q10):第一階段 PoC 短期**共用 FAA 的 service client**
|
||||
# (stage 用 4242ba63...,實測可拿 files:download.delegate token)。MC 規範明訂
|
||||
# 「OAuth client 禁止混用 usage、secret 不共用」——這份 secret 同時被 FAA 與 visionA 持有,
|
||||
# 任一邊洩漏會波及兩個服務。**正式上線前須請 MC 配發 visionA 專屬 usage=file_api client**
|
||||
# 換掉此共用 client,把 secret 邊界收回 visionA。
|
||||
|
||||
# Member Center API base URL(不帶結尾斜線)
|
||||
# stage:https://stage-9527.innovedus.com:7850
|
||||
VISIONA_FILE_ACCESS_MC_BASE_URL=
|
||||
|
||||
# Service client(client_credentials grant,打 MC /oauth/token + Issue download token)
|
||||
# ⚠️ 技術債:第一階段 PoC 共用 FAA service client(stage:4242ba63099d4f318dd3f143d27ef4c5)
|
||||
VISIONA_FILE_ACCESS_SERVICE_CLIENT_ID=
|
||||
|
||||
# Service client secret
|
||||
# ⚠️ 不可 commit;prod 用 Secrets Manager / Vault;log 永遠不印此值全文
|
||||
# ⚠️ 技術債:第一階段 PoC 共用 FAA service client secret(正式上線前換 visionA 專屬 client)
|
||||
VISIONA_FILE_ACCESS_SERVICE_CLIENT_SECRET=
|
||||
|
||||
# 簽 download token 時帶給 MC 的 tenant_id(須與 FAA validate 的 tenant 一致)
|
||||
# stage:732270c0-449c-489c-bfad-321e9bf89b3d
|
||||
VISIONA_FILE_ACCESS_TENANT_ID=
|
||||
|
||||
# File Access Agent 對外 base URL(不帶結尾斜線)— 組回給 Client 的 download_url 用
|
||||
# stage:https://stage-9527.innovedus.com:5081
|
||||
VISIONA_FILE_ACCESS_FAA_BASE_URL=
|
||||
|
||||
# download token 有效期(秒)— ADR-017 Q2 區間 60–300s,預設 120
|
||||
VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS=120
|
||||
|
||||
2
visionA-backend/.gitignore
vendored
2
visionA-backend/.gitignore
vendored
@ -20,6 +20,8 @@ go.work.sum
|
||||
bin/
|
||||
dist/
|
||||
build/
|
||||
# go build 產生的 api-server 二進位(根目錄錨定,避免誤排除其他同名路徑)
|
||||
/api-server
|
||||
|
||||
# ---- 環境變數 / 密鑰 -----------------------------------------------------
|
||||
.env
|
||||
|
||||
@ -67,6 +67,7 @@ func (a *conversionModelStoreAdapter) Save(ctx context.Context, rec *conversion.
|
||||
TargetChip: rec.TargetChip,
|
||||
Source: rec.Source, // 應為 "converted"
|
||||
SourceJobID: rec.SourceJobID,
|
||||
FAAObjectKey: rec.FAAObjectKey, // ADR-017 (a) B1:promote 寫入的 FAA object key
|
||||
CreatedAt: rec.CreatedAt,
|
||||
UpdatedAt: rec.UpdatedAt,
|
||||
UploadedAt: &uploadedAt, // promote 完即 ready(對齊 toModelResponse)
|
||||
@ -120,6 +121,7 @@ func modelToRecord(m *model.Model) *conversion.ModelRecord {
|
||||
TargetChip: m.TargetChip,
|
||||
Source: m.Source,
|
||||
SourceJobID: m.SourceJobID,
|
||||
FAAObjectKey: m.FAAObjectKey, // ADR-017 (a) B1
|
||||
CreatedAt: m.CreatedAt,
|
||||
UpdatedAt: m.UpdatedAt,
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@ import (
|
||||
"visiona-backend/internal/conversion"
|
||||
"visiona-backend/internal/converter"
|
||||
"visiona-backend/internal/device"
|
||||
"visiona-backend/internal/fileaccess"
|
||||
"visiona-backend/internal/logger"
|
||||
"visiona-backend/internal/model"
|
||||
"visiona-backend/internal/oidc"
|
||||
@ -188,6 +189,41 @@ func main() {
|
||||
log.Info("conversion service disabled (set VISIONA_CONVERTER_BASE_URL + VISIONA_CONVERTER_API_KEY to enable)")
|
||||
}
|
||||
|
||||
// ===== Phase 0.9 模型庫 model 直連 FAA 下載(ADR-017 (a)) =====
|
||||
// 對齊 docs/autoflow/04-architecture/adr/adr-017-model-library-access.md §10。
|
||||
//
|
||||
// 啟用條件:cfg.FileAccess.Enabled() — 由 MC base / service client id+secret / tenant /
|
||||
// FAA base 五欄位全非空決定。不啟用時 fileAccessIssuer 為 nil,
|
||||
// GET /api/models/:id/download 自動回 501(modelsDownloadHandler 處理)。
|
||||
//
|
||||
// ⚠️ 技術債(ADR-017 §7 R1 / Q10):第一階段 PoC 共用 FAA 的 service client;
|
||||
// 正式上線前須換 visionA 專屬 usage=file_api client(見 internal/fileaccess 套件註解 + .env.example)。
|
||||
var fileAccessIssuer fileaccess.DownloadTokenIssuer
|
||||
if cfg.FileAccess.Enabled() {
|
||||
issuer, faErr := fileaccess.NewClient(fileaccess.Opts{
|
||||
MCBaseURL: cfg.FileAccess.MCBaseURL,
|
||||
ServiceClientID: cfg.FileAccess.ServiceClientID,
|
||||
ServiceClientSecret: cfg.FileAccess.ServiceClientSecret,
|
||||
TenantID: cfg.FileAccess.TenantID,
|
||||
DownloadTokenTTLSeconds: cfg.FileAccess.DownloadTokenTTLSeconds,
|
||||
Logger: log,
|
||||
})
|
||||
if faErr != nil {
|
||||
log.Error("failed to init file access client", "error", faErr)
|
||||
os.Exit(1)
|
||||
}
|
||||
fileAccessIssuer = issuer
|
||||
log.Info("file access (FAA download) initialized",
|
||||
"mc_base_url", cfg.FileAccess.MCBaseURL,
|
||||
"faa_base_url", cfg.FileAccess.FAABaseURL,
|
||||
"tenant_id", cfg.FileAccess.TenantID,
|
||||
// 安全:絕不印 client secret 全文
|
||||
"service_client_secret_set", cfg.FileAccess.ServiceClientSecret != "",
|
||||
"download_token_ttl_sec", cfg.FileAccess.DownloadTokenTTLSeconds)
|
||||
} else {
|
||||
log.Info("file access (FAA download) disabled (set VISIONA_FILE_ACCESS_* to enable)")
|
||||
}
|
||||
|
||||
// ===== Seed demo data(可選) =====
|
||||
if cfg.Server.SeedDemoData {
|
||||
if err := seedDemoData(deviceRepo, modelRepo, pairingStore, cfg.Auth.StaticUserID, log); err != nil {
|
||||
@ -210,6 +246,8 @@ func main() {
|
||||
Storage: storageStore,
|
||||
Converter: converterClient,
|
||||
Conversion: conversionService, // Phase 0.8(nil 時 /api/conversion/* 回 501)
|
||||
FileAccessIssuer: fileAccessIssuer, // Phase 0.9(nil 時 /api/models/:id/download 回 501)
|
||||
FAABaseURL: cfg.FileAccess.FAABaseURL,
|
||||
MaxUploadSizeMB: cfg.Model.MaxSizeMB,
|
||||
CORSAllowedOrigins: cfg.CORS.AllowedOrigins,
|
||||
RelayPublicURL: cfg.Server.RelayPublicURL,
|
||||
|
||||
@ -26,6 +26,7 @@ import (
|
||||
"visiona-backend/internal/conversion"
|
||||
"visiona-backend/internal/converter"
|
||||
"visiona-backend/internal/device"
|
||||
"visiona-backend/internal/fileaccess"
|
||||
"visiona-backend/internal/model"
|
||||
"visiona-backend/internal/oidc"
|
||||
"visiona-backend/internal/session"
|
||||
@ -77,6 +78,17 @@ type Deps struct {
|
||||
// 設計選擇:用 conversion.Service interface 而非 concrete type — 方便 unit test 注入 stub。
|
||||
Conversion conversion.Service
|
||||
|
||||
// FileAccessIssuer 是 Phase 0.9「模型庫 model 直連 FAA 下載」的 download token 簽發者
|
||||
// (ADR-017 (a))。為 nil 時 GET /api/models/:id/download 回 501 NOT_IMPLEMENTED
|
||||
// (main.go 在 cfg.FileAccess.Enabled() 為 false 時不 wire)。
|
||||
// 用 interface 方便 unit test 注入 fake。
|
||||
FileAccessIssuer fileaccess.DownloadTokenIssuer
|
||||
|
||||
// FAABaseURL 是 File Access Agent 對外 base URL(不帶結尾斜線),用來組回給 Client
|
||||
// 的 download_url(`{FAABaseURL}/files/{object_key}`)。
|
||||
// 由 cfg.FileAccess.FAABaseURL 注入;FileAccessIssuer 非 nil 時必非空(main.go 確保)。
|
||||
FAABaseURL string
|
||||
|
||||
// CORSAllowedOrigins 是允許的瀏覽器 Origin 白名單;空 slice 預設放行
|
||||
// http://localhost:3000(前端 dev server)。
|
||||
CORSAllowedOrigins []string
|
||||
|
||||
@ -20,6 +20,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@ -41,6 +42,9 @@ func registerModelRoutes(g *gin.RouterGroup, deps Deps) {
|
||||
g.POST("/models/:id/finalize", modelsFinalizeHandler(deps))
|
||||
g.DELETE("/models/:id", modelsDeleteHandler(deps))
|
||||
|
||||
// Phase 0.9 模型庫 model 直連 FAA 下載(ADR-017 (a))。
|
||||
g.GET("/models/:id/download", modelsDownloadHandler(deps))
|
||||
|
||||
// load-to-device 雛形先 stub(完整實作需要 presigned GET + 透過 tunnel 送指令給 local agent)
|
||||
g.POST("/models/:id/load-to-device", func(c *gin.Context) {
|
||||
WriteNotImplemented(c, "models.load-to-device — pending Phase 1")
|
||||
@ -431,3 +435,128 @@ func modelsDeleteHandler(deps Deps) gin.HandlerFunc {
|
||||
c.Status(http.StatusNoContent)
|
||||
}
|
||||
}
|
||||
|
||||
// ModelDownloadResponse 是 GET /api/models/:id/download 的 response data(ADR-017 (a) 決策 2)。
|
||||
//
|
||||
// Client(local-tool / browser)拿到後,帶 `Authorization: Bearer {Token}` 直接
|
||||
// GET DownloadURL(= {FAA}/files/{object_key})下載——不經 visionA、不經 AWS。
|
||||
type ModelDownloadResponse struct {
|
||||
// DownloadURL 是 FAA 下載 URL(`{FAABaseURL}/files/{object_key}`)。
|
||||
DownloadURL string `json:"download_url"`
|
||||
// Token 是 MC 簽的 opaque download token(fdt_);放 Authorization: Bearer。
|
||||
Token string `json:"token"`
|
||||
// ExpiresAt 是 token 到期時間(RFC3339 / UTC);MC 沒回填時為零值(前端不應依賴)。
|
||||
ExpiresAt time.Time `json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
// modelsDownloadHandler 實作 GET /api/models/:id/download(ADR-017 (a) 模型庫直連 FAA 下載)。
|
||||
//
|
||||
// 流程(對齊 adr-017 §10.3 e2e 藍本 + 決策 2):
|
||||
// 1. ownership 驗(第一階段 owner-only;B 分享是後續階段)
|
||||
// 2. model 必須有 FAAObjectKey(= 轉檔→promote 類);上傳類(空 key)回 501(Q7 範圍框死)
|
||||
// 3. FileAccessIssuer 簽 MC download token(fdt_)
|
||||
// 4. 回 {download_url, token, expires_at} 給 Client 直連 FAA
|
||||
//
|
||||
// 錯誤:
|
||||
// - issuer / FAABaseURL 未配置(deps.FileAccessIssuer == nil)→ 501 NOT_IMPLEMENTED
|
||||
// - model 不存在 / 非 owner → 404 / 403
|
||||
// - 上傳類 model(無 FAAObjectKey)→ 501 NOT_IMPLEMENTED(明確訊息)
|
||||
// - 簽 token 失敗(MC 不可用)→ 502 INTERNAL_ERROR(對外 mask,不洩漏 MC 內部狀態)
|
||||
func modelsDownloadHandler(deps Deps) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
if deps.ModelRepo == nil {
|
||||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||
return
|
||||
}
|
||||
// FAA 直連下載未啟用(cfg.FileAccess.Enabled() == false → main.go 不 wire)
|
||||
if deps.FileAccessIssuer == nil || deps.FAABaseURL == "" {
|
||||
WriteNotImplemented(c, "model FAA download not configured (set VISIONA_FILE_ACCESS_* env)")
|
||||
return
|
||||
}
|
||||
|
||||
id := c.Param("id")
|
||||
if id == "" {
|
||||
WriteError(c, http.StatusBadRequest, ErrCodeValidationFailed, "model id required", nil)
|
||||
return
|
||||
}
|
||||
// Phase 0.7 security fix C1 (見 .autoflow/05-implementation/review/phase-0.7-security-audit.md)
|
||||
uc, ok := UserContextFrom(c)
|
||||
if !ok || uc.UserID == "" {
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"missing user context (auth middleware misconfigured?)", nil)
|
||||
return
|
||||
}
|
||||
userID := uc.UserID
|
||||
|
||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
m, err := deps.ModelRepo.Get(ctx, id)
|
||||
if err != nil {
|
||||
if errors.Is(err, model.ErrNotFound) {
|
||||
WriteError(c, http.StatusNotFound, ErrCodeNotFound, "model not found", nil)
|
||||
return
|
||||
}
|
||||
WriteError(c, http.StatusInternalServerError, ErrCodeInternalError,
|
||||
"get model failed: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
// 第一階段 owner-only(B 分享後續階段);非 owner 回 403。
|
||||
if m.OwnerUserID != userID {
|
||||
WriteError(c, http.StatusForbidden, ErrCodeForbidden, "not owner", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 第一階段只支援「轉檔→promote 進 FAA」類 model(有 FAAObjectKey)。
|
||||
// 上傳類 model 只在 visionA 自己 storage、不在 FAA,無法直連(ADR-017 Q7 範圍框死)。
|
||||
if m.FAAObjectKey == "" {
|
||||
WriteNotImplemented(c,
|
||||
"uploaded models do not support FAA direct download in this phase (only converted models)")
|
||||
return
|
||||
}
|
||||
|
||||
// 簽 MC download token(fdt_)。userID = OIDC sub(MC 真實 user);
|
||||
// objectKey 必須與 FAA GET path key 一致(= 簽 token 時的 object_key)。
|
||||
issued, err := deps.FileAccessIssuer.IssueDownloadToken(ctx, userID, m.FAAObjectKey)
|
||||
if err != nil {
|
||||
// 對外 mask 成 502(不洩漏「MC 不可用 / token 簽發細節」這類內部運維狀態);
|
||||
// SRE 從 server log 的 error 看 fileaccess sentinel 分類。
|
||||
logOrDefault(deps.Logger).Warn("models: download token issue failed",
|
||||
"model_id", m.ID,
|
||||
"user_id", userID,
|
||||
"err", err.Error(),
|
||||
"request_id", RequestIDFrom(c))
|
||||
WriteError(c, http.StatusBadGateway, ErrCodeInternalError,
|
||||
"download token service unavailable", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// 組對外 download_url:{FAABaseURL}/files/{object_key}。
|
||||
// object_key 內含 '/'(models/{userID}/{jobID}.nef),需逐段 escape 但保留 '/'。
|
||||
downloadURL := strings.TrimRight(deps.FAABaseURL, "/") + "/files/" + escapeFAAObjectKey(m.FAAObjectKey)
|
||||
|
||||
logOrDefault(deps.Logger).Info("models: download token issued",
|
||||
"model_id", m.ID,
|
||||
"user_id", userID,
|
||||
"request_id", RequestIDFrom(c))
|
||||
|
||||
WriteSuccess(c, http.StatusOK, ModelDownloadResponse{
|
||||
DownloadURL: downloadURL,
|
||||
Token: issued.Token,
|
||||
ExpiresAt: issued.ExpiresAt,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// escapeFAAObjectKey 對 FAA object_key 逐段 path-escape,保留 '/' 為 path separator。
|
||||
//
|
||||
// object_key 形如 models/{userID}/{jobID}.nef;userID(OIDC sub) / jobID(UUID) 都是
|
||||
// 安全字元,但仍逐段 url.PathEscape 防禦(避免任何特殊字元破壞 URL 結構)。
|
||||
// 不整體 PathEscape 是因為那會把 '/' 變 %2F,破壞 FAA `/files/{**objectKey}` 的 path 比對。
|
||||
func escapeFAAObjectKey(objectKey string) string {
|
||||
segs := strings.Split(objectKey, "/")
|
||||
for i, s := range segs {
|
||||
segs[i] = url.PathEscape(s)
|
||||
}
|
||||
return strings.Join(segs, "/")
|
||||
}
|
||||
|
||||
223
visionA-backend/internal/api/models_download_test.go
Normal file
223
visionA-backend/internal/api/models_download_test.go
Normal file
@ -0,0 +1,223 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"visiona-backend/internal/fileaccess"
|
||||
"visiona-backend/internal/model"
|
||||
)
|
||||
|
||||
// ==========================================================================
|
||||
// fake DownloadTokenIssuer
|
||||
// ==========================================================================
|
||||
|
||||
type fakeIssuer struct {
|
||||
token string
|
||||
expiresAt time.Time
|
||||
err error
|
||||
|
||||
// 記錄被呼叫的參數,驗 handler 傳對 userID / objectKey。
|
||||
gotUserID string
|
||||
gotObjectKey string
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeIssuer) IssueDownloadToken(ctx context.Context, userID, objectKey string) (*fileaccess.IssuedDownloadToken, error) {
|
||||
f.calls++
|
||||
f.gotUserID = userID
|
||||
f.gotObjectKey = objectKey
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return &fileaccess.IssuedDownloadToken{
|
||||
Token: f.token,
|
||||
TokenType: "file_download",
|
||||
ExpiresAt: f.expiresAt,
|
||||
Scope: "files:download.read",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// newDownloadFixture 建一個帶 fake issuer 的 models route fixture。
|
||||
//
|
||||
// issuer 為 nil 時模擬「FAA download 未啟用」(main.go 不 wire)。
|
||||
// faaBaseURL 空時也視為未配置。
|
||||
func newDownloadFixture(t *testing.T, issuer fileaccess.DownloadTokenIssuer, faaBaseURL, userID string) (*gin.Engine, *model.InMemoryRepository) {
|
||||
t.Helper()
|
||||
repo := model.NewInMemoryRepository()
|
||||
|
||||
r := gin.New()
|
||||
r.Use(RequestIDMiddleware())
|
||||
r.Use(injectStaticUserContext(userID, ""))
|
||||
g := r.Group("/api")
|
||||
registerModelRoutes(g, Deps{
|
||||
ModelRepo: repo,
|
||||
MaxUploadSizeMB: 10,
|
||||
FileAccessIssuer: issuer,
|
||||
FAABaseURL: faaBaseURL,
|
||||
})
|
||||
return r, repo
|
||||
}
|
||||
|
||||
// seedConvertedModel 直接塞一個「轉檔→promote」類 model(有 FAAObjectKey)。
|
||||
func seedConvertedModel(t *testing.T, repo *model.InMemoryRepository, id, owner, faaKey string) {
|
||||
t.Helper()
|
||||
now := time.Now().UTC()
|
||||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||||
ID: id,
|
||||
OwnerUserID: owner,
|
||||
Name: "converted-model",
|
||||
StorageKey: "models/" + owner + "/" + id + ".nef",
|
||||
FAAObjectKey: faaKey,
|
||||
FileSize: 1024,
|
||||
Source: model.SourceConverted,
|
||||
UploadedAt: &now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}))
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// happy path
|
||||
// ==========================================================================
|
||||
|
||||
func TestModelsDownload_OK(t *testing.T) {
|
||||
exp := time.Date(2026, 6, 7, 12, 2, 0, 0, time.UTC)
|
||||
iss := &fakeIssuer{token: "fdt_abc123", expiresAt: exp}
|
||||
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||
seedConvertedModel(t, repo, "m-conv-1", "demo-user", "models/demo-user/job-1.nef")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models/m-conv-1/download", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||||
|
||||
var sb SuccessBody
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
|
||||
data := sb.Data.(map[string]any)
|
||||
|
||||
assert.Equal(t, "https://faa.example.com:5081/files/models/demo-user/job-1.nef", data["download_url"])
|
||||
assert.Equal(t, "fdt_abc123", data["token"])
|
||||
assert.Contains(t, data["expires_at"], "2026-06-07")
|
||||
|
||||
// issuer 必須拿到正確的 userID(OIDC sub)+ objectKey(FAAObjectKey,非 StorageKey)
|
||||
assert.Equal(t, "demo-user", iss.gotUserID)
|
||||
assert.Equal(t, "models/demo-user/job-1.nef", iss.gotObjectKey)
|
||||
assert.Equal(t, 1, iss.calls)
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 501 — 未配置 / 上傳類
|
||||
// ==========================================================================
|
||||
|
||||
func TestModelsDownload_NotConfiguredWhenIssuerNil(t *testing.T) {
|
||||
r, repo := newDownloadFixture(t, nil, "", "demo-user")
|
||||
seedConvertedModel(t, repo, "m1", "demo-user", "models/demo-user/job.nef")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models/m1/download", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotImplemented, w.Code)
|
||||
assert.Contains(t, w.Body.String(), ErrCodeNotImplemented)
|
||||
}
|
||||
|
||||
func TestModelsDownload_NotConfiguredWhenFAABaseURLEmpty(t *testing.T) {
|
||||
iss := &fakeIssuer{token: "fdt_x"}
|
||||
// issuer 有,但 FAABaseURL 空 → 仍視為未配置
|
||||
r, repo := newDownloadFixture(t, iss, "", "demo-user")
|
||||
seedConvertedModel(t, repo, "m1", "demo-user", "models/demo-user/job.nef")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models/m1/download", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotImplemented, w.Code)
|
||||
assert.Equal(t, 0, iss.calls, "should not call issuer when not configured")
|
||||
}
|
||||
|
||||
func TestModelsDownload_UploadedModelReturns501(t *testing.T) {
|
||||
iss := &fakeIssuer{token: "fdt_x"}
|
||||
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||
|
||||
// 上傳類 model:無 FAAObjectKey
|
||||
now := time.Now().UTC()
|
||||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||||
ID: "m-upload",
|
||||
OwnerUserID: "demo-user",
|
||||
Name: "uploaded",
|
||||
StorageKey: "models/demo-user/m-upload.nef",
|
||||
Source: model.SourceUploaded,
|
||||
UploadedAt: &now,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}))
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models/m-upload/download", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotImplemented, w.Code)
|
||||
assert.Contains(t, w.Body.String(), "uploaded models")
|
||||
assert.Equal(t, 0, iss.calls, "should not issue token for uploaded model")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 404 / 403 — ownership
|
||||
// ==========================================================================
|
||||
|
||||
func TestModelsDownload_NotFound(t *testing.T) {
|
||||
iss := &fakeIssuer{token: "fdt_x"}
|
||||
r, _ := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models/does-not-exist/download", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusNotFound, w.Code)
|
||||
assert.Contains(t, w.Body.String(), ErrCodeNotFound)
|
||||
}
|
||||
|
||||
func TestModelsDownload_ForbiddenWhenNotOwner(t *testing.T) {
|
||||
iss := &fakeIssuer{token: "fdt_x"}
|
||||
// 登入 user = demo-user,但 model owner = other-user
|
||||
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||
seedConvertedModel(t, repo, "m-other", "other-user", "models/other-user/job.nef")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models/m-other/download", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||||
assert.Contains(t, w.Body.String(), ErrCodeForbidden)
|
||||
assert.Equal(t, 0, iss.calls, "should not issue token for non-owner")
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 502 — issuer 失敗
|
||||
// ==========================================================================
|
||||
|
||||
func TestModelsDownload_IssuerFailureReturns502(t *testing.T) {
|
||||
iss := &fakeIssuer{err: errors.New("mc unavailable")}
|
||||
r, repo := newDownloadFixture(t, iss, "https://faa.example.com:5081", "demo-user")
|
||||
seedConvertedModel(t, repo, "m1", "demo-user", "models/demo-user/job.nef")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/models/m1/download", nil)
|
||||
r.ServeHTTP(w, req)
|
||||
|
||||
assert.Equal(t, http.StatusBadGateway, w.Code)
|
||||
assert.Contains(t, w.Body.String(), ErrCodeInternalError)
|
||||
// 對外 mask:不應洩漏 "mc unavailable" 內部細節
|
||||
assert.NotContains(t, w.Body.String(), "mc unavailable")
|
||||
}
|
||||
@ -24,6 +24,8 @@ type Config struct {
|
||||
// Conversion 控制 Phase 0.8 轉檔功能整合(converter / FAA / MC service token)。
|
||||
// 對齊 .autoflow/04-architecture/conversion.md §5.3。
|
||||
Conversion ConversionConfig
|
||||
// FileAccess 控制 Phase 0.9 模型庫 model 直連 FAA 下載鏈(ADR-017 (a))。
|
||||
FileAccess FileAccessConfig
|
||||
}
|
||||
|
||||
// ServerConfig 控制 HTTP listener 的位址與埠號。
|
||||
@ -223,6 +225,69 @@ type ConversionConfig struct {
|
||||
MaxModelSizeMB int
|
||||
}
|
||||
|
||||
// FileAccessConfig 控制「模型庫 model 直連 FAA 下載」鏈路(ADR-017 (a))。
|
||||
//
|
||||
// 對齊 adr/adr-017-model-library-access.md §10(stage e2e 實測藍本):
|
||||
//
|
||||
// visionA 用 service client 打 MC `/oauth/token`(scope files:download.delegate)
|
||||
// → 打 MC `POST /file-access/download-tokens`(Issue)簽 opaque `fdt_` token
|
||||
// → 回給 Client「FAA 下載 URL + fdt token」,Client 帶 `Authorization: Bearer fdt_...`
|
||||
// 直接 GET `{FAA}/files/{object_key}`。
|
||||
//
|
||||
// 啟用判定(由 Enabled() 給 main.go 用):4 個必要欄位
|
||||
// (MCBaseURL / ServiceClientID / ServiceClientSecret / TenantID)全部非空才視為啟用;
|
||||
// 任一缺即不 wire download token issuer,model download endpoint 回 501。
|
||||
// FAABaseURL 是「組對外 download_url」用,留空時 endpoint 也回 501(無從組 URL)。
|
||||
//
|
||||
// ⚠️ 技術債(ADR-017 §7 R1 / Q10):第一階段 PoC 短期**共用 FAA 的 service client**
|
||||
// (`4242ba63...`,stage 實測可拿 files:download.delegate token)。MC 規範明訂「OAuth client
|
||||
// 禁止混用 usage、secret 不共用」,故正式上線前須請 MC 配發 visionA 專屬 usage=file_api
|
||||
// client 換掉此共用 client,把 secret 邊界收回 visionA。詳見 .env.example 對應註解。
|
||||
type FileAccessConfig struct {
|
||||
// MCBaseURL 是 Member Center API base URL(不帶結尾斜線)。
|
||||
// stage:https://stage-9527.innovedus.com:7850
|
||||
// 對齊 VISIONA_FILE_ACCESS_MC_BASE_URL。
|
||||
MCBaseURL string
|
||||
|
||||
// ServiceClientID 是打 MC `/oauth/token`(client_credentials)的 client id。
|
||||
// ⚠️ 技術債:第一階段 PoC 共用 FAA service client(`4242ba63...`)。
|
||||
// 對齊 VISIONA_FILE_ACCESS_SERVICE_CLIENT_ID。
|
||||
ServiceClientID string
|
||||
|
||||
// ServiceClientSecret 是 service client 的 secret。
|
||||
// **禁止 commit 進 repo**;對齊 VISIONA_FILE_ACCESS_SERVICE_CLIENT_SECRET。
|
||||
// 安全:log 永遠不印此值全文。
|
||||
ServiceClientSecret string
|
||||
|
||||
// TenantID 是簽 download token 時帶給 MC 的 tenant_id(須與 FAA validate 的 tenant 一致)。
|
||||
// stage:732270c0-449c-489c-bfad-321e9bf89b3d
|
||||
// 對齊 VISIONA_FILE_ACCESS_TENANT_ID。
|
||||
TenantID string
|
||||
|
||||
// FAABaseURL 是 File Access Agent 對外 base URL(不帶結尾斜線),用來組回給 Client 的
|
||||
// download_url(`{FAABaseURL}/files/{object_key}`)。
|
||||
// stage:https://stage-9527.innovedus.com:5081
|
||||
// 對齊 VISIONA_FILE_ACCESS_FAA_BASE_URL。
|
||||
FAABaseURL string
|
||||
|
||||
// DownloadTokenTTLSeconds 是簽 download token 時帶給 MC 的 expires_in_seconds。
|
||||
// ADR-017 Q2 區間 60–300s,預設 120s。對齊 VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS。
|
||||
DownloadTokenTTLSeconds int
|
||||
}
|
||||
|
||||
// Enabled 回傳「模型庫 FAA 直連下載」是否啟用。
|
||||
//
|
||||
// 4 個必要欄位(MCBaseURL / ServiceClientID / ServiceClientSecret / TenantID)+ FAABaseURL
|
||||
// 全部非空才視為啟用;任一缺 → main.go 不 wire download token issuer,
|
||||
// GET /api/models/:id/download 回 501。
|
||||
func (c FileAccessConfig) Enabled() bool {
|
||||
return c.MCBaseURL != "" &&
|
||||
c.ServiceClientID != "" &&
|
||||
c.ServiceClientSecret != "" &&
|
||||
c.TenantID != "" &&
|
||||
c.FAABaseURL != ""
|
||||
}
|
||||
|
||||
// Enabled 回傳 Phase 0.8 / 0.8b conversion 是否啟用。
|
||||
//
|
||||
// **Phase 0.8b v0.6 T4 簡化**(ADR-016 §2 / conversion.md v0.6.1 §3.1):visionA 端撤回
|
||||
|
||||
@ -81,6 +81,17 @@ func Load() *Config {
|
||||
ConverterAPIKey: getEnvString("VISIONA_CONVERTER_API_KEY", ""),
|
||||
MaxModelSizeMB: getEnvInt("VISIONA_CONVERTER_MAX_MODEL_SIZE_MB", 500),
|
||||
},
|
||||
// Phase 0.9 模型庫 FAA 直連下載(ADR-017 (a),見 adr-017 §10 stage e2e 藍本)。
|
||||
// ⚠️ 技術債:ServiceClientID/Secret 第一階段 PoC 共用 FAA 的 service client;
|
||||
// 正式上線前須換 visionA 專屬 usage=file_api client(ADR-017 §7 R1 / Q10)。
|
||||
FileAccess: FileAccessConfig{
|
||||
MCBaseURL: getEnvString("VISIONA_FILE_ACCESS_MC_BASE_URL", ""),
|
||||
ServiceClientID: getEnvString("VISIONA_FILE_ACCESS_SERVICE_CLIENT_ID", ""),
|
||||
ServiceClientSecret: getEnvString("VISIONA_FILE_ACCESS_SERVICE_CLIENT_SECRET", ""),
|
||||
TenantID: getEnvString("VISIONA_FILE_ACCESS_TENANT_ID", ""),
|
||||
FAABaseURL: getEnvString("VISIONA_FILE_ACCESS_FAA_BASE_URL", ""),
|
||||
DownloadTokenTTLSeconds: getEnvInt("VISIONA_FILE_ACCESS_DOWNLOAD_TOKEN_TTL_SECONDS", 120),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -84,6 +84,10 @@ type ModelRecord struct {
|
||||
TargetChip string
|
||||
Source string // 永遠 "converted"
|
||||
SourceJobID string
|
||||
// FAAObjectKey 是該 model 在 FAA 上的 object key(ADR-017 (a) B1)。
|
||||
// = converter promote 的 target_object_key(buildTargetObjectKey:models/{userID}/{jobID}.nef)。
|
||||
// PromoteToModels 寫入;adapter 對映到 model.Model.FAAObjectKey。
|
||||
FAAObjectKey string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@ -650,6 +654,16 @@ func (f *flow) PromoteToModels(ctx context.Context, userID, jobID, name string)
|
||||
}
|
||||
|
||||
// 7. modelStore.Save
|
||||
//
|
||||
// FAAObjectKey(ADR-017 (a) B1):promote 把 NEF 推進 FAA 用的 object_key。
|
||||
// 以 converter promote response 回傳的 TargetObjectKey 為權威值(converter 可能對 key
|
||||
// 正規化);promote response 萬一沒回(理論上不會,parseConverterPromoteResult 已要求非空)
|
||||
// 則 fallback 回 visionA 端組的 targetObjectKey。download endpoint 用此 key Issue MC token。
|
||||
faaObjectKey := promoteRes.TargetObjectKey
|
||||
if faaObjectKey == "" {
|
||||
faaObjectKey = targetObjectKey
|
||||
}
|
||||
|
||||
now := f.now().UTC()
|
||||
rec := &ModelRecord{
|
||||
ID: modelID,
|
||||
@ -661,6 +675,7 @@ func (f *flow) PromoteToModels(ctx context.Context, userID, jobID, name string)
|
||||
TargetChip: normalizeTargetChip(cj.Platform),
|
||||
Source: "converted",
|
||||
SourceJobID: jobID,
|
||||
FAAObjectKey: faaObjectKey,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
|
||||
427
visionA-backend/internal/fileaccess/client.go
Normal file
427
visionA-backend/internal/fileaccess/client.go
Normal file
@ -0,0 +1,427 @@
|
||||
// Package fileaccess 實作「模型庫 model 直連 FAA 下載」的 MC 認證鏈(ADR-017 (a))。
|
||||
//
|
||||
// 對齊 docs/autoflow/04-architecture/adr/adr-017-model-library-access.md §10
|
||||
// (stage 真實環境 e2e 實測藍本)。本套件只負責「跟 MC 拿 service token + 簽
|
||||
// download token(fdt_)」,不負責真的打 FAA 下載——下載由 Client(local-tool /
|
||||
// browser)帶 fdt token 直接 GET {FAA}/files/{object_key}。
|
||||
//
|
||||
// 與 internal/conversion 的關係:
|
||||
// - conversion 走「visionA → converter pre-shared API key」(ADR-015/016),是另一條路徑。
|
||||
// - fileaccess 走「visionA → MC OAuth client_credentials → 簽 fdt token」(ADR-017 (a)),
|
||||
// 是模型庫持久資產的下載路徑。兩者並存、用途不同(ADR-017 決策 4.5)。
|
||||
//
|
||||
// ⚠️ 技術債(ADR-017 §7 R1 / Q10):第一階段 PoC 短期共用 FAA 的 service client
|
||||
// (stage `4242ba63...`)。MC 規範明訂「OAuth client 禁止混用 usage、secret 不共用」,
|
||||
// 正式上線前須請 MC 配發 visionA 專屬 usage=file_api client 換掉,把 secret 邊界收回 visionA。
|
||||
//
|
||||
// 安全:
|
||||
// - 絕不把 client secret / access token / fdt token 全文寫進 log(連前綴也不印)。
|
||||
// - 只 log object_key hash / user hash / 結果狀態。
|
||||
package fileaccess
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ==========================================================================
|
||||
// Errors(sentinel)
|
||||
// ==========================================================================
|
||||
|
||||
var (
|
||||
// ErrServiceTokenFailed 表示打 MC /oauth/token 拿 service token 失敗
|
||||
// (網路 / 4xx / 5xx / 解析)。caller(handler)對外 mask 成 download_unavailable / 502。
|
||||
ErrServiceTokenFailed = errors.New("fileaccess: service token request failed")
|
||||
|
||||
// ErrIssueTokenFailed 表示打 MC Issue(POST /file-access/download-tokens)失敗。
|
||||
// caller 對外 mask 成 download_unavailable / 502。
|
||||
ErrIssueTokenFailed = errors.New("fileaccess: issue download token failed")
|
||||
|
||||
// ErrConfigIncomplete 表示 client 建構參數不完整(缺 MCBaseURL / client / tenant)。
|
||||
ErrConfigIncomplete = errors.New("fileaccess: required config is missing")
|
||||
)
|
||||
|
||||
// ==========================================================================
|
||||
// 對外 type / interface
|
||||
// ==========================================================================
|
||||
|
||||
// IssuedDownloadToken 是 IssueDownloadToken 的結果。
|
||||
//
|
||||
// Token 是 MC 簽的 opaque `fdt_<base64url>`(不是 JWT),caller 直接回給 Client
|
||||
// 當 `Authorization: Bearer {Token}` 打 FAA。ExpiresAt 由 MC 回填(UTC)。
|
||||
type IssuedDownloadToken struct {
|
||||
Token string // opaque fdt_ token(敏感,勿 log 全文)
|
||||
TokenType string // MC 回 "file_download"
|
||||
ExpiresAt time.Time // MC 回填的到期時間(UTC)
|
||||
Scope string // MC 回 "files:download.read"
|
||||
}
|
||||
|
||||
// DownloadTokenIssuer 是「簽 model download token」的最小對外介面。
|
||||
//
|
||||
// handler 依賴此 interface(而非具體 client),方便 unit test 注入 fake、
|
||||
// 也方便未來換成 visionA 專屬 client 時不動 handler。
|
||||
type DownloadTokenIssuer interface {
|
||||
// IssueDownloadToken 對指定 (userID, objectKey) 簽一個 MC download token。
|
||||
//
|
||||
// - userID 必須是 MC 真實 user(OIDC sub);visionA 登入走 MC OIDC,
|
||||
// UserContext.UserID 即 OIDC sub,直接傳入即可(ADR-017 §10 契約細節)。
|
||||
// - objectKey 必須與 FAA GET 的 path key 完全一致(FAA 從 URL path 取 objectKey
|
||||
// 去 MC validate boundary,不一致 FAA 回 object_key_mismatch)。
|
||||
//
|
||||
// 失敗回 ErrServiceTokenFailed / ErrIssueTokenFailed(已包進細節)。
|
||||
IssueDownloadToken(ctx context.Context, userID, objectKey string) (*IssuedDownloadToken, error)
|
||||
}
|
||||
|
||||
// Opts 是 NewClient 的依賴注入。
|
||||
//
|
||||
// 必填:MCBaseURL / ServiceClientID / ServiceClientSecret / TenantID。
|
||||
// HTTPClient / Now / Logger 為 optional(nil 自動填預設)— 方便 unit test 注入 fake。
|
||||
type Opts struct {
|
||||
// MCBaseURL 是 Member Center API base URL(不帶結尾斜線)。
|
||||
MCBaseURL string
|
||||
|
||||
// ServiceClientID / ServiceClientSecret 打 MC /oauth/token 用(client_credentials)。
|
||||
// ⚠️ 技術債:第一階段 PoC 共用 FAA service client(見套件註解)。
|
||||
ServiceClientID string
|
||||
ServiceClientSecret string
|
||||
|
||||
// TenantID 簽 download token 時帶給 MC(須與 FAA validate 的 tenant 一致)。
|
||||
TenantID string
|
||||
|
||||
// DownloadTokenTTLSeconds 是簽 token 時帶給 MC 的 expires_in_seconds。
|
||||
// 0 → 用 defaultDownloadTokenTTLSeconds(120,ADR-017 Q2)。
|
||||
DownloadTokenTTLSeconds int
|
||||
|
||||
// HTTPClient 為 optional;nil 用預設(timeout 10s)。MC call 是輕量 JSON POST。
|
||||
HTTPClient *http.Client
|
||||
|
||||
// Now 為 optional;nil 用 time.Now(service token cache 過期判定用)。
|
||||
Now func() time.Time
|
||||
|
||||
// Logger 為 optional;nil 用 slog.Default()。
|
||||
Logger *slog.Logger
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// 內部常數
|
||||
// ==========================================================================
|
||||
|
||||
const (
|
||||
// downloadDelegateScope 是打 MC /oauth/token 時要的 scope(ADR-017 §10.2/§10.3)。
|
||||
downloadDelegateScope = "files:download.delegate"
|
||||
|
||||
// oauthTokenPath / issuePath 是 MC 的 endpoint path(ADR-017 §10)。
|
||||
oauthTokenPath = "/oauth/token"
|
||||
issuePath = "/file-access/download-tokens"
|
||||
|
||||
// defaultDownloadTokenTTLSeconds 是 download token 預設有效期(秒)—— ADR-017 Q2(傾向 120s)。
|
||||
defaultDownloadTokenTTLSeconds = 120
|
||||
|
||||
// defaultHTTPTimeout 是 MC call 的整體 timeout(OAuth / Issue 都是輕量 JSON)。
|
||||
defaultHTTPTimeout = 10 * time.Second
|
||||
|
||||
// serviceTokenRefreshSkew 是 service token cache 提前刷新的安全邊際——
|
||||
// token 還有少於這個秒數就視為過期、重拿,避免「拿了正好過期的 token」。
|
||||
serviceTokenRefreshSkew = 30 * time.Second
|
||||
|
||||
// issueMethod 是簽 download token 時帶給 MC 的 method(FAA download 是 GET)。
|
||||
issueMethod = "GET"
|
||||
)
|
||||
|
||||
// ==========================================================================
|
||||
// 構造 + 內部 struct
|
||||
// ==========================================================================
|
||||
|
||||
// client 是 DownloadTokenIssuer 的預設實作。
|
||||
type client struct {
|
||||
mcBaseURL string
|
||||
clientID string
|
||||
clientSecret string
|
||||
tenantID string
|
||||
ttlSeconds int
|
||||
|
||||
http *http.Client
|
||||
now func() time.Time
|
||||
logger *slog.Logger
|
||||
|
||||
// service token cache(client_credentials token 可重用到過期前)。
|
||||
mu sync.Mutex
|
||||
cachedToken string
|
||||
cachedTokenExp time.Time // UTC
|
||||
}
|
||||
|
||||
// 編譯時檢查:確保 client 實作 DownloadTokenIssuer。
|
||||
var _ DownloadTokenIssuer = (*client)(nil)
|
||||
|
||||
// NewClient 建立一個 fileaccess client。
|
||||
//
|
||||
// 必填欄位任一為空 → 回 ErrConfigIncomplete(讓 main.go fail-fast,不在「設定不全」
|
||||
// 狀態下把 endpoint 接起來)。
|
||||
func NewClient(opts Opts) (DownloadTokenIssuer, error) {
|
||||
if opts.MCBaseURL == "" || opts.ServiceClientID == "" ||
|
||||
opts.ServiceClientSecret == "" || opts.TenantID == "" {
|
||||
return nil, ErrConfigIncomplete
|
||||
}
|
||||
ttl := opts.DownloadTokenTTLSeconds
|
||||
if ttl <= 0 {
|
||||
ttl = defaultDownloadTokenTTLSeconds
|
||||
}
|
||||
httpClient := opts.HTTPClient
|
||||
if httpClient == nil {
|
||||
httpClient = &http.Client{Timeout: defaultHTTPTimeout}
|
||||
}
|
||||
now := opts.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
logger := opts.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &client{
|
||||
mcBaseURL: strings.TrimRight(opts.MCBaseURL, "/"),
|
||||
clientID: opts.ServiceClientID,
|
||||
clientSecret: opts.ServiceClientSecret,
|
||||
tenantID: opts.TenantID,
|
||||
ttlSeconds: ttl,
|
||||
http: httpClient,
|
||||
now: now,
|
||||
logger: logger,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// GetServiceToken — 打 MC /oauth/token(client_credentials, scope files:download.delegate)
|
||||
// ==========================================================================
|
||||
|
||||
// oauthTokenResponse 是 MC /oauth/token 的 response shape(標準 OAuth2 token response)。
|
||||
type oauthTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"` // 秒
|
||||
Scope string `json:"scope"`
|
||||
}
|
||||
|
||||
// GetServiceToken 取得(或從 cache 重用)MC service access token。
|
||||
//
|
||||
// 帶簡單 cache:token 未過期(含 serviceTokenRefreshSkew 安全邊際)直接回 cache,
|
||||
// 否則打 MC /oauth/token 重拿。goroutine-safe(mutex 保護 cache)。
|
||||
//
|
||||
// 失敗回 ErrServiceTokenFailed(已包細節)。
|
||||
func (c *client) GetServiceToken(ctx context.Context) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
// cache 命中(還有效)→ 直接回
|
||||
if c.cachedToken != "" && c.now().UTC().Add(serviceTokenRefreshSkew).Before(c.cachedTokenExp) {
|
||||
return c.cachedToken, nil
|
||||
}
|
||||
|
||||
form := url.Values{}
|
||||
form.Set("grant_type", "client_credentials")
|
||||
form.Set("client_id", c.clientID)
|
||||
form.Set("client_secret", c.clientSecret)
|
||||
form.Set("scope", downloadDelegateScope)
|
||||
|
||||
endpoint := c.mcBaseURL + oauthTokenPath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint,
|
||||
strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%w: build oauth request: %v", ErrServiceTokenFailed, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
// 網路 / timeout / ctx cancel 都歸 service token 失敗
|
||||
return "", fmt.Errorf("%w: do oauth request: %v", ErrServiceTokenFailed, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, errorBodyReadCap))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
// 安全:不 log secret;body 可能含 error_description(不含 secret),可帶上 status
|
||||
return "", fmt.Errorf("%w: oauth status=%d body=%s", ErrServiceTokenFailed,
|
||||
resp.StatusCode, truncate(string(body)))
|
||||
}
|
||||
if readErr != nil {
|
||||
return "", fmt.Errorf("%w: read oauth body: %v", ErrServiceTokenFailed, readErr)
|
||||
}
|
||||
|
||||
var parsed oauthTokenResponse
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return "", fmt.Errorf("%w: parse oauth body: %v", ErrServiceTokenFailed, err)
|
||||
}
|
||||
if parsed.AccessToken == "" {
|
||||
return "", fmt.Errorf("%w: oauth response missing access_token", ErrServiceTokenFailed)
|
||||
}
|
||||
|
||||
// 更新 cache。expires_in 缺省(0)時保守用 serviceTokenRefreshSkew * 2 當下限,
|
||||
// 避免把過期時間設成「現在」導致每次都 miss。
|
||||
expiresIn := time.Duration(parsed.ExpiresIn) * time.Second
|
||||
if expiresIn <= serviceTokenRefreshSkew {
|
||||
expiresIn = 2 * serviceTokenRefreshSkew
|
||||
}
|
||||
c.cachedToken = parsed.AccessToken
|
||||
c.cachedTokenExp = c.now().UTC().Add(expiresIn)
|
||||
|
||||
c.logger.DebugContext(ctx, "fileaccess.service_token.refreshed",
|
||||
slog.String("scope", parsed.Scope),
|
||||
slog.Int("expires_in_sec", parsed.ExpiresIn),
|
||||
)
|
||||
return parsed.AccessToken, nil
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// IssueDownloadToken — 打 MC POST /file-access/download-tokens(Issue)簽 fdt_ token
|
||||
// ==========================================================================
|
||||
|
||||
// issueRequest 是 MC Issue 的 request body(ADR-017 §10 實測契約)。
|
||||
type issueRequest struct {
|
||||
TenantID string `json:"tenant_id"`
|
||||
UserID string `json:"user_id"`
|
||||
ObjectKey string `json:"object_key"`
|
||||
Method string `json:"method"`
|
||||
ExpiresInSeconds int `json:"expires_in_seconds"`
|
||||
}
|
||||
|
||||
// issueResponse 是 MC Issue 的 response shape(ADR-017 §10.3 實測)。
|
||||
type issueResponse struct {
|
||||
Token string `json:"token"` // fdt_<base64url>
|
||||
TokenType string `json:"token_type"` // "file_download"
|
||||
ExpiresAt string `json:"expires_at"` // RFC3339
|
||||
Scope string `json:"scope"` // "files:download.read"
|
||||
}
|
||||
|
||||
// IssueDownloadToken 對指定 (userID, objectKey) 簽一個 MC download token(fdt_)。
|
||||
//
|
||||
// 流程(ADR-017 §10.3 e2e 藍本):
|
||||
// 1. GetServiceToken — 拿(或重用)MC service token
|
||||
// 2. 帶該 token + tenant/user/object_key/method/expires_in 打 MC Issue
|
||||
// 3. 回 IssuedDownloadToken(Token / ExpiresAt / Scope)
|
||||
func (c *client) IssueDownloadToken(ctx context.Context, userID, objectKey string) (*IssuedDownloadToken, error) {
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("%w: userID is required", ErrIssueTokenFailed)
|
||||
}
|
||||
if objectKey == "" {
|
||||
return nil, fmt.Errorf("%w: objectKey is required", ErrIssueTokenFailed)
|
||||
}
|
||||
|
||||
serviceToken, err := c.GetServiceToken(ctx)
|
||||
if err != nil {
|
||||
return nil, err // 已是 ErrServiceTokenFailed
|
||||
}
|
||||
|
||||
bodyJSON, err := json.Marshal(issueRequest{
|
||||
TenantID: c.tenantID,
|
||||
UserID: userID,
|
||||
ObjectKey: objectKey,
|
||||
Method: issueMethod,
|
||||
ExpiresInSeconds: c.ttlSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: marshal issue request: %v", ErrIssueTokenFailed, err)
|
||||
}
|
||||
|
||||
endpoint := c.mcBaseURL + issuePath
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(bodyJSON))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: build issue request: %v", ErrIssueTokenFailed, err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Accept", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+serviceToken)
|
||||
|
||||
resp, err := c.http.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: do issue request: %v", ErrIssueTokenFailed, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, readErr := io.ReadAll(io.LimitReader(resp.Body, errorBodyReadCap))
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%w: issue status=%d body=%s", ErrIssueTokenFailed,
|
||||
resp.StatusCode, truncate(string(body)))
|
||||
}
|
||||
if readErr != nil {
|
||||
return nil, fmt.Errorf("%w: read issue body: %v", ErrIssueTokenFailed, readErr)
|
||||
}
|
||||
|
||||
var parsed issueResponse
|
||||
if err := json.Unmarshal(body, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("%w: parse issue body: %v", ErrIssueTokenFailed, err)
|
||||
}
|
||||
if parsed.Token == "" {
|
||||
return nil, fmt.Errorf("%w: issue response missing token", ErrIssueTokenFailed)
|
||||
}
|
||||
|
||||
// expires_at 解析失敗不 hard fail(token 本身可用,TTL 只是給 client 參考);
|
||||
// 解析成功則回填 UTC。
|
||||
var expiresAt time.Time
|
||||
if parsed.ExpiresAt != "" {
|
||||
if t, perr := time.Parse(time.RFC3339, parsed.ExpiresAt); perr == nil {
|
||||
expiresAt = t.UTC()
|
||||
} else {
|
||||
c.logger.WarnContext(ctx, "fileaccess.issue.expires_at_parse_failed",
|
||||
slog.String("raw", parsed.ExpiresAt),
|
||||
slog.String("err", perr.Error()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
c.logger.InfoContext(ctx, "fileaccess.issue.success",
|
||||
slog.String("user_hash", hashShort(userID)),
|
||||
slog.String("object_key_hash", hashShort(objectKey)),
|
||||
slog.String("scope", parsed.Scope),
|
||||
slog.Int("ttl_sec", c.ttlSeconds),
|
||||
)
|
||||
|
||||
return &IssuedDownloadToken{
|
||||
Token: parsed.Token,
|
||||
TokenType: parsed.TokenType,
|
||||
ExpiresAt: expiresAt,
|
||||
Scope: parsed.Scope,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// helpers
|
||||
// ==========================================================================
|
||||
|
||||
// errorBodyReadCap 是失敗 response 從 body 讀進記憶體的最大量(4KB)—— 避免惡意大 body。
|
||||
const errorBodyReadCap = 4 * 1024
|
||||
|
||||
// truncate 把 error body 截短(log / error message 用),避免超長 / 換行污染 log。
|
||||
func truncate(s string) string {
|
||||
s = strings.ReplaceAll(strings.TrimSpace(s), "\n", " ")
|
||||
const max = 256
|
||||
if len(s) > max {
|
||||
return s[:max] + "...(truncated)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// hashShort 對輸入做 SHA-256 取前 8 hex char,給 log 用(PII / object_key 保護)。
|
||||
//
|
||||
// 不存原始 user_id / object_key 進 log,避免 log file 洩漏 OIDC sub 或 storage 路徑。
|
||||
// 對齊 internal/conversion.hashUserID 的遮罩慣例。
|
||||
func hashShort(s string) string {
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(s))
|
||||
return hex.EncodeToString(sum[:])[:8]
|
||||
}
|
||||
360
visionA-backend/internal/fileaccess/client_test.go
Normal file
360
visionA-backend/internal/fileaccess/client_test.go
Normal file
@ -0,0 +1,360 @@
|
||||
package fileaccess
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ==========================================================================
|
||||
// 測試輔助
|
||||
// ==========================================================================
|
||||
|
||||
// newTestClient 用 httptest server base URL 建一個 *client(拿具體型別,方便測 cache)。
|
||||
func newTestClient(t *testing.T, baseURL string, ttlSec int) *client {
|
||||
t.Helper()
|
||||
iss, err := NewClient(Opts{
|
||||
MCBaseURL: baseURL,
|
||||
ServiceClientID: "test-client-id",
|
||||
ServiceClientSecret: "test-secret",
|
||||
TenantID: "test-tenant",
|
||||
DownloadTokenTTLSeconds: ttlSec,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
c, ok := iss.(*client)
|
||||
if !ok {
|
||||
t.Fatalf("NewClient did not return *client, got %T", iss)
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// NewClient
|
||||
// ==========================================================================
|
||||
|
||||
func TestNewClient_returnsErrWhenConfigIncomplete(t *testing.T) {
|
||||
cases := map[string]Opts{
|
||||
"missing MCBaseURL": {ServiceClientID: "c", ServiceClientSecret: "s", TenantID: "t"},
|
||||
"missing clientID": {MCBaseURL: "http://mc", ServiceClientSecret: "s", TenantID: "t"},
|
||||
"missing clientSecret": {MCBaseURL: "http://mc", ServiceClientID: "c", TenantID: "t"},
|
||||
"missing tenant": {MCBaseURL: "http://mc", ServiceClientID: "c", ServiceClientSecret: "s"},
|
||||
}
|
||||
for name, opts := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := NewClient(opts)
|
||||
if !errors.Is(err, ErrConfigIncomplete) {
|
||||
t.Fatalf("want ErrConfigIncomplete, got %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewClient_defaultsTTLWhenNonPositive(t *testing.T) {
|
||||
iss, err := NewClient(Opts{
|
||||
MCBaseURL: "http://mc", ServiceClientID: "c", ServiceClientSecret: "s", TenantID: "t",
|
||||
DownloadTokenTTLSeconds: 0,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewClient: %v", err)
|
||||
}
|
||||
if got := iss.(*client).ttlSeconds; got != defaultDownloadTokenTTLSeconds {
|
||||
t.Fatalf("ttl default: want %d, got %d", defaultDownloadTokenTTLSeconds, got)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// GetServiceToken
|
||||
// ==========================================================================
|
||||
|
||||
func TestGetServiceToken_successAndSendsClientCredentials(t *testing.T) {
|
||||
var gotGrant, gotScope, gotClientID, gotSecret string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != oauthTokenPath {
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
if ct := r.Header.Get("Content-Type"); !strings.HasPrefix(ct, "application/x-www-form-urlencoded") {
|
||||
t.Errorf("want form content-type, got %s", ct)
|
||||
}
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
form, _ := url.ParseQuery(string(body))
|
||||
gotGrant = form.Get("grant_type")
|
||||
gotScope = form.Get("scope")
|
||||
gotClientID = form.Get("client_id")
|
||||
gotSecret = form.Get("client_secret")
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{
|
||||
AccessToken: "svc-access-token", TokenType: "Bearer", ExpiresIn: 3600,
|
||||
Scope: downloadDelegateScope,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
tok, err := c.GetServiceToken(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("GetServiceToken: %v", err)
|
||||
}
|
||||
if tok != "svc-access-token" {
|
||||
t.Fatalf("token: want svc-access-token, got %s", tok)
|
||||
}
|
||||
if gotGrant != "client_credentials" {
|
||||
t.Errorf("grant_type: want client_credentials, got %s", gotGrant)
|
||||
}
|
||||
if gotScope != downloadDelegateScope {
|
||||
t.Errorf("scope: want %s, got %s", downloadDelegateScope, gotScope)
|
||||
}
|
||||
if gotClientID != "test-client-id" || gotSecret != "test-secret" {
|
||||
t.Errorf("client creds not sent: id=%s secret=%s", gotClientID, gotSecret)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServiceToken_cachesTokenAcrossCalls(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{
|
||||
AccessToken: "svc-access-token", TokenType: "Bearer", ExpiresIn: 3600,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
for i := 0; i < 3; i++ {
|
||||
if _, err := c.GetServiceToken(context.Background()); err != nil {
|
||||
t.Fatalf("call %d: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 1 {
|
||||
t.Fatalf("want 1 oauth hit (cached), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServiceToken_refetchesWhenExpired(t *testing.T) {
|
||||
var hits int32
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
atomic.AddInt32(&hits, 1)
|
||||
// expires_in 短到一定要重拿
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{
|
||||
AccessToken: "svc-access-token", TokenType: "Bearer", ExpiresIn: 10,
|
||||
})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
// 用可控時鐘:第一次 now=t0;第二次 now=t0+1h(必然過期)
|
||||
base := time.Date(2026, 6, 7, 0, 0, 0, 0, time.UTC)
|
||||
var cur time.Time = base
|
||||
c.now = func() time.Time { return cur }
|
||||
|
||||
if _, err := c.GetServiceToken(context.Background()); err != nil {
|
||||
t.Fatalf("first call: %v", err)
|
||||
}
|
||||
cur = base.Add(time.Hour)
|
||||
if _, err := c.GetServiceToken(context.Background()); err != nil {
|
||||
t.Fatalf("second call: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&hits); got != 2 {
|
||||
t.Fatalf("want 2 oauth hits (refetch after expiry), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServiceToken_failsOnNon200(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusUnauthorized)
|
||||
_, _ = w.Write([]byte(`{"error":"invalid_client"}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
_, err := c.GetServiceToken(context.Background())
|
||||
if !errors.Is(err, ErrServiceTokenFailed) {
|
||||
t.Fatalf("want ErrServiceTokenFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetServiceToken_failsOnMissingAccessToken(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{TokenType: "Bearer", ExpiresIn: 3600})
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
_, err := c.GetServiceToken(context.Background())
|
||||
if !errors.Is(err, ErrServiceTokenFailed) {
|
||||
t.Fatalf("want ErrServiceTokenFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// IssueDownloadToken
|
||||
// ==========================================================================
|
||||
|
||||
func TestIssueDownloadToken_successFullChain(t *testing.T) {
|
||||
var issueAuth string
|
||||
var issueBody issueRequest
|
||||
expiresAt := time.Date(2026, 6, 7, 12, 2, 0, 0, time.UTC)
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case oauthTokenPath:
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{
|
||||
AccessToken: "svc-access-token", TokenType: "Bearer", ExpiresIn: 3600,
|
||||
})
|
||||
case issuePath:
|
||||
issueAuth = r.Header.Get("Authorization")
|
||||
_ = json.NewDecoder(r.Body).Decode(&issueBody)
|
||||
writeJSON(w, http.StatusOK, issueResponse{
|
||||
Token: "fdt_abc123", TokenType: "file_download",
|
||||
ExpiresAt: expiresAt.Format(time.RFC3339), Scope: "files:download.read",
|
||||
})
|
||||
default:
|
||||
t.Errorf("unexpected path %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
res, err := c.IssueDownloadToken(context.Background(), "user-oidc-sub", "models/u/j.nef")
|
||||
if err != nil {
|
||||
t.Fatalf("IssueDownloadToken: %v", err)
|
||||
}
|
||||
|
||||
if res.Token != "fdt_abc123" {
|
||||
t.Errorf("token: want fdt_abc123, got %s", res.Token)
|
||||
}
|
||||
if res.TokenType != "file_download" {
|
||||
t.Errorf("token_type: want file_download, got %s", res.TokenType)
|
||||
}
|
||||
if res.Scope != "files:download.read" {
|
||||
t.Errorf("scope: want files:download.read, got %s", res.Scope)
|
||||
}
|
||||
if !res.ExpiresAt.Equal(expiresAt) {
|
||||
t.Errorf("expires_at: want %v, got %v", expiresAt, res.ExpiresAt)
|
||||
}
|
||||
// Issue 必須帶 service token 當 Bearer
|
||||
if issueAuth != "Bearer svc-access-token" {
|
||||
t.Errorf("issue auth header: want 'Bearer svc-access-token', got %q", issueAuth)
|
||||
}
|
||||
// Issue body 契約(ADR-017 §10)
|
||||
if issueBody.TenantID != "test-tenant" {
|
||||
t.Errorf("tenant_id: want test-tenant, got %s", issueBody.TenantID)
|
||||
}
|
||||
if issueBody.UserID != "user-oidc-sub" {
|
||||
t.Errorf("user_id: want user-oidc-sub, got %s", issueBody.UserID)
|
||||
}
|
||||
if issueBody.ObjectKey != "models/u/j.nef" {
|
||||
t.Errorf("object_key: want models/u/j.nef, got %s", issueBody.ObjectKey)
|
||||
}
|
||||
if issueBody.Method != "GET" {
|
||||
t.Errorf("method: want GET, got %s", issueBody.Method)
|
||||
}
|
||||
if issueBody.ExpiresInSeconds != 120 {
|
||||
t.Errorf("expires_in_seconds: want 120, got %d", issueBody.ExpiresInSeconds)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueDownloadToken_validatesArgs(t *testing.T) {
|
||||
c := newTestClient(t, "http://unused", 120)
|
||||
if _, err := c.IssueDownloadToken(context.Background(), "", "key"); !errors.Is(err, ErrIssueTokenFailed) {
|
||||
t.Errorf("empty userID: want ErrIssueTokenFailed, got %v", err)
|
||||
}
|
||||
if _, err := c.IssueDownloadToken(context.Background(), "user", ""); !errors.Is(err, ErrIssueTokenFailed) {
|
||||
t.Errorf("empty objectKey: want ErrIssueTokenFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueDownloadToken_failsWhenServiceTokenFails(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// oauth 永遠 500
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
_, err := c.IssueDownloadToken(context.Background(), "user", "key")
|
||||
if !errors.Is(err, ErrServiceTokenFailed) {
|
||||
t.Fatalf("want ErrServiceTokenFailed (propagated), got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueDownloadToken_failsOnIssueNon200(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case oauthTokenPath:
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{AccessToken: "tok", ExpiresIn: 3600})
|
||||
case issuePath:
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"error":"forbidden"}`))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
_, err := c.IssueDownloadToken(context.Background(), "user", "key")
|
||||
if !errors.Is(err, ErrIssueTokenFailed) {
|
||||
t.Fatalf("want ErrIssueTokenFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueDownloadToken_failsOnMissingToken(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case oauthTokenPath:
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{AccessToken: "tok", ExpiresIn: 3600})
|
||||
case issuePath:
|
||||
writeJSON(w, http.StatusOK, issueResponse{TokenType: "file_download"}) // 無 token
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
_, err := c.IssueDownloadToken(context.Background(), "user", "key")
|
||||
if !errors.Is(err, ErrIssueTokenFailed) {
|
||||
t.Fatalf("want ErrIssueTokenFailed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueDownloadToken_toleratesBadExpiresAt(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case oauthTokenPath:
|
||||
writeJSON(w, http.StatusOK, oauthTokenResponse{AccessToken: "tok", ExpiresIn: 3600})
|
||||
case issuePath:
|
||||
writeJSON(w, http.StatusOK, issueResponse{
|
||||
Token: "fdt_x", TokenType: "file_download", ExpiresAt: "not-a-date",
|
||||
})
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := newTestClient(t, srv.URL, 120)
|
||||
res, err := c.IssueDownloadToken(context.Background(), "user", "key")
|
||||
if err != nil {
|
||||
t.Fatalf("should tolerate bad expires_at, got err %v", err)
|
||||
}
|
||||
if res.Token != "fdt_x" {
|
||||
t.Errorf("token: want fdt_x, got %s", res.Token)
|
||||
}
|
||||
if !res.ExpiresAt.IsZero() {
|
||||
t.Errorf("bad expires_at should leave zero time, got %v", res.ExpiresAt)
|
||||
}
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// helpers(test-local)
|
||||
// ==========================================================================
|
||||
|
||||
func writeJSON(w http.ResponseWriter, status int, v any) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(v)
|
||||
}
|
||||
@ -57,6 +57,18 @@ type Model struct {
|
||||
FileSize int64 `json:"fileSize"`
|
||||
FileChecksum string `json:"fileChecksum,omitempty"` // sha256 hex
|
||||
|
||||
// FAAObjectKey 是該 model 在 File Access Agent 上的 object key(ADR-017 (a) B1)。
|
||||
//
|
||||
// 只有「轉檔→promote 進 FAA」類 model(Source=converted)有值——promote 時由
|
||||
// PromoteToModels 寫入(= converter promote 的 target_object_key,命名 models/{userID}/{jobID}.nef)。
|
||||
// 上傳類 model(Source=uploaded)只在 visionA 自己 storage、不在 FAA,此欄位留空。
|
||||
//
|
||||
// model download endpoint(GET /api/models/:id/download)用此欄位(非 StorageKey)去 MC
|
||||
// Issue download token + 組 FAA URL;留空時回 501(第一階段不支援上傳類 FAA 直連)。
|
||||
//
|
||||
// nullable:DB 為 NULL(database.md §2.3 待補欄位,見回報);JSON `-` 完全不序列化到 API 回應,不向前端揭露 FAA 內部 object key。
|
||||
FAAObjectKey string `json:"-"` // 不對前端揭露(內部 storage key,ADR-017 決策 2 防曝露)
|
||||
|
||||
// 模型 metadata(可選)
|
||||
TargetChip string `json:"targetChip,omitempty"`
|
||||
InputShape []int `json:"inputShape,omitempty"`
|
||||
|
||||
163
visionA-frontend/src/components/models/model-card.test.tsx
Normal file
163
visionA-frontend/src/components/models/model-card.test.tsx
Normal file
@ -0,0 +1,163 @@
|
||||
/**
|
||||
* ModelCard 互動測試(Phase 0.9 模型下載)
|
||||
*
|
||||
* 覆蓋:
|
||||
* - 下載按鈕顯示條件:converted + ready 顯示;uploaded / 非 ready 不顯示
|
||||
* - 點下載按鈕:preventDefault + stopPropagation(不觸發外層 <Link> 導航)
|
||||
* - 下載中:按鈕 disabled + 顯示「下載中」
|
||||
* - 成功 → toast.success;失敗 → toast.error(用 backend code 對應 i18n)
|
||||
* - 其他卡片下載中時,本卡按鈕 disabled
|
||||
*
|
||||
* Mock:
|
||||
* - sonner toast → 攔截 success / error
|
||||
* - model-store downloadModel action → vi.spyOn 控制回傳
|
||||
* - next/navigation(jsdom 無 app router context)
|
||||
*/
|
||||
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi, type Mock } from "vitest";
|
||||
|
||||
import { LocaleProvider } from "@/lib/i18n/context";
|
||||
import { useModelStore, type ModelSummary } from "@/stores/model-store";
|
||||
|
||||
import { ModelCard } from "./model-card";
|
||||
|
||||
vi.mock("sonner", () => {
|
||||
const success = vi.fn();
|
||||
const error = vi.fn();
|
||||
return { toast: Object.assign(vi.fn(), { success, error }) };
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
useRouter: () => ({
|
||||
push: vi.fn(),
|
||||
replace: vi.fn(),
|
||||
back: vi.fn(),
|
||||
forward: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
prefetch: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
import { toast } from "sonner";
|
||||
|
||||
const convertedReady: ModelSummary = {
|
||||
id: "m1",
|
||||
name: "YOLOv5s",
|
||||
targetChip: "kl520",
|
||||
fileSize: 1024,
|
||||
source: "converted",
|
||||
status: "ready",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
function renderCard(model: ModelSummary) {
|
||||
return render(
|
||||
<LocaleProvider>
|
||||
<ModelCard model={model} />
|
||||
</LocaleProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useModelStore.setState({ downloadingId: null });
|
||||
(toast.success as Mock).mockReset();
|
||||
(toast.error as Mock).mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
useModelStore.setState({ downloadingId: null });
|
||||
});
|
||||
|
||||
describe("ModelCard 下載按鈕顯示條件", () => {
|
||||
it("converted + ready → 顯示下載按鈕", () => {
|
||||
renderCard(convertedReady);
|
||||
expect(screen.getByTestId("model-card-download")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("uploaded(即使 ready)→ 不顯示下載按鈕", () => {
|
||||
renderCard({ ...convertedReady, source: "uploaded" });
|
||||
expect(screen.queryByTestId("model-card-download")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("converted 但 scanning → 不顯示下載按鈕", () => {
|
||||
renderCard({ ...convertedReady, status: "scanning" });
|
||||
expect(screen.queryByTestId("model-card-download")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("preset → 不顯示下載按鈕", () => {
|
||||
renderCard({ ...convertedReady, source: "preset" });
|
||||
expect(screen.queryByTestId("model-card-download")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("ModelCard 下載互動", () => {
|
||||
it("點下載按鈕:preventDefault + stopPropagation(不觸發 Link 導航)", async () => {
|
||||
const spy = vi
|
||||
.spyOn(useModelStore.getState(), "downloadModel")
|
||||
.mockResolvedValue({ ok: true });
|
||||
|
||||
renderCard(convertedReady);
|
||||
const btn = screen.getByTestId("model-card-download");
|
||||
|
||||
// 自建 event 驗 preventDefault / stopPropagation 都被呼叫
|
||||
const event = new MouseEvent("click", { bubbles: true, cancelable: true });
|
||||
const preventDefault = vi.spyOn(event, "preventDefault");
|
||||
const stopPropagation = vi.spyOn(event, "stopPropagation");
|
||||
fireEvent(btn, event);
|
||||
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
expect(stopPropagation).toHaveBeenCalled();
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith(convertedReady));
|
||||
});
|
||||
|
||||
it("下載成功 → toast.success", async () => {
|
||||
vi.spyOn(useModelStore.getState(), "downloadModel").mockResolvedValue({ ok: true });
|
||||
renderCard(convertedReady);
|
||||
fireEvent.click(screen.getByTestId("model-card-download"));
|
||||
await waitFor(() => expect(toast.success).toHaveBeenCalledOnce());
|
||||
expect(toast.error).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("下載失敗(forbidden)→ toast.error,描述用對應 i18n", async () => {
|
||||
vi.spyOn(useModelStore.getState(), "downloadModel").mockResolvedValue({
|
||||
ok: false,
|
||||
code: "forbidden",
|
||||
message: "no",
|
||||
});
|
||||
renderCard(convertedReady);
|
||||
fireEvent.click(screen.getByTestId("model-card-download"));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledOnce());
|
||||
// 第二參數 description 對應 models.download.error.forbidden(繁中)
|
||||
const call = (toast.error as Mock).mock.calls[0];
|
||||
expect(call[1].description).toBe("你沒有權限下載此模型");
|
||||
});
|
||||
|
||||
it("未知 code → toast.error 描述退化成 unknown 文案", async () => {
|
||||
vi.spyOn(useModelStore.getState(), "downloadModel").mockResolvedValue({
|
||||
ok: false,
|
||||
code: "some_unmapped_code",
|
||||
message: "x",
|
||||
});
|
||||
renderCard(convertedReady);
|
||||
fireEvent.click(screen.getByTestId("model-card-download"));
|
||||
await waitFor(() => expect(toast.error).toHaveBeenCalledOnce());
|
||||
const call = (toast.error as Mock).mock.calls[0];
|
||||
expect(call[1].description).toBe("下載失敗,請稍後再試");
|
||||
});
|
||||
|
||||
it("其他卡片下載中(downloadingId != null)→ 本卡按鈕 disabled", () => {
|
||||
useModelStore.setState({ downloadingId: "other-model" });
|
||||
renderCard(convertedReady);
|
||||
expect(screen.getByTestId("model-card-download")).toBeDisabled();
|
||||
});
|
||||
|
||||
it("本卡下載中 → 顯示「下載中…」且 disabled", () => {
|
||||
useModelStore.setState({ downloadingId: "m1" });
|
||||
renderCard(convertedReady);
|
||||
const btn = screen.getByTestId("model-card-download");
|
||||
expect(btn).toBeDisabled();
|
||||
expect(btn).toHaveTextContent("下載中");
|
||||
});
|
||||
});
|
||||
@ -10,15 +10,27 @@
|
||||
* 不再用 local-tool 的 accuracy / fps / supportedHardware(那些是內建 preset 的附加 metadata)
|
||||
* - 狀態 Badge 對齊 flow-model-upload §5.4(uploading / scanning / ready / rejected)
|
||||
* - 比較模式(Checkbox)保留,但預設關閉(雛形不做 comparison)
|
||||
* - Phase 0.9:可下載的 model(converted + ready)顯示「下載」按鈕,走 FAA delegated download。
|
||||
* 整張卡片包在 <Link> 裡 → 下載按鈕需 preventDefault + stopPropagation 避免觸發導航。
|
||||
*/
|
||||
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { useT } from "@/lib/i18n/context";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ModelStatus, ModelSummary } from "@/stores/model-store";
|
||||
import {
|
||||
isModelDownloadable,
|
||||
useModelStore,
|
||||
type ModelStatus,
|
||||
type ModelSummary,
|
||||
} from "@/stores/model-store";
|
||||
|
||||
interface ModelCardProps {
|
||||
model: ModelSummary;
|
||||
@ -43,6 +55,39 @@ export function ModelCard({ model }: ModelCardProps) {
|
||||
const t = useT();
|
||||
const statusMeta = STATUS_VARIANT[model.status];
|
||||
|
||||
const downloadModel = useModelStore((s) => s.downloadModel);
|
||||
// per-card loading:只在「下載中的是這張卡」時顯示 spinner。
|
||||
const isDownloading = useModelStore((s) => s.downloadingId === model.id);
|
||||
// 任一卡片下載中時,其他卡片的下載按鈕 disable(store 同時只允許一個下載)。
|
||||
const isAnyDownloading = useModelStore((s) => s.downloadingId !== null);
|
||||
const [localBusy, setLocalBusy] = useState(false);
|
||||
|
||||
const downloadable = isModelDownloadable(model);
|
||||
|
||||
const handleDownload = async (e: React.MouseEvent) => {
|
||||
// 整張卡片是 <Link> → 阻止冒泡到外層導航。
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (localBusy || isAnyDownloading) return;
|
||||
|
||||
setLocalBusy(true);
|
||||
const result = await downloadModel(model);
|
||||
setLocalBusy(false);
|
||||
|
||||
if (result.ok) {
|
||||
toast.success(t("models.download.toast.start"), {
|
||||
description: t("models.download.toast.hint"),
|
||||
});
|
||||
} else {
|
||||
// 用 backend code 對應 i18n;找不到對應 key 時 t() 回 key 本身,仍退化到 unknown。
|
||||
const key = `models.download.error.${result.code}`;
|
||||
const desc = t(key);
|
||||
toast.error(t("models.download.error.title"), {
|
||||
description: desc === key ? t("models.download.error.unknown") : desc,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Link href={`/models/${model.id}`} data-testid="model-card">
|
||||
<Card
|
||||
@ -93,6 +138,31 @@ export function ModelCard({ model }: ModelCardProps) {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{downloadable && (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDownload}
|
||||
disabled={isAnyDownloading || localBusy}
|
||||
aria-label={t("models.action.download.aria")}
|
||||
data-testid="model-card-download"
|
||||
>
|
||||
{isDownloading || localBusy ? (
|
||||
<>
|
||||
<Spinner size="sm" label={t("models.action.downloading")} />
|
||||
{t("models.action.downloading")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<DownloadIcon aria-hidden />
|
||||
{t("models.action.download")}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
|
||||
292
visionA-frontend/src/lib/api/model-download.test.ts
Normal file
292
visionA-frontend/src/lib/api/model-download.test.ts
Normal file
@ -0,0 +1,292 @@
|
||||
/**
|
||||
* Model Download API Client 單元測試(Phase 0.9)
|
||||
*
|
||||
* 覆蓋:
|
||||
* 1. getModelDownload — 200 正規化 / 404 / 403 / 501 / 502 / parse 缺欄
|
||||
* 2. downloadModelFile — happy(fetch + Bearer → blob → anchor click)/ FAA 非 2xx / CORS network_error / abort
|
||||
* 3. triggerBlobDownload — anchor 屬性 + revokeObjectURL
|
||||
* 4. deriveDownloadFilename — 從 URL path 取檔名 / fallback
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
deriveDownloadFilename,
|
||||
downloadModelFile,
|
||||
getModelDownload,
|
||||
ModelDownloadError,
|
||||
triggerBlobDownload,
|
||||
} from "./model-download";
|
||||
|
||||
function jsonResponse(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMock = vi.fn();
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* 1. getModelDownload */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("getModelDownload", () => {
|
||||
it("200 → 正規化 { downloadUrl, token, expiresAt }(snake_case)", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
download_url: "https://faa.example.com:5081/files/models/u1/j1.nef",
|
||||
token: "fdt_abc123",
|
||||
expires_at: "2026-06-07T12:02:00Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const grant = await getModelDownload("m1");
|
||||
expect(grant.downloadUrl).toBe(
|
||||
"https://faa.example.com:5081/files/models/u1/j1.nef",
|
||||
);
|
||||
expect(grant.token).toBe("fdt_abc123");
|
||||
expect(grant.expiresAt).toBe("2026-06-07T12:02:00Z");
|
||||
});
|
||||
|
||||
it("camelCase 也吃(downloadUrl / expiresAt)", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: {
|
||||
downloadUrl: "https://faa/x.nef",
|
||||
token: "fdt_x",
|
||||
expiresAt: "2026-06-07T00:00:00Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
const grant = await getModelDownload("m1");
|
||||
expect(grant.downloadUrl).toBe("https://faa/x.nef");
|
||||
expect(grant.token).toBe("fdt_x");
|
||||
});
|
||||
|
||||
it("空 modelId → validation_failed(不打 API)", async () => {
|
||||
await expect(getModelDownload("")).rejects.toMatchObject({
|
||||
name: "ModelDownloadError",
|
||||
code: "validation_failed",
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[404, "model_not_found"],
|
||||
[403, "forbidden"],
|
||||
[501, "upload_not_supported"],
|
||||
[502, "sign_failed"],
|
||||
])("backend %i → ModelDownloadError code=%s(小寫)", async (status, code) => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({ success: false, error: { code, message: "x" } }, status),
|
||||
);
|
||||
await expect(getModelDownload("m1")).rejects.toMatchObject({
|
||||
name: "ModelDownloadError",
|
||||
status,
|
||||
code,
|
||||
});
|
||||
});
|
||||
|
||||
it("200 但缺 token → parse_error", async () => {
|
||||
fetchMock.mockResolvedValue(
|
||||
jsonResponse({
|
||||
success: true,
|
||||
data: { download_url: "https://faa/x.nef" },
|
||||
}),
|
||||
);
|
||||
await expect(getModelDownload("m1")).rejects.toMatchObject({
|
||||
code: "parse_error",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* 2. downloadModelFile */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("downloadModelFile", () => {
|
||||
let createObjectURL: ReturnType<typeof vi.fn>;
|
||||
let revokeObjectURL: ReturnType<typeof vi.fn>;
|
||||
let clickSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
createObjectURL = vi.fn(() => "blob:mock-url");
|
||||
revokeObjectURL = vi.fn();
|
||||
globalThis.URL.createObjectURL = createObjectURL as unknown as typeof URL.createObjectURL;
|
||||
globalThis.URL.revokeObjectURL = revokeObjectURL as unknown as typeof URL.revokeObjectURL;
|
||||
// anchor.click 在 jsdom 預設不觸發 navigation,但我們仍想斷言它被呼叫
|
||||
clickSpy = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("happy path:帶 Authorization Bearer → blob → anchor click + 延遲 revoke", async () => {
|
||||
// 直接給帶 .blob() 的物件,避免 fake timers 下 jsdom Response/Blob stream 內部報錯
|
||||
fetchMock.mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: new Headers(),
|
||||
blob: () => Promise.resolve(new Blob(["nef-bytes"])),
|
||||
} as unknown as Response);
|
||||
|
||||
await downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef");
|
||||
|
||||
// 驗 fetch 帶對的 header + credentials omit
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://faa/x.nef",
|
||||
expect.objectContaining({
|
||||
method: "GET",
|
||||
credentials: "omit",
|
||||
headers: { Authorization: "Bearer fdt_tok" },
|
||||
}),
|
||||
);
|
||||
expect(createObjectURL).toHaveBeenCalledOnce();
|
||||
expect(clickSpy).toHaveBeenCalledOnce();
|
||||
// revoke 延遲觸發
|
||||
expect(revokeObjectURL).not.toHaveBeenCalled();
|
||||
vi.advanceTimersByTime(1000);
|
||||
expect(revokeObjectURL).toHaveBeenCalledWith("blob:mock-url");
|
||||
});
|
||||
|
||||
it("FAA 非 2xx → download_failed(帶 status)", async () => {
|
||||
fetchMock.mockResolvedValue(new Response("nope", { status: 404 }));
|
||||
await expect(
|
||||
downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef"),
|
||||
).rejects.toMatchObject({ code: "download_failed", status: 404 });
|
||||
});
|
||||
|
||||
it("fetch throw(CORS / 連不上)→ network_error", async () => {
|
||||
fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
|
||||
await expect(
|
||||
downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef"),
|
||||
).rejects.toMatchObject({ code: "network_error" });
|
||||
});
|
||||
|
||||
it("AbortError → aborted", async () => {
|
||||
const abortErr = new Error("The operation was aborted");
|
||||
abortErr.name = "AbortError";
|
||||
fetchMock.mockRejectedValue(abortErr);
|
||||
await expect(
|
||||
downloadModelFile("https://faa/x.nef", "fdt_tok", "x.nef"),
|
||||
).rejects.toMatchObject({ code: "aborted" });
|
||||
});
|
||||
|
||||
it("空 token → validation_failed(不打 fetch)", async () => {
|
||||
await expect(
|
||||
downloadModelFile("https://faa/x.nef", "", "x.nef"),
|
||||
).rejects.toMatchObject({ code: "validation_failed" });
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* 3. triggerBlobDownload */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("triggerBlobDownload", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
globalThis.URL.createObjectURL = vi.fn(
|
||||
() => "blob:trigger-url",
|
||||
) as unknown as typeof URL.createObjectURL;
|
||||
globalThis.URL.revokeObjectURL = vi.fn() as unknown as typeof URL.revokeObjectURL;
|
||||
});
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
it("建立 anchor、設 download 屬性、click 後從 DOM 移除", () => {
|
||||
const clickSpy = vi
|
||||
.spyOn(HTMLAnchorElement.prototype, "click")
|
||||
.mockImplementation(() => {});
|
||||
let capturedDownload = "";
|
||||
let capturedHref = "";
|
||||
const appendSpy = vi
|
||||
.spyOn(document.body, "appendChild")
|
||||
.mockImplementation((node) => {
|
||||
const a = node as HTMLAnchorElement;
|
||||
capturedDownload = a.download;
|
||||
capturedHref = a.href;
|
||||
return node;
|
||||
});
|
||||
const removeSpy = vi
|
||||
.spyOn(document.body, "removeChild")
|
||||
.mockImplementation((node) => node);
|
||||
|
||||
triggerBlobDownload(new Blob(["x"]), "my-model.nef");
|
||||
|
||||
expect(capturedDownload).toBe("my-model.nef");
|
||||
expect(capturedHref).toContain("blob:trigger-url");
|
||||
expect(clickSpy).toHaveBeenCalledOnce();
|
||||
expect(appendSpy).toHaveBeenCalledOnce();
|
||||
expect(removeSpy).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* 4. deriveDownloadFilename */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("deriveDownloadFilename", () => {
|
||||
it("從 URL path 取最後一段含副檔名", () => {
|
||||
expect(
|
||||
deriveDownloadFilename(
|
||||
"https://faa.example.com:5081/files/models/u1/j1.nef",
|
||||
"YOLOv5s",
|
||||
),
|
||||
).toBe("j1.nef");
|
||||
});
|
||||
|
||||
it("URL 帶 query string 也只取 pathname 最後段", () => {
|
||||
expect(
|
||||
deriveDownloadFilename("https://faa/files/u/abc.nef?token=x", "name"),
|
||||
).toBe("abc.nef");
|
||||
});
|
||||
|
||||
it("path 無副檔名 → fallback 用 modelName.nef", () => {
|
||||
expect(deriveDownloadFilename("https://faa/files/u/blob", "My Model")).toBe(
|
||||
"My_Model.nef",
|
||||
);
|
||||
});
|
||||
|
||||
it("URL 無法解析 → fallback;非法字元被取代", () => {
|
||||
expect(deriveDownloadFilename("not a url", "a/b c")).toBe("a_b_c.nef");
|
||||
});
|
||||
|
||||
it("modelName 已含 .nef 不重複加", () => {
|
||||
expect(deriveDownloadFilename("bad", "model.nef")).toBe("model.nef");
|
||||
});
|
||||
});
|
||||
|
||||
/* ========================================================================== */
|
||||
/* ModelDownloadError 形狀 */
|
||||
/* ========================================================================== */
|
||||
|
||||
describe("ModelDownloadError", () => {
|
||||
it("保留 status / code / message / requestId", () => {
|
||||
const e = new ModelDownloadError(403, "forbidden", "no", "req-1");
|
||||
expect(e.name).toBe("ModelDownloadError");
|
||||
expect(e.status).toBe(403);
|
||||
expect(e.code).toBe("forbidden");
|
||||
expect(e.message).toBe("no");
|
||||
expect(e.requestId).toBe("req-1");
|
||||
expect(e).toBeInstanceOf(Error);
|
||||
});
|
||||
});
|
||||
233
visionA-frontend/src/lib/api/model-download.ts
Normal file
233
visionA-frontend/src/lib/api/model-download.ts
Normal file
@ -0,0 +1,233 @@
|
||||
/**
|
||||
* Model Download API Client(Phase 0.9 — 模型庫「FAA delegated download」對接)
|
||||
*
|
||||
* 對齊:
|
||||
* - `docs/autoflow/04-architecture/adr/adr-017-model-library-access.md` §10.4 + 決策 2
|
||||
* - backend endpoint `GET /api/models/:id/download`(commit c63886a,stage e2e 驗過)
|
||||
*
|
||||
* ⚠️ 為什麼這支「下載」跟既有轉檔下載(conversion.ts `getConversionDownloadURL`)完全不同:
|
||||
* - 轉檔下載:同 origin、走 visionA backend、用 `<a href>` browser navigation(302 redirect)。
|
||||
* - 模型庫下載:**跨 origin 直連 FAA(如 stage-9527:5081)+ 必須帶 `Authorization: Bearer {token}`**。
|
||||
* → browser navigation(`<a href download>` / `window.location.href`)**無法帶 Authorization header**,
|
||||
* 所以一定要用 `fetch(url, { headers: { Authorization } }) → blob → 動態 <a> 觸發下載`。
|
||||
*
|
||||
* 兩步驟流程(ADR-017 決策 2 v1.2 實測流程):
|
||||
* 1. `getModelDownload(modelId)`:打 visionA `GET /api/models/:id/download`,
|
||||
* 回 `{ downloadUrl, token, expiresAt }`(visionA 已向 MC 簽好 opaque `fdt_` token)。
|
||||
* 2. `downloadModelFile(downloadUrl, token, filename)`:帶 `Authorization: Bearer {token}`
|
||||
* 直接 GET `downloadUrl`(FAA),取 blob,建立 object URL + 動態 anchor click 觸發瀏覽器下載。
|
||||
*
|
||||
* 錯誤分層:
|
||||
* - client 不翻譯成中文 — i18n 留在 store / UI(用 `error.code` 對應 `models.download.error.<code>`)。
|
||||
* - backend 回的 code(model_not_found / forbidden / upload_not_supported / sign_failed …)原樣透出。
|
||||
*/
|
||||
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** `GET /api/models/:id/download` 正規化後的回傳。 */
|
||||
export interface ModelDownloadGrant {
|
||||
/** FAA 直連下載 URL(跨 origin,如 https://stage-9527...:5081/files/models/{userID}/{jobID}.nef) */
|
||||
downloadUrl: string;
|
||||
/** MC 簽的 opaque delegated download token(`fdt_...`);只用於下一步的 Authorization header */
|
||||
token: string;
|
||||
/** ISO 8601 — token 過期時間 */
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Error class */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 模型下載專用錯誤。store / UI 用 `error.code` 對應 i18n key(`models.download.error.<code>`)。
|
||||
*
|
||||
* code 統一全小寫(對齊 conversion.ts `ConversionAPIError` 的命名規範,避免 UI 端做大小寫處理)。
|
||||
* 常見 code:
|
||||
* - `model_not_found`(404)/ `forbidden`(403)/ `upload_not_supported`(501,第一階段不支援上傳類)
|
||||
* - `sign_failed`(502,MC 簽 token 失敗)
|
||||
* - `download_failed`(FAA GET 非 2xx;可能含 CORS 被擋 → network_error)
|
||||
* - `network_error` / `timeout` / `aborted` / `parse_error`(client 端網路層)
|
||||
*/
|
||||
export class ModelDownloadError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
readonly requestId?: string;
|
||||
|
||||
constructor(status: number, code: string, message: string, requestId?: string) {
|
||||
super(message);
|
||||
this.name = "ModelDownloadError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
this.requestId = requestId;
|
||||
if (typeof Error.captureStackTrace === "function") {
|
||||
Error.captureStackTrace(this, ModelDownloadError);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 把底層 `ApiError` / 一般 Error 包成 `ModelDownloadError`(code 統一小寫)。 */
|
||||
function wrapError(err: unknown): ModelDownloadError {
|
||||
if (err instanceof ModelDownloadError) return err;
|
||||
if (err instanceof ApiError) {
|
||||
return new ModelDownloadError(err.status, err.code.toLowerCase(), err.message);
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
const maybeCode = (err as unknown as { code?: unknown }).code;
|
||||
const code =
|
||||
typeof maybeCode === "string" ? maybeCode.toLowerCase() : "network_error";
|
||||
return new ModelDownloadError(0, code, err.message);
|
||||
}
|
||||
return new ModelDownloadError(0, "unknown", String(err));
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 1. GET /api/models/:id/download — 取 FAA 下載授權 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 向 visionA backend 取得模型的 FAA delegated download 授權。
|
||||
*
|
||||
* 走既有 `api.get` wrapper(自動帶 cookie session、解 envelope、ApiError mapping)。
|
||||
* 寬容讀取 snake_case / camelCase(download_url / downloadUrl 等)。
|
||||
*
|
||||
* @throws {ModelDownloadError} 404 model_not_found / 403 forbidden /
|
||||
* 501 upload_not_supported / 502 sign_failed / 其他網路層錯誤
|
||||
*/
|
||||
export async function getModelDownload(modelId: string): Promise<ModelDownloadGrant> {
|
||||
if (!modelId) {
|
||||
throw new ModelDownloadError(0, "validation_failed", "modelId is required");
|
||||
}
|
||||
try {
|
||||
const raw = await api.get<Record<string, unknown>>(
|
||||
`/api/models/${encodeURIComponent(modelId)}/download`,
|
||||
);
|
||||
const r = raw ?? {};
|
||||
const downloadUrl = String(r.download_url ?? r.downloadUrl ?? "");
|
||||
const token = String(r.token ?? "");
|
||||
const expiresAt = String(r.expires_at ?? r.expiresAt ?? "");
|
||||
if (!downloadUrl || !token) {
|
||||
throw new ModelDownloadError(
|
||||
500,
|
||||
"parse_error",
|
||||
"download: missing download_url or token in response",
|
||||
);
|
||||
}
|
||||
return { downloadUrl, token, expiresAt };
|
||||
} catch (err) {
|
||||
throw wrapError(err);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* 2. fetch FAA + Bearer header + blob → 觸發瀏覽器下載 */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 帶 `Authorization: Bearer {token}` 跨 origin 直連 FAA 下載檔案,並觸發瀏覽器存檔。
|
||||
*
|
||||
* 為什麼不能用 `<a href download>`:browser navigation 無法帶自訂 header(Authorization),
|
||||
* 而 FAA `TryReadAccessToken` 只認 `Authorization: Bearer`(不認 query / 自訂 header)。
|
||||
* → 必須 fetch → response.blob() → URL.createObjectURL → 動態 anchor click → revokeObjectURL。
|
||||
*
|
||||
* CORS 註記:FAA 端需允許 visionA 前端 origin(ADR-017 決策 2 Q3)。若 FAA 未設 CORS,
|
||||
* 跨 origin fetch 會 throw `TypeError: Failed to fetch` → 落到 `network_error`(UI 顯示下載失敗)。
|
||||
* 這是 FAA 端設定問題,**前端 code 不為 CORS 改**。
|
||||
*
|
||||
* @param downloadUrl FAA 絕對 URL(來自 getModelDownload)
|
||||
* @param token opaque `fdt_` token(只用於 Authorization header,不寫進 URL / log)
|
||||
* @param filename 存檔檔名(如 `{jobID}.nef`)
|
||||
* @throws {ModelDownloadError} download_failed(FAA 非 2xx)/ network_error / aborted
|
||||
*/
|
||||
export async function downloadModelFile(
|
||||
downloadUrl: string,
|
||||
token: string,
|
||||
filename: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (!downloadUrl || !token) {
|
||||
throw new ModelDownloadError(0, "validation_failed", "downloadUrl and token are required");
|
||||
}
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(downloadUrl, {
|
||||
method: "GET",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
// 跨 origin 直連 FAA:不帶 visionA cookie(FAA 用 delegated token 認證,cookie 無意義)
|
||||
credentials: "omit",
|
||||
signal,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof Error && (err.name === "AbortError" || /aborted/i.test(err.message))) {
|
||||
throw new ModelDownloadError(0, "aborted", "Download aborted");
|
||||
}
|
||||
// TypeError: Failed to fetch — 通常是 CORS 被擋 / 連不上 FAA
|
||||
throw new ModelDownloadError(
|
||||
0,
|
||||
"network_error",
|
||||
`Failed to reach FAA: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new ModelDownloadError(
|
||||
res.status,
|
||||
"download_failed",
|
||||
`FAA download failed: HTTP ${res.status}`,
|
||||
res.headers.get("X-Request-Id") ?? undefined,
|
||||
);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
triggerBlobDownload(blob, filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* 建立 object URL + 動態 anchor click 觸發瀏覽器存檔,完成後 revoke 避免記憶體洩漏。
|
||||
*
|
||||
* 抽成獨立函式:便於測試(驗 anchor 屬性 / revoke 被呼叫)、也讓上面 fetch 流程更乾淨。
|
||||
*/
|
||||
export function triggerBlobDownload(blob: Blob, filename: string): void {
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
try {
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = objectUrl;
|
||||
anchor.download = filename;
|
||||
anchor.rel = "noopener";
|
||||
// 不掛進 DOM 也能 click(現代瀏覽器支援),但部分 Firefox 版本需 append;保險起見 append + remove
|
||||
document.body.appendChild(anchor);
|
||||
anchor.click();
|
||||
document.body.removeChild(anchor);
|
||||
} finally {
|
||||
// 立即 revoke 可能在某些瀏覽器中斷下載,延遲一拍再 revoke
|
||||
setTimeout(() => URL.revokeObjectURL(objectUrl), 1000);
|
||||
}
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Helper */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* 從 FAA download URL 推導存檔檔名(`.../models/{userID}/{jobID}.nef` → `{jobID}.nef`)。
|
||||
*
|
||||
* 回退:URL 解析失敗 / 無副檔名時用 `{modelName}.nef`(modelName 已去除非法字元)。
|
||||
*/
|
||||
export function deriveDownloadFilename(downloadUrl: string, modelName: string): string {
|
||||
try {
|
||||
// downloadUrl 可能含 query string;取 pathname 最後一段
|
||||
const path = new URL(downloadUrl).pathname;
|
||||
const last = path.split("/").filter(Boolean).pop() ?? "";
|
||||
if (last && /\.[a-z0-9]+$/i.test(last)) {
|
||||
return decodeURIComponent(last);
|
||||
}
|
||||
} catch {
|
||||
// URL 解析失敗 → 走 fallback
|
||||
}
|
||||
const safe = (modelName || "model").replace(/[^\w.\-]+/g, "_");
|
||||
return safe.endsWith(".nef") ? safe : `${safe}.nef`;
|
||||
}
|
||||
@ -221,6 +221,29 @@ export const en: Dictionary = {
|
||||
"models.upload.error.urlFailed": "Server is busy, please try again.",
|
||||
"models.upload.error.networkLost": "Network lost, upload paused.",
|
||||
"models.upload.toast.uploaded": "Model \"{name}\" uploaded.",
|
||||
// ── Model download (Phase 0.9 FAA delegated download) ──
|
||||
"models.action.download": "Download",
|
||||
"models.action.downloading": "Downloading…",
|
||||
"models.action.download.aria": "Download the model file to your device",
|
||||
"models.action.download.unsupportedTooltip":
|
||||
"Only converted models are downloadable in this phase",
|
||||
"models.download.toast.start": "Download started",
|
||||
"models.download.toast.hint":
|
||||
"If you don't see a download prompt, check your browser settings",
|
||||
"models.download.error.title": "Download failed",
|
||||
"models.download.error.model_not_found": "Model not found.",
|
||||
"models.download.error.forbidden": "You don't have permission to download this model.",
|
||||
"models.download.error.upload_not_supported":
|
||||
"Uploaded models are not downloadable in this phase.",
|
||||
"models.download.error.sign_failed":
|
||||
"Failed to obtain a download grant, please try again later.",
|
||||
"models.download.error.download_failed":
|
||||
"Failed to download from the file server, please try again later.",
|
||||
"models.download.error.network_error":
|
||||
"Network error, please check your connection and retry.",
|
||||
"models.download.error.timeout": "Download timed out, please try again later.",
|
||||
"models.download.error.busy": "A download is already in progress, please wait.",
|
||||
"models.download.error.unknown": "Download failed, please try again later.",
|
||||
|
||||
// ── Workspace ──
|
||||
"workspace.title": "Workspace",
|
||||
|
||||
@ -219,6 +219,24 @@ export const zhHant: Dictionary = {
|
||||
"models.upload.error.urlFailed": "伺服器忙碌,請稍後再試",
|
||||
"models.upload.error.networkLost": "網路中斷,上傳已暫停",
|
||||
"models.upload.toast.uploaded": "模型「{name}」已上傳",
|
||||
// ── 模型下載(Phase 0.9 FAA delegated download)──
|
||||
"models.action.download": "下載",
|
||||
"models.action.downloading": "下載中…",
|
||||
"models.action.download.aria": "下載模型檔到本機",
|
||||
"models.action.download.unsupportedTooltip": "第一階段僅支援轉檔產生的模型下載",
|
||||
"models.download.toast.start": "下載已開始",
|
||||
"models.download.toast.hint": "若沒看到下載提示,請檢查瀏覽器設定",
|
||||
"models.download.error.title": "下載失敗",
|
||||
// 各 error code(對齊 backend:model_not_found / forbidden / upload_not_supported / sign_failed)
|
||||
"models.download.error.model_not_found": "找不到此模型",
|
||||
"models.download.error.forbidden": "你沒有權限下載此模型",
|
||||
"models.download.error.upload_not_supported": "第一階段尚不支援上傳類模型下載",
|
||||
"models.download.error.sign_failed": "取得下載授權失敗,請稍後再試",
|
||||
"models.download.error.download_failed": "從檔案伺服器下載失敗,請稍後再試",
|
||||
"models.download.error.network_error": "網路連線失敗,請檢查網路後重試",
|
||||
"models.download.error.timeout": "下載逾時,請稍後再試",
|
||||
"models.download.error.busy": "已有下載進行中,請稍候",
|
||||
"models.download.error.unknown": "下載失敗,請稍後再試",
|
||||
|
||||
// ── Workspace ──
|
||||
"workspace.title": "推論工作區",
|
||||
|
||||
@ -1,11 +1,45 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useModelStore } from "./model-store";
|
||||
import { ModelDownloadError } from "@/lib/api/model-download";
|
||||
|
||||
import { isModelDownloadable, useModelStore, type ModelSummary } from "./model-store";
|
||||
|
||||
// mock 下載 API client(store 的 downloadModel action 依賴它)
|
||||
vi.mock("@/lib/api/model-download", async () => {
|
||||
const actual = await vi.importActual<typeof import("@/lib/api/model-download")>(
|
||||
"@/lib/api/model-download",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
getModelDownload: vi.fn(),
|
||||
downloadModelFile: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { downloadModelFile, getModelDownload } from "@/lib/api/model-download";
|
||||
|
||||
const mockGetModelDownload = vi.mocked(getModelDownload);
|
||||
const mockDownloadModelFile = vi.mocked(downloadModelFile);
|
||||
|
||||
function reset() {
|
||||
useModelStore.setState({ models: [], selectedModel: null, isLoading: false });
|
||||
useModelStore.setState({
|
||||
models: [],
|
||||
selectedModel: null,
|
||||
isLoading: false,
|
||||
downloadingId: null,
|
||||
});
|
||||
}
|
||||
|
||||
const convertedReady: ModelSummary = {
|
||||
id: "m1",
|
||||
name: "YOLOv5s",
|
||||
targetChip: "kl520",
|
||||
fileSize: 1024,
|
||||
source: "converted",
|
||||
status: "ready",
|
||||
createdAt: "2026-01-01T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("model-store(F5 骨架)", () => {
|
||||
beforeEach(reset);
|
||||
|
||||
@ -36,3 +70,103 @@ describe("model-store(F5 骨架)", () => {
|
||||
await expect(useModelStore.getState().fetchModels()).resolves.toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isModelDownloadable(Phase 0.9)", () => {
|
||||
it("converted + ready → 可下載", () => {
|
||||
expect(isModelDownloadable({ source: "converted", status: "ready" })).toBe(true);
|
||||
});
|
||||
|
||||
it("uploaded(即使 ready)→ 不可下載", () => {
|
||||
expect(isModelDownloadable({ source: "uploaded", status: "ready" })).toBe(false);
|
||||
});
|
||||
|
||||
it("converted 但非 ready(scanning)→ 不可下載", () => {
|
||||
expect(isModelDownloadable({ source: "converted", status: "scanning" })).toBe(false);
|
||||
});
|
||||
|
||||
it("preset → 不可下載", () => {
|
||||
expect(isModelDownloadable({ source: "preset", status: "ready" })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("downloadModel action(Phase 0.9)", () => {
|
||||
beforeEach(() => {
|
||||
reset();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("happy path:取授權 → 下載 → 回 { ok: true },過程中 downloadingId = 該 model,結束清空", async () => {
|
||||
mockGetModelDownload.mockResolvedValue({
|
||||
downloadUrl: "https://faa/files/u/m1.nef",
|
||||
token: "fdt_tok",
|
||||
expiresAt: "2026-06-07T12:00:00Z",
|
||||
});
|
||||
mockDownloadModelFile.mockResolvedValue(undefined);
|
||||
|
||||
const promise = useModelStore.getState().downloadModel(convertedReady);
|
||||
// action 同步階段已設 downloadingId
|
||||
expect(useModelStore.getState().downloadingId).toBe("m1");
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(mockGetModelDownload).toHaveBeenCalledWith("m1");
|
||||
// 檔名從 URL path 推導(m1.nef)
|
||||
expect(mockDownloadModelFile).toHaveBeenCalledWith(
|
||||
"https://faa/files/u/m1.nef",
|
||||
"fdt_tok",
|
||||
"m1.nef",
|
||||
);
|
||||
// 結束後清空 loading
|
||||
expect(useModelStore.getState().downloadingId).toBeNull();
|
||||
});
|
||||
|
||||
it("不可下載的 model(uploaded)→ 不打 API,回 upload_not_supported", async () => {
|
||||
const result = await useModelStore.getState().downloadModel({
|
||||
...convertedReady,
|
||||
source: "uploaded",
|
||||
});
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
code: "upload_not_supported",
|
||||
message: expect.any(String),
|
||||
});
|
||||
expect(mockGetModelDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("已有下載進行中 → 回 busy,不重複打 API", async () => {
|
||||
useModelStore.setState({ downloadingId: "other" });
|
||||
const result = await useModelStore.getState().downloadModel(convertedReady);
|
||||
expect(result).toMatchObject({ ok: false, code: "busy" });
|
||||
expect(mockGetModelDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("getModelDownload 拋 ModelDownloadError → 回對應 code,並清空 downloadingId", async () => {
|
||||
mockGetModelDownload.mockRejectedValue(
|
||||
new ModelDownloadError(403, "forbidden", "no"),
|
||||
);
|
||||
const result = await useModelStore.getState().downloadModel(convertedReady);
|
||||
expect(result).toMatchObject({ ok: false, code: "forbidden" });
|
||||
expect(useModelStore.getState().downloadingId).toBeNull();
|
||||
});
|
||||
|
||||
it("downloadModelFile 拋 network_error(CORS)→ 回 network_error", async () => {
|
||||
mockGetModelDownload.mockResolvedValue({
|
||||
downloadUrl: "https://faa/files/u/m1.nef",
|
||||
token: "fdt_tok",
|
||||
expiresAt: "2026-06-07T12:00:00Z",
|
||||
});
|
||||
mockDownloadModelFile.mockRejectedValue(
|
||||
new ModelDownloadError(0, "network_error", "Failed to fetch"),
|
||||
);
|
||||
const result = await useModelStore.getState().downloadModel(convertedReady);
|
||||
expect(result).toMatchObject({ ok: false, code: "network_error" });
|
||||
expect(useModelStore.getState().downloadingId).toBeNull();
|
||||
});
|
||||
|
||||
it("非 ModelDownloadError 的例外 → 退化成 unknown", async () => {
|
||||
mockGetModelDownload.mockRejectedValue(new Error("weird"));
|
||||
const result = await useModelStore.getState().downloadModel(convertedReady);
|
||||
expect(result).toMatchObject({ ok: false, code: "unknown" });
|
||||
});
|
||||
});
|
||||
|
||||
@ -19,6 +19,12 @@
|
||||
import { create } from "zustand";
|
||||
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import {
|
||||
deriveDownloadFilename,
|
||||
downloadModelFile,
|
||||
getModelDownload,
|
||||
ModelDownloadError,
|
||||
} from "@/lib/api/model-download";
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Types — 對齊 api-spec.md §4 */
|
||||
@ -116,12 +122,31 @@ function normalizeInitResult(raw: unknown): UploadInitResult {
|
||||
/* Store */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/** download action 的回傳:成功 / 失敗(帶 i18n code 給 UI 顯示 toast / inline 錯誤)。 */
|
||||
export type DownloadResult =
|
||||
| { ok: true }
|
||||
| { ok: false; code: string; message: string };
|
||||
|
||||
/**
|
||||
* 判斷一個 model 是否可下載(Phase 0.9 第一階段:只支援轉檔 promote 的 model)。
|
||||
*
|
||||
* 為什麼只有 `converted`:第一階段 FAA delegated download 只覆蓋「轉檔→promote」類 model
|
||||
* (ADR-017 §10.4 B1 object_key 斷層)。上傳類 model 在 visionA 自己的 storage、沒有 FAA object_key,
|
||||
* backend 會回 501 upload_not_supported。UI 依此條件隱藏下載按鈕、避免使用者點了才吃 501。
|
||||
*/
|
||||
export function isModelDownloadable(model: Pick<ModelSummary, "source" | "status">): boolean {
|
||||
return model.source === "converted" && model.status === "ready";
|
||||
}
|
||||
|
||||
interface ModelState {
|
||||
models: ModelSummary[];
|
||||
selectedModel: Model | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
/** 正在下載中的 model id(同時間只允許一個下載;null = 無)。供 UI 顯示 per-card loading。 */
|
||||
downloadingId: string | null;
|
||||
|
||||
/** 呼叫 `GET /api/models` */
|
||||
fetchModels: () => Promise<void>;
|
||||
/** 呼叫 `GET /api/models/:id` */
|
||||
@ -138,6 +163,11 @@ interface ModelState {
|
||||
finalizeUpload: (modelId: string, etag: string | null) => Promise<void>;
|
||||
/** 呼叫 `DELETE /api/models/:id` */
|
||||
deleteModel: (id: string) => Promise<boolean>;
|
||||
/**
|
||||
* 下載模型檔(兩步:`GET /api/models/:id/download` 取 FAA 授權 → 帶 Bearer token 直連 FAA 取 blob)。
|
||||
* 回 `DownloadResult`,由 UI 決定顯示 toast / inline 錯誤(用 `code` 對應 i18n)。
|
||||
*/
|
||||
downloadModel: (model: ModelSummary) => Promise<DownloadResult>;
|
||||
|
||||
/** 測試 / 雛形用 */
|
||||
_setModels: (models: ModelSummary[]) => void;
|
||||
@ -149,6 +179,7 @@ export const useModelStore = create<ModelState>()((set, get) => ({
|
||||
selectedModel: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
downloadingId: null,
|
||||
|
||||
fetchModels: async () => {
|
||||
set({ isLoading: true, error: null });
|
||||
@ -218,6 +249,39 @@ export const useModelStore = create<ModelState>()((set, get) => ({
|
||||
}
|
||||
},
|
||||
|
||||
downloadModel: async (model) => {
|
||||
// 防呆:不可下載的 model(非 converted / 非 ready)直接回對應錯誤、不打 API。
|
||||
if (!isModelDownloadable(model)) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "upload_not_supported",
|
||||
message: "model is not downloadable",
|
||||
};
|
||||
}
|
||||
// 同時間只允許一個下載中(避免重複點擊 / 多檔同時拉爆頻寬)。
|
||||
if (get().downloadingId) {
|
||||
return { ok: false, code: "busy", message: "another download is in progress" };
|
||||
}
|
||||
|
||||
set({ downloadingId: model.id });
|
||||
try {
|
||||
const grant = await getModelDownload(model.id);
|
||||
const filename = deriveDownloadFilename(grant.downloadUrl, model.name);
|
||||
await downloadModelFile(grant.downloadUrl, grant.token, filename);
|
||||
return { ok: true };
|
||||
} catch (err) {
|
||||
// ModelDownloadError 帶 code(i18n 用);其他 Error 退化成 unknown。
|
||||
if (err instanceof ModelDownloadError) {
|
||||
return { ok: false, code: err.code, message: err.message };
|
||||
}
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, code: "unknown", message };
|
||||
} finally {
|
||||
// 無論成功 / 失敗都要清掉 loading 狀態(避免按鈕卡在 disabled)。
|
||||
set({ downloadingId: null });
|
||||
}
|
||||
},
|
||||
|
||||
_setModels: (models) => set({ models }),
|
||||
_setSelected: (selectedModel) => set({ selectedModel }),
|
||||
}));
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user