feat: add organization policy center
This commit is contained in:
@@ -18,6 +18,7 @@
|
|||||||
"smoke:identity": "node scripts/smoke-identity.mjs",
|
"smoke:identity": "node scripts/smoke-identity.mjs",
|
||||||
"smoke:system-users": "node scripts/smoke-system-users.mjs",
|
"smoke:system-users": "node scripts/smoke-system-users.mjs",
|
||||||
"smoke:commercial-governance": "node scripts/smoke-commercial-governance.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:invitations": "node scripts/smoke-invitations.mjs",
|
||||||
"smoke:commercial-ops": "node scripts/smoke-commercial-ops.mjs",
|
"smoke:commercial-ops": "node scripts/smoke-commercial-ops.mjs",
|
||||||
"smoke:commercial-approvals": "node scripts/smoke-commercial-approvals.mjs",
|
"smoke:commercial-approvals": "node scripts/smoke-commercial-approvals.mjs",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const scripts = [
|
|||||||
"smoke:identity",
|
"smoke:identity",
|
||||||
"smoke:system-users",
|
"smoke:system-users",
|
||||||
"smoke:commercial-governance",
|
"smoke:commercial-governance",
|
||||||
|
"smoke:organization-policies",
|
||||||
"smoke:invitations",
|
"smoke:invitations",
|
||||||
"smoke:commercial-ops",
|
"smoke:commercial-ops",
|
||||||
"smoke:commercial-approvals",
|
"smoke:commercial-approvals",
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
+37
-6
@@ -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_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_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_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_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_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)");
|
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:manage", "修改组织设置"],
|
||||||
["organization:members:invite", "邀请组织成员"],
|
["organization:members:invite", "邀请组织成员"],
|
||||||
["organization:roles:manage", "管理组织角色权限策略"],
|
["organization:roles:manage", "管理组织角色权限策略"],
|
||||||
|
["policy:read", "查看组织生产、安全和合规策略"],
|
||||||
|
["policy:manage", "管理组织生产、安全和合规策略"],
|
||||||
["workspace:create", "创建工作区"],
|
["workspace:create", "创建工作区"],
|
||||||
["workspace:manage", "管理工作区设置"],
|
["workspace:manage", "管理工作区设置"],
|
||||||
["workspace:members:manage", "管理工作区成员"],
|
["workspace:members:manage", "管理工作区成员"],
|
||||||
@@ -285,14 +289,14 @@ function seedRoles() {
|
|||||||
org_admin: [
|
org_admin: [
|
||||||
"organization:manage", "organization:members:invite", "workspace:create", "workspace:manage",
|
"organization:manage", "organization:members:invite", "workspace:create", "workspace:manage",
|
||||||
"workspace:members:manage", "project:create", "project:manage", "project:members: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"
|
"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"],
|
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", "style:read", "job:create"],
|
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", "style:read", "style:manage", "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", "style:read", "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", "style:read", "qa:review", "voice:approve", "delivery:view"],
|
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_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_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"]
|
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]);
|
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 = {}) {
|
function commercialEntitlementDefaults(billing = {}) {
|
||||||
const clipLimit = Number(billing.monthly_clip_quota || 2400);
|
const clipLimit = Number(billing.monthly_clip_quota || 2400);
|
||||||
const storageGb = Number(billing.storage_gb || 1024);
|
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()]);
|
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();
|
seedCommercialPlans();
|
||||||
seedOrganizationEntitlements();
|
seedOrganizationEntitlements();
|
||||||
|
rollQuotaPeriods();
|
||||||
seedCommercialApprovals();
|
seedCommercialApprovals();
|
||||||
seedMembersAndProjects();
|
seedMembersAndProjects();
|
||||||
seedModels();
|
seedModels();
|
||||||
|
|||||||
+41
-5
@@ -18,6 +18,7 @@ import { dispatchNotificationEvent } from "./notifications.mjs";
|
|||||||
import { registerJobArtifacts } from "./media-artifacts.mjs";
|
import { registerJobArtifacts } from "./media-artifacts.mjs";
|
||||||
import { resolveKnowledgeContextForJob } from "./knowledge.mjs";
|
import { resolveKnowledgeContextForJob } from "./knowledge.mjs";
|
||||||
import { effectiveStyleKitForProject, styleKitForbiddenTerms, styleKitNegativeTerms } from "./style-kits.mjs";
|
import { effectiveStyleKitForProject, styleKitForbiddenTerms, styleKitNegativeTerms } from "./style-kits.mjs";
|
||||||
|
import { evaluateGenerationPolicy, recordPolicyEvaluation } from "./organization-policies.mjs";
|
||||||
|
|
||||||
const projectRoot = resolve(import.meta.dirname, "..");
|
const projectRoot = resolve(import.meta.dirname, "..");
|
||||||
const jobStorageRoot = resolve(projectRoot, "storage", "jobs");
|
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 kind = String(body.kind || "自定义生成任务").trim();
|
||||||
const knowledge = resolveKnowledgeContextForJob(context, body);
|
const knowledge = resolveKnowledgeContextForJob(context, body);
|
||||||
const styleKit = context.project ? effectiveStyleKitForProject(context, context.project.id).styleKit : null;
|
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 || {}) };
|
const inputs = { ...(body.inputs || {}) };
|
||||||
if (knowledge) {
|
if (knowledge) {
|
||||||
inputs.knowledgeContext = knowledge.promptContext;
|
inputs.knowledgeContext = knowledge.promptContext;
|
||||||
inputs.knowledgeCitations = knowledge.citations;
|
inputs.knowledgeCitations = knowledge.citations;
|
||||||
inputs.knowledgeChunkIds = knowledge.chunkIds;
|
inputs.knowledgeChunkIds = knowledge.chunkIds;
|
||||||
}
|
}
|
||||||
const blockedTerms = styleKitForbiddenTerms(styleKit);
|
const blockedTerms = [...new Set([...styleKitForbiddenTerms(styleKit), ...(organizationPolicy?.generation?.forbiddenTerms || [])])];
|
||||||
const negativeTerms = styleKitNegativeTerms(styleKit);
|
const negativeTerms = [...new Set([...styleKitNegativeTerms(styleKit), ...(organizationPolicy?.generation?.forbiddenTerms || [])])];
|
||||||
// Negative prompts intentionally name the forbidden layouts; only inspect positive generation text here.
|
// 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 promptText = [shot?.prompt, shot?.videoPrompt, shot?.action, shot?.camera, body.prompt].filter(Boolean).join(" ").toLowerCase();
|
||||||
const blocked = blockedTerms.filter((term) => promptText.includes(term.toLowerCase()));
|
const blocked = blockedTerms.filter((term) => promptText.includes(term.toLowerCase()));
|
||||||
if (blocked.length) throw httpError(422, "single_frame_contract_violation", "请求包含禁止的一图多画面表达", { blocked });
|
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))) {
|
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 });
|
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 {
|
return {
|
||||||
schema: "ai-drama.job.v1",
|
schema: "ai-drama.job.v1",
|
||||||
createdAt: now(),
|
createdAt: now(),
|
||||||
@@ -412,6 +430,14 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
|
|||||||
preflight,
|
preflight,
|
||||||
knowledge,
|
knowledge,
|
||||||
styleKit,
|
styleKit,
|
||||||
|
organizationPolicy: policySnapshot,
|
||||||
|
policyEvaluation: organizationPolicyEvaluation ? {
|
||||||
|
result: organizationPolicyEvaluation.result,
|
||||||
|
blockers: organizationPolicyEvaluation.blockers,
|
||||||
|
approvals: organizationPolicyEvaluation.approvals,
|
||||||
|
warnings: organizationPolicyEvaluation.warnings,
|
||||||
|
checks: organizationPolicyEvaluation.checks
|
||||||
|
} : null,
|
||||||
shot,
|
shot,
|
||||||
inputs,
|
inputs,
|
||||||
constraints: {
|
constraints: {
|
||||||
@@ -419,15 +445,15 @@ function buildContract(context, body, adapter, routing = null, preflight = {}) {
|
|||||||
imageOutputCount: 1,
|
imageOutputCount: 1,
|
||||||
batchSize: 1,
|
batchSize: 1,
|
||||||
requireActualLastFrame: true,
|
requireActualLastFrame: true,
|
||||||
localOnly: routing ? routing.policyMode === "local-only" || routing.connectorCostMode === "local" : adapter.costMode === "local",
|
localOnly: currentCostMode === "local" && Boolean(organizationPolicy?.model?.defaultLocalOnly || (routing ? routing.policyMode === "local-only" : adapter.costMode === "local")),
|
||||||
approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired),
|
approvalRequired: Boolean(routing?.requiresApproval || adapter.approvalRequired || organizationPolicyEvaluation?.approvals?.length),
|
||||||
blockedTerms,
|
blockedTerms,
|
||||||
negativePromptTerms: negativeTerms,
|
negativePromptTerms: negativeTerms,
|
||||||
styleEnforcement: styleKit?.source || "default-contract",
|
styleEnforcement: styleKit?.source || "default-contract",
|
||||||
aspectRatio: styleKit?.deliverySpec?.aspectRatio || "9:16",
|
aspectRatio: styleKit?.deliverySpec?.aspectRatio || "9:16",
|
||||||
resolution: styleKit?.deliverySpec?.resolution || "1080x1920",
|
resolution: styleKit?.deliverySpec?.resolution || "1080x1920",
|
||||||
fps: styleKit?.deliverySpec?.fps || 24,
|
fps: styleKit?.deliverySpec?.fps || 24,
|
||||||
voicePolicy: styleKit?.voicePolicy || null,
|
voicePolicy: styleKit?.voicePolicy || organizationPolicy?.voice || null,
|
||||||
subtitlePreset: styleKit?.subtitlePreset || null
|
subtitlePreset: styleKit?.subtitlePreset || null
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -1045,6 +1071,16 @@ export async function createGenerationJob(context, body) {
|
|||||||
}
|
}
|
||||||
attachModelRouteApprovalToJob(context, approvalGrant, jobId);
|
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 } });
|
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 } });
|
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) };
|
return { job: jobPayload(dbGet("SELECT * FROM generation_jobs WHERE id = ?", [jobId])), jobs: listGenerationJobs(context) };
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ import { reclaimStorage, requireStorageQuota, storageCleanupPreview, storageSumm
|
|||||||
import { backupSummary, createDatabaseBackup } from "./backup.mjs";
|
import { backupSummary, createDatabaseBackup } from "./backup.mjs";
|
||||||
import { systemReadiness } from "./readiness.mjs";
|
import { systemReadiness } from "./readiness.mjs";
|
||||||
import { bindProjectStyleKit, createStyleKit, getProjectStyleKit, getStyleKit, listStyleKits, updateStyleKit } from "./style-kits.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 { dispatchNotificationEvent, listUserNotificationPreferences, listUserNotifications, markAllUserNotificationsRead, markUserNotificationRead, notificationDeliveries, updateUserNotificationPreference } from "./notifications.mjs";
|
||||||
import { composeProject, listCompositions } from "./composition.mjs";
|
import { composeProject, listCompositions } from "./composition.mjs";
|
||||||
import { listProjectArtifacts, readArtifactContent } from "./media-artifacts.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)));
|
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(?:\/([^/]+))?$/);
|
const organizationRolePolicyMatch = pathname.match(/^\/api\/organizations\/([^/]+)\/role-policies(?:\/([^/]+))?$/);
|
||||||
if (req.method === "GET" && organizationRolePolicyMatch) {
|
if (req.method === "GET" && organizationRolePolicyMatch) {
|
||||||
const organizationId = decodeURIComponent(organizationRolePolicyMatch[1]);
|
const organizationId = decodeURIComponent(organizationRolePolicyMatch[1]);
|
||||||
|
|||||||
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -501,6 +501,40 @@ CREATE TABLE IF NOT EXISTS organization_entitlements (
|
|||||||
UNIQUE (organization_id, entitlement_key)
|
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 (
|
CREATE TABLE IF NOT EXISTS commercial_approval_requests (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
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_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_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_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_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_requester ON commercial_approval_requests(requester_user_id, status, created_at DESC);
|
||||||
CREATE INDEX IF NOT EXISTS idx_commercial_approvals_target ON commercial_approval_requests(organization_id, request_type, target_key, status);
|
CREATE 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
|
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 (
|
CREATE TABLE IF NOT EXISTS job_attempts (
|
||||||
id TEXT PRIMARY KEY,
|
id TEXT PRIMARY KEY,
|
||||||
job_id TEXT NOT NULL REFERENCES generation_jobs(id) ON DELETE CASCADE,
|
job_id TEXT NOT NULL REFERENCES generation_jobs(id) ON DELETE CASCADE,
|
||||||
|
|||||||
+4
-1
@@ -54,6 +54,7 @@ import {
|
|||||||
AdminAuditPage,
|
AdminAuditPage,
|
||||||
AdminModelsPage,
|
AdminModelsPage,
|
||||||
AdminOverviewPage,
|
AdminOverviewPage,
|
||||||
|
AdminPolicyCenterPage,
|
||||||
AdminStyleKitsPage,
|
AdminStyleKitsPage,
|
||||||
AdminQueuePage,
|
AdminQueuePage,
|
||||||
AdminUsagePage,
|
AdminUsagePage,
|
||||||
@@ -151,6 +152,7 @@ const navigationGroups = [
|
|||||||
{ id: "admin-overview", label: "管理概览", icon: Gauge, requiredPermission: "usage:view" },
|
{ id: "admin-overview", label: "管理概览", icon: Gauge, requiredPermission: "usage:view" },
|
||||||
{ id: "admin-organizations", label: "组织与工作区", icon: Building2, requiredPermission: "organization:manage" },
|
{ id: "admin-organizations", label: "组织与工作区", icon: Building2, requiredPermission: "organization:manage" },
|
||||||
{ id: "admin-members", label: "用户与权限", icon: Users, requiredPermission: "organization:members:invite" },
|
{ 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-style-kits", label: "风格标准", icon: Palette, requiredAnyPermissions: ["style:read", "style:manage"] },
|
||||||
{ id: "admin-models", label: "模型与 Runner", icon: Network, requiredAnyPermissions: ["model:manage", "model:approve"] },
|
{ id: "admin-models", label: "模型与 Runner", icon: Network, requiredAnyPermissions: ["model:manage", "model:approve"] },
|
||||||
{ id: "admin-queue", label: "队列与任务", icon: Wand2, requiredPermission: "queue:manage" },
|
{ 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 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 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 canViewSystem = platformContext?.context?.systemAdmin || effectivePermissions.has("system:settings:view");
|
||||||
const canCreateProject = effectivePermissions.has("project:create");
|
const canCreateProject = effectivePermissions.has("project:create");
|
||||||
const canCreateJob = effectivePermissions.has("job:create");
|
const canCreateJob = effectivePermissions.has("job:create");
|
||||||
@@ -1891,6 +1893,7 @@ function App() {
|
|||||||
{activeTab === "admin" && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
|
{activeTab === "admin" && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
|
||||||
{activeTab === "admin-overview" && <AdminOverviewPage platformContext={platformContext} contextOverrides={contextOverrides} setActiveTab={setActiveTab} />}
|
{activeTab === "admin-overview" && <AdminOverviewPage platformContext={platformContext} contextOverrides={contextOverrides} setActiveTab={setActiveTab} />}
|
||||||
{(activeTab === "admin-organizations" || activeTab === "admin-members") && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
|
{(activeTab === "admin-organizations" || activeTab === "admin-members") && <AdminCenter platformContext={platformContext} onRefresh={refreshContext} onSwitchOrganization={switchOrganization} onInvite={inviteFromAdmin} onResendInvitation={resendInvitationFromAdmin} onRevokeInvitation={revokeInvitationFromAdmin} onCreateOrganization={createOrganizationFromAdmin} onCreateWorkspace={createWorkspaceFromAdmin} onCreateProject={createProjectFromAdmin} onUpdateOrganization={updateOrganizationFromAdmin} onUpdateWorkspace={updateWorkspaceFromAdmin} onUpdateProject={updateProjectFromAdmin} onUpdateOrganizationMember={updateOrganizationMemberFromAdmin} onUpdateOrganizationRolePolicy={updateOrganizationRolePolicyFromAdmin} onUpdateWorkspaceMember={updateWorkspaceMemberFromAdmin} onUpdateProjectMember={updateProjectMemberFromAdmin} onAddProjectMember={addProjectMemberFromAdmin} />}
|
||||||
|
{activeTab === "admin-policies" && <AdminPolicyCenterPage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
||||||
{activeTab === "admin-style-kits" && <AdminStyleKitsPage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
{activeTab === "admin-style-kits" && <AdminStyleKitsPage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
||||||
{activeTab === "admin-models" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />}
|
{activeTab === "admin-models" && <AdminModelsPage platformContext={platformContext} contextOverrides={contextOverrides} onModelsChanged={syncModelRegistry} />}
|
||||||
{activeTab === "admin-queue" && <AdminQueuePage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
{activeTab === "admin-queue" && <AdminQueuePage platformContext={platformContext} contextOverrides={contextOverrides} />}
|
||||||
|
|||||||
@@ -121,11 +121,14 @@ import {
|
|||||||
exportOrganizationInvoicesCsv,
|
exportOrganizationInvoicesCsv,
|
||||||
fetchSystemUser,
|
fetchSystemUser,
|
||||||
fetchSystemUsers,
|
fetchSystemUsers,
|
||||||
|
evaluateOrganizationPolicies,
|
||||||
|
fetchOrganizationPolicies,
|
||||||
resetSystemUserMfa,
|
resetSystemUserMfa,
|
||||||
resetSystemUserPassword,
|
resetSystemUserPassword,
|
||||||
revokeSystemUserSessions,
|
revokeSystemUserSessions,
|
||||||
updateOrganizationBilling,
|
updateOrganizationBilling,
|
||||||
updateOrganizationEntitlement,
|
updateOrganizationEntitlement,
|
||||||
|
updateOrganizationPolicy,
|
||||||
generateOrganizationInvoice,
|
generateOrganizationInvoice,
|
||||||
updateOrganizationInvoiceStatus,
|
updateOrganizationInvoiceStatus,
|
||||||
updateCostCenter,
|
updateCostCenter,
|
||||||
@@ -2572,6 +2575,246 @@ export function AdminStyleKitsPage({ platformContext, contextOverrides }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const policyStatusLabels = {
|
||||||
|
draft: "草稿",
|
||||||
|
enforced: "强制",
|
||||||
|
monitor: "观察",
|
||||||
|
disabled: "停用"
|
||||||
|
};
|
||||||
|
|
||||||
|
const policyEnforcementLabels = {
|
||||||
|
block: "阻断",
|
||||||
|
approval: "审批",
|
||||||
|
warn: "预警",
|
||||||
|
off: "关闭"
|
||||||
|
};
|
||||||
|
|
||||||
|
const policySeverityLabels = {
|
||||||
|
critical: "关键",
|
||||||
|
high: "高",
|
||||||
|
medium: "中",
|
||||||
|
low: "低"
|
||||||
|
};
|
||||||
|
|
||||||
|
const policyCategoryLabels = {
|
||||||
|
model: "模型",
|
||||||
|
production: "生产",
|
||||||
|
voice: "声音",
|
||||||
|
delivery: "交付",
|
||||||
|
compliance: "合规",
|
||||||
|
security: "安全",
|
||||||
|
storage: "存储"
|
||||||
|
};
|
||||||
|
|
||||||
|
const policyResultLabels = {
|
||||||
|
pass: "通过",
|
||||||
|
warn: "预警",
|
||||||
|
approval_required: "需审批",
|
||||||
|
block: "阻断"
|
||||||
|
};
|
||||||
|
|
||||||
|
function policyResultStatus(result) {
|
||||||
|
if (result === "pass") return "approved";
|
||||||
|
if (result === "block") return "blocked";
|
||||||
|
if (result === "approval_required") return "needs-evidence";
|
||||||
|
return "review";
|
||||||
|
}
|
||||||
|
|
||||||
|
function policyFormFromPolicy(policy = {}) {
|
||||||
|
return {
|
||||||
|
policyKey: policy.policyKey || "",
|
||||||
|
label: policy.label || "",
|
||||||
|
description: policy.description || "",
|
||||||
|
status: policy.status || "enforced",
|
||||||
|
enforcement: policy.enforcement || "block",
|
||||||
|
severity: policy.severity || "high",
|
||||||
|
approvalRequired: Boolean(policy.approvalRequired),
|
||||||
|
valueJson: JSON.stringify(policy.value || {}, null, 2),
|
||||||
|
appliesToJson: JSON.stringify(policy.appliesTo || [], null, 2)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePolicyJson(value, label, array = false) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value || (array ? "[]" : "{}"));
|
||||||
|
if (array && !Array.isArray(parsed)) throw new Error("array");
|
||||||
|
if (!array && (!parsed || typeof parsed !== "object" || Array.isArray(parsed))) throw new Error("object");
|
||||||
|
return parsed;
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} 不是合法 ${array ? "JSON 数组" : "JSON 对象"}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AdminPolicyCenterPage({ platformContext, contextOverrides }) {
|
||||||
|
const permissions = new Set(platformContext?.context?.permissions || []);
|
||||||
|
const canManage = Boolean(platformContext?.context?.systemAdmin || permissions.has("policy:manage") || permissions.has("organization:manage"));
|
||||||
|
const organizationId = contextOverrides.organizationId || platformContext?.context?.currentOrganization?.id || "";
|
||||||
|
const [data, setData] = useState({ policies: [], summary: {}, effective: {}, evaluations: [] });
|
||||||
|
const [selectedKey, setSelectedKey] = useState("");
|
||||||
|
const [form, setForm] = useState(policyFormFromPolicy());
|
||||||
|
const [simulation, setSimulation] = useState({
|
||||||
|
kind: "单画面关键帧",
|
||||||
|
prompt: "国漫 2D 动画风格,雨夜地铁口,一个完整单画面。",
|
||||||
|
costMode: "local",
|
||||||
|
endpoint: "http://127.0.0.1:8000/v1",
|
||||||
|
imageOutputCount: 1,
|
||||||
|
batchSize: 1,
|
||||||
|
randomNativeVoice: false,
|
||||||
|
rightsEvidenceApproved: true
|
||||||
|
});
|
||||||
|
const [evaluation, setEvaluation] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [busy, setBusy] = useState("");
|
||||||
|
const [notice, setNotice] = useState("");
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (!organizationId) return;
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const result = await fetchOrganizationPolicies(organizationId, contextOverrides);
|
||||||
|
setData(result);
|
||||||
|
const selected = result.policies.find((policy) => policy.policyKey === selectedKey) || result.policies[0];
|
||||||
|
setSelectedKey(selected?.policyKey || "");
|
||||||
|
setForm(policyFormFromPolicy(selected));
|
||||||
|
setNotice("");
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, [organizationId]);
|
||||||
|
|
||||||
|
const selected = data.policies.find((policy) => policy.policyKey === selectedKey) || null;
|
||||||
|
const recentBlocks = (data.evaluations || []).filter((item) => item.result === "block").length;
|
||||||
|
const effective = data.effective || {};
|
||||||
|
|
||||||
|
function selectPolicy(policy) {
|
||||||
|
setSelectedKey(policy.policyKey);
|
||||||
|
setForm(policyFormFromPolicy(policy));
|
||||||
|
setNotice("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePolicy(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!canManage || !selectedKey) return;
|
||||||
|
setBusy("save");
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
label: form.label,
|
||||||
|
description: form.description,
|
||||||
|
status: form.status,
|
||||||
|
enforcement: form.enforcement,
|
||||||
|
severity: form.severity,
|
||||||
|
approvalRequired: form.approvalRequired,
|
||||||
|
value: parsePolicyJson(form.valueJson, "value"),
|
||||||
|
appliesTo: parsePolicyJson(form.appliesToJson, "appliesTo", true)
|
||||||
|
};
|
||||||
|
const result = await updateOrganizationPolicy(organizationId, selectedKey, body, contextOverrides);
|
||||||
|
setData(result);
|
||||||
|
const saved = result.policies.find((policy) => policy.policyKey === selectedKey);
|
||||||
|
setForm(policyFormFromPolicy(saved));
|
||||||
|
setNotice(`组织策略“${body.label}”已保存,并写入审计日志`);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runSimulation(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
setBusy("simulate");
|
||||||
|
try {
|
||||||
|
const result = await evaluateOrganizationPolicies(organizationId, {
|
||||||
|
subjectType: "generation_request",
|
||||||
|
subjectId: `manual-${Date.now()}`,
|
||||||
|
kind: simulation.kind,
|
||||||
|
prompt: simulation.prompt,
|
||||||
|
costMode: simulation.costMode,
|
||||||
|
endpoint: simulation.endpoint,
|
||||||
|
imageOutputCount: Number(simulation.imageOutputCount || 1),
|
||||||
|
batchSize: Number(simulation.batchSize || 1),
|
||||||
|
randomNativeVoice: simulation.randomNativeVoice,
|
||||||
|
rightsEvidenceApproved: Boolean(simulation.rightsEvidenceApproved)
|
||||||
|
}, contextOverrides);
|
||||||
|
setEvaluation(result.evaluation);
|
||||||
|
setData((current) => ({ ...current, evaluations: result.evaluations || current.evaluations }));
|
||||||
|
setNotice(`策略模拟完成:${policyResultLabels[result.evaluation.result] || result.evaluation.result}`);
|
||||||
|
} catch (error) {
|
||||||
|
setNotice(error.message);
|
||||||
|
} finally {
|
||||||
|
setBusy("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="enterprise-page org-policy-page">
|
||||||
|
<AdminHeader eyebrow="ADMIN CONSOLE / ORGANIZATION POLICIES" title="组织策略中心" description="按组织管理模型、生成、声音、交付、合规、安全和留存策略。策略会进入生成合同、评估记录和审计日志。" action={<div className="header-actions"><StatusBadge status={data.summary?.disabled ? "review" : "enforced"} label={`${data.summary?.enforced || 0} 项强制`} /><button className="subtle" onClick={load} disabled={loading}><RefreshCw size={15} />刷新</button></div>} />
|
||||||
|
{notice && <div className={`inline-notice ${notice.includes("不能") || notice.includes("不是") || notice.includes("阻断") ? "warn" : "ok"}`}><CheckCircle2 size={15} />{notice}</div>}
|
||||||
|
<div className="admin-kpi-grid compact-kpis">
|
||||||
|
<AdminKpi icon={ShieldCheck} label="策略总数" value={data.summary?.total || data.policies.length} detail={`${data.summary?.critical || 0} 项关键硬门`} tone="ok" />
|
||||||
|
<AdminKpi icon={ShieldAlert} label="审批策略" value={data.summary?.approval || 0} detail="外部模型与公网端点" tone="neutral" />
|
||||||
|
<AdminKpi icon={FileText} label="最近评估" value={(data.evaluations || []).length} detail={`${recentBlocks} 条阻断记录`} tone={recentBlocks ? "warn" : "ok"} />
|
||||||
|
<AdminKpi icon={Network} label="默认模型策略" value={effective.model?.defaultLocalOnly ? "Local" : "Mixed"} detail={(effective.model?.allowedCostModes || []).join(" / ")} tone="ok" />
|
||||||
|
</div>
|
||||||
|
<div className="org-policy-layout">
|
||||||
|
<section className="studio-card org-policy-list-card">
|
||||||
|
<SectionBar title="策略目录" detail={loading ? "读取中…" : `${data.policies.length} 项组织策略`} />
|
||||||
|
<div className="org-policy-list">
|
||||||
|
{data.policies.map((policy) => <button key={policy.policyKey} type="button" className={policy.policyKey === selectedKey ? "selected" : ""} onClick={() => selectPolicy(policy)}><div><strong>{policy.label}</strong><span>{policyCategoryLabels[policy.category] || policy.category} · {policyEnforcementLabels[policy.enforcement] || policy.enforcement}</span><small>{policy.description}</small></div><StatusBadge status={policy.status} label={policyStatusLabels[policy.status] || policy.status} /></button>)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<section className="studio-card wide-card org-policy-editor-card">
|
||||||
|
<SectionBar title={selected ? `编辑:${selected.label}` : "编辑策略"} detail={canManage ? "保存后服务端立即执行" : "只读 · 需要 policy:manage"} action={<StatusBadge status={form.severity === "critical" ? "blocked" : "active"} label={policySeverityLabels[form.severity] || form.severity} />} />
|
||||||
|
<form className="org-policy-form" onSubmit={savePolicy}>
|
||||||
|
<label>策略 Key<input value={form.policyKey} readOnly /></label>
|
||||||
|
<label>名称<input value={form.label} disabled={!canManage} onChange={(event) => setForm({ ...form, label: event.target.value })} required /></label>
|
||||||
|
<label>状态<select value={form.status} disabled={!canManage} onChange={(event) => setForm({ ...form, status: event.target.value })}><option value="draft">草稿</option><option value="enforced">强制</option><option value="monitor">观察</option><option value="disabled">停用</option></select></label>
|
||||||
|
<label>执行方式<select value={form.enforcement} disabled={!canManage} onChange={(event) => setForm({ ...form, enforcement: event.target.value })}><option value="block">阻断</option><option value="approval">审批</option><option value="warn">预警</option><option value="off">关闭</option></select></label>
|
||||||
|
<label>级别<select value={form.severity} disabled={!canManage} onChange={(event) => setForm({ ...form, severity: event.target.value })}><option value="critical">关键</option><option value="high">高</option><option value="medium">中</option><option value="low">低</option></select></label>
|
||||||
|
<label className="checkbox-line"><input type="checkbox" checked={form.approvalRequired} disabled={!canManage} onChange={(event) => setForm({ ...form, approvalRequired: event.target.checked })} />需要审批记录</label>
|
||||||
|
<label className="org-policy-description">描述<textarea rows="3" disabled={!canManage} value={form.description} onChange={(event) => setForm({ ...form, description: event.target.value })} /></label>
|
||||||
|
<label className="org-policy-json-field">value JSON<textarea rows="12" disabled={!canManage} value={form.valueJson} onChange={(event) => setForm({ ...form, valueJson: event.target.value })} /></label>
|
||||||
|
<label className="org-policy-json-field">appliesTo JSON<textarea rows="12" disabled={!canManage} value={form.appliesToJson} onChange={(event) => setForm({ ...form, appliesToJson: event.target.value })} /></label>
|
||||||
|
<div className="approval-policy-note"><ShieldCheck size={15} /><span>单画面、固定声线、外部连接器审批属于硬门:服务端会拒绝关闭或降级。</span></div>
|
||||||
|
<div className="form-actions"><button className="primary" type="submit" disabled={!canManage || busy === "save"}><Save size={15} />{busy === "save" ? "保存中…" : "保存策略"}</button></div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div className="enterprise-grid enterprise-grid-main org-policy-bottom-grid">
|
||||||
|
<section className="studio-card">
|
||||||
|
<SectionBar title="策略模拟" detail="写入评估 ledger" action={<SlidersHorizontal size={16} />} />
|
||||||
|
<form className="org-policy-simulator" onSubmit={runSimulation}>
|
||||||
|
<label>任务类型<input value={simulation.kind} onChange={(event) => setSimulation({ ...simulation, kind: event.target.value })} /></label>
|
||||||
|
<label>成本模式<select value={simulation.costMode} onChange={(event) => setSimulation({ ...simulation, costMode: event.target.value })}><option value="local">local</option><option value="mixed">mixed</option><option value="cloud">cloud</option></select></label>
|
||||||
|
<label>端点<input value={simulation.endpoint} onChange={(event) => setSimulation({ ...simulation, endpoint: event.target.value })} /></label>
|
||||||
|
<label>图片数量<input type="number" min="1" value={simulation.imageOutputCount} onChange={(event) => setSimulation({ ...simulation, imageOutputCount: event.target.value })} /></label>
|
||||||
|
<label>Batch Size<input type="number" min="1" value={simulation.batchSize} onChange={(event) => setSimulation({ ...simulation, batchSize: event.target.value })} /></label>
|
||||||
|
<label className="checkbox-line"><input type="checkbox" checked={simulation.randomNativeVoice} onChange={(event) => setSimulation({ ...simulation, randomNativeVoice: event.target.checked })} />随机原生声线</label>
|
||||||
|
<label className="checkbox-line"><input type="checkbox" checked={simulation.rightsEvidenceApproved} onChange={(event) => setSimulation({ ...simulation, rightsEvidenceApproved: event.target.checked })} />素材权利证据已通过</label>
|
||||||
|
<label className="org-policy-description">Prompt<textarea rows="4" value={simulation.prompt} onChange={(event) => setSimulation({ ...simulation, prompt: event.target.value })} /></label>
|
||||||
|
<button className="primary full-width" type="submit" disabled={busy === "simulate"}><Play size={15} />{busy === "simulate" ? "评估中…" : "运行策略评估"}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
<section className="studio-card wide-card">
|
||||||
|
<SectionBar title="评估结果" detail={evaluation ? new Date(evaluation.checkedAt).toLocaleString("zh-CN", { hour12: false }) : "等待模拟"} action={evaluation ? <StatusBadge status={policyResultStatus(evaluation.result)} label={policyResultLabels[evaluation.result] || evaluation.result} /> : null} />
|
||||||
|
{evaluation ? <div className="org-policy-evaluation"><div className="org-policy-check-grid">{evaluation.checks.map((check, index) => <div key={`${check.policyKey}-${index}`}><div><strong>{data.policies.find((policy) => policy.policyKey === check.policyKey)?.label || check.policyKey}</strong><StatusBadge status={policyResultStatus(check.result)} label={policyResultLabels[check.result] || check.result} /></div><span>{check.reason}</span><small>{JSON.stringify(check.evidence || {})}</small></div>)}</div><pre>{JSON.stringify({ result: evaluation.result, blockers: evaluation.blockers, approvals: evaluation.approvals, warnings: evaluation.warnings }, null, 2)}</pre></div> : <div className="empty-table">运行一次策略模拟后会显示每个政策的命中结果。</div>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<section className="studio-card wide-card">
|
||||||
|
<SectionBar title="最近策略评估记录" detail={`${data.evaluations?.length || 0} 条`} />
|
||||||
|
<div className="org-policy-evaluation-table">
|
||||||
|
<div className="org-policy-evaluation-head"><span>时间</span><span>策略</span><span>对象</span><span>结果</span><span>原因</span></div>
|
||||||
|
{(data.evaluations || []).slice(0, 12).map((item) => <div className="org-policy-evaluation-row" key={item.id}><span>{item.createdAt?.replace("T", " ").slice(0, 19)}</span><strong>{data.policies.find((policy) => policy.policyKey === item.policyKey)?.label || item.policyKey}</strong><span>{item.subjectType}<small>{item.subjectId || "manual"}</small></span><StatusBadge status={policyResultStatus(item.result)} label={policyResultLabels[item.result] || item.result} /><small>{item.reason}</small></div>)}
|
||||||
|
{!(data.evaluations || []).length && <div className="empty-table">暂无策略评估记录。</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const auditResultLabels = {
|
const auditResultLabels = {
|
||||||
ok: "成功",
|
ok: "成功",
|
||||||
pass: "通过",
|
pass: "通过",
|
||||||
|
|||||||
@@ -570,6 +570,18 @@ export async function updateCostCenter(organizationId, costCenterId, body, overr
|
|||||||
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/cost-centers/${encodeURIComponent(costCenterId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/cost-centers/${encodeURIComponent(costCenterId)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchOrganizationPolicies(organizationId, overrides = {}) {
|
||||||
|
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/policies`, {}, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateOrganizationPolicy(organizationId, policyKey, body, overrides = {}) {
|
||||||
|
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/policies/${encodeURIComponent(policyKey)}`, { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function evaluateOrganizationPolicies(organizationId, body, overrides = {}) {
|
||||||
|
return apiFetch(`/api/organizations/${encodeURIComponent(organizationId)}/policies/evaluate`, { method: "POST", body: JSON.stringify(body) }, overrides);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateIdentityPolicy(body, overrides = {}) {
|
export async function updateIdentityPolicy(body, overrides = {}) {
|
||||||
return apiFetch("/api/system/identity/policy", { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
return apiFetch("/api/system/identity/policy", { method: "PATCH", body: JSON.stringify(body) }, overrides);
|
||||||
}
|
}
|
||||||
|
|||||||
+235
@@ -7023,6 +7023,234 @@ body {
|
|||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.org-policy-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 360px minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
.org-policy-list-card {
|
||||||
|
position: sticky;
|
||||||
|
top: 14px;
|
||||||
|
}
|
||||||
|
.org-policy-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.org-policy-list button {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 88px;
|
||||||
|
padding: 10px 11px;
|
||||||
|
color: inherit;
|
||||||
|
background: #f8fbf8;
|
||||||
|
border: 1px solid #e0e7e2;
|
||||||
|
border-radius: 8px;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.org-policy-list button.selected,
|
||||||
|
.org-policy-list button:hover {
|
||||||
|
background: #eef8f2;
|
||||||
|
border-color: #8fc7aa;
|
||||||
|
}
|
||||||
|
.org-policy-list strong {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.org-policy-list span,
|
||||||
|
.org-policy-list small {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--faint);
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.45;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.org-policy-form,
|
||||||
|
.org-policy-simulator {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.org-policy-form {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
align-items: end;
|
||||||
|
}
|
||||||
|
.org-policy-form label,
|
||||||
|
.org-policy-simulator label {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
.org-policy-form .checkbox-line,
|
||||||
|
.org-policy-simulator .checkbox-line {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #f8fbf8;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
}
|
||||||
|
.org-policy-form .checkbox-line input,
|
||||||
|
.org-policy-simulator .checkbox-line input {
|
||||||
|
width: 16px;
|
||||||
|
min-width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
min-height: 16px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
accent-color: var(--accent);
|
||||||
|
}
|
||||||
|
.org-policy-form input,
|
||||||
|
.org-policy-form select,
|
||||||
|
.org-policy-form textarea,
|
||||||
|
.org-policy-simulator input,
|
||||||
|
.org-policy-simulator select,
|
||||||
|
.org-policy-simulator textarea {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
color: var(--ink);
|
||||||
|
background: #ffffff;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 7px;
|
||||||
|
outline: 0;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.org-policy-form textarea,
|
||||||
|
.org-policy-simulator textarea {
|
||||||
|
resize: vertical;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.org-policy-description,
|
||||||
|
.org-policy-form .approval-policy-note {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
.org-policy-json-field {
|
||||||
|
grid-column: span 3;
|
||||||
|
}
|
||||||
|
.org-policy-json-field textarea,
|
||||||
|
.org-policy-evaluation pre {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.org-policy-json-field textarea {
|
||||||
|
min-height: 230px;
|
||||||
|
background: #fbfcfb;
|
||||||
|
}
|
||||||
|
.org-policy-bottom-grid {
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.org-policy-check-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.org-policy-check-grid > div {
|
||||||
|
min-width: 0;
|
||||||
|
padding: 11px 12px;
|
||||||
|
background: #f7faf8;
|
||||||
|
border: 1px solid #e0e7e2;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.org-policy-check-grid > div > div {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
.org-policy-check-grid strong,
|
||||||
|
.org-policy-check-grid span,
|
||||||
|
.org-policy-check-grid small {
|
||||||
|
display: block;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.org-policy-check-grid strong {
|
||||||
|
color: var(--ink);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.org-policy-check-grid span {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.org-policy-check-grid small {
|
||||||
|
margin-top: 7px;
|
||||||
|
color: var(--faint);
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.45;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.org-policy-evaluation pre {
|
||||||
|
max-height: 320px;
|
||||||
|
margin: 12px 0 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 12px;
|
||||||
|
color: #f5fbf5;
|
||||||
|
background: #101b17;
|
||||||
|
border-radius: 8px;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
.org-policy-evaluation-table {
|
||||||
|
display: grid;
|
||||||
|
gap: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.org-policy-evaluation-head,
|
||||||
|
.org-policy-evaluation-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 150px minmax(150px, 1fr) minmax(140px, 0.8fr) 96px minmax(180px, 1.2fr);
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 9px 11px;
|
||||||
|
}
|
||||||
|
.org-policy-evaluation-head {
|
||||||
|
color: var(--faint);
|
||||||
|
background: #f3f7f4;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 900;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.org-policy-evaluation-row {
|
||||||
|
background: #ffffff;
|
||||||
|
border-top: 1px solid var(--line);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.org-policy-evaluation-row > * {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.org-policy-evaluation-row small {
|
||||||
|
display: block;
|
||||||
|
color: var(--faint);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1180px) {
|
@media (max-width: 1180px) {
|
||||||
.production-template-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
.production-template-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
|
||||||
.production-three-column { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
.production-three-column { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||||
@@ -7050,12 +7278,17 @@ body {
|
|||||||
.style-kit-layout,
|
.style-kit-layout,
|
||||||
.style-kit-form,
|
.style-kit-form,
|
||||||
.style-kit-contract-preview,
|
.style-kit-contract-preview,
|
||||||
|
.org-policy-layout,
|
||||||
|
.org-policy-form,
|
||||||
|
.org-policy-check-grid,
|
||||||
.governance-form-grid,
|
.governance-form-grid,
|
||||||
.knowledge-search-controls,
|
.knowledge-search-controls,
|
||||||
.studio-queue-grid { grid-template-columns: 1fr; }
|
.studio-queue-grid { grid-template-columns: 1fr; }
|
||||||
.model-resolution-wide { grid-column: auto; }
|
.model-resolution-wide { grid-column: auto; }
|
||||||
.style-kit-list-card { position: static; }
|
.style-kit-list-card { position: static; }
|
||||||
.style-kit-json-field { grid-column: auto; }
|
.style-kit-json-field { grid-column: auto; }
|
||||||
|
.org-policy-list-card { position: static; }
|
||||||
|
.org-policy-json-field { grid-column: auto; }
|
||||||
.studio-banner,
|
.studio-banner,
|
||||||
.studio-stage-head { flex-direction: column; }
|
.studio-stage-head { flex-direction: column; }
|
||||||
.studio-banner-side { width: 100%; }
|
.studio-banner-side { width: 100%; }
|
||||||
@@ -7095,6 +7328,8 @@ body {
|
|||||||
.commercial-asset-inspector { max-height: none; }
|
.commercial-asset-inspector { max-height: none; }
|
||||||
.asset-card-statuses { justify-content: flex-start; max-width: none; }
|
.asset-card-statuses { justify-content: flex-start; max-width: none; }
|
||||||
.governance-action-row button { flex: 1 1 140px; justify-content: center; }
|
.governance-action-row button { flex: 1 1 140px; justify-content: center; }
|
||||||
|
.org-policy-evaluation-head { display: none; }
|
||||||
|
.org-policy-evaluation-row { grid-template-columns: 1fr; align-items: start; }
|
||||||
.studio-queue-actions { flex-direction: column; }
|
.studio-queue-actions { flex-direction: column; }
|
||||||
.studio-queue-actions button { width: 100%; justify-content: flex-start; }
|
.studio-queue-actions button { width: 100%; justify-content: flex-start; }
|
||||||
.production-three-column,
|
.production-three-column,
|
||||||
|
|||||||
Reference in New Issue
Block a user