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
@@ -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');
|
||||
}
|
||||
Reference in new issue
Block a user