feat(visionA-frontend): Phase 0.9 模型庫下載 — 前端對接 FAA delegated download

對齊 ADR-017 v1.2 決策 2:model-card 下載按鈕 → 打 visionA endpoint 拿 url+token
→ fetch 跨 origin 直連 FAA + Authorization Bearer → blob 觸發下載(不經 visionA、不經 AWS)。

- src/lib/api/model-download.ts: getModelDownload(打 GET /api/models/:id/download)+
  downloadModelFile(fetch + Bearer header + blob,credentials:"omit" 不帶 visionA cookie)+
  triggerBlobDownload(延遲 revoke + finally 釋放)+ deriveDownloadFilename + ModelDownloadError
- model-store: downloadingId 互斥(同時只一個下載、finally 必清)+ downloadModel action +
  isModelDownloadable(source==converted && status==ready)
- model-card: 下載按鈕(converted+ready 才顯示、上傳類隱藏;preventDefault+stopPropagation
  防觸發外層 Link 導航;loading;toast 錯誤)
- i18n zh/en: models.action.download.* / models.download.*(各 error code 對應訊息)

關鍵差異:模型庫下載跨 origin + 需 Bearer header,不能用 <a href> navigation(無法帶 header),
必須 fetch+blob。與既有 conversion download(同 origin navigation)分流。

測試: 43 unit + 互動 test(mock fetch / 按鈕互動 / 顯示條件 / error 分流);tsc/lint/build 全綠。
Reviewer: 0 Critical / 0 Major / 3 Minor / 4 Suggestion,通過(ADR 決策 2 規格 7/7 符合)。

待 stage 實測: FAA 端須設 CORS(允許 visionA origin + preflight Allow-Headers: Authorization),
撞到會落 network_error。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jim800121chen 2026-06-07 04:50:17 +08:00
parent c63886a194
commit 53e8ab4ae1
8 changed files with 1001 additions and 4 deletions

View 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/navigationjsdom 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("下載中");
});
});

View File

@ -10,15 +10,27 @@
* local-tool accuracy / fps / supportedHardware preset metadata
* - Badge flow-model-upload §5.4uploading / scanning / ready / rejected
* - Checkbox comparison
* - Phase 0.9 modelconverted + 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);
// 任一卡片下載中時,其他卡片的下載按鈕 disablestore 同時只允許一個下載)。
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>

View File

@ -0,0 +1,292 @@
/**
* Model Download API Client Phase 0.9
*
*
* 1. getModelDownload 200 / 404 / 403 / 501 / 502 / parse
* 2. downloadModelFile happyfetch + 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 throwCORS / 連不上)→ 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);
});
});

View File

@ -0,0 +1,233 @@
/**
* Model Download API ClientPhase 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 c63886astage e2e
*
* conversion.ts `getConversionDownloadURL`
* - origin visionA backend `<a href>` browser navigation302 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 codemodel_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`502MC 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 envelopeApiError mapping
* snake_case / camelCasedownload_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 headerAuthorization
* FAA `TryReadAccessToken` `Authorization: Bearer` query / header
* fetch response.blob() URL.createObjectURL anchor click revokeObjectURL
*
* CORS FAA visionA originADR-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_failedFAA 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 cookieFAA 用 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`;
}

View File

@ -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",

View File

@ -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對齊 backendmodel_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": "推論工作區",

View File

@ -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 clientstore 的 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-storeF5 骨架)", () => {
beforeEach(reset);
@ -36,3 +70,103 @@ describe("model-storeF5 骨架)", () => {
await expect(useModelStore.getState().fetchModels()).resolves.toBeUndefined();
});
});
describe("isModelDownloadablePhase 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 但非 readyscanning→ 不可下載", () => {
expect(isModelDownloadable({ source: "converted", status: "scanning" })).toBe(false);
});
it("preset → 不可下載", () => {
expect(isModelDownloadable({ source: "preset", status: "ready" })).toBe(false);
});
});
describe("downloadModel actionPhase 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("不可下載的 modeluploaded→ 不打 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_errorCORS→ 回 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" });
});
});

View File

@ -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_supportedUI 使 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 帶 codei18n 用);其他 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 }),
}));