feat: wire admin workflows to real APIs

This commit is contained in:
hectorzhao
2026-07-02 13:50:09 +08:00
parent f8c9b78c21
commit ab421cf8a7
42 changed files with 2596 additions and 250 deletions
@@ -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' } });
});
});
+101 -14
View File
@@ -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,
},
});
}
}