Files
ai-drama-platform/server/storage.mjs
T

191 lines
8.4 KiB
JavaScript

import { lstat, readdir, unlink } from "node:fs/promises";
import { resolve } from "node:path";
import { dbAll, dbGet, dbRun } from "./db.mjs";
import { httpError } from "./tenant.mjs";
const projectRoot = resolve(import.meta.dirname, "..");
const storageRoot = resolve(projectRoot, "storage");
const GB = 1024 ** 3;
async function walk(directory, entries = []) {
let children;
try {
children = await readdir(directory, { withFileTypes: true });
} catch (error) {
if (error.code === "ENOENT") return entries;
throw error;
}
for (const child of children) {
const path = resolve(directory, child.name);
if (child.isDirectory()) {
await walk(path, entries);
continue;
}
if (!child.isFile()) continue;
const info = await lstat(path);
entries.push({ path, bytes: info.size, updatedAt: info.mtime.toISOString() });
}
return entries;
}
function quotaRow(context) {
return dbGet(
`SELECT * FROM quota_allocations
WHERE organization_id = ? AND metric = 'storage'
AND (workspace_id = ? OR workspace_id IS NULL)
AND julianday(period_start) <= julianday('now')
AND julianday(period_end) >= julianday('now')
ORDER BY CASE WHEN workspace_id = ? THEN 0 ELSE 1 END
LIMIT 1`,
[context.organization.id, context.workspace.id, context.workspace.id]
);
}
function scopedProjectIds(context) {
if (context.project?.id) return [context.project.id];
return (context.projects || []).map((project) => project.id);
}
async function projectUsage(projectId) {
const roots = [resolve(storageRoot, "assets", projectId)];
const jobs = dbAll("SELECT id FROM generation_jobs WHERE project_id = ?", [projectId]);
roots.push(...jobs.map((job) => resolve(storageRoot, "jobs", job.id)));
const compositions = dbAll("SELECT id FROM media_compositions WHERE project_id = ?", [projectId]);
roots.push(...compositions.map((composition) => resolve(storageRoot, "compositions", composition.id)));
const entries = [];
for (const root of roots) await walk(root, entries);
const files = entries.map((entry) => ({ ...entry, projectId, relativePath: entry.path.replace(`${projectRoot}/`, "") }));
return { projectId, bytes: files.reduce((sum, file) => sum + file.bytes, 0), files };
}
export async function storageSummary(context) {
const projects = [];
for (const projectId of scopedProjectIds(context)) projects.push(await projectUsage(projectId));
const usedBytes = projects.reduce((sum, project) => sum + project.bytes, 0);
const quota = quotaRow(context);
const billingLimitGb = Number(dbGet("SELECT storage_gb FROM billing_accounts WHERE organization_id = ?", [context.organization.id])?.storage_gb || 0);
const quotaLimitGb = Number(quota?.limit_value || 0);
const limitGb = billingLimitGb && quotaLimitGb ? Math.min(billingLimitGb, quotaLimitGb) : billingLimitGb || quotaLimitGb;
const limitBytes = limitGb * GB;
const timestamp = new Date().toISOString();
if (quota) dbRun("UPDATE quota_allocations SET used_value = ?, updated_at = ? WHERE id = ?", [usedBytes / GB, timestamp, quota.id]);
const files = projects.flatMap((project) => project.files).sort((a, b) => b.bytes - a.bytes);
return {
usedBytes,
usedGb: Number((usedBytes / GB).toFixed(4)),
limitBytes,
limitGb,
remainingBytes: Math.max(0, limitBytes - usedBytes),
percent: limitBytes ? Math.min(100, Number(((usedBytes / limitBytes) * 100).toFixed(2))) : 0,
projects: projects.map(({ projectId, bytes }) => ({ projectId, bytes, usedGb: Number((bytes / GB).toFixed(4)) })),
largestFiles: files.slice(0, 12)
};
}
export async function requireStorageQuota(context, bytes) {
const requestedBytes = Math.max(0, Number(bytes || 0));
const summary = await storageSummary(context);
if (summary.limitBytes && summary.usedBytes + requestedBytes > summary.limitBytes) {
throw httpError(429, "storage_quota_exceeded", "当前组织存储空间不足", {
usedBytes: summary.usedBytes,
limitBytes: summary.limitBytes,
requestedBytes,
remainingBytes: summary.remainingBytes
});
}
return { ...summary, remainingBytes: Math.max(0, summary.remainingBytes - requestedBytes) };
}
function configuredSetting(key, fallback) {
const row = dbGet("SELECT value_json FROM system_settings WHERE key = ?", [key]);
if (!row) return fallback;
try { return JSON.parse(row.value_json); } catch { return fallback; }
}
function isTemporaryStorageFile(relativePath) {
return /\/((outputs|frames|tmp|cache))\//i.test(`/${relativePath}`);
}
function protectedPaths(context) {
const protectedSet = new Set();
const add = (value) => {
const path = String(value || "").trim();
if (path.startsWith("storage/") && !path.includes("..")) protectedSet.add(path);
};
for (const row of dbAll("SELECT path, first_frame_path, last_frame_path FROM media_artifacts WHERE organization_id = ? AND workspace_id = ? AND project_id IN (SELECT id FROM projects WHERE workspace_id = ?)", [context.organization.id, context.workspace.id, context.workspace.id])) {
add(row.path); add(row.first_frame_path); add(row.last_frame_path);
}
for (const row of dbAll("SELECT av.storage_path FROM asset_versions av JOIN assets a ON a.id = av.asset_id JOIN projects p ON p.id = a.project_id JOIN workspaces w ON w.id = p.workspace_id WHERE w.organization_id = ? AND p.workspace_id = ?", [context.organization.id, context.workspace.id])) add(row.storage_path);
for (const row of dbAll("SELECT output_path, manifest_path FROM media_compositions WHERE organization_id = ? AND workspace_id = ?", [context.organization.id, context.workspace.id])) {
add(row.output_path); add(row.manifest_path);
}
return protectedSet;
}
export async function storageCleanupPreview(context, options = {}) {
const configuredDays = Number(configuredSetting("storage.retention_days", 30));
const olderThanDays = Math.max(1, Math.min(3650, Number(options.olderThanDays || configuredDays || 30)));
const cutoff = Date.now() - olderThanDays * 86400000;
const protectedSet = protectedPaths(context);
const candidates = [];
for (const projectId of scopedProjectIds(context)) {
const usage = await projectUsage(projectId);
for (const file of usage.files) {
if (!isTemporaryStorageFile(file.relativePath)) continue;
if (protectedSet.has(file.relativePath)) continue;
const modifiedAt = new Date(file.updatedAt).getTime();
if (!Number.isFinite(modifiedAt) || modifiedAt > cutoff) continue;
candidates.push({
relativePath: file.relativePath,
projectId,
bytes: file.bytes,
updatedAt: file.updatedAt,
reason: "超过保留周期且未被媒体证据、资产版本或合成清单引用"
});
}
}
candidates.sort((left, right) => right.bytes - left.bytes);
return {
policy: {
provider: configuredSetting("storage.provider", "local-filesystem"),
retentionDays: olderThanDays,
unreferencedOnly: Boolean(configuredSetting("storage.cleanup_unreferenced_only", true)),
protectedReferenceTypes: ["media_artifacts", "asset_versions", "media_compositions"]
},
cutoff: new Date(cutoff).toISOString(),
candidates,
totalBytes: candidates.reduce((sum, item) => sum + item.bytes, 0),
checkedAt: new Date().toISOString()
};
}
export async function reclaimStorage(context, options = {}) {
const preview = await storageCleanupPreview(context, options);
const requested = new Set(Array.isArray(options.paths) ? options.paths.map((item) => String(item || "").trim()) : preview.candidates.map((item) => item.relativePath));
const targets = preview.candidates.filter((item) => requested.has(item.relativePath));
const deleted = [];
for (const target of targets) {
try {
await unlink(resolve(projectRoot, target.relativePath));
deleted.push(target);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
}
return {
...preview,
candidates: preview.candidates.filter((item) => !deleted.some((entry) => entry.relativePath === item.relativePath)),
deleted,
deletedBytes: deleted.reduce((sum, item) => sum + item.bytes, 0),
completedAt: new Date().toISOString()
};
}
export function bytesToHuman(bytes) {
const value = Number(bytes || 0);
if (value < 1024) return `${value} B`;
if (value < 1024 ** 2) return `${(value / 1024).toFixed(1)} KB`;
if (value < GB) return `${(value / 1024 ** 2).toFixed(1)} MB`;
return `${(value / GB).toFixed(2)} GB`;
}