Files
lingxi/packages/shared/src/config/system-configs.ts
T
Stev_Wang 408dc5e81b feat(shared): A5 审计契约、动作目录与配置
新增审计动作目录(10 分类 / 54 动作)、前后值 diff 纯函数、列表 / 详情 / 导出 Zod 契约;system_configs 增加 audit 分组与保留期 / 导出上限 / 日志平台地址 3 项配置。
2026-11-04 16:26:03 +08:00

592 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 系统配置定义(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<string, SystemConfigDefinition> = 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 };
}