/** * 系统配置定义(A4-BE-02 / PRD R-26) * * `system_configs` 是阈值 / 上限的唯一来源;本文件是键、分组、类型、默认值与校验规则的 * 唯一事实源,前后端共同引用,禁止在业务代码里硬编码阈值。 */ import { validateNamingTemplate } from '../naming.js'; export const SYSTEM_CONFIG_GROUP_KEYS = [ 'basic', 'naming', 'export', 'notify', 'limits', 'threshold', 'audit', ] as const; export type SystemConfigGroupKey = (typeof SYSTEM_CONFIG_GROUP_KEYS)[number]; export const SYSTEM_CONFIG_GROUPS: readonly { key: SystemConfigGroupKey; name: string; description: string; sortOrder: number; }[] = [ { key: 'basic', name: '基础信息', description: '平台展示名与通用列表参数', sortOrder: 10 }, { key: 'naming', name: '命名规则', description: '脚本、图片、视频的命名模板与校验', sortOrder: 20, }, { key: 'export', name: '导出与归档', description: '签名链接、保留期与上传 / 导出上限', sortOrder: 30, }, { key: 'notify', name: '通知开关', description: '任务、审核与导出完成的站内通知', sortOrder: 40 }, { key: 'limits', name: '阈值与上限', description: '角色、字段与分页的硬上限', sortOrder: 50 }, { key: 'threshold', name: '业务阈值', description: '爆量判定、审核超时与质量告警', sortOrder: 60, }, { key: 'audit', name: '审计与日志', description: '审计保留期、导出上限与运行日志平台入口', sortOrder: 70, }, ]; export type SystemConfigInputType = 'text' | 'number' | 'boolean' | 'pattern'; export type SystemConfigValue = string | number | boolean; export type SystemConfigValueType = 'string' | 'number' | 'boolean'; export interface SystemConfigDefinition { key: string; group: SystemConfigGroupKey; label: string; description: string; impact: string; type: SystemConfigInputType; defaultValue: SystemConfigValue; unit?: string; min?: number; max?: number; step?: number; maxLength?: number; editable: boolean; /** 是否允许所有登录用户通过 /system/configs/effective 读取(上限类需要前端读取) */ public: boolean; sortOrder: number; } export const SYSTEM_CONFIG_DEFINITIONS: readonly SystemConfigDefinition[] = [ // 基础信息 { key: 'platform.name', group: 'basic', label: '平台名称', description: '顶栏、登录页与导出清单中展示的平台名称', impact: '影响全站展示文案,不影响数据', type: 'text', defaultValue: '灵犀', maxLength: 32, editable: true, public: true, sortOrder: 10, }, { key: 'platform.page.default_limit', group: 'basic', label: '列表默认条数', description: '列表未显式指定 limit 时的默认条数', impact: '影响前端列表请求与后端默认分页', type: 'number', defaultValue: 20, unit: '条', min: 10, max: 100, editable: true, public: true, sortOrder: 20, }, // 命名规则 { key: 'naming.script.pattern', group: 'naming', label: '脚本命名规则', description: '脚本文件名与交付清单的命名模板,支持占位符', impact: '影响脚本命名、导出目录与交付清单;已在队列中的任务使用提交时规则', type: 'pattern', defaultValue: '{行业}-{客户}-{脚本方向}-{日期}-{序号}', maxLength: 100, editable: true, public: false, sortOrder: 30, }, { key: 'naming.image.pattern', group: 'naming', label: '图片成片命名规则', description: '图片成片沿用脚本规则并追加规格后缀', impact: '影响图片成片文件名与 ZIP 内目录', type: 'pattern', defaultValue: '{行业}-{客户}-{脚本方向}-{日期}-{序号}-{规格}', maxLength: 100, editable: true, public: false, sortOrder: 40, }, { key: 'naming.video.pattern', group: 'naming', label: '视频成片命名规则', description: '视频成片支持通用字符命名,≤ 100 字符', impact: '影响视频成片文件名与下载包', type: 'pattern', defaultValue: '{行业}_{客户}_{日期}_{序号}', maxLength: 100, editable: true, public: false, sortOrder: 50, }, { key: 'naming.max_length', group: 'naming', label: '命名最大长度', description: '生成文件名(含扩展名)的最大字符数', impact: '超过长度会被截断并追加序号,影响可读性', type: 'number', defaultValue: 100, unit: '字符', min: 32, max: 120, editable: true, public: false, sortOrder: 60, }, { key: 'naming.duplicate_start', group: 'naming', label: '同名序号起始', description: '同名冲突时从该序号开始追加,如 -02、-03', impact: '影响同名文件的连续编号', type: 'number', defaultValue: 2, min: 2, max: 9, editable: true, public: false, sortOrder: 70, }, // 导出与归档 { key: 'platform.export.signed_url_ttl_seconds', group: 'export', label: '签名链接有效期', description: '导出产物下载链接的短时效有效期', impact: '过短影响下载体验,过长增加泄露风险', type: 'number', defaultValue: 900, unit: '秒', min: 60, max: 3600, editable: true, public: false, sortOrder: 80, }, { key: 'platform.export.archive_retention_days', group: 'export', label: '打包产物保留天数', description: '临时导出包的保留时间,超期自动清理', impact: '影响历史导出包的可用时间', type: 'number', defaultValue: 3, unit: '天', min: 1, max: 30, editable: true, public: false, sortOrder: 90, }, { key: 'platform.upload.max_upload_mb', group: 'export', label: '单文件上传上限', description: '素材与资产单文件上传的最大体积', impact: '影响前端上传校验与后端接收上限', type: 'number', defaultValue: 500, unit: 'MB', min: 10, max: 2048, editable: true, public: true, sortOrder: 100, }, { key: 'platform.export.max_files', group: 'export', label: '单次导出文件上限', description: '单个导出批次最多包含的文件数,超出建议分卷', impact: '影响导出任务拆分与打包策略', type: 'number', defaultValue: 500, unit: '个', min: 50, max: 2000, editable: true, public: false, sortOrder: 110, }, // 通知开关 { key: 'notify.task.completed', group: 'notify', label: '任务完成通知', description: '生成任务全部完成后发送站内通知', impact: '关闭后用户需主动进入任务中心查看', type: 'boolean', defaultValue: true, editable: true, public: false, sortOrder: 120, }, { key: 'notify.task.failed', group: 'notify', label: '任务失败通知', description: '任务失败或部分失败时发送站内通知', impact: '关闭后失败需在任务中心查看,可能延迟处理', type: 'boolean', defaultValue: true, editable: true, public: false, sortOrder: 130, }, { key: 'notify.review.pending', group: 'notify', label: '待审核通知', description: '脚本 / 素材进入待审核时通知审核员', impact: '关闭后审核队列可能积压', type: 'boolean', defaultValue: true, editable: true, public: false, sortOrder: 140, }, { key: 'notify.export.ready', group: 'notify', label: '导出完成通知', description: '导出包生成后通知发起人', impact: '关闭后需在导出记录中查看下载链接', type: 'boolean', defaultValue: false, editable: true, public: false, sortOrder: 150, }, // 阈值与上限 { key: 'limit.role.max', group: 'limits', label: '启用角色建议上限', description: '超过后仅提示不阻断,避免角色膨胀', impact: '影响角色管理页提示与新建角色引导', type: 'number', defaultValue: 20, unit: '个', min: 1, max: 100, editable: true, public: true, sortOrder: 160, }, { key: 'limit.super_admin.max', group: 'limits', label: '超级管理员建议上限', description: '超过后仅提示不阻断,不改变超管权限', impact: '影响用户管理页提示与授予超管二次确认', type: 'number', defaultValue: 2, unit: '人', min: 1, max: 10, editable: true, public: true, sortOrder: 170, }, { key: 'limit.customer.base_field', group: 'limits', label: '客户基础字段上限', description: '每客户基础字段(仅检索,不进入生成)的最大数量', impact: '前后端共用;修改后新申请按新上限校验,历史数据不受影响', type: 'number', defaultValue: 10, unit: '个', min: 1, max: 50, editable: true, public: true, sortOrder: 180, }, { key: 'limit.customer.gen_field', group: 'limits', label: '进入生成字段上限', description: '每客户进入生成字段(需审批)的最大数量', impact: '前后端共用;修改后新申请按新上限校验,历史数据不受影响', type: 'number', defaultValue: 5, unit: '个', min: 1, max: 20, editable: true, public: true, sortOrder: 190, }, { key: 'limit.page.max', group: 'limits', label: '列表每页上限', description: '游标分页单次返回的最大条数', impact: '影响前端批量拉取与后端查询压力', type: 'number', defaultValue: 100, unit: '条', min: 20, max: 500, editable: true, public: true, sortOrder: 200, }, // 业务阈值 { key: 'platform.ai.cost_soft_limit_ratio', group: 'threshold', label: 'AI 成本软上限比例', description: '达到预算该比例时触发告警', impact: '仅告警不阻断;影响 AI 任务成本提示', type: 'number', defaultValue: 0.8, min: 0, max: 1, step: 0.05, editable: true, public: false, sortOrder: 210, }, { key: 'platform.ai.cost_hard_limit_ratio', group: 'threshold', label: 'AI 成本硬上限比例', description: '达到预算该比例时拒绝新的 AI 任务', impact: '达到后拒绝新任务,避免成本超预算', type: 'number', defaultValue: 1, min: 0, max: 1, step: 0.05, editable: true, public: false, sortOrder: 220, }, { key: 'material.boom.consumption_multiple', group: 'threshold', label: '爆量消耗倍数', description: '素材消耗达到客户日均消耗的倍数时进入爆量候选', impact: 'M4 爆量判定唯一来源;修改后下次判定生效', type: 'number', defaultValue: 3, unit: '倍', min: 1, max: 20, step: 0.1, editable: true, public: false, sortOrder: 230, }, { key: 'material.boom.roi_min', group: 'threshold', label: '爆量 ROI 下限', description: '爆量候选必须同时满足的最低 ROI', impact: 'M4 爆量判定唯一来源;修改后下次判定生效', type: 'number', defaultValue: 1.2, min: 0, max: 10, step: 0.1, editable: true, public: false, sortOrder: 240, }, { key: 'review.timeout_hours', group: 'threshold', label: '审核超时时长', description: '任务进入待审核后超过该时长提醒并计入超时', impact: '影响审核提醒与质量看板;不阻断任务', type: 'number', defaultValue: 24, unit: '小时', min: 1, max: 168, editable: true, public: false, sortOrder: 250, }, { key: 'quality.low_ratio_warn', group: 'threshold', label: '低质量占比告警线', description: '低质量产出占比超过该值时触发质量看板告警', impact: '仅告警不阻断;影响质量看板与通知', type: 'number', defaultValue: 10, unit: '%', min: 1, max: 100, editable: true, public: false, sortOrder: 260, }, // 审计与日志(A5) { key: 'audit.retention_months', group: 'audit', label: '审计日志保留期', description: '审计日志在线保留的最短月数;归档任务按该值执行(QT-10 最终值待确认)', impact: '影响审计可检索范围与归档策略;PRD Q-08 要求 ≥ 12 个月,归档只做只读搬迁、不删除审计数据', type: 'number', defaultValue: 12, unit: '个月', min: 12, max: 60, editable: true, public: false, sortOrder: 270, }, { key: 'audit.export_max_rows', group: 'audit', label: '审计导出单次行数上限', description: '单次异步导出的最大行数,超出部分截断并在导出任务上标记 truncated', impact: '影响导出文件体积与导出耗时;截断后需缩小筛选条件重新导出', type: 'number', defaultValue: 50000, unit: '条', min: 1000, max: 200000, editable: true, public: true, sortOrder: 280, }, { key: 'audit.logs_platform_url', group: 'audit', label: '运行日志平台地址', description: '后台「运行日志」入口跳转的 Grafana / Loki 地址;只存链接,不含任何密钥', impact: '仅影响前端跳转链接;运行日志不入业务库(工程规范 08)', type: 'text', defaultValue: '', maxLength: 500, editable: true, public: true, sortOrder: 290, }, ]; const DEFINITION_MAP: ReadonlyMap = new Map( SYSTEM_CONFIG_DEFINITIONS.map((definition) => [definition.key, definition]), ); export function systemConfigDefinition(key: string): SystemConfigDefinition | undefined { return DEFINITION_MAP.get(key); } export function systemConfigDefinitionsOfGroup( group: SystemConfigGroupKey, ): readonly SystemConfigDefinition[] { return SYSTEM_CONFIG_DEFINITIONS.filter((definition) => definition.group === group).sort( (a, b) => a.sortOrder - b.sortOrder, ); } export function systemConfigValueType(definition: SystemConfigDefinition): SystemConfigValueType { if (definition.type === 'number') return 'number'; if (definition.type === 'boolean') return 'boolean'; return 'string'; } export function isPublicSystemConfigKey(key: string): boolean { return DEFINITION_MAP.get(key)?.public === true; } export type SystemConfigValueValidation = { ok: true; value: SystemConfigValue } | { ok: false; message: string }; /** 校验配置值;服务端与前端保存前共用,避免两套规则 */ // eslint-disable-next-line complexity -- 类型分支 + 边界 / 步长 / 模板校验集中一处,拆散会失去单一校验口径 export function validateSystemConfigValue( definition: SystemConfigDefinition, value: unknown, ): SystemConfigValueValidation { if (definition.type === 'boolean') { if (typeof value !== 'boolean') { return { ok: false, message: '配置值必须是布尔值' }; } return { ok: true, value }; } if (definition.type === 'number') { if (typeof value !== 'number' || !Number.isFinite(value)) { return { ok: false, message: '配置值必须是数字' }; } if (definition.min !== undefined && value < definition.min) { return { ok: false, message: `配置值不能小于 ${String(definition.min)}` }; } if (definition.max !== undefined && value > definition.max) { return { ok: false, message: `配置值不能大于 ${String(definition.max)}` }; } if (definition.step !== undefined && definition.min !== undefined) { const steps = (value - definition.min) / definition.step; if (Math.abs(steps - Math.round(steps)) > 1e-9) { return { ok: false, message: `配置值必须是 ${String(definition.step)} 的整数倍` }; } } return { ok: true, value }; } if (typeof value !== 'string') { return { ok: false, message: '配置值必须是字符串' }; } const text = value.trim(); if (text.length === 0) { // 允许默认值为空串的文本项(如 audit.logs_platform_url:未配置日志平台时为空) if (definition.defaultValue === '') { return { ok: true, value: '' }; } return { ok: false, message: '配置值不能为空' }; } if (definition.type === 'pattern') { const check = validateNamingTemplate(text, { maxLength: definition.maxLength ?? 100 }); if (!check.valid) { return { ok: false, message: check.errors.join(';') }; } return { ok: true, value: text }; } if (definition.maxLength !== undefined && text.length > definition.maxLength) { return { ok: false, message: `配置值不能超过 ${String(definition.maxLength)} 个字符` }; } return { ok: true, value: text }; }