AuditInterceptor 成功 / 失败双写 audit_logs(响应前 await、脱敏、sink 缺失退化 Pino);登录 / 权限 / 客户 / 字典 / 配置等既有动作补审计;审计列表 / 详情 / 动作目录 / 异步 CSV 导出 / HMAC 签名下载;分区维护与保留归档占位;OpenAPI 快照更新。
283 lines
9.8 KiB
TypeScript
283 lines
9.8 KiB
TypeScript
/* eslint-disable max-lines-per-function -- 申请 / 审批共用字段状态与上限校验,集中便于审计 */
|
||
import {
|
||
type ApplyCustomerFieldInput,
|
||
type ApproveCustomerFieldInput,
|
||
CUSTOMER_GEN_FIELD_LIMIT,
|
||
type CustomerField,
|
||
type CustomerFieldListItem,
|
||
type CustomerFieldListQuery,
|
||
ErrorCode,
|
||
type ListResponse,
|
||
ulid,
|
||
} from '@lingxi/shared';
|
||
import { Inject, Injectable } from '@nestjs/common';
|
||
import { Prisma } from '@prisma/client';
|
||
|
||
import { AppException } from '../../common/errors/app-exception';
|
||
import { RequestContextService } from '../../common/security/request-context';
|
||
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 = {
|
||
applicant: { select: { name: true } },
|
||
approver: { select: { name: true } },
|
||
} satisfies Prisma.CustomerFieldInclude;
|
||
|
||
const FIELD_LIST_INCLUDE = {
|
||
...FIELD_INCLUDE,
|
||
customer: {
|
||
select: {
|
||
id: true,
|
||
name: true,
|
||
shortName: true,
|
||
industry: { include: { parent: true } },
|
||
},
|
||
},
|
||
} satisfies Prisma.CustomerFieldInclude;
|
||
|
||
function industryPath(customer: {
|
||
industry: { name: string; parent: { name: string } | null };
|
||
}): string {
|
||
return customer.industry.parent === null
|
||
? customer.industry.name
|
||
: `${customer.industry.parent.name} / ${customer.industry.name}`;
|
||
}
|
||
|
||
/**
|
||
* 客户级字段(A3-BE-04):基础字段走 customers.custom_fields(≤10,免审批);
|
||
* 本表只承载进入生成 / 需要审批的字段(≤5)。
|
||
*/
|
||
@Injectable()
|
||
export class CustomerFieldsService {
|
||
constructor(
|
||
@Inject(PRISMA) private readonly prisma: PrismaService,
|
||
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
|
||
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
|
||
) {}
|
||
|
||
async list(
|
||
query: CustomerFieldListQuery,
|
||
scopeInput: ScopeContext | null,
|
||
): Promise<ListResponse<CustomerFieldListItem>> {
|
||
const scope = requireScopeContext(scopeInput);
|
||
const rows = await this.prisma.customerField.findMany({
|
||
where: {
|
||
customer: {
|
||
deletedAt: null,
|
||
...(scope.customerIds === null ? {} : { id: { in: [...scope.customerIds] } }),
|
||
},
|
||
...(query.customer_id === undefined ? {} : { customerId: query.customer_id }),
|
||
...(query.status === 'all'
|
||
? {}
|
||
: {
|
||
status:
|
||
query.status === 'pending'
|
||
? 'PENDING'
|
||
: query.status === 'active'
|
||
? 'ACTIVE'
|
||
: query.status === 'rejected'
|
||
? 'REJECTED'
|
||
: 'DISABLED',
|
||
}),
|
||
},
|
||
include: FIELD_LIST_INCLUDE,
|
||
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
|
||
take: query.limit + 1,
|
||
...(query.cursor === undefined ? {} : { cursor: { id: query.cursor }, skip: 1 }),
|
||
});
|
||
const page = rows.slice(0, query.limit);
|
||
const next = rows.length > query.limit ? (page[page.length - 1]?.id ?? null) : null;
|
||
return {
|
||
items: page.map((row) => ({
|
||
...toCustomerField(row as CustomerFieldRow),
|
||
customerName: row.customer.name,
|
||
customerShortName: row.customer.shortName,
|
||
industryPath: industryPath(row.customer),
|
||
})),
|
||
next_cursor: next,
|
||
};
|
||
}
|
||
|
||
async listByCustomer(
|
||
customerId: string,
|
||
scopeInput: ScopeContext | null,
|
||
): Promise<CustomerField[]> {
|
||
const scope = requireScopeContext(scopeInput);
|
||
if (!isCustomerVisible(scope, customerId)) {
|
||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||
}
|
||
const rows = await this.prisma.customerField.findMany({
|
||
where: { customerId, customer: { deletedAt: null } },
|
||
include: FIELD_INCLUDE,
|
||
orderBy: [{ status: 'asc' }, { appliedAt: 'desc' }],
|
||
});
|
||
return rows.map((row) => toCustomerField(row as CustomerFieldRow));
|
||
}
|
||
|
||
async apply(
|
||
customerId: string,
|
||
input: ApplyCustomerFieldInput,
|
||
actorId: string,
|
||
scopeInput: ScopeContext | null,
|
||
): Promise<CustomerField> {
|
||
const scope = requireScopeContext(scopeInput);
|
||
if (!isCustomerVisible(scope, customerId)) {
|
||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||
}
|
||
const customer = await this.prisma.customer.findFirst({
|
||
where: { id: customerId, deletedAt: null },
|
||
select: { id: true, name: true, status: true },
|
||
});
|
||
if (customer === null) {
|
||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
|
||
}
|
||
if (customer.status !== 'ACTIVE') {
|
||
throw new AppException({
|
||
code: ErrorCode.STATE_NOT_ALLOWED,
|
||
message: customer.status === 'PAUSED' ? '客户已暂停,字段只读' : '客户已结束,字段只读归档',
|
||
});
|
||
}
|
||
const duplicate = await this.prisma.customerField.findUnique({
|
||
where: { customerId_code: { customerId, code: input.code } },
|
||
select: { id: true },
|
||
});
|
||
if (duplicate !== null) {
|
||
throw new AppException({
|
||
code: ErrorCode.DUPLICATE_SUBMIT,
|
||
message: '该客户下字段编码已存在',
|
||
});
|
||
}
|
||
if (input.entersGeneration) {
|
||
const used = await this.genUsedCount(customerId);
|
||
await this.assertGenFieldLimit(used);
|
||
}
|
||
const id = ulid();
|
||
await this.prisma.customerField.create({
|
||
data: {
|
||
id,
|
||
customerId,
|
||
name: input.name,
|
||
code: input.code,
|
||
type: input.type,
|
||
options: input.options,
|
||
purpose: input.purpose,
|
||
entersGeneration: input.entersGeneration,
|
||
status: input.entersGeneration ? 'PENDING' : 'ACTIVE',
|
||
applicantUserId: actorId,
|
||
...(input.entersGeneration ? {} : { approvedAt: new Date(), approverUserId: actorId }),
|
||
},
|
||
});
|
||
const row = await this.prisma.customerField.findUniqueOrThrow({
|
||
where: { id },
|
||
include: FIELD_INCLUDE,
|
||
});
|
||
this.requestContext.patchAudit({
|
||
targetType: 'customer_field',
|
||
targetId: id,
|
||
targetName: input.name,
|
||
customerId,
|
||
customerName: customer.name,
|
||
after: {
|
||
name: input.name,
|
||
code: input.code,
|
||
type: input.type,
|
||
purpose: input.purpose,
|
||
entersGeneration: input.entersGeneration,
|
||
status: input.entersGeneration ? 'pending' : 'active',
|
||
},
|
||
});
|
||
return toCustomerField(row);
|
||
}
|
||
|
||
async approve(
|
||
id: string,
|
||
input: ApproveCustomerFieldInput,
|
||
actorId: string,
|
||
scopeInput: ScopeContext | null,
|
||
): Promise<CustomerField> {
|
||
const scope = requireScopeContext(scopeInput);
|
||
const existing = await this.prisma.customerField.findUnique({
|
||
where: { id },
|
||
include: { customer: { select: { id: true, name: true, deletedAt: true } } },
|
||
});
|
||
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain -- 保留显式 null 分支,跨客户统一 404
|
||
if (existing === null || existing.customer.deletedAt !== null) {
|
||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字段申请不存在' });
|
||
}
|
||
if (!isCustomerVisible(scope, existing.customerId)) {
|
||
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '字段申请不存在' });
|
||
}
|
||
if (existing.status !== 'PENDING') {
|
||
throw new AppException({ code: ErrorCode.STATE_NOT_ALLOWED, message: '该申请已处理' });
|
||
}
|
||
if (input.decision === 'approve' && existing.entersGeneration) {
|
||
const used = await this.genUsedCount(existing.customerId, id);
|
||
await this.assertGenFieldLimit(used, '审批后仍超限');
|
||
}
|
||
await this.prisma.customerField.update({
|
||
where: { id },
|
||
data:
|
||
input.decision === 'approve'
|
||
? {
|
||
status: 'ACTIVE',
|
||
approverUserId: actorId,
|
||
approvedAt: new Date(),
|
||
rejectReason: null,
|
||
}
|
||
: {
|
||
status: 'REJECTED',
|
||
approverUserId: actorId,
|
||
approvedAt: new Date(),
|
||
rejectReason: input.reason ?? '',
|
||
},
|
||
});
|
||
const row = await this.prisma.customerField.findUniqueOrThrow({
|
||
where: { id },
|
||
include: FIELD_INCLUDE,
|
||
});
|
||
this.requestContext.patchAudit({
|
||
targetType: 'customer_field',
|
||
targetId: id,
|
||
targetName: existing.name,
|
||
customerId: existing.customerId,
|
||
customerName: existing.customer.name,
|
||
before: { status: 'pending', approverUserId: null },
|
||
after: {
|
||
status: input.decision === 'approve' ? 'active' : 'rejected',
|
||
approverUserId: actorId,
|
||
reason: input.decision === 'approve' ? null : (input.reason ?? ''),
|
||
},
|
||
});
|
||
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: {
|
||
customerId,
|
||
entersGeneration: true,
|
||
status: { not: 'REJECTED' },
|
||
...(excludeId === undefined ? {} : { id: { not: excludeId } }),
|
||
},
|
||
});
|
||
}
|
||
}
|