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,
|
||||
});
|
||||
|
||||
@@ -450,13 +450,13 @@
|
||||
|
||||
### 5.17 安全控制
|
||||
|
||||
- 企业黑名单:企业维度号码拦截。
|
||||
- 企业黑名单:企业应用级号码拦截,同一企业不同短信应用的黑名单互不影响。
|
||||
- 全局黑名单:平台维度号码拦截。
|
||||
- 敏感词管理:发送前和审核时命中提示或拦截。
|
||||
- 手机号段库:用于运营商识别和路由。
|
||||
- 手机号段库:用于运营商识别和路由;列表使用服务端游标分页和服务端搜索,不查询或展示全库总条数。
|
||||
- 引流信息字段库:用于签名/报备资料结构化采集。
|
||||
- 企业黑名单、全局黑名单、敏感词管理必须提供搜索、添加、启停/删除功能;所有操作调用真实后端 API,写入系统日志。
|
||||
- 企业黑名单支持按企业、应用、手机号、入库原因、状态搜索;全局黑名单支持按手机号、原因、状态搜索;敏感词支持按词、分类/级别、状态搜索。
|
||||
- 企业应用级黑名单、全局黑名单、敏感词管理必须提供搜索、添加、启停/删除功能;所有操作调用真实后端 API,写入系统日志。
|
||||
- 企业黑名单必须绑定到具体短信应用,支持按企业、应用、手机号、入库原因、状态搜索;发送预览、风控和发送链路只能拦截当前应用的 active 黑名单号码,不得把同企业其他应用的黑名单串用;全局黑名单支持按手机号、原因、状态搜索;敏感词支持按词、分类/级别、状态搜索。
|
||||
- “引流信息字段库”菜单命名为“报备字段库”,编辑、删除按钮使用通用操作按钮样式。
|
||||
|
||||
### 5.18 风控规则闭环
|
||||
|
||||
@@ -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`;页面数据来自真实数据库,可稳定前后翻页和搜索;接口不执行全表总数统计,页面不展示号段总条数。 |
|
||||
|
||||
@@ -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`,正式生产部署脚本同步固化权限。
|
||||
|
||||
+12
-4
@@ -593,6 +593,13 @@ export type PagedResponse<T> = {
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
export type CursorPage<T> = {
|
||||
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<DictionaryItem>('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }),
|
||||
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)),
|
||||
createEnterpriseBlacklist: (body: { tenantId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) =>
|
||||
listEnterpriseBlacklist: (query: { tenantId?: string; applicationId?: string; keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/enterprise', query)),
|
||||
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) }),
|
||||
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 }) =>
|
||||
request<DictionaryItem>('/admin/dictionaries/phone-segments', { method: 'POST', body: JSON.stringify(body) }),
|
||||
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({}) }),
|
||||
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 }) }),
|
||||
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 }) }),
|
||||
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 }) }),
|
||||
|
||||
@@ -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<EnterpriseBlacklistItem[]>([]);
|
||||
const [tenants, setTenants] = useState<TenantOption[]>([]);
|
||||
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
|
||||
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<Array<TableColumn<EnterpriseBlacklistItem>>>(() => [
|
||||
{ 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: '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() {
|
||||
<Input
|
||||
label="搜索"
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索企业、手机号或原因"
|
||||
placeholder="搜索企业、应用、手机号或原因"
|
||||
prefix={<Search size={16} />}
|
||||
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">
|
||||
<Button icon={<Search size={16} />} onClick={loadData}>查询</Button>
|
||||
<Button onClick={() => setKeyword('')} variant="ghost">重置</Button>
|
||||
<Button onClick={() => { setKeyword(''); setFilterTenantId(''); setFilterApplicationId(''); }} variant="ghost">重置</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +130,7 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
footer={(
|
||||
<>
|
||||
<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)}
|
||||
@@ -113,9 +140,15 @@ export function AdminEnterpriseBlacklistPage() {
|
||||
<div className="admin-security-form">
|
||||
<Select
|
||||
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 }))]}
|
||||
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} />
|
||||
<Textarea label="入库原因" onChange={(event) => setReason(event.target.value)} placeholder="请输入入库原因" rows={3} value={reason} />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus, RadioTower, Search, Smartphone } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Select, Table, Tabs, type TableColumn } from '@/components/ui';
|
||||
import { Plus, Search } from 'lucide-react';
|
||||
import { Breadcrumb, Button, Input, Modal, Pagination, Select, Table, Tabs, type TableColumn } from '@/components/ui';
|
||||
import { adminApi, type DictionaryItem } from '@/api/adminApi';
|
||||
|
||||
type PhoneSegment = DictionaryItem & {
|
||||
@@ -18,6 +18,7 @@ type CarrierRule = DictionaryItem & {
|
||||
};
|
||||
|
||||
export function AdminPhoneSegmentsPage() {
|
||||
const pageSize = 20;
|
||||
const [segments, setSegments] = useState<PhoneSegment[]>([]);
|
||||
const [rules, setRules] = useState<CarrierRule[]>([]);
|
||||
const [activeTab, setActiveTab] = useState<'segments' | 'rules'>('segments');
|
||||
@@ -33,25 +34,48 @@ export function AdminPhoneSegmentsPage() {
|
||||
const [rulePriority, setRulePriority] = useState('100');
|
||||
const [ruleRemark, setRuleRemark] = 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() {
|
||||
Promise.all([adminApi.listPhoneSegments(), adminApi.listPhoneCarrierRules()])
|
||||
.then(([segmentItems, ruleItems]) => {
|
||||
setSegments(segmentItems as PhoneSegment[]);
|
||||
useEffect(() => {
|
||||
const timer = window.setTimeout(() => {
|
||||
setSegmentQuery(keyword.trim());
|
||||
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[]);
|
||||
setError('');
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '手机号段加载失败'));
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
const filteredSegments = useMemo(
|
||||
() => segments.filter((segment) => [segment.prefix, segment.carrier, segment.province, segment.city].some((value) => String(value ?? '').includes(keyword))),
|
||||
[keyword, segments],
|
||||
);
|
||||
.catch((failure: Error) => {
|
||||
if (!cancelled) setError(failure.message || '手机号段加载失败');
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [page, pageCursors, reloadKey, segmentQuery]);
|
||||
|
||||
const filteredRules = useMemo(
|
||||
() => rules.filter((rule) => [rule.carrier, rule.pattern, rule.remark].some((value) => String(value ?? '').includes(keyword))),
|
||||
@@ -65,7 +89,9 @@ export function AdminPhoneSegmentsPage() {
|
||||
setProvince('');
|
||||
setCity('');
|
||||
setCreating(false);
|
||||
loadData();
|
||||
setPage(1);
|
||||
setPageCursors([undefined]);
|
||||
setReloadKey((current) => current + 1);
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '手机号段新增失败'));
|
||||
}
|
||||
@@ -76,7 +102,7 @@ export function AdminPhoneSegmentsPage() {
|
||||
setRulePattern('');
|
||||
setRuleRemark('');
|
||||
setCreatingRule(false);
|
||||
loadData();
|
||||
setReloadKey((current) => current + 1);
|
||||
})
|
||||
.catch((failure: Error) => setError(failure.message || '运营商区分规则新增失败'));
|
||||
}
|
||||
@@ -106,23 +132,6 @@ export function AdminPhoneSegmentsPage() {
|
||||
</div>
|
||||
{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">
|
||||
<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)}>
|
||||
@@ -135,8 +144,31 @@ export function AdminPhoneSegmentsPage() {
|
||||
onChange={(value) => setActiveTab(value as 'segments' | 'rules')}
|
||||
value={activeTab}
|
||||
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>
|
||||
|
||||
@@ -133,6 +133,7 @@ export function ClientSendPage() {
|
||||
try {
|
||||
const content = await file.text();
|
||||
const preview = await clientApi.previewImport({
|
||||
applicationId: applicationId || undefined,
|
||||
content,
|
||||
fileName: file.name,
|
||||
delimiter: file.name.endsWith('.tsv') ? '\t' : ',',
|
||||
|
||||
@@ -8,7 +8,7 @@ type QueryPanelProps = {
|
||||
};
|
||||
|
||||
type PaginationProps = {
|
||||
total: number;
|
||||
total?: number;
|
||||
page?: number;
|
||||
previousDisabled?: boolean;
|
||||
nextDisabled?: boolean;
|
||||
@@ -42,7 +42,7 @@ export function Pagination({
|
||||
}: PaginationProps) {
|
||||
return (
|
||||
<div className="ui-pagination">
|
||||
<span>显示 {total} 条记录</span>
|
||||
{typeof total === 'number' ? <span>显示 {total} 条记录</span> : <span />}
|
||||
<div>
|
||||
<Button disabled={previousDisabled} onClick={onPrevious} size="sm" variant="ghost">上一页</Button>
|
||||
<Button size="sm" variant="secondary">{page}</Button>
|
||||
|
||||
@@ -34,6 +34,10 @@ rm -rf api/dist api/tsconfig.build.tsbuildinfo "$APP_DIR/dist/cmpp-gateway"
|
||||
npm 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)
|
||||
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"
|
||||
PROD_ADMIN_CREDENTIAL_FILE="$ADMIN_CREDENTIAL_FILE" node tools/deploy/ensure-production-admin.mjs
|
||||
|
||||
Reference in New Issue
Block a user