visionA/visionA-frontend/src/lib/api/model-download.ts
jim800121chen 3e45532f55 fix(model-download): 改 redirect/query-string token 下載,根除 CORS preflight 405
模型庫下載原本前端用 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>
2026-06-27 06:18:05 +08:00

162 lines
7.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Model Download API ClientPhase 0.9 — 模型庫「FAA delegated download」對接
*
* 對齊:
* - `docs/autoflow/04-architecture/adr/adr-017-model-library-access.md` §11v1.3query-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`。
* 跨 origin9527→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`502MC 簽 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
* **同 origin9527→9527→ 無 CORS。** 寬容讀取 snake_case / camelCasedownload_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);
}