feat(adr-019): 影片分頁接線 localhost 直連,上限 90MB→500MB(WP-4)
影片分頁上傳從舊 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) <noreply@anthropic.com>
This commit is contained in:
parent
329023085e
commit
b10fbb8091
@ -29,6 +29,18 @@ vi.mock("@/hooks/use-inference-stream", () => ({
|
|||||||
useInferenceStream: vi.fn(),
|
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<typeof import("@/lib/local-media")>("@/lib/local-media");
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
uploadVideoViaLocalAgent: (...a: unknown[]) => uploadVideoViaLocalAgent(...a),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// 可控 ResizeObserver:把 callback 存起來,測試決定何時觸發
|
// 可控 ResizeObserver:把 callback 存起來,測試決定何時觸發
|
||||||
let roCallbacks: ResizeObserverCallback[] = [];
|
let roCallbacks: ResizeObserverCallback[] = [];
|
||||||
class ControllableRO {
|
class ControllableRO {
|
||||||
@ -43,6 +55,7 @@ class ControllableRO {
|
|||||||
}
|
}
|
||||||
|
|
||||||
import { useInferenceStream } from "@/hooks/use-inference-stream";
|
import { useInferenceStream } from "@/hooks/use-inference-stream";
|
||||||
|
import { LocalMediaError } from "@/lib/local-media";
|
||||||
|
|
||||||
import { WorkspaceClient } from "./workspace-client";
|
import { WorkspaceClient } from "./workspace-client";
|
||||||
|
|
||||||
@ -91,6 +104,7 @@ function renderClient() {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
post.mockReset();
|
post.mockReset();
|
||||||
|
uploadVideoViaLocalAgent.mockReset();
|
||||||
roCallbacks = [];
|
roCallbacks = [];
|
||||||
(globalThis as { ResizeObserver?: unknown }).ResizeObserver = ControllableRO;
|
(globalThis as { ResizeObserver?: unknown }).ResizeObserver = ControllableRO;
|
||||||
useInferenceStore.setState({
|
useInferenceStore.setState({
|
||||||
@ -280,3 +294,93 @@ describe("WorkspaceClient — serial 路由(WP-C / ADR-018)", () => {
|
|||||||
expect(screen.queryByTestId("media-no-serial")).not.toBeInTheDocument();
|
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<typeof useDeviceStore.getState>["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("不符");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -51,18 +51,26 @@ import {
|
|||||||
VIDEO_ACCEPT,
|
VIDEO_ACCEPT,
|
||||||
uploadBatchImages,
|
uploadBatchImages,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
uploadVideo,
|
|
||||||
validateBatchFiles,
|
validateBatchFiles,
|
||||||
validateImageFile,
|
validateImageFile,
|
||||||
validateVideoFile,
|
validateLocalVideoFile,
|
||||||
} from "@/lib/media";
|
} from "@/lib/media";
|
||||||
|
import {
|
||||||
|
LocalMediaError,
|
||||||
|
uploadVideoViaLocalAgent,
|
||||||
|
} from "@/lib/local-media";
|
||||||
import { useT } from "@/lib/i18n/context";
|
import { useT } from "@/lib/i18n/context";
|
||||||
import { useDeviceStore } from "@/stores/device-store";
|
import { useDeviceStore } from "@/stores/device-store";
|
||||||
import { useInferenceStore } from "@/stores/inference-store";
|
import { useInferenceStore } from "@/stores/inference-store";
|
||||||
import type { MediaUploadResponse } from "@/types/camera";
|
import type { MediaUploadResponse } from "@/types/camera";
|
||||||
import { toast } from "sonner";
|
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;
|
const VIDEO_UPLOAD_TIMEOUT_MS = 0;
|
||||||
|
|
||||||
interface WorkspaceClientProps {
|
interface WorkspaceClientProps {
|
||||||
@ -114,6 +122,36 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
|||||||
const serialNumber = selectedDevice?.serialNumber ?? null;
|
const serialNumber = selectedDevice?.serialNumber ?? null;
|
||||||
const hasSerial = !!serialNumber;
|
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)。
|
// 塊 3:切換 tab。離開 camera tab 時若正在推論 → 停掉(避免 camera 與 media 兩套 WS 同時灌 store)。
|
||||||
const handleTabChange = useCallback(
|
const handleTabChange = useCallback(
|
||||||
(value: string) => {
|
(value: string) => {
|
||||||
@ -345,6 +383,10 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
|||||||
)}
|
)}
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* 影片分頁: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) */}
|
||||||
<TabsContent value="video" className="space-y-4">
|
<TabsContent value="video" className="space-y-4">
|
||||||
{!hasSerial ? (
|
{!hasSerial ? (
|
||||||
noSerialNotice
|
noSerialNotice
|
||||||
@ -354,15 +396,25 @@ export function WorkspaceClient({ deviceId }: WorkspaceClientProps) {
|
|||||||
isOnline={!!isOnline}
|
isOnline={!!isOnline}
|
||||||
sourceType="video"
|
sourceType="video"
|
||||||
accept={VIDEO_ACCEPT}
|
accept={VIDEO_ACCEPT}
|
||||||
|
uploadDisabled={!isOnline}
|
||||||
|
uploadDisabledMessage={t("workspace.media.local.offline")}
|
||||||
validate={(files) =>
|
validate={(files) =>
|
||||||
files[0] ? validateVideoFile(files[0]) : { code: "EMPTY" }
|
files[0] ? validateLocalVideoFile(files[0]) : { code: "EMPTY" }
|
||||||
}
|
}
|
||||||
upload={async (files, ctx) => {
|
upload={async (files, ctx) => {
|
||||||
return uploadVideo(serialNumber!, files[0]!, {
|
try {
|
||||||
onProgress: ctx.onProgress,
|
return await uploadVideoViaLocalAgent(serialNumber!, files[0]!, {
|
||||||
signal: ctx.signal,
|
onProgress: ctx.onProgress,
|
||||||
timeoutMs: VIDEO_UPLOAD_TIMEOUT_MS,
|
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));
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -55,6 +55,14 @@ export interface MediaTabProps {
|
|||||||
files: File[],
|
files: File[],
|
||||||
ctx: { onProgress: (p: number) => void; signal: AbortSignal },
|
ctx: { onProgress: (p: number) => void; signal: AbortSignal },
|
||||||
) => Promise<MediaUploadResponse>;
|
) => Promise<MediaUploadResponse>;
|
||||||
|
/**
|
||||||
|
* 停用上傳並改顯示提示(ADR-019 R-3:影片走 localhost 直連,tunnel 離線時
|
||||||
|
* 上傳 / 結果都不可用 → disable 上傳 + 明確提示,不讓使用者上傳後看不到結果)。
|
||||||
|
* 未設或 false 時行為不變(顯示 uploader)。
|
||||||
|
*/
|
||||||
|
uploadDisabled?: boolean;
|
||||||
|
/** uploadDisabled 為 true 時顯示的提示文字(i18n,由 caller 傳入)。 */
|
||||||
|
uploadDisabledMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaTab({
|
export function MediaTab({
|
||||||
@ -65,6 +73,8 @@ export function MediaTab({
|
|||||||
multiple = false,
|
multiple = false,
|
||||||
validate,
|
validate,
|
||||||
upload,
|
upload,
|
||||||
|
uploadDisabled = false,
|
||||||
|
uploadDisabledMessage,
|
||||||
}: MediaTabProps) {
|
}: MediaTabProps) {
|
||||||
const t = useT();
|
const t = useT();
|
||||||
const [streamUrl, setStreamUrl] = useState("");
|
const [streamUrl, setStreamUrl] = useState("");
|
||||||
@ -163,7 +173,18 @@ export function MediaTab({
|
|||||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_20rem]">
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1fr_20rem]">
|
||||||
<Card className="min-h-[60vh]">
|
<Card className="min-h-[60vh]">
|
||||||
<CardContent className="grid h-full min-h-[60vh] place-items-center p-6">
|
<CardContent className="grid h-full min-h-[60vh] place-items-center p-6">
|
||||||
{!effectiveStreamUrl ? (
|
{!effectiveStreamUrl && uploadDisabled ? (
|
||||||
|
// R-3:tunnel 離線 → 停用上傳 + 明確提示(不顯示 uploader)
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="max-w-md space-y-2 text-center"
|
||||||
|
data-testid={`media-upload-disabled-${sourceType}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm font-medium">
|
||||||
|
{uploadDisabledMessage ?? t("workspace.media.errorGeneric")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : !effectiveStreamUrl ? (
|
||||||
<MediaUploader
|
<MediaUploader
|
||||||
accept={accept}
|
accept={accept}
|
||||||
multiple={multiple}
|
multiple={multiple}
|
||||||
|
|||||||
@ -362,13 +362,23 @@ export const en: Dictionary = {
|
|||||||
"workspace.media.image.primary": "Drag an image here to run inference",
|
"workspace.media.image.primary": "Drag an image here to run inference",
|
||||||
"workspace.media.image.hint": "JPG or PNG · up to 20 MB",
|
"workspace.media.image.hint": "JPG or PNG · up to 20 MB",
|
||||||
"workspace.media.video.primary": "Drag a video here to run inference",
|
"workspace.media.video.primary": "Drag a video here to run inference",
|
||||||
"workspace.media.video.hint": "MP4 / AVI / MOV / MPEG · up to 90 MB",
|
"workspace.media.video.hint": "MP4 / AVI / MOV / MPEG · up to 500 MB",
|
||||||
"workspace.media.video.frame": "Frame",
|
"workspace.media.video.frame": "Frame",
|
||||||
"workspace.media.video.seek": "Seek video",
|
"workspace.media.video.seek": "Seek video",
|
||||||
"workspace.media.batch.primary": "Drag images here (up to 50) for batch inference",
|
"workspace.media.batch.primary": "Drag images here (up to 50) for batch inference",
|
||||||
"workspace.media.batch.hint": "JPG or PNG · up to 50 files · 20 MB each",
|
"workspace.media.batch.hint": "JPG or PNG · up to 50 files · 20 MB each",
|
||||||
"workspace.media.batch.progress": "Image {current} / {total}",
|
"workspace.media.batch.progress": "Image {current} / {total}",
|
||||||
|
|
||||||
|
// ── Video localhost direct-upload errors (ADR-019 WP-4) ──
|
||||||
|
"workspace.media.local.notFound":
|
||||||
|
"Video upload must be done on the same computer running the visionA Agent. Make sure the Agent is running on this machine and try again.",
|
||||||
|
"workspace.media.local.mismatch":
|
||||||
|
"The detected Agent doesn't match this device. Make sure this computer is running the visionA Agent for this device.",
|
||||||
|
"workspace.media.local.offline":
|
||||||
|
"This device is offline; video upload is unavailable. Please wait for the device to reconnect.",
|
||||||
|
"workspace.media.local.tokenInvalid": "Upload authorization expired. Please try uploading again.",
|
||||||
|
"workspace.media.local.tooLarge": "Video file is too large (500 MB max).",
|
||||||
|
|
||||||
// ── Settings ──
|
// ── Settings ──
|
||||||
"settings.title": "Settings",
|
"settings.title": "Settings",
|
||||||
"settings.subtitle": "Manage preferences and cloud endpoints",
|
"settings.subtitle": "Manage preferences and cloud endpoints",
|
||||||
|
|||||||
@ -350,13 +350,23 @@ export const zhHant: Dictionary = {
|
|||||||
"workspace.media.image.primary": "將圖片拖曳到此處開始推論",
|
"workspace.media.image.primary": "將圖片拖曳到此處開始推論",
|
||||||
"workspace.media.image.hint": "JPG 或 PNG · 上限 20 MB",
|
"workspace.media.image.hint": "JPG 或 PNG · 上限 20 MB",
|
||||||
"workspace.media.video.primary": "將影片拖曳到此處開始推論",
|
"workspace.media.video.primary": "將影片拖曳到此處開始推論",
|
||||||
"workspace.media.video.hint": "MP4 / AVI / MOV / MPEG · 上限 90 MB",
|
"workspace.media.video.hint": "MP4 / AVI / MOV / MPEG · 上限 500 MB",
|
||||||
"workspace.media.video.frame": "影格",
|
"workspace.media.video.frame": "影格",
|
||||||
"workspace.media.video.seek": "影片跳轉",
|
"workspace.media.video.seek": "影片跳轉",
|
||||||
"workspace.media.batch.primary": "將圖片拖曳到此處(最多 50 張)進行批次推論",
|
"workspace.media.batch.primary": "將圖片拖曳到此處(最多 50 張)進行批次推論",
|
||||||
"workspace.media.batch.hint": "JPG 或 PNG · 最多 50 張 · 每張上限 20 MB",
|
"workspace.media.batch.hint": "JPG 或 PNG · 最多 50 張 · 每張上限 20 MB",
|
||||||
"workspace.media.batch.progress": "第 {current} / {total} 張",
|
"workspace.media.batch.progress": "第 {current} / {total} 張",
|
||||||
|
|
||||||
|
// ── 影片 localhost 直連上傳錯誤(ADR-019 WP-4)──
|
||||||
|
"workspace.media.local.notFound":
|
||||||
|
"影片上傳需在執行 visionA Agent 的同一台電腦上操作。請確認 Agent 正在此電腦執行後再試。",
|
||||||
|
"workspace.media.local.mismatch":
|
||||||
|
"偵測到的 Agent 與目前裝置不符。請確認此電腦執行的是這台裝置的 visionA Agent。",
|
||||||
|
"workspace.media.local.offline":
|
||||||
|
"裝置目前離線,無法上傳影片。請待裝置重新連線後再試。",
|
||||||
|
"workspace.media.local.tokenInvalid": "上傳授權已失效,請重新上傳。",
|
||||||
|
"workspace.media.local.tooLarge": "影片檔案過大(上限 500 MB)。",
|
||||||
|
|
||||||
// ── Settings ──
|
// ── Settings ──
|
||||||
"settings.title": "設定",
|
"settings.title": "設定",
|
||||||
"settings.subtitle": "管理偏好與雲端端點",
|
"settings.subtitle": "管理偏好與雲端端點",
|
||||||
|
|||||||
208
visionA-frontend/src/lib/local-media.test.ts
Normal file
208
visionA-frontend/src/lib/local-media.test.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
/**
|
||||||
|
* local-media.ts 單元測試(ADR-019 WP-4)
|
||||||
|
*
|
||||||
|
* 對契約 mock(不需實機、不需 local-agent):
|
||||||
|
* - getLocalUploadTicket:打對雲端 ticket route、回 token
|
||||||
|
* - uploadVideoViaLocalAgent 編排:ticket → resolveLocalAgent → uploadToLocalAgent
|
||||||
|
* 的 happy path 與各分支錯誤(NOT_FOUND / MISMATCH / ticket 失敗 / 上傳失敗)
|
||||||
|
*
|
||||||
|
* mock 策略:
|
||||||
|
* - mock `@/lib/api` 的 api.post(ticket endpoint)
|
||||||
|
* - mock `@/lib/local-agent` 的 resolveLocalAgent / uploadToLocalAgent(WP-3 已各自有測試)
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
// mock 雲端 api client(ticket endpoint)
|
||||||
|
const apiPost = vi.fn();
|
||||||
|
vi.mock("@/lib/api", async () => {
|
||||||
|
const actual = await vi.importActual<typeof import("@/lib/api")>("@/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<typeof import("@/lib/local-agent")>("@/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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
135
visionA-frontend/src/lib/local-media.ts
Normal file
135
visionA-frontend/src/lib/local-media.ts
Normal file
@ -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<LocalUploadTicket> {
|
||||||
|
// api.post 已解 envelope、回 data;走 same-origin cookie(BFF),與其他雲端呼叫一致。
|
||||||
|
return api.post<LocalUploadTicket>(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<MediaUploadResponse> {
|
||||||
|
// [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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import {
|
import {
|
||||||
MAX_BATCH_IMAGES,
|
MAX_BATCH_IMAGES,
|
||||||
MAX_BATCH_TOTAL_BYTES,
|
MAX_BATCH_TOTAL_BYTES,
|
||||||
|
MAX_LOCAL_VIDEO_BYTES,
|
||||||
MAX_VIDEO_BYTES,
|
MAX_VIDEO_BYTES,
|
||||||
VIDEO_FALLBACK_FPS,
|
VIDEO_FALLBACK_FPS,
|
||||||
buildBatchImageUrl,
|
buildBatchImageUrl,
|
||||||
@ -21,6 +22,7 @@ import {
|
|||||||
uploadImage,
|
uploadImage,
|
||||||
validateBatchFiles,
|
validateBatchFiles,
|
||||||
validateImageFile,
|
validateImageFile,
|
||||||
|
validateLocalVideoFile,
|
||||||
validateVideoFile,
|
validateVideoFile,
|
||||||
} from "./media";
|
} from "./media";
|
||||||
|
|
||||||
@ -72,6 +74,25 @@ describe("media validation", () => {
|
|||||||
expect(validateVideoFile(makeSizedFile("v.mp4", 91 * mb))?.code).toBe("SIZE");
|
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", () => {
|
it("批次:空陣列 → EMPTY,超過 50 → COUNT,含非圖 → TYPE", () => {
|
||||||
expect(validateBatchFiles([])?.code).toBe("EMPTY");
|
expect(validateBatchFiles([])?.code).toBe("EMPTY");
|
||||||
|
|
||||||
|
|||||||
@ -91,7 +91,22 @@ export const VIDEO_ACCEPT = ".mp4,.avi,.mov,.mpeg,.mpg";
|
|||||||
|
|
||||||
/** 前端上傳大小上限(防呆;影片經 tunnel 有 timeout 考量,見評估 R-M2)。 */
|
/** 前端上傳大小上限(防呆;影片經 tunnel 有 timeout 考量,見評估 R-M2)。 */
|
||||||
export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB
|
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)。
|
* 批次上傳「合計大小」上限(對齊 ADR-019 §2.2 / 後端 batch 合計 80MB)。
|
||||||
@ -305,17 +320,37 @@ export function validateImageFile(file: File): FileValidationError | null {
|
|||||||
return 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)) {
|
if (!/\.(mp4|avi|mov|mpe?g)$/i.test(file.name)) {
|
||||||
return { code: "TYPE", filename: 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 { code: "SIZE", filename: file.name };
|
||||||
}
|
}
|
||||||
return null;
|
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 表通過。
|
* 驗證整批圖片(數量 + 每張型別 / 大小 + 合計大小)。回傳 null 表通過。
|
||||||
*
|
*
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user