模型庫下載原本前端用 fetch + Authorization: Bearer 跨 origin 直連 FAA,
觸發 CORS preflight(OPTIONS);FAA 未設 CORS、OPTIONS 回 405 → 下載失敗。
FAA 設計本就支援「token 放 query string(access_token) + redirect 導航下載」,
故不需 FAA 設 CORS。改採 ADR-017 §11 v1.3 乙案:
- backend: download_url 組成含 ?access_token={url.QueryEscape(token)} 完整 FAA URL;
ModelDownloadResponse.Token 標 deprecated 保留(向下相容);token 不進 log
- frontend: 移除 downloadModelFile/deriveDownloadFilename(fetch+blob),
改 triggerNavDownload 用 <a download href> 導航;ModelDownloadGrant 移除 token 欄
- 整條鏈無 CORS:前端→backend 同 origin、前端→FAA 導航無 preflight
docs: ADR-017 增補 §11(v1.3) + TDD.md §9.5
tests: backend 8 PASS(含 token escape 邊界)、frontend 35 PASS;Reviewer 通過(0C/0M)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
162 lines
7.8 KiB
TypeScript
162 lines
7.8 KiB
TypeScript
/**
|
||
* 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 當「不透明可導航連結」,
|
||
* 用動態 `<a download href>` + 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)`:動態建 `<a href={downloadUrl} download>` + 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.<code>`)。
|
||
*/
|
||
|
||
import { ApiError, api } from "@/lib/api";
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* Types */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
/** `GET /api/models/:id/download` 正規化後的回傳。 */
|
||
export interface ModelDownloadGrant {
|
||
/**
|
||
* FAA 直連下載 URL(跨 origin,且**已含 `?access_token={fdt token}`**)。
|
||
* 前端把它當「不透明可導航連結」直接丟給 `<a href>`,不解析、不附加 token。
|
||
*/
|
||
downloadUrl: string;
|
||
/** ISO 8601 — token 過期時間(UI 可用來顯示「連結有效期」提示;第一階段未用)。 */
|
||
expiresAt: string;
|
||
}
|
||
|
||
/* -------------------------------------------------------------------------- */
|
||
/* Error class */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* 模型下載專用錯誤。store / UI 用 `error.code` 對應 i18n key(`models.download.error.<code>`)。
|
||
*
|
||
* 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<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 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. 動態 <a download href> click → 觸發瀏覽器導航下載 */
|
||
/* -------------------------------------------------------------------------- */
|
||
|
||
/**
|
||
* 動態建立 `<a download href={downloadUrl}>` + click 觸發**瀏覽器導航下載**。
|
||
*
|
||
* 為什麼用 `<a download href>` click 而非 `window.location.href`:
|
||
* - `<a download>` 語意明確「下載」;搭配 FAA 的 `Content-Disposition: attachment`,瀏覽器以下載處理、
|
||
* **不會把目前 SPA 畫面導走**。`window.location.href` 在 header 缺失 / 瀏覽器差異時可能整頁導去 FAA、破壞 SPA。
|
||
*
|
||
* 檔名(重要):跨 origin 導航時 `<a download="xxx">` 的 `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);
|
||
}
|