From d5e668a2e6ecd5868f5690648d18de1d7b43932d Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Fri, 10 Jul 2026 13:42:39 +0800 Subject: [PATCH] feat: scope blacklists and paginate phone segments --- .../migration.sql | 20 ++++ api/prisma/schema.prisma | 5 +- .../dictionaries/dictionaries.controller.ts | 13 ++- .../dictionaries/dictionaries.service.spec.ts | 47 +++++++- api/src/dictionaries/dictionaries.service.ts | 56 ++++++++- .../risk-review/risk-review.service.spec.ts | 8 +- api/src/risk-review/risk-review.service.ts | 10 +- api/src/send-chain/send-chain.service.spec.ts | 5 + api/src/send-chain/send-chain.service.ts | 8 +- .../first-version-development-requirements.md | 8 +- docs/system-functional-test-cases.md | 6 +- docs/testing-progress.md | 32 ++++++ src/api/adminApi.ts | 16 ++- .../admin/AdminEnterpriseBlacklistPage.tsx | 57 +++++++-- src/apps/admin/AdminPhoneSegmentsPage.tsx | 108 ++++++++++++------ src/apps/client/ClientSendPage.tsx | 1 + src/components/ui/PagePrimitives.tsx | 4 +- tools/deploy/production-deploy.sh | 4 + 18 files changed, 322 insertions(+), 86 deletions(-) create mode 100644 api/prisma/migrations/20260710103000_scope_enterprise_blacklist_to_application/migration.sql diff --git a/api/prisma/migrations/20260710103000_scope_enterprise_blacklist_to_application/migration.sql b/api/prisma/migrations/20260710103000_scope_enterprise_blacklist_to_application/migration.sql new file mode 100644 index 0000000..3a564c3 --- /dev/null +++ b/api/prisma/migrations/20260710103000_scope_enterprise_blacklist_to_application/migration.sql @@ -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; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index b2bfd67..7a07ee1 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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[] diff --git a/api/src/dictionaries/dictionaries.controller.ts b/api/src/dictionaries/dictionaries.controller.ts index bf974d2..c403f65 100644 --- a/api/src/dictionaries/dictionaries.controller.ts +++ b/api/src/dictionaries/dictionaries.controller.ts @@ -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') diff --git a/api/src/dictionaries/dictionaries.service.spec.ts b/api/src/dictionaries/dictionaries.service.spec.ts index 7ee31d8..626f99e 100644 --- a/api/src/dictionaries/dictionaries.service.spec.ts +++ b/api/src/dictionaries/dictionaries.service.spec.ts @@ -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' } }); }); }); diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index 3f7caf0..9c3cd6c 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -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, }); diff --git a/api/src/risk-review/risk-review.service.spec.ts b/api/src/risk-review/risk-review.service.spec.ts index 5a12d22..10c3e30 100644 --- a/api/src/risk-review/risk-review.service.spec.ts +++ b/api/src/risk-review/risk-review.service.spec.ts @@ -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' }), diff --git a/api/src/risk-review/risk-review.service.ts b/api/src/risk-review/risk-review.service.ts index 7fa3ba9..ebca272 100644 --- a/api/src/risk-review/risk-review.service.ts +++ b/api/src/risk-review/risk-review.service.ts @@ -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; } diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 845c301..6281936 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -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 () => { diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index e8298ba..cec3cab 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -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, }); diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index bc05e68..ea5ddc5 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -450,13 +450,13 @@ ### 5.17 安全控制 -- 企业黑名单:企业维度号码拦截。 +- 企业黑名单:企业应用级号码拦截,同一企业不同短信应用的黑名单互不影响。 - 全局黑名单:平台维度号码拦截。 - 敏感词管理:发送前和审核时命中提示或拦截。 -- 手机号段库:用于运营商识别和路由。 +- 手机号段库:用于运营商识别和路由;列表使用服务端游标分页和服务端搜索,不查询或展示全库总条数。 - 引流信息字段库:用于签名/报备资料结构化采集。 -- 企业黑名单、全局黑名单、敏感词管理必须提供搜索、添加、启停/删除功能;所有操作调用真实后端 API,写入系统日志。 -- 企业黑名单支持按企业、应用、手机号、入库原因、状态搜索;全局黑名单支持按手机号、原因、状态搜索;敏感词支持按词、分类/级别、状态搜索。 +- 企业应用级黑名单、全局黑名单、敏感词管理必须提供搜索、添加、启停/删除功能;所有操作调用真实后端 API,写入系统日志。 +- 企业黑名单必须绑定到具体短信应用,支持按企业、应用、手机号、入库原因、状态搜索;发送预览、风控和发送链路只能拦截当前应用的 active 黑名单号码,不得把同企业其他应用的黑名单串用;全局黑名单支持按手机号、原因、状态搜索;敏感词支持按词、分类/级别、状态搜索。 - “引流信息字段库”菜单命名为“报备字段库”,编辑、删除按钮使用通用操作按钮样式。 ### 5.18 风控规则闭环 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index f681397..93145b1 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -520,11 +520,12 @@ - 前置条件:运营管理员已登录,存在企业、应用和若干黑名单/敏感词数据。 - 步骤: 1. 在企业黑名单中按企业、应用、手机号、原因、状态搜索。 - 2. 新增企业黑名单,停用后再删除。 + 2. 新增企业应用级黑名单,停用后再删除。 3. 在全局黑名单中按手机号、原因、状态搜索,并新增、停用、删除。 4. 在敏感词管理中按词、分类/级别、状态搜索,并新增、停用、删除。 - 预期结果: - 搜索、添加、启停、删除均调用真实字典 API。 + - 企业黑名单必须绑定短信应用,只影响该应用发送预览和风控;同企业其他应用不被拦截。 - 启停/删除后发送前风控只使用 active 数据。 - 所有安全控制变更写入系统日志。 - 操作按钮使用统一编辑、删除、启停样式。 @@ -3007,7 +3008,7 @@ npm run verify:phase8 | TC-ADMIN-017 | 对有关联历史的通道执行停用、启用、删除。 | 取消确认无请求或无状态变化;停用后不参与路由;删除为软删除/归档;历史发送、报备、日志仍可查。 | | TC-ADMIN-018 | 企业应用列表展示 CMPP 连接数,打开连接详情,删除连接,复制 CMPP 参数。 | 连接数来自连接状态 API;详情含 connectionId/status/heartbeat/window/lastSubmitAt;删除连接调用真实接口;复制文本与 API 返回一致。 | | TC-ADMIN-019 | 打开通道连接日志,按事件类型和时间查看。 | 日志包含 connect、active_test、disconnect、reconnect、auth_failed、slow_response;按时间倒序;可定位 channelId/connectionId。 | -| TC-ADMIN-020 | 企业黑名单、全局黑名单、敏感词分别执行搜索、新增、停用、删除。 | 搜索由 API 处理;停用/删除后发送前风控只使用 active 数据;删除不影响历史命中记录;所有动作写日志。 | +| TC-ADMIN-020 | 企业黑名单、全局黑名单、敏感词分别执行搜索、新增、停用、删除。 | 企业黑名单必须绑定短信应用且按应用生效;搜索由 API 处理;停用/删除后发送前风控只使用 active 数据;删除不影响历史命中记录;所有动作写日志。 | | TC-ADMIN-021 | 创建待审核企业认证、签名、模板、短信审核任务,检查铃铛总数和分类数。 | 总数等于分类汇总;点击分类跳转并带入筛选;审核完成后数量刷新;新增待办触发站内提醒或浏览器通知。 | | TC-ADMIN-022 | 运营日志按客户、操作者、动作、资源、时间搜索,查看长详情。 | 后端分页和搜索准确;详情不截断;可查到通道复制、启停、删除、连接状态变化、安全控制变更、充值等日志。 | @@ -3121,3 +3122,4 @@ npm run verify:phase8 | TC-MOCK-CLEAN-005 | 运营端短信审核通过、批量通过、驳回风控审核任务。 | 调用 `admin/risk-review/tasks` 真实接口;通过必须弹窗确认;状态刷新后仍持久化;不再显示固定手机号样例。 | | TC-MOCK-CLEAN-006 | 运营端短信记录按手机号、状态、日期和内容查询,打开详情。 | 数据来自 `sms_message_records`;详情展示真实 messageId、状态、失败原因;无数据时为空态。 | | TC-MOCK-CLEAN-007 | 访问明确标注待开发的彩信菜单。 | 可以显示待开发/空态;不得作为第一版短信真实功能通过依据。 | +| TC-PHONE-SEGMENT-001 | 生产库存在 50 万级手机号段时打开手机号段库,连续点击下一页、上一页,并按号段、省份、城市或运营商搜索。 | API 使用 `prefix` 游标分页并返回 `hasMore/nextCursor`;页面数据来自真实数据库,可稳定前后翻页和搜索;接口不执行全表总数统计,页面不展示号段总条数。 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 40872a5..25e42e6 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -298,6 +298,7 @@ npm run test:gateway - `DELETE /api/admin/channels/:id`:软删除通道,避免破坏历史发送/报备外键。 - `GET /api/admin/channels/:id/connection-logs`:基于 `OperationLog` 和 `CmppConnectionState` 查询连接日志;保留 `/link-logs` 兼容旧前端。 - 安全控制补齐真实 API:敏感词、全局黑名单、企业黑名单支持 keyword/status 查询、创建、启停/软删除,并写操作日志。 +- 企业黑名单修正为企业应用级黑名单:Prisma `EnterpriseBlacklist` 新增 `applicationId` 并改为 `applicationId + phoneNumber` 唯一;运营端页面按企业和短信应用新增/搜索;发送预览和风控只命中当前应用的 active 黑名单。 - 模板审核补齐真实查询:运营端模板列表支持 keyword/status,并返回企业、应用、签名信息;前端模板审核页已改为调用真实 API。 - 运营端企业模板管理新增短信模板时,后端直接写入 `auditStatus=approved`;短信模板审核页展示为“已通过”,客户端自行提交模板仍保留审核流。 - 企业认证审核补齐真实查询:列表支持 keyword/status,详情返回企业信息和认证 materials;前端企业认证审核页已改为调用真实 API。 @@ -1355,3 +1356,34 @@ git diff --check - API build 通过。 - 前端 build 通过,仍存在既有 Vite chunk size warning。 - `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。 + +## 2026-07-10 手机号段库大数据分页 + +### 本轮修复 + +- `GET /api/admin/dictionaries/phone-segments` 从固定返回前 200 条改为按唯一 `prefix` 游标分页,支持服务端按号段、运营商、省份和城市搜索。 +- API 每页多读取 1 条计算 `hasMore/nextCursor`,不执行 50 万级号段表的 `COUNT(*)`。 +- 运营端手机号段页面使用真实服务端分页,移除号段总数卡片、Tab 数字和分页总数,只显示当前页码及上一页/下一页。 +- 生产手机号段数据已从 `dannyhu926/phone_location` 2026 年 4 月数据导入;源数据 516470 条,过滤 253 条非 7 位异常记录,最终有效 7 位号段 516217 条。 + +### 验证口径 + +- API 定向单测覆盖游标、搜索、每页多取 1 条和不查询总数。 +- 前端 build 和 API build 必须通过。 +- 生产验证应覆盖首尾翻页、关键词搜索、API/Gateway/PostgreSQL 健康状态和典型号段归属地查询。 + +### 已执行命令与结果 + +```bash +npm --prefix api test -- --runTestsByPath src/dictionaries/dictionaries.service.spec.ts +npm --prefix api run build +npm run build +git diff --check +``` + +- DictionariesService 定向单测通过:1 个 test suite、3 个测试通过。 +- API build 和前端 build 通过;前端仍有既有 chunk size warning。 +- 生产 API 实测 `pageSize=2`:第一页返回 `1300000/1300001` 和 `nextCursor=1300001`,下一页返回 `1300002/1300003`,响应无 `total` 字段。 +- 生产 API 搜索 `1882120` 返回“中国移动/上海/上海”;搜索“上海”首屏响应约 80ms。 +- 生产 `cmpp-api`、`cmpp-gateway`、PostgreSQL、Nginx 均为 active,API health 正常。 +- 隔离部署后曾因 `dist/assets` 被保留为 `700 root:root` 导致 Nginx 无权读取 JS/CSS、admin 页面空白;线上已修正为目录 `755`、文件 `644`,正式生产部署脚本同步固化权限。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 53fe4bb..96ce9ec 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -593,6 +593,13 @@ export type PagedResponse = { pageSize: number; }; +export type CursorPage = { + items: T[]; + pageSize: number; + hasMore: boolean; + nextCursor: string | null; +}; + export type EnterpriseApplication = { id: string; tenantId: string; @@ -957,11 +964,12 @@ export const adminApi = { createGlobalBlacklist: (body: { phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => request('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }), deleteGlobalBlacklist: (id: string) => request(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }), - listEnterpriseBlacklist: (query: { tenantId?: string; keyword?: string; status?: string } = {}) => request(withQuery('/admin/dictionaries/blacklists/enterprise', query)), - createEnterpriseBlacklist: (body: { tenantId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => + listEnterpriseBlacklist: (query: { tenantId?: string; applicationId?: string; keyword?: string; status?: string } = {}) => request(withQuery('/admin/dictionaries/blacklists/enterprise', query)), + createEnterpriseBlacklist: (body: { tenantId: string; applicationId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => request('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }), deleteEnterpriseBlacklist: (id: string) => request(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }), - listPhoneSegments: () => request('/admin/dictionaries/phone-segments'), + listPhoneSegments: (query: { keyword?: string; cursor?: string; pageSize?: number } = {}) => + request>(withQuery('/admin/dictionaries/phone-segments', query)), createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) => request('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }), listPhoneCarrierRules: () => request('/admin/dictionaries/phone-carrier-rules'), @@ -1057,7 +1065,7 @@ export const clientApi = { request(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', tenantId, body: JSON.stringify({}) }), createBatchTask: (body: { applicationId?: string; templateId?: string; content: string; category?: string; phones: string[]; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; variables?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/send/batch-tasks', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), - previewImport: (body: { content: string; fileName?: string; delimiter?: ',' | '\t'; requiredVariables?: string[] }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + previewImport: (body: { applicationId?: string; content: string; fileName?: string; delimiter?: ',' | '\t'; requiredVariables?: string[] }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/send/imports/preview', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), confirmImport: (body: { applicationId?: string; templateId?: string; content: string; category?: string; importContent: string; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; requiredVariables?: string[]; variables?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), diff --git a/src/apps/admin/AdminEnterpriseBlacklistPage.tsx b/src/apps/admin/AdminEnterpriseBlacklistPage.tsx index 9e7d5a7..7f439f3 100644 --- a/src/apps/admin/AdminEnterpriseBlacklistPage.tsx +++ b/src/apps/admin/AdminEnterpriseBlacklistPage.tsx @@ -1,11 +1,13 @@ import { useEffect, useMemo, useState } from 'react'; import { Plus, Search, Trash2 } from 'lucide-react'; import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, type TableColumn } from '@/components/ui'; -import { adminApi, type DictionaryItem, type TenantOption } from '@/api/adminApi'; +import { adminApi, type DictionaryItem, type EnterpriseApplication, type TenantOption } from '@/api/adminApi'; type EnterpriseBlacklistItem = DictionaryItem & { tenantId?: string; + applicationId?: string; tenant?: TenantOption; + application?: EnterpriseApplication; phoneNumber?: string; reason?: string | null; }; @@ -13,18 +15,27 @@ type EnterpriseBlacklistItem = DictionaryItem & { export function AdminEnterpriseBlacklistPage() { const [items, setItems] = useState([]); const [tenants, setTenants] = useState([]); + const [applications, setApplications] = useState([]); const [keyword, setKeyword] = useState(''); - const [tenantId, setTenantId] = useState(''); + const [filterTenantId, setFilterTenantId] = useState(''); + const [filterApplicationId, setFilterApplicationId] = useState(''); + const [formTenantId, setFormTenantId] = useState(''); + const [formApplicationId, setFormApplicationId] = useState(''); const [phone, setPhone] = useState(''); const [reason, setReason] = useState(''); const [modalOpen, setModalOpen] = useState(false); const [error, setError] = useState(''); function loadData() { - Promise.all([adminApi.listEnterpriseBlacklist({ keyword }), adminApi.listTenants()]) - .then(([blacklist, tenantItems]) => { + Promise.all([ + adminApi.listEnterpriseBlacklist({ tenantId: filterTenantId || undefined, applicationId: filterApplicationId || undefined, keyword }), + adminApi.listTenants(), + adminApi.listEnterpriseApplications(), + ]) + .then(([blacklist, tenantItems, applicationItems]) => { setItems(blacklist as EnterpriseBlacklistItem[]); setTenants(tenantItems); + setApplications(applicationItems); setError(''); }) .catch((failure: Error) => setError(failure.message || '企业黑名单加载失败')); @@ -35,12 +46,15 @@ export function AdminEnterpriseBlacklistPage() { }, []); const filteredItems = useMemo(() => items.filter((item) => { - const text = [item.tenant?.name, item.phoneNumber, item.reason, item.status].join(' '); + const text = [item.tenant?.name, item.application?.name, item.phoneNumber, item.reason, item.status].join(' '); return !keyword || text.includes(keyword); }), [items, keyword]); + const filterApplications = applications.filter((application) => application.tenantId === filterTenantId && application.status !== 'deleted'); + const modalApplications = applications.filter((application) => application.tenantId === formTenantId && application.status !== 'deleted'); const columns = useMemo>>(() => [ { key: 'enterprise', title: '企业名称', width: '180px', render: (record) => {record.tenant?.name ?? record.tenantId} }, + { key: 'application', title: '应用名称', width: '180px', render: (record) => {record.application?.name ?? record.applicationId} }, { key: 'phone', title: '手机号码', width: '150px', render: (record) => {record.phoneNumber} }, { key: 'createdAt', title: '入库时间', width: '170px', render: (record) => record.createdAt ?? '-' }, { key: 'reason', title: '入库原因', render: (record) => record.reason ?? '-' }, @@ -59,9 +73,10 @@ export function AdminEnterpriseBlacklistPage() { ], []); function addItem() { - adminApi.createEnterpriseBlacklist({ tenantId, phoneNumber: phone, reason, status: 'active' }) + adminApi.createEnterpriseBlacklist({ tenantId: formTenantId, applicationId: formApplicationId, phoneNumber: phone, reason, status: 'active' }) .then(() => { - setTenantId(''); + setFormTenantId(''); + setFormApplicationId(''); setPhone(''); setReason(''); setModalOpen(false); @@ -85,13 +100,25 @@ export function AdminEnterpriseBlacklistPage() { setKeyword(event.target.value)} - placeholder="搜索企业、手机号或原因" + placeholder="搜索企业、应用、手机号或原因" prefix={} value={keyword} /> + setFilterApplicationId(event.target.value)} + options={[{ label: filterTenantId ? '全部应用' : '请先选择企业', value: '' }, ...filterApplications.map((item) => ({ label: item.name, value: item.id }))]} + value={filterApplicationId} + />
- +
@@ -103,7 +130,7 @@ export function AdminEnterpriseBlacklistPage() { footer={( <> - + )} onClose={() => setModalOpen(false)} @@ -113,9 +140,15 @@ export function AdminEnterpriseBlacklistPage() {
setFormApplicationId(event.target.value)} + options={[{ label: formTenantId ? '请选择应用' : '请先选择企业', value: '' }, ...modalApplications.map((item) => ({ label: item.name, value: item.id }))]} + value={formApplicationId} /> setPhone(event.target.value)} placeholder="请输入手机号码" value={phone} />