diff --git a/package.json b/package.json
index 9420202..a5ecc80 100644
--- a/package.json
+++ b/package.json
@@ -18,6 +18,7 @@
"smoke:identity": "node scripts/smoke-identity.mjs",
"smoke:system-users": "node scripts/smoke-system-users.mjs",
"smoke:commercial-governance": "node scripts/smoke-commercial-governance.mjs",
+ "smoke:organization-policies": "node scripts/smoke-organization-policies.mjs",
"smoke:invitations": "node scripts/smoke-invitations.mjs",
"smoke:commercial-ops": "node scripts/smoke-commercial-ops.mjs",
"smoke:commercial-approvals": "node scripts/smoke-commercial-approvals.mjs",
diff --git a/scripts/smoke-all.mjs b/scripts/smoke-all.mjs
index 59f6c74..df88e0b 100644
--- a/scripts/smoke-all.mjs
+++ b/scripts/smoke-all.mjs
@@ -9,6 +9,7 @@ const scripts = [
"smoke:identity",
"smoke:system-users",
"smoke:commercial-governance",
+ "smoke:organization-policies",
"smoke:invitations",
"smoke:commercial-ops",
"smoke:commercial-approvals",
diff --git a/scripts/smoke-organization-policies.mjs b/scripts/smoke-organization-policies.mjs
new file mode 100644
index 0000000..64ec69b
--- /dev/null
+++ b/scripts/smoke-organization-policies.mjs
@@ -0,0 +1,188 @@
+import assert from "node:assert/strict";
+import { rm } from "node:fs/promises";
+import { resolve } from "node:path";
+import { dbRun, withTransaction } from "../server/db.mjs";
+
+const api = process.env.AI_DRAMA_API_BASE || "http://127.0.0.1:8787";
+const projectRoot = resolve(import.meta.dirname, "..");
+const organizationId = "org-studio-lab";
+const workspaceId = "ws-local-aidrama";
+const projectId = "thunder-mouth";
+const createdJobIds = [];
+const evaluationSubjectIds = [];
+
+async function request(path, options = {}) {
+ const response = await fetch(`${api}${path}`, {
+ ...options,
+ headers: { "content-type": "application/json", ...(options.headers || {}) }
+ });
+ const payload = await response.json().catch(() => ({}));
+ return { response, payload };
+}
+
+function expectOk(result, label) {
+ assert.equal(result.response.ok, true, `${label}: ${result.response.status} ${result.payload.detail || result.payload.error || ""}`);
+ return result.payload;
+}
+
+async function login(email) {
+ const result = expectOk(await request("/api/auth/login", {
+ method: "POST",
+ body: JSON.stringify({ email, password: "Demo@123456" })
+ }), `${email} login`);
+ return { authorization: `Bearer ${result.session.token}` };
+}
+
+const ownerAuth = await login("producer@local.test");
+const writerAuth = await login("writer@local.test");
+const headers = { ...ownerAuth, "x-organization-id": organizationId, "x-workspace-id": workspaceId, "x-project-id": projectId };
+const writerHeaders = { ...writerAuth, "x-organization-id": organizationId, "x-workspace-id": workspaceId, "x-project-id": projectId };
+
+let originalRetentionPolicy = null;
+
+try {
+ const initial = expectOk(await request(`/api/organizations/${organizationId}/policies`, { headers }), "list organization policies");
+ assert.ok(initial.policies.length >= 8, "组织策略必须包含默认商业硬门");
+ assert.equal(initial.effective.generation.imageOutputCount, 1, "组织策略必须锁定单张图片输出");
+ assert.equal(initial.effective.generation.batchSize, 1, "组织策略必须锁定 batchSize=1");
+ assert.equal(initial.effective.voice.forbidRandomNativeVoice, true, "组织策略必须禁止随机原生声线作为最终方案");
+ assert.equal(initial.effective.model.externalApprovalRequired, true, "组织策略必须要求外部连接器审批");
+
+ const writerRead = await request(`/api/organizations/${organizationId}/policies`, { headers: writerHeaders });
+ assert.equal(writerRead.response.status, 200, "编剧可以读取组织策略用于生产自检");
+
+ const writerPatch = await request(`/api/organizations/${organizationId}/policies/retention.production_evidence`, {
+ method: "PATCH",
+ headers: writerHeaders,
+ body: JSON.stringify({ value: { temporaryMediaDays: 90 } })
+ });
+ assert.equal(writerPatch.response.status, 403, "编剧不能修改组织策略");
+
+ const disableHardGate = await request(`/api/organizations/${organizationId}/policies/generation.single_frame_contract`, {
+ method: "PATCH",
+ headers,
+ body: JSON.stringify({ status: "disabled", enforcement: "off" })
+ });
+ assert.equal(disableHardGate.response.status, 409, "一图一完整单画面硬门不能被关闭");
+ assert.equal(disableHardGate.payload.error, "policy_hard_gate_locked", "硬门降级错误码必须稳定");
+
+ originalRetentionPolicy = initial.policies.find((policy) => policy.policyKey === "retention.production_evidence");
+ const patched = expectOk(await request(`/api/organizations/${organizationId}/policies/retention.production_evidence`, {
+ method: "PATCH",
+ headers,
+ body: JSON.stringify({
+ ...originalRetentionPolicy,
+ value: { ...originalRetentionPolicy.value, temporaryMediaDays: 45 }
+ })
+ }), "update editable retention policy");
+ assert.equal(patched.policies.find((policy) => policy.policyKey === "retention.production_evidence").value.temporaryMediaDays, 45, "可编辑策略必须落库");
+
+ const passSubjectId = `policy-pass-${Date.now()}`;
+ evaluationSubjectIds.push(passSubjectId);
+ const passEvaluation = expectOk(await request(`/api/organizations/${organizationId}/policies/evaluate`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ subjectType: "generation_request",
+ subjectId: passSubjectId,
+ kind: "单画面关键帧",
+ prompt: "国漫 2D 动画风格,雨夜地铁口,一个完整单画面。",
+ costMode: "local",
+ endpoint: "http://127.0.0.1:8000/v1",
+ imageOutputCount: 1,
+ batchSize: 1,
+ randomNativeVoice: false,
+ rightsEvidenceApproved: true
+ })
+ }), "evaluate passing request");
+ assert.equal(passEvaluation.evaluation.result, "pass", "本地单画面生成请求应通过组织策略");
+
+ const blockedSubjectId = `policy-block-${Date.now()}`;
+ evaluationSubjectIds.push(blockedSubjectId);
+ const blockedEvaluation = expectOk(await request(`/api/organizations/${organizationId}/policies/evaluate`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ subjectType: "generation_request",
+ subjectId: blockedSubjectId,
+ kind: "单画面关键帧",
+ prompt: "请生成 split-screen 对比图和漫画多格拼图。",
+ costMode: "local",
+ imageOutputCount: 2,
+ batchSize: 2,
+ randomNativeVoice: true,
+ rightsEvidenceApproved: true
+ })
+ }), "evaluate blocked request");
+ assert.equal(blockedEvaluation.evaluation.result, "block", "分屏/多图/随机声线请求必须被组织策略阻断");
+ assert.ok(blockedEvaluation.evaluation.blockers.some((item) => item.policyKey === "generation.single_frame_contract"), "阻断结果必须包含单画面硬门");
+ assert.ok(blockedEvaluation.evaluation.blockers.some((item) => item.policyKey === "voice.fixed_voice_required"), "阻断结果必须包含固定声线硬门");
+
+ const cloudSubjectId = `policy-cloud-${Date.now()}`;
+ evaluationSubjectIds.push(cloudSubjectId);
+ const cloudEvaluation = expectOk(await request(`/api/organizations/${organizationId}/policies/evaluate`, {
+ method: "POST",
+ headers,
+ body: JSON.stringify({
+ subjectType: "connector_request",
+ subjectId: cloudSubjectId,
+ kind: "图生视频",
+ prompt: "原创国漫单画面",
+ costMode: "cloud",
+ endpoint: "https://api.example.com/v1",
+ imageOutputCount: 1,
+ batchSize: 1,
+ randomNativeVoice: false,
+ rightsEvidenceApproved: true
+ })
+ }), "evaluate cloud connector request");
+ assert.equal(cloudEvaluation.evaluation.result, "block", "默认商业策略不能直接放行 cloud 成本模式");
+
+ const preview = expectOk(await request("/api/jobs/preview", {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ kind: "单画面关键帧", shotId: "shot-01", adapter: "owned-model-platform" })
+ }), "preview job with organization policy");
+ assert.equal(preview.preview.organizationPolicy.generation.batchSize, 1, "生成预览合同必须包含组织策略快照");
+ assert.equal(preview.preview.policyEvaluation.result, "pass", "生成预览必须返回策略评估结果");
+ assert.ok(preview.preview.constraints.negativePromptTerms.includes("contact sheet"), "生成合同必须注入组织级单画面禁止词");
+
+ const created = expectOk(await request("/api/jobs", {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ kind: "单画面关键帧", shotId: "shot-01", adapter: "owned-model-platform", output: "qa/policy-contract/frame.json" })
+ }), "create job with organization policy");
+ createdJobIds.push(created.job.id);
+ assert.equal(created.job.request.organizationPolicy.generation.imageOutputCount, 1, "创建任务必须持久化组织策略快照");
+ assert.equal(created.job.request.policyEvaluation.result, "pass", "创建任务必须持久化组织策略评估");
+
+ const afterCreate = expectOk(await request(`/api/organizations/${organizationId}/policies`, { headers }), "read policy evaluations after job");
+ assert.ok(afterCreate.evaluations.some((item) => item.subjectId === created.job.id), "创建任务后必须写入策略评估 ledger");
+
+ console.log(`organization policy smoke passed: ${api}`);
+} finally {
+ if (originalRetentionPolicy) {
+ await request(`/api/organizations/${organizationId}/policies/retention.production_evidence`, {
+ method: "PATCH",
+ headers,
+ body: JSON.stringify(originalRetentionPolicy)
+ }).catch(() => {});
+ }
+ withTransaction(() => {
+ for (const jobId of createdJobIds) {
+ dbRun("DELETE FROM job_dependencies WHERE job_id = ? OR depends_on_job_id = ?", [jobId, jobId]);
+ dbRun("DELETE FROM job_attempts WHERE job_id = ?", [jobId]);
+ dbRun("DELETE FROM usage_events WHERE metadata_json LIKE ?", [`%${jobId}%`]);
+ dbRun("DELETE FROM audit_logs WHERE target_id = ?", [jobId]);
+ dbRun("DELETE FROM organization_policy_evaluations WHERE subject_id = ?", [jobId]);
+ dbRun("DELETE FROM generation_jobs WHERE id = ?", [jobId]);
+ }
+ for (const subjectId of evaluationSubjectIds) {
+ dbRun("DELETE FROM organization_policy_evaluations WHERE subject_id = ?", [subjectId]);
+ dbRun("DELETE FROM audit_logs WHERE target_id = ?", [subjectId]);
+ }
+ });
+ for (const jobId of createdJobIds) {
+ await rm(resolve(projectRoot, "storage", "jobs", jobId), { recursive: true, force: true });
+ }
+}
diff --git a/server/db.mjs b/server/db.mjs
index b5a830c..6c44018 100644
--- a/server/db.mjs
+++ b/server/db.mjs
@@ -140,6 +140,8 @@ db.exec("CREATE INDEX IF NOT EXISTS idx_generation_jobs_model_approval ON genera
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_organization_policies_org_category ON organization_policies(organization_id, category, policy_key)");
+db.exec("CREATE INDEX IF NOT EXISTS idx_organization_policy_evaluations_scope ON organization_policy_evaluations(organization_id, workspace_id, project_id, result, created_at DESC)");
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)");
@@ -237,6 +239,8 @@ function seedRoles() {
["organization:manage", "修改组织设置"],
["organization:members:invite", "邀请组织成员"],
["organization:roles:manage", "管理组织角色权限策略"],
+ ["policy:read", "查看组织生产、安全和合规策略"],
+ ["policy:manage", "管理组织生产、安全和合规策略"],
["workspace:create", "创建工作区"],
["workspace:manage", "管理工作区设置"],
["workspace:members:manage", "管理工作区成员"],
@@ -285,14 +289,14 @@ 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", "style:read", "style:manage", "model:manage", "model:approve", "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", "policy:read", "policy:manage", "style:read", "style:manage", "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", "style:read", "style:manage", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
- writer: ["script:read", "script:edit", "task:view", "task:complete", "style:read", "job:create"],
- art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "style:read", "style:manage", "job:create"],
- voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "style:read", "job:create"],
- reviewer: ["script:read", "task:view", "task:complete", "style:read", "qa:review", "voice:approve", "delivery:view"],
+ producer: ["project:create", "project:manage", "project:members:manage", "workflow:manage", "task:view", "task:manage", "task:complete", "policy:read", "style:read", "style:manage", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
+ writer: ["script:read", "script:edit", "task:view", "task:complete", "policy:read", "style:read", "job:create"],
+ art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "policy:read", "style:read", "style:manage", "job:create"],
+ voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "policy:read", "style:read", "job:create"],
+ reviewer: ["script:read", "task:view", "task:complete", "policy:read", "style:read", "qa:review", "voice:approve", "delivery:view"],
project_guest: ["script:read", "task:view", "style:read", "delivery:view"],
project_editor: ["script:read", "script:edit", "asset:edit", "prompt:edit", "voice:edit", "task:view", "task:manage", "task:complete", "style:read", "job:create", "delivery:view"],
project_viewer: ["script:read", "task:view", "style:read", "delivery:view"]
@@ -355,6 +359,32 @@ 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 rollQuotaPeriods() {
+ const start = monthStart();
+ const end = monthEnd();
+ const timestamp = now();
+ const rows = dbAll("SELECT * FROM quota_allocations");
+ for (const row of rows) {
+ const current = dbGet(
+ "SELECT julianday(?) <= julianday('now') AND julianday(?) >= julianday('now') AS active",
+ [row.period_start, row.period_end]
+ );
+ if (current?.active) continue;
+ const usedValue = row.metric === "clip"
+ ? Number(dbGet(
+ `SELECT COALESCE(SUM(units), 0) AS units
+ FROM usage_events
+ WHERE organization_id = ?
+ AND (workspace_id = ? OR (? IS NULL AND workspace_id IS NULL))
+ AND unit_name IN ('job', 'clip', 'clips')
+ AND created_at >= datetime('now', 'start of month')`,
+ [row.organization_id, row.workspace_id, row.workspace_id]
+ )?.units || 0)
+ : Number(row.used_value || 0);
+ dbRun("UPDATE quota_allocations SET used_value = ?, period_start = ?, period_end = ?, updated_at = ? WHERE id = ?", [usedValue, start, end, timestamp, row.id]);
+ }
+}
+
function commercialEntitlementDefaults(billing = {}) {
const clipLimit = Number(billing.monthly_clip_quota || 2400);
const storageGb = Number(billing.storage_gb || 1024);
@@ -1239,6 +1269,7 @@ withTransaction(() => {
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();
+ rollQuotaPeriods();
seedCommercialApprovals();
seedMembersAndProjects();
seedModels();
diff --git a/server/execution.mjs b/server/execution.mjs
index df2fcea..17b9b76 100644
--- a/server/execution.mjs
+++ b/server/execution.mjs
@@ -18,6 +18,7 @@ import { dispatchNotificationEvent } from "./notifications.mjs";
import { registerJobArtifacts } from "./media-artifacts.mjs";
import { resolveKnowledgeContextForJob } from "./knowledge.mjs";
import { effectiveStyleKitForProject, styleKitForbiddenTerms, styleKitNegativeTerms } from "./style-kits.mjs";
+import { evaluateGenerationPolicy, recordPolicyEvaluation } from "./organization-policies.mjs";
const projectRoot = resolve(import.meta.dirname, "..");
const jobStorageRoot = resolve(projectRoot, "storage", "jobs");
@@ -375,21 +376,38 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
const kind = String(body.kind || "自定义生成任务").trim();
const knowledge = resolveKnowledgeContextForJob(context, body);
const styleKit = context.project ? effectiveStyleKitForProject(context, context.project.id).styleKit : null;
+ const organizationPolicyEvaluation = preflight.organizationPolicy || evaluateGenerationPolicy(context, { body, adapter, routing, shot });
+ const organizationPolicy = organizationPolicyEvaluation?.effective || null;
const inputs = { ...(body.inputs || {}) };
if (knowledge) {
inputs.knowledgeContext = knowledge.promptContext;
inputs.knowledgeCitations = knowledge.citations;
inputs.knowledgeChunkIds = knowledge.chunkIds;
}
- const blockedTerms = styleKitForbiddenTerms(styleKit);
- const negativeTerms = styleKitNegativeTerms(styleKit);
+ const blockedTerms = [...new Set([...styleKitForbiddenTerms(styleKit), ...(organizationPolicy?.generation?.forbiddenTerms || [])])];
+ const negativeTerms = [...new Set([...styleKitNegativeTerms(styleKit), ...(organizationPolicy?.generation?.forbiddenTerms || [])])];
// 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();
const blocked = blockedTerms.filter((term) => promptText.includes(term.toLowerCase()));
if (blocked.length) throw httpError(422, "single_frame_contract_violation", "请求包含禁止的一图多画面表达", { blocked });
+ if (organizationPolicyEvaluation?.blockers?.length) {
+ throw httpError(422, "organization_policy_blocked", "生成请求未通过组织策略", { blockers: organizationPolicyEvaluation.blockers });
+ }
if (shot && kind.includes("视频") && shot.transitionFromPrevious !== "episode-start" && (!shot.firstFrame || /pending|auto_previous/i.test(shot.firstFrame))) {
throw httpError(422, "actual_last_frame_required", "连续视频镜头必须先登记上一段实际末帧作为首帧输入", { shotId: shot.id });
}
+ const policySnapshot = organizationPolicy ? {
+ organizationId: organizationPolicy.organizationId,
+ generatedAt: organizationPolicy.generatedAt,
+ model: organizationPolicy.model,
+ generation: organizationPolicy.generation,
+ voice: organizationPolicy.voice,
+ delivery: organizationPolicy.delivery,
+ compliance: organizationPolicy.compliance,
+ security: organizationPolicy.security,
+ retention: organizationPolicy.retention
+ } : null;
+ const currentCostMode = routing?.connectorCostMode || adapter.costMode || "local";
return {
schema: "ai-drama.job.v1",
createdAt: now(),
@@ -412,6 +430,14 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
preflight,
knowledge,
styleKit,
+ organizationPolicy: policySnapshot,
+ policyEvaluation: organizationPolicyEvaluation ? {
+ result: organizationPolicyEvaluation.result,
+ blockers: organizationPolicyEvaluation.blockers,
+ approvals: organizationPolicyEvaluation.approvals,
+ warnings: organizationPolicyEvaluation.warnings,
+ checks: organizationPolicyEvaluation.checks
+ } : null,
shot,
inputs,
constraints: {
@@ -419,15 +445,15 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
imageOutputCount: 1,
batchSize: 1,
requireActualLastFrame: true,
- localOnly: routing ? routing.policyMode === "local-only" || routing.connectorCostMode === "local" : adapter.costMode === "local",
- approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired),
+ localOnly: currentCostMode === "local" && Boolean(organizationPolicy?.model?.defaultLocalOnly || (routing ? routing.policyMode === "local-only" : adapter.costMode === "local")),
+ approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired || organizationPolicyEvaluation?.approvals?.length),
blockedTerms,
negativePromptTerms: negativeTerms,
styleEnforcement: styleKit?.source || "default-contract",
aspectRatio: styleKit?.deliverySpec?.aspectRatio || "9:16",
resolution: styleKit?.deliverySpec?.resolution || "1080x1920",
fps: styleKit?.deliverySpec?.fps || 24,
- voicePolicy: styleKit?.voicePolicy || null,
+ voicePolicy: styleKit?.voicePolicy || organizationPolicy?.voice || null,
subtitlePreset: styleKit?.subtitlePreset || null
}
};
@@ -1045,6 +1071,16 @@ export async function createGenerationJob(context, body) {
}
attachModelRouteApprovalToJob(context, approvalGrant, jobId);
});
+ if (contract.policyEvaluation) {
+ recordPolicyEvaluation(context, {
+ ...contract.policyEvaluation,
+ organizationId: context.organization.id,
+ workspaceId: context.workspace.id,
+ projectId: context.project.id,
+ subjectType: "generation_job",
+ subjectId: jobId
+ });
+ }
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) };
diff --git a/server/local-api.mjs b/server/local-api.mjs
index 5ce5366..77c10e2 100644
--- a/server/local-api.mjs
+++ b/server/local-api.mjs
@@ -159,6 +159,7 @@ import { reclaimStorage, requireStorageQuota, storageCleanupPreview, storageSumm
import { backupSummary, createDatabaseBackup } from "./backup.mjs";
import { systemReadiness } from "./readiness.mjs";
import { bindProjectStyleKit, createStyleKit, getProjectStyleKit, getStyleKit, listStyleKits, updateStyleKit } from "./style-kits.mjs";
+import { evaluateAndRecordOrganizationPolicies, listOrganizationPolicies, updateOrganizationPolicy } from "./organization-policies.mjs";
import { dispatchNotificationEvent, listUserNotificationPreferences, listUserNotifications, markAllUserNotificationsRead, markUserNotificationRead, notificationDeliveries, updateUserNotificationPreference } from "./notifications.mjs";
import { composeProject, listCompositions } from "./composition.mjs";
import { listProjectArtifacts, readArtifactContent } from "./media-artifacts.mjs";
@@ -1852,6 +1853,30 @@ createServer(async (req, res) => {
return send(res, 200, updateCostCenter(context, organizationId, decodeURIComponent(organizationCostCenterMatch[2]), await readBody(req)));
}
+ const organizationPoliciesEvaluateMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/policies\/evaluate$/);
+ if (req.method === "POST" && organizationPoliciesEvaluateMatch) {
+ const organizationId = decodeURIComponent(organizationPoliciesEvaluateMatch[1]);
+ const context = contextWith({
+ organizationId,
+ workspaceId: req.headers["x-workspace-id"] || url.searchParams.get("workspaceId") || "",
+ projectId: req.headers["x-project-id"] || url.searchParams.get("projectId") || ""
+ }, req);
+ return send(res, 200, evaluateAndRecordOrganizationPolicies(context, organizationId, await readBody(req)));
+ }
+
+ const organizationPoliciesMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/policies(?:\/([^/]+))?$/);
+ if (req.method === "GET" && organizationPoliciesMatch && !organizationPoliciesMatch[2]) {
+ const organizationId = decodeURIComponent(organizationPoliciesMatch[1]);
+ const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
+ return send(res, 200, listOrganizationPolicies(context, organizationId, { limit: url.searchParams.get("limit") || 40 }));
+ }
+ if (req.method === "PATCH" && organizationPoliciesMatch?.[2]) {
+ const organizationId = decodeURIComponent(organizationPoliciesMatch[1]);
+ const policyKey = decodeURIComponent(organizationPoliciesMatch[2]);
+ const context = contextWith({ organizationId, workspaceId: "", projectId: "" }, req);
+ return send(res, 200, updateOrganizationPolicy(context, organizationId, policyKey, await readBody(req)));
+ }
+
const organizationRolePolicyMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/role-policies(?:\/([^/]+))?$/);
if (req.method === "GET" && organizationRolePolicyMatch) {
const organizationId = decodeURIComponent(organizationRolePolicyMatch[1]);
diff --git a/server/organization-policies.mjs b/server/organization-policies.mjs
new file mode 100644
index 0000000..1137ae8
--- /dev/null
+++ b/server/organization-policies.mjs
@@ -0,0 +1,735 @@
+import { randomBytes } from "node:crypto";
+import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
+import { addAudit, httpError } from "./tenant.mjs";
+
+const now = () => new Date().toISOString();
+
+const DEFAULT_FORBIDDEN_LAYOUT_TERMS = [
+ "split-screen",
+ "comic panel",
+ "collage",
+ "contact sheet",
+ "storyboard",
+ "multi panel",
+ "grid layout",
+ "多格",
+ "拼图",
+ "分屏",
+ "故事板",
+ "多时间点"
+];
+
+const DEFAULT_MOUTH_FALLBACK_PLAN = ["侧脸", "背影", "低头", "远景", "反应镜头", "环境插入镜头"];
+
+export const ORGANIZATION_POLICY_DEFINITIONS = [
+ {
+ key: "model.local_runner_default",
+ category: "model",
+ label: "自有/本地模型优先",
+ description: "默认把生成、解析、配音和检索任务路由到本地或自有模型平台。",
+ status: "enforced",
+ enforcement: "block",
+ severity: "critical",
+ value: {
+ defaultLocalOnly: true,
+ allowedCostModes: ["local", "mixed-with-approval"],
+ preferredAdapter: "owned-model-platform",
+ comfyuiAdapter: "optional"
+ },
+ appliesTo: ["generation_jobs", "model_routes", "connectors"]
+ },
+ {
+ key: "model.external_connector_approval",
+ category: "model",
+ label: "外部/付费连接器审批",
+ description: "第三方官方 API、公网中转和混合成本连接器必须先经过审批,密钥只能用环境变量引用。",
+ status: "enforced",
+ enforcement: "approval",
+ severity: "critical",
+ approvalRequired: true,
+ value: {
+ requireApprovalForExternal: true,
+ requireSecretRefs: true,
+ allowPublicEndpoints: false,
+ approvedWindowHours: 24
+ },
+ appliesTo: ["model_connectors", "model_route_approvals", "generation_jobs"]
+ },
+ {
+ key: "generation.single_frame_contract",
+ category: "production",
+ label: "一图一完整单画面",
+ description: "每次图片生成只能输出一张完整单画面,禁止分屏、多格、漫画拼图、故事板和 contact sheet。",
+ status: "enforced",
+ enforcement: "block",
+ severity: "critical",
+ value: {
+ enabled: true,
+ imageOutputCount: 1,
+ batchSize: 1,
+ forbiddenTerms: DEFAULT_FORBIDDEN_LAYOUT_TERMS,
+ requiredPhrase: "one single complete frame"
+ },
+ appliesTo: ["image_generation", "image_to_video", "prompt_packs", "qa"]
+ },
+ {
+ key: "voice.fixed_voice_required",
+ category: "voice",
+ label: "固定声线与口型规避",
+ description: "最终角色声音必须来自固定参考声线;口型无法同步时,使用侧脸、背影、低头、远景和反应镜头规避。",
+ status: "enforced",
+ enforcement: "block",
+ severity: "critical",
+ value: {
+ finalVoiceMode: "fixed-reference-required",
+ forbidRandomNativeVoice: true,
+ ttsPreference: "local-or-owned-first",
+ fallbackMouthPlan: DEFAULT_MOUTH_FALLBACK_PLAN
+ },
+ appliesTo: ["voice_lines", "tts_jobs", "generation_contracts"]
+ },
+ {
+ key: "delivery.actual_last_frame_required",
+ category: "delivery",
+ label: "连续镜头实际末帧",
+ description: "非开场视频镜头必须引用上一段真实生成出来的末帧,不允许只用占位帧或评审拼图。",
+ status: "enforced",
+ enforcement: "block",
+ severity: "high",
+ value: {
+ requireActualLastFrameForVideo: true,
+ blockPlaceholderFrames: true,
+ acceptedEvidenceKinds: ["last-frame", "media-qa", "delivery-clearance"]
+ },
+ appliesTo: ["image_to_video", "shot_versions", "delivery_clearance"]
+ },
+ {
+ key: "compliance.rights_evidence_required",
+ category: "compliance",
+ label: "素材权利证据必填",
+ description: "小说、角色图、道具图、声音参考和上传素材必须有原创或授权证据,才能进入商业生成与交付。",
+ status: "enforced",
+ enforcement: "block",
+ severity: "high",
+ value: {
+ requiredForKinds: ["knowledge", "asset", "voice", "delivery"],
+ acceptedStatuses: ["approved", "original"],
+ rejectUnknownSource: true
+ },
+ appliesTo: ["knowledge_documents", "asset_versions", "voice_references", "deliveries"]
+ },
+ {
+ key: "security.admin_mfa_required",
+ category: "security",
+ label: "管理账号 MFA",
+ description: "组织所有者、组织管理员、模型管理员、账单管理员和系统管理员建议或强制启用 MFA。",
+ status: "monitor",
+ enforcement: "warn",
+ severity: "medium",
+ value: {
+ requireForAdmins: true,
+ requireForAll: false,
+ methods: ["totp"]
+ },
+ appliesTo: ["users", "sessions", "admin_actions"]
+ },
+ {
+ key: "retention.production_evidence",
+ category: "storage",
+ label: "生产证据留存",
+ description: "保留生成合同、QA 结果、实际末帧、权利证据和交付清算记录,临时缓存按保留周期治理。",
+ status: "enforced",
+ enforcement: "warn",
+ severity: "medium",
+ value: {
+ generationContractDays: 365,
+ deliveryEvidenceDays: 730,
+ temporaryMediaDays: 30,
+ keepAuditLogs: true
+ },
+ appliesTo: ["generation_jobs", "qa_results", "delivery_releases", "audit_logs"]
+ }
+];
+
+function parseJson(value, fallback) {
+ try {
+ return JSON.parse(value);
+ } catch {
+ return fallback;
+ }
+}
+
+function privateHost(hostname) {
+ const host = String(hostname || "").toLowerCase();
+ if (["localhost", "127.0.0.1", "::1"].includes(host) || host.endsWith(".local")) return true;
+ const octets = host.split(".").map(Number);
+ if (octets.length !== 4 || octets.some((value) => !Number.isInteger(value) || value < 0 || value > 255)) return false;
+ return octets[0] === 10 || octets[0] === 127 || (octets[0] === 172 && octets[1] >= 16 && octets[1] <= 31) || (octets[0] === 192 && octets[1] === 168);
+}
+
+function hasPolicyPermission(context, permission) {
+ return Boolean(context?.systemAdmin || (context?.permissions || []).includes(permission));
+}
+
+function requirePolicyRead(context) {
+ if (hasPolicyPermission(context, "policy:read") || hasPolicyPermission(context, "policy:manage") || hasPolicyPermission(context, "organization:manage") || hasPolicyPermission(context, "compliance:manage")) return;
+ throw httpError(403, "permission_denied", "当前角色没有查看组织策略的权限");
+}
+
+function requirePolicyManage(context) {
+ if (hasPolicyPermission(context, "policy:manage") || hasPolicyPermission(context, "organization:manage")) return;
+ throw httpError(403, "permission_denied", "当前角色没有管理组织策略的权限");
+}
+
+function assertOrganizationScope(context, organizationId) {
+ if (context?.organization?.id === organizationId) return;
+ throw httpError(403, "organization_policy_scope_denied", "不能访问其他组织的策略", { organizationId });
+}
+
+function policyId(organizationId, policyKey) {
+ return `policy-${organizationId}-${policyKey.replace(/[^a-z0-9]+/gi, "-")}`;
+}
+
+function policyPayload(row) {
+ return {
+ id: row.id,
+ organizationId: row.organization_id,
+ policyKey: row.policy_key,
+ category: row.category,
+ label: row.label,
+ description: row.description || "",
+ status: row.status,
+ enforcement: row.enforcement,
+ severity: row.severity,
+ value: parseJson(row.value_json, {}),
+ appliesTo: parseJson(row.applies_to_json, []),
+ approvalRequired: Boolean(row.approval_required),
+ updatedBy: row.updated_by || "",
+ createdAt: row.created_at,
+ updatedAt: row.updated_at
+ };
+}
+
+function evaluationPayload(row) {
+ return {
+ id: row.id,
+ organizationId: row.organization_id,
+ workspaceId: row.workspace_id || "",
+ projectId: row.project_id || "",
+ policyKey: row.policy_key,
+ subjectType: row.subject_type,
+ subjectId: row.subject_id || "",
+ result: row.result,
+ reason: row.reason || "",
+ evidence: parseJson(row.evidence_json, {}),
+ createdBy: row.created_by || "",
+ createdAt: row.created_at
+ };
+}
+
+export function ensureOrganizationPolicies(organizationId) {
+ const organization = dbGet("SELECT id, owner_user_id FROM organizations WHERE id = ?", [organizationId]);
+ if (!organization) throw httpError(404, "organization_not_found", "组织不存在", { organizationId });
+ const timestamp = now();
+ for (const definition of ORGANIZATION_POLICY_DEFINITIONS) {
+ dbRun(
+ `INSERT OR IGNORE INTO organization_policies(
+ id, organization_id, policy_key, category, label, description, status,
+ enforcement, severity, value_json, applies_to_json, approval_required,
+ updated_by, created_at, updated_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ policyId(organizationId, definition.key),
+ organizationId,
+ definition.key,
+ definition.category,
+ definition.label,
+ definition.description,
+ definition.status,
+ definition.enforcement,
+ definition.severity,
+ JSON.stringify(definition.value || {}),
+ JSON.stringify(definition.appliesTo || []),
+ definition.approvalRequired ? 1 : 0,
+ organization.owner_user_id || null,
+ timestamp,
+ timestamp
+ ]
+ );
+ }
+}
+
+function allPolicyRows(organizationId) {
+ ensureOrganizationPolicies(organizationId);
+ return dbAll("SELECT * FROM organization_policies WHERE organization_id = ? ORDER BY category, policy_key", [organizationId]);
+}
+
+function policyRow(organizationId, policyKey) {
+ ensureOrganizationPolicies(organizationId);
+ return dbGet("SELECT * FROM organization_policies WHERE organization_id = ? AND policy_key = ?", [organizationId, policyKey]);
+}
+
+function recentEvaluations(organizationId, limit = 40) {
+ return dbAll(
+ `SELECT *
+ FROM organization_policy_evaluations
+ WHERE organization_id = ?
+ ORDER BY created_at DESC
+ LIMIT ?`,
+ [organizationId, Math.max(1, Math.min(120, Number(limit || 40)))]
+ ).map(evaluationPayload);
+}
+
+function categorySummary(policies) {
+ const summary = {
+ total: policies.length,
+ enforced: policies.filter((policy) => policy.status === "enforced").length,
+ monitor: policies.filter((policy) => policy.status === "monitor").length,
+ approval: policies.filter((policy) => policy.enforcement === "approval").length,
+ disabled: policies.filter((policy) => policy.status === "disabled").length,
+ critical: policies.filter((policy) => policy.severity === "critical").length,
+ byCategory: []
+ };
+ const categories = [...new Set(policies.map((policy) => policy.category))];
+ summary.byCategory = categories.map((category) => ({
+ category,
+ total: policies.filter((policy) => policy.category === category).length,
+ enforced: policies.filter((policy) => policy.category === category && policy.status === "enforced").length
+ }));
+ return summary;
+}
+
+function findEffectivePolicy(policies, key) {
+ const row = policies.find((policy) => policy.policyKey === key);
+ if (row && row.status !== "disabled") return row;
+ const fallback = ORGANIZATION_POLICY_DEFINITIONS.find((definition) => definition.key === key);
+ return fallback ? {
+ policyKey: fallback.key,
+ category: fallback.category,
+ label: fallback.label,
+ status: fallback.status,
+ enforcement: fallback.enforcement,
+ severity: fallback.severity,
+ value: fallback.value || {},
+ appliesTo: fallback.appliesTo || [],
+ approvalRequired: Boolean(fallback.approvalRequired)
+ } : null;
+}
+
+export function organizationPolicySnapshot(contextOrOrganizationId) {
+ const organizationId = typeof contextOrOrganizationId === "string" ? contextOrOrganizationId : contextOrOrganizationId?.organization?.id;
+ if (!organizationId) throw httpError(400, "organization_required", "组织策略需要组织上下文");
+ const policies = allPolicyRows(organizationId).map(policyPayload);
+ const localRunner = findEffectivePolicy(policies, "model.local_runner_default");
+ const externalApproval = findEffectivePolicy(policies, "model.external_connector_approval");
+ const singleFrame = findEffectivePolicy(policies, "generation.single_frame_contract");
+ const voice = findEffectivePolicy(policies, "voice.fixed_voice_required");
+ const lastFrame = findEffectivePolicy(policies, "delivery.actual_last_frame_required");
+ const rights = findEffectivePolicy(policies, "compliance.rights_evidence_required");
+ const security = findEffectivePolicy(policies, "security.admin_mfa_required");
+ const retention = findEffectivePolicy(policies, "retention.production_evidence");
+ const singleFrameValue = singleFrame?.value || {};
+ const voiceValue = voice?.value || {};
+ return {
+ organizationId,
+ generatedAt: now(),
+ policies,
+ model: {
+ defaultLocalOnly: Boolean(localRunner?.value?.defaultLocalOnly ?? true),
+ allowedCostModes: Array.isArray(localRunner?.value?.allowedCostModes) && localRunner.value.allowedCostModes.length ? localRunner.value.allowedCostModes : ["local", "mixed-with-approval"],
+ preferredAdapter: localRunner?.value?.preferredAdapter || "owned-model-platform",
+ comfyuiAdapter: localRunner?.value?.comfyuiAdapter || "optional",
+ externalApprovalRequired: Boolean(externalApproval?.value?.requireApprovalForExternal ?? true),
+ requireSecretRefs: Boolean(externalApproval?.value?.requireSecretRefs ?? true),
+ allowPublicEndpoints: Boolean(externalApproval?.value?.allowPublicEndpoints ?? false),
+ approvedWindowHours: Number(externalApproval?.value?.approvedWindowHours || 24)
+ },
+ generation: {
+ singleFrameOnly: true,
+ imageOutputCount: 1,
+ batchSize: 1,
+ forbiddenTerms: [...new Set([...(Array.isArray(singleFrameValue.forbiddenTerms) ? singleFrameValue.forbiddenTerms : []), ...DEFAULT_FORBIDDEN_LAYOUT_TERMS])],
+ requiredPhrase: singleFrameValue.requiredPhrase || "one single complete frame"
+ },
+ voice: {
+ finalVoiceMode: voiceValue.finalVoiceMode || "fixed-reference-required",
+ forbidRandomNativeVoice: true,
+ ttsPreference: voiceValue.ttsPreference || "local-or-owned-first",
+ fallbackMouthPlan: Array.isArray(voiceValue.fallbackMouthPlan) && voiceValue.fallbackMouthPlan.length ? voiceValue.fallbackMouthPlan : DEFAULT_MOUTH_FALLBACK_PLAN
+ },
+ delivery: {
+ requireActualLastFrameForVideo: Boolean(lastFrame?.value?.requireActualLastFrameForVideo ?? true),
+ blockPlaceholderFrames: Boolean(lastFrame?.value?.blockPlaceholderFrames ?? true),
+ acceptedEvidenceKinds: Array.isArray(lastFrame?.value?.acceptedEvidenceKinds) ? lastFrame.value.acceptedEvidenceKinds : ["last-frame", "media-qa", "delivery-clearance"]
+ },
+ compliance: {
+ rightsEvidenceRequired: Boolean(rights?.value?.rejectUnknownSource ?? true),
+ requiredForKinds: Array.isArray(rights?.value?.requiredForKinds) ? rights.value.requiredForKinds : ["knowledge", "asset", "voice", "delivery"],
+ acceptedStatuses: Array.isArray(rights?.value?.acceptedStatuses) ? rights.value.acceptedStatuses : ["approved", "original"]
+ },
+ security: {
+ adminMfaRequired: Boolean(security?.value?.requireForAdmins ?? true),
+ allUsersMfaRequired: Boolean(security?.value?.requireForAll ?? false),
+ methods: Array.isArray(security?.value?.methods) ? security.value.methods : ["totp"]
+ },
+ retention: {
+ generationContractDays: Number(retention?.value?.generationContractDays || 365),
+ deliveryEvidenceDays: Number(retention?.value?.deliveryEvidenceDays || 730),
+ temporaryMediaDays: Number(retention?.value?.temporaryMediaDays || 30),
+ keepAuditLogs: Boolean(retention?.value?.keepAuditLogs ?? true)
+ }
+ };
+}
+
+function normalizeObject(value, fallback = {}) {
+ if (value === undefined) return fallback;
+ if (typeof value === "string") {
+ const parsed = parseJson(value, null);
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) return parsed;
+ throw httpError(400, "policy_json_invalid", "策略 value 必须是合法 JSON 对象");
+ }
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw httpError(400, "policy_value_invalid", "策略 value 必须是对象");
+ return value;
+}
+
+function normalizeArray(value, fallback = []) {
+ if (value === undefined) return fallback;
+ if (typeof value === "string") {
+ const parsed = parseJson(value, null);
+ if (Array.isArray(parsed)) return parsed;
+ throw httpError(400, "policy_json_invalid", "策略 appliesTo 必须是合法 JSON 数组");
+ }
+ if (!Array.isArray(value)) throw httpError(400, "policy_applies_to_invalid", "策略 appliesTo 必须是数组");
+ return value.map((item) => String(item || "").trim()).filter(Boolean);
+}
+
+function validateHardGate(policyKey, next) {
+ if (policyKey === "generation.single_frame_contract") {
+ if (next.status !== "enforced" || next.enforcement !== "block") {
+ throw httpError(409, "policy_hard_gate_locked", "一图一完整单画面是生产硬门,不能关闭或降级");
+ }
+ if (next.value.enabled === false || Number(next.value.imageOutputCount) !== 1 || Number(next.value.batchSize) !== 1) {
+ throw httpError(409, "policy_hard_gate_locked", "一图一完整单画面必须保持 imageOutputCount=1 且 batchSize=1");
+ }
+ }
+ if (policyKey === "voice.fixed_voice_required") {
+ if (next.status === "disabled" || next.enforcement === "off" || next.value.forbidRandomNativeVoice === false) {
+ throw httpError(409, "policy_hard_gate_locked", "固定声线策略不能允许随机原生声音作为最终方案");
+ }
+ }
+ if (policyKey === "model.external_connector_approval") {
+ if (next.status === "disabled" || next.enforcement === "off" || next.value.requireApprovalForExternal === false) {
+ throw httpError(409, "policy_hard_gate_locked", "外部/付费连接器必须保留显式审批策略");
+ }
+ }
+}
+
+export function listOrganizationPolicies(context, organizationId, options = {}) {
+ assertOrganizationScope(context, organizationId);
+ requirePolicyRead(context);
+ const policies = allPolicyRows(organizationId).map(policyPayload);
+ return {
+ organizationId,
+ summary: categorySummary(policies),
+ policies,
+ effective: organizationPolicySnapshot(organizationId),
+ evaluations: recentEvaluations(organizationId, options.limit || 40)
+ };
+}
+
+export function updateOrganizationPolicy(context, organizationId, policyKey, body = {}) {
+ assertOrganizationScope(context, organizationId);
+ requirePolicyManage(context);
+ const current = policyRow(organizationId, policyKey);
+ if (!current) throw httpError(404, "organization_policy_not_found", "组织策略不存在", { policyKey });
+ const currentPayload = policyPayload(current);
+ const status = String(body.status ?? current.status).trim();
+ const enforcement = String(body.enforcement ?? current.enforcement).trim();
+ const severity = String(body.severity ?? current.severity).trim();
+ if (!["draft", "enforced", "monitor", "disabled"].includes(status)) throw httpError(400, "policy_status_invalid", "策略状态无效");
+ if (!["block", "approval", "warn", "off"].includes(enforcement)) throw httpError(400, "policy_enforcement_invalid", "策略执行方式无效");
+ if (!["critical", "high", "medium", "low"].includes(severity)) throw httpError(400, "policy_severity_invalid", "策略级别无效");
+ const next = {
+ label: String(body.label ?? current.label).trim(),
+ description: String(body.description ?? current.description ?? "").trim(),
+ status,
+ enforcement,
+ severity,
+ value: normalizeObject(body.value ?? body.valueJson, currentPayload.value),
+ appliesTo: normalizeArray(body.appliesTo ?? body.applies_to, currentPayload.appliesTo),
+ approvalRequired: body.approvalRequired === undefined ? Boolean(current.approval_required) : Boolean(body.approvalRequired)
+ };
+ if (!next.label) throw httpError(400, "policy_label_required", "策略名称不能为空");
+ validateHardGate(policyKey, next);
+ const timestamp = now();
+ dbRun(
+ `UPDATE organization_policies
+ SET label = ?, description = ?, status = ?, enforcement = ?, severity = ?,
+ value_json = ?, applies_to_json = ?, approval_required = ?, updated_by = ?, updated_at = ?
+ WHERE organization_id = ? AND policy_key = ?`,
+ [
+ next.label,
+ next.description,
+ next.status,
+ next.enforcement,
+ next.severity,
+ JSON.stringify(next.value),
+ JSON.stringify(next.appliesTo),
+ next.approvalRequired ? 1 : 0,
+ context.user.id,
+ timestamp,
+ organizationId,
+ policyKey
+ ]
+ );
+ addAudit({
+ context,
+ action: "organization.policy.updated",
+ targetType: "organization_policy",
+ targetId: `${organizationId}:${policyKey}`,
+ metadata: { previous: currentPayload, value: next }
+ });
+ return listOrganizationPolicies(context, organizationId, { limit: 40 });
+}
+
+function costModeAllowed(costMode, allowedCostModes) {
+ if (allowedCostModes.includes(costMode)) return true;
+ if (costMode === "mixed" && allowedCostModes.includes("mixed-with-approval")) return true;
+ if (costMode === "cloud" && allowedCostModes.includes("cloud-with-approval")) return true;
+ return false;
+}
+
+function endpointIsPublic(endpoint) {
+ if (!endpoint) return false;
+ try {
+ const url = new URL(String(endpoint));
+ return !privateHost(url.hostname);
+ } catch {
+ return false;
+ }
+}
+
+function checkResult(priority) {
+ if (priority.blockers.length) return "block";
+ if (priority.approvals.length) return "approval_required";
+ if (priority.warnings.length) return "warn";
+ return "pass";
+}
+
+function addCheck(target, check) {
+ target.checks.push(check);
+ if (check.result === "block") target.blockers.push(check);
+ else if (check.result === "approval_required") target.approvals.push(check);
+ else if (check.result === "warn") target.warnings.push(check);
+}
+
+export function evaluateOrganizationPolicies(context, organizationId, input = {}, options = {}) {
+ assertOrganizationScope(context, organizationId);
+ if (options.requireRead !== false) requirePolicyRead(context);
+ const effective = organizationPolicySnapshot(organizationId);
+ const checks = { checks: [], blockers: [], approvals: [], warnings: [] };
+ const subjectType = String(input.subjectType || "generation_request");
+ const subjectId = String(input.subjectId || input.jobId || "");
+ const promptText = [
+ input.prompt,
+ input.positivePrompt,
+ input.title,
+ input.shot?.prompt,
+ input.shot?.videoPrompt,
+ input.shot?.action,
+ input.shot?.camera
+ ].filter(Boolean).join(" ").toLowerCase();
+ const forbiddenHits = effective.generation.forbiddenTerms.filter((term) => promptText.includes(String(term).toLowerCase()));
+ const imageOutputCount = Number(input.imageOutputCount ?? input.image_output_count ?? effective.generation.imageOutputCount);
+ const batchSize = Number(input.batchSize ?? input.batch_size ?? effective.generation.batchSize);
+ if (forbiddenHits.length || imageOutputCount !== 1 || batchSize !== 1) {
+ addCheck(checks, {
+ policyKey: "generation.single_frame_contract",
+ result: "block",
+ severity: "critical",
+ reason: forbiddenHits.length ? `检测到一图多画面风险词:${forbiddenHits.join("、")}` : "图片生成必须保持 imageOutputCount=1 且 batchSize=1",
+ evidence: { forbiddenHits, imageOutputCount, batchSize }
+ });
+ } else {
+ addCheck(checks, {
+ policyKey: "generation.single_frame_contract",
+ result: "pass",
+ severity: "critical",
+ reason: "单画面输出数量与 prompt 风险通过",
+ evidence: { imageOutputCount: 1, batchSize: 1 }
+ });
+ }
+
+ const costMode = String(input.costMode || input.adapter?.costMode || input.adapter?.cost_mode || input.routing?.connectorCostMode || "local").toLowerCase();
+ const endpoint = input.endpoint || input.adapter?.endpoint || input.adapter?.baseUrl || "";
+ const hasApprovalGrant = Boolean(input.approvalGrant || input.approvedExternal || input.modelRouteApproval);
+ if (!costModeAllowed(costMode, effective.model.allowedCostModes)) {
+ addCheck(checks, {
+ policyKey: "model.local_runner_default",
+ result: "block",
+ severity: "critical",
+ reason: `当前成本策略 ${costMode} 不在组织允许范围内`,
+ evidence: { costMode, allowedCostModes: effective.model.allowedCostModes }
+ });
+ } else if (costMode !== "local" && effective.model.externalApprovalRequired && !hasApprovalGrant) {
+ addCheck(checks, {
+ policyKey: "model.external_connector_approval",
+ result: "approval_required",
+ severity: "critical",
+ reason: "外部或混合成本连接器需要显式审批后才能执行",
+ evidence: { costMode, approvedWindowHours: effective.model.approvedWindowHours }
+ });
+ } else {
+ addCheck(checks, {
+ policyKey: costMode === "local" ? "model.local_runner_default" : "model.external_connector_approval",
+ result: "pass",
+ severity: "critical",
+ reason: costMode === "local" ? "命中本地/自有模型策略" : "外部连接器已有审批授权",
+ evidence: { costMode }
+ });
+ }
+ if (endpointIsPublic(endpoint) && !effective.model.allowPublicEndpoints && !hasApprovalGrant) {
+ addCheck(checks, {
+ policyKey: "model.external_connector_approval",
+ result: "approval_required",
+ severity: "critical",
+ reason: "公网模型端点需要审批后才能接入",
+ evidence: { endpointPolicy: "public-network" }
+ });
+ }
+
+ const randomNativeVoice = Boolean(input.randomNativeVoice || input.voice?.randomNativeVoice || String(input.voiceMode || input.voice?.mode || "").toLowerCase() === "random-native");
+ if (randomNativeVoice) {
+ addCheck(checks, {
+ policyKey: "voice.fixed_voice_required",
+ result: "block",
+ severity: "critical",
+ reason: "随机原生声音不能作为最终角色声线",
+ evidence: { finalVoiceMode: effective.voice.finalVoiceMode, fallbackMouthPlan: effective.voice.fallbackMouthPlan }
+ });
+ } else {
+ addCheck(checks, {
+ policyKey: "voice.fixed_voice_required",
+ result: "pass",
+ severity: "critical",
+ reason: "固定声线策略通过",
+ evidence: { finalVoiceMode: effective.voice.finalVoiceMode }
+ });
+ }
+
+ const kind = String(input.kind || input.jobKind || "").toLowerCase();
+ const transitionFromPrevious = input.transitionFromPrevious || input.shot?.transitionFromPrevious;
+ const firstFrame = input.firstFrame || input.shot?.firstFrame || "";
+ if (effective.delivery.requireActualLastFrameForVideo && (kind.includes("视频") || kind.includes("video") || kind.includes("i2v")) && transitionFromPrevious && transitionFromPrevious !== "episode-start" && (!firstFrame || /pending|auto_previous/i.test(firstFrame))) {
+ addCheck(checks, {
+ policyKey: "delivery.actual_last_frame_required",
+ result: "block",
+ severity: "high",
+ reason: "连续视频镜头缺少上一段实际末帧证据",
+ evidence: { transitionFromPrevious, firstFrame: firstFrame || "missing" }
+ });
+ }
+
+ if (input.rightsEvidenceApproved === false && effective.compliance.rightsEvidenceRequired) {
+ addCheck(checks, {
+ policyKey: "compliance.rights_evidence_required",
+ result: "block",
+ severity: "high",
+ reason: "商业生成或交付缺少素材权利证据",
+ evidence: { requiredForKinds: effective.compliance.requiredForKinds }
+ });
+ }
+
+ const result = checkResult(checks);
+ const evaluation = {
+ organizationId,
+ workspaceId: context.workspace?.id || "",
+ projectId: context.project?.id || "",
+ subjectType,
+ subjectId,
+ result,
+ checkedAt: now(),
+ effective,
+ checks: checks.checks,
+ blockers: checks.blockers,
+ approvals: checks.approvals,
+ warnings: checks.warnings
+ };
+ if (options.record) recordPolicyEvaluation(context, evaluation);
+ return evaluation;
+}
+
+export function evaluateGenerationPolicy(context, { body = {}, adapter = {}, routing = null, shot = null, approvalGrant = null } = {}) {
+ return evaluateOrganizationPolicies(context, context.organization.id, {
+ subjectType: "generation_request",
+ subjectId: body.jobId || body.shotId || "",
+ kind: body.kind,
+ prompt: body.prompt,
+ imageOutputCount: body.imageOutputCount,
+ batchSize: body.batchSize,
+ adapter,
+ routing,
+ costMode: routing?.connectorCostMode || adapter?.costMode,
+ endpoint: adapter?.endpoint || adapter?.baseUrl,
+ shot,
+ approvalGrant
+ }, { requireRead: false, record: false });
+}
+
+export function recordPolicyEvaluation(context, evaluation) {
+ const timestamp = now();
+ const subjectId = evaluation.subjectId || `${evaluation.subjectType}-${Date.now()}`;
+ const checks = evaluation.checks?.length ? evaluation.checks : [{
+ policyKey: "organization.policy.bundle",
+ result: evaluation.result || "pass",
+ reason: "组织策略评估",
+ evidence: {}
+ }];
+ withTransaction(() => {
+ for (const check of checks) {
+ dbRun(
+ `INSERT INTO organization_policy_evaluations(
+ id, organization_id, workspace_id, project_id, policy_key,
+ subject_type, subject_id, result, reason, evidence_json, created_by, created_at
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
+ [
+ `pol-eval-${Date.now()}-${randomBytes(4).toString("hex")}`,
+ evaluation.organizationId || context.organization.id,
+ evaluation.workspaceId || context.workspace?.id || null,
+ evaluation.projectId || context.project?.id || null,
+ check.policyKey || "organization.policy.bundle",
+ evaluation.subjectType || "generation_request",
+ subjectId,
+ check.result || evaluation.result || "pass",
+ check.reason || "",
+ JSON.stringify(check.evidence || {}),
+ context.user?.id || null,
+ timestamp
+ ]
+ );
+ }
+ });
+ addAudit({
+ context,
+ action: "organization.policy.evaluated",
+ targetType: evaluation.subjectType || "generation_request",
+ targetId: subjectId,
+ result: evaluation.result || "pass",
+ metadata: {
+ result: evaluation.result,
+ blockers: evaluation.blockers?.map((item) => item.policyKey) || [],
+ approvals: evaluation.approvals?.map((item) => item.policyKey) || []
+ }
+ });
+}
+
+export function evaluateAndRecordOrganizationPolicies(context, organizationId, body = {}) {
+ assertOrganizationScope(context, organizationId);
+ requirePolicyRead(context);
+ const evaluation = evaluateOrganizationPolicies(context, organizationId, body, { requireRead: false, record: true });
+ return {
+ organizationId,
+ evaluation,
+ evaluations: recentEvaluations(organizationId, 40)
+ };
+}
diff --git a/server/schema.sql b/server/schema.sql
index a1db62c..c9b89ed 100644
--- a/server/schema.sql
+++ b/server/schema.sql
@@ -501,6 +501,40 @@ CREATE TABLE IF NOT EXISTS organization_entitlements (
UNIQUE (organization_id, entitlement_key)
);
+CREATE TABLE IF NOT EXISTS organization_policies (
+ id TEXT PRIMARY KEY,
+ organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
+ policy_key TEXT NOT NULL,
+ category TEXT NOT NULL DEFAULT 'production',
+ label TEXT NOT NULL,
+ description TEXT NOT NULL DEFAULT '',
+ status TEXT NOT NULL DEFAULT 'enforced' CHECK (status IN ('draft', 'enforced', 'monitor', 'disabled')),
+ enforcement TEXT NOT NULL DEFAULT 'block' CHECK (enforcement IN ('block', 'approval', 'warn', 'off')),
+ severity TEXT NOT NULL DEFAULT 'high' CHECK (severity IN ('critical', 'high', 'medium', 'low')),
+ value_json TEXT NOT NULL DEFAULT '{}',
+ applies_to_json TEXT NOT NULL DEFAULT '[]',
+ approval_required INTEGER NOT NULL DEFAULT 0,
+ updated_by TEXT REFERENCES users(id),
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ UNIQUE (organization_id, policy_key)
+);
+
+CREATE TABLE IF NOT EXISTS organization_policy_evaluations (
+ 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,
+ policy_key TEXT NOT NULL,
+ subject_type TEXT NOT NULL DEFAULT 'generation_request',
+ subject_id TEXT NOT NULL DEFAULT '',
+ result TEXT NOT NULL DEFAULT 'pass' CHECK (result IN ('pass', 'warn', 'approval_required', 'block')),
+ reason TEXT NOT NULL DEFAULT '',
+ evidence_json TEXT NOT NULL DEFAULT '{}',
+ created_by TEXT REFERENCES users(id),
+ created_at TEXT NOT NULL
+);
+
CREATE TABLE IF NOT EXISTS commercial_approval_requests (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
@@ -574,6 +608,8 @@ CREATE INDEX IF NOT EXISTS idx_organization_invoices_org_status ON organization_
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_organization_policies_org_category ON organization_policies(organization_id, category, policy_key);
+CREATE INDEX IF NOT EXISTS idx_organization_policy_evaluations_scope ON organization_policy_evaluations(organization_id, workspace_id, project_id, result, created_at DESC);
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);
@@ -1035,6 +1071,13 @@ CREATE TABLE IF NOT EXISTS generation_jobs (
updated_at TEXT NOT NULL
);
+CREATE TRIGGER IF NOT EXISTS trg_policy_evaluations_after_job_delete
+AFTER DELETE ON generation_jobs
+BEGIN
+ DELETE FROM organization_policy_evaluations
+ WHERE subject_type = 'generation_job' AND subject_id = OLD.id;
+END;
+
CREATE TABLE IF NOT EXISTS job_attempts (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL REFERENCES generation_jobs(id) ON DELETE CASCADE,
diff --git a/src/App.jsx b/src/App.jsx
index 05944f2..c1dd4d5 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -54,6 +54,7 @@ import {
AdminAuditPage,
AdminModelsPage,
AdminOverviewPage,
+ AdminPolicyCenterPage,
AdminStyleKitsPage,
AdminQueuePage,
AdminUsagePage,
@@ -151,6 +152,7 @@ const navigationGroups = [
{ id: "admin-overview", label: "管理概览", icon: Gauge, requiredPermission: "usage:view" },
{ id: "admin-organizations", label: "组织与工作区", icon: Building2, requiredPermission: "organization:manage" },
{ id: "admin-members", label: "用户与权限", icon: Users, requiredPermission: "organization:members:invite" },
+ { id: "admin-policies", label: "组织策略", icon: ShieldCheck, requiredAnyPermissions: ["policy:read", "policy:manage", "organization:manage"] },
{ id: "admin-style-kits", label: "风格标准", icon: Palette, requiredAnyPermissions: ["style:read", "style:manage"] },
{ id: "admin-models", label: "模型与 Runner", icon: Network, requiredAnyPermissions: ["model:manage", "model:approve"] },
{ id: "admin-queue", label: "队列与任务", icon: Wand2, requiredPermission: "queue:manage" },
@@ -1713,7 +1715,7 @@ function App() {
};
const enterpriseArea = activeTab === "creator-home" || activeTab === "tasks" || activeTab === "assistant" || activeTab === "knowledge" || activeTab === "asset-library" || activeTab === "voice-studio" || activeTab === "batch-production" || activeTab === "account" || activeTab === "factory" || activeTab === "script" || activeTab === "casting" || activeTab === "director" || activeTab === "jobs" || activeTab === "bible" || activeTab === "locks" || activeTab === "shots" || activeTab === "modelops" || activeTab === "qa" || activeTab === "export" || activeTab === "delivery-portal" || activeTab.startsWith("admin-") || activeTab.startsWith("system-");
const effectivePermissions = new Set(platformContext?.context?.permissions || []);
- const canViewAdmin = ["workspace:create", "model:manage", "model:approve", "style:manage", "style:read", "usage:view", "compliance:manage", "audit:view", "queue:manage"].some((permission) => effectivePermissions.has(permission));
+ const canViewAdmin = ["workspace:create", "model:manage", "model:approve", "policy:manage", "policy:read", "organization:manage", "style:manage", "style:read", "usage:view", "compliance:manage", "audit:view", "queue:manage"].some((permission) => effectivePermissions.has(permission));
const canViewSystem = platformContext?.context?.systemAdmin || effectivePermissions.has("system:settings:view");
const canCreateProject = effectivePermissions.has("project:create");
const canCreateJob = effectivePermissions.has("job:create");
@@ -1891,6 +1893,7 @@ function App() {
{activeTab === "admin" &&
{JSON.stringify({ result: evaluation.result, blockers: evaluation.blockers, approvals: evaluation.approvals, warnings: evaluation.warnings }, null, 2)}