diff --git a/README.md b/README.md index 8dd2ed8..6b903ef 100644 --- a/README.md +++ b/README.md @@ -246,6 +246,9 @@ API 客户端 scope 由后端白名单强制执行:`jobs:read` 只能读取任 - 声音作为独立资产锁定,不把 MiniMax H3 随机原生声音作为最终角色声线。 - 当前平台底座已具备真实任务合同、队列、重试、取消、租约、并发控制和 HTTP Runner 执行接口;API 启动时自动运行本地 Worker,只会领取本地 `ready` 连接器任务。若未配置可用的自有图片/视频/TTS/ASR Runner,任务会明确显示 `not-connected`/`blocked`,不会伪称已经生成真实媒体成片。 - 手动执行接口对已完成任务是幂等的:如果本地 Worker 已先完成任务,重复调用 `POST /api/jobs/:jobId/run` 会返回现有完成结果并标记 `idempotent: true`,不会再次调用模型或生成第二份媒体。 +- 流程模板已经是服务端版本化对象:全局内置模板可被组织 / 工作区新版本覆盖,生成队列支持“只生成计划”或“按步骤创建带依赖的 generation jobs”。 +- 媒体证据支持按项目 scope 读取原始图片、视频、音频以及实际首帧 / 末帧;审片中心使用文件级预览、SHA-256、FFprobe 和 QA 证据共同验收,不把路径字符串当作已生成事实。 +- 系统存储页支持保留周期和“仅清理无引用临时文件”的清理预览;回收动作只处理 `outputs/frames/tmp/cache` 中未被资产版本、媒体证据或合成清单引用的文件,并写入审计。 - 模型协议、Worker 环境变量、状态字段和接口返回约定见 `/Users/xz/Documents/daima/ai短剧/ai-drama-platform/docs/API_REFERENCE.md`。 - 本地验证脚本会修改 SQLite,因此多个 smoke 脚本应顺序执行;并行写入会触发 SQLite 的正常写锁保护。 diff --git a/docs/API_REFERENCE.md b/docs/API_REFERENCE.md index 1b62235..83b6df0 100644 --- a/docs/API_REFERENCE.md +++ b/docs/API_REFERENCE.md @@ -28,6 +28,8 @@ user → organization → workspace → project → permission | 内容生产 | `/api/production/catalog`、`/api/production/graph`、`/api/production/script/*`、`/api/production/bible`、`/api/production/shots/*` | `script:edit`、`asset:edit`、`job:create` | | 资产与声音 | `/api/assets/*`、`/api/assets/:assetId/versions/upload`、`/api/assets/:assetId/verify`、`/api/assets/:assetId/versions/:versionId/restore` | `asset:edit`、`voice:edit` | | 生成任务 | `/api/jobs`、`/api/jobs/:jobId/*`、`/api/adapters/dry-run` | `job:create` 或 `queue:manage` | +| 流程模板编排 | `/api/workflows/templates`、`/api/workflows/templates/:id/instantiate`、`/api/workflows/runs` | 读取 / 实例化:`script:read`、`job:create` 或 `project:create`;管理:`workflow:manage` | +| 媒体证据与存储 | `/api/production/media-artifacts`、`/api/production/media-artifacts/:id/content`、`/api/usage/storage`、`/api/system/storage/*` | 媒体:`qa:review` 或 `delivery:view`;存储回收:系统设置权限 | | 审片与交付 | `/api/production/reviews/*`、`/api/production/qa/run`、`/api/production/deliveries/*`、`/api/exports/write` | `qa:review`、`delivery:approve` | | 组织运营 | `/api/usage`、`/api/billing`、`/api/organizations/:id/commercial`、`/api/organizations/:id/usage`、`/api/organizations/:id/usage/export`、`/api/organizations/:id/billing`、`/api/organizations/:id/quotas/:quotaId`、`/api/audit`、`/api/audit/export` | `usage:view`、`billing:manage`、`quota:manage`、`audit:view` | | 模型中台 | `/api/platform/models`、`/api/platform/models/register`、`/api/platform/models/:id/probe` | `model:manage` | @@ -47,6 +49,71 @@ GET /api/search?q=雷雨&scope=project&limit=40 搜索不会把连接器 endpoint、API key、客户交付令牌或本地存储绝对路径作为可搜索字段返回;跨组织、跨工作区或无项目访问权的关键词不会出现在结果中。 +### 流程模板与生产编排 + +流程模板是服务端版本化对象,不是前端写死的按钮配置。平台先提供全局内置模板,组织或工作区可以用同一 `templateKey` 创建自己的版本;列表按当前工作区优先、组织其次、全局最后合并同键模板。全局内置模板只能读取,不能直接修改。 + +```http +GET /api/workflows/templates?includeArchived=1 +POST /api/workflows/templates +PATCH /api/workflows/templates/:templateId +POST /api/workflows/templates/:templateId/instantiate +GET /api/workflows/runs?status=&limit=50 +``` + +创建或更新模板的核心结构如下: + +```json +{ + "name": "AI 漫剧单镜头生产", + "templateKey": "ai-manhua-drama", + "category": "ai-drama", + "status": "active", + "description": "关键帧 -> 图生视频 -> 固定配音 -> ASR -> 合成", + "defaultAdapterId": "owned-model-platform", + "steps": [ + { "key": "keyframe", "label": "单画面关键帧", "jobKind": "单画面关键帧", "requiresShot": true }, + { "key": "video", "label": "首尾帧图生视频", "jobKind": "首尾帧图生视频", "requiresShot": true, "dependsOnPrevious": true } + ], + "gates": [ + { "key": "single-frame", "label": "一图一画面", "blocking": true } + ] +} +``` + +`steps` 最多 32 个,模板状态只能是 `draft`、`active` 或 `archived`;只有 `active` 模板可以进入生产。`POST /instantiate` 要求当前项目上下文,支持: + +- `mode=plan`:只生成流程计划,不创建生成任务,适合导演确认步骤和质检门。 +- `mode=queue`:按步骤创建 `generation_jobs`,默认把前一步任务写入下一步 `depends_on`,失败步骤会使流程运行标记为 `blocked`。 +- `shotId`:镜头级步骤必须绑定当前项目镜头;`episodeId`、`adapter`、`priority` 和 `approveExternal` 可作为运行参数传入。 + +运行记录保存模板版本、步骤状态、任务 ID、当前步骤、错误信息和创建人。模板创建、修改和实例化都会写审计;外部 / 混合成本适配器仍需显式审批,默认适配器为用户自有模型平台。 + +### 媒体证据、首末帧与存储治理 + +生成任务和合成任务完成后,平台会登记文件级媒体证据,保存相对路径、MIME、文件大小、SHA-256、时长、分辨率、音视频轨道状态,以及视频实际首帧和末帧。首末帧是 QA / 连续性依据,不接受 contact sheet、故事板拼图或前端临时缩略图替代。 + +```http +GET /api/production/media-artifacts?shotId=&jobId=&limit=200 +GET /api/production/media-artifacts/:artifactId/content +GET /api/production/media-artifacts/:artifactId/content?frame=first +GET /api/production/media-artifacts/:artifactId/content?frame=last +``` + +媒体列表只返回当前组织、工作区和项目可见的证据;原始文件和首末帧内容接口返回二进制,带真实 `Content-Type`、`Content-Length`、内联文件名和基于内容 SHA-256 的 `ETag`。不存在、路径不安全或文件尚未落盘分别返回 `404` / `422` / `404`,不会把数据库里登记过的路径直接当成成功生成。 + +存储用量和回收接口如下: + +```http +GET /api/usage/storage +GET /api/system/storage/cleanup-preview?olderThanDays=30 +POST /api/system/storage/reclaim +``` + +`/api/usage/storage` 返回当前作用域的已用空间、限额、剩余空间、占用率、项目拆分和最大文件。清理预览根据 `storage.retention_days`(默认 30 天)扫描 `outputs/`、`frames/`、`tmp/`、`cache/` 下超过保留期的临时文件,并明确列出候选文件和预计释放字节。`reclaim` 可接收 `{ "paths": ["storage/jobs/.../outputs/tmp.png"] }` 指定执行,也可以不传路径执行全部预览候选。 + +回收始终保护被 `media_artifacts`、`asset_versions` 或 `media_compositions` 引用的路径,并限制在 `storage/` 安全相对路径内;执行结果写入 `system.storage.reclaimed` 审计事件。`storage.provider` 当前默认是 `local-filesystem`,S3-compatible 等外部存储只作为后续适配层,不会在本地环境自动启用。 + ### 审计与合规中心 ```http diff --git a/package.json b/package.json index c25e4a2..bf9d06b 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "smoke:readiness": "node scripts/smoke-readiness.mjs", "smoke:media-evidence": "node scripts/smoke-media-evidence.mjs", "smoke:api-media": "node scripts/smoke-api-media.mjs", + "smoke:workflow-platform": "node scripts/smoke-workflow-platform.mjs", "smoke:compose-evidence": "node scripts/smoke-compose-evidence.mjs", "smoke:work-items": "node scripts/smoke-work-items.mjs", "smoke:tasks": "node scripts/smoke-tasks.mjs", diff --git a/scripts/smoke-all.mjs b/scripts/smoke-all.mjs index 957b4cd..9bcc5ce 100644 --- a/scripts/smoke-all.mjs +++ b/scripts/smoke-all.mjs @@ -26,6 +26,7 @@ const scripts = [ "smoke:readiness", "smoke:media-evidence", "smoke:api-media", + "smoke:workflow-platform", "smoke:compose-evidence", "smoke:work-items", "smoke:tasks", diff --git a/scripts/smoke-api-media.mjs b/scripts/smoke-api-media.mjs index 6df996b..df334ce 100644 --- a/scripts/smoke-api-media.mjs +++ b/scripts/smoke-api-media.mjs @@ -53,6 +53,14 @@ try { const artifacts = await request(`/api/production/media-artifacts?jobId=${encodeURIComponent(jobId)}`, { headers }); assert.equal(artifacts.response.ok, true, "media artifact API must be readable"); assert.equal(artifacts.payload.artifacts?.[0]?.job_id, jobId, "artifact must be linked to the generation job"); + const artifactId = artifacts.payload.artifacts[0].id; + const contentResponse = await fetch(`${api}/api/production/media-artifacts/${encodeURIComponent(artifactId)}/content`, { headers }); + assert.equal(contentResponse.ok, true, "media artifact content must be readable within the project scope"); + assert.match(contentResponse.headers.get("content-type") || "", /video\/mp4/, "artifact preview must preserve video content type"); + assert.ok((await contentResponse.arrayBuffer()).byteLength > 0, "artifact preview body must not be empty"); + const frameResponse = await fetch(`${api}/api/production/media-artifacts/${encodeURIComponent(artifactId)}/content?frame=last`, { headers }); + assert.equal(frameResponse.ok, true, "actual last-frame preview must be readable"); + assert.match(frameResponse.headers.get("content-type") || "", /image\/jpeg/, "last-frame preview must be JPEG"); console.log(`api media smoke passed: ${jobId} -> ${artifacts.payload.artifacts[0].last_frame_path}`); } finally { for (const jobId of createdJobIds) { diff --git a/scripts/smoke-workflow-platform.mjs b/scripts/smoke-workflow-platform.mjs new file mode 100644 index 0000000..66d342f --- /dev/null +++ b/scripts/smoke-workflow-platform.mjs @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { dbRun } from "../server/db.mjs"; + +const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787"; +const scope = { "x-organization-id": "org-studio-lab", "x-workspace-id": "ws-local-aidrama", "x-project-id": "thunder-mouth" }; + +async function request(path, headers, options = {}) { + const response = await fetch(`${api}${path}`, { ...options, headers: { "content-type": "application/json", ...(headers || {}), ...(options.headers || {}) } }); + const payload = await response.json().catch(() => ({})); + return { response, payload }; +} + +async function login(email) { + const result = await request("/api/auth/login", null, { method: "POST", body: JSON.stringify({ email, password: "Demo@123456" }) }); + assert.equal(result.response.ok, true, `${email} 登录失败`); + return { authorization: `Bearer ${result.payload.session.token}`, ...scope }; +} + +const owner = await login("producer@local.test"); +const writer = await login("writer@local.test"); +let runId = ""; + +try { + const templates = await request("/api/workflows/templates", owner); + assert.equal(templates.response.ok, true, "管理员读取流程模板失败"); + const main = templates.payload.templates.find((template) => template.templateKey === "ai-manhua-drama"); + assert.ok(main, "缺少 AI 漫剧主流程模板"); + assert.equal(main.status, "active", "AI 漫剧主流程必须是 active"); + assert.ok(main.steps.length >= 5, "流程模板必须包含可执行步骤"); + + const writerRead = await request("/api/workflows/templates", writer); + assert.equal(writerRead.response.ok, true, "普通生产成员应能读取可用模板"); + const writerWrite = await request("/api/workflows/templates", writer, { method: "POST", body: JSON.stringify({ name: "越权模板", templateKey: "forbidden-template", steps: [{ label: "测试" }] }) }); + assert.equal(writerWrite.response.status, 403, "普通生产成员不能创建工作区流程模板"); + + const plan = await request(`/api/workflows/templates/${encodeURIComponent(main.id)}/instantiate`, owner, { method: "POST", body: JSON.stringify({ mode: "plan", shotId: "shot-01" }) }); + assert.equal(plan.response.status, 201, `流程计划创建失败:${plan.payload.detail || ""}`); + assert.equal(plan.payload.run.status, "planned", "计划模式不能直接排入任务队列"); + assert.equal(plan.payload.jobs.length, 0, "计划模式不应创建 generation job"); + assert.ok(plan.payload.run.steps.length >= 5, "流程运行记录必须保存步骤快照"); + runId = plan.payload.run.id; + + const runs = await request("/api/workflows/runs?limit=10", owner); + assert.equal(runs.response.ok, true, "流程运行记录读取失败"); + assert.ok(runs.payload.runs.some((run) => run.id === runId), "流程运行记录没有按项目 scope 返回"); + console.log(`workflow platform smoke passed: ${main.name} / ${plan.payload.run.steps.length} steps`); +} finally { + if (runId) dbRun("DELETE FROM workflow_runs WHERE id = ?", [runId]); +} diff --git a/server/db.mjs b/server/db.mjs index da79bbd..c8d7249 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -2,7 +2,7 @@ import { mkdir, readFile } from "node:fs/promises"; import { resolve } from "node:path"; import { DatabaseSync } from "node:sqlite"; import { randomBytes, scryptSync } from "node:crypto"; -import { sampleProject } from "../src/data/sampleProject.js"; +import { sampleProject, workflowTemplates } from "../src/data/sampleProject.js"; import { apiClientStorageMarker, hashApiClientKey } from "./api-client-secrets.mjs"; const serverRoot = resolve(import.meta.dirname); @@ -219,6 +219,7 @@ function seedRoles() { ["project:create", "创建项目"], ["project:manage", "管理项目设置"], ["project:members:manage", "管理项目成员"], + ["workflow:manage", "管理生产流程模板和版本"], ["task:view", "查看项目协作任务"], ["task:manage", "创建、分派和管理项目协作任务"], ["task:complete", "更新本人负责的协作任务状态"], @@ -257,10 +258,10 @@ function seedRoles() { org_admin: [ "organization:manage", "organization:members:invite", "workspace:create", "workspace:manage", "workspace:members:manage", "project:create", "project:manage", "project:members:manage", - "task:view", "task:manage", "task:complete", "model:manage", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve", + "workflow:manage", "task:view", "task:manage", "task:complete", "model:manage", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve", "system:settings:view", "service:health:view", "organization:roles:manage" ], - producer: ["project:create", "project:manage", "project:members:manage", "task:view", "task:manage", "task:complete", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"], + producer: ["project:create", "project:manage", "project:members:manage", "workflow:manage", "task:view", "task:manage", "task:complete", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"], writer: ["script:read", "script:edit", "task:view", "task:complete", "job:create"], art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "job:create"], voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "job:create"], @@ -384,8 +385,11 @@ function seedSystemGovernance() { ["app.timezone", "general", "Asia/Shanghai", "string", "默认时区", 0], ["deployment.mode", "deployment", "private-local", "string", "部署模式", 0], ["deployment.local_runner_only", "deployment", true, "boolean", "默认只允许本地 Runner", 0], + ["storage.provider", "storage", "local-filesystem", "string", "媒体文件存储提供方;生产可切换为 S3-compatible 适配层", 0], ["storage.root_path", "storage", resolve(projectRoot, "storage"), "path", "资产和生成产物根目录", 0], ["storage.max_upload_mb", "storage", 1024, "number", "单文件最大上传大小", 0], + ["storage.retention_days", "storage", 30, "number", "无引用临时媒体的最短保留天数", 0], + ["storage.cleanup_unreferenced_only", "storage", true, "boolean", "清理时只允许处理未被生产证据引用的临时文件", 0], ["queue.max_concurrency", "queue", 2, "number", "全局并发任务数", 0], ["generation.single_frame_only", "generation", true, "boolean", "一图一画面策略", 0], ["generation.require_actual_last_frame", "generation", true, "boolean", "视频片段必须使用真实末帧连续", 0], @@ -433,6 +437,36 @@ function seedSystemGovernance() { } } +function workflowStepDefinition(label, index) { + const normalized = String(label || ""); + let jobKind = ""; + let requiresShot = false; + if (/静态图|分镜图|关键帧|插画/.test(normalized)) { jobKind = "单画面关键帧"; requiresShot = true; } + else if (/图生视频|视频生成|成片/.test(normalized)) { jobKind = "首尾帧图生视频"; requiresShot = true; } + else if (/旁白|配音|TTS/.test(normalized)) { jobKind = "固定 TTS 配音"; requiresShot = true; } + else if (/ASR|字幕|对齐/.test(normalized)) { jobKind = "ASR 台词校验"; requiresShot = true; } + else if (/合成/.test(normalized)) jobKind = "剪辑合成清单"; + return { key: `step-${index + 1}`, label: normalized, jobKind, requiresShot, dependsOnPrevious: index > 0 }; +} + +function seedWorkflowTemplates() { + const timestamp = now(); + for (const template of workflowTemplates) { + const status = template.status === "主流程" || template.status === "可用" ? "active" : "draft"; + const steps = template.steps.map((label, index) => workflowStepDefinition(label, index)); + const gates = [ + { key: "single-frame", label: "一图一画面", blocking: true }, + { key: "continuity-lock", label: "角色 / 场景 / 道具连续性", blocking: true }, + { key: "voice-subtitle-asr", label: "声音 / 字幕 / ASR 对齐", blocking: true }, + { key: "clip-bridge", label: "片段衔接 / 实际末帧", blocking: true } + ]; + insertIgnore( + "INSERT OR IGNORE INTO workflow_templates(id, organization_id, workspace_id, template_key, version_number, name, category, status, description, steps_json, gates_json, default_adapter_id, created_by, created_at, updated_at) VALUES (?, NULL, NULL, ?, 1, ?, ?, ?, ?, ?, ?, 'owned-model-platform', 'u-owner', ?, ?)", + [`workflow-global-${template.id}-v1`, template.id, template.name, template.id, status, template.warning || "", JSON.stringify(steps), JSON.stringify(gates), timestamp, timestamp] + ); + } +} + function seedUserNotifications() { const timestamp = now(); insertIgnore( @@ -583,6 +617,7 @@ withTransaction(() => { seedMembersAndProjects(); seedModels(); seedSystemGovernance(); + seedWorkflowTemplates(); seedProductionGraph(); seedUserNotifications(); }); diff --git a/server/local-api.mjs b/server/local-api.mjs index f28d763..e02bab4 100644 --- a/server/local-api.mjs +++ b/server/local-api.mjs @@ -130,16 +130,17 @@ import { probeModelConnector, updateModelConnector } from "./execution.mjs"; -import { requireStorageQuota, storageSummary } from "./storage.mjs"; +import { reclaimStorage, requireStorageQuota, storageCleanupPreview, storageSummary } from "./storage.mjs"; import { backupSummary, createDatabaseBackup } from "./backup.mjs"; import { systemReadiness } from "./readiness.mjs"; import { dispatchNotificationEvent, listUserNotificationPreferences, listUserNotifications, markAllUserNotificationsRead, markUserNotificationRead, notificationDeliveries, updateUserNotificationPreference } from "./notifications.mjs"; import { composeProject, listCompositions } from "./composition.mjs"; -import { listProjectArtifacts } from "./media-artifacts.mjs"; +import { listProjectArtifacts, readArtifactContent } from "./media-artifacts.mjs"; import { runWorkerOnce, startLocalWorker, workerStatus } from "./worker.mjs"; import { createSsoTicket, handleOidcCallback, redeemSsoTicket, startOidcLogin } from "./oidc.mjs"; import { handleSamlCallback, isSamlProvider, samlServiceProviderMetadata, startSamlLogin } from "./saml.mjs"; import { listWorkItems } from "./work-items.mjs"; +import { createWorkflowTemplate, instantiateWorkflow, listWorkflowRuns, listWorkflowTemplates, updateWorkflowTemplate } from "./workflows.mjs"; import { addTaskLink, createProjectTask, createTaskComment, getProjectTask, listProjectActivity, listProjectTasks, listTaskComments, removeTaskLink, updateProjectTask } from "./tasks.mjs"; import { searchPlatform } from "./search.mjs"; import { consumeRateLimit, rateLimitHeaders, rateLimitIdentity } from "./rate-limit.mjs"; @@ -1909,6 +1910,20 @@ createServer(async (req, res) => { return send(res, 200, { storage: await storageSummary(context) }); } + if (req.method === "GET" && pathname === "/api/system/storage/cleanup-preview") { + const context = resolveContext(req.headers, url.searchParams); + requirePermission(context, "system:settings:view"); + return send(res, 200, { cleanup: await storageCleanupPreview(context, { olderThanDays: url.searchParams.get("olderThanDays") || "" }) }); + } + + if (req.method === "POST" && pathname === "/api/system/storage/reclaim") { + const context = resolveContext(req.headers, url.searchParams); + requirePermission(context, "system:settings:edit"); + const result = await reclaimStorage(context, await readBody(req)); + addAudit({ context, action: "system.storage.reclaimed", targetType: "storage_cleanup", targetId: `cleanup-${Date.now()}`, metadata: { deletedCount: result.deleted.length, deletedBytes: result.deletedBytes, retentionDays: result.policy.retentionDays } }); + return send(res, 200, { cleanup: result, storage: await storageSummary(context) }); + } + if (req.method === "GET" && pathname === "/api/billing") { const context = resolveContext(req.headers, url.searchParams); requirePermission(context, "usage:view"); @@ -2482,6 +2497,53 @@ createServer(async (req, res) => { return send(res, 200, { artifacts: listProjectArtifacts(context, { shotId: url.searchParams.get("shotId") || "", jobId: url.searchParams.get("jobId") || "", limit: url.searchParams.get("limit") || 200 }) }); } + const mediaArtifactContentMatch = pathname.match(/^\/api\/production\/media-artifacts\/([^/]+)\/content$/); + if (req.method === "GET" && mediaArtifactContentMatch) { + const context = resolveContext(req.headers, url.searchParams); + if (!hasPermission(context, "delivery:view") && !hasPermission(context, "qa:review")) throw httpError(403, "permission_denied", "当前角色没有媒体证据访问权限"); + try { + const payload = await readArtifactContent(context, decodeURIComponent(mediaArtifactContentMatch[1]), url.searchParams.get("frame") || ""); + return sendBinary(res, 200, payload.content, payload.contentType, payload.fileName, { etag: `"${payload.contentSha256}"` }); + } catch (error) { + if (error.message === "media_artifact_not_found") throw httpError(404, "media_artifact_not_found", "媒体证据不存在或不属于当前项目"); + if (error.message === "media_artifact_path_invalid") throw httpError(422, "media_artifact_path_invalid", "媒体证据路径不是 storage/ 下的安全路径"); + if (error.code === "ENOENT") throw httpError(404, "media_artifact_content_missing", "媒体文件尚未写入本地存储", { path: error.path || "" }); + throw error; + } + } + + if (req.method === "GET" && pathname === "/api/workflows/templates") { + const context = resolveContext(req.headers, url.searchParams); + if (!hasPermission(context, "workflow:manage") && !hasPermission(context, "job:create") && !hasPermission(context, "script:read") && !hasPermission(context, "project:create")) throw httpError(403, "permission_denied", "当前角色没有流程模板访问权限"); + return send(res, 200, { templates: listWorkflowTemplates(context, { includeArchived: url.searchParams.get("includeArchived") === "1" }), runs: context.project ? listWorkflowRuns(context, { limit: 20 }) : [] }); + } + + if (req.method === "POST" && pathname === "/api/workflows/templates") { + const context = resolveContext(req.headers, url.searchParams); + requirePermission(context, "workflow:manage"); + return send(res, 201, { template: createWorkflowTemplate(context, await readBody(req)), templates: listWorkflowTemplates(context) }); + } + + const workflowTemplateMatch = pathname.match(/^\/api\/workflows\/templates\/([^/]+)$/); + if (req.method === "PATCH" && workflowTemplateMatch) { + const context = resolveContext(req.headers, url.searchParams); + requirePermission(context, "workflow:manage"); + return send(res, 200, { template: updateWorkflowTemplate(context, decodeURIComponent(workflowTemplateMatch[1]), await readBody(req)), templates: listWorkflowTemplates(context) }); + } + + const workflowInstantiateMatch = pathname.match(/^\/api\/workflows\/templates\/([^/]+)\/instantiate$/); + if (req.method === "POST" && workflowInstantiateMatch) { + const context = resolveContext(req.headers, url.searchParams); + requirePermission(context, "job:create"); + return send(res, 201, await instantiateWorkflow(context, decodeURIComponent(workflowInstantiateMatch[1]), await readBody(req))); + } + + if (req.method === "GET" && pathname === "/api/workflows/runs") { + const context = resolveContext(req.headers, url.searchParams); + if (!hasPermission(context, "job:create") && !hasPermission(context, "workflow:manage")) throw httpError(403, "permission_denied", "当前角色没有流程运行记录访问权限"); + return send(res, 200, { runs: listWorkflowRuns(context, { status: url.searchParams.get("status") || "", limit: url.searchParams.get("limit") || 50 }) }); + } + if (req.method === "POST" && pathname === "/api/production/compose") { const context = resolveContext(req.headers, url.searchParams); return send(res, 201, await composeProject(context, await readBody(req))); diff --git a/server/media-artifacts.mjs b/server/media-artifacts.mjs index 828017e..cba6371 100644 --- a/server/media-artifacts.mjs +++ b/server/media-artifacts.mjs @@ -1,7 +1,7 @@ -import { access, mkdir, writeFile } from "node:fs/promises"; +import { access, mkdir, readFile, writeFile } from "node:fs/promises"; import { createHash } from "node:crypto"; import { execFile } from "node:child_process"; -import { resolve, extname } from "node:path"; +import { resolve, extname, basename } from "node:path"; import { promisify } from "node:util"; import { dbAll, dbGet, dbRun } from "./db.mjs"; import { inspectStoragePath } from "./media-qa.mjs"; @@ -59,6 +59,24 @@ function safeOutputPath(value) { return path; } +function mimeForPath(pathname) { + const extension = extname(String(pathname || "")).toLowerCase(); + return { + ".png": "image/png", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".webp": "image/webp", + ".gif": "image/gif", + ".wav": "audio/wav", + ".mp3": "audio/mpeg", + ".m4a": "audio/mp4", + ".mp4": "video/mp4", + ".webm": "video/webm", + ".json": "application/json", + ".txt": "text/plain; charset=utf-8" + }[extension] || "application/octet-stream"; +} + async function materializeBase64(job, value, index, kind, mimeType = "") { const encoded = typeof value === "string" ? value : value?.b64_json || value?.base64 || ""; if (!encoded) return ""; @@ -289,3 +307,37 @@ export function latestArtifactForShot(context, shotId, kind = "video") { const row = dbGet("SELECT * FROM media_artifacts WHERE organization_id = ? AND workspace_id = ? AND project_id = ? AND shot_id = ? AND kind = ? ORDER BY CASE WHEN status = 'inspected' THEN 0 ELSE 1 END, created_at DESC LIMIT 1", [context.organization.id, context.workspace.id, context.project.id, shotId, kind]); return artifactPayload(row); } + +export async function readArtifactContent(context, artifactId, frame = "") { + const artifact = dbGet( + `SELECT * FROM media_artifacts + WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?`, + [artifactId, context.organization.id, context.workspace.id, context.project.id] + ); + if (!artifact) throw new Error("media_artifact_not_found"); + const requestedFrame = frame === "first" ? artifact.first_frame_path : frame === "last" ? artifact.last_frame_path : ""; + const relativePath = safeOutputPath(requestedFrame || artifact.path); + if (!relativePath) throw new Error("media_artifact_path_invalid"); + let content; + try { + content = await readFile(resolve(projectRoot, relativePath)); + } catch (error) { + if (error.code === "ENOENT") { + const missing = new Error("media_artifact_content_missing"); + missing.code = "ENOENT"; + missing.path = relativePath; + throw missing; + } + throw error; + } + const contentSha256 = createHash("sha256").update(content).digest("hex"); + const sourceName = basename(relativePath) || basename(artifact.path) || artifact.id; + return { + content, + contentType: frame ? "image/jpeg" : artifact.mime_type || mimeForPath(relativePath), + fileName: sourceName, + contentSha256, + artifact: artifactPayload(artifact), + frame: frame || "source" + }; +} diff --git a/server/schema.sql b/server/schema.sql index 698efb5..e8cf605 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -990,6 +990,46 @@ CREATE TABLE IF NOT EXISTS system_settings ( updated_at TEXT NOT NULL ); +-- Workflow templates are versioned production contracts. Global templates can +-- be overridden at organization or workspace scope without changing project +-- content already in production. +CREATE TABLE IF NOT EXISTS workflow_templates ( + id TEXT PRIMARY KEY, + organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE, + template_key TEXT NOT NULL, + version_number INTEGER NOT NULL DEFAULT 1, + name TEXT NOT NULL, + category TEXT NOT NULL DEFAULT 'ai-manhua-drama', + status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')), + description TEXT NOT NULL DEFAULT '', + steps_json TEXT NOT NULL DEFAULT '[]', + gates_json TEXT NOT NULL DEFAULT '[]', + default_adapter_id TEXT NOT NULL DEFAULT '', + created_by TEXT REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + UNIQUE (organization_id, workspace_id, template_key, version_number) +); + +CREATE TABLE IF NOT EXISTS workflow_runs ( + id TEXT PRIMARY KEY, + organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + episode_id TEXT REFERENCES episodes(id) ON DELETE SET NULL, + shot_id TEXT REFERENCES shots(id) ON DELETE SET NULL, + template_id TEXT NOT NULL REFERENCES workflow_templates(id), + status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('planned', 'queued', 'running', 'blocked', 'completed', 'failed', 'cancelled')), + current_step_index INTEGER NOT NULL DEFAULT 0, + steps_json TEXT NOT NULL DEFAULT '[]', + job_ids_json TEXT NOT NULL DEFAULT '[]', + error_message TEXT NOT NULL DEFAULT '', + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL +); + CREATE TABLE IF NOT EXISTS feature_flags ( key TEXT PRIMARY KEY, label TEXT NOT NULL, @@ -1161,6 +1201,8 @@ CREATE INDEX IF NOT EXISTS idx_audit_scope ON audit_logs(organization_id, worksp CREATE INDEX IF NOT EXISTS idx_invites_scope ON invitations(organization_id, status, created_at); CREATE INDEX IF NOT EXISTS idx_job_dependencies_dependency ON job_dependencies(depends_on_job_id, job_id); CREATE INDEX IF NOT EXISTS idx_system_settings_category ON system_settings(category, updated_at); +CREATE INDEX IF NOT EXISTS idx_workflow_templates_scope ON workflow_templates(organization_id, workspace_id, status, template_key, version_number DESC); +CREATE INDEX IF NOT EXISTS idx_workflow_runs_scope ON workflow_runs(organization_id, workspace_id, project_id, status, created_at DESC); CREATE INDEX IF NOT EXISTS idx_feature_flags_scope ON feature_flags(scope, enabled); CREATE INDEX IF NOT EXISTS idx_notification_channels_enabled ON notification_channels(enabled, kind); CREATE INDEX IF NOT EXISTS idx_notification_deliveries_scope ON notification_deliveries(organization_id, created_at); diff --git a/server/storage.mjs b/server/storage.mjs index 12b92da..a727a1c 100644 --- a/server/storage.mjs +++ b/server/storage.mjs @@ -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`; diff --git a/server/workflows.mjs b/server/workflows.mjs new file mode 100644 index 0000000..21b2e36 --- /dev/null +++ b/server/workflows.mjs @@ -0,0 +1,167 @@ +import { createGenerationJob } from "./execution.mjs"; +import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs"; +import { addAudit, httpError } from "./tenant.mjs"; + +const now = () => new Date().toISOString(); +const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + +function parseJson(value, fallback) { + try { return JSON.parse(value); } catch { return fallback; } +} + +function normalizeStep(step, index) { + if (typeof step === "string") return { key: `step-${index + 1}`, label: step, jobKind: "", requiresShot: false, dependsOnPrevious: index > 0 }; + return { + key: String(step?.key || `step-${index + 1}`), + label: String(step?.label || step?.name || `步骤 ${index + 1}`), + jobKind: String(step?.jobKind || step?.job_kind || ""), + requiresShot: Boolean(step?.requiresShot ?? step?.requires_shot), + dependsOnPrevious: Boolean(step?.dependsOnPrevious ?? step?.depends_on_previous ?? index > 0), + optional: Boolean(step?.optional) + }; +} + +function templatePayload(row) { + if (!row) return null; + return { + ...row, + organizationId: row.organization_id, + workspaceId: row.workspace_id, + templateKey: row.template_key, + version: Number(row.version_number || 1), + steps: parseJson(row.steps_json, []).map(normalizeStep), + gates: parseJson(row.gates_json, []), + defaultAdapterId: row.default_adapter_id, + scope: row.workspace_id ? "workspace" : row.organization_id ? "organization" : "global" + }; +} + +function scopedTemplateRows(context, includeArchived = false) { + const statusClause = includeArchived ? "1=1" : "status <> 'archived'"; + return dbAll( + `SELECT * FROM workflow_templates + WHERE ${statusClause} AND + ((organization_id IS NULL AND workspace_id IS NULL) + OR (organization_id = ? AND workspace_id IS NULL) + OR (organization_id = ? AND workspace_id = ?)) + ORDER BY CASE WHEN workspace_id = ? THEN 0 WHEN organization_id = ? THEN 1 ELSE 2 END, version_number DESC, updated_at DESC`, + [context.organization.id, context.organization.id, context.workspace.id, context.workspace.id, context.organization.id] + ); +} + +export function listWorkflowTemplates(context, options = {}) { + const seen = new Set(); + return scopedTemplateRows(context, Boolean(options.includeArchived)).map(templatePayload).filter((template) => { + if (seen.has(template.templateKey)) return false; + seen.add(template.templateKey); + return true; + }); +} + +function templateForContext(context, templateId) { + const row = dbGet( + `SELECT * FROM workflow_templates + WHERE id = ? AND ((organization_id IS NULL AND workspace_id IS NULL) OR (organization_id = ? AND workspace_id IS NULL) OR (organization_id = ? AND workspace_id = ?))`, + [templateId, context.organization.id, context.organization.id, context.workspace.id] + ); + if (!row) throw httpError(404, "workflow_template_not_found", "流程模板不存在或不属于当前工作区"); + return row; +} + +function validateTemplateBody(body = {}) { + const name = String(body.name || "").trim(); + const templateKey = String(body.templateKey || body.template_key || "").trim().toLowerCase(); + if (!name || !templateKey) throw httpError(400, "workflow_template_fields_required", "流程模板名称和唯一键不能为空"); + if (!/^[a-z0-9][a-z0-9._-]{1,80}$/.test(templateKey)) throw httpError(400, "workflow_template_key_invalid", "流程模板唯一键只能使用小写字母、数字、点、短横线和下划线"); + const steps = (Array.isArray(body.steps) ? body.steps : []).slice(0, 32).map(normalizeStep); + if (!steps.length) throw httpError(400, "workflow_template_steps_required", "流程模板至少需要一个步骤"); + const status = String(body.status || "draft"); + if (!["draft", "active", "archived"].includes(status)) throw httpError(400, "workflow_template_status_invalid", "流程模板状态无效"); + return { name, templateKey, category: String(body.category || templateKey), status, description: String(body.description || "").trim(), steps, gates: Array.isArray(body.gates) ? body.gates.slice(0, 32) : [], defaultAdapterId: String(body.defaultAdapterId || body.default_adapter_id || "owned-model-platform"), requestedVersion: Number(body.version || 0) }; +} + +export function createWorkflowTemplate(context, body = {}) { + const values = validateTemplateBody(body); + const version = values.requestedVersion > 0 ? Math.floor(values.requestedVersion) : Number(dbGet("SELECT MAX(version_number) AS value FROM workflow_templates WHERE organization_id = ? AND workspace_id = ? AND template_key = ?", [context.organization.id, context.workspace.id, values.templateKey])?.value || 0) + 1; + const id = makeId("workflow-template"); + const timestamp = now(); + dbRun( + `INSERT INTO workflow_templates(id, organization_id, workspace_id, template_key, version_number, name, category, status, description, steps_json, gates_json, default_adapter_id, created_by, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [id, context.organization.id, context.workspace.id, values.templateKey, version, values.name, values.category, values.status, values.description, JSON.stringify(values.steps), JSON.stringify(values.gates), values.defaultAdapterId, context.user.id, timestamp, timestamp] + ); + addAudit({ context, action: "workflow.template.created", targetType: "workflow_template", targetId: id, metadata: { templateKey: values.templateKey, version, status: values.status } }); + return templatePayload(dbGet("SELECT * FROM workflow_templates WHERE id = ?", [id])); +} + +export function updateWorkflowTemplate(context, templateId, body = {}) { + const current = templateForContext(context, templateId); + if (!current.organization_id || current.workspace_id !== context.workspace.id) throw httpError(403, "workflow_template_global_locked", "内置全局模板不能直接修改,请创建当前工作区的新版本"); + const values = validateTemplateBody({ ...templatePayload(current), ...body, steps: body.steps || templatePayload(current).steps }); + const timestamp = now(); + dbRun("UPDATE workflow_templates SET name = ?, category = ?, status = ?, description = ?, steps_json = ?, gates_json = ?, default_adapter_id = ?, updated_at = ? WHERE id = ?", [values.name, values.category, values.status, values.description, JSON.stringify(values.steps), JSON.stringify(values.gates), values.defaultAdapterId, timestamp, templateId]); + addAudit({ context, action: "workflow.template.updated", targetType: "workflow_template", targetId: templateId, metadata: { status: values.status, version: current.version_number } }); + return templatePayload(dbGet("SELECT * FROM workflow_templates WHERE id = ?", [templateId])); +} + +function runPayload(row) { + if (!row) return null; + return { + ...row, + templateId: row.template_id, + episodeId: row.episode_id, + shotId: row.shot_id, + currentStepIndex: Number(row.current_step_index || 0), + steps: parseJson(row.steps_json, []), + jobIds: parseJson(row.job_ids_json, []) + }; +} + +export function listWorkflowRuns(context, options = {}) { + const params = [context.organization.id, context.workspace.id, context.project?.id || ""]; + let where = "organization_id = ? AND workspace_id = ? AND project_id = ?"; + if (options.status) { where += " AND status = ?"; params.push(String(options.status)); } + return dbAll(`SELECT * FROM workflow_runs WHERE ${where} ORDER BY created_at DESC LIMIT ?`, [...params, Math.max(1, Math.min(100, Number(options.limit || 50)))]).map(runPayload); +} + +export async function instantiateWorkflow(context, templateId, body = {}) { + if (!context.project) throw httpError(400, "project_required", "执行流程模板必须绑定项目"); + const template = templatePayload(templateForContext(context, templateId)); + if (template.status !== "active") throw httpError(409, "workflow_template_not_active", "只有启用状态的流程模板可以进入生产"); + const shotId = String(body.shotId || body.shot_id || "").trim(); + const mode = body.mode === "queue" ? "queue" : "plan"; + const steps = template.steps.map((step) => ({ ...step, status: step.jobKind ? "planned" : "skipped", jobId: "", errorMessage: "" })); + if (steps.some((step) => step.requiresShot && !shotId)) throw httpError(400, "workflow_shot_required", "当前流程包含镜头级步骤,请指定一个镜头后再执行"); + const runId = makeId("workflow-run"); + const timestamp = now(); + const jobIds = []; + let previousJobId = ""; + if (mode === "queue") { + for (const step of steps) { + if (!step.jobKind) continue; + try { + const result = await createGenerationJob(context, { + kind: step.jobKind, + adapter: body.adapter || template.defaultAdapterId, + shotId: shotId || undefined, + dependsOnJobIds: step.dependsOnPrevious && previousJobId ? [previousJobId] : [], + approveExternal: Boolean(body.approveExternal), + priority: body.priority || 50 + }); + step.status = result.job.status === "blocked" ? "blocked" : "queued"; + step.jobId = result.job.id; + previousJobId = result.job.id; + jobIds.push(result.job.id); + } catch (error) { + step.status = "blocked"; + step.errorMessage = error.message; + } + } + } + const status = mode === "plan" ? "planned" : steps.some((step) => step.status === "blocked") ? "blocked" : "queued"; + withTransaction(() => { + dbRun("INSERT INTO workflow_runs(id, organization_id, workspace_id, project_id, episode_id, shot_id, template_id, status, current_step_index, steps_json, job_ids_json, error_message, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)", [runId, context.organization.id, context.workspace.id, context.project.id, body.episodeId || body.episode_id || null, shotId || null, template.id, status, JSON.stringify(steps), JSON.stringify(jobIds), steps.find((step) => step.errorMessage)?.errorMessage || "", context.user.id, timestamp, timestamp]); + }); + addAudit({ context, action: "workflow.run.created", targetType: "workflow_run", targetId: runId, result: status === "blocked" ? "blocked" : "ok", metadata: { templateId: template.id, mode, shotId, jobIds, status } }); + return { run: runPayload(dbGet("SELECT * FROM workflow_runs WHERE id = ?", [runId])), template, jobs: jobIds }; +} diff --git a/src/components/EnterprisePages.jsx b/src/components/EnterprisePages.jsx index f1ab453..5928419 100644 --- a/src/components/EnterprisePages.jsx +++ b/src/components/EnterprisePages.jsx @@ -44,7 +44,8 @@ import { Webhook, Workflow, XCircle, - Zap + Zap, + Trash2 } from "lucide-react"; import { createApiClient, @@ -90,6 +91,8 @@ import { testNotification, fetchNotificationDeliveries, fetchStorageUsage, + fetchStorageCleanupPreview, + reclaimStorage, fetchSsoProviders, fetchIdentityCenter, fetchOrganizationCommercial, @@ -2460,6 +2463,7 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) { const [health, setHealth] = useState({ services: [], summary: {} }); const [readiness, setReadiness] = useState({ checks: [], summary: {}, backups: { backups: [], count: 0 } }); const [storage, setStorage] = useState(null); + const [cleanup, setCleanup] = useState(null); const [deliveries, setDeliveries] = useState([]); const [originalSettings, setOriginalSettings] = useState({}); const [loading, setLoading] = useState(true); @@ -2468,17 +2472,19 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) { const [clientName, setClientName] = useState(""); const [apiKeyNotice, setApiKeyNotice] = useState(""); const [backupBusy, setBackupBusy] = useState(false); + const [cleanupBusy, setCleanupBusy] = useState(false); async function load() { setLoading(true); try { - const [nextConfig, nextHealth, nextReadiness, nextStorage, nextDeliveries] = await Promise.all([fetchSystemConfig(contextOverrides), fetchSystemHealth(contextOverrides), fetchSystemReadiness(contextOverrides), fetchStorageUsage(contextOverrides), fetchNotificationDeliveries(contextOverrides)]); + const [nextConfig, nextHealth, nextReadiness, nextStorage, nextDeliveries, nextCleanup] = await Promise.all([fetchSystemConfig(contextOverrides), fetchSystemHealth(contextOverrides), fetchSystemReadiness(contextOverrides), fetchStorageUsage(contextOverrides), fetchNotificationDeliveries(contextOverrides), fetchStorageCleanupPreview("", contextOverrides)]); setConfig(nextConfig); const values = Object.fromEntries(nextConfig.settings.map((setting) => [setting.key, setting.value])); setOriginalSettings(values); setHealth(nextHealth); setReadiness(nextReadiness); setStorage(nextStorage.storage || null); + setCleanup(nextCleanup.cleanup || null); setDeliveries(nextDeliveries.deliveries || []); setNotice(""); } catch (error) { @@ -2572,6 +2578,36 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) { } } + async function previewCleanup() { + setCleanupBusy(true); + try { + const retentionDays = config.settings.find((setting) => setting.key === "storage.retention_days")?.value || ""; + const result = await fetchStorageCleanupPreview(retentionDays, contextOverrides); + setCleanup(result.cleanup || null); + setNotice(`清理预览完成:${result.cleanup?.candidates?.length || 0} 个无引用临时文件,${((result.cleanup?.totalBytes || 0) / 1024 ** 2).toFixed(2)} MB。`); + } catch (error) { + setNotice(error.message); + } finally { + setCleanupBusy(false); + } + } + + async function reclaimCleanup() { + if (!cleanup?.candidates?.length) return; + if (typeof window !== "undefined" && !window.confirm(`确认回收 ${cleanup.candidates.length} 个无引用临时文件吗?角色资产、交付物和 QA 证据不会被处理。`)) return; + setCleanupBusy(true); + try { + const result = await reclaimStorage({ paths: cleanup.candidates.map((item) => item.relativePath) }, contextOverrides); + setCleanup(result.cleanup || null); + setStorage(result.storage || storage); + setNotice(`已回收 ${result.cleanup?.deleted?.length || 0} 个临时文件,释放 ${((result.cleanup?.deletedBytes || 0) / 1024 ** 2).toFixed(2)} MB,并写入审计日志。`); + } catch (error) { + setNotice(error.message); + } finally { + setCleanupBusy(false); + } + } + return (
{JSON.stringify((selectedDetail || selected).request || {}, null, 2)}{JSON.stringify((selectedDetail || selected).result || {}, null, 2)}{JSON.stringify(selected.evidence || {}, null, 2)}{item.body}
{item.created_at?.replace("T", " ").slice(0, 19)}{JSON.stringify(selected.evidence || {}, null, 2)}{item.body}
{item.created_at?.replace("T", " ").slice(0, 19)}