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:
Stev_Wang committed 2026-11-04 11:48:38 +08:00
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 };
}