feat(shared): A4 数据字典与系统配置契约

背景:A4 需要字典三层合并、配置版本与命名规则的共享契约。\n改动:新增 dict/config 契约、system-configs 定义与校验、命名模板解析;新增 dict_types/dict_items/system_config_versions 迁移与 13 类/96 条字典、26 项配置种子。\n影响面:shared 新增导出;prisma 新增表与迁移;客户基础字段 DTO 硬上界调整为 50(实际生效上限仍由配置动态校验)。\n回滚:回滚本提交并保留新增表(不删数据)。\n验证:shared typecheck/test 147;seed 幂等;prisma migrate dev 已应用。
This commit is contained in:
Stev_Wang committed 2026-11-04 11:48:38 +08:00
1 parent 32b44bdeec
commit cc6e84cdbc
15 files changed
+2899 -75

No files matched your search

@@ -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<string, SystemConfigDefinition> = 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 };
}
+3
View File
@@ -1,3 +1,4 @@
export * from './config/system-configs.js';
export * from './constants.js'; export * from './constants.js';
export * from './errors/api-error.js'; export * from './errors/api-error.js';
export * from './errors/codes.js'; export * from './errors/codes.js';
@@ -6,6 +7,8 @@ export * from './permissions.js';
export * from './schemas/api.js'; export * from './schemas/api.js';
export * from './schemas/client.js'; export * from './schemas/client.js';
export * from './schemas/common.js'; export * from './schemas/common.js';
export * from './schemas/config.js';
export * from './schemas/dict.js';
export * from './schemas/identity.js'; export * from './schemas/identity.js';
export * from './schemas/roles.js'; export * from './schemas/roles.js';
export * from './time.js'; export * from './time.js';
+65 -1
View File
@@ -1,6 +1,13 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { dedupeName, sanitizeFilename } from './naming.js'; import {
dedupeName,
formatNamingDate,
formatNamingSequence,
resolveNamingTemplate,
sanitizeFilename,
validateNamingTemplate,
} from './naming.js';
describe('sanitizeFilename', () => { describe('sanitizeFilename', () => {
it('替换路径分隔符与非法字符,保留中文', () => { it('替换路径分隔符与非法字符,保留中文', () => {
@@ -45,3 +52,60 @@ describe('dedupeName(R-01 同名自动加序号)', () => {
expect(() => dedupeName('a', existing, 3)).toThrow(RangeError); 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();
});
});
+193
View File
@@ -105,3 +105,196 @@ export function dedupeName(name: string, existing: ReadonlySet<string>, maxAttem
} }
throw new RangeError(`同名文件过多,无法为「${name}」生成序号`); 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<NamingTokenKey, string | number>;
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<string, NamingTokenKey> = 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');
}
+2 -2
View File
@@ -84,8 +84,8 @@ describe('客户契约', () => {
).toBe(false); ).toBe(false);
}); });
it('新建:基础字段 ≤ 10,接受完整偏好', () => { it('新建:基础字段受 DTO 硬上限 50 保护,实际上限由 system_configs 动态校验', () => {
const baseFields = Array.from({ length: 11 }, (_, index) => ({ const baseFields = Array.from({ length: 51 }, (_, index) => ({
key: `k${index}`, key: `k${index}`,
value: `v${index}`, value: `v${index}`,
})); }));
+9 -3
View File
@@ -37,10 +37,16 @@ export const CUSTOMER_FIELD_STATUSES = ['pending', 'active', 'rejected', 'disabl
export const customerFieldStatusSchema = z.enum(CUSTOMER_FIELD_STATUSES); export const customerFieldStatusSchema = z.enum(CUSTOMER_FIELD_STATUSES);
export type CustomerFieldStatus = z.infer<typeof customerFieldStatusSchema>; export type CustomerFieldStatus = z.infer<typeof customerFieldStatusSchema>;
/** 上限:只存系统配置前的共享常量(A4 迁入 system_configs,R-26) */ /** 默认上限:A4 起权威来源为 system_configs;常量仅作前端兜底与默认值 */
export const CUSTOMER_BASE_FIELD_LIMIT = 10; export const CUSTOMER_BASE_FIELD_LIMIT = 10;
export const CUSTOMER_GEN_FIELD_LIMIT = 5; 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, contactName: customerContactNameSchema,
contactPhone: customerContactPhoneSchema, contactPhone: customerContactPhoneSchema,
preferences: customerPreferencesSchema.optional(), 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, remark: customerRemarkSchema,
}) })
.strict(); .strict();
@@ -274,7 +280,7 @@ export const updateCustomerInputSchema = z
contactName: customerContactNameSchema, contactName: customerContactNameSchema,
contactPhone: customerContactPhoneSchema, contactPhone: customerContactPhoneSchema,
preferences: customerPreferencesSchema.optional(), 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, remark: customerRemarkSchema,
}) })
.strict(); .strict();
@@ -0,0 +1,95 @@
import { describe, expect, it } from 'vitest';
import {
SYSTEM_CONFIG_DEFINITIONS,
SYSTEM_CONFIG_GROUPS,
systemConfigDefinition,
validateSystemConfigValue,
} from '../config/system-configs.js';
import {
effectiveSystemConfigsResponseSchema,
systemConfigSchema,
updateSystemConfigsInputSchema,
} from './config.js';
describe('系统配置契约(A4-BE-02 / R-26)', () => {
it('配置键唯一、分组合法、默认值可通过校验', () => {
const keys = SYSTEM_CONFIG_DEFINITIONS.map((item) => item.key);
expect(new Set(keys).size).toBe(keys.length);
expect(keys.length).toBeGreaterThanOrEqual(20);
const groupKeys = new Set(SYSTEM_CONFIG_GROUPS.map((group) => group.key));
for (const definition of SYSTEM_CONFIG_DEFINITIONS) {
expect(groupKeys.has(definition.group)).toBe(true);
expect(validateSystemConfigValue(definition, definition.defaultValue).ok).toBe(true);
}
});
it('阈值 / 上限类配置向上限读取接口公开', () => {
expect(systemConfigDefinition('limit.customer.base_field')?.public).toBe(true);
expect(systemConfigDefinition('limit.customer.gen_field')?.public).toBe(true);
expect(systemConfigDefinition('platform.page.default_limit')?.public).toBe(true);
expect(systemConfigDefinition('naming.script.pattern')?.public).toBe(false);
});
it('数字边界与布尔类型校验', () => {
const limit = systemConfigDefinition('limit.customer.base_field');
expect(limit).toBeDefined();
if (limit === undefined) return;
expect(validateSystemConfigValue(limit, 10).ok).toBe(true);
expect(validateSystemConfigValue(limit, 1).ok).toBe(true);
expect(validateSystemConfigValue(limit, 0).ok).toBe(false);
expect(validateSystemConfigValue(limit, 51).ok).toBe(false);
expect(validateSystemConfigValue(limit, '10').ok).toBe(false);
const notify = systemConfigDefinition('notify.task.completed');
expect(notify).toBeDefined();
if (notify === undefined) return;
expect(validateSystemConfigValue(notify, true).ok).toBe(true);
expect(validateSystemConfigValue(notify, 'true').ok).toBe(false);
});
it('命名模板配置校验占位符与非法字符', () => {
const naming = systemConfigDefinition('naming.script.pattern');
expect(naming).toBeDefined();
if (naming === undefined) return;
expect(validateSystemConfigValue(naming, '{行业}-{客户}-{序号}').ok).toBe(true);
expect(validateSystemConfigValue(naming, '{行业}*{客户}').ok).toBe(false);
expect(validateSystemConfigValue(naming, '{未知}').ok).toBe(false);
});
it('系统配置响应与保存入参可解析', () => {
const config = {
key: 'limit.customer.base_field',
group: 'limits' as const,
label: '客户基础字段上限',
description: 'desc',
impact: 'impact',
type: 'number' as const,
value: 10,
valueType: 'number' as const,
unit: '个',
version: 1,
updatedAt: '2026-11-04T00:00:00.000Z',
updatedBy: '江岚',
editable: true,
isPublic: true,
min: 1,
max: 50,
step: null,
maxLength: null,
};
expect(systemConfigSchema.parse(config).key).toBe('limit.customer.base_field');
expect(
updateSystemConfigsInputSchema.parse({
changes: [
{ key: 'limit.customer.base_field', value: 12, expectedVersion: 1, reason: 'A3 对齐' },
],
}).changes,
).toHaveLength(1);
expect(
effectiveSystemConfigsResponseSchema.parse({
items: [{ key: 'limit.customer.base_field', value: 10 }],
}).items[0]?.value,
).toBe(10);
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* 系统配置共享契约(A4-BE-02)
* 依据:PRD §5.4.4 / R-26;技术架构 §5.7 / §6.3.7;开发计划 A4-BE-02。
*
* 系统配置是阈值 / 上限唯一来源;本契约的键、分组、类型与默认值来自
* `config/system-configs.ts`,业务代码不得再写阈值常量。
*/
import { z } from 'zod';
import {
SYSTEM_CONFIG_DEFINITIONS,
SYSTEM_CONFIG_GROUPS,
type SystemConfigDefinition,
systemConfigDefinition,
type SystemConfigGroupKey,
type SystemConfigInputType,
} from '../config/system-configs.js';
import { isoDateTimeSchema } from './common.js';
export const systemConfigGroupKeySchema = z.enum(
SYSTEM_CONFIG_GROUPS.map((group) => group.key) as [
SystemConfigGroupKey,
...SystemConfigGroupKey[],
],
);
export const systemConfigInputTypeSchema = z.enum(['text', 'number', 'boolean', 'pattern']);
export const systemConfigValueSchema = z.union([z.string(), z.number(), z.boolean()]);
/** 单条配置(含展示元数据与版本) */
export const systemConfigSchema = z.object({
key: z.string().min(1),
group: systemConfigGroupKeySchema,
label: z.string().min(1),
description: z.string(),
impact: z.string(),
type: systemConfigInputTypeSchema,
value: systemConfigValueSchema,
valueType: z.enum(['string', 'number', 'boolean']),
unit: z.string().nullable(),
version: z.number().int().positive(),
updatedAt: isoDateTimeSchema,
updatedBy: z.string().nullable(),
editable: z.boolean(),
isPublic: z.boolean(),
min: z.number().nullable(),
max: z.number().nullable(),
step: z.number().nullable(),
maxLength: z.number().int().nullable(),
});
export type SystemConfig = z.infer<typeof systemConfigSchema>;
export const systemConfigListResponseSchema = z.object({
items: z.array(systemConfigSchema),
});
export type SystemConfigListResponse = z.infer<typeof systemConfigListResponseSchema>;
export const systemConfigChangeInputSchema = z
.object({
key: z.string().min(1),
value: z.unknown(),
/** 乐观锁:前端读取时的版本;不一致返回 40901 */
expectedVersion: z.number().int().positive().optional(),
reason: z.string().trim().max(255).optional(),
})
.strict();
export type SystemConfigChangeInput = z.infer<typeof systemConfigChangeInputSchema>;
export const updateSystemConfigsInputSchema = z
.object({
changes: z.array(systemConfigChangeInputSchema).min(1).max(50),
})
.strict();
export type UpdateSystemConfigsInput = z.infer<typeof updateSystemConfigsInputSchema>;
export const systemConfigChangeSchema = z.object({
key: z.string().min(1),
label: z.string().min(1),
version: z.number().int().positive(),
oldValue: z.unknown().nullable(),
newValue: z.unknown(),
reason: z.string().nullable(),
operator: z.string().nullable(),
changedAt: isoDateTimeSchema,
result: z.string(),
});
export type SystemConfigChange = z.infer<typeof systemConfigChangeSchema>;
export const systemConfigHistoryQuerySchema = z.object({
config_key: z.string().min(1).optional(),
limit: z.coerce.number().int().min(1).max(200).default(50),
});
export type SystemConfigHistoryQuery = z.infer<typeof systemConfigHistoryQuerySchema>;
export const systemConfigHistoryResponseSchema = z.object({
items: z.array(systemConfigChangeSchema),
next_cursor: z.string().nullable(),
});
export type SystemConfigHistoryResponse = z.infer<typeof systemConfigHistoryResponseSchema>;
export const effectiveSystemConfigSchema = z.object({
key: z.string().min(1),
value: systemConfigValueSchema,
});
export type EffectiveSystemConfig = z.infer<typeof effectiveSystemConfigSchema>;
export const effectiveSystemConfigsResponseSchema = z.object({
items: z.array(effectiveSystemConfigSchema),
});
export type EffectiveSystemConfigsResponse = z.infer<typeof effectiveSystemConfigsResponseSchema>;
/** 供后端 / 前端按 key 查找定义 */
export function systemConfigMetaOf(key: string): SystemConfigDefinition | undefined {
return systemConfigDefinition(key);
}
export type { SystemConfigDefinition, SystemConfigGroupKey, SystemConfigInputType };
/** 配置定义清单(前端设置页分组、种子脚本共用) */
export const SYSTEM_CONFIG_META = SYSTEM_CONFIG_DEFINITIONS;
+81
View File
@@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import {
createDictItemInputSchema,
createDictTypeInputSchema,
dictItemSnapshotSchema,
toDictItemSnapshot,
updateDictItemInputSchema,
} from './dict.js';
describe('数据字典契约(A4-BE-01 / A4-BE-04)', () => {
it('类型编码只允许小写 snake_case,名称必填', () => {
expect(
createDictTypeInputSchema.parse({
code: 'script_direction',
name: '脚本方向',
group: 'content',
}),
).toMatchObject({ code: 'script_direction', kind: 'list', industryScoped: false });
expect(() =>
createDictTypeInputSchema.parse({
code: 'ScriptDirection',
name: '脚本方向',
group: 'content',
}),
).toThrow();
expect(() =>
createDictTypeInputSchema.parse({ code: 'script_direction', name: '', group: 'content' }),
).toThrow();
});
it('条目编码允许数字开头(如 15s)与短横线', () => {
expect(
createDictItemInputSchema.parse({
code: '15s',
name: '15 秒',
}),
).toMatchObject({ code: '15s', status: 'active' });
expect(
createDictItemInputSchema.parse({
code: 'light-style',
name: '光影',
parentCode: null,
industryId: null,
}),
).toMatchObject({ code: 'light-style' });
});
it('编辑条目只接收名称 / 排序 / 启停 / 备注,不接收编码与行业', () => {
expect(
updateDictItemInputSchema.parse({ name: '口播', sortOrder: 10, status: 'disabled' }),
).toEqual({ name: '口播', sortOrder: 10, status: 'disabled' });
expect(() => updateDictItemInputSchema.parse({ code: 'new_code' })).toThrow();
expect(() => updateDictItemInputSchema.parse({ industryId: null })).toThrow();
});
it('快照保留填写时名称,之后改字典不影响历史(R-10)', () => {
const item = {
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
typeCode: 'script_direction',
code: 'oral',
name: '口播',
parentCode: null,
parentName: null,
industryId: null,
industryPath: null,
sortOrder: 10,
status: 'active' as const,
remark: '',
source: 'generic' as const,
overridden: false,
refCount: 0,
updatedAt: '2026-11-04T00:00:00.000Z',
updatedBy: '江岚',
};
const snapshot = toDictItemSnapshot(item, '2026-11-04T10:00:00.000Z');
item.name = '口播(改名后)';
expect(snapshot.name).toBe('口播');
expect(dictItemSnapshotSchema.parse(snapshot)).toEqual(snapshot);
});
});
+258
View File
@@ -0,0 +1,258 @@
/**
* 数据字典共享契约(A4)
* 依据:PRD §5.4.5、R-10;技术架构 §5.3 / §6.3.7;开发计划 A4-BE-01 / A4-BE-04。
*
* 约定:
* - 一张通用枚举表同时表达二级树(parent_code)与词表;
* - 类型编码 / 条目编码创建后不可修改;
* - 条目按「通用 → 父行业 → 本行业」三层合并,同 code 以更具体层覆盖;
* - 历史数据保存填写时快照(toDictItemSnapshot),字典改名不影响历史。
*/
import { z } from 'zod';
import { cursorPaginationQuerySchema } from './api.js';
import { isoDateTimeSchema, ulidSchema } from './common.js';
// ---------------------------------------------------------------------------
// 枚举 / 分组
// ---------------------------------------------------------------------------
export const DICT_GROUP_KEYS = ['content', 'platform', 'quality', 'audience'] as const;
export const dictGroupKeySchema = z.enum(DICT_GROUP_KEYS);
export type DictGroupKey = z.infer<typeof dictGroupKeySchema>;
export const DICT_GROUPS: readonly {
key: DictGroupKey;
name: string;
description: string;
}[] = [
{ key: 'content', name: '内容生产', description: '脚本、素材与投放的结构化选项' },
{ key: 'platform', name: '平台与规格', description: '投放平台、尺寸比例与光影风格' },
{ key: 'quality', name: '质量与合规', description: '失败原因、淘汰理由与合规词表' },
{ key: 'audience', name: '人群与投放', description: '人群标签与素材形态' },
];
export const DICT_TYPE_KINDS = ['tree', 'list'] as const;
export const dictTypeKindSchema = z.enum(DICT_TYPE_KINDS);
export type DictTypeKind = z.infer<typeof dictTypeKindSchema>;
export const DICT_STATUSES = ['active', 'disabled'] as const;
export const dictStatusSchema = z.enum(DICT_STATUSES);
export type DictStatus = z.infer<typeof dictStatusSchema>;
/** 生效条目的来源层(不是存储字段;由三层合并计算) */
export const DICT_LAYER_SOURCES = ['generic', 'parent', 'current'] as const;
export const dictLayerSourceSchema = z.enum(DICT_LAYER_SOURCES);
export type DictLayerSource = z.infer<typeof dictLayerSourceSchema>;
export const DICT_ITEM_SOURCES = DICT_LAYER_SOURCES;
export const dictItemSourceSchema = dictLayerSourceSchema;
export type DictItemSource = z.infer<typeof dictItemSourceSchema>;
// ---------------------------------------------------------------------------
// 编码 / 名称
// ---------------------------------------------------------------------------
/** 类型编码:小写 snake_case,创建后不可改 */
export const dictTypeCodeSchema = z
.string()
.trim()
.regex(/^[a-z][a-z0-9_]{1,48}$/, '类型编码只能使用小写字母、数字与下划线,且以字母开头');
/** 条目编码:字母 / 数字 / 下划线 / 短横线,创建后不可改 */
export const dictItemCodeSchema = z
.string()
.trim()
.regex(
/^[A-Za-z0-9][A-Za-z0-9_-]{1,48}$/,
'条目编码只能使用字母、数字、下划线或短横线,长度 2–49',
);
export const dictNameSchema = z
.string()
.trim()
.min(1, '名称不能为空')
.max(64, '名称最多 64 个字符');
export const dictDescriptionSchema = z.string().trim().max(255, '说明最多 255 个字符');
export const dictItemRemarkSchema = z.string().trim().max(255, '备注最多 255 个字符');
export const dictSortOrderSchema = z.coerce.number().int().min(0).max(9999);
// ---------------------------------------------------------------------------
// 响应资源
// ---------------------------------------------------------------------------
export const dictTypeSummarySchema = z.object({
code: z.string().min(1),
name: z.string().min(1),
group: dictGroupKeySchema,
kind: dictTypeKindSchema,
description: z.string(),
industryScoped: z.boolean(),
builtin: z.boolean(),
status: dictStatusSchema,
sortOrder: z.number().int(),
itemCount: z.number().int().nonnegative(),
activeItemCount: z.number().int().nonnegative(),
industryCount: z.number().int().nonnegative(),
updatedAt: isoDateTimeSchema,
updatedBy: z.string().nullable(),
});
export type DictTypeSummary = z.infer<typeof dictTypeSummarySchema>;
export const dictTypeDetailSchema = dictTypeSummarySchema.extend({
/** 类型下所有条目(不合并),用于编辑 / 诊断;列表与生效项走 items 接口 */
items: z.lazy(() => z.array(dictItemSchema)).optional(),
});
export type DictTypeDetail = z.infer<typeof dictTypeDetailSchema>;
export const dictItemSchema = z.object({
id: ulidSchema,
typeCode: z.string().min(1),
code: z.string().min(1),
name: z.string().min(1),
parentCode: z.string().nullable(),
parentName: z.string().nullable(),
industryId: ulidSchema.nullable(),
industryPath: z.string().nullable(),
sortOrder: z.number().int(),
status: dictStatusSchema,
remark: z.string(),
source: dictItemSourceSchema,
overridden: z.boolean(),
refCount: z.number().int().nonnegative(),
updatedAt: isoDateTimeSchema,
updatedBy: z.string().nullable(),
});
export type DictItem = z.infer<typeof dictItemSchema>;
export const dictLayerSchema = z.object({
source: dictLayerSourceSchema,
industryId: ulidSchema.nullable(),
label: z.string().min(1),
count: z.number().int().nonnegative(),
});
export type DictLayer = z.infer<typeof dictLayerSchema>;
export const dictItemListResponseSchema = z.object({
items: z.array(dictItemSchema),
next_cursor: z.string().nullable(),
layers: z.array(dictLayerSchema),
effectiveCount: z.number().int().nonnegative(),
overriddenCount: z.number().int().nonnegative(),
disabledCount: z.number().int().nonnegative(),
});
export type DictItemListResponse = z.infer<typeof dictItemListResponseSchema>;
// ---------------------------------------------------------------------------
// 查询 / 写接口
// ---------------------------------------------------------------------------
export const dictTypeListQuerySchema = cursorPaginationQuerySchema.extend({
keyword: z.string().trim().max(64).optional(),
group: dictGroupKeySchema.optional(),
status: z.enum(['all', 'active', 'disabled']).default('all'),
});
export type DictTypeListQuery = z.infer<typeof dictTypeListQuerySchema>;
export const dictItemListQuerySchema = z.object({
/** 不传 = 全平台(仅通用层);传一级行业 = 通用 + 该行业;传二级 = 通用 + 父行业 + 该行业 */
industry_id: ulidSchema.optional(),
});
export type DictItemListQuery = z.infer<typeof dictItemListQuerySchema>;
export const createDictTypeInputSchema = z
.object({
code: dictTypeCodeSchema,
name: dictNameSchema,
group: dictGroupKeySchema,
kind: dictTypeKindSchema.default('list'),
description: dictDescriptionSchema.default(''),
industryScoped: z.boolean().default(false),
sortOrder: dictSortOrderSchema.default(10),
})
.strict();
export type CreateDictTypeInput = z.infer<typeof createDictTypeInputSchema>;
/** 类型编码 / 形态 / 行业作用域创建后不可改;名称、说明、分组、排序可改 */
export const updateDictTypeInputSchema = z
.object({
name: dictNameSchema.optional(),
group: dictGroupKeySchema.optional(),
description: dictDescriptionSchema.optional(),
sortOrder: dictSortOrderSchema.optional(),
})
.strict();
export type UpdateDictTypeInput = z.infer<typeof updateDictTypeInputSchema>;
export const setDictTypeStatusInputSchema = z
.object({
status: dictStatusSchema,
})
.strict();
export type SetDictTypeStatusInput = z.infer<typeof setDictTypeStatusInputSchema>;
export const createDictItemInputSchema = z
.object({
code: dictItemCodeSchema,
name: dictNameSchema,
parentCode: z.string().trim().max(64).nullable().optional(),
industryId: ulidSchema.nullable().optional(),
sortOrder: dictSortOrderSchema.default(10),
status: dictStatusSchema.default('active'),
remark: dictItemRemarkSchema.default(''),
})
.strict();
export type CreateDictItemInput = z.infer<typeof createDictItemInputSchema>;
/** 编辑条目:编码、父级、所属行业创建后不可改;名称、排序、启停、备注可改 */
export const updateDictItemInputSchema = z
.object({
name: dictNameSchema.optional(),
sortOrder: dictSortOrderSchema.optional(),
status: dictStatusSchema.optional(),
remark: dictItemRemarkSchema.optional(),
})
.strict();
export type UpdateDictItemInput = z.infer<typeof updateDictItemInputSchema>;
// ---------------------------------------------------------------------------
// 快照(R-10):业务记录引用字典时保存填写时名称 / 编码 / 作用域
// ---------------------------------------------------------------------------
export const dictItemSnapshotSchema = z.object({
typeCode: z.string().min(1),
code: z.string().min(1),
name: z.string().min(1),
parentCode: z.string().nullable(),
industryId: ulidSchema.nullable(),
capturedAt: isoDateTimeSchema,
});
export type DictItemSnapshot = z.infer<typeof dictItemSnapshotSchema>;
/** 生成填写时快照;A4 只提供契约与工具,真正落库由 B0 / M1 业务记录执行 */
export function toDictItemSnapshot(
item: Pick<DictItem, 'typeCode' | 'code' | 'name' | 'parentCode' | 'industryId'>,
capturedAt: string,
): DictItemSnapshot {
return {
typeCode: item.typeCode,
code: item.code,
name: item.name,
parentCode: item.parentCode,
industryId: item.industryId,
capturedAt,
};
}
+10 -9
View File
@@ -6,15 +6,16 @@ A0 只 seed「已建表」的 `system_configs`;A1 补齐组织、账号与角
## 已 seed(`prisma/seed.ts`) ## 已 seed(`prisma/seed.ts`)
| 数据 | 来源 | 幂等键 | 说明 | | 数据 | 来源 | 幂等键 | 说明 |
| ------------------------------ | ---------------------------- | ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------ | ---------------------------- | ------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 平台系统配置 7 项 | `system_configs` | `config_key` | 平台名、导出链接有效期、上传上限、分页大小、AI 预算比例等,A0 起延续 | | 平台系统配置 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` | | 部门 / 小组 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 同步一次 | | 权限点目录 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 项;新建角色仍默认全空、特殊权限单独授予 | | 角色 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` 以固定范围类型,不降级为最小权限) | | 角色数据范围 | `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 原型口径一致(陈默、林清、周岸、赵一诺、高岑、苏禾、沈知远、江岚、唐嘉、何岸);唐嘉 / 何岸为停用态,用于「停用即时失效」联调 | | 用户 11 个(超管 1 + 示例 10) | `users` / `user_roles` | `account`;用户角色先清后建 | 超管 `admin`,示例用户与 A1 原型口径一致(陈默、林清、周岸、赵一诺、高岑、苏禾、沈知远、江岚、唐嘉、何岸);唐嘉 / 何岸为停用态,用于「停用即时失效」联调 |
| 用户初始密码 | `users.password_hash` | — | 统一 `lingxi2026`(Argon2id),`must_change_password=true`:首登强制改密 | | 用户初始密码 | `users.password_hash` | — | 统一 `lingxi2026`(Argon2id),`must_change_password=true`:首登强制改密 |
| A4 数据字典 13 类 / 96 条 | `dict_types` / `dict_items` | 类型 `code`;条目 `(type, code, industry)` | 13 类:平台与规格、光影与风格、时长档、钩子类型、景别、脚本方向、情绪与风格、素材形态、警示语类型、禁用词库、失败原因、淘汰理由、人群标签;4 分组;行业作用域使用 A3 的「电商 / 服饰鞋包 / 金融 / 信贷」等行业 ID;内置类型不可删除,条目编码不可改 |
## 登录账号(本地联调) ## 登录账号(本地联调)
@@ -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");
+81
View File
@@ -226,9 +226,29 @@ model SystemConfig {
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3) updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz(3)
updatedBy String? @map("updated_by") @db.Char(26) updatedBy String? @map("updated_by") @db.Char(26)
versions SystemConfigVersion[]
@@map("system_configs") @@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 两级行业) // A3 客户与行业(client 模块;ADR-002 两级行业)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -254,6 +274,7 @@ model Industry {
parent Industry? @relation("IndustryTree", fields: [parentId], references: [id], onDelete: Restrict) parent Industry? @relation("IndustryTree", fields: [parentId], references: [id], onDelete: Restrict)
children Industry[] @relation("IndustryTree") children Industry[] @relation("IndustryTree")
customers Customer[] customers Customer[]
dictItems DictItem[]
@@index([parentId, sortOrder]) @@index([parentId, sortOrder])
@@index([level, status]) @@index([level, status])
@@ -361,3 +382,63 @@ model CustomerField {
@@index([customerId, status]) @@index([customerId, status])
@@map("customer_fields") @@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")
}
File diff suppressed because it is too large. Load diff
+131 -60
View File
@@ -14,12 +14,17 @@ import { PrismaClient, type RoleStatus, type UserStatus } from '@prisma/client';
import { config as loadDotenv } from 'dotenv'; import { config as loadDotenv } from 'dotenv';
// 直接引用 shared 源码目录(seed 由 tsx 执行;shared 是权限点唯一事实源,避免两处编码漂移) // 直接引用 shared 源码目录(seed 由 tsx 执行;shared 是权限点唯一事实源,避免两处编码漂移)
import {
SYSTEM_CONFIG_DEFINITIONS,
systemConfigValueType,
} from '../packages/shared/src/config/system-configs.js';
import { import {
findSodConflicts, findSodConflicts,
Permission, Permission,
PERMISSION_CATALOG, PERMISSION_CATALOG,
splitPermissions, splitPermissions,
} from '../packages/shared/src/permissions.js'; } from '../packages/shared/src/permissions.js';
import dictSeed from './seed-data/dicts.json' with { type: 'json' };
for (const file of ['.env.local', '.env']) { for (const file of ['.env.local', '.env']) {
const path = resolve(process.cwd(), file); const path = resolve(process.cwd(), file);
@@ -48,58 +53,6 @@ function ulid(): string {
const SEED_PASSWORD_HASH = const SEED_PASSWORD_HASH =
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4'; '$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 { interface SeedDepartment {
code: string; code: string;
name: string; name: string;
@@ -429,20 +382,26 @@ const USERS: SeedUser[] = [
]; ];
async function seedConfigs(): Promise<void> { async function seedConfigs(): Promise<void> {
for (const item of SYSTEM_CONFIGS) { for (const definition of SYSTEM_CONFIG_DEFINITIONS) {
await prisma.systemConfig.upsert({ await prisma.systemConfig.upsert({
where: { configKey: item.key }, where: { configKey: definition.key },
update: {}, // 只同步元数据,不覆盖人工调整过的值 / 版本
update: {
description: definition.description,
valueType: systemConfigValueType(definition),
isEditable: definition.editable,
},
create: { create: {
id: ulid(), id: ulid(),
configKey: item.key, configKey: definition.key,
configValue: item.value, configValue: definition.defaultValue,
valueType: item.valueType, valueType: systemConfigValueType(definition),
description: item.description, 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<void> { async function seedDepartments(): Promise<void> {
@@ -828,6 +787,117 @@ async function seedClient(): Promise<void> {
} }
/* eslint-enable max-lines-per-function */ /* 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<Record<string, string>> = {
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<void> {
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<string, string>();
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<void> { async function main(): Promise<void> {
await seedConfigs(); await seedConfigs();
await seedPermissions(); await seedPermissions();
@@ -836,6 +906,7 @@ async function main(): Promise<void> {
await seedUsers(); await seedUsers();
await seedDepartmentLeaders(); await seedDepartmentLeaders();
await seedClient(); await seedClient();
await seedDicts();
} }
void main() void main()