fix(canvas): prepare durable selection ZIPs before resumable download

This commit is contained in:
134 2026-10-20 18:43:59 +08:00
parent 2fb3ed8512
commit 87e6476ba8
11 changed files with 581 additions and 319 deletions

View File

@ -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<string, ZipTask>;
};
function getTaskStore() {
if (!globalTaskStore.__canvasDownloadZipTasks) {
globalTaskStore.__canvasDownloadZipTasks = new Map<string, ZipTask>();
}
return globalTaskStore.__canvasDownloadZipTasks;
}
const TASK_TTL_MS = 60 * 60_000;
const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set<string> });
const active = running.__canvasZipRunning ??= new Set<string>();
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<void>) | 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<ReturnType<typeof fetchCanvasDownloadAsset>>
| 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<string, unknown>;
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<string, string> = { "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 });
}
}

View File

@ -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]);

View File

@ -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 };
}

View File

@ -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<Uint8Array>;
export function readDownloadZip(path: string, range?: { start: number; end: number }) {
return Readable.toWeb(createReadStream(path, range)) as ReadableStream<Uint8Array>;
}
export function streamDownloadZip(zip: JSZip) {
return Readable.toWeb(zip.generateNodeStream({ type: "nodebuffer", streamFiles: true, compression: "STORE" }) as Readable) as ReadableStream<Uint8Array>;

View File

@ -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<ZipTask | null> {
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 较多,请等待已有任务完成后再试");
}

View File

@ -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;
}

View File

@ -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(); }
}

View File

@ -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();

View File

@ -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 });

View File

@ -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<string, unknown>, env: Record<string, string> = {}) {
const modules = new Map<string, { exports: any }>();
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;
}

View File

@ -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<void>>=[];
const mocks={
'@/app/(auth)/auth': {auth:async()=>owner?{user:{id:owner}}:null},
'next/server': {NextResponse,after:(run:()=>Promise<void>)=>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<string,string>={})=>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<void>>=[];let available=false;
const route=serverLoader({
'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>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<string,string>();
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<void>>=[];let owner='owner';
const route=serverLoader({
'@/app/(auth)/auth':{auth:async()=>({user:{id:owner}})},
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>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});}
});