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 { 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 较多,请等待已有任务完成后再试"); }