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`; }