106 lines
4.4 KiB
JavaScript
106 lines
4.4 KiB
JavaScript
import { lstat, readdir } 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) };
|
|
}
|
|
|
|
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`;
|
|
}
|