237 lines
13 KiB
TypeScript
237 lines
13 KiB
TypeScript
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<string> });
|
||
const active = running.__canvasZipRunning ??= new Set<string>();
|
||
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<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 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<void>) | 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;
|
||
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)));
|
||
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 {
|
||
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 });
|
||
}
|
||
}
|