/** * Model Download API Client(Phase 0.9 — 模型庫「FAA delegated download」對接) * * 對齊: * - `docs/autoflow/04-architecture/adr/adr-017-model-library-access.md` §11(v1.3:query-string token + redirect 導航) * - backend endpoint `GET /api/models/:id/download` * * ⚠️ v1.3 重大改動(推翻 v1.2 的「fetch + Authorization Bearer + blob」): * - 舊作法:前端 `fetch(faaUrl, { headers: { Authorization: 'Bearer fdt_...' } }) → blob → anchor`。 * 跨 origin(9527→5081)+ 自訂 Authorization header → 觸發 CORS preflight OPTIONS → FAA 沒設 CORS * → 405 → 下載死。 * - 新作法:後端把 token 組進 download_url(`?access_token=...`),前端把 download_url 當「不透明可導航連結」, * 用動態 `` + click 觸發**瀏覽器導航下載**。導航不是 fetch、無自訂 header → 無 preflight → 無 CORS。 * 根因與決策見 ADR §11.1–§11.7。 * * 兩步驟流程(ADR §11.8 frontend checklist): * 1. `getModelDownload(modelId)`:打 visionA `GET /api/models/:id/download`(**同 origin、無 CORS**), * 回 `{ downloadUrl, expiresAt }`。`downloadUrl` 後端已組好、含 `?access_token={fdt token}`,前端不碰 token。 * 2. `triggerNavDownload(downloadUrl)`:動態建 `` + click 觸發瀏覽器下載。 * * 錯誤分層(v1.3 縮小範圍): * - 導航式下載**無法攔截 FAA 端 4xx/5xx**(瀏覽器導航不回 Response 給 JS)→ FAA 端錯誤前端偵測不到。 * 錯誤處理只涵蓋「getModelDownload 階段」的 backend 4xx/5xx(同 origin、走 api wrapper)。 * - client 不翻譯成中文 — i18n 留在 store / UI(用 `error.code` 對應 `models.download.error.`)。 */ import { ApiError, api } from "@/lib/api"; /* -------------------------------------------------------------------------- */ /* Types */ /* -------------------------------------------------------------------------- */ /** `GET /api/models/:id/download` 正規化後的回傳。 */ export interface ModelDownloadGrant { /** * FAA 直連下載 URL(跨 origin,且**已含 `?access_token={fdt token}`**)。 * 前端把它當「不透明可導航連結」直接丟給 ``,不解析、不附加 token。 */ downloadUrl: string; /** ISO 8601 — token 過期時間(UI 可用來顯示「連結有效期」提示;第一階段未用)。 */ expiresAt: string; } /* -------------------------------------------------------------------------- */ /* Error class */ /* -------------------------------------------------------------------------- */ /** * 模型下載專用錯誤。store / UI 用 `error.code` 對應 i18n key(`models.download.error.`)。 * * code 統一全小寫(對齊 conversion.ts `ConversionAPIError` 的命名規範,避免 UI 端做大小寫處理)。 * v1.3 後 code 只來自 getModelDownload 階段(backend 4xx/5xx): * - `model_not_found`(404)/ `forbidden`(403)/ `upload_not_supported`(501,第一階段不支援上傳類) * - `sign_failed`(502,MC 簽 token 失敗) * - `network_error` / `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 下載授權(同 origin、無 CORS) */ /* -------------------------------------------------------------------------- */ /** * 向 visionA backend 取得模型的 FAA delegated download 授權。 * * 走既有 `api.get` wrapper(自動帶 cookie session、解 envelope、ApiError mapping)。 * **同 origin(9527→9527)→ 無 CORS。** 寬容讀取 snake_case / camelCase(download_url / downloadUrl 等)。 * * 回傳的 `downloadUrl` 後端已組好、含 `?access_token=`,前端不需要、也不應該再碰 token。 * * @throws {ModelDownloadError} 404 model_not_found / 403 forbidden / * 501 upload_not_supported / 502 sign_failed / 其他網路層錯誤 */ export async function getModelDownload(modelId: string): Promise { if (!modelId) { throw new ModelDownloadError(0, "validation_failed", "modelId is required"); } try { const raw = await api.get>( `/api/models/${encodeURIComponent(modelId)}/download`, ); const r = raw ?? {}; const downloadUrl = String(r.download_url ?? r.downloadUrl ?? ""); const expiresAt = String(r.expires_at ?? r.expiresAt ?? ""); if (!downloadUrl) { throw new ModelDownloadError( 500, "parse_error", "download: missing download_url in response", ); } return { downloadUrl, expiresAt }; } catch (err) { throw wrapError(err); } } /* -------------------------------------------------------------------------- */ /* 2. 動態 click → 觸發瀏覽器導航下載 */ /* -------------------------------------------------------------------------- */ /** * 動態建立 `` + click 觸發**瀏覽器導航下載**。 * * 為什麼用 `` click 而非 `window.location.href`: * - `` 語意明確「下載」;搭配 FAA 的 `Content-Disposition: attachment`,瀏覽器以下載處理、 * **不會把目前 SPA 畫面導走**。`window.location.href` 在 header 缺失 / 瀏覽器差異時可能整頁導去 FAA、破壞 SPA。 * * 檔名(重要):跨 origin 導航時 `` 的 `xxx` 會被瀏覽器**忽略**(安全限制), * 檔名由 FAA 的 `Content-Disposition` 決定。故這裡只設 `download`(空值)純粹表達「下載意圖」、不傳檔名。 * * 不需要 `URL.createObjectURL` / `URL.revokeObjectURL`:href 是 FAA 的真實 URL,不是 blob object URL。 * * ⚠️ 安全:`downloadUrl` 含 token(在 `?access_token=`)→ **絕不**把它印進 console / log / 錯誤訊息。 * * @param downloadUrl FAA 絕對 URL(來自 getModelDownload,已含 `?access_token=`) */ export function triggerNavDownload(downloadUrl: string): void { if (!downloadUrl) { throw new ModelDownloadError(0, "validation_failed", "downloadUrl is required"); } const anchor = document.createElement("a"); anchor.href = downloadUrl; // 跨 origin 導航下檔名由 FAA Content-Disposition 決定;download 空值僅表達下載意圖。 anchor.download = ""; anchor.rel = "noopener"; // 部分瀏覽器需 anchor 在 DOM 內才會觸發下載;append → click → remove。 document.body.appendChild(anchor); anchor.click(); document.body.removeChild(anchor); }