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