feat: add workflow orchestration and media governance
This commit is contained in:
+86
-1
@@ -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`;
|
||||
|
||||
Reference in New Issue
Block a user