feat(api): A5 审计采集、检索与导出
AuditInterceptor 成功 / 失败双写 audit_logs(响应前 await、脱敏、sink 缺失退化 Pino);登录 / 权限 / 客户 / 字典 / 配置等既有动作补审计;审计列表 / 详情 / 动作目录 / 异步 CSV 导出 / HMAC 签名下载;分区维护与保留归档占位;OpenAPI 快照更新。
This commit is contained in:
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) */
|
||||
|
||||
Reference in new issue
Block a user