From cc6e84cdbcc9cd8eaed333418347c86e634614d6 Mon Sep 17 00:00:00 2001 From: Stev_Wang <304865932@qq.com> Date: Wed, 4 Nov 2026 11:48:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(shared):=20A4=20=E6=95=B0=E6=8D=AE?= =?UTF-8?q?=E5=AD=97=E5=85=B8=E4=B8=8E=E7=B3=BB=E7=BB=9F=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 背景: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 已应用。 --- packages/shared/src/config/system-configs.ts | 534 ++++++++ packages/shared/src/index.ts | 3 + packages/shared/src/naming.spec.ts | 66 +- packages/shared/src/naming.ts | 193 +++ packages/shared/src/schemas/client.spec.ts | 4 +- packages/shared/src/schemas/client.ts | 12 +- packages/shared/src/schemas/config.spec.ts | 95 ++ packages/shared/src/schemas/config.ts | 129 ++ packages/shared/src/schemas/dict.spec.ts | 81 ++ packages/shared/src/schemas/dict.ts | 258 ++++ prisma/SEED.md | 19 +- .../migration.sql | 90 ++ prisma/schema.prisma | 81 ++ prisma/seed-data/dicts.json | 1218 +++++++++++++++++ prisma/seed.ts | 191 ++- 15 files changed, 2899 insertions(+), 75 deletions(-) create mode 100644 packages/shared/src/config/system-configs.ts create mode 100644 packages/shared/src/schemas/config.spec.ts create mode 100644 packages/shared/src/schemas/config.ts create mode 100644 packages/shared/src/schemas/dict.spec.ts create mode 100644 packages/shared/src/schemas/dict.ts create mode 100644 prisma/migrations/20261104024511_a4_dict_system_configs/migration.sql create mode 100644 prisma/seed-data/dicts.json diff --git a/packages/shared/src/config/system-configs.ts b/packages/shared/src/config/system-configs.ts new file mode 100644 index 0000000..ddb7a53 --- /dev/null +++ b/packages/shared/src/config/system-configs.ts @@ -0,0 +1,534 @@ +/** + * 系统配置定义(A4-BE-02 / PRD R-26) + * + * `system_configs` 是阈值 / 上限的唯一来源;本文件是键、分组、类型、默认值与校验规则的 + * 唯一事实源,前后端共同引用,禁止在业务代码里硬编码阈值。 + */ +import { validateNamingTemplate } from '../naming.js'; + +export const SYSTEM_CONFIG_GROUP_KEYS = [ + 'basic', + 'naming', + 'export', + 'notify', + 'limits', + 'threshold', +] as const; + +export type SystemConfigGroupKey = (typeof SYSTEM_CONFIG_GROUP_KEYS)[number]; + +export const SYSTEM_CONFIG_GROUPS: readonly { + key: SystemConfigGroupKey; + name: string; + description: string; + sortOrder: number; +}[] = [ + { key: 'basic', name: '基础信息', description: '平台展示名与通用列表参数', sortOrder: 10 }, + { + key: 'naming', + name: '命名规则', + description: '脚本、图片、视频的命名模板与校验', + sortOrder: 20, + }, + { + key: 'export', + name: '导出与归档', + description: '签名链接、保留期与上传 / 导出上限', + sortOrder: 30, + }, + { key: 'notify', name: '通知开关', description: '任务、审核与导出完成的站内通知', sortOrder: 40 }, + { key: 'limits', name: '阈值与上限', description: '角色、字段与分页的硬上限', sortOrder: 50 }, + { + key: 'threshold', + name: '业务阈值', + description: '爆量判定、审核超时与质量告警', + sortOrder: 60, + }, +]; + +export type SystemConfigInputType = 'text' | 'number' | 'boolean' | 'pattern'; +export type SystemConfigValue = string | number | boolean; +export type SystemConfigValueType = 'string' | 'number' | 'boolean'; + +export interface SystemConfigDefinition { + key: string; + group: SystemConfigGroupKey; + label: string; + description: string; + impact: string; + type: SystemConfigInputType; + defaultValue: SystemConfigValue; + unit?: string; + min?: number; + max?: number; + step?: number; + maxLength?: number; + editable: boolean; + /** 是否允许所有登录用户通过 /system/configs/effective 读取(上限类需要前端读取) */ + public: boolean; + sortOrder: number; +} + +export const SYSTEM_CONFIG_DEFINITIONS: readonly SystemConfigDefinition[] = [ + // 基础信息 + { + key: 'platform.name', + group: 'basic', + label: '平台名称', + description: '顶栏、登录页与导出清单中展示的平台名称', + impact: '影响全站展示文案,不影响数据', + type: 'text', + defaultValue: '灵犀', + maxLength: 32, + editable: true, + public: true, + sortOrder: 10, + }, + { + key: 'platform.page.default_limit', + group: 'basic', + label: '列表默认条数', + description: '列表未显式指定 limit 时的默认条数', + impact: '影响前端列表请求与后端默认分页', + type: 'number', + defaultValue: 20, + unit: '条', + min: 10, + max: 100, + editable: true, + public: true, + sortOrder: 20, + }, + + // 命名规则 + { + key: 'naming.script.pattern', + group: 'naming', + label: '脚本命名规则', + description: '脚本文件名与交付清单的命名模板,支持占位符', + impact: '影响脚本命名、导出目录与交付清单;已在队列中的任务使用提交时规则', + type: 'pattern', + defaultValue: '{行业}-{客户}-{脚本方向}-{日期}-{序号}', + maxLength: 100, + editable: true, + public: false, + sortOrder: 30, + }, + { + key: 'naming.image.pattern', + group: 'naming', + label: '图片成片命名规则', + description: '图片成片沿用脚本规则并追加规格后缀', + impact: '影响图片成片文件名与 ZIP 内目录', + type: 'pattern', + defaultValue: '{行业}-{客户}-{脚本方向}-{日期}-{序号}-{规格}', + maxLength: 100, + editable: true, + public: false, + sortOrder: 40, + }, + { + key: 'naming.video.pattern', + group: 'naming', + label: '视频成片命名规则', + description: '视频成片支持通用字符命名,≤ 100 字符', + impact: '影响视频成片文件名与下载包', + type: 'pattern', + defaultValue: '{行业}_{客户}_{日期}_{序号}', + maxLength: 100, + editable: true, + public: false, + sortOrder: 50, + }, + { + key: 'naming.max_length', + group: 'naming', + label: '命名最大长度', + description: '生成文件名(含扩展名)的最大字符数', + impact: '超过长度会被截断并追加序号,影响可读性', + type: 'number', + defaultValue: 100, + unit: '字符', + min: 32, + max: 120, + editable: true, + public: false, + sortOrder: 60, + }, + { + key: 'naming.duplicate_start', + group: 'naming', + label: '同名序号起始', + description: '同名冲突时从该序号开始追加,如 -02、-03', + impact: '影响同名文件的连续编号', + type: 'number', + defaultValue: 2, + min: 2, + max: 9, + editable: true, + public: false, + sortOrder: 70, + }, + + // 导出与归档 + { + key: 'platform.export.signed_url_ttl_seconds', + group: 'export', + label: '签名链接有效期', + description: '导出产物下载链接的短时效有效期', + impact: '过短影响下载体验,过长增加泄露风险', + type: 'number', + defaultValue: 900, + unit: '秒', + min: 60, + max: 3600, + editable: true, + public: false, + sortOrder: 80, + }, + { + key: 'platform.export.archive_retention_days', + group: 'export', + label: '打包产物保留天数', + description: '临时导出包的保留时间,超期自动清理', + impact: '影响历史导出包的可用时间', + type: 'number', + defaultValue: 3, + unit: '天', + min: 1, + max: 30, + editable: true, + public: false, + sortOrder: 90, + }, + { + key: 'platform.upload.max_upload_mb', + group: 'export', + label: '单文件上传上限', + description: '素材与资产单文件上传的最大体积', + impact: '影响前端上传校验与后端接收上限', + type: 'number', + defaultValue: 500, + unit: 'MB', + min: 10, + max: 2048, + editable: true, + public: true, + sortOrder: 100, + }, + { + key: 'platform.export.max_files', + group: 'export', + label: '单次导出文件上限', + description: '单个导出批次最多包含的文件数,超出建议分卷', + impact: '影响导出任务拆分与打包策略', + type: 'number', + defaultValue: 500, + unit: '个', + min: 50, + max: 2000, + editable: true, + public: false, + sortOrder: 110, + }, + + // 通知开关 + { + key: 'notify.task.completed', + group: 'notify', + label: '任务完成通知', + description: '生成任务全部完成后发送站内通知', + impact: '关闭后用户需主动进入任务中心查看', + type: 'boolean', + defaultValue: true, + editable: true, + public: false, + sortOrder: 120, + }, + { + key: 'notify.task.failed', + group: 'notify', + label: '任务失败通知', + description: '任务失败或部分失败时发送站内通知', + impact: '关闭后失败需在任务中心查看,可能延迟处理', + type: 'boolean', + defaultValue: true, + editable: true, + public: false, + sortOrder: 130, + }, + { + key: 'notify.review.pending', + group: 'notify', + label: '待审核通知', + description: '脚本 / 素材进入待审核时通知审核员', + impact: '关闭后审核队列可能积压', + type: 'boolean', + defaultValue: true, + editable: true, + public: false, + sortOrder: 140, + }, + { + key: 'notify.export.ready', + group: 'notify', + label: '导出完成通知', + description: '导出包生成后通知发起人', + impact: '关闭后需在导出记录中查看下载链接', + type: 'boolean', + defaultValue: false, + editable: true, + public: false, + sortOrder: 150, + }, + + // 阈值与上限 + { + key: 'limit.role.max', + group: 'limits', + label: '启用角色建议上限', + description: '超过后仅提示不阻断,避免角色膨胀', + impact: '影响角色管理页提示与新建角色引导', + type: 'number', + defaultValue: 20, + unit: '个', + min: 1, + max: 100, + editable: true, + public: true, + sortOrder: 160, + }, + { + key: 'limit.super_admin.max', + group: 'limits', + label: '超级管理员建议上限', + description: '超过后仅提示不阻断,不改变超管权限', + impact: '影响用户管理页提示与授予超管二次确认', + type: 'number', + defaultValue: 2, + unit: '人', + min: 1, + max: 10, + editable: true, + public: true, + sortOrder: 170, + }, + { + key: 'limit.customer.base_field', + group: 'limits', + label: '客户基础字段上限', + description: '每客户基础字段(仅检索,不进入生成)的最大数量', + impact: '前后端共用;修改后新申请按新上限校验,历史数据不受影响', + type: 'number', + defaultValue: 10, + unit: '个', + min: 1, + max: 50, + editable: true, + public: true, + sortOrder: 180, + }, + { + key: 'limit.customer.gen_field', + group: 'limits', + label: '进入生成字段上限', + description: '每客户进入生成字段(需审批)的最大数量', + impact: '前后端共用;修改后新申请按新上限校验,历史数据不受影响', + type: 'number', + defaultValue: 5, + unit: '个', + min: 1, + max: 20, + editable: true, + public: true, + sortOrder: 190, + }, + { + key: 'limit.page.max', + group: 'limits', + label: '列表每页上限', + description: '游标分页单次返回的最大条数', + impact: '影响前端批量拉取与后端查询压力', + type: 'number', + defaultValue: 100, + unit: '条', + min: 20, + max: 500, + editable: true, + public: true, + sortOrder: 200, + }, + + // 业务阈值 + { + key: 'platform.ai.cost_soft_limit_ratio', + group: 'threshold', + label: 'AI 成本软上限比例', + description: '达到预算该比例时触发告警', + impact: '仅告警不阻断;影响 AI 任务成本提示', + type: 'number', + defaultValue: 0.8, + min: 0, + max: 1, + step: 0.05, + editable: true, + public: false, + sortOrder: 210, + }, + { + key: 'platform.ai.cost_hard_limit_ratio', + group: 'threshold', + label: 'AI 成本硬上限比例', + description: '达到预算该比例时拒绝新的 AI 任务', + impact: '达到后拒绝新任务,避免成本超预算', + type: 'number', + defaultValue: 1, + min: 0, + max: 1, + step: 0.05, + editable: true, + public: false, + sortOrder: 220, + }, + { + key: 'material.boom.consumption_multiple', + group: 'threshold', + label: '爆量消耗倍数', + description: '素材消耗达到客户日均消耗的倍数时进入爆量候选', + impact: 'M4 爆量判定唯一来源;修改后下次判定生效', + type: 'number', + defaultValue: 3, + unit: '倍', + min: 1, + max: 20, + step: 0.1, + editable: true, + public: false, + sortOrder: 230, + }, + { + key: 'material.boom.roi_min', + group: 'threshold', + label: '爆量 ROI 下限', + description: '爆量候选必须同时满足的最低 ROI', + impact: 'M4 爆量判定唯一来源;修改后下次判定生效', + type: 'number', + defaultValue: 1.2, + min: 0, + max: 10, + step: 0.1, + editable: true, + public: false, + sortOrder: 240, + }, + { + key: 'review.timeout_hours', + group: 'threshold', + label: '审核超时时长', + description: '任务进入待审核后超过该时长提醒并计入超时', + impact: '影响审核提醒与质量看板;不阻断任务', + type: 'number', + defaultValue: 24, + unit: '小时', + min: 1, + max: 168, + editable: true, + public: false, + sortOrder: 250, + }, + { + key: 'quality.low_ratio_warn', + group: 'threshold', + label: '低质量占比告警线', + description: '低质量产出占比超过该值时触发质量看板告警', + impact: '仅告警不阻断;影响质量看板与通知', + type: 'number', + defaultValue: 10, + unit: '%', + min: 1, + max: 100, + editable: true, + public: false, + sortOrder: 260, + }, +]; + +const DEFINITION_MAP: ReadonlyMap = new Map( + SYSTEM_CONFIG_DEFINITIONS.map((definition) => [definition.key, definition]), +); + +export function systemConfigDefinition(key: string): SystemConfigDefinition | undefined { + return DEFINITION_MAP.get(key); +} + +export function systemConfigDefinitionsOfGroup( + group: SystemConfigGroupKey, +): readonly SystemConfigDefinition[] { + return SYSTEM_CONFIG_DEFINITIONS.filter((definition) => definition.group === group).sort( + (a, b) => a.sortOrder - b.sortOrder, + ); +} + +export function systemConfigValueType(definition: SystemConfigDefinition): SystemConfigValueType { + if (definition.type === 'number') return 'number'; + if (definition.type === 'boolean') return 'boolean'; + return 'string'; +} + +export function isPublicSystemConfigKey(key: string): boolean { + return DEFINITION_MAP.get(key)?.public === true; +} + +export type SystemConfigValueValidation = + { ok: true; value: SystemConfigValue } | { ok: false; message: string }; + +/** 校验配置值;服务端与前端保存前共用,避免两套规则 */ +// eslint-disable-next-line complexity -- 类型分支 + 边界 / 步长 / 模板校验集中一处,拆散会失去单一校验口径 +export function validateSystemConfigValue( + definition: SystemConfigDefinition, + value: unknown, +): SystemConfigValueValidation { + if (definition.type === 'boolean') { + if (typeof value !== 'boolean') { + return { ok: false, message: '配置值必须是布尔值' }; + } + return { ok: true, value }; + } + if (definition.type === 'number') { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return { ok: false, message: '配置值必须是数字' }; + } + if (definition.min !== undefined && value < definition.min) { + return { ok: false, message: `配置值不能小于 ${String(definition.min)}` }; + } + if (definition.max !== undefined && value > definition.max) { + return { ok: false, message: `配置值不能大于 ${String(definition.max)}` }; + } + if (definition.step !== undefined && definition.min !== undefined) { + const steps = (value - definition.min) / definition.step; + if (Math.abs(steps - Math.round(steps)) > 1e-9) { + return { ok: false, message: `配置值必须是 ${String(definition.step)} 的整数倍` }; + } + } + return { ok: true, value }; + } + + if (typeof value !== 'string') { + return { ok: false, message: '配置值必须是字符串' }; + } + const text = value.trim(); + if (text.length === 0) { + return { ok: false, message: '配置值不能为空' }; + } + if (definition.type === 'pattern') { + const check = validateNamingTemplate(text, { maxLength: definition.maxLength ?? 100 }); + if (!check.valid) { + return { ok: false, message: check.errors.join(';') }; + } + return { ok: true, value: text }; + } + if (definition.maxLength !== undefined && text.length > definition.maxLength) { + return { ok: false, message: `配置值不能超过 ${String(definition.maxLength)} 个字符` }; + } + return { ok: true, value: text }; +} diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index d307d25..8fd81f0 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,3 +1,4 @@ +export * from './config/system-configs.js'; export * from './constants.js'; export * from './errors/api-error.js'; export * from './errors/codes.js'; @@ -6,6 +7,8 @@ export * from './permissions.js'; export * from './schemas/api.js'; export * from './schemas/client.js'; export * from './schemas/common.js'; +export * from './schemas/config.js'; +export * from './schemas/dict.js'; export * from './schemas/identity.js'; export * from './schemas/roles.js'; export * from './time.js'; diff --git a/packages/shared/src/naming.spec.ts b/packages/shared/src/naming.spec.ts index 1f539e9..87a81aa 100644 --- a/packages/shared/src/naming.spec.ts +++ b/packages/shared/src/naming.spec.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { dedupeName, sanitizeFilename } from './naming.js'; +import { + dedupeName, + formatNamingDate, + formatNamingSequence, + resolveNamingTemplate, + sanitizeFilename, + validateNamingTemplate, +} from './naming.js'; describe('sanitizeFilename', () => { it('替换路径分隔符与非法字符,保留中文', () => { @@ -45,3 +52,60 @@ describe('dedupeName(R-01 同名自动加序号)', () => { expect(() => dedupeName('a', existing, 3)).toThrow(RangeError); }); }); + +describe('命名模板(A4-BE-03 / R-01)', () => { + const context = { + industry: '电商', + customer: '花间集', + direction: '口播', + date: '20261104', + sequence: 1, + spec: '1080x1920', + }; + + it('默认脚本模板解析为「行业-客户-方向-日期-序号」', () => { + expect(resolveNamingTemplate('{行业}-{客户}-{脚本方向}-{日期}-{序号}', context)).toBe( + '电商-花间集-口播-20261104-1', + ); + }); + + it('序号可先补零后传入', () => { + expect( + resolveNamingTemplate('{行业}-{客户}-{日期}-{序号}', { + ...context, + sequence: formatNamingSequence(context.sequence), + }), + ).toBe('电商-花间集-20261104-01'); + }); + + it('兼容英文 key 与规格后缀', () => { + expect(resolveNamingTemplate('{industry}-{customer}-{date}-{seq}-{spec}', context)).toBe( + '电商-花间集-20261104-1-1080x1920', + ); + }); + + it('未知占位符 / 非法字符 / 空模板被拒绝', () => { + expect(validateNamingTemplate('{行业}-{未知}').valid).toBe(false); + expect(validateNamingTemplate('{行业}*{客户}').valid).toBe(false); + expect(validateNamingTemplate(' ').valid).toBe(false); + }); + + it('缺失占位符值时抛错', () => { + expect(() => resolveNamingTemplate('{行业}-{客户}', { ...context, customer: '' })).toThrow( + '命名占位符缺少值', + ); + }); + + it('超过最大长度抛错', () => { + expect(() => + resolveNamingTemplate('{客户}', { ...context, customer: '客'.repeat(101) }), + ).toThrow('不能超过 100 个字符'); + }); + + it('日期与序号格式化', () => { + expect(formatNamingDate(new Date(2026, 10, 4))).toBe('20261104'); + expect(formatNamingSequence(7)).toBe('07'); + expect(formatNamingSequence(123)).toBe('123'); + expect(() => formatNamingSequence(-1)).toThrow(); + }); +}); diff --git a/packages/shared/src/naming.ts b/packages/shared/src/naming.ts index 0eec356..f9efac1 100644 --- a/packages/shared/src/naming.ts +++ b/packages/shared/src/naming.ts @@ -105,3 +105,196 @@ export function dedupeName(name: string, existing: ReadonlySet, 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; + +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 = 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'); +} diff --git a/packages/shared/src/schemas/client.spec.ts b/packages/shared/src/schemas/client.spec.ts index 3951bbb..a4feba2 100644 --- a/packages/shared/src/schemas/client.spec.ts +++ b/packages/shared/src/schemas/client.spec.ts @@ -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}`, })); diff --git a/packages/shared/src/schemas/client.ts b/packages/shared/src/schemas/client.ts index 0dff140..35fa4f0 100644 --- a/packages/shared/src/schemas/client.ts +++ b/packages/shared/src/schemas/client.ts @@ -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; -/** 上限:只存系统配置前的共享常量(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(); diff --git a/packages/shared/src/schemas/config.spec.ts b/packages/shared/src/schemas/config.spec.ts new file mode 100644 index 0000000..5d7c2ac --- /dev/null +++ b/packages/shared/src/schemas/config.spec.ts @@ -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); + }); +}); diff --git a/packages/shared/src/schemas/config.ts b/packages/shared/src/schemas/config.ts new file mode 100644 index 0000000..b9b4dc1 --- /dev/null +++ b/packages/shared/src/schemas/config.ts @@ -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; + +export const systemConfigListResponseSchema = z.object({ + items: z.array(systemConfigSchema), +}); + +export type SystemConfigListResponse = z.infer; + +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; + +export const updateSystemConfigsInputSchema = z + .object({ + changes: z.array(systemConfigChangeInputSchema).min(1).max(50), + }) + .strict(); + +export type UpdateSystemConfigsInput = z.infer; + +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; + +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; + +export const systemConfigHistoryResponseSchema = z.object({ + items: z.array(systemConfigChangeSchema), + next_cursor: z.string().nullable(), +}); + +export type SystemConfigHistoryResponse = z.infer; + +export const effectiveSystemConfigSchema = z.object({ + key: z.string().min(1), + value: systemConfigValueSchema, +}); + +export type EffectiveSystemConfig = z.infer; + +export const effectiveSystemConfigsResponseSchema = z.object({ + items: z.array(effectiveSystemConfigSchema), +}); + +export type EffectiveSystemConfigsResponse = z.infer; + +/** 供后端 / 前端按 key 查找定义 */ +export function systemConfigMetaOf(key: string): SystemConfigDefinition | undefined { + return systemConfigDefinition(key); +} + +export type { SystemConfigDefinition, SystemConfigGroupKey, SystemConfigInputType }; + +/** 配置定义清单(前端设置页分组、种子脚本共用) */ +export const SYSTEM_CONFIG_META = SYSTEM_CONFIG_DEFINITIONS; diff --git a/packages/shared/src/schemas/dict.spec.ts b/packages/shared/src/schemas/dict.spec.ts new file mode 100644 index 0000000..21d4ede --- /dev/null +++ b/packages/shared/src/schemas/dict.spec.ts @@ -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); + }); +}); diff --git a/packages/shared/src/schemas/dict.ts b/packages/shared/src/schemas/dict.ts new file mode 100644 index 0000000..58c8a38 --- /dev/null +++ b/packages/shared/src/schemas/dict.ts @@ -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; + +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; + +export const DICT_STATUSES = ['active', 'disabled'] as const; +export const dictStatusSchema = z.enum(DICT_STATUSES); +export type DictStatus = z.infer; + +/** 生效条目的来源层(不是存储字段;由三层合并计算) */ +export const DICT_LAYER_SOURCES = ['generic', 'parent', 'current'] as const; +export const dictLayerSourceSchema = z.enum(DICT_LAYER_SOURCES); +export type DictLayerSource = z.infer; + +export const DICT_ITEM_SOURCES = DICT_LAYER_SOURCES; +export const dictItemSourceSchema = dictLayerSourceSchema; +export type DictItemSource = z.infer; + +// --------------------------------------------------------------------------- +// 编码 / 名称 +// --------------------------------------------------------------------------- + +/** 类型编码:小写 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; + +export const dictTypeDetailSchema = dictTypeSummarySchema.extend({ + /** 类型下所有条目(不合并),用于编辑 / 诊断;列表与生效项走 items 接口 */ + items: z.lazy(() => z.array(dictItemSchema)).optional(), +}); + +export type DictTypeDetail = z.infer; + +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; + +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; + +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; + +// --------------------------------------------------------------------------- +// 查询 / 写接口 +// --------------------------------------------------------------------------- + +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; + +export const dictItemListQuerySchema = z.object({ + /** 不传 = 全平台(仅通用层);传一级行业 = 通用 + 该行业;传二级 = 通用 + 父行业 + 该行业 */ + industry_id: ulidSchema.optional(), +}); + +export type DictItemListQuery = z.infer; + +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; + +/** 类型编码 / 形态 / 行业作用域创建后不可改;名称、说明、分组、排序可改 */ +export const updateDictTypeInputSchema = z + .object({ + name: dictNameSchema.optional(), + group: dictGroupKeySchema.optional(), + description: dictDescriptionSchema.optional(), + sortOrder: dictSortOrderSchema.optional(), + }) + .strict(); + +export type UpdateDictTypeInput = z.infer; + +export const setDictTypeStatusInputSchema = z + .object({ + status: dictStatusSchema, + }) + .strict(); + +export type SetDictTypeStatusInput = z.infer; + +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; + +/** 编辑条目:编码、父级、所属行业创建后不可改;名称、排序、启停、备注可改 */ +export const updateDictItemInputSchema = z + .object({ + name: dictNameSchema.optional(), + sortOrder: dictSortOrderSchema.optional(), + status: dictStatusSchema.optional(), + remark: dictItemRemarkSchema.optional(), + }) + .strict(); + +export type UpdateDictItemInput = z.infer; + +// --------------------------------------------------------------------------- +// 快照(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; + +/** 生成填写时快照;A4 只提供契约与工具,真正落库由 B0 / M1 业务记录执行 */ +export function toDictItemSnapshot( + item: Pick, + capturedAt: string, +): DictItemSnapshot { + return { + typeCode: item.typeCode, + code: item.code, + name: item.name, + parentCode: item.parentCode, + industryId: item.industryId, + capturedAt, + }; +} diff --git a/prisma/SEED.md b/prisma/SEED.md index 1ca0a43..94184bf 100644 --- a/prisma/SEED.md +++ b/prisma/SEED.md @@ -6,15 +6,16 @@ A0 只 seed「已建表」的 `system_configs`;A1 补齐组织、账号与角 ## 已 seed(`prisma/seed.ts`) -| 数据 | 来源 | 幂等键 | 说明 | -| ------------------------------ | ---------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 平台系统配置 7 项 | `system_configs` | `config_key` | 平台名、导出链接有效期、上传上限、分页大小、AI 预算比例等,A0 起延续 | -| 部门 / 小组 9 个(两级) | `departments` | `code` | 内容生产中心(素材一组 / 素材二组 / 设计组)、交付中心(客户成功组)、平台技术部(AI 工程组 / 平台运维组);一级部门负责人取 `leader_account`,用户种子之后回填 `leader_user_id` | -| 权限点目录 30 个 | `permissions` | `code`(只增不删) | 10 个模块(script / material / asset / delivery / customer / customer_group / identity / industry / platform / audit);启动时由 `PermissionCatalogService` 幂等 upsert,seed 同步一次 | -| 角色 10 个 + 角色权限点 | `roles` / `role_permissions` | 角色 `code`;权限映射先清后建 | 与 `design/a2-ui-v1/brief.md` §4 对齐:超级管理员(隐式全集,映射 24 普通 + 6 特殊)、平台管理员 12+1(`audit:read`)、素材运营 9、设计师 4、文案策划 2、审核员 4、AI 工程师 3+1(`ai:configure`)、客户负责人 5、行业配置 4、审核主管 5(停用);非超管角色合计 50 条映射,特殊授予共 8 项;新建角色仍默认全空、特殊权限单独授予 | -| 角色数据范围 | `data_scopes` | 角色 `code`;范围先清后建 | 一个角色只允许一种范围类型:超管 / 平台管理员 / AI 工程师 / 行业配置 = `all`;素材运营 / 设计师 = `owned_customers`;文案策划 = `department`;审核员 / 审核主管 = `department_and_children`;客户负责人 = `customer_groups`(A3 前无客户组,保留一行 `target_id = NULL` 以固定范围类型,不降级为最小权限) | -| 用户 11 个(超管 1 + 示例 10) | `users` / `user_roles` | `account`;用户角色先清后建 | 超管 `admin`,示例用户与 A1 原型口径一致(陈默、林清、周岸、赵一诺、高岑、苏禾、沈知远、江岚、唐嘉、何岸);唐嘉 / 何岸为停用态,用于「停用即时失效」联调 | -| 用户初始密码 | `users.password_hash` | — | 统一 `lingxi2026`(Argon2id),`must_change_password=true`:首登强制改密 | +| 数据 | 来源 | 幂等键 | 说明 | +| ------------------------------ | ---------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 平台系统配置 26 项 | `system_configs` | `config_key` | 定义唯一事实源为 `packages/shared/src/config/system-configs.ts`:基础信息 2、命名规则 5、导出与归档 4、通知开关 4、阈值与上限 5、业务阈值 6;A0 的 7 项继续保留,A4 补齐其余配置;重复执行不覆盖人工调整值 / 版本 | +| 部门 / 小组 9 个(两级) | `departments` | `code` | 内容生产中心(素材一组 / 素材二组 / 设计组)、交付中心(客户成功组)、平台技术部(AI 工程组 / 平台运维组);一级部门负责人取 `leader_account`,用户种子之后回填 `leader_user_id` | +| 权限点目录 30 个 | `permissions` | `code`(只增不删) | 10 个模块(script / material / asset / delivery / customer / customer_group / identity / industry / platform / audit);启动时由 `PermissionCatalogService` 幂等 upsert,seed 同步一次 | +| 角色 10 个 + 角色权限点 | `roles` / `role_permissions` | 角色 `code`;权限映射先清后建 | 与 `design/a2-ui-v1/brief.md` §4 对齐:超级管理员(隐式全集,映射 24 普通 + 6 特殊)、平台管理员 12+1(`audit:read`)、素材运营 9、设计师 4、文案策划 2、审核员 4、AI 工程师 3+1(`ai:configure`)、客户负责人 5、行业配置 4、审核主管 5(停用);非超管角色合计 50 条映射,特殊授予共 8 项;新建角色仍默认全空、特殊权限单独授予 | +| 角色数据范围 | `data_scopes` | 角色 `code`;范围先清后建 | 一个角色只允许一种范围类型:超管 / 平台管理员 / AI 工程师 / 行业配置 = `all`;素材运营 / 设计师 = `owned_customers`;文案策划 = `department`;审核员 / 审核主管 = `department_and_children`;客户负责人 = `customer_groups`(A3 前无客户组,保留一行 `target_id = NULL` 以固定范围类型,不降级为最小权限) | +| 用户 11 个(超管 1 + 示例 10) | `users` / `user_roles` | `account`;用户角色先清后建 | 超管 `admin`,示例用户与 A1 原型口径一致(陈默、林清、周岸、赵一诺、高岑、苏禾、沈知远、江岚、唐嘉、何岸);唐嘉 / 何岸为停用态,用于「停用即时失效」联调 | +| 用户初始密码 | `users.password_hash` | — | 统一 `lingxi2026`(Argon2id),`must_change_password=true`:首登强制改密 | +| A4 数据字典 13 类 / 96 条 | `dict_types` / `dict_items` | 类型 `code`;条目 `(type, code, industry)` | 13 类:平台与规格、光影与风格、时长档、钩子类型、景别、脚本方向、情绪与风格、素材形态、警示语类型、禁用词库、失败原因、淘汰理由、人群标签;4 分组;行业作用域使用 A3 的「电商 / 服饰鞋包 / 金融 / 信贷」等行业 ID;内置类型不可删除,条目编码不可改 | ## 登录账号(本地联调) diff --git a/prisma/migrations/20261104024511_a4_dict_system_configs/migration.sql b/prisma/migrations/20261104024511_a4_dict_system_configs/migration.sql new file mode 100644 index 0000000..ed4c6dd --- /dev/null +++ b/prisma/migrations/20261104024511_a4_dict_system_configs/migration.sql @@ -0,0 +1,90 @@ +-- CreateEnum +CREATE TYPE "DictStatus" AS ENUM ('ACTIVE', 'DISABLED'); + +-- CreateEnum +CREATE TYPE "DictKind" AS ENUM ('TREE', 'LIST'); + +-- CreateTable +CREATE TABLE "system_config_versions" ( + "id" CHAR(26) NOT NULL, + "config_key" VARCHAR(128) NOT NULL, + "version" INTEGER NOT NULL, + "old_value" JSONB, + "new_value" JSONB NOT NULL, + "reason" VARCHAR(255), + "changed_by" CHAR(26), + "changed_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "system_config_versions_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dict_types" ( + "id" CHAR(26) NOT NULL, + "code" VARCHAR(64) NOT NULL, + "name" VARCHAR(64) NOT NULL, + "group_key" VARCHAR(32) NOT NULL, + "kind" "DictKind" NOT NULL DEFAULT 'LIST', + "description" VARCHAR(255) NOT NULL DEFAULT '', + "industry_scoped" BOOLEAN NOT NULL DEFAULT false, + "builtin" BOOLEAN NOT NULL DEFAULT false, + "status" "DictStatus" NOT NULL DEFAULT 'ACTIVE', + "sort_order" INTEGER NOT NULL DEFAULT 0, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + "updated_by" VARCHAR(64), + + CONSTRAINT "dict_types_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "dict_items" ( + "id" CHAR(26) NOT NULL, + "type_id" CHAR(26) NOT NULL, + "code" VARCHAR(64) NOT NULL, + "name" VARCHAR(64) NOT NULL, + "parent_code" VARCHAR(64), + "industry_id" CHAR(26), + "sort_order" INTEGER NOT NULL DEFAULT 0, + "status" "DictStatus" NOT NULL DEFAULT 'ACTIVE', + "remark" VARCHAR(255) NOT NULL DEFAULT '', + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + "updated_by" VARCHAR(64), + + CONSTRAINT "dict_items_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "system_config_versions_changed_at_idx" ON "system_config_versions"("changed_at"); + +-- CreateIndex +CREATE UNIQUE INDEX "system_config_versions_config_key_version_key" ON "system_config_versions"("config_key", "version"); + +-- CreateIndex +CREATE UNIQUE INDEX "dict_types_code_key" ON "dict_types"("code"); + +-- CreateIndex +CREATE INDEX "dict_types_group_key_sort_order_idx" ON "dict_types"("group_key", "sort_order"); + +-- CreateIndex +CREATE INDEX "dict_items_type_id_industry_id_sort_order_idx" ON "dict_items"("type_id", "industry_id", "sort_order"); + +-- CreateIndex +CREATE INDEX "dict_items_type_id_parent_code_idx" ON "dict_items"("type_id", "parent_code"); + +-- AddForeignKey +ALTER TABLE "system_config_versions" ADD CONSTRAINT "system_config_versions_config_key_fkey" FOREIGN KEY ("config_key") REFERENCES "system_configs"("config_key") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dict_items" ADD CONSTRAINT "dict_items_type_id_fkey" FOREIGN KEY ("type_id") REFERENCES "dict_types"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "dict_items" ADD CONSTRAINT "dict_items_industry_id_fkey" FOREIGN KEY ("industry_id") REFERENCES "industries"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- A4 / R-10:同 type + code + 行业层唯一;industry_id 为 NULL 的通用层也必须唯一 +CREATE UNIQUE INDEX "dict_items_type_code_generic_key" ON "dict_items"("type_id", "code") WHERE "industry_id" IS NULL; +CREATE UNIQUE INDEX "dict_items_type_code_industry_key" ON "dict_items"("type_id", "code", "industry_id") WHERE "industry_id" IS NOT NULL; + +-- 一条条目不能把自己作为父级 +ALTER TABLE "dict_items" ADD CONSTRAINT "dict_items_no_self_parent_check" CHECK ("parent_code" IS NULL OR "parent_code" <> "code"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 9db888f..b691a38 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -226,9 +226,29 @@ model SystemConfig { updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedBy String? @map("updated_by") @db.Char(26) + versions SystemConfigVersion[] + @@map("system_configs") } +/// 系统配置版本历史(append-only):每次变更留前后值、原因、操作人(A4-BE-02) +model SystemConfigVersion { + id String @id @db.Char(26) + configKey String @map("config_key") @db.VarChar(128) + version Int + oldValue Json? @map("old_value") @db.JsonB + newValue Json @map("new_value") @db.JsonB + reason String? @db.VarChar(255) + changedBy String? @map("changed_by") @db.Char(26) + changedAt DateTime @default(now()) @map("changed_at") @db.Timestamptz(3) + + config SystemConfig @relation(fields: [configKey], references: [configKey], onDelete: Cascade) + + @@unique([configKey, version]) + @@index([changedAt]) + @@map("system_config_versions") +} + // --------------------------------------------------------------------------- // A3 客户与行业(client 模块;ADR-002 两级行业) // --------------------------------------------------------------------------- @@ -254,6 +274,7 @@ model Industry { parent Industry? @relation("IndustryTree", fields: [parentId], references: [id], onDelete: Restrict) children Industry[] @relation("IndustryTree") customers Customer[] + dictItems DictItem[] @@index([parentId, sortOrder]) @@index([level, status]) @@ -361,3 +382,63 @@ model CustomerField { @@index([customerId, status]) @@map("customer_fields") } + +// --------------------------------------------------------------------------- +// A4 数据字典(catalog 模块):一张通用枚举表同时表达二级树与词表 +// --------------------------------------------------------------------------- + +enum DictStatus { + ACTIVE + DISABLED +} + +enum DictKind { + TREE + LIST +} + +/// 字典类型:编码唯一且创建后不可改;内置类型不可删除 +model DictType { + id String @id @db.Char(26) + code String @unique @db.VarChar(64) + name String @db.VarChar(64) + groupKey String @map("group_key") @db.VarChar(32) + kind DictKind @default(LIST) + description String @default("") @db.VarChar(255) + industryScoped Boolean @default(false) @map("industry_scoped") + builtin Boolean @default(false) + status DictStatus @default(ACTIVE) + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + updatedBy String? @map("updated_by") @db.VarChar(64) + + items DictItem[] + + @@index([groupKey, sortOrder]) + @@map("dict_types") +} + +/// 字典条目:industry_id 为空 = 通用层;parent_code 表达二级树; +/// 同 type + code + industry 唯一由迁移中的部分唯一索引保证(NULL 也唯一)。 +model DictItem { + id String @id @db.Char(26) + typeId String @map("type_id") @db.Char(26) + code String @db.VarChar(64) + name String @db.VarChar(64) + parentCode String? @map("parent_code") @db.VarChar(64) + industryId String? @map("industry_id") @db.Char(26) + sortOrder Int @default(0) @map("sort_order") + status DictStatus @default(ACTIVE) + remark String @default("") @db.VarChar(255) + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(3) + updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) + updatedBy String? @map("updated_by") @db.VarChar(64) + + type DictType @relation(fields: [typeId], references: [id], onDelete: Cascade) + industry Industry? @relation(fields: [industryId], references: [id], onDelete: Restrict) + + @@index([typeId, industryId, sortOrder]) + @@index([typeId, parentCode]) + @@map("dict_items") +} diff --git a/prisma/seed-data/dicts.json b/prisma/seed-data/dicts.json new file mode 100644 index 0000000..3004449 --- /dev/null +++ b/prisma/seed-data/dicts.json @@ -0,0 +1,1218 @@ +{ + "types": [ + { + "code": "platform_spec", + "name": "平台与规格", + "group": "platform", + "kind": "tree", + "description": "投放平台及常用尺寸比例,图片与视频共用;行业可追加平台或覆盖尺寸说明", + "industryScoped": true, + "builtin": true, + "status": "active", + "sortOrder": 10, + "updatedBy": "江岚" + }, + { + "code": "light_style", + "name": "光影与风格", + "group": "platform", + "kind": "list", + "description": "光影池与视觉风格,供图片合成与视频提示词引用", + "industryScoped": true, + "builtin": true, + "status": "active", + "sortOrder": 20, + "updatedBy": "林清" + }, + { + "code": "duration_tier", + "name": "时长档", + "group": "content", + "kind": "list", + "description": "视频成片的时长档位,脚本结构与素材生成共用", + "industryScoped": false, + "builtin": true, + "status": "active", + "sortOrder": 30, + "updatedBy": "江岚" + }, + { + "code": "hook_type", + "name": "钩子类型", + "group": "content", + "kind": "list", + "description": "脚本前三秒钩子的类型,用于生成与质量校核", + "industryScoped": false, + "builtin": true, + "status": "active", + "sortOrder": 40, + "updatedBy": "江岚" + }, + { + "code": "shot_type", + "name": "景别", + "group": "content", + "kind": "list", + "description": "分镜景别,视频线逐镜生成与画面校验使用", + "industryScoped": false, + "builtin": true, + "status": "active", + "sortOrder": 50, + "updatedBy": "林清" + }, + { + "code": "script_direction", + "name": "脚本方向", + "group": "content", + "kind": "list", + "description": "脚本“说什么”的方向;行业可追加或覆盖,视频方向与脚本方向并列", + "industryScoped": true, + "builtin": true, + "status": "active", + "sortOrder": 60, + "updatedBy": "江岚" + }, + { + "code": "emotion_style", + "name": "情绪与风格", + "group": "content", + "kind": "list", + "description": "脚本与画面的情绪风格,供提示词与卖点匹配", + "industryScoped": true, + "builtin": true, + "status": "active", + "sortOrder": 70, + "updatedBy": "周岸" + }, + { + "code": "material_form", + "name": "素材形态", + "group": "audience", + "kind": "list", + "description": "素材的呈现形态,图片线与视频线按形态选择模板", + "industryScoped": true, + "builtin": true, + "status": "active", + "sortOrder": 80, + "updatedBy": "林清" + }, + { + "code": "warning_type", + "name": "警示语类型", + "group": "quality", + "kind": "list", + "description": "合规警示语的分类;金融等行业可追加行业专用类型", + "industryScoped": true, + "builtin": true, + "status": "active", + "sortOrder": 90, + "updatedBy": "江岚" + }, + { + "code": "banned_words", + "name": "禁用词库", + "group": "quality", + "kind": "tree", + "description": "通用极限词、行业词与客户词;发布行业包与脚本校核时引用", + "industryScoped": true, + "builtin": true, + "status": "active", + "sortOrder": 100, + "updatedBy": "江岚" + }, + { + "code": "failure_reason", + "name": "失败原因", + "group": "quality", + "kind": "list", + "description": "任务与素材失败的原因码,任务中心与质量看板共用", + "industryScoped": false, + "builtin": true, + "status": "active", + "sortOrder": 110, + "updatedBy": "苏禾" + }, + { + "code": "reject_reason", + "name": "淘汰理由", + "group": "quality", + "kind": "list", + "description": "快筛淘汰理由,理由进字典统计", + "industryScoped": false, + "builtin": true, + "status": "active", + "sortOrder": 120, + "updatedBy": "赵一诺" + }, + { + "code": "audience_tag", + "name": "人群标签", + "group": "audience", + "kind": "tree", + "description": "年龄、性别、身份、消费力等人群标签,可扩展", + "industryScoped": true, + "builtin": true, + "status": "active", + "sortOrder": 130, + "updatedBy": "高岑" + } + ], + "items": [ + { + "typeCode": "platform_spec", + "code": "douyin", + "name": "抖音", + "parentCode": null, + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "短视频与信息流主投平台", + "updatedBy": "江岚" + }, + { + "typeCode": "platform_spec", + "code": "douyin_feed", + "name": "抖音信息流", + "parentCode": "douyin", + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "竖版 9:16,1080×1920", + "updatedBy": "江岚" + }, + { + "typeCode": "platform_spec", + "code": "douyin_square", + "name": "抖音方图", + "parentCode": "douyin", + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "1:1,1080×1080", + "updatedBy": "江岚" + }, + { + "typeCode": "platform_spec", + "code": "xhs", + "name": "小红书", + "parentCode": null, + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "图文与视频笔记", + "updatedBy": "林清" + }, + { + "typeCode": "platform_spec", + "code": "xhs_note", + "name": "小红书笔记", + "parentCode": "xhs", + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "竖版 3:4,1080×1440", + "updatedBy": "林清" + }, + { + "typeCode": "platform_spec", + "code": "wechat", + "name": "微信朋友圈", + "parentCode": null, + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "朋友圈广告位", + "updatedBy": "江岚" + }, + { + "typeCode": "platform_spec", + "code": "wechat_moments", + "name": "朋友圈竖版", + "parentCode": "wechat", + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "1080×1920", + "updatedBy": "江岚" + }, + { + "typeCode": "platform_spec", + "code": "ec_live", + "name": "电商直播切片", + "parentCode": null, + "industryId": "ec", + "sortOrder": 40, + "status": "active", + "remark": "电商行业追加,直播回放切条后投放", + "updatedBy": "江岚" + }, + { + "typeCode": "platform_spec", + "code": "ec_product", + "name": "商品主图", + "parentCode": null, + "industryId": "ec", + "sortOrder": 50, + "status": "active", + "remark": "电商行业追加,1:1 商品展示位", + "updatedBy": "江岚" + }, + { + "typeCode": "platform_spec", + "code": "apparel_look", + "name": "穿搭场景", + "parentCode": null, + "industryId": "ec_apparel", + "sortOrder": 60, + "status": "active", + "remark": "服饰鞋包二级追加,模特场景图", + "updatedBy": "江岚" + }, + { + "typeCode": "light_style", + "code": "L1", + "name": "自然光", + "parentCode": null, + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "日常、真实感场景", + "updatedBy": "林清" + }, + { + "typeCode": "light_style", + "code": "L2", + "name": "棚拍柔光", + "parentCode": null, + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "电商白底/产品图常用", + "updatedBy": "林清" + }, + { + "typeCode": "light_style", + "code": "L3", + "name": "暖调氛围", + "parentCode": null, + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "食品、家居、生活方式", + "updatedBy": "林清" + }, + { + "typeCode": "light_style", + "code": "L4", + "name": "冷调质感", + "parentCode": null, + "industryId": null, + "sortOrder": 40, + "status": "active", + "remark": "科技、金融、专业感", + "updatedBy": "林清" + }, + { + "typeCode": "light_style", + "code": "L5", + "name": "高反差硬光", + "parentCode": null, + "industryId": null, + "sortOrder": 50, + "status": "disabled", + "remark": "暂停使用,部分平台审核风险较高", + "updatedBy": "林清" + }, + { + "typeCode": "light_style", + "code": "fin_L6", + "name": "商务蓝调", + "parentCode": null, + "industryId": "fin", + "sortOrder": 60, + "status": "active", + "remark": "金融行业追加,专业可信", + "updatedBy": "高岑" + }, + { + "typeCode": "duration_tier", + "code": "15s", + "name": "15 秒", + "parentCode": null, + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "信息流强钩子短版", + "updatedBy": "江岚" + }, + { + "typeCode": "duration_tier", + "code": "30s", + "name": "30 秒", + "parentCode": null, + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "标准卖点展示", + "updatedBy": "江岚" + }, + { + "typeCode": "duration_tier", + "code": "60s", + "name": "60 秒", + "parentCode": null, + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "剧情/口播完整叙事", + "updatedBy": "江岚" + }, + { + "typeCode": "hook_type", + "code": "question", + "name": "提问", + "parentCode": null, + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "用问题制造好奇", + "updatedBy": "周岸" + }, + { + "typeCode": "hook_type", + "code": "contrast", + "name": "反差", + "parentCode": null, + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "前后对比、预期反转", + "updatedBy": "周岸" + }, + { + "typeCode": "hook_type", + "code": "price", + "name": "价格", + "parentCode": null, + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "价格锚点与优惠", + "updatedBy": "周岸" + }, + { + "typeCode": "hook_type", + "code": "scene", + "name": "场景", + "parentCode": null, + "industryId": null, + "sortOrder": 40, + "status": "active", + "remark": "痛点场景代入", + "updatedBy": "周岸" + }, + { + "typeCode": "hook_type", + "code": "identity", + "name": "身份", + "parentCode": null, + "industryId": null, + "sortOrder": 50, + "status": "active", + "remark": "身份标签引发共鸣", + "updatedBy": "周岸" + }, + { + "typeCode": "hook_type", + "code": "suspense", + "name": "悬念", + "parentCode": null, + "industryId": null, + "sortOrder": 60, + "status": "active", + "remark": "先抛结果再解释", + "updatedBy": "周岸" + }, + { + "typeCode": "shot_type", + "code": "medium_close", + "name": "中近景", + "parentCode": null, + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "口播与产品展示常用", + "updatedBy": "林清" + }, + { + "typeCode": "shot_type", + "code": "close_up", + "name": "特写", + "parentCode": null, + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "细节、材质与情绪", + "updatedBy": "林清" + }, + { + "typeCode": "shot_type", + "code": "wide", + "name": "全景", + "parentCode": null, + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "场景与空间关系", + "updatedBy": "林清" + }, + { + "typeCode": "script_direction", + "code": "oral", + "name": "口播", + "parentCode": null, + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "真人出镜讲解", + "updatedBy": "江岚" + }, + { + "typeCode": "script_direction", + "code": "drama", + "name": "剧情", + "parentCode": null, + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "情景短剧", + "updatedBy": "江岚" + }, + { + "typeCode": "script_direction", + "code": "review", + "name": "测评", + "parentCode": null, + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "横向对比与实测", + "updatedBy": "江岚" + }, + { + "typeCode": "script_direction", + "code": "seeding", + "name": "种草", + "parentCode": null, + "industryId": null, + "sortOrder": 40, + "status": "active", + "remark": "体验分享与推荐", + "updatedBy": "江岚" + }, + { + "typeCode": "script_direction", + "code": "scene", + "name": "情景", + "parentCode": null, + "industryId": null, + "sortOrder": 50, + "status": "active", + "remark": "生活场景演绎", + "updatedBy": "江岚" + }, + { + "typeCode": "script_direction", + "code": "live_clip", + "name": "直播切片", + "parentCode": null, + "industryId": "ec", + "sortOrder": 60, + "status": "active", + "remark": "电商行业追加,直播高光片段", + "updatedBy": "江岚" + }, + { + "typeCode": "script_direction", + "code": "seeding", + "name": "种草短视频", + "parentCode": null, + "industryId": "ec", + "sortOrder": 40, + "status": "active", + "remark": "电商行业覆盖名称,优先短视频表达", + "updatedBy": "江岚" + }, + { + "typeCode": "script_direction", + "code": "outfit", + "name": "穿搭教程", + "parentCode": null, + "industryId": "ec_apparel", + "sortOrder": 70, + "status": "active", + "remark": "服饰鞋包二级追加,穿搭教学", + "updatedBy": "江岚" + }, + { + "typeCode": "script_direction", + "code": "calc", + "name": "算账", + "parentCode": null, + "industryId": "fin", + "sortOrder": 60, + "status": "active", + "remark": "金融行业追加,收益/成本测算", + "updatedBy": "高岑" + }, + { + "typeCode": "script_direction", + "code": "risk_note", + "name": "风险提示", + "parentCode": null, + "industryId": "fin", + "sortOrder": 70, + "status": "active", + "remark": "金融行业追加,合规提示前置", + "updatedBy": "高岑" + }, + { + "typeCode": "script_direction", + "code": "apr_read", + "name": "年化利率解读", + "parentCode": null, + "industryId": "fin_credit", + "sortOrder": 80, + "status": "active", + "remark": "信贷二级追加,利率口径解释", + "updatedBy": "高岑" + }, + { + "typeCode": "emotion_style", + "code": "warm", + "name": "亲和", + "parentCode": null, + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "可信、生活化", + "updatedBy": "周岸" + }, + { + "typeCode": "emotion_style", + "code": "pro", + "name": "专业", + "parentCode": null, + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "理性、权威", + "updatedBy": "周岸" + }, + { + "typeCode": "emotion_style", + "code": "passion", + "name": "激情", + "parentCode": null, + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "强节奏、强行动号召", + "updatedBy": "周岸" + }, + { + "typeCode": "emotion_style", + "code": "humor", + "name": "幽默", + "parentCode": null, + "industryId": null, + "sortOrder": 40, + "status": "active", + "remark": "轻松、反套路", + "updatedBy": "周岸" + }, + { + "typeCode": "emotion_style", + "code": "suspense", + "name": "悬念", + "parentCode": null, + "industryId": null, + "sortOrder": 50, + "status": "active", + "remark": "好奇驱动", + "updatedBy": "周岸" + }, + { + "typeCode": "emotion_style", + "code": "fin_trust", + "name": "稳健可信", + "parentCode": null, + "industryId": "fin", + "sortOrder": 60, + "status": "active", + "remark": "金融行业追加,克制、可信", + "updatedBy": "高岑" + }, + { + "typeCode": "material_form", + "code": "display", + "name": "展示", + "parentCode": null, + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "产品/服务直接展示", + "updatedBy": "林清" + }, + { + "typeCode": "material_form", + "code": "oral", + "name": "口播", + "parentCode": null, + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "真人讲解", + "updatedBy": "林清" + }, + { + "typeCode": "material_form", + "code": "drama", + "name": "剧情", + "parentCode": null, + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "情景演绎", + "updatedBy": "林清" + }, + { + "typeCode": "material_form", + "code": "motion_graphics", + "name": "图文动效", + "parentCode": null, + "industryId": null, + "sortOrder": 40, + "status": "active", + "remark": "文字与图形动效", + "updatedBy": "林清" + }, + { + "typeCode": "material_form", + "code": "infographic", + "name": "信息图", + "parentCode": null, + "industryId": null, + "sortOrder": 50, + "status": "active", + "remark": "数据与卖点信息图", + "updatedBy": "林清" + }, + { + "typeCode": "warning_type", + "code": "common", + "name": "行业通用", + "parentCode": null, + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "各行业通用提示语", + "updatedBy": "江岚" + }, + { + "typeCode": "warning_type", + "code": "platform", + "name": "平台强制", + "parentCode": null, + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "投放平台强制展示", + "updatedBy": "江岚" + }, + { + "typeCode": "warning_type", + "code": "customer", + "name": "客户指定", + "parentCode": null, + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "客户合同/品牌规范指定", + "updatedBy": "江岚" + }, + { + "typeCode": "warning_type", + "code": "risk_warning", + "name": "风险提示", + "parentCode": null, + "industryId": "fin", + "sortOrder": 40, + "status": "active", + "remark": "金融行业追加,投资有风险", + "updatedBy": "高岑" + }, + { + "typeCode": "warning_type", + "code": "apr_warning", + "name": "年化利率提示", + "parentCode": null, + "industryId": "fin_credit", + "sortOrder": 50, + "status": "active", + "remark": "信贷二级追加,展示年化区间", + "updatedBy": "高岑" + }, + { + "typeCode": "warning_type", + "code": "price_warning", + "name": "价格真实性提示", + "parentCode": null, + "industryId": "ec", + "sortOrder": 40, + "status": "active", + "remark": "电商行业追加,价格与优惠说明", + "updatedBy": "江岚" + }, + { + "typeCode": "banned_words", + "code": "extreme", + "name": "极限词", + "parentCode": null, + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "广告法通用极限词", + "updatedBy": "江岚" + }, + { + "typeCode": "banned_words", + "code": "extreme_national", + "name": "国家级", + "parentCode": "extreme", + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "禁用", + "updatedBy": "江岚" + }, + { + "typeCode": "banned_words", + "code": "extreme_best", + "name": "最高级", + "parentCode": "extreme", + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "禁用", + "updatedBy": "江岚" + }, + { + "typeCode": "banned_words", + "code": "extreme_first", + "name": "第一品牌", + "parentCode": "extreme", + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "无依据时禁用", + "updatedBy": "江岚" + }, + { + "typeCode": "banned_words", + "code": "medical", + "name": "医疗功效", + "parentCode": null, + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "非医疗类目禁用功效表述", + "updatedBy": "江岚" + }, + { + "typeCode": "banned_words", + "code": "medical_cure", + "name": "治愈", + "parentCode": "medical", + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "禁用", + "updatedBy": "江岚" + }, + { + "typeCode": "banned_words", + "code": "medical_root", + "name": "根治", + "parentCode": "medical", + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "禁用", + "updatedBy": "江岚" + }, + { + "typeCode": "banned_words", + "code": "finance", + "name": "金融承诺", + "parentCode": null, + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "不得承诺收益/保本", + "updatedBy": "江岚" + }, + { + "typeCode": "banned_words", + "code": "finance_return", + "name": "保证收益", + "parentCode": "finance", + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "禁用", + "updatedBy": "江岚" + }, + { + "typeCode": "banned_words", + "code": "finance_principal", + "name": "保本保息", + "parentCode": "finance", + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "禁用", + "updatedBy": "江岚" + }, + { + "typeCode": "banned_words", + "code": "edu", + "name": "教育升学", + "parentCode": null, + "industryId": null, + "sortOrder": 40, + "status": "active", + "remark": "不得承诺升学/通过", + "updatedBy": "江岚" + }, + { + "typeCode": "banned_words", + "code": "edu_pass", + "name": "包过", + "parentCode": "edu", + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "禁用", + "updatedBy": "江岚" + }, + { + "typeCode": "banned_words", + "code": "edu_rate", + "name": "升学率", + "parentCode": "edu", + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "无依据时禁用", + "updatedBy": "江岚" + }, + { + "typeCode": "banned_words", + "code": "fin_stable", + "name": "稳赚不赔", + "parentCode": "finance", + "industryId": "fin", + "sortOrder": 30, + "status": "active", + "remark": "金融行业追加,禁用", + "updatedBy": "高岑" + }, + { + "typeCode": "banned_words", + "code": "ec_lowest", + "name": "全网最低价", + "parentCode": "extreme", + "industryId": "ec", + "sortOrder": 40, + "status": "active", + "remark": "电商行业追加,需证明或禁用", + "updatedBy": "江岚" + }, + { + "typeCode": "failure_reason", + "code": "review_reject", + "name": "审核拒绝", + "parentCode": null, + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "平台或内部审核未通过", + "updatedBy": "苏禾" + }, + { + "typeCode": "failure_reason", + "code": "timeout", + "name": "超时", + "parentCode": null, + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "模型或渲染超时", + "updatedBy": "苏禾" + }, + { + "typeCode": "failure_reason", + "code": "invalid_param", + "name": "参数非法", + "parentCode": null, + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "提交参数或 schema 校验失败", + "updatedBy": "苏禾" + }, + { + "typeCode": "failure_reason", + "code": "rate_limit", + "name": "限流", + "parentCode": null, + "industryId": null, + "sortOrder": 40, + "status": "active", + "remark": "供应商或平台限流", + "updatedBy": "苏禾" + }, + { + "typeCode": "reject_reason", + "code": "poor_visual", + "name": "画面差", + "parentCode": null, + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "构图、清晰度或质感不足", + "updatedBy": "赵一诺" + }, + { + "typeCode": "reject_reason", + "code": "wrong_subject", + "name": "主体不对", + "parentCode": null, + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "主体与要素卡不一致", + "updatedBy": "赵一诺" + }, + { + "typeCode": "reject_reason", + "code": "bad_copy", + "name": "台词怪", + "parentCode": null, + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "表达生硬、口播不自然", + "updatedBy": "赵一诺" + }, + { + "typeCode": "reject_reason", + "code": "off_character", + "name": "人物出戏", + "parentCode": null, + "industryId": null, + "sortOrder": 40, + "status": "active", + "remark": "人物形象与场景不匹配", + "updatedBy": "赵一诺" + }, + { + "typeCode": "audience_tag", + "code": "age", + "name": "年龄", + "parentCode": null, + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "age_18_24", + "name": "18–24 岁", + "parentCode": "age", + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "age_25_34", + "name": "25–34 岁", + "parentCode": "age", + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "age_35_44", + "name": "35–44 岁", + "parentCode": "age", + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "gender", + "name": "性别", + "parentCode": null, + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "gender_male", + "name": "男性", + "parentCode": "gender", + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "gender_female", + "name": "女性", + "parentCode": "gender", + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "identity", + "name": "身份", + "parentCode": null, + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "identity_white", + "name": "白领", + "parentCode": "identity", + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "identity_mom", + "name": "宝妈", + "parentCode": "identity", + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "identity_student", + "name": "学生", + "parentCode": "identity", + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "power", + "name": "消费力", + "parentCode": null, + "industryId": null, + "sortOrder": 40, + "status": "active", + "remark": "", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "power_high", + "name": "高", + "parentCode": "power", + "industryId": null, + "sortOrder": 10, + "status": "active", + "remark": "", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "power_mid", + "name": "中", + "parentCode": "power", + "industryId": null, + "sortOrder": 20, + "status": "active", + "remark": "", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "power_low", + "name": "低", + "parentCode": "power", + "industryId": null, + "sortOrder": 30, + "status": "active", + "remark": "", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "fin_new_citizen", + "name": "新市民", + "parentCode": null, + "industryId": "fin", + "sortOrder": 50, + "status": "active", + "remark": "金融行业追加客群", + "updatedBy": "高岑" + }, + { + "typeCode": "audience_tag", + "code": "apparel_quality", + "name": "品质人群", + "parentCode": null, + "industryId": "ec_apparel", + "sortOrder": 60, + "status": "active", + "remark": "服饰鞋包二级追加客群", + "updatedBy": "高岑" + } + ] +} diff --git a/prisma/seed.ts b/prisma/seed.ts index 43bff62..5e034f2 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -14,12 +14,17 @@ import { PrismaClient, type RoleStatus, type UserStatus } from '@prisma/client'; import { config as loadDotenv } from 'dotenv'; // 直接引用 shared 源码目录(seed 由 tsx 执行;shared 是权限点唯一事实源,避免两处编码漂移) +import { + SYSTEM_CONFIG_DEFINITIONS, + systemConfigValueType, +} from '../packages/shared/src/config/system-configs.js'; import { findSodConflicts, Permission, PERMISSION_CATALOG, splitPermissions, } from '../packages/shared/src/permissions.js'; +import dictSeed from './seed-data/dicts.json' with { type: 'json' }; for (const file of ['.env.local', '.env']) { const path = resolve(process.cwd(), file); @@ -48,58 +53,6 @@ function ulid(): string { const SEED_PASSWORD_HASH = '$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4'; -interface SeedConfig { - key: string; - value: string | number | boolean; - valueType: 'string' | 'number' | 'boolean'; - description: string; -} - -const SYSTEM_CONFIGS: SeedConfig[] = [ - { - key: 'platform.name', - value: '灵犀', - valueType: 'string', - description: '平台名称(展示用)', - }, - { - key: 'platform.export.signed_url_ttl_seconds', - value: 900, - valueType: 'number', - description: '导出产物签名 URL 有效期(秒),短时效', - }, - { - key: 'platform.export.archive_retention_days', - value: 3, - valueType: 'number', - description: '导出打包产物默认保留天数', - }, - { - key: 'platform.upload.max_upload_mb', - value: 500, - valueType: 'number', - description: '单文件上传上限(MB)', - }, - { - key: 'platform.page.default_limit', - value: 20, - valueType: 'number', - description: '列表默认每页数量', - }, - { - key: 'platform.ai.cost_soft_limit_ratio', - value: 0.8, - valueType: 'number', - description: 'AI 成本软上限触发比例', - }, - { - key: 'platform.ai.cost_hard_limit_ratio', - value: 1, - valueType: 'number', - description: 'AI 成本硬上限触发比例(达到即拒绝新任务)', - }, -]; - interface SeedDepartment { code: string; name: string; @@ -429,20 +382,26 @@ const USERS: SeedUser[] = [ ]; async function seedConfigs(): Promise { - for (const item of SYSTEM_CONFIGS) { + for (const definition of SYSTEM_CONFIG_DEFINITIONS) { await prisma.systemConfig.upsert({ - where: { configKey: item.key }, - update: {}, + where: { configKey: definition.key }, + // 只同步元数据,不覆盖人工调整过的值 / 版本 + update: { + description: definition.description, + valueType: systemConfigValueType(definition), + isEditable: definition.editable, + }, create: { id: ulid(), - configKey: item.key, - configValue: item.value, - valueType: item.valueType, - description: item.description, + configKey: definition.key, + configValue: definition.defaultValue, + valueType: systemConfigValueType(definition), + description: definition.description, + isEditable: definition.editable, }, }); } - console.log(`[seed] system_configs 就绪:${String(SYSTEM_CONFIGS.length)} 项(幂等)`); + console.log(`[seed] system_configs 就绪:${String(SYSTEM_CONFIG_DEFINITIONS.length)} 项(幂等)`); } async function seedDepartments(): Promise { @@ -828,6 +787,117 @@ async function seedClient(): Promise { } /* eslint-enable max-lines-per-function */ +interface DictSeedType { + code: string; + name: string; + group: string; + kind: 'tree' | 'list'; + description: string; + industryScoped: boolean; + builtin: boolean; + status: 'active' | 'disabled'; + sortOrder: number; + updatedBy: string; +} + +interface DictSeedItem { + typeCode: string; + code: string; + name: string; + parentCode: string | null; + industryId: string | null; + sortOrder: number; + status: 'active' | 'disabled'; + remark: string; + updatedBy: string; +} + +const DICT_TYPES = dictSeed.types as DictSeedType[]; +const DICT_ITEMS = dictSeed.items as DictSeedItem[]; + +/** 原型示例数据用行业 code 表示作用域;种子落库时映射为 A3 行业名称 */ +const DICT_INDUSTRY_CODE_TO_NAME: Readonly> = { + ec: '电商', + ec_apparel: '服饰鞋包', + ec_beauty: '美妆个护', + fin: '金融', + fin_credit: '信贷', + local: '本地生活', + local_food: '餐饮到店', +}; + +/** A4 数据字典种子:13 类 / 96 条;行业作用域用 A3 的行业 ID 解析;幂等,不覆盖人工调整 */ +// eslint-disable-next-line max-lines-per-function -- 类型 + 条目两段种子共用行业映射,集中便于阅读 +async function seedDicts(): Promise { + const industries = await prisma.industry.findMany({ + select: { id: true, name: true, parentId: true }, + }); + const industryByName = new Map(industries.map((industry) => [industry.name, industry.id])); + const typeIdByCode = new Map(); + + for (const item of DICT_TYPES) { + const status = item.status === 'active' ? 'ACTIVE' : 'DISABLED'; + const existing = await prisma.dictType.findUnique({ + where: { code: item.code }, + select: { id: true }, + }); + if (existing !== null) { + typeIdByCode.set(item.code, existing.id); + continue; + } + const id = ulid(); + await prisma.dictType.create({ + data: { + id, + code: item.code, + name: item.name, + groupKey: item.group, + kind: item.kind === 'tree' ? 'TREE' : 'LIST', + description: item.description, + industryScoped: item.industryScoped, + builtin: item.builtin, + status, + sortOrder: item.sortOrder, + updatedBy: item.updatedBy, + }, + }); + typeIdByCode.set(item.code, id); + } + + for (const item of DICT_ITEMS) { + const typeId = typeIdByCode.get(item.typeCode); + if (typeId === undefined) continue; + const industryName = + item.industryId === null + ? null + : (DICT_INDUSTRY_CODE_TO_NAME[item.industryId] ?? item.industryId); + const industryId = industryName === null ? null : (industryByName.get(industryName) ?? null); + const existing = await prisma.dictItem.findFirst({ + where: { typeId, code: item.code, industryId }, + select: { id: true }, + }); + if (existing !== null) continue; + await prisma.dictItem.create({ + data: { + id: ulid(), + typeId, + code: item.code, + name: item.name, + parentCode: item.parentCode, + industryId, + sortOrder: item.sortOrder, + status: item.status === 'active' ? 'ACTIVE' : 'DISABLED', + remark: item.remark, + updatedBy: item.updatedBy, + }, + }); + } + + console.log( + `[seed] A4 数据字典就绪:${String(DICT_TYPES.length)} 类 / ${String(DICT_ITEMS.length)} 条(幂等)`, + ); +} + async function main(): Promise { await seedConfigs(); await seedPermissions(); @@ -836,6 +906,7 @@ async function main(): Promise { await seedUsers(); await seedDepartmentLeaders(); await seedClient(); + await seedDicts(); } void main()