diff --git a/index.html b/index.html index 55a13da..a0bff70 100644 --- a/index.html +++ b/index.html @@ -3,6 +3,7 @@ + AI 短剧本地生产台 diff --git a/package.json b/package.json index bf9d06b..344fd23 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..f9d83a1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/scripts/cleanup-demo-noise.mjs b/scripts/cleanup-demo-noise.mjs new file mode 100644 index 0000000..eebf633 --- /dev/null +++ b/scripts/cleanup-demo-noise.mjs @@ -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)); diff --git a/scripts/smoke-all.mjs b/scripts/smoke-all.mjs index 9bcc5ce..52def52 100644 --- a/scripts/smoke-all.mjs +++ b/scripts/smoke-all.mjs @@ -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", diff --git a/scripts/smoke-asset-governance.mjs b/scripts/smoke-asset-governance.mjs new file mode 100644 index 0000000..a6303a3 --- /dev/null +++ b/scripts/smoke-asset-governance.mjs @@ -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(); +} diff --git a/scripts/smoke-commercial-approvals.mjs b/scripts/smoke-commercial-approvals.mjs new file mode 100644 index 0000000..e949cf9 --- /dev/null +++ b/scripts/smoke-commercial-approvals.mjs @@ -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]); + } +} diff --git a/scripts/smoke-delivery-clearance.mjs b/scripts/smoke-delivery-clearance.mjs new file mode 100644 index 0000000..6e19b9c --- /dev/null +++ b/scripts/smoke-delivery-clearance.mjs @@ -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 }); +} diff --git a/scripts/smoke-delivery-portal.mjs b/scripts/smoke-delivery-portal.mjs index 7c50d42..ca03760 100644 --- a/scripts/smoke-delivery-portal.mjs +++ b/scripts/smoke-delivery-portal.mjs @@ -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 }); } diff --git a/scripts/smoke-entitlements.mjs b/scripts/smoke-entitlements.mjs new file mode 100644 index 0000000..9c73489 --- /dev/null +++ b/scripts/smoke-entitlements.mjs @@ -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); +} diff --git a/scripts/smoke-knowledge-governance.mjs b/scripts/smoke-knowledge-governance.mjs new file mode 100644 index 0000000..3eb2224 --- /dev/null +++ b/scripts/smoke-knowledge-governance.mjs @@ -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(); +} diff --git a/scripts/smoke-knowledge-rag.mjs b/scripts/smoke-knowledge-rag.mjs new file mode 100644 index 0000000..1c59f75 --- /dev/null +++ b/scripts/smoke-knowledge-rag.mjs @@ -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 }); + } +} diff --git a/scripts/smoke-model-approvals.mjs b/scripts/smoke-model-approvals.mjs new file mode 100644 index 0000000..dd25e0d --- /dev/null +++ b/scripts/smoke-model-approvals.mjs @@ -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)); +} diff --git a/scripts/smoke-model-connectors.mjs b/scripts/smoke-model-connectors.mjs index 0e6d2ca..d51a0ca 100644 --- a/scripts/smoke-model-connectors.mjs +++ b/scripts/smoke-model-connectors.mjs @@ -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", diff --git a/scripts/smoke-model-routing.mjs b/scripts/smoke-model-routing.mjs new file mode 100644 index 0000000..0424f97 --- /dev/null +++ b/scripts/smoke-model-routing.mjs @@ -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 }); + } +} diff --git a/scripts/smoke-plan-templates.mjs b/scripts/smoke-plan-templates.mjs new file mode 100644 index 0000000..4a23209 --- /dev/null +++ b/scripts/smoke-plan-templates.mjs @@ -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]); +} diff --git a/scripts/smoke-project-isolation.mjs b/scripts/smoke-project-isolation.mjs index 39daa86..72d8a84 100644 --- a/scripts/smoke-project-isolation.mjs +++ b/scripts/smoke-project-isolation.mjs @@ -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"); diff --git a/scripts/smoke-release-workflow.mjs b/scripts/smoke-release-workflow.mjs index ab212df..aa21732 100644 --- a/scripts/smoke-release-workflow.mjs +++ b/scripts/smoke-release-workflow.mjs @@ -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 }); + } diff --git a/scripts/smoke-worker.mjs b/scripts/smoke-worker.mjs index a00d0bd..7a69a14 100644 --- a/scripts/smoke-worker.mjs +++ b/scripts/smoke-worker.mjs @@ -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; diff --git a/server/db.mjs b/server/db.mjs index c8d7249..d26fd02 100644 --- a/server/db.mjs +++ b/server/db.mjs @@ -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(); }); diff --git a/server/delivery-portal.mjs b/server/delivery-portal.mjs index 082374d..29ca4fa 100644 --- a/server/delivery-portal.mjs +++ b/server/delivery-portal.mjs @@ -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 }); diff --git a/server/execution.mjs b/server/execution.mjs index 86b8528..134694f 100644 --- a/server/execution.mjs +++ b/server/execution.mjs @@ -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; diff --git a/server/knowledge.mjs b/server/knowledge.mjs new file mode 100644 index 0000000..ad8ba01 --- /dev/null +++ b/server/knowledge.mjs @@ -0,0 +1,1232 @@ +import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs"; +import { addAudit, addUsage, hasPermission, httpError, requireEntitlement, requirePermission, requireProjectWritable } from "./tenant.mjs"; +import { importScript } from "./production.mjs"; + +const now = () => new Date().toISOString(); +const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + +function parseJson(value, fallback) { + try { + return JSON.parse(value); + } catch { + return fallback; + } +} + +function unique(values) { + return [...new Set((values || []).map((item) => String(item || "").trim()).filter(Boolean))]; +} + +function normalizeTags(value) { + if (Array.isArray(value)) return unique(value).slice(0, 24); + return unique(String(value || "").split(/[,,、\s]+/)).slice(0, 24); +} + +function normalizeRightsStatus(value) { + const status = String(value || "needs-evidence").trim(); + if (["needs-evidence", "submitted", "approved", "rejected", "expired"].includes(status)) return status; + return "needs-evidence"; +} + +function normalizeProvenance(body = {}, fallback = {}) { + const source = body.provenance && typeof body.provenance === "object" ? body.provenance : {}; + const metadata = body.metadata && typeof body.metadata === "object" ? body.metadata : {}; + return { + sourceLabel: String(source.sourceLabel || metadata.sourceLabel || fallback.sourceLabel || "").trim(), + author: String(source.author || metadata.author || fallback.author || "").trim(), + rightsOwner: String(source.rightsOwner || metadata.rightsOwner || fallback.rightsOwner || "").trim(), + evidenceRef: String(source.evidenceRef || metadata.evidenceRef || fallback.evidenceRef || "").trim(), + licenseNote: String(source.licenseNote || metadata.licenseNote || fallback.licenseNote || "").trim(), + sourceUrl: String(source.sourceUrl || metadata.sourceUrl || fallback.sourceUrl || "").trim(), + importedFrom: String(source.importedFrom || metadata.importedFrom || fallback.importedFrom || "manual").trim() + }; +} + +function approxTokens(text) { + const value = String(text || "").trim(); + return Math.max(1, Math.ceil(value.length / 1.8)); +} + +function splitSections(content) { + const normalized = String(content || "").replace(/\r/g, "").trim(); + const lines = normalized.split("\n"); + const headingPattern = /^(第[0-9一二三四五六七八九十百零]+[章节集幕]|chapter\s*\d+|CHAPTER\s*\d+)/i; + const sections = []; + let currentTitle = ""; + let buffer = []; + for (const rawLine of lines) { + const line = rawLine.trim(); + if (headingPattern.test(line)) { + if (currentTitle || buffer.length) sections.push({ title: currentTitle || `片段 ${sections.length + 1}`, content: buffer.join("\n").trim() }); + currentTitle = line; + buffer = []; + } else { + buffer.push(rawLine); + } + } + if (currentTitle || buffer.length) sections.push({ title: currentTitle || `片段 ${sections.length + 1}`, content: buffer.join("\n").trim() }); + if (sections.length) return sections.filter((section) => section.content); + const paragraphs = normalized.split(/\n\s*\n/).map((item) => item.trim()).filter(Boolean); + if (!paragraphs.length) return []; + const grouped = []; + for (let index = 0; index < paragraphs.length; index += 3) { + grouped.push({ + title: `片段 ${grouped.length + 1}`, + content: paragraphs.slice(index, index + 3).join("\n\n") + }); + } + return grouped; +} + +function extractEntities(text) { + const value = String(text || ""); + const characters = unique([...value.matchAll(/([\u4e00-\u9fa5]{2,4})[::]/g)].map((match) => match[1])); + const locationKeywords = ["地铁口", "玻璃连廊", "雨棚", "教室", "客厅", "街道", "医院", "仓库", "山路", "门口", "旧城区", "天台", "楼道"]; + const propKeywords = ["手机", "雨伞", "蓝伞", "雨披", "黄色雨披", "路锥", "警戒线", "钥匙", "刀", "书包", "项链", "文件", "录音笔", "相机"]; + const locations = unique(locationKeywords.filter((item) => value.includes(item))); + const props = unique(propKeywords.filter((item) => value.includes(item))); + return { characters, locations, props }; +} + +function keywordsForText(text, entities) { + const base = String(text || "").replace(/[,。!?、:“”"'()()【】\[\]\s]+/g, " ").trim().split(" ").filter(Boolean); + return unique([...(entities.characters || []), ...(entities.locations || []), ...(entities.props || []), ...base.filter((item) => item.length >= 2).slice(0, 6)]).slice(0, 10); +} + +function chunkTypeForContent(text, sectionIndex, paragraphIndex) { + const value = String(text || ""); + const dialogueMatches = [...value.matchAll(/^[\u4e00-\u9fa5]{2,4}[::]/gm)]; + if (dialogueMatches.length >= 2) return "dialogue"; + if (/设定|规则|传说|前史|背景/.test(value)) return "lore"; + if (paragraphIndex === 0) return sectionIndex === 0 ? "chapter" : "scene"; + return "scene"; +} + +function analyzeKnowledgeText(content) { + const sections = splitSections(content); + const chunks = []; + const chapterSummary = []; + let chunkIndex = 1; + for (let sectionIndex = 0; sectionIndex < sections.length; sectionIndex += 1) { + const section = sections[sectionIndex]; + const parts = section.content.split(/\n\s*\n/).map((item) => item.trim()).filter(Boolean); + const merged = []; + for (const part of parts) { + if (!merged.length) { + merged.push(part); + continue; + } + if (merged[merged.length - 1].length < 180) merged[merged.length - 1] = `${merged[merged.length - 1]}\n${part}`; + else merged.push(part); + } + const startChunk = chunkIndex; + for (let paragraphIndex = 0; paragraphIndex < merged.length; paragraphIndex += 1) { + const body = merged[paragraphIndex]; + const entities = extractEntities(body); + chunks.push({ + id: `chunk-${chunkIndex}`, + chunkIndex, + chunkType: chunkTypeForContent(body, sectionIndex, paragraphIndex), + heading: section.title || `片段 ${sectionIndex + 1}`, + content: body, + tokenEstimate: approxTokens(body), + keywords: keywordsForText(body, entities), + entities, + metadata: { + sectionIndex: sectionIndex + 1, + paragraphIndex: paragraphIndex + 1, + sceneHint: paragraphIndex === 0 ? "段首情境建立" : /[::]/.test(body) ? "对白块" : "叙事块" + } + }); + chunkIndex += 1; + } + chapterSummary.push({ + id: `section-${sectionIndex + 1}`, + title: section.title || `片段 ${sectionIndex + 1}`, + words: section.content.replace(/\s/g, "").length, + chunkCount: chunkIndex - startChunk + }); + } + const allEntities = chunks.reduce((accumulator, chunk) => ({ + characters: [...accumulator.characters, ...(chunk.entities.characters || [])], + locations: [...accumulator.locations, ...(chunk.entities.locations || [])], + props: [...accumulator.props, ...(chunk.entities.props || [])] + }), { characters: [], locations: [], props: [] }); + const text = String(content || "").trim(); + return { + parser: "local-rule-v2", + summary: text.slice(0, 120), + chapterCount: chapterSummary.length, + chunkCount: chunks.length, + chapters: chapterSummary, + entities: { + characters: unique(allEntities.characters), + locations: unique(allEntities.locations), + props: unique(allEntities.props) + }, + chunks + }; +} + +function scanKnowledgeGovernance({ title = "", content = "", sourceType = "", rightsStatus = "needs-evidence", provenance = {}, tags = [] } = {}) { + const text = `${title}\n${content}`.toLowerCase(); + const issues = []; + const pushIssue = (severity, code, message, matches = []) => issues.push({ severity, code, message, matches: unique(matches).slice(0, 8) }); + const normalizedRights = normalizeRightsStatus(rightsStatus); + const evidenceRef = String(provenance.evidenceRef || provenance.sourceLabel || "").trim(); + if (normalizedRights === "rejected" || normalizedRights === "expired") { + pushIssue("blocking", "rights_not_usable", "素材版权状态不可用于商用生产。"); + } else if (normalizedRights !== "approved") { + pushIssue("review", "rights_needs_evidence", "素材尚未批准商用使用,正式生产前需要补充来源/授权证据。"); + } + if (!evidenceRef) { + pushIssue("review", "provenance_evidence_missing", "缺少来源或授权证据引用。"); + } + + const ipTerms = ["迪士尼", "漫威", "哈利波特", "火影忍者", "海贼王", "斗罗大陆", "狐妖小红娘", "三体", "庆余年", "盗墓笔记", "鬼吹灯", "原神", "王者荣耀"]; + const ipMatches = ipTerms.filter((term) => text.includes(term.toLowerCase())); + if (ipMatches.length) pushIssue("review", "known_ip_reference", "文本含有已知商业 IP 或游戏/影视/小说名称,需要确认不是仿作或未授权改编。", ipMatches); + + const personaTerms = ["仿明星", "明星脸", "真人脸", "照着某人", "像某明星", "某某同款", "高仿演员", "数字替身"]; + const personaMatches = personaTerms.filter((term) => text.includes(term.toLowerCase())); + if (personaMatches.length) pushIssue("review", "persona_likeness_risk", "文本含有真人形象或仿冒表达,后续角色/视频生成需要权利人授权。", personaMatches); + + const layoutTerms = ["split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "九宫格", "故事板"]; + const layoutMatches = layoutTerms.filter((term) => text.includes(term.toLowerCase())); + if (layoutMatches.length) pushIssue("warn", "single_frame_policy_risk", "素材或提示中含一图多画面表达,进入画面生成前必须改写为单一完整画面。", layoutMatches); + + const sensitiveTerms = ["血腥特写", "未成年人裸露", "自残教程", "诈骗话术", "真实身份证", "银行卡号"]; + const sensitiveMatches = sensitiveTerms.filter((term) => text.includes(term.toLowerCase())); + if (sensitiveMatches.length) pushIssue("blocking", "safety_sensitive_content", "文本含高风险安全或隐私内容,不能直接进入自动生成。", sensitiveMatches); + + const score = issues.reduce((sum, issue) => sum + (issue.severity === "blocking" ? 60 : issue.severity === "review" ? 24 : 10), 0); + const status = issues.some((issue) => issue.severity === "blocking") ? "blocked" : issues.some((issue) => issue.severity === "review") ? "review" : issues.some((issue) => issue.severity === "warn") ? "warn" : "pass"; + return { + scanner: "local-governance-v1", + status, + score: Math.min(100, score), + rightsStatus: normalizedRights, + sourceType, + tags, + issues, + checks: { + provenanceEvidence: Boolean(evidenceRef), + commercialRightsApproved: normalizedRights === "approved", + knownIpReferences: ipMatches.length, + personaLikenessRisks: personaMatches.length, + singleFramePolicyRisks: layoutMatches.length, + safetySensitiveMatches: sensitiveMatches.length + }, + scannedAt: now() + }; +} + +function normalizeGovernanceDecision(value) { + const decision = String(value || "submitted").trim(); + if (["submitted", "approved", "rejected", "needs-revision"].includes(decision)) return decision; + return "submitted"; +} + +function normalizeDocumentStatus(value, fallback = "indexed") { + const status = String(value || fallback || "indexed").trim(); + if (["ingested", "indexed", "draft", "active", "archived"].includes(status)) return status; + return fallback || "indexed"; +} + +function serializeKnowledgeReviewRow(row) { + if (!row) return null; + return { + id: row.id, + documentId: row.document_id, + decision: row.decision, + rightsStatus: row.rights_status, + riskStatus: row.risk_status, + notes: row.notes || "", + evidenceRef: row.evidence_ref || "", + provenance: parseJson(row.provenance_json, {}), + risk: parseJson(row.risk_json, {}), + reviewerUserId: row.reviewer_user_id || "", + createdAt: row.created_at || "" + }; +} + +function latestKnowledgeReview(documentId) { + return serializeKnowledgeReviewRow(dbGet( + `SELECT * + FROM knowledge_governance_reviews + WHERE document_id = ? + ORDER BY created_at DESC + LIMIT 1`, + [documentId] + )); +} + +function knowledgeVersionCount(documentId) { + const row = dbGet("SELECT COUNT(*) AS count FROM knowledge_document_versions WHERE document_id = ?", [documentId]); + return Number(row?.count || 0); +} + +function nextKnowledgeVersionNumber(documentId) { + const row = dbGet("SELECT COALESCE(MAX(version_number), 0) + 1 AS version_number FROM knowledge_document_versions WHERE document_id = ?", [documentId]); + return Number(row?.version_number || 1); +} + +function serializeKnowledgeDocumentRow(row, chunks = null) { + const analysis = parseJson(row.analysis_json, {}); + const metadata = parseJson(row.metadata_json, {}); + const provenance = parseJson(row.provenance_json, {}); + const tags = parseJson(row.tags_json, []); + const risk = parseJson(row.risk_json, {}); + const versionCount = row.version_count === undefined ? knowledgeVersionCount(row.id) : Number(row.version_count || 0); + return { + ...row, + sourceType: row.source_type || "", + projectId: row.project_id || "", + scopeMode: row.scope_mode || "workspace", + rightsStatus: row.rights_status || "needs-evidence", + provenance, + tags, + risk, + riskStatus: risk.status || "unscanned", + riskScore: Number(risk.score || 0), + currentVersionNumber: Number(row.current_version_number || 1), + versionCount, + latestReview: latestKnowledgeReview(row.id), + summary: row.summary || "", + chunkCount: Number(row.chunk_count || chunks?.length || 0), + analysis, + metadata, + chunks: chunks || undefined + }; +} + +function serializeKnowledgeVersionRow(row) { + return { + id: row.id, + documentId: row.document_id, + versionNumber: Number(row.version_number || 0), + title: row.title || "", + sourceType: row.source_type || "novel", + language: row.language || "zh-CN", + content: row.content || "", + summary: row.summary || "", + analysis: parseJson(row.analysis_json, {}), + provenance: parseJson(row.provenance_json, {}), + tags: parseJson(row.tags_json, []), + risk: parseJson(row.risk_json, {}), + metadata: parseJson(row.metadata_json, {}), + createdBy: row.created_by || "", + createdAt: row.created_at || "" + }; +} + +function writeKnowledgeChunks(documentId, chunks, timestamp) { + for (const chunk of chunks) { + dbRun( + `INSERT INTO knowledge_chunks( + id, document_id, chunk_index, chunk_type, heading, content, token_estimate, + keywords_json, entities_json, metadata_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [`${documentId}-${chunk.chunkIndex}`, documentId, chunk.chunkIndex, chunk.chunkType, chunk.heading, chunk.content, chunk.tokenEstimate, JSON.stringify(chunk.keywords), JSON.stringify(chunk.entities), JSON.stringify(chunk.metadata), timestamp] + ); + } +} + +function insertKnowledgeVersion(context, document, versionNumber, timestamp, metadataPatch = {}) { + const metadata = document.metadata && typeof document.metadata === "object" ? document.metadata : parseJson(document.metadata_json, {}); + const analysis = document.analysis && typeof document.analysis === "object" ? document.analysis : parseJson(document.analysis_json, {}); + const provenance = document.provenance && typeof document.provenance === "object" ? document.provenance : parseJson(document.provenance_json, {}); + const tags = Array.isArray(document.tags) ? document.tags : parseJson(document.tags_json, []); + const risk = document.risk && typeof document.risk === "object" ? document.risk : parseJson(document.risk_json, {}); + dbRun( + `INSERT 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + String(metadataPatch.id || `${document.id}-v${versionNumber}`), + document.id, + versionNumber, + document.title, + document.sourceType || document.source_type || "novel", + document.language || "zh-CN", + document.content || "", + document.summary || "", + JSON.stringify({ ...analysis, chunks: undefined }), + JSON.stringify(provenance), + JSON.stringify(tags), + JSON.stringify(risk), + JSON.stringify({ ...metadata, ...metadataPatch }), + context.user.id, + timestamp + ] + ); +} + +function assertKnowledgeDocumentUsable(document) { + const risk = document.risk || parseJson(document.risk_json, {}); + const rightsStatus = document.rightsStatus || document.rights_status || "needs-evidence"; + if (["rejected", "expired"].includes(rightsStatus)) { + throw httpError(409, "knowledge_rights_not_usable", "该素材版权状态不可用于商用生产,请先完成授权治理", { documentId: document.id, rightsStatus }); + } + if (risk.status === "blocked") { + throw httpError(409, "knowledge_risk_blocked", "该素材风险扫描为阻断状态,不能进入生成或剧本生产", { documentId: document.id, risk }); + } +} + +function summarizePackGovernance(chunks) { + const summary = chunks.reduce((accumulator, chunk) => { + const rightsStatus = chunk.rightsStatus || "needs-evidence"; + const riskStatus = chunk.risk?.status || "unscanned"; + accumulator.rights[rightsStatus] = (accumulator.rights[rightsStatus] || 0) + 1; + accumulator.risk[riskStatus] = (accumulator.risk[riskStatus] || 0) + 1; + if (["rejected", "expired"].includes(rightsStatus) || riskStatus === "blocked") accumulator.blocking += 1; + if (rightsStatus !== "approved" || ["review", "warn"].includes(riskStatus)) accumulator.review += 1; + return accumulator; + }, { rights: {}, risk: {}, blocking: 0, review: 0 }); + return { + status: summary.blocking ? "blocked" : summary.review ? "review" : "pass", + rights: summary.rights, + risk: summary.risk, + blockingCount: summary.blocking, + reviewCount: summary.review + }; +} + +function assertPackGovernanceUsable(pack) { + const governance = pack?.governance || pack?.metadata?.governance || {}; + if (governance.status === "blocked" || Number(governance.blockingCount || 0) > 0) { + throw httpError(409, "knowledge_pack_blocked", "上下文包包含版权不可用或风险阻断素材,不能进入生产任务", { packId: pack.id, governance }); + } +} + +function scopedKnowledgeQuery(context) { + const params = [context.organization.id, context.workspace.id]; + let clause = "kd.organization_id = ? AND kd.workspace_id = ?"; + if (context.project?.id) { + clause += " AND (kd.project_id IS NULL OR kd.project_id = ?)"; + params.push(context.project.id); + } else { + clause += " AND kd.project_id IS NULL"; + } + return { clause, params }; +} + +function scopedKnowledgePackQuery(context) { + const params = [context.organization.id, context.workspace.id]; + let clause = "kcp.organization_id = ? AND kcp.workspace_id = ?"; + if (context.project?.id) { + clause += " AND (kcp.project_id IS NULL OR kcp.project_id = ?)"; + params.push(context.project.id); + } else { + clause += " AND kcp.project_id IS NULL"; + } + return { clause, params }; +} + +function placeholders(values) { + return values.map(() => "?").join(","); +} + +function likePattern(value) { + return `%${String(value || "").trim().replace(/[\\%_]/g, "\\$&").slice(0, 100)}%`; +} + +function knowledgeTerms(query) { + const normalized = String(query || "").trim(); + if (!normalized) return []; + const split = normalized.split(/[\s,,。;;、/|]+/).map((item) => item.trim()).filter(Boolean); + return unique([normalized, ...split]).slice(0, 8); +} + +function normalizeKnowledgeChunk(row) { + const entities = parseJson(row.entities_json, {}); + const keywords = parseJson(row.keywords_json, []); + const risk = parseJson(row.risk_json, {}); + return { + id: row.id, + documentId: row.document_id, + documentTitle: row.document_title || row.title || "", + chunkIndex: Number(row.chunk_index || 0), + chunkType: row.chunk_type || "scene", + heading: row.heading || "", + content: row.content || "", + tokenEstimate: Number(row.token_estimate || approxTokens(row.content)), + keywords, + entities, + metadata: parseJson(row.metadata_json, {}), + sourceType: row.source_type || "", + language: row.language || "zh-CN", + scopeMode: row.scope_mode || "workspace", + rightsStatus: row.rights_status || "needs-evidence", + risk, + riskStatus: risk.status || "unscanned", + projectId: row.project_id || "", + organizationId: row.organization_id || "", + workspaceId: row.workspace_id || "", + updatedAt: row.updated_at || row.created_at || "" + }; +} + +function flattenEntities(entities = {}) { + return unique([...(entities.characters || []), ...(entities.locations || []), ...(entities.props || [])]); +} + +function scoreKnowledgeChunk(chunk, terms) { + if (!terms.length) return 1; + const haystacks = { + title: `${chunk.documentTitle}`.toLowerCase(), + heading: `${chunk.heading}`.toLowerCase(), + content: `${chunk.content}`.toLowerCase(), + keywords: (chunk.keywords || []).join(" ").toLowerCase(), + entities: flattenEntities(chunk.entities).join(" ").toLowerCase() + }; + let score = 0; + for (const rawTerm of terms) { + const term = rawTerm.toLowerCase(); + if (!term) continue; + if (haystacks.title.includes(term)) score += 80; + if (haystacks.heading.includes(term)) score += 64; + if (haystacks.keywords.includes(term)) score += 44; + if (haystacks.entities.includes(term)) score += 38; + if (haystacks.content.includes(term)) score += 26; + } + if (chunk.chunkType === "dialogue") score += 5; + if (chunk.scopeMode === "project") score += 3; + return score; +} + +function snippetFor(content, terms, maxLength = 180) { + const text = String(content || "").replace(/\s+/g, " ").trim(); + if (text.length <= maxLength) return text; + const lower = text.toLowerCase(); + const term = terms.map((item) => item.toLowerCase()).find((item) => item && lower.includes(item)); + if (!term) return `${text.slice(0, maxLength - 1)}…`; + const index = lower.indexOf(term); + const start = Math.max(0, index - Math.floor(maxLength / 3)); + const end = Math.min(text.length, start + maxLength); + return `${start > 0 ? "…" : ""}${text.slice(start, end)}${end < text.length ? "…" : ""}`; +} + +function serializeContextPackRow(row) { + const chunkIds = parseJson(row.chunk_ids_json, []); + const citations = parseJson(row.citations_json, []); + const chunks = parseJson(row.chunks_json, []); + const metadata = parseJson(row.metadata_json, {}); + return { + schema: "ai-drama.knowledge-context-pack.v1", + id: row.id, + name: row.name || "", + query: row.query || "", + sourceType: row.source_type || "mixed", + projectId: row.project_id || "", + scopeMode: row.scope_mode || "workspace", + maxTokens: Number(row.max_tokens || 0), + tokenEstimate: Number(row.token_estimate || 0), + selectedCount: chunks.length || chunkIds.length, + chunkIds, + citations, + chunks, + promptContext: row.prompt_context || "", + governance: metadata.governance || {}, + status: row.status || "active", + metadata, + createdBy: row.created_by || "", + createdAt: row.created_at || "", + updatedAt: row.updated_at || "" + }; +} + +function knowledgeDocumentDetail(context, documentId) { + const { clause, params } = scopedKnowledgeQuery(context); + const row = dbGet( + `SELECT kd.*, + (SELECT COUNT(*) FROM knowledge_chunks kc WHERE kc.document_id = kd.id) AS chunk_count, + (SELECT COUNT(*) FROM knowledge_document_versions kdv WHERE kdv.document_id = kd.id) AS version_count + FROM knowledge_documents kd + WHERE kd.id = ? AND ${clause}`, + [documentId, ...params] + ); + if (!row) return null; + const chunks = dbAll( + `SELECT * FROM knowledge_chunks + WHERE document_id = ? + ORDER BY chunk_index`, + [documentId] + ).map((chunk) => ({ + ...chunk, + chunkIndex: Number(chunk.chunk_index || 0), + tokenEstimate: Number(chunk.token_estimate || 0), + keywords: parseJson(chunk.keywords_json, []), + entities: parseJson(chunk.entities_json, {}), + metadata: parseJson(chunk.metadata_json, {}) + })); + return serializeKnowledgeDocumentRow(row, chunks); +} + +export function listKnowledgeDocuments(context) { + requirePermission(context, "script:read"); + const { clause, params } = scopedKnowledgeQuery(context); + const rows = dbAll( + `SELECT kd.*, + (SELECT COUNT(*) FROM knowledge_chunks kc WHERE kc.document_id = kd.id) AS chunk_count, + (SELECT COUNT(*) FROM knowledge_document_versions kdv WHERE kdv.document_id = kd.id) AS version_count + FROM knowledge_documents kd + WHERE ${clause} + ORDER BY kd.updated_at DESC, kd.created_at DESC`, + params + ); + const documents = rows.map((row) => serializeKnowledgeDocumentRow(row)); + return { + documents, + summary: { + total: documents.length, + workspaceScoped: documents.filter((item) => item.scopeMode === "workspace").length, + projectScoped: documents.filter((item) => item.scopeMode === "project").length, + chunks: documents.reduce((sum, item) => sum + Number(item.chunkCount || 0), 0), + rightsApproved: documents.filter((item) => item.rightsStatus === "approved").length, + rightsNeedsEvidence: documents.filter((item) => item.rightsStatus !== "approved").length, + riskBlocked: documents.filter((item) => item.riskStatus === "blocked").length, + riskReview: documents.filter((item) => item.riskStatus === "review").length, + versions: documents.reduce((sum, item) => sum + Number(item.versionCount || 0), 0) + } + }; +} + +export function getKnowledgeDocument(context, documentId) { + requirePermission(context, "script:read"); + const document = knowledgeDocumentDetail(context, documentId); + if (!document) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId }); + return { document }; +} + +export function searchKnowledge(context, body = {}) { + requirePermission(context, "script:read"); + const query = String(body.query || body.q || "").trim().slice(0, 120); + const sourceType = String(body.sourceType || body.source_type || "all").trim(); + const scopeMode = String(body.scopeMode || body.scope_mode || "all").trim(); + const limit = Math.max(1, Math.min(80, Number(body.limit || 24))); + const terms = knowledgeTerms(query); + const { clause, params } = scopedKnowledgeQuery(context); + const where = [clause, "kd.status <> 'archived'"]; + const queryParams = [...params]; + if (sourceType && sourceType !== "all") { + where.push("kd.source_type = ?"); + queryParams.push(sourceType); + } + if (["workspace", "project"].includes(scopeMode)) { + where.push("kd.scope_mode = ?"); + queryParams.push(scopeMode); + } + if (terms.length) { + const termClauses = []; + for (const term of terms) { + termClauses.push("(kd.title LIKE ? ESCAPE '\\' OR kd.summary LIKE ? ESCAPE '\\' OR kc.heading LIKE ? ESCAPE '\\' OR kc.content LIKE ? ESCAPE '\\' OR kc.keywords_json LIKE ? ESCAPE '\\' OR kc.entities_json LIKE ? ESCAPE '\\')"); + const pattern = likePattern(term); + queryParams.push(pattern, pattern, pattern, pattern, pattern, pattern); + } + where.push(`(${termClauses.join(" OR ")})`); + } + const rows = dbAll( + `SELECT kc.*, kd.title AS document_title, kd.source_type, kd.language, kd.scope_mode, + kd.rights_status, kd.risk_json, + kd.project_id, kd.organization_id, kd.workspace_id, kd.updated_at + FROM knowledge_chunks kc + JOIN knowledge_documents kd ON kd.id = kc.document_id + WHERE ${where.join(" AND ")} + ORDER BY kd.updated_at DESC, kc.chunk_index ASC + LIMIT ?`, + [...queryParams, Math.max(limit * 8, 80)] + ); + const ranked = rows + .map((row) => { + const chunk = normalizeKnowledgeChunk(row); + const score = scoreKnowledgeChunk(chunk, terms); + return { + ...chunk, + score, + snippet: snippetFor(chunk.content, terms), + citationKey: "" + }; + }) + .filter((item) => !terms.length || item.score > 0) + .sort((left, right) => right.score - left.score || right.updatedAt.localeCompare(left.updatedAt) || left.chunkIndex - right.chunkIndex) + .slice(0, limit) + .map((item, index) => ({ ...item, citationKey: `K${index + 1}` })); + const documentIds = new Set(ranked.map((item) => item.documentId)); + return { + query, + scopeMode, + sourceType, + total: ranked.length, + results: ranked, + summary: { + documents: documentIds.size, + chunks: ranked.length, + tokenEstimate: ranked.reduce((sum, item) => sum + Number(item.tokenEstimate || 0), 0), + types: ranked.reduce((accumulator, item) => ({ ...accumulator, [item.chunkType]: (accumulator[item.chunkType] || 0) + 1 }), {}) + } + }; +} + +function knowledgeChunksForIds(context, chunkIds) { + const ids = unique(chunkIds).slice(0, 80); + if (!ids.length) return []; + const { clause, params } = scopedKnowledgeQuery(context); + const rows = dbAll( + `SELECT kc.*, kd.title AS document_title, kd.source_type, kd.language, kd.scope_mode, + kd.rights_status, kd.risk_json, + kd.project_id, kd.organization_id, kd.workspace_id, kd.updated_at + FROM knowledge_chunks kc + JOIN knowledge_documents kd ON kd.id = kc.document_id + WHERE kc.id IN (${placeholders(ids)}) AND ${clause} + ORDER BY kd.updated_at DESC, kc.chunk_index ASC`, + [...ids, ...params] + ).map(normalizeKnowledgeChunk); + const byId = new Map(rows.map((row) => [row.id, row])); + const ordered = ids.map((id) => byId.get(id)).filter(Boolean); + if (ordered.length !== ids.length) throw httpError(422, "knowledge_chunk_scope_invalid", "知识片段不存在,或不属于当前组织/工作区/项目作用域", { requested: ids.length, matched: ordered.length }); + return ordered; +} + +function trimChunkForBudget(chunk, remainingTokens) { + const allowedTokens = Math.max(40, Number(remainingTokens || 0)); + if (chunk.tokenEstimate <= allowedTokens) return chunk; + const allowedChars = Math.max(120, Math.floor(allowedTokens * 1.8)); + return { + ...chunk, + content: `${chunk.content.slice(0, allowedChars).trim()}…`, + tokenEstimate: approxTokens(chunk.content.slice(0, allowedChars)) + }; +} + +function selectPackChunks(chunks, maxTokens) { + const selected = []; + let tokenEstimate = 0; + for (const chunk of chunks) { + const remaining = maxTokens - tokenEstimate; + if (remaining <= 0) break; + const candidate = selected.length ? chunk : trimChunkForBudget(chunk, remaining); + if (candidate.tokenEstimate > remaining && selected.length) continue; + selected.push(candidate); + tokenEstimate += Number(candidate.tokenEstimate || 0); + } + return { chunks: selected, tokenEstimate }; +} + +function packPromptContext(name, chunks, citations, governance = {}) { + const blocks = chunks.map((chunk, index) => { + const citation = citations[index]; + return [ + `## [${citation.key}] ${chunk.heading || `片段 ${chunk.chunkIndex}`}`, + `来源:《${chunk.documentTitle}》 / ${chunk.sourceType || "素材"} / chunk ${chunk.chunkIndex} / rights=${citation.rightsStatus || "needs-evidence"} / risk=${citation.riskStatus || "unscanned"}`, + `内容:${chunk.content}` + ].join("\n"); + }); + return [ + `# 知识库上下文包:${name}`, + "使用要求:基于引用素材做原创改编;保留人物、道具、地点和时间线连续性;生成画面仍必须是一张完整单画面,不得输出多格、拼图或分屏。", + `治理摘要:${governance.status || "unscanned"};未批准/需复核片段 ${governance.reviewCount || 0};阻断片段 ${governance.blockingCount || 0}。`, + ...blocks + ].join("\n\n"); +} + +function packPayload(context, body = {}, chunks, tokenEstimate, persist) { + const name = String(body.name || body.title || (body.query ? `检索包:${body.query}` : "知识库上下文包")).trim().slice(0, 80); + const query = String(body.query || "").trim().slice(0, 120); + const scopeMode = String(body.scopeMode || body.scope_mode || (context.project ? "project" : "workspace")).trim(); + const maxTokens = Math.max(100, Math.min(20000, Number(body.maxTokens || body.max_tokens || 1600))); + const sourceType = String(body.sourceType || body.source_type || "mixed").trim(); + const citations = chunks.map((chunk, index) => ({ + key: `K${index + 1}`, + documentId: chunk.documentId, + documentTitle: chunk.documentTitle, + chunkId: chunk.id, + chunkIndex: chunk.chunkIndex, + heading: chunk.heading, + sourceType: chunk.sourceType, + scopeMode: chunk.scopeMode, + rightsStatus: chunk.rightsStatus || "needs-evidence", + riskStatus: chunk.riskStatus || chunk.risk?.status || "unscanned", + projectId: chunk.projectId || null + })); + const compactChunks = chunks.map((chunk, index) => ({ + citationKey: citations[index].key, + id: chunk.id, + documentId: chunk.documentId, + documentTitle: chunk.documentTitle, + chunkIndex: chunk.chunkIndex, + chunkType: chunk.chunkType, + heading: chunk.heading, + content: chunk.content, + tokenEstimate: chunk.tokenEstimate, + keywords: chunk.keywords, + entities: chunk.entities, + rightsStatus: chunk.rightsStatus || "needs-evidence", + riskStatus: chunk.riskStatus || chunk.risk?.status || "unscanned" + })); + const governance = summarizePackGovernance(chunks); + return { + schema: "ai-drama.knowledge-context-pack.v1", + id: String(body.id || makeId(persist ? "knowledge-pack" : "knowledge-pack-preview")), + name, + query, + sourceType, + scopeMode: ["workspace", "project"].includes(scopeMode) ? scopeMode : "workspace", + projectId: scopeMode === "project" ? context.project?.id || "" : "", + maxTokens, + tokenEstimate, + selectedCount: compactChunks.length, + chunkIds: compactChunks.map((chunk) => chunk.id), + citations, + chunks: compactChunks, + promptContext: packPromptContext(name, chunks, citations, governance), + governance, + metadata: body.metadata && typeof body.metadata === "object" ? { ...body.metadata, governance } : { governance } + }; +} + +export function createKnowledgeContextPack(context, body = {}, options = {}) { + const persist = options.persist !== false; + requirePermission(context, persist ? "script:edit" : "script:read"); + if (persist) requireEntitlement(context, "limit.knowledge_context_packs", 1); + const maxTokens = Math.max(100, Math.min(20000, Number(body.maxTokens || body.max_tokens || 1600))); + const chunkIds = Array.isArray(body.chunkIds || body.chunk_ids) ? body.chunkIds || body.chunk_ids : []; + const sourceChunks = chunkIds.length + ? knowledgeChunksForIds(context, chunkIds) + : searchKnowledge(context, { ...body, limit: Math.max(1, Math.min(40, Number(body.limit || 12))) }).results; + if (!sourceChunks.length) throw httpError(404, "knowledge_context_empty", "没有可用于上下文包的知识片段"); + const selected = selectPackChunks(sourceChunks, maxTokens); + const blocked = selected.chunks.filter((chunk) => ["rejected", "expired"].includes(chunk.rightsStatus || "") || chunk.risk?.status === "blocked"); + if (blocked.length) { + throw httpError(409, "knowledge_context_blocked", "选中的知识片段包含版权不可用或风险阻断素材,不能创建生产上下文包", { + blocked: blocked.map((chunk) => ({ id: chunk.id, documentId: chunk.documentId, rightsStatus: chunk.rightsStatus, riskStatus: chunk.risk?.status || "unscanned" })) + }); + } + const containsProjectScopedChunk = selected.chunks.some((chunk) => chunk.scopeMode === "project" || chunk.projectId); + const requestedScopeMode = String(body.scopeMode || body.scope_mode || "workspace").trim(); + const effectiveScopeMode = containsProjectScopedChunk ? "project" : requestedScopeMode; + if (effectiveScopeMode === "project" && !context.project) throw httpError(400, "knowledge_project_required", "项目级上下文包必须绑定当前项目"); + const pack = packPayload(context, { ...body, maxTokens, scopeMode: effectiveScopeMode }, selected.chunks, selected.tokenEstimate, persist); + if (persist) { + const timestamp = now(); + withTransaction(() => { + dbRun( + `INSERT 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, ?)`, + [ + pack.id, + context.organization.id, + context.workspace.id, + pack.scopeMode === "project" ? context.project?.id || null : null, + pack.scopeMode, + pack.name, + pack.query, + pack.sourceType, + pack.maxTokens, + pack.tokenEstimate, + JSON.stringify(pack.chunkIds), + JSON.stringify(pack.citations), + JSON.stringify(pack.chunks), + pack.promptContext, + JSON.stringify(pack.metadata), + context.user.id, + timestamp, + timestamp + ] + ); + }); + addAudit({ context, action: "knowledge.context_pack.created", targetType: "knowledge_context_pack", targetId: pack.id, metadata: { name: pack.name, chunkCount: pack.selectedCount, tokenEstimate: pack.tokenEstimate } }); + addUsage({ context, kind: "knowledge-context-pack", units: pack.selectedCount, unitName: "chunks", metadata: { packId: pack.id, tokenEstimate: pack.tokenEstimate } }); + } + return { pack, packs: persist ? listKnowledgeContextPacks(context).packs : undefined }; +} + +export function listKnowledgeContextPacks(context) { + requirePermission(context, "script:read"); + const { clause, params } = scopedKnowledgePackQuery(context); + const rows = dbAll( + `SELECT * + FROM knowledge_context_packs kcp + WHERE ${clause} AND kcp.status = 'active' + ORDER BY kcp.updated_at DESC, kcp.created_at DESC + LIMIT 80`, + params + ).map(serializeContextPackRow); + return { + packs: rows, + summary: { + total: rows.length, + chunks: rows.reduce((sum, item) => sum + (item.chunkIds?.length || 0), 0), + tokenEstimate: rows.reduce((sum, item) => sum + Number(item.tokenEstimate || 0), 0) + } + }; +} + +export function getKnowledgeContextPack(context, packId) { + requirePermission(context, "script:read"); + const { clause, params } = scopedKnowledgePackQuery(context); + const row = dbGet( + `SELECT * + FROM knowledge_context_packs kcp + WHERE kcp.id = ? AND ${clause}`, + [packId, ...params] + ); + if (!row) throw httpError(404, "knowledge_context_pack_not_found", "知识库上下文包不存在或不属于当前作用域", { packId }); + return { pack: serializeContextPackRow(row) }; +} + +export function materializeKnowledgeContextPack(context, packId, body = {}) { + requirePermission(context, "script:edit"); + requireProjectWritable(context); + if (!context.project) throw httpError(400, "project_required", "上下文包送入剧本工厂时必须绑定项目"); + const { pack } = getKnowledgeContextPack(context, packId); + const chunks = Array.isArray(pack.chunks) ? pack.chunks : []; + assertPackGovernanceUsable(pack); + if (!chunks.length) throw httpError(422, "knowledge_context_pack_empty", "上下文包没有可导入的片段"); + const content = [ + `# ${pack.name}`, + pack.citations?.length ? `引用:${pack.citations.map((item) => `[${item.key}]《${item.documentTitle}》/${item.heading}`).join(";")}` : "", + ...chunks.map((chunk) => [`## ${chunk.citationKey || ""} ${chunk.heading || chunk.id}`.trim(), chunk.content].join("\n")) + ].filter(Boolean).join("\n\n"); + const scriptImport = importScript(context, { + title: String(body.title || `${pack.name} · 剧本草稿`).trim(), + sourceType: `知识库上下文包/${pack.sourceType || "mixed"}`, + content, + episodeId: body.episodeId || undefined, + episodeTitle: body.episodeTitle || undefined, + metadata: { + origin: "knowledge_context_pack", + knowledgePackId: pack.id, + knowledgePackName: pack.name, + knowledgeChunkIds: pack.chunkIds || [], + knowledgeCitations: pack.citations || [], + knowledgeGovernance: pack.governance || {} + } + }); + addAudit({ context, action: "knowledge.context_pack.materialized", targetType: "knowledge_context_pack", targetId: packId, metadata: { scriptDocumentId: scriptImport.document?.id || "", chunkCount: chunks.length } }); + return { + sourcePack: pack, + importedScript: scriptImport.document, + graph: scriptImport.graph + }; +} + +export function resolveKnowledgeContextForJob(context, body = {}) { + const packId = String(body.knowledgePackId || body.knowledge_pack_id || "").trim(); + const inlinePack = body.knowledgePack && typeof body.knowledgePack === "object" ? body.knowledgePack : null; + const chunkIds = Array.isArray(body.knowledgeChunkIds || body.knowledge_chunk_ids) ? body.knowledgeChunkIds || body.knowledge_chunk_ids : []; + const query = String(body.knowledgeQuery || body.knowledge_query || "").trim(); + if (!packId && !inlinePack && !chunkIds.length && !query) return null; + requirePermission(context, "script:read"); + if (packId) { + const pack = getKnowledgeContextPack(context, packId).pack; + assertPackGovernanceUsable(pack); + return pack; + } + if (inlinePack) { + return { + schema: "ai-drama.knowledge-context-pack.v1", + id: String(inlinePack.id || "inline-knowledge-pack"), + name: String(inlinePack.name || "内联知识库上下文包"), + tokenEstimate: Number(inlinePack.tokenEstimate || 0), + chunkIds: Array.isArray(inlinePack.chunkIds) ? inlinePack.chunkIds : [], + citations: Array.isArray(inlinePack.citations) ? inlinePack.citations : [], + chunks: Array.isArray(inlinePack.chunks) ? inlinePack.chunks : [], + promptContext: String(inlinePack.promptContext || ""), + sourceType: String(inlinePack.sourceType || "inline"), + scopeMode: String(inlinePack.scopeMode || "workspace") + }; + } + return createKnowledgeContextPack(context, { + name: body.knowledgePackName || body.knowledge_pack_name || (query ? `任务上下文:${query}` : "任务上下文包"), + query, + chunkIds, + maxTokens: body.knowledgeMaxTokens || body.knowledge_max_tokens || 1600, + sourceType: body.knowledgeSourceType || body.knowledge_source_type || "mixed", + scopeMode: body.knowledgeScopeMode || body.knowledge_scope_mode || "workspace", + metadata: { transient: true, jobKind: body.kind || "" } + }, { persist: false }).pack; +} + +export function importKnowledgeDocument(context, body = {}) { + requirePermission(context, "script:edit"); + requireEntitlement(context, "limit.knowledge_documents", 1); + const content = String(body.content || "").replace(/\r/g, "").trim(); + if (content.length < 30) throw httpError(400, "knowledge_content_required", "导入知识库的文本至少需要 30 个字符"); + const scopeMode = String(body.scopeMode || body.scope_mode || "workspace").trim(); + if (!["workspace", "project"].includes(scopeMode)) throw httpError(400, "knowledge_scope_invalid", "知识库作用域只能是 workspace 或 project"); + if (scopeMode === "project" && !context.project) throw httpError(400, "knowledge_project_required", "项目级知识库必须绑定当前项目"); + const analysis = analyzeKnowledgeText(content); + const title = String(body.title || analysis.chapters[0]?.title || "未命名素材").trim(); + if (!title) throw httpError(400, "knowledge_title_required", "知识库标题不能为空"); + const sourceType = String(body.sourceType || body.source_type || "novel").trim(); + const language = String(body.language || "zh-CN").trim(); + const metadata = body.metadata && typeof body.metadata === "object" ? body.metadata : {}; + const rightsStatus = normalizeRightsStatus(body.rightsStatus || body.rights_status || metadata.rightsStatus || metadata.rights); + const provenance = normalizeProvenance(body, metadata); + const tags = normalizeTags(body.tags || metadata.tags || []); + const risk = scanKnowledgeGovernance({ title, content, sourceType, rightsStatus, provenance, tags }); + const id = String(body.id || makeId("knowledge")); + const timestamp = now(); + withTransaction(() => { + dbRun( + `INSERT INTO knowledge_documents( + id, organization_id, workspace_id, project_id, scope_mode, title, source_type, language, + content, status, rights_status, summary, analysis_json, provenance_json, tags_json, risk_json, + metadata_json, current_version_number, created_by, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'indexed', ?, ?, ?, ?, ?, ?, ?, 1, ?, ?, ?)`, + [id, context.organization.id, context.workspace.id, scopeMode === "project" ? context.project?.id || null : null, scopeMode, title, sourceType, language, content, rightsStatus, analysis.summary, JSON.stringify({ ...analysis, chunks: undefined }), JSON.stringify(provenance), JSON.stringify(tags), JSON.stringify(risk), JSON.stringify(metadata), context.user.id, timestamp, timestamp] + ); + writeKnowledgeChunks(id, analysis.chunks, timestamp); + insertKnowledgeVersion(context, { + id, + title, + sourceType, + language, + content, + summary: analysis.summary, + analysis, + provenance, + tags, + risk, + metadata + }, 1, timestamp, { action: "ingest" }); + }); + addAudit({ context, action: "knowledge.document.ingested", targetType: "knowledge_document", targetId: id, metadata: { title, scopeMode, sourceType, chunkCount: analysis.chunkCount, rightsStatus, riskStatus: risk.status, riskScore: risk.score } }); + addUsage({ context, kind: "knowledge-ingest", units: analysis.chunkCount, unitName: "chunks", metadata: { documentId: id, sourceType, parser: analysis.parser } }); + return { document: knowledgeDocumentDetail(context, id), summary: listKnowledgeDocuments(context).summary }; +} + +export function updateKnowledgeDocument(context, documentId, body = {}) { + requirePermission(context, "script:edit"); + const existing = knowledgeDocumentDetail(context, documentId); + if (!existing) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId }); + const title = body.title === undefined ? existing.title : String(body.title || "").trim(); + if (!title) throw httpError(400, "knowledge_title_required", "知识库标题不能为空"); + const sourceType = body.sourceType === undefined && body.source_type === undefined ? existing.sourceType || existing.source_type || "novel" : String(body.sourceType || body.source_type || "novel").trim(); + const language = body.language === undefined ? existing.language || "zh-CN" : String(body.language || "zh-CN").trim(); + const content = body.content === undefined ? existing.content || "" : String(body.content || "").replace(/\r/g, "").trim(); + if (content.length < 30) throw httpError(400, "knowledge_content_required", "知识库正文至少需要 30 个字符"); + const status = normalizeDocumentStatus(body.status, existing.status || "indexed"); + const rightsInput = body.rightsStatus ?? body.rights_status ?? existing.rightsStatus; + const rightsStatus = normalizeRightsStatus(rightsInput); + const provenance = normalizeProvenance(body, existing.provenance); + const tags = body.tags === undefined ? existing.tags || [] : normalizeTags(body.tags); + const metadata = body.metadata && typeof body.metadata === "object" ? { ...(existing.metadata || {}), ...body.metadata } : existing.metadata || {}; + const contentChanged = content !== existing.content || title !== existing.title || sourceType !== (existing.sourceType || existing.source_type) || language !== existing.language; + const analysis = contentChanged ? analyzeKnowledgeText(content) : existing.analysis || analyzeKnowledgeText(content); + const risk = scanKnowledgeGovernance({ title, content, sourceType, rightsStatus, provenance, tags }); + const versionNumber = nextKnowledgeVersionNumber(documentId); + const timestamp = now(); + withTransaction(() => { + dbRun( + `UPDATE knowledge_documents + SET title = ?, source_type = ?, language = ?, content = ?, status = ?, rights_status = ?, + summary = ?, analysis_json = ?, provenance_json = ?, tags_json = ?, risk_json = ?, + metadata_json = ?, current_version_number = ?, updated_at = ? + WHERE id = ?`, + [title, sourceType, language, content, status, rightsStatus, analysis.summary, JSON.stringify({ ...analysis, chunks: undefined }), JSON.stringify(provenance), JSON.stringify(tags), JSON.stringify(risk), JSON.stringify(metadata), versionNumber, timestamp, documentId] + ); + if (contentChanged) { + dbRun("DELETE FROM knowledge_chunks WHERE document_id = ?", [documentId]); + writeKnowledgeChunks(documentId, analysis.chunks, timestamp); + } + insertKnowledgeVersion(context, { + id: documentId, + title, + sourceType, + language, + content, + summary: analysis.summary, + analysis, + provenance, + tags, + risk, + metadata + }, versionNumber, timestamp, { action: "update", contentChanged }); + }); + addAudit({ context, action: "knowledge.document.updated", targetType: "knowledge_document", targetId: documentId, metadata: { title, versionNumber, contentChanged, rightsStatus, riskStatus: risk.status } }); + return { document: knowledgeDocumentDetail(context, documentId), summary: listKnowledgeDocuments(context).summary }; +} + +export function reviewKnowledgeDocument(context, documentId, body = {}) { + const decision = normalizeGovernanceDecision(body.decision); + if (["approved", "rejected"].includes(decision)) requirePermission(context, "compliance:manage"); + else if (!hasPermission(context, "script:edit") && !hasPermission(context, "compliance:manage") && !hasPermission(context, "asset:edit")) { + throw httpError(403, "permission_denied", "缺少提交素材治理证据的权限", { permission: "script:edit" }); + } + const existing = knowledgeDocumentDetail(context, documentId); + if (!existing) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId }); + const requestedRights = body.rightsStatus ?? body.rights_status; + const rightsStatus = normalizeRightsStatus(requestedRights || (decision === "approved" ? "approved" : decision === "rejected" ? "rejected" : "submitted")); + const provenance = normalizeProvenance(body, existing.provenance); + if (body.evidenceRef || body.evidence_ref) provenance.evidenceRef = String(body.evidenceRef || body.evidence_ref || "").trim(); + const tags = body.tags === undefined ? existing.tags || [] : normalizeTags(body.tags); + const risk = scanKnowledgeGovernance({ + title: existing.title, + content: existing.content, + sourceType: existing.sourceType || existing.source_type, + rightsStatus, + provenance, + tags + }); + const reviewId = String(body.id || makeId("knowledge-review")); + const timestamp = now(); + withTransaction(() => { + dbRun( + `INSERT INTO knowledge_governance_reviews( + id, document_id, organization_id, workspace_id, project_id, decision, rights_status, + risk_status, notes, evidence_ref, provenance_json, risk_json, reviewer_user_id, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + reviewId, + documentId, + context.organization.id, + context.workspace.id, + existing.projectId || null, + decision, + rightsStatus, + risk.status, + String(body.notes || "").trim(), + provenance.evidenceRef || "", + JSON.stringify(provenance), + JSON.stringify(risk), + context.user.id, + timestamp + ] + ); + dbRun( + `UPDATE knowledge_documents + SET rights_status = ?, provenance_json = ?, tags_json = ?, risk_json = ?, updated_at = ? + WHERE id = ?`, + [rightsStatus, JSON.stringify(provenance), JSON.stringify(tags), JSON.stringify(risk), timestamp, documentId] + ); + }); + addAudit({ context, action: `knowledge.document.review.${decision}`, targetType: "knowledge_document", targetId: documentId, metadata: { reviewId, rightsStatus, riskStatus: risk.status, evidenceRef: provenance.evidenceRef || "" } }); + return { document: knowledgeDocumentDetail(context, documentId), review: latestKnowledgeReview(documentId), summary: listKnowledgeDocuments(context).summary }; +} + +export function archiveKnowledgeDocument(context, documentId) { + requirePermission(context, "script:edit"); + const existing = knowledgeDocumentDetail(context, documentId); + if (!existing) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId }); + const timestamp = now(); + dbRun("UPDATE knowledge_documents SET status = 'archived', updated_at = ? WHERE id = ?", [timestamp, documentId]); + addAudit({ context, action: "knowledge.document.archived", targetType: "knowledge_document", targetId: documentId, metadata: { previousStatus: existing.status } }); + return { document: knowledgeDocumentDetail(context, documentId), summary: listKnowledgeDocuments(context).summary }; +} + +export function restoreKnowledgeDocument(context, documentId) { + requirePermission(context, "script:edit"); + const existing = knowledgeDocumentDetail(context, documentId); + if (!existing) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId }); + const timestamp = now(); + dbRun("UPDATE knowledge_documents SET status = 'indexed', updated_at = ? WHERE id = ?", [timestamp, documentId]); + addAudit({ context, action: "knowledge.document.restored", targetType: "knowledge_document", targetId: documentId, metadata: { previousStatus: existing.status } }); + return { document: knowledgeDocumentDetail(context, documentId), summary: listKnowledgeDocuments(context).summary }; +} + +export function listKnowledgeDocumentVersions(context, documentId) { + requirePermission(context, "script:read"); + const existing = knowledgeDocumentDetail(context, documentId); + if (!existing) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId }); + const versions = dbAll( + `SELECT * + FROM knowledge_document_versions + WHERE document_id = ? + ORDER BY version_number DESC`, + [documentId] + ).map(serializeKnowledgeVersionRow); + return { document: existing, versions }; +} + +export function restoreKnowledgeDocumentVersion(context, documentId, versionKey) { + requirePermission(context, "script:edit"); + const existing = knowledgeDocumentDetail(context, documentId); + if (!existing) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId }); + const version = dbGet( + `SELECT * + FROM knowledge_document_versions + WHERE document_id = ? AND (id = ? OR CAST(version_number AS TEXT) = ?) + LIMIT 1`, + [documentId, String(versionKey), String(versionKey)] + ); + if (!version) throw httpError(404, "knowledge_version_not_found", "知识库文档版本不存在", { documentId, versionKey }); + const serialized = serializeKnowledgeVersionRow(version); + const analysis = analyzeKnowledgeText(serialized.content); + const risk = scanKnowledgeGovernance({ + title: serialized.title, + content: serialized.content, + sourceType: serialized.sourceType, + rightsStatus: existing.rightsStatus, + provenance: serialized.provenance, + tags: serialized.tags + }); + const versionNumber = nextKnowledgeVersionNumber(documentId); + const timestamp = now(); + withTransaction(() => { + dbRun( + `UPDATE knowledge_documents + SET title = ?, source_type = ?, language = ?, content = ?, summary = ?, analysis_json = ?, + provenance_json = ?, tags_json = ?, risk_json = ?, current_version_number = ?, status = 'indexed', updated_at = ? + WHERE id = ?`, + [serialized.title, serialized.sourceType, serialized.language, serialized.content, analysis.summary, JSON.stringify({ ...analysis, chunks: undefined }), JSON.stringify(serialized.provenance), JSON.stringify(serialized.tags), JSON.stringify(risk), versionNumber, timestamp, documentId] + ); + dbRun("DELETE FROM knowledge_chunks WHERE document_id = ?", [documentId]); + writeKnowledgeChunks(documentId, analysis.chunks, timestamp); + insertKnowledgeVersion(context, { + id: documentId, + title: serialized.title, + sourceType: serialized.sourceType, + language: serialized.language, + content: serialized.content, + summary: analysis.summary, + analysis, + provenance: serialized.provenance, + tags: serialized.tags, + risk, + metadata: { ...serialized.metadata, restoredFromVersion: serialized.versionNumber } + }, versionNumber, timestamp, { action: "restore-version", restoredFromVersion: serialized.versionNumber, restoredFromVersionId: serialized.id }); + }); + addAudit({ context, action: "knowledge.document.version_restored", targetType: "knowledge_document", targetId: documentId, metadata: { restoredFromVersion: serialized.versionNumber, newVersionNumber: versionNumber } }); + return { document: knowledgeDocumentDetail(context, documentId), versions: listKnowledgeDocumentVersions(context, documentId).versions }; +} + +export function materializeKnowledgeDocument(context, documentId, body = {}) { + requirePermission(context, "script:edit"); + requireProjectWritable(context); + if (!context.project) throw httpError(400, "project_required", "把知识库文档送入剧本工厂时必须绑定项目"); + const document = knowledgeDocumentDetail(context, documentId); + if (!document) throw httpError(404, "knowledge_document_not_found", "知识库文档不存在或不属于当前作用域", { documentId }); + assertKnowledgeDocumentUsable(document); + const selectedChunkIds = Array.isArray(body.chunkIds) ? new Set(body.chunkIds.map((item) => String(item))) : null; + const selectedChunks = selectedChunkIds ? document.chunks.filter((chunk) => selectedChunkIds.has(chunk.id)) : document.chunks; + const content = selectedChunks.length ? selectedChunks.map((chunk) => `${chunk.heading}\n${chunk.content}`.trim()).join("\n\n") : document.content; + const scriptImport = importScript(context, { + title: String(body.title || `${document.title} · 剧本草稿`).trim(), + sourceType: `知识库/${document.source_type || "novel"}`, + content, + episodeId: body.episodeId || undefined, + episodeTitle: body.episodeTitle || undefined, + metadata: { + origin: "knowledge_document", + knowledgeDocumentId: document.id, + knowledgeDocumentTitle: document.title, + knowledgeChunkIds: selectedChunks.map((chunk) => chunk.id), + knowledgeGovernance: { + rightsStatus: document.rightsStatus || document.rights_status || "needs-evidence", + riskStatus: document.risk?.status || document.riskStatus || "unscanned" + } + } + }); + addAudit({ context, action: "knowledge.document.materialized", targetType: "knowledge_document", targetId: documentId, metadata: { scriptDocumentId: scriptImport.document?.id || "", chunkCount: selectedChunks.length } }); + return { + sourceDocument: document, + importedScript: scriptImport.document, + graph: scriptImport.graph + }; +} diff --git a/server/local-api.mjs b/server/local-api.mjs index e02bab4..47539d2 100644 --- a/server/local-api.mjs +++ b/server/local-api.mjs @@ -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); diff --git a/server/production.mjs b/server/production.mjs index e710eeb..ee10f1d 100644 --- a/server/production.mjs +++ b/server/production.mjs @@ -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 }; } diff --git a/server/schema.sql b/server/schema.sql index e8cf605..8161ceb 100644 --- a/server/schema.sql +++ b/server/schema.sql @@ -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); diff --git a/server/search.mjs b/server/search.mjs index 935b49c..28b8607 100644 --- a/server/search.mjs +++ b/server/search.mjs @@ -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)); diff --git a/server/tenant.mjs b/server/tenant.mjs index a278b63..9f905f9 100644 --- a/server/tenant.mjs +++ b/server/tenant.mjs @@ -670,7 +670,10 @@ export function organizationSeatSummary(organizationId) { const billing = billingAccount(organizationId); const activeMembers = Number(dbGet("SELECT COUNT(DISTINCT user_id) AS count FROM organization_members WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0); const pendingInvitations = Number(dbGet("SELECT COUNT(*) AS count FROM invitations WHERE organization_id = ? AND status = 'pending' AND julianday(expires_at) > julianday('now')", [organizationId])?.count || 0); - const seatLimit = Number(billing?.seat_limit || 0); + const entitlement = dbGet("SELECT limit_value, enabled, enforcement FROM organization_entitlements WHERE organization_id = ? AND entitlement_key = 'limit.seats'", [organizationId]); + const billingLimit = Number(billing?.seat_limit || 0); + const entitlementLimit = entitlement && Boolean(entitlement.enabled) && entitlement.enforcement === "block" ? Number(entitlement.limit_value || 0) : 0; + const seatLimit = billingLimit && entitlementLimit ? Math.min(billingLimit, entitlementLimit) : billingLimit || entitlementLimit; return { limit: seatLimit, active: activeMembers, @@ -710,6 +713,717 @@ function organizationQuotaRows(organizationId) { })); } +export function subscriptionPlanTemplates(options = {}) { + const includeArchived = Boolean(options.includeArchived); + return dbAll( + `SELECT * + FROM subscription_plan_templates + ${includeArchived ? "" : "WHERE status = 'active'"} + ORDER BY CASE tier_key + WHEN 'starter-local' THEN 0 + WHEN 'studio-local' THEN 1 + WHEN 'enterprise-private' THEN 2 + ELSE 3 + END, name` + ).map((row) => ({ + id: row.id, + tierKey: row.tier_key, + name: row.name, + description: row.description || "", + billingCycle: row.billing_cycle || "monthly", + currency: row.currency || "CNY", + baseFee: Number(row.base_fee || 0), + seatLimit: Number(row.seat_limit || 0), + storageGb: Number(row.storage_gb || 0), + monthlyClipQuota: Number(row.monthly_clip_quota || 0), + limits: parseJson(row.limits_json, {}), + features: parseJson(row.features_json, {}), + connectorPolicy: parseJson(row.connector_policy_json, {}), + supportSla: row.support_sla || "", + status: row.status || "active", + updatedAt: row.updated_at + })); +} + +function normalizePlanTemplateBody(body = {}, current = null) { + const tierKey = String(body.tierKey ?? body.tier_key ?? current?.tier_key ?? "").trim().toLowerCase(); + if (!tierKey || !/^[a-z0-9][a-z0-9-]{1,60}$/.test(tierKey)) throw httpError(400, "plan_tier_key_invalid", "套餐 tierKey 只能使用小写字母、数字和连字符"); + const name = String(body.name ?? current?.name ?? "").trim().slice(0, 80); + if (name.length < 2) throw httpError(400, "plan_name_required", "套餐名称至少需要 2 个字符"); + const billingCycle = String(body.billingCycle ?? body.billing_cycle ?? current?.billing_cycle ?? "monthly").trim(); + if (!["monthly", "quarterly", "annual"].includes(billingCycle)) throw httpError(400, "plan_billing_cycle_invalid", "账单周期必须是 monthly、quarterly 或 annual"); + const status = String(body.status ?? current?.status ?? "active").trim(); + if (!["active", "archived"].includes(status)) throw httpError(400, "plan_status_invalid", "套餐状态必须是 active 或 archived"); + const numberField = (camelKey, snakeKey, fallback, { integer = true, min = 0 } = {}) => { + const raw = body[camelKey] ?? body[snakeKey] ?? fallback; + const value = integer ? Math.floor(Number(raw)) : Number(raw); + if (!Number.isFinite(value) || value < min) throw httpError(400, `plan_${snakeKey}_invalid`, `${camelKey} 必须是大于或等于 ${min} 的数字`); + return value; + }; + const objectField = (camelKey, snakeKey, fallback) => { + const raw = body[camelKey] ?? body[snakeKey]; + if (raw === undefined) return parseJson(fallback || "{}", {}); + if (typeof raw === "string") return parseJson(raw, {}); + return objectValue(raw); + }; + return { + tierKey, + name, + description: String(body.description ?? current?.description ?? "").trim().slice(0, 500), + billingCycle, + currency: String(body.currency ?? current?.currency ?? "CNY").trim().toUpperCase().slice(0, 3) || "CNY", + baseFee: numberField("baseFee", "base_fee", current?.base_fee || 0, { integer: false, min: 0 }), + seatLimit: numberField("seatLimit", "seat_limit", current?.seat_limit || 1, { integer: true, min: 1 }), + storageGb: numberField("storageGb", "storage_gb", current?.storage_gb || 1, { integer: true, min: 1 }), + monthlyClipQuota: numberField("monthlyClipQuota", "monthly_clip_quota", current?.monthly_clip_quota || 1, { integer: true, min: 1 }), + limits: objectField("limits", "limits_json", current?.limits_json), + features: objectField("features", "features_json", current?.features_json), + connectorPolicy: objectField("connectorPolicy", "connector_policy_json", current?.connector_policy_json), + supportSla: String(body.supportSla ?? body.support_sla ?? current?.support_sla ?? "").trim().slice(0, 120), + status + }; +} + +export function createSubscriptionPlanTemplate(context, body = {}) { + requirePermission(context, "system:settings:edit"); + const plan = normalizePlanTemplateBody(body); + const timestamp = new Date().toISOString(); + const id = `plan-${plan.tierKey}`; + dbRun( + `INSERT 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(tier_key) DO UPDATE SET name = excluded.name, description = excluded.description, billing_cycle = excluded.billing_cycle, currency = excluded.currency, base_fee = excluded.base_fee, seat_limit = excluded.seat_limit, storage_gb = excluded.storage_gb, monthly_clip_quota = excluded.monthly_clip_quota, limits_json = excluded.limits_json, features_json = excluded.features_json, connector_policy_json = excluded.connector_policy_json, support_sla = excluded.support_sla, status = excluded.status, updated_at = excluded.updated_at`, + [id, plan.tierKey, plan.name, plan.description, plan.billingCycle, plan.currency, plan.baseFee, plan.seatLimit, plan.storageGb, plan.monthlyClipQuota, JSON.stringify(plan.limits), JSON.stringify(plan.features), JSON.stringify(plan.connectorPolicy), plan.supportSla, plan.status, timestamp, timestamp] + ); + addAudit({ context, action: "system.plan_template.upserted", targetType: "subscription_plan_template", targetId: plan.tierKey, metadata: { plan } }); + const planTemplates = subscriptionPlanTemplates({ includeArchived: true }); + return { planTemplate: planTemplates.find((item) => item.tierKey === plan.tierKey), planTemplates }; +} + +export function updateSubscriptionPlanTemplate(context, planId, body = {}) { + requirePermission(context, "system:settings:edit"); + const current = dbGet("SELECT * FROM subscription_plan_templates WHERE id = ? OR tier_key = ?", [planId, planId]); + if (!current) throw httpError(404, "plan_template_not_found", "套餐模板不存在", { planId }); + const plan = normalizePlanTemplateBody(body, current); + const collision = dbGet("SELECT id FROM subscription_plan_templates WHERE tier_key = ? AND id != ?", [plan.tierKey, current.id]); + if (collision) throw httpError(409, "plan_tier_key_exists", "套餐 tierKey 已被其他模板使用", { tierKey: plan.tierKey }); + const timestamp = new Date().toISOString(); + dbRun( + `UPDATE subscription_plan_templates + SET 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 = ?, updated_at = ? + WHERE id = ?`, + [plan.tierKey, plan.name, plan.description, plan.billingCycle, plan.currency, plan.baseFee, plan.seatLimit, plan.storageGb, plan.monthlyClipQuota, JSON.stringify(plan.limits), JSON.stringify(plan.features), JSON.stringify(plan.connectorPolicy), plan.supportSla, plan.status, timestamp, current.id] + ); + addAudit({ context, action: "system.plan_template.updated", targetType: "subscription_plan_template", targetId: current.id, metadata: { previous: { tierKey: current.tier_key, name: current.name, status: current.status }, next: plan } }); + const planTemplates = subscriptionPlanTemplates({ includeArchived: true }); + return { planTemplate: planTemplates.find((item) => item.id === current.id || item.tierKey === plan.tierKey), planTemplates }; +} + +function entitlementUsageValue(organizationId, key) { + if (key === "limit.seats") return organizationSeatSummary(organizationId).reserved; + if (key === "limit.workspaces") return Number(dbGet("SELECT COUNT(*) AS count FROM workspaces WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0); + if (key === "limit.projects") return Number(dbGet("SELECT COUNT(*) AS count FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE w.organization_id = ? AND p.status != 'archived'", [organizationId])?.count || 0); + if (key === "limit.model_connectors") return Number(dbGet("SELECT COUNT(*) AS count FROM model_connectors WHERE organization_id = ?", [organizationId])?.count || 0); + if (key === "limit.api_clients") return Number(dbGet("SELECT COUNT(*) AS count FROM api_clients WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0); + if (key === "limit.knowledge_documents") return Number(dbGet("SELECT COUNT(*) AS count FROM knowledge_documents WHERE organization_id = ? AND status != 'archived'", [organizationId])?.count || 0); + if (key === "limit.knowledge_context_packs") return Number(dbGet("SELECT COUNT(*) AS count FROM knowledge_context_packs WHERE organization_id = ? AND status = 'active'", [organizationId])?.count || 0); + if (key === "limit.delivery_channels") return Number(dbGet("SELECT COUNT(*) AS count FROM delivery_channels WHERE organization_id = ?", [organizationId])?.count || 0); + if (key === "limit.storage_gb") return Number(dbGet("SELECT COALESCE(MAX(used_value), 0) AS used_value FROM quota_allocations WHERE organization_id = ? AND metric = 'storage'", [organizationId])?.used_value || 0); + if (key === "limit.generation_jobs_monthly") { + return Number(dbGet( + "SELECT COALESCE(SUM(units), 0) AS units FROM usage_events WHERE organization_id = ? AND unit_name IN ('job', 'clip', 'clips') AND created_at >= datetime('now', 'start of month')", + [organizationId] + )?.units || 0); + } + return 0; +} + +function entitlementPayload(row, organizationId) { + const limitValue = Number(row.limit_value || 0); + const usedValue = entitlementUsageValue(organizationId, row.entitlement_key); + const enabled = Boolean(row.enabled); + const utilization = limitValue ? Number(((usedValue / limitValue) * 100).toFixed(2)) : 0; + const remainingValue = limitValue ? Math.max(0, limitValue - usedValue) : null; + const blocked = enabled && row.enforcement === "block" && limitValue > 0 && usedValue >= limitValue; + const warning = enabled && !blocked && limitValue > 0 && utilization >= 80; + const isFeature = String(row.entitlement_key || "").startsWith("feature."); + return { + id: row.id, + key: row.entitlement_key, + label: row.label, + category: row.category, + limitValue, + usedValue, + remainingValue, + utilization, + unit: row.unit, + enabled, + enforcement: row.enforcement, + source: row.source, + overrideReason: row.override_reason || "", + metadata: parseJson(row.metadata_json, {}), + status: !enabled ? "disabled" : blocked ? "blocked" : warning ? "warning" : isFeature ? "enabled" : "ok", + updatedAt: row.updated_at + }; +} + +function defaultEntitlementRows(organizationId) { + const billing = billingAccount(organizationId) || {}; + const defaults = [ + ["limit.seats", "组织席位", "tenant", Number(billing.seat_limit || 12), "人", 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", Number(billing.monthly_clip_quota || 2400), "job", 1, "block", { commercialGate: "generation_job:create" }], + ["limit.storage_gb", "存储容量", "storage", Number(billing.storage_gb || 1024), "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: "默认关闭,付费/公网模型必须显式审批" }] + ]; + const timestamp = new Date().toISOString(); + for (const [key, label, category, limitValue, unit, enabled, enforcement, metadata] of defaults) { + dbRun( + "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-${organizationId}-${key.replace(/[^a-z0-9]+/gi, "-")}`, organizationId, key, label, category, limitValue, unit, enabled, enforcement, JSON.stringify(metadata), timestamp, timestamp] + ); + } +} + +export function ensureOrganizationEntitlements(organizationId) { + defaultEntitlementRows(organizationId); + return organizationEntitlements(organizationId); +} + +export function organizationEntitlements(organizationId) { + defaultEntitlementRows(organizationId); + const rows = dbAll( + `SELECT * + FROM organization_entitlements + WHERE organization_id = ? + ORDER BY CASE category + WHEN 'tenant' THEN 0 + WHEN 'production' THEN 1 + WHEN 'knowledge' THEN 2 + WHEN 'modelops' THEN 3 + WHEN 'delivery' THEN 4 + WHEN 'storage' THEN 5 + WHEN 'system' THEN 6 + WHEN 'feature' THEN 7 + ELSE 8 + END, entitlement_key`, + [organizationId] + ).map((row) => entitlementPayload(row, organizationId)); + return { + entitlements: rows, + summary: { + total: rows.length, + enabled: rows.filter((item) => item.enabled).length, + blocked: rows.filter((item) => item.status === "blocked").length, + warning: rows.filter((item) => item.status === "warning").length, + overridden: rows.filter((item) => item.source === "override").length + }, + planTemplates: subscriptionPlanTemplates() + }; +} + +function syncBillingEntitlements(organizationId, { seatLimit, storageGb, monthlyClipQuota }, timestamp) { + const values = [ + ["limit.seats", seatLimit], + ["limit.storage_gb", storageGb], + ["limit.generation_jobs_monthly", monthlyClipQuota] + ]; + for (const [key, limitValue] of values) { + dbRun( + "UPDATE organization_entitlements SET limit_value = ?, updated_at = ? WHERE organization_id = ? AND entitlement_key = ? AND source = 'plan'", + [Number(limitValue || 0), timestamp, organizationId, key] + ); + } +} + +export function updateOrganizationEntitlement(context, organizationId, entitlementKey, body = {}) { + if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能修改其他组织的套餐权益", { organizationId }); + requirePermission(context, "quota:manage"); + defaultEntitlementRows(organizationId); + const key = String(entitlementKey || "").trim(); + const current = dbGet("SELECT * FROM organization_entitlements WHERE organization_id = ? AND entitlement_key = ?", [organizationId, key]); + if (!current) throw httpError(404, "entitlement_not_found", "组织权益不存在", { entitlementKey: key }); + const limitValue = body.limitValue === undefined ? Number(current.limit_value || 0) : Number(body.limitValue); + if (!Number.isFinite(limitValue) || limitValue < 0) throw httpError(400, "entitlement_limit_invalid", "权益额度必须是大于或等于 0 的数字"); + const enabled = body.enabled === undefined ? Boolean(current.enabled) : Boolean(body.enabled); + const enforcement = String(body.enforcement || current.enforcement || "block").trim(); + if (!["block", "warn", "off"].includes(enforcement)) throw httpError(400, "entitlement_enforcement_invalid", "权益执行策略只能是 block、warn 或 off"); + const usedValue = entitlementUsageValue(organizationId, key); + if (enabled && enforcement === "block" && limitValue > 0 && usedValue > limitValue) { + throw httpError(409, "entitlement_below_usage", "权益额度不能低于当前已用量", { entitlementKey: key, usedValue, limitValue }); + } + const overrideReason = String(body.overrideReason ?? body.override_reason ?? current.override_reason ?? "").trim().slice(0, 240); + const metadata = body.metadata && typeof body.metadata === "object" ? { ...parseJson(current.metadata_json, {}), ...body.metadata } : parseJson(current.metadata_json, {}); + const timestamp = new Date().toISOString(); + dbRun( + "UPDATE organization_entitlements SET limit_value = ?, enabled = ?, enforcement = ?, source = 'override', override_reason = ?, metadata_json = ?, updated_at = ? WHERE organization_id = ? AND entitlement_key = ?", + [limitValue, enabled ? 1 : 0, enforcement, overrideReason, JSON.stringify(metadata), timestamp, organizationId, key] + ); + addAudit({ + context, + action: "billing.entitlement.updated", + targetType: "organization_entitlement", + targetId: `${organizationId}:${key}`, + metadata: { + previous: { limitValue: Number(current.limit_value || 0), enabled: Boolean(current.enabled), enforcement: current.enforcement, source: current.source }, + next: { limitValue, enabled, enforcement, source: "override", overrideReason }, + usedValue + } + }); + return organizationCommercial(context); +} + +export function requireEntitlement(context, entitlementKey, units = 1) { + if (!context?.organization?.id) return null; + defaultEntitlementRows(context.organization.id); + const key = String(entitlementKey || "").trim(); + const row = dbGet("SELECT * FROM organization_entitlements WHERE organization_id = ? AND entitlement_key = ?", [context.organization.id, key]); + if (!row) return null; + const payload = entitlementPayload(row, context.organization.id); + const requested = Math.max(0, Number(units || 0)); + if (!payload.enabled) { + throw httpError(403, "entitlement_disabled", `当前组织未开通${payload.label}`, { entitlementKey: key, label: payload.label }); + } + if (payload.enforcement !== "block" || !payload.limitValue) return { ...payload, requested }; + if (payload.usedValue + requested > payload.limitValue) { + throw httpError(429, "entitlement_limit_exceeded", `当前组织的${payload.label}权益额度不足`, { + entitlementKey: key, + label: payload.label, + unit: payload.unit, + used: payload.usedValue, + limit: payload.limitValue, + requested + }); + } + return { ...payload, requested, remainingAfterRequest: payload.limitValue - payload.usedValue - requested }; +} + +const COMMERCIAL_APPROVAL_TYPES = { + entitlement_overage: { + label: "套餐超额 / 扩容", + description: "申请临时提高席位、项目、任务、知识库、存储等组织权益。", + reviewerPermissions: ["quota:manage", "billing:manage"], + autoEffect: "entitlement_override" + }, + feature_enablement: { + label: "功能开通", + description: "申请开通批量生产、客户交付门户、ComfyUI 可选桥接等组织功能。", + reviewerPermissions: ["quota:manage", "billing:manage"], + autoEffect: "feature_override" + }, + budget_increase: { + label: "成本中心预算", + description: "申请提高本地 GPU、存储或运营成本中心的月度预算。", + reviewerPermissions: ["billing:manage"], + autoEffect: "cost_center_budget" + }, + external_connector: { + label: "外部模型连接器", + description: "申请启用外部或混合成本模型连接器;不会自动调用付费云端。", + reviewerPermissions: ["model:approve", "billing:manage"], + autoEffect: "external_connector_gate" + }, + compliance_review: { + label: "法务 / 版权复核", + description: "申请对小说素材、角色、声音、交付物进行商用证据复核。", + reviewerPermissions: ["compliance:manage"], + autoEffect: "compliance_record" + }, + delivery_exception: { + label: "交付例外", + description: "申请在特定版本中放行交付策略例外,仍保留审计证据。", + reviewerPermissions: ["delivery:approve", "compliance:manage"], + autoEffect: "audit_only" + }, + storage_retention: { + label: "留存 / 归档策略", + description: "申请延长素材、任务证据、交付包或客户门户访问留存期。", + reviewerPermissions: ["billing:manage", "compliance:manage"], + autoEffect: "audit_only" + } +}; + +function hasAnyPermission(context, permissions = []) { + return Boolean(context?.systemAdmin || permissions.some((permission) => hasPermission(context, permission))); +} + +function normalizedApprovalType(value) { + const type = String(value || "").trim(); + if (!COMMERCIAL_APPROVAL_TYPES[type]) { + throw httpError(400, "commercial_approval_type_invalid", "不支持的商业治理申请类型", { + allowedTypes: Object.keys(COMMERCIAL_APPROVAL_TYPES) + }); + } + return type; +} + +function normalizedApprovalPriority(value) { + const priority = String(value || "medium").trim(); + return ["low", "medium", "high", "urgent"].includes(priority) ? priority : "medium"; +} + +function canReviewCommercialApproval(context, requestType) { + const definition = COMMERCIAL_APPROVAL_TYPES[requestType]; + return Boolean(definition && hasAnyPermission(context, definition.reviewerPermissions)); +} + +function canViewCommercialApprovalQueue(context) { + return hasAnyPermission(context, [ + "usage:view", + "billing:manage", + "quota:manage", + "model:approve", + "compliance:manage", + "delivery:approve", + "audit:view" + ]); +} + +function commercialApprovalCatalog() { + return Object.entries(COMMERCIAL_APPROVAL_TYPES).map(([key, value]) => ({ key, ...value })); +} + +function commercialApprovalTargetSnapshot(context, requestType, body = {}) { + const targetKey = String(body.targetKey || body.target_key || "").trim(); + if (["entitlement_overage", "feature_enablement"].includes(requestType)) { + defaultEntitlementRows(context.organization.id); + const entitlement = dbGet("SELECT * FROM organization_entitlements WHERE organization_id = ? AND entitlement_key = ?", [context.organization.id, targetKey]); + if (!entitlement) throw httpError(400, "entitlement_target_invalid", "申请目标权益不存在", { targetKey }); + const payload = entitlementPayload(entitlement, context.organization.id); + return { + targetKey, + targetLabel: payload.label, + currentValue: requestType === "feature_enablement" ? (payload.enabled ? 1 : 0) : payload.limitValue, + requestedValue: requestType === "feature_enablement" ? 1 : Number(body.requestedValue ?? body.requested_value ?? payload.limitValue + 1), + unit: payload.unit || "项", + metadata: { entitlement: payload } + }; + } + if (requestType === "external_connector") { + const connector = targetKey ? dbGet("SELECT * FROM model_connectors WHERE organization_id = ? AND id = ?", [context.organization.id, targetKey]) : null; + if (connector) { + return { + targetKey: connector.id, + targetLabel: connector.label, + currentValue: connector.cost_mode === "local" ? 0 : 1, + requestedValue: Number(body.requestedValue ?? body.requested_value ?? 1), + unit: connector.cost_mode || "连接器", + metadata: { connectorId: connector.id, costMode: connector.cost_mode, endpoint: connector.endpoint, secretStored: false } + }; + } + const featureKey = targetKey || "feature.external_cloud_connectors"; + const entitlement = dbGet("SELECT * FROM organization_entitlements WHERE organization_id = ? AND entitlement_key = ?", [context.organization.id, featureKey]); + if (!entitlement) throw httpError(400, "external_connector_target_invalid", "外部连接器申请目标不存在", { targetKey: featureKey }); + const payload = entitlementPayload(entitlement, context.organization.id); + return { + targetKey: featureKey, + targetLabel: payload.label, + currentValue: payload.enabled ? 1 : 0, + requestedValue: 1, + unit: payload.unit || "开关", + metadata: { entitlement: payload, paidCloudRequiresApproval: true } + }; + } + if (requestType === "budget_increase") { + const costCenter = dbGet("SELECT * FROM cost_centers WHERE organization_id = ? AND (id = ? OR code = ?)", [context.organization.id, targetKey, targetKey]); + if (!costCenter) throw httpError(400, "cost_center_target_invalid", "成本中心不存在", { targetKey }); + return { + targetKey: costCenter.code, + targetLabel: costCenter.name, + currentValue: Number(costCenter.monthly_budget || 0), + requestedValue: Number(body.requestedValue ?? body.requested_value ?? Number(costCenter.monthly_budget || 0) + 100), + unit: costCenter.currency || "CNY", + metadata: { costCenterId: costCenter.id, code: costCenter.code } + }; + } + const fallbackProjectId = body.projectId || body.project_id || context.project?.id || ""; + return { + targetKey: targetKey || body.policyKey || body.policy_key || (requestType === "compliance_review" ? "commercial-rights-review" : requestType), + targetLabel: body.subjectLabel || body.subject_label || fallbackProjectId || "组织策略", + currentValue: Number(body.currentValue ?? body.current_value ?? 0), + requestedValue: Number(body.requestedValue ?? body.requested_value ?? 1), + unit: body.unit || "次", + metadata: { + subjectType: body.subjectType || body.subject_type || (requestType === "compliance_review" ? "project" : "policy"), + subjectId: body.subjectId || body.subject_id || fallbackProjectId + } + }; +} + +function commercialApprovalScope(context, body = {}) { + let workspaceId = body.workspaceId || body.workspace_id || context.workspace?.id || null; + let projectId = body.projectId || body.project_id || context.project?.id || null; + if (projectId) { + const project = dbGet( + `SELECT p.id, p.workspace_id + FROM projects p + JOIN workspaces w ON w.id = p.workspace_id + WHERE p.id = ? AND w.organization_id = ?`, + [projectId, context.organization.id] + ); + if (!project) throw httpError(403, "approval_project_scope_mismatch", "申请绑定项目不属于当前组织", { projectId }); + projectId = project.id; + workspaceId = workspaceId || project.workspace_id; + } + if (workspaceId) { + const workspace = dbGet("SELECT id FROM workspaces WHERE id = ? AND organization_id = ?", [workspaceId, context.organization.id]); + if (!workspace) throw httpError(403, "approval_workspace_scope_mismatch", "申请绑定工作区不属于当前组织", { workspaceId }); + } + return { workspaceId, projectId }; +} + +function commercialApprovalPayload(row) { + if (!row) return null; + return { + id: row.id, + organizationId: row.organization_id, + workspaceId: row.workspace_id, + workspaceName: row.workspace_name || "", + projectId: row.project_id, + projectName: row.project_name || "", + requestType: row.request_type, + requestTypeLabel: COMMERCIAL_APPROVAL_TYPES[row.request_type]?.label || row.request_type, + status: row.status, + priority: row.priority, + targetKey: row.target_key, + currentValue: Number(row.current_value || 0), + requestedValue: Number(row.requested_value || 0), + unit: row.unit || "", + businessReason: row.business_reason || "", + riskAssessment: parseJson(row.risk_assessment_json, {}), + evidence: parseJson(row.evidence_json, {}), + decisionNote: row.decision_note || "", + effect: parseJson(row.effect_json, {}), + requester: { + id: row.requester_user_id, + name: row.requester_name || row.requester_user_id, + email: row.requester_email || "" + }, + reviewer: row.reviewer_user_id ? { + id: row.reviewer_user_id, + name: row.reviewer_name || row.reviewer_user_id, + email: row.reviewer_email || "" + } : null, + reviewedAt: row.reviewed_at, + expiresAt: row.expires_at, + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +function commercialApprovalRows(organizationId, clauses = [], params = [], limit = 80) { + return dbAll( + `SELECT car.*, w.name AS workspace_name, p.name AS project_name, + requester.display_name AS requester_name, requester.email AS requester_email, + reviewer.display_name AS reviewer_name, reviewer.email AS reviewer_email + FROM commercial_approval_requests car + LEFT JOIN workspaces w ON w.id = car.workspace_id + LEFT JOIN projects p ON p.id = car.project_id + LEFT JOIN users requester ON requester.id = car.requester_user_id + LEFT JOIN users reviewer ON reviewer.id = car.reviewer_user_id + WHERE car.organization_id = ? ${clauses.length ? `AND ${clauses.join(" AND ")}` : ""} + ORDER BY CASE car.status WHEN 'submitted' THEN 0 ELSE 1 END, + CASE car.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'medium' THEN 2 ELSE 3 END, + car.updated_at DESC + LIMIT ?`, + [organizationId, ...params, Math.min(200, Math.max(1, Number(limit || 80)))] + ).map(commercialApprovalPayload); +} + +function commercialApprovalSummary(requests = []) { + return { + total: requests.length, + submitted: requests.filter((request) => request.status === "submitted").length, + approved: requests.filter((request) => request.status === "approved").length, + rejected: requests.filter((request) => request.status === "rejected").length, + urgent: requests.filter((request) => request.priority === "urgent" && request.status === "submitted").length + }; +} + +export function listCommercialApprovalRequests(context, organizationId, options = {}) { + if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能查看其他组织的商业审批", { organizationId }); + const clauses = []; + const params = []; + const status = String(options.status || "").trim(); + if (status) { + if (!["submitted", "approved", "rejected", "cancelled"].includes(status)) throw httpError(400, "approval_status_invalid", "审批状态参数无效"); + clauses.push("car.status = ?"); + params.push(status); + } + if (String(options.type || "").trim()) { + clauses.push("car.request_type = ?"); + params.push(normalizedApprovalType(options.type)); + } + const viewAll = canViewCommercialApprovalQueue(context) && options.mine !== true && String(options.mine || "") !== "1"; + if (!viewAll) { + clauses.push("car.requester_user_id = ?"); + params.push(context.user.id); + } + const requests = commercialApprovalRows(organizationId, clauses, params, options.limit || 80); + return { + requests, + summary: commercialApprovalSummary(requests), + catalog: commercialApprovalCatalog(), + canReviewTypes: Object.keys(COMMERCIAL_APPROVAL_TYPES).filter((type) => canReviewCommercialApproval(context, type)) + }; +} + +export function createCommercialApprovalRequest(context, organizationId, body = {}) { + if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能向其他组织提交商业审批", { organizationId }); + const requestType = normalizedApprovalType(body.requestType || body.request_type); + const target = commercialApprovalTargetSnapshot(context, requestType, body); + const requestedValue = Number(target.requestedValue); + if (!Number.isFinite(requestedValue) || requestedValue < 0) throw httpError(400, "approval_requested_value_invalid", "申请目标值必须是非负数字"); + const businessReason = String(body.businessReason || body.business_reason || "").trim().slice(0, 1200); + if (businessReason.length < 6) throw httpError(400, "approval_reason_required", "请填写申请原因,至少 6 个字符"); + const title = String(body.title || `${COMMERCIAL_APPROVAL_TYPES[requestType].label} · ${target.targetLabel}`).trim().slice(0, 160); + const { workspaceId, projectId } = commercialApprovalScope(context, body); + const riskAssessment = { + localModelFirst: true, + paidCloudRequiresExplicitApproval: true, + singleFrameOnly: true, + continuityLedgerRequired: true, + fixedVoiceEvidenceRequired: true, + ...target.metadata, + ...objectValue(body.riskAssessment || body.risk_assessment) + }; + const evidence = { + source: "manual-request", + ...objectValue(body.evidence), + targetLabel: target.targetLabel + }; + const timestamp = new Date().toISOString(); + const id = `commercial-approval-${Date.now()}-${randomBytes(4).toString("hex")}`; + dbRun( + `INSERT 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, + requester_user_id, created_at, updated_at, expires_at + ) VALUES (?, ?, ?, ?, ?, ?, 'submitted', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + id, + organizationId, + workspaceId, + projectId, + requestType, + title, + normalizedApprovalPriority(body.priority), + target.targetKey, + Number(target.currentValue || 0), + requestedValue, + target.unit, + businessReason, + JSON.stringify(riskAssessment), + JSON.stringify(evidence), + context.user.id, + timestamp, + timestamp, + body.expiresAt || body.expires_at || null + ] + ); + addAudit({ context, action: "commercial.approval.submitted", targetType: "commercial_approval_request", targetId: id, metadata: { requestType, targetKey: target.targetKey, requestedValue, unit: target.unit } }); + return { + request: commercialApprovalRows(organizationId, ["car.id = ?"], [id], 1)[0], + ...listCommercialApprovalRequests(context, organizationId, { limit: 80 }) + }; +} + +function applyCommercialApprovalEffect(context, row, decisionNote) { + const requestType = row.request_type; + const targetKey = row.target_key; + const requestedValue = Number(row.requested_value || 0); + const timestamp = new Date().toISOString(); + if (["entitlement_overage", "feature_enablement"].includes(requestType)) { + const current = dbGet("SELECT * FROM organization_entitlements WHERE organization_id = ? AND entitlement_key = ?", [row.organization_id, targetKey]); + if (!current) throw httpError(404, "entitlement_not_found", "审批目标权益不存在", { targetKey }); + const isFeature = targetKey.startsWith("feature."); + const usedValue = entitlementUsageValue(row.organization_id, targetKey); + const nextLimit = isFeature ? Math.max(1, requestedValue || Number(current.limit_value || 1)) : Math.max(requestedValue, Number(current.limit_value || 0), usedValue); + dbRun( + "UPDATE organization_entitlements SET limit_value = ?, enabled = 1, source = 'override', override_reason = ?, updated_at = ? WHERE organization_id = ? AND entitlement_key = ?", + [nextLimit, decisionNote || `审批通过:${row.title}`, timestamp, row.organization_id, targetKey] + ); + return { applied: true, kind: isFeature ? "feature_enabled" : "entitlement_overridden", entitlementKey: targetKey, previousLimit: Number(current.limit_value || 0), nextLimit, usedValue }; + } + if (requestType === "external_connector") { + const connector = dbGet("SELECT * FROM model_connectors WHERE organization_id = ? AND id = ?", [row.organization_id, targetKey]); + if (connector) { + dbRun("UPDATE model_connectors SET approval_required = 1, updated_at = ? WHERE id = ? AND organization_id = ?", [timestamp, connector.id, row.organization_id]); + return { applied: true, kind: "connector_marked_approval_required", connectorId: connector.id, costMode: connector.cost_mode }; + } + const current = dbGet("SELECT * FROM organization_entitlements WHERE organization_id = ? AND entitlement_key = ?", [row.organization_id, targetKey || "feature.external_cloud_connectors"]); + if (current) { + dbRun( + "UPDATE organization_entitlements SET enabled = 1, limit_value = MAX(limit_value, 1), source = 'override', override_reason = ?, updated_at = ? WHERE organization_id = ? AND entitlement_key = ?", + [decisionNote || `审批通过:${row.title}`, timestamp, row.organization_id, current.entitlement_key] + ); + return { applied: true, kind: "external_connector_feature_enabled", entitlementKey: current.entitlement_key, paidCloudRequiresApproval: true }; + } + return { applied: false, kind: "external_connector_audit_only", targetKey }; + } + if (requestType === "budget_increase") { + const current = dbGet("SELECT * FROM cost_centers WHERE organization_id = ? AND (id = ? OR code = ?)", [row.organization_id, targetKey, targetKey]); + if (!current) throw httpError(404, "cost_center_not_found", "审批目标成本中心不存在", { targetKey }); + const nextBudget = Math.max(Number(current.monthly_budget || 0), requestedValue); + dbRun("UPDATE cost_centers SET monthly_budget = ?, updated_at = ? WHERE id = ? AND organization_id = ?", [nextBudget, timestamp, current.id, row.organization_id]); + return { applied: true, kind: "cost_center_budget_updated", costCenterId: current.id, code: current.code, previousBudget: Number(current.monthly_budget || 0), nextBudget }; + } + if (requestType === "compliance_review") { + const evidence = parseJson(row.evidence_json, {}); + const risk = parseJson(row.risk_assessment_json, {}); + const subjectType = evidence.subjectType || risk.subjectType || "project"; + const subjectId = evidence.subjectId || risk.subjectId || row.project_id || row.workspace_id || row.organization_id; + const recordId = `commercial-compliance-${row.id}`; + dbRun( + `INSERT 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 (?, ?, ?, ?, ?, ?, ?, 'approved', ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET status = 'approved', evidence_json = excluded.evidence_json, reviewed_by = excluded.reviewed_by, reviewed_at = excluded.reviewed_at, updated_at = excluded.updated_at`, + [recordId, row.organization_id, row.workspace_id, row.project_id, subjectType, subjectId, targetKey || "commercial-rights-review", JSON.stringify({ ...evidence, approvalRequestId: row.id }), context.user.id, timestamp, timestamp, timestamp] + ); + return { applied: true, kind: "compliance_record_approved", complianceRecordId: recordId, subjectType, subjectId }; + } + return { applied: true, kind: "audit_only", requestType, targetKey }; +} + +export function decideCommercialApprovalRequest(context, organizationId, approvalId, body = {}) { + if (context.organization.id !== organizationId) throw httpError(403, "organization_forbidden", "不能审批其他组织的商业申请", { organizationId }); + const current = dbGet("SELECT * FROM commercial_approval_requests WHERE id = ? AND organization_id = ?", [approvalId, organizationId]); + if (!current) throw httpError(404, "commercial_approval_not_found", "商业治理申请不存在", { approvalId }); + const decision = String(body.decision || body.status || "").trim(); + if (!["approved", "rejected", "cancelled"].includes(decision)) throw httpError(400, "commercial_approval_decision_invalid", "审批结论只能是 approved、rejected 或 cancelled"); + const requesterCancelling = decision === "cancelled" && current.requester_user_id === context.user.id; + if (!requesterCancelling && !canReviewCommercialApproval(context, current.request_type)) { + throw httpError(403, "commercial_approval_review_denied", "当前角色没有审批此类商业治理申请的权限", { + requestType: current.request_type, + requiredPermissions: COMMERCIAL_APPROVAL_TYPES[current.request_type]?.reviewerPermissions || [] + }); + } + if (current.status !== "submitted") throw httpError(409, "commercial_approval_already_decided", "该商业治理申请已经处理,不能重复审批", { status: current.status }); + const decisionNote = String(body.decisionNote || body.decision_note || "").trim().slice(0, 1200); + const timestamp = new Date().toISOString(); + const effect = decision === "approved" ? applyCommercialApprovalEffect(context, current, decisionNote) : { applied: false, kind: decision }; + dbRun( + "UPDATE commercial_approval_requests SET status = ?, reviewer_user_id = ?, reviewed_at = ?, decision_note = ?, effect_json = ?, updated_at = ? WHERE id = ? AND organization_id = ?", + [decision, requesterCancelling ? null : context.user.id, requesterCancelling ? null : timestamp, decisionNote, JSON.stringify(effect), timestamp, approvalId, organizationId] + ); + addAudit({ context, action: `commercial.approval.${decision}`, targetType: "commercial_approval_request", targetId: approvalId, metadata: { requestType: current.request_type, targetKey: current.target_key, effect } }); + const payload = { + request: commercialApprovalRows(organizationId, ["car.id = ?"], [approvalId], 1)[0], + ...listCommercialApprovalRequests(context, organizationId, { limit: 80 }) + }; + if (hasAnyPermission(context, ["usage:view", "billing:manage", "quota:manage"])) payload.commercial = organizationCommercial(context); + return payload; +} + export function organizationCommercial(context) { if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage") && !hasPermission(context, "quota:manage")) { throw httpError(403, "permission_denied", "当前角色没有查看组织商业运营数据的权限"); @@ -720,6 +1434,8 @@ export function organizationCommercial(context) { const usage = usageSummary(context); usage.quotas = quotas; const costCenters = organizationCostCenters(context.organization.id); + const entitlementState = organizationEntitlements(context.organization.id); + const commercialApprovalState = listCommercialApprovalRequests(context, context.organization.id, { limit: 80 }); return { organization: context.organization, billing, @@ -731,6 +1447,13 @@ export function organizationCommercial(context) { costCenterDetail: costCenters.detail, usageTrend: organizationUsageTrend(context.organization.id), quotaWarnings: quotaWarnings(context.organization.id, billing, quotas, seat), + entitlements: entitlementState.entitlements, + entitlementSummary: entitlementState.summary, + planTemplates: entitlementState.planTemplates, + commercialApprovals: commercialApprovalState.requests, + commercialApprovalSummary: commercialApprovalState.summary, + commercialApprovalCatalog: commercialApprovalState.catalog, + commercialApprovalReviewTypes: commercialApprovalState.canReviewTypes, workspaces: dbAll("SELECT id, name, slug, status FROM workspaces WHERE organization_id = ? ORDER BY name", [context.organization.id]) }; } @@ -769,6 +1492,7 @@ export function updateOrganizationBilling(context, organizationId, body = {}) { const previous = { planName: current.plan_name, billingCycle: current.billing_cycle || "monthly", currency: current.currency || "CNY", baseFee: Number(current.base_fee || 0), seatUnitPrice: Number(current.seat_unit_price || 0), storageUnitPrice: Number(current.storage_unit_price || 0), clipUnitPrice: Number(current.clip_unit_price || 0), seatLimit: current.seat_limit, storageGb: current.storage_gb, monthlyClipQuota: current.monthly_clip_quota, quotaWarningPercent: Number(current.quota_warning_percent || 80), localRunnerOnly: Boolean(current.local_runner_only), cloudApproval: Boolean(current.cloud_connectors_require_approval) }; const next = { planName, billingCycle, currency, baseFee, seatUnitPrice, storageUnitPrice, clipUnitPrice, seatLimit, storageGb, monthlyClipQuota, quotaWarningPercent, localRunnerOnly, cloudApproval }; dbRun("UPDATE billing_accounts SET plan_name = ?, billing_cycle = ?, currency = ?, base_fee = ?, seat_unit_price = ?, storage_unit_price = ?, clip_unit_price = ?, seat_limit = ?, storage_gb = ?, monthly_clip_quota = ?, quota_warning_percent = ?, local_runner_only = ?, cloud_connectors_require_approval = ?, updated_at = ? WHERE organization_id = ?", [planName, billingCycle, currency, baseFee, seatUnitPrice, storageUnitPrice, clipUnitPrice, seatLimit, storageGb, monthlyClipQuota, quotaWarningPercent, localRunnerOnly ? 1 : 0, cloudApproval ? 1 : 0, timestamp, organizationId]); + syncBillingEntitlements(organizationId, { seatLimit, storageGb, monthlyClipQuota }, timestamp); dbRun("INSERT INTO billing_account_events(id, organization_id, billing_account_id, event_type, previous_json, next_json, actor_user_id, created_at) VALUES (?, ?, ?, 'plan.updated', ?, ?, ?, ?)", [`bill-event-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, organizationId, current.id, JSON.stringify(previous), JSON.stringify(next), context.user.id, timestamp]); addAudit({ context, action: "billing.account.updated", targetType: "billing_account", targetId: current.id, metadata: { previous, next } }); return organizationCommercial(context); @@ -1630,13 +2354,242 @@ export function scopedModels(context) { WHERE organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?) ORDER BY created_at DESC`, [context.organization.id, context.workspace.id] - ).map((model) => ({ - ...model, - capability: parseJson(model.capabilities_json, []), - protocol: parseJson(model.protocol_json, {}), - approvalRequired: Boolean(model.approval_required), - costMode: model.cost_mode - })); + ).map(parseModelRow); +} + +function parseModelCatalogRow(row) { + if (!row) return null; + return { + ...row, + connectorId: row.connector_id || "", + connectorLabel: row.connector_label || "", + connectorKind: row.connector_kind || "", + connectorStatus: row.connector_status || "", + capabilities: parseJson(row.capabilities_json, []), + contextWindow: Number(row.context_window || 0), + maxOutputTokens: Number(row.max_output_tokens || 0), + cost: parseJson(row.cost_json, {}), + metadata: parseJson(row.metadata_json, {}) + }; +} + +function scopedModelCatalogRows(context) { + return dbAll( + `SELECT mce.*, mc.label AS connector_label, mc.kind AS connector_kind, mc.status AS connector_status + FROM model_catalog_entries mce + LEFT JOIN model_connectors mc ON mc.id = mce.connector_id + WHERE mce.organization_id = ? AND (mce.workspace_id IS NULL OR mce.workspace_id = ?) + ORDER BY mce.updated_at DESC, mce.created_at DESC`, + [context.organization.id, context.workspace.id] + ); +} + +export function scopedModelCatalog(context) { + return scopedModelCatalogRows(context).map(parseModelCatalogRow); +} + +function ensureScopedConnector(context, connectorId, { allowEmpty = false } = {}) { + const normalized = String(connectorId || "").trim(); + if (!normalized) { + if (allowEmpty) return null; + throw httpError(400, "connector_required", "模型目录必须关联一个连接器"); + } + const connector = dbGet( + `SELECT * FROM model_connectors + WHERE id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)`, + [normalized, context.organization.id, context.workspace.id] + ); + if (!connector) throw httpError(404, "model_connector_not_found", "连接器不存在或不属于当前工作区", { connectorId: normalized }); + return connector; +} + +function ensureScopedModelCatalogEntry(context, modelId, { allowEmpty = false } = {}) { + const normalized = String(modelId || "").trim(); + if (!normalized) { + if (allowEmpty) return null; + throw httpError(400, "model_catalog_entry_required", "必须指定模型目录条目"); + } + const row = dbGet( + `SELECT * FROM model_catalog_entries + WHERE id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)`, + [normalized, context.organization.id, context.workspace.id] + ); + if (!row) throw httpError(404, "model_catalog_entry_not_found", "模型目录条目不存在或不属于当前工作区", { modelId: normalized }); + return row; +} + +function normalizeCatalogCapabilities(value) { + if (Array.isArray(value)) return value.map((item) => String(item || "").trim()).filter(Boolean); + return String(value || "").split(",").map((item) => item.trim()).filter(Boolean); +} + +export function createModelCatalogEntry(context, body = {}) { + requirePermission(context, "model:manage"); + const connector = ensureScopedConnector(context, body.connectorId || body.connector_id); + const displayName = String(body.displayName || body.display_name || "").trim(); + const modelKey = String(body.modelKey || body.model_key || "").trim(); + if (!displayName || !modelKey) throw httpError(400, "model_catalog_fields_required", "模型名称和模型 Key 不能为空"); + const family = String(body.family || "").trim(); + const capabilities = normalizeCatalogCapabilities(body.capabilities || body.capability); + const status = String(body.status || "active").trim(); + const approvalStatus = String(body.approvalStatus || body.approval_status || "approved").trim(); + if (!["draft", "active", "paused", "review", "archived"].includes(status)) throw httpError(400, "model_catalog_status_invalid", "模型目录状态无效"); + if (!["approved", "review", "blocked", "pending"].includes(approvalStatus)) throw httpError(400, "model_catalog_approval_invalid", "模型审批状态无效"); + const contextWindow = Math.max(0, Number(body.contextWindow || body.context_window || 0)); + const maxOutputTokens = Math.max(0, Number(body.maxOutputTokens || body.max_output_tokens || 0)); + const cost = body.cost && typeof body.cost === "object" ? body.cost : {}; + const metadata = body.metadata && typeof body.metadata === "object" ? body.metadata : {}; + const timestamp = new Date().toISOString(); + const id = String(body.id || `catalog-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`); + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [id, context.organization.id, context.workspace.id, connector.id, modelKey, displayName, family, JSON.stringify(capabilities), contextWindow, maxOutputTokens, JSON.stringify(cost), status, approvalStatus, JSON.stringify(metadata), context.user.id, timestamp, timestamp] + ); + addAudit({ context, action: "model.catalog.created", targetType: "model_catalog_entry", targetId: id, metadata: { connectorId: connector.id, modelKey, displayName } }); + return parseModelCatalogRow(dbGet( + `SELECT mce.*, mc.label AS connector_label, mc.kind AS connector_kind, mc.status AS connector_status + FROM model_catalog_entries mce + LEFT JOIN model_connectors mc ON mc.id = mce.connector_id + WHERE mce.id = ?`, + [id] + )); +} + +export function updateModelCatalogEntry(context, entryId, body = {}) { + requirePermission(context, "model:manage"); + const current = ensureScopedModelCatalogEntry(context, entryId); + const connector = body.connectorId === undefined && body.connector_id === undefined + ? ensureScopedConnector(context, current.connector_id) + : ensureScopedConnector(context, body.connectorId || body.connector_id); + const displayName = String(body.displayName ?? body.display_name ?? current.display_name).trim(); + const modelKey = String(body.modelKey ?? body.model_key ?? current.model_key).trim(); + if (!displayName || !modelKey) throw httpError(400, "model_catalog_fields_required", "模型名称和模型 Key 不能为空"); + const family = String(body.family ?? current.family ?? "").trim(); + const capabilities = body.capabilities === undefined && body.capability === undefined ? parseJson(current.capabilities_json, []) : normalizeCatalogCapabilities(body.capabilities || body.capability); + const status = String(body.status ?? current.status ?? "active").trim(); + const approvalStatus = String(body.approvalStatus ?? body.approval_status ?? current.approval_status ?? "approved").trim(); + if (!["draft", "active", "paused", "review", "archived"].includes(status)) throw httpError(400, "model_catalog_status_invalid", "模型目录状态无效"); + if (!["approved", "review", "blocked", "pending"].includes(approvalStatus)) throw httpError(400, "model_catalog_approval_invalid", "模型审批状态无效"); + const contextWindow = Math.max(0, Number(body.contextWindow ?? body.context_window ?? current.context_window ?? 0)); + const maxOutputTokens = Math.max(0, Number(body.maxOutputTokens ?? body.max_output_tokens ?? current.max_output_tokens ?? 0)); + const cost = body.cost === undefined ? parseJson(current.cost_json, {}) : (body.cost && typeof body.cost === "object" ? body.cost : {}); + const metadata = body.metadata === undefined ? parseJson(current.metadata_json, {}) : (body.metadata && typeof body.metadata === "object" ? body.metadata : {}); + const timestamp = new Date().toISOString(); + dbRun( + `UPDATE model_catalog_entries + SET connector_id = ?, model_key = ?, display_name = ?, family = ?, capabilities_json = ?, + context_window = ?, max_output_tokens = ?, cost_json = ?, status = ?, approval_status = ?, + metadata_json = ?, updated_at = ? + WHERE id = ?`, + [connector.id, modelKey, displayName, family, JSON.stringify(capabilities), contextWindow, maxOutputTokens, JSON.stringify(cost), status, approvalStatus, JSON.stringify(metadata), timestamp, entryId] + ); + addAudit({ context, action: "model.catalog.updated", targetType: "model_catalog_entry", targetId: entryId, metadata: { connectorId: connector.id, modelKey, displayName } }); + return parseModelCatalogRow(dbGet( + `SELECT mce.*, mc.label AS connector_label, mc.kind AS connector_kind, mc.status AS connector_status + FROM model_catalog_entries mce + LEFT JOIN model_connectors mc ON mc.id = mce.connector_id + WHERE mce.id = ?`, + [entryId] + )); +} + +function parseModelRouteRow(row) { + if (!row) return null; + return { + ...row, + primaryModelId: row.primary_model_id || "", + fallbackModelId: row.fallback_model_id || "", + budgetLimitCny: Number(row.budget_limit_cny || 0), + policy: parseJson(row.policy_json, {}), + primaryModelLabel: row.primary_model_label || "", + fallbackModelLabel: row.fallback_model_label || "" + }; +} + +export function scopedModelRoutes(context) { + return dbAll( + `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 mrp.organization_id = ? AND (mrp.workspace_id IS NULL OR mrp.workspace_id = ?) + ORDER BY mrp.updated_at DESC, mrp.created_at DESC`, + [context.organization.id, context.workspace.id] + ).map(parseModelRouteRow); +} + +export function createModelRoute(context, body = {}) { + requirePermission(context, "model:manage"); + const name = String(body.name || "").trim(); + const workflowKey = String(body.workflowKey || body.workflow_key || "").trim(); + const operationKey = String(body.operationKey || body.operation_key || "").trim(); + if (!name || !workflowKey || !operationKey) throw httpError(400, "model_route_fields_required", "路由名称、工作流和操作不能为空"); + const primary = ensureScopedModelCatalogEntry(context, body.primaryModelId || body.primary_model_id); + const fallback = ensureScopedModelCatalogEntry(context, body.fallbackModelId || body.fallback_model_id, { allowEmpty: true }); + const policyMode = String(body.policyMode || body.policy_mode || "prefer-local").trim(); + const approvalMode = String(body.approvalMode || body.approval_mode || "follow-model").trim(); + const status = String(body.status || "active").trim(); + if (!["prefer-local", "local-only", "prefer-approved", "manual-select"].includes(policyMode)) throw httpError(400, "model_route_policy_mode_invalid", "路由策略无效"); + if (!["follow-model", "explicit-review", "always-allow"].includes(approvalMode)) throw httpError(400, "model_route_approval_mode_invalid", "审批模式无效"); + if (!["draft", "active", "paused", "archived"].includes(status)) throw httpError(400, "model_route_status_invalid", "路由状态无效"); + const budgetLimitCny = Math.max(0, Number(body.budgetLimitCny || body.budget_limit_cny || 0)); + const policy = body.policy && typeof body.policy === "object" ? body.policy : {}; + const timestamp = new Date().toISOString(); + const id = String(body.id || `route-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`); + 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [id, context.organization.id, context.workspace.id, name, workflowKey, operationKey, primary.id, fallback?.id || null, policyMode, approvalMode, budgetLimitCny, status, JSON.stringify(policy), context.user.id, timestamp, timestamp] + ); + addAudit({ context, action: "model.route.created", targetType: "model_routing_policy", targetId: id, metadata: { name, workflowKey, operationKey, primaryModelId: primary.id, fallbackModelId: fallback?.id || null } }); + return scopedModelRoutes(context).find((item) => item.id === id) || null; +} + +export function updateModelRoute(context, routeId, body = {}) { + requirePermission(context, "model:manage"); + const current = dbGet( + `SELECT * FROM model_routing_policies + WHERE id = ? AND organization_id = ? AND (workspace_id IS NULL OR workspace_id = ?)`, + [routeId, context.organization.id, context.workspace.id] + ); + if (!current) throw httpError(404, "model_route_not_found", "路由策略不存在或不属于当前工作区", { routeId }); + const name = String(body.name ?? current.name ?? "").trim(); + const workflowKey = String(body.workflowKey ?? body.workflow_key ?? current.workflow_key ?? "").trim(); + const operationKey = String(body.operationKey ?? body.operation_key ?? current.operation_key ?? "").trim(); + if (!name || !workflowKey || !operationKey) throw httpError(400, "model_route_fields_required", "路由名称、工作流和操作不能为空"); + const primary = body.primaryModelId === undefined && body.primary_model_id === undefined + ? ensureScopedModelCatalogEntry(context, current.primary_model_id) + : ensureScopedModelCatalogEntry(context, body.primaryModelId || body.primary_model_id); + const fallback = body.fallbackModelId === undefined && body.fallback_model_id === undefined + ? ensureScopedModelCatalogEntry(context, current.fallback_model_id, { allowEmpty: true }) + : ensureScopedModelCatalogEntry(context, body.fallbackModelId || body.fallback_model_id, { allowEmpty: true }); + const policyMode = String(body.policyMode ?? body.policy_mode ?? current.policy_mode ?? "prefer-local").trim(); + const approvalMode = String(body.approvalMode ?? body.approval_mode ?? current.approval_mode ?? "follow-model").trim(); + const status = String(body.status ?? current.status ?? "active").trim(); + if (!["prefer-local", "local-only", "prefer-approved", "manual-select"].includes(policyMode)) throw httpError(400, "model_route_policy_mode_invalid", "路由策略无效"); + if (!["follow-model", "explicit-review", "always-allow"].includes(approvalMode)) throw httpError(400, "model_route_approval_mode_invalid", "审批模式无效"); + if (!["draft", "active", "paused", "archived"].includes(status)) throw httpError(400, "model_route_status_invalid", "路由状态无效"); + const budgetLimitCny = Math.max(0, Number(body.budgetLimitCny ?? body.budget_limit_cny ?? current.budget_limit_cny ?? 0)); + const policy = body.policy === undefined ? parseJson(current.policy_json, {}) : (body.policy && typeof body.policy === "object" ? body.policy : {}); + const timestamp = new Date().toISOString(); + dbRun( + `UPDATE model_routing_policies + SET name = ?, workflow_key = ?, operation_key = ?, primary_model_id = ?, fallback_model_id = ?, + policy_mode = ?, approval_mode = ?, budget_limit_cny = ?, status = ?, policy_json = ?, updated_at = ? + WHERE id = ?`, + [name, workflowKey, operationKey, primary.id, fallback?.id || null, policyMode, approvalMode, budgetLimitCny, status, JSON.stringify(policy), timestamp, routeId] + ); + addAudit({ context, action: "model.route.updated", targetType: "model_routing_policy", targetId: routeId, metadata: { name, workflowKey, operationKey, primaryModelId: primary.id, fallbackModelId: fallback?.id || null } }); + return scopedModelRoutes(context).find((item) => item.id === routeId) || null; } function rolePolicyRows(organizationId) { @@ -1707,6 +2660,179 @@ export function updateOrganizationRolePolicy(context, organizationId, roleKey, b return organizationRolePolicies(organizationId); } +const ASSET_RIGHTS_STATUSES = new Set(["needs-evidence", "submitted", "approved", "rejected", "expired"]); +const ASSET_LAYOUT_TERMS = ["split-screen", "split screen", "comic panel", "comic panels", "collage", "contact sheet", "storyboard", "story board", "多格", "拼图", "分屏", "故事板", "故事板拼图", "九宫格"]; +const ASSET_IP_TERMS = ["迪士尼", "漫威", "蜘蛛侠", "蝙蝠侠", "米老鼠", "奥特曼", "火影", "海贼王", "柯南", "原神", "王者荣耀", "三体", "流浪地球", "哈利波特", "宫崎骏", "吉卜力"]; +const ASSET_LIKENESS_TERMS = ["明星", "名人", "演员本人", "真人肖像", "真人照片", "网红", "博主本人", "face clone", "deepfake", "voice clone", "声音克隆", "复刻声线"]; + +function objectValue(value, fallback = {}) { + return value && typeof value === "object" && !Array.isArray(value) ? value : fallback; +} + +function stringArray(value, fallback = []) { + const source = Array.isArray(value) ? value : fallback; + return [...new Set(source.map((item) => String(item || "").trim()).filter(Boolean))].slice(0, 24); +} + +function mergeAssetTags(...sources) { + return [...new Set(sources.flatMap((source) => stringArray(source)))].slice(0, 24); +} + +function evidenceReference(evidence = {}, metadata = {}, provenance = {}) { + const source = objectValue(evidence); + const currentEvidence = objectValue(metadata?.rightsEvidence); + return String( + source.reference + || source.consentRef + || source.contractRef + || source.licenseRef + || source.evidenceRef + || currentEvidence.reference + || currentEvidence.consentRef + || currentEvidence.contractRef + || currentEvidence.licenseRef + || metadata?.consentRef + || provenance.evidenceRef + || "" + ).trim(); +} + +function assetGovernanceText(asset, version, metadata, provenance, tags) { + return [ + asset?.kind, + asset?.name, + version?.storage_path, + version?.file_name, + metadata.subtitle, + metadata.usage, + metadata.detail, + metadata.lock, + metadata.visualLock, + metadata.continuityLock, + provenance.sourceType, + provenance.sourceName, + provenance.sourceRef, + provenance.creator, + tags.join(" ") + ].filter(Boolean).join("\n"); +} + +function contextNegatesTerm(text, term) { + const normalized = text.toLowerCase(); + const target = term.toLowerCase(); + let index = normalized.indexOf(target); + while (index >= 0) { + const before = normalized.slice(Math.max(0, index - 16), index); + if (!/(禁止|不得|不要|不能|避免|严禁|排除|negative|block|no\s*$|without\s*$)/i.test(before)) return false; + index = normalized.indexOf(target, index + target.length); + } + return true; +} + +function matchingRiskTerms(text, terms, { ignoreNegated = false } = {}) { + return terms.filter((term) => { + const present = text.toLowerCase().includes(term.toLowerCase()); + if (!present) return false; + return ignoreNegated ? !contextNegatesTerm(text, term) : true; + }); +} + +function buildAssetGovernanceScan(asset, version, proposal = {}) { + const metadata = { + ...parseJson(version?.metadata_json, {}), + ...objectValue(proposal.metadata) + }; + const provenance = { + ...objectValue(parseJson(version?.provenance_json, {})), + ...objectValue(metadata.provenance), + ...objectValue(proposal.provenance) + }; + const tags = mergeAssetTags(parseJson(version?.tags_json, []), metadata.tags, proposal.tags); + const evidence = objectValue(proposal.evidence); + const rightsStatus = String(proposal.rightsStatus || proposal.rights_status || version?.rights_status || "needs-evidence").trim(); + const evidenceRef = evidenceReference(evidence, metadata, provenance); + const text = assetGovernanceText(asset, version, metadata, provenance, tags); + const issues = []; + const missingSource = !String(provenance.sourceType || provenance.sourceName || provenance.sourceRef || "").trim(); + if (missingSource) { + issues.push({ code: "provenance_missing", severity: "review", label: "缺少来源", message: "资产未登记原创、委托、授权或本地导入来源。" }); + } + if (!evidenceRef) { + issues.push({ code: "rights_evidence_missing", severity: rightsStatus === "approved" ? "blocking" : "review", label: "缺少授权证据", message: "商业使用前必须绑定合同、授权书、同意书或本地证据路径。" }); + } + if (asset?.kind === "voice" && !evidenceRef) { + issues.push({ code: "voice_consent_missing", severity: rightsStatus === "approved" ? "blocking" : "review", label: "声音同意书缺失", message: "固定参考音频必须有可追溯授权,不使用随机原生声线作为最终方案。" }); + } + const layoutHits = matchingRiskTerms(text, ASSET_LAYOUT_TERMS, { ignoreNegated: true }); + if (layoutHits.length) { + issues.push({ code: "single_frame_layout_risk", severity: "blocking", label: "一图多画面风险", message: `检测到 ${layoutHits.join("、")},生产资产不得是分屏、多格、拼图或故事板。` }); + } + const ipHits = matchingRiskTerms(text, ASSET_IP_TERMS); + if (ipHits.length) { + issues.push({ code: "known_ip_similarity", severity: "blocking", label: "疑似既有 IP", message: `检测到 ${ipHits.join("、")} 等既有 IP/品牌关键词,不能作为原创商用资产直接批准。` }); + } + const likenessHits = matchingRiskTerms(text, ASSET_LIKENESS_TERMS); + if (likenessHits.length) { + issues.push({ code: "likeness_or_voice_clone", severity: "blocking", label: "肖像/声线风险", message: `检测到 ${likenessHits.join("、")},需要独立授权与伦理审核。` }); + } + const externalSourceRisk = /网络下载|截图|搬运|Pinterest|ArtStation|小红书|微博|抖音|B站|YouTube|素材站/i.test(text); + if (externalSourceRisk && !evidenceRef) { + issues.push({ code: "third_party_source_without_license", severity: "blocking", label: "第三方来源未授权", message: "外部来源素材必须先补齐授权证据,不能直接进入生成或交付。" }); + } + const blockingCount = issues.filter((issue) => issue.severity === "blocking").length; + const reviewCount = issues.filter((issue) => issue.severity === "review").length; + const status = blockingCount ? "blocked" : reviewCount ? "review" : "clear"; + const score = Math.max(0, 100 - blockingCount * 35 - reviewCount * 12); + return { + schema: "ai-drama.asset-governance-scan.v1", + status, + score, + rightsStatus, + scannedAt: new Date().toISOString(), + checks: { + provenancePresent: !missingSource, + rightsEvidencePresent: Boolean(evidenceRef), + singleFrameSafe: layoutHits.length === 0, + knownIpSafe: ipHits.length === 0, + likenessSafe: likenessHits.length === 0 + }, + issues, + policy: { + singleFrameOnly: true, + originalCommercialUse: true, + fixedVoiceEvidenceRequired: asset?.kind === "voice" + } + }; +} + +function assetGovernanceReviews(assetId) { + return dbAll( + `SELECT agr.*, u.display_name AS reviewer_name, u.email AS reviewer_email + FROM asset_governance_reviews agr + LEFT JOIN users u ON u.id = agr.reviewer_user_id + WHERE agr.asset_id = ? + ORDER BY agr.created_at DESC + LIMIT 30`, + [assetId] + ).map((row) => ({ + ...row, + reviewerName: row.reviewer_name || row.reviewer_user_id, + reviewerEmail: row.reviewer_email || "", + provenance: parseJson(row.provenance_json, {}), + risk: parseJson(row.risk_json, {}) + })); +} + +function canAccessAssetLibrary(context) { + return hasPermission(context, "asset:edit") || hasPermission(context, "voice:edit") || hasPermission(context, "compliance:manage"); +} + +function assertAssetGovernanceAccess(context, asset) { + if (canAccessAssetLibrary(context)) return; + if (asset?.kind === "voice" && hasPermission(context, "voice:approve")) return; + throw httpError(403, "permission_denied", "当前角色没有资产治理权限"); +} + function ensureAssetContext(context, assetId) { if (!context.project) throw httpError(400, "project_required", "资产操作必须绑定项目"); const asset = dbGet("SELECT * FROM assets WHERE id = ? AND project_id = ?", [assetId, context.project.id]); @@ -1717,9 +2843,14 @@ function ensureAssetContext(context, assetId) { function assetDetail(assetId, projectId) { const asset = dbGet("SELECT * FROM assets WHERE id = ? AND project_id = ?", [assetId, projectId]); if (!asset) return null; - const versions = dbAll("SELECT id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, metadata_json, created_by, created_at FROM asset_versions WHERE asset_id = ? ORDER BY version_number DESC", [assetId]).map((version) => ({ + const versions = dbAll("SELECT id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, provenance_json, risk_json, tags_json, license_scope, expires_at, metadata_json, created_by, created_at FROM asset_versions WHERE asset_id = ? ORDER BY version_number DESC", [assetId]).map((version) => ({ ...version, metadata: parseJson(version.metadata_json, {}), + provenance: parseJson(version.provenance_json, {}), + risk: parseJson(version.risk_json, {}), + tags: parseJson(version.tags_json, []), + licenseScope: version.license_scope || "", + expiresAt: version.expires_at || "", fileName: version.file_name || "", mimeType: version.mime_type || "application/octet-stream", fileSize: Number(version.file_size || 0), @@ -1740,7 +2871,8 @@ function assetDetail(assetId, projectId) { currentVersionId: asset.current_version_id, currentVersion, versions, - bindings + bindings, + governanceReviews: assetGovernanceReviews(assetId) }; } @@ -1766,22 +2898,31 @@ export function createAsset(context, body) { const kind = String(body.kind || "reference").trim(); const lockStatus = String(body.lockStatus || "draft").trim(); const storagePath = String(body.storagePath || `assets/${context.project.id}/${id}/v1/metadata.json`).trim(); + const bodyMetadata = objectValue(body.metadata); const metadata = { + ...bodyMetadata, subtitle: String(body.subtitle || "本地项目资产"), initial: String(body.initial || name.slice(0, 1)), - tags: Array.isArray(body.tags) ? body.tags : [], + tags: Array.isArray(body.tags) ? body.tags : bodyMetadata.tags || [], usage: String(body.usage || "当前项目"), detail: String(body.detail || "待补充资产描述"), lock: String(body.lock || "待补充连续性备注"), mimeType: String(body.mimeType || "application/octet-stream"), size: Number(body.size || 0) }; + const provenance = objectValue(body.provenance); + const tags = mergeAssetTags(metadata.tags, body.tags); + const licenseScope = String(body.licenseScope || body.license_scope || "").trim(); + const expiresAt = String(body.expiresAt || body.expires_at || "").trim() || null; const fileName = String(body.fileName || storagePath.split("/").pop() || "").trim(); const mimeType = String(body.mimeType || "application/octet-stream"); const fileSize = Number(body.fileSize ?? body.size ?? 0); const contentSha256 = String(body.contentSha256 || "").trim(); + const risk = Object.keys(objectValue(body.risk)).length + ? objectValue(body.risk) + : buildAssetGovernanceScan({ id, kind, name }, { metadata_json: JSON.stringify(metadata), provenance_json: JSON.stringify(provenance), tags_json: JSON.stringify(tags), rights_status: String(body.rightsStatus || "needs-evidence"), storage_path: storagePath, file_name: fileName }, body); dbRun("INSERT INTO assets(id, project_id, kind, name, lock_status, current_version_id, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", [id, context.project.id, kind, name, lockStatus, versionId, context.user.id, timestamp, timestamp]); - dbRun("INSERT INTO asset_versions(id, asset_id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, metadata_json, created_by, created_at) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [versionId, id, storagePath, fileName, mimeType, fileSize, contentSha256, String(body.rightsStatus || "needs-evidence"), JSON.stringify(metadata), context.user.id, timestamp]); + dbRun("INSERT INTO asset_versions(id, asset_id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, provenance_json, risk_json, tags_json, license_scope, expires_at, metadata_json, created_by, created_at) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [versionId, id, storagePath, fileName, mimeType, fileSize, contentSha256, String(body.rightsStatus || "needs-evidence"), JSON.stringify(provenance), JSON.stringify(risk), JSON.stringify(tags), licenseScope, expiresAt, JSON.stringify(metadata), context.user.id, timestamp]); addAudit({ context, action: "asset.created", targetType: "asset", targetId: id, metadata: { kind, name, storagePath } }); return assetDetail(id, context.project.id); } @@ -1803,11 +2944,22 @@ export function createAssetVersion(context, assetId, body) { mimeType: String(body.mimeType || currentMetadata.mimeType || "application/octet-stream"), size: Number(body.size ?? currentMetadata.size ?? 0) }; + const currentProvenance = parseJson(currentVersion?.provenance_json, {}); + const currentTags = parseJson(currentVersion?.tags_json, []); + const provenance = { + ...objectValue(currentProvenance), + ...objectValue(body.provenance) + }; + const tags = mergeAssetTags(currentTags, metadata.tags, body.tags); + const licenseScope = String(body.licenseScope || body.license_scope || currentVersion?.license_scope || "").trim(); + const expiresAt = String(body.expiresAt || body.expires_at || currentVersion?.expires_at || "").trim() || null; const fileName = String(body.fileName || currentVersion?.file_name || storagePath.split("/").pop() || "").trim(); const mimeType = String(body.mimeType || currentVersion?.mime_type || currentMetadata.mimeType || "application/octet-stream"); const fileSize = Number(body.fileSize ?? body.size ?? currentVersion?.file_size ?? currentMetadata.size ?? 0); const contentSha256 = String(body.contentSha256 || currentVersion?.content_sha256 || "").trim(); - dbRun("INSERT INTO asset_versions(id, asset_id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, metadata_json, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [versionId, assetId, versionNumber, storagePath, fileName, mimeType, fileSize, contentSha256, String(body.rightsStatus || "needs-evidence"), JSON.stringify(metadata), context.user.id, timestamp]); + const provisionalVersion = { ...currentVersion, metadata_json: JSON.stringify(metadata), provenance_json: JSON.stringify(provenance), tags_json: JSON.stringify(tags), rights_status: String(body.rightsStatus || "needs-evidence"), storage_path: storagePath, file_name: fileName }; + const risk = Object.keys(objectValue(body.risk)).length ? objectValue(body.risk) : buildAssetGovernanceScan(asset, provisionalVersion, body); + dbRun("INSERT INTO asset_versions(id, asset_id, version_number, storage_path, file_name, mime_type, file_size, content_sha256, rights_status, provenance_json, risk_json, tags_json, license_scope, expires_at, metadata_json, created_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [versionId, assetId, versionNumber, storagePath, fileName, mimeType, fileSize, contentSha256, String(body.rightsStatus || "needs-evidence"), JSON.stringify(provenance), JSON.stringify(risk), JSON.stringify(tags), licenseScope, expiresAt, JSON.stringify(metadata), context.user.id, timestamp]); dbRun("UPDATE assets SET current_version_id = ?, updated_at = ? WHERE id = ?", [versionId, timestamp, assetId]); addAudit({ context, action: "asset.version.created", targetType: "asset_version", targetId: versionId, metadata: { assetId, versionNumber, storagePath } }); return assetDetail(asset.id, context.project.id); @@ -1836,34 +2988,125 @@ export function updateAssetLock(context, assetId, body) { return assetDetail(assetId, context.project.id); } -export function updateAssetRights(context, assetId, body) { - requirePermission(context, "voice:approve"); +export function listAssetGovernanceReviews(context, assetId) { const asset = ensureAssetContext(context, assetId); - if (asset.kind !== "voice") throw httpError(400, "voice_asset_required", "只有声音资产需要走声音授权审批"); + assertAssetGovernanceAccess(context, asset); + return { reviews: assetGovernanceReviews(assetId) }; +} + +export function scanAssetGovernance(context, assetId, body = {}) { + const asset = ensureAssetContext(context, assetId); + assertAssetGovernanceAccess(context, asset); + requireProjectWritable(context); const currentVersion = dbGet("SELECT * FROM asset_versions WHERE id = ? AND asset_id = ?", [asset.current_version_id, assetId]); - if (!currentVersion) throw httpError(404, "asset_version_not_found", "当前声音资产版本不存在"); + if (!currentVersion) throw httpError(404, "asset_version_not_found", "当前资产版本不存在"); + const metadata = parseJson(currentVersion.metadata_json, {}); + const provenance = { + ...objectValue(parseJson(currentVersion.provenance_json, {})), + ...objectValue(body.provenance) + }; + const tags = mergeAssetTags(parseJson(currentVersion.tags_json, []), metadata.tags, body.tags); + const risk = buildAssetGovernanceScan(asset, currentVersion, { ...body, provenance, tags }); + dbRun( + "UPDATE asset_versions SET provenance_json = ?, risk_json = ?, tags_json = ?, license_scope = COALESCE(NULLIF(?, ''), license_scope), expires_at = COALESCE(?, expires_at) WHERE id = ? AND asset_id = ?", + [JSON.stringify(provenance), JSON.stringify(risk), JSON.stringify(tags), String(body.licenseScope || body.license_scope || "").trim(), String(body.expiresAt || body.expires_at || "").trim() || null, currentVersion.id, assetId] + ); + dbRun("UPDATE assets SET updated_at = ? WHERE id = ?", [risk.scannedAt, assetId]); + addAudit({ context, action: "asset.governance.scanned", targetType: "asset_version", targetId: currentVersion.id, result: risk.status === "blocked" ? "blocked" : "ok", metadata: { assetId, riskStatus: risk.status, score: risk.score, issues: risk.issues.map((issue) => issue.code) } }); + return { scan: risk, asset: assetDetail(assetId, context.project.id) }; +} + +export function updateAssetRights(context, assetId, body) { + const asset = ensureAssetContext(context, assetId); + const canSubmit = hasPermission(context, "asset:edit") || (asset.kind === "voice" && hasPermission(context, "voice:edit")); + const canDecide = hasPermission(context, "compliance:manage") || (asset.kind === "voice" && hasPermission(context, "voice:approve")); + if (!canSubmit && !canDecide) throw httpError(403, "permission_denied", "当前角色没有资产授权治理权限"); + requireProjectWritable(context); + const currentVersion = dbGet("SELECT * FROM asset_versions WHERE id = ? AND asset_id = ?", [asset.current_version_id, assetId]); + if (!currentVersion) throw httpError(404, "asset_version_not_found", "当前资产版本不存在"); const rightsStatus = String(body.rightsStatus || "needs-evidence").trim(); - if (!["needs-evidence", "submitted", "approved", "rejected", "expired"].includes(rightsStatus)) { - throw httpError(400, "rights_status_invalid", "声音授权状态无效", { rightsStatus }); + if (!ASSET_RIGHTS_STATUSES.has(rightsStatus)) { + throw httpError(400, "rights_status_invalid", "资产授权状态无效", { rightsStatus }); } - const evidence = body.evidence && typeof body.evidence === "object" ? body.evidence : {}; - if (rightsStatus === "approved" && !String(evidence.reference || evidence.consentRef || "").trim()) { - throw httpError(400, "rights_evidence_required", "批准声音资产前必须填写授权证据引用"); + if (["approved", "rejected", "expired"].includes(rightsStatus) && !canDecide) { + throw httpError(403, "asset_rights_decision_forbidden", "只有合规管理员或声音审批人可以做最终授权决定", { rightsStatus, assetKind: asset.kind }); } + const evidence = objectValue(body.evidence); const metadata = parseJson(currentVersion.metadata_json, {}); const timestamp = new Date().toISOString(); + const provenance = { + ...objectValue(parseJson(currentVersion.provenance_json, {})), + ...objectValue(metadata.provenance), + ...objectValue(body.provenance) + }; + const tags = mergeAssetTags(parseJson(currentVersion.tags_json, []), metadata.tags, body.tags); + const reference = evidenceReference(evidence, metadata, provenance); + if (rightsStatus === "approved" && !reference) { + throw httpError(400, "rights_evidence_required", asset.kind === "voice" ? "批准声音资产前必须填写授权证据引用" : "批准商用资产前必须填写来源和授权证据引用"); + } + if (rightsStatus === "approved" && asset.kind !== "voice" && !String(provenance.sourceType || provenance.sourceName || provenance.sourceRef || "").trim()) { + throw httpError(400, "asset_provenance_required", "批准商用资产前必须登记来源类型、来源名称或来源引用"); + } + const expiresAt = String(body.expiresAt || body.expires_at || currentVersion.expires_at || "").trim() || null; + if (rightsStatus === "approved" && expiresAt && Date.parse(expiresAt) <= Date.now()) { + throw httpError(400, "asset_license_expired", "授权到期时间不能早于当前时间", { expiresAt }); + } + const risk = buildAssetGovernanceScan(asset, currentVersion, { ...body, provenance, tags, rightsStatus, evidence }); + if (rightsStatus === "approved" && risk.status === "blocked") { + throw httpError(422, "asset_governance_blocked", "资产风险扫描未通过,不能批准商用使用", { assetId, risk }); + } const nextMetadata = { ...metadata, + tags, + provenance, rightsEvidence: { - ...((metadata && metadata.rightsEvidence) || {}), + ...objectValue(metadata.rightsEvidence), ...evidence, - reviewedBy: context.user.id, - reviewedAt: timestamp + reference, + reviewedBy: canDecide ? context.user.id : objectValue(metadata.rightsEvidence).reviewedBy || "", + reviewedAt: canDecide ? timestamp : objectValue(metadata.rightsEvidence).reviewedAt || "", + submittedBy: context.user.id, + submittedAt: timestamp } }; - dbRun("UPDATE asset_versions SET rights_status = ?, metadata_json = ? WHERE id = ? AND asset_id = ?", [rightsStatus, JSON.stringify(nextMetadata), currentVersion.id, assetId]); - dbRun("UPDATE assets SET lock_status = ?, updated_at = ? WHERE id = ?", [rightsStatus === "approved" ? "locked" : rightsStatus === "rejected" ? "review" : asset.lock_status, timestamp, assetId]); - addAudit({ context, action: "voice.rights.updated", targetType: "asset_version", targetId: currentVersion.id, result: rightsStatus === "approved" ? "pass" : rightsStatus, metadata: { assetId, rightsStatus, evidence: nextMetadata.rightsEvidence } }); + const licenseScope = String(body.licenseScope || body.license_scope || currentVersion.license_scope || "").trim(); + const nextLockStatus = rightsStatus === "approved" && asset.kind === "voice" + ? "locked" + : rightsStatus === "rejected" || rightsStatus === "expired" + ? "review" + : asset.lock_status; + const reviewId = `asset-review-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; + withTransaction(() => { + dbRun( + "UPDATE asset_versions SET rights_status = ?, provenance_json = ?, risk_json = ?, tags_json = ?, license_scope = ?, expires_at = ?, metadata_json = ? WHERE id = ? AND asset_id = ?", + [rightsStatus, JSON.stringify(provenance), JSON.stringify(risk), JSON.stringify(tags), licenseScope, expiresAt, JSON.stringify(nextMetadata), currentVersion.id, assetId] + ); + dbRun("UPDATE assets SET lock_status = ?, updated_at = ? WHERE id = ?", [nextLockStatus, timestamp, assetId]); + dbRun( + `INSERT INTO asset_governance_reviews( + id, organization_id, workspace_id, project_id, asset_id, version_id, reviewer_user_id, + decision, rights_status, risk_status, notes, evidence_ref, provenance_json, risk_json, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + reviewId, + context.organization.id, + context.workspace.id, + context.project.id, + assetId, + currentVersion.id, + context.user.id, + canDecide ? rightsStatus : "submitted", + rightsStatus, + risk.status, + String(body.notes || evidence.notes || "").trim(), + reference, + JSON.stringify(provenance), + JSON.stringify(risk), + timestamp + ] + ); + }); + addAudit({ context, action: asset.kind === "voice" ? "voice.rights.updated" : "asset.rights.updated", targetType: "asset_version", targetId: currentVersion.id, result: rightsStatus === "approved" ? "pass" : rightsStatus, metadata: { assetId, assetKind: asset.kind, rightsStatus, riskStatus: risk.status, evidenceRef: reference, reviewId } }); return assetDetail(assetId, context.project.id); } @@ -1893,9 +3136,11 @@ export function buildContextPayload(context) { const canManageProjectMembers = hasPermission(context, "project:members:manage"); const canViewUsage = hasPermission(context, "usage:view") || hasPermission(context, "billing:manage"); const canViewAudit = hasPermission(context, "audit:view"); - const canViewModels = hasPermission(context, "model:manage"); + const canViewModels = hasPermission(context, "model:manage") || hasPermission(context, "model:approve"); const canUseGenerationAdapters = hasPermission(context, "job:create") || canViewModels; const modelRows = scopedModels(context); + const modelCatalogRows = canViewModels ? scopedModelCatalog(context) : []; + const modelRouteRows = canViewModels ? scopedModelRoutes(context) : []; const adapterCatalog = canViewModels ? modelRows : modelRows.map((model) => ({ id: model.id, label: model.label, @@ -1961,6 +3206,8 @@ export function buildContextPayload(context) { usage, auditLog, modelRegistry: canViewModels ? modelRows : [], + modelCatalog: modelCatalogRows, + modelRouting: modelRouteRows, adapterCatalog: canUseGenerationAdapters ? adapterCatalog : [], rolePolicies: rolePolicyCatalog, organization: context.organization, @@ -2294,13 +3541,56 @@ export function updateSystemUserMemberships(context, userId, body = {}) { return systemUserDetail(context, userId); } +function privateConnectorHost(hostname) { + const host = String(hostname || "").toLowerCase(); + if (["localhost", "127.0.0.1", "::1"].includes(host) || host.endsWith(".local")) return true; + const octets = host.split(".").map(Number); + if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false; + return octets[0] === 10 || octets[0] === 127 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168); +} + +function connectorEndpointPolicy(endpoint, costMode = "local") { + let url; + try { + url = new URL(String(endpoint || "")); + } catch { + return { status: "invalid", locality: "invalid", protocol: "", hostname: "", localOnlySafe: false }; + } + const local = privateConnectorHost(url.hostname); + const status = String(costMode || "local") === "local" && !local ? "blocked" : local ? "private-local" : "public-network"; + return { + status, + locality: local ? "local-or-lan" : "public", + protocol: url.protocol.replace(":", ""), + hostname: url.hostname, + localOnlySafe: local + }; +} + +function connectorSecretPolicy(model, protocol, costMode = "local") { + const authEnv = String(model.auth_env || protocol?.authEnv || "").trim(); + const required = Boolean(authEnv) || String(costMode || "local") !== "local" || Boolean(protocol?.authRequired); + const present = required && Object.prototype.hasOwnProperty.call(process.env, authEnv) && String(process.env[authEnv] || "").length > 0; + return { + authEnv, + required, + present, + status: !required ? "not-required" : present ? "configured" : "missing" + }; +} + export function parseModelRow(model) { + const protocol = parseJson(model.protocol_json, {}); + const costMode = model.cost_mode || "local"; return { ...model, capability: parseJson(model.capabilities_json, []), - protocol: parseJson(model.protocol_json, {}), + protocol, approvalRequired: Boolean(model.approval_required), - costMode: model.cost_mode + costMode, + authEnv: model.auth_env || protocol?.authEnv || "", + secretPolicy: connectorSecretPolicy(model, protocol, costMode), + endpointPolicy: connectorEndpointPolicy(model.endpoint, costMode) }; } diff --git a/src/App.jsx b/src/App.jsx index 885aea7..85e43b3 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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" && group.items).map((item) => item.id)} />} - {activeTab === "asset-library" && } + {activeTab === "knowledge" && } + + {activeTab === "asset-library" && } {activeTab === "voice-studio" && } diff --git a/src/components/CreatorSuitePages.jsx b/src/components/CreatorSuitePages.jsx index d58d39a..e21c073 100644 --- a/src/components/CreatorSuitePages.jsx +++ b/src/components/CreatorSuitePages.jsx @@ -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 ( +