feat: scope blacklists and paginate phone segments
This commit is contained in:
+20
@@ -0,0 +1,20 @@
|
||||
ALTER TABLE "EnterpriseBlacklist" ADD COLUMN "applicationId" TEXT;
|
||||
|
||||
UPDATE "EnterpriseBlacklist" AS blacklist
|
||||
SET "applicationId" = (
|
||||
SELECT "id"
|
||||
FROM "SmsApplication"
|
||||
WHERE "tenantId" = blacklist."tenantId"
|
||||
ORDER BY "createdAt" DESC
|
||||
LIMIT 1
|
||||
);
|
||||
|
||||
DELETE FROM "EnterpriseBlacklist" WHERE "applicationId" IS NULL;
|
||||
|
||||
DROP INDEX IF EXISTS "EnterpriseBlacklist_tenantId_phoneNumber_key";
|
||||
|
||||
ALTER TABLE "EnterpriseBlacklist" ALTER COLUMN "applicationId" SET NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX "EnterpriseBlacklist_applicationId_phoneNumber_key" ON "EnterpriseBlacklist"("applicationId", "phoneNumber");
|
||||
|
||||
ALTER TABLE "EnterpriseBlacklist" ADD CONSTRAINT "EnterpriseBlacklist_applicationId_fkey" FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -203,14 +203,16 @@ model SensitiveWord {
|
||||
model EnterpriseBlacklist {
|
||||
id String @id @default(cuid())
|
||||
tenantId String
|
||||
applicationId String
|
||||
phoneNumber String
|
||||
reason String?
|
||||
status String @default("active")
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
tenant Tenant @relation(fields: [tenantId], references: [id])
|
||||
application SmsApplication @relation(fields: [applicationId], references: [id])
|
||||
|
||||
@@unique([tenantId, phoneNumber])
|
||||
@@unique([applicationId, phoneNumber])
|
||||
}
|
||||
|
||||
model GlobalBlacklist {
|
||||
@@ -361,6 +363,7 @@ model SmsApplication {
|
||||
ipAllowlist SmsApplicationIpAllowlist[]
|
||||
signatures SmsSignature[]
|
||||
templates SmsTemplate[]
|
||||
enterpriseBlacklists EnterpriseBlacklist[]
|
||||
sendTasks SmsSendTask[]
|
||||
batchTasks SmsBatchTask[]
|
||||
messageRecords SmsMessageRecord[]
|
||||
|
||||
@@ -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' } });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -81,6 +81,7 @@ describe('RiskReviewService', () => {
|
||||
it('routes duplicate and blacklist ratio hits to manual review', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.globalBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000001' }]);
|
||||
prisma.enterpriseBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000002' }]);
|
||||
prisma.riskRule.findMany.mockResolvedValue([
|
||||
{
|
||||
id: 'rule-dup',
|
||||
@@ -105,6 +106,7 @@ describe('RiskReviewService', () => {
|
||||
const service = new RiskReviewService(prisma as never);
|
||||
const result = await service.evaluateTask({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
content: 'hello',
|
||||
phones: ['13800000001', '13800000001', '13800000002'],
|
||||
});
|
||||
@@ -113,11 +115,15 @@ describe('RiskReviewService', () => {
|
||||
expect(prisma.smsSendTask.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
duplicateRatio: 0.3333,
|
||||
blacklistHitRatio: 0.3333,
|
||||
blacklistHitRatio: 0.6667,
|
||||
status: 'pending_review',
|
||||
riskDecision: 'manual_review',
|
||||
}),
|
||||
});
|
||||
expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1', applicationId: 'app-1', phoneNumber: { in: ['13800000001', '13800000002'] }, status: 'active' },
|
||||
select: { phoneNumber: true },
|
||||
});
|
||||
expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({
|
||||
data: expect.arrayContaining([
|
||||
expect.objectContaining({ ruleCode: 'DUPLICATE_PHONE_RATIO' }),
|
||||
|
||||
@@ -181,7 +181,7 @@ export class RiskReviewService {
|
||||
const duplicateRatio = ratio(phoneTotal - uniquePhoneTotal, phoneTotal);
|
||||
const illegalCount = phones.filter((phone) => !isMainlandMobile(phone)).length;
|
||||
const illegalRatio = ratio(illegalCount, phoneTotal);
|
||||
const blacklistHitCount = await this.countBlacklistHits(data.tenantId, uniquePhones);
|
||||
const blacklistHitCount = await this.countBlacklistHits(data.tenantId, data.applicationId, uniquePhones);
|
||||
const blacklistHitRatio = ratio(blacklistHitCount, phoneTotal);
|
||||
const [application, template, rules, recentTaskCount, sensitiveWords] = await Promise.all([
|
||||
data.applicationId ? this.prisma.smsApplication.findUnique({ where: { id: data.applicationId } }) : null,
|
||||
@@ -324,7 +324,7 @@ export class RiskReviewService {
|
||||
return [...byCode.values()].sort((a, b) => a.priority - b.priority);
|
||||
}
|
||||
|
||||
private async countBlacklistHits(tenantId: string, phones: string[]) {
|
||||
private async countBlacklistHits(tenantId: string, applicationId: string | undefined, phones: string[]) {
|
||||
if (phones.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
@@ -333,10 +333,10 @@ export class RiskReviewService {
|
||||
where: { phoneNumber: { in: phones }, status: 'active' },
|
||||
select: { phoneNumber: true },
|
||||
}),
|
||||
this.prisma.enterpriseBlacklist.findMany({
|
||||
where: { tenantId, phoneNumber: { in: phones }, status: 'active' },
|
||||
applicationId ? this.prisma.enterpriseBlacklist.findMany({
|
||||
where: { tenantId, applicationId, phoneNumber: { in: phones }, status: 'active' },
|
||||
select: { phoneNumber: true },
|
||||
}),
|
||||
}) : Promise.resolve([]),
|
||||
]);
|
||||
return new Set([...globalHits, ...enterpriseHits].map((hit) => hit.phoneNumber)).size;
|
||||
}
|
||||
|
||||
@@ -490,6 +490,7 @@ describe('SendChainService', () => {
|
||||
await expect(
|
||||
service.previewImport({
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
content: 'phoneNumber,code\n13800000001,1234\n13800000001,1234\nbad,1234\n13800000003,1234\n13900000001,',
|
||||
requiredVariables: ['code'],
|
||||
}),
|
||||
@@ -507,6 +508,10 @@ describe('SendChainService', () => {
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1', applicationId: 'app-1', status: 'active' },
|
||||
select: { phoneNumber: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('adds queued message jobs for a batch task', async () => {
|
||||
|
||||
@@ -144,6 +144,7 @@ export interface TimeoutUnknownDto {
|
||||
|
||||
export interface ImportPreviewDto {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
content: string;
|
||||
fileName?: string;
|
||||
encoding?: 'utf8' | 'gbk';
|
||||
@@ -415,10 +416,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
const phones: string[] = [];
|
||||
const errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }> = [];
|
||||
const requiredVariables = data.requiredVariables ?? [];
|
||||
const enterpriseBlacklist = await this.prisma.enterpriseBlacklist.findMany({
|
||||
where: { tenantId: data.tenantId, status: 'active' },
|
||||
const enterpriseBlacklist = data.applicationId ? await this.prisma.enterpriseBlacklist.findMany({
|
||||
where: { tenantId: data.tenantId, applicationId: data.applicationId, status: 'active' },
|
||||
select: { phoneNumber: true },
|
||||
});
|
||||
}) : [];
|
||||
const globalBlacklist = await this.prisma.globalBlacklist.findMany({
|
||||
where: { status: 'active' },
|
||||
select: { phoneNumber: true },
|
||||
@@ -464,6 +465,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
async confirmImport(data: ConfirmImportDto) {
|
||||
const preview = await this.previewImport({
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
content: data.importContent,
|
||||
requiredVariables: data.requiredVariables,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user