ai-chatbot/lib/canvas/download-zip-task-store.ts

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