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

+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');
}