feat: expand commercial ai drama platform
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>AI 短剧本地生产台</title>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
+11
-1
@@ -20,12 +20,21 @@
|
||||
"smoke:commercial-governance": "node scripts/smoke-commercial-governance.mjs",
|
||||
"smoke:invitations": "node scripts/smoke-invitations.mjs",
|
||||
"smoke:commercial-ops": "node scripts/smoke-commercial-ops.mjs",
|
||||
"smoke:commercial-approvals": "node scripts/smoke-commercial-approvals.mjs",
|
||||
"smoke:entitlements": "node scripts/smoke-entitlements.mjs",
|
||||
"smoke:plan-templates": "node scripts/smoke-plan-templates.mjs",
|
||||
"smoke:billing-ledger": "node scripts/smoke-billing-ledger.mjs",
|
||||
"smoke:release-workflow": "node scripts/smoke-release-workflow.mjs",
|
||||
"smoke:delivery-portal": "node scripts/smoke-delivery-portal.mjs",
|
||||
"smoke:delivery-clearance": "node scripts/smoke-delivery-clearance.mjs",
|
||||
"smoke:oidc": "node scripts/smoke-oidc.mjs",
|
||||
"smoke:creator-suite": "node scripts/smoke-creator-suite.mjs",
|
||||
"smoke:knowledge-rag": "node scripts/smoke-knowledge-rag.mjs",
|
||||
"smoke:knowledge-governance": "node scripts/smoke-knowledge-governance.mjs",
|
||||
"smoke:asset-governance": "node scripts/smoke-asset-governance.mjs",
|
||||
"smoke:model-connectors": "node scripts/smoke-model-connectors.mjs",
|
||||
"smoke:model-approvals": "node scripts/smoke-model-approvals.mjs",
|
||||
"smoke:model-routing": "node scripts/smoke-model-routing.mjs",
|
||||
"smoke:api-clients": "node scripts/smoke-api-clients.mjs",
|
||||
"smoke:api-rate-limit": "node scripts/smoke-api-rate-limit.mjs",
|
||||
"smoke:ops": "node scripts/smoke-ops.mjs",
|
||||
@@ -44,7 +53,8 @@
|
||||
"smoke:search": "node scripts/smoke-search.mjs",
|
||||
"smoke:project-isolation": "node scripts/smoke-project-isolation.mjs",
|
||||
"smoke:project-lifecycle": "node scripts/smoke-project-lifecycle.mjs",
|
||||
"smoke:all": "node scripts/smoke-all.mjs"
|
||||
"smoke:all": "node scripts/smoke-all.mjs",
|
||||
"cleanup:demo-noise": "node scripts/cleanup-demo-noise.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@node-saml/node-saml": "^5.1.0",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="14" fill="#152033"/>
|
||||
<path d="M18 19h28a5 5 0 0 1 5 5v16a5 5 0 0 1-5 5H18a5 5 0 0 1-5-5V24a5 5 0 0 1 5-5Z" fill="#f8fafc"/>
|
||||
<path d="M27 26v12l11-6-11-6Z" fill="#2f6fed"/>
|
||||
<path d="M16 50h32" stroke="#66c6a3" stroke-width="5" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 360 B |
@@ -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));
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+454
-1
@@ -35,6 +35,7 @@ ensureColumn("generation_jobs", "request_json", "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn("generation_jobs", "result_json", "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn("generation_jobs", "error_message", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("generation_jobs", "max_attempts", "INTEGER NOT NULL DEFAULT 3");
|
||||
ensureColumn("generation_jobs", "model_route_approval_id", "TEXT");
|
||||
ensureColumn("generation_jobs", "next_run_at", "TEXT");
|
||||
ensureColumn("generation_jobs", "leased_by", "TEXT");
|
||||
ensureColumn("generation_jobs", "leased_at", "TEXT");
|
||||
@@ -83,11 +84,17 @@ ensureColumn("asset_versions", "file_name", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("asset_versions", "mime_type", "TEXT NOT NULL DEFAULT 'application/octet-stream'");
|
||||
ensureColumn("asset_versions", "file_size", "INTEGER NOT NULL DEFAULT 0");
|
||||
ensureColumn("asset_versions", "content_sha256", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("asset_versions", "provenance_json", "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn("asset_versions", "risk_json", "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn("asset_versions", "tags_json", "TEXT NOT NULL DEFAULT '[]'");
|
||||
ensureColumn("asset_versions", "license_scope", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("asset_versions", "expires_at", "TEXT");
|
||||
ensureColumn("shots", "current_version_id", "TEXT");
|
||||
ensureColumn("deliveries", "active_batch_id", "TEXT");
|
||||
ensureColumn("delivery_releases", "idempotency_key", "TEXT NOT NULL DEFAULT ''");
|
||||
ensureColumn("delivery_releases", "preflight_json", "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn("delivery_releases", "result_json", "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn("script_documents", "metadata_json", "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn("projects", "archived_at", "TEXT");
|
||||
ensureColumn("projects", "archived_by", "TEXT");
|
||||
ensureColumn("projects", "archived_from_status", "TEXT");
|
||||
@@ -96,6 +103,11 @@ ensureColumn("workspace_members", "access_mode", "TEXT NOT NULL DEFAULT 'all'");
|
||||
ensureColumn("auth_sessions", "device_id", "TEXT");
|
||||
ensureColumn("auth_sessions", "risk_level", "TEXT NOT NULL DEFAULT 'medium'");
|
||||
ensureColumn("auth_sessions", "risk_score", "INTEGER NOT NULL DEFAULT 50");
|
||||
ensureColumn("knowledge_documents", "rights_status", "TEXT NOT NULL DEFAULT 'needs-evidence'");
|
||||
ensureColumn("knowledge_documents", "provenance_json", "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn("knowledge_documents", "tags_json", "TEXT NOT NULL DEFAULT '[]'");
|
||||
ensureColumn("knowledge_documents", "risk_json", "TEXT NOT NULL DEFAULT '{}'");
|
||||
ensureColumn("knowledge_documents", "current_version_number", "INTEGER NOT NULL DEFAULT 1");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_projects_lifecycle ON projects(workspace_id, status, updated_at)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_invites_token ON invitations(token_hash, status)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_identity_providers_org ON identity_providers(organization_id, enabled, status)");
|
||||
@@ -113,12 +125,24 @@ db.exec("CREATE INDEX IF NOT EXISTS idx_auth_sessions_device ON auth_sessions(de
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_channels_scope ON delivery_channels(organization_id, workspace_id, project_id, enabled, created_at)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_releases_scope ON delivery_releases(organization_id, workspace_id, project_id, delivery_id, status, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_releases_idempotency ON delivery_releases(organization_id, project_id, idempotency_key)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_clearance_reports_scope ON delivery_clearance_reports(organization_id, workspace_id, project_id, delivery_id, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_clearance_reports_release ON delivery_clearance_reports(release_id, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_links_token ON delivery_access_links(token_hash, status, expires_at)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_links_scope ON delivery_access_links(organization_id, workspace_id, project_id, release_id, status, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_events_link ON delivery_access_events(link_id, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_events_release ON delivery_access_events(release_id, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_link ON delivery_access_feedback(link_id, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_release ON delivery_access_feedback(release_id, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_model_route_approvals_scope ON model_route_approval_requests(organization_id, workspace_id, status, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_model_route_approvals_requester ON model_route_approval_requests(requester_user_id, status, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_model_route_approvals_job ON model_route_approval_requests(job_id, status)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_generation_jobs_model_approval ON generation_jobs(model_route_approval_id)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_org_status ON commercial_approval_requests(organization_id, status, updated_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_requester ON commercial_approval_requests(requester_user_id, status, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_target ON commercial_approval_requests(organization_id, request_type, target_key, status)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_versions_governance ON asset_versions(asset_id, rights_status, expires_at)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_asset ON asset_governance_reviews(asset_id, created_at DESC)");
|
||||
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_scope ON asset_governance_reviews(organization_id, workspace_id, project_id, risk_status, created_at DESC)");
|
||||
|
||||
function migrateApiClientSecrets() {
|
||||
const rows = dbAll("SELECT id, client_key, client_key_hash, client_key_prefix, key_version FROM api_clients");
|
||||
@@ -232,6 +256,7 @@ function seedRoles() {
|
||||
["job:create", "创建生成任务"],
|
||||
["job:prioritize", "调整任务优先级"],
|
||||
["model:manage", "注册和管理模型连接器"],
|
||||
["model:approve", "审批模型路由、外部连接器和一次性生成调用"],
|
||||
["usage:view", "查看用量和成本"],
|
||||
["billing:manage", "管理套餐和账单"],
|
||||
["quota:manage", "管理组织席位与工作区配额"],
|
||||
@@ -258,7 +283,7 @@ function seedRoles() {
|
||||
org_admin: [
|
||||
"organization:manage", "organization:members:invite", "workspace:create", "workspace:manage",
|
||||
"workspace:members:manage", "project:create", "project:manage", "project:members:manage",
|
||||
"workflow:manage", "task:view", "task:manage", "task:complete", "model:manage", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
|
||||
"workflow:manage", "task:view", "task:manage", "task:complete", "model:manage", "model:approve", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
|
||||
"system:settings:view", "service:health:view", "organization:roles:manage"
|
||||
],
|
||||
producer: ["project:create", "project:manage", "project:members:manage", "workflow:manage", "task:view", "task:manage", "task:complete", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
|
||||
@@ -328,6 +353,171 @@ function seedOrganization({ id, name, slug, ownerUserId, description, workspaceI
|
||||
insertIgnore("INSERT OR IGNORE INTO quota_allocations(id, organization_id, workspace_id, metric, limit_value, used_value, unit, period_start, period_end, created_at, updated_at) VALUES (?, ?, ?, 'storage', 1024, 128, 'GB', ?, ?, ?, ?)", [`quota-${workspaceId}-storage`, id, workspaceId, monthStart(), monthEnd(), timestamp, timestamp]);
|
||||
}
|
||||
|
||||
function commercialEntitlementDefaults(billing = {}) {
|
||||
const clipLimit = Number(billing.monthly_clip_quota || 2400);
|
||||
const storageGb = Number(billing.storage_gb || 1024);
|
||||
const seatLimit = Number(billing.seat_limit || 12);
|
||||
return [
|
||||
["limit.seats", "组织席位", "tenant", seatLimit, "人", 1, "block", { commercialGate: "member-invite-and-sso" }],
|
||||
["limit.workspaces", "工作区数量", "tenant", 8, "个", 1, "block", { commercialGate: "workspace:create" }],
|
||||
["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }],
|
||||
["limit.generation_jobs_monthly", "月度生成任务", "production", clipLimit, "job", 1, "block", { commercialGate: "generation_job:create" }],
|
||||
["limit.storage_gb", "存储容量", "storage", storageGb, "GB", 1, "block", { commercialGate: "storage:write" }],
|
||||
["limit.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector:create" }],
|
||||
["limit.api_clients", "API 客户端", "system", 8, "个", 1, "block", { commercialGate: "api_client:create" }],
|
||||
["limit.knowledge_documents", "知识库素材", "knowledge", 300, "篇", 1, "block", { commercialGate: "knowledge_document:ingest" }],
|
||||
["limit.knowledge_context_packs", "知识上下文包", "knowledge", 180, "包", 1, "block", { commercialGate: "knowledge_context_pack:create" }],
|
||||
["limit.delivery_channels", "交付渠道", "delivery", 12, "个", 1, "block", { commercialGate: "delivery_channel:create" }],
|
||||
["feature.batch_generation", "批量生产", "feature", 1, "开关", 1, "block", { description: "允许创建批量生成与流水线任务" }],
|
||||
["feature.private_delivery_portal", "客户交付门户", "feature", 1, "开关", 1, "block", { description: "允许创建带令牌的客户预览/下载门户" }],
|
||||
["feature.comfyui_adapter", "ComfyUI 可选桥接", "feature", 1, "开关", 0, "block", { description: "默认关闭,明确启用后才可作为适配器" }],
|
||||
["feature.external_cloud_connectors", "外部云连接器", "feature", 1, "开关", 0, "block", { description: "默认关闭,付费/公网模型必须显式审批" }]
|
||||
];
|
||||
}
|
||||
|
||||
function seedCommercialPlans() {
|
||||
const timestamp = now();
|
||||
const plans = [
|
||||
["plan-starter-local", "starter-local", "Starter Local", "单工作室本地试制版,适合一条短剧流水线验证。", "monthly", "CNY", 0, 5, 256, 500, { workspaces: 2, projects: 8, modelConnectors: 6, apiClients: 3, knowledgeDocuments: 80, knowledgeContextPacks: 40, deliveryChannels: 3 }, { batchGeneration: false, privateDeliveryPortal: true, comfyuiAdapter: false, externalCloudConnectors: false }, { allowedCostModes: ["local"], externalApprovalRequired: true }, "社区支持"],
|
||||
["plan-studio-local", "studio-local", "Studio Local", "商业工作室私有生产版,覆盖剧本、资产、生成、审片、交付和账单。", "monthly", "CNY", 0, 12, 1024, 2400, { workspaces: 8, projects: 36, modelConnectors: 16, apiClients: 8, knowledgeDocuments: 300, knowledgeContextPacks: 180, deliveryChannels: 12 }, { batchGeneration: true, privateDeliveryPortal: true, comfyuiAdapter: false, externalCloudConnectors: false }, { allowedCostModes: ["local", "mixed-with-approval"], externalApprovalRequired: true }, "工作日响应"],
|
||||
["plan-enterprise-private", "enterprise-private", "Enterprise Private", "多组织私有化商业版,适合平台代理、内容厂牌和多项目制片团队。", "annual", "CNY", 0, 50, 8192, 20000, { workspaces: 50, projects: 300, modelConnectors: 80, apiClients: 50, knowledgeDocuments: 5000, knowledgeContextPacks: 2000, deliveryChannels: 60 }, { batchGeneration: true, privateDeliveryPortal: true, comfyuiAdapter: true, externalCloudConnectors: false }, { allowedCostModes: ["local", "mixed-with-approval"], externalApprovalRequired: true }, "专属运维窗口"]
|
||||
];
|
||||
for (const [id, tierKey, name, description, billingCycle, currency, baseFee, seatLimit, storageGb, monthlyClipQuota, limits, features, connectorPolicy, supportSla] of plans) {
|
||||
insertIgnore(
|
||||
"INSERT OR IGNORE INTO subscription_plan_templates(id, tier_key, name, description, billing_cycle, currency, base_fee, seat_limit, storage_gb, monthly_clip_quota, limits_json, features_json, connector_policy_json, support_sla, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)",
|
||||
[id, tierKey, name, description, billingCycle, currency, baseFee, seatLimit, storageGb, monthlyClipQuota, JSON.stringify(limits), JSON.stringify(features), JSON.stringify(connectorPolicy), supportSla, timestamp, timestamp]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seedOrganizationEntitlements() {
|
||||
const timestamp = now();
|
||||
const organizations = dbAll(
|
||||
`SELECT o.id AS organization_id,
|
||||
b.seat_limit, b.storage_gb, b.monthly_clip_quota
|
||||
FROM organizations o
|
||||
LEFT JOIN billing_accounts b ON b.organization_id = o.id
|
||||
WHERE o.status = 'active'`
|
||||
);
|
||||
for (const organization of organizations) {
|
||||
for (const [key, label, category, limitValue, unit, enabled, enforcement, metadata] of commercialEntitlementDefaults(organization)) {
|
||||
insertIgnore(
|
||||
"INSERT OR IGNORE INTO organization_entitlements(id, organization_id, entitlement_key, label, category, limit_value, unit, enabled, enforcement, source, metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'plan', ?, ?, ?)",
|
||||
[`ent-${organization.organization_id}-${key.replace(/[^a-z0-9]+/gi, "-")}`, organization.organization_id, key, label, category, limitValue, unit, enabled, enforcement, JSON.stringify(metadata), timestamp, timestamp]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function seedCommercialApprovals() {
|
||||
const timestamp = now();
|
||||
const requests = [
|
||||
{
|
||||
id: "commercial-approval-seed-entitlement",
|
||||
organizationId: "org-studio-lab",
|
||||
workspaceId: "ws-local-aidrama",
|
||||
projectId: "thunder-mouth",
|
||||
requestType: "entitlement_overage",
|
||||
title: "《雨夜来电》本月追加 500 个生成任务",
|
||||
status: "submitted",
|
||||
priority: "high",
|
||||
targetKey: "limit.generation_jobs_monthly",
|
||||
currentValue: 2400,
|
||||
requestedValue: 2900,
|
||||
unit: "job",
|
||||
businessReason: "连续三集试制需要补拍转场镜头和反应镜头,仍限定为本地 Runner 执行。",
|
||||
risk: { localOnly: true, paidCloud: false, singleFrameGate: true, continuityLedger: true },
|
||||
evidence: { project: "thunder-mouth", source: "seed://commercial/entitlement-overage" },
|
||||
requester: "u-writer",
|
||||
reviewer: null,
|
||||
reviewedAt: null,
|
||||
decisionNote: "",
|
||||
effect: {}
|
||||
},
|
||||
{
|
||||
id: "commercial-approval-seed-connector",
|
||||
organizationId: "org-studio-lab",
|
||||
workspaceId: "ws-local-aidrama",
|
||||
projectId: "thunder-mouth",
|
||||
requestType: "external_connector",
|
||||
title: "评估 NewAPI 中转的外部官方模型通道",
|
||||
status: "submitted",
|
||||
priority: "urgent",
|
||||
targetKey: "feature.external_cloud_connectors",
|
||||
currentValue: 0,
|
||||
requestedValue: 1,
|
||||
unit: "开关",
|
||||
businessReason: "仅申请连接能力评估,不自动调用付费云端节点;需要管理员确认成本和合规边界。",
|
||||
risk: { localOnly: false, paidCloud: true, requiresExplicitApproval: true, secretStored: false },
|
||||
evidence: { connectorPolicy: "env-secret-only", source: "seed://commercial/external-connector" },
|
||||
requester: "u-owner",
|
||||
reviewer: null,
|
||||
reviewedAt: null,
|
||||
decisionNote: "",
|
||||
effect: {}
|
||||
},
|
||||
{
|
||||
id: "commercial-approval-seed-compliance",
|
||||
organizationId: "org-studio-lab",
|
||||
workspaceId: "ws-local-aidrama",
|
||||
projectId: "thunder-mouth",
|
||||
requestType: "compliance_review",
|
||||
title: "原创小说素材版权与分块入库复核",
|
||||
status: "approved",
|
||||
priority: "medium",
|
||||
targetKey: "commercial-rights-review",
|
||||
currentValue: 0,
|
||||
requestedValue: 1,
|
||||
unit: "次",
|
||||
businessReason: "确认《雨夜来电》素材按原创来源入库,允许用于剧本拆解和知识上下文包。",
|
||||
risk: { sourceType: "original", knowledgeChunking: true, derivativeUse: "internal-production" },
|
||||
evidence: { knowledgeDocumentId: "knowledge-rain-night", source: "seed://commercial/compliance-review" },
|
||||
requester: "u-owner",
|
||||
reviewer: "u-owner",
|
||||
reviewedAt: timestamp,
|
||||
decisionNote: "示例放行:原创素材,后续交付仍需逐素材证据。",
|
||||
effect: { complianceRecordId: "commercial-compliance-seed-rain-night" }
|
||||
}
|
||||
];
|
||||
for (const request of requests) {
|
||||
insertIgnore(
|
||||
`INSERT OR IGNORE INTO commercial_approval_requests(
|
||||
id, organization_id, workspace_id, project_id, request_type, title, status, priority, target_key,
|
||||
current_value, requested_value, unit, business_reason, risk_assessment_json, evidence_json,
|
||||
decision_note, effect_json, requester_user_id, reviewer_user_id, reviewed_at, expires_at, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
|
||||
[
|
||||
request.id,
|
||||
request.organizationId,
|
||||
request.workspaceId,
|
||||
request.projectId,
|
||||
request.requestType,
|
||||
request.title,
|
||||
request.status,
|
||||
request.priority,
|
||||
request.targetKey,
|
||||
request.currentValue,
|
||||
request.requestedValue,
|
||||
request.unit,
|
||||
request.businessReason,
|
||||
JSON.stringify(request.risk),
|
||||
JSON.stringify(request.evidence),
|
||||
request.decisionNote,
|
||||
JSON.stringify(request.effect),
|
||||
request.requester,
|
||||
request.reviewer,
|
||||
request.reviewedAt,
|
||||
timestamp,
|
||||
timestamp
|
||||
]
|
||||
);
|
||||
}
|
||||
insertIgnore(
|
||||
"INSERT OR IGNORE INTO compliance_records(id, organization_id, workspace_id, project_id, subject_type, subject_id, policy_key, status, evidence_json, reviewed_by, reviewed_at, created_at, updated_at) VALUES ('commercial-compliance-seed-rain-night', 'org-studio-lab', 'ws-local-aidrama', 'thunder-mouth', 'knowledge_document', 'knowledge-rain-night', 'commercial-rights-review', 'approved', ?, 'u-owner', ?, ?, ?)",
|
||||
[JSON.stringify({ source: "seed://commercial/compliance-review", knowledgeDocumentId: "knowledge-rain-night" }), timestamp, timestamp, timestamp]
|
||||
);
|
||||
}
|
||||
|
||||
function seedMembersAndProjects() {
|
||||
const timestamp = now();
|
||||
const membershipRows = [
|
||||
@@ -365,6 +555,8 @@ function seedModels() {
|
||||
const models = [
|
||||
["owned-i2v", "org-studio-lab", "ws-local-aidrama", "自有图生视频平台", "http-json", ["image-to-video", "first-last-frame", "vertical-video"], "http://127.0.0.1:7860/api/generate/i2v", "not-connected", "local", 0, "u-owner"],
|
||||
["owned-image", "org-studio-lab", "ws-local-aidrama", "自有图片/改图平台", "http-json", ["text-to-image", "image-edit", "single-frame"], "http://127.0.0.1:7860/api/generate/image", "not-connected", "local", 0, "u-owner"],
|
||||
["owned-story-parser", "org-studio-lab", "ws-local-aidrama", "自有文本解析网关", "openai-compatible", ["chat", "script-split", "knowledge-extract"], "http://127.0.0.1:7862/v1", "ready", "local", 0, "u-owner"],
|
||||
["owned-embedding", "org-studio-lab", "ws-local-aidrama", "自有向量检索网关", "openai-compatible", ["embedding", "rerank", "knowledge-retrieval"], "http://127.0.0.1:7863/v1", "planned", "local", 0, "u-owner"],
|
||||
["local-tts", "org-studio-lab", "ws-local-aidrama", "本地固定声线 TTS", "http-json", ["tts", "voice-lock", "subtitle-timing"], "http://127.0.0.1:7861/api/tts", "planned", "local", 0, "u-owner"],
|
||||
["newapi-audio-production", "org-studio-lab", "ws-local-aidrama", "NewAPI 音频中转", "openai-compatible-audio", ["tts", "voice-lock", "emotion-control", "asr", "subtitle-timing"], "https://newapi.ysblack.com/v1", "ready", "mixed", 1, "u-owner"],
|
||||
["comfyui-optional", "org-studio-lab", "ws-local-aidrama", "ComfyUI 工作流桥接", "comfyui", ["workflow", "qwen-image", "qwen-edit"], "http://127.0.0.1:8188", "optional", "mixed", 1, "u-owner"],
|
||||
@@ -375,6 +567,200 @@ function seedModels() {
|
||||
}
|
||||
}
|
||||
|
||||
function seedModelCatalog() {
|
||||
const timestamp = now();
|
||||
const entries = [
|
||||
["catalog-qwen-image-2d", "org-studio-lab", "ws-local-aidrama", "owned-image", "qwen-image-2d-lock", "Qwen Image 国漫单画面", "qwen-image", ["text-to-image", "single-frame", "continuity-lock"], 32768, 4096, { mode: "per-image", estimatedCny: 0.18, locality: "local" }, "active", "approved", { aspect: "9:16", guardrails: ["single-frame-only", "no-collage"] }],
|
||||
["catalog-wan-i2v", "org-studio-lab", "ws-local-aidrama", "owned-i2v", "wan-i2v-lastframe", "Wan 图生视频连续版", "wan-video", ["image-to-video", "first-last-frame", "clip-bridge"], 65536, 4096, { mode: "per-clip", estimatedCny: 0.42, locality: "local" }, "active", "approved", { durationSec: [5, 10], notes: "优先使用上一段实际末帧" }],
|
||||
["catalog-qwen-parser", "org-studio-lab", "ws-local-aidrama", "owned-story-parser", "qwen-script-parser", "Qwen 剧本/小说解析", "qwen-text", ["chat", "script-split", "knowledge-extract", "scene-parse"], 131072, 8192, { mode: "per-1k-tokens", estimatedCny: 0.03, locality: "local" }, "active", "approved", { languages: ["zh-CN"], preferredFor: ["script-import", "knowledge-ingest"] }],
|
||||
["catalog-bge-m3", "org-studio-lab", "ws-local-aidrama", "owned-embedding", "bge-m3-local", "BGE-M3 向量检索", "embedding", ["embedding", "retrieval", "knowledge-search"], 8192, 0, { mode: "per-1k-tokens", estimatedCny: 0.01, locality: "local" }, "planned", "approved", { dimensions: 1024, preferredFor: ["knowledge-search"] }],
|
||||
["catalog-indextts", "org-studio-lab", "ws-local-aidrama", "newapi-audio-production", "IndexTTS-2.5", "IndexTTS-2.5 固定声线", "tts", ["tts", "voice-lock", "emotion-control"], 16384, 2048, { mode: "per-10s-audio", estimatedCny: 0.12, locality: "mixed" }, "active", "review", { referenceRequired: true, approvalGate: "voice-rights" }],
|
||||
["catalog-paraformer", "org-studio-lab", "ws-local-aidrama", "newapi-audio-production", "paraformer-zh-long", "Paraformer 长音频 ASR", "asr", ["asr", "subtitle-timing", "alignment"], 16384, 2048, { mode: "per-minute-audio", estimatedCny: 0.05, locality: "mixed" }, "active", "approved", { output: "verbose_json" }],
|
||||
["catalog-northstar-image", "org-northstar", "ws-northstar-main", "northstar-image", "northstar-sd-xl", "北辰 SDXL 单画面", "sdxl", ["text-to-image", "single-frame"], 32768, 4096, { mode: "per-image", estimatedCny: 0.16, locality: "local" }, "active", "approved", { aspect: "9:16" }]
|
||||
];
|
||||
for (const [id, organizationId, workspaceId, connectorId, modelKey, displayName, family, capabilities, contextWindow, maxOutputTokens, cost, status, approvalStatus, metadata] of entries) {
|
||||
insertIgnore(
|
||||
"INSERT OR IGNORE 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'u-owner', ?, ?)",
|
||||
[id, organizationId, workspaceId, connectorId, modelKey, displayName, family, JSON.stringify(capabilities), contextWindow, maxOutputTokens, JSON.stringify(cost), status, approvalStatus, JSON.stringify(metadata), timestamp, timestamp]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seedModelRoutes() {
|
||||
const timestamp = now();
|
||||
const routes = [
|
||||
["route-script-ingest", "org-studio-lab", "ws-local-aidrama", "小说/剧本导入解析", "knowledge-ingest", "chunk-and-extract", "catalog-qwen-parser", "catalog-bge-m3", "prefer-local", "follow-model", 15, "active", { chunkStrategy: "chapter-scene-dialogue", maxChunkChars: 520 }],
|
||||
["route-script-materialize", "org-studio-lab", "ws-local-aidrama", "剧本拆解与场景草稿", "script-pipeline", "scene-draft", "catalog-qwen-parser", null, "prefer-local", "follow-model", 10, "active", { target: "script_documents" }],
|
||||
["route-image-keyframe", "org-studio-lab", "ws-local-aidrama", "单画面关键帧", "ai-manhua-drama", "image-keyframe", "catalog-qwen-image-2d", null, "local-only", "follow-model", 60, "active", { qaGate: "single-frame" }],
|
||||
["route-video-clip", "org-studio-lab", "ws-local-aidrama", "图生视频片段", "ai-manhua-drama", "video-clip", "catalog-wan-i2v", null, "local-only", "follow-model", 120, "active", { requireActualLastFrame: true }],
|
||||
["route-voice-tts", "org-studio-lab", "ws-local-aidrama", "角色固定配音", "voice-pipeline", "tts", "catalog-indextts", null, "prefer-approved", "explicit-review", 40, "active", { referenceAssetKind: "voice", forbidRandomNativeVoice: true }],
|
||||
["route-voice-asr", "org-studio-lab", "ws-local-aidrama", "ASR 对齐校验", "voice-pipeline", "asr", "catalog-paraformer", null, "prefer-approved", "follow-model", 20, "active", { output: "verbose_json" }]
|
||||
];
|
||||
for (const [id, organizationId, workspaceId, name, workflowKey, operationKey, primaryModelId, fallbackModelId, policyMode, approvalMode, budgetLimitCny, status, policy] of routes) {
|
||||
insertIgnore(
|
||||
"INSERT OR IGNORE 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'u-owner', ?, ?)",
|
||||
[id, organizationId, workspaceId, name, workflowKey, operationKey, primaryModelId, fallbackModelId, policyMode, approvalMode, budgetLimitCny, status, JSON.stringify(policy), timestamp, timestamp]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function seedKnowledgeLibrary() {
|
||||
const timestamp = now();
|
||||
const content = [
|
||||
"第一章 雨夜来电",
|
||||
"暴雨压在旧城区的玻璃连廊上,陈宇刚结束夜班巡检,就接到唐夏发来的定位。她只说了一句:不要让任何人碰那把蓝伞。",
|
||||
"",
|
||||
"唐夏:我到地铁口了,可这里的警戒线被人挪开了。",
|
||||
"陈宇:别过去,积水边可能有落地电线。你先站在连廊灯下,右手不要离开蓝伞。",
|
||||
"",
|
||||
"第二章 失踪的雨披",
|
||||
"两人会合后发现黄色雨披不见了,警戒锥却比下午多了一组。唐夏开始怀疑,有人提前布置过现场,像是在等他们出现。"
|
||||
].join("\n");
|
||||
const analysis = {
|
||||
parser: "local-rule-v2",
|
||||
chapterCount: 2,
|
||||
chunkCount: 4,
|
||||
summary: "暴雨夜的地铁口事故线索,包含角色对话、场景限制、关键道具和连续性信息。",
|
||||
entities: {
|
||||
characters: ["陈宇", "唐夏"],
|
||||
locations: ["旧城区玻璃连廊", "地铁口"],
|
||||
props: ["蓝伞", "黄色雨披", "警戒线", "警戒锥"]
|
||||
}
|
||||
};
|
||||
const provenance = {
|
||||
sourceLabel: "平台原创示例小说",
|
||||
author: "AI Drama Platform Demo",
|
||||
rightsOwner: "本地示例组织",
|
||||
evidenceRef: "seed://original/rain-night",
|
||||
licenseNote: "原创演示素材,仅用于本地样例和 smoke 测试",
|
||||
sourceUrl: "",
|
||||
importedFrom: "seed"
|
||||
};
|
||||
const tags = ["原创", "雨夜", "连续性样例"];
|
||||
const risk = {
|
||||
scanner: "local-governance-v1",
|
||||
status: "pass",
|
||||
score: 0,
|
||||
rightsStatus: "approved",
|
||||
sourceType: "novel",
|
||||
tags,
|
||||
issues: [],
|
||||
checks: {
|
||||
provenanceEvidence: true,
|
||||
commercialRightsApproved: true,
|
||||
knownIpReferences: 0,
|
||||
personaLikenessRisks: 0,
|
||||
singleFramePolicyRisks: 0,
|
||||
safetySensitiveMatches: 0
|
||||
},
|
||||
scannedAt: timestamp
|
||||
};
|
||||
insertIgnore(
|
||||
"INSERT OR IGNORE INTO knowledge_documents(id, organization_id, workspace_id, project_id, scope_mode, title, source_type, language, content, status, summary, analysis_json, metadata_json, created_by, created_at, updated_at) VALUES ('knowledge-rain-night', 'org-studio-lab', 'ws-local-aidrama', NULL, 'workspace', '《雨夜来电》原始小说素材', 'novel', 'zh-CN', ?, 'indexed', ?, ?, ?, 'u-owner', ?, ?)",
|
||||
[content, analysis.summary, JSON.stringify(analysis), JSON.stringify({ sourceLabel: "原创小说", rights: "original", recommendedWorkflow: "ai-manhua-drama" }), timestamp, timestamp]
|
||||
);
|
||||
dbRun(
|
||||
`UPDATE knowledge_documents
|
||||
SET rights_status = 'approved',
|
||||
provenance_json = ?,
|
||||
tags_json = ?,
|
||||
risk_json = ?,
|
||||
current_version_number = CASE WHEN current_version_number < 1 THEN 1 ELSE current_version_number END
|
||||
WHERE id = 'knowledge-rain-night'
|
||||
AND (rights_status = 'needs-evidence' OR provenance_json = '{}' OR risk_json = '{}')`,
|
||||
[JSON.stringify(provenance), JSON.stringify(tags), JSON.stringify(risk)]
|
||||
);
|
||||
insertIgnore(
|
||||
`INSERT OR IGNORE INTO knowledge_document_versions(
|
||||
id, document_id, version_number, title, source_type, language, content, summary,
|
||||
analysis_json, provenance_json, tags_json, risk_json, metadata_json, created_by, created_at
|
||||
) VALUES ('knowledge-rain-night-v1', 'knowledge-rain-night', 1, '《雨夜来电》原始小说素材', 'novel', 'zh-CN', ?, ?, ?, ?, ?, ?, ?, 'u-owner', ?)`,
|
||||
[content, analysis.summary, JSON.stringify(analysis), JSON.stringify(provenance), JSON.stringify(tags), JSON.stringify(risk), JSON.stringify({ sourceLabel: "原创小说", rights: "original", recommendedWorkflow: "ai-manhua-drama", seed: true }), timestamp]
|
||||
);
|
||||
const chunks = [
|
||||
["knowledge-rain-night-chunk-1", 1, "chapter", "第一章 雨夜来电", "暴雨压在旧城区的玻璃连廊上,陈宇刚结束夜班巡检,就接到唐夏发来的定位。她只说了一句:不要让任何人碰那把蓝伞。", 116, ["暴雨", "玻璃连廊", "蓝伞"], { characters: ["陈宇", "唐夏"], locations: ["旧城区玻璃连廊"], props: ["蓝伞"] }, { chapter: 1, sceneHint: "开场建立场景与冲突" }],
|
||||
["knowledge-rain-night-chunk-2", 2, "dialogue", "地铁口对话", "唐夏:我到地铁口了,可这里的警戒线被人挪开了。\n陈宇:别过去,积水边可能有落地电线。你先站在连廊灯下,右手不要离开蓝伞。", 124, ["地铁口", "警戒线", "落地电线"], { characters: ["陈宇", "唐夏"], locations: ["地铁口"], props: ["蓝伞", "警戒线"] }, { chapter: 1, mouthAvoidance: true, sceneHint: "适合侧脸/反应镜头对白" }],
|
||||
["knowledge-rain-night-chunk-3", 3, "chapter", "第二章 失踪的雨披", "两人会合后发现黄色雨披不见了,警戒锥却比下午多了一组。", 58, ["黄色雨披", "警戒锥"], { characters: ["陈宇", "唐夏"], props: ["黄色雨披", "警戒锥"] }, { chapter: 2, sceneHint: "道具连续性检查点" }],
|
||||
["knowledge-rain-night-chunk-4", 4, "lore", "现场异常", "唐夏开始怀疑,有人提前布置过现场,像是在等他们出现。", 42, ["异常布置", "悬念"], { characters: ["唐夏"] }, { chapter: 2, sceneHint: "结尾悬念与下一集钩子" }]
|
||||
];
|
||||
for (const [id, chunkIndex, chunkType, heading, body, tokenEstimate, keywords, entities, metadata] of chunks) {
|
||||
insertIgnore(
|
||||
"INSERT OR IGNORE INTO knowledge_chunks(id, document_id, chunk_index, chunk_type, heading, content, token_estimate, keywords_json, entities_json, metadata_json, created_at) VALUES (?, 'knowledge-rain-night', ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
[id, chunkIndex, chunkType, heading, body, tokenEstimate, JSON.stringify(keywords), JSON.stringify(entities), JSON.stringify(metadata), timestamp]
|
||||
);
|
||||
}
|
||||
const openingChunks = chunks.slice(0, 2);
|
||||
const citations = openingChunks.map(([id, chunkIndex, chunkType, heading], index) => ({
|
||||
key: `K${index + 1}`,
|
||||
documentId: "knowledge-rain-night",
|
||||
documentTitle: "《雨夜来电》原始小说素材",
|
||||
chunkId: id,
|
||||
chunkIndex,
|
||||
heading,
|
||||
sourceType: "novel",
|
||||
scopeMode: "workspace",
|
||||
rightsStatus: "approved",
|
||||
riskStatus: "pass",
|
||||
projectId: null,
|
||||
chunkType
|
||||
}));
|
||||
const packChunks = openingChunks.map(([id, chunkIndex, chunkType, heading, body, tokenEstimate, keywords, entities], index) => ({
|
||||
citationKey: citations[index].key,
|
||||
id,
|
||||
documentId: "knowledge-rain-night",
|
||||
documentTitle: "《雨夜来电》原始小说素材",
|
||||
chunkIndex,
|
||||
chunkType,
|
||||
heading,
|
||||
content: body,
|
||||
tokenEstimate,
|
||||
keywords,
|
||||
entities,
|
||||
rightsStatus: "approved",
|
||||
riskStatus: "pass"
|
||||
}));
|
||||
const packGovernance = {
|
||||
status: "pass",
|
||||
rights: { approved: packChunks.length },
|
||||
risk: { pass: packChunks.length },
|
||||
blockingCount: 0,
|
||||
reviewCount: 0
|
||||
};
|
||||
const promptContext = [
|
||||
"# 知识库上下文包:雨夜开场冲突",
|
||||
"使用要求:基于引用素材做原创改编;保留人物、道具、地点和时间线连续性;生成画面仍必须是一张完整单画面,不得输出多格、拼图或分屏。",
|
||||
"治理摘要:pass;未批准/需复核片段 0;阻断片段 0。",
|
||||
...packChunks.map((chunk) => [
|
||||
`## [${chunk.citationKey}] ${chunk.heading}`,
|
||||
`来源:《${chunk.documentTitle}》 / novel / chunk ${chunk.chunkIndex} / rights=approved / risk=pass`,
|
||||
`内容:${chunk.content}`
|
||||
].join("\n"))
|
||||
].join("\n\n");
|
||||
insertIgnore(
|
||||
`INSERT OR IGNORE INTO knowledge_context_packs(
|
||||
id, organization_id, workspace_id, project_id, scope_mode, name, query, source_type, max_tokens,
|
||||
token_estimate, chunk_ids_json, citations_json, chunks_json, prompt_context, status,
|
||||
metadata_json, created_by, created_at, updated_at
|
||||
) VALUES ('knowledge-pack-rain-night-opening', 'org-studio-lab', 'ws-local-aidrama', NULL, 'workspace', '雨夜开场冲突', '蓝伞 地铁口', 'novel', 800, ?, ?, ?, ?, ?, 'active', ?, 'u-owner', ?, ?)`,
|
||||
[
|
||||
packChunks.reduce((sum, chunk) => sum + Number(chunk.tokenEstimate || 0), 0),
|
||||
JSON.stringify(packChunks.map((chunk) => chunk.id)),
|
||||
JSON.stringify(citations),
|
||||
JSON.stringify(packChunks),
|
||||
promptContext,
|
||||
JSON.stringify({ seed: true, usage: "script-draft", rights: "original", governance: packGovernance }),
|
||||
timestamp,
|
||||
timestamp
|
||||
]
|
||||
);
|
||||
dbRun(
|
||||
`UPDATE knowledge_context_packs
|
||||
SET citations_json = ?, chunks_json = ?, prompt_context = ?, metadata_json = ?
|
||||
WHERE id = 'knowledge-pack-rain-night-opening'`,
|
||||
[JSON.stringify(citations), JSON.stringify(packChunks), promptContext, JSON.stringify({ seed: true, usage: "script-draft", rights: "original", governance: packGovernance })]
|
||||
);
|
||||
}
|
||||
|
||||
function seedSystemGovernance() {
|
||||
const timestamp = now();
|
||||
insertIgnore("INSERT OR IGNORE INTO system_admins(user_id, role_key, status, created_at, updated_at) VALUES ('u-owner', 'system_admin', 'active', ?, ?)", [timestamp, timestamp]);
|
||||
@@ -577,6 +963,67 @@ function seedProductionGraph() {
|
||||
for (const audit of audits) {
|
||||
insertIgnore("INSERT OR IGNORE INTO audit_logs(id, organization_id, workspace_id, project_id, actor_user_id, action, target_type, target_id, result, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [...audit, timestamp]);
|
||||
}
|
||||
|
||||
insertIgnore("INSERT OR IGNORE INTO series(id, project_id, title, logline, format, visual_style, continuity_rule, show_engine, created_at, updated_at) VALUES (?, ?, ?, ?, 'vertical-9:16', ?, ?, ?, ?, ?)", [
|
||||
"series-northstar-pilot",
|
||||
"northstar-pilot",
|
||||
"山海志异·试播集",
|
||||
"北辰试制部用于验证多组织隔离的原创国漫试播项目。",
|
||||
"原创国漫 2D 动画风格,竖屏 9:16,单一完整画面,干净线稿与克制动效。",
|
||||
"角色、场景、道具、天气、镜头和声线全部随项目隔离,禁止串用其他组织素材。",
|
||||
"以单镜头冲突推进试播片段,所有生成先经过本地 Runner 与审片门。",
|
||||
timestamp,
|
||||
timestamp
|
||||
]);
|
||||
insertIgnore("INSERT OR IGNORE INTO seasons(id, series_id, season_number, title, created_at, updated_at) VALUES (?, ?, 1, '第一季', ?, ?)", ["season-northstar-pilot-1", "series-northstar-pilot", timestamp, timestamp]);
|
||||
insertIgnore("INSERT OR IGNORE INTO episodes(id, season_id, episode_number, title, status, target_duration_sec, hook, cliffhanger, created_at, updated_at) VALUES (?, ?, 1, '雾中石门', 'production', 45, '雾气里出现不该存在的石门。', '门后传来第二个自己的声音。', ?, ?)", ["episode-northstar-pilot-01", "season-northstar-pilot-1", timestamp, timestamp]);
|
||||
const northstarShot = {
|
||||
id: "shot-northstar-pilot-001",
|
||||
title: "雾中石门出现",
|
||||
durationSec: 6,
|
||||
characterIds: ["northstar-yun"],
|
||||
locationId: "northstar-fog-gate",
|
||||
propIds: ["northstar-bronze-bell"],
|
||||
camera: "稳定中景,角色在画面右侧停步,石门在远处雾中显现,单一连续画面。",
|
||||
action: "少年抬手按住铜铃,雾气向石门方向收束,镜头不切分。",
|
||||
firstFrame: "episode-start",
|
||||
lastFrame: "pending-actual-last-frame",
|
||||
transitionFromPrevious: "episode-start",
|
||||
prompt: "ONE SINGLE COMPLETE 9:16 CHINESE ANIMATION FRAME, no panels, one scene only.",
|
||||
negativePrompt: "no split screen, no comic panel, no collage, no contact sheet, no storyboard",
|
||||
seed: 260831
|
||||
};
|
||||
insertIgnore("INSERT OR IGNORE INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, 1, ?, 'draft', ?, ?, ?, ?, ?)", [
|
||||
northstarShot.id,
|
||||
"episode-northstar-pilot-01",
|
||||
northstarShot.title,
|
||||
northstarShot.firstFrame,
|
||||
northstarShot.lastFrame,
|
||||
JSON.stringify({ camera: northstarShot.camera, transition: northstarShot.transitionFromPrevious }),
|
||||
timestamp,
|
||||
timestamp
|
||||
]);
|
||||
insertIgnore("INSERT OR IGNORE INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'draft', 'u-producer', ?)", [`${northstarShot.id}-v1`, northstarShot.id, JSON.stringify(northstarShot), timestamp]);
|
||||
dbRun("UPDATE shots SET current_version_id = COALESCE(current_version_id, ?) WHERE id = ?", [`${northstarShot.id}-v1`, northstarShot.id]);
|
||||
insertIgnore("INSERT OR IGNORE INTO generation_jobs(id, organization_id, workspace_id, project_id, episode_id, shot_id, kind, adapter_id, status, priority, cost_policy, output_path, qa_status, request_json, result_json, error_message, created_by, created_at, updated_at) VALUES (?, 'org-northstar', 'ws-northstar-main', 'northstar-pilot', 'episode-northstar-pilot-01', ?, '单画面关键帧', 'northstar-image', 'queued', 45, 'local', 'storage/jobs/northstar-pilot/shot-001/keyframe.png', 'wait', ?, '{}', '', 'u-producer', ?, ?)", [
|
||||
"job-northstar-pilot-keyframe-001",
|
||||
northstarShot.id,
|
||||
JSON.stringify({ schema: "ai-drama.job.v1", seeded: true, job: { adapterId: "northstar-image" }, constraints: { singleFrameOnly: true, imageOutputCount: 1 } }),
|
||||
timestamp,
|
||||
timestamp
|
||||
]);
|
||||
insertIgnore("INSERT OR IGNORE INTO reviews(id, organization_id, workspace_id, project_id, shot_id, lane, status, score, evidence_json, created_at, updated_at) VALUES (?, 'org-northstar', 'ws-northstar-main', 'northstar-pilot', ?, 'single-frame', 'pending', NULL, ?, ?, ?)", [
|
||||
"review-northstar-pilot-single-frame",
|
||||
northstarShot.id,
|
||||
JSON.stringify({ blockers: ["等待北辰审片员确认一图一画面证据。"], source: "seed" }),
|
||||
timestamp,
|
||||
timestamp
|
||||
]);
|
||||
insertIgnore("INSERT OR IGNORE INTO deliveries(id, organization_id, workspace_id, project_id, version, manifest_path, channel, status, created_at, updated_at) VALUES (?, 'org-northstar', 'ws-northstar-main', 'northstar-pilot', 'v0.1-internal', 'storage/deliveries/northstar-pilot/v0.1/delivery-manifest.json', 'internal', 'review', ?, ?)", [
|
||||
"delivery-northstar-pilot-v01",
|
||||
timestamp,
|
||||
timestamp
|
||||
]);
|
||||
}
|
||||
|
||||
withTransaction(() => {
|
||||
@@ -614,11 +1061,17 @@ withTransaction(() => {
|
||||
risk: "medium"
|
||||
});
|
||||
insertIgnore("INSERT OR IGNORE INTO workspaces(id, organization_id, name, slug, description, status, created_at, updated_at) VALUES ('ws-pilot', 'org-studio-lab', '素材实验室', 'asset-lab', '角色、场景和模型试验空间', 'active', ?, ?)", [now(), now()]);
|
||||
seedCommercialPlans();
|
||||
seedOrganizationEntitlements();
|
||||
seedCommercialApprovals();
|
||||
seedMembersAndProjects();
|
||||
seedModels();
|
||||
seedModelCatalog();
|
||||
seedModelRoutes();
|
||||
seedSystemGovernance();
|
||||
seedWorkflowTemplates();
|
||||
seedProductionGraph();
|
||||
seedKnowledgeLibrary();
|
||||
seedUserNotifications();
|
||||
});
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { basename, dirname, relative, resolve } from "node:path";
|
||||
import { dbAll, dbGet, dbRun } from "./db.mjs";
|
||||
import { addAudit, hasPermission, httpError, requirePermission } from "./tenant.mjs";
|
||||
import { addAudit, hasPermission, httpError, requireEntitlement, requirePermission } from "./tenant.mjs";
|
||||
|
||||
const projectRoot = resolve(import.meta.dirname, "..");
|
||||
const defaultExpiryDays = 7;
|
||||
@@ -282,6 +282,7 @@ export function listDeliveryAccessLinks(context, releaseId) {
|
||||
|
||||
export function createDeliveryAccessLink(context, releaseId, body = {}, options = {}) {
|
||||
requirePermission(context, "delivery:approve");
|
||||
requireEntitlement(context, "feature.private_delivery_portal", 1);
|
||||
const release = releaseForContext(context, releaseId);
|
||||
if (release.status !== "published" || !release.output_path) {
|
||||
throw httpError(409, "delivery_access_release_not_published", "只有已发布且存在发布产物的版本可以创建客户访问链接", { releaseId, status: release.status });
|
||||
|
||||
+837
-22
@@ -8,6 +8,7 @@ import {
|
||||
hasPermission,
|
||||
httpError,
|
||||
parseModelRow,
|
||||
requireEntitlement,
|
||||
requireQuota,
|
||||
requirePermission,
|
||||
requireProjectWritable
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
import { productionGraph } from "./production.mjs";
|
||||
import { dispatchNotificationEvent } from "./notifications.mjs";
|
||||
import { registerJobArtifacts } from "./media-artifacts.mjs";
|
||||
import { resolveKnowledgeContextForJob } from "./knowledge.mjs";
|
||||
|
||||
const projectRoot = resolve(import.meta.dirname, "..");
|
||||
const jobStorageRoot = resolve(projectRoot, "storage", "jobs");
|
||||
@@ -60,6 +62,188 @@ function resolveAdapterId(context, adapterId, kind = "") {
|
||||
return "owned-image";
|
||||
}
|
||||
|
||||
function routingIntent(body = {}) {
|
||||
const explicitWorkflow = String(body.workflowKey || body.workflow_key || "").trim();
|
||||
const explicitOperation = String(body.operationKey || body.operation_key || "").trim();
|
||||
if (explicitWorkflow && explicitOperation) {
|
||||
return { workflowKey: explicitWorkflow, operationKey: explicitOperation, source: "explicit" };
|
||||
}
|
||||
const kind = String(body.kind || "").trim().toLowerCase();
|
||||
if (kind.includes("知识") || kind.includes("小说") || kind.includes("剧本导入") || kind.includes("文本解析")) {
|
||||
return { workflowKey: "knowledge-ingest", operationKey: "chunk-and-extract", source: "kind" };
|
||||
}
|
||||
if (kind.includes("场景草稿") || kind.includes("剧本拆解") || kind.includes("分镜拆解")) {
|
||||
return { workflowKey: "script-pipeline", operationKey: "scene-draft", source: "kind" };
|
||||
}
|
||||
if (kind.includes("试听") || kind.includes("audition")) {
|
||||
return { workflowKey: "voice-pipeline", operationKey: "tts-audition", source: "kind" };
|
||||
}
|
||||
if (kind.includes("tts") || kind.includes("配音") || kind.includes("试听")) {
|
||||
return { workflowKey: "voice-pipeline", operationKey: "tts", source: "kind" };
|
||||
}
|
||||
if (kind.includes("asr") || kind.includes("字幕") || kind.includes("对齐")) {
|
||||
return { workflowKey: "voice-pipeline", operationKey: "asr", source: "kind" };
|
||||
}
|
||||
if (kind.includes("视频") || kind.includes("i2v") || kind.includes("video")) {
|
||||
return { workflowKey: "ai-manhua-drama", operationKey: "video-clip", source: "kind" };
|
||||
}
|
||||
if (kind.includes("图") || kind.includes("关键帧") || kind.includes("image")) {
|
||||
return { workflowKey: "ai-manhua-drama", operationKey: "image-keyframe", source: "kind" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function catalogEntryForContext(context, entryId) {
|
||||
const normalized = String(entryId || "").trim();
|
||||
if (!normalized) return null;
|
||||
const row = dbGet(
|
||||
`SELECT mce.*, mc.label AS connector_label, mc.kind AS connector_kind,
|
||||
mc.status AS connector_status, mc.cost_mode AS connector_cost_mode,
|
||||
mc.approval_required AS connector_approval_required,
|
||||
mc.endpoint AS connector_endpoint, mc.capabilities_json AS connector_capabilities_json,
|
||||
mc.protocol_json AS connector_protocol_json, mc.auth_env AS connector_auth_env
|
||||
FROM model_catalog_entries mce
|
||||
JOIN model_connectors mc ON mc.id = mce.connector_id
|
||||
WHERE mce.id = ? AND mce.organization_id = ?
|
||||
AND (mce.workspace_id IS NULL OR mce.workspace_id = ?)
|
||||
AND mc.organization_id = ?
|
||||
AND (mc.workspace_id IS NULL OR mc.workspace_id = ?)`,
|
||||
[normalized, context.organization.id, context.workspace.id, context.organization.id, context.workspace.id]
|
||||
);
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
id: row.id,
|
||||
connectorId: row.connector_id,
|
||||
displayName: row.display_name,
|
||||
modelKey: row.model_key,
|
||||
approvalStatus: row.approval_status || "approved",
|
||||
status: row.status || "active",
|
||||
connector: {
|
||||
id: row.connector_id,
|
||||
label: row.connector_label,
|
||||
kind: row.connector_kind,
|
||||
status: row.connector_status,
|
||||
costMode: row.connector_cost_mode || "local",
|
||||
approvalRequired: Boolean(row.connector_approval_required),
|
||||
endpoint: row.connector_endpoint,
|
||||
capability: parseJson(row.connector_capabilities_json, []),
|
||||
protocol: parseJson(row.connector_protocol_json, {}),
|
||||
authEnv: row.connector_auth_env || ""
|
||||
},
|
||||
capabilities: parseJson(row.capabilities_json, []),
|
||||
cost: parseJson(row.cost_json, {}),
|
||||
metadata: parseJson(row.metadata_json, {}),
|
||||
policy: null
|
||||
};
|
||||
}
|
||||
|
||||
function routeForContext(context, intent, routeId = "") {
|
||||
if (!intent && !routeId) return null;
|
||||
const params = [context.organization.id, context.workspace.id];
|
||||
let where = "mrp.organization_id = ? AND (mrp.workspace_id IS NULL OR mrp.workspace_id = ?) AND mrp.status = 'active'";
|
||||
if (routeId) {
|
||||
where += " AND mrp.id = ?";
|
||||
params.push(routeId);
|
||||
} else {
|
||||
where += " AND mrp.workflow_key = ? AND mrp.operation_key = ?";
|
||||
params.push(intent.workflowKey, intent.operationKey);
|
||||
}
|
||||
return dbGet(
|
||||
`SELECT mrp.*,
|
||||
primary_model.display_name AS primary_model_label,
|
||||
fallback_model.display_name AS fallback_model_label
|
||||
FROM model_routing_policies mrp
|
||||
LEFT JOIN model_catalog_entries primary_model ON primary_model.id = mrp.primary_model_id
|
||||
LEFT JOIN model_catalog_entries fallback_model ON fallback_model.id = mrp.fallback_model_id
|
||||
WHERE ${where}
|
||||
ORDER BY CASE WHEN mrp.workspace_id = ? THEN 0 ELSE 1 END, mrp.updated_at DESC
|
||||
LIMIT 1`,
|
||||
[...params, context.workspace.id]
|
||||
);
|
||||
}
|
||||
|
||||
function routeResolution(context, body = {}) {
|
||||
const intent = routingIntent(body);
|
||||
const route = routeForContext(context, intent, body.routeId || body.route_id);
|
||||
if (!route) return null;
|
||||
const policy = parseJson(route.policy_json, {});
|
||||
const policyMode = String(route.policy_mode || "prefer-local");
|
||||
const explicitModelId = body.modelId || body.model_id || body.catalogModelId || body.catalog_model_id;
|
||||
const requested = explicitModelId
|
||||
? catalogEntryForContext(context, explicitModelId)
|
||||
: null;
|
||||
if (explicitModelId && !requested) throw httpError(404, "model_catalog_entry_not_found", "指定的模型目录条目不存在或不属于当前工作区", { modelId: explicitModelId });
|
||||
if (policyMode === "manual-select" && !requested) {
|
||||
throw httpError(400, "model_route_manual_selection_required", "当前路由要求明确选择模型目录条目", { routeId: route.id });
|
||||
}
|
||||
const candidates = requested
|
||||
? [{ entry: requested, source: "explicit-model" }]
|
||||
: [
|
||||
{ entry: catalogEntryForContext(context, route.primary_model_id), source: "primary-model" },
|
||||
{ entry: catalogEntryForContext(context, route.fallback_model_id), source: "fallback-model" }
|
||||
].filter((item) => item.entry);
|
||||
const eligible = candidates.filter(({ entry }) => {
|
||||
if (entry.status !== "active") return false;
|
||||
const locality = String(entry.cost?.locality || entry.connector.costMode || "local").toLowerCase();
|
||||
if (policyMode === "local-only" && (locality !== "local" || entry.connector.costMode !== "local")) return false;
|
||||
return true;
|
||||
});
|
||||
if (!eligible.length) {
|
||||
throw httpError(422, "model_route_no_eligible_model", "当前路由没有可用的模型目录条目,请检查状态、成本策略和连接器范围", {
|
||||
routeId: route.id,
|
||||
workflowKey: route.workflow_key,
|
||||
operationKey: route.operation_key
|
||||
});
|
||||
}
|
||||
const ordered = policyMode === "prefer-local"
|
||||
? [...eligible].sort((a, b) => Number(b.entry.connector.costMode === "local") - Number(a.entry.connector.costMode === "local"))
|
||||
: policyMode === "prefer-approved"
|
||||
? [...eligible].sort((a, b) => Number(b.entry.approvalStatus === "approved") - Number(a.entry.approvalStatus === "approved"))
|
||||
: eligible;
|
||||
const chosen = ordered[0];
|
||||
const entry = chosen.entry;
|
||||
const requiresApproval = Boolean(
|
||||
entry.connector.approvalRequired
|
||||
|| entry.connector.costMode !== "local"
|
||||
|| entry.approvalStatus !== "approved"
|
||||
|| route.approval_mode === "explicit-review"
|
||||
);
|
||||
return {
|
||||
routeId: route.id,
|
||||
routeName: route.name,
|
||||
workflowKey: route.workflow_key,
|
||||
operationKey: route.operation_key,
|
||||
policyMode,
|
||||
approvalMode: route.approval_mode || "follow-model",
|
||||
budgetLimitCny: Number(route.budget_limit_cny || 0),
|
||||
policy,
|
||||
modelEntryId: entry.id,
|
||||
modelDisplayName: entry.displayName,
|
||||
modelKey: entry.modelKey,
|
||||
modelStatus: entry.status,
|
||||
modelApprovalStatus: entry.approvalStatus,
|
||||
connectorId: entry.connector.id,
|
||||
connectorLabel: entry.connector.label,
|
||||
connectorKind: entry.connector.kind,
|
||||
connectorStatus: entry.connector.status,
|
||||
connectorCostMode: entry.connector.costMode,
|
||||
connectorApprovalRequired: entry.connector.approvalRequired,
|
||||
resolutionSource: chosen.source,
|
||||
intentSource: intent?.source || "route-id",
|
||||
requiresApproval
|
||||
};
|
||||
}
|
||||
|
||||
function directConnectorMode(body = {}) {
|
||||
const mode = String(body.routingMode || body.routing_mode || body.routeMode || body.route_mode || "").trim().toLowerCase();
|
||||
return ["direct-connector", "direct-adapter", "connector-direct", "adapter-direct"].includes(mode)
|
||||
|| body.directConnector === true
|
||||
|| body.direct_connector === true
|
||||
|| body.directAdapter === true
|
||||
|| body.direct_adapter === true;
|
||||
}
|
||||
|
||||
function modelForContext(context, adapterId, kind = "") {
|
||||
const resolvedAdapterId = resolveAdapterId(context, adapterId, kind);
|
||||
const row = dbGet(
|
||||
@@ -101,6 +285,7 @@ function jobPayload(row) {
|
||||
ORDER BY d.created_at`,
|
||||
[row.id]
|
||||
);
|
||||
const request = parseJson(row.request_json, {});
|
||||
return {
|
||||
...row,
|
||||
shotId: row.shot_id || "E01",
|
||||
@@ -108,9 +293,12 @@ function jobPayload(row) {
|
||||
costPolicy: row.cost_policy,
|
||||
output: row.output_path,
|
||||
qa: row.qa_status,
|
||||
request: parseJson(row.request_json, {}),
|
||||
request,
|
||||
routing: request.routing || null,
|
||||
model: request.model || null,
|
||||
result: parseJson(row.result_json, {}),
|
||||
errorMessage: row.error_message || "",
|
||||
modelRouteApprovalId: row.model_route_approval_id || "",
|
||||
startedAt: row.started_at,
|
||||
finishedAt: row.finished_at,
|
||||
attempts: Number(row.attempts || attempts.length || 0),
|
||||
@@ -181,9 +369,16 @@ function shotForJob(context, shotId) {
|
||||
return graph.shots.find((shot) => shot.id === shotId) || null;
|
||||
}
|
||||
|
||||
function buildContract(context, body, adapter) {
|
||||
function buildContract(context, body, adapter, routing = null, preflight = {}) {
|
||||
const shot = shotForJob(context, body.shotId);
|
||||
const kind = String(body.kind || "自定义生成任务").trim();
|
||||
const knowledge = resolveKnowledgeContextForJob(context, body);
|
||||
const inputs = { ...(body.inputs || {}) };
|
||||
if (knowledge) {
|
||||
inputs.knowledgeContext = knowledge.promptContext;
|
||||
inputs.knowledgeCitations = knowledge.citations;
|
||||
inputs.knowledgeChunkIds = knowledge.chunkIds;
|
||||
}
|
||||
const blockedTerms = ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "故事板拼图"];
|
||||
// Negative prompts intentionally name the forbidden layouts; only inspect positive generation text here.
|
||||
const promptText = [shot?.prompt, shot?.videoPrompt, shot?.action, shot?.camera, body.prompt].filter(Boolean).join(" ").toLowerCase();
|
||||
@@ -196,14 +391,31 @@ function buildContract(context, body, adapter) {
|
||||
schema: "ai-drama.job.v1",
|
||||
createdAt: now(),
|
||||
project: { id: context.project.id, name: context.project.name },
|
||||
job: { kind, shotId: body.shotId || null, adapterId: adapter.id, costPolicy: "local-only" },
|
||||
job: {
|
||||
kind,
|
||||
shotId: body.shotId || null,
|
||||
adapterId: adapter.id,
|
||||
modelEntryId: routing?.modelEntryId || null,
|
||||
costPolicy: routing?.connectorCostMode || adapter.costMode || "local"
|
||||
},
|
||||
routing,
|
||||
model: routing ? {
|
||||
id: routing.modelEntryId,
|
||||
displayName: routing.modelDisplayName,
|
||||
key: routing.modelKey,
|
||||
connectorId: routing.connectorId,
|
||||
connectorLabel: routing.connectorLabel
|
||||
} : null,
|
||||
preflight,
|
||||
knowledge,
|
||||
shot,
|
||||
inputs: body.inputs || {},
|
||||
inputs,
|
||||
constraints: {
|
||||
singleFrameOnly: true,
|
||||
imageOutputCount: 1,
|
||||
requireActualLastFrame: true,
|
||||
localOnly: adapter.costMode === "local",
|
||||
localOnly: routing ? routing.policyMode === "local-only" || routing.connectorCostMode === "local" : adapter.costMode === "local",
|
||||
approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired),
|
||||
blockedTerms
|
||||
}
|
||||
};
|
||||
@@ -217,58 +429,645 @@ async function writeJobRequest(jobId, contract) {
|
||||
return relative;
|
||||
}
|
||||
|
||||
function assertExternalAllowed(context, adapter, body = {}) {
|
||||
function assertExternalAllowed(context, adapter, body = {}, approvalGrant = null) {
|
||||
if (adapter.costMode === "local") return;
|
||||
if (!adapter.approvalRequired) return;
|
||||
if (!body.approveExternal || !hasPermission(context, "model:manage")) {
|
||||
if (!approvalGrant && !inlineModelApprovalAllowed(context, body)) {
|
||||
throw httpError(403, "external_connector_requires_approval", "外部或混合连接器必须由具备模型管理权限的用户显式批准后才能执行", { adapterId: adapter.id });
|
||||
}
|
||||
}
|
||||
|
||||
function assertRoutingApproval(context, routing, adapter, body = {}, approvalGrant = null) {
|
||||
if (!routing?.requiresApproval) return;
|
||||
if (!approvalGrant && !inlineModelApprovalAllowed(context, body)) {
|
||||
throw httpError(403, "model_route_requires_approval", "当前模型路由需要具备模型管理权限的用户显式批准后才能执行", {
|
||||
routeId: routing.routeId,
|
||||
routeName: routing.routeName,
|
||||
modelEntryId: routing.modelEntryId,
|
||||
connectorId: adapter.id
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function voiceReferenceGate(context, body, routing, shot, { enforce = true } = {}) {
|
||||
if (routing?.policy?.referenceAssetKind !== "voice") return null;
|
||||
const kind = String(body.kind || "").toLowerCase();
|
||||
const unresolved = [];
|
||||
if (!shot?.id) {
|
||||
unresolved.push({ reason: "voice_shot_required", message: "固定 TTS 配音必须绑定一个包含台词的镜头" });
|
||||
} else {
|
||||
const lines = dbAll(
|
||||
"SELECT character_key, voice_id FROM voice_lines WHERE shot_id = ? ORDER BY line_number",
|
||||
[shot.id]
|
||||
);
|
||||
if (!lines.length) {
|
||||
unresolved.push({ reason: "voice_lines_required", message: "当前镜头没有可生成的台词" });
|
||||
} else {
|
||||
const assets = dbAll(
|
||||
`SELECT a.id, a.name, a.lock_status, av.rights_status, av.metadata_json
|
||||
FROM assets a
|
||||
LEFT JOIN asset_versions av ON av.id = a.current_version_id
|
||||
WHERE a.project_id = ? AND a.kind = 'voice'`,
|
||||
[context.project.id]
|
||||
).map((asset) => ({ ...asset, metadata: parseJson(asset.metadata_json, {}) }));
|
||||
for (const line of lines) {
|
||||
const voiceId = String(line.voice_id || `voice-${line.character_key || ""}`).trim();
|
||||
const asset = assets.find((item) => item.metadata?.voiceId === voiceId);
|
||||
const evidence = String(asset?.metadata?.consentRef || asset?.metadata?.rightsEvidence?.reference || "").trim();
|
||||
if (!asset || asset.lock_status !== "locked" || asset.rights_status !== "approved" || !evidence) {
|
||||
unresolved.push({
|
||||
characterKey: line.character_key,
|
||||
voiceId,
|
||||
assetId: asset?.id || null,
|
||||
assetName: asset?.name || null,
|
||||
lockStatus: asset?.lock_status || "missing",
|
||||
rightsStatus: asset?.rights_status || "missing",
|
||||
evidencePresent: Boolean(evidence)
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const result = {
|
||||
required: true,
|
||||
status: unresolved.length ? "blocked" : "approved",
|
||||
unresolved
|
||||
};
|
||||
if (unresolved.length) {
|
||||
if (enforce) {
|
||||
const code = unresolved.some((item) => item.reason === "voice_shot_required")
|
||||
? "voice_shot_required"
|
||||
: unresolved.some((item) => item.reason === "voice_lines_required")
|
||||
? "voice_lines_required"
|
||||
: "voice_reference_not_approved";
|
||||
throw httpError(422, code, "固定 TTS 配音必须使用已锁定、已授权且有证据引用的固定参考音频;未授权声线只能创建单句试听", { unresolved });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function visualAssetGovernanceGate(context, body, shot, { enforce = true } = {}) {
|
||||
const kind = String(body.kind || "").toLowerCase();
|
||||
const visualJob = kind.includes("视频") || kind.includes("i2v") || kind.includes("video") || kind.includes("图") || kind.includes("image") || kind.includes("关键帧");
|
||||
if (!visualJob || !shot?.id) return null;
|
||||
const rows = dbAll(
|
||||
`SELECT a.id, a.name, a.kind, a.lock_status, av.rights_status, av.risk_json, av.expires_at
|
||||
FROM asset_bindings ab
|
||||
JOIN assets a ON a.id = ab.asset_id
|
||||
LEFT JOIN asset_versions av ON av.id = a.current_version_id
|
||||
WHERE ab.shot_id = ?
|
||||
ORDER BY a.kind, a.name`,
|
||||
[shot.id]
|
||||
).map((row) => ({ ...row, risk: parseJson(row.risk_json, {}) }));
|
||||
const requiredKinds = ["character", "location", "prop"];
|
||||
const unresolved = [];
|
||||
for (const requiredKind of requiredKinds) {
|
||||
if (!rows.some((row) => row.kind === requiredKind)) unresolved.push({ kind: requiredKind, reason: "asset_binding_missing", message: `${requiredKind} 未绑定到镜头` });
|
||||
}
|
||||
for (const row of rows.filter((item) => requiredKinds.includes(item.kind))) {
|
||||
const expired = row.expires_at && Date.parse(row.expires_at) <= Date.now();
|
||||
if (row.lock_status !== "locked" || row.rights_status !== "approved" || row.risk?.status === "blocked" || expired) {
|
||||
unresolved.push({
|
||||
assetId: row.id,
|
||||
assetName: row.name,
|
||||
kind: row.kind,
|
||||
lockStatus: row.lock_status || "missing",
|
||||
rightsStatus: row.rights_status || "missing",
|
||||
riskStatus: row.risk?.status || "review",
|
||||
expired: Boolean(expired)
|
||||
});
|
||||
}
|
||||
}
|
||||
const result = {
|
||||
required: true,
|
||||
status: unresolved.length ? "blocked" : "approved",
|
||||
assets: rows.map((row) => ({ id: row.id, name: row.name, kind: row.kind, lockStatus: row.lock_status, rightsStatus: row.rights_status, riskStatus: row.risk?.status || "review", expiresAt: row.expires_at || "" })),
|
||||
unresolved
|
||||
};
|
||||
if (unresolved.length && enforce) {
|
||||
throw httpError(422, "asset_governance_gate_failed", "图片/视频生成必须使用已锁定、已授权且风险未阻塞的角色、场景和道具资产", { shotId: shot.id, unresolved });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function resolveJobExecution(context, body = {}) {
|
||||
const requestedAdapterId = String(body.adapter || body.adapterId || "").trim();
|
||||
if (directConnectorMode(body)) {
|
||||
if (!requestedAdapterId) throw httpError(400, "adapter_required", "直连连接器模式必须选择模型连接器");
|
||||
if (!canApproveModels(context)) throw httpError(403, "model_route_override_denied", "只有模型管理员可以绕过组织级模型路由直连连接器");
|
||||
return {
|
||||
adapter: modelForContext(context, requestedAdapterId, body.kind),
|
||||
routing: null,
|
||||
source: "direct-connector",
|
||||
override: {
|
||||
mode: "direct-connector",
|
||||
requestedAdapterId,
|
||||
reason: String(body.directConnectorReason || body.direct_connector_reason || body.reason || "").trim(),
|
||||
approvedBy: context.user.id
|
||||
}
|
||||
};
|
||||
}
|
||||
const routing = routeResolution(context, body);
|
||||
if (routing) {
|
||||
const adapter = modelForContext(context, routing.connectorId, body.kind);
|
||||
return { adapter, routing, source: "routing-policy" };
|
||||
}
|
||||
if (!requestedAdapterId) throw httpError(400, "adapter_required", "生成任务必须选择模型连接器,或提供可命中的路由策略");
|
||||
return { adapter: modelForContext(context, requestedAdapterId, body.kind), routing: null, source: "legacy-adapter" };
|
||||
}
|
||||
|
||||
function modelPreviewPayload(entry) {
|
||||
if (!entry) return null;
|
||||
return {
|
||||
id: entry.id,
|
||||
displayName: entry.displayName,
|
||||
modelKey: entry.modelKey,
|
||||
family: entry.family || "",
|
||||
capabilities: entry.capabilities || [],
|
||||
contextWindow: Number(entry.context_window || entry.contextWindow || 0),
|
||||
maxOutputTokens: Number(entry.max_output_tokens || entry.maxOutputTokens || 0),
|
||||
approvalStatus: entry.approvalStatus || entry.approval_status || "approved",
|
||||
status: entry.status || "active",
|
||||
cost: entry.cost || {},
|
||||
connectorId: entry.connectorId || entry.connector_id || ""
|
||||
};
|
||||
}
|
||||
|
||||
function connectorPreviewPayload(adapter) {
|
||||
if (!adapter) return null;
|
||||
return {
|
||||
id: adapter.id,
|
||||
label: adapter.label,
|
||||
kind: adapter.kind,
|
||||
endpoint: adapter.endpoint,
|
||||
status: adapter.status,
|
||||
capability: adapter.capability || [],
|
||||
costMode: adapter.costMode || adapter.cost_mode || "local",
|
||||
approvalRequired: Boolean(adapter.approvalRequired ?? adapter.approval_required),
|
||||
authEnv: adapter.authEnv || adapter.auth_env || ""
|
||||
};
|
||||
}
|
||||
|
||||
function routeGuard({ adapter, routing, model, body = {} }) {
|
||||
const reasons = [];
|
||||
const costMode = String(adapter?.costMode || adapter?.cost_mode || "local");
|
||||
const modelCost = model?.cost || {};
|
||||
const locality = String(modelCost.locality || costMode || "local");
|
||||
const estimatedCny = Number(body.estimatedCostCny ?? body.estimated_cny ?? modelCost.estimatedCny ?? modelCost.estimated_cny ?? 0);
|
||||
const budgetLimitCny = Number(body.budgetLimitCny ?? body.budget_limit_cny ?? routing?.budgetLimitCny ?? 0);
|
||||
const localOnly = Boolean(body.localOnly ?? body.local_only ?? routing?.policyMode === "local-only");
|
||||
if (!adapter) {
|
||||
reasons.push({ severity: "blocking", code: "connector_missing", message: "没有解析到可用连接器。" });
|
||||
} else {
|
||||
if (adapter.status !== "ready") reasons.push({ severity: "blocking", code: "connector_not_ready", message: `连接器状态为 ${adapter.status},不能直接进入执行队列。` });
|
||||
if (localOnly && (costMode !== "local" || locality !== "local")) reasons.push({ severity: "blocking", code: "local_only_violation", message: "当前试算要求 local-only,但命中模型或连接器不是本地成本策略。" });
|
||||
if (Boolean(adapter.approvalRequired ?? adapter.approval_required) || costMode !== "local") reasons.push({ severity: "approval", code: "connector_requires_approval", message: "连接器属于混合/外部或显式审批模式,执行前需要模型管理员批准。" });
|
||||
}
|
||||
if (routing?.requiresApproval) reasons.push({ severity: "approval", code: "route_requires_approval", message: "命中的业务路由要求执行前审批。" });
|
||||
if (model && model.approvalStatus !== "approved") reasons.push({ severity: "approval", code: "model_not_approved", message: `模型目录审批状态为 ${model.approvalStatus}。` });
|
||||
if (budgetLimitCny > 0 && estimatedCny > budgetLimitCny) reasons.push({ severity: "blocking", code: "budget_limit_exceeded", message: "预估成本超过当前路由预算上限。" });
|
||||
const status = reasons.some((reason) => reason.severity === "blocking")
|
||||
? "blocked"
|
||||
: reasons.some((reason) => reason.severity === "approval")
|
||||
? "approval-required"
|
||||
: "pass";
|
||||
return {
|
||||
status,
|
||||
localOnly,
|
||||
estimatedCny,
|
||||
budgetLimitCny,
|
||||
reasons
|
||||
};
|
||||
}
|
||||
|
||||
function canApproveModels(context) {
|
||||
return hasPermission(context, "model:manage") || hasPermission(context, "model:approve");
|
||||
}
|
||||
|
||||
function canRequestModelRouteApproval(context) {
|
||||
return hasPermission(context, "job:create") || canApproveModels(context);
|
||||
}
|
||||
|
||||
function buildModelRouteResolution(context, body = {}) {
|
||||
const intent = routingIntent(body);
|
||||
const requestedAdapterId = String(body.adapter || body.adapterId || "").trim();
|
||||
const directMode = directConnectorMode(body);
|
||||
const routing = directMode ? null : routeResolution(context, body);
|
||||
const adapter = directMode && requestedAdapterId
|
||||
? modelForContext(context, requestedAdapterId, body.kind)
|
||||
: routing
|
||||
? modelForContext(context, routing.connectorId, body.kind)
|
||||
: requestedAdapterId
|
||||
? modelForContext(context, requestedAdapterId, body.kind)
|
||||
: null;
|
||||
const model = routing?.modelEntryId ? modelPreviewPayload(catalogEntryForContext(context, routing.modelEntryId)) : null;
|
||||
const connector = connectorPreviewPayload(adapter);
|
||||
const guard = routeGuard({ adapter, routing, model, body });
|
||||
return {
|
||||
schema: "ai-drama.model-route-resolution.v1",
|
||||
resolvedAt: now(),
|
||||
source: directMode && adapter ? "direct-connector" : routing ? "routing-policy" : adapter ? "direct-connector" : "unresolved",
|
||||
request: {
|
||||
workflowKey: body.workflowKey || body.workflow_key || intent?.workflowKey || "",
|
||||
operationKey: body.operationKey || body.operation_key || intent?.operationKey || "",
|
||||
kind: body.kind || "",
|
||||
routeId: body.routeId || body.route_id || "",
|
||||
adapter: requestedAdapterId,
|
||||
routingMode: directMode ? "direct-connector" : "routing-policy",
|
||||
modelId: body.modelId || body.model_id || body.catalogModelId || body.catalog_model_id || "",
|
||||
localOnly: guard.localOnly
|
||||
},
|
||||
intent,
|
||||
route: routing,
|
||||
model,
|
||||
connector,
|
||||
guard
|
||||
};
|
||||
}
|
||||
|
||||
export function previewModelRouteResolution(context, body = {}) {
|
||||
if (!canApproveModels(context)) throw httpError(403, "permission_denied", "没有模型路由试算权限");
|
||||
return buildModelRouteResolution(context, body);
|
||||
}
|
||||
|
||||
const approvalSelect = `
|
||||
SELECT mra.*,
|
||||
requester.display_name AS requester_name,
|
||||
requester.email AS requester_email,
|
||||
reviewer.display_name AS reviewer_name,
|
||||
mrp.name AS route_name,
|
||||
mrp.workflow_key AS route_workflow_key,
|
||||
mrp.operation_key AS route_operation_key,
|
||||
mce.display_name AS model_display_name,
|
||||
mce.model_key AS model_key,
|
||||
mc.label AS connector_label,
|
||||
mc.kind AS connector_kind,
|
||||
p.name AS project_name,
|
||||
j.kind AS job_kind,
|
||||
j.status AS job_status
|
||||
FROM model_route_approval_requests mra
|
||||
LEFT JOIN users requester ON requester.id = mra.requester_user_id
|
||||
LEFT JOIN users reviewer ON reviewer.id = mra.reviewer_user_id
|
||||
LEFT JOIN model_routing_policies mrp ON mrp.id = mra.route_id
|
||||
LEFT JOIN model_catalog_entries mce ON mce.id = mra.model_entry_id
|
||||
LEFT JOIN model_connectors mc ON mc.id = mra.connector_id
|
||||
LEFT JOIN projects p ON p.id = mra.project_id
|
||||
LEFT JOIN generation_jobs j ON j.id = mra.job_id
|
||||
`;
|
||||
|
||||
function effectiveApprovalStatus(row) {
|
||||
if (!row) return "";
|
||||
if (row.status === "approved" && row.expires_at && Date.parse(row.expires_at) <= Date.now()) return "expired";
|
||||
return row.status;
|
||||
}
|
||||
|
||||
function approvalRequestPayload(row) {
|
||||
if (!row) return null;
|
||||
const status = effectiveApprovalStatus(row);
|
||||
return {
|
||||
...row,
|
||||
status,
|
||||
storedStatus: row.status,
|
||||
approvalScope: row.approval_scope,
|
||||
routeId: row.route_id || "",
|
||||
routeName: row.route_name || "",
|
||||
workflowKey: row.route_workflow_key || parseJson(row.resolution_json, {})?.request?.workflowKey || "",
|
||||
operationKey: row.route_operation_key || parseJson(row.resolution_json, {})?.request?.operationKey || "",
|
||||
modelEntryId: row.model_entry_id || "",
|
||||
modelDisplayName: row.model_display_name || "",
|
||||
modelKey: row.model_key || "",
|
||||
connectorId: row.connector_id || "",
|
||||
connectorLabel: row.connector_label || "",
|
||||
connectorKind: row.connector_kind || "",
|
||||
requesterName: row.requester_name || row.requester_user_id,
|
||||
requesterEmail: row.requester_email || "",
|
||||
reviewerName: row.reviewer_name || "",
|
||||
projectId: row.project_id || "",
|
||||
projectName: row.project_name || "",
|
||||
jobId: row.job_id || "",
|
||||
jobKind: row.job_kind || "",
|
||||
jobStatus: row.job_status || "",
|
||||
localOnly: Boolean(row.local_only),
|
||||
estimatedCostCny: Number(row.estimated_cost_cny || 0),
|
||||
request: parseJson(row.request_json, {}),
|
||||
resolution: parseJson(row.resolution_json, {}),
|
||||
guard: parseJson(row.guard_json, {}),
|
||||
expiresAt: row.expires_at || "",
|
||||
reviewedAt: row.reviewed_at || "",
|
||||
consumedAt: row.consumed_at || "",
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
}
|
||||
|
||||
function approvalScopeWhere(context) {
|
||||
return {
|
||||
clause: "mra.organization_id = ? AND (mra.workspace_id IS NULL OR mra.workspace_id = ?)",
|
||||
params: [context.organization.id, context.workspace.id]
|
||||
};
|
||||
}
|
||||
|
||||
export function listModelRouteApprovalRequests(context, options = {}) {
|
||||
const canApprove = canApproveModels(context);
|
||||
if (!canApprove && !canRequestModelRouteApproval(context)) throw httpError(403, "permission_denied", "没有查看模型审批单的权限");
|
||||
const { clause, params } = approvalScopeWhere(context);
|
||||
const where = [clause];
|
||||
const queryParams = [...params];
|
||||
const requestedStatus = String(options.status || "").trim();
|
||||
if (requestedStatus && requestedStatus !== "all") {
|
||||
where.push("mra.status = ?");
|
||||
queryParams.push(requestedStatus);
|
||||
}
|
||||
if (!canApprove || options.mine) {
|
||||
where.push("mra.requester_user_id = ?");
|
||||
queryParams.push(context.user.id);
|
||||
}
|
||||
const rows = dbAll(
|
||||
`${approvalSelect}
|
||||
WHERE ${where.join(" AND ")}
|
||||
ORDER BY mra.created_at DESC
|
||||
LIMIT ?`,
|
||||
[...queryParams, Math.max(1, Math.min(200, Number(options.limit || 80)))]
|
||||
).map(approvalRequestPayload);
|
||||
return {
|
||||
approvals: rows,
|
||||
summary: {
|
||||
submitted: rows.filter((item) => item.status === "submitted").length,
|
||||
approved: rows.filter((item) => item.status === "approved").length,
|
||||
rejected: rows.filter((item) => item.status === "rejected").length,
|
||||
expired: rows.filter((item) => item.status === "expired").length
|
||||
},
|
||||
permissions: { canApprove, canSubmit: canRequestModelRouteApproval(context) }
|
||||
};
|
||||
}
|
||||
|
||||
export function requestModelRouteApproval(context, body = {}) {
|
||||
if (!canRequestModelRouteApproval(context)) throw httpError(403, "permission_denied", "没有提交模型路由审批的权限");
|
||||
const resolution = buildModelRouteResolution(context, body);
|
||||
const blocking = (resolution.guard.reasons || []).filter((reason) => reason.severity === "blocking");
|
||||
if (blocking.length) {
|
||||
throw httpError(409, "model_route_approval_blocked", "当前试算存在阻断项,不能通过审批单放行", { resolution, blocking });
|
||||
}
|
||||
if (resolution.guard.status === "pass") {
|
||||
throw httpError(409, "model_route_approval_not_required", "当前路由不需要审批,不能创建空审批单", { resolution });
|
||||
}
|
||||
if (!resolution.connector?.id) throw httpError(422, "model_route_connector_required", "审批单必须解析到连接器");
|
||||
const approvalScope = String(body.approvalScope || body.approval_scope || "single-run").trim();
|
||||
if (!["single-run", "single-job", "route-window", "connector-window"].includes(approvalScope)) throw httpError(400, "model_route_approval_scope_invalid", "模型路由审批范围无效");
|
||||
const timestamp = now();
|
||||
const id = makeId("model-approval");
|
||||
const reason = String(body.reason || "").trim();
|
||||
const requestPayload = {
|
||||
workflowKey: body.workflowKey || body.workflow_key || "",
|
||||
operationKey: body.operationKey || body.operation_key || "",
|
||||
kind: body.kind || "",
|
||||
adapter: body.adapter || body.adapterId || "",
|
||||
modelId: body.modelId || body.model_id || body.catalogModelId || body.catalog_model_id || "",
|
||||
localOnly: Boolean(body.localOnly ?? body.local_only ?? resolution.guard.localOnly),
|
||||
estimatedCostCny: Number(body.estimatedCostCny ?? body.estimated_cny ?? resolution.guard.estimatedCny ?? 0),
|
||||
reason
|
||||
};
|
||||
dbRun(
|
||||
`INSERT INTO model_route_approval_requests(
|
||||
id, organization_id, workspace_id, project_id, route_id, model_entry_id, connector_id,
|
||||
requester_user_id, status, approval_scope, reason, request_json, resolution_json, guard_json,
|
||||
estimated_cost_cny, local_only, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'submitted', ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
context.organization.id,
|
||||
context.workspace.id,
|
||||
context.project?.id || null,
|
||||
resolution.route?.routeId || null,
|
||||
resolution.route?.modelEntryId || resolution.model?.id || null,
|
||||
resolution.connector.id,
|
||||
context.user.id,
|
||||
approvalScope,
|
||||
reason || "生产任务需要使用受控模型路由",
|
||||
JSON.stringify(requestPayload),
|
||||
JSON.stringify(resolution),
|
||||
JSON.stringify(resolution.guard),
|
||||
Number(resolution.guard.estimatedCny || 0),
|
||||
resolution.guard.localOnly ? 1 : 0,
|
||||
timestamp,
|
||||
timestamp
|
||||
]
|
||||
);
|
||||
addAudit({
|
||||
context,
|
||||
action: "model.route_approval.submitted",
|
||||
targetType: "model_route_approval_request",
|
||||
targetId: id,
|
||||
result: "requires-approval",
|
||||
metadata: { routeId: resolution.route?.routeId || null, connectorId: resolution.connector.id, modelEntryId: resolution.route?.modelEntryId || resolution.model?.id || null, reason }
|
||||
});
|
||||
void dispatchNotificationEvent({
|
||||
context,
|
||||
eventKey: "model.route_approval.submitted",
|
||||
payload: { approvalRequestId: id, routeName: resolution.route?.routeName || "", connectorLabel: resolution.connector.label, requesterUserId: context.user.id }
|
||||
});
|
||||
return {
|
||||
approval: approvalRequestPayload(dbGet(`${approvalSelect} WHERE mra.id = ?`, [id])),
|
||||
...listModelRouteApprovalRequests(context)
|
||||
};
|
||||
}
|
||||
|
||||
export function decideModelRouteApproval(context, approvalId, body = {}) {
|
||||
if (!canApproveModels(context)) throw httpError(403, "permission_denied", "没有审批模型路由的权限");
|
||||
const id = String(approvalId || "").trim();
|
||||
const { clause, params } = approvalScopeWhere(context);
|
||||
const row = dbGet(`${approvalSelect} WHERE ${clause} AND mra.id = ?`, [...params, id]);
|
||||
if (!row) throw httpError(404, "model_route_approval_not_found", "模型路由审批单不存在或不属于当前工作区", { approvalId: id });
|
||||
const effectiveStatus = effectiveApprovalStatus(row);
|
||||
if (effectiveStatus !== "submitted") throw httpError(409, "model_route_approval_not_pending", "只有待审批的模型路由申请可以处理", { status: effectiveStatus });
|
||||
const decision = String(body.status || body.decision || "").trim();
|
||||
if (!["approved", "rejected"].includes(decision)) throw httpError(400, "model_route_approval_decision_invalid", "审批决定只能是 approved 或 rejected");
|
||||
const timestamp = now();
|
||||
const expiresHours = Math.max(1, Math.min(168, Number(body.expiresHours || body.expires_hours || 24)));
|
||||
const expiresAt = decision === "approved" ? new Date(Date.now() + expiresHours * 3600000).toISOString() : null;
|
||||
const note = String(body.note || body.decisionNote || body.decision_note || "").trim();
|
||||
dbRun(
|
||||
`UPDATE model_route_approval_requests
|
||||
SET status = ?, reviewer_user_id = ?, decision_note = ?, expires_at = ?, reviewed_at = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
[decision, context.user.id, note, expiresAt, timestamp, timestamp, id]
|
||||
);
|
||||
addAudit({
|
||||
context,
|
||||
action: `model.route_approval.${decision}`,
|
||||
targetType: "model_route_approval_request",
|
||||
targetId: id,
|
||||
result: decision === "approved" ? "ok" : "blocked",
|
||||
metadata: { routeId: row.route_id || null, connectorId: row.connector_id || null, modelEntryId: row.model_entry_id || null, expiresAt, note }
|
||||
});
|
||||
void dispatchNotificationEvent({
|
||||
context,
|
||||
eventKey: `model.route_approval.${decision}`,
|
||||
payload: { approvalRequestId: id, requesterUserId: row.requester_user_id, reviewerUserId: context.user.id, expiresAt, note }
|
||||
});
|
||||
return {
|
||||
approval: approvalRequestPayload(dbGet(`${approvalSelect} WHERE mra.id = ?`, [id])),
|
||||
...listModelRouteApprovalRequests(context)
|
||||
};
|
||||
}
|
||||
|
||||
function approvalGrantSummary(approval) {
|
||||
if (!approval) return null;
|
||||
return {
|
||||
id: approval.id,
|
||||
status: approval.status,
|
||||
approvalScope: approval.approvalScope,
|
||||
routeId: approval.routeId || "",
|
||||
modelEntryId: approval.modelEntryId || "",
|
||||
connectorId: approval.connectorId || "",
|
||||
expiresAt: approval.expiresAt || "",
|
||||
reviewerUserId: approval.reviewer_user_id || "",
|
||||
decisionNote: approval.decision_note || ""
|
||||
};
|
||||
}
|
||||
|
||||
function resolveApprovedModelRouteApproval(context, { adapter, routing, body = {}, jobId = "", contract = null } = {}) {
|
||||
const requestId = String(
|
||||
body.approvalRequestId
|
||||
|| body.modelRouteApprovalId
|
||||
|| body.model_route_approval_id
|
||||
|| contract?.preflight?.modelRouteApproval?.id
|
||||
|| contract?.preflight?.approvalGrant?.id
|
||||
|| ""
|
||||
).trim();
|
||||
if (!requestId) return null;
|
||||
const { clause, params } = approvalScopeWhere(context);
|
||||
const row = dbGet(`${approvalSelect} WHERE ${clause} AND mra.id = ?`, [...params, requestId]);
|
||||
if (!row) throw httpError(403, "model_route_approval_invalid", "审批单不存在或不属于当前组织/工作区", { approvalRequestId: requestId });
|
||||
const approval = approvalRequestPayload(row);
|
||||
if (approval.status !== "approved") throw httpError(403, "model_route_approval_not_active", "模型路由审批单尚未批准或已经失效", { approvalRequestId: requestId, status: approval.status });
|
||||
if (approval.consumedAt) throw httpError(403, "model_route_approval_consumed", "模型路由审批单已经被一次执行消耗,请重新提交审批", { approvalRequestId: requestId, consumedAt: approval.consumedAt });
|
||||
if (approval.projectId && context.project?.id && approval.projectId !== context.project.id) throw httpError(403, "model_route_approval_project_mismatch", "审批单不属于当前项目", { approvalRequestId: requestId });
|
||||
if (approval.jobId && jobId && approval.jobId !== jobId) throw httpError(403, "model_route_approval_job_mismatch", "审批单已绑定其他任务", { approvalRequestId: requestId, jobId: approval.jobId });
|
||||
if (approval.connectorId && adapter?.id && approval.connectorId !== adapter.id) throw httpError(403, "model_route_approval_connector_mismatch", "审批单连接器与当前任务不一致", { approvalRequestId: requestId, connectorId: approval.connectorId, adapterId: adapter.id });
|
||||
if (approval.routeId && routing?.routeId && approval.routeId !== routing.routeId) throw httpError(403, "model_route_approval_route_mismatch", "审批单路由与当前任务不一致", { approvalRequestId: requestId, routeId: approval.routeId, currentRouteId: routing.routeId });
|
||||
if (approval.modelEntryId && routing?.modelEntryId && approval.modelEntryId !== routing.modelEntryId) throw httpError(403, "model_route_approval_model_mismatch", "审批单模型目录条目与当前任务不一致", { approvalRequestId: requestId, modelEntryId: approval.modelEntryId, currentModelEntryId: routing.modelEntryId });
|
||||
return approval;
|
||||
}
|
||||
|
||||
function attachModelRouteApprovalToJob(context, approval, jobId) {
|
||||
if (!approval?.id) return;
|
||||
const timestamp = now();
|
||||
const result = dbRun(
|
||||
`UPDATE model_route_approval_requests
|
||||
SET job_id = COALESCE(job_id, ?), updated_at = ?
|
||||
WHERE id = ? AND (job_id IS NULL OR job_id = ?)`,
|
||||
[jobId, timestamp, approval.id, jobId]
|
||||
);
|
||||
if (!result.changes) throw httpError(409, "model_route_approval_attach_failed", "审批单已经绑定其他任务", { approvalRequestId: approval.id, jobId });
|
||||
addAudit({ context, action: "model.route_approval.attached", targetType: "model_route_approval_request", targetId: approval.id, metadata: { jobId } });
|
||||
}
|
||||
|
||||
function consumeModelRouteApproval(context, approval, jobId) {
|
||||
if (!approval?.id) return;
|
||||
const timestamp = now();
|
||||
const result = dbRun(
|
||||
`UPDATE model_route_approval_requests
|
||||
SET consumed_at = ?, job_id = COALESCE(job_id, ?), updated_at = ?
|
||||
WHERE id = ? AND consumed_at IS NULL AND (job_id IS NULL OR job_id = ?)`,
|
||||
[timestamp, jobId, timestamp, approval.id, jobId]
|
||||
);
|
||||
if (!result.changes) throw httpError(409, "model_route_approval_consume_failed", "审批单已经被消耗或绑定其他任务", { approvalRequestId: approval.id, jobId });
|
||||
addAudit({ context, action: "model.route_approval.consumed", targetType: "model_route_approval_request", targetId: approval.id, metadata: { jobId } });
|
||||
}
|
||||
|
||||
function inlineModelApprovalAllowed(context, body = {}) {
|
||||
return Boolean(body.approveExternal) && canApproveModels(context);
|
||||
}
|
||||
|
||||
export async function createGenerationJob(context, body) {
|
||||
requirePermission(context, "job:create");
|
||||
if (!context.project) throw httpError(400, "project_required", "生成任务必须绑定项目");
|
||||
requireEntitlement(context, "limit.generation_jobs_monthly", 1);
|
||||
requireQuota(context, "clip", 1);
|
||||
const requestedAdapterId = String(body.adapter || "").trim();
|
||||
if (!requestedAdapterId) throw httpError(400, "adapter_required", "生成任务必须选择模型连接器");
|
||||
const adapter = modelForContext(context, requestedAdapterId, body.kind);
|
||||
assertExternalAllowed(context, adapter, body);
|
||||
const execution = resolveJobExecution(context, body);
|
||||
const { adapter, routing } = execution;
|
||||
const approvalGrant = resolveApprovedModelRouteApproval(context, { adapter, routing, body });
|
||||
assertExternalAllowed(context, adapter, body, approvalGrant);
|
||||
assertRoutingApproval(context, routing, adapter, body, approvalGrant);
|
||||
const dependencyIds = [...new Set((Array.isArray(body.dependsOnJobIds) ? body.dependsOnJobIds : []).map((id) => String(id || "").trim()).filter(Boolean))].slice(0, 12);
|
||||
dependencyRows(context, dependencyIds);
|
||||
assertNoDependencyCycle("__new_job__", dependencyIds);
|
||||
const selectedShot = body.shotId ? shotForJob(context, body.shotId) : null;
|
||||
if (body.shotId && !selectedShot) throw httpError(404, "shot_not_found", "镜头不存在或不属于当前项目", { shotId: body.shotId });
|
||||
const contract = buildContract(context, { ...body, shotId: selectedShot?.id || null }, adapter);
|
||||
const voicePreflight = voiceReferenceGate(context, body, routing, selectedShot);
|
||||
const assetPreflight = visualAssetGovernanceGate(context, body, selectedShot);
|
||||
const contract = buildContract(context, { ...body, shotId: selectedShot?.id || null }, adapter, routing, {
|
||||
voiceReference: voicePreflight,
|
||||
assetGovernance: assetPreflight,
|
||||
modelRouteApproval: approvalGrantSummary(approvalGrant),
|
||||
executionSource: execution.source,
|
||||
directConnectorOverride: execution.override || null,
|
||||
inlineApproval: inlineModelApprovalAllowed(context, body) ? { approvedBy: context.user.id, mode: "admin-inline" } : null
|
||||
});
|
||||
const jobId = makeId("job");
|
||||
const timestamp = now();
|
||||
const outputPath = String(body.output || `storage/jobs/${jobId}/output`);
|
||||
if (outputPath.startsWith("/") || outputPath.includes("..")) throw httpError(400, "output_path_invalid", "输出路径必须是项目目录内的相对路径");
|
||||
const maxAttempts = Math.max(1, Math.min(10, Number(body.maxAttempts || 3)));
|
||||
const dependencyBlocked = dependencyIds.some((dependencyId) => dbGet("SELECT status FROM generation_jobs WHERE id = ?", [dependencyId])?.status !== "completed");
|
||||
const initialStatus = dependencyBlocked ? "blocked" : adapter.status === "ready" && adapter.costMode === "local" ? "queued" : "blocked";
|
||||
const approvalAllowsQueue = Boolean(approvalGrant || inlineModelApprovalAllowed(context, body));
|
||||
const initialStatus = dependencyBlocked ? "blocked" : adapter.status === "ready" && (routing?.policyMode === "local-only" || adapter.costMode === "local" || approvalAllowsQueue) ? "queued" : "blocked";
|
||||
const errorMessage = dependencyBlocked
|
||||
? `等待前置任务完成:${dependencyIds.join(", ")}`
|
||||
: initialStatus === "blocked" ? `连接器当前状态为 ${adapter.status},请先在模型中台检测并启用连接器` : "";
|
||||
const costPolicy = routing?.connectorCostMode || adapter.costMode || "local";
|
||||
await writeJobRequest(jobId, contract);
|
||||
withTransaction(() => {
|
||||
dbRun(
|
||||
`INSERT INTO generation_jobs(
|
||||
id, organization_id, workspace_id, project_id, episode_id, shot_id, kind, adapter_id,
|
||||
status, priority, cost_policy, output_path, qa_status, request_json, result_json,
|
||||
error_message, max_attempts, next_run_at, leased_by, leased_at, created_by, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, (SELECT id FROM episodes WHERE season_id IN (SELECT id FROM seasons WHERE series_id = (SELECT id FROM series WHERE project_id = ?)) LIMIT 1), ?, ?, ?, ?, ?, 'local-only', ?, 'wait', ?, '{}', ?, ?, NULL, NULL, NULL, ?, ?, ?)`,
|
||||
[jobId, context.organization.id, context.workspace.id, context.project.id, context.project.id, selectedShot?.id || null, body.kind || "自定义生成任务", adapter.id, initialStatus, Math.max(1, Math.min(100, Number(body.priority || 50))), outputPath, JSON.stringify(contract), errorMessage, maxAttempts, context.user.id, timestamp, timestamp]
|
||||
error_message, max_attempts, model_route_approval_id, next_run_at, leased_by, leased_at, created_by, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, (SELECT id FROM episodes WHERE season_id IN (SELECT id FROM seasons WHERE series_id = (SELECT id FROM series WHERE project_id = ?)) LIMIT 1), ?, ?, ?, ?, ?, ?, ?, 'wait', ?, '{}', ?, ?, ?, NULL, NULL, NULL, ?, ?, ?)`,
|
||||
[jobId, context.organization.id, context.workspace.id, context.project.id, context.project.id, selectedShot?.id || null, body.kind || "自定义生成任务", adapter.id, initialStatus, Math.max(1, Math.min(100, Number(body.priority || 50))), costPolicy, outputPath, JSON.stringify(contract), errorMessage, maxAttempts, approvalGrant?.id || null, context.user.id, timestamp, timestamp]
|
||||
);
|
||||
dbRun("INSERT INTO job_attempts(id, job_id, attempt_number, runner_id, status, error_message, created_at) VALUES (?, ?, 1, ?, ?, ?, ?)", [makeId("attempt"), jobId, adapter.id, initialStatus === "queued" ? "queued" : "blocked", errorMessage || null, timestamp]);
|
||||
for (const dependencyId of dependencyIds) {
|
||||
dbRun("INSERT INTO job_dependencies(job_id, depends_on_job_id, dependency_type, created_at) VALUES (?, ?, 'blocking', ?)", [jobId, dependencyId, timestamp]);
|
||||
}
|
||||
attachModelRouteApprovalToJob(context, approvalGrant, jobId);
|
||||
});
|
||||
addUsage({ context, kind: body.kind || "generation", units: 1, unitName: "job", estimatedCost: 0, metadata: { jobId, adapter: adapter.id, status: initialStatus } });
|
||||
addAudit({ context, action: "generation_job.created", targetType: "generation_job", targetId: jobId, result: initialStatus === "queued" ? "ok" : "blocked", metadata: { kind: body.kind, shotId: body.shotId || null, adapter: adapter.id, status: initialStatus } });
|
||||
addUsage({ context, kind: body.kind || "generation", units: 1, unitName: "job", estimatedCost: 0, metadata: { jobId, adapter: adapter.id, modelEntryId: routing?.modelEntryId || null, routeId: routing?.routeId || null, approvalRequestId: approvalGrant?.id || null, status: initialStatus, knowledgePackId: contract.knowledge?.id || null, knowledgeCitations: contract.knowledge?.citations?.length || 0 } });
|
||||
addAudit({ context, action: "generation_job.created", targetType: "generation_job", targetId: jobId, result: initialStatus === "queued" ? "ok" : "blocked", metadata: { kind: body.kind, shotId: body.shotId || null, adapter: adapter.id, modelEntryId: routing?.modelEntryId || null, routeId: routing?.routeId || null, approvalRequestId: approvalGrant?.id || null, status: initialStatus, knowledgePackId: contract.knowledge?.id || null } });
|
||||
return { job: jobPayload(dbGet("SELECT * FROM generation_jobs WHERE id = ?", [jobId])), jobs: listGenerationJobs(context) };
|
||||
}
|
||||
|
||||
export function previewGenerationJob(context, body = {}) {
|
||||
requirePermission(context, "job:create");
|
||||
if (!context.project) throw httpError(400, "project_required", "任务预览必须绑定项目");
|
||||
const execution = resolveJobExecution(context, body);
|
||||
const selectedShot = body.shotId ? shotForJob(context, body.shotId) : null;
|
||||
if (body.shotId && !selectedShot) throw httpError(404, "shot_not_found", "镜头不存在或不属于当前项目", { shotId: body.shotId });
|
||||
const voicePreflight = voiceReferenceGate(context, body, execution.routing, selectedShot, { enforce: false });
|
||||
const contract = buildContract(context, { ...body, shotId: selectedShot?.id || null }, execution.adapter, execution.routing, {
|
||||
voiceReference: voicePreflight,
|
||||
executionSource: execution.source,
|
||||
directConnectorOverride: execution.override || null
|
||||
});
|
||||
return {
|
||||
preview: contract,
|
||||
resolution: {
|
||||
source: execution.source,
|
||||
route: execution.routing,
|
||||
override: execution.override || null,
|
||||
adapter: {
|
||||
id: execution.adapter.id,
|
||||
label: execution.adapter.label,
|
||||
kind: execution.adapter.kind,
|
||||
status: execution.adapter.status,
|
||||
costMode: execution.adapter.costMode,
|
||||
approvalRequired: execution.adapter.approvalRequired
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function listGenerationJobs(context, options = {}) {
|
||||
if (!context.project) return [];
|
||||
const status = String(options.status || "").trim();
|
||||
@@ -334,6 +1133,7 @@ function operationForJob(job) {
|
||||
|
||||
function contractText(contract) {
|
||||
return [
|
||||
contract?.knowledge?.promptContext,
|
||||
contract?.shot?.prompt,
|
||||
contract?.shot?.videoPrompt,
|
||||
contract?.shot?.action,
|
||||
@@ -411,7 +1211,7 @@ async function buildAdapterRequest(adapter, job, contract, attemptNumber, timest
|
||||
};
|
||||
const url = joinEndpoint(adapter.endpoint, protocol.routes?.[operation] || defaultRoutes[operation]);
|
||||
if (operation === "asr") return openAiMultipartRequest(url, headers, protocol, contract, job, timestamp, attemptNumber);
|
||||
const model = protocol.models?.[operation] || protocol.models?.default || protocol.model || contract?.inputs?.model || "local-model";
|
||||
const model = contract?.routing?.modelKey || protocol.models?.[operation] || protocol.models?.default || protocol.model || contract?.inputs?.model || "local-model";
|
||||
let body;
|
||||
if (operation === "tts") {
|
||||
body = {
|
||||
@@ -459,7 +1259,7 @@ async function buildAdapterRequest(adapter, job, contract, attemptNumber, timest
|
||||
options: {
|
||||
method: "POST",
|
||||
headers: { ...headers, "content-type": "application/json" },
|
||||
body: JSON.stringify({ ...contract, execution })
|
||||
body: JSON.stringify({ ...contract, model: contract?.routing?.modelKey || contract?.model, execution })
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -505,7 +1305,20 @@ export async function executeGenerationJob(context, jobId, body = {}) {
|
||||
throw httpError(409, "job_dependencies_unresolved", message, { dependencies: unresolved });
|
||||
}
|
||||
const adapter = modelForContext(context, job.adapter_id);
|
||||
assertExternalAllowed(context, adapter, body);
|
||||
const contract = parseJson(job.request_json, {});
|
||||
const approvalGrant = resolveApprovedModelRouteApproval(context, {
|
||||
adapter,
|
||||
routing: contract.routing,
|
||||
body: {
|
||||
...body,
|
||||
approvalRequestId: body.approvalRequestId || body.modelRouteApprovalId || job.model_route_approval_id || contract?.preflight?.modelRouteApproval?.id
|
||||
},
|
||||
jobId,
|
||||
contract
|
||||
});
|
||||
assertExternalAllowed(context, adapter, body, approvalGrant);
|
||||
assertRoutingApproval(context, contract.routing, adapter, body, approvalGrant);
|
||||
visualAssetGovernanceGate(context, { ...contract?.job, kind: job.kind }, contract?.shot);
|
||||
if (body.approveExternal && adapter.costMode !== "local") {
|
||||
addAudit({ context, action: "generation_job.external_approved", targetType: "generation_job", targetId: jobId, metadata: { adapter: adapter.id, costMode: adapter.costMode, approvalRequired: adapter.approvalRequired } });
|
||||
}
|
||||
@@ -519,9 +1332,9 @@ export async function executeGenerationJob(context, jobId, body = {}) {
|
||||
const timestamp = now();
|
||||
const attemptNumber = Number(dbGet("SELECT MAX(attempt_number) AS attempt_number FROM job_attempts WHERE job_id = ?", [jobId])?.attempt_number || 0) + 1;
|
||||
const attemptId = makeId("attempt");
|
||||
consumeModelRouteApproval(context, approvalGrant, jobId);
|
||||
dbRun("UPDATE generation_jobs SET status = 'running', error_message = '', started_at = ?, finished_at = NULL, updated_at = ? WHERE id = ?", [timestamp, timestamp, jobId]);
|
||||
dbRun("INSERT INTO job_attempts(id, job_id, attempt_number, runner_id, status, started_at, created_at) VALUES (?, ?, ?, ?, 'running', ?, ?)", [attemptId, jobId, attemptNumber, adapter.id, timestamp, timestamp]);
|
||||
const contract = parseJson(job.request_json, {});
|
||||
try {
|
||||
const request = await buildAdapterRequest(adapter, job, contract, attemptNumber, timestamp);
|
||||
const response = await fetchWithTimeout(request.url.toString(), request.options);
|
||||
@@ -598,6 +1411,8 @@ export function updateModelConnector(context, modelId, body) {
|
||||
const requestedApproval = body.approvalRequired === undefined ? Boolean(current.approval_required) : Boolean(body.approvalRequired);
|
||||
if (costMode !== "local" && !requestedApproval) throw httpError(400, "external_connector_approval_required", "混合或外部连接器必须开启审批策略");
|
||||
const approvalRequired = costMode === "local" ? requestedApproval : true;
|
||||
if (costMode !== "local") requireEntitlement(context, "feature.external_cloud_connectors", 1);
|
||||
if (kind === "comfyui") requireEntitlement(context, "feature.comfyui_adapter", 1);
|
||||
const capabilities = Array.isArray(body.capability) ? body.capability : parseJson(current.capabilities_json, []);
|
||||
const currentProtocol = parseJson(current.protocol_json, {});
|
||||
const protocol = body.protocol && typeof body.protocol === "object" ? body.protocol : currentProtocol;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+317
-7
@@ -15,9 +15,11 @@ import {
|
||||
buildContextPayload,
|
||||
createAsset,
|
||||
createAssetVersion,
|
||||
ensureOrganizationEntitlements,
|
||||
getAsset,
|
||||
httpError,
|
||||
orgMembers,
|
||||
organizationEntitlements,
|
||||
organizationRolePolicies,
|
||||
updateOrganizationRolePolicy,
|
||||
previewInvitation,
|
||||
@@ -41,7 +43,11 @@ import {
|
||||
apiClients,
|
||||
identityCenter,
|
||||
publicIdentityProviders,
|
||||
createModelCatalogEntry,
|
||||
createModelRoute,
|
||||
updateIdentityPolicy,
|
||||
updateModelCatalogEntry,
|
||||
updateModelRoute,
|
||||
saveIdentityProvider,
|
||||
probeIdentityProvider,
|
||||
createDirectorySync,
|
||||
@@ -59,8 +65,10 @@ import {
|
||||
scimPatchUser,
|
||||
scimDeleteUser,
|
||||
serviceHealth,
|
||||
scanAssetGovernance,
|
||||
updateAssetLock,
|
||||
updateAssetRights,
|
||||
listAssetGovernanceReviews,
|
||||
restoreAssetVersion,
|
||||
updateServiceHealth,
|
||||
usageSummary,
|
||||
@@ -82,7 +90,17 @@ import {
|
||||
workspaceMembers,
|
||||
listAuditEvents,
|
||||
getAuditEvent,
|
||||
exportAuditEvents
|
||||
exportAuditEvents,
|
||||
scopedModelCatalog,
|
||||
scopedModelRoutes,
|
||||
subscriptionPlanTemplates,
|
||||
createSubscriptionPlanTemplate,
|
||||
updateSubscriptionPlanTemplate,
|
||||
updateOrganizationEntitlement,
|
||||
requireEntitlement,
|
||||
listCommercialApprovalRequests,
|
||||
createCommercialApprovalRequest,
|
||||
decideCommercialApprovalRequest
|
||||
} from "./tenant.mjs";
|
||||
import { platformData, platformResearchMatrix } from "../src/platform/platformData.js";
|
||||
import { authMode, authenticate, cancelMfaSetup, changePassword, completeMfaChallenge, completeMfaEnrollment, createMfaChallenge, createMfaEnrollmentChallenge, createSession, disableMfa, enableMfa, listSecurityEvents, listUserDevices, listUserSessions, mfaRequiredForUser, mfaStatus, recordSecurityEvent, revokeAllUserSessions, revokeOtherUserSessions, revokeSession, revokeUserSession, safeUser, sessionIdentity, startMfaEnrollment, startMfaSetup, trustUserDevice, untrustUserDevice } from "./auth.mjs";
|
||||
@@ -99,6 +117,7 @@ import {
|
||||
createSeason,
|
||||
createShot,
|
||||
decideReview,
|
||||
getDeliveryClearance,
|
||||
importScript,
|
||||
listDeliveryBatches,
|
||||
listDeliveryChannels,
|
||||
@@ -112,6 +131,7 @@ import {
|
||||
runMediaQa,
|
||||
restoreShotVersion,
|
||||
rollbackDeliveryBatch,
|
||||
runDeliveryClearance,
|
||||
publishDeliveryRelease,
|
||||
decideDeliveryRelease,
|
||||
savePromptVersion,
|
||||
@@ -123,11 +143,16 @@ import {
|
||||
} from "./production.mjs";
|
||||
import {
|
||||
createGenerationJob,
|
||||
previewGenerationJob,
|
||||
executeGenerationJob,
|
||||
getGenerationJob,
|
||||
listGenerationJobs,
|
||||
decideModelRouteApproval,
|
||||
endpointInfo,
|
||||
listModelRouteApprovalRequests,
|
||||
previewModelRouteResolution,
|
||||
probeModelConnector,
|
||||
requestModelRouteApproval,
|
||||
updateModelConnector
|
||||
} from "./execution.mjs";
|
||||
import { reclaimStorage, requireStorageQuota, storageCleanupPreview, storageSummary } from "./storage.mjs";
|
||||
@@ -145,6 +170,23 @@ import { addTaskLink, createProjectTask, createTaskComment, getProjectTask, list
|
||||
import { searchPlatform } from "./search.mjs";
|
||||
import { consumeRateLimit, rateLimitHeaders, rateLimitIdentity } from "./rate-limit.mjs";
|
||||
import { createDeliveryAccessLink, listDeliveryAccessFeedback, listDeliveryAccessLinks, readPublicDeliveryFile, resolvePublicDeliveryPortal, revokeDeliveryAccessLink, submitPublicDeliveryFeedback } from "./delivery-portal.mjs";
|
||||
import {
|
||||
archiveKnowledgeDocument,
|
||||
createKnowledgeContextPack,
|
||||
getKnowledgeContextPack,
|
||||
getKnowledgeDocument,
|
||||
importKnowledgeDocument,
|
||||
listKnowledgeDocumentVersions,
|
||||
listKnowledgeContextPacks,
|
||||
listKnowledgeDocuments,
|
||||
materializeKnowledgeContextPack,
|
||||
materializeKnowledgeDocument,
|
||||
restoreKnowledgeDocument,
|
||||
restoreKnowledgeDocumentVersion,
|
||||
reviewKnowledgeDocument,
|
||||
updateKnowledgeDocument,
|
||||
searchKnowledge
|
||||
} from "./knowledge.mjs";
|
||||
|
||||
const port = Number(process.env.AI_DRAMA_API_PORT || 8787);
|
||||
const root = resolve(import.meta.dirname, "..");
|
||||
@@ -667,6 +709,7 @@ function createOrganization(context, body) {
|
||||
dbRun("INSERT INTO quota_allocations(id, organization_id, workspace_id, metric, limit_value, used_value, unit, period_start, period_end, created_at, updated_at) VALUES (?, ?, ?, 'clip', 2400, 0, 'clips', datetime('now', 'start of month'), datetime('now', 'start of month', '+1 month', '-1 second'), ?, ?)", [createId("quota"), organizationId, workspaceId, timestamp, timestamp]);
|
||||
dbRun("INSERT INTO audit_logs(id, organization_id, workspace_id, actor_user_id, action, target_type, target_id, result, metadata_json, created_at) VALUES (?, ?, ?, ?, 'organization.created', 'organization', ?, 'ok', ?, ?)", [createId("aud"), organizationId, workspaceId, context.user.id, organizationId, JSON.stringify({ createdFrom: context.organization.id }), timestamp]);
|
||||
});
|
||||
ensureOrganizationEntitlements(organizationId);
|
||||
return dbGet("SELECT * FROM organizations WHERE id = ?", [organizationId]);
|
||||
}
|
||||
|
||||
@@ -688,6 +731,7 @@ function updateOrganization(context, organizationId, body) {
|
||||
|
||||
function createWorkspace(context, body) {
|
||||
requirePermission(context, "workspace:create");
|
||||
requireEntitlement(context, "limit.workspaces", 1);
|
||||
const name = String(body.name || "").trim();
|
||||
if (!name) throw httpError(400, "name_required", "工作区名称不能为空");
|
||||
const id = body.id || createId("ws");
|
||||
@@ -742,6 +786,7 @@ function updateWorkspace(context, workspaceId, body) {
|
||||
|
||||
function createProject(context, body) {
|
||||
requirePermission(context, "project:create");
|
||||
requireEntitlement(context, "limit.projects", 1);
|
||||
const name = String(body.name || "").trim();
|
||||
if (!name) throw httpError(400, "name_required", "项目名称不能为空");
|
||||
const id = body.id || createId("project");
|
||||
@@ -836,7 +881,13 @@ async function uploadAssetVersion(context, assetId, body) {
|
||||
contentSha256,
|
||||
mimeType: body.mimeType || "application/octet-stream",
|
||||
rightsStatus: body.rightsStatus || "needs-evidence",
|
||||
versionNote: body.versionNote || "本地文件版本上传"
|
||||
versionNote: body.versionNote || "本地文件版本上传",
|
||||
provenance: body.provenance,
|
||||
risk: body.risk,
|
||||
tags: body.tags,
|
||||
licenseScope: body.licenseScope,
|
||||
expiresAt: body.expiresAt,
|
||||
metadata: body.metadata
|
||||
});
|
||||
}
|
||||
|
||||
@@ -868,7 +919,7 @@ function mimeForPath(pathname) {
|
||||
}
|
||||
|
||||
async function assetContent(context, assetId) {
|
||||
if (!hasPermission(context, "asset:edit") && !hasPermission(context, "voice:edit")) {
|
||||
if (!hasPermission(context, "asset:edit") && !hasPermission(context, "voice:edit") && !hasPermission(context, "compliance:manage")) {
|
||||
throw httpError(403, "permission_denied", "当前角色没有资产预览权限");
|
||||
}
|
||||
const asset = getAsset(context, assetId);
|
||||
@@ -894,7 +945,9 @@ async function assetContent(context, assetId) {
|
||||
}
|
||||
|
||||
async function verifyAssetContent(context, assetId) {
|
||||
requirePermission(context, "asset:edit");
|
||||
if (!hasPermission(context, "asset:edit") && !hasPermission(context, "compliance:manage")) {
|
||||
requirePermission(context, "asset:edit");
|
||||
}
|
||||
const asset = getAsset(context, assetId);
|
||||
if (!asset) throw httpError(404, "asset_not_found", "资产不存在或不属于当前项目");
|
||||
const version = asset.currentVersion;
|
||||
@@ -1054,11 +1107,13 @@ function updateProjectMember(context, projectId, userId, body) {
|
||||
|
||||
function registerModel(context, body) {
|
||||
requirePermission(context, "model:manage");
|
||||
requireEntitlement(context, "limit.model_connectors", 1);
|
||||
const id = body.id || createId("model");
|
||||
const timestamp = new Date().toISOString();
|
||||
const label = String(body.label || "未命名本地模型").trim();
|
||||
const endpoint = String(body.endpoint || "http://127.0.0.1:7860").trim();
|
||||
const costMode = String(body.costMode || "local").trim();
|
||||
const kind = String(body.kind || "http-json").trim();
|
||||
if (!label || !endpoint) throw httpError(400, "adapter_fields_required", "连接器名称和地址不能为空");
|
||||
if (!["local", "mixed", "cloud"].includes(costMode)) throw httpError(400, "cost_mode_invalid", "成本策略无效");
|
||||
const { local } = endpointInfo(endpoint);
|
||||
@@ -1066,9 +1121,11 @@ function registerModel(context, body) {
|
||||
const requestedApproval = Boolean(body.approvalRequired);
|
||||
if (costMode !== "local" && !requestedApproval) throw httpError(400, "external_connector_approval_required", "混合或外部连接器必须开启审批策略");
|
||||
const approvalRequired = costMode === "local" ? requestedApproval : true;
|
||||
if (costMode !== "local") requireEntitlement(context, "feature.external_cloud_connectors", 1);
|
||||
if (kind === "comfyui") requireEntitlement(context, "feature.comfyui_adapter", 1);
|
||||
const protocol = body.protocol && typeof body.protocol === "object" ? body.protocol : {};
|
||||
const authEnv = String(body.authEnv || protocol.authEnv || "").trim();
|
||||
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 (?, ?, ?, ?, ?, ?, ?, 'not-connected', ?, ?, ?, ?, ?, ?, ?)", [id, context.organization.id, context.workspace.id, label, body.kind || "http-json", JSON.stringify(body.capability || ["custom"]), endpoint, costMode, approvalRequired ? 1 : 0, JSON.stringify(protocol), authEnv, context.user.id, timestamp, timestamp]);
|
||||
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 (?, ?, ?, ?, ?, ?, ?, 'not-connected', ?, ?, ?, ?, ?, ?, ?)", [id, context.organization.id, context.workspace.id, label, kind, JSON.stringify(body.capability || ["custom"]), endpoint, costMode, approvalRequired ? 1 : 0, JSON.stringify(protocol), authEnv, context.user.id, timestamp, timestamp]);
|
||||
addAudit({ context, action: "model.registered", targetType: "model_connector", targetId: id, metadata: { label, endpoint, costMode, approvalRequired } });
|
||||
return parseModelRow(dbGet("SELECT * FROM model_connectors WHERE id = ?", [id]));
|
||||
}
|
||||
@@ -1077,6 +1134,7 @@ function systemConfigPayload() {
|
||||
return {
|
||||
settings: systemSettings(),
|
||||
featureFlags: featureFlags(),
|
||||
planTemplates: subscriptionPlanTemplates({ includeArchived: true }),
|
||||
notifications: notificationChannels(),
|
||||
notificationDeliveries: [],
|
||||
apiClients: apiClients(),
|
||||
@@ -1149,6 +1207,7 @@ function updateNotificationChannel(context, body) {
|
||||
|
||||
function createApiClient(context, body) {
|
||||
requirePermission(context, "api_client:manage");
|
||||
requireEntitlement(context, "limit.api_clients", 1);
|
||||
const timestamp = new Date().toISOString();
|
||||
const id = `client-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
|
||||
const issued = issueApiClientKey();
|
||||
@@ -1656,6 +1715,50 @@ createServer(async (req, res) => {
|
||||
return send(res, 200, organizationCommercial(context));
|
||||
}
|
||||
|
||||
const organizationEntitlementsMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/entitlements$/);
|
||||
if (req.method === "GET" && organizationEntitlementsMatch) {
|
||||
const organizationId = decodeURIComponent(organizationEntitlementsMatch[1]);
|
||||
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
|
||||
if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage") && !hasPermission(context, "quota:manage")) {
|
||||
throw httpError(403, "permission_denied", "当前角色没有查看组织套餐权益的权限");
|
||||
}
|
||||
return send(res, 200, organizationEntitlements(organizationId));
|
||||
}
|
||||
|
||||
const organizationEntitlementMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/entitlements\/([^/]+)$/);
|
||||
if (req.method === "PATCH" && organizationEntitlementMatch) {
|
||||
const organizationId = decodeURIComponent(organizationEntitlementMatch[1]);
|
||||
const entitlementKey = decodeURIComponent(organizationEntitlementMatch[2]);
|
||||
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
|
||||
return send(res, 200, updateOrganizationEntitlement(context, organizationId, entitlementKey, await readBody(req)));
|
||||
}
|
||||
|
||||
const organizationCommercialApprovalsMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/commercial-approvals$/);
|
||||
if (req.method === "GET" && organizationCommercialApprovalsMatch) {
|
||||
const organizationId = decodeURIComponent(organizationCommercialApprovalsMatch[1]);
|
||||
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
|
||||
return send(res, 200, listCommercialApprovalRequests(context, organizationId, {
|
||||
status: url.searchParams.get("status") || "",
|
||||
type: url.searchParams.get("type") || "",
|
||||
mine: url.searchParams.get("mine") === "1",
|
||||
limit: url.searchParams.get("limit") || 80
|
||||
}));
|
||||
}
|
||||
|
||||
if (req.method === "POST" && organizationCommercialApprovalsMatch) {
|
||||
const organizationId = decodeURIComponent(organizationCommercialApprovalsMatch[1]);
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 201, createCommercialApprovalRequest(context, organizationId, await readBody(req)));
|
||||
}
|
||||
|
||||
const organizationCommercialApprovalDecisionMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/commercial-approvals\/([^/]+)\/decision$/);
|
||||
if (req.method === "POST" && organizationCommercialApprovalDecisionMatch) {
|
||||
const organizationId = decodeURIComponent(organizationCommercialApprovalDecisionMatch[1]);
|
||||
const approvalId = decodeURIComponent(organizationCommercialApprovalDecisionMatch[2]);
|
||||
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
|
||||
return send(res, 200, decideCommercialApprovalRequest(context, organizationId, approvalId, await readBody(req)));
|
||||
}
|
||||
|
||||
const organizationCommercialExportMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/commercial\/export$/);
|
||||
if (req.method === "GET" && organizationCommercialExportMatch) {
|
||||
const organizationId = decodeURIComponent(organizationCommercialExportMatch[1]);
|
||||
@@ -2045,7 +2148,7 @@ createServer(async (req, res) => {
|
||||
|
||||
if (req.method === "GET" && pathname === "/api/assets") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
if (!hasPermission(context, "asset:edit") && !hasPermission(context, "voice:edit")) {
|
||||
if (!hasPermission(context, "asset:edit") && !hasPermission(context, "voice:edit") && !hasPermission(context, "compliance:manage")) {
|
||||
throw httpError(403, "permission_denied", "当前角色没有资产库访问权限");
|
||||
}
|
||||
return send(res, 200, { assets: scopedAssets(context) });
|
||||
@@ -2065,7 +2168,7 @@ createServer(async (req, res) => {
|
||||
const assetMatch = pathname.match(/^\/api\/assets\/([^/]+)$/);
|
||||
if (req.method === "GET" && assetMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
if (!hasPermission(context, "asset:edit") && !hasPermission(context, "voice:edit")) {
|
||||
if (!hasPermission(context, "asset:edit") && !hasPermission(context, "voice:edit") && !hasPermission(context, "compliance:manage")) {
|
||||
throw httpError(403, "permission_denied", "当前角色没有资产库访问权限");
|
||||
}
|
||||
const asset = getAsset(context, decodeURIComponent(assetMatch[1]));
|
||||
@@ -2098,6 +2201,18 @@ createServer(async (req, res) => {
|
||||
return send(res, 200, { verification: await verifyAssetContent(context, decodeURIComponent(assetVerifyMatch[1])) });
|
||||
}
|
||||
|
||||
const assetGovernanceMatch = pathname.match(/^\/api\/assets\/([^/]+)\/governance$/);
|
||||
if (req.method === "GET" && assetGovernanceMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 200, listAssetGovernanceReviews(context, decodeURIComponent(assetGovernanceMatch[1])));
|
||||
}
|
||||
|
||||
const assetGovernanceScanMatch = pathname.match(/^\/api\/assets\/([^/]+)\/governance\/scan$/);
|
||||
if (req.method === "POST" && assetGovernanceScanMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 200, scanAssetGovernance(context, decodeURIComponent(assetGovernanceScanMatch[1]), await readBody(req)));
|
||||
}
|
||||
|
||||
const assetVersionRestoreMatch = pathname.match(/^\/api\/assets\/([^/]+)\/versions\/([^/]+)\/restore$/);
|
||||
if (req.method === "POST" && assetVersionRestoreMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
@@ -2144,6 +2259,20 @@ createServer(async (req, res) => {
|
||||
return send(res, 200, { models: scopedModels(context), runners: runtimeRunners() });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && pathname === "/api/platform/model-catalog") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
if (context.apiClient) requireApiScope(context, "models:read");
|
||||
else requirePermission(context, "model:manage");
|
||||
return send(res, 200, { catalog: scopedModelCatalog(context), connectors: scopedModels(context) });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && pathname === "/api/platform/model-routes") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
if (context.apiClient) requireApiScope(context, "models:read");
|
||||
else requirePermission(context, "model:manage");
|
||||
return send(res, 200, { routes: scopedModelRoutes(context), catalog: scopedModelCatalog(context) });
|
||||
}
|
||||
|
||||
const modelConnectorMatch = pathname.match(/^\/api\/platform\/models\/([^/]+)$/);
|
||||
if (req.method === "PATCH" && modelConnectorMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
@@ -2396,6 +2525,23 @@ createServer(async (req, res) => {
|
||||
return send(res, 200, { featureFlags: featureFlags() });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && pathname === "/api/system/plan-templates") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
requirePermission(context, "system:settings:view");
|
||||
return send(res, 200, { planTemplates: subscriptionPlanTemplates({ includeArchived: true }) });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/system/plan-templates") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 201, createSubscriptionPlanTemplate(context, await readBody(req)));
|
||||
}
|
||||
|
||||
const planTemplateMatch = pathname.match(/^\/api\/system\/plan-templates\/([^/]+)$/);
|
||||
if (req.method === "PATCH" && planTemplateMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 200, updateSubscriptionPlanTemplate(context, decodeURIComponent(planTemplateMatch[1]), await readBody(req)));
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/system/feature-flags") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 200, { featureFlags: updateFeatureFlag(context, await readBody(req)) });
|
||||
@@ -2457,6 +2603,154 @@ createServer(async (req, res) => {
|
||||
return send(res, 201, { model, models: scopedModels(context) });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/platform/model-catalog") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
if (context.apiClient) requireApiScope(context, "models:write");
|
||||
const entry = createModelCatalogEntry(context, await readBody(req));
|
||||
return send(res, 201, { entry, catalog: scopedModelCatalog(context) });
|
||||
}
|
||||
|
||||
const modelCatalogMatch = pathname.match(/^\/api\/platform\/model-catalog\/([^/]+)$/);
|
||||
if (req.method === "PATCH" && modelCatalogMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
if (context.apiClient) requireApiScope(context, "models:write");
|
||||
const entry = updateModelCatalogEntry(context, decodeURIComponent(modelCatalogMatch[1]), await readBody(req));
|
||||
return send(res, 200, { entry, catalog: scopedModelCatalog(context) });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/platform/model-routes") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
if (context.apiClient) requireApiScope(context, "models:write");
|
||||
const route = createModelRoute(context, await readBody(req));
|
||||
return send(res, 201, { route, routes: scopedModelRoutes(context) });
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/platform/model-routes/resolve") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
if (context.apiClient) requireApiScope(context, "models:read");
|
||||
return send(res, 200, { resolution: previewModelRouteResolution(context, await readBody(req)) });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && pathname === "/api/platform/model-route-approvals") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
if (context.apiClient) requireApiScope(context, "models:read");
|
||||
return send(res, 200, listModelRouteApprovalRequests(context, {
|
||||
status: url.searchParams.get("status") || "",
|
||||
mine: url.searchParams.get("mine") === "1",
|
||||
limit: url.searchParams.get("limit") || 80
|
||||
}));
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/platform/model-route-approvals") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
if (context.apiClient) requireApiScope(context, "models:write");
|
||||
return send(res, 201, requestModelRouteApproval(context, await readBody(req)));
|
||||
}
|
||||
|
||||
const modelRouteApprovalDecisionMatch = pathname.match(/^\/api\/platform\/model-route-approvals\/([^/]+)\/decision$/);
|
||||
if (req.method === "POST" && modelRouteApprovalDecisionMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
if (context.apiClient) requireApiScope(context, "models:write");
|
||||
return send(res, 200, decideModelRouteApproval(context, decodeURIComponent(modelRouteApprovalDecisionMatch[1]), await readBody(req)));
|
||||
}
|
||||
|
||||
const modelRouteMatch = pathname.match(/^\/api\/platform\/model-routes\/([^/]+)$/);
|
||||
if (req.method === "PATCH" && modelRouteMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
if (context.apiClient) requireApiScope(context, "models:write");
|
||||
const route = updateModelRoute(context, decodeURIComponent(modelRouteMatch[1]), await readBody(req));
|
||||
return send(res, 200, { route, routes: scopedModelRoutes(context) });
|
||||
}
|
||||
|
||||
if (req.method === "GET" && pathname === "/api/knowledge/library") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 200, listKnowledgeDocuments(context));
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/knowledge/library/import") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 201, importKnowledgeDocument(context, await readBody(req)));
|
||||
}
|
||||
|
||||
if (req.method === "GET" && pathname === "/api/knowledge/search") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 200, searchKnowledge(context, {
|
||||
query: url.searchParams.get("q") || url.searchParams.get("query") || "",
|
||||
sourceType: url.searchParams.get("sourceType") || url.searchParams.get("source_type") || "all",
|
||||
scopeMode: url.searchParams.get("scopeMode") || url.searchParams.get("scope_mode") || "all",
|
||||
limit: url.searchParams.get("limit") || 24
|
||||
}));
|
||||
}
|
||||
|
||||
if (req.method === "GET" && pathname === "/api/knowledge/context-packs") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 200, listKnowledgeContextPacks(context));
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/knowledge/context-packs") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 201, createKnowledgeContextPack(context, await readBody(req)));
|
||||
}
|
||||
|
||||
const knowledgePackMatch = pathname.match(/^\/api\/knowledge\/context-packs\/([^/]+)$/);
|
||||
if (req.method === "GET" && knowledgePackMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 200, getKnowledgeContextPack(context, decodeURIComponent(knowledgePackMatch[1])));
|
||||
}
|
||||
|
||||
const knowledgePackMaterializeMatch = pathname.match(/^\/api\/knowledge\/context-packs\/([^/]+)\/materialize$/);
|
||||
if (req.method === "POST" && knowledgePackMaterializeMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 201, materializeKnowledgeContextPack(context, decodeURIComponent(knowledgePackMaterializeMatch[1]), await readBody(req)));
|
||||
}
|
||||
|
||||
const knowledgeDocumentMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)$/);
|
||||
if (req.method === "GET" && knowledgeDocumentMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 200, getKnowledgeDocument(context, decodeURIComponent(knowledgeDocumentMatch[1])));
|
||||
}
|
||||
|
||||
if (req.method === "PATCH" && knowledgeDocumentMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 200, updateKnowledgeDocument(context, decodeURIComponent(knowledgeDocumentMatch[1]), await readBody(req)));
|
||||
}
|
||||
|
||||
const knowledgeReviewMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/review$/);
|
||||
if (req.method === "POST" && knowledgeReviewMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 201, reviewKnowledgeDocument(context, decodeURIComponent(knowledgeReviewMatch[1]), await readBody(req)));
|
||||
}
|
||||
|
||||
const knowledgeArchiveMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/archive$/);
|
||||
if (req.method === "POST" && knowledgeArchiveMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 200, archiveKnowledgeDocument(context, decodeURIComponent(knowledgeArchiveMatch[1])));
|
||||
}
|
||||
|
||||
const knowledgeRestoreMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/restore$/);
|
||||
if (req.method === "POST" && knowledgeRestoreMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 200, restoreKnowledgeDocument(context, decodeURIComponent(knowledgeRestoreMatch[1])));
|
||||
}
|
||||
|
||||
const knowledgeVersionsMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/versions$/);
|
||||
if (req.method === "GET" && knowledgeVersionsMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 200, listKnowledgeDocumentVersions(context, decodeURIComponent(knowledgeVersionsMatch[1])));
|
||||
}
|
||||
|
||||
const knowledgeVersionRestoreMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/versions\/([^/]+)\/restore$/);
|
||||
if (req.method === "POST" && knowledgeVersionRestoreMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 200, restoreKnowledgeDocumentVersion(context, decodeURIComponent(knowledgeVersionRestoreMatch[1]), decodeURIComponent(knowledgeVersionRestoreMatch[2])));
|
||||
}
|
||||
|
||||
const knowledgeMaterializeMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/materialize$/);
|
||||
if (req.method === "POST" && knowledgeMaterializeMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 201, materializeKnowledgeDocument(context, decodeURIComponent(knowledgeMaterializeMatch[1]), await readBody(req)));
|
||||
}
|
||||
|
||||
if (req.method === "GET" && pathname === "/api/qa") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
requirePermission(context, "qa:review");
|
||||
@@ -2581,6 +2875,16 @@ createServer(async (req, res) => {
|
||||
return send(res, 201, createDelivery(context, await readBody(req)));
|
||||
}
|
||||
|
||||
const deliveryClearanceMatch = pathname.match(/^\/api\/production\/deliveries\/([^/]+)\/clearance$/);
|
||||
if (req.method === "GET" && deliveryClearanceMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 200, getDeliveryClearance(context, decodeURIComponent(deliveryClearanceMatch[1])));
|
||||
}
|
||||
if (req.method === "POST" && deliveryClearanceMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
return send(res, 201, runDeliveryClearance(context, decodeURIComponent(deliveryClearanceMatch[1]), await readBody(req)));
|
||||
}
|
||||
|
||||
const deliveryReleasesMatch = pathname.match(/^\/api\/production\/deliveries\/([^/]+)\/releases$/);
|
||||
if (req.method === "GET" && deliveryReleasesMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
@@ -2659,6 +2963,12 @@ createServer(async (req, res) => {
|
||||
return send(res, 201, await createGenerationJob(context, await readBody(req)));
|
||||
}
|
||||
|
||||
if (req.method === "POST" && pathname === "/api/jobs/preview") {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
if (context.apiClient) requireApiScope(context, "jobs:read");
|
||||
return send(res, 200, previewGenerationJob(context, await readBody(req)));
|
||||
}
|
||||
|
||||
const jobDetailMatch = pathname.match(/^\/api\/jobs\/([^/]+)$/);
|
||||
if (req.method === "GET" && jobDetailMatch) {
|
||||
const context = resolveContext(req.headers, url.searchParams);
|
||||
|
||||
+656
-62
@@ -1,8 +1,9 @@
|
||||
import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
|
||||
import { addAudit, addUsage, hasPermission, httpError, requirePermission, requireProjectWritable } from "./tenant.mjs";
|
||||
import { addAudit, addUsage, hasPermission, httpError, requireEntitlement, requirePermission, requireProjectWritable } from "./tenant.mjs";
|
||||
import { inspectMediaForProject } from "./media-qa.mjs";
|
||||
import { latestArtifactForShot, listProjectArtifacts, syncProjectJobArtifacts } from "./media-artifacts.mjs";
|
||||
import { dispatchNotificationEvent } from "./notifications.mjs";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
@@ -301,7 +302,8 @@ function scriptDocuments(context, episodeId = null) {
|
||||
const params = episodeId ? [project.id, episodeId] : [project.id];
|
||||
return dbAll(`SELECT * FROM script_documents WHERE project_id = ?${clause} ORDER BY version_number DESC`, params).map((row) => ({
|
||||
...row,
|
||||
analysis: parseJson(row.analysis_json, {})
|
||||
analysis: parseJson(row.analysis_json, {}),
|
||||
metadata: parseJson(row.metadata_json, {})
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -409,6 +411,596 @@ function safePathSegment(value, fallback = "item") {
|
||||
return normalized || fallback;
|
||||
}
|
||||
|
||||
function jsonHash(value) {
|
||||
return createHash("sha256").update(JSON.stringify(value)).digest("hex");
|
||||
}
|
||||
|
||||
function objectValue(value, fallback = {}) {
|
||||
return value && typeof value === "object" && !Array.isArray(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function uniqueStrings(values) {
|
||||
return [...new Set((values || []).map((item) => String(item || "").trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function listFromJson(value) {
|
||||
const parsed = parseJson(value, []);
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
}
|
||||
|
||||
function clearanceEvidenceRef(metadata = {}, provenance = {}) {
|
||||
const rightsEvidence = objectValue(metadata.rightsEvidence);
|
||||
return String(
|
||||
provenance.evidenceRef
|
||||
|| provenance.sourceRef
|
||||
|| rightsEvidence.reference
|
||||
|| rightsEvidence.consentRef
|
||||
|| rightsEvidence.contractRef
|
||||
|| rightsEvidence.licenseRef
|
||||
|| rightsEvidence.evidenceRef
|
||||
|| metadata.consentRef
|
||||
|| metadata.licenseRef
|
||||
|| ""
|
||||
).trim();
|
||||
}
|
||||
|
||||
function isDateInPast(value) {
|
||||
const timestamp = Date.parse(value || "");
|
||||
return Number.isFinite(timestamp) && timestamp <= Date.now();
|
||||
}
|
||||
|
||||
function isDateSoon(value, days = 30) {
|
||||
const timestamp = Date.parse(value || "");
|
||||
if (!Number.isFinite(timestamp)) return false;
|
||||
return timestamp > Date.now() && timestamp <= Date.now() + days * 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
function clearanceStatus(blockers, reviewItems) {
|
||||
if (blockers.length) return "blocked";
|
||||
if (reviewItems.length) return "review";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
function dedupeBlockers(blockers) {
|
||||
const seen = new Set();
|
||||
return blockers.filter((blocker) => {
|
||||
const key = [blocker.type, blocker.id, blocker.assetId, blocker.documentId, blocker.packId, blocker.voiceId, blocker.shotId, blocker.status, blocker.reason].filter(Boolean).join("|");
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function deliveryBatchItems(batchId) {
|
||||
if (!batchId) return [];
|
||||
return dbAll(
|
||||
`SELECT dbi.*, s.title AS shot_title, s.shot_number
|
||||
FROM delivery_batch_items dbi
|
||||
LEFT JOIN shots s ON s.id = dbi.shot_id
|
||||
WHERE dbi.batch_id = ?
|
||||
ORDER BY dbi.sequence_number`,
|
||||
[batchId]
|
||||
).map((item) => ({ ...item, metadata: parseJson(item.metadata_json, {}) }));
|
||||
}
|
||||
|
||||
function activeBatchForDelivery(context, delivery) {
|
||||
const project = requireProject(context);
|
||||
if (!delivery.active_batch_id) return null;
|
||||
return dbGet(
|
||||
`SELECT * FROM delivery_batches
|
||||
WHERE id = ? AND delivery_id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?`,
|
||||
[delivery.active_batch_id, delivery.id, context.organization.id, context.workspace.id, project.id]
|
||||
);
|
||||
}
|
||||
|
||||
function mediaClearanceSection(activeBatch, items) {
|
||||
const blockers = [];
|
||||
const reviewItems = [];
|
||||
if (!activeBatch) {
|
||||
blockers.push({ type: "delivery_batch", status: "missing", reason: "交付版本没有当前生效批次" });
|
||||
return { items: [], blockers, reviewItems };
|
||||
}
|
||||
if (activeBatch.status !== "active") blockers.push({ type: "delivery_batch", status: activeBatch.status, reason: "当前交付批次不是 active" });
|
||||
const batchResult = parseJson(activeBatch.result_json, {});
|
||||
if (Array.isArray(batchResult.blockers) && batchResult.blockers.length) blockers.push({ type: "delivery_batch", status: "blocked", reason: "批次创建时已登记媒体阻断项", items: batchResult.blockers });
|
||||
const manifest = safeStoragePath(activeBatch.manifest_path);
|
||||
if (!manifest || !existsSync(manifest.absolute)) blockers.push({ type: "manifest", status: "missing", reason: "当前批次 Manifest 文件不存在" });
|
||||
if (!items.length) blockers.push({ type: "delivery_batch_items", status: "missing", reason: "当前批次没有镜头条目" });
|
||||
const mediaItems = items.map((item) => {
|
||||
const source = safeStoragePath(item.source_path);
|
||||
const actualLastFrame = safeStoragePath(item.actual_last_frame_path);
|
||||
const itemBlockers = [];
|
||||
if (!source || !existsSync(source.absolute)) itemBlockers.push("视频源文件不存在");
|
||||
if (!item.source_sha256) itemBlockers.push("视频源缺少 SHA-256");
|
||||
if (!actualLastFrame || !existsSync(actualLastFrame.absolute)) itemBlockers.push("实际末帧证据不存在");
|
||||
for (const reason of itemBlockers) blockers.push({ type: reason.includes("末帧") ? "actual-last-frame" : "media", shotId: item.shot_id, status: "blocked", reason });
|
||||
return {
|
||||
id: item.id,
|
||||
shotId: item.shot_id,
|
||||
shotTitle: item.shot_title || "",
|
||||
sequenceNumber: Number(item.sequence_number || 0),
|
||||
sourcePath: item.source_path || "",
|
||||
sourceSha256: item.source_sha256 || "",
|
||||
actualLastFramePath: item.actual_last_frame_path || "",
|
||||
artifactStatus: item.metadata.artifactStatus || "",
|
||||
status: itemBlockers.length ? "blocked" : "pass",
|
||||
blockers: itemBlockers
|
||||
};
|
||||
});
|
||||
return { items: mediaItems, blockers, reviewItems };
|
||||
}
|
||||
|
||||
function boundAssetsForShotIds(shotIds) {
|
||||
const ids = uniqueStrings(shotIds);
|
||||
if (!ids.length) return [];
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
return dbAll(
|
||||
`SELECT a.id AS asset_id, a.kind, a.name, a.lock_status, a.current_version_id,
|
||||
av.id AS version_id, av.version_number, av.storage_path, av.file_name, av.mime_type,
|
||||
av.file_size, av.content_sha256, av.rights_status, av.provenance_json, av.risk_json,
|
||||
av.tags_json, av.license_scope, av.expires_at, av.metadata_json,
|
||||
GROUP_CONCAT(DISTINCT ab.shot_id) AS shot_ids,
|
||||
GROUP_CONCAT(DISTINCT ab.usage_role) AS usage_roles
|
||||
FROM asset_bindings ab
|
||||
JOIN assets a ON a.id = ab.asset_id
|
||||
LEFT JOIN asset_versions av ON av.id = a.current_version_id
|
||||
WHERE ab.shot_id IN (${placeholders})
|
||||
GROUP BY a.id
|
||||
ORDER BY CASE a.kind WHEN 'character' THEN 1 WHEN 'location' THEN 2 WHEN 'prop' THEN 3 WHEN 'voice' THEN 4 ELSE 5 END, a.name`,
|
||||
ids
|
||||
);
|
||||
}
|
||||
|
||||
function voiceAssetsForLines(context, voiceLines) {
|
||||
const voiceIds = uniqueStrings(voiceLines.map((line) => line.voice_id));
|
||||
if (!voiceIds.length) return [];
|
||||
const project = requireProject(context);
|
||||
const placeholders = voiceIds.map(() => "?").join(",");
|
||||
return dbAll(
|
||||
`SELECT a.id AS asset_id, a.kind, a.name, a.lock_status, a.current_version_id,
|
||||
av.id AS version_id, av.version_number, av.storage_path, av.file_name, av.mime_type,
|
||||
av.file_size, av.content_sha256, av.rights_status, av.provenance_json, av.risk_json,
|
||||
av.tags_json, av.license_scope, av.expires_at, av.metadata_json
|
||||
FROM assets a
|
||||
LEFT JOIN asset_versions av ON av.id = a.current_version_id
|
||||
WHERE a.project_id = ? AND a.kind = 'voice'
|
||||
AND (${voiceIds.map(() => "av.metadata_json LIKE ?").join(" OR ")})`,
|
||||
[project.id, ...voiceIds.map((voiceId) => `%"voiceId":"${voiceId}"%`)]
|
||||
);
|
||||
}
|
||||
|
||||
function assetClearanceSection(context, shotIds) {
|
||||
const blockers = [];
|
||||
const reviewItems = [];
|
||||
const rows = boundAssetsForShotIds(shotIds);
|
||||
const assets = rows.map((row) => {
|
||||
const metadata = parseJson(row.metadata_json, {});
|
||||
const provenance = parseJson(row.provenance_json, {});
|
||||
const risk = parseJson(row.risk_json, {});
|
||||
const shotRefs = uniqueStrings(String(row.shot_ids || "").split(","));
|
||||
const usageRoles = uniqueStrings(String(row.usage_roles || "").split(","));
|
||||
const evidenceRef = clearanceEvidenceRef(metadata, provenance);
|
||||
const itemBlockers = [];
|
||||
const itemReviews = [];
|
||||
if (!row.version_id) itemBlockers.push("资产没有当前版本");
|
||||
if (row.lock_status !== "locked") itemBlockers.push("资产未锁定 continuity lock");
|
||||
if ((row.rights_status || "needs-evidence") !== "approved") itemBlockers.push(`资产授权状态为 ${row.rights_status || "needs-evidence"}`);
|
||||
if ((risk.status || "unscanned") === "blocked") itemBlockers.push("资产风险扫描为 blocked");
|
||||
if (row.expires_at && isDateInPast(row.expires_at)) itemBlockers.push("资产授权已过期");
|
||||
if (row.rights_status === "approved" && !evidenceRef) itemBlockers.push("资产缺少授权证据引用");
|
||||
if (!row.license_scope) itemReviews.push("资产缺少授权范围说明");
|
||||
if (!row.content_sha256) itemReviews.push("资产源文件缺少 SHA-256,可在正式素材入库时补齐");
|
||||
if (!risk.status || risk.status === "review" || risk.status === "unscanned") itemReviews.push("资产风险扫描需要复核");
|
||||
if (row.expires_at && isDateSoon(row.expires_at)) itemReviews.push("资产授权 30 天内到期");
|
||||
for (const reason of itemBlockers) blockers.push({ type: "asset", assetId: row.asset_id, shotIds: shotRefs, status: "blocked", reason });
|
||||
for (const reason of itemReviews) reviewItems.push({ type: "asset", assetId: row.asset_id, shotIds: shotRefs, status: "review", reason });
|
||||
return {
|
||||
assetId: row.asset_id,
|
||||
name: row.name,
|
||||
kind: row.kind,
|
||||
versionId: row.version_id || "",
|
||||
versionNumber: Number(row.version_number || 0),
|
||||
lockStatus: row.lock_status || "",
|
||||
rightsStatus: row.rights_status || "needs-evidence",
|
||||
riskStatus: risk.status || "unscanned",
|
||||
licenseScope: row.license_scope || "",
|
||||
expiresAt: row.expires_at || "",
|
||||
evidenceRef,
|
||||
contentSha256: row.content_sha256 || "",
|
||||
storagePath: row.storage_path || "",
|
||||
shotIds: shotRefs,
|
||||
usageRoles,
|
||||
status: itemBlockers.length ? "blocked" : itemReviews.length ? "review" : "pass",
|
||||
blockers: itemBlockers,
|
||||
reviewItems: itemReviews
|
||||
};
|
||||
});
|
||||
return { assets, blockers, reviewItems };
|
||||
}
|
||||
|
||||
function voiceClearanceSection(context, shotIds) {
|
||||
const blockers = [];
|
||||
const reviewItems = [];
|
||||
const ids = uniqueStrings(shotIds);
|
||||
if (!ids.length) return { voices: [], blockers, reviewItems };
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
const voiceLines = dbAll(
|
||||
`SELECT vl.*, s.title AS shot_title
|
||||
FROM voice_lines vl
|
||||
JOIN shots s ON s.id = vl.shot_id
|
||||
WHERE vl.shot_id IN (${placeholders})
|
||||
ORDER BY vl.shot_id, vl.line_number`,
|
||||
ids
|
||||
);
|
||||
const assetRows = voiceAssetsForLines(context, voiceLines);
|
||||
const assetsByVoiceId = new Map();
|
||||
for (const asset of assetRows) {
|
||||
const metadata = parseJson(asset.metadata_json, {});
|
||||
if (metadata.voiceId) assetsByVoiceId.set(String(metadata.voiceId), asset);
|
||||
}
|
||||
const grouped = new Map();
|
||||
for (const line of voiceLines) {
|
||||
const key = line.voice_id || `missing-${line.id}`;
|
||||
if (!grouped.has(key)) grouped.set(key, []);
|
||||
grouped.get(key).push(line);
|
||||
}
|
||||
const voices = [...grouped.entries()].map(([voiceId, lines]) => {
|
||||
const asset = assetsByVoiceId.get(voiceId) || null;
|
||||
const metadata = parseJson(asset?.metadata_json, {});
|
||||
const provenance = parseJson(asset?.provenance_json, {});
|
||||
const risk = parseJson(asset?.risk_json, {});
|
||||
const evidenceRef = clearanceEvidenceRef(metadata, provenance);
|
||||
const itemBlockers = [];
|
||||
const itemReviews = [];
|
||||
if (!voiceId || voiceId.startsWith("missing-")) itemBlockers.push("对白缺少固定 voiceId");
|
||||
if (lines.some((line) => !line.audio_path)) itemBlockers.push("对白缺少已登记音频文件");
|
||||
if (!asset) itemBlockers.push("固定声线未登记为 voice 资产");
|
||||
if (asset && asset.lock_status !== "locked") itemBlockers.push("voice 资产未锁定");
|
||||
if (asset && (asset.rights_status || "needs-evidence") !== "approved") itemBlockers.push(`voice 资产授权状态为 ${asset.rights_status || "needs-evidence"}`);
|
||||
if (asset && risk.status === "blocked") itemBlockers.push("voice 资产风险扫描为 blocked");
|
||||
if (asset?.expires_at && isDateInPast(asset.expires_at)) itemBlockers.push("voice 授权已过期");
|
||||
if (asset && !evidenceRef) itemBlockers.push("voice 资产缺少同意书/授权证据引用");
|
||||
if (asset && !asset.license_scope) itemReviews.push("voice 资产缺少授权范围说明");
|
||||
if (asset && !asset.content_sha256) itemReviews.push("voice 参考音频缺少 SHA-256,可在正式素材入库时补齐");
|
||||
for (const reason of itemBlockers) blockers.push({ type: "voice", voiceId, assetId: asset?.asset_id || "", shotIds: uniqueStrings(lines.map((line) => line.shot_id)), status: "blocked", reason });
|
||||
for (const reason of itemReviews) reviewItems.push({ type: "voice", voiceId, assetId: asset?.asset_id || "", shotIds: uniqueStrings(lines.map((line) => line.shot_id)), status: "review", reason });
|
||||
return {
|
||||
voiceId,
|
||||
assetId: asset?.asset_id || "",
|
||||
name: asset?.name || "",
|
||||
rightsStatus: asset?.rights_status || "missing",
|
||||
riskStatus: risk.status || "unscanned",
|
||||
lockStatus: asset?.lock_status || "missing",
|
||||
licenseScope: asset?.license_scope || "",
|
||||
evidenceRef,
|
||||
lineCount: lines.length,
|
||||
shotIds: uniqueStrings(lines.map((line) => line.shot_id)),
|
||||
status: itemBlockers.length ? "blocked" : itemReviews.length ? "review" : "pass",
|
||||
blockers: itemBlockers,
|
||||
reviewItems: itemReviews
|
||||
};
|
||||
});
|
||||
return { voices, blockers, reviewItems };
|
||||
}
|
||||
|
||||
function scriptSourceIdsFromMetadata(metadata) {
|
||||
const ids = [];
|
||||
const packIds = [];
|
||||
const chunkIds = [];
|
||||
if (metadata.knowledgeDocumentId) ids.push(metadata.knowledgeDocumentId);
|
||||
if (Array.isArray(metadata.knowledgeDocumentIds)) ids.push(...metadata.knowledgeDocumentIds);
|
||||
if (metadata.knowledgePackId) packIds.push(metadata.knowledgePackId);
|
||||
if (Array.isArray(metadata.knowledgePackIds)) packIds.push(...metadata.knowledgePackIds);
|
||||
if (Array.isArray(metadata.knowledgeChunkIds)) chunkIds.push(...metadata.knowledgeChunkIds);
|
||||
if (Array.isArray(metadata.knowledgeCitations)) {
|
||||
for (const citation of metadata.knowledgeCitations) {
|
||||
if (citation?.documentId) ids.push(citation.documentId);
|
||||
if (citation?.chunkId) chunkIds.push(citation.chunkId);
|
||||
}
|
||||
}
|
||||
return { documentIds: uniqueStrings(ids), packIds: uniqueStrings(packIds), chunkIds: uniqueStrings(chunkIds) };
|
||||
}
|
||||
|
||||
function auditMappedKnowledgeSources(projectId, scriptIds) {
|
||||
if (!scriptIds.length) return { documentIds: [], packIds: [] };
|
||||
const rows = dbAll(
|
||||
`SELECT action, target_id, metadata_json
|
||||
FROM audit_logs
|
||||
WHERE project_id = ? AND action IN ('knowledge.document.materialized', 'knowledge.context_pack.materialized')
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 300`,
|
||||
[projectId]
|
||||
);
|
||||
const documentIds = [];
|
||||
const packIds = [];
|
||||
const scriptSet = new Set(scriptIds);
|
||||
for (const row of rows) {
|
||||
const metadata = parseJson(row.metadata_json, {});
|
||||
if (!scriptSet.has(String(metadata.scriptDocumentId || ""))) continue;
|
||||
if (row.action === "knowledge.document.materialized") documentIds.push(row.target_id);
|
||||
if (row.action === "knowledge.context_pack.materialized") packIds.push(row.target_id);
|
||||
}
|
||||
return { documentIds: uniqueStrings(documentIds), packIds: uniqueStrings(packIds) };
|
||||
}
|
||||
|
||||
function knowledgeDocumentsByIds(context, documentIds) {
|
||||
const ids = uniqueStrings(documentIds);
|
||||
if (!ids.length) return [];
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
return dbAll(
|
||||
`SELECT * FROM knowledge_documents
|
||||
WHERE id IN (${placeholders}) AND organization_id = ? AND workspace_id = ?
|
||||
AND (project_id IS NULL OR project_id = ?)`,
|
||||
[...ids, context.organization.id, context.workspace.id, context.project.id]
|
||||
);
|
||||
}
|
||||
|
||||
function knowledgePacksByIds(context, packIds) {
|
||||
const ids = uniqueStrings(packIds);
|
||||
if (!ids.length) return [];
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
return dbAll(
|
||||
`SELECT * FROM knowledge_context_packs
|
||||
WHERE id IN (${placeholders}) AND organization_id = ? AND workspace_id = ?
|
||||
AND (project_id IS NULL OR project_id = ?)`,
|
||||
[...ids, context.organization.id, context.workspace.id, context.project.id]
|
||||
);
|
||||
}
|
||||
|
||||
function knowledgeDocumentsFromChunks(context, chunkIds) {
|
||||
const ids = uniqueStrings(chunkIds);
|
||||
if (!ids.length) return [];
|
||||
const placeholders = ids.map(() => "?").join(",");
|
||||
return dbAll(
|
||||
`SELECT DISTINCT kd.*
|
||||
FROM knowledge_chunks kc
|
||||
JOIN knowledge_documents kd ON kd.id = kc.document_id
|
||||
WHERE kc.id IN (${placeholders}) AND kd.organization_id = ? AND kd.workspace_id = ?
|
||||
AND (kd.project_id IS NULL OR kd.project_id = ?)`,
|
||||
[...ids, context.organization.id, context.workspace.id, context.project.id]
|
||||
);
|
||||
}
|
||||
|
||||
function knowledgeClearanceSection(context) {
|
||||
const project = requireProject(context);
|
||||
const blockers = [];
|
||||
const reviewItems = [];
|
||||
const scripts = dbAll("SELECT id, title, source_type, metadata_json, content FROM script_documents WHERE project_id = ? ORDER BY version_number DESC", [project.id])
|
||||
.map((row) => ({ ...row, metadata: parseJson(row.metadata_json, {}) }));
|
||||
const knowledgeScripts = scripts.filter((script) => /^知识库/.test(script.source_type || "") || objectValue(script.metadata).origin?.startsWith?.("knowledge"));
|
||||
const scriptIds = knowledgeScripts.map((script) => script.id);
|
||||
const fromMetadata = knowledgeScripts.reduce((accumulator, script) => {
|
||||
const sources = scriptSourceIdsFromMetadata(script.metadata);
|
||||
accumulator.documentIds.push(...sources.documentIds);
|
||||
accumulator.packIds.push(...sources.packIds);
|
||||
accumulator.chunkIds.push(...sources.chunkIds);
|
||||
return accumulator;
|
||||
}, { documentIds: [], packIds: [], chunkIds: [] });
|
||||
const fromAudit = auditMappedKnowledgeSources(project.id, scriptIds);
|
||||
const packs = knowledgePacksByIds(context, [...fromMetadata.packIds, ...fromAudit.packIds]);
|
||||
const packChunkIds = packs.flatMap((pack) => listFromJson(pack.chunk_ids_json));
|
||||
const documents = knowledgeDocumentsByIds(context, [...fromMetadata.documentIds, ...fromAudit.documentIds]);
|
||||
const chunkDocuments = knowledgeDocumentsFromChunks(context, [...fromMetadata.chunkIds, ...packChunkIds]);
|
||||
const documentMap = new Map([...documents, ...chunkDocuments].map((document) => [document.id, document]));
|
||||
const knowledgeItems = [];
|
||||
for (const pack of packs) {
|
||||
const metadata = parseJson(pack.metadata_json, {});
|
||||
const governance = objectValue(metadata.governance);
|
||||
const citations = listFromJson(pack.citations_json);
|
||||
const chunks = listFromJson(pack.chunks_json);
|
||||
const itemBlockers = [];
|
||||
const itemReviews = [];
|
||||
if (pack.status !== "active") itemBlockers.push(`上下文包状态为 ${pack.status}`);
|
||||
if (governance.status === "blocked" || Number(governance.blockingCount || 0) > 0) itemBlockers.push("上下文包包含版权不可用或风险阻断片段");
|
||||
if (governance.status === "review" || Number(governance.reviewCount || 0) > 0) itemReviews.push("上下文包包含需要复核的素材片段");
|
||||
if (!citations.length && !chunks.length) itemReviews.push("上下文包缺少引用清单");
|
||||
for (const reason of itemBlockers) blockers.push({ type: "knowledge_pack", packId: pack.id, status: "blocked", reason });
|
||||
for (const reason of itemReviews) reviewItems.push({ type: "knowledge_pack", packId: pack.id, status: "review", reason });
|
||||
knowledgeItems.push({
|
||||
type: "context_pack",
|
||||
id: pack.id,
|
||||
title: pack.name,
|
||||
status: itemBlockers.length ? "blocked" : itemReviews.length ? "review" : "pass",
|
||||
rightsStatus: "derived",
|
||||
riskStatus: governance.status || "unscanned",
|
||||
citationCount: citations.length,
|
||||
chunkCount: listFromJson(pack.chunk_ids_json).length,
|
||||
blockers: itemBlockers,
|
||||
reviewItems: itemReviews
|
||||
});
|
||||
}
|
||||
for (const document of documentMap.values()) {
|
||||
const provenance = parseJson(document.provenance_json, {});
|
||||
const risk = parseJson(document.risk_json, {});
|
||||
const itemBlockers = [];
|
||||
const itemReviews = [];
|
||||
if (!["indexed", "active"].includes(document.status)) itemBlockers.push(`知识素材状态为 ${document.status}`);
|
||||
if ((document.rights_status || "needs-evidence") !== "approved") itemBlockers.push(`知识素材版权状态为 ${document.rights_status || "needs-evidence"}`);
|
||||
if (risk.status === "blocked") itemBlockers.push("知识素材风险扫描为 blocked");
|
||||
if (!clearanceEvidenceRef({}, provenance)) itemBlockers.push("知识素材缺少来源/授权证据引用");
|
||||
if (!risk.status || risk.status === "review" || risk.status === "unscanned") itemReviews.push("知识素材风险扫描需要复核");
|
||||
for (const reason of itemBlockers) blockers.push({ type: "knowledge_document", documentId: document.id, status: "blocked", reason });
|
||||
for (const reason of itemReviews) reviewItems.push({ type: "knowledge_document", documentId: document.id, status: "review", reason });
|
||||
knowledgeItems.push({
|
||||
type: "document",
|
||||
id: document.id,
|
||||
title: document.title,
|
||||
sourceType: document.source_type,
|
||||
rightsStatus: document.rights_status || "needs-evidence",
|
||||
riskStatus: risk.status || "unscanned",
|
||||
evidenceRef: clearanceEvidenceRef({}, provenance),
|
||||
status: itemBlockers.length ? "blocked" : itemReviews.length ? "review" : "pass",
|
||||
blockers: itemBlockers,
|
||||
reviewItems: itemReviews
|
||||
});
|
||||
}
|
||||
for (const script of knowledgeScripts) {
|
||||
const sources = scriptSourceIdsFromMetadata(script.metadata);
|
||||
const hasSource = sources.documentIds.length || sources.packIds.length || sources.chunkIds.length || fromAudit.documentIds.length || fromAudit.packIds.length;
|
||||
if (!hasSource) {
|
||||
const reason = "知识库来源剧本缺少可追溯的素材 ID,请重新从知识库物化或补来源元数据";
|
||||
blockers.push({ type: "script_source", scriptId: script.id, status: "blocked", reason });
|
||||
knowledgeItems.push({ type: "script_source", id: script.id, title: script.title, sourceType: script.source_type, status: "blocked", blockers: [reason], reviewItems: [] });
|
||||
}
|
||||
}
|
||||
return { scripts: knowledgeScripts.map((script) => ({ id: script.id, title: script.title, sourceType: script.source_type, origin: script.metadata.origin || "" })), materials: knowledgeItems, blockers, reviewItems };
|
||||
}
|
||||
|
||||
function qaClearanceSection(context) {
|
||||
const reviews = ensureReviews(context);
|
||||
const blockers = reviews
|
||||
.filter((review) => review.status !== "approved")
|
||||
.map((review) => ({ id: review.id, type: "review", lane: review.lane, status: review.status, shotId: review.shot_id, reason: `${REVIEW_LANES.find(([lane]) => lane === review.lane)?.[1] || review.lane} 未通过` }));
|
||||
return {
|
||||
reviews: reviews.map((review) => ({
|
||||
id: review.id,
|
||||
shotId: review.shot_id,
|
||||
lane: review.lane,
|
||||
status: review.status,
|
||||
decisionByName: review.decision_by_name || ""
|
||||
})),
|
||||
blockers,
|
||||
reviewItems: []
|
||||
};
|
||||
}
|
||||
|
||||
function jobClearanceSection(context) {
|
||||
const project = requireProject(context);
|
||||
const jobs = dbAll("SELECT id, kind, status, shot_id FROM generation_jobs WHERE project_id = ? AND status NOT IN ('completed', 'cancelled') ORDER BY created_at", [project.id]);
|
||||
return {
|
||||
jobs,
|
||||
blockers: jobs.map((job) => ({ id: job.id, type: "job", kind: job.kind, status: job.status, shotId: job.shot_id, reason: "仍有未完成或未取消的生成任务" })),
|
||||
reviewItems: []
|
||||
};
|
||||
}
|
||||
|
||||
function clearanceReportRow(row) {
|
||||
if (!row) return null;
|
||||
return {
|
||||
...row,
|
||||
blockerCount: Number(row.blocker_count || 0),
|
||||
reviewCount: Number(row.review_count || 0),
|
||||
certificatePath: row.certificate_path || "",
|
||||
report: parseJson(row.report_json, {})
|
||||
};
|
||||
}
|
||||
|
||||
function latestClearanceReport(context, deliveryId) {
|
||||
return clearanceReportRow(dbGet(
|
||||
`SELECT dcr.*, u.display_name AS created_by_name
|
||||
FROM delivery_clearance_reports dcr
|
||||
LEFT JOIN users u ON u.id = dcr.created_by
|
||||
WHERE dcr.organization_id = ? AND dcr.workspace_id = ? AND dcr.project_id = ? AND dcr.delivery_id = ?
|
||||
ORDER BY dcr.created_at DESC
|
||||
LIMIT 1`,
|
||||
[context.organization.id, context.workspace.id, context.project.id, deliveryId]
|
||||
));
|
||||
}
|
||||
|
||||
function buildDeliveryClearanceReport(context, delivery, options = {}) {
|
||||
const project = requireProject(context);
|
||||
const activeBatch = activeBatchForDelivery(context, delivery);
|
||||
const items = deliveryBatchItems(activeBatch?.id);
|
||||
const shotIds = items.length ? items.map((item) => item.shot_id) : shotRows(context).map((shot) => shot.id);
|
||||
const media = mediaClearanceSection(activeBatch, items);
|
||||
const assets = assetClearanceSection(context, shotIds);
|
||||
const voices = voiceClearanceSection(context, shotIds);
|
||||
const knowledge = knowledgeClearanceSection(context);
|
||||
const qa = qaClearanceSection(context);
|
||||
const jobs = jobClearanceSection(context);
|
||||
const blockers = [...media.blockers, ...assets.blockers, ...voices.blockers, ...knowledge.blockers, ...qa.blockers, ...jobs.blockers];
|
||||
const reviewItems = [...media.reviewItems, ...assets.reviewItems, ...voices.reviewItems, ...knowledge.reviewItems, ...qa.reviewItems, ...jobs.reviewItems];
|
||||
const status = clearanceStatus(blockers, reviewItems);
|
||||
const checkedAt = now();
|
||||
const baseCertificate = {
|
||||
schema: "ai-drama-platform.delivery-clearance-certificate.v1",
|
||||
issuedAt: checkedAt,
|
||||
localOnly: true,
|
||||
organizationId: context.organization.id,
|
||||
workspaceId: context.workspace.id,
|
||||
projectId: project.id,
|
||||
deliveryId: delivery.id,
|
||||
deliveryVersion: delivery.version,
|
||||
batchId: activeBatch?.id || "",
|
||||
releaseId: options.releaseId || "",
|
||||
status,
|
||||
policy: {
|
||||
singleFrameOnly: true,
|
||||
originalCommercialUse: true,
|
||||
approvedAssetsRequired: true,
|
||||
approvedKnowledgeRequired: true,
|
||||
fixedVoiceEvidenceRequired: true,
|
||||
actualLastFrameRequired: true,
|
||||
paidCloudPublishDisabled: true
|
||||
},
|
||||
counts: {
|
||||
blockers: blockers.length,
|
||||
reviewItems: reviewItems.length,
|
||||
assets: assets.assets.length,
|
||||
voices: voices.voices.length,
|
||||
knowledgeMaterials: knowledge.materials.length,
|
||||
qaReviews: qa.reviews.length,
|
||||
mediaItems: media.items.length,
|
||||
pendingJobs: jobs.jobs.length
|
||||
}
|
||||
};
|
||||
const certificateId = `clearance-${jsonHash(baseCertificate).slice(0, 16)}`;
|
||||
const certificate = { ...baseCertificate, certificateId, issuerUserId: context.user.id, issuerName: context.user.displayName || context.user.email || context.user.id };
|
||||
const report = {
|
||||
schema: "ai-drama-platform.delivery-clearance-report.v1",
|
||||
id: options.reportId || certificateId,
|
||||
checkedAt,
|
||||
status,
|
||||
organizationId: context.organization.id,
|
||||
workspaceId: context.workspace.id,
|
||||
projectId: project.id,
|
||||
delivery: { id: delivery.id, version: delivery.version, status: delivery.status, manifestPath: delivery.manifest_path },
|
||||
batch: activeBatch ? { id: activeBatch.id, status: activeBatch.status, manifestPath: activeBatch.manifest_path, itemCount: items.length } : null,
|
||||
releaseId: options.releaseId || "",
|
||||
channel: options.channel ? { id: options.channel.id, name: options.channel.name, kind: options.channel.kind } : null,
|
||||
blockers,
|
||||
reviewItems,
|
||||
sections: {
|
||||
media,
|
||||
assets: assets.assets,
|
||||
voices: voices.voices,
|
||||
knowledge,
|
||||
qa,
|
||||
jobs
|
||||
},
|
||||
certificate
|
||||
};
|
||||
return report;
|
||||
}
|
||||
|
||||
function persistDeliveryClearanceReport(context, delivery, options = {}) {
|
||||
const id = makeId("clearance");
|
||||
const report = buildDeliveryClearanceReport(context, delivery, { ...options, reportId: id });
|
||||
const certificatePath = `storage/deliveries/${safePathSegment(context.project.id)}/${safePathSegment(delivery.version)}/clearance/${safePathSegment(id)}.json`;
|
||||
const target = safeStoragePath(certificatePath);
|
||||
if (!target) throw httpError(500, "clearance_path_invalid", "清算证书路径不满足本地存储安全约束");
|
||||
mkdirSync(resolve(target.absolute, ".."), { recursive: true });
|
||||
writeFileSync(target.absolute, `${JSON.stringify({ ...report.certificate, report }, null, 2)}\n`, "utf8");
|
||||
dbRun(
|
||||
`INSERT INTO delivery_clearance_reports(
|
||||
id, organization_id, workspace_id, project_id, delivery_id, batch_id, release_id, status,
|
||||
blocker_count, review_count, certificate_path, report_json, created_by, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, context.organization.id, context.workspace.id, context.project.id, delivery.id, report.batch?.id || null, options.releaseId || null, report.status, report.blockers.length, report.reviewItems.length, certificatePath, JSON.stringify(report), context.user.id, report.checkedAt]
|
||||
);
|
||||
addAudit({ context, action: "delivery.clearance.checked", targetType: "delivery", targetId: delivery.id, result: report.status, metadata: { clearanceId: id, blockers: report.blockers.length, reviewItems: report.reviewItems.length, releaseId: options.releaseId || "" } });
|
||||
return clearanceReportRow(dbGet("SELECT * FROM delivery_clearance_reports WHERE id = ?", [id]));
|
||||
}
|
||||
|
||||
function assertDeliveryClearancePass(context, delivery, options = {}) {
|
||||
const clearance = persistDeliveryClearanceReport(context, delivery, options);
|
||||
const report = clearance.report || {};
|
||||
if (report.status === "blocked") {
|
||||
throw httpError(409, "delivery_clearance_blocked", "交付权利清算未通过,不能审批或发布", { blockers: report.blockers || [], clearance: report, clearanceId: clearance.id });
|
||||
}
|
||||
return clearance;
|
||||
}
|
||||
|
||||
function isPrivateHostname(hostname) {
|
||||
const host = String(hostname || "").toLowerCase().replace(/^\[|\]$/g, "");
|
||||
if (["localhost", "::1"].includes(host) || host.endsWith(".local") || host.endsWith(".internal")) return true;
|
||||
@@ -504,6 +1096,7 @@ export function listDeliveryChannels(context) {
|
||||
|
||||
export function createDeliveryChannel(context, body = {}) {
|
||||
requirePermission(context, "delivery:approve");
|
||||
requireEntitlement(context, "limit.delivery_channels", 1);
|
||||
const projectId = body.projectId ? String(body.projectId).trim() : null;
|
||||
if (projectId && (!context.project || context.project.id !== projectId)) {
|
||||
throw httpError(403, "delivery_channel_project_scope", "渠道项目范围必须是当前项目或留空作为工作区渠道");
|
||||
@@ -707,13 +1300,14 @@ export function importScript(context, body) {
|
||||
const latest = dbGet("SELECT MAX(version_number) AS version_number FROM script_documents WHERE project_id = ?", [project.id]);
|
||||
const versionNumber = Number(latest?.version_number || 0) + 1;
|
||||
const analysis = parseScript(content);
|
||||
const metadata = body.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata) ? body.metadata : {};
|
||||
const id = makeId("script");
|
||||
const timestamp = now();
|
||||
dbRun("INSERT INTO script_documents(id, organization_id, workspace_id, project_id, episode_id, version_number, title, source_type, content, status, analysis_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'analyzed', ?, ?, ?, ?)", [id, context.organization.id, context.workspace.id, project.id, body.episodeId || root.episode.id, versionNumber, String(body.title || analysis.chapters[0]?.title || "未命名剧本"), String(body.sourceType || "原创短剧剧本"), content, JSON.stringify(analysis), context.user.id, timestamp, timestamp]);
|
||||
dbRun("INSERT INTO script_documents(id, organization_id, workspace_id, project_id, episode_id, version_number, title, source_type, content, status, analysis_json, metadata_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'analyzed', ?, ?, ?, ?, ?)", [id, context.organization.id, context.workspace.id, project.id, body.episodeId || root.episode.id, versionNumber, String(body.title || analysis.chapters[0]?.title || "未命名剧本"), String(body.sourceType || "原创短剧剧本"), content, JSON.stringify(analysis), JSON.stringify(metadata), context.user.id, timestamp, timestamp]);
|
||||
dbRun("UPDATE episodes SET title = COALESCE(NULLIF(?, ''), title), updated_at = ? WHERE id = ?", [String(body.episodeTitle || "").trim(), timestamp, body.episodeId || root.episode.id]);
|
||||
addAudit({ context, action: "script.imported", targetType: "script_document", targetId: id, metadata: { versionNumber, wordCount: analysis.wordCount, chapterCount: analysis.chapterCount } });
|
||||
addUsage({ context, kind: "script-analysis", units: 1, unitName: "document", metadata: { scriptId: id, parser: analysis.parser } });
|
||||
return { document: { ...dbGet("SELECT * FROM script_documents WHERE id = ?", [id]), analysis }, graph: productionGraph(context, { episodeId: body.episodeId || root.episode.id }) };
|
||||
return { document: { ...dbGet("SELECT * FROM script_documents WHERE id = ?", [id]), analysis, metadata }, graph: productionGraph(context, { episodeId: body.episodeId || root.episode.id }) };
|
||||
}
|
||||
|
||||
export function materializeScript(context, documentId, body = {}) {
|
||||
@@ -1041,6 +1635,33 @@ export function listDeliveries(context) {
|
||||
return { deliveries: deliveries(context) };
|
||||
}
|
||||
|
||||
export function getDeliveryClearance(context, deliveryId) {
|
||||
requireAnyPermission(context, ["delivery:view", "delivery:approve", "compliance:manage"]);
|
||||
const delivery = deliveryForContext(context, deliveryId);
|
||||
const preview = buildDeliveryClearanceReport(context, delivery);
|
||||
return {
|
||||
deliveryId,
|
||||
clearance: preview,
|
||||
latest: latestClearanceReport(context, deliveryId)
|
||||
};
|
||||
}
|
||||
|
||||
export function runDeliveryClearance(context, deliveryId, body = {}) {
|
||||
requireAnyPermission(context, ["delivery:approve", "compliance:manage"], { mutating: true });
|
||||
const delivery = deliveryForContext(context, deliveryId);
|
||||
const releaseId = String(body.releaseId || body.release_id || "").trim();
|
||||
const release = releaseId ? releaseForContext(context, releaseId) : null;
|
||||
if (release && release.delivery_id !== delivery.id) throw httpError(422, "clearance_release_mismatch", "发布申请不属于当前交付版本", { deliveryId, releaseId });
|
||||
const channel = release ? channelForContext(context, release.channel_id) : null;
|
||||
const clearance = persistDeliveryClearanceReport(context, delivery, { releaseId: release?.id || "", channel });
|
||||
return {
|
||||
deliveryId,
|
||||
clearance: clearance.report,
|
||||
latest: clearance,
|
||||
deliveries: deliveries(context)
|
||||
};
|
||||
}
|
||||
|
||||
export function createDelivery(context, body) {
|
||||
requirePermission(context, "delivery:approve");
|
||||
const project = requireProject(context);
|
||||
@@ -1105,65 +1726,35 @@ function releaseForContext(context, releaseId) {
|
||||
return release;
|
||||
}
|
||||
|
||||
function releasePreflight(context, delivery, channel) {
|
||||
const blockers = [];
|
||||
function releasePreflight(context, delivery, channel, options = {}) {
|
||||
let blockers = [];
|
||||
const project = requireProject(context);
|
||||
if (!channel.enabled) blockers.push({ type: "channel", status: "disabled", reason: "发布渠道已停用" });
|
||||
if (delivery.status !== "approved") blockers.push({ type: "delivery", status: delivery.status, reason: "交付版本必须先通过交付审批" });
|
||||
|
||||
const activeBatch = delivery.active_batch_id
|
||||
? dbGet(
|
||||
`SELECT * FROM delivery_batches
|
||||
WHERE id = ? AND delivery_id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?`,
|
||||
[delivery.active_batch_id, delivery.id, context.organization.id, context.workspace.id, project.id]
|
||||
)
|
||||
const clearanceRecord = options.persistClearance
|
||||
? persistDeliveryClearanceReport(context, delivery, { releaseId: options.releaseId || "", channel })
|
||||
: null;
|
||||
if (!activeBatch) {
|
||||
blockers.push({ type: "delivery_batch", status: "missing", reason: "交付版本没有当前生效批次" });
|
||||
} else {
|
||||
if (activeBatch.status !== "active") blockers.push({ type: "delivery_batch", status: activeBatch.status, reason: "当前交付批次不是 active" });
|
||||
const batchResult = parseJson(activeBatch.result_json, {});
|
||||
if (Array.isArray(batchResult.blockers) && batchResult.blockers.length) blockers.push({ type: "delivery_batch", status: "blocked", reason: "批次创建时已登记媒体阻断项", items: batchResult.blockers });
|
||||
const manifest = safeStoragePath(activeBatch.manifest_path);
|
||||
if (!manifest || !existsSync(manifest.absolute)) blockers.push({ type: "manifest", status: "missing", reason: "当前批次 Manifest 文件不存在" });
|
||||
|
||||
const items = dbAll(
|
||||
`SELECT dbi.*, s.title AS shot_title
|
||||
FROM delivery_batch_items dbi
|
||||
LEFT JOIN shots s ON s.id = dbi.shot_id
|
||||
WHERE dbi.batch_id = ? ORDER BY dbi.sequence_number`,
|
||||
[activeBatch.id]
|
||||
);
|
||||
if (!items.length) blockers.push({ type: "delivery_batch_items", status: "missing", reason: "当前批次没有镜头条目" });
|
||||
for (const item of items) {
|
||||
const source = safeStoragePath(item.source_path);
|
||||
const actualLastFrame = safeStoragePath(item.actual_last_frame_path);
|
||||
if (!source || !existsSync(source.absolute)) blockers.push({ type: "media", shotId: item.shot_id, status: "missing", reason: "视频源文件不存在" });
|
||||
if (!item.source_sha256) blockers.push({ type: "media", shotId: item.shot_id, status: "unverified", reason: "视频源缺少 SHA-256" });
|
||||
if (!actualLastFrame || !existsSync(actualLastFrame.absolute)) blockers.push({ type: "actual-last-frame", shotId: item.shot_id, status: "missing", reason: "实际末帧证据不存在" });
|
||||
}
|
||||
return {
|
||||
ok: blockers.length === 0,
|
||||
checkedAt: now(),
|
||||
projectId: project.id,
|
||||
deliveryId: delivery.id,
|
||||
channelId: channel.id,
|
||||
batchId: activeBatch.id,
|
||||
manifestPath: activeBatch.manifest_path,
|
||||
itemCount: items.length,
|
||||
blockers
|
||||
};
|
||||
}
|
||||
const clearance = clearanceRecord?.report || buildDeliveryClearanceReport(context, delivery, { releaseId: options.releaseId || "", channel });
|
||||
if (clearance.status === "blocked") blockers.push(...(clearance.blockers || []));
|
||||
blockers = dedupeBlockers(blockers);
|
||||
return {
|
||||
ok: false,
|
||||
checkedAt: now(),
|
||||
ok: blockers.length === 0,
|
||||
checkedAt: clearance.checkedAt || now(),
|
||||
projectId: project.id,
|
||||
deliveryId: delivery.id,
|
||||
channelId: channel.id,
|
||||
batchId: null,
|
||||
manifestPath: "",
|
||||
itemCount: 0,
|
||||
blockers
|
||||
batchId: clearance.batch?.id || null,
|
||||
manifestPath: clearance.batch?.manifestPath || "",
|
||||
itemCount: clearance.batch?.itemCount || 0,
|
||||
blockers,
|
||||
clearance: {
|
||||
id: clearanceRecord?.id || "",
|
||||
schema: "ai-drama-platform.delivery-clearance-certificate.v1",
|
||||
status: clearance.status,
|
||||
certificateId: clearance.certificate?.certificateId || "",
|
||||
certificatePath: clearanceRecord?.certificatePath || "",
|
||||
counts: clearance.certificate?.counts || {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1216,7 +1807,7 @@ export function createDeliveryRelease(context, deliveryId, body = {}) {
|
||||
if (existing) return { release: releasePayload(existing), idempotent: true, ...listDeliveryReleases(context, deliveryId) };
|
||||
}
|
||||
const submit = body.submit !== false;
|
||||
const preflight = submit ? releasePreflight(context, delivery, channel) : { ok: false, checkedAt: now(), blockers: [{ type: "not_submitted", status: "draft", reason: "发布申请尚未提交" }] };
|
||||
const preflight = submit ? releasePreflight(context, delivery, channel, { persistClearance: true }) : { ok: false, checkedAt: now(), blockers: [{ type: "not_submitted", status: "draft", reason: "发布申请尚未提交" }] };
|
||||
if (submit && !preflight.ok) throw httpError(409, "release_blocked", "当前交付版本不满足发布申请条件", { blockers: preflight.blockers, preflight });
|
||||
const id = makeId("delivery-release");
|
||||
const timestamp = now();
|
||||
@@ -1247,13 +1838,13 @@ export function decideDeliveryRelease(context, releaseId, body = {}) {
|
||||
dbRun("UPDATE delivery_releases SET status = 'draft', decision_note = ?, reviewed_by = NULL, reviewed_at = NULL, updated_at = ? WHERE id = ?", [note, timestamp, releaseId]);
|
||||
} else if (status === "submitted") {
|
||||
if (!["draft", "rejected"].includes(release.status)) throw httpError(409, "release_transition_invalid", "只有草稿或已驳回的发布申请可以重新提交");
|
||||
preflight = releasePreflight(context, delivery, channel);
|
||||
preflight = releasePreflight(context, delivery, channel, { persistClearance: true, releaseId });
|
||||
if (!preflight.ok) throw httpError(409, "release_blocked", "发布申请仍未满足发布条件", { blockers: preflight.blockers, preflight });
|
||||
const timestamp = now();
|
||||
dbRun("UPDATE delivery_releases SET status = 'submitted', requested_by = ?, requested_at = ?, decision_note = ?, preflight_json = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, note, JSON.stringify(preflight), timestamp, releaseId]);
|
||||
} else if (status === "approved") {
|
||||
if (release.status !== "submitted") throw httpError(409, "release_transition_invalid", "只有已提交的发布申请可以批准");
|
||||
preflight = releasePreflight(context, delivery, channel);
|
||||
preflight = releasePreflight(context, delivery, channel, { persistClearance: true, releaseId });
|
||||
if (!preflight.ok) throw httpError(409, "release_blocked", "发布申请审批被质量门阻断", { blockers: preflight.blockers, preflight });
|
||||
const timestamp = now();
|
||||
dbRun("UPDATE delivery_releases SET status = 'approved', reviewed_by = ?, reviewed_at = ?, decision_note = ?, preflight_json = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, note, JSON.stringify(preflight), timestamp, releaseId]);
|
||||
@@ -1276,7 +1867,7 @@ export async function publishDeliveryRelease(context, releaseId) {
|
||||
if (!["approved", "failed"].includes(release.status)) throw httpError(409, "release_transition_invalid", "只有已批准或上次发布失败的申请可以发布");
|
||||
const delivery = deliveryForContext(context, release.delivery_id);
|
||||
const channel = channelForContext(context, release.channel_id);
|
||||
const preflight = releasePreflight(context, delivery, channel);
|
||||
const preflight = releasePreflight(context, delivery, channel, { persistClearance: true, releaseId });
|
||||
if (!preflight.ok) throw httpError(409, "release_blocked", "发布前复核未通过", { blockers: preflight.blockers, preflight });
|
||||
const outputDirectory = `storage/releases/${safePathSegment(context.project.id)}/${safePathSegment(delivery.version)}/${safePathSegment(release.id)}`;
|
||||
const releaseOutputPath = `${outputDirectory}/release.json`;
|
||||
@@ -1289,6 +1880,7 @@ export async function publishDeliveryRelease(context, releaseId) {
|
||||
delivery: { id: delivery.id, version: delivery.version, status: delivery.status, manifestPath: delivery.manifest_path },
|
||||
channel: { id: channel.id, name: channel.name, kind: channel.kind, endpoint: channel.kind === "local-file" ? channel.endpoint : "private-webhook" },
|
||||
batch: { id: preflight.batchId, manifestPath: preflight.manifestPath, itemCount: preflight.itemCount },
|
||||
clearanceCertificate: preflight.clearance,
|
||||
preflight
|
||||
};
|
||||
let result = { kind: channel.kind, outputPath: releaseOutputPath, manifestPath: manifestOutputPath, localOnly: true };
|
||||
@@ -1488,11 +2080,13 @@ export function approveDelivery(context, deliveryId, body = {}) {
|
||||
if (!item.actual_last_frame_path) missing.push("实际末帧");
|
||||
return missing.length ? [{ id: item.id, type: "delivery_batch_item", shotId: item.shot_id, status: "blocked", reason: missing.join("、") }] : [];
|
||||
});
|
||||
const blockers = [...reviewBlockers, ...jobBlockers, ...batchBlockers];
|
||||
if (blockers.length && !body.force) throw httpError(409, "delivery_blocked", "仍有质检项未通过,不能批准交付", { blockers });
|
||||
const clearance = persistDeliveryClearanceReport(context, delivery);
|
||||
const clearanceBlockers = clearance.report?.blockers || [];
|
||||
const blockers = dedupeBlockers([...reviewBlockers, ...jobBlockers, ...batchBlockers, ...clearanceBlockers]);
|
||||
if (clearanceBlockers.length || (blockers.length && !body.force)) throw httpError(409, "delivery_blocked", "交付版本未满足质检、权利清算或媒体证据要求,不能批准交付", { blockers, clearance: clearance.report, clearanceId: clearance.id });
|
||||
const timestamp = now();
|
||||
dbRun("UPDATE deliveries SET status = 'approved', approved_by = ?, approved_at = ?, updated_at = ? WHERE id = ?", [context.user.id, timestamp, timestamp, deliveryId]);
|
||||
addAudit({ context, action: "delivery.approved", targetType: "delivery", targetId: deliveryId, metadata: { forced: Boolean(body.force), blockers: blockers.length } });
|
||||
addAudit({ context, action: "delivery.approved", targetType: "delivery", targetId: deliveryId, metadata: { forced: Boolean(body.force), blockers: blockers.length, clearanceId: clearance.id, clearanceStatus: clearance.report?.status || "" } });
|
||||
void dispatchNotificationEvent({ context, eventKey: "delivery.approved", payload: { deliveryId, version: delivery.version, targetId: deliveryId, forced: Boolean(body.force) } });
|
||||
return { delivery: dbGet("SELECT * FROM deliveries WHERE id = ?", [deliveryId]), deliveries: deliveries(context), blockers };
|
||||
}
|
||||
|
||||
@@ -463,6 +463,70 @@ CREATE TABLE IF NOT EXISTS billing_account_events (
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscription_plan_templates (
|
||||
id TEXT PRIMARY KEY,
|
||||
tier_key TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
billing_cycle TEXT NOT NULL DEFAULT 'monthly' CHECK (billing_cycle IN ('monthly', 'quarterly', 'annual')),
|
||||
currency TEXT NOT NULL DEFAULT 'CNY',
|
||||
base_fee REAL NOT NULL DEFAULT 0,
|
||||
seat_limit INTEGER NOT NULL DEFAULT 1,
|
||||
storage_gb INTEGER NOT NULL DEFAULT 1,
|
||||
monthly_clip_quota INTEGER NOT NULL DEFAULT 1,
|
||||
limits_json TEXT NOT NULL DEFAULT '{}',
|
||||
features_json TEXT NOT NULL DEFAULT '{}',
|
||||
connector_policy_json TEXT NOT NULL DEFAULT '{}',
|
||||
support_sla TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS organization_entitlements (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
entitlement_key TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
category TEXT NOT NULL DEFAULT 'general',
|
||||
limit_value REAL NOT NULL DEFAULT 0,
|
||||
unit TEXT NOT NULL DEFAULT '项',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
enforcement TEXT NOT NULL DEFAULT 'block' CHECK (enforcement IN ('block', 'warn', 'off')),
|
||||
source TEXT NOT NULL DEFAULT 'plan' CHECK (source IN ('plan', 'override')),
|
||||
override_reason TEXT NOT NULL DEFAULT '',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (organization_id, entitlement_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS commercial_approval_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL,
|
||||
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
||||
request_type TEXT NOT NULL CHECK (request_type IN ('entitlement_overage', 'feature_enablement', 'budget_increase', 'external_connector', 'compliance_review', 'delivery_exception', 'storage_retention')),
|
||||
title TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'submitted' CHECK (status IN ('submitted', 'approved', 'rejected', 'cancelled')),
|
||||
priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
|
||||
target_key TEXT NOT NULL DEFAULT '',
|
||||
current_value REAL NOT NULL DEFAULT 0,
|
||||
requested_value REAL NOT NULL DEFAULT 0,
|
||||
unit TEXT NOT NULL DEFAULT '',
|
||||
business_reason TEXT NOT NULL DEFAULT '',
|
||||
risk_assessment_json TEXT NOT NULL DEFAULT '{}',
|
||||
evidence_json TEXT NOT NULL DEFAULT '{}',
|
||||
decision_note TEXT NOT NULL DEFAULT '',
|
||||
effect_json TEXT NOT NULL DEFAULT '{}',
|
||||
requester_user_id TEXT NOT NULL REFERENCES users(id),
|
||||
reviewer_user_id TEXT REFERENCES users(id),
|
||||
reviewed_at TEXT,
|
||||
expires_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Local invoice ledger. This is intentionally payment-provider agnostic: the
|
||||
-- platform records billing snapshots and lifecycle state, while an external
|
||||
-- accounting or payment system can be connected later through an adapter.
|
||||
@@ -508,6 +572,11 @@ CREATE TABLE IF NOT EXISTS invoice_lines (
|
||||
CREATE INDEX IF NOT EXISTS idx_organization_invoices_org_period ON organization_invoices(organization_id, period_end DESC, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_organization_invoices_org_status ON organization_invoices(organization_id, status, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice_sort ON invoice_lines(invoice_id, sort_order, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscription_plan_templates_status ON subscription_plan_templates(status, tier_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_organization_entitlements_org_category ON organization_entitlements(organization_id, category, entitlement_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_commercial_approvals_org_status ON commercial_approval_requests(organization_id, status, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_commercial_approvals_requester ON commercial_approval_requests(requester_user_id, status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_commercial_approvals_target ON commercial_approval_requests(organization_id, request_type, target_key, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS cost_centers (
|
||||
id TEXT PRIMARY KEY,
|
||||
@@ -573,6 +642,73 @@ CREATE TABLE IF NOT EXISTS model_connectors (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_catalog_entries (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
connector_id TEXT REFERENCES model_connectors(id) ON DELETE SET NULL,
|
||||
model_key TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
family TEXT NOT NULL DEFAULT '',
|
||||
capabilities_json TEXT NOT NULL DEFAULT '[]',
|
||||
context_window INTEGER NOT NULL DEFAULT 0,
|
||||
max_output_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cost_json TEXT NOT NULL DEFAULT '{}',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
approval_status TEXT NOT NULL DEFAULT 'approved',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (organization_id, workspace_id, connector_id, model_key)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_routing_policies (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
workflow_key TEXT NOT NULL,
|
||||
operation_key TEXT NOT NULL,
|
||||
primary_model_id TEXT REFERENCES model_catalog_entries(id) ON DELETE SET NULL,
|
||||
fallback_model_id TEXT REFERENCES model_catalog_entries(id) ON DELETE SET NULL,
|
||||
policy_mode TEXT NOT NULL DEFAULT 'prefer-local',
|
||||
approval_mode TEXT NOT NULL DEFAULT 'follow-model',
|
||||
budget_limit_cny REAL NOT NULL DEFAULT 0,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
policy_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS model_route_approval_requests (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
|
||||
job_id TEXT REFERENCES generation_jobs(id) ON DELETE SET NULL,
|
||||
route_id TEXT REFERENCES model_routing_policies(id) ON DELETE SET NULL,
|
||||
model_entry_id TEXT REFERENCES model_catalog_entries(id) ON DELETE SET NULL,
|
||||
connector_id TEXT REFERENCES model_connectors(id) ON DELETE SET NULL,
|
||||
requester_user_id TEXT NOT NULL REFERENCES users(id),
|
||||
reviewer_user_id TEXT REFERENCES users(id),
|
||||
status TEXT NOT NULL DEFAULT 'submitted' CHECK (status IN ('submitted', 'approved', 'rejected', 'cancelled', 'expired')),
|
||||
approval_scope TEXT NOT NULL DEFAULT 'single-run' CHECK (approval_scope IN ('single-run', 'single-job', 'route-window', 'connector-window')),
|
||||
reason TEXT NOT NULL DEFAULT '',
|
||||
decision_note TEXT NOT NULL DEFAULT '',
|
||||
request_json TEXT NOT NULL DEFAULT '{}',
|
||||
resolution_json TEXT NOT NULL DEFAULT '{}',
|
||||
guard_json TEXT NOT NULL DEFAULT '{}',
|
||||
estimated_cost_cny REAL NOT NULL DEFAULT 0,
|
||||
local_only INTEGER NOT NULL DEFAULT 0,
|
||||
expires_at TEXT,
|
||||
reviewed_at TEXT,
|
||||
consumed_at TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS series (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE,
|
||||
@@ -622,12 +758,110 @@ CREATE TABLE IF NOT EXISTS script_documents (
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'draft',
|
||||
analysis_json TEXT NOT NULL DEFAULT '{}',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
UNIQUE (project_id, version_number)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_documents (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
||||
scope_mode TEXT NOT NULL DEFAULT 'workspace',
|
||||
title TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL DEFAULT 'novel',
|
||||
language TEXT NOT NULL DEFAULT 'zh-CN',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'ingested',
|
||||
rights_status TEXT NOT NULL DEFAULT 'needs-evidence',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
analysis_json TEXT NOT NULL DEFAULT '{}',
|
||||
provenance_json TEXT NOT NULL DEFAULT '{}',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
risk_json TEXT NOT NULL DEFAULT '{}',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
current_version_number INTEGER NOT NULL DEFAULT 1,
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_chunks (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL REFERENCES knowledge_documents(id) ON DELETE CASCADE,
|
||||
chunk_index INTEGER NOT NULL,
|
||||
chunk_type TEXT NOT NULL DEFAULT 'scene',
|
||||
heading TEXT NOT NULL DEFAULT '',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
token_estimate INTEGER NOT NULL DEFAULT 0,
|
||||
keywords_json TEXT NOT NULL DEFAULT '[]',
|
||||
entities_json TEXT NOT NULL DEFAULT '{}',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (document_id, chunk_index)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_document_versions (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL REFERENCES knowledge_documents(id) ON DELETE CASCADE,
|
||||
version_number INTEGER NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
source_type TEXT NOT NULL DEFAULT 'novel',
|
||||
language TEXT NOT NULL DEFAULT 'zh-CN',
|
||||
content TEXT NOT NULL DEFAULT '',
|
||||
summary TEXT NOT NULL DEFAULT '',
|
||||
analysis_json TEXT NOT NULL DEFAULT '{}',
|
||||
provenance_json TEXT NOT NULL DEFAULT '{}',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
risk_json TEXT NOT NULL DEFAULT '{}',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (document_id, version_number)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_governance_reviews (
|
||||
id TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL REFERENCES knowledge_documents(id) ON DELETE CASCADE,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
||||
decision TEXT NOT NULL DEFAULT 'submitted',
|
||||
rights_status TEXT NOT NULL DEFAULT 'needs-evidence',
|
||||
risk_status TEXT NOT NULL DEFAULT 'review',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
evidence_ref TEXT NOT NULL DEFAULT '',
|
||||
provenance_json TEXT NOT NULL DEFAULT '{}',
|
||||
risk_json TEXT NOT NULL DEFAULT '{}',
|
||||
reviewer_user_id TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS knowledge_context_packs (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
|
||||
scope_mode TEXT NOT NULL DEFAULT 'workspace',
|
||||
name TEXT NOT NULL,
|
||||
query TEXT NOT NULL DEFAULT '',
|
||||
source_type TEXT NOT NULL DEFAULT '',
|
||||
max_tokens INTEGER NOT NULL DEFAULT 1600,
|
||||
token_estimate INTEGER NOT NULL DEFAULT 0,
|
||||
chunk_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||
citations_json TEXT NOT NULL DEFAULT '[]',
|
||||
chunks_json TEXT NOT NULL DEFAULT '[]',
|
||||
prompt_context TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS assets (
|
||||
id TEXT PRIMARY KEY,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
@@ -650,12 +884,35 @@ CREATE TABLE IF NOT EXISTS asset_versions (
|
||||
file_size INTEGER NOT NULL DEFAULT 0,
|
||||
content_sha256 TEXT NOT NULL DEFAULT '',
|
||||
rights_status TEXT NOT NULL DEFAULT 'needs-evidence',
|
||||
provenance_json TEXT NOT NULL DEFAULT '{}',
|
||||
risk_json TEXT NOT NULL DEFAULT '{}',
|
||||
tags_json TEXT NOT NULL DEFAULT '[]',
|
||||
license_scope TEXT NOT NULL DEFAULT '',
|
||||
expires_at TEXT,
|
||||
metadata_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE (asset_id, version_number)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS asset_governance_reviews (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
asset_id TEXT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
|
||||
version_id TEXT NOT NULL REFERENCES asset_versions(id) ON DELETE CASCADE,
|
||||
reviewer_user_id TEXT NOT NULL REFERENCES users(id),
|
||||
decision TEXT NOT NULL,
|
||||
rights_status TEXT NOT NULL,
|
||||
risk_status TEXT NOT NULL DEFAULT 'review',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
evidence_ref TEXT NOT NULL DEFAULT '',
|
||||
provenance_json TEXT NOT NULL DEFAULT '{}',
|
||||
risk_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS shots (
|
||||
id TEXT PRIMARY KEY,
|
||||
episode_id TEXT NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
|
||||
@@ -728,6 +985,7 @@ CREATE TABLE IF NOT EXISTS generation_jobs (
|
||||
result_json TEXT NOT NULL DEFAULT '{}',
|
||||
error_message TEXT NOT NULL DEFAULT '',
|
||||
max_attempts INTEGER NOT NULL DEFAULT 3,
|
||||
model_route_approval_id TEXT REFERENCES model_route_approval_requests(id) ON DELETE SET NULL,
|
||||
next_run_at TEXT,
|
||||
leased_by TEXT,
|
||||
leased_at TEXT,
|
||||
@@ -897,6 +1155,23 @@ CREATE TABLE IF NOT EXISTS delivery_releases (
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS delivery_clearance_reports (
|
||||
id TEXT PRIMARY KEY,
|
||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
delivery_id TEXT NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE,
|
||||
batch_id TEXT REFERENCES delivery_batches(id) ON DELETE SET NULL,
|
||||
release_id TEXT REFERENCES delivery_releases(id) ON DELETE SET NULL,
|
||||
status TEXT NOT NULL DEFAULT 'review' CHECK (status IN ('pass', 'review', 'blocked')),
|
||||
blocker_count INTEGER NOT NULL DEFAULT 0,
|
||||
review_count INTEGER NOT NULL DEFAULT 0,
|
||||
certificate_path TEXT NOT NULL DEFAULT '',
|
||||
report_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_by TEXT NOT NULL REFERENCES users(id),
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- External delivery portals are scoped to one published release. The bearer
|
||||
-- token is never stored in plaintext; only its SHA-256 digest is persisted.
|
||||
CREATE TABLE IF NOT EXISTS delivery_access_links (
|
||||
@@ -1190,11 +1465,20 @@ CREATE INDEX IF NOT EXISTS idx_org_members_user ON organization_members(user_id,
|
||||
CREATE INDEX IF NOT EXISTS idx_workspace_members_user ON workspace_members(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_projects_workspace ON projects(workspace_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_script_documents_project ON script_documents(project_id, version_number DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_documents_scope ON knowledge_documents(organization_id, workspace_id, project_id, scope_mode, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_document ON knowledge_chunks(document_id, chunk_index);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_document_versions_document ON knowledge_document_versions(document_id, version_number DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_governance_reviews_document ON knowledge_governance_reviews(document_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_knowledge_context_packs_scope ON knowledge_context_packs(organization_id, workspace_id, project_id, status, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_shot_versions_shot ON shot_versions(shot_id, version_number DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_voice_lines_shot ON voice_lines(shot_id, line_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members(user_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_org_role_permissions_scope ON organization_role_permissions(organization_id, role_key, permission_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_catalog_entries_scope ON model_catalog_entries(organization_id, workspace_id, connector_id, status, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_model_routing_policies_scope ON model_routing_policies(organization_id, workspace_id, workflow_key, operation_key, status, updated_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_bindings_shot ON asset_bindings(shot_id, usage_role);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_asset ON asset_governance_reviews(asset_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_scope ON asset_governance_reviews(organization_id, workspace_id, project_id, risk_status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_scope ON generation_jobs(organization_id, workspace_id, project_id, status);
|
||||
CREATE INDEX IF NOT EXISTS idx_usage_scope ON usage_events(organization_id, workspace_id, project_id, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_scope ON audit_logs(organization_id, workspace_id, project_id, created_at);
|
||||
@@ -1220,6 +1504,8 @@ CREATE INDEX IF NOT EXISTS idx_delivery_batch_items_batch ON delivery_batch_item
|
||||
CREATE INDEX IF NOT EXISTS idx_delivery_channels_scope ON delivery_channels(organization_id, workspace_id, project_id, enabled, created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_delivery_releases_scope ON delivery_releases(organization_id, workspace_id, project_id, delivery_id, status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_delivery_releases_idempotency ON delivery_releases(organization_id, project_id, idempotency_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_delivery_clearance_reports_scope ON delivery_clearance_reports(organization_id, workspace_id, project_id, delivery_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_delivery_clearance_reports_release ON delivery_clearance_reports(release_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_delivery_access_links_token ON delivery_access_links(token_hash, status, expires_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_delivery_access_links_scope ON delivery_access_links(organization_id, workspace_id, project_id, release_id, status, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_delivery_access_events_link ON delivery_access_events(link_id, created_at DESC);
|
||||
|
||||
+44
-2
@@ -102,6 +102,48 @@ export function searchPlatform(context, { query = "", scope = "workspace", limit
|
||||
results.push(baseResult("script", "剧本", row, "script", row.title, `${row.project_name}${row.episode_title ? ` · ${row.episode_title}` : ""} · v${row.version_number}`, row.status));
|
||||
}
|
||||
|
||||
const knowledgeScopeClause = scope === "project" && context?.project?.id
|
||||
? " AND (kd.project_id IS NULL OR kd.project_id = ?)"
|
||||
: projectIds.length
|
||||
? ` AND (kd.project_id IS NULL OR kd.project_id IN (${ids}))`
|
||||
: " AND kd.project_id IS NULL";
|
||||
const knowledgeParams = scope === "project" && context?.project?.id
|
||||
? [context.organization.id, context.workspace.id, pattern, pattern, pattern, context.project.id, perType]
|
||||
: projectIds.length
|
||||
? [context.organization.id, context.workspace.id, pattern, pattern, pattern, ...projectIds, perType]
|
||||
: [context.organization.id, context.workspace.id, pattern, pattern, pattern, perType];
|
||||
const knowledgeDocuments = dbAll(
|
||||
`SELECT kd.*
|
||||
FROM knowledge_documents kd
|
||||
WHERE kd.organization_id = ? AND kd.workspace_id = ?
|
||||
AND (kd.title LIKE ? ESCAPE '\\' OR kd.content LIKE ? ESCAPE '\\' OR kd.summary LIKE ? ESCAPE '\\')
|
||||
${knowledgeScopeClause}
|
||||
ORDER BY kd.updated_at DESC LIMIT ?`,
|
||||
knowledgeParams
|
||||
);
|
||||
for (const row of knowledgeDocuments) {
|
||||
results.push(baseResult("knowledge_document", "知识素材", row, "knowledge", row.title, `${row.source_type} · ${row.scope_mode === "project" ? "项目级" : "工作区级"}`, row.status));
|
||||
}
|
||||
|
||||
const knowledgeChunkParams = scope === "project" && context?.project?.id
|
||||
? [context.organization.id, context.workspace.id, pattern, pattern, context.project.id, perType]
|
||||
: projectIds.length
|
||||
? [context.organization.id, context.workspace.id, pattern, pattern, ...projectIds, perType]
|
||||
: [context.organization.id, context.workspace.id, pattern, pattern, perType];
|
||||
const knowledgeChunks = dbAll(
|
||||
`SELECT kc.*, kd.title AS document_title, kd.project_id, kd.scope_mode, kd.organization_id, kd.workspace_id
|
||||
FROM knowledge_chunks kc
|
||||
JOIN knowledge_documents kd ON kd.id = kc.document_id
|
||||
WHERE kd.organization_id = ? AND kd.workspace_id = ?
|
||||
AND (kc.heading LIKE ? ESCAPE '\\' OR kc.content LIKE ? ESCAPE '\\')
|
||||
${knowledgeScopeClause}
|
||||
ORDER BY kd.updated_at DESC, kc.chunk_index ASC LIMIT ?`,
|
||||
knowledgeChunkParams
|
||||
);
|
||||
for (const row of knowledgeChunks) {
|
||||
results.push(baseResult("knowledge_chunk", "知识片段", row, "knowledge", row.heading || row.document_title, `${row.document_title} · chunk ${row.chunk_index}`, row.chunk_type));
|
||||
}
|
||||
|
||||
const shots = dbAll(
|
||||
`SELECT sh.*, e.episode_number, e.title AS episode_title,
|
||||
p.id AS project_id, p.name AS project_name, w.organization_id AS organization_id, p.workspace_id
|
||||
@@ -112,9 +154,9 @@ export function searchPlatform(context, { query = "", scope = "workspace", limit
|
||||
JOIN projects p ON p.id = s.project_id
|
||||
JOIN workspaces w ON w.id = p.workspace_id
|
||||
WHERE p.id IN (${ids})
|
||||
AND (sh.title LIKE ? ESCAPE '\\' OR sh.id LIKE ? ESCAPE '\\' OR sh.continuity_json LIKE ? ESCAPE '\\')
|
||||
AND (sh.title LIKE ? ESCAPE '\\' OR sh.id LIKE ? ESCAPE '\\' OR sh.continuity_json LIKE ? ESCAPE '\\' OR p.name LIKE ? ESCAPE '\\' OR s.title LIKE ? ESCAPE '\\' OR e.title LIKE ? ESCAPE '\\')
|
||||
ORDER BY sh.updated_at DESC LIMIT ?`,
|
||||
[...projectIds, pattern, pattern, pattern, perType]
|
||||
[...projectIds, pattern, pattern, pattern, pattern, pattern, pattern, perType]
|
||||
);
|
||||
for (const row of shots) {
|
||||
results.push(baseResult("shot", "镜头", row, "director", row.title, `${row.project_name} · E${String(row.episode_number).padStart(2, "0")} · S${String(row.shot_number).padStart(2, "0")}`, row.status));
|
||||
|
||||
+1321
-31
File diff suppressed because it is too large
Load Diff
+18
-9
@@ -73,6 +73,7 @@ import {
|
||||
ProjectAssistantPage,
|
||||
VoiceStudioPage
|
||||
} from "./components/CreatorSuitePages";
|
||||
import { KnowledgeLibraryPage } from "./components/KnowledgeLibraryPage";
|
||||
import {
|
||||
CastingBoardPage,
|
||||
DeliveryAccessPage,
|
||||
@@ -110,7 +111,8 @@ const navigationGroups = [
|
||||
{ id: "creator-home", label: "我的工作台", icon: RadioTower },
|
||||
{ id: "tasks", label: "协作任务", icon: ListChecks, requiredPermission: "task:view" },
|
||||
{ id: "assistant", label: "项目 AI 助手", icon: Bot },
|
||||
{ id: "asset-library", label: "资产库", icon: Images, requiredPermission: "asset:edit" },
|
||||
{ id: "knowledge", label: "内容知识库", icon: FileJson2, requiredPermission: "script:read" },
|
||||
{ id: "asset-library", label: "资产库", icon: Images, requiredAnyPermissions: ["asset:edit", "voice:edit", "compliance:manage"] },
|
||||
{ id: "voice-studio", label: "声音与字幕", icon: AudioLines, requiredPermission: "voice:edit" },
|
||||
{ id: "batch-production", label: "批量生产", icon: ListPlus, requiredPermission: "job:create" }
|
||||
]
|
||||
@@ -128,13 +130,13 @@ const navigationGroups = [
|
||||
{ id: "overview", label: "生产总控", icon: RadioTower, requiredPermission: "usage:view" },
|
||||
{ id: "factory", label: "项目工厂", icon: Layers3, requiredPermission: "project:create" },
|
||||
{ id: "script", label: "剧本拆解", icon: BookOpen, requiredPermission: "script:edit" },
|
||||
{ id: "casting", label: "资产与选角", icon: ShieldCheck, requiredPermission: "asset:edit" },
|
||||
{ id: "casting", label: "资产与选角", icon: ShieldCheck, requiredAnyPermissions: ["asset:edit", "compliance:manage"] },
|
||||
{ id: "director", label: "导演工作台", icon: Clapperboard, requiredPermission: "job:create" },
|
||||
{ id: "jobs", label: "生成任务", icon: Wand2, requiredPermission: "job:create" },
|
||||
{ id: "bible", label: "系列 Bible", icon: BookOpen, requiredPermission: "script:edit" },
|
||||
{ id: "locks", label: "连续性锁", icon: ShieldCheck, requiredPermission: "asset:edit" },
|
||||
{ id: "locks", label: "连续性锁", icon: ShieldCheck, requiredAnyPermissions: ["asset:edit", "compliance:manage"] },
|
||||
{ id: "shots", label: "镜头清单", icon: Clapperboard, requiredPermission: "job:create" },
|
||||
{ id: "modelops", label: "模型中台", icon: Network, requiredPermission: "model:manage" },
|
||||
{ id: "modelops", label: "模型中台", icon: Network, requiredAnyPermissions: ["model:manage", "model:approve"] },
|
||||
{ id: "qa", label: "审片中心", icon: ListChecks, requiredPermission: "qa:review" },
|
||||
{ id: "compliance", label: "成本合规", icon: Scale, requiredPermission: "usage:view" },
|
||||
{ id: "export", label: "交付运营", icon: Download, requiredPermission: "delivery:view" },
|
||||
@@ -147,7 +149,7 @@ const navigationGroups = [
|
||||
{ id: "admin-overview", label: "管理概览", icon: Gauge, requiredPermission: "usage:view" },
|
||||
{ id: "admin-organizations", label: "组织与工作区", icon: Building2, requiredPermission: "organization:manage" },
|
||||
{ id: "admin-members", label: "用户与权限", icon: Users, requiredPermission: "organization:members:invite" },
|
||||
{ id: "admin-models", label: "模型与 Runner", icon: Network, requiredPermission: "model:manage" },
|
||||
{ id: "admin-models", label: "模型与 Runner", icon: Network, requiredAnyPermissions: ["model:manage", "model:approve"] },
|
||||
{ id: "admin-queue", label: "队列与任务", icon: Wand2, requiredPermission: "queue:manage" },
|
||||
{ id: "admin-usage", label: "用量与成本", icon: Coins, requiredPermission: "usage:view" },
|
||||
{ id: "admin-audit", label: "审计与合规", icon: ListChecks, requiredPermission: "audit:view" }
|
||||
@@ -1706,9 +1708,9 @@ function App() {
|
||||
projectId: activeProjectId,
|
||||
episodeId: activeEpisodeId
|
||||
};
|
||||
const enterpriseArea = activeTab === "creator-home" || activeTab === "tasks" || activeTab === "assistant" || activeTab === "asset-library" || activeTab === "voice-studio" || activeTab === "batch-production" || activeTab === "account" || activeTab === "factory" || activeTab === "script" || activeTab === "casting" || activeTab === "director" || activeTab === "jobs" || activeTab === "bible" || activeTab === "locks" || activeTab === "shots" || activeTab === "modelops" || activeTab === "qa" || activeTab === "export" || activeTab === "delivery-portal" || activeTab.startsWith("admin-") || activeTab.startsWith("system-");
|
||||
const enterpriseArea = activeTab === "creator-home" || activeTab === "tasks" || activeTab === "assistant" || activeTab === "knowledge" || activeTab === "asset-library" || activeTab === "voice-studio" || activeTab === "batch-production" || activeTab === "account" || activeTab === "factory" || activeTab === "script" || activeTab === "casting" || activeTab === "director" || activeTab === "jobs" || activeTab === "bible" || activeTab === "locks" || activeTab === "shots" || activeTab === "modelops" || activeTab === "qa" || activeTab === "export" || activeTab === "delivery-portal" || activeTab.startsWith("admin-") || activeTab.startsWith("system-");
|
||||
const effectivePermissions = new Set(platformContext?.context?.permissions || []);
|
||||
const canViewAdmin = ["workspace:create", "model:manage", "usage:view", "audit:view", "queue:manage"].some((permission) => effectivePermissions.has(permission));
|
||||
const canViewAdmin = ["workspace:create", "model:manage", "model:approve", "usage:view", "compliance:manage", "audit:view", "queue:manage"].some((permission) => effectivePermissions.has(permission));
|
||||
const canViewSystem = platformContext?.context?.systemAdmin || effectivePermissions.has("system:settings:view");
|
||||
const canCreateProject = effectivePermissions.has("project:create");
|
||||
const canCreateJob = effectivePermissions.has("job:create");
|
||||
@@ -1716,9 +1718,14 @@ function App() {
|
||||
const canManageProject = effectivePermissions.has("project:manage");
|
||||
const canApproveDelivery = effectivePermissions.has("delivery:approve");
|
||||
const canApproveVoice = effectivePermissions.has("voice:approve");
|
||||
const canApproveAssets = effectivePermissions.has("compliance:manage");
|
||||
const canSeeNavigationItem = (item) => {
|
||||
if (item.requiredAnyPermissions) return item.requiredAnyPermissions.some((permission) => effectivePermissions.has(permission));
|
||||
return !item.requiredPermission || effectivePermissions.has(item.requiredPermission);
|
||||
};
|
||||
const visibleNavigationGroups = navigationGroups
|
||||
.filter((group) => group.label === "创作套件" || group.label === "账户" || group.label === "生产流水线" || (group.label === "管理员后台" && canViewAdmin) || (group.label === "系统设置" && canViewSystem))
|
||||
.map((group) => ({ ...group, items: group.items.filter((item) => !item.requiredPermission || effectivePermissions.has(item.requiredPermission)) }))
|
||||
.map((group) => ({ ...group, items: group.items.filter(canSeeNavigationItem) }))
|
||||
.filter((group) => group.items.length);
|
||||
const activeNavigationItem = visibleNavigationGroups.flatMap((group) => group.items).find((item) => item.id === activeTab);
|
||||
|
||||
@@ -1830,7 +1837,9 @@ function App() {
|
||||
|
||||
{activeTab === "assistant" && <ProjectAssistantPage project={project} setActiveTab={setActiveTab} allowedTabs={visibleNavigationGroups.flatMap((group) => group.items).map((item) => item.id)} />}
|
||||
|
||||
{activeTab === "asset-library" && <AssetLibraryPage project={project} contextOverrides={contextOverrides} />}
|
||||
{activeTab === "knowledge" && <KnowledgeLibraryPage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
||||
|
||||
{activeTab === "asset-library" && <AssetLibraryPage project={project} contextOverrides={contextOverrides} canApproveAssets={canApproveAssets} />}
|
||||
|
||||
{activeTab === "voice-studio" && <VoiceStudioPage project={project} onQueueJob={enqueueJob} contextOverrides={contextOverrides} canApproveVoice={canApproveVoice} />}
|
||||
|
||||
|
||||
@@ -37,10 +37,12 @@ import {
|
||||
createAssetVersion,
|
||||
fetchAsset,
|
||||
fetchAssetContent,
|
||||
fetchAssetGovernanceReviews,
|
||||
fetchAssets,
|
||||
fetchJobs,
|
||||
queryAssistant,
|
||||
restoreAssetVersion,
|
||||
scanAssetGovernance,
|
||||
updateAssetLock,
|
||||
updateAssetRights,
|
||||
uploadAsset,
|
||||
@@ -82,6 +84,10 @@ function SuiteStatus({ status }) {
|
||||
review: ["待复核", "warn"],
|
||||
archived: ["已归档", "neutral"],
|
||||
"needs-evidence": ["待版权证据", "warn"],
|
||||
submitted: ["已提交", "warn"],
|
||||
rejected: ["已驳回", "warn"],
|
||||
expired: ["已过期", "warn"],
|
||||
clear: ["低风险", "ok"],
|
||||
completed: ["已完成", "ok"],
|
||||
approved: ["已确认", "ok"],
|
||||
"needs-user-approved-reference": ["待参考音频", "warn"]
|
||||
@@ -156,10 +162,18 @@ function normalizeAsset(row) {
|
||||
initial: metadata.initial || row.name?.slice(0, 1) || "资",
|
||||
subtitle: metadata.subtitle || "本地项目资产",
|
||||
usage: metadata.usage || "当前项目",
|
||||
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
|
||||
tags: Array.isArray(currentVersion.tags) && currentVersion.tags.length ? currentVersion.tags : Array.isArray(metadata.tags) ? metadata.tags : [],
|
||||
detail: metadata.detail || "待补充资产描述",
|
||||
lock: metadata.lock || "待补充连续性备注",
|
||||
rightsStatus: row.currentVersion?.rights_status || "needs-evidence",
|
||||
provenance: currentVersion.provenance || metadata.provenance || {},
|
||||
risk: currentVersion.risk || metadata.risk || {},
|
||||
riskStatus: currentVersion.risk?.status || metadata.risk?.status || "review",
|
||||
riskScore: Number(currentVersion.risk?.score ?? metadata.risk?.score ?? 0),
|
||||
rightsEvidence: metadata.rightsEvidence || {},
|
||||
licenseScope: currentVersion.licenseScope || currentVersion.license_scope || "",
|
||||
expiresAt: currentVersion.expiresAt || currentVersion.expires_at || "",
|
||||
governanceReviews: row.governanceReviews || [],
|
||||
versions: row.versions || [],
|
||||
bindings: row.bindings || [],
|
||||
fileName: currentVersion.fileName || currentVersion.file_name || "",
|
||||
@@ -188,7 +202,144 @@ function inferAssetKind(file) {
|
||||
return "reference";
|
||||
}
|
||||
|
||||
export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
const sourceTypeLabels = {
|
||||
original: "原创",
|
||||
commissioned: "委托创作",
|
||||
licensed: "授权素材",
|
||||
"user-upload": "用户导入",
|
||||
"generated-local": "本地模型生成",
|
||||
"public-domain": "公版/开放授权",
|
||||
"third-party-reference": "第三方参考"
|
||||
};
|
||||
|
||||
const licenseScopeLabels = {
|
||||
project: "仅当前项目",
|
||||
series: "当前系列",
|
||||
organization: "组织内复用",
|
||||
commercial: "商用发行",
|
||||
"review-only": "仅审核参考"
|
||||
};
|
||||
|
||||
function assetGovernanceDefaults(asset) {
|
||||
const provenance = asset?.provenance || {};
|
||||
const evidence = asset?.rightsEvidence || {};
|
||||
return {
|
||||
rightsStatus: asset?.rightsStatus || "needs-evidence",
|
||||
sourceType: provenance.sourceType || "original",
|
||||
sourceName: provenance.sourceName || asset?.name || "",
|
||||
sourceRef: provenance.sourceRef || "",
|
||||
evidenceReference: evidence.reference || evidence.consentRef || provenance.evidenceRef || "",
|
||||
licenseScope: asset?.licenseScope || "project",
|
||||
expiresAt: asset?.expiresAt ? asset.expiresAt.slice(0, 10) : "",
|
||||
notes: evidence.notes || ""
|
||||
};
|
||||
}
|
||||
|
||||
function assetGovernancePayload(form, rightsStatus) {
|
||||
return {
|
||||
rightsStatus,
|
||||
provenance: {
|
||||
sourceType: form.sourceType,
|
||||
sourceName: form.sourceName,
|
||||
sourceRef: form.sourceRef,
|
||||
evidenceRef: form.evidenceReference
|
||||
},
|
||||
evidence: {
|
||||
reference: form.evidenceReference,
|
||||
source: "asset-library-governance",
|
||||
notes: form.notes
|
||||
},
|
||||
licenseScope: form.licenseScope,
|
||||
expiresAt: form.expiresAt || null,
|
||||
notes: form.notes
|
||||
};
|
||||
}
|
||||
|
||||
function AssetInspector({
|
||||
selected,
|
||||
busy,
|
||||
previewLoading,
|
||||
previewText,
|
||||
previewUrl,
|
||||
previewType,
|
||||
previewError,
|
||||
rightsForm,
|
||||
setRightsForm,
|
||||
reviewHistory,
|
||||
canApproveAssets,
|
||||
onScan,
|
||||
onRefreshReviews,
|
||||
onRightsDecision,
|
||||
onVerify,
|
||||
onDownload,
|
||||
onShowVersions,
|
||||
onShowBinding,
|
||||
onToggleLock
|
||||
}) {
|
||||
const risk = selected.risk || {};
|
||||
const issues = risk.issues || [];
|
||||
const evidencePresent = Boolean(String(rightsForm.evidenceReference || "").trim());
|
||||
const sourcePresent = Boolean(String(rightsForm.sourceType || rightsForm.sourceName || rightsForm.sourceRef || "").trim());
|
||||
const canApproveCurrent = canApproveAssets && evidencePresent && (selected.rawKind === "voice" || sourcePresent) && selected.riskStatus !== "blocked";
|
||||
const updateField = (key, value) => setRightsForm((current) => ({ ...current, [key]: value }));
|
||||
return (
|
||||
<aside className="asset-inspector commercial-asset-inspector">
|
||||
<div className="inspector-heading">
|
||||
<div><span className="card-kicker">ASSET GOVERNANCE</span><h3>{selected.name}</h3></div>
|
||||
<SuiteStatus status={selected.status} />
|
||||
</div>
|
||||
{previewLoading ? <div className="asset-preview-placeholder"><RefreshCw size={17} />读取本地预览…</div> : previewText ? <pre className="asset-text-preview">{previewText}</pre> : previewUrl && previewType.startsWith("image/") ? <img className="asset-media-preview" src={previewUrl} alt={`${selected.name} 预览`} /> : previewUrl && previewType.startsWith("audio/") ? <audio className="asset-media-preview" controls src={previewUrl} /> : previewUrl && previewType.startsWith("video/") ? <video className="asset-media-preview" controls src={previewUrl} /> : <AssetVisual asset={selected} />}
|
||||
{previewError && <p className="asset-preview-error">文件预览不可用:{previewError}</p>}
|
||||
{selected.fileName && <div className="asset-file-meta"><FileText size={14} /><div><strong>{selected.fileName}</strong><span>{formatBytes(selected.fileSize)} · {selected.mimeType}</span></div></div>}
|
||||
{selected.contentSha256 && <div className="asset-integrity-line"><span>SHA-256</span><code title={selected.contentSha256}>{selected.contentSha256.slice(0, 16)}…</code><button className="icon-text-button" onClick={onVerify} disabled={busy}><Check size={13} />校验</button></div>}
|
||||
<dl className="suite-definition-list">
|
||||
<dt>类型</dt><dd>{selected.kind}</dd>
|
||||
<dt>版本</dt><dd>{selected.version}</dd>
|
||||
<dt>文件大小</dt><dd>{selected.fileSize ? formatBytes(selected.fileSize) : "无文件"}</dd>
|
||||
<dt>存储路径</dt><dd className="mono">{selected.currentVersion?.storage_path || "仅有元数据"}</dd>
|
||||
<dt>使用范围</dt><dd>{selected.usage}</dd>
|
||||
<dt>版权/授权</dt><dd><SuiteStatus status={selected.rightsStatus} /></dd>
|
||||
<dt>风险</dt><dd><SuiteStatus status={selected.riskStatus} /></dd>
|
||||
<dt>绑定镜头</dt><dd>{selected.bindings.length ? selected.bindings.map((item) => item.shot_id).join("、") : "尚未绑定"}</dd>
|
||||
</dl>
|
||||
<div className={`asset-risk-panel ${selected.riskStatus}`}>
|
||||
<div><CircleAlert size={15} /><strong>风险分 {selected.riskScore || risk.score || 0}</strong><SuiteStatus status={selected.riskStatus} /></div>
|
||||
{issues.length ? <div className="asset-risk-list">{issues.map((issue) => <div key={`${issue.code}-${issue.label}`}><span>{issue.severity}</span><strong>{issue.label}</strong><p>{issue.message}</p></div>)}</div> : <p>当前版本未发现阻断项。</p>}
|
||||
</div>
|
||||
<form className="asset-governance-form" onSubmit={(event) => { event.preventDefault(); onRightsDecision("submitted"); }}>
|
||||
<div className="governance-form-grid">
|
||||
<label>来源类型<select value={rightsForm.sourceType} onChange={(event) => updateField("sourceType", event.target.value)}>{Object.entries(sourceTypeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>授权范围<select value={rightsForm.licenseScope} onChange={(event) => updateField("licenseScope", event.target.value)}>{Object.entries(licenseScopeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>来源名称<input value={rightsForm.sourceName} onChange={(event) => updateField("sourceName", event.target.value)} placeholder="原创角色设定、委托合同或素材包名" /></label>
|
||||
<label>来源引用<input value={rightsForm.sourceRef} onChange={(event) => updateField("sourceRef", event.target.value)} placeholder="本地路径、合同编号或导入批次" /></label>
|
||||
<label className="full-span">授权证据<input value={rightsForm.evidenceReference} onChange={(event) => updateField("evidenceReference", event.target.value)} placeholder="授权书、合同、同意书或本地证据路径" /></label>
|
||||
<label>到期日期<input type="date" value={rightsForm.expiresAt} onChange={(event) => updateField("expiresAt", event.target.value)} /></label>
|
||||
<label className="full-span">审核备注<textarea value={rightsForm.notes} onChange={(event) => updateField("notes", event.target.value)} rows="3" placeholder="授权边界、禁止事项、复用限制" /></label>
|
||||
</div>
|
||||
<div className="governance-action-row">
|
||||
<button type="button" className="subtle" onClick={onScan} disabled={busy}><ShieldCheck size={15} />风险扫描</button>
|
||||
<button type="submit" className="subtle" disabled={busy || !evidencePresent}><FileText size={15} />提交证据</button>
|
||||
<button type="button" className="primary" onClick={() => onRightsDecision("approved")} disabled={busy || !canApproveCurrent}><CheckCircle2 size={15} />批准授权</button>
|
||||
<button type="button" className="danger-button" onClick={() => onRightsDecision("rejected")} disabled={busy}><XCircle size={15} />驳回</button>
|
||||
</div>
|
||||
</form>
|
||||
<div className="inspector-copy"><span>视觉锁</span><p>{selected.detail}</p></div>
|
||||
<div className="inspector-copy"><span>连续性备注</span><p>{selected.lock}</p></div>
|
||||
<div className="asset-review-history">
|
||||
<div className="asset-review-head"><strong>审核记录</strong><button className="icon-text-button" onClick={onRefreshReviews} disabled={busy}><RefreshCw size={13} />刷新</button></div>
|
||||
{reviewHistory.length ? reviewHistory.slice(0, 5).map((review) => <div key={review.id} className="asset-review-row"><div><strong>{review.reviewerName || review.reviewer_user_id}</strong><span>{review.created_at?.replace("T", " ").slice(0, 19)}</span></div><SuiteStatus status={review.rights_status} /><small>{review.notes || review.evidence_ref || "已记录治理动作"}</small></div>) : <div className="asset-review-empty">暂无审核记录</div>}
|
||||
</div>
|
||||
<div className="inspector-actions">
|
||||
<button className="subtle" onClick={onShowVersions}><RefreshCw size={15} />版本记录</button>
|
||||
<button className="subtle" onClick={onDownload} disabled={busy || !selected.fileSize}><Download size={15} />下载当前版</button>
|
||||
<button className="primary" onClick={onShowBinding}><LockKeyhole size={15} />绑定到镜头</button>
|
||||
<button className="subtle" onClick={onToggleLock} disabled={busy}><ShieldCheck size={15} />{selected.status === "locked" ? "转待复核" : "锁定版本"}</button>
|
||||
</div>
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function AssetLibraryPage({ project, contextOverrides, canApproveAssets = false }) {
|
||||
const [filter, setFilter] = useState("全部");
|
||||
const [query, setQuery] = useState("");
|
||||
const [assets, setAssets] = useState([]);
|
||||
@@ -206,8 +357,10 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
const [showUpload, setShowUpload] = useState(false);
|
||||
const [showVersions, setShowVersions] = useState(false);
|
||||
const [showBinding, setShowBinding] = useState(false);
|
||||
const [assetForm, setAssetForm] = useState({ name: "", kind: "reference", subtitle: "本地项目资产", tags: "", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
const [uploadForm, setUploadForm] = useState({ name: "", kind: "reference", subtitle: "本地导入资产", tags: "本地导入, 待版权证据", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
const [assetForm, setAssetForm] = useState({ name: "", kind: "reference", subtitle: "本地项目资产", tags: "", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence", sourceType: "original", sourceName: "", sourceRef: "", licenseScope: "project" });
|
||||
const [uploadForm, setUploadForm] = useState({ name: "", kind: "reference", subtitle: "本地导入资产", tags: "本地导入, 待版权证据", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence", sourceType: "user-upload", sourceName: "", sourceRef: "", licenseScope: "project" });
|
||||
const [rightsForm, setRightsForm] = useState(assetGovernanceDefaults(null));
|
||||
const [reviewHistory, setReviewHistory] = useState([]);
|
||||
const [pendingFile, setPendingFile] = useState(null);
|
||||
const [bindingForm, setBindingForm] = useState({ shotId: project.shots[0]?.id || "", usageRole: "continuity" });
|
||||
const inputRef = useRef(null);
|
||||
@@ -233,6 +386,11 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
|
||||
useEffect(() => { loadAssets(); }, [project.series?.id, contextOverrides?.organizationId, contextOverrides?.workspaceId, contextOverrides?.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
setRightsForm(assetGovernanceDefaults(selected));
|
||||
setReviewHistory(selected?.governanceReviews || []);
|
||||
}, [selected?.id, selected?.currentVersionId]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let objectUrl = "";
|
||||
@@ -242,7 +400,8 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
setPreviewText("");
|
||||
setPreviewError("");
|
||||
setPreviewLoading(false);
|
||||
if (!selected?.id || !selected.currentVersion?.storage_path || selected.currentVersion.storage_path.endsWith("metadata.json")) return;
|
||||
const storagePath = selected?.currentVersion?.storage_path || "";
|
||||
if (!selected?.id || !storagePath || storagePath.endsWith("metadata.json") || !storagePath.startsWith("storage/")) return;
|
||||
setPreviewLoading(true);
|
||||
try {
|
||||
const payload = await fetchAssetContent(selected.id, contextOverrides);
|
||||
@@ -280,7 +439,7 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
const checked = validateAssetFile(file);
|
||||
const name = checked.name.replace(/\.[^.]+$/, "") || checked.name;
|
||||
setPendingFile(checked);
|
||||
setUploadForm({ name, kind: inferAssetKind(checked), subtitle: "本地导入资产", tags: "本地导入, 待版权证据", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
setUploadForm({ name, kind: inferAssetKind(checked), subtitle: "本地导入资产", tags: "本地导入, 待版权证据", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence", sourceType: "user-upload", sourceName: checked.name, sourceRef: `local-upload/${checked.name}`, licenseScope: "project" });
|
||||
setShowUpload(true);
|
||||
setNotice("");
|
||||
} catch (error) {
|
||||
@@ -316,7 +475,9 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
lock: uploadForm.lock,
|
||||
mimeType: pendingFile.type || "application/octet-stream",
|
||||
rightsStatus: uploadForm.rightsStatus,
|
||||
tags: uploadForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean)
|
||||
tags: uploadForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean),
|
||||
provenance: { sourceType: uploadForm.sourceType, sourceName: uploadForm.sourceName || pendingFile.name, sourceRef: uploadForm.sourceRef },
|
||||
licenseScope: uploadForm.licenseScope
|
||||
}, contextOverrides);
|
||||
replaceAsset(asset.asset);
|
||||
setShowUpload(false);
|
||||
@@ -334,10 +495,16 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
if (!assetForm.name.trim()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await createAsset({ ...assetForm, name: assetForm.name.trim(), tags: assetForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean) }, contextOverrides);
|
||||
const payload = await createAsset({
|
||||
...assetForm,
|
||||
name: assetForm.name.trim(),
|
||||
tags: assetForm.tags.split(",").map((tag) => tag.trim()).filter(Boolean),
|
||||
provenance: { sourceType: assetForm.sourceType, sourceName: assetForm.sourceName || assetForm.name.trim(), sourceRef: assetForm.sourceRef },
|
||||
licenseScope: assetForm.licenseScope
|
||||
}, contextOverrides);
|
||||
replaceAsset(payload.asset);
|
||||
setShowCreate(false);
|
||||
setAssetForm({ name: "", kind: "reference", subtitle: "本地项目资产", tags: "", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence" });
|
||||
setAssetForm({ name: "", kind: "reference", subtitle: "本地项目资产", tags: "", usage: "当前项目", detail: "", lock: "", rightsStatus: "needs-evidence", sourceType: "original", sourceName: "", sourceRef: "", licenseScope: "project" });
|
||||
setNotice(`资产“${payload.asset.name}”已创建为 v1 草稿。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
@@ -468,6 +635,50 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGovernanceReviews() {
|
||||
if (!selected?.id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await fetchAssetGovernanceReviews(selected.id, contextOverrides);
|
||||
setReviewHistory(payload.reviews || []);
|
||||
setNotice(`${selected.name} 的审核记录已刷新。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function scanSelectedGovernance() {
|
||||
if (!selected?.id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await scanAssetGovernance(selected.id, assetGovernancePayload(rightsForm, rightsForm.rightsStatus), contextOverrides);
|
||||
const next = replaceAsset(payload.asset);
|
||||
setReviewHistory(next.governanceReviews || []);
|
||||
setNotice(`${selected.name} 风险扫描完成:${payload.scan.status === "blocked" ? "存在阻断项" : payload.scan.status === "review" ? "需要复核" : "低风险"},评分 ${payload.scan.score}。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function updateSelectedRights(rightsStatus) {
|
||||
if (!selected?.id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const payload = await updateAssetRights(selected.id, assetGovernancePayload(rightsForm, rightsStatus), contextOverrides);
|
||||
const next = replaceAsset(payload.asset);
|
||||
setReviewHistory(next.governanceReviews || []);
|
||||
setNotice(`${selected.name} 授权状态已更新为 ${rightsStatus === "approved" ? "已批准" : rightsStatus === "submitted" ? "已提交" : rightsStatus === "rejected" ? "已驳回" : rightsStatus}。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="suite-page">
|
||||
<SuiteHeader
|
||||
@@ -499,14 +710,60 @@ export function AssetLibraryPage({ project, contextOverrides }) {
|
||||
<button className="icon-text-button" onClick={() => inputRef.current?.click()} disabled={busy}><Upload size={14} />选择文件</button>
|
||||
</div>
|
||||
<div className="asset-card-grid">
|
||||
{filtered.map((asset) => <button key={asset.id} className={`asset-card ${selected?.id === asset.id ? "selected" : ""}`} onClick={() => setSelectedId(asset.id)}><AssetVisual asset={asset} /><div className="asset-card-body"><div className="asset-card-title"><strong>{asset.name}</strong><SuiteStatus status={asset.status} /></div><span>{asset.kind} · {asset.subtitle}</span><div className="asset-tag-row">{asset.tags.slice(0, 2).map((tag) => <em key={tag}>{tag}</em>)}</div><small>{asset.version} · {asset.usage}</small></div></button>)}
|
||||
{filtered.map((asset) => <button key={asset.id} className={`asset-card ${selected?.id === asset.id ? "selected" : ""}`} onClick={() => setSelectedId(asset.id)}><AssetVisual asset={asset} /><div className="asset-card-body"><div className="asset-card-title"><strong>{asset.name}</strong><span className="asset-card-statuses"><SuiteStatus status={asset.status} /><SuiteStatus status={asset.rightsStatus} /><SuiteStatus status={asset.riskStatus} /></span></div><span>{asset.kind} · {asset.subtitle}</span><div className="asset-tag-row">{asset.tags.slice(0, 2).map((tag) => <em key={tag}>{tag}</em>)}</div><small>{asset.version} · {asset.usage}</small></div></button>)}
|
||||
</div>
|
||||
{!filtered.length && <div className="suite-empty"><Search size={18} />{loading ? "正在读取资产库…" : "没有匹配资产"}</div>}
|
||||
</section>
|
||||
{selected && <aside className="asset-inspector"><div className="inspector-heading"><div><span className="card-kicker">ASSET INSPECTOR</span><h3>{selected.name}</h3></div><SuiteStatus status={selected.status} /></div>{previewLoading ? <div className="asset-preview-placeholder"><RefreshCw size={17} />读取本地预览…</div> : previewText ? <pre className="asset-text-preview">{previewText}</pre> : previewUrl && previewType.startsWith("image/") ? <img className="asset-media-preview" src={previewUrl} alt={`${selected.name} 预览`} /> : previewUrl && previewType.startsWith("audio/") ? <audio className="asset-media-preview" controls src={previewUrl} /> : previewUrl && previewType.startsWith("video/") ? <video className="asset-media-preview" controls src={previewUrl} /> : <AssetVisual asset={selected} />}{previewError && <p className="asset-preview-error">文件预览不可用:{previewError}</p>}{selected.fileName && <div className="asset-file-meta"><FileText size={14} /><div><strong>{selected.fileName}</strong><span>{formatBytes(selected.fileSize)} · {selected.mimeType}</span></div></div>}{selected.contentSha256 && <div className="asset-integrity-line"><span>SHA-256</span><code title={selected.contentSha256}>{selected.contentSha256.slice(0, 16)}…</code><button className="icon-text-button" onClick={verifySelected} disabled={busy}><Check size={13} />校验</button></div>}<dl className="suite-definition-list"><dt>类型</dt><dd>{selected.kind}</dd><dt>版本</dt><dd>{selected.version}</dd><dt>文件大小</dt><dd>{selected.fileSize ? formatBytes(selected.fileSize) : "无文件"}</dd><dt>存储路径</dt><dd className="mono">{selected.currentVersion?.storage_path || "仅有元数据"}</dd><dt>使用范围</dt><dd>{selected.usage}</dd><dt>版权/授权</dt><dd>{selected.rightsStatus === "approved" ? "已确认" : "待补证据"}</dd><dt>绑定镜头</dt><dd>{selected.bindings.length ? selected.bindings.map((item) => item.shot_id).join("、") : "尚未绑定"}</dd></dl><div className="inspector-copy"><span>视觉锁</span><p>{selected.detail}</p></div><div className="inspector-copy"><span>连续性备注</span><p>{selected.lock}</p></div><div className="inspector-actions"><button className="subtle" onClick={() => setShowVersions(true)}><RefreshCw size={15} />版本记录</button><button className="subtle" onClick={downloadSelected} disabled={busy || !selected.fileSize}><Download size={15} />下载当前版</button><button className="primary" onClick={() => setShowBinding(true)}><LockKeyhole size={15} />绑定到镜头</button><button className="subtle" onClick={toggleLock} disabled={busy}><ShieldCheck size={15} />{selected.status === "locked" ? "转待复核" : "锁定版本"}</button></div></aside>}
|
||||
{selected && <AssetInspector selected={selected} busy={busy} previewLoading={previewLoading} previewText={previewText} previewUrl={previewUrl} previewType={previewType} previewError={previewError} rightsForm={rightsForm} setRightsForm={setRightsForm} reviewHistory={reviewHistory} canApproveAssets={canApproveAssets} onScan={scanSelectedGovernance} onRefreshReviews={loadGovernanceReviews} onRightsDecision={updateSelectedRights} onVerify={verifySelected} onDownload={downloadSelected} onShowVersions={() => setShowVersions(true)} onShowBinding={() => setShowBinding(true)} onToggleLock={toggleLock} />}
|
||||
</div>
|
||||
{showUpload && pendingFile && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && (setShowUpload(false), setPendingFile(null))}><form className="suite-modal" onSubmit={submitUpload}><div className="suite-modal-head"><div><span className="card-kicker">LOCAL ASSET INTAKE</span><h3>导入资产</h3></div><button type="button" className="icon-only-button" onClick={() => { setShowUpload(false); setPendingFile(null); }} aria-label="关闭"><XCircle size={17} /></button></div><div className="upload-file-summary"><FileText size={19} /><div><strong>{pendingFile.name}</strong><span>{formatBytes(pendingFile.size)} · {pendingFile.type || "application/octet-stream"}</span></div><SuiteStatus status="needs-evidence" /></div><div className="suite-form-grid"><label>资产名称<input value={uploadForm.name} onChange={(event) => setUploadForm((current) => ({ ...current, name: event.target.value }))} required /></label><label>资产类型<select value={uploadForm.kind} onChange={(event) => setUploadForm((current) => ({ ...current, kind: event.target.value }))}>{assetKindOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label>副标题<input value={uploadForm.subtitle} onChange={(event) => setUploadForm((current) => ({ ...current, subtitle: event.target.value }))} /></label><label>使用范围<input value={uploadForm.usage} onChange={(event) => setUploadForm((current) => ({ ...current, usage: event.target.value }))} /></label><label>版权状态<select value={uploadForm.rightsStatus} onChange={(event) => setUploadForm((current) => ({ ...current, rightsStatus: event.target.value }))}><option value="needs-evidence">待版权证据</option><option value="submitted">已提交证据</option></select></label><label>标签(逗号分隔)<input value={uploadForm.tags} onChange={(event) => setUploadForm((current) => ({ ...current, tags: event.target.value }))} /></label><label className="full-span">视觉锁<textarea value={uploadForm.detail} onChange={(event) => setUploadForm((current) => ({ ...current, detail: event.target.value }))} placeholder="角色外观、场景构图、服装或风格锚点" /></label><label className="full-span">连续性备注<textarea value={uploadForm.lock} onChange={(event) => setUploadForm((current) => ({ ...current, lock: event.target.value }))} placeholder="后续镜头必须继承的锁定信息" /></label></div><div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => { setShowUpload(false); setPendingFile(null); }}>取消</button><button type="submit" className="primary" disabled={busy}><Upload size={15} />{busy ? "写入中…" : "写入资产库"}</button></div></form></div>}
|
||||
{showCreate && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowCreate(false)}><form className="suite-modal" onSubmit={submitCreate}><div className="suite-modal-head"><div><span className="card-kicker">NEW ASSET</span><h3>新建资产</h3></div><button type="button" className="icon-only-button" onClick={() => setShowCreate(false)} aria-label="关闭"><XCircle size={17} /></button></div><div className="suite-form-grid"><label>资产名称<input value={assetForm.name} onChange={(event) => setAssetForm((current) => ({ ...current, name: event.target.value }))} autoFocus required /></label><label>资产类型<select value={assetForm.kind} onChange={(event) => setAssetForm((current) => ({ ...current, kind: event.target.value }))}>{assetKindOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label><label>副标题<input value={assetForm.subtitle} onChange={(event) => setAssetForm((current) => ({ ...current, subtitle: event.target.value }))} /></label><label>使用范围<input value={assetForm.usage} onChange={(event) => setAssetForm((current) => ({ ...current, usage: event.target.value }))} /></label><label className="full-span">标签(逗号分隔)<input value={assetForm.tags} onChange={(event) => setAssetForm((current) => ({ ...current, tags: event.target.value }))} placeholder="角色锁, 原创, 待审核" /></label><label className="full-span">视觉锁<textarea value={assetForm.detail} onChange={(event) => setAssetForm((current) => ({ ...current, detail: event.target.value }))} /></label><label className="full-span">连续性备注<textarea value={assetForm.lock} onChange={(event) => setAssetForm((current) => ({ ...current, lock: event.target.value }))} /></label></div><div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => setShowCreate(false)}>取消</button><button type="submit" className="primary" disabled={busy}><Plus size={15} />创建资产</button></div></form></div>}
|
||||
{showUpload && pendingFile && (
|
||||
<div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && (setShowUpload(false), setPendingFile(null))}>
|
||||
<form className="suite-modal" onSubmit={submitUpload}>
|
||||
<div className="suite-modal-head">
|
||||
<div><span className="card-kicker">LOCAL ASSET INTAKE</span><h3>导入资产</h3></div>
|
||||
<button type="button" className="icon-only-button" onClick={() => { setShowUpload(false); setPendingFile(null); }} aria-label="关闭"><XCircle size={17} /></button>
|
||||
</div>
|
||||
<div className="upload-file-summary"><FileText size={19} /><div><strong>{pendingFile.name}</strong><span>{formatBytes(pendingFile.size)} · {pendingFile.type || "application/octet-stream"}</span></div><SuiteStatus status="needs-evidence" /></div>
|
||||
<div className="suite-form-grid">
|
||||
<label>资产名称<input value={uploadForm.name} onChange={(event) => setUploadForm((current) => ({ ...current, name: event.target.value }))} required /></label>
|
||||
<label>资产类型<select value={uploadForm.kind} onChange={(event) => setUploadForm((current) => ({ ...current, kind: event.target.value }))}>{assetKindOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>副标题<input value={uploadForm.subtitle} onChange={(event) => setUploadForm((current) => ({ ...current, subtitle: event.target.value }))} /></label>
|
||||
<label>使用范围<input value={uploadForm.usage} onChange={(event) => setUploadForm((current) => ({ ...current, usage: event.target.value }))} /></label>
|
||||
<label>来源类型<select value={uploadForm.sourceType} onChange={(event) => setUploadForm((current) => ({ ...current, sourceType: event.target.value }))}>{Object.entries(sourceTypeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>授权范围<select value={uploadForm.licenseScope} onChange={(event) => setUploadForm((current) => ({ ...current, licenseScope: event.target.value }))}>{Object.entries(licenseScopeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>来源引用<input value={uploadForm.sourceRef} onChange={(event) => setUploadForm((current) => ({ ...current, sourceRef: event.target.value }))} /></label>
|
||||
<label>版权状态<select value={uploadForm.rightsStatus} onChange={(event) => setUploadForm((current) => ({ ...current, rightsStatus: event.target.value }))}><option value="needs-evidence">待版权证据</option><option value="submitted">已提交证据</option></select></label>
|
||||
<label className="full-span">标签(逗号分隔)<input value={uploadForm.tags} onChange={(event) => setUploadForm((current) => ({ ...current, tags: event.target.value }))} /></label>
|
||||
<label className="full-span">视觉锁<textarea value={uploadForm.detail} onChange={(event) => setUploadForm((current) => ({ ...current, detail: event.target.value }))} placeholder="角色外观、场景构图、服装或风格锚点" /></label>
|
||||
<label className="full-span">连续性备注<textarea value={uploadForm.lock} onChange={(event) => setUploadForm((current) => ({ ...current, lock: event.target.value }))} placeholder="后续镜头必须继承的锁定信息" /></label>
|
||||
</div>
|
||||
<div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => { setShowUpload(false); setPendingFile(null); }}>取消</button><button type="submit" className="primary" disabled={busy}><Upload size={15} />{busy ? "写入中…" : "写入资产库"}</button></div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
{showCreate && (
|
||||
<div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowCreate(false)}>
|
||||
<form className="suite-modal" onSubmit={submitCreate}>
|
||||
<div className="suite-modal-head">
|
||||
<div><span className="card-kicker">NEW ASSET</span><h3>新建资产</h3></div>
|
||||
<button type="button" className="icon-only-button" onClick={() => setShowCreate(false)} aria-label="关闭"><XCircle size={17} /></button>
|
||||
</div>
|
||||
<div className="suite-form-grid">
|
||||
<label>资产名称<input value={assetForm.name} onChange={(event) => setAssetForm((current) => ({ ...current, name: event.target.value }))} autoFocus required /></label>
|
||||
<label>资产类型<select value={assetForm.kind} onChange={(event) => setAssetForm((current) => ({ ...current, kind: event.target.value }))}>{assetKindOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>副标题<input value={assetForm.subtitle} onChange={(event) => setAssetForm((current) => ({ ...current, subtitle: event.target.value }))} /></label>
|
||||
<label>使用范围<input value={assetForm.usage} onChange={(event) => setAssetForm((current) => ({ ...current, usage: event.target.value }))} /></label>
|
||||
<label>来源类型<select value={assetForm.sourceType} onChange={(event) => setAssetForm((current) => ({ ...current, sourceType: event.target.value }))}>{Object.entries(sourceTypeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>授权范围<select value={assetForm.licenseScope} onChange={(event) => setAssetForm((current) => ({ ...current, licenseScope: event.target.value }))}>{Object.entries(licenseScopeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label className="full-span">来源引用<input value={assetForm.sourceRef} onChange={(event) => setAssetForm((current) => ({ ...current, sourceRef: event.target.value }))} placeholder="原创设定文档、合同编号或本地证据路径" /></label>
|
||||
<label className="full-span">标签(逗号分隔)<input value={assetForm.tags} onChange={(event) => setAssetForm((current) => ({ ...current, tags: event.target.value }))} placeholder="角色锁, 原创, 待审核" /></label>
|
||||
<label className="full-span">视觉锁<textarea value={assetForm.detail} onChange={(event) => setAssetForm((current) => ({ ...current, detail: event.target.value }))} /></label>
|
||||
<label className="full-span">连续性备注<textarea value={assetForm.lock} onChange={(event) => setAssetForm((current) => ({ ...current, lock: event.target.value }))} /></label>
|
||||
</div>
|
||||
<div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => setShowCreate(false)}>取消</button><button type="submit" className="primary" disabled={busy}><Plus size={15} />创建资产</button></div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
{showVersions && selected && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowVersions(false)}><section className="suite-modal"><div className="suite-modal-head"><div><span className="card-kicker">VERSION HISTORY</span><h3>{selected.name}</h3></div><button className="icon-only-button" onClick={() => setShowVersions(false)} aria-label="关闭"><XCircle size={17} /></button></div><div className="version-list">{selected.versions.map((version) => <div key={version.id}><div><strong>v{version.version_number}{selected.currentVersionId === version.id ? " · 当前" : ""}</strong><span>{version.metadata?.versionNote || "版本记录"}</span></div><SuiteStatus status={version.rights_status === "approved" ? "approved" : "needs-evidence"} /><small>{version.storage_path}{version.content_sha256 ? ` · SHA ${version.content_sha256.slice(0, 12)}…` : ""}</small>{selected.currentVersionId !== version.id && <button className="icon-text-button" onClick={() => restoreVersion(version)} disabled={busy}><RefreshCw size={13} />恢复此版本</button>}</div>)}</div><div className="suite-modal-actions"><button className="subtle" onClick={() => setShowVersions(false)}>关闭</button><button className="subtle" onClick={() => versionInputRef.current?.click()} disabled={busy}><Upload size={15} />上传新版本</button><button className="primary" onClick={createVersion} disabled={busy}><Plus size={15} />创建空白版本</button></div></section></div>}
|
||||
{showBinding && selected && <div className="suite-modal-backdrop" role="presentation" onMouseDown={(event) => event.target === event.currentTarget && setShowBinding(false)}><form className="suite-modal compact" onSubmit={bindSelected}><div className="suite-modal-head"><div><span className="card-kicker">SHOT BINDING</span><h3>绑定到镜头</h3></div><button type="button" className="icon-only-button" onClick={() => setShowBinding(false)} aria-label="关闭"><XCircle size={17} /></button></div><p className="suite-modal-copy">绑定后,生成任务会把“{selected.name}”的当前版本作为连续性输入。</p><div className="suite-form-grid"><label>镜头<select value={bindingForm.shotId} onChange={(event) => setBindingForm((current) => ({ ...current, shotId: event.target.value }))}>{project.shots.map((shot) => <option key={shot.id} value={shot.id}>{shot.id} · {shot.title}</option>)}</select></label><label>使用角色<select value={bindingForm.usageRole} onChange={(event) => setBindingForm((current) => ({ ...current, usageRole: event.target.value }))}><option value="character">角色</option><option value="location">场景</option><option value="prop">道具</option><option value="style">风格</option><option value="continuity">连续性</option></select></label></div><div className="suite-modal-actions"><button type="button" className="subtle" onClick={() => setShowBinding(false)}>取消</button><button type="submit" className="primary" disabled={busy}><LockKeyhole size={15} />确认绑定</button></div></form></div>}
|
||||
</div>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
LogIn,
|
||||
ListFilter,
|
||||
Network,
|
||||
Pencil,
|
||||
Play,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
@@ -70,6 +71,16 @@ import {
|
||||
createSystemBackup,
|
||||
probeModel,
|
||||
registerModel,
|
||||
fetchModelCatalog,
|
||||
createModelCatalog,
|
||||
updateModelCatalog,
|
||||
fetchModelRoutes,
|
||||
createModelRoute,
|
||||
resolveModelRoute,
|
||||
fetchModelRouteApprovals,
|
||||
requestModelRouteApproval,
|
||||
decideModelRouteApproval,
|
||||
updateModelRoute,
|
||||
retryJob,
|
||||
revokeAuthSession,
|
||||
revokeOtherAuthSessions,
|
||||
@@ -85,6 +96,8 @@ import {
|
||||
saveSystemConfig,
|
||||
startMfaSetup,
|
||||
updateFeatureFlag,
|
||||
createSystemPlanTemplate,
|
||||
updateSystemPlanTemplate,
|
||||
updateNotificationChannel,
|
||||
previewInvitation,
|
||||
registerInvitedUser,
|
||||
@@ -96,6 +109,8 @@ import {
|
||||
fetchSsoProviders,
|
||||
fetchIdentityCenter,
|
||||
fetchOrganizationCommercial,
|
||||
createCommercialApproval,
|
||||
decideCommercialApproval,
|
||||
fetchOrganizationUsage,
|
||||
fetchOrganizationInvoices,
|
||||
exportOrganizationCommercial,
|
||||
@@ -109,6 +124,7 @@ import {
|
||||
resetSystemUserPassword,
|
||||
revokeSystemUserSessions,
|
||||
updateOrganizationBilling,
|
||||
updateOrganizationEntitlement,
|
||||
generateOrganizationInvoice,
|
||||
updateOrganizationInvoiceStatus,
|
||||
updateCostCenter,
|
||||
@@ -146,8 +162,8 @@ import {
|
||||
import { downloadJson } from "../lib/exporters";
|
||||
|
||||
function toneForStatus(status) {
|
||||
if (["ready", "active", "completed", "pass", "enforced"].includes(status)) return "ok";
|
||||
if (["failed", "blocked", "error", "disabled"].includes(status)) return "warn";
|
||||
if (["ready", "active", "completed", "pass", "enforced", "approved"].includes(status)) return "ok";
|
||||
if (["failed", "blocked", "error", "disabled", "rejected", "expired", "cancelled"].includes(status)) return "warn";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
@@ -177,6 +193,13 @@ function SectionBar({ title, detail, action }) {
|
||||
return <div className="section-bar"><div><h3>{title}</h3>{detail && <span>{detail}</span>}</div>{action}</div>;
|
||||
}
|
||||
|
||||
function formatMetricValue(value, digits = 2) {
|
||||
const number = Number(value || 0);
|
||||
if (!Number.isFinite(number)) return String(value ?? "");
|
||||
if (Number.isInteger(number)) return String(number);
|
||||
return number.toFixed(digits).replace(/\.?0+$/, "");
|
||||
}
|
||||
|
||||
function ContextLink({ children, onClick }) {
|
||||
return <button className="context-link" onClick={onClick}>{children}<ArrowUpRight size={14} /></button>;
|
||||
}
|
||||
@@ -240,6 +263,36 @@ const taskPriorityLabels = {
|
||||
low: "低优先级"
|
||||
};
|
||||
|
||||
const commercialApprovalTypeLabels = {
|
||||
entitlement_overage: "套餐超额",
|
||||
feature_enablement: "功能开通",
|
||||
budget_increase: "预算提升",
|
||||
external_connector: "外部连接器",
|
||||
compliance_review: "法务复核",
|
||||
delivery_exception: "交付例外",
|
||||
storage_retention: "留存策略"
|
||||
};
|
||||
|
||||
const commercialApprovalStatusLabels = {
|
||||
submitted: "待审批",
|
||||
approved: "已通过",
|
||||
rejected: "已拒绝",
|
||||
cancelled: "已取消"
|
||||
};
|
||||
|
||||
const commercialApprovalPriorityLabels = {
|
||||
urgent: "紧急",
|
||||
high: "高",
|
||||
medium: "普通",
|
||||
low: "低"
|
||||
};
|
||||
|
||||
function approvalStatusTone(status) {
|
||||
if (status === "approved") return "active";
|
||||
if (status === "rejected" || status === "cancelled") return "failed";
|
||||
return "needs-evidence";
|
||||
}
|
||||
|
||||
function formatTaskDue(value, overdue = false) {
|
||||
if (!value) return "无截止时间";
|
||||
const date = new Date(value);
|
||||
@@ -1226,7 +1279,9 @@ export function AdminUsagePage({ contextOverrides, platformContext }) {
|
||||
const permissions = new Set(platformContext?.context?.permissions || []);
|
||||
const canManagePlan = permissions.has("billing:manage");
|
||||
const canManageQuota = permissions.has("quota:manage");
|
||||
const [data, setData] = useState({ billing: null, seat: {}, quotas: [], usage: { usage: [] } });
|
||||
const canManageEntitlements = canManageQuota;
|
||||
const canReviewCommercialApprovals = Boolean(platformContext?.context?.systemAdmin || ["billing:manage", "quota:manage", "model:approve", "compliance:manage", "delivery:approve"].some((permission) => permissions.has(permission)));
|
||||
const [data, setData] = useState({ billing: null, seat: {}, quotas: [], usage: { usage: [] }, entitlements: [], entitlementSummary: {}, planTemplates: [], commercialApprovals: [], commercialApprovalSummary: {}, commercialApprovalCatalog: [], commercialApprovalReviewTypes: [] });
|
||||
const [usageData, setUsageData] = useState({ items: [], summary: { eventCount: 0, totalUnits: 0, totalCost: 0, byKind: [] }, pagination: { page: 1, pageSize: 25, total: 0, totalPages: 0 }, facets: { workspaces: [], projects: [], users: [], kinds: [], costCenters: [] } });
|
||||
const [invoiceData, setInvoiceData] = useState({ invoices: [], summary: { count: 0, amount: 0, draft: 0, issued: 0, paid: 0, overdue: 0, void: 0 }, pagination: { page: 1, pageSize: 8, total: 0, totalPages: 0 } });
|
||||
const [invoiceFilters, setInvoiceFilters] = useState({ status: "", query: "", page: 1, pageSize: 8 });
|
||||
@@ -1238,7 +1293,10 @@ export function AdminUsagePage({ contextOverrides, platformContext }) {
|
||||
});
|
||||
const [planForm, setPlanForm] = useState({ planName: "", billingCycle: "monthly", currency: "CNY", baseFee: 0, seatUnitPrice: 0, storageUnitPrice: 0, clipUnitPrice: 0, seatLimit: 0, storageGb: 0, monthlyClipQuota: 0, quotaWarningPercent: 80, localRunnerOnly: true, cloudConnectorsRequireApproval: true });
|
||||
const [quotaDrafts, setQuotaDrafts] = useState({});
|
||||
const [entitlementDrafts, setEntitlementDrafts] = useState({});
|
||||
const [costCenterDrafts, setCostCenterDrafts] = useState({});
|
||||
const [approvalDraft, setApprovalDraft] = useState({ requestType: "entitlement_overage", targetKey: "limit.generation_jobs_monthly", requestedValue: 2900, priority: "high", title: "", businessReason: "本地 Runner 试制批次需要追加镜头生成额度,仍遵守单画面、连续性台账和固定声线规则。", evidenceRef: "" });
|
||||
const [approvalDecisionNotes, setApprovalDecisionNotes] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [usageLoading, setUsageLoading] = useState(false);
|
||||
const [busy, setBusy] = useState("");
|
||||
@@ -1295,6 +1353,7 @@ export function AdminUsagePage({ contextOverrides, platformContext }) {
|
||||
cloudConnectorsRequireApproval: Boolean(result.billing?.cloud_connectors_require_approval)
|
||||
});
|
||||
setQuotaDrafts(Object.fromEntries((result.quotas || []).map((quota) => [quota.id, quota.limitValue])));
|
||||
setEntitlementDrafts(Object.fromEntries((result.entitlements || []).map((entitlement) => [entitlement.key, { limitValue: entitlement.limitValue, enabled: entitlement.enabled, enforcement: entitlement.enforcement, overrideReason: entitlement.overrideReason || "" }])));
|
||||
setCostCenterDrafts(Object.fromEntries((result.costCenters || []).map((center) => [center.id, center.monthly_budget])));
|
||||
setNotice("");
|
||||
} catch (error) {
|
||||
@@ -1409,6 +1468,78 @@ export function AdminUsagePage({ contextOverrides, platformContext }) {
|
||||
}
|
||||
}
|
||||
|
||||
function setEntitlementDraft(key, patch) {
|
||||
setEntitlementDrafts((current) => ({ ...current, [key]: { ...(current[key] || {}), ...patch } }));
|
||||
}
|
||||
|
||||
async function saveEntitlement(entitlement) {
|
||||
if (!canManageEntitlements) return;
|
||||
const draft = entitlementDrafts[entitlement.key] || {};
|
||||
setBusy(`entitlement-${entitlement.key}`);
|
||||
try {
|
||||
const result = await updateOrganizationEntitlement(organizationId, entitlement.key, {
|
||||
limitValue: Number(draft.limitValue ?? entitlement.limitValue),
|
||||
enabled: draft.enabled ?? entitlement.enabled,
|
||||
enforcement: draft.enforcement || entitlement.enforcement,
|
||||
overrideReason: draft.overrideReason || "管理员手动调整套餐权益"
|
||||
}, contextOverrides);
|
||||
setData(result);
|
||||
setEntitlementDrafts(Object.fromEntries((result.entitlements || []).map((item) => [item.key, { limitValue: item.limitValue, enabled: item.enabled, enforcement: item.enforcement, overrideReason: item.overrideReason || "" }])));
|
||||
setNotice(`${entitlement.label} 权益已更新,并写入审计日志`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCommercialApproval(event) {
|
||||
event.preventDefault();
|
||||
if (!organizationId) return;
|
||||
setBusy("commercial-approval-submit");
|
||||
try {
|
||||
await createCommercialApproval(organizationId, {
|
||||
requestType: approvalDraft.requestType,
|
||||
targetKey: approvalDraft.targetKey,
|
||||
requestedValue: Number(approvalDraft.requestedValue || 0),
|
||||
priority: approvalDraft.priority,
|
||||
title: approvalDraft.title,
|
||||
businessReason: approvalDraft.businessReason,
|
||||
evidence: approvalDraft.evidenceRef ? { evidenceRef: approvalDraft.evidenceRef } : {}
|
||||
}, contextOverrides);
|
||||
await load();
|
||||
setNotice("商业治理申请已提交,管理员审批前不会自动放开额度或外部能力");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function decideCommercialRequest(request, decision) {
|
||||
if (!request?.id) return;
|
||||
setBusy(`commercial-approval-${request.id}-${decision}`);
|
||||
try {
|
||||
const result = await decideCommercialApproval(organizationId, request.id, {
|
||||
decision,
|
||||
decisionNote: approvalDecisionNotes[request.id] || (decision === "approved" ? "审批通过,按申请自动同步组织治理策略。" : "审批拒绝,保留原因供申请人调整后重提。")
|
||||
}, contextOverrides);
|
||||
if (result.commercial) {
|
||||
setData(result.commercial);
|
||||
setQuotaDrafts(Object.fromEntries((result.commercial.quotas || []).map((item) => [item.id, item.limitValue])));
|
||||
setEntitlementDrafts(Object.fromEntries((result.commercial.entitlements || []).map((item) => [item.key, { limitValue: item.limitValue, enabled: item.enabled, enforcement: item.enforcement, overrideReason: item.overrideReason || "" }])));
|
||||
setCostCenterDrafts(Object.fromEntries((result.commercial.costCenters || []).map((item) => [item.id, item.monthly_budget])));
|
||||
} else {
|
||||
await load();
|
||||
}
|
||||
setNotice(decision === "approved" ? "申请已通过,自动生效项已写入后台策略和审计日志" : decision === "cancelled" ? "申请已取消" : "申请已拒绝并保留审批记录");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCostCenter(center) {
|
||||
if (!canManagePlan) return;
|
||||
setBusy(center.id);
|
||||
@@ -1505,6 +1636,35 @@ export function AdminUsagePage({ contextOverrides, platformContext }) {
|
||||
const invoicePageSize = Number(invoicePagination.pageSize || 8);
|
||||
const invoiceStart = invoiceTotal ? (invoicePage - 1) * invoicePageSize + 1 : 0;
|
||||
const invoiceEnd = Math.min(invoicePage * invoicePageSize, invoiceTotal);
|
||||
const entitlementSummary = data.entitlementSummary || {};
|
||||
const planTemplates = data.planTemplates || [];
|
||||
const entitlements = data.entitlements || [];
|
||||
const entitlementCategories = [
|
||||
["tenant", "租户与组织"],
|
||||
["production", "生产流水线"],
|
||||
["knowledge", "知识库"],
|
||||
["modelops", "模型中台"],
|
||||
["delivery", "交付"],
|
||||
["storage", "存储"],
|
||||
["system", "系统集成"],
|
||||
["feature", "功能开关"]
|
||||
].filter(([key]) => entitlements.some((entitlement) => entitlement.category === key));
|
||||
const commercialApprovalSummary = data.commercialApprovalSummary || {};
|
||||
const commercialApprovals = data.commercialApprovals || [];
|
||||
const commercialApprovalCatalog = data.commercialApprovalCatalog || [];
|
||||
function targetsForApprovalType(requestType) {
|
||||
if (requestType === "budget_increase") return (data.costCenters || []).map((center) => ({ value: center.code, label: `${center.name} · 当前 ${formatMetricValue(center.monthly_budget)} ${center.currency}`, suggested: Number(center.monthly_budget || 0) + 100, current: Number(center.monthly_budget || 0) }));
|
||||
if (requestType === "feature_enablement" || requestType === "external_connector") return entitlements.filter((item) => item.key.startsWith("feature.")).map((item) => ({ value: item.key, label: `${item.label} · ${item.enabled ? "已开通" : "未开通"}`, suggested: 1, current: item.enabled ? 1 : 0 }));
|
||||
if (requestType === "compliance_review") return [{ value: "commercial-rights-review", label: "商用版权 / 原创素材复核", suggested: 1, current: 0 }, { value: "voice-consent-review", label: "固定声线授权复核", suggested: 1, current: 0 }, { value: "single-frame-contract-review", label: "单画面生成契约复核", suggested: 1, current: 0 }];
|
||||
return entitlements.filter((item) => !item.key.startsWith("feature.")).map((item) => ({ value: item.key, label: `${item.label} · 当前 ${formatMetricValue(item.limitValue)} ${item.unit}`, suggested: Number(item.limitValue || 0) + Math.max(1, Math.ceil(Number(item.limitValue || 0) * 0.2)), current: Number(item.limitValue || 0) }));
|
||||
}
|
||||
const commercialApprovalTargets = targetsForApprovalType(approvalDraft.requestType);
|
||||
const currentApprovalTarget = commercialApprovalTargets.find((item) => item.value === approvalDraft.targetKey) || commercialApprovalTargets[0] || null;
|
||||
const currentUserId = platformContext?.context?.currentUser?.id;
|
||||
function changeApprovalType(requestType) {
|
||||
const targets = targetsForApprovalType(requestType);
|
||||
setApprovalDraft((current) => ({ ...current, requestType, targetKey: targets[0]?.value || "", requestedValue: targets[0]?.suggested ?? current.requestedValue }));
|
||||
}
|
||||
return (
|
||||
<div className="enterprise-page">
|
||||
<AdminHeader eyebrow="ADMIN CONSOLE / BILLING & QUOTAS" title="用量、席位与配额" description="组织管理员在这里管理席位预留、账单周期、配额预警、成本中心和本地执行策略。所有限制都在服务端执行。" action={<div className="header-actions"><button className="subtle" onClick={exportCommercial}><Download size={15} />导出运营数据</button><button className="subtle" onClick={load} disabled={loading}><RefreshCw size={15} />刷新运营数据</button></div>} />
|
||||
@@ -1513,14 +1673,49 @@ export function AdminUsagePage({ contextOverrides, platformContext }) {
|
||||
<div className="admin-kpi-grid">
|
||||
<AdminKpi icon={Users} label="席位" value={`${seat.active || 0}/${seat.limit || 0}`} detail={`${seat.pending || 0} 个邀请已预留`} tone={seat.remaining <= 0 ? "warn" : "ok"} />
|
||||
<AdminKpi icon={Gauge} label="月度片段" value={`${clipQuota}/${clipLimit}`} detail="组织套餐上限" tone={clipLimit && clipQuota / clipLimit > .85 ? "warn" : "ok"} />
|
||||
<AdminKpi icon={HardDrive} label="存储用量" value={`${storageQuota}/${storageLimit} GB`} detail="工作区已记录用量" tone={storageLimit && storageQuota / storageLimit > .85 ? "warn" : "ok"} />
|
||||
<AdminKpi icon={HardDrive} label="存储用量" value={`${formatMetricValue(storageQuota)}/${formatMetricValue(storageLimit)} GB`} detail="工作区已记录用量" tone={storageLimit && storageQuota / storageLimit > .85 ? "warn" : "ok"} />
|
||||
<AdminKpi icon={CircleDollarSign} label="计量成本" value={data.usage?.totalCost ?? 0} detail="本月本地计量事件" tone="neutral" />
|
||||
</div>
|
||||
<div className="enterprise-grid enterprise-grid-main">
|
||||
<section className="studio-card wide-card"><SectionBar title="组织套餐策略" detail={canManagePlan ? "可编辑" : "只读 · 需要账单管理权限"} /><form className="commercial-plan-form" onSubmit={savePlan}><label>套餐名称<input value={planForm.planName} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, planName: event.target.value })} /></label><label>账单周期<select value={planForm.billingCycle} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, billingCycle: event.target.value })}><option value="monthly">按月</option><option value="quarterly">按季度</option><option value="annual">按年</option></select></label><label>结算货币<input maxLength="3" value={planForm.currency} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, currency: event.target.value.toUpperCase() })} /></label><label>席位上限<input type="number" min="1" value={planForm.seatLimit} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, seatLimit: Number(event.target.value) })} /></label><label>存储上限(GB)<input type="number" min="1" value={planForm.storageGb} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, storageGb: Number(event.target.value) })} /></label><label>月度片段上限<input type="number" min="1" value={planForm.monthlyClipQuota} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, monthlyClipQuota: Number(event.target.value) })} /></label><label>配额预警阈值(%)<input type="number" min="50" max="99" value={planForm.quotaWarningPercent} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, quotaWarningPercent: Number(event.target.value) })} /></label><label className="checkbox-field"><input type="checkbox" checked={planForm.localRunnerOnly} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, localRunnerOnly: event.target.checked })} />只允许本地 Runner</label><label className="checkbox-field"><input type="checkbox" checked={planForm.cloudConnectorsRequireApproval} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, cloudConnectorsRequireApproval: event.target.checked })} />外部连接器必须审批</label><div className="form-actions"><button className="primary" type="submit" disabled={!canManagePlan || busy === "plan"}><Save size={15} />{busy === "plan" ? "保存中…" : "保存套餐策略"}</button></div></form></section>
|
||||
<section className="studio-card"><SectionBar title="席位状态" detail="活跃成员 + 待处理邀请" /><div className="commercial-seat-meter"><div><span>已占用</span><strong>{seat.active || 0}</strong></div><div><span>待入组</span><strong>{seat.pending || 0}</strong></div><div><span>可用</span><strong>{seat.remaining || 0}</strong></div></div><div className="quota-track"><span style={{ width: `${Math.min(100, seat.utilization || 0)}%` }} /></div><p className="muted-copy">新邀请和 SSO/SCIM 入组都会经过席位检查;达到上限时由后端返回 `409 seat_limit_reached`。</p></section>
|
||||
</div>
|
||||
<section className="studio-card wide-card"><SectionBar title="工作区配额" detail={`${(data.quotas || []).length} 条配额 · 工作区额度不能超过组织套餐`} /><div className="enterprise-table commercial-quotas-table"><div className="enterprise-table-head"><span>工作区</span><span>指标</span><span>已用</span><span>上限</span><span>剩余</span><span>周期</span><span>操作</span></div>{(data.quotas || []).map((quota) => <div className="enterprise-table-row" key={quota.id}><strong>{quota.workspaceName}</strong><span>{quota.metric === "clip" ? "生成片段" : quota.metric === "storage" ? "存储" : quota.metric}</span><span>{quota.usedValue} {quota.unit}</span><label className="quota-edit-input"><input type="number" min={quota.usedValue} value={quotaDrafts[quota.id] ?? quota.limitValue} disabled={!canManageQuota} onChange={(event) => setQuotaDrafts({ ...quotaDrafts, [quota.id]: event.target.value })} /><span>{quota.unit}</span></label><span>{quota.remainingValue} {quota.unit}</span><span>{String(quota.period_end || "").slice(0, 10)}</span><button className="icon-text-button" onClick={() => saveQuota(quota)} disabled={!canManageQuota || busy === quota.id}><Save size={13} />{busy === quota.id ? "保存中" : "保存"}</button></div>)}{!(data.quotas || []).length && <div className="empty-table">当前组织还没有工作区配额。</div>}</div></section>
|
||||
<section className="studio-card wide-card commercial-approval-card">
|
||||
<SectionBar title="治理申请中心" detail={`${commercialApprovalSummary.submitted || 0} 个待审批 · ${commercialApprovalSummary.urgent || 0} 个紧急`} action={<StatusBadge status={commercialApprovalSummary.submitted ? "needs-evidence" : "active"} label={commercialApprovalSummary.submitted ? "需要处理" : "当前稳定"} />} />
|
||||
<div className="commercial-approval-summary">
|
||||
<div><strong>{commercialApprovalSummary.total || 0}</strong><span>全部申请</span></div>
|
||||
<div><strong>{commercialApprovalSummary.submitted || 0}</strong><span>待审批</span></div>
|
||||
<div><strong>{commercialApprovalSummary.approved || 0}</strong><span>已通过</span></div>
|
||||
<div><strong>{commercialApprovalSummary.rejected || 0}</strong><span>已拒绝</span></div>
|
||||
</div>
|
||||
<form className="commercial-approval-form" onSubmit={submitCommercialApproval}>
|
||||
<label>申请类型<select value={approvalDraft.requestType} onChange={(event) => changeApprovalType(event.target.value)}>{(commercialApprovalCatalog.length ? commercialApprovalCatalog : Object.keys(commercialApprovalTypeLabels).map((key) => ({ key, label: commercialApprovalTypeLabels[key] }))).map((type) => <option key={type.key} value={type.key}>{type.label || commercialApprovalTypeLabels[type.key]}</option>)}</select></label>
|
||||
<label>治理对象<select value={approvalDraft.targetKey || currentApprovalTarget?.value || ""} onChange={(event) => { const target = commercialApprovalTargets.find((item) => item.value === event.target.value); setApprovalDraft({ ...approvalDraft, targetKey: event.target.value, requestedValue: target?.suggested ?? approvalDraft.requestedValue }); }}>{commercialApprovalTargets.map((target) => <option key={target.value} value={target.value}>{target.label}</option>)}</select></label>
|
||||
<label>目标值<input type="number" min="0" step="1" value={approvalDraft.requestedValue} onChange={(event) => setApprovalDraft({ ...approvalDraft, requestedValue: event.target.value })} /></label>
|
||||
<label>优先级<select value={approvalDraft.priority} onChange={(event) => setApprovalDraft({ ...approvalDraft, priority: event.target.value })}><option value="urgent">紧急</option><option value="high">高</option><option value="medium">普通</option><option value="low">低</option></select></label>
|
||||
<label className="approval-title-field">标题<input value={approvalDraft.title} onChange={(event) => setApprovalDraft({ ...approvalDraft, title: event.target.value })} placeholder={currentApprovalTarget ? `${commercialApprovalTypeLabels[approvalDraft.requestType] || "治理申请"} · ${currentApprovalTarget.label}` : "商业治理申请"} /></label>
|
||||
<label>证据路径<input value={approvalDraft.evidenceRef} onChange={(event) => setApprovalDraft({ ...approvalDraft, evidenceRef: event.target.value })} placeholder="本地合同、授权书、项目说明或审计引用" /></label>
|
||||
<label className="approval-reason-field">申请原因<textarea required rows="3" value={approvalDraft.businessReason} onChange={(event) => setApprovalDraft({ ...approvalDraft, businessReason: event.target.value })} /></label>
|
||||
<div className="approval-policy-note"><ShieldCheck size={15} /><span>通过前不自动放开额度;外部模型/付费云通道仍需显式审批,密钥只允许放在环境变量中。</span></div>
|
||||
<div className="form-actions"><button className="primary" type="submit" disabled={busy === "commercial-approval-submit" || !commercialApprovalTargets.length}><Send size={14} />{busy === "commercial-approval-submit" ? "提交中…" : "提交申请"}</button></div>
|
||||
</form>
|
||||
<div className="enterprise-table commercial-approval-table">
|
||||
<div className="enterprise-table-head"><span>申请</span><span>对象</span><span>数值</span><span>申请人</span><span>状态</span><span>审批备注</span><span>操作</span></div>
|
||||
{commercialApprovals.map((request) => {
|
||||
const canDecide = request.status === "submitted" && canReviewCommercialApprovals;
|
||||
const canCancel = request.status === "submitted" && request.requester?.id === currentUserId;
|
||||
return <div className="enterprise-table-row" key={request.id}><div className="approval-request-main"><span className={`task-priority ${request.priority}`}>{commercialApprovalPriorityLabels[request.priority] || request.priority}</span><strong>{request.title}</strong><small>{request.requestTypeLabel || commercialApprovalTypeLabels[request.requestType] || request.requestType} · {request.businessReason}</small></div><span className="mono">{request.targetKey}</span><strong>{formatMetricValue(request.currentValue)} → {formatMetricValue(request.requestedValue)} {request.unit}</strong><div className="usage-event-cell"><strong>{request.requester?.name || request.requester?.id}</strong><small>{request.projectName || request.workspaceName || "组织级"}</small></div><StatusBadge status={approvalStatusTone(request.status)} label={commercialApprovalStatusLabels[request.status] || request.status} /><label className="approval-note-input"><input value={approvalDecisionNotes[request.id] || ""} disabled={!canDecide} onChange={(event) => setApprovalDecisionNotes((current) => ({ ...current, [request.id]: event.target.value }))} placeholder={request.decisionNote || "审批说明"} /></label><div className="approval-actions">{canDecide && <><button className="icon-text-button" type="button" onClick={() => decideCommercialRequest(request, "approved")} disabled={busy.startsWith(`commercial-approval-${request.id}`)}><CheckCircle2 size={13} />通过</button><button className="icon-text-button danger" type="button" onClick={() => decideCommercialRequest(request, "rejected")} disabled={busy.startsWith(`commercial-approval-${request.id}`)}><XCircle size={13} />拒绝</button></>}{canCancel && <button className="icon-text-button" type="button" onClick={() => decideCommercialRequest(request, "cancelled")} disabled={busy.startsWith(`commercial-approval-${request.id}`)}><Ban size={13} />取消</button>}{!canDecide && !canCancel && <span>{request.reviewer?.name || "等待处理"}</span>}</div></div>;
|
||||
})}
|
||||
{!commercialApprovals.length && <div className="empty-table">当前没有商业治理申请。</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="studio-card wide-card entitlement-matrix-card">
|
||||
<SectionBar title="套餐权益矩阵" detail={`${entitlementSummary.enabled || 0}/${entitlementSummary.total || 0} 项启用 · ${entitlementSummary.overridden || 0} 项组织覆盖`} />
|
||||
<div className="plan-template-strip">{planTemplates.map((plan) => <div key={plan.id}><div><strong>{plan.name}</strong><StatusBadge status={plan.name === data.billing?.plan_name ? "active" : "neutral"} label={plan.name === data.billing?.plan_name ? "当前" : plan.supportSla || "模板"} /></div><span>{plan.seatLimit} 席 · {plan.storageGb}GB · {plan.monthlyClipQuota} clips/月</span><small>{plan.description}</small></div>)}</div>
|
||||
<div className="entitlement-summary-row"><div><strong>{entitlementSummary.blocked || 0}</strong><span>已阻断</span></div><div><strong>{entitlementSummary.warning || 0}</strong><span>预警</span></div><div><strong>{entitlementSummary.overridden || 0}</strong><span>组织覆盖</span></div><div><strong>{entitlements.filter((item) => item.key.startsWith("feature.") && item.enabled).length}</strong><span>已开通功能</span></div></div>
|
||||
{entitlementCategories.map(([category, label]) => <div className="entitlement-category" key={category}><div className="subsection-heading"><strong>{label}</strong><span>{entitlements.filter((item) => item.category === category).length} 项</span></div><div className="enterprise-table entitlement-table"><div className="enterprise-table-head"><span>权益</span><span>已用</span><span>额度</span><span>开通</span><span>执行</span><span>状态</span><span>操作</span></div>{entitlements.filter((item) => item.category === category).map((entitlement) => { const draft = entitlementDrafts[entitlement.key] || {}; const badgeStatus = entitlement.status === "blocked" ? "failed" : entitlement.status === "warning" ? "needs-evidence" : entitlement.status === "disabled" ? "disabled" : "active"; return <div className="enterprise-table-row" key={entitlement.key}><div className="entitlement-name-cell"><strong>{entitlement.label}</strong><small>{entitlement.key} · {entitlement.source === "override" ? "组织覆盖" : "套餐继承"}</small></div><span>{formatMetricValue(entitlement.usedValue)} {entitlement.unit}</span><label className="quota-edit-input"><input type="number" min={entitlement.usedValue || 0} value={draft.limitValue ?? entitlement.limitValue} disabled={!canManageEntitlements} onChange={(event) => setEntitlementDraft(entitlement.key, { limitValue: event.target.value })} /><span>{entitlement.unit}</span></label><button type="button" className={`switch-control small ${draft.enabled ?? entitlement.enabled ? "on" : ""}`} disabled={!canManageEntitlements} onClick={() => setEntitlementDraft(entitlement.key, { enabled: !(draft.enabled ?? entitlement.enabled) })}><span />{draft.enabled ?? entitlement.enabled ? "开通" : "关闭"}</button><select value={draft.enforcement || entitlement.enforcement} disabled={!canManageEntitlements} onChange={(event) => setEntitlementDraft(entitlement.key, { enforcement: event.target.value })}><option value="block">阻断</option><option value="warn">预警</option><option value="off">记录</option></select><StatusBadge status={badgeStatus} label={entitlement.status === "blocked" ? "已达上限" : entitlement.status === "warning" ? `${entitlement.utilization}%` : entitlement.status === "disabled" ? "未开通" : "可用"} /><button className="icon-text-button" type="button" onClick={() => saveEntitlement(entitlement)} disabled={!canManageEntitlements || busy === `entitlement-${entitlement.key}`}><Save size={13} />{busy === `entitlement-${entitlement.key}` ? "保存中" : "保存"}</button></div>; })}</div></div>)}
|
||||
</section>
|
||||
<section className="studio-card wide-card"><SectionBar title="工作区配额" detail={`${(data.quotas || []).length} 条配额 · 工作区额度不能超过组织套餐`} /><div className="enterprise-table commercial-quotas-table"><div className="enterprise-table-head"><span>工作区</span><span>指标</span><span>已用</span><span>上限</span><span>剩余</span><span>周期</span><span>操作</span></div>{(data.quotas || []).map((quota) => <div className="enterprise-table-row" key={quota.id}><strong>{quota.workspaceName}</strong><span>{quota.metric === "clip" ? "生成片段" : quota.metric === "storage" ? "存储" : quota.metric}</span><span>{formatMetricValue(quota.usedValue)} {quota.unit}</span><label className="quota-edit-input"><input type="number" min={quota.usedValue} value={quotaDrafts[quota.id] ?? quota.limitValue} disabled={!canManageQuota} onChange={(event) => setQuotaDrafts({ ...quotaDrafts, [quota.id]: event.target.value })} /><span>{quota.unit}</span></label><span>{formatMetricValue(quota.remainingValue)} {quota.unit}</span><span>{String(quota.period_end || "").slice(0, 10)}</span><button className="icon-text-button" onClick={() => saveQuota(quota)} disabled={!canManageQuota || busy === quota.id}><Save size={13} />{busy === quota.id ? "保存中" : "保存"}</button></div>)}{!(data.quotas || []).length && <div className="empty-table">当前组织还没有工作区配额。</div>}</div></section>
|
||||
<div className="enterprise-grid enterprise-grid-main commercial-alert-grid"><section className="studio-card"><SectionBar title="配额预警" detail={`${(data.quotaWarnings || []).length} 个需要关注`} /><div className="policy-check-list">{(data.quotaWarnings || []).map((warning) => <button className="policy-check-row" type="button" key={`${warning.metric}-${warning.workspaceId || "org"}`} onClick={() => focusUsageByWorkspace(warning.workspaceId)}><AlertTriangle size={17} /><span>{warning.workspaceName || "组织"} · {warning.label}</span><StatusBadge status={warning.status === "critical" ? "failed" : "needs-evidence"} label={`${warning.utilization}%`} /></button>)}{!(data.quotaWarnings || []).length && <div className="empty-table">当前没有达到预警阈值的额度。</div>}</div></section><section className="studio-card"><SectionBar title="账期与变更记录" detail={`${data.billing?.billing_cycle || "monthly"} · 下一账期 ${String(data.billing?.next_invoice_at || "").slice(0, 10) || "未设置"}`} /><div className="audit-enterprise-list compact-audit-list">{(data.billingHistory || []).slice(0, 5).map((event) => <div key={event.id}><div className="audit-time">{formatAdminDate(event.created_at)}</div><div className="audit-main"><strong>{event.event_type}</strong><span>{event.actor_name || "system"}</span></div><StatusBadge status="active" label="已记录" /></div>)}{!(data.billingHistory || []).length && <div className="empty-table">还没有套餐变更记录。</div>}</div></section></div>
|
||||
<section className="studio-card wide-card"><SectionBar title="成本中心" detail="按组织运营预算与实际计量拆分" /><div className="enterprise-table commercial-quotas-table"><div className="enterprise-table-head"><span>成本中心</span><span>本月使用</span><span>月度预算</span><span>剩余</span><span>利用率</span><span>操作</span></div>{(data.costCenters || []).map((center) => <div className="enterprise-table-row" key={center.id}><strong>{center.name}</strong><span>{center.used} {center.currency}</span><label className="quota-edit-input"><input type="number" min="0" value={costCenterDrafts[center.id] ?? center.monthly_budget} disabled={!canManagePlan} onChange={(event) => setCostCenterDrafts({ ...costCenterDrafts, [center.id]: event.target.value })} /><span>{center.currency}</span></label><span>{center.remaining} {center.currency}</span><StatusBadge status={center.utilization >= 100 ? "failed" : center.utilization >= 80 ? "needs-evidence" : "active"} label={`${center.utilization}%`} /><button className="icon-text-button" onClick={() => saveCostCenter(center)} disabled={!canManagePlan || busy === center.id}><Save size={13} />{busy === center.id ? "保存中" : "保存"}</button></div>)}{!(data.costCenters || []).length && <div className="empty-table">当前组织没有成本中心。</div>}</div><div className="commercial-usage-list cost-center-detail-list">{(data.costCenterDetail || []).slice(0, 8).map((item) => <div key={`${item.code}-${item.workspaceId || "org"}`}><div><strong>{item.code}</strong><span>{item.workspaceName}</span></div><b>{item.units}</b><em>{item.cost} CNY · {item.events} 事件</em><button className="icon-text-button" type="button" onClick={() => focusUsageByCostCenter(item.code)}><ArrowUpRight size={13} />查看明细</button></div>)}</div></section>
|
||||
<section className="studio-card wide-card"><SectionBar title="账单计价参数" detail="发票生成使用的本地计价快照" /><form className="commercial-plan-form" onSubmit={savePlan}><label>套餐固定费<input type="number" min="0" step="0.01" value={planForm.baseFee} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, baseFee: Number(event.target.value) })} /></label><label>席位单价<input type="number" min="0" step="0.01" value={planForm.seatUnitPrice} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, seatUnitPrice: Number(event.target.value) })} /></label><label>存储单价 / GB<input type="number" min="0" step="0.01" value={planForm.storageUnitPrice} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, storageUnitPrice: Number(event.target.value) })} /></label><label>片段单价<input type="number" min="0" step="0.01" value={planForm.clipUnitPrice} disabled={!canManagePlan} onChange={(event) => setPlanForm({ ...planForm, clipUnitPrice: Number(event.target.value) })} /></label><div className="form-actions"><button className="primary" type="submit" disabled={!canManagePlan || busy === "plan"}><Save size={15} />{busy === "plan" ? "保存中…" : "保存计价参数"}</button></div></form></section>
|
||||
@@ -1709,15 +1904,128 @@ function ModelConnectorFields({ form, setForm, widePrefix = "" }) {
|
||||
export function AdminModelsPage({ platformContext, contextOverrides, onModelsChanged }) {
|
||||
const platform = platformContext?.platform || {};
|
||||
const [models, setModels] = useState(platform.modelRegistry || []);
|
||||
const [catalog, setCatalog] = useState(platform.modelCatalog || []);
|
||||
const [routes, setRoutes] = useState(platform.modelRouting || []);
|
||||
const [form, setForm] = useState(emptyModelConnectorForm);
|
||||
const [editingModel, setEditingModel] = useState(null);
|
||||
const [editForm, setEditForm] = useState(emptyModelConnectorForm);
|
||||
const [catalogForm, setCatalogForm] = useState({ connectorId: "", displayName: "", modelKey: "", family: "", capabilities: "chat", contextWindow: 131072, maxOutputTokens: 8192, locality: "local", estimatedCny: "0.03", status: "active", approvalStatus: "approved", notes: "" });
|
||||
const [editingCatalog, setEditingCatalog] = useState(null);
|
||||
const [catalogEditForm, setCatalogEditForm] = useState({ connectorId: "", displayName: "", modelKey: "", family: "", capabilities: "", contextWindow: 0, maxOutputTokens: 0, locality: "local", estimatedCny: "0", status: "active", approvalStatus: "approved", notes: "" });
|
||||
const [routeForm, setRouteForm] = useState({ name: "", workflowKey: "knowledge-ingest", operationKey: "chunk-and-extract", primaryModelId: "", fallbackModelId: "", policyMode: "prefer-local", approvalMode: "follow-model", budgetLimitCny: "10", status: "active", notes: "" });
|
||||
const [editingRoute, setEditingRoute] = useState(null);
|
||||
const [routeEditForm, setRouteEditForm] = useState({ name: "", workflowKey: "", operationKey: "", primaryModelId: "", fallbackModelId: "", policyMode: "prefer-local", approvalMode: "follow-model", budgetLimitCny: "0", status: "active", notes: "" });
|
||||
const [resolutionForm, setResolutionForm] = useState({ workflowKey: "ai-manhua-drama", operationKey: "image-keyframe", kind: "单画面关键帧", adapter: "owned-model-platform", modelId: "", localOnly: true, estimatedCostCny: "0" });
|
||||
const [resolution, setResolution] = useState(null);
|
||||
const [approvals, setApprovals] = useState([]);
|
||||
const [approvalSummary, setApprovalSummary] = useState({ submitted: 0, approved: 0, rejected: 0, expired: 0 });
|
||||
const [approvalReason, setApprovalReason] = useState("本次生产需要使用受控模型路由,请审批一次执行窗口。");
|
||||
const [approvalBusy, setApprovalBusy] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const permissions = new Set(platformContext?.context?.permissions || []);
|
||||
const canApproveRoutes = Boolean(platformContext?.context?.systemAdmin || permissions.has("model:approve") || permissions.has("model:manage"));
|
||||
|
||||
useEffect(() => setModels(platform.modelRegistry || []), [platform.modelRegistry]);
|
||||
useEffect(() => setCatalog(platform.modelCatalog || []), [platform.modelCatalog]);
|
||||
useEffect(() => setRoutes(platform.modelRouting || []), [platform.modelRouting]);
|
||||
useEffect(() => {
|
||||
if (!catalogForm.connectorId && (platform.modelRegistry || [])[0]?.id) setCatalogForm((current) => ({ ...current, connectorId: (platform.modelRegistry || [])[0].id }));
|
||||
}, [platform.modelRegistry]);
|
||||
useEffect(() => {
|
||||
if (!routeForm.primaryModelId && (platform.modelCatalog || [])[0]?.id) setRouteForm((current) => ({ ...current, primaryModelId: (platform.modelCatalog || [])[0].id }));
|
||||
}, [platform.modelCatalog]);
|
||||
useEffect(() => {
|
||||
loadModelOps();
|
||||
}, [contextOverrides.organizationId, contextOverrides.workspaceId]);
|
||||
|
||||
function syncModels(nextModels) {
|
||||
setModels(nextModels);
|
||||
onModelsChanged?.(nextModels);
|
||||
}
|
||||
|
||||
function catalogFormFromEntry(entry) {
|
||||
return {
|
||||
connectorId: entry.connectorId || entry.connector_id || "",
|
||||
displayName: entry.display_name || entry.displayName || "",
|
||||
modelKey: entry.model_key || entry.modelKey || "",
|
||||
family: entry.family || "",
|
||||
capabilities: (entry.capabilities || []).join(", "),
|
||||
contextWindow: entry.contextWindow || entry.context_window || 0,
|
||||
maxOutputTokens: entry.maxOutputTokens || entry.max_output_tokens || 0,
|
||||
locality: entry.cost?.locality || "local",
|
||||
estimatedCny: String(entry.cost?.estimatedCny ?? entry.cost?.estimated_cny ?? 0),
|
||||
status: entry.status || "active",
|
||||
approvalStatus: entry.approval_status || entry.approvalStatus || "approved",
|
||||
notes: entry.metadata?.notes || ""
|
||||
};
|
||||
}
|
||||
|
||||
function routeFormFromEntry(route) {
|
||||
return {
|
||||
name: route.name || "",
|
||||
workflowKey: route.workflow_key || route.workflowKey || "",
|
||||
operationKey: route.operation_key || route.operationKey || "",
|
||||
primaryModelId: route.primaryModelId || route.primary_model_id || "",
|
||||
fallbackModelId: route.fallbackModelId || route.fallback_model_id || "",
|
||||
policyMode: route.policy_mode || route.policyMode || "prefer-local",
|
||||
approvalMode: route.approval_mode || route.approvalMode || "follow-model",
|
||||
budgetLimitCny: String(route.budgetLimitCny ?? route.budget_limit_cny ?? 0),
|
||||
status: route.status || "active",
|
||||
notes: route.policy?.notes || ""
|
||||
};
|
||||
}
|
||||
|
||||
function buildCatalogPayload(source) {
|
||||
return {
|
||||
connectorId: source.connectorId,
|
||||
displayName: source.displayName,
|
||||
modelKey: source.modelKey,
|
||||
family: source.family,
|
||||
capabilities: source.capabilities.split(",").map((item) => item.trim()).filter(Boolean),
|
||||
contextWindow: Number(source.contextWindow || 0),
|
||||
maxOutputTokens: Number(source.maxOutputTokens || 0),
|
||||
cost: {
|
||||
mode: "estimated",
|
||||
estimatedCny: Number(source.estimatedCny || 0),
|
||||
locality: source.locality || "local"
|
||||
},
|
||||
status: source.status,
|
||||
approvalStatus: source.approvalStatus,
|
||||
metadata: { notes: source.notes || "" }
|
||||
};
|
||||
}
|
||||
|
||||
function buildRoutePayload(source) {
|
||||
return {
|
||||
name: source.name,
|
||||
workflowKey: source.workflowKey,
|
||||
operationKey: source.operationKey,
|
||||
primaryModelId: source.primaryModelId,
|
||||
fallbackModelId: source.fallbackModelId || "",
|
||||
policyMode: source.policyMode,
|
||||
approvalMode: source.approvalMode,
|
||||
budgetLimitCny: Number(source.budgetLimitCny || 0),
|
||||
status: source.status,
|
||||
policy: { notes: source.notes || "" }
|
||||
};
|
||||
}
|
||||
|
||||
async function loadModelOps() {
|
||||
try {
|
||||
const [catalogResult, routeResult, approvalResult] = await Promise.all([
|
||||
fetchModelCatalog(contextOverrides),
|
||||
fetchModelRoutes(contextOverrides),
|
||||
fetchModelRouteApprovals({}, contextOverrides)
|
||||
]);
|
||||
setCatalog(catalogResult.catalog || []);
|
||||
setRoutes(routeResult.routes || []);
|
||||
setApprovals(approvalResult.approvals || []);
|
||||
setApprovalSummary(approvalResult.summary || { submitted: 0, approved: 0, rejected: 0, expired: 0 });
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
@@ -1729,15 +2037,18 @@ export function AdminModelsPage({ platformContext, contextOverrides, onModelsCha
|
||||
syncModels(result.models || [result.model, ...models]);
|
||||
setForm((current) => ({ ...current, label: "" }));
|
||||
setNotice("模型连接器已写入当前组织");
|
||||
await loadModelOps();
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
function openEditor(model) {
|
||||
setEditingModel(model);
|
||||
setEditForm(modelConnectorForm(model));
|
||||
setNotice("");
|
||||
}
|
||||
|
||||
async function saveEdit(event) {
|
||||
event.preventDefault();
|
||||
if (!editingModel) return;
|
||||
@@ -1751,10 +2062,12 @@ export function AdminModelsPage({ platformContext, contextOverrides, onModelsCha
|
||||
syncModels(nextModels);
|
||||
setEditingModel(null);
|
||||
setNotice("模型连接器配置已更新,密钥仍只通过环境变量引用");
|
||||
await loadModelOps();
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function probe(model) {
|
||||
try {
|
||||
const result = await probeModel(model.id, contextOverrides);
|
||||
@@ -1764,6 +2077,7 @@ export function AdminModelsPage({ platformContext, contextOverrides, onModelsCha
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle(model) {
|
||||
try {
|
||||
const nextStatus = model.status === "paused" ? "ready" : "paused";
|
||||
@@ -1771,23 +2085,210 @@ export function AdminModelsPage({ platformContext, contextOverrides, onModelsCha
|
||||
const nextModels = models.map((item) => item.id === model.id ? result.model : item);
|
||||
syncModels(nextModels);
|
||||
setNotice(`${model.label} 已${nextStatus === "ready" ? "启用" : "暂停"}`);
|
||||
await loadModelOps();
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCatalog(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const result = await createModelCatalog(buildCatalogPayload(catalogForm), contextOverrides);
|
||||
setCatalog(result.catalog || []);
|
||||
setCatalogForm({ connectorId: models[0]?.id || "", displayName: "", modelKey: "", family: "", capabilities: "chat", contextWindow: 131072, maxOutputTokens: 8192, locality: "local", estimatedCny: "0.03", status: "active", approvalStatus: "approved", notes: "" });
|
||||
setNotice("模型目录条目已创建");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCatalogEdit(event) {
|
||||
event.preventDefault();
|
||||
if (!editingCatalog) return;
|
||||
try {
|
||||
const result = await updateModelCatalog(editingCatalog.id, buildCatalogPayload(catalogEditForm), contextOverrides);
|
||||
setCatalog((result.catalog || catalog).map((item) => item.id === editingCatalog.id ? result.entry || item : item));
|
||||
setEditingCatalog(null);
|
||||
setNotice("模型目录条目已更新");
|
||||
await loadModelOps();
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRoute(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
const result = await createModelRoute(buildRoutePayload(routeForm), contextOverrides);
|
||||
setRoutes(result.routes || []);
|
||||
setRouteForm({ name: "", workflowKey: "knowledge-ingest", operationKey: "chunk-and-extract", primaryModelId: catalog[0]?.id || "", fallbackModelId: "", policyMode: "prefer-local", approvalMode: "follow-model", budgetLimitCny: "10", status: "active", notes: "" });
|
||||
setNotice("路由策略已创建");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRouteEdit(event) {
|
||||
event.preventDefault();
|
||||
if (!editingRoute) return;
|
||||
try {
|
||||
const result = await updateModelRoute(editingRoute.id, buildRoutePayload(routeEditForm), contextOverrides);
|
||||
setRoutes((result.routes || routes).map((item) => item.id === editingRoute.id ? result.route || item : item));
|
||||
setEditingRoute(null);
|
||||
setNotice("路由策略已更新");
|
||||
await loadModelOps();
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function runRouteResolution(event) {
|
||||
event?.preventDefault?.();
|
||||
try {
|
||||
const payload = {
|
||||
...resolutionForm,
|
||||
localOnly: Boolean(resolutionForm.localOnly),
|
||||
estimatedCostCny: Number(resolutionForm.estimatedCostCny || 0),
|
||||
modelId: resolutionForm.modelId || undefined
|
||||
};
|
||||
const result = await resolveModelRoute(payload, contextOverrides);
|
||||
setResolution(result.resolution);
|
||||
setNotice(result.resolution.guard.status === "pass" ? "路由试算通过,可按该策略进入任务队列。" : result.resolution.guard.status === "approval-required" ? "路由试算需要审批,执行前会被审批门拦截。" : "路由试算发现阻断项,请调整连接器、模型或成本策略。");
|
||||
} catch (error) {
|
||||
setResolution(null);
|
||||
setNotice(error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRouteApproval() {
|
||||
try {
|
||||
setApprovalBusy("submit");
|
||||
const payload = {
|
||||
...resolutionForm,
|
||||
localOnly: Boolean(resolutionForm.localOnly),
|
||||
estimatedCostCny: Number(resolutionForm.estimatedCostCny || 0),
|
||||
modelId: resolutionForm.modelId || undefined,
|
||||
reason: approvalReason,
|
||||
approvalScope: "single-run"
|
||||
};
|
||||
const result = await requestModelRouteApproval(payload, contextOverrides);
|
||||
setApprovals(result.approvals || []);
|
||||
setApprovalSummary(result.summary || approvalSummary);
|
||||
setNotice(`模型路由审批单已提交:${result.approval?.id || ""}`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setApprovalBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function decideRouteApproval(approval, status) {
|
||||
if (!approval?.id) return;
|
||||
try {
|
||||
setApprovalBusy(approval.id);
|
||||
const result = await decideModelRouteApproval(approval.id, {
|
||||
status,
|
||||
expiresHours: status === "approved" ? 24 : undefined,
|
||||
note: status === "approved" ? "批准一次受控模型执行,24 小时内有效。" : "当前连接器、成本或模型证据不满足放行要求。"
|
||||
}, contextOverrides);
|
||||
setApprovals(result.approvals || []);
|
||||
setApprovalSummary(result.summary || approvalSummary);
|
||||
setNotice(status === "approved" ? `已批准审批单:${approval.id}` : `已驳回审批单:${approval.id}`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setApprovalBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
function approvalLabel(status) {
|
||||
return ({ submitted: "待审批", approved: "已批准", rejected: "已驳回", expired: "已过期", cancelled: "已取消" })[status] || status || "未知";
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="enterprise-page">
|
||||
<AdminHeader eyebrow="ADMIN CONSOLE / MODEL OPS" title="模型与 Runner 中台" description="组织级管理本地模型平台、OpenAI-compatible 接口和可选 ComfyUI 桥接,所有连接器都要有能力标签、成本策略和审批状态。" />
|
||||
<AdminHeader eyebrow="ADMIN CONSOLE / MODEL OPS" title="模型与 Runner 中台" description="把连接器、模型目录和路由策略拆开治理:连接器解决怎么接,模型目录解决有哪些模型,路由策略解决业务流程到底走谁。" action={<div className="header-actions"><StatusBadge status="active" label="默认 local-only" /><button className="subtle" onClick={loadModelOps}><RefreshCw size={15} />刷新目录</button></div>} />
|
||||
{notice && <div className="inline-notice ok"><CheckCircle2 size={15} />{notice}</div>}
|
||||
<section className="studio-card wide-card">
|
||||
<SectionBar title="连接器注册表" detail={`${models.length} 个连接器`} action={<StatusBadge status="active" label="默认 local-only" />} />
|
||||
<div className="enterprise-table model-enterprise-table"><div className="enterprise-table-head"><span>名称</span><span>能力</span><span>Endpoint</span><span>成本</span><span>审批</span><span>状态</span><span>操作</span></div>{models.map((model) => <div className="enterprise-table-row" key={model.id}><strong>{model.label}</strong><span>{(model.capability || []).join(" · ")}</span><span className="mono">{model.endpoint}<small className="model-probe-meta">{model.last_probe_at ? `最近探活 ${model.latency_ms || "-"}ms` : "尚未探活"}</small></span><span>{model.costMode || model.cost_mode}</span><span>{model.approvalRequired || model.approval_required ? "需要审批" : "无需"}</span><StatusBadge status={model.status} label={model.status === "not-connected" ? "未连接" : model.status === "optional" ? "可选" : model.status === "error" ? "错误" : model.status} /><div className="model-actions"><button className="icon-text-button" onClick={() => openEditor(model)}><Settings2 size={13} />编辑</button><button className="icon-text-button" onClick={() => probe(model)}><Activity size={13} />检测</button>{["ready", "paused"].includes(model.status) && <button className="icon-text-button" onClick={() => toggle(model)}><ServerCog size={13} />{model.status === "paused" ? "启用" : "暂停"}</button>}</div></div>)}</div>
|
||||
<SectionBar title="连接器注册表" detail={`${models.length} 个连接器`} />
|
||||
<div className="enterprise-table model-enterprise-table"><div className="enterprise-table-head"><span>名称</span><span>能力</span><span>Endpoint</span><span>成本</span><span>审批</span><span>状态</span><span>操作</span></div>{models.map((model) => <div className="enterprise-table-row" key={model.id}><strong>{model.label}</strong><span>{(model.capability || []).join(" · ")}</span><span className="mono">{model.endpoint}<small className="model-probe-meta">{model.last_probe_at ? `最近探活 ${model.latency_ms || "-"}ms` : "尚未探活"}</small><small className={`model-secret-meta ${model.secretPolicy?.status || "not-required"}`}>{model.endpointPolicy?.locality === "public" ? "公网 endpoint" : "本地/内网 endpoint"} · {model.secretPolicy?.required ? `${model.secretPolicy.authEnv || "未配置密钥变量"} ${model.secretPolicy.present ? "已配置" : "缺失"}` : "无需密钥"}</small></span><span>{model.costMode || model.cost_mode}</span><span>{model.approvalRequired || model.approval_required ? "需要审批" : "无需"}</span><StatusBadge status={model.status} label={model.status === "not-connected" ? "未连接" : model.status === "optional" ? "可选" : model.status === "error" ? "错误" : model.status} /><div className="model-actions"><button className="icon-text-button" onClick={() => openEditor(model)}><Settings2 size={13} />编辑</button><button className="icon-text-button" onClick={() => probe(model)}><Activity size={13} />检测</button>{["ready", "paused"].includes(model.status) && <button className="icon-text-button" onClick={() => toggle(model)}><ServerCog size={13} />{model.status === "paused" ? "启用" : "暂停"}</button>}</div></div>)}</div>
|
||||
</section>
|
||||
<div className="enterprise-grid enterprise-grid-main">
|
||||
<section className="studio-card wide-card"><SectionBar title="注册新连接器" detail="只保存连接信息,不直接调用外部服务" /><form className="enterprise-form-grid" onSubmit={submit}><ModelConnectorFields form={form} setForm={setForm} widePrefix="register-model-fields" /><div className="form-actions"><button className="primary" type="submit"><Plus size={15} />注册连接器</button></div></form></section>
|
||||
<section className="studio-card wide-card"><SectionBar title="注册新连接器" detail="只保存连接信息,不直接保存密钥" /><form className="enterprise-form-grid" onSubmit={submit}><ModelConnectorFields form={form} setForm={setForm} widePrefix="register-model-fields" /><div className="form-actions"><button className="primary" type="submit"><Plus size={15} />注册连接器</button></div></form></section>
|
||||
<section className="studio-card"><SectionBar title="连接策略" detail="当前组织生效" /><div className="policy-check-list"><div><ShieldCheck size={17} /><span>自有模型平台优先</span><StatusBadge status="enforced" label="已启用" /></div><div><ShieldCheck size={17} /><span>单画面生成契约</span><StatusBadge status="enforced" label="已启用" /></div><div><ShieldAlert size={17} /><span>云端节点调用</span><StatusBadge status="disabled" label="默认禁用" /></div><div><Network size={17} /><span>ComfyUI 桥接</span><StatusBadge status="optional" label="可选" /></div></div></section>
|
||||
</div>
|
||||
<section className="studio-card wide-card model-resolution-panel">
|
||||
<SectionBar title="模型路由试算器" detail="不执行任务,只预检业务流程会命中哪个连接器和模型" action={<button className="subtle" onClick={runRouteResolution}><Activity size={15} />试算</button>} />
|
||||
<form className="model-resolution-form" onSubmit={runRouteResolution}>
|
||||
<label>工作流 Key<input value={resolutionForm.workflowKey} onChange={(event) => setResolutionForm((current) => ({ ...current, workflowKey: event.target.value }))} placeholder="ai-manhua-drama" /></label>
|
||||
<label>操作 Key<input value={resolutionForm.operationKey} onChange={(event) => setResolutionForm((current) => ({ ...current, operationKey: event.target.value }))} placeholder="image-keyframe" /></label>
|
||||
<label>任务类型<input value={resolutionForm.kind} onChange={(event) => setResolutionForm((current) => ({ ...current, kind: event.target.value }))} placeholder="单画面关键帧" /></label>
|
||||
<label>直接连接器<select value={resolutionForm.adapter} onChange={(event) => setResolutionForm((current) => ({ ...current, adapter: event.target.value }))}><option value="">只按路由</option><option value="owned-model-platform">自有模型平台自动选择</option>{models.map((model) => <option key={model.id} value={model.id}>{model.label}</option>)}</select></label>
|
||||
<label>指定模型<select value={resolutionForm.modelId} onChange={(event) => setResolutionForm((current) => ({ ...current, modelId: event.target.value }))}><option value="">按路由主/备模型</option>{catalog.map((entry) => <option key={entry.id} value={entry.id}>{entry.display_name || entry.displayName}</option>)}</select></label>
|
||||
<label>预估成本 CNY<input type="number" min="0" step="0.01" value={resolutionForm.estimatedCostCny} onChange={(event) => setResolutionForm((current) => ({ ...current, estimatedCostCny: event.target.value }))} /></label>
|
||||
<label className="checkbox-field model-resolution-checkbox"><input type="checkbox" checked={resolutionForm.localOnly} onChange={(event) => setResolutionForm((current) => ({ ...current, localOnly: event.target.checked }))} />要求 local-only</label>
|
||||
<label className="model-resolution-wide">审批理由<textarea value={approvalReason} onChange={(event) => setApprovalReason(event.target.value)} placeholder="说明为什么需要使用该模型、连接器或外部/混合成本策略" /></label>
|
||||
<div className="form-actions"><button className="primary" type="submit"><Activity size={15} />执行试算</button><button className="subtle" type="button" onClick={submitRouteApproval} disabled={approvalBusy === "submit" || resolution?.guard?.status === "blocked"}><ShieldCheck size={15} />{approvalBusy === "submit" ? "提交中…" : "提交审批单"}</button></div>
|
||||
</form>
|
||||
{resolution && <div className="model-resolution-result">
|
||||
<div className={`model-resolution-banner ${resolution.guard.status}`}>
|
||||
{resolution.guard.status === "pass" ? <ShieldCheck size={18} /> : <ShieldAlert size={18} />}
|
||||
<div><strong>{resolution.guard.status === "pass" ? "准入通过" : resolution.guard.status === "approval-required" ? "需要审批" : "已阻断"}</strong><span>{resolution.source} · {resolution.request.workflowKey || "未指定工作流"} / {resolution.request.operationKey || "未指定操作"}</span></div>
|
||||
<StatusBadge status={resolution.guard.status === "pass" ? "active" : resolution.guard.status === "approval-required" ? "review" : "blocked"} label={resolution.guard.status} />
|
||||
</div>
|
||||
<div className="model-resolution-cards">
|
||||
<div><span>命中路由</span><strong>{resolution.route?.routeName || "未命中路由"}</strong><small>{resolution.route?.policyMode || "direct"} · {resolution.route?.approvalMode || "follow-connector"}</small></div>
|
||||
<div><span>模型</span><strong>{resolution.model?.displayName || "直接连接器"}</strong><small>{resolution.model?.modelKey || resolution.connector?.id || "-"}</small></div>
|
||||
<div><span>连接器</span><strong>{resolution.connector?.label || "未解析"}</strong><small>{resolution.connector?.kind || "-"} · {resolution.connector?.costMode || "-"}</small></div>
|
||||
<div><span>成本/预算</span><strong>{Number(resolution.guard.estimatedCny || 0).toFixed(2)} / {Number(resolution.guard.budgetLimitCny || 0).toFixed(2)} CNY</strong><small>{resolution.guard.localOnly ? "local-only 预检" : "允许混合策略"}</small></div>
|
||||
</div>
|
||||
<div className="model-resolution-reasons">
|
||||
{(resolution.guard.reasons || []).map((reason) => <div key={reason.code}><StatusBadge status={reason.severity === "blocking" ? "blocked" : "review"} label={reason.severity} /><strong>{reason.code}</strong><span>{reason.message}</span></div>)}
|
||||
{!resolution.guard.reasons?.length && <div><StatusBadge status="active" label="pass" /><strong>no_blockers</strong><span>没有发现审批或阻断原因。</span></div>}
|
||||
</div>
|
||||
<details className="model-resolution-json"><summary>查看试算 JSON</summary><pre>{JSON.stringify(resolution, null, 2)}</pre></details>
|
||||
</div>}
|
||||
</section>
|
||||
<section className="studio-card wide-card model-approval-panel">
|
||||
<SectionBar title="模型路由审批台" detail={`待审批 ${approvalSummary.submitted || 0} · 已批准 ${approvalSummary.approved || 0} · 过期 ${approvalSummary.expired || 0}`} action={<button className="subtle" onClick={loadModelOps}><RefreshCw size={15} />刷新审批</button>} />
|
||||
<div className="model-approval-kpis">
|
||||
<div><span>待审批</span><strong>{approvalSummary.submitted || 0}</strong><small>需要模型管理员处理</small></div>
|
||||
<div><span>已批准</span><strong>{approvalSummary.approved || 0}</strong><small>一次执行窗口</small></div>
|
||||
<div><span>已驳回</span><strong>{approvalSummary.rejected || 0}</strong><small>保留审计记录</small></div>
|
||||
<div><span>已过期</span><strong>{approvalSummary.expired || 0}</strong><small>需重新申请</small></div>
|
||||
</div>
|
||||
<div className="enterprise-table model-approval-table">
|
||||
<div className="enterprise-table-head"><span>审批单</span><span>申请人</span><span>路由 / 模型</span><span>连接器</span><span>成本</span><span>状态</span><span>有效期</span><span>操作</span></div>
|
||||
{approvals.map((approval) => <div className="enterprise-table-row" key={approval.id}>
|
||||
<strong className="mono">{approval.id}</strong>
|
||||
<span>{approval.requesterName}<small className="model-probe-meta">{approval.projectName || approval.projectId || "工作区级"}</small></span>
|
||||
<span>{approval.routeName || "直接连接器"}<small className="model-probe-meta">{approval.modelDisplayName || approval.modelEntryId || approval.request?.kind || "-"}</small></span>
|
||||
<span>{approval.connectorLabel || approval.connectorId}<small className="model-probe-meta">{approval.connectorKind || "-"}</small></span>
|
||||
<span>{Number(approval.estimatedCostCny || 0).toFixed(2)} CNY<small className="model-probe-meta">{approval.localOnly ? "local-only" : "mixed allowed"}</small></span>
|
||||
<StatusBadge status={approval.status} label={approvalLabel(approval.status)} />
|
||||
<span>{approval.expiresAt ? formatAdminDate(approval.expiresAt) : approval.status === "submitted" ? "待审批" : "无"}</span>
|
||||
<div className="model-actions">{approval.status === "submitted" && canApproveRoutes ? <><button className="icon-text-button" onClick={() => decideRouteApproval(approval, "approved")} disabled={approvalBusy === approval.id}><ShieldCheck size={13} />批准</button><button className="icon-text-button danger" onClick={() => decideRouteApproval(approval, "rejected")} disabled={approvalBusy === approval.id}><XCircle size={13} />驳回</button></> : <span className="muted-mini">{approval.decision_note || approval.reason || "已归档"}</span>}</div>
|
||||
</div>)}
|
||||
{!approvals.length && <div className="empty-table">当前没有模型路由审批单。使用上方试算器命中“需要审批”的路由后可以提交申请。</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="studio-card wide-card">
|
||||
<SectionBar title="模型目录" detail={`${catalog.length} 个模型条目`} />
|
||||
<div className="enterprise-table model-enterprise-table"><div className="enterprise-table-head"><span>名称</span><span>模型 Key</span><span>连接器</span><span>能力</span><span>成本</span><span>审批</span><span>状态</span><span>操作</span></div>{catalog.map((entry) => <div className="enterprise-table-row" key={entry.id}><strong>{entry.display_name || entry.displayName}</strong><span className="mono">{entry.model_key || entry.modelKey}<small className="model-probe-meta">{entry.family || "generic"}</small></span><span>{entry.connectorLabel || entry.connector_id || entry.connectorId}</span><span>{(entry.capabilities || []).join(" · ")}</span><span>{Number(entry.cost?.estimatedCny || 0).toFixed(2)} CNY<small className="model-probe-meta">{entry.cost?.locality || "local"}</small></span><span>{entry.approval_status || entry.approvalStatus}</span><StatusBadge status={entry.status} label={entry.status === "active" ? "启用" : entry.status === "review" ? "复核中" : entry.status} /><div className="model-actions"><button className="icon-text-button" onClick={() => { setEditingCatalog(entry); setCatalogEditForm(catalogFormFromEntry(entry)); }}><Settings2 size={13} />编辑</button></div></div>)}</div>
|
||||
</section>
|
||||
<div className="enterprise-grid enterprise-grid-main">
|
||||
<section className="studio-card wide-card"><SectionBar title="新增模型条目" detail="一个连接器下可挂多个具体模型" /><form className="enterprise-form-grid" onSubmit={submitCatalog}><label>挂载连接器<select value={catalogForm.connectorId} onChange={(event) => setCatalogForm((current) => ({ ...current, connectorId: event.target.value }))}>{models.map((model) => <option key={model.id} value={model.id}>{model.label}</option>)}</select></label><label>模型名称<input value={catalogForm.displayName} onChange={(event) => setCatalogForm((current) => ({ ...current, displayName: event.target.value }))} placeholder="例如:Qwen 剧本/小说解析" /></label><label>模型 Key<input value={catalogForm.modelKey} onChange={(event) => setCatalogForm((current) => ({ ...current, modelKey: event.target.value }))} placeholder="例如:qwen-script-parser" /></label><label>模型族<input value={catalogForm.family} onChange={(event) => setCatalogForm((current) => ({ ...current, family: event.target.value }))} placeholder="qwen-text / tts / asr" /></label><label>能力标签<input value={catalogForm.capabilities} onChange={(event) => setCatalogForm((current) => ({ ...current, capabilities: event.target.value }))} placeholder="chat, script-split, knowledge-extract" /></label><label>上下文窗口<input type="number" min="0" value={catalogForm.contextWindow} onChange={(event) => setCatalogForm((current) => ({ ...current, contextWindow: event.target.value }))} /></label><label>最大输出<input type="number" min="0" value={catalogForm.maxOutputTokens} onChange={(event) => setCatalogForm((current) => ({ ...current, maxOutputTokens: event.target.value }))} /></label><label>成本归属<select value={catalogForm.locality} onChange={(event) => setCatalogForm((current) => ({ ...current, locality: event.target.value }))}><option value="local">local</option><option value="mixed">mixed</option><option value="cloud">cloud</option></select></label><label>估算成本 CNY<input type="number" min="0" step="0.01" value={catalogForm.estimatedCny} onChange={(event) => setCatalogForm((current) => ({ ...current, estimatedCny: event.target.value }))} /></label><label>状态<select value={catalogForm.status} onChange={(event) => setCatalogForm((current) => ({ ...current, status: event.target.value }))}><option value="active">active</option><option value="draft">draft</option><option value="paused">paused</option><option value="review">review</option></select></label><label>审批<select value={catalogForm.approvalStatus} onChange={(event) => setCatalogForm((current) => ({ ...current, approvalStatus: event.target.value }))}><option value="approved">approved</option><option value="review">review</option><option value="pending">pending</option><option value="blocked">blocked</option></select></label><label className="model-editor-wide">说明<textarea value={catalogForm.notes} onChange={(event) => setCatalogForm((current) => ({ ...current, notes: event.target.value }))} placeholder="例如:适合小说/剧本拆解;中文优先。" /></label><div className="form-actions"><button className="primary" type="submit"><Plus size={15} />新增模型条目</button></div></form></section>
|
||||
<section className="studio-card"><SectionBar title="商业语义" detail="连接器 != 模型" /><div className="policy-check-list"><div><ShieldCheck size={17} /><span>连接器</span><StatusBadge status="active" label="解决怎么接" /></div><div><ShieldCheck size={17} /><span>模型目录</span><StatusBadge status="active" label="解决有哪些模型" /></div><div><ShieldCheck size={17} /><span>路由策略</span><StatusBadge status="active" label="解决流程走谁" /></div></div></section>
|
||||
</div>
|
||||
<section className="studio-card wide-card">
|
||||
<SectionBar title="路由策略" detail={`${routes.length} 条业务路由`} />
|
||||
<div className="enterprise-table model-enterprise-table"><div className="enterprise-table-head"><span>名称</span><span>工作流</span><span>操作</span><span>主模型</span><span>回退模型</span><span>预算</span><span>模式</span><span>状态</span><span>操作</span></div>{routes.map((route) => <div className="enterprise-table-row" key={route.id}><strong>{route.name}</strong><span>{route.workflow_key || route.workflowKey}</span><span>{route.operation_key || route.operationKey}</span><span>{route.primaryModelLabel || route.primary_model_id || route.primaryModelId}</span><span>{route.fallbackModelLabel || route.fallback_model_id || route.fallbackModelId || "无"}</span><span>{Number(route.budgetLimitCny || route.budget_limit_cny || 0).toFixed(2)} CNY</span><span>{route.policy_mode || route.policyMode}<small className="model-probe-meta">{route.approval_mode || route.approvalMode}</small></span><StatusBadge status={route.status} label={route.status === "active" ? "启用" : route.status} /><div className="model-actions"><button className="icon-text-button" onClick={() => { setEditingRoute(route); setRouteEditForm(routeFormFromEntry(route)); }}><Settings2 size={13} />编辑</button></div></div>)}</div>
|
||||
</section>
|
||||
<section className="studio-card wide-card"><SectionBar title="新增路由策略" detail="按业务流程选择主模型与回退模型" /><form className="enterprise-form-grid" onSubmit={submitRoute}><label>策略名称<input value={routeForm.name} onChange={(event) => setRouteForm((current) => ({ ...current, name: event.target.value }))} placeholder="例如:小说/剧本导入解析" /></label><label>工作流 Key<input value={routeForm.workflowKey} onChange={(event) => setRouteForm((current) => ({ ...current, workflowKey: event.target.value }))} placeholder="knowledge-ingest" /></label><label>操作 Key<input value={routeForm.operationKey} onChange={(event) => setRouteForm((current) => ({ ...current, operationKey: event.target.value }))} placeholder="chunk-and-extract" /></label><label>主模型<select value={routeForm.primaryModelId} onChange={(event) => setRouteForm((current) => ({ ...current, primaryModelId: event.target.value }))}>{catalog.map((entry) => <option key={entry.id} value={entry.id}>{entry.display_name || entry.displayName}</option>)}</select></label><label>回退模型<select value={routeForm.fallbackModelId} onChange={(event) => setRouteForm((current) => ({ ...current, fallbackModelId: event.target.value }))}><option value="">无</option>{catalog.map((entry) => <option key={entry.id} value={entry.id}>{entry.display_name || entry.displayName}</option>)}</select></label><label>策略模式<select value={routeForm.policyMode} onChange={(event) => setRouteForm((current) => ({ ...current, policyMode: event.target.value }))}><option value="prefer-local">prefer-local</option><option value="local-only">local-only</option><option value="prefer-approved">prefer-approved</option><option value="manual-select">manual-select</option></select></label><label>审批模式<select value={routeForm.approvalMode} onChange={(event) => setRouteForm((current) => ({ ...current, approvalMode: event.target.value }))}><option value="follow-model">follow-model</option><option value="explicit-review">explicit-review</option><option value="always-allow">always-allow</option></select></label><label>预算上限 CNY<input type="number" min="0" step="0.01" value={routeForm.budgetLimitCny} onChange={(event) => setRouteForm((current) => ({ ...current, budgetLimitCny: event.target.value }))} /></label><label>状态<select value={routeForm.status} onChange={(event) => setRouteForm((current) => ({ ...current, status: event.target.value }))}><option value="active">active</option><option value="draft">draft</option><option value="paused">paused</option><option value="archived">archived</option></select></label><label className="model-editor-wide">路由说明<textarea value={routeForm.notes} onChange={(event) => setRouteForm((current) => ({ ...current, notes: event.target.value }))} placeholder="例如:知识库先走本地解析;若后续接入 embedding,可作为检索补充。" /></label><div className="form-actions"><button className="primary" type="submit"><Plus size={15} />新增路由策略</button></div></form></section>
|
||||
{editingModel && <div className="production-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && setEditingModel(null)}><section className="production-modal model-edit-modal" role="dialog" aria-modal="true" aria-labelledby="model-edit-title"><div className="production-modal-head"><div><span className="card-kicker">MODEL CONNECTOR</span><h3 id="model-edit-title">编辑连接器 · {editingModel.label}</h3></div><button className="icon-only-button" type="button" onClick={() => setEditingModel(null)} title="关闭编辑连接器" aria-label="关闭编辑连接器"><X size={16} /></button></div><p className="muted-copy">修改会写入当前组织的连接器注册表。平台只保存环境变量名称,不会把 API Key 写入数据库。</p><form className="enterprise-form-grid" onSubmit={saveEdit}><ModelConnectorFields form={editForm} setForm={setEditForm} widePrefix="edit-model-fields" /><div className="form-actions"><button className="subtle" type="button" onClick={() => setEditingModel(null)}>取消</button><button className="primary" type="submit"><Save size={15} />保存连接器</button></div></form></section></div>}
|
||||
{editingCatalog && <div className="production-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && setEditingCatalog(null)}><section className="production-modal model-edit-modal" role="dialog" aria-modal="true" aria-labelledby="catalog-edit-title"><div className="production-modal-head"><div><span className="card-kicker">MODEL CATALOG</span><h3 id="catalog-edit-title">编辑模型条目 · {editingCatalog.display_name || editingCatalog.displayName}</h3></div><button className="icon-only-button" type="button" onClick={() => setEditingCatalog(null)} title="关闭编辑模型条目" aria-label="关闭编辑模型条目"><X size={16} /></button></div><form className="enterprise-form-grid" onSubmit={saveCatalogEdit}><label>挂载连接器<select value={catalogEditForm.connectorId} onChange={(event) => setCatalogEditForm((current) => ({ ...current, connectorId: event.target.value }))}>{models.map((model) => <option key={model.id} value={model.id}>{model.label}</option>)}</select></label><label>模型名称<input value={catalogEditForm.displayName} onChange={(event) => setCatalogEditForm((current) => ({ ...current, displayName: event.target.value }))} /></label><label>模型 Key<input value={catalogEditForm.modelKey} onChange={(event) => setCatalogEditForm((current) => ({ ...current, modelKey: event.target.value }))} /></label><label>模型族<input value={catalogEditForm.family} onChange={(event) => setCatalogEditForm((current) => ({ ...current, family: event.target.value }))} /></label><label>能力标签<input value={catalogEditForm.capabilities} onChange={(event) => setCatalogEditForm((current) => ({ ...current, capabilities: event.target.value }))} /></label><label>上下文窗口<input type="number" min="0" value={catalogEditForm.contextWindow} onChange={(event) => setCatalogEditForm((current) => ({ ...current, contextWindow: event.target.value }))} /></label><label>最大输出<input type="number" min="0" value={catalogEditForm.maxOutputTokens} onChange={(event) => setCatalogEditForm((current) => ({ ...current, maxOutputTokens: event.target.value }))} /></label><label>成本归属<select value={catalogEditForm.locality} onChange={(event) => setCatalogEditForm((current) => ({ ...current, locality: event.target.value }))}><option value="local">local</option><option value="mixed">mixed</option><option value="cloud">cloud</option></select></label><label>估算成本 CNY<input type="number" min="0" step="0.01" value={catalogEditForm.estimatedCny} onChange={(event) => setCatalogEditForm((current) => ({ ...current, estimatedCny: event.target.value }))} /></label><label>状态<select value={catalogEditForm.status} onChange={(event) => setCatalogEditForm((current) => ({ ...current, status: event.target.value }))}><option value="active">active</option><option value="draft">draft</option><option value="paused">paused</option><option value="review">review</option></select></label><label>审批<select value={catalogEditForm.approvalStatus} onChange={(event) => setCatalogEditForm((current) => ({ ...current, approvalStatus: event.target.value }))}><option value="approved">approved</option><option value="review">review</option><option value="pending">pending</option><option value="blocked">blocked</option></select></label><label className="model-editor-wide">说明<textarea value={catalogEditForm.notes} onChange={(event) => setCatalogEditForm((current) => ({ ...current, notes: event.target.value }))} /></label><div className="form-actions"><button className="subtle" type="button" onClick={() => setEditingCatalog(null)}>取消</button><button className="primary" type="submit"><Save size={15} />保存模型条目</button></div></form></section></div>}
|
||||
{editingRoute && <div className="production-modal-backdrop" onMouseDown={(event) => event.target === event.currentTarget && setEditingRoute(null)}><section className="production-modal model-edit-modal" role="dialog" aria-modal="true" aria-labelledby="route-edit-title"><div className="production-modal-head"><div><span className="card-kicker">ROUTING POLICY</span><h3 id="route-edit-title">编辑路由策略 · {editingRoute.name}</h3></div><button className="icon-only-button" type="button" onClick={() => setEditingRoute(null)} title="关闭编辑路由策略" aria-label="关闭编辑路由策略"><X size={16} /></button></div><form className="enterprise-form-grid" onSubmit={saveRouteEdit}><label>策略名称<input value={routeEditForm.name} onChange={(event) => setRouteEditForm((current) => ({ ...current, name: event.target.value }))} /></label><label>工作流 Key<input value={routeEditForm.workflowKey} onChange={(event) => setRouteEditForm((current) => ({ ...current, workflowKey: event.target.value }))} /></label><label>操作 Key<input value={routeEditForm.operationKey} onChange={(event) => setRouteEditForm((current) => ({ ...current, operationKey: event.target.value }))} /></label><label>主模型<select value={routeEditForm.primaryModelId} onChange={(event) => setRouteEditForm((current) => ({ ...current, primaryModelId: event.target.value }))}>{catalog.map((entry) => <option key={entry.id} value={entry.id}>{entry.display_name || entry.displayName}</option>)}</select></label><label>回退模型<select value={routeEditForm.fallbackModelId} onChange={(event) => setRouteEditForm((current) => ({ ...current, fallbackModelId: event.target.value }))}><option value="">无</option>{catalog.map((entry) => <option key={entry.id} value={entry.id}>{entry.display_name || entry.displayName}</option>)}</select></label><label>策略模式<select value={routeEditForm.policyMode} onChange={(event) => setRouteEditForm((current) => ({ ...current, policyMode: event.target.value }))}><option value="prefer-local">prefer-local</option><option value="local-only">local-only</option><option value="prefer-approved">prefer-approved</option><option value="manual-select">manual-select</option></select></label><label>审批模式<select value={routeEditForm.approvalMode} onChange={(event) => setRouteEditForm((current) => ({ ...current, approvalMode: event.target.value }))}><option value="follow-model">follow-model</option><option value="explicit-review">explicit-review</option><option value="always-allow">always-allow</option></select></label><label>预算上限 CNY<input type="number" min="0" step="0.01" value={routeEditForm.budgetLimitCny} onChange={(event) => setRouteEditForm((current) => ({ ...current, budgetLimitCny: event.target.value }))} /></label><label>状态<select value={routeEditForm.status} onChange={(event) => setRouteEditForm((current) => ({ ...current, status: event.target.value }))}><option value="active">active</option><option value="draft">draft</option><option value="paused">paused</option><option value="archived">archived</option></select></label><label className="model-editor-wide">路由说明<textarea value={routeEditForm.notes} onChange={(event) => setRouteEditForm((current) => ({ ...current, notes: event.target.value }))} /></label><div className="form-actions"><button className="subtle" type="button" onClick={() => setEditingRoute(null)}>取消</button><button className="primary" type="submit"><Save size={15} />保存路由策略</button></div></form></section></div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2458,8 +2959,76 @@ function readinessBadge(check) {
|
||||
return map[check.status] || { status: "neutral", label: check.status || "未知" };
|
||||
}
|
||||
|
||||
function emptyPlanTemplateForm() {
|
||||
return {
|
||||
id: "",
|
||||
tierKey: "",
|
||||
name: "",
|
||||
description: "",
|
||||
billingCycle: "monthly",
|
||||
currency: "CNY",
|
||||
baseFee: 0,
|
||||
seatLimit: 5,
|
||||
storageGb: 256,
|
||||
monthlyClipQuota: 500,
|
||||
supportSla: "工作日响应",
|
||||
status: "active",
|
||||
limitsJson: JSON.stringify({ workspaces: 2, projects: 8, modelConnectors: 6, apiClients: 3, knowledgeDocuments: 80, knowledgeContextPacks: 40, deliveryChannels: 3 }, null, 2),
|
||||
featuresJson: JSON.stringify({ batchGeneration: false, privateDeliveryPortal: true, comfyuiAdapter: false, externalCloudConnectors: false }, null, 2),
|
||||
connectorPolicyJson: JSON.stringify({ allowedCostModes: ["local"], externalApprovalRequired: true }, null, 2)
|
||||
};
|
||||
}
|
||||
|
||||
function planTemplateFormFromPlan(plan) {
|
||||
return {
|
||||
id: plan.id || "",
|
||||
tierKey: plan.tierKey || "",
|
||||
name: plan.name || "",
|
||||
description: plan.description || "",
|
||||
billingCycle: plan.billingCycle || "monthly",
|
||||
currency: plan.currency || "CNY",
|
||||
baseFee: Number(plan.baseFee || 0),
|
||||
seatLimit: Number(plan.seatLimit || 1),
|
||||
storageGb: Number(plan.storageGb || 1),
|
||||
monthlyClipQuota: Number(plan.monthlyClipQuota || 1),
|
||||
supportSla: plan.supportSla || "",
|
||||
status: plan.status || "active",
|
||||
limitsJson: JSON.stringify(plan.limits || {}, null, 2),
|
||||
featuresJson: JSON.stringify(plan.features || {}, null, 2),
|
||||
connectorPolicyJson: JSON.stringify(plan.connectorPolicy || {}, null, 2)
|
||||
};
|
||||
}
|
||||
|
||||
function parsePlanTemplateForm(form) {
|
||||
const parseObject = (value, label) => {
|
||||
try {
|
||||
const parsed = JSON.parse(value || "{}");
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("not-object");
|
||||
return parsed;
|
||||
} catch {
|
||||
throw new Error(`${label} 必须是 JSON 对象`);
|
||||
}
|
||||
};
|
||||
return {
|
||||
tierKey: form.tierKey,
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
billingCycle: form.billingCycle,
|
||||
currency: form.currency,
|
||||
baseFee: Number(form.baseFee || 0),
|
||||
seatLimit: Number(form.seatLimit || 0),
|
||||
storageGb: Number(form.storageGb || 0),
|
||||
monthlyClipQuota: Number(form.monthlyClipQuota || 0),
|
||||
supportSla: form.supportSla,
|
||||
status: form.status,
|
||||
limits: parseObject(form.limitsJson, "limits"),
|
||||
features: parseObject(form.featuresJson, "features"),
|
||||
connectorPolicy: parseObject(form.connectorPolicyJson, "connectorPolicy")
|
||||
};
|
||||
}
|
||||
|
||||
export function SystemSettingsPage({ section = "overview", contextOverrides }) {
|
||||
const [config, setConfig] = useState({ settings: [], featureFlags: [], notifications: [], apiClients: [], apiClientScopes: fallbackApiClientScopes });
|
||||
const [config, setConfig] = useState({ settings: [], featureFlags: [], planTemplates: [], notifications: [], apiClients: [], apiClientScopes: fallbackApiClientScopes });
|
||||
const [health, setHealth] = useState({ services: [], summary: {} });
|
||||
const [readiness, setReadiness] = useState({ checks: [], summary: {}, backups: { backups: [], count: 0 } });
|
||||
const [storage, setStorage] = useState(null);
|
||||
@@ -2473,6 +3042,8 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) {
|
||||
const [apiKeyNotice, setApiKeyNotice] = useState("");
|
||||
const [backupBusy, setBackupBusy] = useState(false);
|
||||
const [cleanupBusy, setCleanupBusy] = useState(false);
|
||||
const [planTemplateForm, setPlanTemplateForm] = useState(emptyPlanTemplateForm());
|
||||
const [planTemplateBusy, setPlanTemplateBusy] = useState("");
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
@@ -2565,6 +3136,38 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) {
|
||||
}
|
||||
}
|
||||
|
||||
async function savePlanTemplate(event) {
|
||||
event.preventDefault();
|
||||
setPlanTemplateBusy("save");
|
||||
try {
|
||||
const body = parsePlanTemplateForm(planTemplateForm);
|
||||
const result = planTemplateForm.id
|
||||
? await updateSystemPlanTemplate(planTemplateForm.id, body, contextOverrides)
|
||||
: await createSystemPlanTemplate(body, contextOverrides);
|
||||
setConfig((current) => ({ ...current, planTemplates: result.planTemplates || current.planTemplates }));
|
||||
setPlanTemplateForm(planTemplateFormFromPlan(result.planTemplate || (result.planTemplates || []).find((plan) => plan.tierKey === body.tierKey) || body));
|
||||
setNotice(`套餐模板“${body.name}”已保存,并写入系统审计日志`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setPlanTemplateBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function archivePlanTemplate(plan) {
|
||||
setPlanTemplateBusy(plan.id);
|
||||
try {
|
||||
const result = await updateSystemPlanTemplate(plan.id, { ...plan, status: plan.status === "archived" ? "active" : "archived" }, contextOverrides);
|
||||
setConfig((current) => ({ ...current, planTemplates: result.planTemplates || current.planTemplates }));
|
||||
setPlanTemplateForm(planTemplateFormFromPlan(result.planTemplate || plan));
|
||||
setNotice(`套餐模板“${plan.name}”已${plan.status === "archived" ? "恢复" : "归档"}`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setPlanTemplateBusy("");
|
||||
}
|
||||
}
|
||||
|
||||
async function createBackup() {
|
||||
setBackupBusy(true);
|
||||
try {
|
||||
@@ -2618,6 +3221,28 @@ export function SystemSettingsPage({ section = "overview", contextOverrides }) {
|
||||
{(section === "overview" || section === "deployment") && <section className="studio-card wide-card"><SectionBar title="生产就绪度" detail={`${readiness.profile || "local-development"} · ${readiness.summary?.attention || 0} 项需要关注`} action={<StatusBadge status={readiness.summary?.status === "ready" ? "ready" : readiness.summary?.status === "blocked" ? "failed" : "needs-evidence"} label={readiness.summary?.status === "ready" ? "可运行" : readiness.summary?.status === "blocked" ? "有阻断项" : "需处理"} />} /><div className="readiness-grid">{(readiness.checks || []).map((check) => { const badge = readinessBadge(check); return <div key={check.key}><div><strong>{check.label}</strong><StatusBadge status={badge.status} label={badge.label} /></div><span>{check.detail}</span>{check.envName && <small>{check.envName}</small>}</div>; })}</div></section>}
|
||||
{(section !== "notifications" && section !== "overview" && section !== "features") && <section className="studio-card wide-card"><SectionBar title="系统配置" detail={dirtySettings.length ? `${dirtySettings.length} 项未保存` : "已同步"} action={<span className="setting-scope"><Database size={14} />SQLite / system_settings</span>} /><div className="settings-list">{visibleSettings.map((setting) => <div className="system-setting-row" key={setting.key}><div><strong>{settingLabels[setting.category] || setting.category} · {setting.key}</strong><span>{setting.description}</span></div><SettingInput setting={setting} value={setting.value} onChange={(value) => setSetting(setting.key, value)} /></div>)}</div></section>}
|
||||
{(section === "overview" || section === "generation" || section === "deployment" || section === "features") && <section className="studio-card wide-card"><SectionBar title="功能开关" detail="系统范围,变更立即生效" /><div className="feature-flag-grid">{config.featureFlags.map((flag) => <div key={flag.key}><div><strong>{flag.label}</strong><span>{flag.description}</span></div><button type="button" className={`switch-control ${flag.enabled ? "on" : ""}`} onClick={() => toggleFlag(flag)}><span />{flag.enabled ? "启用" : "关闭"}</button></div>)}</div></section>}
|
||||
{(section === "overview" || section === "features") && <section className="studio-card wide-card system-plan-management-card">
|
||||
<SectionBar title="商业套餐模板" detail={`${config.planTemplates?.length || 0} 个模板 · 系统级商品配置`} action={<button className="subtle" type="button" onClick={() => setPlanTemplateForm(emptyPlanTemplateForm())}><Plus size={14} />新建模板</button>} />
|
||||
<div className="plan-template-strip system-plan-template-strip">{(config.planTemplates || []).map((plan) => <div key={plan.id} className={planTemplateForm.id === plan.id ? "selected" : ""}><div><strong>{plan.name}</strong><StatusBadge status={plan.status === "archived" ? "disabled" : "active"} label={plan.status === "archived" ? "已归档" : plan.billingCycle} /></div><span>{plan.seatLimit} 席 · {plan.storageGb}GB · {plan.monthlyClipQuota} clips/月</span><small>{plan.description}</small><div className="plan-template-actions"><button className="icon-text-button" type="button" onClick={() => setPlanTemplateForm(planTemplateFormFromPlan(plan))}><Pencil size={13} />编辑</button><button className="icon-text-button" type="button" onClick={() => archivePlanTemplate(plan)} disabled={planTemplateBusy === plan.id}><Ban size={13} />{plan.status === "archived" ? "恢复" : "归档"}</button></div></div>)}</div>
|
||||
<form className="commercial-plan-form system-plan-template-form" onSubmit={savePlanTemplate}>
|
||||
<label>Tier Key<input value={planTemplateForm.tierKey} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, tierKey: event.target.value })} placeholder="studio-local" required /></label>
|
||||
<label>套餐名称<input value={planTemplateForm.name} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, name: event.target.value })} required /></label>
|
||||
<label>状态<select value={planTemplateForm.status} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, status: event.target.value })}><option value="active">active</option><option value="archived">archived</option></select></label>
|
||||
<label>账单周期<select value={planTemplateForm.billingCycle} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, billingCycle: event.target.value })}><option value="monthly">monthly</option><option value="quarterly">quarterly</option><option value="annual">annual</option></select></label>
|
||||
<label>货币<input maxLength="3" value={planTemplateForm.currency} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, currency: event.target.value.toUpperCase() })} /></label>
|
||||
<label>固定费<input type="number" min="0" step="0.01" value={planTemplateForm.baseFee} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, baseFee: event.target.value })} /></label>
|
||||
<label>席位上限<input type="number" min="1" value={planTemplateForm.seatLimit} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, seatLimit: event.target.value })} /></label>
|
||||
<label>存储 GB<input type="number" min="1" value={planTemplateForm.storageGb} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, storageGb: event.target.value })} /></label>
|
||||
<label>月度片段<input type="number" min="1" value={planTemplateForm.monthlyClipQuota} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, monthlyClipQuota: event.target.value })} /></label>
|
||||
<label>支持 SLA<input value={planTemplateForm.supportSla} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, supportSla: event.target.value })} /></label>
|
||||
<label className="plan-template-description">描述<textarea rows="3" value={planTemplateForm.description} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, description: event.target.value })} /></label>
|
||||
<label className="plan-template-json">limits JSON<textarea rows="6" value={planTemplateForm.limitsJson} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, limitsJson: event.target.value })} /></label>
|
||||
<label className="plan-template-json">features JSON<textarea rows="6" value={planTemplateForm.featuresJson} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, featuresJson: event.target.value })} /></label>
|
||||
<label className="plan-template-json">connectorPolicy JSON<textarea rows="6" value={planTemplateForm.connectorPolicyJson} onChange={(event) => setPlanTemplateForm({ ...planTemplateForm, connectorPolicyJson: event.target.value })} /></label>
|
||||
<div className="approval-policy-note"><ShieldCheck size={15} /><span>模板只定义商品与默认能力,不会直接改某个组织;组织页仍通过套餐策略和权益覆盖生效。</span></div>
|
||||
<div className="form-actions"><button className="primary" type="submit" disabled={planTemplateBusy === "save"}><Save size={15} />{planTemplateBusy === "save" ? "保存中…" : planTemplateForm.id ? "保存模板" : "创建模板"}</button></div>
|
||||
</form>
|
||||
</section>}
|
||||
{(section === "storage" || section === "overview") && <section className="studio-card wide-card"><SectionBar title="真实存储用量" detail={storage ? `当前项目 ${storage.percent}%` : "读取中"} /><div className="storage-admin-grid"><div className="storage-admin-kpi"><HardDrive size={20} /><strong>{storage ? `${storage.usedGb} GB` : "—"}</strong><span>已使用 / {storage?.limitGb || 0} GB</span></div><div className="storage-admin-track"><div><span>文件系统测量</span><b>{storage ? `${storage.usedBytes.toLocaleString()} / ${storage.limitBytes.toLocaleString()} bytes` : "—"}</b></div><div className="quota-track"><span style={{ width: `${storage?.percent || 0}%` }} /></div><small>资产、任务结果和本地合成输出均计入存储配额。</small></div><div className="storage-admin-files"><span>最大文件</span>{storage?.largestFiles?.slice(0, 4).map((file) => <div key={file.path}><strong>{file.relativePath}</strong><em>{(file.bytes / 1024 ** 2).toFixed(2)} MB</em></div>) || <small>暂无文件</small>}</div></div></section>}
|
||||
{(section === "storage" || section === "overview") && <section className="studio-card wide-card"><SectionBar title="临时媒体治理" detail={cleanup ? `保留 ${cleanup.policy.retentionDays} 天 · ${cleanup.candidates.length} 个候选` : "只处理无引用临时文件"} action={<div className="header-actions"><button className="subtle" onClick={previewCleanup} disabled={cleanupBusy}><RefreshCw size={14} />{cleanupBusy ? "检查中…" : "生成清理预览"}</button>{cleanup?.candidates?.length > 0 && <button className="danger-button" onClick={reclaimCleanup} disabled={cleanupBusy}><Trash2 size={14} />回收候选文件</button>}</div>} /><div className="storage-cleanup-summary"><div><strong>{cleanup?.candidates?.length || 0}</strong><span>无引用临时文件</span></div><div><strong>{((cleanup?.totalBytes || 0) / 1024 ** 2).toFixed(2)} MB</strong><span>预计可释放</span></div><div><strong>{cleanup?.policy?.provider || "local-filesystem"}</strong><span>存储提供方</span></div><div><strong>{cleanup?.cutoff ? cleanup.cutoff.slice(0, 10) : "—"}</strong><span>本次截止日期</span></div></div>{cleanup?.candidates?.length > 0 ? <div className="storage-cleanup-list">{cleanup.candidates.slice(0, 8).map((item) => <div key={item.relativePath}><span className="mono">{item.relativePath}</span><strong>{(item.bytes / 1024 ** 2).toFixed(2)} MB</strong><small>{item.reason} · {item.updatedAt?.replace("T", " ").slice(0, 19)}</small></div>)}{cleanup.candidates.length > 8 && <small className="muted-copy">还有 {cleanup.candidates.length - 8} 个候选文件,执行时会按服务端预览结果处理。</small>}</div> : <div className="empty-table">没有满足保留周期且未被生产证据引用的临时文件。先调整“存储 · storage.retention_days”后重新生成预览。</div>}</section>}
|
||||
{(section === "overview" || section === "deployment" || section === "storage") && <section className="studio-card wide-card"><SectionBar title="数据库快照备份" detail={`${readiness.backups?.count || 0} 个本地快照`} action={<button className="subtle" onClick={createBackup} disabled={backupBusy}><Database size={14} />{backupBusy ? "创建中…" : "立即创建快照"}</button>} /><div className="backup-summary-row"><div><Database size={18} /><strong>当前业务真源:SQLite</strong><span>快照写入 `data/backups/`,创建动作进入审计日志。</span></div><StatusBadge status={readiness.backups?.count ? "ready" : "needs-evidence"} label={readiness.backups?.count ? "已有备份" : "待创建"} /></div><div className="backup-list">{(readiness.backups?.backups || []).slice(0, 6).map((backup) => <div className="backup-row" key={backup.relativePath}><div><strong>{backup.relativePath}</strong><span>{formatAdminDate(backup.modifiedAt)} · {(Number(backup.bytes || 0) / 1024 ** 2).toFixed(2)} MB</span></div><StatusBadge status="active" label="可用" /></div>)}{!(readiness.backups?.backups || []).length && <div className="empty-table">还没有数据库快照。</div>}</div></section>}
|
||||
|
||||
@@ -0,0 +1,780 @@
|
||||
import React, { useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
AlertTriangle,
|
||||
Archive,
|
||||
BookOpenText,
|
||||
CheckCircle2,
|
||||
Database,
|
||||
Download,
|
||||
FileCheck2,
|
||||
FileSearch,
|
||||
History,
|
||||
Layers3,
|
||||
PackageCheck,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Search,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
Sparkles,
|
||||
Tags,
|
||||
WandSparkles
|
||||
} from "lucide-react";
|
||||
import {
|
||||
archiveKnowledgeLibraryDocument,
|
||||
createJob,
|
||||
createKnowledgeContextPack,
|
||||
fetchKnowledgeContextPacks,
|
||||
fetchKnowledgeDocument,
|
||||
fetchKnowledgeLibrary,
|
||||
fetchKnowledgeLibraryDocumentVersions,
|
||||
importKnowledgeLibraryDocument,
|
||||
materializeKnowledgeContextPack,
|
||||
materializeKnowledgeLibraryDocument,
|
||||
previewJob,
|
||||
restoreKnowledgeLibraryDocument,
|
||||
restoreKnowledgeLibraryDocumentVersion,
|
||||
reviewKnowledgeLibraryDocument,
|
||||
searchKnowledgeLibrary,
|
||||
updateKnowledgeLibraryDocument
|
||||
} from "../lib/api";
|
||||
|
||||
function SuiteHeader({ kicker, title, description, actions }) {
|
||||
return (
|
||||
<div className="suite-header">
|
||||
<div>
|
||||
<span className="card-kicker">{kicker}</span>
|
||||
<h2>{title}</h2>
|
||||
<p>{description}</p>
|
||||
</div>
|
||||
{actions && <div className="suite-header-actions">{actions}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuiteMetric({ icon: Icon, label, value, detail, tone = "neutral" }) {
|
||||
return (
|
||||
<div className={`suite-metric ${tone}`}>
|
||||
<div className="suite-metric-icon"><Icon size={17} /></div>
|
||||
<div><span>{label}</span><strong>{value}</strong><small>{detail}</small></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SuiteStatus({ status }) {
|
||||
const config = {
|
||||
indexed: ["已建索引", "ok"],
|
||||
ingested: ["已导入", "neutral"],
|
||||
draft: ["草稿", "neutral"],
|
||||
active: ["可复用", "ok"],
|
||||
archived: ["已归档", "neutral"],
|
||||
workspace: ["工作区级", "ok"],
|
||||
project: ["项目级", "warn"],
|
||||
approved: ["商用已批", "ok"],
|
||||
submitted: ["证据待审", "warn"],
|
||||
"needs-evidence": ["缺授权证据", "warn"],
|
||||
rejected: ["已驳回", "danger"],
|
||||
expired: ["授权过期", "danger"],
|
||||
pass: ["风险通过", "ok"],
|
||||
review: ["需复核", "warn"],
|
||||
warn: ["提示风险", "warn"],
|
||||
blocked: ["阻断", "danger"],
|
||||
unscanned: ["未扫描", "neutral"]
|
||||
}[status] || [status || "未设置", "neutral"];
|
||||
return <span className={`suite-status ${config[1]}`}><span />{config[0]}</span>;
|
||||
}
|
||||
|
||||
const rightsOptions = [
|
||||
["needs-evidence", "缺授权证据"],
|
||||
["submitted", "证据待审核"],
|
||||
["approved", "商用已批准"],
|
||||
["rejected", "已驳回"],
|
||||
["expired", "授权过期"]
|
||||
];
|
||||
|
||||
function provenanceFromDraft(draft) {
|
||||
return {
|
||||
sourceLabel: draft.provenance?.sourceLabel || "",
|
||||
author: draft.provenance?.author || "",
|
||||
rightsOwner: draft.provenance?.rightsOwner || "",
|
||||
evidenceRef: draft.provenance?.evidenceRef || "",
|
||||
licenseNote: draft.provenance?.licenseNote || "",
|
||||
sourceUrl: draft.provenance?.sourceUrl || "",
|
||||
importedFrom: draft.provenance?.importedFrom || "manual"
|
||||
};
|
||||
}
|
||||
|
||||
function provenanceFromDocument(document) {
|
||||
return {
|
||||
sourceLabel: document?.provenance?.sourceLabel || "",
|
||||
author: document?.provenance?.author || "",
|
||||
rightsOwner: document?.provenance?.rightsOwner || "",
|
||||
evidenceRef: document?.provenance?.evidenceRef || "",
|
||||
licenseNote: document?.provenance?.licenseNote || "",
|
||||
sourceUrl: document?.provenance?.sourceUrl || "",
|
||||
importedFrom: document?.provenance?.importedFrom || "manual"
|
||||
};
|
||||
}
|
||||
|
||||
function dedupe(values) {
|
||||
return [...new Set((values || []).map((item) => String(item || "").trim()).filter(Boolean))];
|
||||
}
|
||||
|
||||
function entityList(document, key) {
|
||||
const fromAnalysis = document?.analysis?.entities?.[key] || [];
|
||||
const fromChunks = (document?.chunks || []).flatMap((chunk) => chunk.entities?.[key] || []);
|
||||
return dedupe([...fromAnalysis, ...fromChunks]);
|
||||
}
|
||||
|
||||
function downloadJson(filename, payload) {
|
||||
const blob = new Blob([JSON.stringify(payload || {}, null, 2)], { type: "application/json" });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
const defaultDraft = {
|
||||
title: "《雾港来信》小说原稿",
|
||||
sourceType: "novel",
|
||||
scopeMode: "workspace",
|
||||
rightsStatus: "needs-evidence",
|
||||
provenance: {
|
||||
sourceLabel: "原创小说导入",
|
||||
author: "",
|
||||
rightsOwner: "",
|
||||
evidenceRef: "",
|
||||
licenseNote: "未填写授权证明前,只允许作为待审核素材。",
|
||||
sourceUrl: "",
|
||||
importedFrom: "manual"
|
||||
},
|
||||
tags: "原创,待审核,小说",
|
||||
content: [
|
||||
"第一章 夜航",
|
||||
"海雾刚压到码头上,顾临舟就看见灯塔下的白色信封。信封背面只有一行字:不要让任何人知道你今晚见过林栀。",
|
||||
"",
|
||||
"顾临舟:你不是说已经离开雾港了吗?",
|
||||
"林栀:我回来只做一件事,把当年的录音带找出来。",
|
||||
"",
|
||||
"第二章 空仓库",
|
||||
"他们沿着生锈的货轨走进旧仓库,风把卷帘门吹得作响。顾临舟意识到,仓库里提前有人来过,脚印还没被潮气完全抹掉。"
|
||||
].join("\n")
|
||||
};
|
||||
|
||||
const sourceTypes = [
|
||||
["all", "全部来源"],
|
||||
["novel", "小说"],
|
||||
["script", "剧本"],
|
||||
["worldbook", "世界观"],
|
||||
["character-bible", "人物设定"],
|
||||
["reference", "文字参考"]
|
||||
];
|
||||
|
||||
export function KnowledgeLibraryPage({ platformContext, contextOverrides }) {
|
||||
const permissions = new Set(platformContext?.context?.permissions || []);
|
||||
const canEditKnowledge = permissions.has("script:edit");
|
||||
const canManageCompliance = permissions.has("compliance:manage");
|
||||
const [library, setLibrary] = useState({ documents: [], summary: {} });
|
||||
const [packs, setPacks] = useState({ packs: [], summary: {} });
|
||||
const [selectedId, setSelectedId] = useState("");
|
||||
const [detail, setDetail] = useState(null);
|
||||
const [versions, setVersions] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [query, setQuery] = useState("");
|
||||
const [draft, setDraft] = useState(defaultDraft);
|
||||
const [editDraft, setEditDraft] = useState(null);
|
||||
const [reviewDraft, setReviewDraft] = useState({
|
||||
decision: "submitted",
|
||||
rightsStatus: "submitted",
|
||||
sourceLabel: "",
|
||||
author: "",
|
||||
rightsOwner: "",
|
||||
evidenceRef: "",
|
||||
licenseNote: "",
|
||||
sourceUrl: "",
|
||||
tags: "",
|
||||
notes: ""
|
||||
});
|
||||
const [selectedChunkIds, setSelectedChunkIds] = useState([]);
|
||||
const [searchForm, setSearchForm] = useState({ query: "蓝伞 地铁口", sourceType: "all", scopeMode: "all", limit: 12 });
|
||||
const [searchResult, setSearchResult] = useState({ results: [], summary: {} });
|
||||
const [selectedSearchChunkIds, setSelectedSearchChunkIds] = useState([]);
|
||||
const [activePack, setActivePack] = useState(null);
|
||||
const [packBudget, setPackBudget] = useState("1200");
|
||||
const [searchLoading, setSearchLoading] = useState(false);
|
||||
const [jobPreview, setJobPreview] = useState(null);
|
||||
|
||||
async function loadLibrary(nextSelectedId = "") {
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await fetchKnowledgeLibrary(contextOverrides);
|
||||
setLibrary(result);
|
||||
const preferredId = nextSelectedId || selectedId || result.documents?.[0]?.id || "";
|
||||
setSelectedId(preferredId);
|
||||
if (!nextSelectedId && !selectedId && result.documents?.[0]?.id) setNotice("");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPacks() {
|
||||
try {
|
||||
const result = await fetchKnowledgeContextPacks(contextOverrides);
|
||||
setPacks(result);
|
||||
setActivePack((current) => current || result.packs?.[0] || null);
|
||||
} catch {
|
||||
setPacks({ packs: [], summary: {} });
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDetail(documentId) {
|
||||
if (!documentId) {
|
||||
setDetail(null);
|
||||
setVersions([]);
|
||||
return;
|
||||
}
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
const result = await fetchKnowledgeDocument(documentId, contextOverrides);
|
||||
setDetail(result.document);
|
||||
setSelectedChunkIds((result.document.chunks || []).map((chunk) => chunk.id));
|
||||
setEditDraft({
|
||||
title: result.document.title || "",
|
||||
sourceType: result.document.sourceType || result.document.source_type || "novel",
|
||||
content: result.document.content || ""
|
||||
});
|
||||
const provenance = provenanceFromDocument(result.document);
|
||||
setReviewDraft({
|
||||
decision: "submitted",
|
||||
rightsStatus: result.document.rightsStatus || "needs-evidence",
|
||||
sourceLabel: provenance.sourceLabel,
|
||||
author: provenance.author,
|
||||
rightsOwner: provenance.rightsOwner,
|
||||
evidenceRef: provenance.evidenceRef,
|
||||
licenseNote: provenance.licenseNote,
|
||||
sourceUrl: provenance.sourceUrl,
|
||||
tags: (result.document.tags || []).join(","),
|
||||
notes: result.document.latestReview?.notes || ""
|
||||
});
|
||||
const versionResult = await fetchKnowledgeLibraryDocumentVersions(documentId, contextOverrides);
|
||||
setVersions(versionResult.versions || []);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runKnowledgeSearch(nextForm = searchForm, { preselect = true } = {}) {
|
||||
setSearchLoading(true);
|
||||
try {
|
||||
const result = await searchKnowledgeLibrary(nextForm, contextOverrides);
|
||||
setSearchResult(result);
|
||||
if (preselect) setSelectedSearchChunkIds((result.results || []).slice(0, 3).map((item) => item.id));
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setSearchLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadLibrary();
|
||||
loadPacks();
|
||||
runKnowledgeSearch(searchForm);
|
||||
}, [contextOverrides.organizationId, contextOverrides.workspaceId, contextOverrides.projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedId) loadDetail(selectedId);
|
||||
else setDetail(null);
|
||||
}, [selectedId]);
|
||||
|
||||
const filteredDocuments = useMemo(() => (library.documents || []).filter((item) => `${item.title} ${item.source_type} ${item.sourceType} ${item.summary} ${item.rightsStatus} ${item.riskStatus} ${(item.tags || []).join(" ")} ${item.provenance?.sourceLabel || ""}`.toLowerCase().includes(query.toLowerCase())), [library.documents, query]);
|
||||
const characters = entityList(detail, "characters");
|
||||
const locations = entityList(detail, "locations");
|
||||
const props = entityList(detail, "props");
|
||||
const selectedPackChunkIds = selectedSearchChunkIds.length ? selectedSearchChunkIds : selectedChunkIds;
|
||||
|
||||
async function submitImport() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await importKnowledgeLibraryDocument({
|
||||
...draft,
|
||||
provenance: provenanceFromDraft(draft),
|
||||
tags: draft.tags
|
||||
}, contextOverrides);
|
||||
setNotice(`已导入《${result.document.title}》,生成 ${result.document.chunkCount} 个知识片段。`);
|
||||
await loadLibrary(result.document.id);
|
||||
await loadDetail(result.document.id);
|
||||
await runKnowledgeSearch({ ...searchForm, query: result.document.title }, { preselect: true });
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateDraftProvenance(patch) {
|
||||
setDraft((current) => ({ ...current, provenance: { ...(current.provenance || {}), ...patch } }));
|
||||
}
|
||||
|
||||
function reviewPayload(decision = reviewDraft.decision) {
|
||||
const rightsStatus = decision === "approved" ? "approved" : decision === "rejected" ? "rejected" : reviewDraft.rightsStatus;
|
||||
return {
|
||||
decision,
|
||||
rightsStatus,
|
||||
tags: reviewDraft.tags,
|
||||
notes: reviewDraft.notes,
|
||||
provenance: {
|
||||
sourceLabel: reviewDraft.sourceLabel,
|
||||
author: reviewDraft.author,
|
||||
rightsOwner: reviewDraft.rightsOwner,
|
||||
evidenceRef: reviewDraft.evidenceRef,
|
||||
licenseNote: reviewDraft.licenseNote,
|
||||
sourceUrl: reviewDraft.sourceUrl,
|
||||
importedFrom: "manual"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function saveDocumentUpdate() {
|
||||
if (!detail?.id || !editDraft) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await updateKnowledgeLibraryDocument(detail.id, {
|
||||
...editDraft,
|
||||
rightsStatus: reviewDraft.rightsStatus,
|
||||
tags: reviewDraft.tags,
|
||||
provenance: reviewPayload().provenance
|
||||
}, contextOverrides);
|
||||
setNotice(`已保存《${result.document.title}》为 v${result.document.currentVersionNumber},并重新扫描治理风险。`);
|
||||
await loadLibrary(result.document.id);
|
||||
await loadDetail(result.document.id);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function submitGovernance(decision) {
|
||||
if (!detail?.id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await reviewKnowledgeLibraryDocument(detail.id, reviewPayload(decision), contextOverrides);
|
||||
setNotice(decision === "approved" ? "素材已批准商用,审批记录已写入审计链。" : decision === "rejected" ? "素材已驳回,生产入口会阻断该素材。" : "素材证据已提交审核。");
|
||||
await loadLibrary(result.document.id);
|
||||
await loadDetail(result.document.id);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleArchiveDocument() {
|
||||
if (!detail?.id) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = detail.status === "archived"
|
||||
? await restoreKnowledgeLibraryDocument(detail.id, contextOverrides)
|
||||
: await archiveKnowledgeLibraryDocument(detail.id, contextOverrides);
|
||||
setNotice(result.document.status === "archived" ? "素材已归档,检索与引用包创建会隐藏它。" : "素材已恢复索引,可重新检索和引用。");
|
||||
await loadLibrary(result.document.id);
|
||||
await loadDetail(result.document.id);
|
||||
await runKnowledgeSearch(searchForm, { preselect: false });
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreVersion(versionKey) {
|
||||
if (!detail?.id || !versionKey) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await restoreKnowledgeLibraryDocumentVersion(detail.id, versionKey, contextOverrides);
|
||||
setNotice(`已从历史版本恢复,并生成新的当前版本 v${result.document.currentVersionNumber}。`);
|
||||
await loadLibrary(result.document.id);
|
||||
await loadDetail(result.document.id);
|
||||
await runKnowledgeSearch({ ...searchForm, query: result.document.title }, { preselect: false });
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function materializeSelected(pack = activePack) {
|
||||
const chunkIds = pack?.chunkIds?.length ? pack.chunkIds : selectedChunkIds;
|
||||
if (!detail?.id && !chunkIds.length) {
|
||||
setNotice("请先选择知识片段或上下文包。");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = pack?.id
|
||||
? await materializeKnowledgeContextPack(pack.id, { title: `${pack.name || "知识库上下文包"} · 剧本草稿` }, contextOverrides)
|
||||
: await materializeKnowledgeLibraryDocument(detail?.id || "", {
|
||||
title: `${detail?.title || "知识库素材"} · 剧本草稿`,
|
||||
chunkIds
|
||||
}, contextOverrides);
|
||||
setNotice(`已送入剧本工厂,生成剧本版本 ${result.importedScript?.version_number || ""}。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function buildContextPack() {
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await createKnowledgeContextPack({
|
||||
name: searchForm.query ? `检索包:${searchForm.query}` : `${detail?.title || "选中素材"} · 上下文包`,
|
||||
query: searchForm.query,
|
||||
sourceType: searchForm.sourceType === "all" ? "mixed" : searchForm.sourceType,
|
||||
scopeMode: searchForm.scopeMode === "project" ? "project" : "workspace",
|
||||
maxTokens: Number(packBudget || 1200),
|
||||
chunkIds: selectedPackChunkIds
|
||||
}, contextOverrides);
|
||||
setActivePack(result.pack);
|
||||
setPacks({ packs: result.packs || [result.pack, ...(packs.packs || [])], summary: result.summary || packs.summary });
|
||||
setNotice(`已创建上下文包“${result.pack.name}”,包含 ${result.pack.selectedCount} 个片段、约 ${result.pack.tokenEstimate} tokens。`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function previewScriptJob() {
|
||||
if (!activePack?.id) {
|
||||
setNotice("请先创建或选择一个上下文包。");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await previewJob({
|
||||
kind: "场景草稿",
|
||||
workflowKey: "script-pipeline",
|
||||
operationKey: "scene-draft",
|
||||
adapter: "owned-model-platform",
|
||||
knowledgePackId: activePack.id,
|
||||
inputs: { prompt: "基于知识库上下文包生成 5-10 秒连续短剧镜头草稿,保留引用证据。" }
|
||||
}, contextOverrides);
|
||||
setJobPreview(result.preview);
|
||||
setNotice("已生成剧本拆解任务预览,引用包已写入请求合同。");
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function createScriptJob() {
|
||||
if (!activePack?.id) {
|
||||
setNotice("请先创建或选择一个上下文包。");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await createJob({
|
||||
kind: "场景草稿",
|
||||
workflowKey: "script-pipeline",
|
||||
operationKey: "scene-draft",
|
||||
adapter: "owned-model-platform",
|
||||
knowledgePackId: activePack.id,
|
||||
inputs: { prompt: "基于知识库上下文包生成 5-10 秒连续短剧镜头草稿,保留引用证据。" }
|
||||
}, contextOverrides);
|
||||
setJobPreview(result.job?.request || null);
|
||||
setNotice(`已创建剧本拆解任务:${result.job?.id || ""}`);
|
||||
} catch (error) {
|
||||
setNotice(error.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleChunk(chunkId) {
|
||||
setSelectedChunkIds((current) => current.includes(chunkId) ? current.filter((item) => item !== chunkId) : [...current, chunkId]);
|
||||
}
|
||||
|
||||
function toggleSearchChunk(chunkId) {
|
||||
setSelectedSearchChunkIds((current) => current.includes(chunkId) ? current.filter((item) => item !== chunkId) : [...current, chunkId]);
|
||||
}
|
||||
|
||||
function updateSearchForm(patch) {
|
||||
setSearchForm((current) => ({ ...current, ...patch }));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="production-page">
|
||||
<SuiteHeader
|
||||
kicker="CREATOR SUITE / KNOWLEDGE LIBRARY"
|
||||
title="内容知识库"
|
||||
description="存放小说、剧本原稿、角色设定、世界观和参考文本;导入后自动分块、抽实体、建引用包,并可接入剧本拆解任务。"
|
||||
actions={<>
|
||||
<button className="subtle" onClick={() => { loadLibrary(); loadPacks(); runKnowledgeSearch(searchForm, { preselect: false }); }}><RefreshCw size={15} />刷新</button>
|
||||
<button className="primary" onClick={submitImport} disabled={busy || !canEditKnowledge}><Sparkles size={15} />{busy ? "导入中…" : "导入并分块"}</button>
|
||||
</>}
|
||||
/>
|
||||
{notice && <div className="inline-notice ok"><CheckCircle2 size={15} />{notice}</div>}
|
||||
<div className="suite-metric-grid">
|
||||
<SuiteMetric icon={Database} label="知识文档" value={library.summary?.total || 0} detail={`${library.summary?.workspaceScoped || 0} 工作区级 / ${library.summary?.projectScoped || 0} 项目级`} tone="ok" />
|
||||
<SuiteMetric icon={Layers3} label="知识片段" value={library.summary?.chunks || 0} detail="chapter / scene / dialogue / lore" />
|
||||
<SuiteMetric icon={FileCheck2} label="商用已批" value={library.summary?.rightsApproved || 0} detail={`${library.summary?.rightsNeedsEvidence || 0} 份仍需证据`} tone={library.summary?.rightsNeedsEvidence ? "warn" : "ok"} />
|
||||
<SuiteMetric icon={ShieldAlert} label="风险待处理" value={(library.summary?.riskBlocked || 0) + (library.summary?.riskReview || 0)} detail={`${packs.summary?.total || packs.packs?.length || 0} 个上下文包`} tone={(library.summary?.riskBlocked || library.summary?.riskReview) ? "warn" : "ok"} />
|
||||
</div>
|
||||
<div className="knowledge-library-layout">
|
||||
<section className="studio-card">
|
||||
<div className="section-bar">
|
||||
<div><h3>文档目录</h3><span>{loading ? "读取中…" : `${filteredDocuments.length} / ${library.documents?.length || 0} 份素材`}</span></div>
|
||||
</div>
|
||||
<div className="search-field">
|
||||
<Search size={15} />
|
||||
<input value={query} onChange={(event) => setQuery(event.target.value)} placeholder="搜索标题、来源或摘要" />
|
||||
</div>
|
||||
<div className="knowledge-doc-list">
|
||||
{filteredDocuments.map((document) => (
|
||||
<button key={document.id} type="button" className={`knowledge-doc-row ${selectedId === document.id ? "selected" : ""}`} onClick={() => setSelectedId(document.id)}>
|
||||
<div>
|
||||
<strong>{document.title}</strong>
|
||||
<span>{document.source_type} · {document.chunkCount || 0} 个片段</span>
|
||||
</div>
|
||||
<div className="knowledge-doc-statuses">
|
||||
<SuiteStatus status={document.scopeMode} />
|
||||
<SuiteStatus status={document.status} />
|
||||
<SuiteStatus status={document.rightsStatus} />
|
||||
<SuiteStatus status={document.riskStatus} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
{!filteredDocuments.length && <div className="empty-table">没有匹配的知识文档。</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="studio-card">
|
||||
<div className="section-bar">
|
||||
<div><h3>导入新素材</h3><span>本地规则分块,可后续切换自有模型</span></div>
|
||||
</div>
|
||||
<div className="knowledge-import-form">
|
||||
<label>文档标题<input value={draft.title} onChange={(event) => setDraft((current) => ({ ...current, title: event.target.value }))} /></label>
|
||||
<div className="knowledge-import-grid">
|
||||
<label>来源类型<select value={draft.sourceType} onChange={(event) => setDraft((current) => ({ ...current, sourceType: event.target.value }))}>{sourceTypes.filter(([value]) => value !== "all").map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>作用域<select value={draft.scopeMode} onChange={(event) => setDraft((current) => ({ ...current, scopeMode: event.target.value }))}><option value="workspace">工作区复用</option><option value="project">当前项目专用</option></select></label>
|
||||
<label>版权状态<select value={draft.rightsStatus} onChange={(event) => setDraft((current) => ({ ...current, rightsStatus: event.target.value }))}>{rightsOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>来源名称<input value={draft.provenance?.sourceLabel || ""} onChange={(event) => updateDraftProvenance({ sourceLabel: event.target.value })} placeholder="原创小说 / 已签授权 / 内部资料" /></label>
|
||||
<label>作者<input value={draft.provenance?.author || ""} onChange={(event) => updateDraftProvenance({ author: event.target.value })} /></label>
|
||||
<label>权利人<input value={draft.provenance?.rightsOwner || ""} onChange={(event) => updateDraftProvenance({ rightsOwner: event.target.value })} /></label>
|
||||
<label>证据引用<input value={draft.provenance?.evidenceRef || ""} onChange={(event) => updateDraftProvenance({ evidenceRef: event.target.value })} placeholder="contract:// 或本地授权编号" /></label>
|
||||
<label>标签<input value={draft.tags || ""} onChange={(event) => setDraft((current) => ({ ...current, tags: event.target.value }))} placeholder="原创,待审核,都市悬疑" /></label>
|
||||
</div>
|
||||
<label>许可证/授权备注<textarea rows={3} value={draft.provenance?.licenseNote || ""} onChange={(event) => updateDraftProvenance({ licenseNote: event.target.value })} /></label>
|
||||
<label>素材正文<textarea rows={14} value={draft.content} onChange={(event) => setDraft((current) => ({ ...current, content: event.target.value }))} /></label>
|
||||
<div className="form-actions">
|
||||
<button className="primary" type="button" onClick={submitImport} disabled={busy || !canEditKnowledge}><Sparkles size={15} />导入并切分</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<div className="knowledge-rag-grid">
|
||||
<section className="studio-card wide-card">
|
||||
<div className="section-bar">
|
||||
<div><h3>检索与引用包</h3><span>{searchLoading ? "检索中…" : `${searchResult.total || 0} 个匹配片段`}</span></div>
|
||||
<button className="subtle" type="button" onClick={() => runKnowledgeSearch(searchForm)}><FileSearch size={14} />检索</button>
|
||||
</div>
|
||||
<div className="knowledge-search-controls">
|
||||
<label>检索词<input value={searchForm.query} onChange={(event) => updateSearchForm({ query: event.target.value })} placeholder="例如:蓝伞 地铁口 唐夏" /></label>
|
||||
<label>来源<select value={searchForm.sourceType} onChange={(event) => updateSearchForm({ sourceType: event.target.value })}>{sourceTypes.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>作用域<select value={searchForm.scopeMode} onChange={(event) => updateSearchForm({ scopeMode: event.target.value })}><option value="all">全部可访问</option><option value="workspace">工作区级</option><option value="project">项目级</option></select></label>
|
||||
<label>Token 预算<input type="number" min="100" max="20000" step="100" value={packBudget} onChange={(event) => setPackBudget(event.target.value)} /></label>
|
||||
</div>
|
||||
<div className="knowledge-search-results">
|
||||
{(searchResult.results || []).map((result) => (
|
||||
<label key={result.id} className={`knowledge-result-row ${selectedSearchChunkIds.includes(result.id) ? "active" : ""}`}>
|
||||
<input type="checkbox" checked={selectedSearchChunkIds.includes(result.id)} onChange={() => toggleSearchChunk(result.id)} />
|
||||
<span className="knowledge-result-main">
|
||||
<strong>{result.citationKey} · {result.heading || result.documentTitle}</strong>
|
||||
<small>{result.documentTitle} · {result.chunkType} · score {result.score} · {result.rightsStatus || "needs-evidence"} / {result.riskStatus || "unscanned"}</small>
|
||||
<em>{result.snippet}</em>
|
||||
</span>
|
||||
<span className="knowledge-result-meta">~{result.tokenEstimate} tokens</span>
|
||||
</label>
|
||||
))}
|
||||
{!searchResult.results?.length && <div className="empty-table">还没有检索结果。输入关键词后点击检索。</div>}
|
||||
</div>
|
||||
</section>
|
||||
<section className="studio-card">
|
||||
<div className="section-bar">
|
||||
<div><h3>上下文包</h3><span>{activePack ? activePack.name : "未选择"}</span></div>
|
||||
</div>
|
||||
<div className="knowledge-pack-actions">
|
||||
<button className="primary" type="button" onClick={buildContextPack} disabled={busy || !selectedPackChunkIds.length || !canEditKnowledge}><PackageCheck size={14} />创建引用包</button>
|
||||
<button className="subtle" type="button" onClick={() => materializeSelected(activePack)} disabled={busy || !activePack?.id || !canEditKnowledge}><WandSparkles size={14} />包入剧本</button>
|
||||
<button className="subtle" type="button" onClick={previewScriptJob} disabled={busy || !activePack?.id}><ShieldCheck size={14} />预览任务合同</button>
|
||||
<button className="subtle" type="button" onClick={createScriptJob} disabled={busy || !activePack?.id}><Sparkles size={14} />创建拆解任务</button>
|
||||
</div>
|
||||
<div className="knowledge-pack-list">
|
||||
{(packs.packs || []).map((pack) => (
|
||||
<button key={pack.id} type="button" className={activePack?.id === pack.id ? "selected" : ""} onClick={() => { setActivePack(pack); setJobPreview(null); }}>
|
||||
<strong>{pack.name}</strong>
|
||||
<span>{pack.chunkIds?.length || 0} 个片段 · ~{pack.tokenEstimate || 0} tokens · {pack.governance?.status || "unscanned"}</span>
|
||||
</button>
|
||||
))}
|
||||
{!packs.packs?.length && <div className="empty-table">还没有保存的上下文包。</div>}
|
||||
</div>
|
||||
{activePack && <div className="knowledge-pack-preview">
|
||||
<div className="json-heading">
|
||||
<span>引用预览</span>
|
||||
<button className="icon-only-button" type="button" title="下载上下文包" aria-label="下载上下文包" onClick={() => downloadJson(`${activePack.id}.json`, activePack)}><Download size={14} /></button>
|
||||
</div>
|
||||
<pre>{activePack.promptContext}</pre>
|
||||
</div>}
|
||||
</section>
|
||||
</div>
|
||||
{jobPreview && <section className="studio-card wide-card knowledge-job-preview">
|
||||
<div className="json-heading">
|
||||
<h3>剧本拆解任务合同预览</h3>
|
||||
<button className="subtle" onClick={() => downloadJson("knowledge-script-job-preview.json", jobPreview)}><Download size={14} />下载合同</button>
|
||||
</div>
|
||||
<pre>{JSON.stringify(jobPreview, null, 2)}</pre>
|
||||
</section>}
|
||||
<div className="knowledge-detail-grid">
|
||||
<section className="studio-card wide-card">
|
||||
<div className="section-bar">
|
||||
<div><h3>文档详情</h3><span>{detailLoading ? "读取中…" : (detail?.title || "未选择文档")}</span></div>
|
||||
<div className="header-actions">
|
||||
<button className="subtle" type="button" onClick={() => selectedId && loadDetail(selectedId)} disabled={!selectedId || detailLoading}><RefreshCw size={14} />刷新详情</button>
|
||||
<button className="subtle" type="button" onClick={saveDocumentUpdate} disabled={busy || !detail?.id || !canEditKnowledge}><Save size={14} />保存新版本</button>
|
||||
<button className="subtle" type="button" onClick={toggleArchiveDocument} disabled={busy || !detail?.id || !canEditKnowledge}><Archive size={14} />{detail?.status === "archived" ? "恢复索引" : "归档素材"}</button>
|
||||
<button className="primary" type="button" onClick={() => materializeSelected(null)} disabled={busy || !selectedChunkIds.length || !canEditKnowledge || ["rejected", "expired"].includes(detail?.rightsStatus) || detail?.riskStatus === "blocked"}><WandSparkles size={14} />送入剧本工厂</button>
|
||||
</div>
|
||||
</div>
|
||||
{detail ? (
|
||||
<>
|
||||
<div className="knowledge-summary-grid">
|
||||
<div><span>摘要</span><strong>{detail.summary || "暂无摘要"}</strong></div>
|
||||
<div><span>解析器</span><strong>{detail.analysis?.parser || "local-rule-v2"}</strong></div>
|
||||
<div><span>章节</span><strong>{detail.analysis?.chapterCount || 0}</strong></div>
|
||||
<div><span>片段</span><strong>{detail.chunkCount || 0}</strong></div>
|
||||
<div><span>版权状态</span><strong><SuiteStatus status={detail.rightsStatus} /></strong></div>
|
||||
<div><span>风险状态</span><strong><SuiteStatus status={detail.riskStatus} /></strong></div>
|
||||
<div><span>当前版本</span><strong>v{detail.currentVersionNumber || 1} / {detail.versionCount || versions.length || 1}</strong></div>
|
||||
<div><span>最新审批</span><strong>{detail.latestReview ? `${detail.latestReview.decision} · ${detail.latestReview.createdAt.slice(0, 10)}` : "暂无审批记录"}</strong></div>
|
||||
</div>
|
||||
<div className="knowledge-governance-grid">
|
||||
<div className="knowledge-governance-panel">
|
||||
<div className="json-heading">
|
||||
<h3>来源与授权</h3>
|
||||
<SuiteStatus status={reviewDraft.rightsStatus} />
|
||||
</div>
|
||||
<div className="knowledge-governance-form">
|
||||
<label>版权状态<select value={reviewDraft.rightsStatus} onChange={(event) => setReviewDraft((current) => ({ ...current, rightsStatus: event.target.value }))}>{rightsOptions.map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label>来源名称<input value={reviewDraft.sourceLabel} onChange={(event) => setReviewDraft((current) => ({ ...current, sourceLabel: event.target.value }))} /></label>
|
||||
<label>作者<input value={reviewDraft.author} onChange={(event) => setReviewDraft((current) => ({ ...current, author: event.target.value }))} /></label>
|
||||
<label>权利人<input value={reviewDraft.rightsOwner} onChange={(event) => setReviewDraft((current) => ({ ...current, rightsOwner: event.target.value }))} /></label>
|
||||
<label>证据引用<input value={reviewDraft.evidenceRef} onChange={(event) => setReviewDraft((current) => ({ ...current, evidenceRef: event.target.value }))} /></label>
|
||||
<label>标签<input value={reviewDraft.tags} onChange={(event) => setReviewDraft((current) => ({ ...current, tags: event.target.value }))} /></label>
|
||||
<label className="wide">来源链接<input value={reviewDraft.sourceUrl} onChange={(event) => setReviewDraft((current) => ({ ...current, sourceUrl: event.target.value }))} /></label>
|
||||
<label className="wide">授权备注<textarea rows={3} value={reviewDraft.licenseNote} onChange={(event) => setReviewDraft((current) => ({ ...current, licenseNote: event.target.value }))} /></label>
|
||||
<label className="wide">审批备注<textarea rows={3} value={reviewDraft.notes} onChange={(event) => setReviewDraft((current) => ({ ...current, notes: event.target.value }))} /></label>
|
||||
</div>
|
||||
<div className="knowledge-governance-actions">
|
||||
<button className="subtle" type="button" onClick={() => submitGovernance("submitted")} disabled={busy || !canEditKnowledge}><FileCheck2 size={14} />提交证据</button>
|
||||
{canManageCompliance && <button className="primary" type="button" onClick={() => submitGovernance("approved")} disabled={busy}><ShieldCheck size={14} />批准商用</button>}
|
||||
{canManageCompliance && <button className="subtle" type="button" onClick={() => submitGovernance("rejected")} disabled={busy}><AlertTriangle size={14} />驳回</button>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="knowledge-governance-panel">
|
||||
<div className="json-heading">
|
||||
<h3>本地风险扫描</h3>
|
||||
<SuiteStatus status={detail.riskStatus} />
|
||||
</div>
|
||||
<div className="knowledge-risk-score">
|
||||
<ShieldAlert size={18} />
|
||||
<strong>{detail.riskScore || 0}</strong>
|
||||
<span>{detail.risk?.scanner || "local-governance-v1"}</span>
|
||||
</div>
|
||||
<div className="knowledge-risk-list">
|
||||
{(detail.risk?.issues || []).map((issue) => (
|
||||
<div key={`${issue.code}-${issue.message}`}>
|
||||
<SuiteStatus status={issue.severity === "blocking" ? "blocked" : issue.severity === "review" ? "review" : "warn"} />
|
||||
<strong>{issue.code}</strong>
|
||||
<p>{issue.message}</p>
|
||||
{issue.matches?.length ? <span>{issue.matches.join(" / ")}</span> : null}
|
||||
</div>
|
||||
))}
|
||||
{!detail.risk?.issues?.length && <div className="empty-table">本地扫描未发现阻断项。</div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="knowledge-governance-panel">
|
||||
<div className="json-heading">
|
||||
<h3>版本历史</h3>
|
||||
<History size={15} />
|
||||
</div>
|
||||
<div className="knowledge-version-list">
|
||||
{versions.map((version) => (
|
||||
<button key={version.id} type="button" onClick={() => restoreVersion(version.id)} disabled={busy || !canEditKnowledge}>
|
||||
<span>v{version.versionNumber}</span>
|
||||
<strong>{version.title}</strong>
|
||||
<em>{version.createdAt.slice(0, 16).replace("T", " ")}</em>
|
||||
<RotateCcw size={14} />
|
||||
</button>
|
||||
))}
|
||||
{!versions.length && <div className="empty-table">暂无版本快照。</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="knowledge-editor-panel">
|
||||
<div className="json-heading">
|
||||
<h3>素材正文编辑</h3>
|
||||
<button className="subtle" type="button" onClick={saveDocumentUpdate} disabled={busy || !canEditKnowledge}><Save size={14} />保存新版本</button>
|
||||
</div>
|
||||
<div className="knowledge-editor-grid">
|
||||
<label>标题<input value={editDraft?.title || ""} onChange={(event) => setEditDraft((current) => ({ ...(current || {}), title: event.target.value }))} /></label>
|
||||
<label>来源类型<select value={editDraft?.sourceType || "novel"} onChange={(event) => setEditDraft((current) => ({ ...(current || {}), sourceType: event.target.value }))}>{sourceTypes.filter(([value]) => value !== "all").map(([value, label]) => <option key={value} value={value}>{label}</option>)}</select></label>
|
||||
<label className="wide">正文<textarea rows={8} value={editDraft?.content || ""} onChange={(event) => setEditDraft((current) => ({ ...(current || {}), content: event.target.value }))} /></label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="knowledge-entity-columns">
|
||||
<div><span>角色</span><div>{characters.map((item) => <button key={item} type="button">{item}</button>)}{!characters.length && <em>未识别</em>}</div></div>
|
||||
<div><span>场景</span><div>{locations.map((item) => <button key={item} type="button">{item}</button>)}{!locations.length && <em>未识别</em>}</div></div>
|
||||
<div><span>道具</span><div>{props.map((item) => <button key={item} type="button">{item}</button>)}{!props.length && <em>未识别</em>}</div></div>
|
||||
</div>
|
||||
<div className="knowledge-chunk-list">
|
||||
{detail.chunks?.map((chunk) => (
|
||||
<div key={chunk.id} className={`knowledge-chunk-card ${selectedChunkIds.includes(chunk.id) ? "active" : ""}`}>
|
||||
<label className="knowledge-chunk-toggle">
|
||||
<input type="checkbox" checked={selectedChunkIds.includes(chunk.id)} onChange={() => toggleChunk(chunk.id)} />
|
||||
<strong>{chunk.heading}</strong>
|
||||
<span>{chunk.chunk_type} · ~{chunk.tokenEstimate} tokens</span>
|
||||
</label>
|
||||
<p>{chunk.content}</p>
|
||||
<div className="knowledge-chunk-statuses">
|
||||
<SuiteStatus status={detail.rightsStatus} />
|
||||
<SuiteStatus status={detail.riskStatus} />
|
||||
</div>
|
||||
<div className="knowledge-chunk-tags">
|
||||
{(chunk.keywords || []).map((keyword) => <span key={keyword}>{keyword}</span>)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-table">选择一份素材后,可查看分块、实体和入剧本动作。</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
+164
@@ -330,6 +330,10 @@ export async function createJob(body, overrides = {}) {
|
||||
return apiFetch("/api/jobs", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function previewJob(body, overrides = {}) {
|
||||
return apiFetch("/api/jobs/preview", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAssets(overrides = {}) {
|
||||
return apiFetch("/api/assets", {}, overrides);
|
||||
}
|
||||
@@ -358,6 +362,14 @@ export async function verifyAsset(assetId, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/verify`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchAssetGovernanceReviews(assetId, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/governance`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function scanAssetGovernance(assetId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/governance/scan`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function createAssetVersion(assetId, body, overrides = {}) {
|
||||
return apiFetch(`/api/assets/${encodeURIComponent(assetId)}/versions`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
@@ -471,6 +483,27 @@ export async function fetchOrganizationCommercial(organizationId, overrides = {}
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/commercial`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchOrganizationEntitlements(organizationId, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/entitlements`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function updateOrganizationEntitlement(organizationId, entitlementKey, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/entitlements/${encodeURIComponent(entitlementKey)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchCommercialApprovals(organizationId, filters = {}, overrides = {}) {
|
||||
const query = usageQuery(filters);
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/commercial-approvals${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function createCommercialApproval(organizationId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/commercial-approvals`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function decideCommercialApproval(organizationId, approvalId, body, overrides = {}) {
|
||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/commercial-approvals/${encodeURIComponent(approvalId)}/decision`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
function usageQuery(filters = {}) {
|
||||
const params = new URLSearchParams();
|
||||
for (const key of ["query", "workspaceId", "projectId", "userId", "kind", "unitName", "costCenter", "status", "from", "to", "page", "pageSize", "format"]) {
|
||||
@@ -573,6 +606,18 @@ export async function updateFeatureFlag(key, enabled, overrides = {}) {
|
||||
return apiFetch("/api/system/feature-flags", { method: "POST", body: JSON.stringify({ key, enabled }) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchSystemPlanTemplates(overrides = {}) {
|
||||
return apiFetch("/api/system/plan-templates", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createSystemPlanTemplate(body, overrides = {}) {
|
||||
return apiFetch("/api/system/plan-templates", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateSystemPlanTemplate(planId, body, overrides = {}) {
|
||||
return apiFetch(`/api/system/plan-templates/${encodeURIComponent(planId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateNotificationChannel(channel, overrides = {}) {
|
||||
return apiFetch("/api/system/notifications", { method: "POST", body: JSON.stringify(channel) }, overrides);
|
||||
}
|
||||
@@ -686,6 +731,71 @@ export async function materializeProductionScript(body, overrides = {}) {
|
||||
return apiFetch("/api/production/script/materialize", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeLibrary(overrides = {}) {
|
||||
return apiFetch("/api/knowledge/library", {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeDocument(documentId, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function importKnowledgeLibraryDocument(body, overrides = {}) {
|
||||
return apiFetch("/api/knowledge/library/import", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateKnowledgeLibraryDocument(documentId, body, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function reviewKnowledgeLibraryDocument(documentId, body, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}/review`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function archiveKnowledgeLibraryDocument(documentId, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}/archive`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function restoreKnowledgeLibraryDocument(documentId, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}/restore`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeLibraryDocumentVersions(documentId, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}/versions`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function restoreKnowledgeLibraryDocumentVersion(documentId, versionKey, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}/versions/${encodeURIComponent(versionKey)}/restore`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function searchKnowledgeLibrary(params = {}, overrides = {}) {
|
||||
const search = new URLSearchParams();
|
||||
if (params.query) search.set("q", params.query);
|
||||
if (params.sourceType) search.set("sourceType", params.sourceType);
|
||||
if (params.scopeMode) search.set("scopeMode", params.scopeMode);
|
||||
if (params.limit) search.set("limit", String(params.limit));
|
||||
return apiFetch(`/api/knowledge/search${search.toString() ? `?${search.toString()}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeContextPacks(overrides = {}) {
|
||||
return apiFetch("/api/knowledge/context-packs", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createKnowledgeContextPack(body, overrides = {}) {
|
||||
return apiFetch("/api/knowledge/context-packs", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchKnowledgeContextPack(packId, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/context-packs/${encodeURIComponent(packId)}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function materializeKnowledgeContextPack(packId, body, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/context-packs/${encodeURIComponent(packId)}/materialize`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function materializeKnowledgeLibraryDocument(documentId, body, overrides = {}) {
|
||||
return apiFetch(`/api/knowledge/library/${encodeURIComponent(documentId)}/materialize`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateProductionBible(body, overrides = {}) {
|
||||
return apiFetch("/api/production/bible", { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
@@ -783,6 +893,14 @@ export async function approveDelivery(deliveryId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/approve`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveryClearance(deliveryId, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/clearance`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function runDeliveryClearance(deliveryId, body = {}, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/clearance`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchDeliveryReleases(deliveryId, overrides = {}) {
|
||||
return apiFetch(`/api/production/deliveries/${encodeURIComponent(deliveryId)}/releases`, {}, overrides);
|
||||
}
|
||||
@@ -902,6 +1020,52 @@ export async function probeModel(modelId, overrides = {}) {
|
||||
return apiFetch(`/api/platform/models/${encodeURIComponent(modelId)}/probe`, { method: "POST", body: JSON.stringify({}) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchModelCatalog(overrides = {}) {
|
||||
return apiFetch("/api/platform/model-catalog", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createModelCatalog(body, overrides = {}) {
|
||||
return apiFetch("/api/platform/model-catalog", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateModelCatalog(entryId, body, overrides = {}) {
|
||||
return apiFetch(`/api/platform/model-catalog/${encodeURIComponent(entryId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchModelRoutes(overrides = {}) {
|
||||
return apiFetch("/api/platform/model-routes", {}, overrides);
|
||||
}
|
||||
|
||||
export async function createModelRoute(body, overrides = {}) {
|
||||
return apiFetch("/api/platform/model-routes", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function resolveModelRoute(body, overrides = {}) {
|
||||
return apiFetch("/api/platform/model-routes/resolve", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchModelRouteApprovals(filters = {}, overrides = {}) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(filters || {})) {
|
||||
if (value === undefined || value === null || value === "") continue;
|
||||
params.set(key, String(value));
|
||||
}
|
||||
const query = params.toString();
|
||||
return apiFetch(`/api/platform/model-route-approvals${query ? `?${query}` : ""}`, {}, overrides);
|
||||
}
|
||||
|
||||
export async function requestModelRouteApproval(body, overrides = {}) {
|
||||
return apiFetch("/api/platform/model-route-approvals", { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function decideModelRouteApproval(approvalId, body, overrides = {}) {
|
||||
return apiFetch(`/api/platform/model-route-approvals/${encodeURIComponent(approvalId)}/decision`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function updateModelRoute(routeId, body, overrides = {}) {
|
||||
return apiFetch(`/api/platform/model-routes/${encodeURIComponent(routeId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||
}
|
||||
|
||||
export async function fetchJobs(overrides = {}) {
|
||||
return apiFetch("/api/jobs", {}, overrides);
|
||||
}
|
||||
|
||||
+1032
-2
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user