Files
ai-drama-platform/scripts/smoke-delivery-clearance.mjs
2026-09-01 14:38:42 +08:00

330 lines
20 KiB
JavaScript

import assert from "node:assert/strict";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { dbAll, dbGet, dbRun, withTransaction } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const projectRoot = resolve(import.meta.dirname, "..");
const organizationId = "org-studio-lab";
const workspaceId = "ws-local-aidrama";
const runId = Date.now();
const projectId = `smoke-clearance-${runId}`;
const sourceRoot = `storage/smoke-clearance-${runId}`;
const sourcePath = `${sourceRoot}/clip.mp4`;
const lastFramePath = `${sourceRoot}/actual-last-frame.jpg`;
const manifestPath = `${sourceRoot}/manifest.json`;
const batchId = `delivery-clearance-batch-${runId}`;
const voiceId = `voice-clearance-${runId}`;
const customerId = `cust-clearance-${runId}`;
const contractId = `contract-clearance-${runId}`;
const createdAssetIds = [];
let deliveryId = "";
let releaseId = "";
let knowledgeDocumentId = "";
let scriptId = "";
async function request(path, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
function expectOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
function expectStatus(result, status, label) {
assert.equal(result.response.status, status, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
async function login(email) {
const result = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password: "Demo@123456" })
}), `${email} login`);
return { authorization: `Bearer ${result.session.token}`, "x-organization-id": organizationId, "x-workspace-id": workspaceId };
}
async function createApprovedAsset(headers, body) {
const created = expectOk(await request("/api/assets", {
method: "POST",
headers,
body: JSON.stringify({
lockStatus: "locked",
contentSha256: "b".repeat(64),
provenance: { sourceType: "original", sourceName: `${body.name} 原创设定`, sourceRef: `smoke://${runId}/${body.kind}` },
tags: ["smoke", "clearance"],
...body
})
}), `create ${body.kind} asset`);
createdAssetIds.push(created.asset.id);
const approved = expectOk(await request(`/api/assets/${encodeURIComponent(created.asset.id)}/rights`, {
method: "POST",
headers,
body: JSON.stringify({
rightsStatus: "approved",
provenance: { sourceType: "original", sourceName: `${body.name} 原创设定`, sourceRef: `smoke://${runId}/${body.kind}` },
evidence: { reference: `smoke-evidence://${runId}/${body.kind}` },
licenseScope: "commercial-platform-test"
})
}), `approve ${body.kind} asset`);
return approved.asset;
}
async function bind(headers, assetId, shotId, usageRole) {
return expectOk(await request(`/api/assets/${encodeURIComponent(assetId)}/bindings`, {
method: "POST",
headers,
body: JSON.stringify({ shotId, usageRole })
}), `bind ${assetId}`);
}
function cleanup() {
withTransaction(() => {
dbRun("DELETE FROM contract_license_events WHERE contract_id = ? OR customer_id = ? OR project_id = ?", [contractId, customerId, projectId]);
dbRun("DELETE FROM contract_project_bindings WHERE contract_id = ? OR project_id = ?", [contractId, projectId]);
dbRun("DELETE FROM production_contracts WHERE id = ?", [contractId]);
dbRun("DELETE FROM customer_contacts WHERE customer_id = ?", [customerId]);
dbRun("DELETE FROM customers WHERE id = ?", [customerId]);
dbRun("DELETE FROM delivery_clearance_reports WHERE project_id = ?", [projectId]);
dbRun("DELETE FROM delivery_releases WHERE project_id = ?", [projectId]);
dbRun("DELETE FROM delivery_batch_items WHERE batch_id = ?", [batchId]);
dbRun("DELETE FROM delivery_batches WHERE project_id = ?", [projectId]);
dbRun("DELETE FROM deliveries WHERE project_id = ?", [projectId]);
for (const assetId of createdAssetIds) {
dbRun("DELETE FROM asset_governance_reviews WHERE asset_id = ?", [assetId]);
dbRun("DELETE FROM asset_bindings WHERE asset_id = ?", [assetId]);
dbRun("DELETE FROM asset_versions WHERE asset_id = ?", [assetId]);
dbRun("DELETE FROM assets WHERE id = ?", [assetId]);
}
if (knowledgeDocumentId) {
dbRun("DELETE FROM knowledge_governance_reviews WHERE document_id = ?", [knowledgeDocumentId]);
dbRun("DELETE FROM knowledge_chunks WHERE document_id = ?", [knowledgeDocumentId]);
dbRun("DELETE FROM knowledge_document_versions WHERE document_id = ?", [knowledgeDocumentId]);
dbRun("DELETE FROM knowledge_documents WHERE id = ?", [knowledgeDocumentId]);
}
if (scriptId) dbRun("DELETE FROM script_documents WHERE id = ?", [scriptId]);
dbRun("DELETE FROM voice_lines WHERE shot_id IN (SELECT id FROM shots WHERE episode_id IN (SELECT id FROM episodes WHERE season_id IN (SELECT id FROM seasons WHERE series_id = ?)))", [`series-${projectId}`]);
dbRun("DELETE FROM reviews WHERE project_id = ?", [projectId]);
dbRun("DELETE FROM shots WHERE episode_id IN (SELECT id FROM episodes WHERE season_id IN (SELECT id FROM seasons WHERE series_id = ?))", [`series-${projectId}`]);
dbRun("DELETE FROM episodes WHERE season_id IN (SELECT id FROM seasons WHERE series_id = ?)", [`series-${projectId}`]);
dbRun("DELETE FROM seasons WHERE series_id = ?", [`series-${projectId}`]);
dbRun("DELETE FROM series WHERE id = ?", [`series-${projectId}`]);
dbRun("DELETE FROM audit_logs WHERE project_id = ? OR metadata_json LIKE ?", [projectId, `%${projectId}%`]);
dbRun("DELETE FROM usage_events WHERE project_id = ?", [projectId]);
dbRun("DELETE FROM projects WHERE id = ?", [projectId]);
});
}
function seedContractClearance() {
const timestamp = new Date().toISOString();
dbRun(
"INSERT OR IGNORE INTO customers(id, organization_id, name, legal_name, code, customer_type, status, industry, region, billing_email, tax_id, notes, tags_json, metadata_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'internal', 'active', 'AI 短剧', 'CN', 'clearance@example.local', '', 'delivery clearance smoke customer', '[\"smoke\",\"clearance\"]', '{}', ?, ?, ?)",
[customerId, organizationId, `清算烟测客户 ${runId}`, `清算烟测客户 ${runId} 有限公司`, `CLR-${runId}`, producerUserId(), timestamp, timestamp]
);
dbRun(
"INSERT OR IGNORE INTO production_contracts(id, organization_id, customer_id, contract_number, title, contract_type, status, approval_status, effective_at, expires_at, signed_at, currency, amount, license_scope_json, rights_json, delivery_terms_json, evidence_json, risk_json, approval_note, created_by, updated_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 'production_license', 'active', 'approved', ?, ?, ?, 'CNY', 0, ?, ?, ?, ?, ?, 'smoke clearance contract', ?, ?, ?, ?)",
[
contractId,
organizationId,
customerId,
`CLR-CTR-${runId}`,
`清算烟测授权 ${runId}`,
new Date(Date.now() - 86400000).toISOString(),
new Date(Date.now() + 365 * 86400000).toISOString(),
timestamp,
JSON.stringify({ channels: ["local-file", "private-delivery-portal"], territories: ["CN"], deliverables: ["vertical-video", "delivery-manifest"], platforms: ["internal-review"], exclusive: false, commercialUse: true, language: ["zh-CN"] }),
JSON.stringify({ originalStory: true, derivativeProduction: true, aiGeneratedAssetsAllowed: true, voiceCloneAllowed: false, fixedVoiceRequired: true, singleFramePolicyRequired: true }),
JSON.stringify({ customerPortalAllowed: true, requireClearanceCertificate: true }),
JSON.stringify({ contractRef: `smoke://contract/${runId}`, rightsEvidenceRef: `smoke://rights/${runId}`, signedCopyPath: `storage/contracts/smoke/${runId}.json` }),
JSON.stringify({ paidCloudAllowed: false, publicModelAllowed: false, rightsReview: "passed" }),
producerUserId(),
producerUserId(),
timestamp,
timestamp
]
);
dbRun(
"INSERT OR IGNORE INTO contract_project_bindings(id, organization_id, workspace_id, project_id, contract_id, customer_id, usage_mode, status, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, 'primary-license', 'active', 'delivery clearance smoke binding', ?, ?, ?)",
[`contract-binding-clearance-${runId}`, organizationId, workspaceId, projectId, contractId, customerId, producerUserId(), timestamp, timestamp]
);
}
function producerUserId() {
return dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"])?.id || "u-owner";
}
try {
cleanup();
const ownerBase = await login("producer@local.test");
const ownerHeaders = { ...ownerBase, "x-project-id": "thunder-mouth" };
const project = expectOk(await request("/api/projects", {
method: "POST",
headers: ownerHeaders,
body: JSON.stringify({ id: projectId, name: `清算烟测项目 ${runId}`, type: "AI 漫剧" })
}), "create clearance project");
assert.equal(project.project.id, projectId);
const scopedHeaders = { ...ownerBase, "x-project-id": projectId };
const graph = expectOk(await request("/api/production/graph", { headers: scopedHeaders }), "read project graph");
const shotId = graph.graph.shots[0]?.id;
assert.ok(shotId, "new project must have a starter shot");
const character = await createApprovedAsset(scopedHeaders, { name: `清算角色 ${runId}`, kind: "character", detail: "原创角色单画面设定", lock: "服装和发型锁定" });
const location = await createApprovedAsset(scopedHeaders, { name: `清算场景 ${runId}`, kind: "location", detail: "原创地铁口内景", lock: "固定中景机位" });
const prop = await createApprovedAsset(scopedHeaders, { name: `清算道具 ${runId}`, kind: "prop", detail: "原创蓝色工作证", lock: "挂在胸前" });
const voice = await createApprovedAsset(scopedHeaders, {
name: `清算固定声线 ${runId}`,
kind: "voice",
storagePath: `assets/${projectId}/voices/ref.wav`,
mimeType: "audio/wav",
detail: "原创授权参考音频",
lock: "只用于本项目固定 TTS",
metadata: { voiceId, ttsModel: "IndexTTS-2.5", referencePolicy: "fixed-local-reference" }
});
const unapproved = expectOk(await request("/api/assets", {
method: "POST",
headers: scopedHeaders,
body: JSON.stringify({
name: `未授权道具 ${runId}`,
kind: "prop",
lockStatus: "locked",
detail: "原创但未授权的临时道具",
lock: "用于清算阻断测试",
contentSha256: "c".repeat(64),
provenance: { sourceType: "original", sourceName: "清算阻断样本", sourceRef: `smoke://${runId}/blocked-prop` },
tags: ["smoke", "blocked"]
})
}), "create unapproved asset");
createdAssetIds.push(unapproved.asset.id);
await bind(scopedHeaders, character.id, shotId, "character");
await bind(scopedHeaders, location.id, shotId, "location");
await bind(scopedHeaders, prop.id, shotId, "prop");
await bind(scopedHeaders, voice.id, shotId, "voice");
await bind(scopedHeaders, unapproved.asset.id, shotId, "prop");
knowledgeDocumentId = `knowledge-clearance-${runId}`;
const knowledge = expectOk(await request("/api/knowledge/library/import", {
method: "POST",
headers: scopedHeaders,
body: JSON.stringify({
id: knowledgeDocumentId,
title: `清算测试小说 ${runId}`,
content: "第一章 雨夜任务\n陈宇来到地铁口,发现蓝色工作证背面写着新的线索。\n\n第二章 反应镜头\n角色以侧脸对白推进冲突,避免正脸大段说话。",
sourceType: "novel",
scopeMode: "project",
rightsStatus: "needs-evidence",
provenance: { sourceLabel: "清算测试小说", author: "本地原创", rightsOwner: "测试组织", evidenceRef: "" },
tags: ["smoke", "knowledge"]
})
}), "import knowledge document");
assert.equal(knowledge.document.rightsStatus, "needs-evidence");
const materialized = expectOk(await request(`/api/knowledge/library/${encodeURIComponent(knowledgeDocumentId)}/materialize`, {
method: "POST",
headers: scopedHeaders,
body: JSON.stringify({ title: `清算剧本 ${runId}`, episodeId: graph.graph.episode.id })
}), "materialize knowledge document");
scriptId = materialized.importedScript.id;
const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]);
const timestamp = new Date().toISOString();
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 (?, ?, 1, 'smoke-role', '我们只交付已经清算的版本。', 'calm', '侧脸/反应镜头', 2.2, ?, ?, 'locked', ?, ?, ?)", [`voice-line-clearance-${runId}`, shotId, voiceId, `voices/${projectId}/line-001.wav`, producerUser.id, timestamp, timestamp]);
await mkdir(resolve(projectRoot, sourceRoot), { recursive: true });
await writeFile(resolve(projectRoot, sourcePath), Buffer.from("fake local video evidence"));
await writeFile(resolve(projectRoot, lastFramePath), Buffer.from("fake actual last frame evidence"));
await writeFile(resolve(projectRoot, manifestPath), JSON.stringify({ schema: "smoke-clearance-manifest", shotId }, null, 2));
const delivery = expectOk(await request("/api/production/deliveries", {
method: "POST",
headers: scopedHeaders,
body: JSON.stringify({ version: `clearance-${runId}`, channel: "internal" })
}), "create delivery");
deliveryId = delivery.delivery.id;
dbRun("INSERT INTO delivery_batches(id, organization_id, workspace_id, project_id, delivery_id, batch_number, label, status, manifest_path, source_json, result_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, 1, 'clearance smoke active batch', 'active', ?, '{}', ?, ?, ?, ?)", [batchId, organizationId, workspaceId, projectId, deliveryId, manifestPath, JSON.stringify({ blockers: [], manifestWritten: true }), producerUser.id, timestamp, timestamp]);
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 (?, ?, ?, 1, ?, ?, ?, ?, ?)", [`delivery-clearance-item-${runId}`, batchId, shotId, sourcePath, lastFramePath, "d".repeat(64), JSON.stringify({ artifactStatus: "inspected" }), timestamp]);
dbRun("UPDATE deliveries SET active_batch_id = ?, updated_at = ? WHERE id = ?", [batchId, timestamp, deliveryId]);
expectOk(await request("/api/production/qa/run", { method: "POST", headers: scopedHeaders, body: "{}" }), "run contract QA");
const blockedClearance = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/clearance`, { method: "POST", headers: scopedHeaders, body: "{}" }), 201, "run blocked clearance");
assert.equal(blockedClearance.clearance.status, "blocked", "unapproved asset and knowledge source must block clearance");
assert.ok(blockedClearance.clearance.blockers.some((item) => item.type === "asset"), "clearance must include asset blockers");
assert.ok(blockedClearance.clearance.blockers.some((item) => item.type === "knowledge_document"), "clearance must include knowledge blockers");
const blockedApproval = await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/approve`, { method: "POST", headers: scopedHeaders, body: "{}" });
assert.equal(blockedApproval.response.status, 409, "delivery approval must be blocked by clearance");
assert.equal(blockedApproval.payload.error, "delivery_blocked");
expectOk(await request(`/api/assets/${encodeURIComponent(unapproved.asset.id)}/rights`, {
method: "POST",
headers: scopedHeaders,
body: JSON.stringify({
rightsStatus: "approved",
provenance: { sourceType: "original", sourceName: "清算阻断样本已补证", sourceRef: `smoke://${runId}/blocked-prop` },
evidence: { reference: `smoke-evidence://${runId}/blocked-prop` },
licenseScope: "commercial-platform-test"
})
}), "approve formerly blocked asset");
expectOk(await request(`/api/knowledge/library/${encodeURIComponent(knowledgeDocumentId)}/review`, {
method: "POST",
headers: scopedHeaders,
body: JSON.stringify({
decision: "approved",
rightsStatus: "approved",
evidenceRef: `smoke-evidence://${runId}/knowledge`,
provenance: { sourceLabel: "清算测试小说", author: "本地原创", rightsOwner: "测试组织", evidenceRef: `smoke-evidence://${runId}/knowledge` },
notes: "清算烟测批准"
})
}), "approve knowledge source");
seedContractClearance();
const passedClearance = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/clearance`, { method: "POST", headers: scopedHeaders, body: "{}" }), 201, "run passed clearance");
assert.equal(passedClearance.clearance.status, "pass", "all commercial gates should pass after evidence is fixed");
assert.ok(passedClearance.latest.certificatePath, "clearance must persist a certificate path");
const approvedDelivery = expectOk(await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/approve`, { method: "POST", headers: scopedHeaders, body: "{}" }), "approve delivery after clearance");
assert.equal(approvedDelivery.delivery.status, "approved");
const channels = expectOk(await request("/api/production/delivery-channels", { headers: scopedHeaders }), "list channels");
const channel = channels.channels.find((item) => item.kind === "local-file" && item.enabled);
assert.ok(channel?.id, "local-file channel must exist");
const release = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/releases`, {
method: "POST",
headers: scopedHeaders,
body: JSON.stringify({ channelId: channel.id, submit: true, idempotencyKey: `clearance-release-${runId}` })
}), 201, "submit release after clearance");
releaseId = release.release.id;
assert.equal(release.release.status, "submitted");
assert.equal(release.release.preflight.clearance.status, "pass");
const approvedRelease = expectOk(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/decision`, {
method: "POST",
headers: scopedHeaders,
body: JSON.stringify({ status: "approved", note: "clearance smoke approved" })
}), "approve release with clearance");
assert.equal(approvedRelease.release.status, "approved");
const published = expectOk(await request(`/api/production/releases/${encodeURIComponent(releaseId)}/publish`, {
method: "POST",
headers: scopedHeaders,
body: "{}"
}), "publish release with clearance");
assert.equal(published.release.status, "published");
const releaseDocument = await readFile(resolve(projectRoot, published.release.output_path), "utf8");
assert.match(releaseDocument, /delivery-clearance-certificate/, "release document must include clearance certificate summary");
const residue = dbAll("SELECT id FROM delivery_clearance_reports WHERE project_id = ?", [projectId]);
assert.ok(residue.length >= 4, "approval, release approval and publish should persist clearance reports");
console.log(`delivery clearance smoke passed: ${projectId}, ${releaseId}`);
} finally {
cleanup();
await rm(resolve(projectRoot, sourceRoot), { recursive: true, force: true });
await rm(resolve(projectRoot, "storage", "deliveries", projectId), { recursive: true, force: true });
await rm(resolve(projectRoot, "storage", "releases", projectId), { recursive: true, force: true });
await rm(resolve(projectRoot, "exports", projectId), { recursive: true, force: true });
}