From b10fbb8091c63ef1bd0af174a3e02749d8ca4e31 Mon Sep 17 00:00:00 2001 From: jim800121chen Date: Thu, 30 Jul 2026 14:23:21 +0800 Subject: [PATCH] =?UTF-8?q?feat(adr-019):=20=E5=BD=B1=E7=89=87=E5=88=86?= =?UTF-8?q?=E9=A0=81=E6=8E=A5=E7=B7=9A=20localhost=20=E7=9B=B4=E9=80=A3?= =?UTF-8?q?=EF=BC=8C=E4=B8=8A=E9=99=90=2090MB=E2=86=92500MB=EF=BC=88WP-4?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 影片分頁上傳從舊 tunnel 路徑(/api/media/upload/video + 90MB)切到同機 localhost 直連(取 token → resolveLocalAgent → /api/local/media/upload/video)。 端到端啟用 ADR-019,取代 90MB 過渡限制。 - 新 lib/local-media.ts 編排層:getLocalUploadTicket + uploadVideoViaLocalAgent - MAX_LOCAL_VIDEO_BYTES=500MB + validateLocalVideoFile(只綁 localhost 路徑; 舊 MAX_VIDEO_BYTES=90MB + tunnel uploadVideo 完全不碰,向下相容) - R-3 tunnel 離線三層防護:UI disable 不渲染 uploader + ticket 502 + 錯誤映射 - 5 種錯誤 i18n(NOT_FOUND/MISMATCH/離線/401/413)+ AbortError 靜默 reviewer 通過(0C/0M)。tsc/eslint/build 0 error、WP-4 相關 70 test pass。 ⚠️ 實機驗證(真序號 hash 同形 fail-closed / PNA / 500MB 大檔實傳 / 混合路徑 結果面)待 stage 部署後驗證。Minor M-1/M-2 留 WP-6 一併處理。 Refs: ADR-019 WP-4。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../[deviceId]/workspace-client.test.tsx | 104 +++++++++ .../workspace/[deviceId]/workspace-client.tsx | 70 +++++- .../src/components/workspace/media-tab.tsx | 23 +- .../src/lib/i18n/dictionaries/en.ts | 12 +- .../src/lib/i18n/dictionaries/zh-Hant.ts | 12 +- visionA-frontend/src/lib/local-media.test.ts | 208 ++++++++++++++++++ visionA-frontend/src/lib/local-media.ts | 135 ++++++++++++ visionA-frontend/src/lib/media.test.ts | 21 ++ visionA-frontend/src/lib/media.ts | 43 +++- 9 files changed, 612 insertions(+), 16 deletions(-) create mode 100644 visionA-frontend/src/lib/local-media.test.ts create mode 100644 visionA-frontend/src/lib/local-media.ts diff --git a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx index 82b7012..167984b 100644 --- a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx +++ b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.test.tsx @@ -29,6 +29,18 @@ vi.mock("@/hooks/use-inference-stream", () => ({ useInferenceStream: vi.fn(), })); +// ADR-019 WP-4:mock 影片 localhost 直連編排(單元隔離;local-media 自身有 local-media.test.ts)。 +// 保留真實 LocalMediaError 供錯誤分支測試。 +const uploadVideoViaLocalAgent = vi.fn(); +vi.mock("@/lib/local-media", async () => { + const actual = + await vi.importActual("@/lib/local-media"); + return { + ...actual, + uploadVideoViaLocalAgent: (...a: unknown[]) => uploadVideoViaLocalAgent(...a), + }; +}); + // 可控 ResizeObserver:把 callback 存起來,測試決定何時觸發 let roCallbacks: ResizeObserverCallback[] = []; class ControllableRO { @@ -43,6 +55,7 @@ class ControllableRO { } import { useInferenceStream } from "@/hooks/use-inference-stream"; +import { LocalMediaError } from "@/lib/local-media"; import { WorkspaceClient } from "./workspace-client"; @@ -91,6 +104,7 @@ function renderClient() { beforeEach(() => { post.mockReset(); + uploadVideoViaLocalAgent.mockReset(); roCallbacks = []; (globalThis as { ResizeObserver?: unknown }).ResizeObserver = ControllableRO; useInferenceStore.setState({ @@ -280,3 +294,93 @@ describe("WorkspaceClient — serial 路由(WP-C / ADR-018)", () => { expect(screen.queryByTestId("media-no-serial")).not.toBeInTheDocument(); }); }); + +/** + * ADR-019 WP-4:影片分頁走 localhost 直連 local-agent(非 tunnel)。 + * 這裡驗證接線點——影片上傳呼叫 uploadVideoViaLocalAgent(帶 serial)、 + * tunnel 離線時停用上傳、錯誤碼轉 friendly i18n。 + */ +describe("WorkspaceClient — 影片 localhost 直連(WP-4)", () => { + /** 在影片分頁的 dropzone 選檔(觸發上傳流程)。 */ + function selectVideo(file: File) { + const input = document.querySelector( + '[data-testid="media-uploader-video"] input[type=file]', + ) as HTMLInputElement; + Object.defineProperty(input, "files", { value: [file], configurable: true }); + fireEvent.change(input); + } + + it("影片上傳走 uploadVideoViaLocalAgent(帶 serial),不走雲端 uploadVideo", async () => { + uploadVideoViaLocalAgent.mockResolvedValue({ + streamUrl: "/api/camera/stream", + sourceType: "video", + totalFrames: 100, + }); + renderClient(); + await switchTab("影片"); + // 線上 + 有 serial → 顯示 uploader(非離線提示) + expect(screen.getByTestId("media-uploader-video")).toBeInTheDocument(); + + selectVideo(new File([new Blob(["x"])], "clip.mp4")); + + await waitFor(() => { + expect(uploadVideoViaLocalAgent).toHaveBeenCalled(); + }); + // 第一個參數是 serial、第二個是 File + const [serialArg, fileArg] = uploadVideoViaLocalAgent.mock.calls[0]!; + expect(serialArg).toBe("KN00123456"); + expect(fileArg).toBeInstanceOf(File); + }); + + it("tunnel 離線(remoteStatus != online)→ 影片分頁停用上傳 + 顯示離線提示", async () => { + useDeviceStore.setState({ + selectedDevice: { + id: "dev-1", + name: "KL520", + remoteStatus: "offline", + lastSeenAt: null, + serialNumber: "KN00123456", + } as ReturnType["selectedDevice"], + isLoading: false, + }); + renderClient(); + // 離線時整頁會有 offline 遮罩,但分頁內容仍 render;切到影片分頁驗證停用提示 + await switchTab("影片"); + expect( + screen.getByTestId("media-upload-disabled-video"), + ).toBeInTheDocument(); + // 不出現 uploader + expect(screen.queryByTestId("media-uploader-video")).not.toBeInTheDocument(); + }); + + it("NOT_FOUND → 顯示「需同機操作」friendly 訊息(非 raw code)", async () => { + uploadVideoViaLocalAgent.mockRejectedValue( + new LocalMediaError("LOCAL_AGENT_NOT_FOUND"), + ); + renderClient(); + await switchTab("影片"); + selectVideo(new File([new Blob(["x"])], "clip.mp4")); + + await waitFor(() => { + const err = screen.getByTestId("media-uploader-video-error"); + expect(err.textContent).toContain("同一台電腦"); + // 不可洩漏 raw code + expect(err.textContent).not.toContain("LOCAL_AGENT_NOT_FOUND"); + }); + }); + + it("MISMATCH → 顯示「Agent 與裝置不符」friendly 訊息", async () => { + uploadVideoViaLocalAgent.mockRejectedValue( + new LocalMediaError("LOCAL_AGENT_MISMATCH"), + ); + renderClient(); + await switchTab("影片"); + selectVideo(new File([new Blob(["x"])], "clip.mp4")); + + await waitFor(() => { + expect( + screen.getByTestId("media-uploader-video-error").textContent, + ).toContain("不符"); + }); + }); +}); diff --git a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx index d7d1114..535460b 100644 --- a/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx +++ b/visionA-frontend/src/app/workspace/[deviceId]/workspace-client.tsx @@ -51,18 +51,26 @@ import { VIDEO_ACCEPT, uploadBatchImages, uploadImage, - uploadVideo, validateBatchFiles, validateImageFile, - validateVideoFile, + validateLocalVideoFile, } from "@/lib/media"; +import { + LocalMediaError, + uploadVideoViaLocalAgent, +} from "@/lib/local-media"; import { useT } from "@/lib/i18n/context"; import { useDeviceStore } from "@/stores/device-store"; import { useInferenceStore } from "@/stores/inference-store"; import type { MediaUploadResponse } from "@/types/camera"; import { toast } from "sonner"; -/** 影片上傳 timeout 拉長(大檔經 tunnel,評估 R-M2);0 = 不限。 */ +/** + * 影片上傳 timeout;0 = 不限。 + * + * ADR-019 WP-4:影片改走 localhost 直連(loopback,0.1–2.5s),大檔不再經雲端 tunnel, + * 理論上很快;但保留不限(0)避免慢速磁碟 / 大檔(≤500MB)時前端搶先 timeout。 + */ const VIDEO_UPLOAD_TIMEOUT_MS = 0; interface WorkspaceClientProps { @@ -114,6 +122,36 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { const serialNumber = selectedDevice?.serialNumber ?? null; const hasSerial = !!serialNumber; + // ADR-019 WP-4:影片 localhost 直連上傳失敗 → 依 error.code 對應 friendly i18n。 + // MediaUploader 以 thrown error 的 message 顯示,故這裡把 code 轉成已本地化的字串再拋。 + const localVideoErrorMessage = useCallback( + (err: unknown): string => { + const code = + err instanceof LocalMediaError || err instanceof ApiError + ? err.code + : undefined; + switch (code) { + case "LOCAL_AGENT_NOT_FOUND": + return t("workspace.media.local.notFound"); + case "LOCAL_AGENT_MISMATCH": + return t("workspace.media.local.mismatch"); + case "LOCAL_TOKEN_INVALID": + return t("workspace.media.local.tokenInvalid"); + case "LOCAL_UPLOAD_TOO_LARGE": + return t("workspace.media.local.tooLarge"); + // tunnel 離線時取 token 會失敗(502);理論上 UI 已先 disable(見 video tab + // uploadDisabled),此為防禦性 fallback。 + case "TUNNEL_DISCONNECTED": + case "TUNNEL_ERROR": + return t("workspace.media.local.offline"); + default: + // 其他(network / timeout / 未預期)→ 保留原始訊息(含 abort 由 uploader 自行忽略) + return err instanceof Error ? err.message : String(err); + } + }, + [t], + ); + // 塊 3:切換 tab。離開 camera tab 時若正在推論 → 停掉(避免 camera 與 media 兩套 WS 同時灌 store)。 const handleTabChange = useCallback( (value: string) => { @@ -345,6 +383,10 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { )} + {/* 影片分頁:ADR-019 WP-4 走 localhost 直連 local-agent(非 tunnel)。 + - validate 用 500MB 上限(localhost 硬牆,見 media.ts validateLocalVideoFile) + - R-3:tunnel 離線(!isOnline)→ 停用上傳 + 明確提示(不做 tunnel fallback) + - 上傳失敗 → 依 error.code 對應 friendly i18n(localVideoErrorMessage) */} {!hasSerial ? ( noSerialNotice @@ -354,15 +396,25 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) { isOnline={!!isOnline} sourceType="video" accept={VIDEO_ACCEPT} + uploadDisabled={!isOnline} + uploadDisabledMessage={t("workspace.media.local.offline")} validate={(files) => - files[0] ? validateVideoFile(files[0]) : { code: "EMPTY" } + files[0] ? validateLocalVideoFile(files[0]) : { code: "EMPTY" } } upload={async (files, ctx) => { - return uploadVideo(serialNumber!, files[0]!, { - onProgress: ctx.onProgress, - signal: ctx.signal, - timeoutMs: VIDEO_UPLOAD_TIMEOUT_MS, - }); + try { + return await uploadVideoViaLocalAgent(serialNumber!, files[0]!, { + onProgress: ctx.onProgress, + signal: ctx.signal, + timeoutMs: VIDEO_UPLOAD_TIMEOUT_MS, + }); + } catch (err) { + // 使用者取消(AbortError,api.ts code=ABORTED / name=AbortError)→ 原樣拋回, + // 讓 MediaUploader 依 signal.aborted 靜默回 idle(不顯示錯誤)。 + if (err instanceof Error && err.name === "AbortError") throw err; + // 其餘(NOT_FOUND / MISMATCH / token / 413 / network …)轉 friendly i18n + throw new Error(localVideoErrorMessage(err)); + } }} /> )} diff --git a/visionA-frontend/src/components/workspace/media-tab.tsx b/visionA-frontend/src/components/workspace/media-tab.tsx index e5e5f27..673967d 100644 --- a/visionA-frontend/src/components/workspace/media-tab.tsx +++ b/visionA-frontend/src/components/workspace/media-tab.tsx @@ -55,6 +55,14 @@ export interface MediaTabProps { files: File[], ctx: { onProgress: (p: number) => void; signal: AbortSignal }, ) => Promise; + /** + * 停用上傳並改顯示提示(ADR-019 R-3:影片走 localhost 直連,tunnel 離線時 + * 上傳 / 結果都不可用 → disable 上傳 + 明確提示,不讓使用者上傳後看不到結果)。 + * 未設或 false 時行為不變(顯示 uploader)。 + */ + uploadDisabled?: boolean; + /** uploadDisabled 為 true 時顯示的提示文字(i18n,由 caller 傳入)。 */ + uploadDisabledMessage?: string; } export function MediaTab({ @@ -65,6 +73,8 @@ export function MediaTab({ multiple = false, validate, upload, + uploadDisabled = false, + uploadDisabledMessage, }: MediaTabProps) { const t = useT(); const [streamUrl, setStreamUrl] = useState(""); @@ -163,7 +173,18 @@ export function MediaTab({
- {!effectiveStreamUrl ? ( + {!effectiveStreamUrl && uploadDisabled ? ( + // R-3:tunnel 離線 → 停用上傳 + 明確提示(不顯示 uploader) +
+

+ {uploadDisabledMessage ?? t("workspace.media.errorGeneric")} +

+
+ ) : !effectiveStreamUrl ? ( { + const actual = await vi.importActual("@/lib/api"); + return { ...actual, api: { ...actual.api, post: (...a: unknown[]) => apiPost(...a) } }; +}); + +// mock WP-3 低階 util(各自已有 local-agent.test.ts 覆蓋) +const resolveLocalAgent = vi.fn(); +const uploadToLocalAgent = vi.fn(); +vi.mock("@/lib/local-agent", async () => { + const actual = + await vi.importActual("@/lib/local-agent"); + return { + ...actual, + resolveLocalAgent: (...a: unknown[]) => resolveLocalAgent(...a), + uploadToLocalAgent: (...a: unknown[]) => uploadToLocalAgent(...a), + }; +}); + +import { ApiError } from "@/lib/api"; +import { LOCAL_UPLOAD_VIDEO_PATH } from "@/lib/local-agent"; +import type { MediaUploadResponse } from "@/types/camera"; + +import { + LOCAL_UPLOAD_TICKET_PATH, + LocalMediaError, + getLocalUploadTicket, + uploadVideoViaLocalAgent, +} from "./local-media"; + +const SERIAL = "KN00123456"; + +function videoFile(): File { + return new File([new Blob([new Uint8Array(8)])], "v.mp4"); +} + +function okUploadResponse(): MediaUploadResponse { + return { + streamUrl: "/api/camera/stream", + sourceType: "video", + totalFrames: 100, + durationSeconds: 3.3, + }; +} + +beforeEach(() => { + apiPost.mockReset(); + resolveLocalAgent.mockReset(); + uploadToLocalAgent.mockReset(); +}); + +/* -------------------------------------------------------------------------- */ +/* getLocalUploadTicket */ +/* -------------------------------------------------------------------------- */ + +describe("getLocalUploadTicket", () => { + it("打對雲端 ticket route(含 serial encode)、回 token", async () => { + apiPost.mockResolvedValue({ token: "tok-1", expiresAt: 123, ttlSeconds: 120 }); + const ticket = await getLocalUploadTicket(SERIAL); + expect(ticket.token).toBe("tok-1"); + expect(apiPost).toHaveBeenCalledWith(LOCAL_UPLOAD_TICKET_PATH(SERIAL)); + expect(LOCAL_UPLOAD_TICKET_PATH(SERIAL)).toBe( + `/api/devices/${SERIAL}/local-upload-ticket`, + ); + }); + + it("serial 含特殊字元 → path 有 encode", () => { + expect(LOCAL_UPLOAD_TICKET_PATH("a/b c")).toBe( + "/api/devices/a%2Fb%20c/local-upload-ticket", + ); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* uploadVideoViaLocalAgent — happy path */ +/* -------------------------------------------------------------------------- */ + +describe("uploadVideoViaLocalAgent — happy path", () => { + it("ticket → resolve OK → 直連上傳,回 MediaUploadResponse", async () => { + apiPost.mockResolvedValue({ token: "tok-abc", ttlSeconds: 120 }); + resolveLocalAgent.mockResolvedValue({ status: "OK", port: 3725 }); + uploadToLocalAgent.mockResolvedValue(okUploadResponse()); + + const res = await uploadVideoViaLocalAgent(SERIAL, videoFile(), { + timeoutMs: 0, + }); + + expect(res.sourceType).toBe("video"); + // resolve 用 serial + expect(resolveLocalAgent).toHaveBeenCalledWith(SERIAL); + // upload 用 resolve 回的 port + 影片 route + token + const [port, path, form, opts] = uploadToLocalAgent.mock.calls[0]!; + expect(port).toBe(3725); + expect(path).toBe(LOCAL_UPLOAD_VIDEO_PATH); + expect((form as FormData).get("deviceId")).toBe(SERIAL); + expect((form as FormData).get("file")).toBeInstanceOf(File); + expect((opts as { token: string }).token).toBe("tok-abc"); + expect((opts as { timeoutMs: number }).timeoutMs).toBe(0); + }); + + it("progress / signal 透傳給 uploadToLocalAgent", async () => { + apiPost.mockResolvedValue({ token: "t" }); + resolveLocalAgent.mockResolvedValue({ status: "OK", port: 3721 }); + uploadToLocalAgent.mockResolvedValue(okUploadResponse()); + + const onProgress = vi.fn(); + const ctrl = new AbortController(); + await uploadVideoViaLocalAgent(SERIAL, videoFile(), { + onProgress, + signal: ctrl.signal, + }); + const opts = uploadToLocalAgent.mock.calls[0]![3] as { + onProgress: unknown; + signal: unknown; + }; + expect(opts.onProgress).toBe(onProgress); + expect(opts.signal).toBe(ctrl.signal); + }); +}); + +/* -------------------------------------------------------------------------- */ +/* uploadVideoViaLocalAgent — 錯誤分支 */ +/* -------------------------------------------------------------------------- */ + +describe("uploadVideoViaLocalAgent — 錯誤分支", () => { + it("resolve NOT_FOUND → 拋 LocalMediaError(LOCAL_AGENT_NOT_FOUND),不呼叫 upload", async () => { + apiPost.mockResolvedValue({ token: "t" }); + resolveLocalAgent.mockResolvedValue({ status: "NOT_FOUND" }); + + await expect( + uploadVideoViaLocalAgent(SERIAL, videoFile()), + ).rejects.toMatchObject({ code: "LOCAL_AGENT_NOT_FOUND" }); + expect(uploadToLocalAgent).not.toHaveBeenCalled(); + }); + + it("resolve MISMATCH → 拋 LocalMediaError(LOCAL_AGENT_MISMATCH),不呼叫 upload", async () => { + apiPost.mockResolvedValue({ token: "t" }); + resolveLocalAgent.mockResolvedValue({ status: "MISMATCH" }); + + const err = await uploadVideoViaLocalAgent(SERIAL, videoFile()).catch( + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(LocalMediaError); + expect((err as LocalMediaError).code).toBe("LOCAL_AGENT_MISMATCH"); + expect(uploadToLocalAgent).not.toHaveBeenCalled(); + }); + + it("resolve 回 OK 但沒有 port(防禦)→ 視為 MISMATCH", async () => { + apiPost.mockResolvedValue({ token: "t" }); + resolveLocalAgent.mockResolvedValue({ status: "OK", port: undefined }); + + await expect( + uploadVideoViaLocalAgent(SERIAL, videoFile()), + ).rejects.toMatchObject({ code: "LOCAL_AGENT_MISMATCH" }); + expect(uploadToLocalAgent).not.toHaveBeenCalled(); + }); + + it("ticket 取得失敗(如 tunnel 離線 502)→ 直接往上拋、不 resolve / upload", async () => { + apiPost.mockRejectedValue( + new ApiError(502, { code: "TUNNEL_DISCONNECTED", message: "offline" }), + ); + + await expect( + uploadVideoViaLocalAgent(SERIAL, videoFile()), + ).rejects.toMatchObject({ code: "TUNNEL_DISCONNECTED", status: 502 }); + expect(resolveLocalAgent).not.toHaveBeenCalled(); + expect(uploadToLocalAgent).not.toHaveBeenCalled(); + }); + + it("上傳階段 413 → 原樣拋 ApiError(LOCAL_UPLOAD_TOO_LARGE)", async () => { + apiPost.mockResolvedValue({ token: "t" }); + resolveLocalAgent.mockResolvedValue({ status: "OK", port: 3721 }); + uploadToLocalAgent.mockRejectedValue( + new ApiError(413, { code: "LOCAL_UPLOAD_TOO_LARGE", message: "too large" }), + ); + + await expect( + uploadVideoViaLocalAgent(SERIAL, videoFile()), + ).rejects.toMatchObject({ code: "LOCAL_UPLOAD_TOO_LARGE", status: 413 }); + }); + + it("上傳階段 401 → 原樣拋 ApiError(LOCAL_TOKEN_INVALID)", async () => { + apiPost.mockResolvedValue({ token: "t" }); + resolveLocalAgent.mockResolvedValue({ status: "OK", port: 3721 }); + uploadToLocalAgent.mockRejectedValue( + new ApiError(401, { code: "LOCAL_TOKEN_INVALID", message: "invalid" }), + ); + + await expect( + uploadVideoViaLocalAgent(SERIAL, videoFile()), + ).rejects.toMatchObject({ code: "LOCAL_TOKEN_INVALID", status: 401 }); + }); +}); diff --git a/visionA-frontend/src/lib/local-media.ts b/visionA-frontend/src/lib/local-media.ts new file mode 100644 index 0000000..1719489 --- /dev/null +++ b/visionA-frontend/src/lib/local-media.ts @@ -0,0 +1,135 @@ +/** + * Local-agent 直連上傳的「編排層」— visionA Cloud 前端(ADR-019 WP-4) + * + * 職責(把 WP-3 的低階 util 串成影片分頁要用的完整流程): + * 1. `getLocalUploadTicket(serial)`:向雲端 `POST /api/devices/:serial/local-upload-ticket` + * 取 one-time upload token(走既有 OIDC cookie session + tunnel,見 api-spec §6.3)。 + * 2. `uploadVideoViaLocalAgent(serial, file, opts)`:取 token → 探測同機 local-agent + * → 直連 POST `/api/local/media/upload/video`(帶 X-Visiona-Local-Token)。 + * + * 與 media.ts(雲端 tunnel 路徑)的分工: + * - media.ts 的 uploadVideo 走雲端 same-origin cookie(BFF)+ tunnel forward(保留不動)。 + * - 本檔走「上傳走 localhost、控制面(token)走雲端」的混合路徑(ADR-019 §2.1)。 + * - 影片分頁 **完全切 localhost、不做 tunnel fallback**(ADR §4.2「非同機停用分頁」), + * 故本檔不引用 media.ts 的 uploadVideo。 + * + * 錯誤模型(讓 caller 能對 UI 分流 i18n,api-spec §6.5): + * - resolve 階段用 `LocalMediaError`(code: LOCAL_AGENT_NOT_FOUND / LOCAL_AGENT_MISMATCH) + * - 取 token / 上傳階段的 HTTP 錯誤沿用 api.ts 的 ApiError(code: LOCAL_TOKEN_INVALID / + * LOCAL_TOKEN_LIMIT / LOCAL_UPLOAD_TOO_LARGE / TUNNEL_DISCONNECTED / …)。 + * caller(workspace-client 影片分頁)以 error.code 對應具體 i18n 文案。 + */ + +import { api } from "@/lib/api"; +import { + LOCAL_UPLOAD_VIDEO_PATH, + resolveLocalAgent, + uploadToLocalAgent, +} from "@/lib/local-agent"; +import type { UploadMediaOptions } from "@/lib/media"; +import type { KnownErrorCode } from "@/types/api"; +import type { MediaUploadResponse } from "@/types/camera"; + +/** 雲端 ticket endpoint(api-spec §6.3;驗 OIDC session + 裝置歸屬後經 tunnel issue-token)。 */ +export const LOCAL_UPLOAD_TICKET_PATH = (serial: string): string => + `/api/devices/${encodeURIComponent(serial)}/local-upload-ticket`; + +/** ticket endpoint 回傳(api-spec §6.3:issue-token 的 data 透傳)。 */ +export interface LocalUploadTicket { + token: string; + /** token 到期時間(unix ms)。 */ + expiresAt?: number; + /** TTL 秒數(契約固定 120s)。 */ + ttlSeconds?: number; +} + +/** + * resolve local-agent 階段的錯誤(純前端狀態、非後端回傳的 HTTP 錯誤)。 + * + * 為什麼獨立一個 Error 類別而非沿用 ApiError: + * NOT_FOUND / MISMATCH 是「前端掃描結論」(api-spec §6.5 標「—(前端內部狀態)」), + * 沒有 HTTP status;用專屬類別讓 caller 能 `instanceof` 精準分流,且 code 型別安全。 + */ +export class LocalMediaError extends Error { + readonly code: Extract< + KnownErrorCode, + "LOCAL_AGENT_NOT_FOUND" | "LOCAL_AGENT_MISMATCH" + >; + constructor( + code: LocalMediaError["code"], + message = code, + ) { + super(message); + this.name = "LocalMediaError"; + this.code = code; + } +} + +/** + * 向雲端要 one-time upload token(走既有 OIDC cookie session + tunnel)。 + * + * @param serial 目前裝置 serialNumber(kn_number;ADR-018 serial 路由) + * @throws ApiError(TUNNEL_DISCONNECTED / LOCAL_TOKEN_LIMIT / … 由後端 envelope 帶出) + */ +export function getLocalUploadTicket( + serial: string, +): Promise { + // api.post 已解 envelope、回 data;走 same-origin cookie(BFF),與其他雲端呼叫一致。 + return api.post(LOCAL_UPLOAD_TICKET_PATH(serial)); +} + +/** uploadVideoViaLocalAgent 的選項(沿用 media 的進度 / 取消 / timeout)。 */ +export type UploadLocalVideoOptions = UploadMediaOptions; + +/** + * 影片分頁的完整直連上傳流程(ADR-019 §2.4 契約 [1]→[4])。 + * + * 流程: + * 1. 取 token(雲端 ticket)— 失敗直接往上拋(ApiError)。 + * 2. resolveLocalAgent(serial) → 探測 + 同機 + serial 身分驗證。 + * NOT_FOUND / MISMATCH → 拋 LocalMediaError(caller 對應 i18n)。 + * 3. uploadToLocalAgent(port, /api/local/media/upload/video, form, { token })。 + * + * **前提(caller 責任)**:呼叫前須已確認裝置 tunnel 在線(R-3)。tunnel 離線時 + * token 取不到(雲端經 tunnel issue-token 會失敗),但為了給使用者清楚提示, + * caller(影片分頁)應在 UI 層先 disable 上傳、不進到這裡(見 workspace-client)。 + * + * @param serial 裝置 serialNumber(kn_number) + * @param file 影片檔(副檔名 / 大小驗證由 caller 先做,見 media.ts validateLocalVideoFile) + * @param options 進度 / 取消 / timeout + * @returns 解開 envelope 的 MediaUploadResponse(含 streamUrl / totalFrames / durationSeconds) + * @throws LocalMediaError | ApiError | NetworkError | TimeoutError | AbortError + */ +export async function uploadVideoViaLocalAgent( + serial: string, + file: File, + options: UploadLocalVideoOptions = {}, +): Promise { + // [1] 取 token(雲端;此步失敗會拋 ApiError,含 TUNNEL_DISCONNECTED 等) + const ticket = await getLocalUploadTicket(serial); + + // 已取消就不必再探測 / 上傳 + if (options.signal?.aborted) { + // 讓 caller 的 abort 分支一致:沿用 local-agent 的 AbortError(透過 upload 拋) + // 這裡直接進入 upload 前的 guard 由 uploadToLocalAgent 處理 aborted signal。 + } + + // [2] 探測同機 local-agent + serial 身分驗證 + const resolved = await resolveLocalAgent(serial); + if (resolved.status === "NOT_FOUND") { + throw new LocalMediaError("LOCAL_AGENT_NOT_FOUND"); + } + if (resolved.status === "MISMATCH" || resolved.port === undefined) { + throw new LocalMediaError("LOCAL_AGENT_MISMATCH"); + } + + // [3] 直連上傳(FormData 欄位名維持 deviceId、值帶 serial,與雲端 route 相同) + const form = new FormData(); + form.append("deviceId", serial); + form.append("file", file); + + return uploadToLocalAgent(resolved.port, LOCAL_UPLOAD_VIDEO_PATH, form, { + ...options, + token: ticket.token, + }); +} diff --git a/visionA-frontend/src/lib/media.test.ts b/visionA-frontend/src/lib/media.test.ts index 5644c99..f37c3ac 100644 --- a/visionA-frontend/src/lib/media.test.ts +++ b/visionA-frontend/src/lib/media.test.ts @@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MAX_BATCH_IMAGES, MAX_BATCH_TOTAL_BYTES, + MAX_LOCAL_VIDEO_BYTES, MAX_VIDEO_BYTES, VIDEO_FALLBACK_FPS, buildBatchImageUrl, @@ -21,6 +22,7 @@ import { uploadImage, validateBatchFiles, validateImageFile, + validateLocalVideoFile, validateVideoFile, } from "./media"; @@ -72,6 +74,25 @@ describe("media validation", () => { expect(validateVideoFile(makeSizedFile("v.mp4", 91 * mb))?.code).toBe("SIZE"); }); + it("影片 localhost 路徑:上限為 500 MB(ADR-019 WP-4 放寬,常數校驗)", () => { + expect(MAX_LOCAL_VIDEO_BYTES).toBe(500 * 1024 * 1024); + }); + + it("validateLocalVideoFile:91 MB(過 tunnel 上限)仍通過,剛好 500 MB 通過,501 MB 擋 SIZE", () => { + const mb = 1024 * 1024; + // 91 MB 若走舊 tunnel 上限(90MB)會被擋;localhost 路徑放寬到 500MB → 通過 + expect(validateLocalVideoFile(makeSizedFile("v.mp4", 91 * mb))).toBeNull(); + expect(validateLocalVideoFile(makeSizedFile("v.mp4", 500 * mb))).toBeNull(); + expect(validateLocalVideoFile(makeSizedFile("v.mp4", 501 * mb))?.code).toBe( + "SIZE", + ); + }); + + it("validateLocalVideoFile:型別檢查與 validateVideoFile 相同(非影片擋 TYPE)", () => { + expect(validateLocalVideoFile(makeFile("v.mp4"))).toBeNull(); + expect(validateLocalVideoFile(makeFile("v.jpg"))?.code).toBe("TYPE"); + }); + it("批次:空陣列 → EMPTY,超過 50 → COUNT,含非圖 → TYPE", () => { expect(validateBatchFiles([])?.code).toBe("EMPTY"); diff --git a/visionA-frontend/src/lib/media.ts b/visionA-frontend/src/lib/media.ts index 0db3658..0110f59 100644 --- a/visionA-frontend/src/lib/media.ts +++ b/visionA-frontend/src/lib/media.ts @@ -91,7 +91,22 @@ export const VIDEO_ACCEPT = ".mp4,.avi,.mov,.mpeg,.mpg"; /** 前端上傳大小上限(防呆;影片經 tunnel 有 timeout 考量,見評估 R-M2)。 */ export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB -export const MAX_VIDEO_BYTES = 90 * 1024 * 1024; // 90 MB(過渡值:對齊 nginx client_max_body_size 100M,留 10 MB buffer 給 multipart overhead,避免 HTTP 413。未來影片走 localhost 直連後可放寬) +export const MAX_VIDEO_BYTES = 90 * 1024 * 1024; // 90 MB(過渡值:對齊 nginx client_max_body_size 100M,留 10 MB buffer 給 multipart overhead,避免 HTTP 413。此上限僅適用於**經 tunnel 的雲端路徑**(uploadVideo);影片分頁已改走 localhost 直連,見 MAX_LOCAL_VIDEO_BYTES) + +/** + * 影片走 **localhost 直連 local-agent** 時的上傳上限(ADR-019 WP-4)。 + * + * 為什麼從 90MB 放寬到 500MB: + * 90MB 是為了對齊 nginx `client_max_body_size 100M` 的**過渡值**——只在「經雲端 tunnel」 + * 時才受此約束。影片分頁改走 localhost 直連(繞過 nginx / 300s timeout / stage 落地), + * 後端硬牆是 500MB(ADR-019 §4.3.1:`http.MaxBytesReader` ≤ 500MB),故前端上限 + * 對齊到 500MB。security 明確要求「不要因為走 loopback 就給 1GB」——攻擊面不因 loopback 縮小。 + * + * 為什麼放寬是安全的:影片分頁**完全切 localhost、不做 tunnel fallback** + * (ADR §4.2「非同機停用分頁」),故此路徑不會退回受 90MB 約束的 tunnel 路徑。 + * 舊 `MAX_VIDEO_BYTES`(90MB)與 tunnel 路徑的 `uploadVideo` 保留不動(向下相容 / 其他 caller)。 + */ +export const MAX_LOCAL_VIDEO_BYTES = 500 * 1024 * 1024; // 500 MB(對齊後端 localhost 硬牆) /** * 批次上傳「合計大小」上限(對齊 ADR-019 §2.2 / 後端 batch 合計 80MB)。 @@ -305,17 +320,37 @@ export function validateImageFile(file: File): FileValidationError | null { return null; } -/** 依副檔名 + 大小驗證影片。回傳 null 表通過。 */ -export function validateVideoFile(file: File): FileValidationError | null { +/** + * 依副檔名 + 大小驗證影片。回傳 null 表通過。 + * + * @param file 影片檔 + * @param maxBytes 大小上限(預設 MAX_VIDEO_BYTES = 90MB,用於**經 tunnel 的雲端路徑**)。 + * 影片分頁走 localhost 直連時傳 MAX_LOCAL_VIDEO_BYTES(500MB)—— 見 validateLocalVideoFile。 + */ +export function validateVideoFile( + file: File, + maxBytes: number = MAX_VIDEO_BYTES, +): FileValidationError | null { if (!/\.(mp4|avi|mov|mpe?g)$/i.test(file.name)) { return { code: "TYPE", filename: file.name }; } - if (file.size > MAX_VIDEO_BYTES) { + if (file.size > maxBytes) { return { code: "SIZE", filename: file.name }; } return null; } +/** + * 影片走 localhost 直連 local-agent 的驗證(ADR-019 WP-4)——大小上限用 500MB。 + * + * 與 validateVideoFile 的差異:只有 size 上限不同(型別檢查相同)。 + * 抽成具名函式(而非在 caller inline 傳 maxBytes)讓「影片分頁用 500MB」的意圖在 + * 呼叫端一目了然,且與 image / batch 的具名 validator 慣例一致。 + */ +export function validateLocalVideoFile(file: File): FileValidationError | null { + return validateVideoFile(file, MAX_LOCAL_VIDEO_BYTES); +} + /** * 驗證整批圖片(數量 + 每張型別 / 大小 + 合計大小)。回傳 null 表通過。 *