import { dbAll, dbGet, dbRun, withTransaction } from "./db.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 { evaluateProjectContractClearance } from "./contracts.mjs"; import { createHash } from "node:crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { resolve } from "node:path"; const projectRoot = resolve(import.meta.dirname, ".."); const REVIEW_LANES = [ ["single-frame", "一图一画面"], ["continuity-lock", "角色 / 场景 / 道具连续性"], ["voice-subtitle-asr", "声音 / 字幕 / ASR 对齐"], ["clip-bridge", "片段衔接 / 实际末帧"] ]; const DEFAULT_SERIES_TEMPLATE = { visualStyle: "原创国产漫画/国漫 2D 动画风格,竖屏 9:16,干净赛璐璐上色,电影感中景,稳定机位。", continuityRule: "角色、服装、道具、场景、天气、机位、声音全部进入连续性台账;下一段视频优先使用上一段实际末帧。", showEngine: "每集围绕一个可拍摄的冲突推进,结尾留下下一处行动钩子。" }; 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 requireProject(context) { if (!context.project) throw httpError(400, "project_required", "生产操作必须绑定项目"); return context.project; } function requireAnyPermission(context, permissions, { mutating = false } = {}) { if (!permissions.some((permission) => hasPermission(context, permission))) { throw httpError(403, "permission_denied", `缺少生产权限:${permissions.join(" / ")}`, { permissions }); } if (mutating) requireProjectWritable(context); } function projectEpisode(context, episodeId) { const project = requireProject(context); const episode = dbGet( `SELECT e.*, se.series_id, sr.project_id FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE e.id = ? AND sr.project_id = ?`, [episodeId, project.id] ); if (!episode) throw httpError(404, "episode_not_found", "分集不存在或不属于当前项目", { episodeId }); return episode; } function ensureProductionRoot(context) { const project = requireProject(context); const timestamp = now(); let series = dbGet("SELECT * FROM series WHERE project_id = ?", [project.id]); if (!series) { const seriesId = `series-${project.id}`; dbRun("INSERT INTO series(id, project_id, title, logline, format, visual_style, continuity_rule, show_engine, created_at, updated_at) VALUES (?, ?, ?, ?, 'vertical-9:16', ?, ?, ?, ?, ?)", [ seriesId, project.id, project.name, `${project.name} 的本地化 AI 短剧生产项目。`, DEFAULT_SERIES_TEMPLATE.visualStyle, DEFAULT_SERIES_TEMPLATE.continuityRule, DEFAULT_SERIES_TEMPLATE.showEngine, timestamp, timestamp ]); series = dbGet("SELECT * FROM series WHERE id = ?", [seriesId]); } let season = dbGet("SELECT * FROM seasons WHERE series_id = ? ORDER BY season_number LIMIT 1", [series.id]); if (!season) { const seasonId = `season-${project.id}-1`; dbRun("INSERT INTO seasons(id, series_id, season_number, title, created_at, updated_at) VALUES (?, ?, 1, '第一季', ?, ?)", [seasonId, series.id, timestamp, timestamp]); season = dbGet("SELECT * FROM seasons WHERE id = ?", [seasonId]); } let episode = dbGet("SELECT * FROM episodes WHERE season_id = ? ORDER BY episode_number LIMIT 1", [season.id]); if (!episode) { const episodeId = `episode-${project.id}-01`; dbRun("INSERT INTO episodes(id, season_id, episode_number, title, status, target_duration_sec, hook, cliffhanger, created_at, updated_at) VALUES (?, ?, 1, '试播集', 'draft', 0, '', '', ?, ?)", [episodeId, season.id, timestamp, timestamp]); episode = dbGet("SELECT * FROM episodes WHERE id = ?", [episodeId]); } const shotCount = dbGet("SELECT COUNT(*) AS count FROM shots WHERE episode_id = ?", [episode.id]); if (Number(shotCount?.count || 0) === 0) { const shotId = `shot-${project.id}-001`; const payload = { id: shotId, title: "新镜头 01", durationSec: 6, characterIds: [], locationId: "", propIds: [], camera: "稳定中景,保持单一连续画面", action: "待编剧填写镜头动作", firstFrame: "episode-start", lastFrame: "pending-actual-last-frame", transitionFromPrevious: "episode-start", prompt: "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY.", negativePrompt: "no split screen, no comic panel, no collage, no contact sheet", videoPrompt: "待填写视频动作和实际末帧要求", seed: null }; dbRun("INSERT INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, 1, ?, 'draft', ?, ?, ?, ?, ?)", [shotId, episode.id, payload.title, payload.firstFrame, payload.lastFrame, JSON.stringify(payload), timestamp, timestamp]); dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'draft', ?, ?)", [`${shotId}-v1`, shotId, JSON.stringify(payload), context.user.id, timestamp]); dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${shotId}-v1`, shotId]); } return { series, season, episode }; } function episodeSummary(row) { if (!row) return null; return { id: row.id, seasonId: row.season_id, episodeNumber: Number(row.episode_number), title: row.title, status: row.status, targetDurationSec: Number(row.target_duration_sec || 0), hook: row.hook || "", cliffhanger: row.cliffhanger || "", shotCount: Number(row.shot_count || 0), scriptVersionCount: Number(row.script_version_count || 0), updatedAt: row.updated_at }; } export function productionCatalog(context) { const root = ensureProductionRoot(context); const seasons = dbAll( `SELECT se.*, COUNT(DISTINCT e.id) AS episode_count FROM seasons se LEFT JOIN episodes e ON e.season_id = se.id WHERE se.series_id = ? GROUP BY se.id ORDER BY se.season_number`, [root.series.id] ).map((season) => ({ id: season.id, seriesId: season.series_id, seasonNumber: Number(season.season_number), title: season.title, episodeCount: Number(season.episode_count || 0), episodes: dbAll( `SELECT e.*, (SELECT COUNT(*) FROM shots s WHERE s.episode_id = e.id) AS shot_count, (SELECT COUNT(*) FROM script_documents d WHERE d.episode_id = e.id) AS script_version_count FROM episodes e WHERE e.season_id = ? ORDER BY e.episode_number`, [season.id] ).map(episodeSummary) })); return { series: root.series, activeSeasonId: root.season.id, activeEpisodeId: root.episode.id, seasons }; } function insertStarterShot(context, episodeId, shotId, timestamp) { const payload = { id: shotId, title: "新镜头 01", durationSec: 6, characterIds: [], locationId: "", propIds: [], camera: "稳定中景,保持单一连续画面", action: "待编剧填写镜头动作", firstFrame: "episode-start", lastFrame: "pending-actual-last-frame", transitionFromPrevious: "episode-start", prompt: "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY.", negativePrompt: "no split screen, no comic panel, no collage, no contact sheet", videoPrompt: "待填写视频动作和实际末帧要求", seed: null }; dbRun("INSERT INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, 1, ?, 'draft', ?, ?, ?, ?, ?)", [shotId, episodeId, payload.title, payload.firstFrame, payload.lastFrame, JSON.stringify(payload), timestamp, timestamp]); dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'draft', ?, ?)", [`${shotId}-v1`, shotId, JSON.stringify(payload), context.user.id, timestamp]); dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${shotId}-v1`, shotId]); } export function createSeason(context, body = {}) { requirePermission(context, "script:edit"); const root = ensureProductionRoot(context); const title = String(body.title || "").trim(); if (title.length < 1 || title.length > 120) throw httpError(400, "season_title_invalid", "季名称不能为空且不能超过 120 个字符"); const latest = dbGet("SELECT MAX(season_number) AS season_number FROM seasons WHERE series_id = ?", [root.series.id]); const seasonNumber = Number(body.seasonNumber || Number(latest?.season_number || 0) + 1); if (!Number.isInteger(seasonNumber) || seasonNumber < 1 || seasonNumber > 999) throw httpError(400, "season_number_invalid", "季编号必须是 1 到 999 的整数"); if (dbGet("SELECT id FROM seasons WHERE series_id = ? AND season_number = ?", [root.series.id, seasonNumber])) throw httpError(409, "season_exists", "该季编号已经存在"); const id = body.id || `season-${context.project.id}-${seasonNumber}-${Date.now().toString(36)}`; const timestamp = now(); dbRun("INSERT INTO seasons(id, series_id, season_number, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)", [id, root.series.id, seasonNumber, title, timestamp, timestamp]); addAudit({ context, action: "season.created", targetType: "season", targetId: id, metadata: { seasonNumber, title } }); return { season: dbGet("SELECT * FROM seasons WHERE id = ?", [id]), catalog: productionCatalog(context) }; } export function createEpisode(context, body = {}) { requirePermission(context, "script:edit"); const root = ensureProductionRoot(context); const seasonId = String(body.seasonId || root.season.id); const season = dbGet("SELECT * FROM seasons WHERE id = ? AND series_id = ?", [seasonId, root.series.id]); if (!season) throw httpError(404, "season_not_found", "目标季不存在或不属于当前项目", { seasonId }); const title = String(body.title || "").trim(); if (title.length < 1 || title.length > 160) throw httpError(400, "episode_title_invalid", "集标题不能为空且不能超过 160 个字符"); const latest = dbGet("SELECT MAX(episode_number) AS episode_number FROM episodes WHERE season_id = ?", [seasonId]); const episodeNumber = Number(body.episodeNumber || Number(latest?.episode_number || 0) + 1); if (!Number.isInteger(episodeNumber) || episodeNumber < 1 || episodeNumber > 9999) throw httpError(400, "episode_number_invalid", "集编号必须是 1 到 9999 的整数"); if (dbGet("SELECT id FROM episodes WHERE season_id = ? AND episode_number = ?", [seasonId, episodeNumber])) throw httpError(409, "episode_exists", "该集编号已经存在"); const targetDurationSec = Number(body.targetDurationSec || 0); if (!Number.isFinite(targetDurationSec) || targetDurationSec < 0 || targetDurationSec > 3600) throw httpError(400, "target_duration_invalid", "目标时长必须在 0 到 3600 秒之间"); const id = body.id || `episode-${context.project.id}-${season.season_number}-${episodeNumber}-${Date.now().toString(36)}`; const timestamp = now(); const shotId = `${id}-shot-001`; withTransaction(() => { dbRun("INSERT INTO episodes(id, season_id, episode_number, title, status, target_duration_sec, hook, cliffhanger, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, seasonId, episodeNumber, title, String(body.status || "draft"), targetDurationSec, String(body.hook || ""), String(body.cliffhanger || ""), timestamp, timestamp]); insertStarterShot(context, id, shotId, timestamp); }); addAudit({ context, action: "episode.created", targetType: "episode", targetId: id, metadata: { seasonId, episodeNumber, title, starterShotId: shotId } }); return { episode: episodeSummary(dbGet("SELECT e.*, (SELECT COUNT(*) FROM shots s WHERE s.episode_id = e.id) AS shot_count, (SELECT COUNT(*) FROM script_documents d WHERE d.episode_id = e.id) AS script_version_count FROM episodes e WHERE e.id = ?", [id])), catalog: productionCatalog(context), graph: productionGraph(context, { episodeId: id }) }; } export function updateEpisode(context, episodeId, body = {}) { requirePermission(context, "script:edit"); const episode = projectEpisode(context, episodeId); const title = String(body.title ?? episode.title).trim(); if (title.length < 1 || title.length > 160) throw httpError(400, "episode_title_invalid", "集标题不能为空且不能超过 160 个字符"); const targetDurationSec = Number(body.targetDurationSec ?? episode.target_duration_sec ?? 0); if (!Number.isFinite(targetDurationSec) || targetDurationSec < 0 || targetDurationSec > 3600) throw httpError(400, "target_duration_invalid", "目标时长必须在 0 到 3600 秒之间"); const status = String(body.status ?? episode.status).trim(); if (!["draft", "production", "review", "approved", "archived"].includes(status)) throw httpError(400, "episode_status_invalid", "分集状态无效"); const timestamp = now(); dbRun("UPDATE episodes SET title = ?, status = ?, target_duration_sec = ?, hook = ?, cliffhanger = ?, updated_at = ? WHERE id = ?", [title, status, targetDurationSec, String(body.hook ?? episode.hook ?? ""), String(body.cliffhanger ?? episode.cliffhanger ?? ""), timestamp, episodeId]); addAudit({ context, action: "episode.updated", targetType: "episode", targetId: episodeId, metadata: { fields: Object.keys(body) } }); return { episode: episodeSummary(dbGet("SELECT e.*, (SELECT COUNT(*) FROM shots s WHERE s.episode_id = e.id) AS shot_count, (SELECT COUNT(*) FROM script_documents d WHERE d.episode_id = e.id) AS script_version_count FROM episodes e WHERE e.id = ?", [episodeId])), catalog: productionCatalog(context), graph: productionGraph(context, { episodeId }) }; } function shotRows(context, episodeId = null) { const project = requireProject(context); const params = [project.id]; const episodeClause = episodeId ? " AND e.id = ?" : ""; if (episodeId) params.push(episodeId); return dbAll( `SELECT s.*, e.episode_number, e.title AS episode_title FROM shots s JOIN episodes e ON e.id = s.episode_id JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE sr.project_id = ?${episodeClause} ORDER BY e.episode_number, s.shot_number`, params ); } function shotPayload(row) { const latest = (row.current_version_id && dbGet("SELECT * FROM shot_versions WHERE id = ? AND shot_id = ?", [row.current_version_id, row.id])) || dbGet("SELECT * FROM shot_versions WHERE shot_id = ? ORDER BY version_number DESC LIMIT 1", [row.id]); const payload = latest ? parseJson(latest.payload_json, {}) : parseJson(row.continuity_json, {}); const voiceLines = dbAll("SELECT * FROM voice_lines WHERE shot_id = ? ORDER BY line_number", [row.id]).map((line) => ({ id: line.id, characterId: line.character_key, text: line.text, emotion: line.emotion, targetDurationSec: line.target_duration_sec, audioFile: line.audio_path, mouthPlan: line.mouth_plan, voiceId: line.voice_id, status: line.status })); return { ...payload, id: row.id, title: row.title, status: row.status, episodeId: row.episode_id, episodeNumber: row.episode_number, shotNumber: row.shot_number, firstFrame: row.first_frame_path || payload.firstFrame || "episode-start", lastFrame: row.last_frame_path || payload.lastFrame || "pending-actual-last-frame", continuity: parseJson(row.continuity_json, {}), versionNumber: Number(latest?.version_number || 1), versionId: latest?.id || "", voiceLines }; } function scriptDocuments(context, episodeId = null) { const project = requireProject(context); const clause = episodeId ? " AND episode_id = ?" : ""; 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, {}), metadata: parseJson(row.metadata_json, {}) })); } function reviewRows(context) { const project = requireProject(context); return dbAll( `SELECT r.*, s.title AS shot_title, s.shot_number, u.display_name AS decision_by_name FROM reviews r LEFT JOIN shots s ON s.id = r.shot_id LEFT JOIN users u ON u.id = r.decision_by WHERE r.project_id = ? ORDER BY COALESCE(s.shot_number, 999), r.lane`, [project.id] ).map((row) => ({ ...row, evidence: parseJson(row.evidence_json, {}), comments: dbAll("SELECT c.*, u.display_name AS author_name FROM review_comments c LEFT JOIN users u ON u.id = c.author_user_id WHERE c.review_id = ? ORDER BY c.created_at", [row.id]) })); } function ensureReviews(context) { const project = requireProject(context); const shots = shotRows(context); const timestamp = now(); for (const shot of shots) { for (const [lane, label] of REVIEW_LANES) { const id = `review-${shot.id}-${lane}`; dbRun("INSERT OR IGNORE INTO reviews(id, organization_id, workspace_id, project_id, shot_id, lane, status, evidence_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, ?, ?)", [id, context.organization.id, context.workspace.id, project.id, shot.id, lane, JSON.stringify({ label, source: "production-gate" }), timestamp, timestamp]); } } return reviewRows(context); } function projectAssetLocks(context) { const project = requireProject(context); const rows = dbAll( `SELECT a.*, av.version_number, av.storage_path, av.file_name, av.mime_type, av.file_size, av.content_sha256, av.rights_status, av.metadata_json FROM assets a LEFT JOIN asset_versions av ON av.id = a.current_version_id WHERE a.project_id = ? ORDER BY CASE a.kind WHEN 'character' THEN 1 WHEN 'location' THEN 2 WHEN 'prop' THEN 3 ELSE 4 END, a.name`, [project.id] ); return rows.map((row) => { const metadata = parseJson(row.metadata_json, {}); const base = { id: row.id, name: row.name, kind: row.kind, status: row.lock_status, lockStatus: row.lock_status, version: Number(row.version_number || 1), storagePath: row.storage_path || "", rightsStatus: row.rights_status || "needs-evidence", contentSha256: row.content_sha256 || "", metadata, detail: metadata.detail || metadata.visualLock || metadata.description || "", lock: metadata.lock || metadata.continuityLock || "", cameraLock: metadata.cameraLock || metadata.camera || "", bindings: dbAll("SELECT shot_id, usage_role FROM asset_bindings WHERE asset_id = ? ORDER BY shot_id", [row.id]) }; if (row.kind === "character") { return { ...base, visualLock: metadata.visualLock || metadata.detail || "", costumeState: metadata.costumeState || metadata.lock || "", voiceLock: { status: metadata.voiceStatus || row.rights_status || row.lock_status, voiceId: metadata.voiceId || "", tone: metadata.tone || "", ttsModel: metadata.ttsModel || "", asrModel: metadata.asrModel || "", referencePolicy: metadata.referencePolicy || metadata.lock || "" } }; } if (row.kind === "location") return { ...base, visualLock: metadata.visualLock || metadata.detail || "" }; if (row.kind === "prop") return { ...base, visualLock: metadata.visualLock || metadata.detail || "" }; return base; }); } function deliveries(context) { const project = requireProject(context); return dbAll("SELECT d.*, u.display_name AS approved_by_name FROM deliveries d LEFT JOIN users u ON u.id = d.approved_by WHERE d.project_id = ? ORDER BY d.created_at DESC", [project.id]).map((row) => ({ ...row, manifest: row.manifest_path, releaseCount: Number(dbGet("SELECT COUNT(*) AS count FROM delivery_releases WHERE delivery_id = ?", [row.id])?.count || 0), publishedReleaseCount: Number(dbGet("SELECT COUNT(*) AS count FROM delivery_releases WHERE delivery_id = ? AND status = 'published'", [row.id])?.count || 0) })); } function safeStoragePath(value) { const relative = String(value || "").trim(); if (!relative || relative.startsWith("/") || relative.includes("..") || !relative.startsWith("storage/")) return null; const storageRoot = resolve(projectRoot, "storage"); const absolute = resolve(projectRoot, relative); if (!absolute.startsWith(`${storageRoot}/`)) return null; return { relative, absolute }; } function safePathSegment(value, fallback = "item") { const normalized = String(value || "").trim().replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, ""); 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 contracts = evaluateProjectContractClearance(context, project.id, { channel: options.channel || null, channelKind: options.channel?.kind || "", deliverables: ["vertical-video", "delivery-manifest"], territory: options.territory || "CN" }); const blockers = [...media.blockers, ...assets.blockers, ...voices.blockers, ...knowledge.blockers, ...qa.blockers, ...jobs.blockers, ...contracts.blockers]; const reviewItems = [...media.reviewItems, ...assets.reviewItems, ...voices.reviewItems, ...knowledge.reviewItems, ...qa.reviewItems, ...jobs.reviewItems, ...contracts.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, contractAuthorizationRequired: 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, contractBindings: contracts.counts?.activeBindings || 0 } }; 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, contracts }, 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; const octets = host.split(".").map((part) => Number(part)); if (octets.length !== 4 || octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return false; const [first, second] = octets; return first === 10 || first === 127 || (first === 172 && second >= 16 && second <= 31) || (first === 192 && second === 168) || (first === 169 && second === 254); } function normalizeChannelEndpoint(kind, endpoint) { const value = String(endpoint || "").trim(); if (kind === "local-file") { const path = safeStoragePath(value || "storage/releases"); if (!path) throw httpError(400, "delivery_channel_endpoint_invalid", "本地文件渠道必须写入 storage/ 下的安全相对目录"); return path.relative; } let parsed; try { parsed = new URL(value); } catch { throw httpError(400, "delivery_channel_endpoint_invalid", "本地 Webhook 渠道必须填写有效的 HTTP(S) 地址"); } if (!["http:", "https:"].includes(parsed.protocol) || !isPrivateHostname(parsed.hostname)) { throw httpError(400, "delivery_channel_endpoint_public", "为遵守本地化约束,Webhook 只能指向 localhost、.local、.internal 或私网地址"); } return parsed.toString(); } function normalizeAuthEnv(value) { const authEnv = String(value || "").trim(); if (authEnv && !/^[A-Z][A-Z0-9_]{0,99}$/.test(authEnv)) { throw httpError(400, "delivery_channel_auth_env_invalid", "认证环境变量名只能使用大写字母、数字和下划线"); } return authEnv; } function channelPayload(row) { if (!row) return null; return { ...row, enabled: Boolean(row.enabled), requireApproval: Boolean(row.require_approval), config: parseJson(row.config_json, {}) }; } function ensureDefaultDeliveryChannel(context) { const existing = dbGet( "SELECT * FROM delivery_channels WHERE organization_id = ? AND workspace_id = ? AND project_id IS NULL ORDER BY created_at LIMIT 1", [context.organization.id, context.workspace.id] ); if (existing) return existing; const timestamp = now(); const id = `channel-${safePathSegment(context.organization.id)}-${safePathSegment(context.workspace.id)}-local`; dbRun( `INSERT OR IGNORE INTO delivery_channels( id, organization_id, workspace_id, project_id, name, kind, enabled, endpoint, auth_env, require_approval, config_json, created_by, created_at, updated_at ) VALUES (?, ?, ?, NULL, '本地文件(默认)', 'local-file', 1, 'storage/releases', '', 1, ?, ?, ?, ?)`, [id, context.organization.id, context.workspace.id, JSON.stringify({ root: "storage/releases", localOnly: true }), context.user.id, timestamp, timestamp] ); return dbGet("SELECT * FROM delivery_channels WHERE id = ?", [id]); } function channelForContext(context, channelId) { const projectId = context.project?.id || null; const channel = dbGet( `SELECT * FROM delivery_channels WHERE id = ? AND organization_id = ? AND workspace_id = ? AND (project_id IS NULL OR project_id = ?)`, [channelId, context.organization.id, context.workspace.id, projectId] ); if (!channel) throw httpError(404, "delivery_channel_not_found", "发布渠道不存在或不属于当前工作区", { channelId }); return channel; } export function listDeliveryChannels(context) { requireAnyPermission(context, ["delivery:view", "delivery:approve"]); ensureDefaultDeliveryChannel(context); const projectId = context.project?.id || null; return { channels: dbAll( `SELECT dc.*, u.display_name AS created_by_name FROM delivery_channels dc LEFT JOIN users u ON u.id = dc.created_by WHERE dc.organization_id = ? AND dc.workspace_id = ? AND (dc.project_id IS NULL OR dc.project_id = ?) ORDER BY dc.enabled DESC, dc.created_at`, [context.organization.id, context.workspace.id, projectId] ).map(channelPayload) }; } 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", "渠道项目范围必须是当前项目或留空作为工作区渠道"); } const name = String(body.name || "").trim(); if (!name || name.length > 80) throw httpError(400, "delivery_channel_name_invalid", "渠道名称不能为空且不能超过 80 个字符"); const kind = String(body.kind || "local-file").trim(); if (!["local-file", "local-webhook"].includes(kind)) throw httpError(400, "delivery_channel_kind_invalid", "只允许 local-file 或 local-webhook 渠道"); const endpoint = normalizeChannelEndpoint(kind, body.endpoint); const authEnv = normalizeAuthEnv(body.authEnv); if (kind === "local-file" && authEnv) throw httpError(400, "delivery_channel_auth_env_invalid", "本地文件渠道不需要认证环境变量"); const config = body.config && typeof body.config === "object" && !Array.isArray(body.config) ? body.config : {}; const id = makeId("delivery-channel"); const timestamp = now(); dbRun( `INSERT INTO delivery_channels( id, organization_id, workspace_id, project_id, name, kind, enabled, endpoint, auth_env, require_approval, config_json, created_by, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [id, context.organization.id, context.workspace.id, projectId, name, kind, body.enabled === false ? 0 : 1, endpoint, authEnv, body.requireApproval === false ? 0 : 1, JSON.stringify(config), context.user.id, timestamp, timestamp] ); addAudit({ context, action: "delivery.channel.created", targetType: "delivery_channel", targetId: id, metadata: { name, kind, projectId, endpoint: kind === "local-webhook" ? endpoint : "local-file" } }); return { channel: channelPayload(dbGet("SELECT * FROM delivery_channels WHERE id = ?", [id])), ...listDeliveryChannels(context) }; } export function updateDeliveryChannel(context, channelId, body = {}) { requirePermission(context, "delivery:approve"); const channel = channelForContext(context, channelId); const nextName = body.name === undefined ? channel.name : String(body.name || "").trim(); if (!nextName || nextName.length > 80) throw httpError(400, "delivery_channel_name_invalid", "渠道名称不能为空且不能超过 80 个字符"); const kind = body.kind === undefined ? channel.kind : String(body.kind || "").trim(); if (kind !== channel.kind) throw httpError(409, "delivery_channel_kind_immutable", "渠道类型创建后不可修改,请新建渠道"); const endpoint = body.endpoint === undefined ? channel.endpoint : normalizeChannelEndpoint(kind, body.endpoint); const authEnv = body.authEnv === undefined ? channel.auth_env : normalizeAuthEnv(body.authEnv); if (kind === "local-file" && authEnv) throw httpError(400, "delivery_channel_auth_env_invalid", "本地文件渠道不需要认证环境变量"); const config = body.config === undefined ? parseJson(channel.config_json, {}) : (body.config && typeof body.config === "object" && !Array.isArray(body.config) ? body.config : {}); const timestamp = now(); dbRun( `UPDATE delivery_channels SET name = ?, enabled = ?, endpoint = ?, auth_env = ?, require_approval = ?, config_json = ?, updated_at = ? WHERE id = ?`, [nextName, body.enabled === undefined ? Number(channel.enabled) : (body.enabled ? 1 : 0), endpoint, authEnv, body.requireApproval === undefined ? Number(channel.require_approval) : (body.requireApproval ? 1 : 0), JSON.stringify(config), timestamp, channelId] ); addAudit({ context, action: "delivery.channel.updated", targetType: "delivery_channel", targetId: channelId, metadata: { name: nextName, enabled: body.enabled === undefined ? Boolean(channel.enabled) : Boolean(body.enabled) } }); return { channel: channelPayload(dbGet("SELECT * FROM delivery_channels WHERE id = ?", [channelId])), ...listDeliveryChannels(context) }; } function parseScript(content) { const clean = String(content || "").replace(/\r/g, "").trim(); const wordCount = clean.replace(/\s/g, "").length; const headingMatches = [...clean.matchAll(/第\s*(\d+)\s*集[^\n]*/g)]; const chapterBlocks = headingMatches.length ? headingMatches.map((match, index) => { const start = match.index + match[0].length; const end = headingMatches[index + 1]?.index || clean.length; const block = clean.slice(start, end).trim(); return { id: `chapter-${index + 1}`, title: match[0].trim(), words: block.replace(/\s/g, "").length, scenes: Math.max(1, (block.match(/(?:内|外)[::]/g) || []).length), status: "已拆解" }; }) : clean.split(/\n\s*\n/).filter(Boolean).slice(0, 12).map((block, index) => ({ id: `chapter-${index + 1}`, title: `场次 ${String(index + 1).padStart(2, "0")}`, words: block.replace(/\s/g, "").length, scenes: 1, status: "已拆解" })); const names = [...clean.matchAll(/([\u4e00-\u9fa5]{2,4})[::]/g)].map((match) => match[1]).filter((name, index, list) => list.indexOf(name) === index).slice(0, 12); const locationKeywords = ["地铁口", "玻璃连廊", "雨棚", "教室", "客厅", "街道", "医院", "仓库", "山路", "门口"]; const propKeywords = ["手机", "雨伞", "雨披", "路锥", "警戒线", "钥匙", "刀", "书包", "项链", "文件"]; const locations = locationKeywords.filter((item) => clean.includes(item)).map((name) => ({ type: "场景", name, confidence: 0.88, target: "location-locks" })); const props = propKeywords.filter((item) => clean.includes(item)).map((name) => ({ type: "道具", name, confidence: 0.82, target: "prop-locks" })); const extracted = [ ...names.map((name) => ({ type: "角色", name, confidence: 0.94, target: "character-locks" })), ...locations, ...props ]; const lines = clean.split("\n").map((line) => line.trim()).filter(Boolean); const sceneHeaders = lines.map((line, index) => { const match = line.match(/^(?:场景\s*)?(?:\d+[.、]\s*)?(内|外)[::]\s*(.+)$/); return match ? { index, title: `${match[1]} · ${match[2]}` } : null; }).filter(Boolean); const sceneDrafts = (sceneHeaders.length ? sceneHeaders : [{ index: 0, title: "试播集 · 开场" }]).map((header, index) => { const end = sceneHeaders[index + 1]?.index || lines.length; const blockLines = lines.slice(header.index + (sceneHeaders.length ? 1 : 0), end); const dialogueLines = blockLines.map((line) => { const match = line.match(/^([\u4e00-\u9fa5]{2,4})[::]\s*(.+)$/); return match ? { characterId: match[1], text: match[2], emotion: "自然、克制", mouthPlan: "侧脸/反应镜头", targetDurationSec: Math.max(1.5, Math.min(8, Math.round(match[2].length * 0.22 * 10) / 10)) } : null; }).filter(Boolean); const actionLines = blockLines.filter((line) => !/^[\u4e00-\u9fa5]{2,4}[::]/.test(line)); return { id: `scene-${index + 1}`, sceneNumber: index + 1, title: header.title, action: actionLines.join(" ") || "人物在当前场景内完成一个连续、可拍摄的动作。", camera: index === 0 ? "稳定中景,保留环境信息" : "沿用上一镜头轴线的连续中景", dialogue: dialogueLines, sourceLines: blockLines.length }; }); return { wordCount, chapterCount: chapterBlocks.length, chapters: chapterBlocks.length ? chapterBlocks : [{ id: "chapter-1", title: "未命名章节", words: wordCount, scenes: 1, status: "已拆解" }], extracted, sceneDrafts, parser: "local-rule-v1" }; } export function productionGraph(context, options = {}) { const root = ensureProductionRoot(context); const project = requireProject(context); const activeEpisode = options.episodeId ? projectEpisode(context, options.episodeId) : root.episode; const shots = shotRows(context, activeEpisode.id).map(shotPayload); const documents = scriptDocuments(context, activeEpisode.id); const reviews = ensureReviews(context).filter((review) => !review.shot_id || shots.some((shot) => shot.id === review.shot_id)); const locks = projectAssetLocks(context); const characters = locks.filter((asset) => asset.kind === "character"); const locations = locks.filter((asset) => asset.kind === "location"); const props = locks.filter((asset) => asset.kind === "prop"); return { series: root.series, season: root.season, episode: activeEpisode, activeEpisodeId: activeEpisode.id, catalog: { ...productionCatalog(context), activeSeasonId: activeEpisode.season_id, activeEpisodeId: activeEpisode.id }, documents, shots, characters, locations, props, assets: locks, reviews, deliveries: deliveries(context), constraints: { singleFrameOnly: true, requireActualLastFrame: true, localOnlyDefault: true, blockedImageTerms: ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图"] } }; } export function updateBible(context, body = {}) { requirePermission(context, "script:edit"); const project = requireProject(context); const root = ensureProductionRoot(context); const episode = projectEpisode(context, body.episodeId || root.episode.id); const timestamp = now(); const seriesValues = { title: body.title ?? body.seriesTitle ?? root.series.title, logline: body.logline ?? root.series.logline, format: body.format ?? root.series.format, visualStyle: body.visualStyle ?? body.visual_style ?? root.series.visual_style, continuityRule: body.continuityRule ?? body.continuity_rule ?? root.series.continuity_rule, showEngine: body.showEngine ?? body.show_engine ?? root.series.show_engine }; const episodeValues = { title: body.episodeTitle ?? episode.title, status: body.episodeStatus ?? episode.status, targetDurationSec: body.targetDurationSec ?? episode.target_duration_sec, hook: body.hook ?? episode.hook, cliffhanger: body.cliffhanger ?? episode.cliffhanger }; const targetDuration = Number(episodeValues.targetDurationSec); if (!Number.isFinite(targetDuration) || targetDuration < 0 || targetDuration > 3600) { throw httpError(400, "target_duration_invalid", "目标时长必须在 0 到 3600 秒之间"); } withTransaction(() => { dbRun("UPDATE series SET title = ?, logline = ?, format = ?, visual_style = ?, continuity_rule = ?, show_engine = ?, updated_at = ? WHERE id = ? AND project_id = ?", [ String(seriesValues.title).trim(), String(seriesValues.logline).trim(), String(seriesValues.format).trim(), String(seriesValues.visualStyle).trim(), String(seriesValues.continuityRule).trim(), String(seriesValues.showEngine).trim(), timestamp, root.series.id, project.id ]); dbRun("UPDATE episodes SET title = ?, status = ?, target_duration_sec = ?, hook = ?, cliffhanger = ?, updated_at = ? WHERE id = ?", [ String(episodeValues.title).trim(), String(episodeValues.status).trim(), targetDuration, String(episodeValues.hook).trim(), String(episodeValues.cliffhanger).trim(), timestamp, episode.id ]); }); addAudit({ context, action: "series_bible.updated", targetType: "series", targetId: root.series.id, metadata: { episodeId: episode.id, fields: Object.keys(body) } }); return { bible: { series: dbGet("SELECT * FROM series WHERE id = ?", [root.series.id]), season: dbGet("SELECT * FROM seasons WHERE id = ?", [root.season.id]), episode: dbGet("SELECT * FROM episodes WHERE id = ?", [episode.id]) }, graph: productionGraph(context, { episodeId: episode.id }) }; } export function importScript(context, body) { requirePermission(context, "script:edit"); const project = requireProject(context); const content = String(body.content || "").trim(); if (content.length < 10) throw httpError(400, "script_content_required", "剧本内容至少需要 10 个字符"); const root = ensureProductionRoot(context); 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, 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, metadata }, graph: productionGraph(context, { episodeId: body.episodeId || root.episode.id }) }; } export function materializeScript(context, documentId, body = {}) { requirePermission(context, "script:edit"); const project = requireProject(context); const document = dbGet("SELECT * FROM script_documents WHERE id = ? AND project_id = ?", [documentId, project.id]); if (!document) throw httpError(404, "script_not_found", "剧本版本不存在或不属于当前项目", { documentId }); const analysis = parseJson(document.analysis_json, {}); const drafts = Array.isArray(analysis.sceneDrafts) && analysis.sceneDrafts.length ? analysis.sceneDrafts : [{ title: document.title, action: document.content, camera: "稳定中景,保持单一连续画面", dialogue: [] }]; const episodeId = document.episode_id || ensureProductionRoot(context).episode.id; projectEpisode(context, episodeId); const existing = dbGet("SELECT MAX(shot_number) AS shot_number FROM shots WHERE episode_id = ?", [episodeId]); let nextShotNumber = Number(existing?.shot_number || 0); const created = []; const timestamp = now(); withTransaction(() => { for (const draft of drafts) { nextShotNumber += 1; const shotId = `shot-${project.id}-${String(nextShotNumber).padStart(3, "0")}-${Date.now().toString(36)}-${nextShotNumber}`; const payload = { id: shotId, title: String(draft.title || `场景 ${String(nextShotNumber).padStart(2, "0")}`), durationSec: Number(body.durationSec || 6), characterIds: [], locationId: "", propIds: [], camera: String(draft.camera || "稳定中景,保持单一连续画面"), action: String(draft.action || "待补充镜头动作"), firstFrame: nextShotNumber === 1 ? "episode-start" : "AUTO_PREVIOUS_ACTUAL_LAST_FRAME", lastFrame: "pending-actual-last-frame", transitionFromPrevious: nextShotNumber === 1 ? "episode-start" : "actual-last-frame", prompt: "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY.", negativePrompt: "no split screen, no comic panel, no collage, no contact sheet, no storyboard", videoPrompt: "保持角色、服装、道具、天气和机位连续;结尾保留可作为下一段首帧的实际末帧。", seed: null }; dbRun("INSERT INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)", [shotId, episodeId, nextShotNumber, payload.title, payload.firstFrame, payload.lastFrame, JSON.stringify(payload), timestamp, timestamp]); dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'draft', ?, ?)", [`${shotId}-v1`, shotId, JSON.stringify(payload), context.user.id, timestamp]); dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${shotId}-v1`, shotId]); for (let index = 0; index < (draft.dialogue || []).length; index += 1) insertVoiceLine(context, shotId, index + 1, draft.dialogue[index], timestamp); created.push(shotId); } }); addAudit({ context, action: "script.materialized", targetType: "script_document", targetId: documentId, metadata: { shotsCreated: created.length, shotIds: created } }); return { created, graph: productionGraph(context, { episodeId }) }; } export function createShot(context, body) { requireAnyPermission(context, ["script:edit", "prompt:edit"], { mutating: true }); const project = requireProject(context); const root = ensureProductionRoot(context); const episodeId = body.episodeId || root.episode.id; projectEpisode(context, episodeId); const next = dbGet("SELECT MAX(shot_number) AS shot_number FROM shots WHERE episode_id = ?", [episodeId]); const shotNumber = Number(next?.shot_number || 0) + 1; const id = body.id || `shot-${project.id}-${String(shotNumber).padStart(3, "0")}-${Date.now().toString(36)}`; const timestamp = now(); const payload = { id, title: String(body.title || `新镜头 ${String(shotNumber).padStart(2, "0")}`), durationSec: Number(body.durationSec || 6), characterIds: Array.isArray(body.characterIds) ? body.characterIds : [], locationId: String(body.locationId || ""), propIds: Array.isArray(body.propIds) ? body.propIds : [], camera: String(body.camera || "稳定中景,保持单一连续画面"), action: String(body.action || "待填写镜头动作"), firstFrame: String(body.firstFrame || (shotNumber === 1 ? "episode-start" : "AUTO_PREVIOUS_ACTUAL_LAST_FRAME")), lastFrame: String(body.lastFrame || "pending-actual-last-frame"), transitionFromPrevious: String(body.transitionFromPrevious || (shotNumber === 1 ? "episode-start" : "actual-last-frame")), prompt: String(body.prompt || "ONE SINGLE STANDALONE STILL IMAGE FOR ONE VIDEO SHOT ONLY."), negativePrompt: String(body.negativePrompt || "no split screen, no comic panel, no collage, no contact sheet"), videoPrompt: String(body.videoPrompt || "待填写视频动作和实际末帧要求"), seed: body.seed === undefined || body.seed === "" ? null : Number(body.seed) }; withTransaction(() => { dbRun("INSERT INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)", [id, episodeId, shotNumber, payload.title, payload.firstFrame, payload.lastFrame, JSON.stringify(payload), timestamp, timestamp]); dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'draft', ?, ?)", [`${id}-v1`, id, JSON.stringify(payload), context.user.id, timestamp]); dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${id}-v1`, id]); for (let index = 0; index < (body.voiceLines || []).length; index += 1) insertVoiceLine(context, id, index + 1, body.voiceLines[index], timestamp); }); addAudit({ context, action: "shot.created", targetType: "shot", targetId: id, metadata: { shotNumber, episodeId } }); return { shot: shotPayload(dbGet("SELECT * FROM shots WHERE id = ?", [id])), graph: productionGraph(context, { episodeId }) }; } function ensureShot(context, shotId) { const project = requireProject(context); const row = dbGet( `SELECT s.* FROM shots s JOIN episodes e ON e.id = s.episode_id JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE s.id = ? AND sr.project_id = ?`, [shotId, project.id] ); if (!row) throw httpError(404, "shot_not_found", "镜头不存在或不属于当前项目", { shotId }); return row; } function insertVoiceLine(context, shotId, lineNumber, line, timestamp = now()) { const item = line || {}; const id = item.id || `${shotId}-line-${lineNumber}`; dbRun("INSERT INTO voice_lines(id, shot_id, line_number, character_key, text, emotion, mouth_plan, target_duration_sec, voice_id, audio_path, status, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(shot_id, line_number) DO UPDATE SET character_key = excluded.character_key, text = excluded.text, emotion = excluded.emotion, mouth_plan = excluded.mouth_plan, target_duration_sec = excluded.target_duration_sec, voice_id = excluded.voice_id, audio_path = excluded.audio_path, status = excluded.status, updated_at = excluded.updated_at", [id, shotId, lineNumber, String(item.characterId || item.character_key || ""), String(item.text || ""), String(item.emotion || ""), String(item.mouthPlan || item.mouth_plan || "侧脸/反应镜头"), Number(item.targetDurationSec || item.target_duration_sec || 2), String(item.voiceId || item.voice_id || ""), String(item.audioFile || item.audio_path || ""), String(item.status || "draft"), context.user.id, timestamp, timestamp]); } export function updateShot(context, shotId, body) { requireAnyPermission(context, ["script:edit", "prompt:edit"], { mutating: true }); const current = ensureShot(context, shotId); const existing = shotPayload(current); const timestamp = now(); const payload = { ...existing, ...body, id: shotId, characterIds: Array.isArray(body.characterIds) ? body.characterIds : existing.characterIds || [], propIds: Array.isArray(body.propIds) ? body.propIds : existing.propIds || [], durationSec: Number(body.durationSec ?? existing.durationSec ?? 6) }; withTransaction(() => { const nextVersion = Number(existing.versionNumber || 0) + 1; dbRun("UPDATE shots SET title = ?, status = ?, first_frame_path = ?, last_frame_path = ?, continuity_json = ?, updated_at = ? WHERE id = ?", [payload.title, String(body.status || current.status || "draft"), payload.firstFrame, payload.lastFrame, JSON.stringify(payload), timestamp, shotId]); dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)", [`${shotId}-v${nextVersion}`, shotId, nextVersion, JSON.stringify(payload), String(body.status || current.status || "draft"), context.user.id, timestamp]); dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${shotId}-v${nextVersion}`, shotId]); if (Array.isArray(body.voiceLines)) { dbRun("DELETE FROM voice_lines WHERE shot_id = ?", [shotId]); for (let index = 0; index < body.voiceLines.length; index += 1) insertVoiceLine(context, shotId, index + 1, body.voiceLines[index], timestamp); } }); addAudit({ context, action: "shot.updated", targetType: "shot", targetId: shotId, metadata: { version: Number(existing.versionNumber || 0) + 1 } }); return { shot: shotPayload(dbGet("SELECT * FROM shots WHERE id = ?", [shotId])), graph: productionGraph(context, { episodeId: current.episode_id }) }; } export function savePromptVersion(context, shotId, body) { requirePermission(context, "prompt:edit"); const current = ensureShot(context, shotId); const existing = shotPayload(current); const timestamp = now(); const nextVersion = Number(existing.versionNumber || 0) + 1; const payload = { ...existing, prompt: String(body.imagePrompt ?? body.prompt ?? existing.prompt ?? ""), negativePrompt: String(body.negativePrompt ?? existing.negativePrompt ?? ""), videoPrompt: String(body.videoPrompt ?? existing.videoPrompt ?? ""), seed: body.seed === undefined || body.seed === "" ? existing.seed : Number(body.seed) }; dbRun("UPDATE shots SET continuity_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify(payload), timestamp, shotId]); dbRun("INSERT INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, ?, ?, 'draft', ?, ?)", [`${shotId}-v${nextVersion}`, shotId, nextVersion, JSON.stringify(payload), context.user.id, timestamp]); dbRun("UPDATE shots SET current_version_id = ? WHERE id = ?", [`${shotId}-v${nextVersion}`, shotId]); addAudit({ context, action: "shot.prompt.version.created", targetType: "shot_version", targetId: `${shotId}-v${nextVersion}`, metadata: { shotId, version: nextVersion } }); return { shot: shotPayload(dbGet("SELECT * FROM shots WHERE id = ?", [shotId])), graph: productionGraph(context, { episodeId: current.episode_id }) }; } export function listShotVersions(context, shotId) { requireAnyPermission(context, ["script:read", "script:edit", "prompt:edit"]); const shot = ensureShot(context, shotId); return { shotId, currentVersionId: shot.current_version_id || "", versions: dbAll( `SELECT sv.*, u.display_name AS created_by_name FROM shot_versions sv LEFT JOIN users u ON u.id = sv.created_by WHERE sv.shot_id = ? ORDER BY sv.version_number DESC`, [shotId] ).map((version) => ({ ...version, payload: parseJson(version.payload_json, {}), isCurrent: version.id === shot.current_version_id })) }; } export function restoreShotVersion(context, shotId, versionId) { requireAnyPermission(context, ["script:edit", "prompt:edit"], { mutating: true }); const current = ensureShot(context, shotId); const version = dbGet("SELECT * FROM shot_versions WHERE id = ? AND shot_id = ?", [versionId, shotId]); if (!version) throw httpError(404, "shot_version_not_found", "镜头版本不存在或不属于当前镜头", { shotId, versionId }); if (version.id === current.current_version_id) return { shot: shotPayload(current), versions: listShotVersions(context, shotId), graph: productionGraph(context, { episodeId: current.episode_id }) }; const payload = parseJson(version.payload_json, {}); const timestamp = now(); withTransaction(() => { dbRun( `UPDATE shots SET title = ?, status = ?, first_frame_path = ?, last_frame_path = ?, current_version_id = ?, continuity_json = ?, updated_at = ? WHERE id = ?`, [ String(payload.title || current.title), String(version.status || current.status || "draft"), String(payload.firstFrame || current.first_frame_path || ""), String(payload.lastFrame || current.last_frame_path || ""), version.id, JSON.stringify(payload), timestamp, shotId ] ); if (Array.isArray(payload.voiceLines)) { dbRun("DELETE FROM voice_lines WHERE shot_id = ?", [shotId]); for (let index = 0; index < payload.voiceLines.length; index += 1) insertVoiceLine(context, shotId, index + 1, payload.voiceLines[index], timestamp); } }); addAudit({ context, action: "shot.version.restored", targetType: "shot_version", targetId: version.id, metadata: { shotId, version: version.version_number, previousVersionId: current.current_version_id || null } }); const refreshed = dbGet("SELECT * FROM shots WHERE id = ?", [shotId]); return { shot: shotPayload(refreshed), versions: listShotVersions(context, shotId), graph: productionGraph(context, { episodeId: current.episode_id }) }; } export function qaReviews(context) { requirePermission(context, "qa:review"); ensureProductionRoot(context); return { reviews: ensureReviews(context), graph: productionGraph(context) }; } export function runAutomatedQa(context) { requirePermission(context, "qa:review"); requireProjectWritable(context); const project = requireProject(context); ensureReviews(context); const shots = shotRows(context).map(shotPayload); const timestamp = now(); const blockedTerms = ["storyboard", "comic panel", "collage", "contact sheet", "split screen", "多格", "拼图", "分屏", "故事板拼图"]; const results = []; for (const shot of shots) { const bindings = dbAll( `SELECT a.kind, a.lock_status, av.rights_status 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 = ?`, [shot.id] ); // The negative prompt is the evidence of the block rule, not a request to generate that layout. const promptText = [shot.prompt, shot.action, shot.camera].join(" ").toLowerCase(); const blocked = blockedTerms.filter((term) => promptText.includes(term)); const missingLocks = ["character", "location", "prop"].filter((kind) => !bindings.some((binding) => binding.kind === kind && binding.lock_status === "locked" && binding.rights_status === "approved")); const hasVoiceIssue = shot.voiceLines.some((line) => !line.voiceId || !line.audioFile); const needsActualLastFrame = shot.shotNumber > 1 && shot.transitionFromPrevious !== "episode-start"; const bridgeIssue = needsActualLastFrame && (!shot.firstFrame || /pending|auto_previous/i.test(shot.firstFrame)); const checks = [ { lane: "single-frame", ok: blocked.length === 0, blockers: blocked.length ? [`禁用词:${blocked.join("、")}`] : [] }, { lane: "continuity-lock", ok: missingLocks.length === 0, blockers: missingLocks.length ? [`未锁定或未确认:${missingLocks.join("、")}`] : [] }, { lane: "voice-subtitle-asr", ok: !hasVoiceIssue, blockers: hasVoiceIssue ? ["对白缺少固定 voiceId 或音频产物"] : [] }, { lane: "clip-bridge", ok: !bridgeIssue, blockers: bridgeIssue ? ["连续片段缺少上一段实际末帧首帧"] : [] } ]; for (const check of checks) { const reviewId = `review-${shot.id}-${check.lane}`; const current = dbGet("SELECT * FROM reviews WHERE id = ?", [reviewId]); const status = check.ok ? "approved" : "changes_requested"; const evidence = { source: "automated-contract-qa", checkedAt: timestamp, blockers: check.blockers, shotId: shot.id }; if (!current || current.status !== "approved" || parseJson(current.evidence_json, {}).source === "automated-contract-qa") { dbRun("UPDATE reviews SET status = ?, score = ?, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE id = ?", [status, check.ok ? 100 : 40, context.user.id, timestamp, JSON.stringify(evidence), timestamp, reviewId]); } results.push({ shotId: shot.id, lane: check.lane, status, blockers: check.blockers }); } } addAudit({ context, action: "qa.automated.run", targetType: "project", targetId: project.id, metadata: { checks: results.length, passed: results.filter((item) => item.status === "approved").length } }); return { results, reviews: ensureReviews(context), graph: productionGraph(context) }; } export async function runMediaQa(context) { requirePermission(context, "qa:review"); requireProjectWritable(context); const project = requireProject(context); ensureReviews(context); await syncProjectJobArtifacts(context); const shots = shotRows(context).map(shotPayload); const report = await inspectMediaForProject(context, shots); const timestamp = now(); for (const shotReport of report.reports) { for (const gate of shotReport.gates) { const reviewId = `review-${shotReport.shotId}-${gate.lane}`; const current = dbGet("SELECT * FROM reviews WHERE id = ?", [reviewId]); const currentEvidence = parseJson(current?.evidence_json, {}); const isManualDecision = current?.status === "approved" && currentEvidence.source === "manual-review"; if (!isManualDecision) { dbRun( `UPDATE reviews SET status = ?, score = ?, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE id = ?`, [gate.status, gate.status === "approved" ? 100 : gate.status === "changes_requested" ? 40 : null, context.user.id, gate.status === "pending" ? null : timestamp, JSON.stringify({ source: "media-inspection-v1", checkedAt: report.checkedAt, blockers: gate.blockers, evidence: gate.evidence, media: shotReport.media }), timestamp, reviewId] ); } } } addAudit({ context, action: "qa.media_inspection.run", targetType: "project", targetId: project.id, metadata: report.totals }); return { report, reviews: ensureReviews(context), graph: productionGraph(context) }; } function ensureReview(context, reviewId) { const project = requireProject(context); const review = dbGet("SELECT * FROM reviews WHERE id = ? AND project_id = ?", [reviewId, project.id]); if (!review) throw httpError(404, "review_not_found", "质检项不存在或不属于当前项目", { reviewId }); return review; } export function decideReview(context, reviewId, body) { requirePermission(context, "qa:review"); requireProjectWritable(context); const review = ensureReview(context, reviewId); const status = String(body.status || "").trim(); if (!["approved", "rejected", "changes_requested", "pending"].includes(status)) throw httpError(400, "review_status_invalid", "质检状态无效"); const timestamp = now(); const evidence = body.evidence && typeof body.evidence === "object" ? body.evidence : parseJson(review.evidence_json, {}); dbRun("UPDATE reviews SET status = ?, score = ?, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE id = ?", [status, body.score === undefined ? null : Number(body.score), context.user.id, status === "pending" ? null : timestamp, JSON.stringify(evidence), timestamp, reviewId]); addAudit({ context, action: `qa.review.${status}`, targetType: "review", targetId: reviewId, result: status === "approved" ? "pass" : status, metadata: { lane: review.lane, score: body.score ?? null, evidence } }); const eventKey = status === "approved" ? "review.approved" : status === "changes_requested" ? "review.changes_requested" : status === "rejected" ? "review.rejected" : "review.updated"; void dispatchNotificationEvent({ context, eventKey, payload: { reviewId, lane: review.lane, status, shotId: review.shot_id, targetId: reviewId } }); return { review: dbGet("SELECT * FROM reviews WHERE id = ?", [reviewId]), reviews: ensureReviews(context) }; } export function addReviewComment(context, reviewId, body) { requirePermission(context, "qa:review"); requireProjectWritable(context); ensureReview(context, reviewId); const bodyText = String(body.body || "").trim(); if (!bodyText) throw httpError(400, "comment_required", "质检评论不能为空"); const id = makeId("comment"); dbRun("INSERT INTO review_comments(id, review_id, author_user_id, body, created_at) VALUES (?, ?, ?, ?, ?)", [id, reviewId, context.user.id, bodyText, now()]); addAudit({ context, action: "qa.comment.created", targetType: "review_comment", targetId: id, metadata: { reviewId } }); void dispatchNotificationEvent({ context, eventKey: "review.comment", payload: { reviewId, targetId: reviewId, body: bodyText } }); return { comments: dbAll("SELECT c.*, u.display_name AS author_name FROM review_comments c LEFT JOIN users u ON u.id = c.author_user_id WHERE c.review_id = ? ORDER BY c.created_at", [reviewId]) }; } export function listDeliveries(context) { requireAnyPermission(context, ["delivery:view", "delivery:approve"]); 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); const latest = dbGet("SELECT COUNT(*) AS count FROM deliveries WHERE project_id = ?", [project.id]); const version = String(body.version || `v${Number(latest?.count || 0) + 1}.0`); const id = makeId("delivery"); const timestamp = now(); const manifestPath = String(body.manifestPath || `storage/deliveries/${project.id}/${version}/delivery-manifest.json`); if (!manifestPath.startsWith("storage/") || manifestPath.includes("..") || manifestPath.startsWith("/")) throw httpError(400, "manifest_path_invalid", "交付 manifest 必须写入 storage/ 下的安全相对路径"); dbRun("INSERT INTO deliveries(id, organization_id, workspace_id, project_id, version, manifest_path, channel, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'draft', ?, ?)", [id, context.organization.id, context.workspace.id, project.id, version, manifestPath, String(body.channel || "internal"), timestamp, timestamp]); addAudit({ context, action: "delivery.created", targetType: "delivery", targetId: id, metadata: { version, manifestPath } }); void dispatchNotificationEvent({ context, eventKey: "delivery.created", payload: { deliveryId: id, version, targetId: id } }); return { delivery: dbGet("SELECT * FROM deliveries WHERE id = ?", [id]), deliveries: deliveries(context) }; } function deliveryForContext(context, deliveryId) { const project = requireProject(context); const delivery = dbGet( `SELECT * FROM deliveries WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?`, [deliveryId, context.organization.id, context.workspace.id, project.id] ); if (!delivery) throw httpError(404, "delivery_not_found", "交付版本不存在或不属于当前项目", { deliveryId }); return delivery; } function releasePayload(row) { if (!row) return null; return { ...row, channel: { id: row.channel_id, name: row.channel_name, kind: row.channel_kind, enabled: Boolean(row.channel_enabled), endpoint: row.channel_endpoint || "", requireApproval: Boolean(row.channel_require_approval) }, preflight: parseJson(row.preflight_json, {}), result: parseJson(row.result_json, {}) }; } function releaseForContext(context, releaseId) { const project = requireProject(context); const release = dbGet( `SELECT dr.*, dc.name AS channel_name, dc.kind AS channel_kind, dc.enabled AS channel_enabled, dc.endpoint AS channel_endpoint, dc.require_approval AS channel_require_approval, d.version AS delivery_version, d.status AS delivery_status, requested.display_name AS requested_by_name, reviewed.display_name AS reviewed_by_name, published.display_name AS published_by_name FROM delivery_releases dr JOIN delivery_channels dc ON dc.id = dr.channel_id JOIN deliveries d ON d.id = dr.delivery_id LEFT JOIN users requested ON requested.id = dr.requested_by LEFT JOIN users reviewed ON reviewed.id = dr.reviewed_by LEFT JOIN users published ON published.id = dr.published_by WHERE dr.id = ? AND dr.organization_id = ? AND dr.workspace_id = ? AND dr.project_id = ?`, [releaseId, context.organization.id, context.workspace.id, project.id] ); if (!release) throw httpError(404, "delivery_release_not_found", "发布记录不存在或不属于当前项目", { releaseId }); return release; } 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 clearanceRecord = options.persistClearance ? persistDeliveryClearanceReport(context, delivery, { releaseId: options.releaseId || "", channel }) : null; const clearance = clearanceRecord?.report || buildDeliveryClearanceReport(context, delivery, { releaseId: options.releaseId || "", channel }); if (clearance.status === "blocked") blockers.push(...(clearance.blockers || [])); blockers = dedupeBlockers(blockers); return { ok: blockers.length === 0, checkedAt: clearance.checkedAt || now(), projectId: project.id, deliveryId: delivery.id, channelId: channel.id, 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 || {} } }; } export function listDeliveryReleases(context, deliveryId) { requireAnyPermission(context, ["delivery:view", "delivery:approve"]); deliveryForContext(context, deliveryId); return { deliveryId, releases: dbAll( `SELECT dr.*, dc.name AS channel_name, dc.kind AS channel_kind, dc.enabled AS channel_enabled, dc.endpoint AS channel_endpoint, dc.require_approval AS channel_require_approval, d.version AS delivery_version, d.status AS delivery_status, requested.display_name AS requested_by_name, reviewed.display_name AS reviewed_by_name, published.display_name AS published_by_name FROM delivery_releases dr JOIN delivery_channels dc ON dc.id = dr.channel_id JOIN deliveries d ON d.id = dr.delivery_id LEFT JOIN users requested ON requested.id = dr.requested_by LEFT JOIN users reviewed ON reviewed.id = dr.reviewed_by LEFT JOIN users published ON published.id = dr.published_by WHERE dr.delivery_id = ? AND dr.organization_id = ? AND dr.workspace_id = ? AND dr.project_id = ? ORDER BY dr.created_at DESC`, [deliveryId, context.organization.id, context.workspace.id, context.project.id] ).map(releasePayload) }; } export function createDeliveryRelease(context, deliveryId, body = {}) { requirePermission(context, "delivery:approve"); const delivery = deliveryForContext(context, deliveryId); const requestedChannelId = String(body.channelId || "").trim(); const channel = requestedChannelId ? channelForContext(context, requestedChannelId) : ensureDefaultDeliveryChannel(context); const idempotencyKey = String(body.idempotencyKey || "").trim().slice(0, 160); if (idempotencyKey) { const existing = dbGet( `SELECT dr.*, dc.name AS channel_name, dc.kind AS channel_kind, dc.enabled AS channel_enabled, dc.endpoint AS channel_endpoint, dc.require_approval AS channel_require_approval, d.version AS delivery_version, d.status AS delivery_status, requested.display_name AS requested_by_name, reviewed.display_name AS reviewed_by_name, published.display_name AS published_by_name FROM delivery_releases dr JOIN delivery_channels dc ON dc.id = dr.channel_id JOIN deliveries d ON d.id = dr.delivery_id LEFT JOIN users requested ON requested.id = dr.requested_by LEFT JOIN users reviewed ON reviewed.id = dr.reviewed_by LEFT JOIN users published ON published.id = dr.published_by WHERE dr.organization_id = ? AND dr.project_id = ? AND dr.idempotency_key = ?`, [context.organization.id, context.project.id, idempotencyKey] ); if (existing) return { release: releasePayload(existing), idempotent: true, ...listDeliveryReleases(context, deliveryId) }; } const submit = body.submit !== false; 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(); const status = submit ? "submitted" : "draft"; dbRun( `INSERT INTO delivery_releases( id, organization_id, workspace_id, project_id, delivery_id, channel_id, status, idempotency_key, requested_by, requested_at, preflight_json, result_json, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '{}', ?, ?)`, [id, context.organization.id, context.workspace.id, context.project.id, delivery.id, channel.id, status, idempotencyKey, submit ? context.user.id : null, submit ? timestamp : null, JSON.stringify(preflight), timestamp, timestamp] ); addAudit({ context, action: submit ? "delivery.release.submitted" : "delivery.release.created", targetType: "delivery_release", targetId: id, metadata: { deliveryId, channelId: channel.id, status, idempotencyKey: Boolean(idempotencyKey) } }); void dispatchNotificationEvent({ context, eventKey: submit ? "delivery.release.submitted" : "delivery.release.created", payload: { releaseId: id, deliveryId, targetId: id, status } }); return { release: releasePayload(releaseForContext(context, id)), ...listDeliveryReleases(context, deliveryId) }; } export function decideDeliveryRelease(context, releaseId, body = {}) { requirePermission(context, "delivery:approve"); const release = releaseForContext(context, releaseId); const status = String(body.status || "").trim(); const note = String(body.note || "").trim().slice(0, 4000); const delivery = deliveryForContext(context, release.delivery_id); const channel = channelForContext(context, release.channel_id); let preflight = parseJson(release.preflight_json, {}); if (status === "draft") { if (release.status !== "rejected") throw httpError(409, "release_transition_invalid", "只有已驳回的发布申请可以退回草稿"); const timestamp = now(); 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, { 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, { 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]); } else if (status === "rejected") { if (release.status !== "submitted") throw httpError(409, "release_transition_invalid", "只有已提交的发布申请可以驳回"); const timestamp = now(); dbRun("UPDATE delivery_releases SET status = 'rejected', reviewed_by = ?, reviewed_at = ?, decision_note = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, note || "审批人要求修改后重新提交", timestamp, releaseId]); } else { throw httpError(400, "release_status_invalid", "发布申请状态只能是 draft、submitted、approved 或 rejected"); } addAudit({ context, action: `delivery.release.${status}`, targetType: "delivery_release", targetId: releaseId, result: status === "approved" ? "pass" : status, metadata: { deliveryId: release.delivery_id, channelId: release.channel_id, note } }); void dispatchNotificationEvent({ context, eventKey: `delivery.release.${status}`, payload: { releaseId, deliveryId: release.delivery_id, targetId: releaseId, status } }); return { release: releasePayload(releaseForContext(context, releaseId)), ...listDeliveryReleases(context, release.delivery_id) }; } export async function publishDeliveryRelease(context, releaseId) { requirePermission(context, "delivery:approve"); const release = releaseForContext(context, releaseId); if (release.status === "published") return { release: releasePayload(release), idempotent: true, ...listDeliveryReleases(context, release.delivery_id) }; 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, { 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`; const manifestOutputPath = `${outputDirectory}/delivery-manifest.json`; const timestamp = now(); const releaseDocument = { schema: "ai-drama-platform.delivery-release.v1", publishedAt: timestamp, release: { id: release.id, status: "published", requestedBy: release.requested_by, reviewedBy: release.reviewed_by }, 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 }; try { const outputDirectoryTarget = safeStoragePath(`${outputDirectory}/release.json`); const manifestTarget = safeStoragePath(`${outputDirectory}/delivery-manifest.json`); const sourceManifest = safeStoragePath(preflight.manifestPath); if (!outputDirectoryTarget || !manifestTarget || !sourceManifest) throw httpError(500, "release_output_path_invalid", "发布输出路径不满足本地存储安全约束"); mkdirSync(resolve(projectRoot, outputDirectory), { recursive: true }); if (!existsSync(sourceManifest.absolute)) throw httpError(409, "release_manifest_missing", "发布时找不到当前批次 Manifest"); writeFileSync(manifestTarget.absolute, readFileSync(sourceManifest.absolute)); writeFileSync(outputDirectoryTarget.absolute, `${JSON.stringify(releaseDocument, null, 2)}\n`, "utf8"); if (channel.kind === "local-webhook") { const authValue = channel.auth_env ? process.env[channel.auth_env] : ""; if (channel.auth_env && !authValue) throw httpError(409, "delivery_channel_auth_missing", `本地 Webhook 所需环境变量未设置:${channel.auth_env}`); const headers = { "content-type": "application/json", accept: "application/json" }; if (authValue) headers.authorization = `Bearer ${authValue}`; const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { const response = await fetch(channel.endpoint, { method: "POST", headers, body: JSON.stringify(releaseDocument), signal: controller.signal }); const responseText = await response.text(); if (!response.ok) throw httpError(502, "delivery_webhook_failed", `本地 Webhook 返回 HTTP ${response.status}`, { status: response.status, body: responseText.slice(0, 1000) }); result = { ...result, webhookStatus: response.status, webhookBody: responseText.slice(0, 1000) }; } finally { clearTimeout(timeout); } } dbRun("UPDATE delivery_releases SET status = 'published', published_by = ?, published_at = ?, output_path = ?, preflight_json = ?, result_json = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, releaseOutputPath, JSON.stringify(preflight), JSON.stringify(result), timestamp, releaseId]); addUsage({ context, kind: "delivery_release_published", units: 1, unitName: "release", metadata: { releaseId, deliveryId: delivery.id, channelId: channel.id, channelKind: channel.kind } }); addAudit({ context, action: "delivery.release.published", targetType: "delivery_release", targetId: releaseId, metadata: { deliveryId: delivery.id, channelId: channel.id, outputPath: releaseOutputPath, channelKind: channel.kind } }); void dispatchNotificationEvent({ context, eventKey: "delivery.release.published", payload: { releaseId, deliveryId: delivery.id, targetId: releaseId, outputPath: releaseOutputPath } }); } catch (error) { const failure = { kind: channel.kind, error: error.message, code: error.code || "delivery_release_publish_failed" }; dbRun("UPDATE delivery_releases SET status = 'failed', result_json = ?, updated_at = ? WHERE id = ?", [JSON.stringify(failure), now(), releaseId]); addAudit({ context, action: "delivery.release.publish_failed", targetType: "delivery_release", targetId: releaseId, result: "error", metadata: { deliveryId: delivery.id, channelId: channel.id, error: error.message, code: error.code || "delivery_release_publish_failed" } }); if (error.status) throw error; throw httpError(502, "delivery_release_publish_failed", `发布执行失败:${error.message}`); } return { release: releasePayload(releaseForContext(context, releaseId)), ...listDeliveryReleases(context, delivery.id) }; } function deliveryBatchPayload(row) { return { ...row, source: parseJson(row.source_json, {}), result: parseJson(row.result_json, {}), 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`, [row.id] ).map((item) => ({ ...item, metadata: parseJson(item.metadata_json, {}) })) }; } export function listDeliveryBatches(context, deliveryId) { requireAnyPermission(context, ["delivery:view", "delivery:approve"]); deliveryForContext(context, deliveryId); return { deliveryId, batches: dbAll( `SELECT db.*, u.display_name AS created_by_name FROM delivery_batches db LEFT JOIN users u ON u.id = db.created_by WHERE db.delivery_id = ? ORDER BY db.batch_number DESC`, [deliveryId] ).map(deliveryBatchPayload) }; } export function createDeliveryBatch(context, deliveryId, body = {}) { requirePermission(context, "delivery:approve"); const delivery = deliveryForContext(context, deliveryId); const compositionId = body.compositionId ? String(body.compositionId) : ""; const composition = compositionId ? dbGet("SELECT * FROM media_compositions WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [compositionId, context.organization.id, context.workspace.id, context.project.id]) : null; if (compositionId && !composition) throw httpError(404, "composition_not_found", "合成记录不存在或不属于当前项目", { compositionId }); const latest = dbGet("SELECT MAX(batch_number) AS batch_number FROM delivery_batches WHERE delivery_id = ?", [deliveryId]); const batchNumber = Number(latest?.batch_number || 0) + 1; const id = makeId("delivery-batch"); const timestamp = now(); const shots = shotRows(context).map(shotPayload); const shotArtifacts = shots.map((shot) => ({ shot, artifact: latestArtifactForShot(context, shot.id, "video") })); const artifactBlockers = shotArtifacts .filter(({ artifact }) => !artifact || artifact.status !== "inspected" || !artifact.sha256 || !artifact.path) .map(({ shot, artifact }) => ({ shotId: shot.id, reason: artifact ? `视频文件${artifact.status === "missing" ? "不存在" : "未通过文件检查"}` : "没有已登记的视频媒体证据" })); const source = { schema: "ai-drama-platform.delivery-batch-source.v2", capturedAt: timestamp, deliveryId, deliveryVersion: delivery.version, compositionId: composition?.id || null, blockers: artifactBlockers, shots: shotArtifacts.map(({ shot, artifact }) => ({ shotId: shot.id, versionId: shot.versionId || null, versionNumber: shot.versionNumber || 1, firstFrame: shot.firstFrame || "", lastFrame: artifact?.last_frame_path || shot.lastFrame || "", transitionFromPrevious: shot.transitionFromPrevious || "", artifactId: artifact?.id || null, sourcePath: artifact?.path || "", sourceSha256: artifact?.sha256 || "", mediaStatus: artifact?.status || "missing" })) }; const manifestPath = String(body.manifestPath || `storage/deliveries/${context.project.id}/${delivery.version}/batches/batch-${batchNumber}/manifest.json`); if (!manifestPath.startsWith("storage/") || manifestPath.includes("..") || manifestPath.startsWith("/")) throw httpError(400, "manifest_path_invalid", "交付 manifest 必须写入 storage/ 下的安全相对路径"); withTransaction(() => { dbRun( `INSERT INTO delivery_batches( id, organization_id, workspace_id, project_id, delivery_id, batch_number, label, status, manifest_path, composition_id, source_json, result_json, created_by, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, 'prepared', ?, ?, ?, ?, ?, ?, ?)`, [id, context.organization.id, context.workspace.id, context.project.id, deliveryId, batchNumber, String(body.label || `批次 ${batchNumber}`), manifestPath, composition?.id || null, JSON.stringify(source), JSON.stringify({ blockers: artifactBlockers, artifactCount: shotArtifacts.filter(({ artifact }) => artifact).length }), context.user.id, timestamp, timestamp] ); for (let index = 0; index < shotArtifacts.length; index += 1) { const { shot, artifact } = shotArtifacts[index]; dbRun( `INSERT INTO delivery_batch_items( id, batch_id, shot_id, sequence_number, source_path, actual_last_frame_path, source_sha256, metadata_json, created_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [makeId("delivery-item"), id, shot.id, index + 1, String(artifact?.path || ""), String(artifact?.last_frame_path || shot.lastFrame || ""), String(artifact?.sha256 || ""), JSON.stringify({ versionId: shot.versionId || null, versionNumber: shot.versionNumber || 1, artifactId: artifact?.id || null, artifactStatus: artifact?.status || "missing", durationSec: artifact?.durationSec || 0, width: artifact?.width || 0, height: artifact?.height || 0 }), timestamp] ); } }); const manifest = { schema: "ai-drama-platform.delivery-manifest.v2", generatedAt: timestamp, delivery: { id: delivery.id, version: delivery.version, channel: delivery.channel }, batch: { id, number: batchNumber, label: String(body.label || `批次 ${batchNumber}`), compositionId: composition?.id || null }, blockers: artifactBlockers, items: source.shots }; mkdirSync(resolve(projectRoot, manifestPath, ".."), { recursive: true }); writeFileSync(resolve(projectRoot, manifestPath), `${JSON.stringify(manifest, null, 2)}\n`, "utf8"); dbRun("UPDATE delivery_batches SET result_json = ? WHERE id = ?", [JSON.stringify({ blockers: artifactBlockers, artifactCount: shotArtifacts.filter(({ artifact }) => artifact).length, manifestWritten: true }), id]); addAudit({ context, action: "delivery.batch.created", targetType: "delivery_batch", targetId: id, metadata: { deliveryId, batchNumber, shotCount: shots.length, manifestPath, artifactBlockers: artifactBlockers.length } }); return { batch: deliveryBatchPayload(dbGet("SELECT * FROM delivery_batches WHERE id = ?", [id])), batches: listDeliveryBatches(context, deliveryId).batches }; } export function activateDeliveryBatch(context, batchId) { requirePermission(context, "delivery:approve"); const project = requireProject(context); const batch = dbGet("SELECT * FROM delivery_batches WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [batchId, context.organization.id, context.workspace.id, project.id]); if (!batch) throw httpError(404, "delivery_batch_not_found", "交付批次不存在或不属于当前项目", { batchId }); if (batch.status === "rolled_back") throw httpError(409, "delivery_batch_rolled_back", "已回滚批次不能重新激活"); const timestamp = now(); withTransaction(() => { dbRun("UPDATE delivery_batches SET status = 'superseded', updated_at = ? WHERE delivery_id = ? AND status = 'active'", [timestamp, batch.delivery_id]); dbRun("UPDATE delivery_batches SET status = 'active', updated_at = ? WHERE id = ?", [timestamp, batchId]); dbRun("UPDATE deliveries SET active_batch_id = ?, updated_at = ? WHERE id = ?", [batchId, timestamp, batch.delivery_id]); }); addAudit({ context, action: "delivery.batch.activated", targetType: "delivery_batch", targetId: batchId, metadata: { deliveryId: batch.delivery_id, batchNumber: batch.batch_number } }); return { batch: deliveryBatchPayload(dbGet("SELECT * FROM delivery_batches WHERE id = ?", [batchId])), batches: listDeliveryBatches(context, batch.delivery_id).batches }; } export function rollbackDeliveryBatch(context, batchId) { requirePermission(context, "delivery:approve"); const project = requireProject(context); const batch = dbGet("SELECT * FROM delivery_batches WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [batchId, context.organization.id, context.workspace.id, project.id]); if (!batch) throw httpError(404, "delivery_batch_not_found", "交付批次不存在或不属于当前项目", { batchId }); if (batch.status !== "active") throw httpError(409, "delivery_batch_not_active", "只有当前生效批次可以回滚", { status: batch.status }); const previous = dbGet("SELECT * FROM delivery_batches WHERE delivery_id = ? AND batch_number < ? AND status IN ('superseded', 'prepared', 'active') ORDER BY batch_number DESC LIMIT 1", [batch.delivery_id, batch.batch_number]); if (!previous) throw httpError(409, "delivery_batch_no_rollback_target", "当前批次没有可恢复的历史批次"); const timestamp = now(); withTransaction(() => { dbRun("UPDATE delivery_batches SET status = 'rolled_back', rollback_of_batch_id = ?, updated_at = ? WHERE id = ?", [previous.id, timestamp, batch.id]); dbRun("UPDATE delivery_batches SET status = 'active', updated_at = ? WHERE id = ?", [timestamp, previous.id]); dbRun("UPDATE deliveries SET active_batch_id = ?, updated_at = ? WHERE id = ?", [previous.id, timestamp, batch.delivery_id]); }); addAudit({ context, action: "delivery.batch.rolled_back", targetType: "delivery_batch", targetId: batchId, metadata: { deliveryId: batch.delivery_id, fromBatch: batch.batch_number, toBatch: previous.batch_number, restoredBatchId: previous.id } }); return { restoredBatch: deliveryBatchPayload(dbGet("SELECT * FROM delivery_batches WHERE id = ?", [previous.id])), batches: listDeliveryBatches(context, batch.delivery_id).batches }; } export function approveDelivery(context, deliveryId, body = {}) { requirePermission(context, "delivery:approve"); const project = requireProject(context); const delivery = dbGet("SELECT * FROM deliveries WHERE id = ? AND project_id = ?", [deliveryId, project.id]); if (!delivery) throw httpError(404, "delivery_not_found", "交付版本不存在或不属于当前项目", { deliveryId }); const reviews = ensureReviews(context); const reviewBlockers = reviews.filter((review) => review.status !== "approved").map((review) => ({ id: review.id, type: "review", lane: review.lane, status: review.status, shotId: review.shot_id })); const jobBlockers = 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]).map((job) => ({ id: job.id, type: "job", kind: job.kind, status: job.status, shotId: job.shot_id })); const activeBatch = delivery.active_batch_id ? dbGet("SELECT * FROM delivery_batches WHERE id = ? AND delivery_id = ?", [delivery.active_batch_id, delivery.id]) : null; const batchBlockers = !activeBatch ? [{ id: delivery.id, type: "delivery_batch", status: "missing", reason: "交付版本尚未激活一个交付批次" }] : dbAll("SELECT id, shot_id, source_path, actual_last_frame_path, source_sha256, metadata_json FROM delivery_batch_items WHERE batch_id = ? ORDER BY sequence_number", [activeBatch.id]) .flatMap((item) => { const metadata = parseJson(item.metadata_json, {}); const missing = []; if (!item.source_path || !item.source_sha256 || metadata.artifactStatus !== "inspected") missing.push("视频文件证据"); 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 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, 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 }; } function scopedJob(context, jobId) { const project = requireProject(context); const job = dbGet("SELECT * FROM generation_jobs WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?", [jobId, context.organization.id, context.workspace.id, project.id]); if (!job) throw httpError(404, "job_not_found", "任务不存在或不属于当前项目", { jobId }); return job; } export function updateJob(context, jobId, action, body = {}) { const job = scopedJob(context, jobId); if (action === "priority") { requireAnyPermission(context, ["job:prioritize", "queue:manage"]); requireProjectWritable(context); const priority = Number(body.priority); if (!Number.isFinite(priority) || priority < 1 || priority > 100) throw httpError(400, "priority_invalid", "优先级必须在 1 到 100 之间"); dbRun("UPDATE generation_jobs SET priority = ?, updated_at = ? WHERE id = ?", [priority, now(), jobId]); addAudit({ context, action: "generation_job.priority.updated", targetType: "generation_job", targetId: jobId, metadata: { previous: job.priority, priority } }); } else if (action === "retry") { requireAnyPermission(context, ["job:prioritize", "queue:manage"]); requireProjectWritable(context); if (!["failed", "cancelled"].includes(job.status)) throw httpError(409, "job_not_retryable", "只有失败或已取消任务可以重试", { status: job.status }); const timestamp = now(); const attempt = Number(dbGet("SELECT MAX(attempt_number) AS attempt_number FROM job_attempts WHERE job_id = ?", [jobId])?.attempt_number || 0) + 1; dbRun("UPDATE generation_jobs SET status = 'queued', qa_status = 'wait', updated_at = ? WHERE id = ?", [timestamp, jobId]); dbRun("INSERT INTO job_attempts(id, job_id, attempt_number, runner_id, status, created_at) VALUES (?, ?, ?, 'local-runner', 'queued', ?)", [makeId("attempt"), jobId, attempt, timestamp]); addAudit({ context, action: "generation_job.retried", targetType: "generation_job", targetId: jobId, metadata: { attempt } }); } else if (action === "cancel") { requireAnyPermission(context, ["job:prioritize", "queue:manage"]); requireProjectWritable(context); if (!["queued", "running"].includes(job.status)) throw httpError(409, "job_not_cancellable", "当前任务状态不能取消", { status: job.status }); dbRun("UPDATE generation_jobs SET status = 'cancelled', updated_at = ? WHERE id = ?", [now(), jobId]); addAudit({ context, action: "generation_job.cancelled", targetType: "generation_job", targetId: jobId }); } else { throw httpError(400, "job_action_invalid", "不支持的任务动作", { action }); } return { job: dbGet("SELECT * FROM generation_jobs WHERE id = ?", [jobId]) }; } export function exportAudit(context, limit = 500) { requirePermission(context, "audit:view"); return dbAll( `SELECT a.*, u.display_name AS actor_name FROM audit_logs a LEFT JOIN users u ON u.id = a.actor_user_id WHERE a.organization_id = ? AND (a.workspace_id IS NULL OR a.workspace_id = ?) AND (a.project_id IS NULL OR a.project_id = ?) ORDER BY a.created_at DESC LIMIT ?`, [context.organization.id, context.workspace.id, context.project?.id || "", limit] ).map((item) => ({ ...item, metadata: parseJson(item.metadata_json, {}) })); }