feat: scope blacklists and paginate phone segments

This commit is contained in:
hectorzhao
2026-07-10 13:42:39 +08:00
parent ff0756ff9c
commit d5e668a2e6
18 changed files with 322 additions and 86 deletions
@@ -1,6 +1,5 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import {
CreateBlacklistDto,
CreateDrainageFieldDto,
@@ -17,8 +16,12 @@ export class DictionariesController {
constructor(private readonly dictionaries: DictionariesService) {}
@Get('phone-segments')
listPhoneSegments() {
return this.dictionaries.listPhoneSegments();
listPhoneSegments(
@Query('keyword') keyword?: string,
@Query('cursor') cursor?: string,
@Query('pageSize') pageSize?: string,
) {
return this.dictionaries.listPhoneSegments({ keyword, cursor, pageSize: Number(pageSize) || undefined });
}
@Post('phone-segments')
@@ -77,8 +80,8 @@ export class DictionariesController {
}
@Get('blacklists/enterprise')
listEnterpriseBlacklist(@TenantId() tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) {
return this.dictionaries.listEnterpriseBlacklist({ tenantId, keyword, status });
listEnterpriseBlacklist(@Query('tenantId') tenantId?: string, @Query('applicationId') applicationId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) {
return this.dictionaries.listEnterpriseBlacklist({ tenantId, applicationId, keyword, status });
}
@Post('blacklists/enterprise')
@@ -2,6 +2,9 @@ import { DictionariesService } from './dictionaries.service';
function createPrismaMock() {
return {
phoneSegment: {
findMany: jest.fn(),
},
sensitiveWord: {
findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'word-1', ...data })),
@@ -17,6 +20,9 @@ function createPrismaMock() {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
},
smsApplication: {
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
},
operationLog: {
create: jest.fn(),
},
@@ -24,13 +30,43 @@ function createPrismaMock() {
}
describe('DictionariesService', () => {
it('paginates phone segments without counting the full table', async () => {
const prisma = createPrismaMock();
prisma.phoneSegment.findMany.mockResolvedValue([
{ id: 'segment-1', prefix: '1300001', carrier: '中国联通', province: '江苏', city: '常州' },
{ id: 'segment-2', prefix: '1300002', carrier: '中国联通', province: '安徽', city: '合肥' },
{ id: 'segment-3', prefix: '1300003', carrier: '中国联通', province: '四川', city: '宜宾' },
]);
const service = new DictionariesService(prisma as never);
await expect(service.listPhoneSegments({ keyword: '中国联通', cursor: '1300000', pageSize: 2 })).resolves.toEqual({
items: expect.arrayContaining([
expect.objectContaining({ prefix: '1300001' }),
expect.objectContaining({ prefix: '1300002' }),
]),
pageSize: 2,
hasMore: true,
nextCursor: '1300002',
});
expect(prisma.phoneSegment.findMany).toHaveBeenCalledWith({
where: {
AND: [
{ prefix: { gt: '1300000' } },
{ OR: expect.any(Array) },
],
},
orderBy: { prefix: 'asc' },
take: 3,
});
});
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' });
await service.listEnterpriseBlacklist({ tenantId: 'tenant-1', applicationId: 'app-1', keyword: '投诉', status: 'active' });
expect(prisma.sensitiveWord.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }),
@@ -39,8 +75,8 @@ describe('DictionariesService', () => {
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 },
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', status: 'active', OR: expect.any(Array) }),
include: { tenant: true, application: true },
}));
});
@@ -50,12 +86,15 @@ describe('DictionariesService', () => {
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.createEnterpriseBlacklist({ tenantId: 'tenant-1', applicationId: 'app-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.create).toHaveBeenCalledWith({
data: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', phoneNumber: '13900000000' }),
});
expect(prisma.enterpriseBlacklist.update).toHaveBeenCalledWith({ where: { id: 'enterprise-1' }, data: { status: 'deleted' } });
});
});
+51 -5
View File
@@ -9,6 +9,12 @@ export interface CreatePhoneSegmentDto {
city?: string;
}
export interface PhoneSegmentListQuery {
keyword?: string;
cursor?: string;
pageSize?: number;
}
export interface CreatePhoneCarrierRuleDto {
carrier: string;
pattern: string;
@@ -25,6 +31,7 @@ export interface CreateSensitiveWordDto {
export interface CreateBlacklistDto {
tenantId?: string;
applicationId?: string;
phoneNumber: string;
reason?: string;
status?: string;
@@ -48,6 +55,7 @@ export interface DictionaryStatusDto {
export interface DictionaryListQuery {
tenantId?: string;
applicationId?: string;
keyword?: string;
status?: string;
}
@@ -56,8 +64,34 @@ export interface DictionaryListQuery {
export class DictionariesService {
constructor(private readonly prisma: PrismaService) {}
listPhoneSegments() {
return this.prisma.phoneSegment.findMany({ orderBy: { prefix: 'asc' }, take: 200 });
async listPhoneSegments(query: PhoneSegmentListQuery = {}) {
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 20)));
const keyword = query.keyword?.trim();
const items = await this.prisma.phoneSegment.findMany({
where: {
AND: [
query.cursor ? { prefix: { gt: query.cursor } } : {},
keyword ? {
OR: [
{ prefix: { startsWith: keyword } },
{ carrier: { contains: keyword } },
{ province: { contains: keyword } },
{ city: { contains: keyword } },
],
} : {},
],
},
orderBy: { prefix: 'asc' },
take: pageSize + 1,
});
const hasMore = items.length > pageSize;
const pageItems = hasMore ? items.slice(0, pageSize) : items;
return {
items: pageItems,
pageSize,
hasMore,
nextCursor: hasMore ? pageItems.at(-1)?.prefix ?? null : null,
};
}
createPhoneSegment(data: CreatePhoneSegmentDto) {
@@ -161,24 +195,35 @@ export class DictionariesService {
return this.prisma.enterpriseBlacklist.findMany({
where: {
tenantId: query.tenantId,
applicationId: query.applicationId,
status: query.status && query.status !== 'all' ? query.status : undefined,
OR: query.keyword ? [
{ phoneNumber: { contains: query.keyword } },
{ reason: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
] : undefined,
},
include: { tenant: true },
include: { tenant: true, application: true },
orderBy: { createdAt: 'desc' },
take: 200,
});
}
async createEnterpriseBlacklist(data: CreateBlacklistDto) {
if (!data.tenantId) {
throw new BadRequestException('tenantId is required for enterprise blacklist');
if (!data.tenantId || !data.applicationId) {
throw new BadRequestException('tenantId and applicationId are required for application blacklist');
}
const application = await this.prisma.smsApplication.findUnique({
where: { id: data.applicationId },
select: { tenantId: true },
});
if (!application || application.tenantId !== data.tenantId) {
throw new BadRequestException('applicationId does not belong to tenantId');
}
const createData: Prisma.EnterpriseBlacklistUncheckedCreateInput = {
tenantId: data.tenantId,
applicationId: data.applicationId,
phoneNumber: data.phoneNumber,
reason: data.reason,
status: data.status ?? 'active',
@@ -186,6 +231,7 @@ export class DictionariesService {
const created = await this.prisma.enterpriseBlacklist.create({ data: createData });
await this.writeOperationLog(data.operatorId, 'enterprise_blacklist.create', 'enterprise_blacklist', created.id, {
tenantId: data.tenantId,
applicationId: data.applicationId,
phoneNumber: data.phoneNumber,
reason: data.reason,
});