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:
1 parent
cc6e84cdbc
commit
e4330a43e4
25 files changed
+4189
-30
No files matched your search
@@ -7,6 +7,7 @@ import { APP_CONFIG, AppConfigModule } from './config/config.module';
|
|||||||
import type { AppConfig } from './config/env';
|
import type { AppConfig } from './config/env';
|
||||||
import { PrismaModule } from './infra/prisma/prisma.module';
|
import { PrismaModule } from './infra/prisma/prisma.module';
|
||||||
import { RedisModule } from './infra/redis/redis.module';
|
import { RedisModule } from './infra/redis/redis.module';
|
||||||
|
import { CatalogModule } from './modules/catalog/catalog.module';
|
||||||
import { ClientModule } from './modules/client/client.module';
|
import { ClientModule } from './modules/client/client.module';
|
||||||
import { IdentityModule } from './modules/identity/identity.module';
|
import { IdentityModule } from './modules/identity/identity.module';
|
||||||
import { PlatformModule } from './modules/platform/platform.module';
|
import { PlatformModule } from './modules/platform/platform.module';
|
||||||
@@ -51,6 +52,7 @@ import { PlatformModule } from './modules/platform/platform.module';
|
|||||||
SecurityModule,
|
SecurityModule,
|
||||||
IdentityModule,
|
IdentityModule,
|
||||||
ClientModule,
|
ClientModule,
|
||||||
|
CatalogModule,
|
||||||
PlatformModule,
|
PlatformModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,515 @@
|
|||||||
|
import { API_PREFIX, ErrorCode, Permission, ulid } from '@lingxi/shared';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createApp, type CreatedApiApp } from '../../bootstrap';
|
||||||
|
import { loadDotenv } from '../../config/load-dotenv';
|
||||||
|
|
||||||
|
const PASSWORD_HASH =
|
||||||
|
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||||||
|
const PASSWORD = 'lingxi2026';
|
||||||
|
const PREFIX = 'itqa4.';
|
||||||
|
const TYPE_PREFIX = 'itqa4_';
|
||||||
|
const INDUSTRY_PREFIX = 'ITQA4-';
|
||||||
|
const API = API_PREFIX;
|
||||||
|
|
||||||
|
loadDotenv();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
interface TokenBody {
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonResult<T> {
|
||||||
|
status: number;
|
||||||
|
body: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DictTypeSummaryBody {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
group: string;
|
||||||
|
kind: string;
|
||||||
|
status: string;
|
||||||
|
builtin: boolean;
|
||||||
|
industryScoped: boolean;
|
||||||
|
itemCount: number;
|
||||||
|
activeItemCount: number;
|
||||||
|
industryCount: number;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DictItemBody {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
source: string;
|
||||||
|
overridden: boolean;
|
||||||
|
status: string;
|
||||||
|
parentName: string | null;
|
||||||
|
industryPath: string | null;
|
||||||
|
refCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DictItemListBody {
|
||||||
|
items: DictItemBody[];
|
||||||
|
layers: { source: string; industryId: string | null; label: string; count: number }[];
|
||||||
|
effectiveCount: number;
|
||||||
|
overriddenCount: number;
|
||||||
|
disabledCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
let phoneCounter = 0;
|
||||||
|
let app: CreatedApiApp['app'];
|
||||||
|
let adminToken = '';
|
||||||
|
let noPermissionToken = '';
|
||||||
|
let rootIndustryId = '';
|
||||||
|
let leafIndustryId = '';
|
||||||
|
const industryTypeCode = `${TYPE_PREFIX}industry_scoped`;
|
||||||
|
const emptyTypeCode = `${TYPE_PREFIX}empty_tmp`;
|
||||||
|
const createdRoleIds: string[] = [];
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
|
|
||||||
|
function nextPhone(): string {
|
||||||
|
phoneCounter += 1;
|
||||||
|
return `135${String(phoneCounter).padStart(8, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRole(suffix: string, permissions: string[]): Promise<string> {
|
||||||
|
const id = ulid();
|
||||||
|
await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
code: `${PREFIX}role_${suffix}`,
|
||||||
|
name: `${PREFIX}角色-${suffix}`,
|
||||||
|
description: 'A4 集成测试角色',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) },
|
||||||
|
dataScopes: { create: [{ id: ulid(), scopeType: 'all', targetId: null }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdRoleIds.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(suffix: string, roleId: string): Promise<string> {
|
||||||
|
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
|
||||||
|
const account = `${PREFIX}${suffix}`;
|
||||||
|
const id = ulid();
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
name: `A4 集成测试${suffix}`,
|
||||||
|
account,
|
||||||
|
email: `${account}@lingxi.cn`,
|
||||||
|
phone: nextPhone(),
|
||||||
|
passwordHash: PASSWORD_HASH,
|
||||||
|
departmentId: department.id,
|
||||||
|
mustChangePassword: false,
|
||||||
|
roles: { create: [{ roleId }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdUserIds.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(account: string): Promise<string> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${API}/auth/login`,
|
||||||
|
payload: { account, password: PASSWORD },
|
||||||
|
});
|
||||||
|
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
||||||
|
throw new Error(`login failed ${response.statusCode}: ${response.body}`);
|
||||||
|
}
|
||||||
|
return response.json<TokenBody>().accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call<T>(
|
||||||
|
method: 'GET' | 'POST' | 'PATCH' | 'DELETE',
|
||||||
|
url: string,
|
||||||
|
token: string,
|
||||||
|
payload?: unknown,
|
||||||
|
): Promise<JsonResult<T>> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method,
|
||||||
|
url: `${API}${url}`,
|
||||||
|
headers: { authorization: `Bearer ${token}` },
|
||||||
|
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||||||
|
});
|
||||||
|
const body = response.body.length === 0 ? (null as T) : response.json<T>();
|
||||||
|
return { status: response.statusCode, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createType(
|
||||||
|
code: string,
|
||||||
|
body: Record<string, unknown>,
|
||||||
|
): Promise<DictTypeSummaryBody> {
|
||||||
|
const created = await call<DictTypeSummaryBody>('POST', '/dicts', adminToken, {
|
||||||
|
code,
|
||||||
|
...body,
|
||||||
|
});
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
return created.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createItem(typeCode: string, body: Record<string, unknown>): Promise<DictItemBody> {
|
||||||
|
const created = await call<DictItemBody>('POST', `/dicts/${typeCode}/items`, adminToken, body);
|
||||||
|
expect(created.status).toBe(201);
|
||||||
|
return created.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupFixtures(): Promise<void> {
|
||||||
|
await prisma.dictType.deleteMany({ where: { code: { startsWith: TYPE_PREFIX } } });
|
||||||
|
await prisma.industry.deleteMany({ where: { name: { startsWith: INDUSTRY_PREFIX } } });
|
||||||
|
await prisma.user.deleteMany({ where: { account: { startsWith: PREFIX } } });
|
||||||
|
if (createdRoleIds.length > 0) {
|
||||||
|
await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanupFixtures();
|
||||||
|
const adminRole = await createRole('admin', [Permission.DICT_MANAGE]);
|
||||||
|
await createUser('admin', adminRole);
|
||||||
|
const readRole = await createRole('reader', [Permission.CUSTOMER_READ]);
|
||||||
|
await createUser('reader', readRole);
|
||||||
|
|
||||||
|
const root = await prisma.industry.create({
|
||||||
|
data: { id: ulid(), parentId: null, level: 1, name: `${INDUSTRY_PREFIX}电商`, sortOrder: 10 },
|
||||||
|
});
|
||||||
|
rootIndustryId = root.id;
|
||||||
|
const leaf = await prisma.industry.create({
|
||||||
|
data: {
|
||||||
|
id: ulid(),
|
||||||
|
parentId: rootIndustryId,
|
||||||
|
level: 2,
|
||||||
|
name: `${INDUSTRY_PREFIX}服饰鞋包`,
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
leafIndustryId = leaf.id;
|
||||||
|
|
||||||
|
const created = await createApp();
|
||||||
|
app = created.app;
|
||||||
|
await app.init();
|
||||||
|
adminToken = await login(`${PREFIX}admin`);
|
||||||
|
noPermissionToken = await login(`${PREFIX}reader`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await cleanupFixtures();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值
|
||||||
|
if (app) await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4 字典 · 类型列表 / 详情 / 写操作', () => {
|
||||||
|
it('类型列表:分组顺序 + 统计;keyword / group 过滤;无权限 403', async () => {
|
||||||
|
const list = await call<{ items: DictTypeSummaryBody[]; next_cursor: string | null }>(
|
||||||
|
'GET',
|
||||||
|
'/dicts?limit=100',
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(list.status).toBe(200);
|
||||||
|
expect(list.body.items.length).toBeGreaterThanOrEqual(13);
|
||||||
|
const groupOrder = ['content', 'platform', 'quality', 'audience'];
|
||||||
|
const ranks = list.body.items.map((item) => groupOrder.indexOf(item.group));
|
||||||
|
expect(ranks).toEqual([...ranks].sort((left, right) => left - right));
|
||||||
|
const platformSpec = list.body.items.find((item) => item.code === 'platform_spec');
|
||||||
|
expect(platformSpec).toMatchObject({ builtin: true, industryScoped: true });
|
||||||
|
expect(platformSpec?.itemCount).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const filtered = await call<{ items: DictTypeSummaryBody[] }>(
|
||||||
|
'GET',
|
||||||
|
'/dicts?group=quality&status=active&limit=100',
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(filtered.status).toBe(200);
|
||||||
|
expect(filtered.body.items.every((item) => item.group === 'quality')).toBe(true);
|
||||||
|
|
||||||
|
const forbidden = await call<{ code: number }>('GET', '/dicts', noPermissionToken);
|
||||||
|
expect(forbidden.status).toBe(403);
|
||||||
|
expect(forbidden.body.code).toBe(ErrorCode.PERMISSION_DENIED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('新建类型 / 详情 / 编码重复 40902 / 不存在 40401', async () => {
|
||||||
|
const created = await createType(industryTypeCode, {
|
||||||
|
name: 'ITQA4 行业脚本方向',
|
||||||
|
group: 'content',
|
||||||
|
kind: 'tree',
|
||||||
|
description: 'A4 集成测试三层合并',
|
||||||
|
industryScoped: true,
|
||||||
|
sortOrder: 95,
|
||||||
|
});
|
||||||
|
expect(created).toMatchObject({
|
||||||
|
code: industryTypeCode,
|
||||||
|
builtin: false,
|
||||||
|
industryScoped: true,
|
||||||
|
kind: 'tree',
|
||||||
|
});
|
||||||
|
|
||||||
|
const detail = await call<DictTypeSummaryBody>('GET', `/dicts/${industryTypeCode}`, adminToken);
|
||||||
|
expect(detail.status).toBe(200);
|
||||||
|
expect(detail.body.code).toBe(industryTypeCode);
|
||||||
|
|
||||||
|
const duplicate = await call<{ code: number }>('POST', '/dicts', adminToken, {
|
||||||
|
code: industryTypeCode,
|
||||||
|
name: '重复编码',
|
||||||
|
group: 'content',
|
||||||
|
});
|
||||||
|
expect(duplicate.status).toBe(409);
|
||||||
|
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
|
||||||
|
|
||||||
|
const missing = await call<{ code: number }>('GET', '/dicts/itqa4_missing_type', adminToken);
|
||||||
|
expect(missing.status).toBe(404);
|
||||||
|
expect(missing.body.code).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('更新类型(编码 / 形态 / 行业作用域不可改)与启停', async () => {
|
||||||
|
const updated = await call<DictTypeSummaryBody>(
|
||||||
|
'PATCH',
|
||||||
|
`/dicts/${industryTypeCode}`,
|
||||||
|
adminToken,
|
||||||
|
{ name: 'ITQA4 行业脚本方向(改)', sortOrder: 96 },
|
||||||
|
);
|
||||||
|
expect(updated.status).toBe(200);
|
||||||
|
expect(updated.body).toMatchObject({ name: 'ITQA4 行业脚本方向(改)', sortOrder: 96 });
|
||||||
|
|
||||||
|
const immutable = await call<{ code: number }>(
|
||||||
|
'PATCH',
|
||||||
|
`/dicts/${industryTypeCode}`,
|
||||||
|
adminToken,
|
||||||
|
{ code: 'itqa4_changed_code' },
|
||||||
|
);
|
||||||
|
expect(immutable.status).toBe(400);
|
||||||
|
expect(immutable.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const disabled = await call<DictTypeSummaryBody>(
|
||||||
|
'PATCH',
|
||||||
|
`/dicts/${industryTypeCode}/status`,
|
||||||
|
adminToken,
|
||||||
|
{ status: 'disabled' },
|
||||||
|
);
|
||||||
|
expect(disabled.status).toBe(200);
|
||||||
|
expect(disabled.body.status).toBe('disabled');
|
||||||
|
const enabled = await call<DictTypeSummaryBody>(
|
||||||
|
'PATCH',
|
||||||
|
`/dicts/${industryTypeCode}/status`,
|
||||||
|
adminToken,
|
||||||
|
{ status: 'active' },
|
||||||
|
);
|
||||||
|
expect(enabled.body.status).toBe('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('删除:内置类型 40001、有子条目 40001、空自定义类型 204 后 404', async () => {
|
||||||
|
const builtin = await call<{ code: number }>('DELETE', '/dicts/platform_spec', adminToken);
|
||||||
|
expect(builtin.status).toBe(400);
|
||||||
|
expect(builtin.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const blockedCode = `${TYPE_PREFIX}delete_blocked`;
|
||||||
|
await createType(blockedCode, { name: 'ITQA4 待删类型', group: 'content' });
|
||||||
|
await createItem(blockedCode, { code: 'keep_me', name: '保留条目' });
|
||||||
|
const withItems = await call<{ code: number }>('DELETE', `/dicts/${blockedCode}`, adminToken);
|
||||||
|
expect(withItems.status).toBe(400);
|
||||||
|
expect(withItems.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
await createType(emptyTypeCode, { name: 'ITQA4 空类型', group: 'content' });
|
||||||
|
const removed = await call<null>('DELETE', `/dicts/${emptyTypeCode}`, adminToken);
|
||||||
|
expect(removed.status).toBe(204);
|
||||||
|
const gone = await call<{ code: number }>('GET', `/dicts/${emptyTypeCode}`, adminToken);
|
||||||
|
expect(gone.status).toBe(404);
|
||||||
|
expect(gone.body.code).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4 字典 · 条目三层合并(R-10 / A4-QA-01)', () => {
|
||||||
|
it('准备通用 5 / 父行业 2 / 本行业 1 数据', async () => {
|
||||||
|
const generic = [
|
||||||
|
{ code: 'oral', name: '口播', sortOrder: 10 },
|
||||||
|
{ code: 'drama', name: '剧情', sortOrder: 20 },
|
||||||
|
{ code: 'review', name: '测评', sortOrder: 30 },
|
||||||
|
{ code: 'seeding', name: '种草', sortOrder: 40 },
|
||||||
|
{ code: 'scene', name: '情景', sortOrder: 50 },
|
||||||
|
];
|
||||||
|
for (const item of generic) {
|
||||||
|
await createItem(industryTypeCode, { ...item, parentCode: null, industryId: null });
|
||||||
|
}
|
||||||
|
await createItem(industryTypeCode, {
|
||||||
|
code: 'live_clip',
|
||||||
|
name: '直播切片',
|
||||||
|
industryId: rootIndustryId,
|
||||||
|
sortOrder: 60,
|
||||||
|
});
|
||||||
|
await createItem(industryTypeCode, {
|
||||||
|
code: 'seeding',
|
||||||
|
name: '种草短视频',
|
||||||
|
industryId: rootIndustryId,
|
||||||
|
sortOrder: 40,
|
||||||
|
});
|
||||||
|
await createItem(industryTypeCode, {
|
||||||
|
code: 'outfit',
|
||||||
|
name: '穿搭教程',
|
||||||
|
industryId: leafIndustryId,
|
||||||
|
sortOrder: 70,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('二级行业三层合并:7 条生效、source / overridden / layers 正确', async () => {
|
||||||
|
const result = await call<DictItemListBody>(
|
||||||
|
'GET',
|
||||||
|
`/dicts/${industryTypeCode}/items?industry_id=${leafIndustryId}`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(result.body.items).toHaveLength(7);
|
||||||
|
expect(result.body.effectiveCount).toBe(7);
|
||||||
|
expect(result.body.overriddenCount).toBe(1);
|
||||||
|
expect(result.body.disabledCount).toBe(0);
|
||||||
|
expect(result.body.layers).toEqual([
|
||||||
|
{ source: 'generic', industryId: null, label: '通用', count: 5 },
|
||||||
|
{ source: 'parent', industryId: rootIndustryId, label: `${INDUSTRY_PREFIX}电商`, count: 2 },
|
||||||
|
{
|
||||||
|
source: 'current',
|
||||||
|
industryId: leafIndustryId,
|
||||||
|
label: `${INDUSTRY_PREFIX}服饰鞋包`,
|
||||||
|
count: 1,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const seeding = result.body.items.find((item) => item.code === 'seeding');
|
||||||
|
expect(seeding).toMatchObject({
|
||||||
|
name: '种草短视频',
|
||||||
|
source: 'parent',
|
||||||
|
overridden: true,
|
||||||
|
industryPath: `${INDUSTRY_PREFIX}电商`,
|
||||||
|
refCount: 0,
|
||||||
|
});
|
||||||
|
expect(result.body.items.find((item) => item.code === 'live_clip')).toMatchObject({
|
||||||
|
source: 'parent',
|
||||||
|
overridden: false,
|
||||||
|
});
|
||||||
|
expect(result.body.items.find((item) => item.code === 'outfit')).toMatchObject({
|
||||||
|
source: 'current',
|
||||||
|
industryPath: `${INDUSTRY_PREFIX}电商 / ${INDUSTRY_PREFIX}服饰鞋包`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('不传行业只返回通用层;一级行业 = 通用 + 本行业;行业不存在 40401', async () => {
|
||||||
|
const genericOnly = await call<DictItemListBody>(
|
||||||
|
'GET',
|
||||||
|
`/dicts/${industryTypeCode}/items`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(genericOnly.body.items).toHaveLength(5);
|
||||||
|
expect(genericOnly.body.items.every((item) => item.source === 'generic')).toBe(true);
|
||||||
|
|
||||||
|
const rootView = await call<DictItemListBody>(
|
||||||
|
'GET',
|
||||||
|
`/dicts/${industryTypeCode}/items?industry_id=${rootIndustryId}`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(rootView.body.layers.map((layer) => layer.source)).toEqual(['generic', 'current']);
|
||||||
|
expect(rootView.body.items).toHaveLength(6);
|
||||||
|
|
||||||
|
const missing = await call<{ code: number }>(
|
||||||
|
'GET',
|
||||||
|
`/dicts/${industryTypeCode}/items?industry_id=01HZZZZZZZZZZZZZZZZZZZZZZZ`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(missing.status).toBe(404);
|
||||||
|
expect(missing.body.code).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非行业作用域类型强制忽略 industry_id,只返回通用层', async () => {
|
||||||
|
const code = `${TYPE_PREFIX}list_tmp`;
|
||||||
|
await createType(code, { name: 'ITQA4 时长档', group: 'content', kind: 'list' });
|
||||||
|
await createItem(code, { code: '15s', name: '15 秒', sortOrder: 10 });
|
||||||
|
const result = await call<DictItemListBody>(
|
||||||
|
'GET',
|
||||||
|
`/dicts/${code}/items?industry_id=${leafIndustryId}`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(result.body.items).toHaveLength(1);
|
||||||
|
expect(result.body.layers).toEqual([
|
||||||
|
{ source: 'generic', industryId: null, label: '通用', count: 1 },
|
||||||
|
]);
|
||||||
|
expect(result.body.items[0]).toMatchObject({ code: '15s', source: 'generic' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('条目写校验:非行业类型传行业 / 列表型传父级 / 父级不存在 / 重复编码', async () => {
|
||||||
|
const code = `${TYPE_PREFIX}list_tmp`;
|
||||||
|
const industryOnList = await call<{ code: number }>(
|
||||||
|
'POST',
|
||||||
|
`/dicts/${code}/items`,
|
||||||
|
adminToken,
|
||||||
|
{ code: '30s', name: '30 秒', industryId: leafIndustryId },
|
||||||
|
);
|
||||||
|
expect(industryOnList.status).toBe(400);
|
||||||
|
expect(industryOnList.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const parentOnList = await call<{ code: number }>('POST', `/dicts/${code}/items`, adminToken, {
|
||||||
|
code: '30s',
|
||||||
|
name: '30 秒',
|
||||||
|
parentCode: '15s',
|
||||||
|
});
|
||||||
|
expect(parentOnList.status).toBe(400);
|
||||||
|
expect(parentOnList.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const missingParent = await call<{ code: number }>(
|
||||||
|
'POST',
|
||||||
|
`/dicts/${industryTypeCode}/items`,
|
||||||
|
adminToken,
|
||||||
|
{ code: 'new_child', name: '新子项', parentCode: 'missing_parent' },
|
||||||
|
);
|
||||||
|
expect(missingParent.status).toBe(400);
|
||||||
|
expect(missingParent.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const duplicate = await call<{ code: number }>(
|
||||||
|
'POST',
|
||||||
|
`/dicts/${industryTypeCode}/items`,
|
||||||
|
adminToken,
|
||||||
|
{ code: 'oral', name: '口播重复' },
|
||||||
|
);
|
||||||
|
expect(duplicate.status).toBe(409);
|
||||||
|
expect(duplicate.body.code).toBe(ErrorCode.DUPLICATE_SUBMIT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('条目编辑:改名 / 停用;编码不可改(unknown 字段 40001)', async () => {
|
||||||
|
const created = await createItem(industryTypeCode, {
|
||||||
|
code: 'voiceover',
|
||||||
|
name: '口播旁白',
|
||||||
|
sortOrder: 80,
|
||||||
|
});
|
||||||
|
const updated = await call<DictItemBody>(
|
||||||
|
'PATCH',
|
||||||
|
`/dicts/${industryTypeCode}/items/${created.id}`,
|
||||||
|
adminToken,
|
||||||
|
{ name: '口播旁白(改)', status: 'disabled' },
|
||||||
|
);
|
||||||
|
expect(updated.status).toBe(200);
|
||||||
|
expect(updated.body).toMatchObject({
|
||||||
|
code: 'voiceover',
|
||||||
|
name: '口播旁白(改)',
|
||||||
|
status: 'disabled',
|
||||||
|
});
|
||||||
|
|
||||||
|
const immutable = await call<{ code: number }>(
|
||||||
|
'PATCH',
|
||||||
|
`/dicts/${industryTypeCode}/items/${created.id}`,
|
||||||
|
adminToken,
|
||||||
|
{ code: 'changed_code' },
|
||||||
|
);
|
||||||
|
expect(immutable.status).toBe(400);
|
||||||
|
expect(immutable.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const missing = await call<{ code: number }>(
|
||||||
|
'PATCH',
|
||||||
|
`/dicts/${industryTypeCode}/items/01HZZZZZZZZZZZZZZZZZZZZZZZ`,
|
||||||
|
adminToken,
|
||||||
|
{ name: '不存在' },
|
||||||
|
);
|
||||||
|
expect(missing.status).toBe(404);
|
||||||
|
expect(missing.body.code).toBe(ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { DictsController } from './dicts.controller';
|
||||||
|
import { DictsService } from './dicts.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* catalog 模块(A4-BE-01 / A4-BE-04):数据字典类型与条目、行业作用域三层合并、快照工具。
|
||||||
|
* 只依赖 @Global 的 PrismaModule / SecurityModule(RequestContextService),不反向依赖其他业务模块。
|
||||||
|
*/
|
||||||
|
@Module({
|
||||||
|
controllers: [DictsController],
|
||||||
|
providers: [DictsService],
|
||||||
|
exports: [DictsService],
|
||||||
|
})
|
||||||
|
export class CatalogModule {}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import type { DictItemSource } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { type DictMergeLayer, type DictMergeRow, mergeDictLayers } from './dict-merge';
|
||||||
|
|
||||||
|
function row(overrides: Partial<DictMergeRow> & Pick<DictMergeRow, 'code' | 'name'>): DictMergeRow {
|
||||||
|
return {
|
||||||
|
id: `id-${overrides.code}`,
|
||||||
|
parentCode: null,
|
||||||
|
industryId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
updatedAt: '2026-11-04T00:00:00.000Z',
|
||||||
|
updatedBy: '江岚',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function layer(
|
||||||
|
source: DictItemSource,
|
||||||
|
industryId: string | null,
|
||||||
|
items: DictMergeRow[],
|
||||||
|
): DictMergeLayer {
|
||||||
|
const label = source === 'generic' ? '通用' : source === 'parent' ? '父行业' : '本行业';
|
||||||
|
return { source, industryId, label, items };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A4-QA-01 口径:通用 5 + 父行业 2 + 本行业 1,其中 seeding 被父行业覆盖 → 生效 7 条 */
|
||||||
|
function scriptDirectionLayers(): DictMergeLayer[] {
|
||||||
|
return [
|
||||||
|
layer('generic', null, [
|
||||||
|
row({ code: 'oral', name: '口播', sortOrder: 10 }),
|
||||||
|
row({ code: 'drama', name: '剧情', sortOrder: 20 }),
|
||||||
|
row({ code: 'review', name: '测评', sortOrder: 30 }),
|
||||||
|
row({ code: 'seeding', name: '种草', sortOrder: 40 }),
|
||||||
|
row({ code: 'scene', name: '情景', sortOrder: 50 }),
|
||||||
|
]),
|
||||||
|
layer('parent', 'ec', [
|
||||||
|
row({ code: 'live_clip', name: '直播切片', industryId: 'ec', sortOrder: 60 }),
|
||||||
|
row({ code: 'seeding', name: '种草短视频', industryId: 'ec', sortOrder: 40 }),
|
||||||
|
]),
|
||||||
|
layer('current', 'ec_apparel', [
|
||||||
|
row({ code: 'outfit', name: '穿搭教程', industryId: 'ec_apparel', sortOrder: 70 }),
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('mergeDictLayers · 三层合并', () => {
|
||||||
|
it('通用 5 + 父行业 2 + 本行业 1:同 code 覆盖后生效 7 条', () => {
|
||||||
|
const result = mergeDictLayers(scriptDirectionLayers());
|
||||||
|
expect(result.items).toHaveLength(7);
|
||||||
|
expect(result.effectiveCount).toBe(7);
|
||||||
|
expect(result.disabledCount).toBe(0);
|
||||||
|
expect(result.overriddenCount).toBe(1);
|
||||||
|
expect(result.layers).toEqual([
|
||||||
|
{ source: 'generic', industryId: null, label: '通用', count: 5 },
|
||||||
|
{ source: 'parent', industryId: 'ec', label: '父行业', count: 2 },
|
||||||
|
{ source: 'current', industryId: 'ec_apparel', label: '本行业', count: 1 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('来源与覆盖标记正确:种草短视频 parent / 直播切片 parent / 穿搭教程 current', () => {
|
||||||
|
const result = mergeDictLayers(scriptDirectionLayers());
|
||||||
|
const seeding = result.items.find((item) => item.code === 'seeding');
|
||||||
|
const liveClip = result.items.find((item) => item.code === 'live_clip');
|
||||||
|
const outfit = result.items.find((item) => item.code === 'outfit');
|
||||||
|
expect(seeding).toMatchObject({
|
||||||
|
name: '种草短视频',
|
||||||
|
source: 'parent',
|
||||||
|
overridden: true,
|
||||||
|
});
|
||||||
|
expect(liveClip).toMatchObject({ source: 'parent', overridden: false });
|
||||||
|
expect(outfit).toMatchObject({ source: 'current', overridden: false });
|
||||||
|
// 通用层的「种草」被父行业覆盖,不再出现在生效结果中
|
||||||
|
expect(result.items.filter((item) => item.code === 'seeding')).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('条目按 sortOrder asc, name asc 排序', () => {
|
||||||
|
const result = mergeDictLayers(scriptDirectionLayers());
|
||||||
|
expect(result.items.map((item) => item.code)).toEqual([
|
||||||
|
'oral',
|
||||||
|
'drama',
|
||||||
|
'review',
|
||||||
|
'seeding',
|
||||||
|
'scene',
|
||||||
|
'live_clip',
|
||||||
|
'outfit',
|
||||||
|
]);
|
||||||
|
expect(result.items.map((item) => item.sortOrder)).toEqual([10, 20, 30, 40, 50, 60, 70]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('非行业作用域只传入通用层:全部 source=generic、无覆盖', () => {
|
||||||
|
const genericLayer = layer('generic', null, [
|
||||||
|
row({ code: '15s', name: '15 秒' }),
|
||||||
|
row({ code: '30s', name: '30 秒' }),
|
||||||
|
]);
|
||||||
|
const result = mergeDictLayers([genericLayer]);
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
expect(result.items.every((item) => item.source === 'generic')).toBe(true);
|
||||||
|
expect(result.items.every((item) => !item.overridden)).toBe(true);
|
||||||
|
expect(result.overriddenCount).toBe(0);
|
||||||
|
expect(result.layers).toEqual([
|
||||||
|
{ source: 'generic', industryId: null, label: '通用', count: 2 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('effectiveCount 只计 active,disabledCount 计 disabled,被覆盖的禁用条目不重复计数', () => {
|
||||||
|
const result = mergeDictLayers([
|
||||||
|
layer('generic', null, [
|
||||||
|
row({ code: 'L1', name: '自然光', sortOrder: 10 }),
|
||||||
|
row({ code: 'L5', name: '高反差硬光', sortOrder: 50, status: 'disabled' }),
|
||||||
|
]),
|
||||||
|
layer('current', 'fin', [
|
||||||
|
row({ code: 'L1', name: '自然光(金融)', industryId: 'fin', sortOrder: 10 }),
|
||||||
|
row({ code: 'fin_L6', name: '商务蓝调', industryId: 'fin', status: 'disabled' }),
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
expect(result.effectiveCount).toBe(1);
|
||||||
|
expect(result.disabledCount).toBe(2);
|
||||||
|
expect(result.overriddenCount).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('三层同 code 时以本行业为准,只计一条生效;层级顺序无关', () => {
|
||||||
|
const layers: DictMergeLayer[] = [
|
||||||
|
layer('current', 'leaf', [row({ code: 'oral', name: '本行业口播', industryId: 'leaf' })]),
|
||||||
|
layer('generic', null, [row({ code: 'oral', name: '口播' })]),
|
||||||
|
layer('parent', 'root', [row({ code: 'oral', name: '父行业口播', industryId: 'root' })]),
|
||||||
|
];
|
||||||
|
const result = mergeDictLayers(layers);
|
||||||
|
expect(result.items).toHaveLength(1);
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
name: '本行业口播',
|
||||||
|
source: 'current',
|
||||||
|
overridden: true,
|
||||||
|
});
|
||||||
|
expect(result.overriddenCount).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/**
|
||||||
|
* 数据字典三层合并(A4-BE-01 / PRD R-10)。
|
||||||
|
*
|
||||||
|
* 存储只有一张通用枚举表 `dict_items`:`industry_id IS NULL` 为通用层,
|
||||||
|
* 否则为行业层;二级行业的视角为「通用 → 父行业 → 本行业」。
|
||||||
|
* 同 code 以更具体层覆盖,最终只返回生效条目(每条带 source / overridden)。
|
||||||
|
*
|
||||||
|
* 本文件是纯函数,不依赖 Prisma / Nest,便于单测覆盖合并口径。
|
||||||
|
*/
|
||||||
|
import type { DictItemSource, DictLayer, DictStatus } from '@lingxi/shared';
|
||||||
|
|
||||||
|
/** 参与合并的条目行(已完成 Prisma -> 领域字段映射) */
|
||||||
|
export interface DictMergeRow {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
parentCode: string | null;
|
||||||
|
industryId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
status: DictStatus;
|
||||||
|
remark: string;
|
||||||
|
updatedAt: string;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一层来源:通用 / 父行业 / 本行业 */
|
||||||
|
export interface DictMergeLayer {
|
||||||
|
source: DictItemSource;
|
||||||
|
industryId: string | null;
|
||||||
|
label: string;
|
||||||
|
items: readonly DictMergeRow[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合并后的生效条目:source 表示来自哪一层,overridden 表示覆盖了更笼统层的同 code 条目 */
|
||||||
|
export interface DictEffectiveItem extends DictMergeRow {
|
||||||
|
source: DictItemSource;
|
||||||
|
overridden: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictMergeResult {
|
||||||
|
items: DictEffectiveItem[];
|
||||||
|
layers: DictLayer[];
|
||||||
|
/** 只统计 active 条目 */
|
||||||
|
effectiveCount: number;
|
||||||
|
/** 统计被更具体层覆盖的生效条目数(同 code 覆盖) */
|
||||||
|
overriddenCount: number;
|
||||||
|
/** 统计合并结果中 disabled 的条目数 */
|
||||||
|
disabledCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LAYER_PRIORITY: Readonly<Record<DictItemSource, number>> = {
|
||||||
|
generic: 0,
|
||||||
|
parent: 1,
|
||||||
|
current: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
function layerRank(source: DictItemSource): number {
|
||||||
|
return LAYER_PRIORITY[source];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 排序:sortOrder asc, name asc, code asc(最后一个键保证结果稳定) */
|
||||||
|
function compareEffective(left: DictEffectiveItem, right: DictEffectiveItem): number {
|
||||||
|
if (left.sortOrder !== right.sortOrder) return left.sortOrder - right.sortOrder;
|
||||||
|
const byName = left.name.localeCompare(right.name, 'zh-CN');
|
||||||
|
if (byName !== 0) return byName;
|
||||||
|
return left.code.localeCompare(right.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 三层合并:低优先级层先落表,高优先级层同 code 覆盖并标记 overridden。
|
||||||
|
* 入参 layers 的顺序不影响结果(内部按 generic < parent < current 排序)。
|
||||||
|
*/
|
||||||
|
export function mergeDictLayers(layers: readonly DictMergeLayer[]): DictMergeResult {
|
||||||
|
const orderedLayers = [...layers].sort(
|
||||||
|
(left, right) => layerRank(left.source) - layerRank(right.source),
|
||||||
|
);
|
||||||
|
const merged = new Map<string, DictEffectiveItem>();
|
||||||
|
|
||||||
|
for (const layer of orderedLayers) {
|
||||||
|
for (const item of layer.items) {
|
||||||
|
const overridden = merged.has(item.code);
|
||||||
|
merged.set(item.code, { ...item, source: layer.source, overridden });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const items = [...merged.values()].sort(compareEffective);
|
||||||
|
const resultLayers: DictLayer[] = orderedLayers.map((layer) => ({
|
||||||
|
source: layer.source,
|
||||||
|
industryId: layer.industryId,
|
||||||
|
label: layer.label,
|
||||||
|
count: layer.items.length,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
layers: resultLayers,
|
||||||
|
effectiveCount: items.filter((item) => item.status === 'active').length,
|
||||||
|
overriddenCount: items.filter((item) => item.overridden).length,
|
||||||
|
disabledCount: items.filter((item) => item.status === 'disabled').length,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
/**
|
||||||
|
* 数据字典 Prisma 行 -> 共享 DTO 映射(A4-BE-01 / A4-BE-04)。
|
||||||
|
*
|
||||||
|
* 只做字段映射与枚举转换;三层合并、行业路径与父级名称由 service 计算结果后传入。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
DICT_GROUP_KEYS,
|
||||||
|
type DictGroupKey,
|
||||||
|
type DictItem,
|
||||||
|
type DictItemSnapshot,
|
||||||
|
type DictItemSource,
|
||||||
|
type DictStatus,
|
||||||
|
type DictTypeKind,
|
||||||
|
type DictTypeSummary,
|
||||||
|
toDictItemSnapshot,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import type { DictKind, DictStatus as PrismaDictStatus } from '@prisma/client';
|
||||||
|
|
||||||
|
export function iso(value: Date): string {
|
||||||
|
return value.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDictStatus(value: PrismaDictStatus): DictStatus {
|
||||||
|
return value === 'ACTIVE' ? 'active' : 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapDictKind(value: DictKind): DictTypeKind {
|
||||||
|
return value === 'TREE' ? 'tree' : 'list';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 分组键是共享契约的封闭枚举;库内异常值回退 content,避免整页 500 */
|
||||||
|
export function mapDictGroup(value: string): DictGroupKey {
|
||||||
|
return DICT_GROUP_KEYS.find((key): key is DictGroupKey => key === value) ?? 'content';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 类型统计(由 dict_items 聚合,A4 不跨模块取引用数) */
|
||||||
|
export interface DictTypeCounts {
|
||||||
|
itemCount: number;
|
||||||
|
activeItemCount: number;
|
||||||
|
industryCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictTypeRow {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
groupKey: string;
|
||||||
|
kind: DictKind;
|
||||||
|
description: string;
|
||||||
|
industryScoped: boolean;
|
||||||
|
builtin: boolean;
|
||||||
|
status: PrismaDictStatus;
|
||||||
|
sortOrder: number;
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toDictTypeSummary(row: DictTypeRow, counts: DictTypeCounts): DictTypeSummary {
|
||||||
|
return {
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
group: mapDictGroup(row.groupKey),
|
||||||
|
kind: mapDictKind(row.kind),
|
||||||
|
description: row.description,
|
||||||
|
industryScoped: row.industryScoped,
|
||||||
|
builtin: row.builtin,
|
||||||
|
status: mapDictStatus(row.status),
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
itemCount: counts.itemCount,
|
||||||
|
activeItemCount: counts.activeItemCount,
|
||||||
|
industryCount: counts.industryCount,
|
||||||
|
updatedAt: iso(row.updatedAt),
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictItemRow {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
parentCode: string | null;
|
||||||
|
industryId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
status: PrismaDictStatus;
|
||||||
|
remark: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictItemView {
|
||||||
|
/** 所属类型编码(行本身不带,由 service 传入) */
|
||||||
|
typeCode: string;
|
||||||
|
source: DictItemSource;
|
||||||
|
overridden: boolean;
|
||||||
|
parentName: string | null;
|
||||||
|
/** 行业路径:一级行业名 / 二级行业名;通用层为 null */
|
||||||
|
industryPath: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 字典引用数:A4 暂无引用表,B0 / B1 接入后改为真实统计(接口字段已保留) */
|
||||||
|
const DICT_ITEM_REF_COUNT = 0;
|
||||||
|
|
||||||
|
export function toDictItem(row: DictItemRow, view: DictItemView): DictItem {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
typeCode: view.typeCode,
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
parentCode: row.parentCode,
|
||||||
|
parentName: view.parentName,
|
||||||
|
industryId: row.industryId,
|
||||||
|
industryPath: view.industryPath,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
status: mapDictStatus(row.status),
|
||||||
|
remark: row.remark,
|
||||||
|
source: view.source,
|
||||||
|
overridden: view.overridden,
|
||||||
|
refCount: DICT_ITEM_REF_COUNT,
|
||||||
|
updatedAt: iso(row.updatedAt),
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 填写时快照(R-10):引用字典的业务记录保存该快照,之后字典改名 / 停用不影响历史数据。
|
||||||
|
* 直接委托 shared 的 `toDictItemSnapshot`,保证前后端快照结构同源。
|
||||||
|
*/
|
||||||
|
export function toDictSnapshot(item: DictItem, capturedAt: string): DictItemSnapshot {
|
||||||
|
return toDictItemSnapshot(item, capturedAt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import {
|
||||||
|
type CreateDictItemInput,
|
||||||
|
createDictItemInputSchema,
|
||||||
|
type CreateDictTypeInput,
|
||||||
|
createDictTypeInputSchema,
|
||||||
|
type DictItem,
|
||||||
|
type DictItemListQuery,
|
||||||
|
dictItemListQuerySchema,
|
||||||
|
type DictItemListResponse,
|
||||||
|
type DictTypeListQuery,
|
||||||
|
dictTypeListQuerySchema,
|
||||||
|
type DictTypeSummary,
|
||||||
|
ErrorCode,
|
||||||
|
type ListResponse,
|
||||||
|
Permission,
|
||||||
|
type SetDictTypeStatusInput,
|
||||||
|
setDictTypeStatusInputSchema,
|
||||||
|
type UpdateDictItemInput,
|
||||||
|
updateDictItemInputSchema,
|
||||||
|
type UpdateDictTypeInput,
|
||||||
|
updateDictTypeInputSchema,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
Inject,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import { ZodValidationPipe } from '../../common/pipes/zod-validation.pipe';
|
||||||
|
import type { AuthContext } from '../../common/security/auth.types';
|
||||||
|
import { Audit, CurrentUser, RequirePermissions } from '../../common/security/decorators';
|
||||||
|
import { DictsService } from './dicts.service';
|
||||||
|
|
||||||
|
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||||
|
if (auth === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||||
|
}
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据字典管理(A4-BE-01 / A4-BE-04):全部端点要求 `dict:manage`;
|
||||||
|
* 写接口加 @Audit,Service 内通过 patchAudit 记录前后值。
|
||||||
|
*/
|
||||||
|
@ApiTags('catalog-dicts')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('dicts')
|
||||||
|
@RequirePermissions(Permission.DICT_MANAGE)
|
||||||
|
export class DictsController {
|
||||||
|
constructor(@Inject(DictsService) private readonly dicts: DictsService) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@ApiOperation({ summary: '字典类型列表(分组顺序 + 排序;含条目 / 行业层统计)' })
|
||||||
|
list(
|
||||||
|
@Query(new ZodValidationPipe(dictTypeListQuerySchema)) query: DictTypeListQuery,
|
||||||
|
): Promise<ListResponse<DictTypeSummary>> {
|
||||||
|
return this.dicts.list(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':code')
|
||||||
|
@ApiOperation({ summary: '字典类型详情(不存在 40401)' })
|
||||||
|
detail(@Param('code') code: string): Promise<DictTypeSummary> {
|
||||||
|
return this.dicts.detail(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@Audit('catalog.dict_type.create')
|
||||||
|
@ApiOperation({ summary: '新建字典类型(编码唯一;builtin=false)' })
|
||||||
|
create(
|
||||||
|
@Body(new ZodValidationPipe(createDictTypeInputSchema)) input: CreateDictTypeInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<DictTypeSummary> {
|
||||||
|
return this.dicts.create(input, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':code')
|
||||||
|
@Audit('catalog.dict_type.update')
|
||||||
|
@ApiOperation({ summary: '更新字典类型(编码 / 形态 / 行业作用域不可改)' })
|
||||||
|
update(
|
||||||
|
@Param('code') code: string,
|
||||||
|
@Body(new ZodValidationPipe(updateDictTypeInputSchema)) input: UpdateDictTypeInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<DictTypeSummary> {
|
||||||
|
return this.dicts.update(code, input, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':code/status')
|
||||||
|
@Audit('catalog.dict_type.status')
|
||||||
|
@ApiOperation({ summary: '启用 / 停用字典类型(内置类型允许停用)' })
|
||||||
|
setStatus(
|
||||||
|
@Param('code') code: string,
|
||||||
|
@Body(new ZodValidationPipe(setDictTypeStatusInputSchema)) input: SetDictTypeStatusInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<DictTypeSummary> {
|
||||||
|
return this.dicts.setStatus(code, input, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':code')
|
||||||
|
@HttpCode(204)
|
||||||
|
@Audit('catalog.dict_type.delete')
|
||||||
|
@ApiOperation({ summary: '删除自定义字典类型(内置 / 有子条目时不可删)' })
|
||||||
|
async remove(@Param('code') code: string): Promise<void> {
|
||||||
|
await this.dicts.remove(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':code/items')
|
||||||
|
@ApiOperation({ summary: '生效条目列表(industry_id 三层合并;非行业类型忽略该参数)' })
|
||||||
|
listItems(
|
||||||
|
@Param('code') code: string,
|
||||||
|
@Query(new ZodValidationPipe(dictItemListQuerySchema)) query: DictItemListQuery,
|
||||||
|
): Promise<DictItemListResponse> {
|
||||||
|
return this.dicts.listItems(code, query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post(':code/items')
|
||||||
|
@Audit('catalog.dict_item.create')
|
||||||
|
@ApiOperation({ summary: '新建字典条目(编码 / 父级 / 行业校验;编码创建后不可改)' })
|
||||||
|
createItem(
|
||||||
|
@Param('code') code: string,
|
||||||
|
@Body(new ZodValidationPipe(createDictItemInputSchema)) input: CreateDictItemInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<DictItem> {
|
||||||
|
return this.dicts.createItem(code, input, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':code/items/:itemId')
|
||||||
|
@Audit('catalog.dict_item.update')
|
||||||
|
@ApiOperation({ summary: '更新字典条目(仅名称 / 排序 / 启停 / 备注)' })
|
||||||
|
updateItem(
|
||||||
|
@Param('code') code: string,
|
||||||
|
@Param('itemId') itemId: string,
|
||||||
|
@Body(new ZodValidationPipe(updateDictItemInputSchema)) input: UpdateDictItemInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<DictItem> {
|
||||||
|
return this.dicts.updateItem(code, itemId, input, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,562 @@
|
|||||||
|
import { ErrorCode } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import type { RequestContextService } from '../../common/security/request-context';
|
||||||
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
|
import { DictsService } from './dicts.service';
|
||||||
|
|
||||||
|
const NOW = new Date('2026-11-04T02:00:00.000Z');
|
||||||
|
|
||||||
|
interface DictTypeFixture {
|
||||||
|
id: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
groupKey: string;
|
||||||
|
kind: 'LIST' | 'TREE';
|
||||||
|
description: string;
|
||||||
|
industryScoped: boolean;
|
||||||
|
builtin: boolean;
|
||||||
|
status: 'ACTIVE' | 'DISABLED';
|
||||||
|
sortOrder: number;
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeFixture(overrides: Partial<DictTypeFixture> = {}): DictTypeFixture {
|
||||||
|
return {
|
||||||
|
id: 't1',
|
||||||
|
code: 'script_direction',
|
||||||
|
name: '脚本方向',
|
||||||
|
groupKey: 'content',
|
||||||
|
kind: 'LIST',
|
||||||
|
description: '脚本与素材的结构化方向',
|
||||||
|
industryScoped: false,
|
||||||
|
builtin: true,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sortOrder: 60,
|
||||||
|
updatedAt: NOW,
|
||||||
|
updatedBy: '江岚',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ItemFixture {
|
||||||
|
id: string;
|
||||||
|
typeId: string;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
parentCode: string | null;
|
||||||
|
industryId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
status: 'ACTIVE' | 'DISABLED';
|
||||||
|
remark: string;
|
||||||
|
updatedAt: Date;
|
||||||
|
updatedBy: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemFixture(overrides: Partial<ItemFixture> = {}): ItemFixture {
|
||||||
|
return {
|
||||||
|
id: `i-${overrides.code ?? 'oral'}-${overrides.industryId ?? 'generic'}`,
|
||||||
|
typeId: 't1',
|
||||||
|
code: 'oral',
|
||||||
|
name: '口播',
|
||||||
|
parentCode: null,
|
||||||
|
industryId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
remark: '',
|
||||||
|
updatedAt: NOW,
|
||||||
|
updatedBy: '江岚',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
service: DictsService;
|
||||||
|
prisma: {
|
||||||
|
dictType: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
dictItem: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
industry: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
user: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
};
|
||||||
|
patchAudit: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(
|
||||||
|
prismaInput: Record<string, Record<string, ReturnType<typeof vi.fn>>> = {},
|
||||||
|
): Harness {
|
||||||
|
const dictType = {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
delete: vi.fn().mockResolvedValue({}),
|
||||||
|
...prismaInput.dictType,
|
||||||
|
};
|
||||||
|
const dictItem = {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
findFirst: vi.fn().mockResolvedValue(null),
|
||||||
|
findUniqueOrThrow: vi.fn(),
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
count: vi.fn().mockResolvedValue(0),
|
||||||
|
groupBy: vi.fn().mockResolvedValue([]),
|
||||||
|
...prismaInput.dictItem,
|
||||||
|
};
|
||||||
|
const industry = {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
...prismaInput.industry,
|
||||||
|
};
|
||||||
|
const user = {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({ name: '江岚' }),
|
||||||
|
...prismaInput.user,
|
||||||
|
};
|
||||||
|
const patchAudit = vi.fn();
|
||||||
|
const service = new DictsService(
|
||||||
|
{ dictType, dictItem, industry, user } as unknown as PrismaService,
|
||||||
|
{ patchAudit } as unknown as RequestContextService,
|
||||||
|
);
|
||||||
|
return { service, prisma: { dictType, dictItem, industry, user }, patchAudit };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||||
|
return promise.then(
|
||||||
|
() => expect.unreachable('应抛出异常'),
|
||||||
|
(error: unknown) => {
|
||||||
|
expect(error).toBeInstanceOf(AppException);
|
||||||
|
expect((error as AppException).code).toBe(code);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('DictsService · 类型列表 / 详情', () => {
|
||||||
|
it('按 DICT_GROUPS 顺序 + sortOrder 排序,并返回条目 / 启用 / 行业层统计', async () => {
|
||||||
|
const rows = [
|
||||||
|
typeFixture({
|
||||||
|
id: 't-quality',
|
||||||
|
code: 'quality_banned',
|
||||||
|
name: '禁用词',
|
||||||
|
groupKey: 'quality',
|
||||||
|
sortOrder: 10,
|
||||||
|
}),
|
||||||
|
typeFixture({ id: 't-content', code: 'hook_type', name: '钩子类型', sortOrder: 40 }),
|
||||||
|
typeFixture({ id: 't-content-2', code: 'shot_type', name: '景别', sortOrder: 50 }),
|
||||||
|
];
|
||||||
|
const { service } = harness({
|
||||||
|
dictType: { findMany: vi.fn().mockResolvedValue(rows) },
|
||||||
|
dictItem: {
|
||||||
|
groupBy: vi.fn().mockResolvedValue([
|
||||||
|
{ typeId: 't-content', status: 'ACTIVE', industryId: null, _count: { _all: 6 } },
|
||||||
|
{ typeId: 't-content', status: 'DISABLED', industryId: 'ec', _count: { _all: 1 } },
|
||||||
|
{ typeId: 't-content-2', status: 'ACTIVE', industryId: null, _count: { _all: 3 } },
|
||||||
|
{ typeId: 't-quality', status: 'ACTIVE', industryId: null, _count: { _all: 15 } },
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.list({ status: 'all', limit: 20 });
|
||||||
|
expect(result.items.map((item) => item.code)).toEqual([
|
||||||
|
'hook_type',
|
||||||
|
'shot_type',
|
||||||
|
'quality_banned',
|
||||||
|
]);
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
itemCount: 7,
|
||||||
|
activeItemCount: 6,
|
||||||
|
industryCount: 1,
|
||||||
|
group: 'content',
|
||||||
|
kind: 'list',
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
expect(result.next_cursor).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('游标分页:limit 截断并返回 next_cursor;非法游标 40001', async () => {
|
||||||
|
const rows = [
|
||||||
|
typeFixture({ id: 't1', code: 'a_type', groupKey: 'content', sortOrder: 10 }),
|
||||||
|
typeFixture({ id: 't2', code: 'b_type', groupKey: 'content', sortOrder: 20 }),
|
||||||
|
typeFixture({ id: 't3', code: 'c_type', groupKey: 'content', sortOrder: 30 }),
|
||||||
|
];
|
||||||
|
const { service } = harness({ dictType: { findMany: vi.fn().mockResolvedValue(rows) } });
|
||||||
|
const first = await service.list({ status: 'all', limit: 2 });
|
||||||
|
expect(first.items.map((item) => item.code)).toEqual(['a_type', 'b_type']);
|
||||||
|
expect(first.next_cursor).toBe('b_type');
|
||||||
|
const second = await service.list({ status: 'all', limit: 2, cursor: 'b_type' });
|
||||||
|
expect(second.items.map((item) => item.code)).toEqual(['c_type']);
|
||||||
|
expect(second.next_cursor).toBeNull();
|
||||||
|
await expectRejected(
|
||||||
|
service.list({ status: 'all', limit: 2, cursor: 'missing' }),
|
||||||
|
ErrorCode.PARAM_INVALID,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('详情不存在返回 40401', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expectRejected(service.detail('missing_type'), ErrorCode.NOT_FOUND);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DictsService · 类型写操作', () => {
|
||||||
|
it('新建:编码重复 40902;成功后 builtin=false、updatedBy 为操作人姓名、写审计', async () => {
|
||||||
|
const duplicate = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue({ id: 'existing' }) },
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
duplicate.service.create(
|
||||||
|
{
|
||||||
|
code: 'new_type',
|
||||||
|
name: '新类型',
|
||||||
|
group: 'content',
|
||||||
|
kind: 'list',
|
||||||
|
description: '',
|
||||||
|
industryScoped: false,
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
ErrorCode.DUPLICATE_SUBMIT,
|
||||||
|
);
|
||||||
|
|
||||||
|
const created = typeFixture({
|
||||||
|
id: 't-new',
|
||||||
|
code: 'new_type',
|
||||||
|
name: '新类型',
|
||||||
|
builtin: false,
|
||||||
|
sortOrder: 10,
|
||||||
|
updatedBy: '江岚',
|
||||||
|
});
|
||||||
|
const success = harness({
|
||||||
|
dictType: {
|
||||||
|
findUnique: vi.fn().mockResolvedValueOnce(null).mockResolvedValue(created),
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const summary = await success.service.create(
|
||||||
|
{
|
||||||
|
code: 'new_type',
|
||||||
|
name: '新类型',
|
||||||
|
group: 'content',
|
||||||
|
kind: 'list',
|
||||||
|
description: '',
|
||||||
|
industryScoped: false,
|
||||||
|
sortOrder: 10,
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
expect(summary).toMatchObject({ code: 'new_type', builtin: false, updatedBy: '江岚' });
|
||||||
|
expect(success.prisma.dictType.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ builtin: false, status: 'ACTIVE', updatedBy: '江岚' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(success.patchAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ targetType: 'dict_type', before: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('内置类型允许改名 / 停用;删除内置类型 40001', async () => {
|
||||||
|
const updated = typeFixture({ name: '脚本方向(新)', updatedBy: '江岚' });
|
||||||
|
const edit = harness({
|
||||||
|
dictType: {
|
||||||
|
findUnique: vi.fn().mockResolvedValueOnce(typeFixture()).mockResolvedValue(updated),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const summary = await edit.service.update('script_direction', { name: '脚本方向(新)' }, 'u1');
|
||||||
|
expect(summary.name).toBe('脚本方向(新)');
|
||||||
|
expect(edit.prisma.dictType.update).toHaveBeenCalled();
|
||||||
|
|
||||||
|
const disabled = typeFixture({ status: 'DISABLED' });
|
||||||
|
const status = harness({
|
||||||
|
dictType: {
|
||||||
|
findUnique: vi.fn().mockResolvedValueOnce(typeFixture()).mockResolvedValue(disabled),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(
|
||||||
|
status.service.setStatus('script_direction', { status: 'disabled' }, 'u1'),
|
||||||
|
).resolves.toMatchObject({ status: 'disabled' });
|
||||||
|
|
||||||
|
const remove = harness({ dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture()) } });
|
||||||
|
await expectRejected(remove.service.remove('script_direction'), ErrorCode.PARAM_INVALID);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('自定义类型有子条目不可删,无条目可删并写审计', async () => {
|
||||||
|
const withItems = harness({
|
||||||
|
dictType: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(typeFixture({ builtin: false, id: 't-custom' })),
|
||||||
|
},
|
||||||
|
dictItem: { count: vi.fn().mockResolvedValue(3) },
|
||||||
|
});
|
||||||
|
await expectRejected(withItems.service.remove('custom_type'), ErrorCode.PARAM_INVALID);
|
||||||
|
expect(withItems.prisma.dictType.delete).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
const empty = harness({
|
||||||
|
dictType: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(typeFixture({ builtin: false, id: 't-custom' })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(empty.service.remove('custom_type')).resolves.toBeUndefined();
|
||||||
|
expect(empty.prisma.dictType.delete).toHaveBeenCalledWith({ where: { id: 't-custom' } });
|
||||||
|
expect(empty.patchAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ targetType: 'dict_type', after: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DictsService · 条目三层合并', () => {
|
||||||
|
it('非行业作用域类型强制忽略 industry_id,只返回通用层', async () => {
|
||||||
|
const { service, prisma } = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ industryScoped: false })) },
|
||||||
|
dictItem: {
|
||||||
|
findMany: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
itemFixture({ code: 'oral', name: '口播' }),
|
||||||
|
itemFixture({ code: 'drama', name: '剧情', sortOrder: 20 }),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.listItems('script_direction', { industry_id: 'ec' });
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
expect(result.items.every((item) => item.source === 'generic')).toBe(true);
|
||||||
|
expect(result.layers).toEqual([
|
||||||
|
{ source: 'generic', industryId: null, label: '通用', count: 2 },
|
||||||
|
]);
|
||||||
|
expect(prisma.industry.findUnique).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('行业不存在返回 40401;一级行业只叠加通用 + 本行业', async () => {
|
||||||
|
const missing = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ industryScoped: true })) },
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
missing.service.listItems('script_direction', { industry_id: 'missing' }),
|
||||||
|
ErrorCode.NOT_FOUND,
|
||||||
|
);
|
||||||
|
|
||||||
|
const root = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ industryScoped: true })) },
|
||||||
|
industry: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ id: 'ec', name: '电商', parentId: null, parent: null }),
|
||||||
|
},
|
||||||
|
dictItem: {
|
||||||
|
findMany: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
itemFixture({ code: 'oral', name: '口播' }),
|
||||||
|
itemFixture({ code: 'live_clip', name: '直播切片', industryId: 'ec', sortOrder: 60 }),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await root.service.listItems('script_direction', { industry_id: 'ec' });
|
||||||
|
expect(result.layers.map((layer) => layer.source)).toEqual(['generic', 'current']);
|
||||||
|
expect(result.items.map((item) => [item.code, item.source])).toEqual([
|
||||||
|
['oral', 'generic'],
|
||||||
|
['live_clip', 'current'],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('二级行业三层合并:通用 5 + 父行业 2 + 本行业 1,同 code 覆盖后 7 条', async () => {
|
||||||
|
const { service } = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ industryScoped: true })) },
|
||||||
|
industry: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue({
|
||||||
|
id: 'leaf',
|
||||||
|
name: '服饰鞋包',
|
||||||
|
parentId: 'ec',
|
||||||
|
parent: { id: 'ec', name: '电商' },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
dictItem: {
|
||||||
|
findMany: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
itemFixture({ code: 'oral', name: '口播' }),
|
||||||
|
itemFixture({ code: 'drama', name: '剧情', sortOrder: 20 }),
|
||||||
|
itemFixture({ code: 'review', name: '测评', sortOrder: 30 }),
|
||||||
|
itemFixture({ code: 'seeding', name: '种草', sortOrder: 40 }),
|
||||||
|
itemFixture({ code: 'scene', name: '情景', sortOrder: 50 }),
|
||||||
|
itemFixture({ code: 'live_clip', name: '直播切片', industryId: 'ec', sortOrder: 60 }),
|
||||||
|
itemFixture({ code: 'seeding', name: '种草短视频', industryId: 'ec', sortOrder: 40 }),
|
||||||
|
itemFixture({ code: 'outfit', name: '穿搭教程', industryId: 'leaf', sortOrder: 70 }),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.listItems('script_direction', { industry_id: 'leaf' });
|
||||||
|
expect(result.items).toHaveLength(7);
|
||||||
|
expect(result.effectiveCount).toBe(7);
|
||||||
|
expect(result.overriddenCount).toBe(1);
|
||||||
|
expect(result.layers).toEqual([
|
||||||
|
{ source: 'generic', industryId: null, label: '通用', count: 5 },
|
||||||
|
{ source: 'parent', industryId: 'ec', label: '电商', count: 2 },
|
||||||
|
{ source: 'current', industryId: 'leaf', label: '服饰鞋包', count: 1 },
|
||||||
|
]);
|
||||||
|
const seeding = result.items.find((item) => item.code === 'seeding');
|
||||||
|
expect(seeding).toMatchObject({
|
||||||
|
name: '种草短视频',
|
||||||
|
source: 'parent',
|
||||||
|
overridden: true,
|
||||||
|
industryPath: '电商',
|
||||||
|
});
|
||||||
|
expect(result.items.find((item) => item.code === 'outfit')).toMatchObject({
|
||||||
|
source: 'current',
|
||||||
|
industryPath: '电商 / 服饰鞋包',
|
||||||
|
});
|
||||||
|
expect(result.items.find((item) => item.code === 'oral')).toMatchObject({
|
||||||
|
industryPath: null,
|
||||||
|
parentName: null,
|
||||||
|
refCount: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('DictsService · 条目写操作', () => {
|
||||||
|
it('新建条目:非行业类型传 industryId / 列表型传 parentCode / 父级不存在 / 重复编码均 40001 或 40902', async () => {
|
||||||
|
const nonScoped = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ industryScoped: false })) },
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
nonScoped.service.createItem(
|
||||||
|
'script_direction',
|
||||||
|
{
|
||||||
|
code: 'new_item',
|
||||||
|
name: '新条目',
|
||||||
|
parentCode: null,
|
||||||
|
industryId: 'ec',
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
ErrorCode.PARAM_INVALID,
|
||||||
|
);
|
||||||
|
|
||||||
|
const listType = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ kind: 'LIST' })) },
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
listType.service.createItem(
|
||||||
|
'script_direction',
|
||||||
|
{
|
||||||
|
code: 'new_item',
|
||||||
|
name: '新条目',
|
||||||
|
parentCode: 'oral',
|
||||||
|
industryId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
ErrorCode.PARAM_INVALID,
|
||||||
|
);
|
||||||
|
|
||||||
|
const missingParent = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture({ kind: 'TREE' })) },
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
missingParent.service.createItem(
|
||||||
|
'script_direction',
|
||||||
|
{
|
||||||
|
code: 'new_item',
|
||||||
|
name: '新条目',
|
||||||
|
parentCode: 'missing',
|
||||||
|
industryId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
ErrorCode.PARAM_INVALID,
|
||||||
|
);
|
||||||
|
|
||||||
|
const duplicate = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture()) },
|
||||||
|
dictItem: { findFirst: vi.fn().mockResolvedValue({ id: 'existing' }) },
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
duplicate.service.createItem(
|
||||||
|
'script_direction',
|
||||||
|
{
|
||||||
|
code: 'oral',
|
||||||
|
name: '口播',
|
||||||
|
parentCode: null,
|
||||||
|
industryId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
ErrorCode.DUPLICATE_SUBMIT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('新建条目成功:写审计前后值并返回条目 DTO', async () => {
|
||||||
|
const row = itemFixture({ id: 'i-new', code: 'new_item', name: '新条目' });
|
||||||
|
const { service, prisma, patchAudit } = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture()) },
|
||||||
|
dictItem: { findUniqueOrThrow: vi.fn().mockResolvedValue(row) },
|
||||||
|
});
|
||||||
|
const item = await service.createItem(
|
||||||
|
'script_direction',
|
||||||
|
{
|
||||||
|
code: 'new_item',
|
||||||
|
name: '新条目',
|
||||||
|
parentCode: null,
|
||||||
|
industryId: null,
|
||||||
|
sortOrder: 10,
|
||||||
|
status: 'active',
|
||||||
|
remark: '',
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
expect(item).toMatchObject({
|
||||||
|
code: 'new_item',
|
||||||
|
source: 'generic',
|
||||||
|
overridden: false,
|
||||||
|
refCount: 0,
|
||||||
|
});
|
||||||
|
expect(prisma.dictItem.create).toHaveBeenCalled();
|
||||||
|
expect(patchAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ targetType: 'dict_item', before: null }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('编辑条目:不存在 40401;成功只更新名称 / 排序 / 启停 / 备注并写前后值', async () => {
|
||||||
|
const missing = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture()) },
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
missing.service.updateItem('script_direction', 'missing', { name: '新名' }, 'u1'),
|
||||||
|
ErrorCode.NOT_FOUND,
|
||||||
|
);
|
||||||
|
|
||||||
|
const existing = itemFixture({ id: 'i1', code: 'oral', name: '口播' });
|
||||||
|
const updated = itemFixture({ id: 'i1', code: 'oral', name: '口播(改)', status: 'DISABLED' });
|
||||||
|
const { service, patchAudit } = harness({
|
||||||
|
dictType: { findUnique: vi.fn().mockResolvedValue(typeFixture()) },
|
||||||
|
dictItem: {
|
||||||
|
findFirst: vi.fn().mockResolvedValue(existing),
|
||||||
|
findUniqueOrThrow: vi.fn().mockResolvedValue(updated),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const item = await service.updateItem(
|
||||||
|
'script_direction',
|
||||||
|
'i1',
|
||||||
|
{ name: '口播(改)', status: 'disabled' },
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
expect(item).toMatchObject({ code: 'oral', name: '口播(改)', status: 'disabled' });
|
||||||
|
expect(patchAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
targetType: 'dict_item',
|
||||||
|
before: expect.objectContaining({ name: '口播' }),
|
||||||
|
after: expect.objectContaining({ name: '口播(改)' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,619 @@
|
|||||||
|
/* eslint-disable max-lines-per-function -- 字典类型 / 条目 / 三层合并集中在一个服务,按方法拆分后仍需共享校验与审计口径 */
|
||||||
|
import {
|
||||||
|
type CreateDictItemInput,
|
||||||
|
type CreateDictTypeInput,
|
||||||
|
DICT_GROUPS,
|
||||||
|
type DictGroupKey,
|
||||||
|
type DictItem,
|
||||||
|
type DictItemListQuery,
|
||||||
|
type DictItemListResponse,
|
||||||
|
type DictItemSource,
|
||||||
|
type DictTypeListQuery,
|
||||||
|
type DictTypeSummary,
|
||||||
|
ErrorCode,
|
||||||
|
type ListResponse,
|
||||||
|
type SetDictTypeStatusInput,
|
||||||
|
ulid,
|
||||||
|
type UpdateDictItemInput,
|
||||||
|
type UpdateDictTypeInput,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
import type { DictItem as PrismaDictItem, DictType as PrismaDictType } from '@prisma/client';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
|
|
||||||
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
|
import { RequestContextService } from '../../common/security/request-context';
|
||||||
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
|
import { PRISMA } from '../../infra/tokens';
|
||||||
|
import {
|
||||||
|
type DictTypeCounts,
|
||||||
|
iso,
|
||||||
|
mapDictGroup,
|
||||||
|
mapDictStatus,
|
||||||
|
toDictItem,
|
||||||
|
toDictTypeSummary,
|
||||||
|
} from './dict.mapper';
|
||||||
|
import { type DictMergeLayer, type DictMergeRow, mergeDictLayers } from './dict-merge';
|
||||||
|
|
||||||
|
interface IndustryLayerContext {
|
||||||
|
layers: { source: DictItemSource; industryId: string | null; label: string }[];
|
||||||
|
industryIds: string[];
|
||||||
|
/** 行业 id -> 展示路径(一级名 / 二级名) */
|
||||||
|
paths: Map<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DictTypeAuditSnapshot {
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
group: DictGroupKey;
|
||||||
|
description: string;
|
||||||
|
sortOrder: number;
|
||||||
|
status: 'active' | 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
const GROUP_ORDER: ReadonlyMap<DictGroupKey, number> = new Map(
|
||||||
|
DICT_GROUPS.map((group, index) => [group.key, index]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const EMPTY_COUNTS: DictTypeCounts = { itemCount: 0, activeItemCount: 0, industryCount: 0 };
|
||||||
|
|
||||||
|
function isUniqueViolation(error: unknown): boolean {
|
||||||
|
return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002';
|
||||||
|
}
|
||||||
|
|
||||||
|
function layerCodeKey(industryId: string | null, code: string): string {
|
||||||
|
return `${industryId ?? ''}\u0000${code}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeSortRank(row: PrismaDictType): number {
|
||||||
|
return GROUP_ORDER.get(mapDictGroup(row.groupKey)) ?? DICT_GROUPS.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareTypeRows(left: PrismaDictType, right: PrismaDictType): number {
|
||||||
|
const byGroup = typeSortRank(left) - typeSortRank(right);
|
||||||
|
if (byGroup !== 0) return byGroup;
|
||||||
|
if (left.sortOrder !== right.sortOrder) return left.sortOrder - right.sortOrder;
|
||||||
|
return left.code.localeCompare(right.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
function typeRowAudit(row: PrismaDictType): DictTypeAuditSnapshot {
|
||||||
|
return {
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
group: mapDictGroup(row.groupKey),
|
||||||
|
description: row.description,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
status: mapDictStatus(row.status),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemAudit(row: PrismaDictItem): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
parentCode: row.parentCode,
|
||||||
|
industryId: row.industryId,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
status: mapDictStatus(row.status),
|
||||||
|
remark: row.remark,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toMergeRow(row: PrismaDictItem): DictMergeRow {
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
code: row.code,
|
||||||
|
name: row.name,
|
||||||
|
parentCode: row.parentCode,
|
||||||
|
industryId: row.industryId,
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
status: mapDictStatus(row.status),
|
||||||
|
remark: row.remark,
|
||||||
|
updatedAt: iso(row.updatedAt),
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 数据字典(A4-BE-01 / A4-BE-04):
|
||||||
|
* - 类型:列表 / 详情 / 新建 / 编辑 / 启停 / 删除(编码、形态、行业作用域创建后不可改);
|
||||||
|
* - 条目:行业视角三层合并(通用 → 父行业 → 本行业)、新建 / 编辑;
|
||||||
|
* - 写操作统一 `RequestContextService.patchAudit` 记录前后值;
|
||||||
|
* - 快照由 `dict.mapper#toDictSnapshot` 提供,业务记录落库在 B0 / M1 接入(R-10)。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DictsService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
|
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async list(query: DictTypeListQuery): Promise<ListResponse<DictTypeSummary>> {
|
||||||
|
const where: Prisma.DictTypeWhereInput = {
|
||||||
|
...(query.keyword === undefined
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
OR: [
|
||||||
|
{ name: { contains: query.keyword, mode: 'insensitive' } },
|
||||||
|
{ code: { contains: query.keyword, mode: 'insensitive' } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
...(query.group === undefined ? {} : { groupKey: query.group }),
|
||||||
|
...(query.status === 'all'
|
||||||
|
? {}
|
||||||
|
: { status: query.status === 'active' ? 'ACTIVE' : 'DISABLED' }),
|
||||||
|
};
|
||||||
|
const rows = await this.prisma.dictType.findMany({ where });
|
||||||
|
const counts = await this.countsByType(rows.map((row) => row.id));
|
||||||
|
const sorted = [...rows].sort(compareTypeRows);
|
||||||
|
const start = this.cursorStart(sorted, query.cursor);
|
||||||
|
const page = sorted.slice(start, start + query.limit);
|
||||||
|
const hasMore = start + query.limit < sorted.length;
|
||||||
|
return {
|
||||||
|
items: page.map((row) => toDictTypeSummary(row, counts.get(row.id) ?? EMPTY_COUNTS)),
|
||||||
|
next_cursor: hasMore ? (page[page.length - 1]?.code ?? null) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async detail(code: string): Promise<DictTypeSummary> {
|
||||||
|
const row = await this.findTypeOrThrow(code);
|
||||||
|
const counts = await this.countsByType([row.id]);
|
||||||
|
return toDictTypeSummary(row, counts.get(row.id) ?? EMPTY_COUNTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(input: CreateDictTypeInput, actorId: string): Promise<DictTypeSummary> {
|
||||||
|
const duplicate = await this.prisma.dictType.findUnique({
|
||||||
|
where: { code: input.code },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (duplicate !== null) {
|
||||||
|
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '字典类型编码已存在' });
|
||||||
|
}
|
||||||
|
const id = ulid();
|
||||||
|
const updatedBy = await this.actorName(actorId);
|
||||||
|
try {
|
||||||
|
await this.prisma.dictType.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
code: input.code,
|
||||||
|
name: input.name,
|
||||||
|
groupKey: input.group,
|
||||||
|
kind: input.kind === 'tree' ? 'TREE' : 'LIST',
|
||||||
|
description: input.description,
|
||||||
|
industryScoped: input.industryScoped,
|
||||||
|
builtin: false,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
updatedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new AppException({ code: ErrorCode.DUPLICATE_SUBMIT, message: '字典类型编码已存在' });
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const summary = await this.detail(input.code);
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'dict_type',
|
||||||
|
targetId: id,
|
||||||
|
before: null,
|
||||||
|
after: summary,
|
||||||
|
});
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
code: string,
|
||||||
|
input: UpdateDictTypeInput,
|
||||||
|
actorId: string,
|
||||||
|
): Promise<DictTypeSummary> {
|
||||||
|
const existing = await this.findTypeOrThrow(code);
|
||||||
|
const updatedBy = await this.actorName(actorId);
|
||||||
|
await this.prisma.dictType.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
...(input.name === undefined ? {} : { name: input.name }),
|
||||||
|
...(input.group === undefined ? {} : { groupKey: input.group }),
|
||||||
|
...(input.description === undefined ? {} : { description: input.description }),
|
||||||
|
...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }),
|
||||||
|
updatedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const summary = await this.detail(code);
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'dict_type',
|
||||||
|
targetId: existing.id,
|
||||||
|
before: typeRowAudit(existing),
|
||||||
|
after: {
|
||||||
|
code: summary.code,
|
||||||
|
name: summary.name,
|
||||||
|
group: summary.group,
|
||||||
|
description: summary.description,
|
||||||
|
sortOrder: summary.sortOrder,
|
||||||
|
status: summary.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async setStatus(
|
||||||
|
code: string,
|
||||||
|
input: SetDictTypeStatusInput,
|
||||||
|
actorId: string,
|
||||||
|
): Promise<DictTypeSummary> {
|
||||||
|
const existing = await this.findTypeOrThrow(code);
|
||||||
|
const updatedBy = await this.actorName(actorId);
|
||||||
|
await this.prisma.dictType.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
status: input.status === 'active' ? 'ACTIVE' : 'DISABLED',
|
||||||
|
updatedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const summary = await this.detail(code);
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'dict_type',
|
||||||
|
targetId: existing.id,
|
||||||
|
before: typeRowAudit(existing),
|
||||||
|
after: {
|
||||||
|
code: summary.code,
|
||||||
|
name: summary.name,
|
||||||
|
group: summary.group,
|
||||||
|
description: summary.description,
|
||||||
|
sortOrder: summary.sortOrder,
|
||||||
|
status: summary.status,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(code: string): Promise<void> {
|
||||||
|
const existing = await this.findTypeOrThrow(code);
|
||||||
|
if (existing.builtin) {
|
||||||
|
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '内置类型不可删除' });
|
||||||
|
}
|
||||||
|
const itemCount = await this.prisma.dictItem.count({ where: { typeId: existing.id } });
|
||||||
|
if (itemCount > 0) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: '请先删除条目或改为停用',
|
||||||
|
details: { itemCount },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await this.prisma.dictType.delete({ where: { id: existing.id } });
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'dict_type',
|
||||||
|
targetId: existing.id,
|
||||||
|
before: typeRowAudit(existing),
|
||||||
|
after: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async listItems(code: string, query: DictItemListQuery): Promise<DictItemListResponse> {
|
||||||
|
const type = await this.findTypeOrThrow(code);
|
||||||
|
const view = await this.industryView(type, query.industry_id);
|
||||||
|
const rows = await this.prisma.dictItem.findMany({
|
||||||
|
where: {
|
||||||
|
typeId: type.id,
|
||||||
|
OR: [
|
||||||
|
{ industryId: null },
|
||||||
|
...(view.industryIds.length === 0 ? [] : [{ industryId: { in: view.industryIds } }]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const mergeLayers: DictMergeLayer[] = view.layers.map((definition) => ({
|
||||||
|
source: definition.source,
|
||||||
|
industryId: definition.industryId,
|
||||||
|
label: definition.label,
|
||||||
|
items: rows
|
||||||
|
.filter((row) => row.industryId === definition.industryId)
|
||||||
|
.map((row) => toMergeRow(row)),
|
||||||
|
}));
|
||||||
|
const merged = mergeDictLayers(mergeLayers);
|
||||||
|
|
||||||
|
const nameByLayerCode = new Map<string, string>();
|
||||||
|
for (const row of rows) {
|
||||||
|
nameByLayerCode.set(layerCodeKey(row.industryId, row.code), row.name);
|
||||||
|
}
|
||||||
|
const effectiveNameByCode = new Map(merged.items.map((item) => [item.code, item.name]));
|
||||||
|
|
||||||
|
const items: DictItem[] = merged.items.map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
typeCode: type.code,
|
||||||
|
code: item.code,
|
||||||
|
name: item.name,
|
||||||
|
parentCode: item.parentCode,
|
||||||
|
parentName:
|
||||||
|
item.parentCode === null
|
||||||
|
? null
|
||||||
|
: (nameByLayerCode.get(layerCodeKey(item.industryId, item.parentCode)) ??
|
||||||
|
effectiveNameByCode.get(item.parentCode) ??
|
||||||
|
null),
|
||||||
|
industryId: item.industryId,
|
||||||
|
industryPath: item.industryId === null ? null : (view.paths.get(item.industryId) ?? null),
|
||||||
|
sortOrder: item.sortOrder,
|
||||||
|
status: item.status,
|
||||||
|
remark: item.remark,
|
||||||
|
source: item.source,
|
||||||
|
overridden: item.overridden,
|
||||||
|
// 引用数:A4 尚无引用表,B0 行业资产 / B1 素材接入后改为真实统计(接口字段已保留)
|
||||||
|
refCount: 0,
|
||||||
|
updatedAt: item.updatedAt,
|
||||||
|
updatedBy: item.updatedBy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return {
|
||||||
|
items,
|
||||||
|
next_cursor: null,
|
||||||
|
layers: merged.layers,
|
||||||
|
effectiveCount: merged.effectiveCount,
|
||||||
|
overriddenCount: merged.overriddenCount,
|
||||||
|
disabledCount: merged.disabledCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createItem(code: string, input: CreateDictItemInput, actorId: string): Promise<DictItem> {
|
||||||
|
const type = await this.findTypeOrThrow(code);
|
||||||
|
const industryId = input.industryId ?? null;
|
||||||
|
if (!type.industryScoped && industryId !== null) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: '该字典类型不支持行业作用域,不能指定行业',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (industryId !== null) {
|
||||||
|
await this.assertIndustryExists(industryId);
|
||||||
|
}
|
||||||
|
const parentCode = input.parentCode ?? null;
|
||||||
|
let parentName: string | null = null;
|
||||||
|
if (parentCode !== null) {
|
||||||
|
if (type.kind !== 'TREE') {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: '列表型字典不允许设置父级编码',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const parent = await this.prisma.dictItem.findFirst({
|
||||||
|
where: { typeId: type.id, code: parentCode, industryId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
if (parent === null) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: '父级条目不存在或不属于同一行业层',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
parentName = parent.name;
|
||||||
|
}
|
||||||
|
const duplicate = await this.prisma.dictItem.findFirst({
|
||||||
|
where: { typeId: type.id, code: input.code, industryId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (duplicate !== null) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||||
|
message: '同一字典类型与行业层下条目编码已存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = ulid();
|
||||||
|
const updatedBy = await this.actorName(actorId);
|
||||||
|
try {
|
||||||
|
await this.prisma.dictItem.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
typeId: type.id,
|
||||||
|
code: input.code,
|
||||||
|
name: input.name,
|
||||||
|
parentCode,
|
||||||
|
industryId,
|
||||||
|
sortOrder: input.sortOrder,
|
||||||
|
status: input.status === 'active' ? 'ACTIVE' : 'DISABLED',
|
||||||
|
remark: input.remark,
|
||||||
|
updatedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueViolation(error)) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.DUPLICATE_SUBMIT,
|
||||||
|
message: '同一字典类型与行业层下条目编码已存在',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const row = await this.prisma.dictItem.findUniqueOrThrow({ where: { id } });
|
||||||
|
const item = toDictItem(row, {
|
||||||
|
typeCode: type.code,
|
||||||
|
source: industryId === null ? 'generic' : 'current',
|
||||||
|
overridden: false,
|
||||||
|
parentName,
|
||||||
|
industryPath: await this.industryPathOf(industryId),
|
||||||
|
});
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'dict_item',
|
||||||
|
targetId: id,
|
||||||
|
before: null,
|
||||||
|
after: itemAudit(row),
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateItem(
|
||||||
|
code: string,
|
||||||
|
itemId: string,
|
||||||
|
input: UpdateDictItemInput,
|
||||||
|
actorId: string,
|
||||||
|
): Promise<DictItem> {
|
||||||
|
const type = await this.findTypeOrThrow(code);
|
||||||
|
const existing = await this.prisma.dictItem.findFirst({
|
||||||
|
where: { id: itemId, typeId: type.id },
|
||||||
|
});
|
||||||
|
if (existing === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字典条目不存在' });
|
||||||
|
}
|
||||||
|
const updatedBy = await this.actorName(actorId);
|
||||||
|
await this.prisma.dictItem.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
...(input.name === undefined ? {} : { name: input.name }),
|
||||||
|
...(input.sortOrder === undefined ? {} : { sortOrder: input.sortOrder }),
|
||||||
|
...(input.status === undefined
|
||||||
|
? {}
|
||||||
|
: { status: input.status === 'active' ? 'ACTIVE' : 'DISABLED' }),
|
||||||
|
...(input.remark === undefined ? {} : { remark: input.remark }),
|
||||||
|
updatedBy,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const row = await this.prisma.dictItem.findUniqueOrThrow({ where: { id: existing.id } });
|
||||||
|
const parent =
|
||||||
|
row.parentCode === null
|
||||||
|
? null
|
||||||
|
: await this.prisma.dictItem.findFirst({
|
||||||
|
where: { typeId: type.id, code: row.parentCode, industryId: row.industryId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
const item = toDictItem(row, {
|
||||||
|
typeCode: type.code,
|
||||||
|
source: row.industryId === null ? 'generic' : 'current',
|
||||||
|
overridden: false,
|
||||||
|
parentName: parent?.name ?? null,
|
||||||
|
industryPath: await this.industryPathOf(row.industryId),
|
||||||
|
});
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'dict_item',
|
||||||
|
targetId: row.id,
|
||||||
|
before: itemAudit(existing),
|
||||||
|
after: itemAudit(row),
|
||||||
|
});
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async findTypeOrThrow(code: string): Promise<PrismaDictType> {
|
||||||
|
const row = await this.prisma.dictType.findUnique({ where: { code } });
|
||||||
|
if (row === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字典类型不存在' });
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private cursorStart(rows: readonly PrismaDictType[], cursor: string | undefined): number {
|
||||||
|
if (cursor === undefined) return 0;
|
||||||
|
const index = rows.findIndex((row) => row.code === cursor);
|
||||||
|
if (index < 0) {
|
||||||
|
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '分页游标无效' });
|
||||||
|
}
|
||||||
|
return index + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按类型聚合:总条目、启用条目、出现过的行业层数(distinct industry_id) */
|
||||||
|
private async countsByType(typeIds: readonly string[]): Promise<Map<string, DictTypeCounts>> {
|
||||||
|
if (typeIds.length === 0) return new Map();
|
||||||
|
const grouped = await this.prisma.dictItem.groupBy({
|
||||||
|
by: ['typeId', 'status', 'industryId'],
|
||||||
|
where: { typeId: { in: [...typeIds] } },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const counts = new Map<
|
||||||
|
string,
|
||||||
|
{ itemCount: number; activeItemCount: number; industries: Set<string> }
|
||||||
|
>();
|
||||||
|
for (const row of grouped) {
|
||||||
|
const entry = counts.get(row.typeId) ?? {
|
||||||
|
itemCount: 0,
|
||||||
|
activeItemCount: 0,
|
||||||
|
industries: new Set<string>(),
|
||||||
|
};
|
||||||
|
entry.itemCount += row._count._all;
|
||||||
|
if (row.status === 'ACTIVE') entry.activeItemCount += row._count._all;
|
||||||
|
if (row.industryId !== null) entry.industries.add(row.industryId);
|
||||||
|
counts.set(row.typeId, entry);
|
||||||
|
}
|
||||||
|
return new Map(
|
||||||
|
[...counts].map(([typeId, entry]) => [
|
||||||
|
typeId,
|
||||||
|
{
|
||||||
|
itemCount: entry.itemCount,
|
||||||
|
activeItemCount: entry.activeItemCount,
|
||||||
|
industryCount: entry.industries.size,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 行业视角:非行业作用域类型强制忽略 industry_id(只返回通用层);
|
||||||
|
* 一级行业 = 通用 + 本行业;二级行业 = 通用 + 父行业 + 本行业。
|
||||||
|
*/
|
||||||
|
private async industryView(
|
||||||
|
type: PrismaDictType,
|
||||||
|
industryId: string | undefined,
|
||||||
|
): Promise<IndustryLayerContext> {
|
||||||
|
const generic: { source: DictItemSource; industryId: string | null; label: string } = {
|
||||||
|
source: 'generic',
|
||||||
|
industryId: null,
|
||||||
|
label: '通用',
|
||||||
|
};
|
||||||
|
if (!type.industryScoped || industryId === undefined) {
|
||||||
|
return { layers: [generic], industryIds: [], paths: new Map() };
|
||||||
|
}
|
||||||
|
const industry = await this.prisma.industry.findUnique({
|
||||||
|
where: { id: industryId },
|
||||||
|
include: { parent: true },
|
||||||
|
});
|
||||||
|
if (industry === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '行业不存在' });
|
||||||
|
}
|
||||||
|
const paths = new Map<string, string>();
|
||||||
|
paths.set(
|
||||||
|
industry.id,
|
||||||
|
industry.parent === null ? industry.name : `${industry.parent.name} / ${industry.name}`,
|
||||||
|
);
|
||||||
|
const industryIds = [industry.id];
|
||||||
|
const layers: { source: DictItemSource; industryId: string | null; label: string }[] = [
|
||||||
|
generic,
|
||||||
|
];
|
||||||
|
if (industry.parent !== null) {
|
||||||
|
layers.push({
|
||||||
|
source: 'parent',
|
||||||
|
industryId: industry.parent.id,
|
||||||
|
label: industry.parent.name,
|
||||||
|
});
|
||||||
|
industryIds.push(industry.parent.id);
|
||||||
|
paths.set(industry.parent.id, industry.parent.name);
|
||||||
|
}
|
||||||
|
layers.push({ source: 'current', industryId: industry.id, label: industry.name });
|
||||||
|
return { layers, industryIds, paths };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertIndustryExists(industryId: string): Promise<void> {
|
||||||
|
const industry = await this.prisma.industry.findUnique({
|
||||||
|
where: { id: industryId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (industry === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '行业不存在' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async industryPathOf(industryId: string | null): Promise<string | null> {
|
||||||
|
if (industryId === null) return null;
|
||||||
|
const industry = await this.prisma.industry.findUnique({
|
||||||
|
where: { id: industryId },
|
||||||
|
include: { parent: true },
|
||||||
|
});
|
||||||
|
if (industry === null) return null;
|
||||||
|
return industry.parent === null ? industry.name : `${industry.parent.name} / ${industry.name}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** updated_by 存展示名(种子 / 前端均按姓名展示);用户不存在时回退 actorId,避免写入丢失 */
|
||||||
|
private async actorName(actorId: string): Promise<string> {
|
||||||
|
const user = await this.prisma.user.findUnique({
|
||||||
|
where: { id: actorId },
|
||||||
|
select: { name: true },
|
||||||
|
});
|
||||||
|
return user?.name ?? actorId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import type { ScopeContext } from '../../common/security/scope/scope-context';
|
|||||||
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
|
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
import { PRISMA } from '../../infra/tokens';
|
import { PRISMA } from '../../infra/tokens';
|
||||||
|
import { SystemConfigService } from '../platform/system-config/system-config.service';
|
||||||
import { type CustomerFieldRow, toCustomerField } from './client.mapper';
|
import { type CustomerFieldRow, toCustomerField } from './client.mapper';
|
||||||
|
|
||||||
const FIELD_INCLUDE = {
|
const FIELD_INCLUDE = {
|
||||||
@@ -51,7 +52,10 @@ function industryPath(customer: {
|
|||||||
*/
|
*/
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CustomerFieldsService {
|
export class CustomerFieldsService {
|
||||||
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
|
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
query: CustomerFieldListQuery,
|
query: CustomerFieldListQuery,
|
||||||
@@ -147,12 +151,7 @@ export class CustomerFieldsService {
|
|||||||
}
|
}
|
||||||
if (input.entersGeneration) {
|
if (input.entersGeneration) {
|
||||||
const used = await this.genUsedCount(customerId);
|
const used = await this.genUsedCount(customerId);
|
||||||
if (used >= CUSTOMER_GEN_FIELD_LIMIT) {
|
await this.assertGenFieldLimit(used);
|
||||||
throw new AppException({
|
|
||||||
code: ErrorCode.PARAM_INVALID,
|
|
||||||
message: `进入生成字段已达 ${CUSTOMER_GEN_FIELD_LIMIT} 个上限,请先停用或驳回其他字段`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const id = ulid();
|
const id = ulid();
|
||||||
await this.prisma.customerField.create({
|
await this.prisma.customerField.create({
|
||||||
@@ -200,12 +199,7 @@ export class CustomerFieldsService {
|
|||||||
}
|
}
|
||||||
if (input.decision === 'approve' && existing.entersGeneration) {
|
if (input.decision === 'approve' && existing.entersGeneration) {
|
||||||
const used = await this.genUsedCount(existing.customerId, id);
|
const used = await this.genUsedCount(existing.customerId, id);
|
||||||
if (used >= CUSTOMER_GEN_FIELD_LIMIT) {
|
await this.assertGenFieldLimit(used, '审批后仍超限');
|
||||||
throw new AppException({
|
|
||||||
code: ErrorCode.PARAM_INVALID,
|
|
||||||
message: `进入生成字段已达 ${CUSTOMER_GEN_FIELD_LIMIT} 个上限,审批后仍超限`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
await this.prisma.customerField.update({
|
await this.prisma.customerField.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
@@ -231,6 +225,20 @@ export class CustomerFieldsService {
|
|||||||
return toCustomerField(row);
|
return toCustomerField(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 进入生成字段上限读 system_configs(R-26);shared 常量仅作读失败兜底(默认 5) */
|
||||||
|
private async assertGenFieldLimit(used: number, hint = '请先停用或驳回其他字段'): Promise<void> {
|
||||||
|
const limit = await this.systemConfig.getNumber(
|
||||||
|
'limit.customer.gen_field',
|
||||||
|
CUSTOMER_GEN_FIELD_LIMIT,
|
||||||
|
);
|
||||||
|
if (used >= limit) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: `进入生成字段已达 ${String(limit)} 个上限,${hint}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async genUsedCount(customerId: string, excludeId?: string): Promise<number> {
|
private async genUsedCount(customerId: string, excludeId?: string): Promise<number> {
|
||||||
return this.prisma.customerField.count({
|
return this.prisma.customerField.count({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import type { ScopeContext } from '../../common/security/scope/scope-context';
|
|||||||
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
|
import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
import { PRISMA } from '../../infra/tokens';
|
import { PRISMA } from '../../infra/tokens';
|
||||||
|
import { SystemConfigService } from '../platform/system-config/system-config.service';
|
||||||
import { type CustomerSummaryRow, toCustomerDetail, toCustomerSummary } from './client.mapper';
|
import { type CustomerSummaryRow, toCustomerDetail, toCustomerSummary } from './client.mapper';
|
||||||
import { IndustriesService } from './industries.service';
|
import { IndustriesService } from './industries.service';
|
||||||
|
|
||||||
@@ -61,6 +62,7 @@ export class CustomersService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
@Inject(IndustriesService) private readonly industries: IndustriesService,
|
@Inject(IndustriesService) private readonly industries: IndustriesService,
|
||||||
|
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(
|
async list(
|
||||||
@@ -126,7 +128,7 @@ export class CustomersService {
|
|||||||
await this.industries.assertLeafIndustry(input.industryId);
|
await this.industries.assertLeafIndustry(input.industryId);
|
||||||
await this.ensureOwner(input.ownerUserId);
|
await this.ensureOwner(input.ownerUserId);
|
||||||
const baseFields = input.baseFields ?? [];
|
const baseFields = input.baseFields ?? [];
|
||||||
this.assertBaseFieldLimit(baseFields.length);
|
await this.assertBaseFieldLimit(baseFields.length);
|
||||||
const id = ulid();
|
const id = ulid();
|
||||||
try {
|
try {
|
||||||
await this.prisma.customer.create({
|
await this.prisma.customer.create({
|
||||||
@@ -184,7 +186,7 @@ export class CustomersService {
|
|||||||
if (input.ownerUserId !== undefined && input.ownerUserId !== existing.ownerUserId) {
|
if (input.ownerUserId !== undefined && input.ownerUserId !== existing.ownerUserId) {
|
||||||
await this.ensureOwner(input.ownerUserId);
|
await this.ensureOwner(input.ownerUserId);
|
||||||
}
|
}
|
||||||
if (input.baseFields !== undefined) this.assertBaseFieldLimit(input.baseFields.length);
|
if (input.baseFields !== undefined) await this.assertBaseFieldLimit(input.baseFields.length);
|
||||||
if (input.name !== undefined && input.name !== existing.name) {
|
if (input.name !== undefined && input.name !== existing.name) {
|
||||||
const duplicate = await this.prisma.customer.findFirst({
|
const duplicate = await this.prisma.customer.findFirst({
|
||||||
where: {
|
where: {
|
||||||
@@ -377,11 +379,16 @@ export class CustomersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private assertBaseFieldLimit(count: number): void {
|
/** 基础字段上限读 system_configs(R-26);shared 常量仅作读失败兜底(默认 10) */
|
||||||
if (count > CUSTOMER_BASE_FIELD_LIMIT) {
|
private async assertBaseFieldLimit(count: number): Promise<void> {
|
||||||
|
const limit = await this.systemConfig.getNumber(
|
||||||
|
'limit.customer.base_field',
|
||||||
|
CUSTOMER_BASE_FIELD_LIMIT,
|
||||||
|
);
|
||||||
|
if (count > limit) {
|
||||||
throw new AppException({
|
throw new AppException({
|
||||||
code: ErrorCode.PARAM_INVALID,
|
code: ErrorCode.PARAM_INVALID,
|
||||||
message: `基础字段最多 ${CUSTOMER_BASE_FIELD_LIMIT} 个,当前 ${count} 个`,
|
message: `基础字段最多 ${String(limit)} 个,当前 ${String(count)} 个`,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AppException } from '../../common/errors/app-exception';
|
|||||||
import type { UserAuthRecord } from '../../common/security/permission.service';
|
import type { UserAuthRecord } from '../../common/security/permission.service';
|
||||||
import type { RequestContextService } from '../../common/security/request-context';
|
import type { RequestContextService } from '../../common/security/request-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
|
import type { SystemConfigService } from '../platform/system-config/system-config.service';
|
||||||
import { DepartmentsService } from './departments.service';
|
import { DepartmentsService } from './departments.service';
|
||||||
import { LoginFailureService } from './login-failure.service';
|
import { LoginFailureService } from './login-failure.service';
|
||||||
import { MeService } from './me.service';
|
import { MeService } from './me.service';
|
||||||
@@ -213,10 +214,15 @@ describe('LoginFailureService', () => {
|
|||||||
describe('UsersService', () => {
|
describe('UsersService', () => {
|
||||||
const passwords = { hash: vi.fn().mockResolvedValue('hashed') };
|
const passwords = { hash: vi.fn().mockResolvedValue('hashed') };
|
||||||
|
|
||||||
|
const systemConfig = {
|
||||||
|
getNumber: vi.fn((_key: string, fallback?: number) => Promise.resolve(fallback ?? 0)),
|
||||||
|
} as unknown as SystemConfigService;
|
||||||
|
|
||||||
function serviceOf(prisma: Record<string, unknown>): UsersService {
|
function serviceOf(prisma: Record<string, unknown>): UsersService {
|
||||||
return new UsersService(
|
return new UsersService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
passwords as unknown as PasswordService,
|
passwords as unknown as PasswordService,
|
||||||
|
systemConfig,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -598,6 +604,9 @@ describe('MeService / RolesService', () => {
|
|||||||
const service = new RolesService(
|
const service = new RolesService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
{ get: () => null, patchAudit: vi.fn() } as unknown as RequestContextService,
|
{ get: () => null, patchAudit: vi.fn() } as unknown as RequestContextService,
|
||||||
|
{
|
||||||
|
getNumber: vi.fn((_key: string, fallback?: number) => Promise.resolve(fallback ?? 0)),
|
||||||
|
} as unknown as SystemConfigService,
|
||||||
);
|
);
|
||||||
const result = await service.list({
|
const result = await service.list({
|
||||||
status: 'active',
|
status: 'active',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
import type { RequestContextService } from '../../common/security/request-context';
|
import type { RequestContextService } from '../../common/security/request-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
|
import type { SystemConfigService } from '../platform/system-config/system-config.service';
|
||||||
import { RolesService } from './roles.service';
|
import { RolesService } from './roles.service';
|
||||||
|
|
||||||
const NOW = new Date('2026-11-03T02:00:00.000Z');
|
const NOW = new Date('2026-11-03T02:00:00.000Z');
|
||||||
@@ -102,10 +103,14 @@ function harness(): Harness {
|
|||||||
get: vi.fn().mockReturnValue({ traceId: 'trace-1' }),
|
get: vi.fn().mockReturnValue({ traceId: 'trace-1' }),
|
||||||
patchAudit: vi.fn(),
|
patchAudit: vi.fn(),
|
||||||
};
|
};
|
||||||
|
const systemConfig = {
|
||||||
|
getNumber: vi.fn((_key: string, fallback?: number) => Promise.resolve(fallback ?? 0)),
|
||||||
|
} as unknown as SystemConfigService;
|
||||||
return {
|
return {
|
||||||
service: new RolesService(
|
service: new RolesService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
requestContext as unknown as RequestContextService,
|
requestContext as unknown as RequestContextService,
|
||||||
|
systemConfig,
|
||||||
),
|
),
|
||||||
prisma,
|
prisma,
|
||||||
requestContext,
|
requestContext,
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import { AppException } from '../../common/errors/app-exception';
|
|||||||
import { RequestContextService } from '../../common/security/request-context';
|
import { RequestContextService } from '../../common/security/request-context';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
import { PRISMA } from '../../infra/tokens';
|
import { PRISMA } from '../../infra/tokens';
|
||||||
|
import { SystemConfigService } from '../platform/system-config/system-config.service';
|
||||||
import { iso, maskPhone } from './user.mapper';
|
import { iso, maskPhone } from './user.mapper';
|
||||||
|
|
||||||
const ROLE_LIST_INCLUDE = {
|
const ROLE_LIST_INCLUDE = {
|
||||||
@@ -238,6 +239,7 @@ export class RolesService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||||
|
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(query: RoleListQuery): Promise<ListResponse<RoleListItem>> {
|
async list(query: RoleListQuery): Promise<ListResponse<RoleListItem>> {
|
||||||
@@ -694,22 +696,27 @@ export class RolesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 软提示(不阻断):角色数量 / 超管人数超过 PRD 建议值;A4 迁 system_configs */
|
/** 软提示(不阻断):角色 / 超管人数超过建议值;阈值读 system_configs(R-26),常量仅作读失败兜底 */
|
||||||
private async buildWarnings(roleCode: string): Promise<string[]> {
|
private async buildWarnings(roleCode: string): Promise<string[]> {
|
||||||
const warnings: string[] = [];
|
const warnings: string[] = [];
|
||||||
|
const roleSoftLimit = await this.systemConfig.getNumber('limit.role.max', ROLE_SOFT_LIMIT);
|
||||||
const activeRoleCount = await this.prisma.role.count({ where: { status: 'ACTIVE' } });
|
const activeRoleCount = await this.prisma.role.count({ where: { status: 'ACTIVE' } });
|
||||||
if (activeRoleCount > ROLE_SOFT_LIMIT) {
|
if (activeRoleCount > roleSoftLimit) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`启用角色数 ${String(activeRoleCount)} 已超过建议上限 ${String(ROLE_SOFT_LIMIT)},建议合并角色`,
|
`启用角色数 ${String(activeRoleCount)} 已超过建议上限 ${String(roleSoftLimit)},建议合并角色`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (roleCode === 'super_admin') {
|
if (roleCode === 'super_admin') {
|
||||||
|
const superAdminSoftLimit = await this.systemConfig.getNumber(
|
||||||
|
'limit.super_admin.max',
|
||||||
|
SUPER_ADMIN_SOFT_LIMIT,
|
||||||
|
);
|
||||||
const superAdminCount = await this.prisma.user.count({
|
const superAdminCount = await this.prisma.user.count({
|
||||||
where: { status: 'ACTIVE', roles: { some: { role: { code: 'super_admin' } } } },
|
where: { status: 'ACTIVE', roles: { some: { role: { code: 'super_admin' } } } },
|
||||||
});
|
});
|
||||||
if (superAdminCount > SUPER_ADMIN_SOFT_LIMIT) {
|
if (superAdminCount > superAdminSoftLimit) {
|
||||||
warnings.push(
|
warnings.push(
|
||||||
`超级管理员 ${String(superAdminCount)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人`,
|
`超级管理员 ${String(superAdminCount)} 人已超过建议上限 ${String(superAdminSoftLimit)} 人`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { Prisma } from '@prisma/client';
|
|||||||
import { AppException } from '../../common/errors/app-exception';
|
import { AppException } from '../../common/errors/app-exception';
|
||||||
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
import type { PrismaService } from '../../infra/prisma/prisma.service';
|
||||||
import { PRISMA } from '../../infra/tokens';
|
import { PRISMA } from '../../infra/tokens';
|
||||||
|
import { SystemConfigService } from '../platform/system-config/system-config.service';
|
||||||
import { PasswordService } from './password.service';
|
import { PasswordService } from './password.service';
|
||||||
import { toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper';
|
import { toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper';
|
||||||
|
|
||||||
@@ -37,6 +38,7 @@ export class UsersService {
|
|||||||
constructor(
|
constructor(
|
||||||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
@Inject(PasswordService) private readonly passwords: PasswordService,
|
@Inject(PasswordService) private readonly passwords: PasswordService,
|
||||||
|
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(query: UserListQuery): Promise<ListResponse<UserSummary>> {
|
async list(query: UserListQuery): Promise<ListResponse<UserSummary>> {
|
||||||
@@ -181,8 +183,8 @@ export class UsersService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 超管人数软上限(PRD §5.4.1 建议 ≤ 2 人):只记录告警,不阻断保存;
|
* 超管人数软上限(PRD §5.4.1):只记录告警,不阻断保存;
|
||||||
* 前端「超管授予二次确认」是主要提示手段。A4 阈值迁 system_configs。
|
* 前端「超管授予二次确认」是主要提示手段。阈值读 system_configs(R-26),常量仅作读失败兜底。
|
||||||
*/
|
*/
|
||||||
private async warnIfSuperAdminExceeded(roleIds: readonly string[]): Promise<void> {
|
private async warnIfSuperAdminExceeded(roleIds: readonly string[]): Promise<void> {
|
||||||
const superAdminRole = await this.prisma.role.findUnique({
|
const superAdminRole = await this.prisma.role.findUnique({
|
||||||
@@ -192,12 +194,16 @@ export class UsersService {
|
|||||||
if (superAdminRole === null || !roleIds.includes(superAdminRole.id)) {
|
if (superAdminRole === null || !roleIds.includes(superAdminRole.id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const superAdminSoftLimit = await this.systemConfig.getNumber(
|
||||||
|
'limit.super_admin.max',
|
||||||
|
SUPER_ADMIN_SOFT_LIMIT,
|
||||||
|
);
|
||||||
const count = await this.prisma.user.count({
|
const count = await this.prisma.user.count({
|
||||||
where: { status: 'ACTIVE', roles: { some: { roleId: superAdminRole.id } } },
|
where: { status: 'ACTIVE', roles: { some: { roleId: superAdminRole.id } } },
|
||||||
});
|
});
|
||||||
if (count > SUPER_ADMIN_SOFT_LIMIT) {
|
if (count > superAdminSoftLimit) {
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`超级管理员 ${String(count)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人(仅提示,不阻断)`,
|
`超级管理员 ${String(count)} 人已超过建议上限 ${String(superAdminSoftLimit)} 人(仅提示,不阻断)`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
import { ErrorCode, formatNamingDate, NAMING_MAX_LENGTH } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AppException } from '../../../common/errors/app-exception';
|
||||||
|
import type { SystemConfigService } from '../system-config/system-config.service';
|
||||||
|
import { type NamingNameContext, NamingService } from './naming.service';
|
||||||
|
|
||||||
|
const SCRIPT_PATTERN = '{行业}-{客户}-{脚本方向}-{日期}-{序号}';
|
||||||
|
const IMAGE_PATTERN = '{行业}-{客户}-{脚本方向}-{日期}-{序号}-{规格}';
|
||||||
|
const VIDEO_PATTERN = '{行业}_{客户}_{日期}_{序号}';
|
||||||
|
|
||||||
|
const BASE_CONTEXT: NamingNameContext = {
|
||||||
|
industry: '电商',
|
||||||
|
customer: '花间集',
|
||||||
|
direction: '口播',
|
||||||
|
date: '20261104',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface HarnessOptions {
|
||||||
|
script?: string;
|
||||||
|
image?: string;
|
||||||
|
video?: string;
|
||||||
|
maxLength?: number;
|
||||||
|
duplicateStart?: number;
|
||||||
|
patternError?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(options: HarnessOptions = {}) {
|
||||||
|
const patterns: Record<string, string> = {
|
||||||
|
script: options.script ?? SCRIPT_PATTERN,
|
||||||
|
image: options.image ?? IMAGE_PATTERN,
|
||||||
|
video: options.video ?? VIDEO_PATTERN,
|
||||||
|
};
|
||||||
|
const getNamingPattern = vi.fn((kind: string) => {
|
||||||
|
if (options.patternError === true) return Promise.reject(new Error('配置读取失败'));
|
||||||
|
return Promise.resolve(patterns[kind] ?? SCRIPT_PATTERN);
|
||||||
|
});
|
||||||
|
const getNumber = vi.fn((key: string, fallback?: number) => {
|
||||||
|
if (key === 'naming.max_length') {
|
||||||
|
return Promise.resolve(options.maxLength ?? fallback ?? NAMING_MAX_LENGTH);
|
||||||
|
}
|
||||||
|
return Promise.resolve(options.duplicateStart ?? fallback ?? 2);
|
||||||
|
});
|
||||||
|
const configs = { getNamingPattern, getNumber } as unknown as SystemConfigService;
|
||||||
|
return { service: new NamingService(configs), getNamingPattern, getNumber };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||||
|
return promise.then(
|
||||||
|
() => expect.unreachable('应抛出异常'),
|
||||||
|
(error: unknown) => {
|
||||||
|
expect(error).toBeInstanceOf(AppException);
|
||||||
|
expect((error as AppException).code).toBe(code);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('NamingService · 默认模板', () => {
|
||||||
|
it('脚本:占位符替换 + 序号补零为 01,日期可显式传入', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expect(service.resolveScriptName(BASE_CONTEXT)).resolves.toBe(
|
||||||
|
'电商-花间集-口播-20261104-01',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未传日期时取生成当天(YYYYMMDD)', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
const name = await service.resolveScriptName({
|
||||||
|
industry: '电商',
|
||||||
|
customer: '花间集',
|
||||||
|
direction: '口播',
|
||||||
|
});
|
||||||
|
expect(name).toContain(formatNamingDate(new Date()));
|
||||||
|
expect(name).toMatch(/^电商-花间集-口播-\d{8}-01$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('图片:追加规格后缀;视频:下划线分隔', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expect(service.resolveImageName({ ...BASE_CONTEXT, spec: '1080x1920' })).resolves.toBe(
|
||||||
|
'电商-花间集-口播-20261104-01-1080x1920',
|
||||||
|
);
|
||||||
|
await expect(service.resolveVideoName(BASE_CONTEXT)).resolves.toBe('电商_花间集_20261104_01');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('批次序号可传入(数值补零为两位)', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expect(service.resolveScriptName({ ...BASE_CONTEXT, sequence: 5 })).resolves.toBe(
|
||||||
|
'电商-花间集-口播-20261104-05',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NamingService · 同名序号(R-01)', () => {
|
||||||
|
it('默认从 -02 起连续递增,已占用则继续 +1', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
const base = '电商-花间集-口播-20261104-01';
|
||||||
|
await expect(service.resolveScriptName(BASE_CONTEXT, [base])).resolves.toBe(
|
||||||
|
'电商-花间集-口播-20261104-02',
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
service.resolveScriptName(BASE_CONTEXT, [base, '电商-花间集-口播-20261104-02']),
|
||||||
|
).resolves.toBe('电商-花间集-口播-20261104-03');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无同名冲突时保持原名(不追加序号)', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expect(service.resolveScriptName(BASE_CONTEXT, ['其它名称'])).resolves.toBe(
|
||||||
|
'电商-花间集-口播-20261104-01',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('naming.duplicate_start 可配置起始序号(配置 3 时从 -03 起)', async () => {
|
||||||
|
const { service } = harness({ duplicateStart: 3 });
|
||||||
|
await expect(
|
||||||
|
service.resolveScriptName(BASE_CONTEXT, ['电商-花间集-口播-20261104-01']),
|
||||||
|
).resolves.toBe('电商-花间集-口播-20261104-03');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('NamingService · 校验与业务错误', () => {
|
||||||
|
it('缺失占位符值返回 40001(缺 {客户})', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expectRejected(
|
||||||
|
service.resolveScriptName({ industry: '电商', direction: '口播', date: '20261104' }),
|
||||||
|
ErrorCode.PARAM_INVALID,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('解析结果含非法字符返回 42201', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expectRejected(
|
||||||
|
service.resolveScriptName({ ...BASE_CONTEXT, customer: '花间集*' }),
|
||||||
|
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('超过 naming.max_length 返回 42201;正常名称长度 ≤ 100', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expectRejected(
|
||||||
|
service.resolveScriptName({ ...BASE_CONTEXT, customer: '花'.repeat(110) }),
|
||||||
|
ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
);
|
||||||
|
const name = await service.resolveScriptName(BASE_CONTEXT);
|
||||||
|
expect(name.length).toBeLessThanOrEqual(NAMING_MAX_LENGTH);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('配置读取失败时向上抛出(由调用方决定重试 / 降级)', async () => {
|
||||||
|
const { service } = harness({ patternError: true });
|
||||||
|
await expect(service.resolveScriptName(BASE_CONTEXT)).rejects.toThrow('配置读取失败');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('读取参数使用 naming.* 配置键', async () => {
|
||||||
|
const { service, getNamingPattern, getNumber } = harness();
|
||||||
|
await service.resolveScriptName(BASE_CONTEXT, []);
|
||||||
|
expect(getNamingPattern).toHaveBeenCalledWith('script');
|
||||||
|
expect(getNumber).toHaveBeenCalledWith('naming.max_length', NAMING_MAX_LENGTH);
|
||||||
|
expect(getNumber).toHaveBeenCalledWith('naming.duplicate_start', 2);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
import {
|
||||||
|
dedupeName,
|
||||||
|
ErrorCode,
|
||||||
|
formatNamingDate,
|
||||||
|
formatNamingSequence,
|
||||||
|
NAMING_MAX_LENGTH,
|
||||||
|
type NamingContext,
|
||||||
|
type NamingTokenKey,
|
||||||
|
resolveNamingTemplate,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppException } from '../../../common/errors/app-exception';
|
||||||
|
import { SystemConfigService } from '../system-config/system-config.service';
|
||||||
|
|
||||||
|
/** 调用方只传自己知道的占位符;未传的占位符由 resolveNamingTemplate 报「缺少值」 */
|
||||||
|
export type NamingNameContext = Partial<Record<NamingTokenKey, string | number>>;
|
||||||
|
|
||||||
|
export type NamingKind = 'script' | 'image' | 'video';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 命名规则服务(A4-BE-03 / PRD R-01)。
|
||||||
|
*
|
||||||
|
* 读 `naming.{script|image|video}.pattern` + `naming.max_length` + `naming.duplicate_start`,
|
||||||
|
* 调用 shared 的 `resolveNamingTemplate` / `dedupeName` 生成最终文件名;
|
||||||
|
* 占位符缺失、非法字符、超长、同名过多统一转业务异常(40001 / 42201)。
|
||||||
|
* 供脚本、图片、视频、导出共用,避免各模块各写一套命名。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class NamingService {
|
||||||
|
constructor(@Inject(SystemConfigService) private readonly configs: SystemConfigService) {}
|
||||||
|
|
||||||
|
resolveScriptName(
|
||||||
|
context: NamingNameContext,
|
||||||
|
existingNames?: readonly string[],
|
||||||
|
): Promise<string> {
|
||||||
|
return this.resolve('script', context, existingNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveImageName(context: NamingNameContext, existingNames?: readonly string[]): Promise<string> {
|
||||||
|
return this.resolve('image', context, existingNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveVideoName(context: NamingNameContext, existingNames?: readonly string[]): Promise<string> {
|
||||||
|
return this.resolve('video', context, existingNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolve(
|
||||||
|
kind: NamingKind,
|
||||||
|
input: NamingNameContext,
|
||||||
|
existingNames: readonly string[] | undefined,
|
||||||
|
): Promise<string> {
|
||||||
|
const [pattern, maxLength, duplicateStart] = await Promise.all([
|
||||||
|
this.configs.getNamingPattern(kind),
|
||||||
|
this.configs.getNumber('naming.max_length', NAMING_MAX_LENGTH),
|
||||||
|
this.configs.getNumber('naming.duplicate_start', 2),
|
||||||
|
]);
|
||||||
|
const resolved = this.resolvePattern(pattern, this.contextOf(input), maxLength);
|
||||||
|
const existing = new Set(existingNames ?? []);
|
||||||
|
if (existing.size === 0) return resolved;
|
||||||
|
return this.dedupe(resolved, existing, duplicateStart);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 缺省值:日期取生成当天、序号取 01;其余占位符缺失交给 shared 报错 */
|
||||||
|
private contextOf(input: NamingNameContext, now: Date = new Date()): NamingContext {
|
||||||
|
const rawSequence = input.sequence ?? 1;
|
||||||
|
const sequence =
|
||||||
|
typeof rawSequence === 'number' ? formatNamingSequence(rawSequence) : rawSequence;
|
||||||
|
return {
|
||||||
|
industry: input.industry ?? '',
|
||||||
|
customer: input.customer ?? '',
|
||||||
|
direction: input.direction ?? '',
|
||||||
|
date: input.date === undefined ? formatNamingDate(now) : String(input.date),
|
||||||
|
sequence,
|
||||||
|
spec: input.spec ?? '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private resolvePattern(pattern: string, context: NamingContext, maxLength: number): string {
|
||||||
|
try {
|
||||||
|
return resolveNamingTemplate(pattern, context, { maxLength });
|
||||||
|
} catch (error) {
|
||||||
|
throw this.toAppException(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private dedupe(name: string, existing: ReadonlySet<string>, start: number): string {
|
||||||
|
let candidate: string;
|
||||||
|
try {
|
||||||
|
candidate = dedupeName(name, existing);
|
||||||
|
} catch (error) {
|
||||||
|
throw this.toAppException(error);
|
||||||
|
}
|
||||||
|
return this.applyDuplicateStart(candidate, name, existing, start);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `dedupeName` 固定从 -02 起;`naming.duplicate_start` 可配 2–9。
|
||||||
|
* 配置大于 2 时,把候选序号抬到配置起点,保证「同名序号起始」生效。
|
||||||
|
*/
|
||||||
|
private applyDuplicateStart(
|
||||||
|
candidate: string,
|
||||||
|
base: string,
|
||||||
|
existing: ReadonlySet<string>,
|
||||||
|
start: number,
|
||||||
|
): string {
|
||||||
|
if (start <= 2 || candidate === base) return candidate;
|
||||||
|
const dotIndex = candidate.lastIndexOf('.');
|
||||||
|
const stem = dotIndex > 0 ? candidate.slice(0, dotIndex) : candidate;
|
||||||
|
const extension = dotIndex > 0 ? candidate.slice(dotIndex) : '';
|
||||||
|
const dashIndex = stem.lastIndexOf('-');
|
||||||
|
if (dashIndex <= 0) return candidate;
|
||||||
|
const digits = stem.slice(dashIndex + 1);
|
||||||
|
if (!/^\d{2,}$/.test(digits)) return candidate;
|
||||||
|
const sequence = Number(digits);
|
||||||
|
if (sequence >= start) return candidate;
|
||||||
|
const root = stem.slice(0, dashIndex);
|
||||||
|
for (let next = start; next <= 999; next += 1) {
|
||||||
|
const nextName = `${root}-${String(next).padStart(2, '0')}${extension}`;
|
||||||
|
if (!existing.has(nextName)) return nextName;
|
||||||
|
}
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
message: `同名文件过多,无法为「${base}」生成序号`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private toAppException(error: unknown): AppException {
|
||||||
|
if (error instanceof AppException) return error;
|
||||||
|
const message = error instanceof Error ? error.message : '命名生成失败';
|
||||||
|
if (message.includes('占位符缺少值')) {
|
||||||
|
return new AppException({ code: ErrorCode.PARAM_INVALID, message });
|
||||||
|
}
|
||||||
|
return new AppException({
|
||||||
|
code: ErrorCode.BUSINESS_RULE_VIOLATION,
|
||||||
|
message: `命名规则校验未通过:${message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import { Global, Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { HealthModule } from './health/health.module';
|
import { HealthModule } from './health/health.module';
|
||||||
|
import { NamingService } from './naming/naming.service';
|
||||||
|
import { SystemConfigController } from './system-config/system-config.controller';
|
||||||
|
import { SystemConfigService } from './system-config/system-config.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* platform 模块:健康检查、系统配置、审计、AI 网关等平台能力的归属模块(技术架构 §3)。
|
* platform 模块:健康检查、系统配置、命名规则、审计、AI 网关等平台能力的归属模块
|
||||||
* A0 只落地健康检查;其余能力按里程碑在本模块内新增。
|
* (技术架构 §3)。@Global 导出 SystemConfigService / NamingService,供 identity / client /
|
||||||
|
* 后续 script / media / delivery 直接构造器注入,避免跨模块重复 import(R-26 阈值同源)。
|
||||||
*/
|
*/
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
imports: [HealthModule],
|
imports: [HealthModule],
|
||||||
exports: [HealthModule],
|
controllers: [SystemConfigController],
|
||||||
|
providers: [SystemConfigService, NamingService],
|
||||||
|
exports: [HealthModule, SystemConfigService, NamingService],
|
||||||
})
|
})
|
||||||
export class PlatformModule {}
|
export class PlatformModule {}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import {
|
||||||
|
type EffectiveSystemConfigsResponse,
|
||||||
|
ErrorCode,
|
||||||
|
Permission,
|
||||||
|
type SystemConfigHistoryQuery,
|
||||||
|
systemConfigHistoryQuerySchema,
|
||||||
|
type SystemConfigHistoryResponse,
|
||||||
|
type SystemConfigListResponse,
|
||||||
|
type UpdateSystemConfigsInput,
|
||||||
|
updateSystemConfigsInputSchema,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Body, Controller, Get, Inject, Put, Query } from '@nestjs/common';
|
||||||
|
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { AppException } from '../../../common/errors/app-exception';
|
||||||
|
import { ZodValidationPipe } from '../../../common/pipes/zod-validation.pipe';
|
||||||
|
import type { AuthContext } from '../../../common/security/auth.types';
|
||||||
|
import { Audit, CurrentUser, RequirePermissions } from '../../../common/security/decorators';
|
||||||
|
import { SystemConfigService } from './system-config.service';
|
||||||
|
|
||||||
|
function requireAuth(auth: AuthContext | null): AuthContext {
|
||||||
|
if (auth === null) {
|
||||||
|
throw new AppException({ code: ErrorCode.UNAUTHENTICATED });
|
||||||
|
}
|
||||||
|
return auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置(A4-BE-02 / PRD R-26):
|
||||||
|
* - `/system/configs` 全量读写需要 `system:configure`;
|
||||||
|
* - `/system/configs/effective` 仅要求登录,供前端读取上限(阈值单一来源)。
|
||||||
|
*/
|
||||||
|
@ApiTags('platform-system-configs')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@Controller('system')
|
||||||
|
export class SystemConfigController {
|
||||||
|
constructor(@Inject(SystemConfigService) private readonly configs: SystemConfigService) {}
|
||||||
|
|
||||||
|
@Get('configs')
|
||||||
|
@RequirePermissions(Permission.SYSTEM_CONFIGURE)
|
||||||
|
@ApiOperation({ summary: '全量系统配置(定义元数据 + 当前值 / 版本)' })
|
||||||
|
list(): Promise<SystemConfigListResponse> {
|
||||||
|
return this.configs.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('configs/effective')
|
||||||
|
@ApiOperation({ summary: '生效配置(仅登录;只返回 public 项,前端上限读取入口)' })
|
||||||
|
effective(): Promise<EffectiveSystemConfigsResponse> {
|
||||||
|
return this.configs.getEffective();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('configs')
|
||||||
|
@RequirePermissions(Permission.SYSTEM_CONFIGURE)
|
||||||
|
@Audit('platform.system_config.update')
|
||||||
|
@ApiOperation({ summary: '批量保存配置(乐观锁 expectedVersion;写版本历史与审计)' })
|
||||||
|
update(
|
||||||
|
@Body(new ZodValidationPipe(updateSystemConfigsInputSchema)) input: UpdateSystemConfigsInput,
|
||||||
|
@CurrentUser() auth: AuthContext | null,
|
||||||
|
): Promise<SystemConfigListResponse> {
|
||||||
|
return this.configs.update(input, requireAuth(auth).userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('configs/history')
|
||||||
|
@RequirePermissions(Permission.SYSTEM_CONFIGURE)
|
||||||
|
@ApiOperation({ summary: '配置变更记录(按变更时间倒序;操作人为用户姓名)' })
|
||||||
|
history(
|
||||||
|
@Query(new ZodValidationPipe(systemConfigHistoryQuerySchema))
|
||||||
|
query: SystemConfigHistoryQuery,
|
||||||
|
): Promise<SystemConfigHistoryResponse> {
|
||||||
|
return this.configs.history(query);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,376 @@
|
|||||||
|
import {
|
||||||
|
API_PREFIX,
|
||||||
|
ErrorCode,
|
||||||
|
Permission,
|
||||||
|
SYSTEM_CONFIG_DEFINITIONS,
|
||||||
|
systemConfigDefinition,
|
||||||
|
ulid,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { PrismaClient } from '@prisma/client';
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import { createApp, type CreatedApiApp } from '../../../bootstrap';
|
||||||
|
import { loadDotenv } from '../../../config/load-dotenv';
|
||||||
|
|
||||||
|
const PASSWORD_HASH =
|
||||||
|
'$argon2id$v=19$m=65536,p=4,t=3$Osdgg3Fz53AwcoC2kyGiGg$QVbxW7Ok/SlCNbML95go2J/6GMSfE1EG3jBTJgR4zO4';
|
||||||
|
const PASSWORD = 'lingxi2026';
|
||||||
|
const PREFIX = 'itqa4c.';
|
||||||
|
const REASON = 'ITQA4 集成测试';
|
||||||
|
const CONFIG_KEY = 'limit.page.max';
|
||||||
|
const API = API_PREFIX;
|
||||||
|
|
||||||
|
loadDotenv();
|
||||||
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
interface TokenBody {
|
||||||
|
accessToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JsonResult<T> {
|
||||||
|
status: number;
|
||||||
|
body: T;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemConfigBody {
|
||||||
|
key: string;
|
||||||
|
group: string;
|
||||||
|
label: string;
|
||||||
|
value: string | number | boolean;
|
||||||
|
valueType: string;
|
||||||
|
version: number;
|
||||||
|
updatedBy: string | null;
|
||||||
|
editable: boolean;
|
||||||
|
isPublic: boolean;
|
||||||
|
min: number | null;
|
||||||
|
max: number | null;
|
||||||
|
maxLength: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SystemConfigChangeBody {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
version: number;
|
||||||
|
oldValue: unknown;
|
||||||
|
newValue: unknown;
|
||||||
|
reason: string | null;
|
||||||
|
operator: string | null;
|
||||||
|
changedAt: string;
|
||||||
|
result: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface OriginalConfig {
|
||||||
|
configValue: string | number | boolean;
|
||||||
|
version: number;
|
||||||
|
updatedBy: string | null;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let phoneCounter = 0;
|
||||||
|
let app: CreatedApiApp['app'];
|
||||||
|
let adminToken = '';
|
||||||
|
let readerToken = '';
|
||||||
|
let original: OriginalConfig | null = null;
|
||||||
|
const createdRoleIds: string[] = [];
|
||||||
|
const createdUserIds: string[] = [];
|
||||||
|
|
||||||
|
function nextPhone(): string {
|
||||||
|
phoneCounter += 1;
|
||||||
|
return `134${String(phoneCounter).padStart(8, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRole(suffix: string, permissions: string[]): Promise<string> {
|
||||||
|
const id = ulid();
|
||||||
|
await prisma.role.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
code: `${PREFIX}role_${suffix}`,
|
||||||
|
name: `${PREFIX}角色-${suffix}`,
|
||||||
|
description: 'A4 系统配置集成测试角色',
|
||||||
|
status: 'ACTIVE',
|
||||||
|
permissions: { create: permissions.map((permissionCode) => ({ permissionCode })) },
|
||||||
|
dataScopes: { create: [{ id: ulid(), scopeType: 'all', targetId: null }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdRoleIds.push(id);
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createUser(suffix: string, roleId: string): Promise<void> {
|
||||||
|
const department = await prisma.department.findUniqueOrThrow({ where: { code: 'content-g1' } });
|
||||||
|
const account = `${PREFIX}${suffix}`;
|
||||||
|
const id = ulid();
|
||||||
|
await prisma.user.create({
|
||||||
|
data: {
|
||||||
|
id,
|
||||||
|
name: `A4 集成测试${suffix}`,
|
||||||
|
account,
|
||||||
|
email: `${account}@lingxi.cn`,
|
||||||
|
phone: nextPhone(),
|
||||||
|
passwordHash: PASSWORD_HASH,
|
||||||
|
departmentId: department.id,
|
||||||
|
mustChangePassword: false,
|
||||||
|
roles: { create: [{ roleId }] },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
createdUserIds.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function login(account: string): Promise<string> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: `${API}/auth/login`,
|
||||||
|
payload: { account, password: PASSWORD },
|
||||||
|
});
|
||||||
|
if (response.statusCode !== 200 && response.statusCode !== 201) {
|
||||||
|
throw new Error(`login failed ${response.statusCode}: ${response.body}`);
|
||||||
|
}
|
||||||
|
return response.json<TokenBody>().accessToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function call<T>(
|
||||||
|
method: 'GET' | 'PUT',
|
||||||
|
url: string,
|
||||||
|
token?: string,
|
||||||
|
payload?: unknown,
|
||||||
|
): Promise<JsonResult<T>> {
|
||||||
|
const response = await app.inject({
|
||||||
|
method,
|
||||||
|
url: `${API}${url}`,
|
||||||
|
...(token === undefined ? {} : { headers: { authorization: `Bearer ${token}` } }),
|
||||||
|
...(payload === undefined ? {} : { payload: payload as Record<string, unknown> }),
|
||||||
|
});
|
||||||
|
const body = response.body.length === 0 ? (null as T) : response.json<T>();
|
||||||
|
return { status: response.statusCode, body };
|
||||||
|
}
|
||||||
|
|
||||||
|
function configOf(items: SystemConfigBody[], key: string): SystemConfigBody {
|
||||||
|
const item = items.find((config) => config.key === key);
|
||||||
|
if (item === undefined) throw new Error(`配置项缺失:${key}`);
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function primitive(value: unknown): string | number | boolean {
|
||||||
|
return typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean'
|
||||||
|
? value
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cleanupFixtures(): Promise<void> {
|
||||||
|
await prisma.user.deleteMany({ where: { account: { startsWith: PREFIX } } });
|
||||||
|
if (createdRoleIds.length > 0) {
|
||||||
|
await prisma.role.deleteMany({ where: { id: { in: createdRoleIds } } });
|
||||||
|
}
|
||||||
|
await prisma.systemConfigVersion.deleteMany({
|
||||||
|
where: { configKey: CONFIG_KEY, reason: { startsWith: REASON } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await cleanupFixtures();
|
||||||
|
const adminRole = await createRole('admin', [Permission.SYSTEM_CONFIGURE]);
|
||||||
|
await createUser('admin', adminRole);
|
||||||
|
const readerRole = await createRole('reader', [Permission.CUSTOMER_READ]);
|
||||||
|
await createUser('reader', readerRole);
|
||||||
|
|
||||||
|
const row = await prisma.systemConfig.findUnique({ where: { configKey: CONFIG_KEY } });
|
||||||
|
original =
|
||||||
|
row === null
|
||||||
|
? null
|
||||||
|
: {
|
||||||
|
configValue: primitive(row.configValue),
|
||||||
|
version: row.version,
|
||||||
|
updatedBy: row.updatedBy,
|
||||||
|
description: row.description,
|
||||||
|
};
|
||||||
|
|
||||||
|
const created = await createApp();
|
||||||
|
app = created.app;
|
||||||
|
await app.init();
|
||||||
|
adminToken = await login(`${PREFIX}admin`);
|
||||||
|
readerToken = await login(`${PREFIX}reader`);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (original !== null) {
|
||||||
|
await prisma.systemConfig.update({
|
||||||
|
where: { configKey: CONFIG_KEY },
|
||||||
|
data: {
|
||||||
|
configValue: original.configValue,
|
||||||
|
version: original.version,
|
||||||
|
updatedBy: original.updatedBy,
|
||||||
|
description: original.description,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await cleanupFixtures();
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- beforeAll 失败时 app 未赋值
|
||||||
|
if (app) await app.close();
|
||||||
|
await prisma.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4 系统配置 · 读取与权限三态', () => {
|
||||||
|
it('全量配置:定义清单为准,返回阈值元数据 / 版本 / 公开标记', async () => {
|
||||||
|
const result = await call<{ items: SystemConfigBody[] }>('GET', '/system/configs', adminToken);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
expect(result.body.items).toHaveLength(SYSTEM_CONFIG_DEFINITIONS.length);
|
||||||
|
const limitPage = configOf(result.body.items, CONFIG_KEY);
|
||||||
|
expect(limitPage).toMatchObject({
|
||||||
|
group: 'limits',
|
||||||
|
valueType: 'number',
|
||||||
|
editable: true,
|
||||||
|
isPublic: true,
|
||||||
|
min: 20,
|
||||||
|
max: 500,
|
||||||
|
});
|
||||||
|
expect(limitPage.version).toBeGreaterThanOrEqual(1);
|
||||||
|
const naming = configOf(result.body.items, 'naming.script.pattern');
|
||||||
|
expect(naming.isPublic).toBe(false);
|
||||||
|
expect(naming.value).toBe(systemConfigDefinition('naming.script.pattern')?.defaultValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('权限三态:无权限 403、未登录 401、有权限 200', async () => {
|
||||||
|
const forbidden = await call<{ code: number }>('GET', '/system/configs', readerToken);
|
||||||
|
expect(forbidden.status).toBe(403);
|
||||||
|
expect(forbidden.body.code).toBe(ErrorCode.PERMISSION_DENIED);
|
||||||
|
|
||||||
|
const anonymous = await call<{ code: number }>('GET', '/system/configs');
|
||||||
|
expect(anonymous.status).toBe(401);
|
||||||
|
expect(anonymous.body.code).toBe(ErrorCode.UNAUTHENTICATED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('生效配置:仅登录即可读取,只返回 public 项(前端上限入口 R-26)', async () => {
|
||||||
|
const result = await call<{ items: { key: string; value: string | number | boolean }[] }>(
|
||||||
|
'GET',
|
||||||
|
'/system/configs/effective',
|
||||||
|
readerToken,
|
||||||
|
);
|
||||||
|
expect(result.status).toBe(200);
|
||||||
|
const keys = result.body.items.map((item) => item.key);
|
||||||
|
expect(keys).toContain(CONFIG_KEY);
|
||||||
|
expect(keys).toContain('limit.customer.base_field');
|
||||||
|
expect(keys).not.toContain('naming.script.pattern');
|
||||||
|
expect(keys).not.toContain('platform.export.signed_url_ttl_seconds');
|
||||||
|
|
||||||
|
const anonymous = await call<{ code: number }>('GET', '/system/configs/effective');
|
||||||
|
expect(anonymous.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('A4 系统配置 · 保存 / 版本历史 / 审计', () => {
|
||||||
|
it('未定义 key、非法值均 40001', async () => {
|
||||||
|
const unknownKey = await call<{ code: number }>('PUT', '/system/configs', adminToken, {
|
||||||
|
changes: [{ key: 'unknown.config', value: 1 }],
|
||||||
|
});
|
||||||
|
expect(unknownKey.status).toBe(400);
|
||||||
|
expect(unknownKey.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const invalidValue = await call<{ code: number }>('PUT', '/system/configs', adminToken, {
|
||||||
|
changes: [{ key: CONFIG_KEY, value: 9999 }],
|
||||||
|
});
|
||||||
|
expect(invalidValue.status).toBe(400);
|
||||||
|
expect(invalidValue.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
|
||||||
|
const noPermission = await call<{ code: number }>('PUT', '/system/configs', readerToken, {
|
||||||
|
changes: [{ key: CONFIG_KEY, value: 120 }],
|
||||||
|
});
|
||||||
|
expect(noPermission.status).toBe(403);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expectedVersion 不一致返回 40901', async () => {
|
||||||
|
const current = await call<{ items: SystemConfigBody[] }>('GET', '/system/configs', adminToken);
|
||||||
|
const staleVersion = configOf(current.body.items, CONFIG_KEY).version + 99;
|
||||||
|
const conflict = await call<{ code: number }>('PUT', '/system/configs', adminToken, {
|
||||||
|
changes: [{ key: CONFIG_KEY, value: 120, expectedVersion: staleVersion }],
|
||||||
|
});
|
||||||
|
expect(conflict.status).toBe(409);
|
||||||
|
expect(conflict.body.code).toBe(ErrorCode.VERSION_CONFLICT);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('保存生效:version+1、写版本历史、生效配置同步、操作人解析为姓名', async () => {
|
||||||
|
const before = await call<{ items: SystemConfigBody[] }>('GET', '/system/configs', adminToken);
|
||||||
|
const current = configOf(before.body.items, CONFIG_KEY);
|
||||||
|
const targetValue = current.value === 120 ? 130 : 120;
|
||||||
|
const saved = await call<{ items: SystemConfigBody[] }>('PUT', '/system/configs', adminToken, {
|
||||||
|
changes: [
|
||||||
|
{ key: CONFIG_KEY, value: targetValue, expectedVersion: current.version, reason: REASON },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(saved.status).toBe(200);
|
||||||
|
const updated = configOf(saved.body.items, CONFIG_KEY);
|
||||||
|
expect(updated).toMatchObject({ value: targetValue, version: current.version + 1 });
|
||||||
|
|
||||||
|
const effective = await call<{ items: { key: string; value: unknown }[] }>(
|
||||||
|
'GET',
|
||||||
|
'/system/configs/effective',
|
||||||
|
readerToken,
|
||||||
|
);
|
||||||
|
expect(effective.body.items.find((item) => item.key === CONFIG_KEY)?.value).toBe(targetValue);
|
||||||
|
|
||||||
|
const history = await call<{ items: SystemConfigChangeBody[]; next_cursor: string | null }>(
|
||||||
|
'GET',
|
||||||
|
`/system/configs/history?config_key=${CONFIG_KEY}&limit=20`,
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(history.status).toBe(200);
|
||||||
|
expect(history.body.next_cursor).toBeNull();
|
||||||
|
const latest = history.body.items[0];
|
||||||
|
expect(latest).toMatchObject({
|
||||||
|
key: CONFIG_KEY,
|
||||||
|
label: systemConfigDefinition(CONFIG_KEY)?.label,
|
||||||
|
version: current.version + 1,
|
||||||
|
oldValue: current.value,
|
||||||
|
newValue: targetValue,
|
||||||
|
reason: REASON,
|
||||||
|
operator: 'A4 集成测试admin',
|
||||||
|
result: '已生效',
|
||||||
|
});
|
||||||
|
expect(Number.isNaN(new Date(latest?.changedAt ?? '').getTime())).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('相同值跳过:版本号不变、不新增历史', async () => {
|
||||||
|
const before = await call<{ items: SystemConfigBody[] }>('GET', '/system/configs', adminToken);
|
||||||
|
const current = configOf(before.body.items, CONFIG_KEY);
|
||||||
|
const saved = await call<{ items: SystemConfigBody[] }>('PUT', '/system/configs', adminToken, {
|
||||||
|
changes: [{ key: CONFIG_KEY, value: current.value, expectedVersion: current.version }],
|
||||||
|
});
|
||||||
|
expect(saved.status).toBe(200);
|
||||||
|
expect(configOf(saved.body.items, CONFIG_KEY).version).toBe(current.version);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('恢复测试前的配置值', async () => {
|
||||||
|
const before = await call<{ items: SystemConfigBody[] }>('GET', '/system/configs', adminToken);
|
||||||
|
const current = configOf(before.body.items, CONFIG_KEY);
|
||||||
|
const originalValue = original?.configValue ?? 100;
|
||||||
|
const restored = await call<{ items: SystemConfigBody[] }>(
|
||||||
|
'PUT',
|
||||||
|
'/system/configs',
|
||||||
|
adminToken,
|
||||||
|
{
|
||||||
|
changes: [
|
||||||
|
{
|
||||||
|
key: CONFIG_KEY,
|
||||||
|
value: originalValue,
|
||||||
|
expectedVersion: current.version,
|
||||||
|
reason: `${REASON}-恢复`,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
expect(restored.status).toBe(200);
|
||||||
|
expect(configOf(restored.body.items, CONFIG_KEY).value).toBe(originalValue);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('历史查询:无权限 403;limit 越界 40001', async () => {
|
||||||
|
const forbidden = await call<{ code: number }>('GET', '/system/configs/history', readerToken);
|
||||||
|
expect(forbidden.status).toBe(403);
|
||||||
|
|
||||||
|
const invalidLimit = await call<{ code: number }>(
|
||||||
|
'GET',
|
||||||
|
'/system/configs/history?limit=999',
|
||||||
|
adminToken,
|
||||||
|
);
|
||||||
|
expect(invalidLimit.status).toBe(400);
|
||||||
|
expect(invalidLimit.body.code).toBe(ErrorCode.PARAM_INVALID);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/**
|
||||||
|
* 系统配置 Prisma 行 -> 共享 DTO 映射(A4-BE-02)。
|
||||||
|
*
|
||||||
|
* 元数据(标签 / 说明 / 影响面 / 类型 / 上下限 / 是否公开)唯一来源是
|
||||||
|
* `SYSTEM_CONFIG_DEFINITIONS`;`system_configs` 只提供当前值与版本。
|
||||||
|
* DB 缺行时用定义默认值 + version 1 兜底,不写库。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
type SystemConfig,
|
||||||
|
type SystemConfigChange,
|
||||||
|
type SystemConfigDefinition,
|
||||||
|
type SystemConfigValue,
|
||||||
|
systemConfigValueType,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import type { Prisma } from '@prisma/client';
|
||||||
|
import type {
|
||||||
|
SystemConfig as PrismaSystemConfig,
|
||||||
|
SystemConfigVersion as PrismaSystemConfigVersion,
|
||||||
|
} from '@prisma/client';
|
||||||
|
|
||||||
|
/** 从未落库的配置:用 epoch 表示「未更新过」,避免伪造读取时间 */
|
||||||
|
const NEVER_UPDATED_AT = new Date(0).toISOString();
|
||||||
|
|
||||||
|
/** Prisma JsonB 只应存原始值;异常结构回退配置默认值,避免脏数据打穿前端 schema */
|
||||||
|
export function normalizeConfigValue(
|
||||||
|
raw: Prisma.JsonValue | undefined,
|
||||||
|
fallback: SystemConfigValue,
|
||||||
|
): SystemConfigValue {
|
||||||
|
if (typeof raw === 'string' || typeof raw === 'number' || typeof raw === 'boolean') {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSystemConfig(
|
||||||
|
definition: SystemConfigDefinition,
|
||||||
|
row: PrismaSystemConfig | undefined,
|
||||||
|
updatedByName?: string | null,
|
||||||
|
): SystemConfig {
|
||||||
|
return {
|
||||||
|
key: definition.key,
|
||||||
|
group: definition.group,
|
||||||
|
label: definition.label,
|
||||||
|
description: definition.description,
|
||||||
|
impact: definition.impact,
|
||||||
|
type: definition.type,
|
||||||
|
value: normalizeConfigValue(row?.configValue, definition.defaultValue),
|
||||||
|
valueType: systemConfigValueType(definition),
|
||||||
|
unit: definition.unit ?? null,
|
||||||
|
version: row?.version ?? 1,
|
||||||
|
updatedAt: row === undefined ? NEVER_UPDATED_AT : row.updatedAt.toISOString(),
|
||||||
|
updatedBy: updatedByName ?? row?.updatedBy ?? null,
|
||||||
|
editable: definition.editable,
|
||||||
|
isPublic: definition.public,
|
||||||
|
min: definition.min ?? null,
|
||||||
|
max: definition.max ?? null,
|
||||||
|
step: definition.step ?? null,
|
||||||
|
maxLength: definition.maxLength ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toSystemConfigChange(
|
||||||
|
row: PrismaSystemConfigVersion,
|
||||||
|
label: string,
|
||||||
|
operator: string | null,
|
||||||
|
): SystemConfigChange {
|
||||||
|
return {
|
||||||
|
key: row.configKey,
|
||||||
|
label,
|
||||||
|
version: row.version,
|
||||||
|
oldValue: row.oldValue,
|
||||||
|
newValue: row.newValue,
|
||||||
|
reason: row.reason,
|
||||||
|
operator,
|
||||||
|
changedAt: row.changedAt.toISOString(),
|
||||||
|
result: '已生效',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
import { ErrorCode, SYSTEM_CONFIG_DEFINITIONS, systemConfigDefinition } from '@lingxi/shared';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { AppException } from '../../../common/errors/app-exception';
|
||||||
|
import type { RequestContextService } from '../../../common/security/request-context';
|
||||||
|
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||||
|
import { SystemConfigService } from './system-config.service';
|
||||||
|
|
||||||
|
const NOW = new Date('2026-11-04T02:00:00.000Z');
|
||||||
|
|
||||||
|
function configRow(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
id: '01HZZZZZZZZZZZZZZZZZZZZZZZ',
|
||||||
|
configKey: 'platform.name',
|
||||||
|
configValue: '灵犀',
|
||||||
|
valueType: 'string',
|
||||||
|
description: '平台名称',
|
||||||
|
version: 1,
|
||||||
|
isEditable: true,
|
||||||
|
createdAt: NOW,
|
||||||
|
updatedAt: NOW,
|
||||||
|
updatedBy: null,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Harness {
|
||||||
|
service: SystemConfigService;
|
||||||
|
prisma: {
|
||||||
|
systemConfig: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
systemConfigVersion: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
user: Record<string, ReturnType<typeof vi.fn>>;
|
||||||
|
$transaction: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
patchAudit: ReturnType<typeof vi.fn>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function harness(
|
||||||
|
prismaInput: Record<string, Record<string, ReturnType<typeof vi.fn>>> = {},
|
||||||
|
): Harness {
|
||||||
|
const systemConfig = {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
findUnique: vi.fn().mockResolvedValue(null),
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
update: vi.fn().mockResolvedValue({}),
|
||||||
|
updateMany: vi.fn().mockResolvedValue({ count: 1 }),
|
||||||
|
...prismaInput.systemConfig,
|
||||||
|
};
|
||||||
|
const systemConfigVersion = {
|
||||||
|
findMany: vi.fn().mockResolvedValue([]),
|
||||||
|
create: vi.fn().mockResolvedValue({}),
|
||||||
|
...prismaInput.systemConfigVersion,
|
||||||
|
};
|
||||||
|
const user = { findMany: vi.fn().mockResolvedValue([]), ...prismaInput.user };
|
||||||
|
const $transaction = vi.fn(async (callback: unknown) => {
|
||||||
|
if (typeof callback === 'function') {
|
||||||
|
return (callback as (client: unknown) => Promise<unknown>)({
|
||||||
|
systemConfig,
|
||||||
|
systemConfigVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.all(callback as Promise<unknown>[]);
|
||||||
|
});
|
||||||
|
const patchAudit = vi.fn();
|
||||||
|
const service = new SystemConfigService(
|
||||||
|
{ systemConfig, systemConfigVersion, user, $transaction } as unknown as PrismaService,
|
||||||
|
{ patchAudit } as unknown as RequestContextService,
|
||||||
|
);
|
||||||
|
return { service, prisma: { systemConfig, systemConfigVersion, user, $transaction }, patchAudit };
|
||||||
|
}
|
||||||
|
|
||||||
|
function expectRejected(promise: Promise<unknown>, code: ErrorCode): Promise<void> {
|
||||||
|
return promise.then(
|
||||||
|
() => expect.unreachable('应抛出异常'),
|
||||||
|
(error: unknown) => {
|
||||||
|
expect(error).toBeInstanceOf(AppException);
|
||||||
|
expect((error as AppException).code).toBe(code);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SystemConfigService · 列表与生效配置', () => {
|
||||||
|
it('以定义清单为元数据源合并当前值;DB 缺行用默认值 + version 1 兜底', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
const result = await service.list();
|
||||||
|
expect(result.items).toHaveLength(SYSTEM_CONFIG_DEFINITIONS.length);
|
||||||
|
const platformName = result.items.find((item) => item.key === 'platform.name');
|
||||||
|
expect(platformName).toMatchObject({
|
||||||
|
label: '平台名称',
|
||||||
|
value: '灵犀',
|
||||||
|
valueType: 'string',
|
||||||
|
version: 1,
|
||||||
|
updatedBy: null,
|
||||||
|
isPublic: true,
|
||||||
|
maxLength: 32,
|
||||||
|
min: null,
|
||||||
|
});
|
||||||
|
expect(platformName?.updatedAt).toBe(new Date(0).toISOString());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DB 有行时使用库内值 / 版本 / 更新人,元数据仍取定义', async () => {
|
||||||
|
const { service } = harness({
|
||||||
|
systemConfig: {
|
||||||
|
findMany: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([configRow({ configValue: '灵犀 Pro', version: 4, updatedBy: 'u1' })]),
|
||||||
|
},
|
||||||
|
user: { findMany: vi.fn().mockResolvedValue([{ id: 'u1', name: '江岚' }]) },
|
||||||
|
});
|
||||||
|
const result = await service.list();
|
||||||
|
const platformName = result.items.find((item) => item.key === 'platform.name');
|
||||||
|
expect(platformName).toMatchObject({
|
||||||
|
value: '灵犀 Pro',
|
||||||
|
version: 4,
|
||||||
|
updatedBy: '江岚',
|
||||||
|
updatedAt: NOW.toISOString(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('生效配置只返回 public 项(不含命名模板等非公开配置)', async () => {
|
||||||
|
const { service } = harness({
|
||||||
|
systemConfig: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([configRow({ configValue: '灵犀 Pro' })]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.getEffective();
|
||||||
|
const keys = result.items.map((item) => item.key);
|
||||||
|
expect(keys).toContain('platform.name');
|
||||||
|
expect(keys).toContain('limit.customer.base_field');
|
||||||
|
expect(keys).not.toContain('naming.script.pattern');
|
||||||
|
expect(result.items.find((item) => item.key === 'platform.name')?.value).toBe('灵犀 Pro');
|
||||||
|
expect(result.items.find((item) => item.key === 'limit.customer.base_field')?.value).toBe(10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SystemConfigService · 批量保存', () => {
|
||||||
|
it('未定义 key / 非法值返回 40001', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expectRejected(
|
||||||
|
service.update({ changes: [{ key: 'unknown.key', value: 1 }] }, 'u1'),
|
||||||
|
ErrorCode.PARAM_INVALID,
|
||||||
|
);
|
||||||
|
await expectRejected(
|
||||||
|
service.update({ changes: [{ key: 'limit.customer.base_field', value: 999 }] }, 'u1'),
|
||||||
|
ErrorCode.PARAM_INVALID,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('expectedVersion 不一致返回 40901,且不写库', async () => {
|
||||||
|
const { service, prisma } = harness({
|
||||||
|
systemConfig: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
configRow({ configKey: 'limit.customer.base_field', configValue: 10, version: 2 }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
service.update(
|
||||||
|
{
|
||||||
|
changes: [
|
||||||
|
{ key: 'limit.customer.base_field', value: 12, expectedVersion: 1, reason: '调整上限' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
),
|
||||||
|
ErrorCode.VERSION_CONFLICT,
|
||||||
|
);
|
||||||
|
expect(prisma.systemConfig.updateMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.systemConfigVersion.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('条件更新命中 0 行(并发修改)返回 40901', async () => {
|
||||||
|
const { service } = harness({
|
||||||
|
systemConfig: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
configRow({ configKey: 'limit.customer.base_field', configValue: 10 }),
|
||||||
|
),
|
||||||
|
updateMany: vi.fn().mockResolvedValue({ count: 0 }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expectRejected(
|
||||||
|
service.update({ changes: [{ key: 'limit.customer.base_field', value: 12 }] }, 'u1'),
|
||||||
|
ErrorCode.VERSION_CONFLICT,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('值相同跳过:不写库、不写版本历史', async () => {
|
||||||
|
const { service, prisma, patchAudit } = harness({
|
||||||
|
systemConfig: {
|
||||||
|
findUnique: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue(
|
||||||
|
configRow({ configKey: 'limit.customer.base_field', configValue: 10 }),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await service.update({ changes: [{ key: 'limit.customer.base_field', value: 10 }] }, 'u1');
|
||||||
|
expect(prisma.systemConfig.updateMany).not.toHaveBeenCalled();
|
||||||
|
expect(prisma.systemConfigVersion.create).not.toHaveBeenCalled();
|
||||||
|
expect(patchAudit).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('成功保存:version+1、写版本历史(old/new/reason/changedBy)与审计前后值', async () => {
|
||||||
|
const before = configRow({
|
||||||
|
configKey: 'limit.customer.base_field',
|
||||||
|
configValue: 10,
|
||||||
|
version: 1,
|
||||||
|
});
|
||||||
|
const { service, prisma, patchAudit } = harness({
|
||||||
|
systemConfig: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(before),
|
||||||
|
findMany: vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue([
|
||||||
|
configRow({ configKey: 'limit.customer.base_field', configValue: 12, version: 2 }),
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.update(
|
||||||
|
{
|
||||||
|
changes: [
|
||||||
|
{ key: 'limit.customer.base_field', value: 12, expectedVersion: 1, reason: 'A4 验收' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'u1',
|
||||||
|
);
|
||||||
|
expect(prisma.systemConfig.updateMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
where: { configKey: 'limit.customer.base_field', version: 1 },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
configValue: 12,
|
||||||
|
version: { increment: 1 },
|
||||||
|
updatedBy: 'u1',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(prisma.systemConfigVersion.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({
|
||||||
|
configKey: 'limit.customer.base_field',
|
||||||
|
version: 2,
|
||||||
|
oldValue: 10,
|
||||||
|
newValue: 12,
|
||||||
|
reason: 'A4 验收',
|
||||||
|
changedBy: 'u1',
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(patchAudit).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
targetType: 'system_config',
|
||||||
|
before: { 'limit.customer.base_field': 10 },
|
||||||
|
after: { 'limit.customer.base_field': 12 },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.items).toHaveLength(SYSTEM_CONFIG_DEFINITIONS.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DB 缺行时首次落库 version 1 并写版本历史(oldValue 为空)', async () => {
|
||||||
|
const { service, prisma } = harness({
|
||||||
|
systemConfig: { findUnique: vi.fn().mockResolvedValue(null) },
|
||||||
|
});
|
||||||
|
await service.update({ changes: [{ key: 'platform.name', value: '灵犀 Pro' }] }, 'u1');
|
||||||
|
expect(prisma.systemConfig.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ version: 1, configValue: '灵犀 Pro' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(prisma.systemConfigVersion.create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
data: expect.objectContaining({ version: 1, newValue: '灵犀 Pro' }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SystemConfigService · 变更历史', () => {
|
||||||
|
it('按倒序读取、解析操作人姓名、label 取定义、result 固定已生效', async () => {
|
||||||
|
const { service, prisma } = harness({
|
||||||
|
systemConfigVersion: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'v1',
|
||||||
|
configKey: 'limit.customer.base_field',
|
||||||
|
version: 2,
|
||||||
|
oldValue: 10,
|
||||||
|
newValue: 12,
|
||||||
|
reason: 'A4 验收',
|
||||||
|
changedBy: 'u1',
|
||||||
|
changedAt: NOW,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
user: { findMany: vi.fn().mockResolvedValue([{ id: 'u1', name: '江岚' }]) },
|
||||||
|
});
|
||||||
|
const result = await service.history({ limit: 50 });
|
||||||
|
expect(prisma.systemConfigVersion.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ orderBy: [{ changedAt: 'desc' }, { id: 'desc' }], take: 50 }),
|
||||||
|
);
|
||||||
|
expect(result.next_cursor).toBeNull();
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
key: 'limit.customer.base_field',
|
||||||
|
label: systemConfigDefinition('limit.customer.base_field')?.label,
|
||||||
|
version: 2,
|
||||||
|
oldValue: 10,
|
||||||
|
newValue: 12,
|
||||||
|
reason: 'A4 验收',
|
||||||
|
operator: '江岚',
|
||||||
|
changedAt: NOW.toISOString(),
|
||||||
|
result: '已生效',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('支持 config_key 过滤;操作人用户已删除时 operator 为 null', async () => {
|
||||||
|
const { service, prisma } = harness({
|
||||||
|
systemConfigVersion: {
|
||||||
|
findMany: vi.fn().mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: 'v2',
|
||||||
|
configKey: 'platform.name',
|
||||||
|
version: 3,
|
||||||
|
oldValue: '灵犀',
|
||||||
|
newValue: '灵犀 Pro',
|
||||||
|
reason: null,
|
||||||
|
changedBy: 'deleted-user',
|
||||||
|
changedAt: NOW,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.history({ config_key: 'platform.name', limit: 20 });
|
||||||
|
expect(prisma.systemConfigVersion.findMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { configKey: 'platform.name' } }),
|
||||||
|
);
|
||||||
|
expect(result.items[0]?.operator).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('SystemConfigService · 阈值读取(R-26)', () => {
|
||||||
|
it('缺行时回退定义默认值;读库异常时回退调用方兜底值', async () => {
|
||||||
|
const { service } = harness();
|
||||||
|
await expect(service.getNumber('limit.customer.base_field')).resolves.toBe(10);
|
||||||
|
await expect(service.getBoolean('notify.task.completed')).resolves.toBe(true);
|
||||||
|
await expect(service.getString('platform.name')).resolves.toBe('灵犀');
|
||||||
|
await expect(service.getNamingPattern('script')).resolves.toBe(
|
||||||
|
'{行业}-{客户}-{脚本方向}-{日期}-{序号}',
|
||||||
|
);
|
||||||
|
|
||||||
|
const broken = harness({
|
||||||
|
systemConfig: { findUnique: vi.fn().mockRejectedValue(new Error('connect ECONNREFUSED')) },
|
||||||
|
});
|
||||||
|
await expect(broken.service.getNumber('limit.role.max', 20)).resolves.toBe(20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('库内命名模板非法时回退定义默认模板', async () => {
|
||||||
|
const { service } = harness({
|
||||||
|
systemConfig: {
|
||||||
|
findUnique: vi.fn().mockResolvedValue(configRow({ configValue: '{未知占位符}*' })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await expect(service.getNamingPattern('image')).resolves.toBe(
|
||||||
|
'{行业}-{客户}-{脚本方向}-{日期}-{序号}-{规格}',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
/* eslint-disable max-lines-per-function, complexity -- 批量事务更新需逐条校验 / 乐观锁 / 版本历史,集中保证事务边界与审计口径一致 */
|
||||||
|
import {
|
||||||
|
type EffectiveSystemConfigsResponse,
|
||||||
|
ErrorCode,
|
||||||
|
NAMING_MAX_LENGTH,
|
||||||
|
SYSTEM_CONFIG_DEFINITIONS,
|
||||||
|
systemConfigDefinition,
|
||||||
|
type SystemConfigHistoryQuery,
|
||||||
|
type SystemConfigHistoryResponse,
|
||||||
|
type SystemConfigListResponse,
|
||||||
|
type SystemConfigValue,
|
||||||
|
systemConfigValueType,
|
||||||
|
ulid,
|
||||||
|
type UpdateSystemConfigsInput,
|
||||||
|
validateNamingTemplate,
|
||||||
|
validateSystemConfigValue,
|
||||||
|
} from '@lingxi/shared';
|
||||||
|
import { Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { AppException } from '../../../common/errors/app-exception';
|
||||||
|
import { RequestContextService } from '../../../common/security/request-context';
|
||||||
|
import type { PrismaService } from '../../../infra/prisma/prisma.service';
|
||||||
|
import { PRISMA } from '../../../infra/tokens';
|
||||||
|
import { normalizeConfigValue, toSystemConfig, toSystemConfigChange } from './system-config.mapper';
|
||||||
|
|
||||||
|
interface ConfigChangeSnapshot {
|
||||||
|
key: string;
|
||||||
|
oldValue: SystemConfigValue | null;
|
||||||
|
newValue: SystemConfigValue;
|
||||||
|
version: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type NamingKind = 'script' | 'image' | 'video';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置(A4-BE-02 / PRD R-26):阈值 / 上限的唯一读取入口。
|
||||||
|
*
|
||||||
|
* - 元数据来自 `SYSTEM_CONFIG_DEFINITIONS`,值来自 `system_configs`;
|
||||||
|
* - 批量保存走事务 + 乐观锁(expectedVersion,冲突 40901),每次变更写 `system_config_versions`;
|
||||||
|
* - 其他模块只通过本 Service 读配置,禁止再硬编码阈值(R-26)。
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SystemConfigService {
|
||||||
|
constructor(
|
||||||
|
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||||||
|
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** 全量配置(含非公开项,需 system:configure) */
|
||||||
|
async list(): Promise<SystemConfigListResponse> {
|
||||||
|
const rows = await this.prisma.systemConfig.findMany();
|
||||||
|
const byKey = new Map(rows.map((row) => [row.configKey, row]));
|
||||||
|
const nameById = await this.userNames(
|
||||||
|
rows.flatMap((row) => (row.updatedBy === null ? [] : [row.updatedBy])),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
items: SYSTEM_CONFIG_DEFINITIONS.map((definition) => {
|
||||||
|
const row = byKey.get(definition.key);
|
||||||
|
return toSystemConfig(
|
||||||
|
definition,
|
||||||
|
row,
|
||||||
|
row?.updatedBy === null || row?.updatedBy === undefined
|
||||||
|
? null
|
||||||
|
: (nameById.get(row.updatedBy) ?? null),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 生效配置:只返回 public 项,供前端读取上限(R-26),仅登录即可 */
|
||||||
|
async getEffective(): Promise<EffectiveSystemConfigsResponse> {
|
||||||
|
const publicDefinitions = SYSTEM_CONFIG_DEFINITIONS.filter((definition) => definition.public);
|
||||||
|
const rows = await this.prisma.systemConfig.findMany({
|
||||||
|
where: { configKey: { in: publicDefinitions.map((definition) => definition.key) } },
|
||||||
|
});
|
||||||
|
const byKey = new Map(rows.map((row) => [row.configKey, row]));
|
||||||
|
return {
|
||||||
|
items: publicDefinitions.map((definition) => ({
|
||||||
|
key: definition.key,
|
||||||
|
value: normalizeConfigValue(
|
||||||
|
byKey.get(definition.key)?.configValue,
|
||||||
|
definition.defaultValue,
|
||||||
|
),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 批量保存:事务内逐条校验 + 乐观锁 + 版本历史;值未变化可跳过(不写历史)。
|
||||||
|
* 返回保存后的全量配置,便于前端原地刷新版本号。
|
||||||
|
*/
|
||||||
|
async update(
|
||||||
|
input: UpdateSystemConfigsInput,
|
||||||
|
actorId: string,
|
||||||
|
): Promise<SystemConfigListResponse> {
|
||||||
|
const snapshots = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const changed: ConfigChangeSnapshot[] = [];
|
||||||
|
for (const change of input.changes) {
|
||||||
|
const definition = systemConfigDefinition(change.key);
|
||||||
|
if (definition === undefined) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: `配置项不存在:${change.key}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const validated = validateSystemConfigValue(definition, change.value);
|
||||||
|
if (!validated.ok) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.PARAM_INVALID,
|
||||||
|
message: `${definition.label}:${validated.message}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const row = await tx.systemConfig.findUnique({ where: { configKey: change.key } });
|
||||||
|
const baseVersion = row?.version ?? 1;
|
||||||
|
if (change.expectedVersion !== undefined && change.expectedVersion !== baseVersion) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.VERSION_CONFLICT,
|
||||||
|
message: `配置「${definition.label}」已被他人修改,请刷新后重试`,
|
||||||
|
details: {
|
||||||
|
key: change.key,
|
||||||
|
expectedVersion: change.expectedVersion,
|
||||||
|
currentVersion: baseVersion,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const baseValue = normalizeConfigValue(row?.configValue, definition.defaultValue);
|
||||||
|
if (baseValue === validated.value) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const nextVersion = row === null ? 1 : row.version + 1;
|
||||||
|
if (row === null) {
|
||||||
|
await tx.systemConfig.create({
|
||||||
|
data: {
|
||||||
|
id: ulid(),
|
||||||
|
configKey: change.key,
|
||||||
|
configValue: validated.value,
|
||||||
|
valueType: systemConfigValueType(definition),
|
||||||
|
description: definition.description,
|
||||||
|
version: 1,
|
||||||
|
isEditable: definition.editable,
|
||||||
|
updatedBy: actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await tx.systemConfigVersion.create({
|
||||||
|
data: {
|
||||||
|
id: ulid(),
|
||||||
|
configKey: change.key,
|
||||||
|
version: 1,
|
||||||
|
newValue: validated.value,
|
||||||
|
...(change.reason === undefined ? {} : { reason: change.reason }),
|
||||||
|
changedBy: actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// 条件更新(DB-29):并发保存时只有一个请求能命中旧 version,其余返回 40901
|
||||||
|
const conditional = await tx.systemConfig.updateMany({
|
||||||
|
where: { configKey: change.key, version: row.version },
|
||||||
|
data: {
|
||||||
|
configValue: validated.value,
|
||||||
|
version: { increment: 1 },
|
||||||
|
description: definition.description,
|
||||||
|
updatedBy: actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (conditional.count === 0) {
|
||||||
|
throw new AppException({
|
||||||
|
code: ErrorCode.VERSION_CONFLICT,
|
||||||
|
message: `配置「${definition.label}」已被他人修改,请刷新后重试`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
await tx.systemConfigVersion.create({
|
||||||
|
data: {
|
||||||
|
id: ulid(),
|
||||||
|
configKey: change.key,
|
||||||
|
version: nextVersion,
|
||||||
|
oldValue: baseValue,
|
||||||
|
newValue: validated.value,
|
||||||
|
reason: change.reason ?? null,
|
||||||
|
changedBy: actorId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
changed.push({
|
||||||
|
key: change.key,
|
||||||
|
oldValue: row === null ? null : baseValue,
|
||||||
|
newValue: validated.value,
|
||||||
|
version: nextVersion,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (snapshots.length > 0) {
|
||||||
|
this.requestContext.patchAudit({
|
||||||
|
targetType: 'system_config',
|
||||||
|
targetId: snapshots.map((snapshot) => snapshot.key).join(','),
|
||||||
|
before: Object.fromEntries(snapshots.map((snapshot) => [snapshot.key, snapshot.oldValue])),
|
||||||
|
after: Object.fromEntries(snapshots.map((snapshot) => [snapshot.key, snapshot.newValue])),
|
||||||
|
changes: snapshots,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return this.list();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 变更记录:按 changed_at desc;操作人解析为用户姓名 */
|
||||||
|
async history(query: SystemConfigHistoryQuery): Promise<SystemConfigHistoryResponse> {
|
||||||
|
const rows = await this.prisma.systemConfigVersion.findMany({
|
||||||
|
where: query.config_key === undefined ? {} : { configKey: query.config_key },
|
||||||
|
orderBy: [{ changedAt: 'desc' }, { id: 'desc' }],
|
||||||
|
take: query.limit,
|
||||||
|
});
|
||||||
|
const changedByIds = [
|
||||||
|
...new Set(rows.flatMap((row) => (row.changedBy === null ? [] : [row.changedBy]))),
|
||||||
|
];
|
||||||
|
const nameById = await this.userNames(changedByIds);
|
||||||
|
return {
|
||||||
|
items: rows.map((row) => {
|
||||||
|
const label = systemConfigDefinition(row.configKey)?.label ?? row.configKey;
|
||||||
|
const operator = row.changedBy === null ? null : (nameById.get(row.changedBy) ?? null);
|
||||||
|
return toSystemConfigChange(row, label, operator);
|
||||||
|
}),
|
||||||
|
next_cursor: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量解析 updated_by / changed_by(ULID)为用户姓名,供列表与历史展示 */
|
||||||
|
private async userNames(userIds: readonly string[]): Promise<Map<string, string>> {
|
||||||
|
const uniqueIds = [...new Set(userIds)];
|
||||||
|
if (uniqueIds.length === 0) return new Map();
|
||||||
|
const users = await this.prisma.user.findMany({
|
||||||
|
where: { id: { in: uniqueIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
return new Map(users.map((user) => [user.id, user.name]));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
// 供其他模块读取配置(R-26):读失败 / 类型不符时回退定义默认值或调用方兜底
|
||||||
|
// -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async getNumber(key: string, fallback?: number): Promise<number> {
|
||||||
|
const value = await this.readValue(key);
|
||||||
|
if (typeof value === 'number' && Number.isFinite(value)) return value;
|
||||||
|
if (fallback !== undefined) return fallback;
|
||||||
|
const definition = systemConfigDefinition(key);
|
||||||
|
if (definition !== undefined && typeof definition.defaultValue === 'number') {
|
||||||
|
return definition.defaultValue;
|
||||||
|
}
|
||||||
|
throw new Error(`系统配置「${key}」不是数字且缺少兜底值`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getBoolean(key: string, fallback?: boolean): Promise<boolean> {
|
||||||
|
const value = await this.readValue(key);
|
||||||
|
if (typeof value === 'boolean') return value;
|
||||||
|
if (fallback !== undefined) return fallback;
|
||||||
|
const definition = systemConfigDefinition(key);
|
||||||
|
if (definition !== undefined && typeof definition.defaultValue === 'boolean') {
|
||||||
|
return definition.defaultValue;
|
||||||
|
}
|
||||||
|
throw new Error(`系统配置「${key}」不是布尔值且缺少兜底值`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getString(key: string, fallback?: string): Promise<string> {
|
||||||
|
const value = await this.readValue(key);
|
||||||
|
if (typeof value === 'string' && value.length > 0) return value;
|
||||||
|
if (fallback !== undefined) return fallback;
|
||||||
|
const definition = systemConfigDefinition(key);
|
||||||
|
if (definition !== undefined && typeof definition.defaultValue === 'string') {
|
||||||
|
return definition.defaultValue;
|
||||||
|
}
|
||||||
|
throw new Error(`系统配置「${key}」不是字符串且缺少兜底值`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读取命名模板类配置:库内模板非法时回退默认模板,避免脏配置导致命名全线失败 */
|
||||||
|
async getPattern(key: string, fallback?: string): Promise<string> {
|
||||||
|
const definition = systemConfigDefinition(key);
|
||||||
|
const value = await this.readValue(key);
|
||||||
|
const maxLength = definition?.maxLength ?? NAMING_MAX_LENGTH;
|
||||||
|
if (typeof value === 'string' && validateNamingTemplate(value, { maxLength }).valid) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (fallback !== undefined) return fallback;
|
||||||
|
if (definition !== undefined && typeof definition.defaultValue === 'string') {
|
||||||
|
return definition.defaultValue;
|
||||||
|
}
|
||||||
|
throw new Error(`系统配置「${key}」不是合法命名模板且缺少兜底值`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async getNamingPattern(kind: NamingKind): Promise<string> {
|
||||||
|
return this.getPattern(`naming.${kind}.pattern`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 读库失败(连接抖动等)不阻断业务:返回 undefined,由调用方兜底默认值 */
|
||||||
|
private async readValue(key: string): Promise<SystemConfigValue | undefined> {
|
||||||
|
const definition = systemConfigDefinition(key);
|
||||||
|
try {
|
||||||
|
const row = await this.prisma.systemConfig.findUnique({
|
||||||
|
where: { configKey: key },
|
||||||
|
select: { configValue: true },
|
||||||
|
});
|
||||||
|
if (row === null) return undefined;
|
||||||
|
return normalizeConfigValue(row.configValue, definition?.defaultValue ?? '');
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -922,6 +922,256 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/dicts": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "DictsController_list",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "字典类型列表(分组顺序 + 排序;含条目 / 行业层统计)",
|
||||||
|
"tags": [
|
||||||
|
"catalog-dicts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "DictsController_create",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "新建字典类型(编码唯一;builtin=false)",
|
||||||
|
"tags": [
|
||||||
|
"catalog-dicts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/dicts/{code}": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "DictsController_detail",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "字典类型详情(不存在 40401)",
|
||||||
|
"tags": [
|
||||||
|
"catalog-dicts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"patch": {
|
||||||
|
"operationId": "DictsController_update",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "更新字典类型(编码 / 形态 / 行业作用域不可改)",
|
||||||
|
"tags": [
|
||||||
|
"catalog-dicts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"operationId": "DictsController_remove",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"204": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "删除自定义字典类型(内置 / 有子条目时不可删)",
|
||||||
|
"tags": [
|
||||||
|
"catalog-dicts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/dicts/{code}/status": {
|
||||||
|
"patch": {
|
||||||
|
"operationId": "DictsController_setStatus",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "启用 / 停用字典类型(内置类型允许停用)",
|
||||||
|
"tags": [
|
||||||
|
"catalog-dicts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/dicts/{code}/items": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "DictsController_listItems",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "生效条目列表(industry_id 三层合并;非行业类型忽略该参数)",
|
||||||
|
"tags": [
|
||||||
|
"catalog-dicts"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"post": {
|
||||||
|
"operationId": "DictsController_createItem",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "新建字典条目(编码 / 父级 / 行业校验;编码创建后不可改)",
|
||||||
|
"tags": [
|
||||||
|
"catalog-dicts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/dicts/{code}/items/{itemId}": {
|
||||||
|
"patch": {
|
||||||
|
"operationId": "DictsController_updateItem",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "更新字典条目(仅名称 / 排序 / 启停 / 备注)",
|
||||||
|
"tags": [
|
||||||
|
"catalog-dicts"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/system/configs": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "SystemConfigController_list",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "全量系统配置(定义元数据 + 当前值 / 版本)",
|
||||||
|
"tags": [
|
||||||
|
"platform-system-configs"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"put": {
|
||||||
|
"operationId": "SystemConfigController_update",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "批量保存配置(乐观锁 expectedVersion;写版本历史与审计)",
|
||||||
|
"tags": [
|
||||||
|
"platform-system-configs"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/system/configs/effective": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "SystemConfigController_effective",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "生效配置(仅登录;只返回 public 项,前端上限读取入口)",
|
||||||
|
"tags": [
|
||||||
|
"platform-system-configs"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/system/configs/history": {
|
||||||
|
"get": {
|
||||||
|
"operationId": "SystemConfigController_history",
|
||||||
|
"parameters": [],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"bearer": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "配置变更记录(按变更时间倒序;操作人为用户姓名)",
|
||||||
|
"tags": [
|
||||||
|
"platform-system-configs"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/health": {
|
"/health": {
|
||||||
"get": {
|
"get": {
|
||||||
"description": "真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。",
|
"description": "真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。",
|
||||||
|
|||||||
Reference in new issue
Block a user