fix(canvas): prepare durable selection ZIPs before resumable download
This commit is contained in:
parent
2fb3ed8512
commit
87e6476ba8
@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
11
lib/canvas/download-byte-range.ts
Normal file
11
lib/canvas/download-byte-range.ts
Normal 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 };
|
||||
}
|
||||
@ -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>;
|
||||
|
||||
100
lib/canvas/download-zip-task-store.ts
Normal file
100
lib/canvas/download-zip-task-store.ts
Normal 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 较多,请等待已有任务完成后再试");
|
||||
}
|
||||
50
lib/client/canvas-zip-download.ts
Normal file
50
lib/client/canvas-zip-download.ts
Normal 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;
|
||||
}
|
||||
40
lib/storage/canvas-zip-delivery.ts
Normal file
40
lib/storage/canvas-zip-delivery.ts
Normal 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(); }
|
||||
}
|
||||
3
proxy.ts
3
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();
|
||||
|
||||
@ -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 });
|
||||
|
||||
69
tests/helpers/canvas-download-server-loader.ts
Normal file
69
tests/helpers/canvas-download-server-loader.ts
Normal 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;
|
||||
}
|
||||
126
tests/routes/canvas-zip-download.test.ts
Normal file
126
tests/routes/canvas-zip-download.test.ts
Normal 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});}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user