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; }; function getTaskStore() { if (!globalTaskStore.__canvasDownloadZipTasks) { globalTaskStore.__canvasDownloadZipTasks = new Map(); } 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; 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> | 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; 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 }); } }