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 { 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 }; const MAX_DOWNLOAD_FILES = 200; const TASK_TTL_MS = 60 * 60_000; const running = (globalThis as typeof globalThis & { __canvasZipRunning?: Set }); const active = running.__canvasZipRunning ??= new Set(); function sanitizeFolderName(value: string): string { const cleaned = String(value || "") .replace(/[\\/:*?"<>|]+/g, "-") .replace(/\s+/g, "-") .replace(/-+/g, "-") .replace(/^-+|-+$/g, "") .trim(); return cleaned && !/^\.+$/.test(cleaned) ? cleaned.slice(0, 100) : `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 serializeTask(task: ZipTask) { 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 processTask(id: string, userId: string, requestOrigin: string, cookie: string) { if (active.has(id) || active.size >= 2) return; active.add(id); let release: (() => Promise) | null = null; let task: ZipTask | null = null; try { 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; 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; delete file.prepared; delete file.filename; 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(); } })); 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)) .once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); })); 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.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; } } 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) { 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 { 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); after(() => cleanupZipTasks().catch(() => { console.warn("[CanvasDownloadZipTask] Expired file cleanup deferred"); })); return NextResponse.json({ success: true, task: serializeTask(task) }, { status: 202, headers: { "Cache-Control": "no-store" } }); } catch (error) { console.warn("[CanvasDownloadZipTask] Create rejected", { message: (error as Error).message }); return NextResponse.json({ error: "无法创建打包任务,请稍后重试" }, { status: 400 }); } } export async function GET(request: NextRequest) { const session = await auth(); if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 }); try { const id = request.nextUrl.searchParams.get("taskId") || ""; const task = await loadZipTask(id, session.user.id); if (!task) return NextResponse.json({ error: "打包任务不存在或已过期,请重新打包" }, { status: 404 }); if (request.nextUrl.searchParams.get("download") === "1") { if (task.status !== "completed" || !task.zipFile) return NextResponse.json({ error: "ZIP 尚未准备好" }, { status: 409 }); if (task.cdnUrl && request.nextUrl.searchParams.get("local") !== "1") return NextResponse.redirect(task.cdnUrl, 307); const info = await stat(task.zipFile.path).catch(() => null); if (!info || info.size !== task.zipFile.bytes) return NextResponse.json({ error: "下载文件已过期,请重新打包" }, { status: 410 }); const etag = `"${task.id}-${info.size}"`; const headers: Record = { "Content-Type": "application/zip", "Content-Disposition": task.contentDisposition!, "Accept-Ranges": "bytes", "ETag": etag, "Cache-Control": "private, no-store", "X-Download-Success-Count": String(task.successCount), "X-Download-Failed-Count": String(task.failedCount) }; const ifRange = request.headers.get("if-range"); let range; try { range = parseDownloadByteRange(!ifRange || ifRange === etag ? request.headers.get("range") : null, info.size); } catch { return new NextResponse(null, { status: 416, headers: { ...headers, "Content-Range": `bytes */${info.size}` } }); } headers["Content-Length"] = String(range ? range.end - range.start + 1 : info.size); if (range) headers["Content-Range"] = `bytes ${range.start}-${range.end}/${info.size}`; return new NextResponse(readDownloadZip(task.zipFile.path, range || undefined), { status: range ? 206 : 200, headers }); } if (task.status === "pending" || task.status === "running") schedule(task, request); return NextResponse.json({ success: true, task: serializeTask(task) }, { headers: { "Cache-Control": "no-store" } }); } catch { return NextResponse.json({ error: "查询打包任务失败,请稍后重试" }, { status: 500 }); } }