feat(shared): A4 数据字典与系统配置契约
背景:A4 需要字典三层合并、配置版本与命名规则的共享契约。\n改动:新增 dict/config 契约、system-configs 定义与校验、命名模板解析;新增 dict_types/dict_items/system_config_versions 迁移与 13 类/96 条字典、26 项配置种子。\n影响面:shared 新增导出;prisma 新增表与迁移;客户基础字段 DTO 硬上界调整为 50(实际生效上限仍由配置动态校验)。\n回滚:回滚本提交并保留新增表(不删数据)。\n验证:shared typecheck/test 147;seed 幂等;prisma migrate dev 已应用。
This commit is contained in:
1 parent
32b44bdeec
commit
cc6e84cdbc
15 files changed
+2899
-75
No files matched your search
@@ -0,0 +1,534 @@
|
||||
/**
|
||||
* 系统配置定义(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',
|
||||
] 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,
|
||||
},
|
||||
];
|
||||
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
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) {
|
||||
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 };
|
||||
}
|
||||
Reference in new issue
Block a user