feat: expand commercial ai drama platform

This commit is contained in:
xz
2026-08-31 21:09:48 +08:00
parent f06e583c9b
commit 8caf947b80
35 changed files with 10181 additions and 188 deletions
+656 -62
View File
@@ -1,8 +1,9 @@
import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
import { addAudit, addUsage, hasPermission, httpError, requirePermission, requireProjectWritable } from "./tenant.mjs";
import { addAudit, addUsage, hasPermission, httpError, requireEntitlement, requirePermission, requireProjectWritable } from "./tenant.mjs";
import { inspectMediaForProject } from "./media-qa.mjs";
import { latestArtifactForShot, listProjectArtifacts, syncProjectJobArtifacts } from "./media-artifacts.mjs";
import { dispatchNotificationEvent } from "./notifications.mjs";
import { createHash } from "node:crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { resolve } from "node:path";
@@ -301,7 +302,8 @@ function scriptDocuments(context, episodeId = null) {
const params = episodeId ? [project.id, episodeId] : [project.id];
return dbAll(`SELECT * FROM script_documents WHERE project_id = ?${clause} ORDER BY version_number DESC`, params).map((row) => ({
...row,
analysis: parseJson(row.analysis_json, {})
analysis: parseJson(row.analysis_json, {}),
metadata: parseJson(row.metadata_json, {})
}));
}
@@ -409,6 +411,596 @@ function safePathSegment(value, fallback = "item") {
return normalized || fallback;
}
function jsonHash(value) {
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
}
function objectValue(value, fallback = {}) {
return value && typeof value === "object" && !Array.isArray(value) ? value : fallback;
}
function uniqueStrings(values) {
return [...new Set((values || []).map((item) => String(item || "").trim()).filter(Boolean))];
}
function listFromJson(value) {
const parsed = parseJson(value, []);
return Array.isArray(parsed) ? parsed : [];
}
function clearanceEvidenceRef(metadata = {}, provenance = {}) {
const rightsEvidence = objectValue(metadata.rightsEvidence);
return String(
provenance.evidenceRef
|| provenance.sourceRef
|| rightsEvidence.reference
|| rightsEvidence.consentRef
|| rightsEvidence.contractRef
|| rightsEvidence.licenseRef
|| rightsEvidence.evidenceRef
|| metadata.consentRef
|| metadata.licenseRef
|| ""
).trim();
}
function isDateInPast(value) {
const timestamp = Date.parse(value || "");
return Number.isFinite(timestamp) && timestamp <= Date.now();
}
function isDateSoon(value, days = 30) {
const timestamp = Date.parse(value || "");
if (!Number.isFinite(timestamp)) return false;
return timestamp > Date.now() && timestamp <= Date.now() + days * 24 * 60 * 60 * 1000;
}
function clearanceStatus(blockers, reviewItems) {
if (blockers.length) return "blocked";
if (reviewItems.length) return "review";
return "pass";
}
function dedupeBlockers(blockers) {
const seen = new Set();
return blockers.filter((blocker) => {
const key = [blocker.type, blocker.id, blocker.assetId, blocker.documentId, blocker.packId, blocker.voiceId, blocker.shotId, blocker.status, blocker.reason].filter(Boolean).join("|");
if (seen.has(key)) return false;
seen.add(key);
return true;
});
}
function deliveryBatchItems(batchId) {
if (!batchId) return [];
return dbAll(
`SELECT dbi.*, s.title AS shot_title, s.shot_number
FROM delivery_batch_items dbi
LEFT JOIN shots s ON s.id = dbi.shot_id
WHERE dbi.batch_id = ?
ORDER BY dbi.sequence_number`,
[batchId]
).map((item) => ({ ...item, metadata: parseJson(item.metadata_json, {}) }));
}
function activeBatchForDelivery(context, delivery) {
const project = requireProject(context);
if (!delivery.active_batch_id) return null;
return dbGet(
`SELECT * FROM delivery_batches
WHERE id = ? AND delivery_id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?`,
[delivery.active_batch_id, delivery.id, context.organization.id, context.workspace.id, project.id]
);
}
function mediaClearanceSection(activeBatch, items) {
const blockers = [];
const reviewItems = [];
if (!activeBatch) {
blockers.push({ type: "delivery_batch", status: "missing", reason: "交付版本没有当前生效批次" });
return { items: [], blockers, reviewItems };
}
if (activeBatch.status !== "active") blockers.push({ type: "delivery_batch", status: activeBatch.status, reason: "当前交付批次不是 active" });
const batchResult = parseJson(activeBatch.result_json, {});
if (Array.isArray(batchResult.blockers) && batchResult.blockers.length) blockers.push({ type: "delivery_batch", status: "blocked", reason: "批次创建时已登记媒体阻断项", items: batchResult.blockers });
const manifest = safeStoragePath(activeBatch.manifest_path);
if (!manifest || !existsSync(manifest.absolute)) blockers.push({ type: "manifest", status: "missing", reason: "当前批次 Manifest 文件不存在" });
if (!items.length) blockers.push({ type: "delivery_batch_items", status: "missing", reason: "当前批次没有镜头条目" });
const mediaItems = items.map((item) => {
const source = safeStoragePath(item.source_path);
const actualLastFrame = safeStoragePath(item.actual_last_frame_path);
const itemBlockers = [];
if (!source || !existsSync(source.absolute)) itemBlockers.push("视频源文件不存在");
if (!item.source_sha256) itemBlockers.push("视频源缺少 SHA-256");
if (!actualLastFrame || !existsSync(actualLastFrame.absolute)) itemBlockers.push("实际末帧证据不存在");
for (const reason of itemBlockers) blockers.push({ type: reason.includes("末帧") ? "actual-last-frame" : "media", shotId: item.shot_id, status: "blocked", reason });
return {
id: item.id,
shotId: item.shot_id,
shotTitle: item.shot_title || "",
sequenceNumber: Number(item.sequence_number || 0),
sourcePath: item.source_path || "",
sourceSha256: item.source_sha256 || "",
actualLastFramePath: item.actual_last_frame_path || "",
artifactStatus: item.metadata.artifactStatus || "",
status: itemBlockers.length ? "blocked" : "pass",
blockers: itemBlockers
};
});
return { items: mediaItems, blockers, reviewItems };
}
function boundAssetsForShotIds(shotIds) {
const ids = uniqueStrings(shotIds);
if (!ids.length) return [];
const placeholders = ids.map(() => "?").join(",");
return dbAll(
`SELECT a.id AS asset_id, a.kind, a.name, a.lock_status, a.current_version_id,
av.id AS version_id, av.version_number, av.storage_path, av.file_name, av.mime_type,
av.file_size, av.content_sha256, av.rights_status, av.provenance_json, av.risk_json,
av.tags_json, av.license_scope, av.expires_at, av.metadata_json,
GROUP_CONCAT(DISTINCT ab.shot_id) AS shot_ids,
GROUP_CONCAT(DISTINCT ab.usage_role) AS usage_roles
FROM asset_bindings ab
JOIN assets a ON a.id = ab.asset_id
LEFT JOIN asset_versions av ON av.id = a.current_version_id
WHERE ab.shot_id IN (${placeholders})
GROUP BY a.id
ORDER BY CASE a.kind WHEN 'character' THEN 1 WHEN 'location' THEN 2 WHEN 'prop' THEN 3 WHEN 'voice' THEN 4 ELSE 5 END, a.name`,
ids
);
}
function voiceAssetsForLines(context, voiceLines) {
const voiceIds = uniqueStrings(voiceLines.map((line) => line.voice_id));
if (!voiceIds.length) return [];
const project = requireProject(context);
const placeholders = voiceIds.map(() => "?").join(",");
return dbAll(
`SELECT a.id AS asset_id, a.kind, a.name, a.lock_status, a.current_version_id,
av.id AS version_id, av.version_number, av.storage_path, av.file_name, av.mime_type,
av.file_size, av.content_sha256, av.rights_status, av.provenance_json, av.risk_json,
av.tags_json, av.license_scope, av.expires_at, av.metadata_json
FROM assets a
LEFT JOIN asset_versions av ON av.id = a.current_version_id
WHERE a.project_id = ? AND a.kind = 'voice'
AND (${voiceIds.map(() => "av.metadata_json LIKE ?").join(" OR ")})`,
[project.id, ...voiceIds.map((voiceId) => `%"voiceId":"${voiceId}"%`)]
);
}
function assetClearanceSection(context, shotIds) {
const blockers = [];
const reviewItems = [];
const rows = boundAssetsForShotIds(shotIds);
const assets = rows.map((row) => {
const metadata = parseJson(row.metadata_json, {});
const provenance = parseJson(row.provenance_json, {});
const risk = parseJson(row.risk_json, {});
const shotRefs = uniqueStrings(String(row.shot_ids || "").split(","));
const usageRoles = uniqueStrings(String(row.usage_roles || "").split(","));
const evidenceRef = clearanceEvidenceRef(metadata, provenance);
const itemBlockers = [];
const itemReviews = [];
if (!row.version_id) itemBlockers.push("资产没有当前版本");
if (row.lock_status !== "locked") itemBlockers.push("资产未锁定 continuity lock");
if ((row.rights_status || "needs-evidence") !== "approved") itemBlockers.push(`资产授权状态为 ${row.rights_status || "needs-evidence"}`);
if ((risk.status || "unscanned") === "blocked") itemBlockers.push("资产风险扫描为 blocked");
if (row.expires_at && isDateInPast(row.expires_at)) itemBlockers.push("资产授权已过期");
if (row.rights_status === "approved" && !evidenceRef) itemBlockers.push("资产缺少授权证据引用");
if (!row.license_scope) itemReviews.push("资产缺少授权范围说明");
if (!row.content_sha256) itemReviews.push("资产源文件缺少 SHA-256,可在正式素材入库时补齐");
if (!risk.status || risk.status === "review" || risk.status === "unscanned") itemReviews.push("资产风险扫描需要复核");
if (row.expires_at && isDateSoon(row.expires_at)) itemReviews.push("资产授权 30 天内到期");
for (const reason of itemBlockers) blockers.push({ type: "asset", assetId: row.asset_id, shotIds: shotRefs, status: "blocked", reason });
for (const reason of itemReviews) reviewItems.push({ type: "asset", assetId: row.asset_id, shotIds: shotRefs, status: "review", reason });
return {
assetId: row.asset_id,
name: row.name,
kind: row.kind,
versionId: row.version_id || "",
versionNumber: Number(row.version_number || 0),
lockStatus: row.lock_status || "",
rightsStatus: row.rights_status || "needs-evidence",
riskStatus: risk.status || "unscanned",
licenseScope: row.license_scope || "",
expiresAt: row.expires_at || "",
evidenceRef,
contentSha256: row.content_sha256 || "",
storagePath: row.storage_path || "",
shotIds: shotRefs,
usageRoles,
status: itemBlockers.length ? "blocked" : itemReviews.length ? "review" : "pass",
blockers: itemBlockers,
reviewItems: itemReviews
};
});
return { assets, blockers, reviewItems };
}
function voiceClearanceSection(context, shotIds) {
const blockers = [];
const reviewItems = [];
const ids = uniqueStrings(shotIds);
if (!ids.length) return { voices: [], blockers, reviewItems };
const placeholders = ids.map(() => "?").join(",");
const voiceLines = dbAll(
`SELECT vl.*, s.title AS shot_title
FROM voice_lines vl
JOIN shots s ON s.id = vl.shot_id
WHERE vl.shot_id IN (${placeholders})
ORDER BY vl.shot_id, vl.line_number`,
ids
);
const assetRows = voiceAssetsForLines(context, voiceLines);
const assetsByVoiceId = new Map();
for (const asset of assetRows) {
const metadata = parseJson(asset.metadata_json, {});
if (metadata.voiceId) assetsByVoiceId.set(String(metadata.voiceId), asset);
}
const grouped = new Map();
for (const line of voiceLines) {
const key = line.voice_id || `missing-${line.id}`;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(line);
}
const voices = [...grouped.entries()].map(([voiceId, lines]) => {
const asset = assetsByVoiceId.get(voiceId) || null;
const metadata = parseJson(asset?.metadata_json, {});
const provenance = parseJson(asset?.provenance_json, {});
const risk = parseJson(asset?.risk_json, {});
const evidenceRef = clearanceEvidenceRef(metadata, provenance);
const itemBlockers = [];
const itemReviews = [];
if (!voiceId || voiceId.startsWith("missing-")) itemBlockers.push("对白缺少固定 voiceId");
if (lines.some((line) => !line.audio_path)) itemBlockers.push("对白缺少已登记音频文件");
if (!asset) itemBlockers.push("固定声线未登记为 voice 资产");
if (asset && asset.lock_status !== "locked") itemBlockers.push("voice 资产未锁定");
if (asset && (asset.rights_status || "needs-evidence") !== "approved") itemBlockers.push(`voice 资产授权状态为 ${asset.rights_status || "needs-evidence"}`);
if (asset && risk.status === "blocked") itemBlockers.push("voice 资产风险扫描为 blocked");
if (asset?.expires_at && isDateInPast(asset.expires_at)) itemBlockers.push("voice 授权已过期");
if (asset && !evidenceRef) itemBlockers.push("voice 资产缺少同意书/授权证据引用");
if (asset && !asset.license_scope) itemReviews.push("voice 资产缺少授权范围说明");
if (asset && !asset.content_sha256) itemReviews.push("voice 参考音频缺少 SHA-256,可在正式素材入库时补齐");
for (const reason of itemBlockers) blockers.push({ type: "voice", voiceId, assetId: asset?.asset_id || "", shotIds: uniqueStrings(lines.map((line) => line.shot_id)), status: "blocked", reason });
for (const reason of itemReviews) reviewItems.push({ type: "voice", voiceId, assetId: asset?.asset_id || "", shotIds: uniqueStrings(lines.map((line) => line.shot_id)), status: "review", reason });
return {
voiceId,
assetId: asset?.asset_id || "",
name: asset?.name || "",
rightsStatus: asset?.rights_status || "missing",
riskStatus: risk.status || "unscanned",
lockStatus: asset?.lock_status || "missing",
licenseScope: asset?.license_scope || "",
evidenceRef,
lineCount: lines.length,
shotIds: uniqueStrings(lines.map((line) => line.shot_id)),
status: itemBlockers.length ? "blocked" : itemReviews.length ? "review" : "pass",
blockers: itemBlockers,
reviewItems: itemReviews
};
});
return { voices, blockers, reviewItems };
}
function scriptSourceIdsFromMetadata(metadata) {
const ids = [];
const packIds = [];
const chunkIds = [];
if (metadata.knowledgeDocumentId) ids.push(metadata.knowledgeDocumentId);
if (Array.isArray(metadata.knowledgeDocumentIds)) ids.push(...metadata.knowledgeDocumentIds);
if (metadata.knowledgePackId) packIds.push(metadata.knowledgePackId);
if (Array.isArray(metadata.knowledgePackIds)) packIds.push(...metadata.knowledgePackIds);
if (Array.isArray(metadata.knowledgeChunkIds)) chunkIds.push(...metadata.knowledgeChunkIds);
if (Array.isArray(metadata.knowledgeCitations)) {
for (const citation of metadata.knowledgeCitations) {
if (citation?.documentId) ids.push(citation.documentId);
if (citation?.chunkId) chunkIds.push(citation.chunkId);
}
}
return { documentIds: uniqueStrings(ids), packIds: uniqueStrings(packIds), chunkIds: uniqueStrings(chunkIds) };
}
function auditMappedKnowledgeSources(projectId, scriptIds) {
if (!scriptIds.length) return { documentIds: [], packIds: [] };
const rows = dbAll(
`SELECT action, target_id, metadata_json
FROM audit_logs
WHERE project_id = ? AND action IN ('knowledge.document.materialized', 'knowledge.context_pack.materialized')
ORDER BY created_at DESC
LIMIT 300`,
[projectId]
);
const documentIds = [];
const packIds = [];
const scriptSet = new Set(scriptIds);
for (const row of rows) {
const metadata = parseJson(row.metadata_json, {});
if (!scriptSet.has(String(metadata.scriptDocumentId || ""))) continue;
if (row.action === "knowledge.document.materialized") documentIds.push(row.target_id);
if (row.action === "knowledge.context_pack.materialized") packIds.push(row.target_id);
}
return { documentIds: uniqueStrings(documentIds), packIds: uniqueStrings(packIds) };
}
function knowledgeDocumentsByIds(context, documentIds) {
const ids = uniqueStrings(documentIds);
if (!ids.length) return [];
const placeholders = ids.map(() => "?").join(",");
return dbAll(
`SELECT * FROM knowledge_documents
WHERE id IN (${placeholders}) AND organization_id = ? AND workspace_id = ?
AND (project_id IS NULL OR project_id = ?)`,
[...ids, context.organization.id, context.workspace.id, context.project.id]
);
}
function knowledgePacksByIds(context, packIds) {
const ids = uniqueStrings(packIds);
if (!ids.length) return [];
const placeholders = ids.map(() => "?").join(",");
return dbAll(
`SELECT * FROM knowledge_context_packs
WHERE id IN (${placeholders}) AND organization_id = ? AND workspace_id = ?
AND (project_id IS NULL OR project_id = ?)`,
[...ids, context.organization.id, context.workspace.id, context.project.id]
);
}
function knowledgeDocumentsFromChunks(context, chunkIds) {
const ids = uniqueStrings(chunkIds);
if (!ids.length) return [];
const placeholders = ids.map(() => "?").join(",");
return dbAll(
`SELECT DISTINCT kd.*
FROM knowledge_chunks kc
JOIN knowledge_documents kd ON kd.id = kc.document_id
WHERE kc.id IN (${placeholders}) AND kd.organization_id = ? AND kd.workspace_id = ?
AND (kd.project_id IS NULL OR kd.project_id = ?)`,
[...ids, context.organization.id, context.workspace.id, context.project.id]
);
}
function knowledgeClearanceSection(context) {
const project = requireProject(context);
const blockers = [];
const reviewItems = [];
const scripts = dbAll("SELECT id, title, source_type, metadata_json, content FROM script_documents WHERE project_id = ? ORDER BY version_number DESC", [project.id])
.map((row) => ({ ...row, metadata: parseJson(row.metadata_json, {}) }));
const knowledgeScripts = scripts.filter((script) => /^知识库/.test(script.source_type || "") || objectValue(script.metadata).origin?.startsWith?.("knowledge"));
const scriptIds = knowledgeScripts.map((script) => script.id);
const fromMetadata = knowledgeScripts.reduce((accumulator, script) => {
const sources = scriptSourceIdsFromMetadata(script.metadata);
accumulator.documentIds.push(...sources.documentIds);
accumulator.packIds.push(...sources.packIds);
accumulator.chunkIds.push(...sources.chunkIds);
return accumulator;
}, { documentIds: [], packIds: [], chunkIds: [] });
const fromAudit = auditMappedKnowledgeSources(project.id, scriptIds);
const packs = knowledgePacksByIds(context, [...fromMetadata.packIds, ...fromAudit.packIds]);
const packChunkIds = packs.flatMap((pack) => listFromJson(pack.chunk_ids_json));
const documents = knowledgeDocumentsByIds(context, [...fromMetadata.documentIds, ...fromAudit.documentIds]);
const chunkDocuments = knowledgeDocumentsFromChunks(context, [...fromMetadata.chunkIds, ...packChunkIds]);
const documentMap = new Map([...documents, ...chunkDocuments].map((document) => [document.id, document]));
const knowledgeItems = [];
for (const pack of packs) {
const metadata = parseJson(pack.metadata_json, {});
const governance = objectValue(metadata.governance);
const citations = listFromJson(pack.citations_json);
const chunks = listFromJson(pack.chunks_json);
const itemBlockers = [];
const itemReviews = [];
if (pack.status !== "active") itemBlockers.push(`上下文包状态为 ${pack.status}`);
if (governance.status === "blocked" || Number(governance.blockingCount || 0) > 0) itemBlockers.push("上下文包包含版权不可用或风险阻断片段");
if (governance.status === "review" || Number(governance.reviewCount || 0) > 0) itemReviews.push("上下文包包含需要复核的素材片段");
if (!citations.length && !chunks.length) itemReviews.push("上下文包缺少引用清单");
for (const reason of itemBlockers) blockers.push({ type: "knowledge_pack", packId: pack.id, status: "blocked", reason });
for (const reason of itemReviews) reviewItems.push({ type: "knowledge_pack", packId: pack.id, status: "review", reason });
knowledgeItems.push({
type: "context_pack",
id: pack.id,
title: pack.name,
status: itemBlockers.length ? "blocked" : itemReviews.length ? "review" : "pass",
rightsStatus: "derived",
riskStatus: governance.status || "unscanned",
citationCount: citations.length,
chunkCount: listFromJson(pack.chunk_ids_json).length,
blockers: itemBlockers,
reviewItems: itemReviews
});
}
for (const document of documentMap.values()) {
const provenance = parseJson(document.provenance_json, {});
const risk = parseJson(document.risk_json, {});
const itemBlockers = [];
const itemReviews = [];
if (!["indexed", "active"].includes(document.status)) itemBlockers.push(`知识素材状态为 ${document.status}`);
if ((document.rights_status || "needs-evidence") !== "approved") itemBlockers.push(`知识素材版权状态为 ${document.rights_status || "needs-evidence"}`);
if (risk.status === "blocked") itemBlockers.push("知识素材风险扫描为 blocked");
if (!clearanceEvidenceRef({}, provenance)) itemBlockers.push("知识素材缺少来源/授权证据引用");
if (!risk.status || risk.status === "review" || risk.status === "unscanned") itemReviews.push("知识素材风险扫描需要复核");
for (const reason of itemBlockers) blockers.push({ type: "knowledge_document", documentId: document.id, status: "blocked", reason });
for (const reason of itemReviews) reviewItems.push({ type: "knowledge_document", documentId: document.id, status: "review", reason });
knowledgeItems.push({
type: "document",
id: document.id,
title: document.title,
sourceType: document.source_type,
rightsStatus: document.rights_status || "needs-evidence",
riskStatus: risk.status || "unscanned",
evidenceRef: clearanceEvidenceRef({}, provenance),
status: itemBlockers.length ? "blocked" : itemReviews.length ? "review" : "pass",
blockers: itemBlockers,
reviewItems: itemReviews
});
}
for (const script of knowledgeScripts) {
const sources = scriptSourceIdsFromMetadata(script.metadata);
const hasSource = sources.documentIds.length || sources.packIds.length || sources.chunkIds.length || fromAudit.documentIds.length || fromAudit.packIds.length;
if (!hasSource) {
const reason = "知识库来源剧本缺少可追溯的素材 ID,请重新从知识库物化或补来源元数据";
blockers.push({ type: "script_source", scriptId: script.id, status: "blocked", reason });
knowledgeItems.push({ type: "script_source", id: script.id, title: script.title, sourceType: script.source_type, status: "blocked", blockers: [reason], reviewItems: [] });
}
}
return { scripts: knowledgeScripts.map((script) => ({ id: script.id, title: script.title, sourceType: script.source_type, origin: script.metadata.origin || "" })), materials: knowledgeItems, blockers, reviewItems };
}
function qaClearanceSection(context) {
const reviews = ensureReviews(context);
const blockers = reviews
.filter((review) => review.status !== "approved")
.map((review) => ({ id: review.id, type: "review", lane: review.lane, status: review.status, shotId: review.shot_id, reason: `${REVIEW_LANES.find(([lane]) => lane === review.lane)?.[1] || review.lane} 未通过` }));
return {
reviews: reviews.map((review) => ({
id: review.id,
shotId: review.shot_id,
lane: review.lane,
status: review.status,
decisionByName: review.decision_by_name || ""
})),
blockers,
reviewItems: []
};
}
function jobClearanceSection(context) {
const project = requireProject(context);
const jobs = dbAll("SELECT id, kind, status, shot_id FROM generation_jobs WHERE project_id = ? AND status NOT IN ('completed', 'cancelled') ORDER BY created_at", [project.id]);
return {
jobs,
blockers: jobs.map((job) => ({ id: job.id, type: "job", kind: job.kind, status: job.status, shotId: job.shot_id, reason: "仍有未完成或未取消的生成任务" })),
reviewItems: []
};
}
function clearanceReportRow(row) {
if (!row) return null;
return {
...row,
blockerCount: Number(row.blocker_count || 0),
reviewCount: Number(row.review_count || 0),
certificatePath: row.certificate_path || "",
report: parseJson(row.report_json, {})
};
}
function latestClearanceReport(context, deliveryId) {
return clearanceReportRow(dbGet(
`SELECT dcr.*, u.display_name AS created_by_name
FROM delivery_clearance_reports dcr
LEFT JOIN users u ON u.id = dcr.created_by
WHERE dcr.organization_id = ? AND dcr.workspace_id = ? AND dcr.project_id = ? AND dcr.delivery_id = ?
ORDER BY dcr.created_at DESC
LIMIT 1`,
[context.organization.id, context.workspace.id, context.project.id, deliveryId]
));
}
function buildDeliveryClearanceReport(context, delivery, options = {}) {
const project = requireProject(context);
const activeBatch = activeBatchForDelivery(context, delivery);
const items = deliveryBatchItems(activeBatch?.id);
const shotIds = items.length ? items.map((item) => item.shot_id) : shotRows(context).map((shot) => shot.id);
const media = mediaClearanceSection(activeBatch, items);
const assets = assetClearanceSection(context, shotIds);
const voices = voiceClearanceSection(context, shotIds);
const knowledge = knowledgeClearanceSection(context);
const qa = qaClearanceSection(context);
const jobs = jobClearanceSection(context);
const blockers = [...media.blockers, ...assets.blockers, ...voices.blockers, ...knowledge.blockers, ...qa.blockers, ...jobs.blockers];
const reviewItems = [...media.reviewItems, ...assets.reviewItems, ...voices.reviewItems, ...knowledge.reviewItems, ...qa.reviewItems, ...jobs.reviewItems];
const status = clearanceStatus(blockers, reviewItems);
const checkedAt = now();
const baseCertificate = {
schema: "ai-drama-platform.delivery-clearance-certificate.v1",
issuedAt: checkedAt,
localOnly: true,
organizationId: context.organization.id,
workspaceId: context.workspace.id,
projectId: project.id,
deliveryId: delivery.id,
deliveryVersion: delivery.version,
batchId: activeBatch?.id || "",
releaseId: options.releaseId || "",
status,
policy: {
singleFrameOnly: true,
originalCommercialUse: true,
approvedAssetsRequired: true,
approvedKnowledgeRequired: true,
fixedVoiceEvidenceRequired: true,
actualLastFrameRequired: true,
paidCloudPublishDisabled: true
},
counts: {
blockers: blockers.length,
reviewItems: reviewItems.length,
assets: assets.assets.length,
voices: voices.voices.length,
knowledgeMaterials: knowledge.materials.length,
qaReviews: qa.reviews.length,
mediaItems: media.items.length,
pendingJobs: jobs.jobs.length
}
};
const certificateId = `clearance-${jsonHash(baseCertificate).slice(0, 16)}`;
const certificate = { ...baseCertificate, certificateId, issuerUserId: context.user.id, issuerName: context.user.displayName || context.user.email || context.user.id };
const report = {
schema: "ai-drama-platform.delivery-clearance-report.v1",
id: options.reportId || certificateId,
checkedAt,
status,
organizationId: context.organization.id,
workspaceId: context.workspace.id,
projectId: project.id,
delivery: { id: delivery.id, version: delivery.version, status: delivery.status, manifestPath: delivery.manifest_path },
batch: activeBatch ? { id: activeBatch.id, status: activeBatch.status, manifestPath: activeBatch.manifest_path, itemCount: items.length } : null,
releaseId: options.releaseId || "",
channel: options.channel ? { id: options.channel.id, name: options.channel.name, kind: options.channel.kind } : null,
blockers,
reviewItems,
sections: {
media,
assets: assets.assets,
voices: voices.voices,
knowledge,
qa,
jobs
},
certificate
};
return report;
}
function persistDeliveryClearanceReport(context, delivery, options = {}) {
const id = makeId("clearance");
const report = buildDeliveryClearanceReport(context, delivery, { ...options, reportId: id });
const certificatePath = `storage/deliveries/${safePathSegment(context.project.id)}/${safePathSegment(delivery.version)}/clearance/${safePathSegment(id)}.json`;
const target = safeStoragePath(certificatePath);
if (!target) throw httpError(500, "clearance_path_invalid", "清算证书路径不满足本地存储安全约束");
mkdirSync(resolve(target.absolute, ".."), { recursive: true });
writeFileSync(target.absolute, `${JSON.stringify({ ...report.certificate, report }, null, 2)}\n`, "utf8");
dbRun(
`INSERT INTO delivery_clearance_reports(
id, organization_id, workspace_id, project_id, delivery_id, batch_id, release_id, status,
blocker_count, review_count, certificate_path, report_json, created_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id, context.organization.id, context.workspace.id, context.project.id, delivery.id, report.batch?.id || null, options.releaseId || null, report.status, report.blockers.length, report.reviewItems.length, certificatePath, JSON.stringify(report), context.user.id, report.checkedAt]
);
addAudit({ context, action: "delivery.clearance.checked", targetType: "delivery", targetId: delivery.id, result: report.status, metadata: { clearanceId: id, blockers: report.blockers.length, reviewItems: report.reviewItems.length, releaseId: options.releaseId || "" } });
return clearanceReportRow(dbGet("SELECT * FROM delivery_clearance_reports WHERE id = ?", [id]));
}
function assertDeliveryClearancePass(context, delivery, options = {}) {
const clearance = persistDeliveryClearanceReport(context, delivery, options);
const report = clearance.report || {};
if (report.status === "blocked") {
throw httpError(409, "delivery_clearance_blocked", "交付权利清算未通过,不能审批或发布", { blockers: report.blockers || [], clearance: report, clearanceId: clearance.id });
}
return clearance;
}
function isPrivateHostname(hostname) {
const host = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
if (["localhost", "::1"].includes(host) || host.endsWith(".local") || host.endsWith(".internal")) return true;
@@ -504,6 +1096,7 @@ export function listDeliveryChannels(context) {
export function createDeliveryChannel(context, body = {}) {
requirePermission(context, "delivery:approve");
requireEntitlement(context, "limit.delivery_channels", 1);
const projectId = body.projectId ? String(body.projectId).trim() : null;
if (projectId && (!context.project || context.project.id !== projectId)) {
throw httpError(403, "delivery_channel_project_scope", "渠道项目范围必须是当前项目或留空作为工作区渠道");
@@ -707,13 +1300,14 @@ export function importScript(context, body) {
const latest = dbGet("SELECT MAX(version_number) AS version_number FROM script_documents WHERE project_id = ?", [project.id]);
const versionNumber = Number(latest?.version_number || 0) + 1;
const analysis = parseScript(content);
const metadata = body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata) ? body.metadata : {};
const id = makeId("script");
const timestamp = now();
dbRun("INSERT INTO script_documents(id, organization_id, workspace_id, project_id, episode_id, version_number, title, source_type, content, status, analysis_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'analyzed', ?, ?, ?, ?)", [id, context.organization.id, context.workspace.id, project.id, body.episodeId || root.episode.id, versionNumber, String(body.title || analysis.chapters[0]?.title || "未命名剧本"), String(body.sourceType || "原创短剧剧本"), content, JSON.stringify(analysis), context.user.id, timestamp, timestamp]);
dbRun("INSERT INTO script_documents(id, organization_id, workspace_id, project_id, episode_id, version_number, title, source_type, content, status, analysis_json, metadata_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'analyzed', ?, ?, ?, ?, ?)", [id, context.organization.id, context.workspace.id, project.id, body.episodeId || root.episode.id, versionNumber, String(body.title || analysis.chapters[0]?.title || "未命名剧本"), String(body.sourceType || "原创短剧剧本"), content, JSON.stringify(analysis), JSON.stringify(metadata), context.user.id, timestamp, timestamp]);
dbRun("UPDATE episodes SET title = COALESCE(NULLIF(?, ''), title), updated_at = ? WHERE id = ?", [String(body.episodeTitle || "").trim(), timestamp, body.episodeId || root.episode.id]);
addAudit({ context, action: "script.imported", targetType: "script_document", targetId: id, metadata: { versionNumber, wordCount: analysis.wordCount, chapterCount: analysis.chapterCount } });
addUsage({ context, kind: "script-analysis", units: 1, unitName: "document", metadata: { scriptId: id, parser: analysis.parser } });
return { document: { ...dbGet("SELECT * FROM script_documents WHERE id = ?", [id]), analysis }, graph: productionGraph(context, { episodeId: body.episodeId || root.episode.id }) };
return { document: { ...dbGet("SELECT * FROM script_documents WHERE id = ?", [id]), analysis, metadata }, graph: productionGraph(context, { episodeId: body.episodeId || root.episode.id }) };
}
export function materializeScript(context, documentId, body = {}) {
@@ -1041,6 +1635,33 @@ export function listDeliveries(context) {
return { deliveries: deliveries(context) };
}
export function getDeliveryClearance(context, deliveryId) {
requireAnyPermission(context, ["delivery:view", "delivery:approve", "compliance:manage"]);
const delivery = deliveryForContext(context, deliveryId);
const preview = buildDeliveryClearanceReport(context, delivery);
return {
deliveryId,
clearance: preview,
latest: latestClearanceReport(context, deliveryId)
};
}
export function runDeliveryClearance(context, deliveryId, body = {}) {
requireAnyPermission(context, ["delivery:approve", "compliance:manage"], { mutating: true });
const delivery = deliveryForContext(context, deliveryId);
const releaseId = String(body.releaseId || body.release_id || "").trim();
const release = releaseId ? releaseForContext(context, releaseId) : null;
if (release && release.delivery_id !== delivery.id) throw httpError(422, "clearance_release_mismatch", "发布申请不属于当前交付版本", { deliveryId, releaseId });
const channel = release ? channelForContext(context, release.channel_id) : null;
const clearance = persistDeliveryClearanceReport(context, delivery, { releaseId: release?.id || "", channel });
return {
deliveryId,
clearance: clearance.report,
latest: clearance,
deliveries: deliveries(context)
};
}
export function createDelivery(context, body) {
requirePermission(context, "delivery:approve");
const project = requireProject(context);
@@ -1105,65 +1726,35 @@ function releaseForContext(context, releaseId) {
return release;
}
function releasePreflight(context, delivery, channel) {
const blockers = [];
function releasePreflight(context, delivery, channel, options = {}) {
let blockers = [];
const project = requireProject(context);
if (!channel.enabled) blockers.push({ type: "channel", status: "disabled", reason: "发布渠道已停用" });
if (delivery.status !== "approved") blockers.push({ type: "delivery", status: delivery.status, reason: "交付版本必须先通过交付审批" });
const activeBatch = delivery.active_batch_id
? dbGet(
`SELECT * FROM delivery_batches
WHERE id = ? AND delivery_id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?`,
[delivery.active_batch_id, delivery.id, context.organization.id, context.workspace.id, project.id]
)
const clearanceRecord = options.persistClearance
? persistDeliveryClearanceReport(context, delivery, { releaseId: options.releaseId || "", channel })
: null;
if (!activeBatch) {
blockers.push({ type: "delivery_batch", status: "missing", reason: "交付版本没有当前生效批次" });
} else {
if (activeBatch.status !== "active") blockers.push({ type: "delivery_batch", status: activeBatch.status, reason: "当前交付批次不是 active" });
const batchResult = parseJson(activeBatch.result_json, {});
if (Array.isArray(batchResult.blockers) && batchResult.blockers.length) blockers.push({ type: "delivery_batch", status: "blocked", reason: "批次创建时已登记媒体阻断项", items: batchResult.blockers });
const manifest = safeStoragePath(activeBatch.manifest_path);
if (!manifest || !existsSync(manifest.absolute)) blockers.push({ type: "manifest", status: "missing", reason: "当前批次 Manifest 文件不存在" });
const items = dbAll(
`SELECT dbi.*, s.title AS shot_title
FROM delivery_batch_items dbi
LEFT JOIN shots s ON s.id = dbi.shot_id
WHERE dbi.batch_id = ? ORDER BY dbi.sequence_number`,
[activeBatch.id]
);
if (!items.length) blockers.push({ type: "delivery_batch_items", status: "missing", reason: "当前批次没有镜头条目" });
for (const item of items) {
const source = safeStoragePath(item.source_path);
const actualLastFrame = safeStoragePath(item.actual_last_frame_path);
if (!source || !existsSync(source.absolute)) blockers.push({ type: "media", shotId: item.shot_id, status: "missing", reason: "视频源文件不存在" });
if (!item.source_sha256) blockers.push({ type: "media", shotId: item.shot_id, status: "unverified", reason: "视频源缺少 SHA-256" });
if (!actualLastFrame || !existsSync(actualLastFrame.absolute)) blockers.push({ type: "actual-last-frame", shotId: item.shot_id, status: "missing", reason: "实际末帧证据不存在" });
}
return {
ok: blockers.length === 0,
checkedAt: now(),
projectId: project.id,
deliveryId: delivery.id,
channelId: channel.id,
batchId: activeBatch.id,
manifestPath: activeBatch.manifest_path,
itemCount: items.length,
blockers
};
}
const clearance = clearanceRecord?.report || buildDeliveryClearanceReport(context, delivery, { releaseId: options.releaseId || "", channel });
if (clearance.status === "blocked") blockers.push(...(clearance.blockers || []));
blockers = dedupeBlockers(blockers);
return {
ok: false,
checkedAt: now(),
ok: blockers.length === 0,
checkedAt: clearance.checkedAt || now(),
projectId: project.id,
deliveryId: delivery.id,
channelId: channel.id,
batchId: null,
manifestPath: "",
itemCount: 0,
blockers
batchId: clearance.batch?.id || null,
manifestPath: clearance.batch?.manifestPath || "",
itemCount: clearance.batch?.itemCount || 0,
blockers,
clearance: {
id: clearanceRecord?.id || "",
schema: "ai-drama-platform.delivery-clearance-certificate.v1",
status: clearance.status,
certificateId: clearance.certificate?.certificateId || "",
certificatePath: clearanceRecord?.certificatePath || "",
counts: clearance.certificate?.counts || {}
}
};
}
@@ -1216,7 +1807,7 @@ export function createDeliveryRelease(context, deliveryId, body = {}) {
if (existing) return { release: releasePayload(existing), idempotent: true, ...listDeliveryReleases(context, deliveryId) };
}
const submit = body.submit !== false;
const preflight = submit ? releasePreflight(context, delivery, channel) : { ok: false, checkedAt: now(), blockers: [{ type: "not_submitted", status: "draft", reason: "发布申请尚未提交" }] };
const preflight = submit ? releasePreflight(context, delivery, channel, { persistClearance: true }) : { ok: false, checkedAt: now(), blockers: [{ type: "not_submitted", status: "draft", reason: "发布申请尚未提交" }] };
if (submit && !preflight.ok) throw httpError(409, "release_blocked", "当前交付版本不满足发布申请条件", { blockers: preflight.blockers, preflight });
const id = makeId("delivery-release");
const timestamp = now();
@@ -1247,13 +1838,13 @@ export function decideDeliveryRelease(context, releaseId, body = {}) {
dbRun("UPDATE delivery_releases SET status = 'draft', decision_note = ?, reviewed_by = NULL, reviewed_at = NULL, updated_at = ? WHERE id = ?", [note, timestamp, releaseId]);
} else if (status === "submitted") {
if (!["draft", "rejected"].includes(release.status)) throw httpError(409, "release_transition_invalid", "只有草稿或已驳回的发布申请可以重新提交");
preflight = releasePreflight(context, delivery, channel);
preflight = releasePreflight(context, delivery, channel, { persistClearance: true, releaseId });
if (!preflight.ok) throw httpError(409, "release_blocked", "发布申请仍未满足发布条件", { blockers: preflight.blockers, preflight });
const timestamp = now();
dbRun("UPDATE delivery_releases SET status = 'submitted', requested_by = ?, requested_at = ?, decision_note = ?, preflight_json = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, note, JSON.stringify(preflight), timestamp, releaseId]);
} else if (status === "approved") {
if (release.status !== "submitted") throw httpError(409, "release_transition_invalid", "只有已提交的发布申请可以批准");
preflight = releasePreflight(context, delivery, channel);
preflight = releasePreflight(context, delivery, channel, { persistClearance: true, releaseId });
if (!preflight.ok) throw httpError(409, "release_blocked", "发布申请审批被质量门阻断", { blockers: preflight.blockers, preflight });
const timestamp = now();
dbRun("UPDATE delivery_releases SET status = 'approved', reviewed_by = ?, reviewed_at = ?, decision_note = ?, preflight_json = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, note, JSON.stringify(preflight), timestamp, releaseId]);
@@ -1276,7 +1867,7 @@ export async function publishDeliveryRelease(context, releaseId) {
if (!["approved", "failed"].includes(release.status)) throw httpError(409, "release_transition_invalid", "只有已批准或上次发布失败的申请可以发布");
const delivery = deliveryForContext(context, release.delivery_id);
const channel = channelForContext(context, release.channel_id);
const preflight = releasePreflight(context, delivery, channel);
const preflight = releasePreflight(context, delivery, channel, { persistClearance: true, releaseId });
if (!preflight.ok) throw httpError(409, "release_blocked", "发布前复核未通过", { blockers: preflight.blockers, preflight });
const outputDirectory = `storage/releases/${safePathSegment(context.project.id)}/${safePathSegment(delivery.version)}/${safePathSegment(release.id)}`;
const releaseOutputPath = `${outputDirectory}/release.json`;
@@ -1289,6 +1880,7 @@ export async function publishDeliveryRelease(context, releaseId) {
delivery: { id: delivery.id, version: delivery.version, status: delivery.status, manifestPath: delivery.manifest_path },
channel: { id: channel.id, name: channel.name, kind: channel.kind, endpoint: channel.kind === "local-file" ? channel.endpoint : "private-webhook" },
batch: { id: preflight.batchId, manifestPath: preflight.manifestPath, itemCount: preflight.itemCount },
clearanceCertificate: preflight.clearance,
preflight
};
let result = { kind: channel.kind, outputPath: releaseOutputPath, manifestPath: manifestOutputPath, localOnly: true };
@@ -1488,11 +2080,13 @@ export function approveDelivery(context, deliveryId, body = {}) {
if (!item.actual_last_frame_path) missing.push("实际末帧");
return missing.length ? [{ id: item.id, type: "delivery_batch_item", shotId: item.shot_id, status: "blocked", reason: missing.join("、") }] : [];
});
const blockers = [...reviewBlockers, ...jobBlockers, ...batchBlockers];
if (blockers.length && !body.force) throw httpError(409, "delivery_blocked", "仍有质检项未通过,不能批准交付", { blockers });
const clearance = persistDeliveryClearanceReport(context, delivery);
const clearanceBlockers = clearance.report?.blockers || [];
const blockers = dedupeBlockers([...reviewBlockers, ...jobBlockers, ...batchBlockers, ...clearanceBlockers]);
if (clearanceBlockers.length || (blockers.length && !body.force)) throw httpError(409, "delivery_blocked", "交付版本未满足质检、权利清算或媒体证据要求,不能批准交付", { blockers, clearance: clearance.report, clearanceId: clearance.id });
const timestamp = now();
dbRun("UPDATE deliveries SET status = 'approved', approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, timestamp, deliveryId]);
addAudit({ context, action: "delivery.approved", targetType: "delivery", targetId: deliveryId, metadata: { forced: Boolean(body.force), blockers: blockers.length } });
addAudit({ context, action: "delivery.approved", targetType: "delivery", targetId: deliveryId, metadata: { forced: Boolean(body.force), blockers: blockers.length, clearanceId: clearance.id, clearanceStatus: clearance.report?.status || "" } });
void dispatchNotificationEvent({ context, eventKey: "delivery.approved", payload: { deliveryId, version: delivery.version, targetId: deliveryId, forced: Boolean(body.force) } });
return { delivery: dbGet("SELECT * FROM deliveries WHERE id = ?", [deliveryId]), deliveries: deliveries(context), blockers };
}