feat: expand commercial ai drama platform
This commit is contained in:
+656
-62
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user