import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs"; import { addAudit, hasPermission, httpError, requireEntitlement, requirePermission, requireProjectWritable } from "./tenant.mjs"; const now = () => new Date().toISOString(); const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`; const DEFAULT_FORBIDDEN_TERMS = [ "split-screen", "comic panel", "collage", "contact sheet", "storyboard", "多格", "拼图", "分屏", "故事板拼图" ]; const DEFAULT_STYLE_KIT = { id: "", name: "平台默认单画面生产标准", scopeMode: "default", status: "active", visualProfile: { styleFamily: "国产漫画 / 国漫 2D 动画", cameraLanguage: "one continuous scene, one complete frame", continuityLocks: ["character", "costume", "location", "prop", "weather", "camera"] }, subtitlePreset: { language: "zh-CN", position: "bottom-safe-area", maxCharsPerLine: 18 }, voicePolicy: { finalVoiceMode: "fixed-reference-required", forbidRandomNativeVoice: true, fallbackMouthPlan: ["侧脸", "背影", "低头", "远景", "反应镜头", "环境插入镜头"] }, deliverySpec: { aspectRatio: "9:16", resolution: "1080x1920", fps: 24, clipDurationSec: [5, 10], exportFormat: "mp4" }, promptRules: { positivePrefix: "ONE SINGLE COMPLETE 9:16 CHINESE ANIMATION FRAME, one continuous scene, one complete image.", negativeTerms: DEFAULT_FORBIDDEN_TERMS, renderNotes: ["batch size must be 1", "do not generate storyboards or panels"] }, singleFramePolicy: { enabled: true, imageOutputCount: 1, batchSize: 1, forbiddenTerms: DEFAULT_FORBIDDEN_TERMS, requiredPhrase: "one single complete frame" }, namingRules: { shotPattern: "{projectId}/S{season}E{episode}/shot-{shotNumber}-{assetKind}-{version}", deliveryPattern: "{projectName}-{episodeId}-{releaseVersion}" }, qaPolicy: { gates: ["single-frame", "continuity", "voice-rights", "subtitle-asr-alignment", "clip-transition"], requireLockedAssets: true }, metadata: { source: "platform-default" } }; function parseJson(value, fallback) { try { const parsed = JSON.parse(value); return parsed === undefined || parsed === null ? fallback : parsed; } catch { return fallback; } } function jsonObject(value, fallback = {}) { if (value === undefined) return fallback; if (typeof value === "string") { const parsed = parseJson(value, fallback); return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : fallback; } return value && typeof value === "object" && !Array.isArray(value) ? value : fallback; } function uniqueList(values = []) { return [...new Set(values.map((item) => String(item || "").trim()).filter(Boolean))]; } export function styleKitForbiddenTerms(styleKit) { return uniqueList([ ...DEFAULT_FORBIDDEN_TERMS, ...(styleKit?.singleFramePolicy?.forbiddenTerms || []), ...(styleKit?.promptRules?.forbiddenTerms || []) ]); } export function styleKitNegativeTerms(styleKit) { return uniqueList([ ...DEFAULT_FORBIDDEN_TERMS, ...(styleKit?.promptRules?.negativeTerms || []), ...(styleKit?.singleFramePolicy?.forbiddenTerms || []) ]); } function canReadStyleKits(context) { return [ "style:read", "style:manage", "script:read", "job:create", "project:manage", "model:manage", "compliance:manage" ].some((permission) => hasPermission(context, permission)); } function requireStyleRead(context) { if (!canReadStyleKits(context)) throw httpError(403, "permission_denied", "当前角色没有风格标准访问权限"); } function accessibleProjectIds(context) { return new Set((context.projects || []).map((project) => project.id).filter(Boolean)); } function ensureProjectInContext(context, projectId = "") { const id = String(projectId || context.project?.id || "").trim(); if (!id) throw httpError(400, "project_required", "项目级风格标准必须绑定项目"); const project = dbGet( `SELECT p.*, w.organization_id FROM projects p JOIN workspaces w ON w.id = p.workspace_id WHERE p.id = ? AND p.workspace_id = ? AND w.organization_id = ?`, [id, context.workspace.id, context.organization.id] ); if (!project) throw httpError(404, "project_not_found", "项目不存在或不属于当前工作区", { projectId: id }); if (!accessibleProjectIds(context).has(project.id) && context.project?.id !== project.id) { throw httpError(403, "project_forbidden", "当前用户没有该项目访问权", { projectId: id }); } return project; } function styleKitRowVisible(context, id) { const projectIds = [...accessibleProjectIds(context)]; const projectClause = projectIds.length ? `OR sk.project_id IN (${projectIds.map(() => "?").join(",")})` : ""; const row = dbGet( `SELECT sk.*, w.name AS workspace_name, p.name AS project_name, u.display_name AS creator_name FROM style_kits sk LEFT JOIN workspaces w ON w.id = sk.workspace_id LEFT JOIN projects p ON p.id = sk.project_id LEFT JOIN users u ON u.id = sk.created_by WHERE sk.id = ? AND sk.organization_id = ? AND ( (sk.workspace_id IS NULL AND sk.project_id IS NULL) OR sk.workspace_id = ? ${projectClause} )`, [id, context.organization.id, context.workspace.id, ...projectIds] ); if (!row) throw httpError(404, "style_kit_not_found", "风格标准不存在或不属于当前组织/工作区", { styleKitId: id }); return row; } function styleKitPayload(row, source = "") { if (!row) return null; return { id: row.id, organizationId: row.organization_id, workspaceId: row.workspace_id || "", projectId: row.project_id || "", scopeMode: row.scope_mode, name: row.name, description: row.description || "", status: row.status, visualProfile: parseJson(row.visual_profile_json, {}), subtitlePreset: parseJson(row.subtitle_preset_json, {}), voicePolicy: parseJson(row.voice_policy_json, {}), deliverySpec: parseJson(row.delivery_spec_json, {}), promptRules: parseJson(row.prompt_rules_json, {}), singleFramePolicy: parseJson(row.single_frame_policy_json, {}), namingRules: parseJson(row.naming_rules_json, {}), qaPolicy: parseJson(row.qa_policy_json, {}), metadata: parseJson(row.metadata_json, {}), workspaceName: row.workspace_name || "", projectName: row.project_name || "", creatorName: row.creator_name || row.created_by || "", createdAt: row.created_at, updatedAt: row.updated_at, ...(source ? { source } : {}) }; } function bindingPayload(row) { if (!row) return null; return { id: row.id, organizationId: row.organization_id, workspaceId: row.workspace_id, projectId: row.project_id, styleKitId: row.style_kit_id, enforcement: row.enforcement, notes: row.notes || "", createdBy: row.created_by, createdAt: row.created_at, updatedAt: row.updated_at }; } function normalizeStyleKitBody(context, body = {}, current = null) { const scopeMode = String(body.scopeMode ?? body.scope_mode ?? current?.scope_mode ?? "workspace").trim(); if (!["organization", "workspace", "project"].includes(scopeMode)) throw httpError(400, "style_kit_scope_invalid", "风格标准作用域只能是 organization、workspace 或 project"); const project = scopeMode === "project" ? ensureProjectInContext(context, body.projectId ?? body.project_id ?? current?.project_id) : null; const name = String(body.name ?? current?.name ?? "").trim().slice(0, 100); if (name.length < 2) throw httpError(400, "style_kit_name_required", "风格标准名称至少需要 2 个字符"); const status = String(body.status ?? current?.status ?? "draft").trim(); if (!["draft", "active", "paused", "archived"].includes(status)) throw httpError(400, "style_kit_status_invalid", "风格标准状态无效"); const currentJson = (column, fallback) => current ? parseJson(current[column], fallback) : fallback; const styleKit = { scopeMode, name, description: String(body.description ?? current?.description ?? "").trim().slice(0, 800), status, workspaceId: scopeMode === "organization" ? null : context.workspace.id, projectId: scopeMode === "project" ? project.id : null, visualProfile: jsonObject(body.visualProfile ?? body.visual_profile, currentJson("visual_profile_json", DEFAULT_STYLE_KIT.visualProfile)), subtitlePreset: jsonObject(body.subtitlePreset ?? body.subtitle_preset, currentJson("subtitle_preset_json", DEFAULT_STYLE_KIT.subtitlePreset)), voicePolicy: jsonObject(body.voicePolicy ?? body.voice_policy, currentJson("voice_policy_json", DEFAULT_STYLE_KIT.voicePolicy)), deliverySpec: jsonObject(body.deliverySpec ?? body.delivery_spec, currentJson("delivery_spec_json", DEFAULT_STYLE_KIT.deliverySpec)), promptRules: jsonObject(body.promptRules ?? body.prompt_rules, currentJson("prompt_rules_json", DEFAULT_STYLE_KIT.promptRules)), singleFramePolicy: jsonObject(body.singleFramePolicy ?? body.single_frame_policy, currentJson("single_frame_policy_json", DEFAULT_STYLE_KIT.singleFramePolicy)), namingRules: jsonObject(body.namingRules ?? body.naming_rules, currentJson("naming_rules_json", DEFAULT_STYLE_KIT.namingRules)), qaPolicy: jsonObject(body.qaPolicy ?? body.qa_policy, currentJson("qa_policy_json", DEFAULT_STYLE_KIT.qaPolicy)), metadata: jsonObject(body.metadata ?? body.metadata_json, currentJson("metadata_json", {})) }; styleKit.promptRules.negativeTerms = styleKitNegativeTerms(styleKit); styleKit.singleFramePolicy = { enabled: true, imageOutputCount: 1, batchSize: 1, ...styleKit.singleFramePolicy, forbiddenTerms: styleKitForbiddenTerms(styleKit) }; styleKit.voicePolicy = { ...styleKit.voicePolicy, forbidRandomNativeVoice: styleKit.voicePolicy.forbidRandomNativeVoice !== false }; return styleKit; } function scopedStyleKitRows(context, { includeArchived = false } = {}) { const projectIds = [...accessibleProjectIds(context)]; const projectClause = projectIds.length ? `OR sk.project_id IN (${projectIds.map(() => "?").join(",")})` : ""; return dbAll( `SELECT sk.*, w.name AS workspace_name, p.name AS project_name, u.display_name AS creator_name FROM style_kits sk LEFT JOIN workspaces w ON w.id = sk.workspace_id LEFT JOIN projects p ON p.id = sk.project_id LEFT JOIN users u ON u.id = sk.created_by WHERE sk.organization_id = ? AND (? = 1 OR sk.status != 'archived') AND ( (sk.workspace_id IS NULL AND sk.project_id IS NULL) OR sk.workspace_id = ? ${projectClause} ) ORDER BY CASE sk.scope_mode WHEN 'project' THEN 0 WHEN 'workspace' THEN 1 ELSE 2 END, sk.updated_at DESC, sk.name`, [context.organization.id, includeArchived ? 1 : 0, context.workspace.id, ...projectIds] ); } function currentBinding(context, projectId = "") { const project = ensureProjectInContext(context, projectId || context.project?.id); const row = dbGet( `SELECT b.* FROM project_style_kit_bindings b WHERE b.organization_id = ? AND b.workspace_id = ? AND b.project_id = ?`, [context.organization.id, context.workspace.id, project.id] ); return bindingPayload(row); } function activeStyleKitCandidate(context, projectId = "") { const project = ensureProjectInContext(context, projectId || context.project?.id); const binding = dbGet( `SELECT sk.*, w.name AS workspace_name, p.name AS project_name, u.display_name AS creator_name, b.id AS binding_id, b.organization_id AS binding_organization_id, b.workspace_id AS binding_workspace_id, b.project_id AS binding_project_id, b.style_kit_id AS binding_style_kit_id, b.enforcement AS binding_enforcement, b.notes AS binding_notes, b.created_by AS binding_created_by, b.created_at AS binding_created_at, b.updated_at AS binding_updated_at FROM project_style_kit_bindings b JOIN style_kits sk ON sk.id = b.style_kit_id LEFT JOIN workspaces w ON w.id = sk.workspace_id LEFT JOIN projects p ON p.id = sk.project_id LEFT JOIN users u ON u.id = sk.created_by WHERE b.organization_id = ? AND b.workspace_id = ? AND b.project_id = ? AND sk.organization_id = b.organization_id AND sk.status = 'active' AND ( (sk.scope_mode = 'organization' AND sk.workspace_id IS NULL AND sk.project_id IS NULL) OR (sk.scope_mode = 'workspace' AND sk.workspace_id = b.workspace_id AND sk.project_id IS NULL) OR (sk.scope_mode = 'project' AND sk.project_id = b.project_id) ) LIMIT 1`, [context.organization.id, context.workspace.id, project.id] ); if (binding) { return { styleKit: styleKitPayload(binding, "project-binding"), binding: { id: binding.binding_id, organizationId: binding.binding_organization_id, workspaceId: binding.binding_workspace_id, projectId: binding.binding_project_id, styleKitId: binding.binding_style_kit_id, enforcement: binding.binding_enforcement, notes: binding.binding_notes || "", createdBy: binding.binding_created_by, createdAt: binding.binding_created_at, updatedAt: binding.binding_updated_at }, project }; } const fallback = dbGet( `SELECT sk.*, w.name AS workspace_name, p.name AS project_name, u.display_name AS creator_name FROM style_kits sk LEFT JOIN workspaces w ON w.id = sk.workspace_id LEFT JOIN projects p ON p.id = sk.project_id LEFT JOIN users u ON u.id = sk.created_by WHERE sk.organization_id = ? AND sk.status = 'active' AND ( sk.project_id = ? OR (sk.project_id IS NULL AND sk.workspace_id = ?) OR (sk.project_id IS NULL AND sk.workspace_id IS NULL) ) ORDER BY CASE WHEN sk.project_id = ? THEN 0 WHEN sk.workspace_id = ? THEN 1 ELSE 2 END, sk.updated_at DESC LIMIT 1`, [context.organization.id, project.id, context.workspace.id, project.id, context.workspace.id] ); return { styleKit: fallback ? styleKitPayload(fallback, fallback.project_id ? "project-active" : fallback.workspace_id ? "workspace-default" : "organization-default") : null, binding: null, project }; } export function effectiveStyleKitForProject(context, projectId = "") { if (!context?.organization?.id || !context?.workspace?.id) { return { styleKit: { ...DEFAULT_STYLE_KIT, source: "default-contract" }, binding: null, project: null }; } const effective = activeStyleKitCandidate(context, projectId); return { ...effective, styleKit: effective.styleKit || { ...DEFAULT_STYLE_KIT, source: "default-contract" } }; } export function listStyleKits(context, options = {}) { requireStyleRead(context); const effective = context.project ? effectiveStyleKitForProject(context) : { styleKit: { ...DEFAULT_STYLE_KIT, source: "default-contract" }, binding: null, project: null }; return { kits: scopedStyleKitRows(context, { includeArchived: options.includeArchived }).map(styleKitPayload), effective, projects: (context.projects || []).map((project) => ({ id: project.id, name: project.name, status: project.status })), summary: { total: Number(dbGet("SELECT COUNT(*) AS count FROM style_kits WHERE organization_id = ? AND status != 'archived'", [context.organization.id])?.count || 0), active: Number(dbGet("SELECT COUNT(*) AS count FROM style_kits WHERE organization_id = ? AND status = 'active'", [context.organization.id])?.count || 0), currentWorkspace: Number(dbGet("SELECT COUNT(*) AS count FROM style_kits WHERE organization_id = ? AND workspace_id = ? AND status != 'archived'", [context.organization.id, context.workspace.id])?.count || 0), boundProjects: Number(dbGet("SELECT COUNT(*) AS count FROM project_style_kit_bindings WHERE organization_id = ? AND workspace_id = ?", [context.organization.id, context.workspace.id])?.count || 0) }, defaults: DEFAULT_STYLE_KIT }; } export function getStyleKit(context, styleKitId) { requireStyleRead(context); return { styleKit: styleKitPayload(styleKitRowVisible(context, String(styleKitId || "").trim())) }; } export function createStyleKit(context, body = {}) { requirePermission(context, "style:manage"); requireEntitlement(context, "limit.style_kits", 1); const kit = normalizeStyleKitBody(context, body); const id = String(body.id || makeId("stylekit")).trim(); const timestamp = now(); dbRun( `INSERT INTO style_kits( id, organization_id, workspace_id, project_id, scope_mode, name, description, status, visual_profile_json, subtitle_preset_json, voice_policy_json, delivery_spec_json, prompt_rules_json, single_frame_policy_json, naming_rules_json, qa_policy_json, metadata_json, created_by, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ id, context.organization.id, kit.workspaceId, kit.projectId, kit.scopeMode, kit.name, kit.description, kit.status, JSON.stringify(kit.visualProfile), JSON.stringify(kit.subtitlePreset), JSON.stringify(kit.voicePolicy), JSON.stringify(kit.deliverySpec), JSON.stringify(kit.promptRules), JSON.stringify(kit.singleFramePolicy), JSON.stringify(kit.namingRules), JSON.stringify(kit.qaPolicy), JSON.stringify(kit.metadata), context.user.id, timestamp, timestamp ] ); addAudit({ context, action: "style_kit.created", targetType: "style_kit", targetId: id, metadata: { name: kit.name, scopeMode: kit.scopeMode, status: kit.status } }); return { styleKit: styleKitPayload(styleKitRowVisible(context, id)), ...listStyleKits(context, { includeArchived: true }) }; } export function updateStyleKit(context, styleKitId, body = {}) { requirePermission(context, "style:manage"); const id = String(styleKitId || "").trim(); const current = styleKitRowVisible(context, id); const kit = normalizeStyleKitBody(context, body, current); const timestamp = now(); dbRun( `UPDATE style_kits SET workspace_id = ?, project_id = ?, scope_mode = ?, name = ?, description = ?, status = ?, visual_profile_json = ?, subtitle_preset_json = ?, voice_policy_json = ?, delivery_spec_json = ?, prompt_rules_json = ?, single_frame_policy_json = ?, naming_rules_json = ?, qa_policy_json = ?, metadata_json = ?, updated_at = ? WHERE id = ?`, [ kit.workspaceId, kit.projectId, kit.scopeMode, kit.name, kit.description, kit.status, JSON.stringify(kit.visualProfile), JSON.stringify(kit.subtitlePreset), JSON.stringify(kit.voicePolicy), JSON.stringify(kit.deliverySpec), JSON.stringify(kit.promptRules), JSON.stringify(kit.singleFramePolicy), JSON.stringify(kit.namingRules), JSON.stringify(kit.qaPolicy), JSON.stringify(kit.metadata), timestamp, id ] ); addAudit({ context, action: "style_kit.updated", targetType: "style_kit", targetId: id, metadata: { name: kit.name, scopeMode: kit.scopeMode, status: kit.status } }); return { styleKit: styleKitPayload(styleKitRowVisible(context, id)), ...listStyleKits(context, { includeArchived: true }) }; } export function bindProjectStyleKit(context, projectId, body = {}) { requirePermission(context, "style:manage"); const project = ensureProjectInContext(context, projectId); requireProjectWritable({ ...context, project }); const styleKitId = String(body.styleKitId || body.style_kit_id || "").trim(); const timestamp = now(); if (!styleKitId) { dbRun("DELETE FROM project_style_kit_bindings WHERE organization_id = ? AND workspace_id = ? AND project_id = ?", [context.organization.id, context.workspace.id, project.id]); addAudit({ context: { ...context, project }, action: "style_kit.unbound", targetType: "project", targetId: project.id, metadata: { projectId: project.id } }); return listStyleKits({ ...context, project }, { includeArchived: true }); } const kit = styleKitPayload(styleKitRowVisible(context, styleKitId)); if (kit.status !== "active") throw httpError(409, "style_kit_not_active", "只有 active 状态的风格标准可以绑定到项目", { styleKitId, status: kit.status }); if (kit.scopeMode === "project" && kit.projectId !== project.id) throw httpError(403, "style_kit_project_mismatch", "项目级风格标准不能绑定到其他项目", { styleKitId, kitProjectId: kit.projectId, projectId: project.id }); if (kit.scopeMode === "workspace" && kit.workspaceId !== context.workspace.id) throw httpError(403, "style_kit_workspace_mismatch", "工作区级风格标准不能跨工作区绑定", { styleKitId, workspaceId: kit.workspaceId }); const enforcement = String(body.enforcement || "locked").trim(); if (!["advisory", "locked", "strict"].includes(enforcement)) throw httpError(400, "style_kit_enforcement_invalid", "项目风格绑定强度无效"); const notes = String(body.notes || "").trim().slice(0, 500); withTransaction(() => { dbRun( `INSERT INTO project_style_kit_bindings(id, organization_id, workspace_id, project_id, style_kit_id, enforcement, notes, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(project_id) DO UPDATE SET style_kit_id = excluded.style_kit_id, enforcement = excluded.enforcement, notes = excluded.notes, created_by = excluded.created_by, updated_at = excluded.updated_at`, [`binding-${project.id}-style-kit`, context.organization.id, context.workspace.id, project.id, styleKitId, enforcement, notes, context.user.id, timestamp, timestamp] ); }); addAudit({ context: { ...context, project }, action: "style_kit.bound", targetType: "project", targetId: project.id, metadata: { projectId: project.id, styleKitId, enforcement } }); return listStyleKits({ ...context, project }, { includeArchived: true }); } export function getProjectStyleKit(context, projectId = "") { requireStyleRead(context); const project = ensureProjectInContext(context, projectId || context.project?.id); return effectiveStyleKitForProject({ ...context, project }, project.id); }