feat(api): A5 审计采集、检索与导出

AuditInterceptor 成功 / 失败双写 audit_logs(响应前 await、脱敏、sink 缺失退化 Pino);登录 / 权限 / 客户 / 字典 / 配置等既有动作补审计;审计列表 / 详情 / 动作目录 / 异步 CSV 导出 / HMAC 签名下载;分区维护与保留归档占位;OpenAPI 快照更新。
This commit is contained in:
Stev_Wang committed 2026-11-04 16:26:42 +08:00
1 parent 6215f204ce
commit 6bdda6ec62
44 files changed
+3845 -127

No files matched your search

@@ -14,6 +14,7 @@ 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';
@@ -55,6 +56,7 @@ export class CustomerFieldsService {
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
) {}
async list(
@@ -128,7 +130,7 @@ export class CustomerFieldsService {
}
const customer = await this.prisma.customer.findFirst({
where: { id: customerId, deletedAt: null },
select: { id: true, status: true },
select: { id: true, name: true, status: true },
});
if (customer === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
@@ -173,6 +175,21 @@ export class CustomerFieldsService {
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);
}
@@ -185,7 +202,7 @@ export class CustomerFieldsService {
const scope = requireScopeContext(scopeInput);
const existing = await this.prisma.customerField.findUnique({
where: { id },
include: { customer: { select: { id: true, deletedAt: true } } },
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) {
@@ -222,6 +239,19 @@ export class CustomerFieldsService {
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);
}
@@ -13,6 +13,7 @@ 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 { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import {
@@ -45,7 +46,10 @@ function isUniqueViolation(error: unknown): boolean {
/** 客户组(A3-BE-03):授权与数据范围最小单位;成员增删幂等。 */
@Injectable()
export class CustomerGroupsService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
) {}
async list(query: CustomerGroupListQuery): Promise<ListResponse<CustomerGroupSummary>> {
const rows = await this.prisma.customerGroup.findMany({
@@ -96,11 +100,21 @@ export class CustomerGroupsService {
if (input.customerIds !== undefined && input.customerIds.length > 0) {
await this.addMembers(id, input.customerIds);
}
this.requestContext.patchAudit({
targetType: 'customer_group',
targetId: id,
targetName: input.name,
after: {
name: input.name,
description: input.description ?? '',
customerIds: input.customerIds ?? [],
},
});
return this.detail(id);
}
async update(id: string, input: UpdateCustomerGroupInput): Promise<CustomerGroupDetail> {
await this.findByIdOrThrow(id);
const existing = await this.findByIdOrThrow(id);
if (input.name !== undefined) await this.assertNameUnique(input.name, id);
await this.prisma.customerGroup.update({
where: { id },
@@ -109,11 +123,21 @@ export class CustomerGroupsService {
...(input.description === undefined ? {} : { description: input.description }),
},
});
this.requestContext.patchAudit({
targetType: 'customer_group',
targetId: id,
targetName: input.name ?? existing.name,
before: { name: existing.name, description: existing.description },
after: {
name: input.name ?? existing.name,
description: input.description ?? existing.description,
},
});
return this.detail(id);
}
async remove(id: string): Promise<void> {
await this.findByIdOrThrow(id);
const existing = await this.findByIdOrThrow(id);
const referenced = await this.prisma.dataScope.count({ where: { targetId: id } });
if (referenced > 0) {
throw new AppException({
@@ -122,14 +146,32 @@ export class CustomerGroupsService {
});
}
await this.prisma.customerGroup.delete({ where: { id } });
this.requestContext.patchAudit({
targetType: 'customer_group',
targetId: id,
targetName: existing.name,
before: { name: existing.name, description: existing.description },
});
}
async updateMembers(
id: string,
input: UpdateCustomerGroupMembersInput,
): Promise<CustomerGroupDetail> {
await this.findByIdOrThrow(id);
const existing = await this.findByIdOrThrow(id);
if (input.addCustomerIds.length > 0) await this.assertCustomersExist(input.addCustomerIds);
const current = await this.prisma.customerGroupMember.findMany({
where: { groupId: id },
select: { customerId: true },
});
const beforeMemberIds = current.map((item) => item.customerId).sort();
const removed = new Set(input.removeCustomerIds);
const afterMemberIds = [
...new Set([
...beforeMemberIds.filter((customerId) => !removed.has(customerId)),
...input.addCustomerIds,
]),
].sort();
await this.prisma.$transaction([
this.prisma.customerGroupMember.deleteMany({
where: { groupId: id, customerId: { in: [...new Set(input.removeCustomerIds)] } },
@@ -143,6 +185,13 @@ export class CustomerGroupsService {
}),
this.prisma.customerGroup.update({ where: { id }, data: { updatedAt: new Date() } }),
]);
this.requestContext.patchAudit({
targetType: 'customer_group',
targetId: id,
targetName: existing.name,
before: { customerIds: beforeMemberIds },
after: { customerIds: afterMemberIds },
});
return this.detail(id);
}
@@ -18,6 +18,7 @@ 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';
@@ -63,6 +64,7 @@ export class CustomersService {
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(IndustriesService) private readonly industries: IndustriesService,
@Inject(SystemConfigService) private readonly systemConfig: SystemConfigService,
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
) {}
async list(
@@ -155,6 +157,23 @@ export class CustomersService {
}
throw error;
}
this.requestContext.patchAudit({
targetType: 'customer',
targetId: id,
targetName: input.name,
customerId: id,
customerName: input.name,
after: {
name: input.name,
shortName: input.shortName,
industryId: input.industryId,
ownerUserId: input.ownerUserId,
contactName: input.contactName ?? null,
contactPhone: input.contactPhone ?? null,
baseFields,
remark: input.remark ?? null,
},
});
return this.detail(id, scope);
}
@@ -217,6 +236,33 @@ export class CustomersService {
...(input.remark === undefined ? {} : { remark: input.remark }),
},
});
this.requestContext.patchAudit({
targetType: 'customer',
targetId: id,
targetName: input.name ?? existing.name,
customerId: id,
customerName: input.name ?? existing.name,
before: {
name: existing.name,
shortName: existing.shortName,
ownerUserId: existing.ownerUserId,
contactName: existing.contactName,
contactPhone: existing.contactPhone,
preferences: existing.preferences,
baseFields: existing.customFields,
remark: existing.remark,
},
after: {
name: input.name ?? existing.name,
shortName: input.shortName ?? existing.shortName,
ownerUserId: input.ownerUserId ?? existing.ownerUserId,
contactName: input.contactName ?? existing.contactName,
contactPhone: input.contactPhone ?? existing.contactPhone,
preferences: input.preferences ?? existing.preferences,
baseFields: input.baseFields ?? existing.customFields,
remark: input.remark ?? existing.remark,
},
});
return this.detail(id, scope);
}
@@ -240,6 +286,22 @@ export class CustomersService {
const target =
input.status === 'active' ? 'ACTIVE' : input.status === 'paused' ? 'PAUSED' : 'ENDED';
await this.prisma.customer.update({ where: { id }, data: { status: target } });
this.requestContext.patchAudit({
targetType: 'customer',
targetId: id,
targetName: existing.name,
customerId: id,
customerName: existing.name,
before: {
status:
existing.status === 'ACTIVE'
? 'active'
: existing.status === 'PAUSED'
? 'paused'
: 'ended',
},
after: { status: input.status },
});
return this.detail(id, scope);
}
@@ -266,6 +328,15 @@ export class CustomersService {
}
await this.ensureOwner(input.ownerUserId);
await this.prisma.customer.update({ where: { id }, data: { ownerUserId: input.ownerUserId } });
this.requestContext.patchAudit({
targetType: 'customer',
targetId: id,
targetName: existing.name,
customerId: id,
customerName: existing.name,
before: { ownerUserId: existing.ownerUserId },
after: { ownerUserId: input.ownerUserId },
});
return this.detail(id, scope);
}
@@ -292,6 +363,16 @@ export class CustomersService {
},
data: { ownerUserId: toUserId },
});
this.requestContext.patchAudit({
targetType: 'customer',
targetName: `客户转交(${String(result.count)} 个)`,
changes: {
fromUserId,
toUserId,
transferredCount: result.count,
customerIds: customerIds === undefined ? null : [...customerIds],
},
});
return { transferred: result.count };
}
@@ -301,7 +382,7 @@ export class CustomersService {
scopeInput: ScopeContext | null,
): Promise<CustomerDetail> {
const scope = requireScopeContext(scopeInput);
await this.findRow(id);
const existing = await this.findRow(id);
this.assertVisible(scope, id);
if (input.groupIds.length > 0) {
const found = await this.prisma.customerGroup.findMany({
@@ -312,12 +393,27 @@ export class CustomersService {
throw new AppException({ code: ErrorCode.PARAM_INVALID, message: '客户组不存在' });
}
}
const currentMemberships = await this.prisma.customerGroupMember.findMany({
where: { customerId: id },
select: { groupId: true },
});
const beforeGroupIds = currentMemberships.map((item) => item.groupId).sort();
const afterGroupIds = [...new Set(input.groupIds)].sort();
await this.prisma.$transaction([
this.prisma.customerGroupMember.deleteMany({ where: { customerId: id } }),
this.prisma.customerGroupMember.createMany({
data: [...new Set(input.groupIds)].map((groupId) => ({ groupId, customerId: id })),
}),
]);
this.requestContext.patchAudit({
targetType: 'customer',
targetId: id,
targetName: existing.name,
customerId: id,
customerName: existing.name,
before: { groupIds: beforeGroupIds },
after: { groupIds: afterGroupIds },
});
return this.detail(id, scope);
}
@@ -334,10 +430,28 @@ export class CustomersService {
ownerUserId: string;
industryId: string;
name: string;
shortName: string;
contactName: string | null;
contactPhone: string | null;
preferences: Prisma.JsonValue;
customFields: Prisma.JsonValue;
remark: string | null;
}> {
const row = await this.prisma.customer.findFirst({
where: { id, deletedAt: null },
select: { id: true, status: true, ownerUserId: true, industryId: true, name: true },
select: {
id: true,
status: true,
ownerUserId: true,
industryId: true,
name: true,
shortName: true,
contactName: true,
contactPhone: true,
preferences: true,
customFields: true,
remark: true,
},
});
if (row === null) {
throw new AppException({ code: ErrorCode.NOT_FOUND, message: '客户不存在' });
@@ -14,6 +14,7 @@ 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 { PrismaService } from '../../infra/prisma/prisma.service';
import { PRISMA } from '../../infra/tokens';
import { type IndustryRow, toIndustryNode } from './client.mapper';
@@ -28,7 +29,10 @@ function isUniqueViolation(error: unknown): boolean {
*/
@Injectable()
export class IndustriesService {
constructor(@Inject(PRISMA) private readonly prisma: PrismaService) {}
constructor(
@Inject(PRISMA) private readonly prisma: PrismaService,
@Inject(RequestContextService) private readonly requestContext: RequestContextService,
) {}
async list(query: IndustryListQuery): Promise<ListResponse<IndustryTreeItem>> {
const rows = await this.prisma.industry.findMany({
@@ -116,6 +120,7 @@ export class IndustriesService {
};
}
// eslint-disable-next-line complexity -- 两级行业校验 + 唯一约束映射集中保证事务前校验完整
async create(input: CreateIndustryInput): Promise<IndustryDetail> {
const level = input.parentId === undefined || input.parentId === null ? 1 : 2;
if (level === 2 && input.parentId !== undefined && input.parentId !== null) {
@@ -149,6 +154,18 @@ export class IndustriesService {
}
throw error;
}
this.requestContext.patchAudit({
targetType: 'industry',
targetId: id,
targetName: input.name,
after: {
name: input.name,
parentId: level === 1 ? null : (input.parentId ?? null),
level,
sortOrder: input.sortOrder,
remark: input.remark,
},
});
return this.detail(id);
}
@@ -165,6 +182,18 @@ export class IndustriesService {
...(input.remark === undefined ? {} : { remark: input.remark }),
},
});
const before = { name: existing.name, sortOrder: existing.sortOrder, remark: existing.remark };
this.requestContext.patchAudit({
targetType: 'industry',
targetId: id,
targetName: input.name ?? existing.name,
before,
after: {
name: input.name ?? existing.name,
sortOrder: input.sortOrder ?? existing.sortOrder,
remark: input.remark ?? existing.remark,
},
});
return this.detail(id);
}
@@ -197,10 +226,18 @@ export class IndustriesService {
where: { id },
data: { status: input.status === 'active' ? 'ACTIVE' : 'DISABLED' },
});
this.requestContext.patchAudit({
targetType: 'industry',
targetId: id,
targetName: existing.name,
before: { status: existing.status === 'ACTIVE' ? 'active' : 'disabled' },
after: { status: input.status },
});
return this.detail(id);
}
async remove(id: string): Promise<void> {
const existing = await this.findOrThrow(id);
const references = await this.references(id);
if (references.childCount > 0 || references.customerCount > 0 || references.assetCount > 0) {
throw new AppException({
@@ -220,6 +257,17 @@ export class IndustriesService {
}
throw error;
}
this.requestContext.patchAudit({
targetType: 'industry',
targetId: id,
targetName: existing.name,
before: {
name: existing.name,
parentId: existing.parentId,
level: existing.level,
status: existing.status === 'ACTIVE' ? 'active' : 'disabled',
},
});
}
/** 客户归属校验:客户只能挂末级行业(A3-BE-02 / ADR-002 D2) */