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