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 {
|
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' } });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 风控规则闭环
|
||||||
|
|||||||
@@ -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`;页面数据来自真实数据库,可稳定前后翻页和搜索;接口不执行全表总数统计,页面不展示号段总条数。 |
|
||||||
|
|||||||
@@ -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 均为 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;
|
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 }) }),
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' : ',',
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user