From 87e6476ba8e9bae6f0653f81393be298ea8c3302 Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 20 Oct 2026 18:43:59 +0800 Subject: [PATCH] fix(canvas): prepare durable selection ZIPs before resumable download --- app/api/canvas/download-zip-task/route.ts | 442 +++++++----------- components/canvas/CanvasWorkspace.tsx | 39 +- lib/canvas/download-byte-range.ts | 11 + lib/canvas/download-zip-stream.ts | 18 +- lib/canvas/download-zip-task-store.ts | 100 ++++ lib/client/canvas-zip-download.ts | 50 ++ lib/storage/canvas-zip-delivery.ts | 40 ++ proxy.ts | 3 +- tests/canvas-download-speed.config.ts | 2 +- .../helpers/canvas-download-server-loader.ts | 69 +++ tests/routes/canvas-zip-download.test.ts | 126 +++++ 11 files changed, 581 insertions(+), 319 deletions(-) create mode 100644 lib/canvas/download-byte-range.ts create mode 100644 lib/canvas/download-zip-task-store.ts create mode 100644 lib/client/canvas-zip-download.ts create mode 100644 lib/storage/canvas-zip-delivery.ts create mode 100644 tests/helpers/canvas-download-server-loader.ts create mode 100644 tests/routes/canvas-zip-download.test.ts diff --git a/app/api/canvas/download-zip-task/route.ts b/app/api/canvas/download-zip-task/route.ts index 7ec23089b..7ad1533f5 100644 --- a/app/api/canvas/download-zip-task/route.ts +++ b/app/api/canvas/download-zip-task/route.ts @@ -1,55 +1,22 @@ -import { writeDownloadZip, readDownloadZip, removeDownloadZip } from "@/lib/canvas/download-zip-stream"; +import { writeDownloadZip, readDownloadZip } from "@/lib/canvas/download-zip-stream"; +import { claimZipTask, loadZipTask, saveZipTask, zipTaskPath, assertZipDiskSpace, cleanupZipTasks, assertZipTaskCapacity, type ZipTask } from "@/lib/canvas/download-zip-task-store"; +import { parseDownloadByteRange } from "@/lib/canvas/download-byte-range"; +import { createReadStream } from "node:fs"; +import { mkdir, stat, writeFile, rm } from "node:fs/promises"; +import { join } from "node:path"; import { randomUUID } from "node:crypto"; -import { NextRequest, NextResponse } from "next/server"; +import { after, NextRequest, NextResponse } from "next/server"; import JSZip from "jszip"; import { auth } from "@/app/(auth)/auth"; import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download"; - +import { publishCanvasZip } from "@/lib/storage/canvas-zip-delivery"; export const runtime = "nodejs"; export const maxDuration = 300; - -type DownloadFileItem = { - url: string; - name?: string; -}; - -type ZipTaskStatus = "pending" | "running" | "completed" | "failed"; - -type ZipTask = { - id: string; - userId: string; - folderName: string; - files: DownloadFileItem[]; - totalCount: number; - processedCount: number; - successCount: number; - failedCount: number; - compressPercent: number; - status: ZipTaskStatus; - stage: string; - error?: string; - zipFile?: { path: string; bytes: number }; - contentDisposition?: string; - createdAt: number; - updatedAt: number; - expiresAt: number; -}; - -const MAX_CONCURRENCY = 8; +type DownloadFileItem = { url: string; name?: string }; const MAX_DOWNLOAD_FILES = 200; -const TASK_TTL_MS = 30 * 60 * 1000; - -const globalTaskStore = globalThis as typeof globalThis & { - __canvasDownloadZipTasks?: Map; -}; - -function getTaskStore() { - if (!globalTaskStore.__canvasDownloadZipTasks) { - globalTaskStore.__canvasDownloadZipTasks = new Map(); - } - return globalTaskStore.__canvasDownloadZipTasks; -} - +const TASK_TTL_MS = 60 * 60_000; +const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set }); +const active = running.__canvasZipRunning ??= new Set(); function sanitizeFolderName(value: string): string { const cleaned = String(value || "") .replace(/[\\/:*?"<>|]+/g, "-") @@ -57,7 +24,7 @@ function sanitizeFolderName(value: string): string { .replace(/-+/g, "-") .replace(/^-+|-+$/g, "") .trim(); - return cleaned || `canvas-images-${Date.now()}`; + return cleaned && !/^\.+$/.test(cleaned) ? cleaned.slice(0, 100) : `canvas-images-${Date.now()}`; } function buildContentDisposition(folderName: string): string { @@ -111,252 +78,159 @@ function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] { .slice(0, MAX_DOWNLOAD_FILES); } -function cleanupExpiredTasks() { - const now = Date.now(); - const store = getTaskStore(); - for (const [taskId, task] of store.entries()) { - if (task.expiresAt <= now) { - store.delete(taskId); - if (task.zipFile) void removeDownloadZip(task.zipFile.path); - } - } -} - -function getTaskProgress(task: ZipTask) { - if (task.status === "completed") { - return 100; - } - if (task.status === "failed") { - return Math.max( - 0, - Math.min(100, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85)) - ); - } - if (task.processedCount >= task.totalCount && task.totalCount > 0) { - return Math.min(99, 85 + Math.round((task.compressPercent / 100) * 14)); - } - return Math.max( - 4, - Math.min(85, Math.round((task.processedCount / Math.max(1, task.totalCount)) * 85)) - ); -} function serializeTask(task: ZipTask) { - const downloadUrl = - task.status === "completed" - ? `/api/canvas/download-zip-task?taskId=${encodeURIComponent(task.id)}&download=1` - : ""; - return { - id: task.id, - folderName: task.folderName, - status: task.status, - stage: task.stage, - totalCount: task.totalCount, - processedCount: task.processedCount, - successCount: task.successCount, - failedCount: task.failedCount, - progress: getTaskProgress(task), - error: task.error || "", - downloadUrl, - createdAt: task.createdAt, - updatedAt: task.updatedAt, - expiresAt: task.expiresAt, - }; + return { id: task.id, folderName: task.folderName, status: task.status, stage: task.stage, + totalCount: task.totalCount, processedCount: task.processedCount, successCount: task.successCount, + failedCount: task.failedCount, progress: task.status === "completed" ? 100 : Math.min(99, + Math.round(task.processedCount / Math.max(1, task.totalCount) * 85 + task.compressPercent * .14)), + error: task.error || "", expiresAt: task.expiresAt, + downloadUrl: task.status === "completed" ? `/api/canvas/download-zip-task?taskId=${task.id}&download=1` : "" }; } - -async function processZipTask(params: { - task: ZipTask; - requestOrigin: string; - cookie: string; -}) { - const { task, requestOrigin, cookie } = params; - const store = getTaskStore(); +async function processTask(id: string, userId: string, requestOrigin: string, cookie: string) { + if (active.has(id) || active.size >= 2) return; + active.add(id); + let release: (() => Promise) | null = null; + let task: ZipTask | null = null; try { - task.status = "running"; - task.stage = "正在读取源图"; - task.updatedAt = Date.now(); - - const zip = new JSZip(); - const folder = zip.folder(task.folderName) || zip; - const results: Array< - | Awaited> - | null - > = new Array(task.files.length).fill(null); + release = await claimZipTask(id); + if (!release) return; + task = await loadZipTask(id, userId); + if (!task || task.status === "completed" || task.status === "failed") return; + await assertZipDiskSpace(); + task.status = "running"; task.stage = "正在准备原图"; task.updatedAt = Date.now(); + await saveZipTask(task); + // Stage each image on disk, not all PNG buffers in the web process heap. + // A restart resumes from these files using the current owner's session. let cursor = 0; - const workerCount = Math.min(MAX_CONCURRENCY, task.files.length); - - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (true) { - const index = cursor; - cursor += 1; - if (index >= task.files.length) break; - - const file = task.files[index]!; - results[index] = await fetchCanvasDownloadAsset({ - file, - index, - requestOrigin, - cookie, - }); - - const result = results[index]; - task.processedCount += 1; - if (result?.ok) { - task.successCount += 1; - } else { - task.failedCount += 1; - } - task.stage = `正在处理 ${task.processedCount}/${task.totalCount} 张`; - task.updatedAt = Date.now(); - } - }) - ); - - const failures: Array<{ index: number; url: string; name?: string; error: string }> = []; - for (const [index, item] of results.entries()) { - if (!item || !item.ok) { - failures.push({ - index: index + 1, - url: item?.ok === false ? item.url : task.files[index]?.url || "", - name: item?.ok === false ? item.name : task.files[index]?.name, - error: item?.ok === false ? item.error : "未知错误", - }); - continue; + let writes = Promise.resolve(); + const persist = () => { const current = task!; writes = writes.then(() => saveZipTask(current)); return writes; }; + const results = await Promise.allSettled(Array.from({ length: Math.min(2, task.files.length) }, async () => { + while (cursor < task!.files.length) { + const index = cursor++; const file = task!.files[index]; + const path = join(zipTaskPath(id), `${index}.image`); + if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue; + const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie, + signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" })); + if (result.ok) { + await assertZipDiskSpace(result.bytes.length); + await writeFile(path, result.bytes, { mode: 0o600 }); + file.prepared = `${index}.image`; file.filename = result.filename; delete file.error; + } else file.error = result.error; + task!.processedCount = task!.files.filter(f => f.prepared || f.error).length; + task!.successCount = task!.files.filter(f => f.prepared).length; + task!.failedCount = task!.files.filter(f => f.error).length; + task!.stage = `正在准备原图 ${task!.processedCount}/${task!.totalCount}`; + task!.updatedAt = Date.now(); await persist(); } - folder.file(item.filename, item.bytes); - } - - if (failures.length > 0) { - folder.file( - "_download-failures.txt", - [ - "以下图片暂时无法下载。可稍后重试,或复制链接检查源图是否仍可访问。", - "", - ...failures.map((item) => { - const label = item.name ? `${item.index}. ${item.name}` : `${item.index}. image`; - return `${label}\nURL: ${item.url}\n原因: ${item.error}`; - }), - ].join("\n\n") - ); - console.warn("[CanvasDownloadZipTask] Partial download failures:", failures); - } - - task.stage = "正在生成 ZIP 文件"; - task.updatedAt = Date.now(); - task.zipFile = await writeDownloadZip(zip, task.id, percent => { - task.compressPercent = Math.max(0, Math.min(100, percent || 0)); - task.updatedAt = Date.now(); - }); + })); + const rejected = results.find(result => result.status === "rejected"); + if (rejected?.status === "rejected") throw rejected.reason; + if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除"); + const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip; + for (const file of task.files) if (file.prepared && file.filename) + folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))); + if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error) + .map(f => `${f.name || "图片"}:${f.error}`).join("\n")); + task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task); + task.zipFile = await writeDownloadZip(zip, task.id, percent => { task!.compressPercent = percent; }); task.contentDisposition = buildContentDisposition(task.folderName); - task.status = "completed"; - task.stage = "打包完成"; - task.compressPercent = 100; - task.updatedAt = Date.now(); - task.expiresAt = Date.now() + TASK_TTL_MS; - } catch (error) { - console.error("[CanvasDownloadZipTask] Failed:", error); - task.status = "failed"; - task.stage = "打包失败"; - task.error = error instanceof Error ? error.message : "打包下载失败"; - task.updatedAt = Date.now(); - task.expiresAt = Date.now() + TASK_TTL_MS; - } finally { - store.set(task.id, task); - } -} - -export async function POST(request: NextRequest) { - try { - cleanupExpiredTasks(); - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); - } - - const body = (await request.json().catch(() => ({}))) as Record; - const requestOrigin = getRequestOrigin(request); - const files = toDownloadItems(body.files, requestOrigin); - const folderName = sanitizeFolderName(String(body.folderName || "")); - if (files.length === 0) { - return NextResponse.json({ success: false, error: "没有可下载的图片" }, { status: 400 }); - } - - const task: ZipTask = { - id: randomUUID(), - userId: session.user.id, - folderName, - files, - totalCount: files.length, - processedCount: 0, - successCount: 0, - failedCount: 0, - compressPercent: 0, - status: "pending", - stage: "等待服务端开始打包", - createdAt: Date.now(), - updatedAt: Date.now(), - expiresAt: Date.now() + TASK_TTL_MS, - }; - getTaskStore().set(task.id, task); - - void processZipTask({ - task, - requestOrigin, - cookie: request.headers.get("cookie") || "", - }); - - return NextResponse.json({ - success: true, - task: serializeTask(task), - }); - } catch (error) { - console.error("[CanvasDownloadZipTask] Create failed:", error); - return NextResponse.json({ success: false, error: "创建打包任务失败" }, { status: 500 }); - } -} - -export async function GET(request: NextRequest) { - try { - cleanupExpiredTasks(); - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); - } - - const taskId = String(request.nextUrl.searchParams.get("taskId") || "").trim(); - if (!taskId) { - return NextResponse.json({ success: false, error: "缺少任务 ID" }, { status: 400 }); - } - - const task = getTaskStore().get(taskId); - if (!task || task.userId !== session.user.id) { - return NextResponse.json({ success: false, error: "打包任务不存在或已过期" }, { status: 404 }); - } - - if (request.nextUrl.searchParams.get("download") === "1") { - if (task.status !== "completed" || !task.zipFile) { - return NextResponse.json({ success: false, error: "ZIP 文件尚未准备好" }, { status: 409 }); + task.stage = "正在准备下载链接"; await saveZipTask(task); + try { + const cdn = await publishCanvasZip({ id: task.id, ...task.zipFile, disposition: task.contentDisposition }); + if (cdn) { + task.cdnObject = { storageConfigId: cdn.storageConfigId, key: cdn.key }; + if (cdn.url) task.cdnUrl = cdn.url; } - return new NextResponse(readDownloadZip(task.zipFile.path), { - status: 200, - headers: { - "Content-Type": "application/zip", - "Content-Disposition": task.contentDisposition || buildContentDisposition(task.folderName), - "Content-Length": String(task.zipFile.bytes), - "Cache-Control": "no-store", - "X-Download-Success-Count": String(task.successCount), - "X-Download-Failed-Count": String(task.failedCount), - }, - }); - } - - return NextResponse.json({ - success: true, - task: serializeTask(task), - }); + } catch { console.warn("[CanvasDownloadZipTask] CDN delivery unavailable; complete local ZIP retained", { taskId: id }); } + task.status = "completed"; task.stage = task.failedCount ? `已打包 ${task.successCount} 张,${task.failedCount} 张未能读取` : "打包完成"; + task.compressPercent = 100; task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; + await saveZipTask(task); + // The complete ZIP is enough for retries; release staged duplicate bytes. + await Promise.all(task.files.filter(f => f.prepared).map(f => rm(join(zipTaskPath(id), f.prepared!), { force: true }).catch(() => undefined))); + console.info("[CanvasDownloadZipTask] Complete", { taskId: id, count: task.successCount, failed: task.failedCount, + bytes: task.zipFile.bytes, delivery: task.cdnUrl ? "cdn" : "local" }); } catch (error) { - console.error("[CanvasDownloadZipTask] Query failed:", error); - return NextResponse.json({ success: false, error: "查询打包任务失败" }, { status: 500 }); + if (task) { + task.status = "failed"; task.stage = "打包未完成"; + task.error = error instanceof Error ? error.message : "打包失败,请稍后重试"; + task.updatedAt = Date.now(); task.expiresAt = Date.now() + TASK_TTL_MS; await saveZipTask(task).catch(() => undefined); + } + console.error("[CanvasDownloadZipTask] Preparation failed", { taskId: id, message: task?.error }); + } finally { await release?.().catch(() => undefined); active.delete(id); } +} +function schedule(task: ZipTask, request: NextRequest) { + const run = () => processTask(task.id, task.userId, getRequestOrigin(request), request.headers.get("cookie") || ""); + after(run); +} +export async function POST(request: NextRequest) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 }); + try { + await cleanupZipTasks(); + const body = await request.json(); + let id = typeof body.taskId === "string" ? body.taskId : randomUUID(); + let task = await loadZipTask(id, session.user.id); + if (!task) { + await assertZipTaskCapacity(session.user.id); + if (await stat(zipTaskPath(id)).catch(() => null)) id = randomUUID(); + const files = toDownloadItems(body.files, getRequestOrigin(request)); + if (!files.length || !Array.isArray(body.files) || body.files.length > MAX_DOWNLOAD_FILES || files.length !== body.files.length) + return NextResponse.json({ error: "请选择 1 到 200 张可下载图片" }, { status: 400 }); + // Exclusive creation prevents an arbitrary taskId from overwriting another owner's manifest. + await mkdir(zipTaskPath(id), { recursive: false, mode: 0o700 }).catch(async error => { + if (error.code !== "ENOENT") throw error; + await mkdir((await import("@/lib/canvas/download-zip-stream")).downloadZipDirectory, { recursive: true, mode: 0o700 }); + await mkdir(zipTaskPath(id), { mode: 0o700 }); + }); + const now = Date.now(); + task = { id, userId: session.user.id, folderName: sanitizeFolderName(String(body.folderName || "")), files, + totalCount: files.length, processedCount: 0, successCount: 0, failedCount: 0, compressPercent: 0, + status: "pending", stage: "等待后台准备", createdAt: now, updatedAt: now, expiresAt: now + 6 * TASK_TTL_MS }; + await saveZipTask(task); + } else if (task.status === "failed" && body.retry === true) { + task.status = "pending"; task.error = undefined; + await assertZipTaskCapacity(session.user.id); + for (const file of task.files) delete file.error; + task.processedCount = task.successCount = task.files.filter(file => file.prepared).length; + task.failedCount = 0; task.updatedAt = Date.now(); task.expiresAt = Date.now() + 6 * TASK_TTL_MS; + await saveZipTask(task); + } + if (task.status === "pending" || task.status === "running") schedule(task, request); + return NextResponse.json({ success: true, task: serializeTask(task) }, { status: 202, headers: { "Cache-Control": "no-store" } }); + } catch (error) { + console.warn("[CanvasDownloadZipTask] Create rejected", { message: (error as Error).message }); + return NextResponse.json({ error: "无法创建打包任务,请稍后重试" }, { status: 400 }); + } +} +export async function GET(request: NextRequest) { + const session = await auth(); + if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 }); + try { + const id = request.nextUrl.searchParams.get("taskId") || ""; + const task = await loadZipTask(id, session.user.id); + if (!task) return NextResponse.json({ error: "打包任务不存在或已过期,请重新打包" }, { status: 404 }); + if (request.nextUrl.searchParams.get("download") === "1") { + if (task.status !== "completed" || !task.zipFile) return NextResponse.json({ error: "ZIP 尚未准备好" }, { status: 409 }); + if (task.cdnUrl && request.nextUrl.searchParams.get("local") !== "1") + return NextResponse.redirect(task.cdnUrl, 307); + const info = await stat(task.zipFile.path).catch(() => null); + if (!info || info.size !== task.zipFile.bytes) return NextResponse.json({ error: "下载文件已过期,请重新打包" }, { status: 410 }); + const etag = `"${task.id}-${info.size}"`; + const headers: Record = { "Content-Type": "application/zip", "Content-Disposition": task.contentDisposition!, + "Accept-Ranges": "bytes", "ETag": etag, "Cache-Control": "private, no-store", + "X-Download-Success-Count": String(task.successCount), "X-Download-Failed-Count": String(task.failedCount) }; + const ifRange = request.headers.get("if-range"); + let range; + try { range = parseDownloadByteRange(!ifRange || ifRange === etag ? request.headers.get("range") : null, info.size); } + catch { return new NextResponse(null, { status: 416, headers: { ...headers, "Content-Range": `bytes */${info.size}` } }); } + headers["Content-Length"] = String(range ? range.end - range.start + 1 : info.size); + if (range) headers["Content-Range"] = `bytes ${range.start}-${range.end}/${info.size}`; + return new NextResponse(readDownloadZip(task.zipFile.path, range || undefined), { status: range ? 206 : 200, headers }); + } + if (task.status === "pending" || task.status === "running") schedule(task, request); + return NextResponse.json({ success: true, task: serializeTask(task) }, { headers: { "Cache-Control": "no-store" } }); + } catch { + return NextResponse.json({ error: "查询打包任务失败,请稍后重试" }, { status: 500 }); } } diff --git a/components/canvas/CanvasWorkspace.tsx b/components/canvas/CanvasWorkspace.tsx index ea7ebfe40..2290761a3 100644 --- a/components/canvas/CanvasWorkspace.tsx +++ b/components/canvas/CanvasWorkspace.tsx @@ -14,6 +14,7 @@ import { WorkflowNode } from "./nodes/WorkflowNode"; import { CanvasToolbarIcon } from "./CanvasToolbarIcon"; import { CanvasBlankContextMenu } from "./CanvasBlankContextMenu"; import { getCanvasFitViewport } from "@/lib/canvas/viewport-fit"; +import { downloadCanvasSelectionZip } from "@/lib/client/canvas-zip-download"; import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry"; import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; @@ -1914,10 +1915,10 @@ export function CanvasWorkspace({ const rawImageUrl = String(nodeData?.src || "").trim(); if (!rawImageUrl) return null; let imageUrl = inferOriginalImageUrl(rawImageUrl) || rawImageUrl; - if (browserOrigin && rawImageUrl.startsWith("/")) { - imageUrl = `${browserOrigin}${rawImageUrl}`; - } else if (browserOrigin && rawImageUrl.startsWith("//")) { + if (browserOrigin && rawImageUrl.startsWith("//")) { imageUrl = `${window.location.protocol}${rawImageUrl}`; + } else if (browserOrigin && rawImageUrl.startsWith("/")) { + imageUrl = `${browserOrigin}${rawImageUrl}`; } const name = String(nodeData?.title || "").trim() || @@ -1933,32 +1934,18 @@ export function CanvasWorkspace({ } setIsDownloadSubmitting(true); + const toastId = toast.loading("正在准备所选图片..."); try { - const form = document.createElement("form"); - form.method = "POST"; - form.action = "/api/canvas/download-zip"; - form.style.display = "none"; - - const payloadInput = document.createElement("input"); - payloadInput.type = "hidden"; - payloadInput.name = "payload"; - payloadInput.value = JSON.stringify({ - folderName, - files, + const result = await downloadCanvasSelectionZip({ folderName, files, + selectionKey: `${useCanvasStore.getState().projectId}:${selectedImageNodes.map(node => node.id).sort().join(",")}`, + onProgress: task => toast.loading(`${task.stage}(${task.progress}%)`, { id: toastId }), }); - form.appendChild(payloadInput); - - document.body.appendChild(form); - form.submit(); - form.remove(); - - toast.success("已开始打包下载,请留意浏览器下载栏"); - } catch (_error) { - toast.error("下载失败,请稍后重试"); + if (result.failedCount) toast.warning(`已开始下载 ${result.successCount} 张图片,${result.failedCount} 张未能读取;ZIP 内附失败清单`, { id: toastId, duration: 10000 }); + else toast.success(`ZIP 已准备完成,正在下载 ${result.successCount} 张图片`, { id: toastId }); + } catch (error) { + toast.error(error instanceof Error ? error.message : "打包下载失败,请稍后重试", { id: toastId }); } finally { - window.setTimeout(() => { - setIsDownloadSubmitting(false); - }, 800); + setIsDownloadSubmitting(false); } }, [isDownloadSubmitting, selectedImageNodes]); diff --git a/lib/canvas/download-byte-range.ts b/lib/canvas/download-byte-range.ts new file mode 100644 index 000000000..af349d7df --- /dev/null +++ b/lib/canvas/download-byte-range.ts @@ -0,0 +1,11 @@ +/** A single immutable complete ZIP can be resumed using a normal GET request. */ +export function parseDownloadByteRange(value: string | null, bytes: number): { start: number; end: number } | null { + if (!value) return null; + const match = /^bytes=(\d*)-(\d*)$/.exec(value); + if (!match || (!match[1] && !match[2]) || bytes < 1) throw new Error("Invalid range"); + const start = match[1] ? Number(match[1]) : Math.max(0, bytes - Number(match[2])); + const end = match[1] && match[2] ? Math.min(bytes - 1, Number(match[2])) : bytes - 1; + if (!Number.isSafeInteger(start) || !Number.isSafeInteger(end) || start < 0 || start > end || start >= bytes) + throw new Error("Unsatisfiable range"); + return { start, end }; +} diff --git a/lib/canvas/download-zip-stream.ts b/lib/canvas/download-zip-stream.ts index 1ff4757f9..941252007 100644 --- a/lib/canvas/download-zip-stream.ts +++ b/lib/canvas/download-zip-stream.ts @@ -1,20 +1,23 @@ import "server-only"; import { createReadStream, createWriteStream } from "node:fs"; -import { mkdir, stat, statfs, unlink, readdir } from "node:fs/promises"; +import { mkdir, stat, statfs, unlink, readdir, rename } from "node:fs/promises"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { pipeline } from "node:stream/promises"; import { Readable, Transform } from "node:stream"; import JSZip from "jszip"; -const directory = join(tmpdir(), "canvas-download-zips-v1"); +export const downloadZipDirectory = process.env.CANVAS_DOWNLOAD_ZIP_DIR || join(tmpdir(), "canvas-download-zips-v2"); +const directory = downloadZipDirectory; export async function writeDownloadZip(zip: JSZip, id: string, progress: (percent: number) => void) { await mkdir(directory, { recursive: true, mode: 0o700 }); // Remove abandoned files after restart as well as expired in-process tasks. for (const name of await readdir(directory)) { const path = join(directory, name); - if ((await stat(path)).mtimeMs < Date.now() - 60 * 60_000) await unlink(path).catch(() => undefined); + const info = await stat(path).catch(() => null); + if (info?.isFile() && name.endsWith(".partial") && info.mtimeMs < Date.now() - 60 * 60_000) await unlink(path).catch(() => undefined); } const path = join(directory, `${id}.zip`); + const temporary = `${path}.partial`; try { const disk = await statfs(directory); const budget = disk.bavail * disk.bsize - 1024 * 1024 * 1024; @@ -24,12 +27,13 @@ export async function writeDownloadZip(zip: JSZip, id: string, progress: (percen callback(written > budget ? new Error("服务器下载空间不足,请减少本次图片数量") : null, chunk); } }); await pipeline(zip.generateNodeStream({ type: "nodebuffer", streamFiles: true, compression: "STORE" }, - metadata => progress(metadata.percent)), guard, createWriteStream(path, { mode: 0o600 })); + metadata => progress(metadata.percent)), guard, createWriteStream(temporary, { mode: 0o600 })); + await rename(temporary, path); return { path, bytes: (await stat(path)).size }; - } catch (error) { await unlink(path).catch(() => undefined); throw error; } + } catch (error) { await unlink(temporary).catch(() => undefined); throw error; } } -export function readDownloadZip(path: string) { - return Readable.toWeb(createReadStream(path)) as ReadableStream; +export function readDownloadZip(path: string, range?: { start: number; end: number }) { + return Readable.toWeb(createReadStream(path, range)) as ReadableStream; } export function streamDownloadZip(zip: JSZip) { return Readable.toWeb(zip.generateNodeStream({ type: "nodebuffer", streamFiles: true, compression: "STORE" }) as Readable) as ReadableStream; diff --git a/lib/canvas/download-zip-task-store.ts b/lib/canvas/download-zip-task-store.ts new file mode 100644 index 000000000..d7f95a338 --- /dev/null +++ b/lib/canvas/download-zip-task-store.ts @@ -0,0 +1,100 @@ +import "server-only"; +import { randomUUID } from "node:crypto"; +import { mkdir, readFile, rename, rm, statfs, writeFile, readdir } from "node:fs/promises"; +import { join } from "node:path"; +import { downloadZipDirectory } from "./download-zip-stream"; + +export type ZipTask = { + id: string; userId: string; folderName: string; + files: Array<{ url: string; name?: string; prepared?: string; filename?: string; error?: string }>; + totalCount: number; processedCount: number; successCount: number; failedCount: number; + compressPercent: number; status: "pending" | "running" | "completed" | "failed"; + stage: string; error?: string; zipFile?: { path: string; bytes: number }; + contentDisposition?: string; cdnUrl?: string; cdnObject?: { storageConfigId: string; key: string }; + createdAt: number; updatedAt: number; expiresAt: number; +}; +const validId = (id: string) => /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(id); +export function zipTaskPath(id: string) { + if (!validId(id)) throw new Error("无效的打包任务编号"); + return join(downloadZipDirectory, id); +} +export async function saveZipTask(task: ZipTask) { + const dir = zipTaskPath(task.id); + await mkdir(dir, { recursive: true, mode: 0o700 }); + const path = join(dir, `task-${randomUUID()}.partial`); + // No cookies, storage credentials or provider keys are persisted. + await writeFile(path, JSON.stringify(task), { mode: 0o600 }); + await rename(path, join(dir, "task.json")); +} +export async function loadZipTask(id: string, userId: string): Promise { + if (!validId(id)) return null; + try { + const task: ZipTask = JSON.parse(await readFile(join(zipTaskPath(id), "task.json"), "utf8")); + if (task.userId !== userId || task.id !== id || task.expiresAt <= Date.now()) return null; + return task; + } catch { return null; } +} +export async function claimZipTask(id: string) { + const dir = zipTaskPath(id); const lock = join(dir, "lock"); + await mkdir(dir, { recursive: true, mode: 0o700 }); + for (let attempt = 0; attempt < 2; attempt++) { + try { + await mkdir(lock, { mode: 0o700 }); + try { await writeFile(join(lock, "owner.json"), JSON.stringify({ pid: process.pid, createdAt: Date.now() }), { mode: 0o600 }); } + catch (error) { await rm(lock, { recursive: true, force: true }); throw error; } + return async () => { await rm(lock, { recursive: true, force: true }); }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + try { + const owner = JSON.parse(await readFile(join(lock, "owner.json"), "utf8")); + // An older worker can finish even if a second web process handles a poll. + try { process.kill(owner.pid, 0); return null; } + catch (e) { if ((e as NodeJS.ErrnoException).code !== "ESRCH") return null; } + await rm(lock, { recursive: true, force: true }); + } catch { return null; } + } + } + return null; +} +export async function assertZipDiskSpace(bytes = 0) { + const disk = await statfs(downloadZipDirectory); + if (disk.bavail * disk.bsize < bytes + 1024 ** 3) throw new Error("服务器下载空间不足,请减少本次图片数量"); +} + +let cleanupAt = 0; +export async function cleanupZipTasks() { + if (Date.now() - cleanupAt < 60_000) return; + cleanupAt = Date.now(); + for (const id of await readdir(downloadZipDirectory).catch(() => [] as string[])) { + if (!validId(id)) continue; + const dir = zipTaskPath(id); + try { + const task: ZipTask = JSON.parse(await readFile(join(dir, "task.json"), "utf8")); + if (task.expiresAt > Date.now()) continue; + const release = await claimZipTask(id); + if (!release) continue; + try { + if (task.cdnObject) { + const { removeCanvasZipObject } = await import("@/lib/storage/canvas-zip-delivery"); + await removeCanvasZipObject(task.cdnObject).catch(() => undefined); + } + await rm(join(downloadZipDirectory, `${id}.zip`), { force: true }); + await rm(join(downloadZipDirectory, `${id}.zip.partial`), { force: true }); + } finally { await release(); } + await rm(dir, { recursive: true, force: true }); + } catch { /* An incomplete manifest is never treated as another owner's task. */ } + } +} +export async function assertZipTaskCapacity(userId: string) { + let total = 0; let own = 0; + for (const id of await readdir(downloadZipDirectory).catch(() => [] as string[])) { + if (!validId(id)) continue; + try { + const task: ZipTask = JSON.parse(await readFile(join(zipTaskPath(id), "task.json"), "utf8")); + if (task.expiresAt > Date.now() && ["pending", "running"].includes(task.status)) { + total++; if (task.userId === userId) own++; + } + } catch { /* Ignore a concurrently created task until its atomic manifest exists. */ } + } + if (own >= 2 || total >= 8) throw new Error("正在准备的 ZIP 较多,请等待已有任务完成后再试"); +} diff --git a/lib/client/canvas-zip-download.ts b/lib/client/canvas-zip-download.ts new file mode 100644 index 000000000..fe09eab83 --- /dev/null +++ b/lib/client/canvas-zip-download.ts @@ -0,0 +1,50 @@ +export type CanvasZipTaskView = { + id: string; status: "pending" | "running" | "completed" | "failed"; + stage: string; progress: number; totalCount: number; successCount: number; failedCount: number; + error?: string; downloadUrl?: string; +}; +const endpoint = "/api/canvas/download-zip-task"; +const wait = (ms: number) => new Promise(resolve => setTimeout(resolve, ms)); +async function taskRequest(url: string, init?: RequestInit) { + for (let attempt = 0; ; attempt++) { + let response: Response; + try { response = await fetch(url, { ...init, cache: "no-store", signal: AbortSignal.timeout(20_000) }); } + catch { if (attempt < 3) { await wait(1000 * (attempt + 1)); continue; } throw new Error("打包连接暂时中断,再次点击可继续查询原任务"); } + const payload = await response.json().catch(() => null); + if ((response.status === 429 || response.status >= 500) && attempt < 3) { + await wait(Math.min(30_000, Math.max(1000, Number(response.headers.get("retry-after") || 2) * 1000))); continue; + } + if (!response.ok || !payload?.task) throw new Error(payload?.error || "无法查询打包任务,请稍后重试"); + return payload.task as CanvasZipTaskView; + } +} +export async function downloadCanvasSelectionZip(params: { + folderName: string; files: Array<{ url: string; name: string }>; selectionKey: string; + onProgress: (task: CanvasZipTaskView) => void; +}) { + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify([params.selectionKey, params.files]))); + const key = "canvas-zip:" + Array.from(new Uint8Array(digest), byte => byte.toString(16).padStart(2, "0")).join(""); + let taskId = ""; + try { taskId = sessionStorage.getItem(key) || ""; } catch { /* Storage is optional. */ } + if (!/^[0-9a-f-]{36}$/i.test(taskId)) taskId = crypto.randomUUID(); + try { sessionStorage.setItem(key, taskId); } catch { /* Downloads work without browser persistence. */ } + let task = await taskRequest(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ taskId, folderName: params.folderName, files: params.files, retry: true }) }); + taskId = task.id; + try { sessionStorage.setItem(key, taskId); } catch {} + const deadline = Date.now() + 30 * 60_000; + for (;;) { + params.onProgress(task); + if (task.status === "failed") throw new Error(task.error || "打包失败,再次点击可重试"); + if (task.status === "completed") break; + if (Date.now() > deadline) throw new Error("后台仍在准备图片,再次点击可继续查询进度"); + await wait(2500); + task = await taskRequest(`${endpoint}?taskId=${encodeURIComponent(taskId)}`); + } + if (!task.successCount || !task.downloadUrl) throw new Error("没有成功准备的图片,请稍后重试"); + // A GET for a complete immutable file supports browser retries and byte ranges. + const frame = document.createElement("iframe"); + frame.hidden = true; frame.src = task.downloadUrl; document.body.appendChild(frame); + window.setTimeout(() => frame.remove(), 60 * 60_000); + return task; +} diff --git a/lib/storage/canvas-zip-delivery.ts b/lib/storage/canvas-zip-delivery.ts new file mode 100644 index 000000000..9049f1bb5 --- /dev/null +++ b/lib/storage/canvas-zip-delivery.ts @@ -0,0 +1,40 @@ +import "server-only"; +import { createReadStream } from "node:fs"; +import { PutObjectCommand } from "@aws-sdk/client-s3"; +import { getDefaultStorageConfig, getStorageConfigById } from "@/lib/db/queries"; +import { createS3Client, toDbS3UploadConfig, buildS3PublicUrl } from "./service"; + +/** Only a completely written archive is uploaded. Local GET remains a fallback. */ +export async function publishCanvasZip(params: { id: string; path: string; bytes: number; disposition: string }) { + const raw = await getDefaultStorageConfig(); + const config = toDbS3UploadConfig(raw); + if (config?.provider !== "qiniu-kodo" || !config.publicUrl?.startsWith("https://")) return null; + const key = `canvas-downloads/${params.id}.zip`; + const client = createS3Client(config); + // Qiniu's S3 endpoint expects an ordinary known-length body, without AWS trailers. + client.config.requestChecksumCalculation = async () => "WHEN_REQUIRED"; + const body = createReadStream(params.path); + try { + await client.send(new PutObjectCommand({ Bucket: config.bucket, Key: key, Body: body, + ContentLength: params.bytes, ContentType: "application/zip", ContentDisposition: params.disposition, + CacheControl: "public, max-age=1800" }), { abortSignal: AbortSignal.timeout(90_000) }); + const url = buildS3PublicUrl(config, key); + const response = await fetch(url, { method: "HEAD", redirect: "error", signal: AbortSignal.timeout(10_000) }); + const length = Number(response.headers.get("content-length")); + if (!response.ok || length !== params.bytes) throw new Error("CDN object is not ready"); + return { url, storageConfigId: raw!.id, key }; + } catch (error) { + console.warn("[CanvasDownloadZipTask] CDN upload/check failed; using complete local file", { taskId: params.id, kind: (error as Error).name }); + return { url: null, storageConfigId: raw!.id, key }; + } finally { body.destroy(); client.destroy(); } +} + +export async function removeCanvasZipObject(object: { storageConfigId: string; key: string }) { + if (!/^canvas-downloads\/[0-9a-f-]{36}\.zip$/i.test(object.key)) return; + const config = toDbS3UploadConfig(await getStorageConfigById({ id: object.storageConfigId })); + if (!config || config.provider !== "qiniu-kodo") return; + const { DeleteObjectCommand } = await import("@aws-sdk/client-s3"); + const client = createS3Client(config); + try { await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: object.key }), { abortSignal: AbortSignal.timeout(5000) }); } + finally { client.destroy(); } +} diff --git a/proxy.ts b/proxy.ts index 7a69ecdc9..a3ad45afe 100644 --- a/proxy.ts +++ b/proxy.ts @@ -43,6 +43,7 @@ const manualIpBlockCache = new Map< >(); function isHighFrequencyReadApi(pathname: string, method: string) { + if (method === "GET" && pathname === "/api/canvas/download-zip-task") return true; if (method !== "GET") return false; if (/^\/api\/canvas\/[^/]+\/detail-archives$/.test(pathname)) return true; if (/^\/api\/canvas\/[^/]+\/revision$/.test(pathname)) return true; @@ -532,7 +533,7 @@ export async function proxy(request: NextRequest, event: NextFetchEvent) { ); } } else if ( - pathname === "/api/canvas/download-image" && + (pathname === "/api/canvas/download-image" || pathname === "/api/canvas/download-zip-task") && request.method === "POST" ) { const token = await resolveToken(); diff --git a/tests/canvas-download-speed.config.ts b/tests/canvas-download-speed.config.ts index 97c1e4e4f..ecedb09b6 100644 --- a/tests/canvas-download-speed.config.ts +++ b/tests/canvas-download-speed.config.ts @@ -1,2 +1,2 @@ import { defineConfig } from "@playwright/test"; -export default defineConfig({ testDir: "./routes", testMatch: /(?:canvas-download-speed|canvas-download-rate-limit|image-archive-flow)\.test\.ts/, workers: 1, reporter: "list", timeout: 30_000 }); +export default defineConfig({ testDir: "./routes", testMatch: /(?:canvas-zip-download|canvas-download-speed|canvas-download-rate-limit|image-archive-flow)\.test\.ts/, workers: 1, reporter: "list", timeout: 30_000 }); diff --git a/tests/helpers/canvas-download-server-loader.ts b/tests/helpers/canvas-download-server-loader.ts new file mode 100644 index 000000000..2a82f1142 --- /dev/null +++ b/tests/helpers/canvas-download-server-loader.ts @@ -0,0 +1,69 @@ +import { readFileSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import vm from "node:vm"; +import * as crypto from "node:crypto"; +import ts from "typescript"; +const root = path.resolve(__dirname, "../.."); +export function serverLoader(mocks: Record, env: Record = {}) { + const modules = new Map(); + const runtimeGlobal = {}; + const load = (relative: string): any => { + const filename = path.resolve(root, relative); + if (modules.has(filename)) return modules.get(filename)!.exports; + const module = { exports: {} }; + modules.set(filename, module); + const source = ts.transpileModule(readFileSync(filename, "utf8"), { + compilerOptions: { + module: ts.ModuleKind.CommonJS, + target: ts.ScriptTarget.ES2022, + esModuleInterop: true, + }, + }).outputText; + const nativeRequire = createRequire(filename); + const requireMock = (name: string) => { + if (name === "server-only") return {}; + if (Object.prototype.hasOwnProperty.call(mocks, name)) return mocks[name]; + if (name.startsWith("@/")) return load(`${name.slice(2)}.ts`); + if (name.startsWith(".")) + return load(path.relative(root, path.resolve(path.dirname(filename), `${name}.ts`))); + return nativeRequire(name); + }; + const context = vm.createContext({ + module, + exports: module.exports, + require: requireMock, + globalThis: runtimeGlobal, + process: { env, pid: process.pid, kill: process.kill }, + Buffer, + URL, + URLSearchParams, + DOMException, + Response, + crypto, + setTimeout: mocks.setTimeout || setTimeout, + clearTimeout, + TextEncoder, + sessionStorage: mocks.sessionStorage, + setImmediate, + AbortSignal, + AbortController, + ReadableStream, + Headers, + Blob, + Request, + Date: mocks.Date || Date, + document: mocks.document, + window: mocks.window, + console: { log() {}, warn() {}, error() {}, info() {} }, + fetch: + mocks.fetch || + (() => { + throw new Error("Unexpected network call"); + }), + }); + new vm.Script(source, { filename }).runInContext(context); + return module.exports; + }; + return load; +} diff --git a/tests/routes/canvas-zip-download.test.ts b/tests/routes/canvas-zip-download.test.ts new file mode 100644 index 000000000..5a22aa8f1 --- /dev/null +++ b/tests/routes/canvas-zip-download.test.ts @@ -0,0 +1,126 @@ +import { test, expect } from "@playwright/test"; +import { NextRequest, NextResponse } from "next/server"; +import { mkdtemp, rm, readFile, mkdir, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import JSZip from "jszip"; +import { serverLoader } from "../helpers/canvas-download-server-loader"; +import { parseDownloadByteRange } from "@/lib/canvas/download-byte-range"; + +test("ranges reject invalid offsets and preserve normal, open and suffix byte ranges", () => { + expect(parseDownloadByteRange("bytes=0-3", 20)).toEqual({start:0,end:3}); + expect(parseDownloadByteRange("bytes=10-", 20)).toEqual({start:10,end:19}); + expect(parseDownloadByteRange("bytes=-4", 20)).toEqual({start:16,end:19}); + for (const range of ["bytes=-0","bytes=20-","bytes=4-2","bytes=1-2,5-6","invalid"]) + expect(()=>parseDownloadByteRange(range,20)).toThrow(); +}); + +test("multi-select ZIP admission is immediate; complete archives survive restart and support authorized range retries", async () => { + const dir=await mkdtemp(join(tmpdir(),'canvas-zip-test-')); let owner:string|null='owner';let fetches=0; + const callbacks:Array<()=>Promise>=[]; + const mocks={ + '@/app/(auth)/auth': {auth:async()=>owner?{user:{id:owner}}:null}, + 'next/server': {NextResponse,after:(run:()=>Promise)=>callbacks.push(run)}, + '@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async({index}:any)=>{ + fetches++;return {ok:true,filename:`${index}-花瓶.png`,bytes:Buffer.from(`image-${index}`),contentType:'image/png'}; + }}, + '@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>null,removeCanvasZipObject:async()=>{}}, + }; + const loader=()=>serverLoader(mocks,{CANVAS_DOWNLOAD_ZIP_DIR:dir})('app/api/canvas/download-zip-task/route.ts'); + const request=(query:string,headers:Record={})=>new NextRequest('http://localhost/api/canvas/download-zip-task?'+query,{headers}); + const taskId=randomUUID();const files=[{url:'https://provider.example/1.png'},{url:'https://provider.example/2.png'}]; + try { + const route=loader(); + const response=await route.POST(new NextRequest('http://localhost/api/canvas/download-zip-task',{method:'POST',body:JSON.stringify({taskId,folderName:'作品',files})})); + expect(response.status).toBe(202);expect(fetches).toBe(0); + expect((await route.GET(request(`taskId=${taskId}&download=1`))).status).toBe(409); + await callbacks.shift()!(); + const state=await (await route.GET(request(`taskId=${taskId}`))).json(); + expect(state.task).toMatchObject({status:'completed',successCount:2,failedCount:0}); + const complete=await route.GET(request(`taskId=${taskId}&download=1`)); + const data=Buffer.from(await complete.arrayBuffer());const zip=await JSZip.loadAsync(data); + expect((await zip.file('作品/0-花瓶.png')!.async('nodebuffer')).toString()).toBe('image-0'); + expect(complete.headers.get('content-length')).toBe(String(data.length)); + const range=await route.GET(request(`taskId=${taskId}&download=1`,{range:'bytes=0-3'})); + expect(range.status).toBe(206);expect(Buffer.from(await range.arrayBuffer())).toEqual(data.subarray(0,4)); + expect((await route.GET(request(`taskId=${taskId}&download=1`,{range:'bytes=999999-'}))).status).toBe(416); + expect((await route.GET(request(`taskId=${taskId}&download=1`,{range:'bytes=0-3','if-range':'"old-file"'}))).status).toBe(200); + const fresh=loader();expect((await (await fresh.GET(request(`taskId=${taskId}`))).json()).task.status).toBe('completed');expect(fetches).toBe(2); + const manifest=await readFile(join(dir,taskId,'task.json'),'utf8');expect(manifest).not.toContain('cookie'); + owner='stranger';expect((await fresh.GET(request(`taskId=${taskId}`))).status).toBe(404); + owner=null;expect((await fresh.GET(request(`taskId=${taskId}`))).status).toBe(401); + } finally {await rm(dir,{recursive:true,force:true});} +}); + +test("all-source failures never produce an empty successful ZIP; retries reuse the task and resolve sources again", async()=>{ + const dir=await mkdtemp(join(tmpdir(),'canvas-zip-retry-'));const callbacks:Array<()=>Promise>=[];let available=false; + const route=serverLoader({ + '@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})}, + 'next/server':{NextResponse,after:(run:()=>Promise)=>callbacks.push(run)}, + '@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async()=>available?{ok:true,filename:'one.png',bytes:Buffer.from('one'),contentType:'image/png'}:{ok:false,error:'expired provider URL'}}, + '@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>null}, + },{CANVAS_DOWNLOAD_ZIP_DIR:dir})('app/api/canvas/download-zip-task/route.ts'); + const id=randomUUID();const post=()=>new NextRequest('http://localhost/api/canvas/download-zip-task',{method:'POST',body:JSON.stringify({taskId:id,retry:true,files:[{url:'https://provider.example/expired.png'}]})}); + try { + await route.POST(post());await callbacks.shift()!(); + let state=await(await route.GET(new NextRequest(`http://localhost/api/canvas/download-zip-task?taskId=${id}`))).json(); + expect(state.task.status).toBe('failed');expect(state.task.downloadUrl).toBe(''); + available=true;await route.POST(post());await callbacks.shift()!(); + state=await(await route.GET(new NextRequest(`http://localhost/api/canvas/download-zip-task?taskId=${id}`))).json(); + expect(state.task).toMatchObject({id,status:'completed',successCount:1}); + } finally {await rm(dir,{recursive:true,force:true});} +}); + +test("a stale process lock can be recovered, while a live worker cannot be duplicated",async()=>{ + const dir=await mkdtemp(join(tmpdir(),'canvas-zip-lock-'));const id=randomUUID(); + const store=serverLoader({},{CANVAS_DOWNLOAD_ZIP_DIR:dir})('lib/canvas/download-zip-task-store.ts'); + try{ + const release=await store.claimZipTask(id);expect(release).toBeTruthy();expect(await store.claimZipTask(id)).toBeNull();await release(); + await mkdir(join(dir,id,'lock'));await writeFile(join(dir,id,'lock','owner.json'),JSON.stringify({pid:2147483647})); + const recovered=await store.claimZipTask(id);expect(recovered).toBeTruthy();await recovered(); + }finally{await rm(dir,{recursive:true,force:true});} +}); + +test("the browser polls a complete ZIP, preserves an idempotent task after a lost POST response, and waits to start the browser download",async()=>{ + let calls=0;let firstId='';let frames:any[]=[];const stored=new Map(); + const start=serverLoader({ + sessionStorage:{getItem:(key:string)=>stored.get(key),setItem:(key:string,value:string)=>stored.set(key,value)}, + document:{createElement:()=>({remove(){}}),body:{appendChild:(frame:any)=>frames.push(frame)}}, + window:{setTimeout:()=>0}, + setTimeout:(fn:()=>void,ms:number)=>{if(ms<10000)queueMicrotask(fn);return 0;}, + fetch:async(url:string,init:any)=>{ + calls++; + if(init?.method==='POST'){ + const payload=JSON.parse(init.body); + if(calls===1){firstId=payload.taskId;throw new Error('response disconnected');} + expect(payload.taskId).toBe(firstId); + return Response.json({task:{id:firstId,status:'pending',progress:0}}); + } + expect(frames).toHaveLength(0); + return Response.json({task:{id:firstId,status:'completed',progress:100,successCount:2,failedCount:0,downloadUrl:'/api/canvas/download-zip-task?taskId='+firstId+'&download=1'}}); + }, + })('lib/client/canvas-zip-download.ts'); + const result=await start.downloadCanvasSelectionZip({folderName:'作品',files:[{url:'https://cdn.example/a.png',name:'a'}],selectionKey:'project:node-1',onProgress:()=>{}}); + expect(calls).toBe(3);expect(result.successCount).toBe(2);expect(frames).toHaveLength(1);expect(frames[0].src).toContain('download=1'); +}); + +test("partial source failures are explicit; CDN redirects retain an authenticated local range fallback",async()=>{ + const dir=await mkdtemp(join(tmpdir(),'canvas-zip-partial-'));const callbacks:Array<()=>Promise>=[];let owner='owner'; + const route=serverLoader({ + '@/app/(auth)/auth':{auth:async()=>({user:{id:owner}})}, + 'next/server':{NextResponse,after:(run:()=>Promise)=>callbacks.push(run)}, + '@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async({index}:any)=>index?{ok:false,error:'source expired'}:{ok:true,filename:'original.png',bytes:Buffer.from('original'),contentType:'image/png'}}, + '@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>({url:'https://cdn.example/complete.zip',key:'canvas-downloads/x.zip',storageConfigId:'config'})}, + },{CANVAS_DOWNLOAD_ZIP_DIR:dir})('app/api/canvas/download-zip-task/route.ts'); + const id=randomUUID();const request=(extra='')=>new NextRequest(`http://localhost/api/canvas/download-zip-task?taskId=${id}${extra}`); + try{ + await route.POST(new NextRequest('http://localhost/api/canvas/download-zip-task',{method:'POST',body:JSON.stringify({taskId:id,files:[{url:'https://provider.example/1'},{url:'https://provider.example/2'}]})})); + await callbacks.shift()!(); + const state=await(await route.GET(request())).json();expect(state.task).toMatchObject({status:'completed',successCount:1,failedCount:1}); + const cdn=await route.GET(request('&download=1'));expect(cdn.status).toBe(307);expect(cdn.headers.get('location')).toBe('https://cdn.example/complete.zip'); + const local=await route.GET(request('&download=1&local=1'));const zip=await JSZip.loadAsync(await local.arrayBuffer()); + expect(Object.keys(zip.files).some(name=>name.endsWith('_download-failures.txt'))).toBe(true); + owner='stranger';expect((await route.GET(request('&download=1'))).status).toBe(404); + }finally{await rm(dir,{recursive:true,force:true});} +});