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

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

No files matched your search

@@ -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<void> {
const limit = await this.systemConfig.getNumber(
'limit.customer.gen_field',
CUSTOMER_GEN_FIELD_LIMIT,
);
if (used >= limit) {
throw new AppException({
code: ErrorCode.PARAM_INVALID,
message: `进入生成字段已达 ${String(limit)} 个上限,${hint}`,
});
}
}
private async genUsedCount(customerId: string, excludeId?: string): Promise<number> {
return this.prisma.customerField.count({
where: {
@@ -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<void> {
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)} 个`,
});
}
}