diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 3dd062d..5e00bb4 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -7,6 +7,7 @@ import { APP_CONFIG, AppConfigModule } from './config/config.module'; import type { AppConfig } from './config/env'; import { PrismaModule } from './infra/prisma/prisma.module'; import { RedisModule } from './infra/redis/redis.module'; +import { CatalogModule } from './modules/catalog/catalog.module'; import { ClientModule } from './modules/client/client.module'; import { IdentityModule } from './modules/identity/identity.module'; import { PlatformModule } from './modules/platform/platform.module'; @@ -51,6 +52,7 @@ import { PlatformModule } from './modules/platform/platform.module'; SecurityModule, IdentityModule, ClientModule, + CatalogModule, PlatformModule, ], }) diff --git a/apps/api/src/modules/catalog/catalog.int.spec.ts b/apps/api/src/modules/catalog/catalog.int.spec.ts new file mode 100644 index 0000000..ee1fd08 --- /dev/null +++ b/apps/api/src/modules/catalog/catalog.int.spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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().accessToken; +} + +async function call( + method: 'GET' | 'POST' | 'PATCH' | 'DELETE', + url: string, + token: string, + payload?: unknown, +): Promise> { + const response = await app.inject({ + method, + url: `${API}${url}`, + headers: { authorization: `Bearer ${token}` }, + ...(payload === undefined ? {} : { payload: payload as Record }), + }); + const body = response.body.length === 0 ? (null as T) : response.json(); + return { status: response.statusCode, body }; +} + +async function createType( + code: string, + body: Record, +): Promise { + const created = await call('POST', '/dicts', adminToken, { + code, + ...body, + }); + expect(created.status).toBe(201); + return created.body; +} + +async function createItem(typeCode: string, body: Record): Promise { + const created = await call('POST', `/dicts/${typeCode}/items`, adminToken, body); + expect(created.status).toBe(201); + return created.body; +} + +async function cleanupFixtures(): Promise { + 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('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( + '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( + 'PATCH', + `/dicts/${industryTypeCode}/status`, + adminToken, + { status: 'disabled' }, + ); + expect(disabled.status).toBe(200); + expect(disabled.body.status).toBe('disabled'); + const enabled = await call( + '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('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( + '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( + '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( + '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( + '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( + '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); + }); +}); diff --git a/apps/api/src/modules/catalog/catalog.module.ts b/apps/api/src/modules/catalog/catalog.module.ts new file mode 100644 index 0000000..59587f6 --- /dev/null +++ b/apps/api/src/modules/catalog/catalog.module.ts @@ -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 {} diff --git a/apps/api/src/modules/catalog/dict-merge.spec.ts b/apps/api/src/modules/catalog/dict-merge.spec.ts new file mode 100644 index 0000000..3d1bed5 --- /dev/null +++ b/apps/api/src/modules/catalog/dict-merge.spec.ts @@ -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 & Pick): 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); + }); +}); diff --git a/apps/api/src/modules/catalog/dict-merge.ts b/apps/api/src/modules/catalog/dict-merge.ts new file mode 100644 index 0000000..ccb116a --- /dev/null +++ b/apps/api/src/modules/catalog/dict-merge.ts @@ -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> = { + 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(); + + 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, + }; +} diff --git a/apps/api/src/modules/catalog/dict.mapper.spec.ts b/apps/api/src/modules/catalog/dict.mapper.spec.ts new file mode 100644 index 0000000..9f57c48 --- /dev/null +++ b/apps/api/src/modules/catalog/dict.mapper.spec.ts @@ -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); + }); +}); diff --git a/apps/api/src/modules/catalog/dict.mapper.ts b/apps/api/src/modules/catalog/dict.mapper.ts new file mode 100644 index 0000000..6100c97 --- /dev/null +++ b/apps/api/src/modules/catalog/dict.mapper.ts @@ -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); +} diff --git a/apps/api/src/modules/catalog/dicts.controller.ts b/apps/api/src/modules/catalog/dicts.controller.ts new file mode 100644 index 0000000..7c7d377 --- /dev/null +++ b/apps/api/src/modules/catalog/dicts.controller.ts @@ -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> { + return this.dicts.list(query); + } + + @Get(':code') + @ApiOperation({ summary: '字典类型详情(不存在 40401)' }) + detail(@Param('code') code: string): Promise { + 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 { + 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 { + 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 { + 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 { + await this.dicts.remove(code); + } + + @Get(':code/items') + @ApiOperation({ summary: '生效条目列表(industry_id 三层合并;非行业类型忽略该参数)' }) + listItems( + @Param('code') code: string, + @Query(new ZodValidationPipe(dictItemListQuerySchema)) query: DictItemListQuery, + ): Promise { + 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 { + 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 { + return this.dicts.updateItem(code, itemId, input, requireAuth(auth).userId); + } +} diff --git a/apps/api/src/modules/catalog/dicts.service.spec.ts b/apps/api/src/modules/catalog/dicts.service.spec.ts new file mode 100644 index 0000000..d3c2be0 --- /dev/null +++ b/apps/api/src/modules/catalog/dicts.service.spec.ts @@ -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 { + 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 { + 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>; + dictItem: Record>; + industry: Record>; + user: Record>; + }; + patchAudit: ReturnType; +} + +function harness( + prismaInput: Record>> = {}, +): 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, code: ErrorCode): Promise { + 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: '口播(改)' }), + }), + ); + }); +}); diff --git a/apps/api/src/modules/catalog/dicts.service.ts b/apps/api/src/modules/catalog/dicts.service.ts new file mode 100644 index 0000000..863d74c --- /dev/null +++ b/apps/api/src/modules/catalog/dicts.service.ts @@ -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; +} + +interface DictTypeAuditSnapshot { + code: string; + name: string; + group: DictGroupKey; + description: string; + sortOrder: number; + status: 'active' | 'disabled'; +} + +const GROUP_ORDER: ReadonlyMap = 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + 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 { + 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 { + 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 { + 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> { + 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 } + >(); + for (const row of grouped) { + const entry = counts.get(row.typeId) ?? { + itemCount: 0, + activeItemCount: 0, + industries: new Set(), + }; + 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 { + 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(); + 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 { + 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 { + 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 { + const user = await this.prisma.user.findUnique({ + where: { id: actorId }, + select: { name: true }, + }); + return user?.name ?? actorId; + } +} diff --git a/apps/api/src/modules/client/customer-fields.service.ts b/apps/api/src/modules/client/customer-fields.service.ts index c33410f..fe59b90 100644 --- a/apps/api/src/modules/client/customer-fields.service.ts +++ b/apps/api/src/modules/client/customer-fields.service.ts @@ -18,6 +18,7 @@ import type { ScopeContext } from '../../common/security/scope/scope-context'; import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context'; import type { PrismaService } from '../../infra/prisma/prisma.service'; import { PRISMA } from '../../infra/tokens'; +import { SystemConfigService } from '../platform/system-config/system-config.service'; import { type CustomerFieldRow, toCustomerField } from './client.mapper'; const FIELD_INCLUDE = { @@ -51,7 +52,10 @@ function industryPath(customer: { */ @Injectable() 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( query: CustomerFieldListQuery, @@ -147,12 +151,7 @@ export class CustomerFieldsService { } if (input.entersGeneration) { const used = await this.genUsedCount(customerId); - if (used >= CUSTOMER_GEN_FIELD_LIMIT) { - throw new AppException({ - code: ErrorCode.PARAM_INVALID, - message: `进入生成字段已达 ${CUSTOMER_GEN_FIELD_LIMIT} 个上限,请先停用或驳回其他字段`, - }); - } + await this.assertGenFieldLimit(used); } const id = ulid(); await this.prisma.customerField.create({ @@ -200,12 +199,7 @@ export class CustomerFieldsService { } if (input.decision === 'approve' && existing.entersGeneration) { const used = await this.genUsedCount(existing.customerId, id); - if (used >= CUSTOMER_GEN_FIELD_LIMIT) { - throw new AppException({ - code: ErrorCode.PARAM_INVALID, - message: `进入生成字段已达 ${CUSTOMER_GEN_FIELD_LIMIT} 个上限,审批后仍超限`, - }); - } + await this.assertGenFieldLimit(used, '审批后仍超限'); } await this.prisma.customerField.update({ where: { id }, @@ -231,6 +225,20 @@ export class CustomerFieldsService { return toCustomerField(row); } + /** 进入生成字段上限读 system_configs(R-26);shared 常量仅作读失败兜底(默认 5) */ + private async assertGenFieldLimit(used: number, hint = '请先停用或驳回其他字段'): Promise { + 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 { return this.prisma.customerField.count({ where: { diff --git a/apps/api/src/modules/client/customers.service.ts b/apps/api/src/modules/client/customers.service.ts index 9e0a298..5e5df94 100644 --- a/apps/api/src/modules/client/customers.service.ts +++ b/apps/api/src/modules/client/customers.service.ts @@ -22,6 +22,7 @@ import type { ScopeContext } from '../../common/security/scope/scope-context'; import { isCustomerVisible, requireScopeContext } from '../../common/security/scope/scope-context'; import type { PrismaService } from '../../infra/prisma/prisma.service'; import { PRISMA } from '../../infra/tokens'; +import { SystemConfigService } from '../platform/system-config/system-config.service'; import { type CustomerSummaryRow, toCustomerDetail, toCustomerSummary } from './client.mapper'; import { IndustriesService } from './industries.service'; @@ -61,6 +62,7 @@ export class CustomersService { constructor( @Inject(PRISMA) private readonly prisma: PrismaService, @Inject(IndustriesService) private readonly industries: IndustriesService, + @Inject(SystemConfigService) private readonly systemConfig: SystemConfigService, ) {} async list( @@ -126,7 +128,7 @@ export class CustomersService { await this.industries.assertLeafIndustry(input.industryId); await this.ensureOwner(input.ownerUserId); const baseFields = input.baseFields ?? []; - this.assertBaseFieldLimit(baseFields.length); + await this.assertBaseFieldLimit(baseFields.length); const id = ulid(); try { await this.prisma.customer.create({ @@ -184,7 +186,7 @@ export class CustomersService { if (input.ownerUserId !== undefined && input.ownerUserId !== existing.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) { const duplicate = await this.prisma.customer.findFirst({ where: { @@ -377,11 +379,16 @@ export class CustomersService { } } - private assertBaseFieldLimit(count: number): void { - if (count > CUSTOMER_BASE_FIELD_LIMIT) { + /** 基础字段上限读 system_configs(R-26);shared 常量仅作读失败兜底(默认 10) */ + private async assertBaseFieldLimit(count: number): Promise { + const limit = await this.systemConfig.getNumber( + 'limit.customer.base_field', + CUSTOMER_BASE_FIELD_LIMIT, + ); + if (count > limit) { throw new AppException({ code: ErrorCode.PARAM_INVALID, - message: `基础字段最多 ${CUSTOMER_BASE_FIELD_LIMIT} 个,当前 ${count} 个`, + message: `基础字段最多 ${String(limit)} 个,当前 ${String(count)} 个`, }); } } diff --git a/apps/api/src/modules/identity/identity.services.spec.ts b/apps/api/src/modules/identity/identity.services.spec.ts index f558f12..1695ca1 100644 --- a/apps/api/src/modules/identity/identity.services.spec.ts +++ b/apps/api/src/modules/identity/identity.services.spec.ts @@ -5,6 +5,7 @@ import { AppException } from '../../common/errors/app-exception'; import type { UserAuthRecord } from '../../common/security/permission.service'; import type { RequestContextService } from '../../common/security/request-context'; import type { PrismaService } from '../../infra/prisma/prisma.service'; +import type { SystemConfigService } from '../platform/system-config/system-config.service'; import { DepartmentsService } from './departments.service'; import { LoginFailureService } from './login-failure.service'; import { MeService } from './me.service'; @@ -213,10 +214,15 @@ describe('LoginFailureService', () => { describe('UsersService', () => { 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): UsersService { return new UsersService( prisma as unknown as PrismaService, passwords as unknown as PasswordService, + systemConfig, ); } @@ -598,6 +604,9 @@ describe('MeService / RolesService', () => { const service = new RolesService( prisma as unknown as PrismaService, { 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({ status: 'active', diff --git a/apps/api/src/modules/identity/roles.service.spec.ts b/apps/api/src/modules/identity/roles.service.spec.ts index 4a39aa7..d8b853c 100644 --- a/apps/api/src/modules/identity/roles.service.spec.ts +++ b/apps/api/src/modules/identity/roles.service.spec.ts @@ -4,6 +4,7 @@ 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 type { SystemConfigService } from '../platform/system-config/system-config.service'; import { RolesService } from './roles.service'; const NOW = new Date('2026-11-03T02:00:00.000Z'); @@ -102,10 +103,14 @@ function harness(): Harness { get: vi.fn().mockReturnValue({ traceId: 'trace-1' }), patchAudit: vi.fn(), }; + const systemConfig = { + getNumber: vi.fn((_key: string, fallback?: number) => Promise.resolve(fallback ?? 0)), + } as unknown as SystemConfigService; return { service: new RolesService( prisma as unknown as PrismaService, requestContext as unknown as RequestContextService, + systemConfig, ), prisma, requestContext, diff --git a/apps/api/src/modules/identity/roles.service.ts b/apps/api/src/modules/identity/roles.service.ts index a041696..dd645f1 100644 --- a/apps/api/src/modules/identity/roles.service.ts +++ b/apps/api/src/modules/identity/roles.service.ts @@ -28,6 +28,7 @@ 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 { SystemConfigService } from '../platform/system-config/system-config.service'; import { iso, maskPhone } from './user.mapper'; const ROLE_LIST_INCLUDE = { @@ -238,6 +239,7 @@ export class RolesService { constructor( @Inject(PRISMA) private readonly prisma: PrismaService, @Inject(RequestContextService) private readonly requestContext: RequestContextService, + @Inject(SystemConfigService) private readonly systemConfig: SystemConfigService, ) {} async list(query: RoleListQuery): Promise> { @@ -694,22 +696,27 @@ export class RolesService { } } - /** 软提示(不阻断):角色数量 / 超管人数超过 PRD 建议值;A4 迁 system_configs */ + /** 软提示(不阻断):角色 / 超管人数超过建议值;阈值读 system_configs(R-26),常量仅作读失败兜底 */ private async buildWarnings(roleCode: string): Promise { 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' } }); - if (activeRoleCount > ROLE_SOFT_LIMIT) { + if (activeRoleCount > roleSoftLimit) { warnings.push( - `启用角色数 ${String(activeRoleCount)} 已超过建议上限 ${String(ROLE_SOFT_LIMIT)},建议合并角色`, + `启用角色数 ${String(activeRoleCount)} 已超过建议上限 ${String(roleSoftLimit)},建议合并角色`, ); } 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({ where: { status: 'ACTIVE', roles: { some: { role: { code: 'super_admin' } } } }, }); - if (superAdminCount > SUPER_ADMIN_SOFT_LIMIT) { + if (superAdminCount > superAdminSoftLimit) { warnings.push( - `超级管理员 ${String(superAdminCount)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人`, + `超级管理员 ${String(superAdminCount)} 人已超过建议上限 ${String(superAdminSoftLimit)} 人`, ); } } diff --git a/apps/api/src/modules/identity/users.service.ts b/apps/api/src/modules/identity/users.service.ts index f2d9a7c..af407fc 100644 --- a/apps/api/src/modules/identity/users.service.ts +++ b/apps/api/src/modules/identity/users.service.ts @@ -17,6 +17,7 @@ import { Prisma } from '@prisma/client'; import { AppException } from '../../common/errors/app-exception'; import type { PrismaService } from '../../infra/prisma/prisma.service'; import { PRISMA } from '../../infra/tokens'; +import { SystemConfigService } from '../platform/system-config/system-config.service'; import { PasswordService } from './password.service'; import { toUserDetail, toUserSummary, type UserWithRelations } from './user.mapper'; @@ -37,6 +38,7 @@ export class UsersService { constructor( @Inject(PRISMA) private readonly prisma: PrismaService, @Inject(PasswordService) private readonly passwords: PasswordService, + @Inject(SystemConfigService) private readonly systemConfig: SystemConfigService, ) {} async list(query: UserListQuery): Promise> { @@ -181,8 +183,8 @@ export class UsersService { } /** - * 超管人数软上限(PRD §5.4.1 建议 ≤ 2 人):只记录告警,不阻断保存; - * 前端「超管授予二次确认」是主要提示手段。A4 阈值迁 system_configs。 + * 超管人数软上限(PRD §5.4.1):只记录告警,不阻断保存; + * 前端「超管授予二次确认」是主要提示手段。阈值读 system_configs(R-26),常量仅作读失败兜底。 */ private async warnIfSuperAdminExceeded(roleIds: readonly string[]): Promise { const superAdminRole = await this.prisma.role.findUnique({ @@ -192,12 +194,16 @@ export class UsersService { if (superAdminRole === null || !roleIds.includes(superAdminRole.id)) { return; } + const superAdminSoftLimit = await this.systemConfig.getNumber( + 'limit.super_admin.max', + SUPER_ADMIN_SOFT_LIMIT, + ); const count = await this.prisma.user.count({ where: { status: 'ACTIVE', roles: { some: { roleId: superAdminRole.id } } }, }); - if (count > SUPER_ADMIN_SOFT_LIMIT) { + if (count > superAdminSoftLimit) { this.logger.warn( - `超级管理员 ${String(count)} 人已超过建议上限 ${String(SUPER_ADMIN_SOFT_LIMIT)} 人(仅提示,不阻断)`, + `超级管理员 ${String(count)} 人已超过建议上限 ${String(superAdminSoftLimit)} 人(仅提示,不阻断)`, ); } } diff --git a/apps/api/src/modules/platform/naming/naming.service.spec.ts b/apps/api/src/modules/platform/naming/naming.service.spec.ts new file mode 100644 index 0000000..8b1301e --- /dev/null +++ b/apps/api/src/modules/platform/naming/naming.service.spec.ts @@ -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 = { + 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, code: ErrorCode): Promise { + 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); + }); +}); diff --git a/apps/api/src/modules/platform/naming/naming.service.ts b/apps/api/src/modules/platform/naming/naming.service.ts new file mode 100644 index 0000000..83600c1 --- /dev/null +++ b/apps/api/src/modules/platform/naming/naming.service.ts @@ -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>; + +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 { + return this.resolve('script', context, existingNames); + } + + resolveImageName(context: NamingNameContext, existingNames?: readonly string[]): Promise { + return this.resolve('image', context, existingNames); + } + + resolveVideoName(context: NamingNameContext, existingNames?: readonly string[]): Promise { + return this.resolve('video', context, existingNames); + } + + private async resolve( + kind: NamingKind, + input: NamingNameContext, + existingNames: readonly string[] | undefined, + ): Promise { + 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, 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, + 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}`, + }); + } +} diff --git a/apps/api/src/modules/platform/platform.module.ts b/apps/api/src/modules/platform/platform.module.ts index dcc6df6..3cd1826 100644 --- a/apps/api/src/modules/platform/platform.module.ts +++ b/apps/api/src/modules/platform/platform.module.ts @@ -1,14 +1,20 @@ import { Global, Module } from '@nestjs/common'; 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)。 - * A0 只落地健康检查;其余能力按里程碑在本模块内新增。 + * platform 模块:健康检查、系统配置、命名规则、审计、AI 网关等平台能力的归属模块 + * (技术架构 §3)。@Global 导出 SystemConfigService / NamingService,供 identity / client / + * 后续 script / media / delivery 直接构造器注入,避免跨模块重复 import(R-26 阈值同源)。 */ @Global() @Module({ imports: [HealthModule], - exports: [HealthModule], + controllers: [SystemConfigController], + providers: [SystemConfigService, NamingService], + exports: [HealthModule, SystemConfigService, NamingService], }) export class PlatformModule {} diff --git a/apps/api/src/modules/platform/system-config/system-config.controller.ts b/apps/api/src/modules/platform/system-config/system-config.controller.ts new file mode 100644 index 0000000..395d3a9 --- /dev/null +++ b/apps/api/src/modules/platform/system-config/system-config.controller.ts @@ -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 { + return this.configs.list(); + } + + @Get('configs/effective') + @ApiOperation({ summary: '生效配置(仅登录;只返回 public 项,前端上限读取入口)' }) + effective(): Promise { + 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 { + 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 { + return this.configs.history(query); + } +} diff --git a/apps/api/src/modules/platform/system-config/system-config.int.spec.ts b/apps/api/src/modules/platform/system-config/system-config.int.spec.ts new file mode 100644 index 0000000..138af8c --- /dev/null +++ b/apps/api/src/modules/platform/system-config/system-config.int.spec.ts @@ -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 { + 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 { + 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 { + 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 { + 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().accessToken; +} + +async function call( + method: 'GET' | 'PUT', + url: string, + token?: string, + payload?: unknown, +): Promise> { + const response = await app.inject({ + method, + url: `${API}${url}`, + ...(token === undefined ? {} : { headers: { authorization: `Bearer ${token}` } }), + ...(payload === undefined ? {} : { payload: payload as Record }), + }); + const body = response.body.length === 0 ? (null as T) : response.json(); + 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 { + 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); + }); +}); diff --git a/apps/api/src/modules/platform/system-config/system-config.mapper.ts b/apps/api/src/modules/platform/system-config/system-config.mapper.ts new file mode 100644 index 0000000..8737d62 --- /dev/null +++ b/apps/api/src/modules/platform/system-config/system-config.mapper.ts @@ -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: '已生效', + }; +} diff --git a/apps/api/src/modules/platform/system-config/system-config.service.spec.ts b/apps/api/src/modules/platform/system-config/system-config.service.spec.ts new file mode 100644 index 0000000..7f1d62e --- /dev/null +++ b/apps/api/src/modules/platform/system-config/system-config.service.spec.ts @@ -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 = {}): Record { + 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>; + systemConfigVersion: Record>; + user: Record>; + $transaction: ReturnType; + }; + patchAudit: ReturnType; +} + +function harness( + prismaInput: Record>> = {}, +): 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)({ + systemConfig, + systemConfigVersion, + }); + } + return Promise.all(callback as Promise[]); + }); + 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, code: ErrorCode): Promise { + 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( + '{行业}-{客户}-{脚本方向}-{日期}-{序号}-{规格}', + ); + }); +}); diff --git a/apps/api/src/modules/platform/system-config/system-config.service.ts b/apps/api/src/modules/platform/system-config/system-config.service.ts new file mode 100644 index 0000000..a321479 --- /dev/null +++ b/apps/api/src/modules/platform/system-config/system-config.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + return this.getPattern(`naming.${kind}.pattern`); + } + + /** 读库失败(连接抖动等)不阻断业务:返回 undefined,由调用方兜底默认值 */ + private async readValue(key: string): Promise { + 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; + } + } +} diff --git a/openapi/lingxi.openapi.json b/openapi/lingxi.openapi.json index 1679556..7d9bfa9 100644 --- a/openapi/lingxi.openapi.json +++ b/openapi/lingxi.openapi.json @@ -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": { "get": { "description": "真实检查 PostgreSQL 与 Redis;任一不可用返回 503 与统一错误体。",