191 lines
8.4 KiB
JavaScript
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`;
|
|
}
|