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

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