/**
* 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);
}