feat: bootstrap commercial AI drama platform
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { resolve } from "node:path";
|
||||
import { dbAll, dbRun, withTransaction } from "../server/db.mjs";
|
||||
|
||||
const api = "http://127.0.0.1:8787";
|
||||
const runId = Date.now();
|
||||
let createdAssetId = "";
|
||||
let uploadedAssetId = "";
|
||||
let jobId = "";
|
||||
let originalSeries = null;
|
||||
let originalEpisode = null;
|
||||
|
||||
async function cleanup() {
|
||||
const assetIds = [createdAssetId, uploadedAssetId].filter(Boolean);
|
||||
const assetPaths = assetIds.length
|
||||
? dbAll(`SELECT storage_path FROM asset_versions WHERE asset_id IN (${assetIds.map(() => "?").join(",")})`, assetIds)
|
||||
: [];
|
||||
|
||||
withTransaction(() => {
|
||||
if (assetIds.length) {
|
||||
const placeholders = assetIds.map(() => "?").join(",");
|
||||
dbRun(`DELETE FROM asset_bindings WHERE asset_id IN (${placeholders})`, assetIds);
|
||||
dbRun(`DELETE FROM asset_versions WHERE asset_id IN (${placeholders})`, assetIds);
|
||||
dbRun(`DELETE FROM assets WHERE id IN (${placeholders})`, assetIds);
|
||||
}
|
||||
if (jobId) {
|
||||
dbRun("DELETE FROM media_artifacts WHERE job_id = ?", [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 usage_events WHERE metadata_json LIKE ?", [`%${jobId}%`]);
|
||||
dbRun("DELETE FROM audit_logs WHERE target_id = ?", [jobId]);
|
||||
dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]);
|
||||
}
|
||||
if (originalSeries?.id) {
|
||||
dbRun("UPDATE series SET title = ?, logline = ?, format = ?, visual_style = ?, continuity_rule = ?, show_engine = ?, updated_at = ? WHERE id = ?", [originalSeries.title, originalSeries.logline, originalSeries.format, originalSeries.visual_style, originalSeries.continuity_rule, originalSeries.show_engine, originalSeries.updated_at, originalSeries.id]);
|
||||
}
|
||||
if (originalEpisode?.id) {
|
||||
dbRun("UPDATE episodes SET title = ?, status = ?, target_duration_sec = ?, hook = ?, cliffhanger = ?, updated_at = ? WHERE id = ?", [originalEpisode.title, originalEpisode.status, originalEpisode.target_duration_sec, originalEpisode.hook, originalEpisode.cliffhanger, originalEpisode.updated_at, originalEpisode.id]);
|
||||
}
|
||||
});
|
||||
|
||||
await Promise.all(assetPaths.map((asset) => rm(resolve(import.meta.dirname, "..", asset.storage_path), { force: true }).catch(() => {})));
|
||||
for (const assetId of assetIds) {
|
||||
await rm(resolve(import.meta.dirname, "..", "storage", "assets", "thunder-mouth", assetId), { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const response = await fetch(`${api}${path}`, {
|
||||
...options,
|
||||
headers: { "content-type": "application/json", ...(options.headers || {}) }
|
||||
});
|
||||
const payload = await response.json();
|
||||
assert.equal(response.ok, true, `${options.method || "GET"} ${path}: ${response.status} ${payload.detail || payload.error || ""}`);
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function rawRequest(path, options = {}) {
|
||||
const response = await fetch(`${api}${path}`, {
|
||||
...options,
|
||||
headers: { "content-type": "application/json", ...(options.headers || {}) }
|
||||
});
|
||||
return { response, payload: await response.json().catch(() => ({})) };
|
||||
}
|
||||
|
||||
try {
|
||||
const login = await request("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "producer@local.test", password: "Demo@123456" })
|
||||
});
|
||||
const headers = {
|
||||
authorization: `Bearer ${login.session.token}`,
|
||||
"x-organization-id": "org-studio-lab",
|
||||
"x-workspace-id": "ws-local-aidrama",
|
||||
"x-project-id": "thunder-mouth"
|
||||
};
|
||||
|
||||
const reviewerLogin = await request("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ email: "review@local.test", password: "Demo@123456" })
|
||||
});
|
||||
const reviewerHeaders = {
|
||||
authorization: `Bearer ${reviewerLogin.session.token}`,
|
||||
"x-organization-id": "org-studio-lab",
|
||||
"x-workspace-id": "ws-local-aidrama",
|
||||
"x-project-id": "thunder-mouth"
|
||||
};
|
||||
const reviewerAssets = await rawRequest("/api/assets", { headers: reviewerHeaders });
|
||||
assert.equal(reviewerAssets.response.status, 403, "没有资产权限的审片用户不能读取资产库");
|
||||
const reviewerUpload = await rawRequest("/api/assets/upload", {
|
||||
method: "POST",
|
||||
headers: reviewerHeaders,
|
||||
body: JSON.stringify({ data: Buffer.from("reviewer-must-be-blocked").toString("base64"), fileName: "blocked.txt" })
|
||||
});
|
||||
assert.equal(reviewerUpload.response.status, 403, "没有资产权限的审片用户不能上传资产");
|
||||
|
||||
const assets = await request("/api/assets", { headers });
|
||||
assert.ok(Array.isArray(assets.assets), "资产列表必须是数组");
|
||||
assert.ok(assets.assets.length > 0, "示例项目必须有资产");
|
||||
|
||||
const invalidUpload = await rawRequest("/api/assets/upload", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ data: "not-base64@@", fileName: "invalid.txt" })
|
||||
});
|
||||
assert.equal(invalidUpload.response.status, 400, "非法 Base64 必须被服务端拒绝");
|
||||
assert.equal(invalidUpload.payload.error, "asset_data_invalid", "非法 Base64 必须返回明确错误码");
|
||||
|
||||
const created = await request("/api/assets", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ name: `smoke-asset-${Date.now()}`, kind: "reference", subtitle: "smoke" })
|
||||
});
|
||||
assert.ok(created.asset?.id, "创建资产必须返回 id");
|
||||
createdAssetId = created.asset.id;
|
||||
|
||||
const bound = await request(`/api/assets/${encodeURIComponent(created.asset.id)}/bindings`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ shotId: "shot-01", usageRole: "reference" })
|
||||
});
|
||||
assert.equal(bound.asset.bindings.some((item) => item.shot_id === "shot-01"), true, "资产绑定必须写入镜头");
|
||||
|
||||
const uploaded = await request("/api/assets/upload", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ data: Buffer.from("creator-suite-smoke").toString("base64"), fileName: "smoke-reference.txt", kind: "reference" })
|
||||
});
|
||||
assert.ok(uploaded.asset?.currentVersion?.storage_path?.includes("storage/assets/"), "本地上传必须写入 storage/assets");
|
||||
uploadedAssetId = uploaded.asset.id;
|
||||
const firstHash = createHash("sha256").update("creator-suite-smoke").digest("hex");
|
||||
assert.equal(uploaded.asset.currentVersion.contentSha256, firstHash, "上传资产必须登记 SHA-256");
|
||||
|
||||
const uploadedVersion = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/versions/upload`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ data: Buffer.from("creator-suite-smoke-v2").toString("base64"), fileName: "smoke-reference-v2.txt", mimeType: "text/plain", versionNote: "smoke file version" })
|
||||
});
|
||||
const secondHash = createHash("sha256").update("creator-suite-smoke-v2").digest("hex");
|
||||
assert.equal(uploadedVersion.asset.currentVersion.version_number, 2, "文件上传版本必须递增");
|
||||
assert.equal(uploadedVersion.asset.currentVersion.contentSha256, secondHash, "文件版本必须登记新的 SHA-256");
|
||||
const verification = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/verify`, { method: "POST", headers, body: JSON.stringify({}) });
|
||||
assert.equal(verification.verification.verified, true, "当前资产文件完整性校验必须通过");
|
||||
const contentResponse = await fetch(`${api}/api/assets/${encodeURIComponent(uploaded.asset.id)}/content`, { headers });
|
||||
assert.equal(contentResponse.status, 200, "资产内容读取必须成功");
|
||||
assert.equal(contentResponse.headers.get("etag"), `"${secondHash}"`, "资产内容响应必须返回 ETag");
|
||||
assert.equal(await contentResponse.text(), "creator-suite-smoke-v2", "资产内容必须与当前文件版本一致");
|
||||
|
||||
const versioned = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/versions`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ versionNote: "smoke version" })
|
||||
});
|
||||
assert.equal(versioned.asset.currentVersion.version_number, 3, "资产版本必须递增");
|
||||
|
||||
const restored = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/versions/${encodeURIComponent(versioned.asset.versions.find((version) => version.version_number === 1).id)}/restore`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({})
|
||||
});
|
||||
assert.equal(restored.asset.currentVersion.version_number, 1, "资产版本必须支持恢复到历史版本");
|
||||
|
||||
const locked = await request(`/api/assets/${encodeURIComponent(uploaded.asset.id)}/lock`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ lockStatus: "locked" })
|
||||
});
|
||||
assert.equal(locked.asset.lockStatus, "locked", "资产锁定状态必须持久化");
|
||||
|
||||
const assistant = await request("/api/assistant/query", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ question: "检查连续性", actionId: "continuity" })
|
||||
});
|
||||
assert.ok(assistant.answer?.length > 10, "项目助手必须返回真实答案");
|
||||
|
||||
const originalGraph = await request("/api/production/graph", { headers });
|
||||
originalSeries = originalGraph.graph.series;
|
||||
originalEpisode = originalGraph.graph.episode;
|
||||
const bibleUpdate = await request("/api/production/bible", {
|
||||
method: "PATCH",
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
logline: "smoke test:雷暴来临前的原创避险选择。",
|
||||
continuityRule: "smoke test:角色、服装、道具、天气、机位和实际末帧必须连续。"
|
||||
})
|
||||
});
|
||||
assert.equal(bibleUpdate.bible.series.logline, "smoke test:雷暴来临前的原创避险选择。", "系列 Bible 更新必须写入数据库");
|
||||
assert.equal(bibleUpdate.bible.series.continuity_rule, "smoke test:角色、服装、道具、天气、机位和实际末帧必须连续。", "连续性规则必须写入数据库");
|
||||
|
||||
const graphAfterBibleUpdate = await request("/api/production/graph", { headers });
|
||||
assert.equal(graphAfterBibleUpdate.graph.series.logline, "smoke test:雷暴来临前的原创避险选择。", "生产图谱必须读回最新系列 Bible");
|
||||
|
||||
const usageBeforeJob = await request("/api/usage", { headers });
|
||||
const job = await request("/api/jobs", {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ adapter: "local-tts", kind: "单句 TTS 试听", shotId: "shot-01", output: `voices/auditions/smoke-${runId}-line.wav` })
|
||||
});
|
||||
jobId = job.job?.id || "";
|
||||
assert.ok(["queued", "blocked"].includes(job.job?.status), "单句试听必须写入可追踪任务");
|
||||
if (job.job?.status === "blocked") assert.match(job.job.errorMessage || "", /连接器|planned|not-connected/i, "连接器未就绪时必须记录阻塞原因");
|
||||
assert.ok(job.jobs.some((item) => item.output === `voices/auditions/smoke-${runId}-line.wav`), "任务输出路径必须可追踪");
|
||||
const usageAfterJob = await request("/api/usage", { headers });
|
||||
const beforeClip = usageBeforeJob.usage?.quotas?.find((quota) => quota.metric === "clip")?.used_value || 0;
|
||||
const afterClip = usageAfterJob.usage?.quotas?.find((quota) => quota.metric === "clip")?.used_value || 0;
|
||||
assert.ok(afterClip > beforeClip, "生成任务写入后必须递增片段额度用量");
|
||||
|
||||
console.log("creator-suite smoke passed");
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
Reference in New Issue
Block a user