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

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

No files matched your search

+2
View File
@@ -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);
});
});
+101
View File
@@ -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);
});
});
+129
View File
@@ -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,
+12 -5
View File
@@ -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)} 人`,
); );
} }
} }
+10 -4
View File
@@ -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;
}
}
}
+250
View File
@@ -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 与统一错误体。",