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 };
}
+3
View File
@@ -1,3 +1,4 @@
export * from './config/system-configs.js';
export * from './constants.js';
export * from './errors/api-error.js';
export * from './errors/codes.js';
@@ -6,6 +7,8 @@ export * from './permissions.js';
export * from './schemas/api.js';
export * from './schemas/client.js';
export * from './schemas/common.js';
export * from './schemas/config.js';
export * from './schemas/dict.js';
export * from './schemas/identity.js';
export * from './schemas/roles.js';
export * from './time.js';
+65 -1
View File
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest';
import { dedupeName, sanitizeFilename } from './naming.js';
import {
dedupeName,
formatNamingDate,
formatNamingSequence,
resolveNamingTemplate,
sanitizeFilename,
validateNamingTemplate,
} from './naming.js';
describe('sanitizeFilename', () => {
it('替换路径分隔符与非法字符,保留中文', () => {
@@ -45,3 +52,60 @@ describe('dedupeName(R-01 同名自动加序号)', () => {
expect(() => dedupeName('a', existing, 3)).toThrow(RangeError);
});
});
describe('命名模板(A4-BE-03 / R-01)', () => {
const context = {
industry: '电商',
customer: '花间集',
direction: '口播',
date: '20261104',
sequence: 1,
spec: '1080x1920',
};
it('默认脚本模板解析为「行业-客户-方向-日期-序号」', () => {
expect(resolveNamingTemplate('{行业}-{客户}-{脚本方向}-{日期}-{序号}', context)).toBe(
'电商-花间集-口播-20261104-1',
);
});
it('序号可先补零后传入', () => {
expect(
resolveNamingTemplate('{行业}-{客户}-{日期}-{序号}', {
...context,
sequence: formatNamingSequence(context.sequence),
}),
).toBe('电商-花间集-20261104-01');
});
it('兼容英文 key 与规格后缀', () => {
expect(resolveNamingTemplate('{industry}-{customer}-{date}-{seq}-{spec}', context)).toBe(
'电商-花间集-20261104-1-1080x1920',
);
});
it('未知占位符 / 非法字符 / 空模板被拒绝', () => {
expect(validateNamingTemplate('{行业}-{未知}').valid).toBe(false);
expect(validateNamingTemplate('{行业}*{客户}').valid).toBe(false);
expect(validateNamingTemplate(' ').valid).toBe(false);
});
it('缺失占位符值时抛错', () => {
expect(() => resolveNamingTemplate('{行业}-{客户}', { ...context, customer: '' })).toThrow(
'命名占位符缺少值',
);
});
it('超过最大长度抛错', () => {
expect(() =>
resolveNamingTemplate('{客户}', { ...context, customer: '客'.repeat(101) }),
).toThrow('不能超过 100 个字符');
});
it('日期与序号格式化', () => {
expect(formatNamingDate(new Date(2026, 10, 4))).toBe('20261104');
expect(formatNamingSequence(7)).toBe('07');
expect(formatNamingSequence(123)).toBe('123');
expect(() => formatNamingSequence(-1)).toThrow();
});
});
+193
View File
@@ -105,3 +105,196 @@ export function dedupeName(name: string, existing: ReadonlySet<string>, maxAttem
}
throw new RangeError(`同名文件过多,无法为「${name}」生成序号`);
}
// ---------------------------------------------------------------------------
// 命名模板(A4-BE-03 / PRD R-01)
// 占位符:{行业} {客户} {脚本方向} {日期} {序号} {规格},同时兼容英文 key。
// ---------------------------------------------------------------------------
export const NAMING_MAX_LENGTH = 100;
export const NAMING_TOKEN_DEFINITIONS = [
{
key: 'industry',
token: '{行业}',
aliases: ['{industry}'],
label: '行业',
example: '电商',
description: '客户所属末级行业名称',
},
{
key: 'customer',
token: '{客户}',
aliases: ['{customer}'],
label: '客户',
example: '花间集',
description: '客户名称(优先使用简称)',
},
{
key: 'direction',
token: '{脚本方向}',
aliases: ['{direction}'],
label: '脚本方向',
example: '口播',
description: '来自字典「脚本方向」',
},
{
key: 'date',
token: '{日期}',
aliases: ['{date}'],
label: '日期',
example: '20261104',
description: 'YYYYMMDD,取生成当天',
},
{
key: 'sequence',
token: '{序号}',
aliases: ['{seq}', '{sequence}'],
label: '序号',
example: '01',
description: '同名自动递增,2 位起',
},
{
key: 'spec',
token: '{规格}',
aliases: ['{spec}'],
label: '规格',
example: '1080x1920',
description: '图片规格后缀,仅图片线可用',
},
] as const;
export type NamingTokenKey = (typeof NAMING_TOKEN_DEFINITIONS)[number]['key'];
export type NamingContext = Record<NamingTokenKey, string | number>;
export interface NamingTemplateCheck {
valid: boolean;
errors: string[];
/** 模板中出现的规范 token key(去重、按出现顺序) */
tokens: NamingTokenKey[];
unknownTokens: string[];
/** 模板字符长度(未解析,用于设置页展示) */
length: number;
}
const NAMING_TOKEN_PATTERN = /\{([^{}]+)\}/g;
const NAMING_ALLOWED_CHARS = /^[\u4e00-\u9fa5A-Za-z0-9_-]+$/;
const NAMING_TOKEN_MAP: ReadonlyMap<string, NamingTokenKey> = new Map(
NAMING_TOKEN_DEFINITIONS.flatMap((definition) =>
[definition.token, ...definition.aliases].map((token) => {
const key = token.slice(1, -1);
return [key, definition.key] as const;
}),
),
);
function tokensOf(template: string): { keys: NamingTokenKey[]; unknown: string[] } {
const keys: NamingTokenKey[] = [];
const unknown: string[] = [];
for (const match of template.matchAll(NAMING_TOKEN_PATTERN)) {
const raw = match[1];
if (raw === undefined) continue;
const key = NAMING_TOKEN_MAP.get(raw);
if (key === undefined) {
if (!unknown.includes(raw)) unknown.push(raw);
} else if (!keys.includes(key)) {
keys.push(key);
}
}
return { keys, unknown };
}
/** 校验模板:占位符是否可识别、模板字符是否在白名单内、长度是否超限 */
export function validateNamingTemplate(
template: string,
options: { maxLength?: number } = {},
): NamingTemplateCheck {
const maxLength = options.maxLength ?? NAMING_MAX_LENGTH;
const value = template.trim();
const errors: string[] = [];
if (value.length === 0) {
errors.push('命名模板不能为空');
}
if (value.length > maxLength) {
errors.push(`命名模板不能超过 ${String(maxLength)} 个字符`);
}
const { keys, unknown } = tokensOf(value);
if (unknown.length > 0) {
errors.push(`存在未识别的占位符:${unknown.map((item) => `{${item}}`).join('、')}`);
}
const remaining = value.replace(NAMING_TOKEN_PATTERN, '');
if (remaining.length > 0 && !NAMING_ALLOWED_CHARS.test(remaining)) {
errors.push('命名模板只能包含中文、字母、数字、下划线、短横线与占位符');
}
return {
valid: errors.length === 0,
errors,
tokens: keys,
unknownTokens: unknown,
length: value.length,
};
}
function normalizeNamingValue(value: string | number): string {
return String(value).trim().replace(/\s+/g, '-').replace(/-+/g, '-');
}
/**
* 解析命名模板。缺失占位符 / 非法字符 / 超长时抛错,调用方映射 42201。
* 同日同名序号由 dedupeName 处理,不在本函数内。
*/
export function resolveNamingTemplate(
template: string,
context: NamingContext,
options: { maxLength?: number } = {},
): string {
const maxLength = options.maxLength ?? NAMING_MAX_LENGTH;
const check = validateNamingTemplate(template, { maxLength });
if (!check.valid) {
throw new Error(check.errors.join(';'));
}
const missing: string[] = [];
const resolved = template.trim().replace(NAMING_TOKEN_PATTERN, (_match, raw: string) => {
const key = NAMING_TOKEN_MAP.get(raw);
if (key === undefined) return '';
const value = context[key];
if (String(value).trim() === '') {
const definition = NAMING_TOKEN_DEFINITIONS.find((item) => item.key === key);
missing.push(definition?.token ?? `{${raw}}`);
return '';
}
return normalizeNamingValue(value);
});
if (missing.length > 0) {
throw new Error(`命名占位符缺少值:${missing.join('、')}`);
}
if (!NAMING_ALLOWED_CHARS.test(resolved)) {
throw new Error('解析后的名称只能包含中文、字母、数字、下划线或短横线');
}
if (resolved.length > maxLength) {
throw new Error(`解析后的名称不能超过 ${String(maxLength)} 个字符`);
}
return resolved;
}
/** YYYYMMDD(按本地时区取日期,避免 UTC 跨日) */
export function formatNamingDate(value: Date | string): string {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
throw new Error('无效的日期');
}
const year = String(date.getFullYear());
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}${month}${day}`;
}
/** 序号补零;默认 2 位,超出宽度不截断 */
export function formatNamingSequence(sequence: number, width = 2): string {
if (!Number.isInteger(sequence) || sequence < 0) {
throw new Error('序号必须是非负整数');
}
return String(sequence).padStart(width, '0');
}
+2 -2
View File
@@ -84,8 +84,8 @@ describe('客户契约', () => {
).toBe(false);
});
it('新建:基础字段 ≤ 10,接受完整偏好', () => {
const baseFields = Array.from({ length: 11 }, (_, index) => ({
it('新建:基础字段受 DTO 硬上限 50 保护,实际上限由 system_configs 动态校验', () => {
const baseFields = Array.from({ length: 51 }, (_, index) => ({
key: `k${index}`,
value: `v${index}`,
}));
+9 -3
View File
@@ -37,10 +37,16 @@ export const CUSTOMER_FIELD_STATUSES = ['pending', 'active', 'rejected', 'disabl
export const customerFieldStatusSchema = z.enum(CUSTOMER_FIELD_STATUSES);
export type CustomerFieldStatus = z.infer<typeof customerFieldStatusSchema>;
/** 上限:只存系统配置前的共享常量(A4 迁入 system_configs,R-26) */
/** 默认上限:A4 起权威来源为 system_configs;常量仅作前端兜底与默认值 */
export const CUSTOMER_BASE_FIELD_LIMIT = 10;
export const CUSTOMER_GEN_FIELD_LIMIT = 5;
/**
* 共享 DTO 的硬上限(防御性上界,与 system_configs 定义的最大值一致)。
* 实际生效上限由后端 service 读取 system_configs 动态校验(R-26)。
*/
export const CUSTOMER_BASE_FIELD_HARD_LIMIT = 50;
// ---------------------------------------------------------------------------
// 行业
// ---------------------------------------------------------------------------
@@ -258,7 +264,7 @@ export const createCustomerInputSchema = z
contactName: customerContactNameSchema,
contactPhone: customerContactPhoneSchema,
preferences: customerPreferencesSchema.optional(),
baseFields: z.array(baseFieldSchema).max(CUSTOMER_BASE_FIELD_LIMIT).optional(),
baseFields: z.array(baseFieldSchema).max(CUSTOMER_BASE_FIELD_HARD_LIMIT).optional(),
remark: customerRemarkSchema,
})
.strict();
@@ -274,7 +280,7 @@ export const updateCustomerInputSchema = z
contactName: customerContactNameSchema,
contactPhone: customerContactPhoneSchema,
preferences: customerPreferencesSchema.optional(),
baseFields: z.array(baseFieldSchema).max(CUSTOMER_BASE_FIELD_LIMIT).optional(),
baseFields: z.array(baseFieldSchema).max(CUSTOMER_BASE_FIELD_HARD_LIMIT).optional(),
remark: customerRemarkSchema,
})
.strict();
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import {
SYSTEM_CONFIG_DEFINITIONS,
SYSTEM_CONFIG_GROUPS,
systemConfigDefinition,
validateSystemConfigValue,
} from '../config/system-configs.js';
import {
effectiveSystemConfigsResponseSchema,
systemConfigSchema,
updateSystemConfigsInputSchema,
} from './config.js';
describe('系统配置契约(A4-BE-02 / R-26)', () => {
it('配置键唯一、分组合法、默认值可通过校验', () => {
const keys = SYSTEM_CONFIG_DEFINITIONS.map((item) => item.key);
expect(new Set(keys).size).toBe(keys.length);
expect(keys.length).toBeGreaterThanOrEqual(20);
const groupKeys = new Set(SYSTEM_CONFIG_GROUPS.map((group) => group.key));
for (const definition of SYSTEM_CONFIG_DEFINITIONS) {
expect(groupKeys.has(definition.group)).toBe(true);
expect(validateSystemConfigValue(definition, definition.defaultValue).ok).toBe(true);
}
});
it('阈值 / 上限类配置向上限读取接口公开', () => {
expect(systemConfigDefinition('limit.customer.base_field')?.public).toBe(true);
expect(systemConfigDefinition('limit.customer.gen_field')?.public).toBe(true);
expect(systemConfigDefinition('platform.page.default_limit')?.public).toBe(true);
expect(systemConfigDefinition('naming.script.pattern')?.public).toBe(false);
});
it('数字边界与布尔类型校验', () => {
const limit = systemConfigDefinition('limit.customer.base_field');
expect(limit).toBeDefined();
if (limit === undefined) return;
expect(validateSystemConfigValue(limit, 10).ok).toBe(true);
expect(validateSystemConfigValue(limit, 1).ok).toBe(true);
expect(validateSystemConfigValue(limit, 0).ok).toBe(false);
expect(validateSystemConfigValue(limit, 51).ok).toBe(false);
expect(validateSystemConfigValue(limit, '10').ok).toBe(false);
const notify = systemConfigDefinition('notify.task.completed');
expect(notify).toBeDefined();
if (notify === undefined) return;
expect(validateSystemConfigValue(notify, true).ok).toBe(true);
expect(validateSystemConfigValue(notify, 'true').ok).toBe(false);
});
it('命名模板配置校验占位符与非法字符', () => {
const naming = systemConfigDefinition('naming.script.pattern');
expect(naming).toBeDefined();
if (naming === undefined) return;
expect(validateSystemConfigValue(naming, '{行业}-{客户}-{序号}').ok).toBe(true);
expect(validateSystemConfigValue(naming, '{行业}*{客户}').ok).toBe(false);
expect(validateSystemConfigValue(naming, '{未知}').ok).toBe(false);
});
it('系统配置响应与保存入参可解析', () => {
const config = {
key: 'limit.customer.base_field',
group: 'limits' as const,
label: '客户基础字段上限',
description: 'desc',
impact: 'impact',
type: 'number' as const,
value: 10,
valueType: 'number' as const,
unit: '个',
version: 1,
updatedAt: '2026-11-04T00:00:00.000Z',
updatedBy: '江岚',
editable: true,
isPublic: true,
min: 1,
max: 50,
step: null,
maxLength: null,
};
expect(systemConfigSchema.parse(config).key).toBe('limit.customer.base_field');
expect(
updateSystemConfigsInputSchema.parse({
changes: [
{ key: 'limit.customer.base_field', value: 12, expectedVersion: 1, reason: 'A3 对齐' },
],
}).changes,
).toHaveLength(1);
expect(
effectiveSystemConfigsResponseSchema.parse({
items: [{ key: 'limit.customer.base_field', value: 10 }],
}).items[0]?.value,
).toBe(10);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* 系统配置共享契约(A4-BE-02)
* 依据:PRD §5.4.4 / R-26;技术架构 §5.7 / §6.3.7;开发计划 A4-BE-02。
*
* 系统配置是阈值 / 上限唯一来源;本契约的键、分组、类型与默认值来自
* `config/system-configs.ts`,业务代码不得再写阈值常量。
*/
import { z } from 'zod';
import {
SYSTEM_CONFIG_DEFINITIONS,
SYSTEM_CONFIG_GROUPS,
type SystemConfigDefinition,
systemConfigDefinition,
type SystemConfigGroupKey,
type SystemConfigInputType,
} from '../config/system-configs.js';
import { isoDateTimeSchema } from './common.js';
export const systemConfigGroupKeySchema = z.enum(
SYSTEM_CONFIG_GROUPS.map((group) => group.key) as [
SystemConfigGroupKey,
...SystemConfigGroupKey[],
],
);
export const systemConfigInputTypeSchema = z.enum(['text', 'number', 'boolean', 'pattern']);
export const systemConfigValueSchema = z.union([z.string(), z.number(), z.boolean()]);
/** 单条配置(含展示元数据与版本) */
export const systemConfigSchema = z.object({
key: z.string().min(1),
group: systemConfigGroupKeySchema,
label: z.string().min(1),
description: z.string(),
impact: z.string(),
type: systemConfigInputTypeSchema,
value: systemConfigValueSchema,
valueType: z.enum(['string', 'number', 'boolean']),
unit: z.string().nullable(),
version: z.number().int().positive(),
updatedAt: isoDateTimeSchema,
updatedBy: z.string().nullable(),
editable: z.boolean(),
isPublic: z.boolean(),
min: z.number().nullable(),
max: z.number().nullable(),
step: z.number().nullable(),
maxLength: z.number().int().nullable(),
});
export type SystemConfig = z.infer<typeof systemConfigSchema>;
export const systemConfigListResponseSchema = z.object({
items: z.array(systemConfigSchema),
});
export type SystemConfigListResponse = z.infer<typeof systemConfigListResponseSchema>;
export const systemConfigChangeInputSchema = z
.object({
key: z.string().min(1),
value: z.unknown(),
/** 乐观锁:前端读取时的版本;不一致返回 40901 */
expectedVersion: z.number().int().positive().optional(),
reason: z.string().trim().max(255).optional(),
})
.strict();
export type SystemConfigChangeInput = z.infer<typeof systemConfigChangeInputSchema>;
export const updateSystemConfigsInputSchema = z
.object({
changes: z.array(systemConfigChangeInputSchema).min(1).max(50),
})
.strict();
export type UpdateSystemConfigsInput = z.infer<typeof updateSystemConfigsInputSchema>;
export const systemConfigChangeSchema = z.object({
key: z.string().min(1),
label: z.string().min(1),
version: z.number().int().positive(),
oldValue: z.unknown().nullable(),
newValue: z.unknown(),
reason: z.string().nullable(),
operator: z.string().nullable(),
changedAt: isoDateTimeSchema,
result: z.string(),
});
export type SystemConfigChange = z.infer<typeof systemConfigChangeSchema>;
export const systemConfigHistoryQuerySchema = z.object({
config_key: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export type SystemConfigHistoryQuery = z.infer<typeof systemConfigHistoryQuerySchema>;
export const systemConfigHistoryResponseSchema = z.object({
items: z.array(systemConfigChangeSchema),
next_cursor: z.string().nullable(),
});
export type SystemConfigHistoryResponse = z.infer<typeof systemConfigHistoryResponseSchema>;
export const effectiveSystemConfigSchema = z.object({
key: z.string().min(1),
value: systemConfigValueSchema,
});
export type EffectiveSystemConfig = z.infer<typeof effectiveSystemConfigSchema>;
export const effectiveSystemConfigsResponseSchema = z.object({
items: z.array(effectiveSystemConfigSchema),
});
export type EffectiveSystemConfigsResponse = z.infer<typeof effectiveSystemConfigsResponseSchema>;
/** 供后端 / 前端按 key 查找定义 */
export function systemConfigMetaOf(key: string): SystemConfigDefinition | undefined {
return systemConfigDefinition(key);
}
export type { SystemConfigDefinition, SystemConfigGroupKey, SystemConfigInputType };
/** 配置定义清单(前端设置页分组、种子脚本共用) */
export const SYSTEM_CONFIG_META = SYSTEM_CONFIG_DEFINITIONS;
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import {
createDictItemInputSchema,
createDictTypeInputSchema,
dictItemSnapshotSchema,
toDictItemSnapshot,
updateDictItemInputSchema,
} from './dict.js';
describe('数据字典契约(A4-BE-01 / A4-BE-04)', () => {
it('类型编码只允许小写 snake_case,名称必填', () => {
expect(
createDictTypeInputSchema.parse({
code: 'script_direction',
name: '脚本方向',
group: 'content',
}),
).toMatchObject({ code: 'script_direction', kind: 'list', industryScoped: false });
expect(() =>
createDictTypeInputSchema.parse({
code: 'ScriptDirection',
name: '脚本方向',
group: 'content',
}),
).toThrow();
expect(() =>
createDictTypeInputSchema.parse({ code: 'script_direction', name: '', group: 'content' }),
).toThrow();
});
it('条目编码允许数字开头(如 15s)与短横线', () => {
expect(
createDictItemInputSchema.parse({
code: '15s',
name: '15 秒',
}),
).toMatchObject({ code: '15s', status: 'active' });
expect(
createDictItemInputSchema.parse({
code: 'light-style',
name: '光影',
parentCode: null,
industryId: null,
}),
).toMatchObject({ code: 'light-style' });
});
it('编辑条目只接收名称 / 排序 / 启停 / 备注,不接收编码与行业', () => {
expect(
updateDictItemInputSchema.parse({ name: '口播', sortOrder: 10, status: 'disabled' }),
).toEqual({ name: '口播', sortOrder: 10, status: 'disabled' });
expect(() => updateDictItemInputSchema.parse({ code: 'new_code' })).toThrow();
expect(() => updateDictItemInputSchema.parse({ industryId: null })).toThrow();
});
it('快照保留填写时名称,之后改字典不影响历史(R-10)', () => {
const item = {
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
typeCode: 'script_direction',
code: 'oral',
name: '口播',
parentCode: null,
parentName: null,
industryId: null,
industryPath: null,
sortOrder: 10,
status: 'active' as const,
remark: '',
source: 'generic' as const,
overridden: false,
refCount: 0,
updatedAt: '2026-11-04T00:00:00.000Z',
updatedBy: '江岚',
};
const snapshot = toDictItemSnapshot(item, '2026-11-04T10:00:00.000Z');
item.name = '口播(改名后)';
expect(snapshot.name).toBe('口播');
expect(dictItemSnapshotSchema.parse(snapshot)).toEqual(snapshot);
});
});
+258
View File
@@ -0,0 +1,258 @@
/**
* 数据字典共享契约(A4)
* 依据:PRD §5.4.5、R-10;技术架构 §5.3 / §6.3.7;开发计划 A4-BE-01 / A4-BE-04。
*
* 约定:
* - 一张通用枚举表同时表达二级树(parent_code)与词表;
* - 类型编码 / 条目编码创建后不可修改;
* - 条目按「通用 → 父行业 → 本行业」三层合并,同 code 以更具体层覆盖;
* - 历史数据保存填写时快照(toDictItemSnapshot),字典改名不影响历史。
*/
import { z } from 'zod';
import { cursorPaginationQuerySchema } from './api.js';
import { isoDateTimeSchema, ulidSchema } from './common.js';
// ---------------------------------------------------------------------------
// 枚举 / 分组
// ---------------------------------------------------------------------------
export const DICT_GROUP_KEYS = ['content', 'platform', 'quality', 'audience'] as const;
export const dictGroupKeySchema = z.enum(DICT_GROUP_KEYS);
export type DictGroupKey = z.infer<typeof dictGroupKeySchema>;
export const DICT_GROUPS: readonly {
key: DictGroupKey;
name: string;
description: string;
}[] = [
{ key: 'content', name: '内容生产', description: '脚本、素材与投放的结构化选项' },
{ key: 'platform', name: '平台与规格', description: '投放平台、尺寸比例与光影风格' },
{ key: 'quality', name: '质量与合规', description: '失败原因、淘汰理由与合规词表' },
{ key: 'audience', name: '人群与投放', description: '人群标签与素材形态' },
];
export const DICT_TYPE_KINDS = ['tree', 'list'] as const;
export const dictTypeKindSchema = z.enum(DICT_TYPE_KINDS);
export type DictTypeKind = z.infer<typeof dictTypeKindSchema>;
export const DICT_STATUSES = ['active', 'disabled'] as const;
export const dictStatusSchema = z.enum(DICT_STATUSES);
export type DictStatus = z.infer<typeof dictStatusSchema>;
/** 生效条目的来源层(不是存储字段;由三层合并计算) */
export const DICT_LAYER_SOURCES = ['generic', 'parent', 'current'] as const;
export const dictLayerSourceSchema = z.enum(DICT_LAYER_SOURCES);
export type DictLayerSource = z.infer<typeof dictLayerSourceSchema>;
export const DICT_ITEM_SOURCES = DICT_LAYER_SOURCES;
export const dictItemSourceSchema = dictLayerSourceSchema;
export type DictItemSource = z.infer<typeof dictItemSourceSchema>;
// ---------------------------------------------------------------------------
// 编码 / 名称
// ---------------------------------------------------------------------------
/** 类型编码:小写 snake_case,创建后不可改 */
export const dictTypeCodeSchema = z
.string()
.trim()
.regex(/^[a-z][a-z0-9_]{1,48}$/, '类型编码只能使用小写字母、数字与下划线,且以字母开头');
/** 条目编码:字母 / 数字 / 下划线 / 短横线,创建后不可改 */
export const dictItemCodeSchema = z
.string()
.trim()
.regex(
/^[A-Za-z0-9][A-Za-z0-9_-]{1,48}$/,
'条目编码只能使用字母、数字、下划线或短横线,长度 2–49',
);
export const dictNameSchema = z
.string()
.trim()
.min(1, '名称不能为空')
.max(64, '名称最多 64 个字符');
export const dictDescriptionSchema = z.string().trim().max(255, '说明最多 255 个字符');
export const dictItemRemarkSchema = z.string().trim().max(255, '备注最多 255 个字符');
export const dictSortOrderSchema = z.coerce.number().int().min(0).max(9999);
// ---------------------------------------------------------------------------
// 响应资源
// ---------------------------------------------------------------------------
export const dictTypeSummarySchema = z.object({
code: z.string().min(1),
name: z.string().min(1),
group: dictGroupKeySchema,
kind: dictTypeKindSchema,
description: z.string(),
industryScoped: z.boolean(),
builtin: z.boolean(),
status: dictStatusSchema,
sortOrder: z.number().int(),
itemCount: z.number().int().nonnegative(),
activeItemCount: z.number().int().nonnegative(),
industryCount: z.number().int().nonnegative(),
updatedAt: isoDateTimeSchema,
updatedBy: z.string().nullable(),
});
export type DictTypeSummary = z.infer<typeof dictTypeSummarySchema>;
export const dictTypeDetailSchema = dictTypeSummarySchema.extend({
/** 类型下所有条目(不合并),用于编辑 / 诊断;列表与生效项走 items 接口 */
items: z.lazy(() => z.array(dictItemSchema)).optional(),
});
export type DictTypeDetail = z.infer<typeof dictTypeDetailSchema>;
export const dictItemSchema = z.object({
id: ulidSchema,
typeCode: z.string().min(1),
code: z.string().min(1),
name: z.string().min(1),
parentCode: z.string().nullable(),
parentName: z.string().nullable(),
industryId: ulidSchema.nullable(),
industryPath: z.string().nullable(),
sortOrder: z.number().int(),
status: dictStatusSchema,
remark: z.string(),
source: dictItemSourceSchema,
overridden: z.boolean(),
refCount: z.number().int().nonnegative(),
updatedAt: isoDateTimeSchema,
updatedBy: z.string().nullable(),
});
export type DictItem = z.infer<typeof dictItemSchema>;
export const dictLayerSchema = z.object({
source: dictLayerSourceSchema,
industryId: ulidSchema.nullable(),
label: z.string().min(1),
count: z.number().int().nonnegative(),
});
export type DictLayer = z.infer<typeof dictLayerSchema>;
export const dictItemListResponseSchema = z.object({
items: z.array(dictItemSchema),
next_cursor: z.string().nullable(),
layers: z.array(dictLayerSchema),
effectiveCount: z.number().int().nonnegative(),
overriddenCount: z.number().int().nonnegative(),
disabledCount: z.number().int().nonnegative(),
});
export type DictItemListResponse = z.infer<typeof dictItemListResponseSchema>;
// ---------------------------------------------------------------------------
// 查询 / 写接口
// ---------------------------------------------------------------------------
export const dictTypeListQuerySchema = cursorPaginationQuerySchema.extend({
keyword: z.string().trim().max(64).optional(),
group: dictGroupKeySchema.optional(),
status: z.enum(['all', 'active', 'disabled']).default('all'),
});
export type DictTypeListQuery = z.infer<typeof dictTypeListQuerySchema>;
export const dictItemListQuerySchema = z.object({
/** 不传 = 全平台(仅通用层);传一级行业 = 通用 + 该行业;传二级 = 通用 + 父行业 + 该行业 */
industry_id: ulidSchema.optional(),
});
export type DictItemListQuery = z.infer<typeof dictItemListQuerySchema>;
export const createDictTypeInputSchema = z
.object({
code: dictTypeCodeSchema,
name: dictNameSchema,
group: dictGroupKeySchema,
kind: dictTypeKindSchema.default('list'),
description: dictDescriptionSchema.default(''),
industryScoped: z.boolean().default(false),
sortOrder: dictSortOrderSchema.default(10),
})
.strict();
export type CreateDictTypeInput = z.infer<typeof createDictTypeInputSchema>;
/** 类型编码 / 形态 / 行业作用域创建后不可改;名称、说明、分组、排序可改 */
export const updateDictTypeInputSchema = z
.object({
name: dictNameSchema.optional(),
group: dictGroupKeySchema.optional(),
description: dictDescriptionSchema.optional(),
sortOrder: dictSortOrderSchema.optional(),
})
.strict();
export type UpdateDictTypeInput = z.infer<typeof updateDictTypeInputSchema>;
export const setDictTypeStatusInputSchema = z
.object({
status: dictStatusSchema,
})
.strict();
export type SetDictTypeStatusInput = z.infer<typeof setDictTypeStatusInputSchema>;
export const createDictItemInputSchema = z
.object({
code: dictItemCodeSchema,
name: dictNameSchema,
parentCode: z.string().trim().max(64).nullable().optional(),
industryId: ulidSchema.nullable().optional(),
sortOrder: dictSortOrderSchema.default(10),
status: dictStatusSchema.default('active'),
remark: dictItemRemarkSchema.default(''),
})
.strict();
export type CreateDictItemInput = z.infer<typeof createDictItemInputSchema>;
/** 编辑条目:编码、父级、所属行业创建后不可改;名称、排序、启停、备注可改 */
export const updateDictItemInputSchema = z
.object({
name: dictNameSchema.optional(),
sortOrder: dictSortOrderSchema.optional(),
status: dictStatusSchema.optional(),
remark: dictItemRemarkSchema.optional(),
})
.strict();
export type UpdateDictItemInput = z.infer<typeof updateDictItemInputSchema>;
// ---------------------------------------------------------------------------
// 快照(R-10):业务记录引用字典时保存填写时名称 / 编码 / 作用域
// ---------------------------------------------------------------------------
export const dictItemSnapshotSchema = z.object({
typeCode: z.string().min(1),
code: z.string().min(1),
name: z.string().min(1),
parentCode: z.string().nullable(),
industryId: ulidSchema.nullable(),
capturedAt: isoDateTimeSchema,
});
export type DictItemSnapshot = z.infer<typeof dictItemSnapshotSchema>;
/** 生成填写时快照;A4 只提供契约与工具,真正落库由 B0 / M1 业务记录执行 */
export function toDictItemSnapshot(
item: Pick<DictItem, 'typeCode' | 'code' | 'name' | 'parentCode' | 'industryId'>,
capturedAt: string,
): DictItemSnapshot {
return {
typeCode: item.typeCode,
code: item.code,
name: item.name,
parentCode: item.parentCode,
industryId: item.industryId,
capturedAt,
};
}