feat: expand commercial ai drama platform

This commit is contained in:
xz
2026-08-31 21:09:48 +08:00
parent f06e583c9b
commit 8caf947b80
35 changed files with 10181 additions and 188 deletions
+319
View File
@@ -0,0 +1,319 @@
import { copyFile, mkdir, readdir, rm } from "node:fs/promises";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { dbAll, dbPath, dbRun, withTransaction } from "../server/db.mjs";
const projectRoot = resolve(import.meta.dirname, "..");
const storageRoot = resolve(projectRoot, "storage");
const backupsRoot = resolve(projectRoot, "data", "backups");
const dryRun = process.argv.includes("--dry-run");
const keep = {
userIds: ["u-owner", "u-producer", "u-writer", "u-art", "u-voice", "u-review", "u-local-worker"],
notificationIds: ["user-notification-seed-review", "user-notification-seed-policy"],
usageIds: ["usage-seed-001"],
auditIds: ["aud-001", "aud-002", "aud-003", "aud-004"],
modelCatalogIds: [
"catalog-qwen-image-2d",
"catalog-wan-i2v",
"catalog-qwen-parser",
"catalog-bge-m3",
"catalog-indextts",
"catalog-paraformer",
"catalog-northstar-image"
],
modelRouteIds: [
"route-script-ingest",
"route-script-materialize",
"route-image-keyframe",
"route-video-clip",
"route-voice-tts",
"route-voice-asr"
],
knowledgeDocumentIds: ["knowledge-rain-night"],
knowledgeContextPackIds: ["knowledge-pack-rain-night-opening"],
scriptIds: ["script-thunder-mouth-v1"],
shotIds: ["shot-01", "shot-02", "shot-03"],
assetIds: [
"asset-character-chen-yu",
"asset-character-tang-xia",
"asset-location-metro-canopy-rain",
"asset-prop-blue-umbrella",
"asset-prop-yellow-poncho",
"asset-prop-warning-cones",
"asset-prop-phone",
"asset-voice-chen-yu",
"asset-voice-tang-xia",
"asset-style-cel-shading",
"asset-lora-rain-local"
],
jobIds: ["job-img-001", "job-i2v-002", "job-tts-003", "job-edit-004", "job-asr-005"],
apiClientIds: ["client-local-runner"]
};
const summary = {
db: {},
files: []
};
class DryRunRollback extends Error {
constructor() {
super("dry-run rollback");
}
}
function stamp() {
return new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "").replace("T", "-");
}
function placeholders(values) {
return values.map(() => "?").join(", ");
}
function record(table, changes) {
if (!changes) return;
summary.db[table] = (summary.db[table] || 0) + changes;
}
function deleteWhere(table, where, params = []) {
const result = dbRun(`DELETE FROM ${table} WHERE ${where}`, params);
record(table, Number(result.changes || 0));
}
function deleteExceptIds(table, ids, column = "id") {
deleteWhere(table, `${column} NOT IN (${placeholders(ids)})`, ids);
}
function selectIds(sql, params = []) {
return dbAll(sql, params).map((row) => row.id);
}
async function backupDatabase() {
await mkdir(backupsRoot, { recursive: true });
dbRun("PRAGMA wal_checkpoint(FULL)");
const backupPath = resolve(backupsRoot, `platform-demo-noise-snapshot-${stamp()}.sqlite`);
if (!dryRun) await copyFile(dbPath, backupPath);
return backupPath;
}
async function removePath(path) {
if (!path || !existsSync(path)) return;
if (!dryRun) await rm(path, { recursive: true, force: true });
summary.files.push(path);
}
async function removeChildrenByPrefix(rootPath, prefix) {
if (!existsSync(rootPath)) return;
const entries = await readdir(rootPath, { withFileTypes: true });
for (const entry of entries) {
if (!entry.name.startsWith(prefix)) continue;
await removePath(resolve(rootPath, entry.name));
}
}
async function walkAndRemove(rootPath, matcher) {
if (!existsSync(rootPath)) return;
const entries = await readdir(rootPath, { withFileTypes: true });
for (const entry of entries) {
const current = resolve(rootPath, entry.name);
if (entry.isDirectory()) {
await walkAndRemove(current, matcher);
continue;
}
if (matcher(entry.name, current)) await removePath(current);
}
}
function listRelativePathsForJobs(jobIds) {
if (!jobIds.length) return [];
return dbAll(
`SELECT output_path
FROM generation_jobs
WHERE id IN (${placeholders(jobIds)})`,
jobIds
)
.map((row) => String(row.output_path || "").trim())
.filter(Boolean);
}
const smokeDeviceIds = selectIds("SELECT id FROM auth_devices WHERE label LIKE '%Smoke Browser%' OR label LIKE '%smoke%'");
const tempUserIds = selectIds(
`SELECT id
FROM users
WHERE id NOT IN (${placeholders(keep.userIds)})
OR email LIKE 'mfa-%@local.test'
OR email LIKE '%smoke%@local.test'
OR display_name LIKE '%测试%'`,
keep.userIds
);
const tempReviewIds = selectIds(
`SELECT id
FROM reviews
WHERE shot_id NOT IN (${placeholders(keep.shotIds)})`,
keep.shotIds
);
const tempJobIds = selectIds(
`SELECT id
FROM generation_jobs
WHERE id NOT IN (${placeholders(keep.jobIds)})
OR kind IN ('单句 TTS 试听', '回归队列任务', '依赖测试关键帧', '依赖测试任务')
OR output_path LIKE 'voices/auditions/%'
OR output_path LIKE '%pending-output%'`,
keep.jobIds
);
const tempAssetIds = selectIds(
`SELECT id
FROM assets
WHERE id NOT IN (${placeholders(keep.assetIds)})
OR name LIKE 'content-regression%'
OR name LIKE '%smoke%'`,
keep.assetIds
);
const tempCompositionIds = selectIds(
`SELECT id
FROM media_compositions
WHERE source_json LIKE '%missing-a.mp4%'
OR source_json LIKE '%missing-b.mp4%'
OR version LIKE 'local-%'`
);
const tempDeliveryIds = selectIds(
`SELECT id
FROM deliveries
WHERE version LIKE 'smoke-%'
OR version LIKE 'v-regression%'
OR manifest_path LIKE '%smoke%'
OR manifest_path LIKE '%regression%'`
);
const tempKnowledgeIds = selectIds(
`SELECT id
FROM knowledge_documents
WHERE id NOT IN (${placeholders(keep.knowledgeDocumentIds)})
OR title LIKE '接口验收%'`,
keep.knowledgeDocumentIds
);
const tempKnowledgePackIds = selectIds(
`SELECT id
FROM knowledge_context_packs
WHERE id NOT IN (${placeholders(keep.knowledgeContextPackIds)})
OR name LIKE '接口验收%'
OR name LIKE '回归%'`,
keep.knowledgeContextPackIds
);
const tempScriptIds = selectIds(
`SELECT id
FROM script_documents
WHERE id NOT IN (${placeholders(keep.scriptIds)})
OR title LIKE '接口验收%'
OR title LIKE '自动化验收%'
OR title LIKE '回归%'`,
keep.scriptIds
);
const tempJobOutputPaths = listRelativePathsForJobs(tempJobIds);
const backupPath = await backupDatabase();
try {
withTransaction(() => {
deleteWhere("notification_deliveries", "1 = 1");
deleteExceptIds("user_notifications", keep.notificationIds);
deleteExceptIds("usage_events", keep.usageIds);
deleteExceptIds("audit_logs", keep.auditIds);
deleteWhere("invitations", "1 = 1");
deleteExceptIds("api_clients", keep.apiClientIds);
deleteWhere("oidc_login_states", "1 = 1");
deleteWhere("saml_login_states", "1 = 1");
deleteWhere("auth_sso_tickets", "1 = 1");
deleteWhere("auth_mfa_challenges", "1 = 1");
deleteWhere("auth_mfa_enrollment_challenges", "1 = 1");
if (smokeDeviceIds.length) {
deleteWhere("auth_sessions", `device_id IN (${placeholders(smokeDeviceIds)})`, smokeDeviceIds);
deleteWhere("auth_devices", `id IN (${placeholders(smokeDeviceIds)})`, smokeDeviceIds);
}
deleteWhere("auth_sessions", "revoked_at IS NOT NULL OR julianday(expires_at) < julianday('now')");
if (tempUserIds.length) {
deleteWhere("auth_sessions", `user_id IN (${placeholders(tempUserIds)})`, tempUserIds);
deleteWhere("user_mfa_methods", `user_id IN (${placeholders(tempUserIds)})`, tempUserIds);
deleteWhere("auth_devices", `user_id IN (${placeholders(tempUserIds)})`, tempUserIds);
deleteWhere("organization_members", `user_id IN (${placeholders(tempUserIds)})`, tempUserIds);
deleteWhere("workspace_members", `user_id IN (${placeholders(tempUserIds)})`, tempUserIds);
deleteWhere("project_members", `user_id IN (${placeholders(tempUserIds)})`, tempUserIds);
deleteWhere("user_credentials", `user_id IN (${placeholders(tempUserIds)})`, tempUserIds);
deleteWhere("users", `id IN (${placeholders(tempUserIds)})`, tempUserIds);
}
deleteWhere("auth_security_events", "1 = 1");
deleteWhere("auth_devices", "id NOT IN (SELECT DISTINCT device_id FROM auth_sessions WHERE device_id IS NOT NULL)");
deleteWhere("review_comments", "1 = 1");
if (tempReviewIds.length) deleteWhere("reviews", `id IN (${placeholders(tempReviewIds)})`, tempReviewIds);
deleteWhere("delivery_access_feedback", "1 = 1");
deleteWhere("delivery_access_events", "1 = 1");
deleteWhere("delivery_access_links", "1 = 1");
deleteWhere("delivery_batch_items", "1 = 1");
deleteWhere("delivery_batches", "1 = 1");
deleteWhere("delivery_releases", "1 = 1");
if (tempDeliveryIds.length) deleteWhere("deliveries", `id IN (${placeholders(tempDeliveryIds)})`, tempDeliveryIds);
else deleteWhere("deliveries", "version LIKE 'smoke-%' OR version LIKE 'v-regression%'");
deleteWhere("media_compositions", "1 = 1");
if (tempJobIds.length) {
deleteWhere("job_attempts", `job_id IN (${placeholders(tempJobIds)})`, tempJobIds);
deleteWhere(
"job_dependencies",
`job_id IN (${placeholders(tempJobIds)}) OR depends_on_job_id IN (${placeholders(tempJobIds)})`,
[...tempJobIds, ...tempJobIds]
);
deleteWhere("generation_jobs", `id IN (${placeholders(tempJobIds)})`, tempJobIds);
}
deleteWhere("voice_lines", `shot_id NOT IN (${placeholders(keep.shotIds)})`, keep.shotIds);
deleteWhere("asset_bindings", `shot_id NOT IN (${placeholders(keep.shotIds)}) OR asset_id NOT IN (${placeholders(keep.assetIds)})`, [...keep.shotIds, ...keep.assetIds]);
deleteWhere("shot_versions", `shot_id NOT IN (${placeholders(keep.shotIds)})`, keep.shotIds);
deleteWhere("shots", `id NOT IN (${placeholders(keep.shotIds)})`, keep.shotIds);
if (tempAssetIds.length) {
deleteWhere("asset_versions", `asset_id IN (${placeholders(tempAssetIds)})`, tempAssetIds);
deleteWhere("assets", `id IN (${placeholders(tempAssetIds)})`, tempAssetIds);
}
if (tempKnowledgePackIds.length) deleteWhere("knowledge_context_packs", `id IN (${placeholders(tempKnowledgePackIds)})`, tempKnowledgePackIds);
if (tempKnowledgeIds.length) {
deleteWhere("knowledge_governance_reviews", `document_id IN (${placeholders(tempKnowledgeIds)})`, tempKnowledgeIds);
deleteWhere("knowledge_document_versions", `document_id IN (${placeholders(tempKnowledgeIds)})`, tempKnowledgeIds);
deleteWhere("knowledge_chunks", `document_id IN (${placeholders(tempKnowledgeIds)})`, tempKnowledgeIds);
deleteWhere("knowledge_documents", `id IN (${placeholders(tempKnowledgeIds)})`, tempKnowledgeIds);
}
if (tempScriptIds.length) deleteWhere("script_documents", `id IN (${placeholders(tempScriptIds)})`, tempScriptIds);
deleteExceptIds("model_routing_policies", keep.modelRouteIds);
deleteExceptIds("model_catalog_entries", keep.modelCatalogIds);
if (dryRun) throw new DryRunRollback();
});
} catch (error) {
if (!(error instanceof DryRunRollback)) throw error;
}
await removeChildrenByPrefix(resolve(storageRoot, "releases", "thunder-mouth"), "smoke-");
await removeChildrenByPrefix(resolve(storageRoot, "deliveries", "thunder-mouth"), "smoke-");
await removeChildrenByPrefix(resolve(storageRoot, "compositions"), "");
await walkAndRemove(resolve(storageRoot, "assets"), (name) => name.startsWith("content-regression") || name === "smoke-reference.txt");
for (const relativePath of tempJobOutputPaths) {
await removePath(resolve(projectRoot, relativePath));
}
const report = {
dryRun,
backupPath,
removedTables: summary.db,
removedFiles: summary.files.length,
sampleFiles: summary.files.slice(0, 10)
};
console.log(JSON.stringify(report, null, 2));
+9
View File
@@ -11,12 +11,21 @@ const scripts = [
"smoke:commercial-governance",
"smoke:invitations",
"smoke:commercial-ops",
"smoke:commercial-approvals",
"smoke:entitlements",
"smoke:plan-templates",
"smoke:billing-ledger",
"smoke:release-workflow",
"smoke:delivery-portal",
"smoke:delivery-clearance",
"smoke:oidc",
"smoke:creator-suite",
"smoke:knowledge-rag",
"smoke:knowledge-governance",
"smoke:asset-governance",
"smoke:model-connectors",
"smoke:model-approvals",
"smoke:model-routing",
"smoke:api-clients",
"smoke:api-rate-limit",
"smoke:ops",
+206
View File
@@ -0,0 +1,206 @@
import assert from "node:assert/strict";
import { dbAll, dbRun, withTransaction } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const scope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
const createdAssetIds = [];
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 };
}
async function assertOk(path, options = {}) {
const result = await request(path, options);
assert.equal(result.response.ok, true, `${options.method || "GET"} ${path}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
async function login(email) {
const result = await assertOk("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password: "Demo@123456" })
});
return { authorization: `Bearer ${result.session.token}`, ...scope };
}
function cleanup() {
withTransaction(() => {
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]);
dbRun("DELETE FROM audit_logs WHERE target_id = ? OR metadata_json LIKE ?", [assetId, `%${assetId}%`]);
}
});
}
try {
const art = await login("art@local.test");
const owner = await login("producer@local.test");
const reviewer = await login("review@local.test");
const reviewerAssets = await request("/api/assets", { headers: reviewer });
assert.equal(reviewerAssets.response.status, 403, "审片角色不能因为 voice:approve 越权读取全资产库");
const risky = await assertOk("/api/assets", {
method: "POST",
headers: art,
body: JSON.stringify({
name: `smoke-risk-${Date.now()}`,
kind: "character",
subtitle: "疑似既有 IP 风险",
detail: "外形参考蜘蛛侠,生成分屏故事板拼图",
lock: "用于测试风险阻断",
provenance: { sourceType: "third-party-reference", sourceName: "网络下载截图", sourceRef: "" },
tags: ["smoke", "风险样本"]
})
});
createdAssetIds.push(risky.asset.id);
assert.equal(risky.asset.currentVersion.risk.status, "blocked", "疑似 IP 和多画面素材必须被扫描为 blocked");
const riskyScan = await assertOk(`/api/assets/${encodeURIComponent(risky.asset.id)}/governance/scan`, {
method: "POST",
headers: art,
body: JSON.stringify({ rightsStatus: "submitted" })
});
assert.equal(riskyScan.scan.status, "blocked", "手动风险扫描必须返回阻断状态");
const riskyApproval = await request(`/api/assets/${encodeURIComponent(risky.asset.id)}/rights`, {
method: "POST",
headers: owner,
body: JSON.stringify({
rightsStatus: "approved",
provenance: { sourceType: "licensed", sourceName: "smoke-risk-license", sourceRef: "local-license/risk" },
evidence: { reference: "local-license/risk" },
licenseScope: "commercial"
})
});
assert.equal(riskyApproval.response.status, 422, "blocked 风险资产不能被批准商用");
assert.equal(riskyApproval.payload.error, "asset_governance_blocked", "风险阻断错误码必须明确");
const clean = await assertOk("/api/assets", {
method: "POST",
headers: art,
body: JSON.stringify({
name: `smoke-clean-prop-${Date.now()}`,
kind: "prop",
subtitle: "原创道具",
detail: "原创蓝色防水背包,单一完整画面引用",
lock: "斜挎在角色左肩,不遮挡脸",
provenance: { sourceType: "original", sourceName: "smoke 原创道具设定", sourceRef: "local-original/prop" },
tags: ["smoke", "原创", "道具锁"]
})
});
createdAssetIds.push(clean.asset.id);
assert.notEqual(clean.asset.currentVersion.risk.status, "blocked", "原创资产初始扫描不应阻塞");
const artSubmit = await assertOk(`/api/assets/${encodeURIComponent(clean.asset.id)}/rights`, {
method: "POST",
headers: art,
body: JSON.stringify({
rightsStatus: "submitted",
provenance: { sourceType: "original", sourceName: "smoke 原创道具设定", sourceRef: "local-original/prop" },
evidence: { reference: "local-evidence/prop-original", notes: "smoke 提交证据" },
licenseScope: "project"
})
});
assert.equal(artSubmit.asset.currentVersion.rights_status, "submitted", "资产编辑者必须能提交证据");
const artApprove = await request(`/api/assets/${encodeURIComponent(clean.asset.id)}/rights`, {
method: "POST",
headers: art,
body: JSON.stringify({
rightsStatus: "approved",
provenance: { sourceType: "original", sourceName: "smoke 原创道具设定", sourceRef: "local-original/prop" },
evidence: { reference: "local-evidence/prop-original" },
licenseScope: "commercial"
})
});
assert.equal(artApprove.response.status, 403, "资产编辑者不能做最终授权批准");
const noEvidence = await assertOk("/api/assets", {
method: "POST",
headers: art,
body: JSON.stringify({
name: `smoke-no-evidence-${Date.now()}`,
kind: "reference",
subtitle: "缺证据审批测试",
detail: "原创单画面参考",
lock: "仅用于 smoke",
provenance: { sourceType: "original", sourceName: "smoke 无证据资产", sourceRef: "local-original/no-evidence" },
tags: ["smoke"]
})
});
createdAssetIds.push(noEvidence.asset.id);
const missingEvidence = await request(`/api/assets/${encodeURIComponent(noEvidence.asset.id)}/rights`, {
method: "POST",
headers: owner,
body: JSON.stringify({
rightsStatus: "approved",
provenance: { sourceType: "original", sourceName: "smoke 无证据资产", sourceRef: "local-original/no-evidence" },
evidence: {},
licenseScope: "commercial"
})
});
assert.equal(missingEvidence.response.status, 400, "缺少授权证据不能批准");
assert.equal(missingEvidence.payload.error, "rights_evidence_required", "缺证据错误码必须明确");
const approved = await assertOk(`/api/assets/${encodeURIComponent(clean.asset.id)}/rights`, {
method: "POST",
headers: owner,
body: JSON.stringify({
rightsStatus: "approved",
provenance: { sourceType: "original", sourceName: "smoke 原创道具设定", sourceRef: "local-original/prop" },
evidence: { reference: "local-evidence/prop-original", notes: "smoke 批准商用" },
licenseScope: "commercial"
})
});
assert.equal(approved.asset.currentVersion.rights_status, "approved", "合规/管理员必须能批准干净资产");
assert.equal(approved.asset.currentVersion.licenseScope, "commercial", "授权范围必须持久化");
assert.ok(approved.asset.governanceReviews.length >= 2, "提交和批准都必须写入审核记录");
const gateAsset = await assertOk("/api/assets", {
method: "POST",
headers: art,
body: JSON.stringify({
name: `smoke-unapproved-prop-${Date.now()}`,
kind: "prop",
subtitle: "未授权生成前拦截",
detail: "原创临时道具",
lock: "用于测试生成门禁",
provenance: { sourceType: "original", sourceName: "smoke 临时道具", sourceRef: "local-original/unapproved" },
tags: ["smoke", "未授权"]
})
});
createdAssetIds.push(gateAsset.asset.id);
await assertOk(`/api/assets/${encodeURIComponent(gateAsset.asset.id)}/bindings`, {
method: "POST",
headers: art,
body: JSON.stringify({ shotId: "shot-01", usageRole: "prop" })
});
const visualJob = await request("/api/jobs", {
method: "POST",
headers: art,
body: JSON.stringify({ adapter: "owned-image", kind: "单画面关键帧", shotId: "shot-01", output: `storage/jobs/smoke-asset-governance-${Date.now()}/output` })
});
assert.equal(visualJob.response.status, 422, "绑定未授权道具后,图片生成必须被资产治理门禁拦截");
assert.equal(visualJob.payload.error, "asset_governance_gate_failed", "生成门禁错误码必须明确");
const residue = dbAll(`SELECT id FROM assets WHERE id IN (${createdAssetIds.map(() => "?").join(",")})`, createdAssetIds);
assert.equal(residue.length, createdAssetIds.length, "清理前测试资产应仍在库中,确保测试过程真实写入");
console.log("asset governance smoke passed: RBAC, scan, approval ledger, generation gate");
} finally {
cleanup();
}
+133
View File
@@ -0,0 +1,133 @@
import assert from "node:assert/strict";
import { dbRun } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const scope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
async function request(path, headers = {}, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...headers, ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
function assertOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
async function login(email) {
const result = await request("/api/auth/login", {}, {
method: "POST",
body: JSON.stringify({ email, password: "Demo@123456" })
});
return { authorization: `Bearer ${assertOk(result, `${email} 登录`).session.token}` };
}
function findEntitlement(payload, key) {
const entitlement = payload.entitlements?.find((item) => item.key === key);
assert.ok(entitlement, `缺少权益 ${key}`);
return entitlement;
}
function entitlementSnapshot(entitlement) {
return {
limitValue: Number(entitlement.limitValue || 0),
enabled: Boolean(entitlement.enabled),
enforcement: entitlement.enforcement || "block",
source: entitlement.source || "plan",
overrideReason: entitlement.overrideReason || ""
};
}
const owner = { ...(await login("producer@local.test")), ...scope };
const writer = { ...(await login("writer@local.test")), ...scope };
const createdRequestIds = [];
let originalKnowledgeEntitlement = null;
try {
const commercial = assertOk(await request("/api/organizations/org-studio-lab/commercial", owner), "读取商业运营");
assert.ok(Array.isArray(commercial.commercialApprovalCatalog) && commercial.commercialApprovalCatalog.length >= 6, "商业审批类型目录缺失");
assert.ok(commercial.commercialApprovalSummary && Number.isFinite(commercial.commercialApprovalSummary.total), "商业审批摘要缺失");
const knowledgeEntitlement = findEntitlement(commercial, "limit.knowledge_documents");
originalKnowledgeEntitlement = entitlementSnapshot(knowledgeEntitlement);
const requestedLimit = Number(knowledgeEntitlement.limitValue || 0) + 13;
const crossTenant = await request("/api/organizations/org-studio-lab/commercial-approvals", writer, {
method: "POST",
body: JSON.stringify({
requestType: "entitlement_overage",
targetKey: "limit.knowledge_documents",
requestedValue: requestedLimit,
workspaceId: "ws-northstar-main",
businessReason: "验证商业审批不能把申请单挂到其他组织工作区。"
})
});
assert.equal(crossTenant.response.status, 403, "跨组织工作区绑定必须被拒绝");
assert.equal(crossTenant.payload.error, "approval_workspace_scope_mismatch", "跨组织审批错误码不稳定");
const submitted = assertOk(await request("/api/organizations/org-studio-lab/commercial-approvals", writer, {
method: "POST",
body: JSON.stringify({
requestType: "entitlement_overage",
targetKey: "limit.knowledge_documents",
requestedValue: requestedLimit,
priority: "high",
title: "Smoke 知识库素材额度扩容",
businessReason: "导入原创小说后需要更多知识库分块素材,仍限定本地 RAG 和原创商用证据。",
evidence: { evidenceRef: "smoke://commercial-approval/knowledge-entitlement" }
})
}), "提交商业审批");
createdRequestIds.push(submitted.request.id);
assert.equal(submitted.request.status, "submitted", "新申请应处于待审批状态");
const mine = assertOk(await request("/api/organizations/org-studio-lab/commercial-approvals?mine=1", writer), "读取我的申请");
assert.ok(mine.requests.some((item) => item.id === submitted.request.id), "申请人必须能看到自己的商业审批");
const decided = assertOk(await request(`/api/organizations/org-studio-lab/commercial-approvals/${encodeURIComponent(submitted.request.id)}/decision`, owner, {
method: "POST",
body: JSON.stringify({ decision: "approved", decisionNote: "smoke 自动审批扩容" })
}), "审批通过商业申请");
assert.equal(decided.request.status, "approved", "审批通过后状态应变为 approved");
assert.equal(decided.request.effect?.kind, "entitlement_overridden", "权益扩容审批应自动同步组织权益");
assert.equal(findEntitlement(decided.commercial, "limit.knowledge_documents").limitValue, requestedLimit, "知识库素材额度未按审批结果提升");
const cancelTarget = commercial.costCenters?.[0]?.code || "local-gpu";
const cancellable = assertOk(await request("/api/organizations/org-studio-lab/commercial-approvals", writer, {
method: "POST",
body: JSON.stringify({
requestType: "budget_increase",
targetKey: cancelTarget,
requestedValue: 999,
priority: "low",
title: "Smoke 可取消预算申请",
businessReason: "验证申请人可以取消尚未审批的商业治理申请。"
})
}), "提交可取消申请");
createdRequestIds.push(cancellable.request.id);
const cancelled = assertOk(await request(`/api/organizations/org-studio-lab/commercial-approvals/${encodeURIComponent(cancellable.request.id)}/decision`, writer, {
method: "POST",
body: JSON.stringify({ decision: "cancelled", decisionNote: "smoke 自助取消" })
}), "申请人取消申请");
assert.equal(cancelled.request.status, "cancelled", "申请人取消后状态应为 cancelled");
console.log(`commercial approvals smoke passed: ${api}`);
} finally {
if (originalKnowledgeEntitlement) {
dbRun(
"UPDATE organization_entitlements SET limit_value = ?, enabled = ?, enforcement = ?, source = ?, override_reason = ?, updated_at = ? WHERE organization_id = 'org-studio-lab' AND entitlement_key = 'limit.knowledge_documents'",
[originalKnowledgeEntitlement.limitValue, originalKnowledgeEntitlement.enabled ? 1 : 0, originalKnowledgeEntitlement.enforcement, originalKnowledgeEntitlement.source, originalKnowledgeEntitlement.overrideReason, new Date().toISOString()]
);
}
for (const requestId of createdRequestIds) {
dbRun("DELETE FROM audit_logs WHERE target_type = 'commercial_approval_request' AND target_id = ?", [requestId]);
dbRun("DELETE FROM commercial_approval_requests WHERE id = ?", [requestId]);
}
}
+283
View File
@@ -0,0 +1,283 @@
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 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 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]);
});
}
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");
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 });
}
+38 -8
View File
@@ -8,8 +8,8 @@ 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 projectId = "thunder-mouth";
const runId = Date.now();
const projectId = `smoke-portal-project-${runId}`;
const sourceRoot = `storage/smoke-delivery-portal-${runId}`;
const sourcePath = `${sourceRoot}/clip.mp4`;
const lastFramePath = `${sourceRoot}/actual-last-frame.jpg`;
@@ -61,6 +61,12 @@ const reviewerLogin = expectOk(await request("/api/auth/login", {
body: JSON.stringify({ email: "review@local.test", password: "Demo@123456" })
}), "reviewer login");
const producerSetupHeaders = {
authorization: `Bearer ${producerLogin.session.token}`,
"x-organization-id": organizationId,
"x-workspace-id": workspaceId,
"x-project-id": "thunder-mouth"
};
const producerHeaders = {
authorization: `Bearer ${producerLogin.session.token}`,
"x-organization-id": organizationId,
@@ -80,9 +86,7 @@ const crossOrganizationHeaders = {
"x-project-id": "northstar-pilot"
};
const projectShot = dbGet("SELECT id FROM shots WHERE episode_id IN (SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE sr.project_id = ?) ORDER BY shot_number LIMIT 1", [projectId]);
const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]);
assert.ok(projectShot?.id, "smoke project must have a starter shot");
assert.ok(producerUser?.id, "producer user must exist");
let channelId = "";
@@ -91,8 +95,20 @@ let primaryToken = "";
let expiredToken = "";
let revokedToken = "";
let seeded = false;
let projectCreated = false;
try {
const project = expectStatus(await request("/api/projects", {
method: "POST",
headers: producerSetupHeaders,
body: JSON.stringify({ id: projectId, name: `门户烟测项目 ${runId}`, type: "AI 漫剧" })
}), 201, "producer creates isolated portal project");
projectCreated = true;
assert.equal(project.project.id, projectId, "isolated portal project must be created");
const graph = expectOk(await request("/api/production/graph", { headers: producerHeaders }), "read isolated portal graph");
const projectShot = { id: graph.graph.shots[0]?.id };
assert.ok(projectShot.id, "smoke project must have a starter shot");
const channels = expectOk(await request("/api/production/delivery-channels", { headers: reviewerHeaders }), "reviewer can view delivery channels");
channelId = channels.channels.find((channel) => channel.kind === "local-file")?.id || "";
assert.ok(channelId, "workspace must expose a local-file delivery channel");
@@ -122,11 +138,13 @@ try {
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-delivery-portal-manifest", deliveryId: delivery.delivery.id }, null, 2));
const timestamp = new Date().toISOString();
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, 'smoke portal batch', 'active', ?, '{}', ?, ?, ?, ?)", [batchId, organizationId, workspaceId, projectId, delivery.delivery.id, 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, ?, ?, ?, '{}', ?)", [`smoke-portal-item-${runId}`, batchId, projectShot.id, sourcePath, lastFramePath, "a".repeat(64), timestamp]);
dbRun("UPDATE deliveries SET status = 'approved', active_batch_id = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [batchId, producerUser.id, timestamp, timestamp, delivery.delivery.id]);
seeded = true;
const timestamp = new Date().toISOString();
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, 'smoke portal batch', 'active', ?, '{}', ?, ?, ?, ?)", [batchId, organizationId, workspaceId, projectId, delivery.delivery.id, 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, ?, ?, ?, ?, ?)", [`smoke-portal-item-${runId}`, batchId, projectShot.id, sourcePath, lastFramePath, "a".repeat(64), JSON.stringify({ artifactStatus: "inspected" }), timestamp]);
dbRun("UPDATE deliveries SET status = 'approved', active_batch_id = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [batchId, producerUser.id, timestamp, timestamp, delivery.delivery.id]);
expectOk(await request("/api/production/reviews", { headers: producerHeaders }), "ensure portal QA reviews");
dbRun("UPDATE reviews SET status = 'approved', score = 100, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE project_id = ?", [producerUser.id, timestamp, JSON.stringify({ source: "smoke-delivery-portal", checkedAt: timestamp }), timestamp, projectId]);
seeded = true;
const release = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, {
method: "POST",
@@ -257,5 +275,17 @@ try {
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [deliveryId]);
dbRun("DELETE FROM deliveries WHERE id = ?", [deliveryId]);
}
dbRun("DELETE FROM delivery_clearance_reports WHERE project_id = ?", [projectId]);
dbRun("DELETE FROM reviews WHERE project_id = ?", [projectId]);
if (projectCreated) {
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 projects WHERE id = ?", [projectId]);
}
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 });
}
+131
View File
@@ -0,0 +1,131 @@
import assert from "node:assert/strict";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const scope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
async function request(path, headers = {}, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...headers, ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
function assertOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
async function login(email) {
const result = await request("/api/auth/login", {}, {
method: "POST",
body: JSON.stringify({ email, password: "Demo@123456" })
});
return { authorization: `Bearer ${assertOk(result, `${email} 登录`).session.token}` };
}
function findEntitlement(payload, key) {
const entitlement = payload.entitlements?.find((item) => item.key === key);
assert.ok(entitlement, `缺少权益 ${key}`);
return entitlement;
}
function snapshot(entitlement) {
return {
limitValue: Number(entitlement.limitValue || 0),
enabled: Boolean(entitlement.enabled),
enforcement: entitlement.enforcement || "block",
overrideReason: entitlement.overrideReason || ""
};
}
async function updateEntitlement(headers, key, body) {
return request(`/api/organizations/org-studio-lab/entitlements/${encodeURIComponent(key)}`, headers, {
method: "PATCH",
body: JSON.stringify(body)
});
}
const owner = { ...(await login("producer@local.test")), ...scope };
const writer = { ...(await login("writer@local.test")), ...scope };
let apiClientOriginal = null;
let workspaceOriginal = null;
let cloudFeatureOriginal = null;
try {
const commercial = assertOk(await request("/api/organizations/org-studio-lab/commercial", owner), "读取商业权益");
assert.ok(Array.isArray(commercial.planTemplates) && commercial.planTemplates.length >= 3, "必须返回套餐模板");
assert.ok(Array.isArray(commercial.entitlements) && commercial.entitlements.length >= 10, "必须返回组织权益矩阵");
assert.ok(commercial.entitlementSummary && Number.isFinite(commercial.entitlementSummary.total), "必须返回权益摘要");
const apiClientEntitlement = findEntitlement(commercial, "limit.api_clients");
apiClientOriginal = snapshot(apiClientEntitlement);
const writerDenied = await updateEntitlement(writer, "limit.api_clients", { limitValue: apiClientOriginal.limitValue + 1 });
assert.equal(writerDenied.response.status, 403, "普通编剧不应修改商业权益");
const apiLimit = Math.max(1, Number(apiClientEntitlement.usedValue || 0));
const apiLimitUpdate = assertOk(await updateEntitlement(owner, "limit.api_clients", {
limitValue: apiLimit,
enabled: true,
enforcement: "block",
overrideReason: "smoke entitlement limit"
}), "压低 API 客户端权益");
assert.equal(findEntitlement(apiLimitUpdate, "limit.api_clients").status, "blocked", "已用量达到额度时应显示 blocked");
const blockedClient = await request("/api/system/api-clients", owner, {
method: "POST",
body: JSON.stringify({ name: `blocked-client-${Date.now()}`, scopes: ["jobs:read"] })
});
assert.equal(blockedClient.response.status, 429, "API 客户端超过权益时必须被阻断");
assert.equal(blockedClient.payload.error, "entitlement_limit_exceeded", "API 客户端权益阻断错误码不稳定");
const workspaceEntitlement = findEntitlement(commercial, "limit.workspaces");
workspaceOriginal = snapshot(workspaceEntitlement);
const workspaceLimit = Math.max(1, Number(workspaceEntitlement.usedValue || 0));
assertOk(await updateEntitlement(owner, "limit.workspaces", {
limitValue: workspaceLimit,
enabled: true,
enforcement: "block",
overrideReason: "smoke workspace limit"
}), "压低工作区权益");
const blockedWorkspace = await request("/api/workspaces", owner, {
method: "POST",
body: JSON.stringify({ name: `Blocked Workspace ${Date.now()}` })
});
assert.equal(blockedWorkspace.response.status, 429, "工作区超过权益时必须被阻断");
assert.equal(blockedWorkspace.payload.error, "entitlement_limit_exceeded", "工作区权益阻断错误码不稳定");
const cloudEntitlement = findEntitlement(commercial, "feature.external_cloud_connectors");
cloudFeatureOriginal = snapshot(cloudEntitlement);
assertOk(await updateEntitlement(owner, "feature.external_cloud_connectors", {
limitValue: 1,
enabled: false,
enforcement: "block",
overrideReason: "smoke cloud connector disabled"
}), "关闭外部云连接器权益");
const blockedCloudConnector = await request("/api/platform/models/register", owner, {
method: "POST",
body: JSON.stringify({
label: `Blocked Cloud Connector ${Date.now()}`,
endpoint: "https://example.com/v1",
kind: "openai-compatible",
capability: ["chat"],
costMode: "mixed",
approvalRequired: true
})
});
assert.equal(blockedCloudConnector.response.status, 403, "外部云连接器权益关闭时必须被阻断");
assert.equal(blockedCloudConnector.payload.error, "entitlement_disabled", "外部连接器权益错误码不稳定");
console.log(`entitlement smoke passed: ${api}`);
} finally {
if (apiClientOriginal) await updateEntitlement(owner, "limit.api_clients", apiClientOriginal);
if (workspaceOriginal) await updateEntitlement(owner, "limit.workspaces", workspaceOriginal);
if (cloudFeatureOriginal) await updateEntitlement(owner, "feature.external_cloud_connectors", cloudFeatureOriginal);
}
+187
View File
@@ -0,0 +1,187 @@
import assert from "node:assert/strict";
import { dbRun, withTransaction } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const runId = `smoke-knowledge-governance-${Date.now()}`;
const documentId = `${runId}-doc`;
const scope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
const createdScriptIds = [];
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;
}
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}` };
}
function cleanup() {
withTransaction(() => {
for (const scriptId of createdScriptIds) dbRun("DELETE FROM script_documents WHERE id = ?", [scriptId]);
dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ?", [`%${documentId}%`]);
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [documentId]);
dbRun("DELETE FROM knowledge_governance_reviews WHERE document_id = ?", [documentId]);
dbRun("DELETE FROM knowledge_document_versions WHERE document_id = ?", [documentId]);
dbRun("DELETE FROM knowledge_chunks WHERE document_id = ?", [documentId]);
dbRun("DELETE FROM knowledge_documents WHERE id = ?", [documentId]);
});
}
try {
const ownerHeaders = { ...(await login("producer@local.test")), ...scope };
const writerHeaders = { ...(await login("writer@local.test")), ...scope };
const uniqueTerm = `治理暗号${runId.slice(-6)}`;
const imported = expectOk(await request("/api/knowledge/library/import", {
method: "POST",
headers: ownerHeaders,
body: JSON.stringify({
id: documentId,
title: `治理验收素材 ${runId.slice(-6)}`,
sourceType: "novel",
scopeMode: "project",
rightsStatus: "needs-evidence",
provenance: { sourceLabel: "", rightsOwner: "", evidenceRef: "" },
tags: "smoke,版权待审",
content: [
"第一章 未清来源",
`唐夏在雨夜提到三体改编和${uniqueTerm},这段文字必须触发已知 IP 复核。`,
"",
"唐夏:如果口型同步不稳,就只拍我的侧脸。",
"陈宇:先保留蓝伞和地铁口,不要生成分屏或多格。"
].join("\n")
})
}), "import governance document");
assert.equal(imported.document.id, documentId, "导入必须返回指定文档");
assert.equal(imported.document.rightsStatus, "needs-evidence", "缺省版权状态必须保留");
assert.equal(imported.document.riskStatus, "review", "缺证据和已知 IP 应进入复核状态");
assert.ok(imported.document.risk.issues.some((issue) => issue.code === "known_ip_reference"), "必须检测已知 IP 风险");
assert.equal(imported.document.currentVersionNumber, 1, "导入必须创建 v1");
const deniedApprove = await request(`/api/knowledge/library/${encodeURIComponent(documentId)}/review`, {
method: "POST",
headers: writerHeaders,
body: JSON.stringify({ decision: "approved", rightsStatus: "approved", evidenceRef: "contract://writer-denied" })
});
assert.equal(deniedApprove.response.status, 403, "普通编剧不能批准商用版权");
const rejected = expectOk(await request(`/api/knowledge/library/${encodeURIComponent(documentId)}/review`, {
method: "POST",
headers: ownerHeaders,
body: JSON.stringify({ decision: "rejected", rightsStatus: "rejected", notes: "未提供来源证明" })
}), "reject document");
assert.equal(rejected.document.rightsStatus, "rejected", "驳回后版权状态必须为 rejected");
const blockedMaterialize = await request(`/api/knowledge/library/${encodeURIComponent(documentId)}/materialize`, {
method: "POST",
headers: ownerHeaders,
body: JSON.stringify({ title: "被驳回素材不应入剧本" })
});
assert.equal(blockedMaterialize.response.status, 409, "被驳回素材不能进入剧本工厂");
const updated = expectOk(await request(`/api/knowledge/library/${encodeURIComponent(documentId)}`, {
method: "PATCH",
headers: ownerHeaders,
body: JSON.stringify({
title: `原创治理验收素材 ${runId.slice(-6)}`,
sourceType: "novel",
rightsStatus: "approved",
provenance: {
sourceLabel: "原创小说授权",
author: "本地测试作者",
rightsOwner: "本地测试组织",
evidenceRef: `contract://knowledge-governance/${runId}`,
licenseNote: "原创素材,可用于本地商业生产验收。"
},
tags: "原创,商用已批,smoke",
content: [
"第一章 清洁来源",
`唐夏把${uniqueTerm}写在蓝伞内侧,陈宇在地铁口远景镜头里看见伞柄反光。`,
"",
"唐夏:不要正脸长对白,用侧脸和反应镜头。",
"陈宇:我会把上一段真实末帧接进下一段。"
].join("\n")
})
}), "update clean document");
assert.ok(updated.document.currentVersionNumber >= 2, "内容更新必须生成新版本");
assert.equal(updated.document.rightsStatus, "approved", "更新后应保留已批准状态");
assert.equal(updated.document.riskStatus, "pass", "清洁原创素材应通过本地扫描");
const approved = expectOk(await request(`/api/knowledge/library/${encodeURIComponent(documentId)}/review`, {
method: "POST",
headers: ownerHeaders,
body: JSON.stringify({
decision: "approved",
rightsStatus: "approved",
evidenceRef: `contract://knowledge-governance/${runId}`,
provenance: updated.document.provenance,
tags: "原创,商用已批,smoke",
notes: "原创素材证据完整"
})
}), "approve clean document");
assert.equal(approved.review.decision, "approved", "审批记录必须写入 approved");
assert.equal(approved.document.riskStatus, "pass", "批准后的清洁素材应保持通过");
const materialized = expectOk(await request(`/api/knowledge/library/${encodeURIComponent(documentId)}/materialize`, {
method: "POST",
headers: ownerHeaders,
body: JSON.stringify({ title: `治理验收入剧本 ${runId.slice(-6)}` })
}), "materialize approved document");
createdScriptIds.push(materialized.importedScript.id);
assert.ok(materialized.importedScript.content.includes(uniqueTerm), "批准素材入剧本必须保留正文");
const searchable = expectOk(await request(`/api/knowledge/search?q=${encodeURIComponent(uniqueTerm)}&scopeMode=project`, { headers: ownerHeaders }), "search before archive");
assert.ok(searchable.results.some((item) => item.documentId === documentId), "恢复前素材必须可检索");
const archived = expectOk(await request(`/api/knowledge/library/${encodeURIComponent(documentId)}/archive`, {
method: "POST",
headers: ownerHeaders,
body: "{}"
}), "archive document");
assert.equal(archived.document.status, "archived", "归档状态必须返回 archived");
const hidden = expectOk(await request(`/api/knowledge/search?q=${encodeURIComponent(uniqueTerm)}&scopeMode=project`, { headers: ownerHeaders }), "search after archive");
assert.equal(hidden.results.some((item) => item.documentId === documentId), false, "归档素材不能被检索引用");
const restored = expectOk(await request(`/api/knowledge/library/${encodeURIComponent(documentId)}/restore`, {
method: "POST",
headers: ownerHeaders,
body: "{}"
}), "restore document");
assert.equal(restored.document.status, "indexed", "恢复后必须回到索引状态");
const versions = expectOk(await request(`/api/knowledge/library/${encodeURIComponent(documentId)}/versions`, { headers: ownerHeaders }), "list versions");
assert.ok(versions.versions.length >= 2, "版本列表至少包含 v1 和 v2");
const v1 = versions.versions.find((version) => version.versionNumber === 1);
assert.ok(v1, "版本列表必须包含 v1");
const rolledBack = expectOk(await request(`/api/knowledge/library/${encodeURIComponent(documentId)}/versions/${encodeURIComponent(v1.id)}/restore`, {
method: "POST",
headers: ownerHeaders,
body: "{}"
}), "restore v1");
assert.ok(rolledBack.document.currentVersionNumber > updated.document.currentVersionNumber, "恢复历史版本必须生成新的当前版本");
assert.ok(rolledBack.document.content.includes("三体"), "恢复 v1 后正文应回到历史内容");
assert.equal(rolledBack.document.riskStatus, "review", "恢复含 IP 的历史版本必须重新进入复核");
console.log(`knowledge governance smoke passed: document=${documentId}, versions=${rolledBack.versions.length}`);
} finally {
cleanup();
}
+174
View File
@@ -0,0 +1,174 @@
import assert from "node:assert/strict";
import { rm } from "node:fs/promises";
import { resolve } from "node:path";
import { 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 runId = `smoke-knowledge-rag-${Date.now()}`;
const organizationId = "org-studio-lab";
const workspaceId = "ws-local-aidrama";
const projectId = "thunder-mouth";
const tempProjectId = `${runId}-project`;
const projectDocId = `${runId}-doc`;
const packId = `${runId}-pack`;
const scope = {
"x-organization-id": organizationId,
"x-workspace-id": workspaceId,
"x-project-id": projectId
};
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;
}
const quotaBefore = dbGet(
"SELECT id, used_value FROM quota_allocations WHERE organization_id = ? AND workspace_id = ? AND metric = 'clip' ORDER BY updated_at DESC LIMIT 1",
[organizationId, workspaceId]
);
const createdJobIds = [];
const createdScriptIds = [];
function cleanup() {
withTransaction(() => {
for (const jobId of createdJobIds) {
dbRun("DELETE FROM job_dependencies WHERE job_id = ? OR depends_on_job_id = ?", [jobId, jobId]);
dbRun("DELETE FROM job_attempts WHERE job_id = ?", [jobId]);
dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ?", [`%${jobId}%`]);
dbRun("DELETE FROM audit_logs WHERE target_type = 'generation_job' AND target_id = ?", [jobId]);
dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]);
}
dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ? OR metadata_json LIKE ?", [`%${packId}%`, `%${projectDocId}%`]);
dbRun("DELETE FROM audit_logs WHERE target_id IN (?, ?, ?)", [packId, projectDocId, tempProjectId]);
for (const scriptId of createdScriptIds) dbRun("DELETE FROM script_documents WHERE id = ?", [scriptId]);
dbRun("DELETE FROM knowledge_context_packs WHERE id = ?", [packId]);
dbRun("DELETE FROM knowledge_chunks WHERE document_id = ?", [projectDocId]);
dbRun("DELETE FROM knowledge_documents WHERE id = ?", [projectDocId]);
dbRun("DELETE FROM projects WHERE id = ?", [tempProjectId]);
if (quotaBefore?.id) dbRun("UPDATE quota_allocations SET used_value = ? WHERE id = ?", [quotaBefore.used_value, quotaBefore.id]);
});
}
try {
const login = expectOk(await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
}), "owner login");
const headers = { authorization: `Bearer ${login.session.token}`, ...scope };
expectOk(await request("/api/projects", {
method: "POST",
headers,
body: JSON.stringify({ id: tempProjectId, name: `知识隔离 ${runId.slice(-6)}`, type: "AI 漫剧" })
}), "create temp project");
const uniqueTerm = `验收暗号${runId.slice(-6)}`;
const imported = expectOk(await request("/api/knowledge/library/import", {
method: "POST",
headers,
body: JSON.stringify({
id: projectDocId,
title: `接口验收知识库 ${runId.slice(-6)}`,
sourceType: "novel",
scopeMode: "project",
content: [
"第一章 临港暗号",
`唐夏把${uniqueTerm}写在蓝伞内侧,陈宇只能在地铁口的远景镜头里看见伞柄反光。`,
"",
"唐夏:不要正脸说太久,雨声会盖住口型。",
"陈宇:那就用背影和环境插入镜头,把暗号留到下一段。"
].join("\n")
})
}), "import project knowledge");
assert.equal(imported.document.id, projectDocId, "导入接口必须返回指定文档 id");
assert.ok(imported.document.chunkCount >= 1, "导入后必须生成知识片段");
const projectSearch = expectOk(await request(`/api/knowledge/search?q=${encodeURIComponent(uniqueTerm)}&scopeMode=project`, { headers }), "project knowledge search");
assert.ok(projectSearch.results.some((item) => item.documentId === projectDocId), "当前项目必须能检索自己的项目级知识");
const otherProjectHeaders = { ...headers, "x-project-id": tempProjectId };
const isolatedSearch = expectOk(await request(`/api/knowledge/search?q=${encodeURIComponent(uniqueTerm)}&scopeMode=project`, { headers: otherProjectHeaders }), "isolated project search");
assert.equal(isolatedSearch.results.length, 0, "其他项目不能检索 thunder-mouth 的项目级知识");
const seedSearch = expectOk(await request("/api/knowledge/search?q=%E8%93%9D%E4%BC%9E%20%E5%9C%B0%E9%93%81%E5%8F%A3&scopeMode=workspace&limit=6", { headers }), "seed knowledge search");
assert.ok(seedSearch.results.length >= 2, "种子小说应能检索出多个知识片段");
const chunkIds = seedSearch.results.slice(0, 2).map((item) => item.id);
const packCreated = expectOk(await request("/api/knowledge/context-packs", {
method: "POST",
headers,
body: JSON.stringify({
id: packId,
name: `回归知识包 ${runId.slice(-6)}`,
query: "蓝伞 地铁口",
sourceType: "novel",
scopeMode: "workspace",
maxTokens: 700,
chunkIds
})
}), "create context pack");
assert.equal(packCreated.pack.id, packId, "上下文包必须使用指定 id");
assert.equal(packCreated.pack.citations.length, chunkIds.length, "上下文包必须保留每个片段引用");
assert.ok(packCreated.pack.promptContext.includes("[K1]"), "上下文包 prompt 必须包含引用编号");
const packList = expectOk(await request("/api/knowledge/context-packs", { headers }), "list context packs");
assert.ok(packList.packs.some((item) => item.id === packId), "上下文包列表必须包含刚创建的包");
const materialized = expectOk(await request(`/api/knowledge/context-packs/${encodeURIComponent(packId)}/materialize`, {
method: "POST",
headers,
body: JSON.stringify({ title: `回归知识包剧本 ${runId.slice(-6)}` })
}), "materialize context pack");
createdScriptIds.push(materialized.importedScript.id);
assert.ok(materialized.importedScript.content.includes("[K1]"), "包入剧本必须保留引用编号");
const preview = expectOk(await request("/api/jobs/preview", {
method: "POST",
headers,
body: JSON.stringify({
kind: "场景草稿",
workflowKey: "script-pipeline",
operationKey: "scene-draft",
adapter: "owned-model-platform",
knowledgePackId: packId,
inputs: { prompt: "基于引用包生成连续短剧分镜草稿。" }
})
}), "preview job with context pack");
assert.equal(preview.preview.knowledge.id, packId, "任务预览必须包含上下文包");
assert.ok(preview.preview.inputs.knowledgeContext.includes("知识库上下文包"), "任务输入必须包含上下文正文");
const createdJob = expectOk(await request("/api/jobs", {
method: "POST",
headers,
body: JSON.stringify({
kind: "场景草稿",
workflowKey: "script-pipeline",
operationKey: "scene-draft",
adapter: "owned-model-platform",
knowledgePackId: packId,
output: `storage/jobs/${runId}/scene-draft.json`,
inputs: { prompt: "基于引用包生成连续短剧分镜草稿。" }
})
}), "create job with context pack");
createdJobIds.push(createdJob.job.id);
assert.equal(createdJob.job.request.knowledge.id, packId, "已创建任务合同必须保存上下文包");
assert.equal(createdJob.job.request.knowledge.citations.length, chunkIds.length, "已创建任务合同必须保存引用列表");
console.log(`knowledge rag smoke passed: search=${seedSearch.results.length}, pack=${packId}, job=${createdJob.job.id}`);
} finally {
cleanup();
await rm(resolve(projectRoot, "storage", "jobs", runId), { recursive: true, force: true });
for (const jobId of createdJobIds) {
await rm(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true, force: true });
}
}
+184
View File
@@ -0,0 +1,184 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import { rm } from "node:fs/promises";
import { dbRun, withTransaction } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const scope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
const onePixelPng = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=";
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 };
}
async function login(email = "producer@local.test") {
const result = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password: "Demo@123456" })
});
assert.equal(result.response.ok, true, `${email} 登录失败:${result.payload.detail || result.payload.error || ""}`);
return { authorization: `Bearer ${result.payload.session.token}`, ...scope };
}
const mockRunner = createServer((req, res) => {
if (req.method === "POST" && req.url === "/generate") {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify({
data: [{ b64_json: onePixelPng, mimeType: "image/png" }],
metadata: { source: "local-smoke-runner", jobId: req.headers["x-ai-drama-job-id"] || "" }
}));
return;
}
res.writeHead(404, { "content-type": "application/json" });
res.end(JSON.stringify({ error: "not_found" }));
});
await new Promise((resolve) => mockRunner.listen(0, "127.0.0.1", resolve));
const runnerPort = mockRunner.address().port;
const suffix = `${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const connectorId = `smoke-approval-connector-${suffix}`;
const catalogId = `smoke-approval-catalog-${suffix}`;
const routeId = `smoke-approval-route-${suffix}`;
let approvalId = "";
let jobId = "";
function seedApprovalRoute() {
const timestamp = new Date().toISOString();
withTransaction(() => {
dbRun(
`INSERT INTO model_connectors(
id, organization_id, workspace_id, label, kind, capabilities_json, endpoint,
status, cost_mode, approval_required, protocol_json, auth_env, created_by, created_at, updated_at
) VALUES (?, 'org-studio-lab', 'ws-local-aidrama', 'Smoke Mixed Approval Runner', 'http-json', ?, ?, 'ready', 'mixed', 1, '{}', '', 'u-owner', ?, ?)`,
[connectorId, JSON.stringify(["text-to-image", "single-frame"]), `http://127.0.0.1:${runnerPort}/generate`, timestamp, timestamp]
);
dbRun(
`INSERT INTO model_catalog_entries(
id, organization_id, workspace_id, connector_id, model_key, display_name, family,
capabilities_json, context_window, max_output_tokens, cost_json, status,
approval_status, metadata_json, created_by, created_at, updated_at
) VALUES (?, 'org-studio-lab', 'ws-local-aidrama', ?, 'smoke-single-frame', 'Smoke 单画面审批模型', 'smoke', ?, 4096, 1024, ?, 'active', 'approved', '{}', 'u-owner', ?, ?)`,
[catalogId, connectorId, JSON.stringify(["text-to-image", "single-frame"]), JSON.stringify({ mode: "per-image", estimatedCny: 0.02, locality: "mixed" }), timestamp, timestamp]
);
dbRun(
`INSERT INTO model_routing_policies(
id, organization_id, workspace_id, name, workflow_key, operation_key,
primary_model_id, fallback_model_id, policy_mode, approval_mode,
budget_limit_cny, status, policy_json, created_by, created_at, updated_at
) VALUES (?, 'org-studio-lab', 'ws-local-aidrama', 'Smoke 审批路由', 'smoke-approval', 'image-keyframe', ?, NULL, 'prefer-approved', 'explicit-review', 1, 'active', ?, 'u-owner', ?, ?)`,
[routeId, catalogId, JSON.stringify({ qaGate: "single-frame", smoke: true }), timestamp, timestamp]
);
});
}
function cleanup() {
withTransaction(() => {
if (jobId) {
dbRun("DELETE FROM job_dependencies WHERE job_id = ? OR depends_on_job_id = ?", [jobId, jobId]);
dbRun("DELETE FROM job_attempts WHERE job_id = ?", [jobId]);
dbRun("DELETE FROM media_artifacts WHERE job_id = ?", [jobId]);
dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ?", [`%${jobId}%`]);
dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]);
}
if (approvalId) dbRun("DELETE FROM model_route_approval_requests WHERE id = ?", [approvalId]);
dbRun("DELETE FROM audit_logs WHERE target_id IN (?, ?, ?, ?)", [approvalId || "none", jobId || "none", routeId, connectorId]);
dbRun("DELETE FROM model_routing_policies WHERE id = ?", [routeId]);
dbRun("DELETE FROM model_catalog_entries WHERE id = ?", [catalogId]);
dbRun("DELETE FROM model_connectors WHERE id = ?", [connectorId]);
});
}
try {
seedApprovalRoute();
const owner = await login("producer@local.test");
const writer = await login("writer@local.test");
const approvalBody = {
workflowKey: "smoke-approval",
operationKey: "image-keyframe",
kind: "单画面关键帧",
localOnly: false,
estimatedCostCny: 0.02,
reason: "smoke: 需要一次受控混合成本模型执行审批"
};
const withoutApproval = await request("/api/jobs", {
method: "POST",
headers: writer,
body: JSON.stringify({ ...approvalBody, shotId: "shot-01", adapter: "owned-model-platform" })
});
assert.equal(withoutApproval.response.status, 403, "未携带审批单时,普通编剧不能创建受控模型任务");
assert.ok(["external_connector_requires_approval", "model_route_requires_approval"].includes(withoutApproval.payload.error), "未审批阻断错误码不正确");
const submitted = await request("/api/platform/model-route-approvals", {
method: "POST",
headers: writer,
body: JSON.stringify(approvalBody)
});
assert.equal(submitted.response.status, 201, `审批单提交失败:${submitted.payload.detail || submitted.payload.error || ""}`);
approvalId = submitted.payload.approval.id;
assert.equal(submitted.payload.approval.status, "submitted", "审批单初始状态必须是 submitted");
assert.equal(submitted.payload.approval.routeId, routeId, "审批单必须绑定命中的路由");
assert.equal(submitted.payload.approval.connectorId, connectorId, "审批单必须绑定命中的连接器");
const writerDecision = await request(`/api/platform/model-route-approvals/${encodeURIComponent(approvalId)}/decision`, {
method: "POST",
headers: writer,
body: JSON.stringify({ status: "approved" })
});
assert.equal(writerDecision.response.status, 403, "普通编剧不能审批模型路由申请");
const approved = await request(`/api/platform/model-route-approvals/${encodeURIComponent(approvalId)}/decision`, {
method: "POST",
headers: owner,
body: JSON.stringify({ status: "approved", expiresHours: 24, note: "smoke approval" })
});
assert.equal(approved.response.status, 200, `管理员审批失败:${approved.payload.detail || approved.payload.error || ""}`);
assert.equal(approved.payload.approval.status, "approved", "审批后状态必须是 approved");
assert.ok(approved.payload.approval.expiresAt, "审批通过必须写入过期时间");
const created = await request("/api/jobs", {
method: "POST",
headers: writer,
body: JSON.stringify({ ...approvalBody, shotId: "shot-01", adapter: "owned-model-platform", approvalRequestId: approvalId })
});
assert.equal(created.response.status, 201, `审批后任务创建失败:${created.payload.detail || created.payload.error || ""}`);
jobId = created.payload.job.id;
assert.equal(created.payload.job.status, "queued", "审批后的 ready 连接器任务应进入队列");
assert.equal(created.payload.job.modelRouteApprovalId, approvalId, "任务必须保存审批单 ID");
assert.equal(created.payload.job.request.preflight.modelRouteApproval.id, approvalId, "请求合同必须携带审批证据");
const executed = await request(`/api/jobs/${encodeURIComponent(jobId)}/run`, {
method: "POST",
headers: writer,
body: JSON.stringify({})
});
assert.equal(executed.response.status, 200, `审批任务执行失败:${executed.payload.detail || executed.payload.error || ""}`);
assert.equal(executed.payload.job.status, "completed", "本地 mock runner 应完成审批任务");
const approvalsAfterRun = await request("/api/platform/model-route-approvals", { headers: owner });
const consumed = approvalsAfterRun.payload.approvals.find((item) => item.id === approvalId);
assert.ok(consumed?.consumedAt, "执行后审批单必须被消耗");
const reused = await request("/api/jobs", {
method: "POST",
headers: writer,
body: JSON.stringify({ ...approvalBody, shotId: "shot-01", adapter: "owned-model-platform", approvalRequestId: approvalId })
});
assert.equal(reused.response.status, 403, "已消耗审批单不能复用创建下一次任务");
assert.equal(reused.payload.error, "model_route_approval_consumed", "审批单复用错误码不正确");
console.log(`model approval smoke passed: request, RBAC decision, job binding, execution consume (${approvalId})`);
} finally {
cleanup();
if (jobId) await rm(new URL(`../storage/jobs/${jobId}`, import.meta.url), { recursive: true, force: true });
await new Promise((resolve) => mockRunner.close(resolve));
}
+5
View File
@@ -4,6 +4,7 @@ import { withTransaction, dbRun } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const runnerPort = Number(process.env.AI_DRAMA_SMOKE_MODEL_PORT || 8792);
const secretEnv = "AI_DRAMA_SMOKE_MISSING_SECRET";
const scope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
@@ -69,12 +70,16 @@ try {
kind: "http-json",
capability: ["text-to-image", "single-frame"],
costMode: "local",
authEnv: secretEnv,
protocol: { healthRoute: "health" }
})
});
assert.equal(created.response.status, 201, "本地自定义 Runner 注册失败");
modelId = created.payload.model.id;
assert.equal(created.payload.model.approvalRequired, false, "本地连接器不应被强制标记为外部审批");
assert.equal(created.payload.model.secretPolicy.authEnv, secretEnv, "连接器必须只返回密钥环境变量名");
assert.equal(created.payload.model.secretPolicy.status, "missing", "未设置的密钥环境变量必须显示为 missing");
assert.equal(created.payload.model.endpointPolicy.localOnlySafe, true, "本地连接器 endpoint 应被识别为 local-only safe");
const edited = await request(`/api/platform/models/${encodeURIComponent(modelId)}`, owner, {
method: "PATCH",
+172
View File
@@ -0,0 +1,172 @@
import assert from "node:assert/strict";
import { rm } from "node:fs/promises";
import { resolve } from "node:path";
import { 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 scope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
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 };
}
async function login(email = "producer@local.test") {
const result = await request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password: "Demo@123456" })
});
assert.equal(result.response.ok, true, `模型路由 smoke 登录失败:${result.payload.detail || result.payload.error || ""}`);
return { authorization: `Bearer ${result.payload.session.token}`, ...scope };
}
const headers = await login();
const writerHeaders = await login("writer@local.test");
const createdJobIds = [];
let workflowRunId = "";
async function preview(body) {
const result = await request("/api/jobs/preview", { method: "POST", headers, body: JSON.stringify(body) });
assert.equal(result.response.ok, true, `任务预览失败:${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
function cleanupGeneratedRecords() {
withTransaction(() => {
for (const jobId of createdJobIds) {
dbRun("DELETE FROM job_dependencies WHERE job_id = ? OR depends_on_job_id = ?", [jobId, jobId]);
dbRun("DELETE FROM job_attempts WHERE job_id = ?", [jobId]);
dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ?", [`%${jobId}%`]);
dbRun("DELETE FROM audit_logs WHERE target_type = 'generation_job' AND target_id = ?", [jobId]);
dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]);
}
if (workflowRunId) {
dbRun("DELETE FROM audit_logs WHERE target_type = 'workflow_run' AND target_id = ?", [workflowRunId]);
dbRun("DELETE FROM workflow_runs WHERE id = ?", [workflowRunId]);
}
});
}
try {
const imageRouteResolution = await request("/api/platform/model-routes/resolve", {
method: "POST",
headers,
body: JSON.stringify({
workflowKey: "ai-manhua-drama",
operationKey: "image-keyframe",
kind: "单画面关键帧",
adapter: "comfyui-optional",
localOnly: true
})
});
assert.equal(imageRouteResolution.response.status, 200, `模型路由试算失败:${imageRouteResolution.payload.detail || imageRouteResolution.payload.error || ""}`);
assert.equal(imageRouteResolution.payload.resolution.source, "routing-policy", "模型路由试算必须优先命中业务路由");
assert.equal(imageRouteResolution.payload.resolution.route.routeId, "route-image-keyframe", "关键帧试算没有命中单画面路由");
assert.equal(imageRouteResolution.payload.resolution.connector.id, "owned-image", "试算不应被前端连接器选择绕过");
assert.equal(imageRouteResolution.payload.resolution.guard.status, "blocked", "未探活的本地关键帧连接器应被准入预检阻断");
assert.ok(imageRouteResolution.payload.resolution.guard.reasons.some((reason) => reason.code === "connector_not_ready"), "试算结果必须说明连接器尚未就绪");
const mixedRouteResolution = await request("/api/platform/model-routes/resolve", {
method: "POST",
headers,
body: JSON.stringify({
adapter: "newapi-audio-production",
kind: "固定 TTS 配音",
localOnly: true
})
});
assert.equal(mixedRouteResolution.response.status, 200, "混合连接器试算接口失败");
assert.equal(mixedRouteResolution.payload.resolution.guard.status, "blocked", "local-only 预检必须阻断混合/外部连接器");
assert.ok(mixedRouteResolution.payload.resolution.guard.reasons.some((reason) => reason.code === "local_only_violation"), "试算结果必须说明 local-only 冲突");
const deniedResolution = await request("/api/platform/model-routes/resolve", {
method: "POST",
headers: writerHeaders,
body: JSON.stringify({ workflowKey: "ai-manhua-drama", operationKey: "image-keyframe" })
});
assert.equal(deniedResolution.response.status, 403, "普通编剧不应访问模型路由试算器");
const imagePreview = await preview({
kind: "单画面关键帧",
shotId: "shot-01",
adapter: "comfyui-optional"
});
assert.equal(imagePreview.resolution.source, "routing-policy", "关键帧任务必须由路由策略解析");
assert.equal(imagePreview.resolution.route.routeId, "route-image-keyframe", "关键帧没有命中单画面路由");
assert.equal(imagePreview.resolution.route.modelKey, "qwen-image-2d-lock", "关键帧没有解析到 Qwen 单画面模型");
assert.equal(imagePreview.resolution.adapter.id, "owned-image", "路由策略不应被前端连接器选择绕过");
assert.equal(imagePreview.preview.job.modelEntryId, "catalog-qwen-image-2d", "请求合同必须记录模型目录条目");
const auditionPreview = await preview({
kind: "单句 TTS 试听",
shotId: "shot-01",
adapter: "local-tts"
});
assert.equal(auditionPreview.resolution.source, "legacy-adapter", "单句试听应保留本地试听连接器,不进入正式配音路由");
assert.equal(auditionPreview.resolution.adapter.id, "local-tts", "单句试听没有使用本地 TTS 连接器");
const formalTtsPreview = await preview({
kind: "固定 TTS 配音",
shotId: "shot-01",
adapter: "owned-model-platform"
});
assert.equal(formalTtsPreview.resolution.route.routeId, "route-voice-tts", "固定配音没有命中声音路由");
assert.equal(formalTtsPreview.resolution.route.requiresApproval, true, "混合固定配音必须要求审批");
const blockedVoice = await request("/api/jobs", {
method: "POST",
headers,
body: JSON.stringify({
kind: "固定 TTS 配音",
shotId: "shot-01",
adapter: "owned-model-platform",
approveExternal: true
})
});
assert.equal(blockedVoice.response.status, 422, "未授权角色声线必须阻断固定 TTS 任务");
assert.equal(blockedVoice.payload.error, "voice_reference_not_approved", "固定 TTS 的声音授权错误码不正确");
const createdImage = await request("/api/jobs", {
method: "POST",
headers,
body: JSON.stringify({ kind: "单画面关键帧", shotId: "shot-01", adapter: "owned-model-platform" })
});
assert.equal(createdImage.response.status, 201, `关键帧任务创建失败:${createdImage.payload.detail || createdImage.payload.error || ""}`);
createdJobIds.push(createdImage.payload.job.id);
assert.equal(createdImage.payload.job.routing.routeId, "route-image-keyframe", "已创建任务没有保存路由证据");
assert.equal(createdImage.payload.job.request.routing.modelKey, "qwen-image-2d-lock", "请求合同没有保存实际模型 Key");
assert.equal(createdImage.payload.job.cost_policy, "local", "路由成本策略没有写入任务");
const workflow = await request("/api/workflows/templates/workflow-global-ai-manhua-drama-v1/instantiate", {
method: "POST",
headers,
body: JSON.stringify({ mode: "queue", shotId: "shot-01", adapter: "comfyui-optional", approveExternal: true })
});
assert.equal(workflow.response.status, 201, `工作流入队失败:${workflow.payload.detail || workflow.payload.error || ""}`);
workflowRunId = workflow.payload.run.id;
createdJobIds.push(...workflow.payload.jobs);
assert.equal(workflow.payload.run.status, "blocked", "存在未授权固定声线时,工作流必须保持阻塞");
assert.equal(workflow.payload.jobs.length, 2, "未授权固定 TTS 步骤不得创建任务,前序图像和视频任务仍应保留");
for (const jobId of workflow.payload.jobs) {
const detail = await request(`/api/jobs/${encodeURIComponent(jobId)}`, { headers });
assert.equal(detail.response.ok, true, "工作流任务详情读取失败");
assert.ok(detail.payload.job.routing?.routeId, "工作流创建的任务必须记录模型路由");
}
console.log("model routing smoke passed: route resolution, local audition, voice-rights gate, workflow queue");
} finally {
cleanupGeneratedRecords();
for (const jobId of createdJobIds) {
await rm(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true, force: true });
}
}
+94
View File
@@ -0,0 +1,94 @@
import assert from "node:assert/strict";
import { dbRun } from "../server/db.mjs";
const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
const scope = {
"x-organization-id": "org-studio-lab",
"x-workspace-id": "ws-local-aidrama",
"x-project-id": "thunder-mouth"
};
async function request(path, headers = {}, options = {}) {
const response = await fetch(`${api}${path}`, {
...options,
headers: { "content-type": "application/json", ...headers, ...(options.headers || {}) }
});
const payload = await response.json().catch(() => ({}));
return { response, payload };
}
function assertOk(result, label) {
assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
return result.payload;
}
async function login(email) {
const result = await request("/api/auth/login", {}, {
method: "POST",
body: JSON.stringify({ email, password: "Demo@123456" })
});
return { authorization: `Bearer ${assertOk(result, `${email} 登录`).session.token}` };
}
const owner = { ...(await login("producer@local.test")), ...scope };
const writer = { ...(await login("writer@local.test")), ...scope };
const tierKey = `smoke-plan-${Date.now()}`;
const planId = `plan-${tierKey}`;
try {
const before = assertOk(await request("/api/system/plan-templates", owner), "读取套餐模板");
assert.ok(before.planTemplates.length >= 3, "系统应至少有默认套餐模板");
const denied = await request("/api/system/plan-templates", writer, {
method: "POST",
body: JSON.stringify({
tierKey,
name: "Writer Should Not Create Plan",
seatLimit: 3,
storageGb: 128,
monthlyClipQuota: 200,
limits: {},
features: {},
connectorPolicy: {}
})
});
assert.equal(denied.response.status, 403, "非系统管理员不能创建系统套餐模板");
const created = assertOk(await request("/api/system/plan-templates", owner, {
method: "POST",
body: JSON.stringify({
tierKey,
name: "Smoke Studio Plan",
description: "烟测创建的本地商用套餐模板",
billingCycle: "monthly",
currency: "CNY",
baseFee: 0,
seatLimit: 6,
storageGb: 384,
monthlyClipQuota: 900,
limits: { workspaces: 3, projects: 12, modelConnectors: 8, apiClients: 4 },
features: { batchGeneration: true, privateDeliveryPortal: true, comfyuiAdapter: false, externalCloudConnectors: false },
connectorPolicy: { allowedCostModes: ["local"], externalApprovalRequired: true },
supportSla: "烟测支持",
status: "active"
})
}), "创建套餐模板");
assert.equal(created.planTemplate.tierKey, tierKey, "创建后应返回新套餐");
assert.equal(created.planTemplate.seatLimit, 6, "席位上限未保存");
const updated = assertOk(await request(`/api/system/plan-templates/${encodeURIComponent(planId)}`, owner, {
method: "PATCH",
body: JSON.stringify({ ...created.planTemplate, seatLimit: 9, status: "archived" })
}), "更新并归档套餐模板");
assert.equal(updated.planTemplate.seatLimit, 9, "套餐模板更新未生效");
assert.equal(updated.planTemplate.status, "archived", "套餐模板归档未生效");
assert.ok(updated.planTemplates.some((plan) => plan.tierKey === tierKey && plan.status === "archived"), "系统模板列表应包含归档模板");
const commercial = assertOk(await request("/api/organizations/org-studio-lab/commercial", owner), "读取组织商业运营");
assert.equal(commercial.planTemplates.some((plan) => plan.tierKey === tierKey), false, "组织商业页不应把归档模板作为可分配模板");
console.log(`plan template smoke passed: ${api}`);
} finally {
dbRun("DELETE FROM audit_logs WHERE target_type = 'subscription_plan_template' AND target_id IN (?, ?)", [tierKey, planId]);
dbRun("DELETE FROM subscription_plan_templates WHERE tier_key = ?", [tierKey]);
}
+60 -1
View File
@@ -25,6 +25,61 @@ function expectOk(result, label) {
return result.payload;
}
async function createApprovedLockAsset(headers, { projectId, shotId, kind, name, usageRole }) {
const upload = expectOk(await request("/api/assets/upload", {
method: "POST",
headers,
body: JSON.stringify({
data: Buffer.from(JSON.stringify({ projectId, kind, name, generatedBy: "smoke-project-isolation" })).toString("base64"),
fileName: `${kind}-lock.json`,
kind,
name,
mimeType: "application/json",
metadata: {
detail: `${name} 是隔离验收项目的原创 ${kind} 锁资产。`,
lock: "仅绑定当前项目首个镜头,用于验证连续性治理和项目隔离。"
},
provenance: {
sourceType: "original",
sourceName: projectId,
sourceRef: `smoke://${projectId}/${kind}`,
creator: "local-smoke"
},
tags: ["original", "continuity-lock", kind]
})
}), `upload ${kind} lock asset`);
const assetId = upload.asset.id;
const rights = expectOk(await request(`/api/assets/${encodeURIComponent(assetId)}/rights`, {
method: "POST",
headers,
body: JSON.stringify({
rightsStatus: "approved",
evidence: { reference: `local-evidence/${projectId}/${kind}`, notes: "隔离 smoke 本地原创资产证据" },
provenance: {
sourceType: "original",
sourceName: projectId,
sourceRef: `smoke://${projectId}/${kind}`,
creator: "local-smoke"
},
licenseScope: "commercial-test-local"
})
}), `approve ${kind} lock asset`);
assert.equal(rights.asset.currentVersion.rights_status, "approved", `${kind} asset rights must be approved`);
const locked = expectOk(await request(`/api/assets/${encodeURIComponent(assetId)}/lock`, {
method: "POST",
headers,
body: JSON.stringify({ lockStatus: "locked" })
}), `lock ${kind} asset`);
assert.equal(locked.asset.lock_status, "locked", `${kind} asset must be locked`);
const bound = expectOk(await request(`/api/assets/${encodeURIComponent(assetId)}/bindings`, {
method: "POST",
headers,
body: JSON.stringify({ shotId, usageRole })
}), `bind ${kind} asset`);
assert.ok(bound.asset.bindings.some((binding) => binding.shot_id === shotId), `${kind} asset must bind to isolated shot`);
return bound.asset;
}
let created = false;
let emptyOrganizationCreated = false;
let isolatedAssetPath = "";
@@ -81,11 +136,15 @@ try {
assert.equal(graph.graph.series.title, projectName, "production graph must use the new series");
assert.equal(graph.graph.shots.length, 1, "production graph must contain the new starter shot only");
assert.equal(graph.graph.characters.length, 0, "production graph must not include seeded characters");
const isolatedShotId = scoped.project.shots[0].id;
await createApprovedLockAsset(projectHeaders, { projectId, shotId: isolatedShotId, kind: "character", name: "隔离项目角色锁", usageRole: "character" });
await createApprovedLockAsset(projectHeaders, { projectId, shotId: isolatedShotId, kind: "location", name: "隔离项目场景锁", usageRole: "location" });
await createApprovedLockAsset(projectHeaders, { projectId, shotId: isolatedShotId, kind: "prop", name: "隔离项目道具锁", usageRole: "prop" });
const job = expectOk(await request("/api/jobs", {
method: "POST",
headers: projectHeaders,
body: JSON.stringify({ adapter: "owned-image", kind: "隔离验收关键帧", shotId: scoped.project.shots[0].id, output: `qa/${projectId}/frame.json` })
body: JSON.stringify({ adapter: "owned-image", kind: "隔离验收关键帧", shotId: isolatedShotId, output: `qa/${projectId}/frame.json` })
}), "create isolated job");
const afterJob = expectOk(await request("/api/project", { headers: projectHeaders }), "read isolated project after job");
assert.equal(afterJob.jobs.length, 1, "new project job list must contain only its own job");
+39 -9
View File
@@ -7,8 +7,8 @@ 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 projectId = "thunder-mouth";
const runId = Date.now();
const projectId = `smoke-release-project-${runId}`;
const sourceRoot = `storage/smoke-release-${runId}`;
const deliveryLabel = `smoke-release-delivery-${runId}`;
const batchId = `smoke-release-batch-${runId}`;
@@ -46,6 +46,12 @@ const reviewerLogin = expectOk(await request("/api/auth/login", {
body: JSON.stringify({ email: "review@local.test", password: "Demo@123456" })
}), "reviewer login");
const producerSetupHeaders = {
authorization: `Bearer ${producerLogin.session.token}`,
"x-organization-id": organizationId,
"x-workspace-id": workspaceId,
"x-project-id": "thunder-mouth"
};
const producerHeaders = {
authorization: `Bearer ${producerLogin.session.token}`,
"x-organization-id": organizationId,
@@ -59,8 +65,6 @@ const reviewerHeaders = {
"x-project-id": projectId
};
const projectShot = dbGet("SELECT id FROM shots WHERE episode_id IN (SELECT e.id FROM episodes e JOIN seasons se ON se.id = e.season_id JOIN series sr ON sr.id = se.series_id WHERE sr.project_id = ?) ORDER BY shot_number LIMIT 1", [projectId]);
assert.ok(projectShot?.id, "smoke project must have a starter shot");
const producerUser = dbGet("SELECT id FROM users WHERE email = ?", ["producer@local.test"]);
assert.ok(producerUser?.id, "producer user must exist");
const channelPath = `/api/production/delivery-channels`;
@@ -68,8 +72,20 @@ let channelId = "";
let releaseId = "";
let createdDeliveryId = "";
let seeded = false;
let projectCreated = false;
try {
const project = expectStatus(await request("/api/projects", {
method: "POST",
headers: producerSetupHeaders,
body: JSON.stringify({ id: projectId, name: `发布流程烟测 ${runId}`, type: "AI 漫剧" })
}), 201, "producer creates isolated release project");
projectCreated = true;
assert.equal(project.project.id, projectId, "isolated project must be created");
const graph = expectOk(await request("/api/production/graph", { headers: producerHeaders }), "read isolated production graph");
const projectShot = { id: graph.graph.shots[0]?.id };
assert.ok(projectShot.id, "smoke project must have a starter shot");
const channels = expectOk(await request(channelPath, { headers: reviewerHeaders }), "reviewer can view channels");
assert.ok(channels.channels.some((channel) => channel.kind === "local-file"), "workspace must expose the default local-file channel");
@@ -104,11 +120,13 @@ try {
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-manifest", deliveryId: createdDeliveryId }, null, 2));
const timestamp = new Date().toISOString();
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, 'smoke active batch', 'active', ?, '{}', ?, ?, ?, ?)", [batchId, organizationId, workspaceId, projectId, delivery.delivery.id, 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, ?, ?, ?, '{}', ?)", [`smoke-release-item-${runId}`, batchId, projectShot.id, sourcePath, lastFramePath, "a".repeat(64), timestamp]);
dbRun("UPDATE deliveries SET status = 'approved', active_batch_id = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [batchId, producerUser.id, timestamp, timestamp, delivery.delivery.id]);
seeded = true;
const timestamp = new Date().toISOString();
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, 'smoke active batch', 'active', ?, '{}', ?, ?, ?, ?)", [batchId, organizationId, workspaceId, projectId, delivery.delivery.id, 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, ?, ?, ?, ?, ?)", [`smoke-release-item-${runId}`, batchId, projectShot.id, sourcePath, lastFramePath, "a".repeat(64), JSON.stringify({ artifactStatus: "inspected" }), timestamp]);
dbRun("UPDATE deliveries SET status = 'approved', active_batch_id = ?, approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [batchId, producerUser.id, timestamp, timestamp, delivery.delivery.id]);
expectOk(await request("/api/production/reviews", { headers: producerHeaders }), "ensure isolated QA reviews");
dbRun("UPDATE reviews SET status = 'approved', score = 100, decision_by = ?, decision_at = ?, evidence_json = ?, updated_at = ? WHERE project_id = ?", [producerUser.id, timestamp, JSON.stringify({ source: "smoke-release-workflow", checkedAt: timestamp }), timestamp, projectId]);
seeded = true;
const release = expectStatus(await request(`/api/production/deliveries/${encodeURIComponent(delivery.delivery.id)}/releases`, {
method: "POST",
@@ -169,5 +187,17 @@ try {
}
if (channelId) dbRun("DELETE FROM delivery_channels WHERE id = ?", [channelId]);
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [createdDeliveryId]);
dbRun("DELETE FROM delivery_clearance_reports WHERE project_id = ?", [projectId]);
dbRun("DELETE FROM reviews WHERE project_id = ?", [projectId]);
if (projectCreated) {
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 projects WHERE id = ?", [projectId]);
}
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 });
}
+18 -2
View File
@@ -89,18 +89,34 @@ try {
const customJob = await request("/api/jobs", {
method: "POST",
headers,
body: JSON.stringify({ adapter: customModelId, kind: "自定义单画面关键帧", shotId: "shot-01", output: "storage/jobs/smoke-custom/output.json" })
body: JSON.stringify({
adapter: customModelId,
routingMode: "direct-connector",
directConnectorReason: "本地 Worker 自定义 JSON 协议验收",
kind: "自定义单画面关键帧",
shotId: "shot-01",
output: "storage/jobs/smoke-custom/output.json"
})
});
assert.equal(customJob.response.status, 201, "custom job creation failed");
assert.equal(customJob.payload.job.status, "queued", "custom job should be queued for local Worker");
assert.equal(customJob.payload.job.request.preflight.executionSource, "direct-connector", "custom job must bypass business routing only in direct connector mode");
const openAiJob = await request("/api/jobs", {
method: "POST",
headers,
body: JSON.stringify({ adapter: openAiModelId, kind: "OpenAI-compatible 单画面关键帧", shotId: "shot-01", output: "storage/jobs/smoke-openai/output.png" })
body: JSON.stringify({
adapter: openAiModelId,
routingMode: "direct-connector",
directConnectorReason: "本地 Worker OpenAI-compatible 图像协议验收",
kind: "OpenAI-compatible 单画面关键帧",
shotId: "shot-01",
output: "storage/jobs/smoke-openai/output.png"
})
});
assert.equal(openAiJob.response.status, 201, "OpenAI-compatible job creation failed");
assert.equal(openAiJob.payload.job.status, "queued", "OpenAI-compatible job should be queued");
assert.equal(openAiJob.payload.job.request.preflight.executionSource, "direct-connector", "OpenAI-compatible job must use direct connector mode");
jobIds = [customJob.payload.job.id, openAiJob.payload.job.id];
const deadline = Date.now() + 12_000;