Files
ai-drama-platform/server/organization-policies.mjs
2026-09-01 13:22:04 +08:00

736 lines
30 KiB
JavaScript

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)
};
}