feat: add workflow orchestration and media governance

This commit is contained in:
xz
2026-08-24 22:36:43 +08:00
parent 9811469a64
commit aa0e0be27a
16 changed files with 775 additions and 13 deletions
+86 -1
View File
@@ -1,4 +1,4 @@
import { lstat, readdir } from "node:fs/promises";
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";
@@ -96,6 +96,91 @@ export async function requireStorageQuota(context, bytes) {
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`;