feat: wire admin workflows to real APIs
This commit is contained in:
@@ -25,8 +25,8 @@ export class AdminCertificationController {
|
||||
constructor(private readonly certifications: CertificationService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query('tenantId') tenantId?: string, @Query('status') status?: string) {
|
||||
return this.certifications.list(tenantId, status);
|
||||
list(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) {
|
||||
return this.certifications.list(tenantId, status, keyword);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
|
||||
@@ -20,16 +20,34 @@ export interface ReviewCertificationDto {
|
||||
export class CertificationService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
list(tenantId?: string, status?: string) {
|
||||
list(tenantId?: string, status?: string, keyword?: string) {
|
||||
return this.prisma.enterpriseCertification.findMany({
|
||||
where: { tenantId, status },
|
||||
where: {
|
||||
tenantId,
|
||||
status: status && status !== 'all' ? status : undefined,
|
||||
OR: keyword ? [
|
||||
{ companyName: { contains: keyword } },
|
||||
{ licenseNo: { contains: keyword } },
|
||||
{ contactName: { contains: keyword } },
|
||||
{ contactPhone: { contains: keyword } },
|
||||
{ tenant: { name: { contains: keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { tenant: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
return this.prisma.enterpriseCertification.findUnique({ where: { id } });
|
||||
async get(id: string) {
|
||||
const certification = await this.prisma.enterpriseCertification.findUnique({
|
||||
where: { id },
|
||||
include: { tenant: true },
|
||||
});
|
||||
if (!certification) {
|
||||
throw new NotFoundException('Enterprise certification not found');
|
||||
}
|
||||
return certification;
|
||||
}
|
||||
|
||||
async submit(data: SubmitCertificationDto) {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ChannelsService,
|
||||
ChangeChannelStatusDto,
|
||||
CopyChannelDto,
|
||||
CreateChannelDto,
|
||||
CreateChannelGroupDto,
|
||||
CreateChannelGroupItemDto,
|
||||
@@ -40,11 +41,26 @@ export class ChannelsController {
|
||||
return this.channels.changeChannelStatus(channelId, body);
|
||||
}
|
||||
|
||||
@Post('channels/:id/copy')
|
||||
copyChannel(@Param('id') channelId: string, @Body() body: CopyChannelDto) {
|
||||
return this.channels.copyChannel(channelId, body);
|
||||
}
|
||||
|
||||
@Delete('channels/:id')
|
||||
deleteChannel(@Param('id') channelId: string, @Body() body: ChangeChannelStatusDto) {
|
||||
return this.channels.deleteChannel(channelId, body);
|
||||
}
|
||||
|
||||
@Get('channels/:id/metrics')
|
||||
listChannelMetrics(@Param('id') channelId: string) {
|
||||
return this.channels.listChannelMetrics(channelId);
|
||||
}
|
||||
|
||||
@Get('channels/:id/link-logs')
|
||||
listChannelLinkLogs(@Param('id') channelId: string) {
|
||||
return this.channels.listChannelLinkLogs(channelId);
|
||||
}
|
||||
|
||||
@Get('channels/:id/connections')
|
||||
listChannelConnections(@Param('id') channelId: string) {
|
||||
return this.channels.listChannelConnections(channelId);
|
||||
|
||||
@@ -2,11 +2,42 @@ import { ChannelsService } from './channels.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' };
|
||||
const channel = {
|
||||
id: 'channel-1',
|
||||
code: 'CMPP-A',
|
||||
name: '主通道',
|
||||
carrier: 'mobile',
|
||||
protocol: 'CMPP',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 7890,
|
||||
enterpriseCode: 'EC',
|
||||
account: 'sp',
|
||||
passwordCipher: 'secret',
|
||||
srcId: '10690000',
|
||||
cmppVersion: '3.0',
|
||||
rateLimitPerSecond: 100,
|
||||
unitPrice: 3,
|
||||
status: 'active',
|
||||
config: { serviceId: 'SMS' },
|
||||
reportFields: [{ code: 'license', name: '营业执照', fieldType: 'file', required: true, description: null, sortOrder: 1, status: 'active' }],
|
||||
};
|
||||
return {
|
||||
$transaction: jest.fn((callback) => callback({
|
||||
smsChannel: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })),
|
||||
},
|
||||
signatureReportMaterial: {
|
||||
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
})),
|
||||
smsChannel: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', status: 'active' }),
|
||||
findUnique: jest.fn().mockResolvedValue(channel),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-1', ...data })),
|
||||
},
|
||||
channelHealthMetric: { findMany: jest.fn() },
|
||||
@@ -26,7 +57,8 @@ function createPrismaMock() {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||
},
|
||||
signatureReportMaterial: {
|
||||
findMany: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
|
||||
createMany: jest.fn(),
|
||||
upsert: jest.fn().mockImplementation(({ create }) => Promise.resolve({ id: 'material-1', ...create })),
|
||||
},
|
||||
channelSignatureReportTask: {
|
||||
@@ -54,6 +86,7 @@ function createPrismaMock() {
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'log-1', action: 'cmpp_connection.heartbeat', resourceId: 'channel-1:conn-a', detail: {}, createdAt: new Date() }]),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -161,6 +194,25 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('copies channels with report field configuration and report materials', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
const copied = await service.copyChannel('channel-1', { operatorId: 'admin-1' });
|
||||
|
||||
expect(copied).toEqual(expect.objectContaining({ id: 'channel-copy', name: '主通道副本' }));
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('soft deletes channels through status change', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.deleteChannel('channel-1', { operatorId: 'admin-1', status: 'deleted' });
|
||||
|
||||
expect(prisma.smsChannel.update).toHaveBeenCalledWith({ where: { id: 'channel-1' }, data: { status: 'deleted' } });
|
||||
});
|
||||
|
||||
it('upserts and lists CMPP connection states', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
@@ -175,6 +227,7 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
await service.listChannelConnections('channel-1');
|
||||
await service.listTenantConnections('tenant-1');
|
||||
await service.listChannelLinkLogs('channel-1');
|
||||
|
||||
expect(prisma.cmppConnectionState.upsert).toHaveBeenCalledWith({
|
||||
where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } },
|
||||
@@ -192,5 +245,13 @@ describe('ChannelsService', () => {
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: 'cmpp_connection.connected',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: 'channel-1:conn-a',
|
||||
}),
|
||||
});
|
||||
expect(prisma.operationLog.findMany).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,6 +112,12 @@ export interface ChangeChannelStatusDto {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface CopyChannelDto {
|
||||
name?: string;
|
||||
code?: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ChannelsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -164,6 +170,91 @@ export class ChannelsService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async copyChannel(channelId: string, data: CopyChannelDto = {}) {
|
||||
const source = await this.prisma.smsChannel.findUnique({
|
||||
where: { id: channelId },
|
||||
include: { reportFields: true },
|
||||
});
|
||||
if (!source) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
|
||||
const suffix = Date.now().toString(36).toUpperCase();
|
||||
const nextName = data.name ?? `${source.name}副本`;
|
||||
const nextCode = data.code ?? `${source.code}-COPY-${suffix}`;
|
||||
|
||||
const copied = await this.prisma.$transaction(async (tx) => {
|
||||
const nextChannel = await tx.smsChannel.create({
|
||||
data: {
|
||||
code: nextCode,
|
||||
name: nextName,
|
||||
carrier: source.carrier,
|
||||
protocol: source.protocol,
|
||||
gatewayHost: source.gatewayHost,
|
||||
gatewayPort: source.gatewayPort,
|
||||
enterpriseCode: source.enterpriseCode,
|
||||
account: source.account,
|
||||
passwordCipher: source.passwordCipher,
|
||||
srcId: source.srcId,
|
||||
cmppVersion: source.cmppVersion,
|
||||
rateLimitPerSecond: source.rateLimitPerSecond,
|
||||
unitPrice: source.unitPrice,
|
||||
status: source.status,
|
||||
config: source.config as Prisma.InputJsonValue | undefined,
|
||||
reportFields: {
|
||||
create: source.reportFields.map((field) => ({
|
||||
code: field.code,
|
||||
name: field.name,
|
||||
fieldType: field.fieldType,
|
||||
required: field.required,
|
||||
description: field.description,
|
||||
sortOrder: field.sortOrder,
|
||||
status: field.status,
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { reportFields: true },
|
||||
});
|
||||
|
||||
const reportMaterials = await tx.signatureReportMaterial.findMany({ where: { channelId } });
|
||||
if (reportMaterials.length > 0) {
|
||||
await tx.signatureReportMaterial.createMany({
|
||||
data: reportMaterials.map((material) => ({
|
||||
signatureId: material.signatureId,
|
||||
channelId: nextChannel.id,
|
||||
fieldCode: material.fieldCode,
|
||||
fieldValue: material.fieldValue,
|
||||
fileObjectId: material.fileObjectId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
|
||||
await tx.operationLog.create({
|
||||
data: {
|
||||
userId: data.operatorId,
|
||||
action: 'sms_channel.copy',
|
||||
resource: 'sms_channel',
|
||||
resourceId: nextChannel.id,
|
||||
detail: {
|
||||
sourceChannelId: source.id,
|
||||
sourceCode: source.code,
|
||||
copiedReportFields: source.reportFields.length,
|
||||
copiedReportMaterials: reportMaterials.length,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
|
||||
return nextChannel;
|
||||
});
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
async deleteChannel(channelId: string, data: ChangeChannelStatusDto = { status: 'deleted' }) {
|
||||
return this.changeChannelStatus(channelId, { ...data, status: 'deleted' });
|
||||
}
|
||||
|
||||
testChannel(channelId: string) {
|
||||
return {
|
||||
channelId,
|
||||
@@ -188,6 +279,42 @@ export class ChannelsService {
|
||||
});
|
||||
}
|
||||
|
||||
async listChannelLinkLogs(channelId: string) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId }, select: { id: true } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
const [connectionStates, logs] = await Promise.all([
|
||||
this.prisma.cmppConnectionState.findMany({
|
||||
where: { channelId },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 50,
|
||||
}),
|
||||
this.prisma.operationLog.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ resource: 'sms_channel', resourceId: channelId },
|
||||
{ resource: 'cmpp_connection', resourceId: { startsWith: `${channelId}:` } },
|
||||
],
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
}),
|
||||
]);
|
||||
return {
|
||||
channelId,
|
||||
connectionStates,
|
||||
logs: logs.map((log) => ({
|
||||
id: log.id,
|
||||
time: log.createdAt,
|
||||
event: normalizeLinkEvent(log.action),
|
||||
action: log.action,
|
||||
resourceId: log.resourceId,
|
||||
detail: log.detail,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
listTenantConnections(tenantId: string) {
|
||||
return this.prisma.cmppConnectionState.findMany({
|
||||
where: { tenantId },
|
||||
@@ -197,7 +324,7 @@ export class ChannelsService {
|
||||
});
|
||||
}
|
||||
|
||||
upsertConnectionState(data: UpsertConnectionStateDto) {
|
||||
async upsertConnectionState(data: UpsertConnectionStateDto) {
|
||||
const payload = {
|
||||
tenantId: data.tenantId,
|
||||
status: data.status,
|
||||
@@ -209,7 +336,7 @@ export class ChannelsService {
|
||||
reconnectCount: data.reconnectCount ?? 0,
|
||||
lastError: data.lastError,
|
||||
};
|
||||
return this.prisma.cmppConnectionState.upsert({
|
||||
const state = await this.prisma.cmppConnectionState.upsert({
|
||||
where: { channelId_connectionId: { channelId: data.channelId, connectionId: data.connectionId } },
|
||||
update: payload,
|
||||
create: {
|
||||
@@ -218,6 +345,21 @@ export class ChannelsService {
|
||||
...payload,
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
action: `cmpp_connection.${normalizeConnectionAction(data.status)}`,
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: `${data.channelId}:${data.connectionId}`,
|
||||
detail: {
|
||||
status: data.status,
|
||||
desiredConnections: state.desiredConnections,
|
||||
currentConnections: state.currentConnections,
|
||||
lastError: state.lastError,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return state;
|
||||
}
|
||||
|
||||
listGroups() {
|
||||
@@ -445,3 +587,42 @@ export class ChannelsService {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeConnectionAction(status: string) {
|
||||
const normalized = status.toLowerCase();
|
||||
if (['online', 'connected', 'open'].includes(normalized)) {
|
||||
return 'connected';
|
||||
}
|
||||
if (['heartbeat', 'active_test'].includes(normalized)) {
|
||||
return 'heartbeat';
|
||||
}
|
||||
if (['reconnecting', 'reconnect'].includes(normalized)) {
|
||||
return 'reconnecting';
|
||||
}
|
||||
if (['offline', 'closed', 'disconnected'].includes(normalized)) {
|
||||
return 'disconnected';
|
||||
}
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
function normalizeLinkEvent(action: string) {
|
||||
if (action.includes('connected')) {
|
||||
return '新建';
|
||||
}
|
||||
if (action.includes('heartbeat')) {
|
||||
return '心跳';
|
||||
}
|
||||
if (action.includes('reconnecting')) {
|
||||
return '重连';
|
||||
}
|
||||
if (action.includes('disconnected')) {
|
||||
return '断开';
|
||||
}
|
||||
if (action.includes('copy')) {
|
||||
return '复制';
|
||||
}
|
||||
if (action.includes('deleted')) {
|
||||
return '删除';
|
||||
}
|
||||
return '更新';
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import {
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
CreatePhoneSegmentDto,
|
||||
CreateSensitiveWordDto,
|
||||
DictionariesService,
|
||||
DictionaryStatusDto,
|
||||
} from './dictionaries.service';
|
||||
|
||||
@ApiTags('dictionaries')
|
||||
@@ -25,8 +26,8 @@ export class DictionariesController {
|
||||
}
|
||||
|
||||
@Get('sensitive-words')
|
||||
listSensitiveWords() {
|
||||
return this.dictionaries.listSensitiveWords();
|
||||
listSensitiveWords(@Query('keyword') keyword?: string, @Query('status') status?: string) {
|
||||
return this.dictionaries.listSensitiveWords({ keyword, status });
|
||||
}
|
||||
|
||||
@Post('sensitive-words')
|
||||
@@ -34,9 +35,19 @@ export class DictionariesController {
|
||||
return this.dictionaries.createSensitiveWord(body);
|
||||
}
|
||||
|
||||
@Post('sensitive-words/:id/status')
|
||||
changeSensitiveWordStatus(@Param('id') id: string, @Body() body: DictionaryStatusDto) {
|
||||
return this.dictionaries.changeSensitiveWordStatus(id, body);
|
||||
}
|
||||
|
||||
@Delete('sensitive-words/:id')
|
||||
deleteSensitiveWord(@Param('id') id: string) {
|
||||
return this.dictionaries.changeSensitiveWordStatus(id, { status: 'deleted' });
|
||||
}
|
||||
|
||||
@Get('blacklists/global')
|
||||
listGlobalBlacklist() {
|
||||
return this.dictionaries.listGlobalBlacklist();
|
||||
listGlobalBlacklist(@Query('keyword') keyword?: string, @Query('status') status?: string) {
|
||||
return this.dictionaries.listGlobalBlacklist({ keyword, status });
|
||||
}
|
||||
|
||||
@Post('blacklists/global')
|
||||
@@ -44,9 +55,19 @@ export class DictionariesController {
|
||||
return this.dictionaries.createGlobalBlacklist(body);
|
||||
}
|
||||
|
||||
@Post('blacklists/global/:id/status')
|
||||
changeGlobalBlacklistStatus(@Param('id') id: string, @Body() body: DictionaryStatusDto) {
|
||||
return this.dictionaries.changeGlobalBlacklistStatus(id, body);
|
||||
}
|
||||
|
||||
@Delete('blacklists/global/:id')
|
||||
deleteGlobalBlacklist(@Param('id') id: string) {
|
||||
return this.dictionaries.changeGlobalBlacklistStatus(id, { status: 'deleted' });
|
||||
}
|
||||
|
||||
@Get('blacklists/enterprise')
|
||||
listEnterpriseBlacklist(@TenantId() tenantId?: string) {
|
||||
return this.dictionaries.listEnterpriseBlacklist(tenantId);
|
||||
listEnterpriseBlacklist(@TenantId() tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) {
|
||||
return this.dictionaries.listEnterpriseBlacklist({ tenantId, keyword, status });
|
||||
}
|
||||
|
||||
@Post('blacklists/enterprise')
|
||||
@@ -54,6 +75,16 @@ export class DictionariesController {
|
||||
return this.dictionaries.createEnterpriseBlacklist(body);
|
||||
}
|
||||
|
||||
@Post('blacklists/enterprise/:id/status')
|
||||
changeEnterpriseBlacklistStatus(@Param('id') id: string, @Body() body: DictionaryStatusDto) {
|
||||
return this.dictionaries.changeEnterpriseBlacklistStatus(id, body);
|
||||
}
|
||||
|
||||
@Delete('blacklists/enterprise/:id')
|
||||
deleteEnterpriseBlacklist(@Param('id') id: string) {
|
||||
return this.dictionaries.changeEnterpriseBlacklistStatus(id, { status: 'deleted' });
|
||||
}
|
||||
|
||||
@Get('drainage-fields')
|
||||
listDrainageFields() {
|
||||
return this.dictionaries.listDrainageFields();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { DictionariesService } from './dictionaries.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
return {
|
||||
sensitiveWord: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'word-1', ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'word-1', ...data })),
|
||||
},
|
||||
globalBlacklist: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'global-1', ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'global-1', ...data })),
|
||||
},
|
||||
enterpriseBlacklist: {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('DictionariesService', () => {
|
||||
it('searches security control dictionaries with keyword and status filters', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
await service.listSensitiveWords({ keyword: '贷款', status: 'active' });
|
||||
await service.listGlobalBlacklist({ keyword: '138', status: 'active' });
|
||||
await service.listEnterpriseBlacklist({ tenantId: 'tenant-1', keyword: '投诉', status: 'active' });
|
||||
|
||||
expect(prisma.sensitiveWord.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }),
|
||||
}));
|
||||
expect(prisma.globalBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }),
|
||||
}));
|
||||
expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ tenantId: 'tenant-1', status: 'active', OR: expect.any(Array) }),
|
||||
include: { tenant: true },
|
||||
}));
|
||||
});
|
||||
|
||||
it('creates and soft deletes blacklist and sensitive word entries with operation logs', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
await service.createSensitiveWord({ word: '高息贷款', level: 'high' });
|
||||
await service.createGlobalBlacklist({ phoneNumber: '13800000000', reason: '投诉', operatorId: 'admin-1' });
|
||||
await service.createEnterpriseBlacklist({ tenantId: 'tenant-1', phoneNumber: '13900000000', reason: '退订', operatorId: 'admin-1' });
|
||||
await service.changeSensitiveWordStatus('word-1', { status: 'deleted' });
|
||||
await service.changeGlobalBlacklistStatus('global-1', { status: 'deleted' });
|
||||
await service.changeEnterpriseBlacklistStatus('enterprise-1', { status: 'deleted' });
|
||||
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledTimes(6);
|
||||
expect(prisma.enterpriseBlacklist.update).toHaveBeenCalledWith({ where: { id: 'enterprise-1' }, data: { status: 'deleted' } });
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface CreateBlacklistDto {
|
||||
phoneNumber: string;
|
||||
reason?: string;
|
||||
status?: string;
|
||||
operatorId?: string;
|
||||
}
|
||||
|
||||
export interface CreateDrainageFieldDto {
|
||||
@@ -31,6 +32,18 @@ export interface CreateDrainageFieldDto {
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface DictionaryStatusDto {
|
||||
status?: string;
|
||||
operatorId?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface DictionaryListQuery {
|
||||
tenantId?: string;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DictionariesService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -43,45 +56,94 @@ export class DictionariesService {
|
||||
return this.prisma.phoneSegment.create({ data });
|
||||
}
|
||||
|
||||
listSensitiveWords() {
|
||||
return this.prisma.sensitiveWord.findMany({ orderBy: { createdAt: 'desc' }, take: 200 });
|
||||
listSensitiveWords(query: DictionaryListQuery = {}) {
|
||||
return this.prisma.sensitiveWord.findMany({
|
||||
where: {
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ word: { contains: query.keyword } },
|
||||
{ level: { contains: query.keyword } },
|
||||
] : undefined,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
createSensitiveWord(data: CreateSensitiveWordDto) {
|
||||
return this.prisma.sensitiveWord.create({
|
||||
async createSensitiveWord(data: CreateSensitiveWordDto) {
|
||||
const created = await this.prisma.sensitiveWord.create({
|
||||
data: {
|
||||
word: data.word,
|
||||
level: data.level ?? 'block',
|
||||
status: data.status ?? 'active',
|
||||
},
|
||||
});
|
||||
await this.writeOperationLog(undefined, 'sensitive_word.create', 'sensitive_word', created.id, { word: data.word });
|
||||
return created;
|
||||
}
|
||||
|
||||
listGlobalBlacklist() {
|
||||
return this.prisma.globalBlacklist.findMany({ orderBy: { createdAt: 'desc' }, take: 200 });
|
||||
async changeSensitiveWordStatus(id: string, data: DictionaryStatusDto) {
|
||||
const status = data.status ?? 'active';
|
||||
const updated = await this.prisma.sensitiveWord.update({ where: { id }, data: { status } });
|
||||
await this.writeOperationLog(data.operatorId, `sensitive_word.${status}`, 'sensitive_word', id, { reason: data.reason });
|
||||
return updated;
|
||||
}
|
||||
|
||||
createGlobalBlacklist(data: CreateBlacklistDto) {
|
||||
return this.prisma.globalBlacklist.create({
|
||||
listGlobalBlacklist(query: DictionaryListQuery = {}) {
|
||||
return this.prisma.globalBlacklist.findMany({
|
||||
where: {
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ phoneNumber: { contains: query.keyword } },
|
||||
{ reason: { contains: query.keyword } },
|
||||
] : undefined,
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
async createGlobalBlacklist(data: CreateBlacklistDto) {
|
||||
const created = await this.prisma.globalBlacklist.create({
|
||||
data: {
|
||||
phoneNumber: data.phoneNumber,
|
||||
reason: data.reason,
|
||||
status: data.status ?? 'active',
|
||||
},
|
||||
});
|
||||
await this.writeOperationLog(data.operatorId, 'global_blacklist.create', 'global_blacklist', created.id, {
|
||||
phoneNumber: data.phoneNumber,
|
||||
reason: data.reason,
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
listEnterpriseBlacklist(tenantId?: string) {
|
||||
async changeGlobalBlacklistStatus(id: string, data: DictionaryStatusDto) {
|
||||
const status = data.status ?? 'active';
|
||||
const updated = await this.prisma.globalBlacklist.update({ where: { id }, data: { status } });
|
||||
await this.writeOperationLog(data.operatorId, `global_blacklist.${status}`, 'global_blacklist', id, { reason: data.reason });
|
||||
return updated;
|
||||
}
|
||||
|
||||
listEnterpriseBlacklist(query: DictionaryListQuery = {}) {
|
||||
return this.prisma.enterpriseBlacklist.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
status: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ phoneNumber: { contains: query.keyword } },
|
||||
{ reason: { contains: query.keyword } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { tenant: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 200,
|
||||
});
|
||||
}
|
||||
|
||||
createEnterpriseBlacklist(data: CreateBlacklistDto) {
|
||||
async createEnterpriseBlacklist(data: CreateBlacklistDto) {
|
||||
if (!data.tenantId) {
|
||||
throw new Error('tenantId is required for enterprise blacklist');
|
||||
throw new BadRequestException('tenantId is required for enterprise blacklist');
|
||||
}
|
||||
const createData: Prisma.EnterpriseBlacklistUncheckedCreateInput = {
|
||||
tenantId: data.tenantId,
|
||||
@@ -89,7 +151,20 @@ export class DictionariesService {
|
||||
reason: data.reason,
|
||||
status: data.status ?? 'active',
|
||||
};
|
||||
return this.prisma.enterpriseBlacklist.create({ data: createData });
|
||||
const created = await this.prisma.enterpriseBlacklist.create({ data: createData });
|
||||
await this.writeOperationLog(data.operatorId, 'enterprise_blacklist.create', 'enterprise_blacklist', created.id, {
|
||||
tenantId: data.tenantId,
|
||||
phoneNumber: data.phoneNumber,
|
||||
reason: data.reason,
|
||||
});
|
||||
return created;
|
||||
}
|
||||
|
||||
async changeEnterpriseBlacklistStatus(id: string, data: DictionaryStatusDto) {
|
||||
const status = data.status ?? 'active';
|
||||
const updated = await this.prisma.enterpriseBlacklist.update({ where: { id }, data: { status } });
|
||||
await this.writeOperationLog(data.operatorId, `enterprise_blacklist.${status}`, 'enterprise_blacklist', id, { reason: data.reason });
|
||||
return updated;
|
||||
}
|
||||
|
||||
listDrainageFields() {
|
||||
@@ -108,4 +183,16 @@ export class DictionariesService {
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record<string, unknown>) {
|
||||
return this.prisma.operationLog.create({
|
||||
data: {
|
||||
userId,
|
||||
action,
|
||||
resource,
|
||||
resourceId,
|
||||
detail: detail as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ export class AdminSmsConfigController {
|
||||
}
|
||||
|
||||
@Get('enterprise-templates')
|
||||
listTemplates(@Query('tenantId') tenantId?: string) {
|
||||
return this.smsConfig.listTemplates(tenantId);
|
||||
listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) {
|
||||
return this.smsConfig.listTemplates({ tenantId, status, keyword });
|
||||
}
|
||||
|
||||
@Get('audit-records')
|
||||
|
||||
@@ -51,6 +51,12 @@ export interface StatusChangeDto {
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface TemplateListQuery {
|
||||
tenantId?: string;
|
||||
status?: string;
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsConfigService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -169,10 +175,21 @@ export class SmsConfigService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
listTemplates(tenantId?: string) {
|
||||
listTemplates(queryOrTenantId?: string | TemplateListQuery) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||
return this.prisma.smsTemplate.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
include: { variables: true },
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ content: { contains: query.keyword } },
|
||||
{ category: { contains: query.keyword } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user