feat(api): A4 数据字典与系统配置后端

背景:A4 需要字典三层合并、配置单一来源与阈值同源。\n改动:新增 catalog 模块(类型/条目/三层合并/编码不可改/快照工具);platform 新增 SystemConfigService/Controller、NamingService 与配置版本历史;roles/users/customers/customer-fields 改为读 system_configs 上限。\n影响面:新增 13 个 REST 端点与 4 个模块注入;system_configs 写入走事务/乐观锁/版本历史;OpenAPI 快照更新。\n回滚:回滚本提交并恢复 shared 契约;新增表可保留。\n验证:api typecheck/build;单测 137;集成 58;OpenAPI diff 无破坏性变更。
This commit is contained in:
Stev_Wang committed 2026-11-04 11:49:05 +08:00
1 parent cc6e84cdbc
commit e4330a43e4
25 files changed
+4189 -30

No files matched your search

@@ -0,0 +1,133 @@
import { dictItemSnapshotSchema } from '@lingxi/shared';
import { describe, expect, it } from 'vitest';
import {
mapDictGroup,
mapDictKind,
mapDictStatus,
toDictItem,
toDictSnapshot,
toDictTypeSummary,
} from './dict.mapper';
describe('dict.mapper · Prisma 行 -> 共享 DTO', () => {
it('类型统计与枚举转换:group / kind / status 映射为契约小写值', () => {
const summary = toDictTypeSummary(
{
code: 'script_direction',
name: '脚本方向',
groupKey: 'content',
kind: 'LIST',
description: '脚本与素材的结构化方向',
industryScoped: true,
builtin: true,
status: 'ACTIVE',
sortOrder: 60,
updatedAt: new Date('2026-11-04T02:00:00.000Z'),
updatedBy: '江岚',
},
{ itemCount: 11, activeItemCount: 10, industryCount: 2 },
);
expect(summary).toEqual({
code: 'script_direction',
name: '脚本方向',
group: 'content',
kind: 'list',
description: '脚本与素材的结构化方向',
industryScoped: true,
builtin: true,
status: 'active',
sortOrder: 60,
itemCount: 11,
activeItemCount: 10,
industryCount: 2,
updatedAt: '2026-11-04T02:00:00.000Z',
updatedBy: '江岚',
});
});
it('库内异常分组回退 content;kind / status 只映射两个分支', () => {
expect(mapDictGroup('unknown_group')).toBe('content');
expect(mapDictGroup('quality')).toBe('quality');
expect(mapDictKind('TREE')).toBe('tree');
expect(mapDictKind('LIST')).toBe('list');
expect(mapDictStatus('ACTIVE')).toBe('active');
expect(mapDictStatus('DISABLED')).toBe('disabled');
});
it('条目映射:source / overridden / parentName / industryPath / refCount=0', () => {
const item = toDictItem(
{
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
code: 'seeding',
name: '种草短视频',
parentCode: null,
industryId: 'ec',
sortOrder: 40,
status: 'ACTIVE',
remark: '父行业覆盖通用层',
updatedAt: new Date('2026-11-04T02:00:00.000Z'),
updatedBy: '江岚',
},
{
typeCode: 'script_direction',
source: 'parent',
overridden: true,
parentName: null,
industryPath: '电商',
},
);
expect(item).toMatchObject({
typeCode: 'script_direction',
code: 'seeding',
name: '种草短视频',
parentName: null,
industryId: 'ec',
industryPath: '电商',
status: 'active',
source: 'parent',
overridden: true,
refCount: 0,
updatedAt: '2026-11-04T02:00:00.000Z',
updatedBy: '江岚',
});
});
it('快照 R-10:先取快照再改字典名称,快照保留填写时名称', () => {
const item = toDictItem(
{
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
code: 'oral',
name: '口播',
parentCode: null,
industryId: null,
sortOrder: 10,
status: 'ACTIVE',
remark: '',
updatedAt: new Date('2026-11-04T02:00:00.000Z'),
updatedBy: '江岚',
},
{
typeCode: 'script_direction',
source: 'generic',
overridden: false,
parentName: null,
industryPath: null,
},
);
const snapshot = toDictSnapshot(item, '2026-11-04T10:00:00.000Z');
// 模拟字典改名 / 停用(历史引用方不感知)
const renamed = { ...item, name: '口播(改名后)', status: 'disabled' as const };
expect(renamed.name).not.toBe(snapshot.name);
expect(snapshot.name).toBe('口播');
expect(snapshot).toEqual({
typeCode: 'script_direction',
code: 'oral',
name: '口播',
parentCode: null,
industryId: null,
capturedAt: '2026-11-04T10:00:00.000Z',
});
expect(dictItemSnapshotSchema.parse(snapshot)).toEqual(snapshot);
});
});