背景: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 已应用。
259 lines
9.3 KiB
TypeScript
259 lines
9.3 KiB
TypeScript
/**
|
||
* 数据字典共享契约(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,
|
||
};
|
||
}
|