feat: expand commercial ai drama platform

This commit is contained in:
xz
2026-08-31 21:09:48 +08:00
parent f06e583c9b
commit 8caf947b80
35 changed files with 10181 additions and 188 deletions
+454 -1
View File
@@ -35,6 +35,7 @@ ensureColumn("generation_jobs", "request_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("generation_jobs", "result_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("generation_jobs", "error_message", "TEXT NOT NULL DEFAULT ''");
ensureColumn("generation_jobs", "max_attempts", "INTEGER NOT NULL DEFAULT 3");
ensureColumn("generation_jobs", "model_route_approval_id", "TEXT");
ensureColumn("generation_jobs", "next_run_at", "TEXT");
ensureColumn("generation_jobs", "leased_by", "TEXT");
ensureColumn("generation_jobs", "leased_at", "TEXT");
@@ -83,11 +84,17 @@ ensureColumn("asset_versions", "file_name", "TEXT NOT NULL DEFAULT ''");
ensureColumn("asset_versions", "mime_type", "TEXT NOT NULL DEFAULT 'application/octet-stream'");
ensureColumn("asset_versions", "file_size", "INTEGER NOT NULL DEFAULT 0");
ensureColumn("asset_versions", "content_sha256", "TEXT NOT NULL DEFAULT ''");
ensureColumn("asset_versions", "provenance_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("asset_versions", "risk_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("asset_versions", "tags_json", "TEXT NOT NULL DEFAULT '[]'");
ensureColumn("asset_versions", "license_scope", "TEXT NOT NULL DEFAULT ''");
ensureColumn("asset_versions", "expires_at", "TEXT");
ensureColumn("shots", "current_version_id", "TEXT");
ensureColumn("deliveries", "active_batch_id", "TEXT");
ensureColumn("delivery_releases", "idempotency_key", "TEXT NOT NULL DEFAULT ''");
ensureColumn("delivery_releases", "preflight_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("delivery_releases", "result_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("script_documents", "metadata_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("projects", "archived_at", "TEXT");
ensureColumn("projects", "archived_by", "TEXT");
ensureColumn("projects", "archived_from_status", "TEXT");
@@ -96,6 +103,11 @@ ensureColumn("workspace_members", "access_mode", "TEXT NOT NULL DEFAULT 'all'");
ensureColumn("auth_sessions", "device_id", "TEXT");
ensureColumn("auth_sessions", "risk_level", "TEXT NOT NULL DEFAULT 'medium'");
ensureColumn("auth_sessions", "risk_score", "INTEGER NOT NULL DEFAULT 50");
ensureColumn("knowledge_documents", "rights_status", "TEXT NOT NULL DEFAULT 'needs-evidence'");
ensureColumn("knowledge_documents", "provenance_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("knowledge_documents", "tags_json", "TEXT NOT NULL DEFAULT '[]'");
ensureColumn("knowledge_documents", "risk_json", "TEXT NOT NULL DEFAULT '{}'");
ensureColumn("knowledge_documents", "current_version_number", "INTEGER NOT NULL DEFAULT 1");
db.exec("CREATE INDEX IF NOT EXISTS idx_projects_lifecycle ON projects(workspace_id, status, updated_at)");
db.exec("CREATE INDEX IF NOT EXISTS idx_invites_token ON invitations(token_hash, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_identity_providers_org ON identity_providers(organization_id, enabled, status)");
@@ -113,12 +125,24 @@ db.exec("CREATE INDEX IF NOT EXISTS idx_auth_sessions_device ON auth_sessions(de
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_channels_scope ON delivery_channels(organization_id, workspace_id, project_id, enabled, created_at)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_releases_scope ON delivery_releases(organization_id, workspace_id, project_id, delivery_id, status, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_releases_idempotency ON delivery_releases(organization_id, project_id, idempotency_key)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_clearance_reports_scope ON delivery_clearance_reports(organization_id, workspace_id, project_id, delivery_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_clearance_reports_release ON delivery_clearance_reports(release_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_links_token ON delivery_access_links(token_hash, status, expires_at)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_links_scope ON delivery_access_links(organization_id, workspace_id, project_id, release_id, status, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_events_link ON delivery_access_events(link_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_events_release ON delivery_access_events(release_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_link ON delivery_access_feedback(link_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_delivery_access_feedback_release ON delivery_access_feedback(release_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_model_route_approvals_scope ON model_route_approval_requests(organization_id, workspace_id, status, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_model_route_approvals_requester ON model_route_approval_requests(requester_user_id, status, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_model_route_approvals_job ON model_route_approval_requests(job_id, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_generation_jobs_model_approval ON generation_jobs(model_route_approval_id)");
db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_org_status ON commercial_approval_requests(organization_id, status, updated_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_requester ON commercial_approval_requests(requester_user_id, status, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_commercial_approvals_target ON commercial_approval_requests(organization_id, request_type, target_key, status)");
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_versions_governance ON asset_versions(asset_id, rights_status, expires_at)");
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_asset ON asset_governance_reviews(asset_id, created_at DESC)");
db.exec("CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_scope ON asset_governance_reviews(organization_id, workspace_id, project_id, risk_status, created_at DESC)");
function migrateApiClientSecrets() {
const rows = dbAll("SELECT id, client_key, client_key_hash, client_key_prefix, key_version FROM api_clients");
@@ -232,6 +256,7 @@ function seedRoles() {
["job:create", "创建生成任务"],
["job:prioritize", "调整任务优先级"],
["model:manage", "注册和管理模型连接器"],
["model:approve", "审批模型路由、外部连接器和一次性生成调用"],
["usage:view", "查看用量和成本"],
["billing:manage", "管理套餐和账单"],
["quota:manage", "管理组织席位与工作区配额"],
@@ -258,7 +283,7 @@ function seedRoles() {
org_admin: [
"organization:manage", "organization:members:invite", "workspace:create", "workspace:manage",
"workspace:members:manage", "project:create", "project:manage", "project:members:manage",
"workflow:manage", "task:view", "task:manage", "task:complete", "model:manage", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
"workflow:manage", "task:view", "task:manage", "task:complete", "model:manage", "model:approve", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
"system:settings:view", "service:health:view", "organization:roles:manage"
],
producer: ["project:create", "project:manage", "project:members:manage", "workflow:manage", "task:view", "task:manage", "task:complete", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
@@ -328,6 +353,171 @@ function seedOrganization({ id, name, slug, ownerUserId, description, workspaceI
insertIgnore("INSERT OR IGNORE INTO quota_allocations(id, organization_id, workspace_id, metric, limit_value, used_value, unit, period_start, period_end, created_at, updated_at) VALUES (?, ?, ?, 'storage', 1024, 128, 'GB', ?, ?, ?, ?)", [`quota-${workspaceId}-storage`, id, workspaceId, monthStart(), monthEnd(), timestamp, timestamp]);
}
function commercialEntitlementDefaults(billing = {}) {
const clipLimit = Number(billing.monthly_clip_quota || 2400);
const storageGb = Number(billing.storage_gb || 1024);
const seatLimit = Number(billing.seat_limit || 12);
return [
["limit.seats", "组织席位", "tenant", seatLimit, "人", 1, "block", { commercialGate: "member-invite-and-sso" }],
["limit.workspaces", "工作区数量", "tenant", 8, "个", 1, "block", { commercialGate: "workspace:create" }],
["limit.projects", "项目数量", "production", 36, "个", 1, "block", { commercialGate: "project:create" }],
["limit.generation_jobs_monthly", "月度生成任务", "production", clipLimit, "job", 1, "block", { commercialGate: "generation_job:create" }],
["limit.storage_gb", "存储容量", "storage", storageGb, "GB", 1, "block", { commercialGate: "storage:write" }],
["limit.model_connectors", "模型连接器", "modelops", 16, "个", 1, "block", { commercialGate: "model_connector:create" }],
["limit.api_clients", "API 客户端", "system", 8, "个", 1, "block", { commercialGate: "api_client:create" }],
["limit.knowledge_documents", "知识库素材", "knowledge", 300, "篇", 1, "block", { commercialGate: "knowledge_document:ingest" }],
["limit.knowledge_context_packs", "知识上下文包", "knowledge", 180, "包", 1, "block", { commercialGate: "knowledge_context_pack:create" }],
["limit.delivery_channels", "交付渠道", "delivery", 12, "个", 1, "block", { commercialGate: "delivery_channel:create" }],
["feature.batch_generation", "批量生产", "feature", 1, "开关", 1, "block", { description: "允许创建批量生成与流水线任务" }],
["feature.private_delivery_portal", "客户交付门户", "feature", 1, "开关", 1, "block", { description: "允许创建带令牌的客户预览/下载门户" }],
["feature.comfyui_adapter", "ComfyUI 可选桥接", "feature", 1, "开关", 0, "block", { description: "默认关闭,明确启用后才可作为适配器" }],
["feature.external_cloud_connectors", "外部云连接器", "feature", 1, "开关", 0, "block", { description: "默认关闭,付费/公网模型必须显式审批" }]
];
}
function seedCommercialPlans() {
const timestamp = now();
const plans = [
["plan-starter-local", "starter-local", "Starter Local", "单工作室本地试制版,适合一条短剧流水线验证。", "monthly", "CNY", 0, 5, 256, 500, { workspaces: 2, projects: 8, modelConnectors: 6, apiClients: 3, knowledgeDocuments: 80, knowledgeContextPacks: 40, deliveryChannels: 3 }, { batchGeneration: false, privateDeliveryPortal: true, comfyuiAdapter: false, externalCloudConnectors: false }, { allowedCostModes: ["local"], externalApprovalRequired: true }, "社区支持"],
["plan-studio-local", "studio-local", "Studio Local", "商业工作室私有生产版,覆盖剧本、资产、生成、审片、交付和账单。", "monthly", "CNY", 0, 12, 1024, 2400, { workspaces: 8, projects: 36, modelConnectors: 16, apiClients: 8, knowledgeDocuments: 300, knowledgeContextPacks: 180, deliveryChannels: 12 }, { batchGeneration: true, privateDeliveryPortal: true, comfyuiAdapter: false, externalCloudConnectors: false }, { allowedCostModes: ["local", "mixed-with-approval"], externalApprovalRequired: true }, "工作日响应"],
["plan-enterprise-private", "enterprise-private", "Enterprise Private", "多组织私有化商业版,适合平台代理、内容厂牌和多项目制片团队。", "annual", "CNY", 0, 50, 8192, 20000, { workspaces: 50, projects: 300, modelConnectors: 80, apiClients: 50, knowledgeDocuments: 5000, knowledgeContextPacks: 2000, deliveryChannels: 60 }, { batchGeneration: true, privateDeliveryPortal: true, comfyuiAdapter: true, externalCloudConnectors: false }, { allowedCostModes: ["local", "mixed-with-approval"], externalApprovalRequired: true }, "专属运维窗口"]
];
for (const [id, tierKey, name, description, billingCycle, currency, baseFee, seatLimit, storageGb, monthlyClipQuota, limits, features, connectorPolicy, supportSla] of plans) {
insertIgnore(
"INSERT OR IGNORE INTO subscription_plan_templates(id, tier_key, name, description, billing_cycle, currency, base_fee, seat_limit, storage_gb, monthly_clip_quota, limits_json, features_json, connector_policy_json, support_sla, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'active', ?, ?)",
[id, tierKey, name, description, billingCycle, currency, baseFee, seatLimit, storageGb, monthlyClipQuota, JSON.stringify(limits), JSON.stringify(features), JSON.stringify(connectorPolicy), supportSla, timestamp, timestamp]
);
}
}
function seedOrganizationEntitlements() {
const timestamp = now();
const organizations = dbAll(
`SELECT o.id AS organization_id,
b.seat_limit, b.storage_gb, b.monthly_clip_quota
FROM organizations o
LEFT JOIN billing_accounts b ON b.organization_id = o.id
WHERE o.status = 'active'`
);
for (const organization of organizations) {
for (const [key, label, category, limitValue, unit, enabled, enforcement, metadata] of commercialEntitlementDefaults(organization)) {
insertIgnore(
"INSERT OR IGNORE INTO organization_entitlements(id, organization_id, entitlement_key, label, category, limit_value, unit, enabled, enforcement, source, metadata_json, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'plan', ?, ?, ?)",
[`ent-${organization.organization_id}-${key.replace(/[^a-z0-9]+/gi, "-")}`, organization.organization_id, key, label, category, limitValue, unit, enabled, enforcement, JSON.stringify(metadata), timestamp, timestamp]
);
}
}
}
function seedCommercialApprovals() {
const timestamp = now();
const requests = [
{
id: "commercial-approval-seed-entitlement",
organizationId: "org-studio-lab",
workspaceId: "ws-local-aidrama",
projectId: "thunder-mouth",
requestType: "entitlement_overage",
title: "《雨夜来电》本月追加 500 个生成任务",
status: "submitted",
priority: "high",
targetKey: "limit.generation_jobs_monthly",
currentValue: 2400,
requestedValue: 2900,
unit: "job",
businessReason: "连续三集试制需要补拍转场镜头和反应镜头,仍限定为本地 Runner 执行。",
risk: { localOnly: true, paidCloud: false, singleFrameGate: true, continuityLedger: true },
evidence: { project: "thunder-mouth", source: "seed://commercial/entitlement-overage" },
requester: "u-writer",
reviewer: null,
reviewedAt: null,
decisionNote: "",
effect: {}
},
{
id: "commercial-approval-seed-connector",
organizationId: "org-studio-lab",
workspaceId: "ws-local-aidrama",
projectId: "thunder-mouth",
requestType: "external_connector",
title: "评估 NewAPI 中转的外部官方模型通道",
status: "submitted",
priority: "urgent",
targetKey: "feature.external_cloud_connectors",
currentValue: 0,
requestedValue: 1,
unit: "开关",
businessReason: "仅申请连接能力评估,不自动调用付费云端节点;需要管理员确认成本和合规边界。",
risk: { localOnly: false, paidCloud: true, requiresExplicitApproval: true, secretStored: false },
evidence: { connectorPolicy: "env-secret-only", source: "seed://commercial/external-connector" },
requester: "u-owner",
reviewer: null,
reviewedAt: null,
decisionNote: "",
effect: {}
},
{
id: "commercial-approval-seed-compliance",
organizationId: "org-studio-lab",
workspaceId: "ws-local-aidrama",
projectId: "thunder-mouth",
requestType: "compliance_review",
title: "原创小说素材版权与分块入库复核",
status: "approved",
priority: "medium",
targetKey: "commercial-rights-review",
currentValue: 0,
requestedValue: 1,
unit: "次",
businessReason: "确认《雨夜来电》素材按原创来源入库,允许用于剧本拆解和知识上下文包。",
risk: { sourceType: "original", knowledgeChunking: true, derivativeUse: "internal-production" },
evidence: { knowledgeDocumentId: "knowledge-rain-night", source: "seed://commercial/compliance-review" },
requester: "u-owner",
reviewer: "u-owner",
reviewedAt: timestamp,
decisionNote: "示例放行:原创素材,后续交付仍需逐素材证据。",
effect: { complianceRecordId: "commercial-compliance-seed-rain-night" }
}
];
for (const request of requests) {
insertIgnore(
`INSERT OR IGNORE INTO commercial_approval_requests(
id, organization_id, workspace_id, project_id, request_type, title, status, priority, target_key,
current_value, requested_value, unit, business_reason, risk_assessment_json, evidence_json,
decision_note, effect_json, requester_user_id, reviewer_user_id, reviewed_at, expires_at, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
[
request.id,
request.organizationId,
request.workspaceId,
request.projectId,
request.requestType,
request.title,
request.status,
request.priority,
request.targetKey,
request.currentValue,
request.requestedValue,
request.unit,
request.businessReason,
JSON.stringify(request.risk),
JSON.stringify(request.evidence),
request.decisionNote,
JSON.stringify(request.effect),
request.requester,
request.reviewer,
request.reviewedAt,
timestamp,
timestamp
]
);
}
insertIgnore(
"INSERT OR IGNORE INTO compliance_records(id, organization_id, workspace_id, project_id, subject_type, subject_id, policy_key, status, evidence_json, reviewed_by, reviewed_at, created_at, updated_at) VALUES ('commercial-compliance-seed-rain-night', 'org-studio-lab', 'ws-local-aidrama', 'thunder-mouth', 'knowledge_document', 'knowledge-rain-night', 'commercial-rights-review', 'approved', ?, 'u-owner', ?, ?, ?)",
[JSON.stringify({ source: "seed://commercial/compliance-review", knowledgeDocumentId: "knowledge-rain-night" }), timestamp, timestamp, timestamp]
);
}
function seedMembersAndProjects() {
const timestamp = now();
const membershipRows = [
@@ -365,6 +555,8 @@ function seedModels() {
const models = [
["owned-i2v", "org-studio-lab", "ws-local-aidrama", "自有图生视频平台", "http-json", ["image-to-video", "first-last-frame", "vertical-video"], "http://127.0.0.1:7860/api/generate/i2v", "not-connected", "local", 0, "u-owner"],
["owned-image", "org-studio-lab", "ws-local-aidrama", "自有图片/改图平台", "http-json", ["text-to-image", "image-edit", "single-frame"], "http://127.0.0.1:7860/api/generate/image", "not-connected", "local", 0, "u-owner"],
["owned-story-parser", "org-studio-lab", "ws-local-aidrama", "自有文本解析网关", "openai-compatible", ["chat", "script-split", "knowledge-extract"], "http://127.0.0.1:7862/v1", "ready", "local", 0, "u-owner"],
["owned-embedding", "org-studio-lab", "ws-local-aidrama", "自有向量检索网关", "openai-compatible", ["embedding", "rerank", "knowledge-retrieval"], "http://127.0.0.1:7863/v1", "planned", "local", 0, "u-owner"],
["local-tts", "org-studio-lab", "ws-local-aidrama", "本地固定声线 TTS", "http-json", ["tts", "voice-lock", "subtitle-timing"], "http://127.0.0.1:7861/api/tts", "planned", "local", 0, "u-owner"],
["newapi-audio-production", "org-studio-lab", "ws-local-aidrama", "NewAPI 音频中转", "openai-compatible-audio", ["tts", "voice-lock", "emotion-control", "asr", "subtitle-timing"], "https://newapi.ysblack.com/v1", "ready", "mixed", 1, "u-owner"],
["comfyui-optional", "org-studio-lab", "ws-local-aidrama", "ComfyUI 工作流桥接", "comfyui", ["workflow", "qwen-image", "qwen-edit"], "http://127.0.0.1:8188", "optional", "mixed", 1, "u-owner"],
@@ -375,6 +567,200 @@ function seedModels() {
}
}
function seedModelCatalog() {
const timestamp = now();
const entries = [
["catalog-qwen-image-2d", "org-studio-lab", "ws-local-aidrama", "owned-image", "qwen-image-2d-lock", "Qwen Image 国漫单画面", "qwen-image", ["text-to-image", "single-frame", "continuity-lock"], 32768, 4096, { mode: "per-image", estimatedCny: 0.18, locality: "local" }, "active", "approved", { aspect: "9:16", guardrails: ["single-frame-only", "no-collage"] }],
["catalog-wan-i2v", "org-studio-lab", "ws-local-aidrama", "owned-i2v", "wan-i2v-lastframe", "Wan 图生视频连续版", "wan-video", ["image-to-video", "first-last-frame", "clip-bridge"], 65536, 4096, { mode: "per-clip", estimatedCny: 0.42, locality: "local" }, "active", "approved", { durationSec: [5, 10], notes: "优先使用上一段实际末帧" }],
["catalog-qwen-parser", "org-studio-lab", "ws-local-aidrama", "owned-story-parser", "qwen-script-parser", "Qwen 剧本/小说解析", "qwen-text", ["chat", "script-split", "knowledge-extract", "scene-parse"], 131072, 8192, { mode: "per-1k-tokens", estimatedCny: 0.03, locality: "local" }, "active", "approved", { languages: ["zh-CN"], preferredFor: ["script-import", "knowledge-ingest"] }],
["catalog-bge-m3", "org-studio-lab", "ws-local-aidrama", "owned-embedding", "bge-m3-local", "BGE-M3 向量检索", "embedding", ["embedding", "retrieval", "knowledge-search"], 8192, 0, { mode: "per-1k-tokens", estimatedCny: 0.01, locality: "local" }, "planned", "approved", { dimensions: 1024, preferredFor: ["knowledge-search"] }],
["catalog-indextts", "org-studio-lab", "ws-local-aidrama", "newapi-audio-production", "IndexTTS-2.5", "IndexTTS-2.5 固定声线", "tts", ["tts", "voice-lock", "emotion-control"], 16384, 2048, { mode: "per-10s-audio", estimatedCny: 0.12, locality: "mixed" }, "active", "review", { referenceRequired: true, approvalGate: "voice-rights" }],
["catalog-paraformer", "org-studio-lab", "ws-local-aidrama", "newapi-audio-production", "paraformer-zh-long", "Paraformer 长音频 ASR", "asr", ["asr", "subtitle-timing", "alignment"], 16384, 2048, { mode: "per-minute-audio", estimatedCny: 0.05, locality: "mixed" }, "active", "approved", { output: "verbose_json" }],
["catalog-northstar-image", "org-northstar", "ws-northstar-main", "northstar-image", "northstar-sd-xl", "北辰 SDXL 单画面", "sdxl", ["text-to-image", "single-frame"], 32768, 4096, { mode: "per-image", estimatedCny: 0.16, locality: "local" }, "active", "approved", { aspect: "9:16" }]
];
for (const [id, organizationId, workspaceId, connectorId, modelKey, displayName, family, capabilities, contextWindow, maxOutputTokens, cost, status, approvalStatus, metadata] of entries) {
insertIgnore(
"INSERT OR IGNORE INTO model_catalog_entries(id, organization_id, workspace_id, connector_id, model_key, display_name, family, capabilities_json, context_window, max_output_tokens, cost_json, status, approval_status, metadata_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'u-owner', ?, ?)",
[id, organizationId, workspaceId, connectorId, modelKey, displayName, family, JSON.stringify(capabilities), contextWindow, maxOutputTokens, JSON.stringify(cost), status, approvalStatus, JSON.stringify(metadata), timestamp, timestamp]
);
}
}
function seedModelRoutes() {
const timestamp = now();
const routes = [
["route-script-ingest", "org-studio-lab", "ws-local-aidrama", "小说/剧本导入解析", "knowledge-ingest", "chunk-and-extract", "catalog-qwen-parser", "catalog-bge-m3", "prefer-local", "follow-model", 15, "active", { chunkStrategy: "chapter-scene-dialogue", maxChunkChars: 520 }],
["route-script-materialize", "org-studio-lab", "ws-local-aidrama", "剧本拆解与场景草稿", "script-pipeline", "scene-draft", "catalog-qwen-parser", null, "prefer-local", "follow-model", 10, "active", { target: "script_documents" }],
["route-image-keyframe", "org-studio-lab", "ws-local-aidrama", "单画面关键帧", "ai-manhua-drama", "image-keyframe", "catalog-qwen-image-2d", null, "local-only", "follow-model", 60, "active", { qaGate: "single-frame" }],
["route-video-clip", "org-studio-lab", "ws-local-aidrama", "图生视频片段", "ai-manhua-drama", "video-clip", "catalog-wan-i2v", null, "local-only", "follow-model", 120, "active", { requireActualLastFrame: true }],
["route-voice-tts", "org-studio-lab", "ws-local-aidrama", "角色固定配音", "voice-pipeline", "tts", "catalog-indextts", null, "prefer-approved", "explicit-review", 40, "active", { referenceAssetKind: "voice", forbidRandomNativeVoice: true }],
["route-voice-asr", "org-studio-lab", "ws-local-aidrama", "ASR 对齐校验", "voice-pipeline", "asr", "catalog-paraformer", null, "prefer-approved", "follow-model", 20, "active", { output: "verbose_json" }]
];
for (const [id, organizationId, workspaceId, name, workflowKey, operationKey, primaryModelId, fallbackModelId, policyMode, approvalMode, budgetLimitCny, status, policy] of routes) {
insertIgnore(
"INSERT OR IGNORE INTO model_routing_policies(id, organization_id, workspace_id, name, workflow_key, operation_key, primary_model_id, fallback_model_id, policy_mode, approval_mode, budget_limit_cny, status, policy_json, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'u-owner', ?, ?)",
[id, organizationId, workspaceId, name, workflowKey, operationKey, primaryModelId, fallbackModelId, policyMode, approvalMode, budgetLimitCny, status, JSON.stringify(policy), timestamp, timestamp]
);
}
}
function seedKnowledgeLibrary() {
const timestamp = now();
const content = [
"第一章 雨夜来电",
"暴雨压在旧城区的玻璃连廊上,陈宇刚结束夜班巡检,就接到唐夏发来的定位。她只说了一句:不要让任何人碰那把蓝伞。",
"",
"唐夏:我到地铁口了,可这里的警戒线被人挪开了。",
"陈宇:别过去,积水边可能有落地电线。你先站在连廊灯下,右手不要离开蓝伞。",
"",
"第二章 失踪的雨披",
"两人会合后发现黄色雨披不见了,警戒锥却比下午多了一组。唐夏开始怀疑,有人提前布置过现场,像是在等他们出现。"
].join("\n");
const analysis = {
parser: "local-rule-v2",
chapterCount: 2,
chunkCount: 4,
summary: "暴雨夜的地铁口事故线索,包含角色对话、场景限制、关键道具和连续性信息。",
entities: {
characters: ["陈宇", "唐夏"],
locations: ["旧城区玻璃连廊", "地铁口"],
props: ["蓝伞", "黄色雨披", "警戒线", "警戒锥"]
}
};
const provenance = {
sourceLabel: "平台原创示例小说",
author: "AI Drama Platform Demo",
rightsOwner: "本地示例组织",
evidenceRef: "seed://original/rain-night",
licenseNote: "原创演示素材,仅用于本地样例和 smoke 测试",
sourceUrl: "",
importedFrom: "seed"
};
const tags = ["原创", "雨夜", "连续性样例"];
const risk = {
scanner: "local-governance-v1",
status: "pass",
score: 0,
rightsStatus: "approved",
sourceType: "novel",
tags,
issues: [],
checks: {
provenanceEvidence: true,
commercialRightsApproved: true,
knownIpReferences: 0,
personaLikenessRisks: 0,
singleFramePolicyRisks: 0,
safetySensitiveMatches: 0
},
scannedAt: timestamp
};
insertIgnore(
"INSERT OR IGNORE INTO knowledge_documents(id, organization_id, workspace_id, project_id, scope_mode, title, source_type, language, content, status, summary, analysis_json, metadata_json, created_by, created_at, updated_at) VALUES ('knowledge-rain-night', 'org-studio-lab', 'ws-local-aidrama', NULL, 'workspace', '《雨夜来电》原始小说素材', 'novel', 'zh-CN', ?, 'indexed', ?, ?, ?, 'u-owner', ?, ?)",
[content, analysis.summary, JSON.stringify(analysis), JSON.stringify({ sourceLabel: "原创小说", rights: "original", recommendedWorkflow: "ai-manhua-drama" }), timestamp, timestamp]
);
dbRun(
`UPDATE knowledge_documents
SET rights_status = 'approved',
provenance_json = ?,
tags_json = ?,
risk_json = ?,
current_version_number = CASE WHEN current_version_number < 1 THEN 1 ELSE current_version_number END
WHERE id = 'knowledge-rain-night'
AND (rights_status = 'needs-evidence' OR provenance_json = '{}' OR risk_json = '{}')`,
[JSON.stringify(provenance), JSON.stringify(tags), JSON.stringify(risk)]
);
insertIgnore(
`INSERT OR IGNORE INTO knowledge_document_versions(
id, document_id, version_number, title, source_type, language, content, summary,
analysis_json, provenance_json, tags_json, risk_json, metadata_json, created_by, created_at
) VALUES ('knowledge-rain-night-v1', 'knowledge-rain-night', 1, '《雨夜来电》原始小说素材', 'novel', 'zh-CN', ?, ?, ?, ?, ?, ?, ?, 'u-owner', ?)`,
[content, analysis.summary, JSON.stringify(analysis), JSON.stringify(provenance), JSON.stringify(tags), JSON.stringify(risk), JSON.stringify({ sourceLabel: "原创小说", rights: "original", recommendedWorkflow: "ai-manhua-drama", seed: true }), timestamp]
);
const chunks = [
["knowledge-rain-night-chunk-1", 1, "chapter", "第一章 雨夜来电", "暴雨压在旧城区的玻璃连廊上,陈宇刚结束夜班巡检,就接到唐夏发来的定位。她只说了一句:不要让任何人碰那把蓝伞。", 116, ["暴雨", "玻璃连廊", "蓝伞"], { characters: ["陈宇", "唐夏"], locations: ["旧城区玻璃连廊"], props: ["蓝伞"] }, { chapter: 1, sceneHint: "开场建立场景与冲突" }],
["knowledge-rain-night-chunk-2", 2, "dialogue", "地铁口对话", "唐夏:我到地铁口了,可这里的警戒线被人挪开了。\n陈宇:别过去,积水边可能有落地电线。你先站在连廊灯下,右手不要离开蓝伞。", 124, ["地铁口", "警戒线", "落地电线"], { characters: ["陈宇", "唐夏"], locations: ["地铁口"], props: ["蓝伞", "警戒线"] }, { chapter: 1, mouthAvoidance: true, sceneHint: "适合侧脸/反应镜头对白" }],
["knowledge-rain-night-chunk-3", 3, "chapter", "第二章 失踪的雨披", "两人会合后发现黄色雨披不见了,警戒锥却比下午多了一组。", 58, ["黄色雨披", "警戒锥"], { characters: ["陈宇", "唐夏"], props: ["黄色雨披", "警戒锥"] }, { chapter: 2, sceneHint: "道具连续性检查点" }],
["knowledge-rain-night-chunk-4", 4, "lore", "现场异常", "唐夏开始怀疑,有人提前布置过现场,像是在等他们出现。", 42, ["异常布置", "悬念"], { characters: ["唐夏"] }, { chapter: 2, sceneHint: "结尾悬念与下一集钩子" }]
];
for (const [id, chunkIndex, chunkType, heading, body, tokenEstimate, keywords, entities, metadata] of chunks) {
insertIgnore(
"INSERT OR IGNORE INTO knowledge_chunks(id, document_id, chunk_index, chunk_type, heading, content, token_estimate, keywords_json, entities_json, metadata_json, created_at) VALUES (?, 'knowledge-rain-night', ?, ?, ?, ?, ?, ?, ?, ?, ?)",
[id, chunkIndex, chunkType, heading, body, tokenEstimate, JSON.stringify(keywords), JSON.stringify(entities), JSON.stringify(metadata), timestamp]
);
}
const openingChunks = chunks.slice(0, 2);
const citations = openingChunks.map(([id, chunkIndex, chunkType, heading], index) => ({
key: `K${index + 1}`,
documentId: "knowledge-rain-night",
documentTitle: "《雨夜来电》原始小说素材",
chunkId: id,
chunkIndex,
heading,
sourceType: "novel",
scopeMode: "workspace",
rightsStatus: "approved",
riskStatus: "pass",
projectId: null,
chunkType
}));
const packChunks = openingChunks.map(([id, chunkIndex, chunkType, heading, body, tokenEstimate, keywords, entities], index) => ({
citationKey: citations[index].key,
id,
documentId: "knowledge-rain-night",
documentTitle: "《雨夜来电》原始小说素材",
chunkIndex,
chunkType,
heading,
content: body,
tokenEstimate,
keywords,
entities,
rightsStatus: "approved",
riskStatus: "pass"
}));
const packGovernance = {
status: "pass",
rights: { approved: packChunks.length },
risk: { pass: packChunks.length },
blockingCount: 0,
reviewCount: 0
};
const promptContext = [
"# 知识库上下文包:雨夜开场冲突",
"使用要求:基于引用素材做原创改编;保留人物、道具、地点和时间线连续性;生成画面仍必须是一张完整单画面,不得输出多格、拼图或分屏。",
"治理摘要:pass;未批准/需复核片段 0;阻断片段 0。",
...packChunks.map((chunk) => [
`## [${chunk.citationKey}] ${chunk.heading}`,
`来源:《${chunk.documentTitle}》 / novel / chunk ${chunk.chunkIndex} / rights=approved / risk=pass`,
`内容:${chunk.content}`
].join("\n"))
].join("\n\n");
insertIgnore(
`INSERT OR IGNORE INTO knowledge_context_packs(
id, organization_id, workspace_id, project_id, scope_mode, name, query, source_type, max_tokens,
token_estimate, chunk_ids_json, citations_json, chunks_json, prompt_context, status,
metadata_json, created_by, created_at, updated_at
) VALUES ('knowledge-pack-rain-night-opening', 'org-studio-lab', 'ws-local-aidrama', NULL, 'workspace', '雨夜开场冲突', '蓝伞 地铁口', 'novel', 800, ?, ?, ?, ?, ?, 'active', ?, 'u-owner', ?, ?)`,
[
packChunks.reduce((sum, chunk) => sum + Number(chunk.tokenEstimate || 0), 0),
JSON.stringify(packChunks.map((chunk) => chunk.id)),
JSON.stringify(citations),
JSON.stringify(packChunks),
promptContext,
JSON.stringify({ seed: true, usage: "script-draft", rights: "original", governance: packGovernance }),
timestamp,
timestamp
]
);
dbRun(
`UPDATE knowledge_context_packs
SET citations_json = ?, chunks_json = ?, prompt_context = ?, metadata_json = ?
WHERE id = 'knowledge-pack-rain-night-opening'`,
[JSON.stringify(citations), JSON.stringify(packChunks), promptContext, JSON.stringify({ seed: true, usage: "script-draft", rights: "original", governance: packGovernance })]
);
}
function seedSystemGovernance() {
const timestamp = now();
insertIgnore("INSERT OR IGNORE INTO system_admins(user_id, role_key, status, created_at, updated_at) VALUES ('u-owner', 'system_admin', 'active', ?, ?)", [timestamp, timestamp]);
@@ -577,6 +963,67 @@ function seedProductionGraph() {
for (const audit of audits) {
insertIgnore("INSERT OR IGNORE INTO audit_logs(id, organization_id, workspace_id, project_id, actor_user_id, action, target_type, target_id, result, metadata_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", [...audit, timestamp]);
}
insertIgnore("INSERT OR IGNORE INTO series(id, project_id, title, logline, format, visual_style, continuity_rule, show_engine, created_at, updated_at) VALUES (?, ?, ?, ?, 'vertical-9:16', ?, ?, ?, ?, ?)", [
"series-northstar-pilot",
"northstar-pilot",
"山海志异·试播集",
"北辰试制部用于验证多组织隔离的原创国漫试播项目。",
"原创国漫 2D 动画风格,竖屏 9:16,单一完整画面,干净线稿与克制动效。",
"角色、场景、道具、天气、镜头和声线全部随项目隔离,禁止串用其他组织素材。",
"以单镜头冲突推进试播片段,所有生成先经过本地 Runner 与审片门。",
timestamp,
timestamp
]);
insertIgnore("INSERT OR IGNORE INTO seasons(id, series_id, season_number, title, created_at, updated_at) VALUES (?, ?, 1, '第一季', ?, ?)", ["season-northstar-pilot-1", "series-northstar-pilot", timestamp, timestamp]);
insertIgnore("INSERT OR IGNORE INTO episodes(id, season_id, episode_number, title, status, target_duration_sec, hook, cliffhanger, created_at, updated_at) VALUES (?, ?, 1, '雾中石门', 'production', 45, '雾气里出现不该存在的石门。', '门后传来第二个自己的声音。', ?, ?)", ["episode-northstar-pilot-01", "season-northstar-pilot-1", timestamp, timestamp]);
const northstarShot = {
id: "shot-northstar-pilot-001",
title: "雾中石门出现",
durationSec: 6,
characterIds: ["northstar-yun"],
locationId: "northstar-fog-gate",
propIds: ["northstar-bronze-bell"],
camera: "稳定中景,角色在画面右侧停步,石门在远处雾中显现,单一连续画面。",
action: "少年抬手按住铜铃,雾气向石门方向收束,镜头不切分。",
firstFrame: "episode-start",
lastFrame: "pending-actual-last-frame",
transitionFromPrevious: "episode-start",
prompt: "ONE SINGLE COMPLETE 9:16 CHINESE ANIMATION FRAME, no panels, one scene only.",
negativePrompt: "no split screen, no comic panel, no collage, no contact sheet, no storyboard",
seed: 260831
};
insertIgnore("INSERT OR IGNORE INTO shots(id, episode_id, shot_number, title, status, first_frame_path, last_frame_path, continuity_json, created_at, updated_at) VALUES (?, ?, 1, ?, 'draft', ?, ?, ?, ?, ?)", [
northstarShot.id,
"episode-northstar-pilot-01",
northstarShot.title,
northstarShot.firstFrame,
northstarShot.lastFrame,
JSON.stringify({ camera: northstarShot.camera, transition: northstarShot.transitionFromPrevious }),
timestamp,
timestamp
]);
insertIgnore("INSERT OR IGNORE INTO shot_versions(id, shot_id, version_number, payload_json, status, created_by, created_at) VALUES (?, ?, 1, ?, 'draft', 'u-producer', ?)", [`${northstarShot.id}-v1`, northstarShot.id, JSON.stringify(northstarShot), timestamp]);
dbRun("UPDATE shots SET current_version_id = COALESCE(current_version_id, ?) WHERE id = ?", [`${northstarShot.id}-v1`, northstarShot.id]);
insertIgnore("INSERT OR IGNORE INTO generation_jobs(id, organization_id, workspace_id, project_id, episode_id, shot_id, kind, adapter_id, status, priority, cost_policy, output_path, qa_status, request_json, result_json, error_message, created_by, created_at, updated_at) VALUES (?, 'org-northstar', 'ws-northstar-main', 'northstar-pilot', 'episode-northstar-pilot-01', ?, '单画面关键帧', 'northstar-image', 'queued', 45, 'local', 'storage/jobs/northstar-pilot/shot-001/keyframe.png', 'wait', ?, '{}', '', 'u-producer', ?, ?)", [
"job-northstar-pilot-keyframe-001",
northstarShot.id,
JSON.stringify({ schema: "ai-drama.job.v1", seeded: true, job: { adapterId: "northstar-image" }, constraints: { singleFrameOnly: true, imageOutputCount: 1 } }),
timestamp,
timestamp
]);
insertIgnore("INSERT OR IGNORE INTO reviews(id, organization_id, workspace_id, project_id, shot_id, lane, status, score, evidence_json, created_at, updated_at) VALUES (?, 'org-northstar', 'ws-northstar-main', 'northstar-pilot', ?, 'single-frame', 'pending', NULL, ?, ?, ?)", [
"review-northstar-pilot-single-frame",
northstarShot.id,
JSON.stringify({ blockers: ["等待北辰审片员确认一图一画面证据。"], source: "seed" }),
timestamp,
timestamp
]);
insertIgnore("INSERT OR IGNORE INTO deliveries(id, organization_id, workspace_id, project_id, version, manifest_path, channel, status, created_at, updated_at) VALUES (?, 'org-northstar', 'ws-northstar-main', 'northstar-pilot', 'v0.1-internal', 'storage/deliveries/northstar-pilot/v0.1/delivery-manifest.json', 'internal', 'review', ?, ?)", [
"delivery-northstar-pilot-v01",
timestamp,
timestamp
]);
}
withTransaction(() => {
@@ -614,11 +1061,17 @@ withTransaction(() => {
risk: "medium"
});
insertIgnore("INSERT OR IGNORE INTO workspaces(id, organization_id, name, slug, description, status, created_at, updated_at) VALUES ('ws-pilot', 'org-studio-lab', '素材实验室', 'asset-lab', '角色、场景和模型试验空间', 'active', ?, ?)", [now(), now()]);
seedCommercialPlans();
seedOrganizationEntitlements();
seedCommercialApprovals();
seedMembersAndProjects();
seedModels();
seedModelCatalog();
seedModelRoutes();
seedSystemGovernance();
seedWorkflowTemplates();
seedProductionGraph();
seedKnowledgeLibrary();
seedUserNotifications();
});
+2 -1
View File
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
import { createHash, randomBytes } from "node:crypto";
import { basename, dirname, relative, resolve } from "node:path";
import { dbAll, dbGet, dbRun } from "./db.mjs";
import { addAudit, hasPermission, httpError, requirePermission } from "./tenant.mjs";
import { addAudit, hasPermission, httpError, requireEntitlement, requirePermission } from "./tenant.mjs";
const projectRoot = resolve(import.meta.dirname, "..");
const defaultExpiryDays = 7;
@@ -282,6 +282,7 @@ export function listDeliveryAccessLinks(context, releaseId) {
export function createDeliveryAccessLink(context, releaseId, body = {}, options = {}) {
requirePermission(context, "delivery:approve");
requireEntitlement(context, "feature.private_delivery_portal", 1);
const release = releaseForContext(context, releaseId);
if (release.status !== "published" || !release.output_path) {
throw httpError(409, "delivery_access_release_not_published", "只有已发布且存在发布产物的版本可以创建客户访问链接", { releaseId, status: release.status });
+837 -22
View File
@@ -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;
+1232
View File
File diff suppressed because it is too large Load Diff
+317 -7
View File
@@ -15,9 +15,11 @@ import {
buildContextPayload,
createAsset,
createAssetVersion,
ensureOrganizationEntitlements,
getAsset,
httpError,
orgMembers,
organizationEntitlements,
organizationRolePolicies,
updateOrganizationRolePolicy,
previewInvitation,
@@ -41,7 +43,11 @@ import {
apiClients,
identityCenter,
publicIdentityProviders,
createModelCatalogEntry,
createModelRoute,
updateIdentityPolicy,
updateModelCatalogEntry,
updateModelRoute,
saveIdentityProvider,
probeIdentityProvider,
createDirectorySync,
@@ -59,8 +65,10 @@ import {
scimPatchUser,
scimDeleteUser,
serviceHealth,
scanAssetGovernance,
updateAssetLock,
updateAssetRights,
listAssetGovernanceReviews,
restoreAssetVersion,
updateServiceHealth,
usageSummary,
@@ -82,7 +90,17 @@ import {
workspaceMembers,
listAuditEvents,
getAuditEvent,
exportAuditEvents
exportAuditEvents,
scopedModelCatalog,
scopedModelRoutes,
subscriptionPlanTemplates,
createSubscriptionPlanTemplate,
updateSubscriptionPlanTemplate,
updateOrganizationEntitlement,
requireEntitlement,
listCommercialApprovalRequests,
createCommercialApprovalRequest,
decideCommercialApprovalRequest
} from "./tenant.mjs";
import { platformData, platformResearchMatrix } from "../src/platform/platformData.js";
import { authMode, authenticate, cancelMfaSetup, changePassword, completeMfaChallenge, completeMfaEnrollment, createMfaChallenge, createMfaEnrollmentChallenge, createSession, disableMfa, enableMfa, listSecurityEvents, listUserDevices, listUserSessions, mfaRequiredForUser, mfaStatus, recordSecurityEvent, revokeAllUserSessions, revokeOtherUserSessions, revokeSession, revokeUserSession, safeUser, sessionIdentity, startMfaEnrollment, startMfaSetup, trustUserDevice, untrustUserDevice } from "./auth.mjs";
@@ -99,6 +117,7 @@ import {
createSeason,
createShot,
decideReview,
getDeliveryClearance,
importScript,
listDeliveryBatches,
listDeliveryChannels,
@@ -112,6 +131,7 @@ import {
runMediaQa,
restoreShotVersion,
rollbackDeliveryBatch,
runDeliveryClearance,
publishDeliveryRelease,
decideDeliveryRelease,
savePromptVersion,
@@ -123,11 +143,16 @@ import {
} from "./production.mjs";
import {
createGenerationJob,
previewGenerationJob,
executeGenerationJob,
getGenerationJob,
listGenerationJobs,
decideModelRouteApproval,
endpointInfo,
listModelRouteApprovalRequests,
previewModelRouteResolution,
probeModelConnector,
requestModelRouteApproval,
updateModelConnector
} from "./execution.mjs";
import { reclaimStorage, requireStorageQuota, storageCleanupPreview, storageSummary } from "./storage.mjs";
@@ -145,6 +170,23 @@ import { addTaskLink, createProjectTask, createTaskComment, getProjectTask, list
import { searchPlatform } from "./search.mjs";
import { consumeRateLimit, rateLimitHeaders, rateLimitIdentity } from "./rate-limit.mjs";
import { createDeliveryAccessLink, listDeliveryAccessFeedback, listDeliveryAccessLinks, readPublicDeliveryFile, resolvePublicDeliveryPortal, revokeDeliveryAccessLink, submitPublicDeliveryFeedback } from "./delivery-portal.mjs";
import {
archiveKnowledgeDocument,
createKnowledgeContextPack,
getKnowledgeContextPack,
getKnowledgeDocument,
importKnowledgeDocument,
listKnowledgeDocumentVersions,
listKnowledgeContextPacks,
listKnowledgeDocuments,
materializeKnowledgeContextPack,
materializeKnowledgeDocument,
restoreKnowledgeDocument,
restoreKnowledgeDocumentVersion,
reviewKnowledgeDocument,
updateKnowledgeDocument,
searchKnowledge
} from "./knowledge.mjs";
const port = Number(process.env.AI_DRAMA_API_PORT || 8787);
const root = resolve(import.meta.dirname, "..");
@@ -667,6 +709,7 @@ function createOrganization(context, body) {
dbRun("INSERT INTO quota_allocations(id, organization_id, workspace_id, metric, limit_value, used_value, unit, period_start, period_end, created_at, updated_at) VALUES (?, ?, ?, 'clip', 2400, 0, 'clips', datetime('now', 'start of month'), datetime('now', 'start of month', '+1 month', '-1 second'), ?, ?)", [createId("quota"), organizationId, workspaceId, timestamp, timestamp]);
dbRun("INSERT INTO audit_logs(id, organization_id, workspace_id, actor_user_id, action, target_type, target_id, result, metadata_json, created_at) VALUES (?, ?, ?, ?, 'organization.created', 'organization', ?, 'ok', ?, ?)", [createId("aud"), organizationId, workspaceId, context.user.id, organizationId, JSON.stringify({ createdFrom: context.organization.id }), timestamp]);
});
ensureOrganizationEntitlements(organizationId);
return dbGet("SELECT * FROM organizations WHERE id = ?", [organizationId]);
}
@@ -688,6 +731,7 @@ function updateOrganization(context, organizationId, body) {
function createWorkspace(context, body) {
requirePermission(context, "workspace:create");
requireEntitlement(context, "limit.workspaces", 1);
const name = String(body.name || "").trim();
if (!name) throw httpError(400, "name_required", "工作区名称不能为空");
const id = body.id || createId("ws");
@@ -742,6 +786,7 @@ function updateWorkspace(context, workspaceId, body) {
function createProject(context, body) {
requirePermission(context, "project:create");
requireEntitlement(context, "limit.projects", 1);
const name = String(body.name || "").trim();
if (!name) throw httpError(400, "name_required", "项目名称不能为空");
const id = body.id || createId("project");
@@ -836,7 +881,13 @@ async function uploadAssetVersion(context, assetId, body) {
contentSha256,
mimeType: body.mimeType || "application/octet-stream",
rightsStatus: body.rightsStatus || "needs-evidence",
versionNote: body.versionNote || "本地文件版本上传"
versionNote: body.versionNote || "本地文件版本上传",
provenance: body.provenance,
risk: body.risk,
tags: body.tags,
licenseScope: body.licenseScope,
expiresAt: body.expiresAt,
metadata: body.metadata
});
}
@@ -868,7 +919,7 @@ function mimeForPath(pathname) {
}
async function assetContent(context, assetId) {
if (!hasPermission(context, "asset:edit") && !hasPermission(context, "voice:edit")) {
if (!hasPermission(context, "asset:edit") && !hasPermission(context, "voice:edit") && !hasPermission(context, "compliance:manage")) {
throw httpError(403, "permission_denied", "当前角色没有资产预览权限");
}
const asset = getAsset(context, assetId);
@@ -894,7 +945,9 @@ async function assetContent(context, assetId) {
}
async function verifyAssetContent(context, assetId) {
requirePermission(context, "asset:edit");
if (!hasPermission(context, "asset:edit") && !hasPermission(context, "compliance:manage")) {
requirePermission(context, "asset:edit");
}
const asset = getAsset(context, assetId);
if (!asset) throw httpError(404, "asset_not_found", "资产不存在或不属于当前项目");
const version = asset.currentVersion;
@@ -1054,11 +1107,13 @@ function updateProjectMember(context, projectId, userId, body) {
function registerModel(context, body) {
requirePermission(context, "model:manage");
requireEntitlement(context, "limit.model_connectors", 1);
const id = body.id || createId("model");
const timestamp = new Date().toISOString();
const label = String(body.label || "未命名本地模型").trim();
const endpoint = String(body.endpoint || "http://127.0.0.1:7860").trim();
const costMode = String(body.costMode || "local").trim();
const kind = String(body.kind || "http-json").trim();
if (!label || !endpoint) throw httpError(400, "adapter_fields_required", "连接器名称和地址不能为空");
if (!["local", "mixed", "cloud"].includes(costMode)) throw httpError(400, "cost_mode_invalid", "成本策略无效");
const { local } = endpointInfo(endpoint);
@@ -1066,9 +1121,11 @@ function registerModel(context, body) {
const requestedApproval = Boolean(body.approvalRequired);
if (costMode !== "local" && !requestedApproval) throw httpError(400, "external_connector_approval_required", "混合或外部连接器必须开启审批策略");
const approvalRequired = costMode === "local" ? requestedApproval : true;
if (costMode !== "local") requireEntitlement(context, "feature.external_cloud_connectors", 1);
if (kind === "comfyui") requireEntitlement(context, "feature.comfyui_adapter", 1);
const protocol = body.protocol && typeof body.protocol === "object" ? body.protocol : {};
const authEnv = String(body.authEnv || protocol.authEnv || "").trim();
dbRun("INSERT INTO model_connectors(id, organization_id, workspace_id, label, kind, capabilities_json, endpoint, status, cost_mode, approval_required, protocol_json, auth_env, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'not-connected', ?, ?, ?, ?, ?, ?, ?)", [id, context.organization.id, context.workspace.id, label, body.kind || "http-json", JSON.stringify(body.capability || ["custom"]), endpoint, costMode, approvalRequired ? 1 : 0, JSON.stringify(protocol), authEnv, context.user.id, timestamp, timestamp]);
dbRun("INSERT INTO model_connectors(id, organization_id, workspace_id, label, kind, capabilities_json, endpoint, status, cost_mode, approval_required, protocol_json, auth_env, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, 'not-connected', ?, ?, ?, ?, ?, ?, ?)", [id, context.organization.id, context.workspace.id, label, kind, JSON.stringify(body.capability || ["custom"]), endpoint, costMode, approvalRequired ? 1 : 0, JSON.stringify(protocol), authEnv, context.user.id, timestamp, timestamp]);
addAudit({ context, action: "model.registered", targetType: "model_connector", targetId: id, metadata: { label, endpoint, costMode, approvalRequired } });
return parseModelRow(dbGet("SELECT * FROM model_connectors WHERE id = ?", [id]));
}
@@ -1077,6 +1134,7 @@ function systemConfigPayload() {
return {
settings: systemSettings(),
featureFlags: featureFlags(),
planTemplates: subscriptionPlanTemplates({ includeArchived: true }),
notifications: notificationChannels(),
notificationDeliveries: [],
apiClients: apiClients(),
@@ -1149,6 +1207,7 @@ function updateNotificationChannel(context, body) {
function createApiClient(context, body) {
requirePermission(context, "api_client:manage");
requireEntitlement(context, "limit.api_clients", 1);
const timestamp = new Date().toISOString();
const id = `client-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
const issued = issueApiClientKey();
@@ -1656,6 +1715,50 @@ createServer(async (req, res) => {
return send(res, 200, organizationCommercial(context));
}
const organizationEntitlementsMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/entitlements$/);
if (req.method === "GET" && organizationEntitlementsMatch) {
const organizationId = decodeURIComponent(organizationEntitlementsMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
if (!hasPermission(context, "usage:view") && !hasPermission(context, "billing:manage") && !hasPermission(context, "quota:manage")) {
throw httpError(403, "permission_denied", "当前角色没有查看组织套餐权益的权限");
}
return send(res, 200, organizationEntitlements(organizationId));
}
const organizationEntitlementMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/entitlements\/([^/]+)$/);
if (req.method === "PATCH" && organizationEntitlementMatch) {
const organizationId = decodeURIComponent(organizationEntitlementMatch[1]);
const entitlementKey = decodeURIComponent(organizationEntitlementMatch[2]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, updateOrganizationEntitlement(context, organizationId, entitlementKey, await readBody(req)));
}
const organizationCommercialApprovalsMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/commercial-approvals$/);
if (req.method === "GET" && organizationCommercialApprovalsMatch) {
const organizationId = decodeURIComponent(organizationCommercialApprovalsMatch[1]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, listCommercialApprovalRequests(context, organizationId, {
status: url.searchParams.get("status") || "",
type: url.searchParams.get("type") || "",
mine: url.searchParams.get("mine") === "1",
limit: url.searchParams.get("limit") || 80
}));
}
if (req.method === "POST" && organizationCommercialApprovalsMatch) {
const organizationId = decodeURIComponent(organizationCommercialApprovalsMatch[1]);
const context = resolveContext(req.headers, url.searchParams);
return send(res, 201, createCommercialApprovalRequest(context, organizationId, await readBody(req)));
}
const organizationCommercialApprovalDecisionMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/commercial-approvals\/([^/]+)\/decision$/);
if (req.method === "POST" && organizationCommercialApprovalDecisionMatch) {
const organizationId = decodeURIComponent(organizationCommercialApprovalDecisionMatch[1]);
const approvalId = decodeURIComponent(organizationCommercialApprovalDecisionMatch[2]);
const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
return send(res, 200, decideCommercialApprovalRequest(context, organizationId, approvalId, await readBody(req)));
}
const organizationCommercialExportMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/commercial\/export$/);
if (req.method === "GET" && organizationCommercialExportMatch) {
const organizationId = decodeURIComponent(organizationCommercialExportMatch[1]);
@@ -2045,7 +2148,7 @@ createServer(async (req, res) => {
if (req.method === "GET" && pathname === "/api/assets") {
const context = resolveContext(req.headers, url.searchParams);
if (!hasPermission(context, "asset:edit") && !hasPermission(context, "voice:edit")) {
if (!hasPermission(context, "asset:edit") && !hasPermission(context, "voice:edit") && !hasPermission(context, "compliance:manage")) {
throw httpError(403, "permission_denied", "当前角色没有资产库访问权限");
}
return send(res, 200, { assets: scopedAssets(context) });
@@ -2065,7 +2168,7 @@ createServer(async (req, res) => {
const assetMatch = pathname.match(/^\/api\/assets\/([^/]+)$/);
if (req.method === "GET" && assetMatch) {
const context = resolveContext(req.headers, url.searchParams);
if (!hasPermission(context, "asset:edit") && !hasPermission(context, "voice:edit")) {
if (!hasPermission(context, "asset:edit") && !hasPermission(context, "voice:edit") && !hasPermission(context, "compliance:manage")) {
throw httpError(403, "permission_denied", "当前角色没有资产库访问权限");
}
const asset = getAsset(context, decodeURIComponent(assetMatch[1]));
@@ -2098,6 +2201,18 @@ createServer(async (req, res) => {
return send(res, 200, { verification: await verifyAssetContent(context, decodeURIComponent(assetVerifyMatch[1])) });
}
const assetGovernanceMatch = pathname.match(/^\/api\/assets\/([^/]+)\/governance$/);
if (req.method === "GET" && assetGovernanceMatch) {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, listAssetGovernanceReviews(context, decodeURIComponent(assetGovernanceMatch[1])));
}
const assetGovernanceScanMatch = pathname.match(/^\/api\/assets\/([^/]+)\/governance\/scan$/);
if (req.method === "POST" && assetGovernanceScanMatch) {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, scanAssetGovernance(context, decodeURIComponent(assetGovernanceScanMatch[1]), await readBody(req)));
}
const assetVersionRestoreMatch = pathname.match(/^\/api\/assets\/([^/]+)\/versions\/([^/]+)\/restore$/);
if (req.method === "POST" && assetVersionRestoreMatch) {
const context = resolveContext(req.headers, url.searchParams);
@@ -2144,6 +2259,20 @@ createServer(async (req, res) => {
return send(res, 200, { models: scopedModels(context), runners: runtimeRunners() });
}
if (req.method === "GET" && pathname === "/api/platform/model-catalog") {
const context = resolveContext(req.headers, url.searchParams);
if (context.apiClient) requireApiScope(context, "models:read");
else requirePermission(context, "model:manage");
return send(res, 200, { catalog: scopedModelCatalog(context), connectors: scopedModels(context) });
}
if (req.method === "GET" && pathname === "/api/platform/model-routes") {
const context = resolveContext(req.headers, url.searchParams);
if (context.apiClient) requireApiScope(context, "models:read");
else requirePermission(context, "model:manage");
return send(res, 200, { routes: scopedModelRoutes(context), catalog: scopedModelCatalog(context) });
}
const modelConnectorMatch = pathname.match(/^\/api\/platform\/models\/([^/]+)$/);
if (req.method === "PATCH" && modelConnectorMatch) {
const context = resolveContext(req.headers, url.searchParams);
@@ -2396,6 +2525,23 @@ createServer(async (req, res) => {
return send(res, 200, { featureFlags: featureFlags() });
}
if (req.method === "GET" && pathname === "/api/system/plan-templates") {
const context = resolveContext(req.headers, url.searchParams);
requirePermission(context, "system:settings:view");
return send(res, 200, { planTemplates: subscriptionPlanTemplates({ includeArchived: true }) });
}
if (req.method === "POST" && pathname === "/api/system/plan-templates") {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 201, createSubscriptionPlanTemplate(context, await readBody(req)));
}
const planTemplateMatch = pathname.match(/^\/api\/system\/plan-templates\/([^/]+)$/);
if (req.method === "PATCH" && planTemplateMatch) {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, updateSubscriptionPlanTemplate(context, decodeURIComponent(planTemplateMatch[1]), await readBody(req)));
}
if (req.method === "POST" && pathname === "/api/system/feature-flags") {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, { featureFlags: updateFeatureFlag(context, await readBody(req)) });
@@ -2457,6 +2603,154 @@ createServer(async (req, res) => {
return send(res, 201, { model, models: scopedModels(context) });
}
if (req.method === "POST" && pathname === "/api/platform/model-catalog") {
const context = resolveContext(req.headers, url.searchParams);
if (context.apiClient) requireApiScope(context, "models:write");
const entry = createModelCatalogEntry(context, await readBody(req));
return send(res, 201, { entry, catalog: scopedModelCatalog(context) });
}
const modelCatalogMatch = pathname.match(/^\/api\/platform\/model-catalog\/([^/]+)$/);
if (req.method === "PATCH" && modelCatalogMatch) {
const context = resolveContext(req.headers, url.searchParams);
if (context.apiClient) requireApiScope(context, "models:write");
const entry = updateModelCatalogEntry(context, decodeURIComponent(modelCatalogMatch[1]), await readBody(req));
return send(res, 200, { entry, catalog: scopedModelCatalog(context) });
}
if (req.method === "POST" && pathname === "/api/platform/model-routes") {
const context = resolveContext(req.headers, url.searchParams);
if (context.apiClient) requireApiScope(context, "models:write");
const route = createModelRoute(context, await readBody(req));
return send(res, 201, { route, routes: scopedModelRoutes(context) });
}
if (req.method === "POST" && pathname === "/api/platform/model-routes/resolve") {
const context = resolveContext(req.headers, url.searchParams);
if (context.apiClient) requireApiScope(context, "models:read");
return send(res, 200, { resolution: previewModelRouteResolution(context, await readBody(req)) });
}
if (req.method === "GET" && pathname === "/api/platform/model-route-approvals") {
const context = resolveContext(req.headers, url.searchParams);
if (context.apiClient) requireApiScope(context, "models:read");
return send(res, 200, listModelRouteApprovalRequests(context, {
status: url.searchParams.get("status") || "",
mine: url.searchParams.get("mine") === "1",
limit: url.searchParams.get("limit") || 80
}));
}
if (req.method === "POST" && pathname === "/api/platform/model-route-approvals") {
const context = resolveContext(req.headers, url.searchParams);
if (context.apiClient) requireApiScope(context, "models:write");
return send(res, 201, requestModelRouteApproval(context, await readBody(req)));
}
const modelRouteApprovalDecisionMatch = pathname.match(/^\/api\/platform\/model-route-approvals\/([^/]+)\/decision$/);
if (req.method === "POST" && modelRouteApprovalDecisionMatch) {
const context = resolveContext(req.headers, url.searchParams);
if (context.apiClient) requireApiScope(context, "models:write");
return send(res, 200, decideModelRouteApproval(context, decodeURIComponent(modelRouteApprovalDecisionMatch[1]), await readBody(req)));
}
const modelRouteMatch = pathname.match(/^\/api\/platform\/model-routes\/([^/]+)$/);
if (req.method === "PATCH" && modelRouteMatch) {
const context = resolveContext(req.headers, url.searchParams);
if (context.apiClient) requireApiScope(context, "models:write");
const route = updateModelRoute(context, decodeURIComponent(modelRouteMatch[1]), await readBody(req));
return send(res, 200, { route, routes: scopedModelRoutes(context) });
}
if (req.method === "GET" && pathname === "/api/knowledge/library") {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, listKnowledgeDocuments(context));
}
if (req.method === "POST" && pathname === "/api/knowledge/library/import") {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 201, importKnowledgeDocument(context, await readBody(req)));
}
if (req.method === "GET" && pathname === "/api/knowledge/search") {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, searchKnowledge(context, {
query: url.searchParams.get("q") || url.searchParams.get("query") || "",
sourceType: url.searchParams.get("sourceType") || url.searchParams.get("source_type") || "all",
scopeMode: url.searchParams.get("scopeMode") || url.searchParams.get("scope_mode") || "all",
limit: url.searchParams.get("limit") || 24
}));
}
if (req.method === "GET" && pathname === "/api/knowledge/context-packs") {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, listKnowledgeContextPacks(context));
}
if (req.method === "POST" && pathname === "/api/knowledge/context-packs") {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 201, createKnowledgeContextPack(context, await readBody(req)));
}
const knowledgePackMatch = pathname.match(/^\/api\/knowledge\/context-packs\/([^/]+)$/);
if (req.method === "GET" && knowledgePackMatch) {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, getKnowledgeContextPack(context, decodeURIComponent(knowledgePackMatch[1])));
}
const knowledgePackMaterializeMatch = pathname.match(/^\/api\/knowledge\/context-packs\/([^/]+)\/materialize$/);
if (req.method === "POST" && knowledgePackMaterializeMatch) {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 201, materializeKnowledgeContextPack(context, decodeURIComponent(knowledgePackMaterializeMatch[1]), await readBody(req)));
}
const knowledgeDocumentMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)$/);
if (req.method === "GET" && knowledgeDocumentMatch) {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, getKnowledgeDocument(context, decodeURIComponent(knowledgeDocumentMatch[1])));
}
if (req.method === "PATCH" && knowledgeDocumentMatch) {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, updateKnowledgeDocument(context, decodeURIComponent(knowledgeDocumentMatch[1]), await readBody(req)));
}
const knowledgeReviewMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/review$/);
if (req.method === "POST" && knowledgeReviewMatch) {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 201, reviewKnowledgeDocument(context, decodeURIComponent(knowledgeReviewMatch[1]), await readBody(req)));
}
const knowledgeArchiveMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/archive$/);
if (req.method === "POST" && knowledgeArchiveMatch) {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, archiveKnowledgeDocument(context, decodeURIComponent(knowledgeArchiveMatch[1])));
}
const knowledgeRestoreMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/restore$/);
if (req.method === "POST" && knowledgeRestoreMatch) {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, restoreKnowledgeDocument(context, decodeURIComponent(knowledgeRestoreMatch[1])));
}
const knowledgeVersionsMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/versions$/);
if (req.method === "GET" && knowledgeVersionsMatch) {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, listKnowledgeDocumentVersions(context, decodeURIComponent(knowledgeVersionsMatch[1])));
}
const knowledgeVersionRestoreMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/versions\/([^/]+)\/restore$/);
if (req.method === "POST" && knowledgeVersionRestoreMatch) {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, restoreKnowledgeDocumentVersion(context, decodeURIComponent(knowledgeVersionRestoreMatch[1]), decodeURIComponent(knowledgeVersionRestoreMatch[2])));
}
const knowledgeMaterializeMatch = pathname.match(/^\/api\/knowledge\/library\/([^/]+)\/materialize$/);
if (req.method === "POST" && knowledgeMaterializeMatch) {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 201, materializeKnowledgeDocument(context, decodeURIComponent(knowledgeMaterializeMatch[1]), await readBody(req)));
}
if (req.method === "GET" && pathname === "/api/qa") {
const context = resolveContext(req.headers, url.searchParams);
requirePermission(context, "qa:review");
@@ -2581,6 +2875,16 @@ createServer(async (req, res) => {
return send(res, 201, createDelivery(context, await readBody(req)));
}
const deliveryClearanceMatch = pathname.match(/^\/api\/production\/deliveries\/([^/]+)\/clearance$/);
if (req.method === "GET" && deliveryClearanceMatch) {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 200, getDeliveryClearance(context, decodeURIComponent(deliveryClearanceMatch[1])));
}
if (req.method === "POST" && deliveryClearanceMatch) {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 201, runDeliveryClearance(context, decodeURIComponent(deliveryClearanceMatch[1]), await readBody(req)));
}
const deliveryReleasesMatch = pathname.match(/^\/api\/production\/deliveries\/([^/]+)\/releases$/);
if (req.method === "GET" && deliveryReleasesMatch) {
const context = resolveContext(req.headers, url.searchParams);
@@ -2659,6 +2963,12 @@ createServer(async (req, res) => {
return send(res, 201, await createGenerationJob(context, await readBody(req)));
}
if (req.method === "POST" && pathname === "/api/jobs/preview") {
const context = resolveContext(req.headers, url.searchParams);
if (context.apiClient) requireApiScope(context, "jobs:read");
return send(res, 200, previewGenerationJob(context, await readBody(req)));
}
const jobDetailMatch = pathname.match(/^\/api\/jobs\/([^/]+)$/);
if (req.method === "GET" && jobDetailMatch) {
const context = resolveContext(req.headers, url.searchParams);
+656 -62
View File
@@ -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 };
}
+286
View File
@@ -463,6 +463,70 @@ CREATE TABLE IF NOT EXISTS billing_account_events (
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS subscription_plan_templates (
id TEXT PRIMARY KEY,
tier_key TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
billing_cycle TEXT NOT NULL DEFAULT 'monthly' CHECK (billing_cycle IN ('monthly', 'quarterly', 'annual')),
currency TEXT NOT NULL DEFAULT 'CNY',
base_fee REAL NOT NULL DEFAULT 0,
seat_limit INTEGER NOT NULL DEFAULT 1,
storage_gb INTEGER NOT NULL DEFAULT 1,
monthly_clip_quota INTEGER NOT NULL DEFAULT 1,
limits_json TEXT NOT NULL DEFAULT '{}',
features_json TEXT NOT NULL DEFAULT '{}',
connector_policy_json TEXT NOT NULL DEFAULT '{}',
support_sla TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'archived')),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS organization_entitlements (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
entitlement_key TEXT NOT NULL,
label TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'general',
limit_value REAL NOT NULL DEFAULT 0,
unit TEXT NOT NULL DEFAULT '项',
enabled INTEGER NOT NULL DEFAULT 1,
enforcement TEXT NOT NULL DEFAULT 'block' CHECK (enforcement IN ('block', 'warn', 'off')),
source TEXT NOT NULL DEFAULT 'plan' CHECK (source IN ('plan', 'override')),
override_reason TEXT NOT NULL DEFAULT '',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (organization_id, entitlement_key)
);
CREATE TABLE IF NOT EXISTS commercial_approval_requests (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
workspace_id TEXT REFERENCES workspaces(id) ON DELETE SET NULL,
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
request_type TEXT NOT NULL CHECK (request_type IN ('entitlement_overage', 'feature_enablement', 'budget_increase', 'external_connector', 'compliance_review', 'delivery_exception', 'storage_retention')),
title TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'submitted' CHECK (status IN ('submitted', 'approved', 'rejected', 'cancelled')),
priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('low', 'medium', 'high', 'urgent')),
target_key TEXT NOT NULL DEFAULT '',
current_value REAL NOT NULL DEFAULT 0,
requested_value REAL NOT NULL DEFAULT 0,
unit TEXT NOT NULL DEFAULT '',
business_reason TEXT NOT NULL DEFAULT '',
risk_assessment_json TEXT NOT NULL DEFAULT '{}',
evidence_json TEXT NOT NULL DEFAULT '{}',
decision_note TEXT NOT NULL DEFAULT '',
effect_json TEXT NOT NULL DEFAULT '{}',
requester_user_id TEXT NOT NULL REFERENCES users(id),
reviewer_user_id TEXT REFERENCES users(id),
reviewed_at TEXT,
expires_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
-- Local invoice ledger. This is intentionally payment-provider agnostic: the
-- platform records billing snapshots and lifecycle state, while an external
-- accounting or payment system can be connected later through an adapter.
@@ -508,6 +572,11 @@ CREATE TABLE IF NOT EXISTS invoice_lines (
CREATE INDEX IF NOT EXISTS idx_organization_invoices_org_period ON organization_invoices(organization_id, period_end DESC, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_organization_invoices_org_status ON organization_invoices(organization_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_invoice_lines_invoice_sort ON invoice_lines(invoice_id, sort_order, created_at);
CREATE INDEX IF NOT EXISTS idx_subscription_plan_templates_status ON subscription_plan_templates(status, tier_key);
CREATE INDEX IF NOT EXISTS idx_organization_entitlements_org_category ON organization_entitlements(organization_id, category, entitlement_key);
CREATE INDEX IF NOT EXISTS idx_commercial_approvals_org_status ON commercial_approval_requests(organization_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_commercial_approvals_requester ON commercial_approval_requests(requester_user_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_commercial_approvals_target ON commercial_approval_requests(organization_id, request_type, target_key, status);
CREATE TABLE IF NOT EXISTS cost_centers (
id TEXT PRIMARY KEY,
@@ -573,6 +642,73 @@ CREATE TABLE IF NOT EXISTS model_connectors (
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS model_catalog_entries (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
connector_id TEXT REFERENCES model_connectors(id) ON DELETE SET NULL,
model_key TEXT NOT NULL,
display_name TEXT NOT NULL,
family TEXT NOT NULL DEFAULT '',
capabilities_json TEXT NOT NULL DEFAULT '[]',
context_window INTEGER NOT NULL DEFAULT 0,
max_output_tokens INTEGER NOT NULL DEFAULT 0,
cost_json TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'active',
approval_status TEXT NOT NULL DEFAULT 'approved',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (organization_id, workspace_id, connector_id, model_key)
);
CREATE TABLE IF NOT EXISTS model_routing_policies (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
workflow_key TEXT NOT NULL,
operation_key TEXT NOT NULL,
primary_model_id TEXT REFERENCES model_catalog_entries(id) ON DELETE SET NULL,
fallback_model_id TEXT REFERENCES model_catalog_entries(id) ON DELETE SET NULL,
policy_mode TEXT NOT NULL DEFAULT 'prefer-local',
approval_mode TEXT NOT NULL DEFAULT 'follow-model',
budget_limit_cny REAL NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
policy_json TEXT NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS model_route_approval_requests (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
project_id TEXT REFERENCES projects(id) ON DELETE SET NULL,
job_id TEXT REFERENCES generation_jobs(id) ON DELETE SET NULL,
route_id TEXT REFERENCES model_routing_policies(id) ON DELETE SET NULL,
model_entry_id TEXT REFERENCES model_catalog_entries(id) ON DELETE SET NULL,
connector_id TEXT REFERENCES model_connectors(id) ON DELETE SET NULL,
requester_user_id TEXT NOT NULL REFERENCES users(id),
reviewer_user_id TEXT REFERENCES users(id),
status TEXT NOT NULL DEFAULT 'submitted' CHECK (status IN ('submitted', 'approved', 'rejected', 'cancelled', 'expired')),
approval_scope TEXT NOT NULL DEFAULT 'single-run' CHECK (approval_scope IN ('single-run', 'single-job', 'route-window', 'connector-window')),
reason TEXT NOT NULL DEFAULT '',
decision_note TEXT NOT NULL DEFAULT '',
request_json TEXT NOT NULL DEFAULT '{}',
resolution_json TEXT NOT NULL DEFAULT '{}',
guard_json TEXT NOT NULL DEFAULT '{}',
estimated_cost_cny REAL NOT NULL DEFAULT 0,
local_only INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
reviewed_at TEXT,
consumed_at TEXT,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS series (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE,
@@ -622,12 +758,110 @@ CREATE TABLE IF NOT EXISTS script_documents (
content TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
analysis_json TEXT NOT NULL DEFAULT '{}',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (project_id, version_number)
);
CREATE TABLE IF NOT EXISTS knowledge_documents (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
scope_mode TEXT NOT NULL DEFAULT 'workspace',
title TEXT NOT NULL,
source_type TEXT NOT NULL DEFAULT 'novel',
language TEXT NOT NULL DEFAULT 'zh-CN',
content TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'ingested',
rights_status TEXT NOT NULL DEFAULT 'needs-evidence',
summary TEXT NOT NULL DEFAULT '',
analysis_json TEXT NOT NULL DEFAULT '{}',
provenance_json TEXT NOT NULL DEFAULT '{}',
tags_json TEXT NOT NULL DEFAULT '[]',
risk_json TEXT NOT NULL DEFAULT '{}',
metadata_json TEXT NOT NULL DEFAULT '{}',
current_version_number INTEGER NOT NULL DEFAULT 1,
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS knowledge_chunks (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES knowledge_documents(id) ON DELETE CASCADE,
chunk_index INTEGER NOT NULL,
chunk_type TEXT NOT NULL DEFAULT 'scene',
heading TEXT NOT NULL DEFAULT '',
content TEXT NOT NULL DEFAULT '',
token_estimate INTEGER NOT NULL DEFAULT 0,
keywords_json TEXT NOT NULL DEFAULT '[]',
entities_json TEXT NOT NULL DEFAULT '{}',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL,
UNIQUE (document_id, chunk_index)
);
CREATE TABLE IF NOT EXISTS knowledge_document_versions (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES knowledge_documents(id) ON DELETE CASCADE,
version_number INTEGER NOT NULL,
title TEXT NOT NULL,
source_type TEXT NOT NULL DEFAULT 'novel',
language TEXT NOT NULL DEFAULT 'zh-CN',
content TEXT NOT NULL DEFAULT '',
summary TEXT NOT NULL DEFAULT '',
analysis_json TEXT NOT NULL DEFAULT '{}',
provenance_json TEXT NOT NULL DEFAULT '{}',
tags_json TEXT NOT NULL DEFAULT '[]',
risk_json TEXT NOT NULL DEFAULT '{}',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
UNIQUE (document_id, version_number)
);
CREATE TABLE IF NOT EXISTS knowledge_governance_reviews (
id TEXT PRIMARY KEY,
document_id TEXT NOT NULL REFERENCES knowledge_documents(id) ON DELETE CASCADE,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
decision TEXT NOT NULL DEFAULT 'submitted',
rights_status TEXT NOT NULL DEFAULT 'needs-evidence',
risk_status TEXT NOT NULL DEFAULT 'review',
notes TEXT NOT NULL DEFAULT '',
evidence_ref TEXT NOT NULL DEFAULT '',
provenance_json TEXT NOT NULL DEFAULT '{}',
risk_json TEXT NOT NULL DEFAULT '{}',
reviewer_user_id TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS knowledge_context_packs (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
project_id TEXT REFERENCES projects(id) ON DELETE CASCADE,
scope_mode TEXT NOT NULL DEFAULT 'workspace',
name TEXT NOT NULL,
query TEXT NOT NULL DEFAULT '',
source_type TEXT NOT NULL DEFAULT '',
max_tokens INTEGER NOT NULL DEFAULT 1600,
token_estimate INTEGER NOT NULL DEFAULT 0,
chunk_ids_json TEXT NOT NULL DEFAULT '[]',
citations_json TEXT NOT NULL DEFAULT '[]',
chunks_json TEXT NOT NULL DEFAULT '[]',
prompt_context TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'active',
metadata_json TEXT NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS assets (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
@@ -650,12 +884,35 @@ CREATE TABLE IF NOT EXISTS asset_versions (
file_size INTEGER NOT NULL DEFAULT 0,
content_sha256 TEXT NOT NULL DEFAULT '',
rights_status TEXT NOT NULL DEFAULT 'needs-evidence',
provenance_json TEXT NOT NULL DEFAULT '{}',
risk_json TEXT NOT NULL DEFAULT '{}',
tags_json TEXT NOT NULL DEFAULT '[]',
license_scope TEXT NOT NULL DEFAULT '',
expires_at TEXT,
metadata_json TEXT NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
UNIQUE (asset_id, version_number)
);
CREATE TABLE IF NOT EXISTS asset_governance_reviews (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
asset_id TEXT NOT NULL REFERENCES assets(id) ON DELETE CASCADE,
version_id TEXT NOT NULL REFERENCES asset_versions(id) ON DELETE CASCADE,
reviewer_user_id TEXT NOT NULL REFERENCES users(id),
decision TEXT NOT NULL,
rights_status TEXT NOT NULL,
risk_status TEXT NOT NULL DEFAULT 'review',
notes TEXT NOT NULL DEFAULT '',
evidence_ref TEXT NOT NULL DEFAULT '',
provenance_json TEXT NOT NULL DEFAULT '{}',
risk_json TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS shots (
id TEXT PRIMARY KEY,
episode_id TEXT NOT NULL REFERENCES episodes(id) ON DELETE CASCADE,
@@ -728,6 +985,7 @@ CREATE TABLE IF NOT EXISTS generation_jobs (
result_json TEXT NOT NULL DEFAULT '{}',
error_message TEXT NOT NULL DEFAULT '',
max_attempts INTEGER NOT NULL DEFAULT 3,
model_route_approval_id TEXT REFERENCES model_route_approval_requests(id) ON DELETE SET NULL,
next_run_at TEXT,
leased_by TEXT,
leased_at TEXT,
@@ -897,6 +1155,23 @@ CREATE TABLE IF NOT EXISTS delivery_releases (
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS delivery_clearance_reports (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
delivery_id TEXT NOT NULL REFERENCES deliveries(id) ON DELETE CASCADE,
batch_id TEXT REFERENCES delivery_batches(id) ON DELETE SET NULL,
release_id TEXT REFERENCES delivery_releases(id) ON DELETE SET NULL,
status TEXT NOT NULL DEFAULT 'review' CHECK (status IN ('pass', 'review', 'blocked')),
blocker_count INTEGER NOT NULL DEFAULT 0,
review_count INTEGER NOT NULL DEFAULT 0,
certificate_path TEXT NOT NULL DEFAULT '',
report_json TEXT NOT NULL DEFAULT '{}',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL
);
-- External delivery portals are scoped to one published release. The bearer
-- token is never stored in plaintext; only its SHA-256 digest is persisted.
CREATE TABLE IF NOT EXISTS delivery_access_links (
@@ -1190,11 +1465,20 @@ CREATE INDEX IF NOT EXISTS idx_org_members_user ON organization_members(user_id,
CREATE INDEX IF NOT EXISTS idx_workspace_members_user ON workspace_members(user_id, status);
CREATE INDEX IF NOT EXISTS idx_projects_workspace ON projects(workspace_id, status);
CREATE INDEX IF NOT EXISTS idx_script_documents_project ON script_documents(project_id, version_number DESC);
CREATE INDEX IF NOT EXISTS idx_knowledge_documents_scope ON knowledge_documents(organization_id, workspace_id, project_id, scope_mode, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_knowledge_chunks_document ON knowledge_chunks(document_id, chunk_index);
CREATE INDEX IF NOT EXISTS idx_knowledge_document_versions_document ON knowledge_document_versions(document_id, version_number DESC);
CREATE INDEX IF NOT EXISTS idx_knowledge_governance_reviews_document ON knowledge_governance_reviews(document_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_knowledge_context_packs_scope ON knowledge_context_packs(organization_id, workspace_id, project_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_shot_versions_shot ON shot_versions(shot_id, version_number DESC);
CREATE INDEX IF NOT EXISTS idx_voice_lines_shot ON voice_lines(shot_id, line_number);
CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members(user_id, status);
CREATE INDEX IF NOT EXISTS idx_org_role_permissions_scope ON organization_role_permissions(organization_id, role_key, permission_key);
CREATE INDEX IF NOT EXISTS idx_model_catalog_entries_scope ON model_catalog_entries(organization_id, workspace_id, connector_id, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_model_routing_policies_scope ON model_routing_policies(organization_id, workspace_id, workflow_key, operation_key, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_asset_bindings_shot ON asset_bindings(shot_id, usage_role);
CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_asset ON asset_governance_reviews(asset_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_asset_governance_reviews_scope ON asset_governance_reviews(organization_id, workspace_id, project_id, risk_status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_jobs_scope ON generation_jobs(organization_id, workspace_id, project_id, status);
CREATE INDEX IF NOT EXISTS idx_usage_scope ON usage_events(organization_id, workspace_id, project_id, created_at);
CREATE INDEX IF NOT EXISTS idx_audit_scope ON audit_logs(organization_id, workspace_id, project_id, created_at);
@@ -1220,6 +1504,8 @@ CREATE INDEX IF NOT EXISTS idx_delivery_batch_items_batch ON delivery_batch_item
CREATE INDEX IF NOT EXISTS idx_delivery_channels_scope ON delivery_channels(organization_id, workspace_id, project_id, enabled, created_at);
CREATE INDEX IF NOT EXISTS idx_delivery_releases_scope ON delivery_releases(organization_id, workspace_id, project_id, delivery_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_delivery_releases_idempotency ON delivery_releases(organization_id, project_id, idempotency_key);
CREATE INDEX IF NOT EXISTS idx_delivery_clearance_reports_scope ON delivery_clearance_reports(organization_id, workspace_id, project_id, delivery_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_delivery_clearance_reports_release ON delivery_clearance_reports(release_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_delivery_access_links_token ON delivery_access_links(token_hash, status, expires_at);
CREATE INDEX IF NOT EXISTS idx_delivery_access_links_scope ON delivery_access_links(organization_id, workspace_id, project_id, release_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_delivery_access_events_link ON delivery_access_events(link_id, created_at DESC);
+44 -2
View File
@@ -102,6 +102,48 @@ export function searchPlatform(context, { query = "", scope = "workspace", limit
results.push(baseResult("script", "剧本", row, "script", row.title, `${row.project_name}${row.episode_title ? ` · ${row.episode_title}` : ""} · v${row.version_number}`, row.status));
}
const knowledgeScopeClause = scope === "project" && context?.project?.id
? " AND (kd.project_id IS NULL OR kd.project_id = ?)"
: projectIds.length
? ` AND (kd.project_id IS NULL OR kd.project_id IN (${ids}))`
: " AND kd.project_id IS NULL";
const knowledgeParams = scope === "project" && context?.project?.id
? [context.organization.id, context.workspace.id, pattern, pattern, pattern, context.project.id, perType]
: projectIds.length
? [context.organization.id, context.workspace.id, pattern, pattern, pattern, ...projectIds, perType]
: [context.organization.id, context.workspace.id, pattern, pattern, pattern, perType];
const knowledgeDocuments = dbAll(
`SELECT kd.*
FROM knowledge_documents kd
WHERE kd.organization_id = ? AND kd.workspace_id = ?
AND (kd.title LIKE ? ESCAPE '\\' OR kd.content LIKE ? ESCAPE '\\' OR kd.summary LIKE ? ESCAPE '\\')
${knowledgeScopeClause}
ORDER BY kd.updated_at DESC LIMIT ?`,
knowledgeParams
);
for (const row of knowledgeDocuments) {
results.push(baseResult("knowledge_document", "知识素材", row, "knowledge", row.title, `${row.source_type} · ${row.scope_mode === "project" ? "项目级" : "工作区级"}`, row.status));
}
const knowledgeChunkParams = scope === "project" && context?.project?.id
? [context.organization.id, context.workspace.id, pattern, pattern, context.project.id, perType]
: projectIds.length
? [context.organization.id, context.workspace.id, pattern, pattern, ...projectIds, perType]
: [context.organization.id, context.workspace.id, pattern, pattern, perType];
const knowledgeChunks = dbAll(
`SELECT kc.*, kd.title AS document_title, kd.project_id, kd.scope_mode, kd.organization_id, kd.workspace_id
FROM knowledge_chunks kc
JOIN knowledge_documents kd ON kd.id = kc.document_id
WHERE kd.organization_id = ? AND kd.workspace_id = ?
AND (kc.heading LIKE ? ESCAPE '\\' OR kc.content LIKE ? ESCAPE '\\')
${knowledgeScopeClause}
ORDER BY kd.updated_at DESC, kc.chunk_index ASC LIMIT ?`,
knowledgeChunkParams
);
for (const row of knowledgeChunks) {
results.push(baseResult("knowledge_chunk", "知识片段", row, "knowledge", row.heading || row.document_title, `${row.document_title} · chunk ${row.chunk_index}`, row.chunk_type));
}
const shots = dbAll(
`SELECT sh.*, e.episode_number, e.title AS episode_title,
p.id AS project_id, p.name AS project_name, w.organization_id AS organization_id, p.workspace_id
@@ -112,9 +154,9 @@ export function searchPlatform(context, { query = "", scope = "workspace", limit
JOIN projects p ON p.id = s.project_id
JOIN workspaces w ON w.id = p.workspace_id
WHERE p.id IN (${ids})
AND (sh.title LIKE ? ESCAPE '\\' OR sh.id LIKE ? ESCAPE '\\' OR sh.continuity_json LIKE ? ESCAPE '\\')
AND (sh.title LIKE ? ESCAPE '\\' OR sh.id LIKE ? ESCAPE '\\' OR sh.continuity_json LIKE ? ESCAPE '\\' OR p.name LIKE ? ESCAPE '\\' OR s.title LIKE ? ESCAPE '\\' OR e.title LIKE ? ESCAPE '\\')
ORDER BY sh.updated_at DESC LIMIT ?`,
[...projectIds, pattern, pattern, pattern, perType]
[...projectIds, pattern, pattern, pattern, pattern, pattern, pattern, perType]
);
for (const row of shots) {
results.push(baseResult("shot", "镜头", row, "director", row.title, `${row.project_name} · E${String(row.episode_number).padStart(2, "0")} · S${String(row.shot_number).padStart(2, "0")}`, row.status));
+1321 -31
View File
File diff suppressed because it is too large Load Diff