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
@@ -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;
+4 -1
View File
@@ -203,14 +203,16 @@ model SensitiveWord {
model EnterpriseBlacklist { model EnterpriseBlacklist {
id String @id @default(cuid()) id String @id @default(cuid())
tenantId String tenantId String
applicationId String
phoneNumber String phoneNumber String
reason String? reason String?
status String @default("active") status String @default("active")
createdAt DateTime @default(now()) createdAt DateTime @default(now())
tenant Tenant @relation(fields: [tenantId], references: [id]) tenant Tenant @relation(fields: [tenantId], references: [id])
application SmsApplication @relation(fields: [applicationId], references: [id])
@@unique([tenantId, phoneNumber]) @@unique([applicationId, phoneNumber])
} }
model GlobalBlacklist { model GlobalBlacklist {
@@ -361,6 +363,7 @@ model SmsApplication {
ipAllowlist SmsApplicationIpAllowlist[] ipAllowlist SmsApplicationIpAllowlist[]
signatures SmsSignature[] signatures SmsSignature[]
templates SmsTemplate[] templates SmsTemplate[]
enterpriseBlacklists EnterpriseBlacklist[]
sendTasks SmsSendTask[] sendTasks SmsSendTask[]
batchTasks SmsBatchTask[] batchTasks SmsBatchTask[]
messageRecords SmsMessageRecord[] messageRecords SmsMessageRecord[]
@@ -1,6 +1,5 @@
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common'; import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { TenantId } from '../common/tenant-id.decorator';
import { import {
CreateBlacklistDto, CreateBlacklistDto,
CreateDrainageFieldDto, CreateDrainageFieldDto,
@@ -17,8 +16,12 @@ export class DictionariesController {
constructor(private readonly dictionaries: DictionariesService) {} constructor(private readonly dictionaries: DictionariesService) {}
@Get('phone-segments') @Get('phone-segments')
listPhoneSegments() { listPhoneSegments(
return this.dictionaries.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') @Post('phone-segments')
@@ -77,8 +80,8 @@ export class DictionariesController {
} }
@Get('blacklists/enterprise') @Get('blacklists/enterprise')
listEnterpriseBlacklist(@TenantId() tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) { listEnterpriseBlacklist(@Query('tenantId') tenantId?: string, @Query('applicationId') applicationId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) {
return this.dictionaries.listEnterpriseBlacklist({ tenantId, keyword, status }); return this.dictionaries.listEnterpriseBlacklist({ tenantId, applicationId, keyword, status });
} }
@Post('blacklists/enterprise') @Post('blacklists/enterprise')
@@ -2,6 +2,9 @@ import { DictionariesService } from './dictionaries.service';
function createPrismaMock() { function createPrismaMock() {
return { return {
phoneSegment: {
findMany: jest.fn(),
},
sensitiveWord: { sensitiveWord: {
findMany: jest.fn(), findMany: jest.fn(),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'word-1', ...data })), 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 })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
update: 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: { operationLog: {
create: jest.fn(), create: jest.fn(),
}, },
@@ -24,13 +30,43 @@ function createPrismaMock() {
} }
describe('DictionariesService', () => { 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 () => { it('searches security control dictionaries with keyword and status filters', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new DictionariesService(prisma as never); const service = new DictionariesService(prisma as never);
await service.listSensitiveWords({ keyword: '贷款', status: 'active' }); await service.listSensitiveWords({ keyword: '贷款', status: 'active' });
await service.listGlobalBlacklist({ keyword: '138', 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({ expect(prisma.sensitiveWord.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }), where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }),
@@ -39,8 +75,8 @@ describe('DictionariesService', () => {
where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }), where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }),
})); }));
expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1', status: 'active', OR: expect.any(Array) }), where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', status: 'active', OR: expect.any(Array) }),
include: { tenant: true }, include: { tenant: true, application: true },
})); }));
}); });
@@ -50,12 +86,15 @@ describe('DictionariesService', () => {
await service.createSensitiveWord({ word: '高息贷款', level: 'high' }); await service.createSensitiveWord({ word: '高息贷款', level: 'high' });
await service.createGlobalBlacklist({ phoneNumber: '13800000000', reason: '投诉', operatorId: 'admin-1' }); 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.changeSensitiveWordStatus('word-1', { status: 'deleted' });
await service.changeGlobalBlacklistStatus('global-1', { status: 'deleted' }); await service.changeGlobalBlacklistStatus('global-1', { status: 'deleted' });
await service.changeEnterpriseBlacklistStatus('enterprise-1', { status: 'deleted' }); await service.changeEnterpriseBlacklistStatus('enterprise-1', { status: 'deleted' });
expect(prisma.operationLog.create).toHaveBeenCalledTimes(6); 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' } }); 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; city?: string;
} }
export interface PhoneSegmentListQuery {
keyword?: string;
cursor?: string;
pageSize?: number;
}
export interface CreatePhoneCarrierRuleDto { export interface CreatePhoneCarrierRuleDto {
carrier: string; carrier: string;
pattern: string; pattern: string;
@@ -25,6 +31,7 @@ export interface CreateSensitiveWordDto {
export interface CreateBlacklistDto { export interface CreateBlacklistDto {
tenantId?: string; tenantId?: string;
applicationId?: string;
phoneNumber: string; phoneNumber: string;
reason?: string; reason?: string;
status?: string; status?: string;
@@ -48,6 +55,7 @@ export interface DictionaryStatusDto {
export interface DictionaryListQuery { export interface DictionaryListQuery {
tenantId?: string; tenantId?: string;
applicationId?: string;
keyword?: string; keyword?: string;
status?: string; status?: string;
} }
@@ -56,8 +64,34 @@ export interface DictionaryListQuery {
export class DictionariesService { export class DictionariesService {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
listPhoneSegments() { async listPhoneSegments(query: PhoneSegmentListQuery = {}) {
return this.prisma.phoneSegment.findMany({ orderBy: { prefix: 'asc' }, take: 200 }); 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) { createPhoneSegment(data: CreatePhoneSegmentDto) {
@@ -161,24 +195,35 @@ export class DictionariesService {
return this.prisma.enterpriseBlacklist.findMany({ return this.prisma.enterpriseBlacklist.findMany({
where: { where: {
tenantId: query.tenantId, tenantId: query.tenantId,
applicationId: query.applicationId,
status: query.status && query.status !== 'all' ? query.status : undefined, status: query.status && query.status !== 'all' ? query.status : undefined,
OR: query.keyword ? [ OR: query.keyword ? [
{ phoneNumber: { contains: query.keyword } }, { phoneNumber: { contains: query.keyword } },
{ reason: { contains: query.keyword } }, { reason: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
] : undefined, ] : undefined,
}, },
include: { tenant: true }, include: { tenant: true, application: true },
orderBy: { createdAt: 'desc' }, orderBy: { createdAt: 'desc' },
take: 200, take: 200,
}); });
} }
async createEnterpriseBlacklist(data: CreateBlacklistDto) { async createEnterpriseBlacklist(data: CreateBlacklistDto) {
if (!data.tenantId) { if (!data.tenantId || !data.applicationId) {
throw new BadRequestException('tenantId is required for enterprise blacklist'); 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 = { const createData: Prisma.EnterpriseBlacklistUncheckedCreateInput = {
tenantId: data.tenantId, tenantId: data.tenantId,
applicationId: data.applicationId,
phoneNumber: data.phoneNumber, phoneNumber: data.phoneNumber,
reason: data.reason, reason: data.reason,
status: data.status ?? 'active', status: data.status ?? 'active',
@@ -186,6 +231,7 @@ export class DictionariesService {
const created = await 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, { await this.writeOperationLog(data.operatorId, 'enterprise_blacklist.create', 'enterprise_blacklist', created.id, {
tenantId: data.tenantId, tenantId: data.tenantId,
applicationId: data.applicationId,
phoneNumber: data.phoneNumber, phoneNumber: data.phoneNumber,
reason: data.reason, reason: data.reason,
}); });
@@ -81,6 +81,7 @@ describe('RiskReviewService', () => {
it('routes duplicate and blacklist ratio hits to manual review', async () => { it('routes duplicate and blacklist ratio hits to manual review', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.globalBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000001' }]); prisma.globalBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000001' }]);
prisma.enterpriseBlacklist.findMany.mockResolvedValue([{ phoneNumber: '13800000002' }]);
prisma.riskRule.findMany.mockResolvedValue([ prisma.riskRule.findMany.mockResolvedValue([
{ {
id: 'rule-dup', id: 'rule-dup',
@@ -105,6 +106,7 @@ describe('RiskReviewService', () => {
const service = new RiskReviewService(prisma as never); const service = new RiskReviewService(prisma as never);
const result = await service.evaluateTask({ const result = await service.evaluateTask({
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1',
content: 'hello', content: 'hello',
phones: ['13800000001', '13800000001', '13800000002'], phones: ['13800000001', '13800000001', '13800000002'],
}); });
@@ -113,11 +115,15 @@ describe('RiskReviewService', () => {
expect(prisma.smsSendTask.create).toHaveBeenCalledWith({ expect(prisma.smsSendTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({ data: expect.objectContaining({
duplicateRatio: 0.3333, duplicateRatio: 0.3333,
blacklistHitRatio: 0.3333, blacklistHitRatio: 0.6667,
status: 'pending_review', status: 'pending_review',
riskDecision: 'manual_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({ expect(prisma.riskHitRecord.createMany).toHaveBeenCalledWith({
data: expect.arrayContaining([ data: expect.arrayContaining([
expect.objectContaining({ ruleCode: 'DUPLICATE_PHONE_RATIO' }), expect.objectContaining({ ruleCode: 'DUPLICATE_PHONE_RATIO' }),
+5 -5
View File
@@ -181,7 +181,7 @@ export class RiskReviewService {
const duplicateRatio = ratio(phoneTotal - uniquePhoneTotal, phoneTotal); const duplicateRatio = ratio(phoneTotal - uniquePhoneTotal, phoneTotal);
const illegalCount = phones.filter((phone) => !isMainlandMobile(phone)).length; const illegalCount = phones.filter((phone) => !isMainlandMobile(phone)).length;
const illegalRatio = ratio(illegalCount, phoneTotal); 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 blacklistHitRatio = ratio(blacklistHitCount, phoneTotal);
const [application, template, rules, recentTaskCount, sensitiveWords] = await Promise.all([ const [application, template, rules, recentTaskCount, sensitiveWords] = await Promise.all([
data.applicationId ? this.prisma.smsApplication.findUnique({ where: { id: data.applicationId } }) : null, 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); 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) { if (phones.length === 0) {
return 0; return 0;
} }
@@ -333,10 +333,10 @@ export class RiskReviewService {
where: { phoneNumber: { in: phones }, status: 'active' }, where: { phoneNumber: { in: phones }, status: 'active' },
select: { phoneNumber: true }, select: { phoneNumber: true },
}), }),
this.prisma.enterpriseBlacklist.findMany({ applicationId ? this.prisma.enterpriseBlacklist.findMany({
where: { tenantId, phoneNumber: { in: phones }, status: 'active' }, where: { tenantId, applicationId, phoneNumber: { in: phones }, status: 'active' },
select: { phoneNumber: true }, select: { phoneNumber: true },
}), }) : Promise.resolve([]),
]); ]);
return new Set([...globalHits, ...enterpriseHits].map((hit) => hit.phoneNumber)).size; return new Set([...globalHits, ...enterpriseHits].map((hit) => hit.phoneNumber)).size;
} }
@@ -490,6 +490,7 @@ describe('SendChainService', () => {
await expect( await expect(
service.previewImport({ service.previewImport({
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1',
content: 'phoneNumber,code\n13800000001,1234\n13800000001,1234\nbad,1234\n13800000003,1234\n13900000001,', content: 'phoneNumber,code\n13800000001,1234\n13800000001,1234\nbad,1234\n13800000003,1234\n13900000001,',
requiredVariables: ['code'], 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 () => { it('adds queued message jobs for a batch task', async () => {
+5 -3
View File
@@ -144,6 +144,7 @@ export interface TimeoutUnknownDto {
export interface ImportPreviewDto { export interface ImportPreviewDto {
tenantId: string; tenantId: string;
applicationId?: string;
content: string; content: string;
fileName?: string; fileName?: string;
encoding?: 'utf8' | 'gbk'; encoding?: 'utf8' | 'gbk';
@@ -415,10 +416,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const phones: string[] = []; const phones: string[] = [];
const errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }> = []; const errors: Array<{ rowNumber: number; phoneNumber?: string; reason: string }> = [];
const requiredVariables = data.requiredVariables ?? []; const requiredVariables = data.requiredVariables ?? [];
const enterpriseBlacklist = await this.prisma.enterpriseBlacklist.findMany({ const enterpriseBlacklist = data.applicationId ? await this.prisma.enterpriseBlacklist.findMany({
where: { tenantId: data.tenantId, status: 'active' }, where: { tenantId: data.tenantId, applicationId: data.applicationId, status: 'active' },
select: { phoneNumber: true }, select: { phoneNumber: true },
}); }) : [];
const globalBlacklist = await this.prisma.globalBlacklist.findMany({ const globalBlacklist = await this.prisma.globalBlacklist.findMany({
where: { status: 'active' }, where: { status: 'active' },
select: { phoneNumber: true }, select: { phoneNumber: true },
@@ -464,6 +465,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
async confirmImport(data: ConfirmImportDto) { async confirmImport(data: ConfirmImportDto) {
const preview = await this.previewImport({ const preview = await this.previewImport({
tenantId: data.tenantId, tenantId: data.tenantId,
applicationId: data.applicationId,
content: data.importContent, content: data.importContent,
requiredVariables: data.requiredVariables, requiredVariables: data.requiredVariables,
}); });
@@ -450,13 +450,13 @@
### 5.17 安全控制 ### 5.17 安全控制
- 企业黑名单:企业维度号码拦截。 - 企业黑名单:企业应用级号码拦截,同一企业不同短信应用的黑名单互不影响
- 全局黑名单:平台维度号码拦截。 - 全局黑名单:平台维度号码拦截。
- 敏感词管理:发送前和审核时命中提示或拦截。 - 敏感词管理:发送前和审核时命中提示或拦截。
- 手机号段库:用于运营商识别和路由。 - 手机号段库:用于运营商识别和路由;列表使用服务端游标分页和服务端搜索,不查询或展示全库总条数
- 引流信息字段库:用于签名/报备资料结构化采集。 - 引流信息字段库:用于签名/报备资料结构化采集。
- 企业黑名单、全局黑名单、敏感词管理必须提供搜索、添加、启停/删除功能;所有操作调用真实后端 API,写入系统日志。 - 企业应用级黑名单、全局黑名单、敏感词管理必须提供搜索、添加、启停/删除功能;所有操作调用真实后端 API,写入系统日志。
- 企业黑名单支持按企业、应用、手机号、入库原因、状态搜索;全局黑名单支持按手机号、原因、状态搜索;敏感词支持按词、分类/级别、状态搜索。 - 企业黑名单必须绑定到具体短信应用,支持按企业、应用、手机号、入库原因、状态搜索;发送预览、风控和发送链路只能拦截当前应用的 active 黑名单号码,不得把同企业其他应用的黑名单串用;全局黑名单支持按手机号、原因、状态搜索;敏感词支持按词、分类/级别、状态搜索。
- “引流信息字段库”菜单命名为“报备字段库”,编辑、删除按钮使用通用操作按钮样式。 - “引流信息字段库”菜单命名为“报备字段库”,编辑、删除按钮使用通用操作按钮样式。
### 5.18 风控规则闭环 ### 5.18 风控规则闭环
+4 -2
View File
@@ -520,11 +520,12 @@
- 前置条件:运营管理员已登录,存在企业、应用和若干黑名单/敏感词数据。 - 前置条件:运营管理员已登录,存在企业、应用和若干黑名单/敏感词数据。
- 步骤: - 步骤:
1. 在企业黑名单中按企业、应用、手机号、原因、状态搜索。 1. 在企业黑名单中按企业、应用、手机号、原因、状态搜索。
2. 新增企业黑名单,停用后再删除。 2. 新增企业应用级黑名单,停用后再删除。
3. 在全局黑名单中按手机号、原因、状态搜索,并新增、停用、删除。 3. 在全局黑名单中按手机号、原因、状态搜索,并新增、停用、删除。
4. 在敏感词管理中按词、分类/级别、状态搜索,并新增、停用、删除。 4. 在敏感词管理中按词、分类/级别、状态搜索,并新增、停用、删除。
- 预期结果: - 预期结果:
- 搜索、添加、启停、删除均调用真实字典 API。 - 搜索、添加、启停、删除均调用真实字典 API。
- 企业黑名单必须绑定短信应用,只影响该应用发送预览和风控;同企业其他应用不被拦截。
- 启停/删除后发送前风控只使用 active 数据。 - 启停/删除后发送前风控只使用 active 数据。
- 所有安全控制变更写入系统日志。 - 所有安全控制变更写入系统日志。
- 操作按钮使用统一编辑、删除、启停样式。 - 操作按钮使用统一编辑、删除、启停样式。
@@ -3007,7 +3008,7 @@ npm run verify:phase8
| TC-ADMIN-017 | 对有关联历史的通道执行停用、启用、删除。 | 取消确认无请求或无状态变化;停用后不参与路由;删除为软删除/归档;历史发送、报备、日志仍可查。 | | TC-ADMIN-017 | 对有关联历史的通道执行停用、启用、删除。 | 取消确认无请求或无状态变化;停用后不参与路由;删除为软删除/归档;历史发送、报备、日志仍可查。 |
| TC-ADMIN-018 | 企业应用列表展示 CMPP 连接数,打开连接详情,删除连接,复制 CMPP 参数。 | 连接数来自连接状态 API;详情含 connectionId/status/heartbeat/window/lastSubmitAt;删除连接调用真实接口;复制文本与 API 返回一致。 | | 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-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-021 | 创建待审核企业认证、签名、模板、短信审核任务,检查铃铛总数和分类数。 | 总数等于分类汇总;点击分类跳转并带入筛选;审核完成后数量刷新;新增待办触发站内提醒或浏览器通知。 |
| TC-ADMIN-022 | 运营日志按客户、操作者、动作、资源、时间搜索,查看长详情。 | 后端分页和搜索准确;详情不截断;可查到通道复制、启停、删除、连接状态变化、安全控制变更、充值等日志。 | | TC-ADMIN-022 | 运营日志按客户、操作者、动作、资源、时间搜索,查看长详情。 | 后端分页和搜索准确;详情不截断;可查到通道复制、启停、删除、连接状态变化、安全控制变更、充值等日志。 |
@@ -3121,3 +3122,4 @@ npm run verify:phase8
| TC-MOCK-CLEAN-005 | 运营端短信审核通过、批量通过、驳回风控审核任务。 | 调用 `admin/risk-review/tasks` 真实接口;通过必须弹窗确认;状态刷新后仍持久化;不再显示固定手机号样例。 | | TC-MOCK-CLEAN-005 | 运营端短信审核通过、批量通过、驳回风控审核任务。 | 调用 `admin/risk-review/tasks` 真实接口;通过必须弹窗确认;状态刷新后仍持久化;不再显示固定手机号样例。 |
| TC-MOCK-CLEAN-006 | 运营端短信记录按手机号、状态、日期和内容查询,打开详情。 | 数据来自 `sms_message_records`;详情展示真实 messageId、状态、失败原因;无数据时为空态。 | | TC-MOCK-CLEAN-006 | 运营端短信记录按手机号、状态、日期和内容查询,打开详情。 | 数据来自 `sms_message_records`;详情展示真实 messageId、状态、失败原因;无数据时为空态。 |
| TC-MOCK-CLEAN-007 | 访问明确标注待开发的彩信菜单。 | 可以显示待开发/空态;不得作为第一版短信真实功能通过依据。 | | TC-MOCK-CLEAN-007 | 访问明确标注待开发的彩信菜单。 | 可以显示待开发/空态;不得作为第一版短信真实功能通过依据。 |
| TC-PHONE-SEGMENT-001 | 生产库存在 50 万级手机号段时打开手机号段库,连续点击下一页、上一页,并按号段、省份、城市或运营商搜索。 | API 使用 `prefix` 游标分页并返回 `hasMore/nextCursor`;页面数据来自真实数据库,可稳定前后翻页和搜索;接口不执行全表总数统计,页面不展示号段总条数。 |
+32
View File
@@ -298,6 +298,7 @@ npm run test:gateway
- `DELETE /api/admin/channels/:id`:软删除通道,避免破坏历史发送/报备外键。 - `DELETE /api/admin/channels/:id`:软删除通道,避免破坏历史发送/报备外键。
- `GET /api/admin/channels/:id/connection-logs`:基于 `OperationLog``CmppConnectionState` 查询连接日志;保留 `/link-logs` 兼容旧前端。 - `GET /api/admin/channels/:id/connection-logs`:基于 `OperationLog``CmppConnectionState` 查询连接日志;保留 `/link-logs` 兼容旧前端。
- 安全控制补齐真实 API:敏感词、全局黑名单、企业黑名单支持 keyword/status 查询、创建、启停/软删除,并写操作日志。 - 安全控制补齐真实 API:敏感词、全局黑名单、企业黑名单支持 keyword/status 查询、创建、启停/软删除,并写操作日志。
- 企业黑名单修正为企业应用级黑名单:Prisma `EnterpriseBlacklist` 新增 `applicationId` 并改为 `applicationId + phoneNumber` 唯一;运营端页面按企业和短信应用新增/搜索;发送预览和风控只命中当前应用的 active 黑名单。
- 模板审核补齐真实查询:运营端模板列表支持 keyword/status,并返回企业、应用、签名信息;前端模板审核页已改为调用真实 API。 - 模板审核补齐真实查询:运营端模板列表支持 keyword/status,并返回企业、应用、签名信息;前端模板审核页已改为调用真实 API。
- 运营端企业模板管理新增短信模板时,后端直接写入 `auditStatus=approved`;短信模板审核页展示为“已通过”,客户端自行提交模板仍保留审核流。 - 运营端企业模板管理新增短信模板时,后端直接写入 `auditStatus=approved`;短信模板审核页展示为“已通过”,客户端自行提交模板仍保留审核流。
- 企业认证审核补齐真实查询:列表支持 keyword/status,详情返回企业信息和认证 materials;前端企业认证审核页已改为调用真实 API。 - 企业认证审核补齐真实查询:列表支持 keyword/status,详情返回企业信息和认证 materials;前端企业认证审核页已改为调用真实 API。
@@ -1355,3 +1356,34 @@ git diff --check
- API build 通过。 - API build 通过。
- 前端 build 通过,仍存在既有 Vite chunk size warning。 - 前端 build 通过,仍存在既有 Vite chunk size warning。
- `git diff --check` 无空白错误,仅 Windows 工作区 LF/CRLF 提示。 - `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 均为 activeAPI health 正常。
- 隔离部署后曾因 `dist/assets` 被保留为 `700 root:root` 导致 Nginx 无权读取 JS/CSS、admin 页面空白;线上已修正为目录 `755`、文件 `644`,正式生产部署脚本同步固化权限。
+12 -4
View File
@@ -593,6 +593,13 @@ export type PagedResponse<T> = {
pageSize: number; pageSize: number;
}; };
export type CursorPage<T> = {
items: T[];
pageSize: number;
hasMore: boolean;
nextCursor: string | null;
};
export type EnterpriseApplication = { export type EnterpriseApplication = {
id: string; id: string;
tenantId: string; tenantId: string;
@@ -957,11 +964,12 @@ export const adminApi = {
createGlobalBlacklist: (body: { phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => createGlobalBlacklist: (body: { phoneNumber: string; reason?: string; status?: string; operatorId?: string }) =>
request<DictionaryItem>('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }), request<DictionaryItem>('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }),
deleteGlobalBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }), deleteGlobalBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }),
listEnterpriseBlacklist: (query: { tenantId?: string; keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/enterprise', query)), listEnterpriseBlacklist: (query: { tenantId?: string; applicationId?: string; keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/enterprise', query)),
createEnterpriseBlacklist: (body: { tenantId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => createEnterpriseBlacklist: (body: { tenantId: string; applicationId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) =>
request<DictionaryItem>('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }), request<DictionaryItem>('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }),
deleteEnterpriseBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }), deleteEnterpriseBlacklist: (id: string) => request<DictionaryItem>(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }),
listPhoneSegments: () => request<DictionaryItem[]>('/admin/dictionaries/phone-segments'), listPhoneSegments: (query: { keyword?: string; cursor?: string; pageSize?: number } = {}) =>
request<CursorPage<DictionaryItem>>(withQuery('/admin/dictionaries/phone-segments', query)),
createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) => createPhoneSegment: (body: { prefix: string; carrier: string; province?: string; city?: string }) =>
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }), request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
listPhoneCarrierRules: () => request<DictionaryItem[]>('/admin/dictionaries/phone-carrier-rules'), listPhoneCarrierRules: () => request<DictionaryItem[]>('/admin/dictionaries/phone-carrier-rules'),
@@ -1057,7 +1065,7 @@ export const clientApi = {
request<SmsBatchTask>(`/client/send/batch-tasks/${id}/cancel`, { method: 'POST', tenantId, body: JSON.stringify({}) }), request<SmsBatchTask>(`/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<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => createBatchTask: (body: { applicationId?: string; templateId?: string; content: string; category?: string; phones: string[]; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; variables?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsBatchTask>('/client/send/batch-tasks', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), request<SmsBatchTask>('/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<ImportPreviewResponse>('/client/send/imports/preview', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), request<ImportPreviewResponse>('/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<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => confirmImport: (body: { applicationId?: string; templateId?: string; content: string; category?: string; importContent: string; sendMode?: 'immediate' | 'scheduled'; scheduledAt?: string; requiredVariables?: string[]; variables?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsBatchTask>('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }), request<SmsBatchTask>('/client/send/imports/confirm', { method: 'POST', tenantId, body: JSON.stringify({ ...body, tenantId }) }),
+45 -12
View File
@@ -1,11 +1,13 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Plus, Search, Trash2 } from 'lucide-react'; import { Plus, Search, Trash2 } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Table, Textarea, type TableColumn } from '@/components/ui'; 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 & { type EnterpriseBlacklistItem = DictionaryItem & {
tenantId?: string; tenantId?: string;
applicationId?: string;
tenant?: TenantOption; tenant?: TenantOption;
application?: EnterpriseApplication;
phoneNumber?: string; phoneNumber?: string;
reason?: string | null; reason?: string | null;
}; };
@@ -13,18 +15,27 @@ type EnterpriseBlacklistItem = DictionaryItem & {
export function AdminEnterpriseBlacklistPage() { export function AdminEnterpriseBlacklistPage() {
const [items, setItems] = useState<EnterpriseBlacklistItem[]>([]); const [items, setItems] = useState<EnterpriseBlacklistItem[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]); const [tenants, setTenants] = useState<TenantOption[]>([]);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [keyword, setKeyword] = 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 [phone, setPhone] = useState('');
const [reason, setReason] = useState(''); const [reason, setReason] = useState('');
const [modalOpen, setModalOpen] = useState(false); const [modalOpen, setModalOpen] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
function loadData() { function loadData() {
Promise.all([adminApi.listEnterpriseBlacklist({ keyword }), adminApi.listTenants()]) Promise.all([
.then(([blacklist, tenantItems]) => { adminApi.listEnterpriseBlacklist({ tenantId: filterTenantId || undefined, applicationId: filterApplicationId || undefined, keyword }),
adminApi.listTenants(),
adminApi.listEnterpriseApplications(),
])
.then(([blacklist, tenantItems, applicationItems]) => {
setItems(blacklist as EnterpriseBlacklistItem[]); setItems(blacklist as EnterpriseBlacklistItem[]);
setTenants(tenantItems); setTenants(tenantItems);
setApplications(applicationItems);
setError(''); setError('');
}) })
.catch((failure: Error) => setError(failure.message || '企业黑名单加载失败')); .catch((failure: Error) => setError(failure.message || '企业黑名单加载失败'));
@@ -35,12 +46,15 @@ export function AdminEnterpriseBlacklistPage() {
}, []); }, []);
const filteredItems = useMemo(() => items.filter((item) => { 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); return !keyword || text.includes(keyword);
}), [items, 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<Array<TableColumn<EnterpriseBlacklistItem>>>(() => [ const columns = useMemo<Array<TableColumn<EnterpriseBlacklistItem>>>(() => [
{ key: 'enterprise', title: '企业名称', width: '180px', render: (record) => <strong>{record.tenant?.name ?? record.tenantId}</strong> }, { key: 'enterprise', title: '企业名称', width: '180px', render: (record) => <strong>{record.tenant?.name ?? record.tenantId}</strong> },
{ key: 'application', title: '应用名称', width: '180px', render: (record) => <span>{record.application?.name ?? record.applicationId}</span> },
{ key: 'phone', title: '手机号码', width: '150px', render: (record) => <strong>{record.phoneNumber}</strong> }, { key: 'phone', title: '手机号码', width: '150px', render: (record) => <strong>{record.phoneNumber}</strong> },
{ key: 'createdAt', title: '入库时间', width: '170px', render: (record) => record.createdAt ?? '-' }, { key: 'createdAt', title: '入库时间', width: '170px', render: (record) => record.createdAt ?? '-' },
{ key: 'reason', title: '入库原因', render: (record) => record.reason ?? '-' }, { key: 'reason', title: '入库原因', render: (record) => record.reason ?? '-' },
@@ -59,9 +73,10 @@ export function AdminEnterpriseBlacklistPage() {
], []); ], []);
function addItem() { function addItem() {
adminApi.createEnterpriseBlacklist({ tenantId, phoneNumber: phone, reason, status: 'active' }) adminApi.createEnterpriseBlacklist({ tenantId: formTenantId, applicationId: formApplicationId, phoneNumber: phone, reason, status: 'active' })
.then(() => { .then(() => {
setTenantId(''); setFormTenantId('');
setFormApplicationId('');
setPhone(''); setPhone('');
setReason(''); setReason('');
setModalOpen(false); setModalOpen(false);
@@ -85,13 +100,25 @@ export function AdminEnterpriseBlacklistPage() {
<Input <Input
label="搜索" label="搜索"
onChange={(event) => setKeyword(event.target.value)} onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索企业、手机号或原因" placeholder="搜索企业、应用、手机号或原因"
prefix={<Search size={16} />} prefix={<Search size={16} />}
value={keyword} value={keyword}
/> />
<Select
label="企业"
onChange={(event) => { setFilterTenantId(event.target.value); setFilterApplicationId(''); }}
options={[{ label: '全部企业', value: '' }, ...tenants.map((item) => ({ label: item.name, value: item.id }))]}
value={filterTenantId}
/>
<Select
label="短信应用"
onChange={(event) => setFilterApplicationId(event.target.value)}
options={[{ label: filterTenantId ? '全部应用' : '请先选择企业', value: '' }, ...filterApplications.map((item) => ({ label: item.name, value: item.id }))]}
value={filterApplicationId}
/>
<div className="admin-security-filter__actions"> <div className="admin-security-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button> <Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={() => setKeyword('')} variant="ghost"></Button> <Button onClick={() => { setKeyword(''); setFilterTenantId(''); setFilterApplicationId(''); }} variant="ghost"></Button>
</div> </div>
</div> </div>
@@ -103,7 +130,7 @@ export function AdminEnterpriseBlacklistPage() {
footer={( footer={(
<> <>
<Button onClick={() => setModalOpen(false)} variant="ghost"></Button> <Button onClick={() => setModalOpen(false)} variant="ghost"></Button>
<Button disabled={!tenantId || !phone} onClick={addItem}></Button> <Button disabled={!formTenantId || !formApplicationId || !phone} onClick={addItem}></Button>
</> </>
)} )}
onClose={() => setModalOpen(false)} onClose={() => setModalOpen(false)}
@@ -113,9 +140,15 @@ export function AdminEnterpriseBlacklistPage() {
<div className="admin-security-form"> <div className="admin-security-form">
<Select <Select
label="企业" label="企业"
onChange={(event) => setTenantId(event.target.value)} onChange={(event) => { setFormTenantId(event.target.value); setFormApplicationId(''); }}
options={[{ label: '请选择企业', value: '' }, ...tenants.map((item) => ({ label: item.name, value: item.id }))]} options={[{ label: '请选择企业', value: '' }, ...tenants.map((item) => ({ label: item.name, value: item.id }))]}
value={tenantId} value={formTenantId}
/>
<Select
label="短信应用"
onChange={(event) => setFormApplicationId(event.target.value)}
options={[{ label: formTenantId ? '请选择应用' : '请先选择企业', value: '' }, ...modalApplications.map((item) => ({ label: item.name, value: item.id }))]}
value={formApplicationId}
/> />
<Input label="手机号码" onChange={(event) => setPhone(event.target.value)} placeholder="请输入手机号码" value={phone} /> <Input label="手机号码" onChange={(event) => setPhone(event.target.value)} placeholder="请输入手机号码" value={phone} />
<Textarea label="入库原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入入库原因" rows={3} value={reason} /> <Textarea label="入库原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入入库原因" rows={3} value={reason} />
+70 -38
View File
@@ -1,6 +1,6 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { Plus, RadioTower, Search, Smartphone } from 'lucide-react'; import { Plus, Search } from 'lucide-react';
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, type TableColumn } from '@/components/ui'; import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem } from '@/api/adminApi'; import { adminApi, type DictionaryItem } from '@/api/adminApi';
type PhoneSegment = DictionaryItem & { type PhoneSegment = DictionaryItem & {
@@ -18,6 +18,7 @@ type CarrierRule = DictionaryItem & {
}; };
export function AdminPhoneSegmentsPage() { export function AdminPhoneSegmentsPage() {
const pageSize = 20;
const [segments, setSegments] = useState<PhoneSegment[]>([]); const [segments, setSegments] = useState<PhoneSegment[]>([]);
const [rules, setRules] = useState<CarrierRule[]>([]); const [rules, setRules] = useState<CarrierRule[]>([]);
const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments'); const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments');
@@ -33,25 +34,48 @@ export function AdminPhoneSegmentsPage() {
const [rulePriority, setRulePriority] = useState('100'); const [rulePriority, setRulePriority] = useState('100');
const [ruleRemark, setRuleRemark] = useState(''); const [ruleRemark, setRuleRemark] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
const [segmentQuery, setSegmentQuery] = useState('');
const [page, setPage] = useState(1);
const [pageCursors, setPageCursors] = useState<Array<string | undefined>>([undefined]);
const [hasMore, setHasMore] = useState(false);
const [nextCursor, setNextCursor] = useState<string | null>(null);
const [reloadKey, setReloadKey] = useState(0);
function loadData() { useEffect(() => {
Promise.all([adminApi.listPhoneSegments(), adminApi.listPhoneCarrierRules()]) const timer = window.setTimeout(() => {
.then(([segmentItems, ruleItems]) => { setSegmentQuery(keyword.trim());
setSegments(segmentItems as PhoneSegment[]); setPage(1);
setPageCursors([undefined]);
}, 300);
return () => window.clearTimeout(timer);
}, [keyword]);
useEffect(() => {
let cancelled = false;
setLoading(true);
Promise.all([
adminApi.listPhoneSegments({ keyword: segmentQuery || undefined, cursor: pageCursors[page - 1], pageSize }),
adminApi.listPhoneCarrierRules(),
])
.then(([segmentPage, ruleItems]) => {
if (cancelled) return;
setSegments(segmentPage.items as PhoneSegment[]);
setHasMore(segmentPage.hasMore);
setNextCursor(segmentPage.nextCursor);
setRules(ruleItems as CarrierRule[]); setRules(ruleItems as CarrierRule[]);
setError(''); setError('');
}) })
.catch((failure: Error) => setError(failure.message || '手机号段加载失败')); .catch((failure: Error) => {
} if (!cancelled) setError(failure.message || '手机号段加载失败');
})
useEffect(() => { .finally(() => {
loadData(); if (!cancelled) setLoading(false);
}, []); });
return () => {
const filteredSegments = useMemo( cancelled = true;
() => segments.filter((segment) => [segment.prefix, segment.carrier, segment.province, segment.city].some((value) => String(value ?? '').includes(keyword))), };
[keyword, segments], }, [page, pageCursors, reloadKey, segmentQuery]);
);
const filteredRules = useMemo( const filteredRules = useMemo(
() => rules.filter((rule) => [rule.carrier, rule.pattern, rule.remark].some((value) => String(value ?? '').includes(keyword))), () => rules.filter((rule) => [rule.carrier, rule.pattern, rule.remark].some((value) => String(value ?? '').includes(keyword))),
@@ -65,7 +89,9 @@ export function AdminPhoneSegmentsPage() {
setProvince(''); setProvince('');
setCity(''); setCity('');
setCreating(false); setCreating(false);
loadData(); setPage(1);
setPageCursors([undefined]);
setReloadKey((current) => current + 1);
}) })
.catch((failure: Error) => setError(failure.message || '手机号段新增失败')); .catch((failure: Error) => setError(failure.message || '手机号段新增失败'));
} }
@@ -76,7 +102,7 @@ export function AdminPhoneSegmentsPage() {
setRulePattern(''); setRulePattern('');
setRuleRemark(''); setRuleRemark('');
setCreatingRule(false); setCreatingRule(false);
loadData(); setReloadKey((current) => current + 1);
}) })
.catch((failure: Error) => setError(failure.message || '运营商区分规则新增失败')); .catch((failure: Error) => setError(failure.message || '运营商区分规则新增失败'));
} }
@@ -106,23 +132,6 @@ export function AdminPhoneSegmentsPage() {
</div> </div>
{error ? <p className="form-error">{error}</p> : null} {error ? <p className="form-error">{error}</p> : null}
<div className="phone-segment-overview">
<section>
<span><Smartphone size={20} /></span>
<div>
<strong>{segments.length}</strong>
<p></p>
</div>
</section>
<section>
<span><RadioTower size={20} /></span>
<div>
<strong>{rules.length}</strong>
<p></p>
</div>
</section>
</div>
<div className="surface admin-system-toolbar phone-segment-toolbar"> <div className="surface admin-system-toolbar phone-segment-toolbar">
<Input onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '搜索手机号段、运营商、省份或城市' : '搜索运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} /> <Input onChange={(event) => setKeyword(event.target.value)} placeholder={activeTab === 'segments' ? '搜索手机号段、运营商、省份或城市' : '搜索运营商、正则或备注'} prefix={<Search size={16} />} value={keyword} />
<Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}> <Button icon={<Plus size={16} />} onClick={() => activeTab === 'segments' ? setCreating(true) : setCreatingRule(true)}>
@@ -135,8 +144,31 @@ export function AdminPhoneSegmentsPage() {
onChange={(value) => setActiveTab(value as 'segments' | 'rules')} onChange={(value) => setActiveTab(value as 'segments' | 'rules')}
value={activeTab} value={activeTab}
items={[ items={[
{ label: `手机号段 ${segments.length}`, value: 'segments', content: <Table columns={columns} data={filteredSegments} emptyText="暂无手机号段" rowKey="id" /> }, {
{ label: `运营商区分规则 ${rules.length}`, value: 'rules', content: <Table columns={ruleColumns} data={filteredRules} emptyText="暂无运营商区分规则" rowKey="id" /> }, label: '手机号段',
value: 'segments',
content: (
<>
<Table columns={columns} data={segments} emptyText={loading ? '加载中...' : '暂无手机号段'} pagination={false} rowKey="id" />
<Pagination
page={page}
previousDisabled={page <= 1 || loading}
nextDisabled={!hasMore || loading}
onPrevious={() => setPage((current) => Math.max(1, current - 1))}
onNext={() => {
if (!nextCursor) return;
setPageCursors((current) => {
const updated = [...current];
updated[page] = nextCursor;
return updated;
});
setPage((current) => current + 1);
}}
/>
</>
),
},
{ label: '运营商区分规则', value: 'rules', content: <Table columns={ruleColumns} data={filteredRules} emptyText="暂无运营商区分规则" rowKey="id" /> },
]} ]}
/> />
</div> </div>
+1
View File
@@ -133,6 +133,7 @@ export function ClientSendPage() {
try { try {
const content = await file.text(); const content = await file.text();
const preview = await clientApi.previewImport({ const preview = await clientApi.previewImport({
applicationId: applicationId || undefined,
content, content,
fileName: file.name, fileName: file.name,
delimiter: file.name.endsWith('.tsv') ? '\t' : ',', delimiter: file.name.endsWith('.tsv') ? '\t' : ',',
+2 -2
View File
@@ -8,7 +8,7 @@ type QueryPanelProps = {
}; };
type PaginationProps = { type PaginationProps = {
total: number; total?: number;
page?: number; page?: number;
previousDisabled?: boolean; previousDisabled?: boolean;
nextDisabled?: boolean; nextDisabled?: boolean;
@@ -42,7 +42,7 @@ export function Pagination({
}: PaginationProps) { }: PaginationProps) {
return ( return (
<div className="ui-pagination"> <div className="ui-pagination">
<span> {total} </span> {typeof total === 'number' ? <span> {total} </span> : <span />}
<div> <div>
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost"></Button> <Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost"></Button>
<Button size="sm" variant="secondary">{page}</Button> <Button size="sm" variant="secondary">{page}</Button>
+4
View File
@@ -34,6 +34,10 @@ rm -rf api/dist api/tsconfig.build.tsbuildinfo "$APP_DIR/dist/cmpp-gateway"
npm run build npm run build
npm --prefix api run build npm --prefix api run build
(cd gateway && GOPROXY="${GOPROXY:-https://goproxy.cn,direct}" /usr/local/bin/go build -o "$APP_DIR/dist/cmpp-gateway" ./cmd/gateway) (cd gateway && GOPROXY="${GOPROXY:-https://goproxy.cn,direct}" /usr/local/bin/go build -o "$APP_DIR/dist/cmpp-gateway" ./cmd/gateway)
chmod 755 "$APP_DIR/dist" "$APP_DIR/dist/assets"
find "$APP_DIR/dist/assets" -type d -exec chmod 755 {} +
find "$APP_DIR/dist/assets" -type f -exec chmod 644 {} +
chmod 644 "$APP_DIR/dist/index.html"
echo "[deploy] Ensuring production admin" echo "[deploy] Ensuring production admin"
PROD_ADMIN_CREDENTIAL_FILE="$ADMIN_CREDENTIAL_FILE" node tools/deploy/ensure-production-admin.mjs PROD_ADMIN_CREDENTIAL_FILE="$ADMIN_CREDENTIAL_FILE" node tools/deploy/ensure-production-admin.mjs