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

+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,
};
}