363 lines
11 KiB
TypeScript
363 lines
11 KiB
TypeScript
import { writeDownloadZip, readDownloadZip, removeDownloadZip } from "@/lib/canvas/download-zip-stream";
|
|
import { randomUUID } from "node:crypto";
|
|
import { NextRequest, NextResponse } from "next/server";
|
|
import JSZip from "jszip";
|
|
import { auth } from "@/app/(auth)/auth";
|
|
import { fetchCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
|
|
|
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;
|
|
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;
|
|
}
|
|
|
|
function sanitizeFolderName(value: string): string {
|
|
const cleaned = String(value || "")
|
|
.replace(/[\\/:*?"<>|]+/g, "-")
|
|
.replace(/\s+/g, "-")
|
|
.replace(/-+/g, "-")
|
|
.replace(/^-+|-+$/g, "")
|
|
.trim();
|
|
return cleaned || `canvas-images-${Date.now()}`;
|
|
}
|
|
|
|
function buildContentDisposition(folderName: string): string {
|
|
const encodedName = encodeURIComponent(`${folderName}.zip`);
|
|
const asciiName =
|
|
folderName
|
|
.replace(/[^\x20-\x7E]+/g, "")
|
|
.replace(/"/g, "")
|
|
.trim() || "canvas-images";
|
|
return `attachment; filename="${asciiName}.zip"; filename*=UTF-8''${encodedName}`;
|
|
}
|
|
|
|
function getRequestOrigin(request: NextRequest): string {
|
|
const forwardedHost = (request.headers.get("x-forwarded-host") || "")
|
|
.split(",")[0]
|
|
?.trim();
|
|
const forwardedProto = (request.headers.get("x-forwarded-proto") || "")
|
|
.split(",")[0]
|
|
?.trim();
|
|
if (forwardedHost && forwardedProto) {
|
|
return `${forwardedProto}://${forwardedHost}`;
|
|
}
|
|
return new URL(request.url).origin;
|
|
}
|
|
|
|
function toDownloadItems(input: unknown, origin: string): DownloadFileItem[] {
|
|
if (!Array.isArray(input)) return [];
|
|
return input
|
|
.map((item) => {
|
|
const raw = (item || {}) as Record<string, unknown>;
|
|
const rawUrl = String(raw.url || "").trim();
|
|
if (!rawUrl) return null;
|
|
|
|
let resolvedUrl = "";
|
|
try {
|
|
const parsed = new URL(rawUrl, origin);
|
|
if (!/^https?:$/i.test(parsed.protocol)) return null;
|
|
resolvedUrl = parsed.toString();
|
|
} catch (_error) {
|
|
return null;
|
|
}
|
|
|
|
const name = String(raw.name || "").trim();
|
|
const nextItem: DownloadFileItem = { url: resolvedUrl };
|
|
if (name) {
|
|
nextItem.name = name;
|
|
}
|
|
return nextItem;
|
|
})
|
|
.filter((item): item is DownloadFileItem => item !== null)
|
|
.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,
|
|
};
|
|
}
|
|
|
|
async function processZipTask(params: {
|
|
task: ZipTask;
|
|
requestOrigin: string;
|
|
cookie: string;
|
|
}) {
|
|
const { task, requestOrigin, cookie } = params;
|
|
const store = getTaskStore();
|
|
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);
|
|
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;
|
|
}
|
|
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();
|
|
});
|
|
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 });
|
|
}
|
|
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 (error) {
|
|
console.error("[CanvasDownloadZipTask] Query failed:", error);
|
|
return NextResponse.json({ success: false, error: "查询打包任务失败" }, { status: 500 });
|
|
}
|
|
}
|