feat: complete drainage review and admin search workflows

This commit is contained in:
hectorzhao
2026-07-14 09:58:18 +08:00
parent dec2430b7e
commit 6421671259
33 changed files with 1107 additions and 255 deletions
@@ -0,0 +1,78 @@
CREATE TABLE "SmsDrainageInfo" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"signatureId" TEXT NOT NULL,
"applicationId" TEXT,
"siteName" TEXT NOT NULL,
"url" TEXT NOT NULL,
"remark" TEXT,
"reportValues" JSONB,
"auditStatus" TEXT NOT NULL DEFAULT 'pending',
"rejectReason" TEXT,
"submittedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reviewedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsDrainageInfo_pkey" PRIMARY KEY ("id")
);
CREATE TEMP TABLE drainage_backfill_map AS
SELECT
signature."id" AS "signatureId",
COALESCE(NULLIF(item.value->>'id', ''), item.ordinality::text) AS "oldId",
'drain_' || md5(signature."id" || ':' || COALESCE(NULLIF(item.value->>'id', ''), item.ordinality::text)) AS "newId",
signature."tenantId" AS "tenantId",
signature."applicationId" AS "applicationId",
item.value AS item
FROM "SmsSignature" signature
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(signature."drainageInfo"->'links', '[]'::jsonb)) WITH ORDINALITY AS item(value, ordinality);
INSERT INTO "SmsDrainageInfo" (
"id", "tenantId", "signatureId", "applicationId", "siteName", "url", "remark", "reportValues",
"auditStatus", "submittedAt", "createdAt", "updatedAt"
)
SELECT
"newId",
"tenantId",
"signatureId",
"applicationId",
COALESCE(NULLIF(item->>'siteName', ''), '未命名站点'),
COALESCE(item->>'url', ''),
NULLIF(item->>'remark', ''),
COALESCE(item->'reportValues', '{}'::jsonb),
CASE WHEN item->>'auditStatus' IN ('pending', 'approved', 'rejected') THEN item->>'auditStatus' ELSE 'pending' END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM drainage_backfill_map;
UPDATE "ChannelSignatureReportTask" task
SET "drainageItemId" = map."newId"
FROM drainage_backfill_map map
WHERE task."signatureId" = map."signatureId"
AND task."reportType" = 'drainage'
AND task."drainageItemId" = map."oldId";
UPDATE "DrainageReportMaterial" material
SET "drainageItemId" = map."newId"
FROM drainage_backfill_map map
WHERE material."signatureId" = map."signatureId"
AND material."drainageItemId" = map."oldId";
DROP TABLE drainage_backfill_map;
CREATE INDEX "SmsDrainageInfo_tenantId_auditStatus_updatedAt_idx"
ON "SmsDrainageInfo"("tenantId", "auditStatus", "updatedAt");
CREATE INDEX "SmsDrainageInfo_signatureId_auditStatus_idx"
ON "SmsDrainageInfo"("signatureId", "auditStatus");
CREATE INDEX "SmsDrainageInfo_applicationId_idx"
ON "SmsDrainageInfo"("applicationId");
ALTER TABLE "SmsDrainageInfo" ADD CONSTRAINT "SmsDrainageInfo_tenantId_fkey"
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "SmsDrainageInfo" ADD CONSTRAINT "SmsDrainageInfo_signatureId_fkey"
FOREIGN KEY ("signatureId") REFERENCES "SmsSignature"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SmsDrainageInfo" ADD CONSTRAINT "SmsDrainageInfo_applicationId_fkey"
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ChannelSignatureReportTask" ADD CONSTRAINT "ChannelSignatureReportTask_drainageItemId_fkey"
FOREIGN KEY ("drainageItemId") REFERENCES "SmsDrainageInfo"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
ALTER TABLE "ChannelSignatureReportRecord"
ADD COLUMN "sourceEntry" TEXT NOT NULL DEFAULT 'system';
+31
View File
@@ -26,6 +26,7 @@ model Tenant {
smsBillingRecords SmsBillingRecord[]
smsApplications SmsApplication[]
smsSignatures SmsSignature[]
smsDrainageInfos SmsDrainageInfo[]
smsTemplates SmsTemplate[]
auditRecords AuditRecord[]
riskRules RiskRule[]
@@ -366,6 +367,7 @@ model SmsApplication {
tenant Tenant @relation(fields: [tenantId], references: [id])
ipAllowlist SmsApplicationIpAllowlist[]
signatures SmsSignature[]
drainageInfos SmsDrainageInfo[]
templates SmsTemplate[]
enterpriseBlacklists EnterpriseBlacklist[]
sendTasks SmsSendTask[]
@@ -413,6 +415,7 @@ model SmsSignature {
templates SmsTemplate[]
reportMaterials SignatureReportMaterial[]
drainageReportMaterials DrainageReportMaterial[]
drainageItems SmsDrainageInfo[]
reportTasks ChannelSignatureReportTask[]
messageRecords SmsMessageRecord[]
@@ -420,6 +423,32 @@ model SmsSignature {
@@index([tenantId, reportStatus])
}
model SmsDrainageInfo {
id String @id @default(cuid())
tenantId String
signatureId String
applicationId String?
siteName String
url String
remark String?
reportValues Json?
auditStatus String @default("pending")
rejectReason String?
submittedAt DateTime @default(now())
reviewedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id])
signature SmsSignature @relation(fields: [signatureId], references: [id], onDelete: Cascade)
application SmsApplication? @relation(fields: [applicationId], references: [id])
reportTasks ChannelSignatureReportTask[]
@@index([tenantId, auditStatus, updatedAt])
@@index([signatureId, auditStatus])
@@index([applicationId])
}
model SignatureMaterial {
id String @id @default(cuid())
signatureId String
@@ -730,6 +759,7 @@ model ChannelSignatureReportTask {
signature SmsSignature @relation(fields: [signatureId], references: [id])
channel SmsChannel @relation(fields: [channelId], references: [id])
drainageInfo SmsDrainageInfo? @relation(fields: [drainageItemId], references: [id])
records ChannelSignatureReportRecord[]
exportFiles ReportExportFile[]
receiptImports ReportReceiptImport[]
@@ -749,6 +779,7 @@ model ChannelSignatureReportRecord {
statusAfter String
reason String?
operatorId String?
sourceEntry String @default("system")
createdAt DateTime @default(now())
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
+2 -2
View File
@@ -146,8 +146,8 @@ export class ChannelsController {
}
@Get('report-tasks')
listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('channelId') channelId?: string) {
return this.channels.listReportTasks(tenantId, status, channelId);
listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('channelId') channelId?: string, @Query('reportType') reportType?: string) {
return this.channels.listReportTasks(tenantId, status, channelId, reportType);
}
@Post('report-tasks/generate')
+9 -4
View File
@@ -108,6 +108,9 @@ function createPrismaMock() {
findUnique: jest.fn().mockResolvedValue(reportTask),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })),
},
smsDrainageInfo: {
findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }),
},
channelSignatureReportRecord: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'record-1', ...data })),
findMany: jest.fn(),
@@ -183,8 +186,8 @@ describe('ChannelsService', () => {
await service.listReportTasks(undefined, undefined, 'channel-1');
expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith({
where: { tenantId: undefined, status: undefined, channelId: 'channel-1' },
include: { signature: true, channel: true },
where: { tenantId: undefined, status: undefined, channelId: 'channel-1', reportType: undefined },
include: { signature: true, channel: true, drainageInfo: true },
orderBy: { createdAt: 'desc' },
});
});
@@ -197,6 +200,7 @@ describe('ChannelsService', () => {
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
},
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) },
smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) },
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'reporting' }),
update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }),
@@ -209,10 +213,10 @@ describe('ChannelsService', () => {
prisma.$transaction.mockImplementation((callback) => callback(tx));
const service = new ChannelsService(prisma as never);
await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }], reason: '运营商确认' })).resolves.toEqual([
await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }], reason: '运营商确认', sourceEntry: 'enterprise_signature' })).resolves.toEqual([
expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' }),
]);
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) });
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved', sourceEntry: 'enterprise_signature' }) });
expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } });
});
@@ -224,6 +228,7 @@ describe('ChannelsService', () => {
update: jest.fn(),
},
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) },
smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) },
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue({ id: 'drainage-task-1', signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'reporting' }),
update: jest.fn().mockResolvedValue({ id: 'drainage-task-1', status: 'approved' }),
+28 -6
View File
@@ -107,6 +107,7 @@ export interface ChangeReportTaskStatusesDto {
items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>;
reason?: string;
operatorId?: string;
sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report';
}
export interface CreateReportExportDto {
@@ -912,6 +913,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
}
async createReportField(data: CreateReportFieldDto) {
if (!data.drainageFieldId) throw new BadRequestException('drainageFieldId is required');
const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } });
if (!field || field.status !== 'active') {
throw new BadRequestException('报备字段库字段不存在或已停用');
@@ -966,10 +968,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
});
}
listReportTasks(tenantId?: string, status?: string, channelId?: string) {
listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) {
return this.prisma.channelSignatureReportTask.findMany({
where: { tenantId, status, channelId },
include: { signature: true, channel: true },
where: { tenantId, status, channelId, reportType },
include: { signature: true, channel: true, drainageInfo: true },
orderBy: { createdAt: 'desc' },
});
}
@@ -977,13 +979,19 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
async createReportTask(data: CreateReportTaskDto) {
const reportType = data.reportType ?? 'signature';
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required');
if (reportType === 'drainage') {
const drainageInfo = await this.prisma.smsDrainageInfo.findUnique({ where: { id: data.drainageItemId! } });
if (!drainageInfo || drainageInfo.signatureId !== data.signatureId) throw new NotFoundException('Drainage info not found');
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
}
const task = await this.prisma.channelSignatureReportTask.create({
data: {
tenantId: data.tenantId,
signatureId: data.signatureId,
channelId: data.channelId,
reportType,
drainageItemId: reportType === 'drainage' ? data.drainageItemId : undefined,
drainageItemId: undefined,
createdById: data.createdById,
status: 'pending',
},
@@ -998,6 +1006,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
for (const item of data.items) {
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
}
const sourceEntry = data.sourceEntry ?? 'report_task';
if (!['enterprise_signature', 'report_task', 'channel_report'].includes(sourceEntry)) {
throw new BadRequestException('unsupported report task source entry');
}
return this.prisma.$transaction(async (tx) => {
const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))];
const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = [];
@@ -1007,11 +1019,17 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
if (reportType === 'drainage') {
const drainageInfo = await tx.smsDrainageInfo.findUnique({ where: { id: item.drainageItemId! } });
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId) throw new NotFoundException('Drainage info not found');
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
}
const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : null } });
if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
const task = existing
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } })
: await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined, status: item.status, reason: data.reason, createdById: data.operatorId } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId, sourceEntry } });
if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status });
}
const summaries = [];
@@ -1086,15 +1104,19 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
listReportRecords(taskId?: string, channelId?: string) {
return this.prisma.channelSignatureReportRecord.findMany({
where: { taskId, channelId },
include: { channel: true, task: { include: { signature: true, drainageInfo: true } } },
orderBy: { createdAt: 'desc' },
});
}
private async getReportTaskOrThrow(taskId: string) {
const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId } });
const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, include: { drainageInfo: true } });
if (!task) {
throw new NotFoundException('Report task not found');
}
if (task.reportType === 'drainage' && task.drainageInfo?.auditStatus !== 'approved') {
throw new BadRequestException('引流信息审核通过后才能处理通道报备任务');
}
return task;
}
@@ -80,8 +80,8 @@ export class DictionariesController {
}
@Get('blacklists/enterprise')
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 });
listEnterpriseBlacklist(@Query('tenantId') tenantId?: string, @Query('applicationId') applicationId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('phoneNumber') phoneNumber?: string, @Query('reasonKeyword') reasonKeyword?: string) {
return this.dictionaries.listEnterpriseBlacklist({ tenantId, applicationId, keyword, status, enterpriseKeyword, applicationKeyword, phoneNumber, reasonKeyword });
}
@Post('blacklists/enterprise')
@@ -73,7 +73,7 @@ describe('DictionariesService', () => {
await service.listSensitiveWords({ keyword: '贷款', status: 'active' });
await service.listGlobalBlacklist({ keyword: '138', status: 'active' });
await service.listEnterpriseBlacklist({ tenantId: 'tenant-1', applicationId: 'app-1', keyword: '投诉', status: 'active' });
await service.listEnterpriseBlacklist({ enterpriseKeyword: '租户', applicationKeyword: '应用', phoneNumber: '138', reasonKeyword: '投诉', status: 'active' });
expect(prisma.sensitiveWord.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }),
@@ -82,7 +82,7 @@ describe('DictionariesService', () => {
where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }),
}));
expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', status: 'active', OR: expect.any(Array) }),
where: expect.objectContaining({ status: 'active', tenant: { name: { contains: '租户' } }, application: { name: { contains: '应用' } }, phoneNumber: { contains: '138' }, reason: { contains: '投诉' } }),
include: { tenant: true, application: true },
}));
});
@@ -64,6 +64,10 @@ export interface DictionaryListQuery {
applicationId?: string;
keyword?: string;
status?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
phoneNumber?: string;
reasonKeyword?: string;
}
@Injectable()
@@ -203,6 +207,10 @@ export class DictionariesService {
tenantId: query.tenantId,
applicationId: query.applicationId,
status: query.status && query.status !== 'all' ? query.status : undefined,
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
reason: query.reasonKeyword ? { contains: query.reasonKeyword } : undefined,
OR: query.keyword ? [
{ phoneNumber: { contains: query.keyword } },
{ reason: { contains: query.keyword } },
@@ -40,6 +40,9 @@ function createPrismaMock() {
smsSignature: {
count: jest.fn().mockResolvedValue(1),
},
smsDrainageInfo: {
count: jest.fn().mockResolvedValue(0),
},
enterpriseCertification: {
count: jest.fn().mockResolvedValue(1),
},
@@ -261,6 +264,7 @@ describe('OperationsService', () => {
enterpriseCertifications: 1,
smsAudits: 2,
signatures: 1,
drainageInfos: 0,
templates: 1,
total: 5,
},
+4 -2
View File
@@ -731,14 +731,16 @@ export class OperationsService {
return Promise.all([
this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }),
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
this.prisma.smsDrainageInfo.count({ where: { tenantId, auditStatus: 'pending' } }),
this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }),
]).then(([templates, signatures, enterpriseCertifications, smsAudits]) => ({
]).then(([templates, signatures, drainageInfos, enterpriseCertifications, smsAudits]) => ({
templates,
signatures,
drainageInfos,
enterpriseCertifications,
smsAudits,
total: templates + signatures + enterpriseCertifications + smsAudits,
total: templates + signatures + drainageInfos + enterpriseCertifications + smsAudits,
}));
}
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CreateSmsApplicationDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service';
import { CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service';
@ApiTags('admin-sms-config')
@Controller('admin')
@@ -8,8 +8,8 @@ export class AdminSmsConfigController {
constructor(private readonly smsConfig: SmsConfigService) {}
@Get('enterprise-applications')
listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string) {
return this.smsConfig.listApplications({ tenantId, keyword, includeConnections: true });
listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('status') status?: string) {
return this.smsConfig.listApplications({ tenantId, keyword, enterpriseKeyword, applicationKeyword, status, includeConnections: true });
}
@Get('enterprise-applications/:id')
@@ -48,8 +48,8 @@ export class AdminSmsConfigController {
}
@Get('enterprise-signatures')
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) {
return this.smsConfig.listSignatures({ tenantId, keyword, status });
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string) {
return this.smsConfig.listSignatures({ tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword });
}
@Post('enterprise-signatures')
@@ -62,9 +62,39 @@ export class AdminSmsConfigController {
return this.smsConfig.updateSignature(signatureId, body);
}
@Get('drainage-infos')
listDrainageInfos(@Query('tenantId') tenantId?: string, @Query('signatureId') signatureId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) {
return this.smsConfig.listDrainageInfos({ tenantId, signatureId, status, keyword });
}
@Post('enterprise-signatures/:id/drainage-infos')
createDrainageInfo(@Param('id') signatureId: string, @Body() body: CreateSmsDrainageInfoDto) {
return this.smsConfig.createDrainageInfo(signatureId, body, { initialAuditStatus: 'approved' });
}
@Put('drainage-infos/:id')
updateDrainageInfo(@Param('id') itemId: string, @Body() body: UpdateSmsDrainageInfoDto) {
return this.smsConfig.updateDrainageInfo(itemId, body, { initialAuditStatus: 'approved' });
}
@Post('drainage-infos/:id/approve')
approveDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto) {
return this.smsConfig.approveDrainageInfo(itemId, body);
}
@Post('drainage-infos/:id/reject')
rejectDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto) {
return this.smsConfig.rejectDrainageInfo(itemId, body);
}
@Post('drainage-infos/:id/status')
changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto) {
return this.smsConfig.changeDrainageInfoStatus(itemId, body);
}
@Get('enterprise-templates')
listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) {
return this.smsConfig.listTemplates({ tenantId, status, keyword });
listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('nameKeyword') nameKeyword?: string, @Query('contentKeyword') contentKeyword?: string) {
return this.smsConfig.listTemplates({ tenantId, status, keyword, enterpriseKeyword, applicationKeyword, nameKeyword, contentKeyword });
}
@Post('enterprise-templates')
@@ -4,11 +4,13 @@ import { TenantId } from '../common/tenant-id.decorator';
import {
CreateSignatureMaterialDto,
CreateSmsApplicationDto,
CreateSmsDrainageInfoDto,
CreateSmsSignatureDto,
CreateSmsTemplateDto,
StatusChangeDto,
SmsConfigService,
UpdateSmsTemplateDto,
UpdateSmsDrainageInfoDto,
} from './sms-config.service';
@ApiTags('client-sms-config')
@@ -31,6 +33,11 @@ export class ClientSmsConfigController {
return this.smsConfig.getApplicationCmppParams(applicationId, tenantId);
}
@Get('applications/:id/report-fields')
getApplicationReportFields(@Param('id') applicationId: string, @TenantId() tenantId?: string) {
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getApplicationReportFields(applicationId, 'drainage'));
}
@Post('applications/:id/secret/reset')
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
return this.smsConfig.resetApplicationSecret(applicationId, body);
@@ -56,6 +63,26 @@ export class ClientSmsConfigController {
return this.smsConfig.createSignatureMaterial({ ...body, signatureId });
}
@Get('drainage-infos')
listDrainageInfos(@TenantId() tenantId?: string) {
return this.smsConfig.listDrainageInfos({ tenantId });
}
@Post('signatures/:id/drainage-infos')
createDrainageInfo(@Param('id') signatureId: string, @Body() body: CreateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
return this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId);
}
@Put('drainage-infos/:id')
updateDrainageInfo(@Param('id') itemId: string, @Body() body: UpdateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
return this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId);
}
@Post('drainage-infos/:id/status')
changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
return this.smsConfig.changeDrainageInfoStatus(itemId, body, tenantId);
}
@Post('signatures/:id/submit')
submitSignature(@Param('id') signatureId: string) {
return this.smsConfig.submitSignature(signatureId);
+67 -20
View File
@@ -64,6 +64,8 @@ function createPrismaMock() {
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
application: { id: 'app-1', name: '应用A' },
materials: [],
drainageItems: [],
reportTasks: [],
}]),
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-new', tenantId: 'tenant-1', ...data })),
@@ -74,11 +76,20 @@ function createPrismaMock() {
},
drainageReportMaterial: {
upsert: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }),
create: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
smsDrainageInfo: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-1', createdAt: new Date(), updatedAt: new Date(), submittedAt: new Date(), ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-1', tenantId: 'tenant-1', signatureId: 'sig-1', applicationId: 'app-1', siteName: '官网', url: 'https://example.com', createdAt: new Date(), updatedAt: new Date(), submittedAt: new Date(), ...data })),
},
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-task-1', ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-task-1', ...data })),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
channelSignatureReportRecord: {
@@ -188,7 +199,7 @@ describe('SmsConfigService', () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.listApplications({ includeConnections: true })).resolves.toEqual([
await expect(service.listApplications({ includeConnections: true, enterpriseKeyword: '租户', applicationKeyword: '应用', status: 'active' })).resolves.toEqual([
expect.objectContaining({
id: 'app-1',
cmppStatus: 'connected',
@@ -199,6 +210,7 @@ describe('SmsConfigService', () => {
}),
]);
expect(prisma.smsApplication.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ status: 'active', tenant: { name: { contains: '租户' } }, name: { contains: '应用' } }),
include: { tenant: true, ipAllowlist: true },
}));
expect(prisma.smsApplication.findMany.mock.calls[0][0]).not.toHaveProperty('take');
@@ -427,7 +439,7 @@ describe('SmsConfigService', () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.listSignatures({ keyword: '签名A' })).resolves.toEqual([
await expect(service.listSignatures({ enterpriseKeyword: '租户', applicationKeyword: '应用', signatureKeyword: '签名', drainageKeyword: '官网' })).resolves.toEqual([
expect.objectContaining({
id: 'sig-1',
tenant: expect.objectContaining({ name: '租户A' }),
@@ -437,9 +449,18 @@ describe('SmsConfigService', () => {
expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
auditStatus: { not: 'deleted' },
OR: expect.any(Array),
tenant: { name: { contains: '租户' } },
application: { name: { contains: '应用' } },
name: { contains: '签名' },
drainageItems: expect.objectContaining({ some: expect.objectContaining({ OR: expect.any(Array) }) }),
}),
include: { materials: true, tenant: true, application: true, reportTasks: { include: { channel: true } } },
include: {
materials: true,
tenant: true,
application: true,
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
reportTasks: { include: { channel: true, drainageInfo: true } },
},
}));
});
@@ -473,7 +494,7 @@ describe('SmsConfigService', () => {
]);
});
it('validates and persists dynamic signature and drainage report values by channel', async () => {
it('validates and persists dynamic signature report values by channel without bypassing drainage audit', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'draft' });
prisma.smsSignature.update.mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', ...data }));
@@ -498,25 +519,48 @@ describe('SmsConfigService', () => {
applicationId: 'app-1',
drainageInfo: {
signatureReportValues: { license: { fileObjectId: 'file-1', fileName: 'license.pdf' } },
links: [{ id: 'drain-1', reportValues: { site_owner: '企业A' } }],
links: [{ id: 'legacy-drain-1', reportValues: { site_owner: '企业A' } }],
},
});
expect(prisma.signatureReportMaterial.upsert).toHaveBeenCalledWith(expect.objectContaining({
create: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license', fileObjectId: 'file-1' }),
}));
expect(prisma.drainageReportMaterial.upsert).toHaveBeenCalledWith(expect.objectContaining({
create: expect.objectContaining({ signatureId: 'sig-1', drainageItemId: 'drain-1', channelId: 'channel-1', fieldCode: 'site_owner', fieldValue: '企业A' }),
}));
expect(prisma.drainageReportMaterial.deleteMany).toHaveBeenCalledWith({
where: { signatureId: 'sig-1', drainageItemId: { notIn: ['drain-1'] } },
});
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'pending' }),
});
expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ taskId: 'drainage-task-1', action: 'create', statusAfter: 'pending' }),
});
expect(prisma.drainageReportMaterial.create).not.toHaveBeenCalled();
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
});
it('creates client drainage info as pending without generating channel report tasks', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'approved' });
prisma.channelRouteRule.findMany.mockResolvedValue([]);
const service = new SmsConfigService(prisma as never);
await expect(service.createDrainageInfo('sig-1', { siteName: '官网', url: 'https://example.com', reportValues: {} }, {}, 'tenant-1'))
.resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' }));
expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ targetType: 'sms_drainage_info', action: 'submit', statusAfter: 'pending' }) });
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
});
it('creates real drainage materials and channel tasks after operations approval', async () => {
const prisma = createPrismaMock();
const pendingItem = { id: 'drainage-1', tenantId: 'tenant-1', signatureId: 'sig-1', applicationId: 'app-1', siteName: '官网', url: 'https://example.com', reportValues: { site_owner: '企业A' }, auditStatus: 'pending' };
const approvedItem = { ...pendingItem, auditStatus: 'approved', signature: { id: 'sig-1', applicationId: 'app-1' } };
prisma.smsDrainageInfo.findUnique.mockResolvedValueOnce(pendingItem).mockResolvedValueOnce(approvedItem);
prisma.smsDrainageInfo.update.mockResolvedValue({ ...approvedItem, tenant: {}, application: {} });
prisma.channelRouteRule.findMany.mockResolvedValue([{
id: 'route-1', priority: 10,
group: { id: 'group-1', name: '默认通道组', items: [{ channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [{ status: 'active', required: true, reportType: 'drainage', drainageField: { id: 'field-2', code: 'site_owner', name: '网站主体', fieldType: 'string', description: null, status: 'active' } }] } }] },
}] as never);
prisma.$transaction.mockImplementation((callback) => callback(prisma));
const service = new SmsConfigService(prisma as never);
await service.approveDrainageInfo('drainage-1', {});
expect(prisma.drainageReportMaterial.create).toHaveBeenCalledWith({ data: expect.objectContaining({ drainageItemId: 'drainage-1', channelId: 'channel-1', fieldCode: 'site_owner', fieldValue: '企业A' }) });
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ reportType: 'drainage', drainageItemId: 'drainage-1', status: 'pending' }) });
expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'audit_approved_create', statusAfter: 'pending' }) });
});
it('creates admin signatures with an approved initial audit status', async () => {
@@ -565,7 +609,7 @@ describe('SmsConfigService', () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.listTemplates({ keyword: '模板A' })).resolves.toEqual([
await expect(service.listTemplates({ enterpriseKeyword: '租户', applicationKeyword: '应用', nameKeyword: '模板', contentKeyword: '验证码' })).resolves.toEqual([
expect.objectContaining({
id: 'tpl-1',
tenant: expect.objectContaining({ name: '租户A' }),
@@ -576,7 +620,10 @@ describe('SmsConfigService', () => {
expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
auditStatus: { not: 'deleted' },
OR: expect.any(Array),
tenant: { name: { contains: '租户' } },
application: { name: { contains: '应用' } },
name: { contains: '模板' },
content: { contains: '验证码' },
}),
include: { variables: true, application: true, tenant: true, signature: true },
}));
+296 -77
View File
@@ -52,6 +52,22 @@ export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantI
auditStatus?: string;
};
export interface CreateSmsDrainageInfoDto {
siteName: string;
url: string;
remark?: string;
reportValues?: Record<string, unknown>;
}
export type UpdateSmsDrainageInfoDto = Partial<CreateSmsDrainageInfoDto>;
export interface DrainageInfoListQuery {
tenantId?: string;
signatureId?: string;
status?: string;
keyword?: string;
}
export interface CreateSignatureMaterialDto {
signatureId: string;
fileObjectId?: string;
@@ -93,14 +109,31 @@ export interface TemplateListQuery {
tenantId?: string;
status?: string;
keyword?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
nameKeyword?: string;
contentKeyword?: string;
}
export interface ApplicationListQuery {
tenantId?: string;
keyword?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
status?: string;
includeConnections?: boolean;
}
export interface SignatureListQuery {
tenantId?: string;
keyword?: string;
status?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
signatureKeyword?: string;
drainageKeyword?: string;
}
export interface GatewayDownstreamConnectionEventDto {
account: string;
connectionId: string;
@@ -130,6 +163,9 @@ export class SmsConfigService {
const applications = await this.prisma.smsApplication.findMany({
where: {
tenantId: query.tenantId,
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
name: query.applicationKeyword ? { contains: query.applicationKeyword } : undefined,
OR: query.keyword ? [
{ name: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } },
@@ -171,7 +207,7 @@ export class SmsConfigService {
});
}
async getApplication(applicationId: string) {
async getApplication(applicationId: string, tenantId?: string) {
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
include: {
@@ -179,7 +215,7 @@ export class SmsConfigService {
ipAllowlist: true,
},
});
if (!application) {
if (!application || (tenantId && application.tenantId !== tenantId)) {
throw new NotFoundException('Application not found');
}
return application;
@@ -574,12 +610,25 @@ export class SmsConfigService {
});
}
async listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) {
async listSignatures(queryOrTenantId?: string | SignatureListQuery) {
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
const signatures = await this.prisma.smsSignature.findMany({
where: {
tenantId: query.tenantId,
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined,
drainageItems: query.drainageKeyword ? {
some: {
auditStatus: { not: 'deleted' },
OR: [
{ siteName: { contains: query.drainageKeyword } },
{ url: { contains: query.drainageKeyword } },
{ remark: { contains: query.drainageKeyword } },
],
},
} : undefined,
OR: query.keyword ? [
{ name: { contains: query.keyword } },
{ purpose: { contains: query.keyword } },
@@ -587,7 +636,13 @@ export class SmsConfigService {
{ application: { name: { contains: query.keyword } } },
] : undefined,
},
include: { materials: true, tenant: true, application: true, reportTasks: { include: { channel: true } } },
include: {
materials: true,
tenant: true,
application: true,
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
reportTasks: { include: { channel: true, drainageInfo: true } },
},
orderBy: { createdAt: 'desc' },
});
const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id));
@@ -595,24 +650,43 @@ export class SmsConfigService {
where: { applicationId: { in: applicationIds }, status: 'active' },
include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } },
}) : [];
return signatures.map((signature) => ({
return signatures.map((signature) => {
const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
const drainageLinks = signature.drainageItems.map((item) => ({
id: item.id,
siteName: item.siteName,
url: item.url,
remark: item.remark ?? '',
reportValues: isRecord(item.reportValues) ? item.reportValues : {},
auditStatus: item.auditStatus,
rejectReason: item.rejectReason,
submittedAt: item.submittedAt.toISOString(),
reviewedAt: item.reviewedAt?.toISOString(),
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
}));
return {
...signature,
drainageInfo: { ...legacyPayload, links: drainageLinks },
reportTargets: (() => {
const channels = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
return [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }));
})(),
drainageReportTargets: Object.fromEntries((Array.isArray((signature.drainageInfo as Record<string, unknown> | null)?.links) ? (signature.drainageInfo as Record<string, unknown>).links as Array<Record<string, unknown>> : []).map((link) => {
const drainageItemId = String(link.id ?? '');
drainageReportTargets: Object.fromEntries(signature.drainageItems.map((drainageItem) => {
const drainageItemId = drainageItem.id;
const channels = routes
.filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel))
.filter((channel) => channel.status !== 'deleted' && channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)));
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task]));
return [drainageItemId, [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }))];
return [drainageItemId, [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => {
const task = taskByChannel.get(channel.id);
return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : [];
})];
})),
drainageCarrierReportSummary: Object.fromEntries((Array.isArray((signature.drainageInfo as Record<string, unknown> | null)?.links) ? (signature.drainageInfo as Record<string, unknown>).links as Array<Record<string, unknown>> : []).map((link) => {
const drainageItemId = String(link.id ?? '');
drainageCarrierReportSummary: Object.fromEntries(signature.drainageItems.map((drainageItem) => {
const drainageItemId = drainageItem.id;
const channels = routes
.filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel))
@@ -621,7 +695,7 @@ export class SmsConfigService {
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task]));
return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const carrierTargets = targets.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
const statuses = carrierTargets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []);
const approved = statuses.filter((status) => status === 'approved').length;
const status = !statuses.length ? 'not_applicable' : approved === statuses.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
return [carrier, { status, approved, total: statuses.length }];
@@ -636,7 +710,8 @@ export class SmsConfigService {
const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
return [carrier, { status, approved, total: targets.length }];
})),
}));
};
});
}
async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) {
@@ -691,6 +766,118 @@ export class SmsConfigService {
return updated;
}
listDrainageInfos(query: DrainageInfoListQuery = {}) {
return this.prisma.smsDrainageInfo.findMany({
where: {
tenantId: query.tenantId,
signatureId: query.signatureId,
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
OR: query.keyword ? [
{ siteName: { contains: query.keyword } },
{ url: { contains: query.keyword } },
{ signature: { name: { contains: query.keyword } } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
] : undefined,
},
include: { tenant: true, signature: true, application: true, reportTasks: { include: { channel: true } } },
orderBy: { updatedAt: 'desc' },
});
}
async createDrainageInfo(signatureId: string, data: CreateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) throw new NotFoundException('Signature not found');
if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found');
if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息');
if (!data.siteName?.trim() || !data.url?.trim()) throw new BadRequestException('siteName and url are required');
await this.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues);
const auditStatus = options.initialAuditStatus ?? 'pending';
const item = await this.prisma.smsDrainageInfo.create({
data: {
tenantId: signature.tenantId,
signatureId,
applicationId: signature.applicationId,
siteName: data.siteName.trim(),
url: data.url.trim(),
remark: data.remark,
reportValues: data.reportValues as Prisma.InputJsonValue | undefined,
auditStatus,
reviewedAt: auditStatus === 'approved' ? new Date() : undefined,
},
include: { tenant: true, signature: true, application: true },
});
await this.createAuditRecord({
tenantId: item.tenantId,
targetType: 'sms_drainage_info',
targetId: item.id,
action: auditStatus === 'approved' ? 'admin_create_approved' : 'submit',
statusAfter: auditStatus,
reason: auditStatus === 'approved' ? '运营端新建引流信息自动审核通过' : undefined,
});
if (auditStatus === 'approved') await this.activateDrainageReporting(item.id);
return item;
}
async updateDrainageInfo(itemId: string, data: UpdateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) {
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
if (!current) throw new NotFoundException('Drainage info not found');
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改');
if (data.siteName !== undefined && !data.siteName.trim()) throw new BadRequestException('siteName is required');
if (data.url !== undefined && !data.url.trim()) throw new BadRequestException('url is required');
const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined;
await this.validateDrainageReportValues(applicationId, data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {}));
const auditStatus = options.initialAuditStatus ?? 'pending';
const updated = await this.prisma.smsDrainageInfo.update({
where: { id: itemId },
data: {
applicationId,
siteName: data.siteName?.trim(),
url: data.url?.trim(),
remark: data.remark,
reportValues: data.reportValues as Prisma.InputJsonValue | undefined,
auditStatus,
rejectReason: null,
submittedAt: new Date(),
reviewedAt: auditStatus === 'approved' ? new Date() : null,
},
include: { tenant: true, signature: true, application: true },
});
await this.createAuditRecord({
tenantId: current.tenantId,
targetType: 'sms_drainage_info',
targetId: itemId,
action: auditStatus === 'approved' ? 'admin_update_approved' : 'update_submit',
statusBefore: current.auditStatus,
statusAfter: auditStatus,
reason: auditStatus === 'approved' ? '运营端修改引流信息并自动审核通过' : undefined,
});
if (auditStatus === 'approved') await this.activateDrainageReporting(itemId);
else await this.suspendDrainageReporting(itemId, '引流信息修改后等待运营审核');
return updated;
}
approveDrainageInfo(itemId: string, data: ReviewDto) {
return this.reviewDrainageInfo(itemId, 'approved', 'approve', data);
}
rejectDrainageInfo(itemId: string, data: ReviewDto) {
return this.reviewDrainageInfo(itemId, 'rejected', 'reject', data);
}
async changeDrainageInfoStatus(itemId: string, data: StatusChangeDto, tenantId?: string) {
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!current) throw new NotFoundException('Drainage info not found');
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
const status = data.status ?? 'deleted';
if (tenantId && status !== 'deleted') throw new BadRequestException('客户端只能删除引流信息,不能直接修改审核状态');
const updated = await this.prisma.smsDrainageInfo.update({ where: { id: itemId }, data: { auditStatus: status } });
if (status === 'deleted') await this.suspendDrainageReporting(itemId, data.reason ?? '引流信息已删除', 'abandoned');
await this.createAuditRecord({ tenantId: current.tenantId, targetType: 'sms_drainage_info', targetId: itemId, action: status, statusBefore: current.auditStatus, statusAfter: status, reason: data.reason });
return updated;
}
private async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record<string, unknown>) {
if (!drainageInfo || !applicationId) return drainageInfo;
const fields = await this.getApplicationReportFields(applicationId);
@@ -716,21 +903,6 @@ export class SmsConfigService {
if (!applicationId || !drainageInfo) return;
const fields = await this.getApplicationReportFields(applicationId);
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
const links = Array.isArray(drainageInfo.links) ? drainageInfo.links.filter(isRecord) : [];
const drainageItemIds = links.map((link) => String(link.id ?? '')).filter(Boolean);
await this.prisma.drainageReportMaterial.deleteMany({
where: {
signatureId,
...(drainageItemIds.length > 0 ? { drainageItemId: { notIn: drainageItemIds } } : {}),
},
});
await this.prisma.channelSignatureReportTask.deleteMany({
where: {
signatureId,
reportType: 'drainage',
...(drainageItemIds.length > 0 ? { drainageItemId: { notIn: drainageItemIds } } : {}),
},
});
for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) {
const value = reportValueParts(signatureValues[field.code]);
for (const channel of field.channels) {
@@ -741,46 +913,6 @@ export class SmsConfigService {
});
}
}
const drainageFields = fields.filter((item) => item.reportTypes.some((type) => type === 'drainage' || type === 'both'));
const drainageChannels = new Map(drainageFields.flatMap((item) => item.channels).map((channel) => [channel.id, channel]));
const signatureOwner = links.length > 0
? await this.prisma.smsSignature.findUnique({ where: { id: signatureId }, select: { tenantId: true } })
: null;
for (const link of links) {
const drainageItemId = String(link.id ?? '');
const values = isRecord(link.reportValues) ? link.reportValues : {};
if (!drainageItemId) continue;
for (const channel of drainageChannels.values()) {
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({
where: { signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId },
});
if (!existingTask && signatureOwner) {
const task = await this.prisma.channelSignatureReportTask.create({
data: { tenantId: signatureOwner.tenantId, signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId, status: 'pending' },
});
await this.prisma.channelSignatureReportRecord.create({
data: { taskId: task.id, channelId: channel.id, action: 'create', statusAfter: 'pending' },
});
}
}
for (const field of drainageFields) {
const value = reportValueParts(values[field.code]);
for (const channel of field.channels) {
await this.prisma.drainageReportMaterial.upsert({
where: {
signatureId_drainageItemId_channelId_fieldCode: {
signatureId,
drainageItemId,
channelId: channel.id,
fieldCode: field.code,
},
},
update: value,
create: { signatureId, drainageItemId, channelId: channel.id, fieldCode: field.code, ...value },
});
}
}
}
}
private async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record<string, unknown>) {
@@ -793,19 +925,68 @@ export class SmsConfigService {
if (missingSignature.length > 0) {
throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`);
}
const drainageFields = fields.filter(
(field) => field.required && field.reportTypes.some((type) => type === 'drainage' || type === 'both'),
);
const links = Array.isArray(drainageInfo.links) ? drainageInfo.links.filter(isRecord) : [];
for (const link of links) {
const values = isRecord(link.reportValues) ? link.reportValues : {};
const missing = drainageFields.filter((field) => !hasReportValue(values[field.code]));
if (missing.length > 0) {
throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`);
}
}
private async validateDrainageReportValues(applicationId?: string, reportValues: Record<string, unknown> = {}) {
if (!applicationId) return;
const fields = await this.getApplicationReportFields(applicationId, 'drainage');
const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code]));
if (missing.length > 0) {
throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`);
}
}
private async activateDrainageReporting(itemId: string) {
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
if (!item) throw new NotFoundException('Drainage info not found');
if (item.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
const applicationId = item.signature.applicationId ?? item.applicationId ?? undefined;
if (!applicationId) return;
const fields = (await this.getApplicationReportFields(applicationId, 'drainage'))
.filter((field) => field.reportTypes.some((type) => type === 'drainage' || type === 'both'));
const channels = new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel]));
const values = isRecord(item.reportValues) ? item.reportValues : {};
await this.prisma.$transaction(async (tx) => {
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
for (const field of fields) {
const value = reportValueParts(values[field.code]);
for (const channel of field.channels) {
await tx.drainageReportMaterial.create({
data: { signatureId: item.signatureId, drainageItemId: item.id, channelId: channel.id, fieldCode: field.code, ...value },
});
}
}
const existingTasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } });
const existingByChannel = new Map(existingTasks.map((task) => [task.channelId, task]));
for (const channel of channels.values()) {
const existing = existingByChannel.get(channel.id);
const task = existing
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: 'pending', reason: null } })
: await tx.channelSignatureReportTask.create({ data: { tenantId: item.tenantId, signatureId: item.signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId: item.id, status: 'pending' } });
await tx.channelSignatureReportRecord.create({
data: { taskId: task.id, channelId: channel.id, action: existing ? 'audit_approved_reset' : 'audit_approved_create', statusBefore: existing?.status, statusAfter: 'pending', reason: '引流信息运营审核通过' },
});
}
for (const task of existingTasks.filter((current) => !channels.has(current.channelId) && current.status !== 'abandoned')) {
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: 'abandoned', reason: '应用当前路由已不包含此通道' } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'route_removed', statusBefore: task.status, statusAfter: 'abandoned', reason: '应用当前路由已不包含此通道' } });
}
});
}
private async suspendDrainageReporting(itemId: string, reason: string, statusAfter = 'waiting_review') {
await this.prisma.$transaction(async (tx) => {
const item = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!item) throw new NotFoundException('Drainage info not found');
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
const tasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } });
for (const task of tasks.filter((current) => current.status !== statusAfter)) {
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: statusAfter, reason } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'audit_suspended', statusBefore: task.status, statusAfter, reason } });
}
});
}
createSignatureMaterial(data: CreateSignatureMaterialDto) {
return this.prisma.signatureMaterial.create({
data: {
@@ -845,6 +1026,10 @@ export class SmsConfigService {
where: {
tenantId: query.tenantId,
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
name: query.nameKeyword ? { contains: query.nameKeyword } : undefined,
content: query.contentKeyword ? { contains: query.contentKeyword } : undefined,
OR: query.keyword ? [
{ name: { contains: query.keyword } },
{ content: { contains: query.keyword } },
@@ -1030,6 +1215,40 @@ export class SmsConfigService {
return updated;
}
private async reviewDrainageInfo(itemId: string, statusAfter: string, action: string, data: ReviewDto) {
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!item) throw new NotFoundException('Drainage info not found');
if (!['pending', 'rejected'].includes(item.auditStatus)) {
throw new BadRequestException('只有待审核或已驳回的引流信息可以审核');
}
if (statusAfter === 'rejected' && !data.reason?.trim()) {
throw new BadRequestException('驳回引流信息时必须填写原因');
}
const reviewerId = await this.resolveReviewerId(data.reviewerId);
const updated = await this.prisma.smsDrainageInfo.update({
where: { id: itemId },
data: {
auditStatus: statusAfter,
rejectReason: statusAfter === 'rejected' ? data.reason?.trim() : null,
reviewedAt: new Date(),
},
include: { tenant: true, signature: true, application: true },
});
await this.createAuditRecord({
tenantId: item.tenantId,
targetType: 'sms_drainage_info',
targetId: itemId,
action,
statusBefore: item.auditStatus,
statusAfter,
reason: data.reason,
reviewerId,
});
if (statusAfter === 'approved') await this.activateDrainageReporting(itemId);
else await this.suspendDrainageReporting(itemId, data.reason?.trim() || '引流信息运营审核驳回');
return updated;
}
private async reviewTemplate(templateId: string, statusAfter: string, action: string, data: ReviewDto) {
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
if (!template) {
+10 -3
View File
@@ -185,14 +185,15 @@
2. 通道组配置通道,企业应用通过路由规则选择通道组。企业签名和引流信息编辑时,系统必须沿“企业应用 -> 生效路由规则 -> 通道组 -> 组内通道 -> 通道报备字段”实时解析字段合集。
3. 同一字段被多个通道引用时按字段库记录去重;任一通道将该字段配置为必填,则企业资料中按必填处理,并保留该字段来源的全部通道用于后续分别报备。
4. 企业签名弹窗只展示签名报备/两者共用字段;每条引流信息只展示引流信息报备/两者共用字段。文件字段走真实对象存储上传,其他字段保存真实值,必填校验同时在前端和 NestJS API 执行。
5. 企业资料保存后,原始动态值随签名 JSON 保存,同时按实际目标通道分别写入签名报备材料和引流报备材料表,供通道报备任务导出使用;删除引流项时同步清理其规范化材料记录
6. 客户端上传签名资料,运营端审核企业签名资料
7. 运营端在通道资料更新后生成通道签名报备任务,并在报备任务中导出通道报备资料
5. 签名动态资料保留在签名记录;每条引流信息必须保存为独立 `SmsDrainageInfo` PostgreSQL 实体,包含所属企业、签名、应用、站点、地址、动态字段、审核状态和驳回原因,不得再以签名 JSON 数组作为引流审核事实来源
6. 客户端上传签名资料后进入签名审核;签名审核通过后方可新增引流信息。客户端新建或修改引流信息均自动进入 `pending`,运营端必须在独立“引流信息审核”页面查看资料后通过或带原因驳回。运营端在企业签名管理中新增或修改引流信息视为运营操作,自动审核通过并写审核记录
7. 引流信息审核通过前不得创建新的通道报备任务、写入可导出的引流报备材料或人工修改通道报备状态;已报备引流信息再次修改时,原通道任务冻结为 `waiting_review` 且旧材料停止使用。审核通过后系统按应用当前真实路由通道生成/重置 `reportType=drainage` 任务与材料,并写报备记录
8. 运营端在报备任务或通道报备详情页导入通道回执,系统根据回执同步签名在各通道的报备状态。
9. 报备记录保留每次导出、导入、状态变更和操作人。
10. 发送前必须校验最终选中通道上的签名报备任务为 approved;补发切换到新通道时必须重新按新通道校验报备状态,未通过则该次发送失败。
11. 企业签名页、通道报备详情页和报备任务页均允许人工修正报备状态,但三个入口必须操作同一份 `ChannelSignatureReportTask` 通道级事实并写 `ChannelSignatureReportRecord`;企业签名页修改时必须展示应用当前通道组内的具体通道矩阵,不允许直接修改移动/联通/电信汇总标签。
12. 每次人工状态变更或回执导入后,系统必须按应用当前生效路由规则重新汇总各运营商目标通道状态和签名全局 `reportStatus`。新增目标通道但尚无任务时按未报备计入分母;移出当前配置的历史通道不参与当前汇总,但任务和记录继续保留。
13. 报备任务和报备记录页面必须可按签名/引流信息类型筛选,并显示引流信息的站名称、地址和所属签名。报备记录查询必须关联真实任务、通道和引流实体,完整展示审核通过后创建、重置、冻结、导出、回执和人工状态变化。
### 4.8 CMPP Gateway 与外部接入
@@ -460,11 +461,14 @@
- 支持在报备任务中导出通道报备资料。
- 支持在报备任务中导入回执。
- 支持查看报备任务详情和处理历史。
- 企业签名、报备任务、通道报备详情三个状态修改入口必须向统一状态接口传递入口标识,并持久化到报备记录,不能只靠前端文案推断。
### 5.16 运营端报备记录
- 记录报备任务生成、导出、导入、状态同步、失败原因。
- 支持按企业、签名、通道、状态、操作时间搜索。
- 列表必须显示真实通道名称,不以通道 ID 代替;明确展示变更主体为签名或引流信息,并展示签名内容,或“所属签名 + 引流站点 + URL + 备注”。动作和前后状态统一显示中文。
- 人工状态变化必须显示实际修改入口:企业签名修改、报备任务修改或通道信息修改;系统自动审核、冻结、导入等动作显示为系统自动处理,历史无入口字段的数据明确标注为历史记录。
### 5.17 安全控制
@@ -475,6 +479,9 @@
- 引流信息字段库:用于签名/报备资料结构化采集。
- 企业应用级黑名单、全局黑名单、敏感词管理必须提供搜索、添加、启停/删除功能;所有操作调用真实后端 API,写入系统日志。
- 企业黑名单必须绑定到具体短信应用,支持按企业、应用、手机号、入库原因、状态搜索;发送预览、风控和发送链路只能拦截当前应用的 active 黑名单号码,不得把同企业其他应用的黑名单串用;全局黑名单支持按手机号、原因、状态搜索;敏感词支持按词、分类/级别、状态搜索。
- 企业模板管理的企业名称、企业应用、模板名称、模板内容必须是互相独立且可组合的服务端查询条件;企业签名管理的企业名称、企业应用、签名名称/用途和引流信息同样独立查询。按引流信息搜索时,以签名为父级、命中的引流信息为子级分组展开。
- 企业应用管理提供企业名称、企业应用名称和状态三个独立服务端查询条件。企业黑名单搜索区提供企业名称、企业应用、手机号码、入库原因和状态五个独立条件。
- 运营端充值记录和短信记录表头统一左对齐。
- “引流信息字段库”菜单命名为“报备字段库”,编辑、删除按钮使用通用操作按钮样式。
### 5.18 风控规则闭环
+28 -8
View File
@@ -326,18 +326,38 @@
- 优先级:P0
- 前置条件:企业应用已绑定通道组,至少一个目标通道配置 `drainage``both` 报备字段。
- 步骤:
1. 企业签名中新增引流信息,填写动态字段并保存
2. 查询 `DrainageReportMaterial``ChannelSignatureReportTask(reportType=drainage)`
3. 分别从企业签名、通道报备详情、报备任务页修改同一引流项在同一通道的状态
4. 查看报备记录,导出任务并导入回执
1. 客户端在已审核通过的企业签名中新增引流信息,填写动态字段并提交
2. 查询 `SmsDrainageInfo``DrainageReportMaterial``ChannelSignatureReportTask(reportType=drainage)`,并尝试直接调用状态变更接口
3. 在运营端“引流信息审核”查看完整资料并通过,再次查询上述表和报备记录
4. 分别从企业签名、通道报备详情、报备任务页修改同一引流项在同一通道的状态,并在报备记录页按引流信息筛选
5. 客户端修改已通过的引流信息,确认任务冻结后由运营再次审核通过;随后导出任务并导入回执。
6. 对另一条待审引流信息执行带原因驳回,客户端查看驳回原因。
- 预期结果:
- 每个“签名 + 引流项 + 通道”对应独立真实任务,初始为 pending
- 三个入口的状态和通过数/总数一致,修改写入 `ChannelSignatureReportRecord`
- 新建/修改均写独立 `SmsDrainageInfo``AuditRecord(targetType=sms_drainage_info)`;客户端提交为 pending,运营端列表和待审数量同步增加
- 审核通过前没有新的可处理通道任务或可导出材料,直接修改通道报备状态返回 400;审核通过后每个“签名 + 引流项 + 通道”生成独立 pending 任务和 `audit_approved_create/reset` 记录
- 已通过引流信息再次修改后,旧材料被停用、已有任务变为 waiting_review;再次审核通过后材料按新值重建且任务恢复 pending,历史记录保留。
- 三个入口的状态和通过数/总数一致,修改写入 `ChannelSignatureReportRecord.sourceEntry`;报备记录分别显示“企业签名修改”“通道信息修改”“报备任务修改”。
- 引流任务不参与 `SmsSignature.reportStatus` 聚合,也不能被短信发送选路误当为签名报备通过。
- 字段库页面只提供字符串、图片、文件三种类型;API 对其他类型返回 400,历史其他类型迁移为字符串。
- 缺少必填资料时前端禁止提交;直接调用 API 也返回 400,不能绕过页面保存不完整资料。
- 文件通过真实对象存储上传;动态值随签名 JSON 保存,并按来源通道分别写入规范化报备材料表。
- 删除引流项后,对应引流报备材料记录被同步删除,不保留可被后续导出误用的孤立资料
- 文件通过真实对象存储上传;动态值保存在 `SmsDrainageInfo.reportValues`,审核通过后按来源通道分别写入规范化报备材料表。
- 报备任务与报备记录页可区分签名/引流信息并显示真实通道名称、签名内容、站点、地址、备注和所属签名;动作、状态变化显示中文;删除引流项后任务进入 abandoned,不再可导出或改状态,历史记录仍可追溯
### TC-ADMIN-005C 运营列表独立组合搜索与表头对齐
- 优先级:P1
- 前置条件:存在不同企业、应用、签名、引流信息、模板、黑名单和启停状态的数据。
- 步骤:
1. 在企业模板管理分别填写企业名称、企业应用、模板名称、模板内容,再组合查询。
2. 在企业签名管理分别填写企业名称、企业应用、签名名称/用途、引流信息;使用引流站点或 URL 查询。
3. 在企业应用管理组合企业名称、应用名称、状态查询。
4. 在企业黑名单组合企业名称、应用名称、手机号、入库原因、状态查询。
5. 查看充值记录和短信记录表头。
- 预期结果:
- 每个查询条件作为独立 API 参数进入 NestJS,并由 Prisma 对应字段执行 AND 组合过滤,不拼成一个模糊关键字。
- 引流信息命中后只展示包含该命中项的签名分组,并自动展开匹配的引流信息。
- 重置恢复全部数据;列表仍来自真实 PostgreSQL。
- 充值记录和短信记录表头全部靠左对齐。
### TC-ADMIN-005B 企业签名、通道详情和报备任务状态一致性
+17
View File
@@ -1679,3 +1679,20 @@ git diff --check
- 本地真实 PostgreSQL 已成功应用 3 条新 migrationAPI 全量 13 suites、139 项通过,Gateway 全量 Go 测试、Prisma validate、API build、前端 build 和 `git diff --check` 通过,前端仅有既有 chunk size warning。应用内浏览器确认本地运营端登录路由标题正确、DOM 非空、无框架错误覆盖且 console 无 error/warn;真实图形验证码阻止进入受保护页,未代解验证码、未绕过认证、未注入 mock。
- 功能提交 `551b99cb` 已 push 并部署生产。部署前 PostgreSQL 备份为 `/opt/cmpp-platform/backups/cmpp-20260713-174148.sql`,运行源码备份为 `/opt/cmpp-platform/deploy-backups/full-19d47b45-20260713-174148`;三条 migration 成功应用。生产 `.deployed-commit=551b99cb`,四项服务 active`12026/17890/8090/3000` 监听,API/Gateway health 和外部 HTTP 200;前端产物已包含“短信签名审核”“按通道修改引流信息报备状态”“签名与引流信息报备任务”。生产现有 4 条任务均已回填为 `reportType=signature``DrainageReportMaterial=0`,因此没有伪造引流任务,待真实引流字段资料保存时自动生成。
- 部署后交付复核发现历史运营端新建的 draft 签名虽可在审核页筛选,但操作按钮只对 pending 开放。已改为 draft/pending 都可由运营直接通过或驳回,用于处理【安徽航天信息】等存量草稿;新增签名仍按新规则自动通过。修正提交 `ff3e5607` 已部署,二次备份时间戳 `20260713-174512`;生产 `.deployed-commit=ff3e5607`,四项服务、端口、API/Gateway health 和外部 HTTP 200 再次验证通过,部署后 API stderr 无新错误。
## 2026-07-13 引流信息独立审核与报备任务门禁
- 生产只读核查确认当前 `DrainageReportMaterial=0``reportType=drainage` 任务为 0、包含引流数组的签名为 0,因此本轮可安全引入规范化模型,不需要改写活跃引流业务数据。生产运行代码仍为 `ff3e5607`,本轮暂未部署。
- 新增独立 PostgreSQL 实体 `SmsDrainageInfo`,客户端在已审核签名下新建或修改引流信息均进入 pending,并写 `AuditRecord(targetType=sms_drainage_info)`;运营端新增/修改自动通过。运营端审核中心新增“引流信息审核”页,支持真实 API 查询、详情、通过和带原因驳回,首页和侧栏待审数量同步纳入引流信息。
- 通道报备增加审核门禁:审核通过前不创建新任务或可导出材料;已通过引流信息再次修改时删除旧材料并将已有任务冻结为 waiting_review。审核通过后按应用当前生效路由及通道引流字段重建材料,创建或重置 `ChannelSignatureReportTask(reportType=drainage)`,并写 `audit_approved_create/reset` 报备记录。
- 企业端签名页面补充引流信息新建、修改、删除、动态字段和真实对象存储上传;运营端企业签名页改为调用独立引流 API,并显示引流审核状态。报备任务和报备记录页增加类型筛选,直接关联真实引流实体显示站点、地址和所属签名。
- 本地真实 PostgreSQL 已成功应用 migration `20260713190000_add_drainage_audit_workflow`Prisma validate/migrate status 通过。真实 NestJS API 验收使用现有已审核签名、应用路由和通道临时增加报备字段:客户端创建后为 pending 且任务数 0;运营审核通过后生成 1 条 drainage 任务和报备记录;客户端修改后任务冻结为 waiting_review;驳回原因可从 API 返回。验收数据、临时报备字段、材料、任务、审核及报备记录已在本地数据库清理,不污染业务样本。
- API 全量测试 13 suites、141 项通过,API build、前端 build、`git diff --check` 通过;前端仅有既有 Vite chunk size warning。应用内浏览器确认本地构建标题、非空登录 DOM、无框架错误覆盖且 console 无 error/warn;访问 `/admin/drainage-audits` 按真实权限跳转登录页,因图形验证码未获授权代解,登录后的新增审核页点击和视觉验收未执行。该批改动纳入 2026-07-14 发布批次统一提交和部署。
## 2026-07-14 运营列表组合搜索与报备记录可追溯性
- 企业模板管理将企业名称、企业应用、模板名称、模板内容拆成独立查询参数;企业签名管理拆分企业名称、企业应用、签名名称/用途和引流信息。引流信息查询命中后按签名父级分组,并只展开显示命中的站点、URL 或备注。
- 企业应用管理新增应用名称和状态条件;企业黑名单搜索区重做为企业名称、企业应用、手机号码、入库原因、状态五个独立条件。上述条件均由 NestJS 接收并通过 Prisma AND 组合查询真实 PostgreSQL,不再拼接为单个模糊关键字。
- `ChannelSignatureReportRecord` 新增 `sourceEntry`,企业签名、报备任务、通道报备详情三个入口分别持久化 `enterprise_signature/report_task/channel_report`。报备记录列表展示真实通道名称、签名或引流信息主体、完整主体内容、中文动作和状态变化,并将入口翻译为“企业签名修改 / 报备任务修改 / 通道信息修改”。
- 充值记录、短信记录表头统一左对齐;搜索区使用自适应网格,增加条件后不挤压操作按钮。
- 本地真实 PostgreSQL 已应用 `20260714100000_add_report_record_source_entry`,并通过编译后的 NestJS 服务对现有应用、签名、模板执行真实组合查询。API 全量测试 13 suites、141 项通过;Prisma validate、API build、前端 build、Gateway 测试和 `git diff --check` 通过。应用内浏览器确认真实鉴权跳转、页面标题、非空 DOM、无框架错误覆盖及 console 无 error/warn;因图形验证码未获授权代解,登录后页面点击验收未执行。
+54 -9
View File
@@ -210,7 +210,7 @@ export type DashboardResponse = {
transactions: { _count: { _all: number }; _sum: { amountCents?: number | null; smsUnits?: number | null } };
gatewayConnections: Array<{ status: string; _count: { _all: number }; _sum: { currentConnections?: number | null; desiredConnections?: number | null } }>;
pendingAuditCount: number;
pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; total: number };
pendingAudits: { enterpriseCertifications: number; smsAudits: number; templates: number; signatures: number; drainageInfos: number; total: number };
downstreamDeliverySummary?: {
pending: number;
failed: number;
@@ -289,6 +289,27 @@ export type ClientSmsSignature = {
drainageCarrierReportSummary?: Record<string, Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>>;
};
export type SmsDrainageInfo = {
id: string;
tenantId: string;
signatureId: string;
applicationId?: string | null;
siteName: string;
url: string;
remark?: string | null;
reportValues?: Record<string, unknown> | null;
auditStatus: string;
rejectReason?: string | null;
submittedAt: string;
reviewedAt?: string | null;
createdAt: string;
updatedAt: string;
tenant?: TenantOption;
signature?: ClientSmsSignature;
application?: ClientSmsApplication | null;
reportTasks?: ReportTask[];
};
export type ClientSmsTemplate = {
id: string;
tenantId: string;
@@ -303,7 +324,7 @@ export type ClientSmsTemplate = {
updatedAt: string;
variables?: Array<{ name: string; example?: string | null; required?: boolean }>;
application?: { id: string; name: string };
signature?: { id: string; name: string; drainageInfo?: Record<string, unknown> | null };
signature?: { id: string; name: string; purpose?: string | null; drainageInfo?: Record<string, unknown> | null };
tenant?: TenantOption;
};
@@ -544,7 +565,8 @@ export type ReportTask = DictionaryItem & {
reportType?: 'signature' | 'drainage';
drainageItemId?: string | null;
status: string;
signature?: { id: string; name: string; drainageInfo?: Record<string, unknown> | null };
signature?: { id: string; name: string; purpose?: string | null; drainageInfo?: Record<string, unknown> | null };
drainageInfo?: SmsDrainageInfo | null;
channel?: { id: string; name: string; code: string };
};
@@ -555,6 +577,9 @@ export type ReportRecord = DictionaryItem & {
statusBefore?: string | null;
statusAfter?: string | null;
reason?: string | null;
sourceEntry?: 'system' | 'enterprise_signature' | 'report_task' | 'channel_report';
channel?: AdminChannel;
task?: ReportTask;
};
export type FileObject = {
@@ -883,7 +908,7 @@ export const adminApi = {
listManualRecharges: (tenantId?: string) => request<RechargeOrder[]>(withQuery('/admin/billing/manual-recharges', { tenantId })),
createManualRecharge: (body: { tenantId: string; amountCents: number; smsUnits?: number; operatorId?: string; remark?: string }) =>
request<RechargeOrder>('/admin/billing/manual-recharges', { method: 'POST', body: JSON.stringify(body) }),
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string } = {}) =>
listEnterpriseApplications: (query: { tenantId?: string; keyword?: string; enterpriseKeyword?: string; applicationKeyword?: string; status?: string } = {}) =>
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
getEnterpriseApplication: (id: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`),
@@ -939,7 +964,7 @@ export const adminApi = {
method: 'POST',
body: JSON.stringify({ reason }),
}),
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string } = {}) =>
listEnterpriseSignatures: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; signatureKeyword?: string; drainageKeyword?: string } = {}) =>
request<ClientSmsSignature[]>(withQuery('/admin/enterprise-signatures', query)),
createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }) =>
request<ClientSmsSignature>('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }),
@@ -947,7 +972,19 @@ export const adminApi = {
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) =>
request<ClientSmsSignature>(`/admin/enterprise-signatures/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string } = {}) =>
listDrainageInfos: (query: { tenantId?: string; signatureId?: string; keyword?: string; status?: string } = {}) =>
request<SmsDrainageInfo[]>(withQuery('/admin/drainage-infos', query)),
createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> }) =>
request<SmsDrainageInfo>(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, { method: 'POST', body: JSON.stringify(body) }),
updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record<string, unknown> }) =>
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
approveDrainageInfo: (id: string) =>
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }),
rejectDrainageInfo: (id: string, reason: string) =>
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }),
changeDrainageInfoStatus: (id: string, status: string, reason?: string) =>
request<SmsDrainageInfo>(`/admin/drainage-infos/${id}/status`, { method: 'POST', body: JSON.stringify({ status, reason }) }),
listEnterpriseTemplates: (query: { tenantId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; nameKeyword?: string; contentKeyword?: string } = {}) =>
request<ClientSmsTemplate[]>(withQuery('/admin/enterprise-templates', query)),
createEnterpriseTemplate: (body: { tenantId: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }) =>
request<ClientSmsTemplate>('/admin/enterprise-templates', { method: 'POST', body: JSON.stringify(body) }),
@@ -989,10 +1026,10 @@ export const adminApi = {
listChannelReportFields: (channelId?: string) => request<ChannelReportField[]>(withQuery('/admin/channel-report-fields', { channelId })),
createChannelReportField: (body: Record<string, unknown>) =>
request<ChannelReportField>('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }),
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request<ReportTask[]>(withQuery('/admin/report-tasks', query)),
createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) =>
request<ReportTask>('/admin/report-tasks/generate', { method: 'POST', body: JSON.stringify(body) }),
changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string }) =>
changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string; sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report' }) =>
request<Array<{ signatureId: string; reportStatus: string; carrierReportSummary: Record<string, { status: string; approved: number; total: number }> }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }),
createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) =>
request<Record<string, unknown>>(`/admin/report-tasks/${id}/export`, { method: 'POST', body: JSON.stringify(body) }),
@@ -1042,7 +1079,7 @@ 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; applicationId?: string; keyword?: string; status?: string } = {}) => request<DictionaryItem[]>(withQuery('/admin/dictionaries/blacklists/enterprise', query)),
listEnterpriseBlacklist: (query: { tenantId?: string; applicationId?: string; keyword?: string; status?: string; enterpriseKeyword?: string; applicationKeyword?: string; phoneNumber?: string; reasonKeyword?: 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' }),
@@ -1118,6 +1155,8 @@ export const clientApi = {
request<ClientSmsApplication[]>('/client/applications', { tenantId }),
getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ApplicationCmppParams>(`/client/applications/${applicationId}/cmpp-params`, { tenantId }),
listApplicationReportFields: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ApplicationReportField[]>(`/client/applications/${applicationId}/report-fields`, { tenantId }),
listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsSignature[]>('/client/signatures', { tenantId }),
createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
@@ -1128,6 +1167,12 @@ export const clientApi = {
request<ClientSmsSignature>(`/client/signatures/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
createSignatureMaterial: (id: string, body: { fileObjectId?: string; materialType: string; title: string; description?: string }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<Record<string, unknown>>(`/client/signatures/${id}/materials`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsDrainageInfo>(`/client/signatures/${signatureId}/drainage-infos`, { method: 'POST', tenantId, body: JSON.stringify(body) }),
updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record<string, unknown> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsDrainageInfo>(`/client/drainage-infos/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }),
changeDrainageInfoStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<SmsDrainageInfo>(`/client/drainage-infos/${id}/status`, { method: 'POST', tenantId, body: JSON.stringify({ status }) }),
listTemplates: (query: { status?: string; keyword?: string } = {}, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
request<ClientSmsTemplate[]>(withQuery('/client/templates', query), { tenantId }),
createTemplate: (body: { tenantId?: string; applicationId: string; signatureId?: string; name: string; content: string; category?: string; variables?: Array<{ name: string; example?: string; required?: boolean }> }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) =>
+1 -1
View File
@@ -128,7 +128,7 @@ export function AdminChannelReportPage() {
function saveTaskStatus() {
if (!statusTask) return;
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason })
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason, sourceEntry: 'channel_report' })
.then(() => { setStatusTask(undefined); setStatusReason(''); loadData(); })
.catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
}
+94
View File
@@ -0,0 +1,94 @@
import { useEffect, useMemo, useState } from 'react';
import { Check, Eye, Search, X } from 'lucide-react';
import { adminApi, type FileRef, type SmsDrainageInfo } from '@/api/adminApi';
import { Breadcrumb, Button, FileActions, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { formatDateTime } from '@/utils/dateTime';
const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'success' | 'danger' }> = {
pending: { label: '待审核', tone: 'info' },
approved: { label: '已通过', tone: 'success' },
rejected: { label: '已驳回', tone: 'danger' },
deleted: { label: '已删除', tone: 'neutral' },
};
function fileRef(value: unknown): FileRef | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const item = value as Record<string, unknown>;
const fileObjectId = String(item.fileObjectId ?? '');
const fileName = String(item.fileName ?? '');
return fileObjectId && fileName ? { fileObjectId, fileName, contentType: item.contentType ? String(item.contentType) : undefined } : null;
}
function DrainageDetail({ item, onClose }: { item: SmsDrainageInfo; onClose: () => void }) {
const values = item.reportValues && typeof item.reportValues === 'object' ? item.reportValues : {};
const meta = statusMeta[item.auditStatus] ?? { label: item.auditStatus, tone: 'neutral' as const };
return <Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title="引流信息审核详情">
<div className="page-stack">
<div className="detail-grid">
<div><span></span><strong>{item.tenant?.name ?? item.tenantId}</strong></div>
<div><span></span><strong>{item.application?.name ?? '-'}</strong></div>
<div><span></span><strong>{item.signature?.name ?? item.signatureId}</strong></div>
<div><span></span><Tag tone={meta.tone}>{meta.label}</Tag></div>
<div><span></span><strong>{item.siteName}</strong></div>
<div><span></span><strong>{item.url}</strong></div>
<div><span></span><strong>{formatDateTime(item.submittedAt)}</strong></div>
<div className="detail-grid__wide"><span></span><strong>{item.remark || '-'}</strong></div>
{item.rejectReason ? <div className="detail-grid__wide"><span></span><strong>{item.rejectReason}</strong></div> : null}
</div>
<div className="surface" style={{ padding: 16 }}>
<strong></strong>
<div className="detail-grid" style={{ marginTop: 12 }}>
{Object.keys(values).length ? Object.entries(values).map(([key, value]) => {
const file = fileRef(value);
return <div key={key}><span>{key}</span><strong>{file ? <FileActions file={file} /> : String(value ?? '-')}</strong></div>;
}) : <span className="muted"></span>}
</div>
</div>
</div>
</Modal>;
}
export function AdminDrainageAuditPage() {
const [items, setItems] = useState<SmsDrainageInfo[]>([]);
const [keyword, setKeyword] = useState('');
const [status, setStatus] = useState('pending');
const [detail, setDetail] = useState<SmsDrainageInfo>();
const [rejectTarget, setRejectTarget] = useState<SmsDrainageInfo>();
const [reason, setReason] = useState('');
const [error, setError] = useState('');
function loadData() {
adminApi.listDrainageInfos({ keyword, status: status === 'all' ? undefined : status })
.then((records) => { setItems(records); setError(''); })
.catch((failure: Error) => setError(failure.message || '引流信息审核列表加载失败'));
}
useEffect(loadData, [keyword, status]);
async function approve(item: SmsDrainageInfo) {
try { await adminApi.approveDrainageInfo(item.id); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '引流信息审核通过失败'); }
}
async function reject() {
if (!rejectTarget || !reason.trim()) return;
try { await adminApi.rejectDrainageInfo(rejectTarget.id, reason.trim()); setRejectTarget(undefined); setReason(''); loadData(); } catch (failure) { setError(failure instanceof Error ? failure.message : '引流信息审核驳回失败'); }
}
const columns = useMemo<Array<TableColumn<SmsDrainageInfo>>>(() => [
{ key: 'siteName', title: '站名称/引流地址', render: (record) => <div className="admin-task-enterprise"><strong>{record.siteName}</strong><span>{record.url}</span></div> },
{ key: 'signature', title: '所属签名', render: (record) => record.signature?.name ?? record.signatureId },
{ key: 'tenant', title: '企业/应用', render: (record) => <div className="admin-task-enterprise"><strong>{record.tenant?.name ?? record.tenantId}</strong><span>{record.application?.name ?? '-'}</span></div> },
{ key: 'submittedAt', title: '提交时间', render: (record) => formatDateTime(record.submittedAt) },
{ key: 'status', title: '状态', render: (record) => { const meta = statusMeta[record.auditStatus] ?? { label: record.auditStatus, tone: 'neutral' as const }; return <Tag tone={meta.tone}>{meta.label}</Tag>; } },
{ key: 'actions', title: '操作', align: 'right', render: (record) => <div className="table-actions"><Button icon={<Eye size={15} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button><Button disabled={record.auditStatus !== 'pending'} icon={<Check size={15} />} onClick={() => void approve(record)} size="sm" variant="success"></Button><Button disabled={record.auditStatus !== 'pending'} icon={<X size={15} />} onClick={() => setRejectTarget(record)} size="sm" variant="danger"></Button></div> },
], []);
return <section className="page-stack admin-template-audit-page">
<div className="page-heading"><div><Breadcrumb items={['审核中心', '引流信息审核']} /><h1></h1></div></div>
{error ? <p className="form-error">{error}</p> : null}
<div className="surface audit-filter-card"><div className="audit-filter-grid audit-filter-grid--template"><Input label="搜索" onChange={(event) => setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、站点或网址" prefix={<Search size={16} />} value={keyword} /><Select label="审核状态" onChange={(event) => setStatus(event.target.value)} options={[{ label: '全部状态', value: 'all' }, { label: '待审核', value: 'pending' }, { label: '已通过', value: 'approved' }, { label: '已驳回', value: 'rejected' }]} value={status} /><div className="audit-filter-actions"><Button icon={<Search size={17} />} onClick={loadData}></Button><Button onClick={() => { setKeyword(''); setStatus('pending'); }} variant="ghost"></Button></div></div></div>
<div className="surface"><Table columns={columns} data={items} emptyText="暂无引流信息审核记录" rowKey="id" /></div>
{detail ? <DrainageDetail item={detail} onClose={() => setDetail(undefined)} /> : null}
<Modal footer={<><Button onClick={() => setRejectTarget(undefined)} variant="ghost"></Button><Button disabled={!reason.trim()} onClick={() => void reject()} variant="danger"></Button></>} onClose={() => setRejectTarget(undefined)} open={Boolean(rejectTarget)} title="驳回引流信息"><Textarea label="驳回原因" onChange={(event) => setReason(event.target.value)} rows={4} value={reason} /></Modal>
</section>;
}
@@ -244,6 +244,10 @@ export function AdminEnterpriseApplicationsPage() {
const [smsApps, setSmsApps] = useState<SmsApp[]>([]);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
const [applicationKeyword, setApplicationKeyword] = useState('');
const [appliedApplicationKeyword, setAppliedApplicationKeyword] = useState('');
const [status, setStatus] = useState('all');
const [appliedStatus, setAppliedStatus] = useState('all');
const [connectionApp, setConnectionApp] = useState<SmsApp | null>(null);
const [paramsApp, setParamsApp] = useState<SmsApp | null>(null);
const [paramsDetail, setParamsDetail] = useState<ApplicationCmppParams | null>(null);
@@ -258,9 +262,9 @@ export function AdminEnterpriseApplicationsPage() {
| null
>(null);
async function loadSmsApps(keyword = appliedEnterpriseKeyword) {
async function loadSmsApps(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, status: appliedStatus }) {
try {
const applications = await adminApi.listEnterpriseApplications({ keyword });
const applications = await adminApi.listEnterpriseApplications(filters);
setSmsApps(applications.map(mapApplication));
setError('');
} catch (err) {
@@ -271,7 +275,7 @@ export function AdminEnterpriseApplicationsPage() {
useEffect(() => {
void loadSmsApps();
}, [appliedEnterpriseKeyword]);
}, [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedStatus]);
async function openAddModal() {
setAddModalOpen(true);
@@ -303,7 +307,7 @@ export function AdminEnterpriseApplicationsPage() {
async function confirmDelete(id: string) {
await adminApi.changeApplicationStatus(id, 'deleted', '运营端删除应用');
await loadSmsApps();
await loadSmsApps();
}
async function runConfirmedAction() {
@@ -324,8 +328,10 @@ export function AdminEnterpriseApplicationsPage() {
}
const filteredSmsApps = useMemo(
() => smsApps.filter((item) => !appliedEnterpriseKeyword || item.enterprise.includes(appliedEnterpriseKeyword)),
[appliedEnterpriseKeyword, smsApps],
() => smsApps.filter((item) => (!appliedEnterpriseKeyword || item.enterprise.includes(appliedEnterpriseKeyword))
&& (!appliedApplicationKeyword || item.name.includes(appliedApplicationKeyword))
&& (appliedStatus === 'all' || (appliedStatus === 'active' ? item.enabled : !item.enabled))),
[appliedApplicationKeyword, appliedEnterpriseKeyword, appliedStatus, smsApps],
);
const smsColumns = useMemo<Array<TableColumn<SmsApp>>>(() => [
@@ -389,9 +395,22 @@ export function AdminEnterpriseApplicationsPage() {
prefix={<Search size={16} />}
value={enterpriseKeyword}
/>
<Input
label="企业应用名称"
onChange={(event) => setApplicationKeyword(event.target.value)}
placeholder="请输入企业应用名称"
prefix={<Search size={16} />}
value={applicationKeyword}
/>
<Select
label="状态"
onChange={(event) => setStatus(event.target.value)}
options={[{ label: '全部状态', value: 'all' }, { label: '启用', value: 'active' }, { label: '停用', value: 'disabled' }]}
value={status}
/>
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => setAppliedEnterpriseKeyword(enterpriseKeyword.trim())}></Button>
<Button onClick={() => { setEnterpriseKeyword(''); setAppliedEnterpriseKeyword(''); }} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={() => { setAppliedEnterpriseKeyword(enterpriseKeyword.trim()); setAppliedApplicationKeyword(applicationKeyword.trim()); setAppliedStatus(status); }}></Button>
<Button onClick={() => { setEnterpriseKeyword(''); setApplicationKeyword(''); setStatus('all'); setAppliedEnterpriseKeyword(''); setAppliedApplicationKeyword(''); setAppliedStatus('all'); }} variant="ghost"></Button>
</div>
</div>
+31 -30
View File
@@ -1,6 +1,6 @@
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 { Breadcrumb, Button, Input, Modal, Select, Table, Tag, Textarea, type TableColumn } from '@/components/ui';
import { adminApi, type DictionaryItem, type EnterpriseApplication, type TenantOption } from '@/api/adminApi';
type EnterpriseBlacklistItem = DictionaryItem & {
@@ -16,9 +16,12 @@ export function AdminEnterpriseBlacklistPage() {
const [items, setItems] = useState<EnterpriseBlacklistItem[]>([]);
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [applications, setApplications] = useState<EnterpriseApplication[]>([]);
const [keyword, setKeyword] = useState('');
const [filterTenantId, setFilterTenantId] = useState('');
const [filterApplicationId, setFilterApplicationId] = useState('');
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [applicationKeyword, setApplicationKeyword] = useState('');
const [phoneKeyword, setPhoneKeyword] = useState('');
const [reasonKeyword, setReasonKeyword] = useState('');
const [status, setStatus] = useState('all');
const [appliedFilters, setAppliedFilters] = useState({ enterpriseKeyword: '', applicationKeyword: '', phoneNumber: '', reasonKeyword: '', status: 'all' });
const [formTenantId, setFormTenantId] = useState('');
const [formApplicationId, setFormApplicationId] = useState('');
const [phone, setPhone] = useState('');
@@ -26,9 +29,9 @@ export function AdminEnterpriseBlacklistPage() {
const [modalOpen, setModalOpen] = useState(false);
const [error, setError] = useState('');
function loadData() {
function loadData(filters = appliedFilters) {
Promise.all([
adminApi.listEnterpriseBlacklist({ tenantId: filterTenantId || undefined, applicationId: filterApplicationId || undefined, keyword }),
adminApi.listEnterpriseBlacklist(filters),
adminApi.listTenants(),
adminApi.listEnterpriseApplications(),
])
@@ -45,11 +48,6 @@ export function AdminEnterpriseBlacklistPage() {
loadData();
}, []);
const filteredItems = useMemo(() => items.filter((item) => {
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>>>(() => [
@@ -58,19 +56,19 @@ export function AdminEnterpriseBlacklistPage() {
{ 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 ?? '-' },
{ key: 'status', title: '状态', width: '130px', render: (record) => record.status ?? '-' },
{ key: 'status', title: '状态', width: '130px', render: (record) => <Tag tone={record.status === 'active' ? 'success' : 'neutral'}>{record.status === 'active' ? '生效中' : record.status === 'deleted' ? '已删除' : record.status ?? '-'}</Tag> },
{
key: 'actions',
title: '操作',
width: '130px',
align: 'right',
render: (record) => (
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteEnterpriseBlacklist(record.id).then(loadData).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
<Button icon={<Trash2 size={15} />} onClick={() => adminApi.deleteEnterpriseBlacklist(record.id).then(() => loadData()).catch((failure: Error) => setError(failure.message))} size="sm" variant="danger">
</Button>
),
},
], []);
], [appliedFilters]);
function addItem() {
adminApi.createEnterpriseBlacklist({ tenantId: formTenantId, applicationId: formApplicationId, phoneNumber: phone, reason, status: 'active' })
@@ -98,32 +96,35 @@ export function AdminEnterpriseBlacklistPage() {
<div className="surface admin-security-filter">
<Input
label="搜索"
onChange={(event) => setKeyword(event.target.value)}
placeholder="搜索企业、应用、手机号或原因"
label="企业名称"
onChange={(event) => setEnterpriseKeyword(event.target.value)}
placeholder="请输入企业名称"
prefix={<Search size={16} />}
value={keyword}
value={enterpriseKeyword}
/>
<Select
label="企业"
onChange={(event) => { setFilterTenantId(event.target.value); setFilterApplicationId(''); }}
options={[{ label: '全部企业', value: '' }, ...tenants.map((item) => ({ label: item.name, value: item.id }))]}
value={filterTenantId}
<Input
label="企业应用"
onChange={(event) => setApplicationKeyword(event.target.value)}
placeholder="请输入企业应用名称"
prefix={<Search size={16} />}
value={applicationKeyword}
/>
<Input label="手机号码" onChange={(event) => setPhoneKeyword(event.target.value)} placeholder="请输入手机号码" value={phoneKeyword} />
<Input label="入库原因" onChange={(event) => setReasonKeyword(event.target.value)} placeholder="请输入入库原因" value={reasonKeyword} />
<Select
label="短信应用"
onChange={(event) => setFilterApplicationId(event.target.value)}
options={[{ label: filterTenantId ? '全部应用' : '请先选择企业', value: '' }, ...filterApplications.map((item) => ({ label: item.name, value: item.id }))]}
value={filterApplicationId}
label="状态"
onChange={(event) => setStatus(event.target.value)}
options={[{ label: '全部状态', value: 'all' }, { label: '生效中', value: 'active' }, { label: '已删除', value: 'deleted' }]}
value={status}
/>
<div className="admin-security-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={() => { setKeyword(''); setFilterTenantId(''); setFilterApplicationId(''); }} variant="ghost"></Button>
<Button icon={<Search size={16} />} onClick={() => { const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), phoneNumber: phoneKeyword.trim(), reasonKeyword: reasonKeyword.trim(), status }; setAppliedFilters(filters); loadData(filters); }}></Button>
<Button onClick={() => { const filters = { enterpriseKeyword: '', applicationKeyword: '', phoneNumber: '', reasonKeyword: '', status: 'all' }; setEnterpriseKeyword(''); setApplicationKeyword(''); setPhoneKeyword(''); setReasonKeyword(''); setStatus('all'); setAppliedFilters(filters); loadData(filters); }} variant="ghost"></Button>
</div>
</div>
<div className="surface admin-security-table-card">
<Table columns={columns} data={filteredItems} emptyText="暂无企业黑名单记录" rowKey="id" />
<Table columns={columns} data={items} emptyText="暂无企业黑名单记录" rowKey="id" />
</div>
<Modal
@@ -27,6 +27,8 @@ type DrainageInfo = {
submittedAt: string;
remark: string;
reportValues: ReportValues;
auditStatus?: string;
rejectReason?: string | null;
};
type UploadedFileRef = FileRef;
@@ -134,6 +136,8 @@ function readDrainagePayload(signature: ClientSmsSignature) {
submittedAt: String(item.submittedAt ?? ''),
remark: String(item.remark ?? ''),
reportValues: normalizeReportValues(item.reportValues),
auditStatus: String(item.auditStatus ?? 'pending'),
rejectReason: item.rejectReason ? String(item.rejectReason) : null,
})),
};
}
@@ -582,7 +586,7 @@ function ChannelReportStatusModal({ item, onClose, onSaved }: { item: ClientSmsS
async function save() {
setSaving(true);
try {
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: item.id, channelId: target.channelId, status: statuses[target.channelId] ?? target.status })), reason });
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: item.id, channelId: target.channelId, status: statuses[target.channelId] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
onSaved();
} catch (failure) { setError(failure instanceof Error ? failure.message : '报备状态保存失败'); } finally { setSaving(false); }
}
@@ -623,7 +627,7 @@ function DrainageReportStatusModal({ item, onClose, onSaved, signature }: { item
async function save() {
setSaving(true);
try {
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: signature.id, channelId: target.channelId, reportType: 'drainage', drainageItemId: item.id, status: statuses[target.channelId] ?? target.status })), reason });
await adminApi.changeReportTaskStatuses({ items: targets.map((target) => ({ signatureId: signature.id, channelId: target.channelId, reportType: 'drainage', drainageItemId: item.id, status: statuses[target.channelId] ?? target.status })), reason, sourceEntry: 'enterprise_signature' });
onSaved();
} catch (failure) { setError(failure instanceof Error ? failure.message : '引流报备状态保存失败'); } finally { setSaving(false); }
}
@@ -663,6 +667,10 @@ export function AdminEnterpriseSignaturesPage() {
const [drainageStatusTarget, setDrainageStatusTarget] = useState<{ signature: ClientSmsSignature; item: DrainageInfo } | null>(null);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
const [applicationKeyword, setApplicationKeyword] = useState('');
const [appliedApplicationKeyword, setAppliedApplicationKeyword] = useState('');
const [drainageKeyword, setDrainageKeyword] = useState('');
const [appliedDrainageKeyword, setAppliedDrainageKeyword] = useState('');
const [error, setError] = useState('');
const [expandedSignatureId, setExpandedSignatureId] = useState('');
const [signatureKeyword, setSignatureKeyword] = useState('');
@@ -674,10 +682,10 @@ export function AdminEnterpriseSignaturesPage() {
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1);
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, signatureKeyword: appliedSignatureKeyword }) {
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, signatureKeyword: appliedSignatureKeyword, drainageKeyword: appliedDrainageKeyword }) {
try {
const [signatureItems, tenantItems, applicationItems] = await Promise.all([
adminApi.listEnterpriseSignatures({ keyword: [filters.enterpriseKeyword, filters.signatureKeyword].filter(Boolean).join(' ') }),
adminApi.listEnterpriseSignatures(filters),
adminApi.listTenants(),
adminApi.listEnterpriseApplications(),
]);
@@ -697,9 +705,12 @@ export function AdminEnterpriseSignaturesPage() {
const filteredSignatures = useMemo(() => signatures.filter((item) => {
const enterprise = item.tenant?.name ?? item.tenantId;
const application = item.application?.name ?? '';
const drainageItems = readDrainagePayload(item).links;
return (!appliedEnterpriseKeyword || enterprise.includes(appliedEnterpriseKeyword))
&& (!appliedSignatureKeyword || item.name.includes(appliedSignatureKeyword) || application.includes(appliedSignatureKeyword) || (item.purpose ?? '').includes(appliedSignatureKeyword));
}), [appliedEnterpriseKeyword, appliedSignatureKeyword, signatures]);
&& (!appliedApplicationKeyword || application.includes(appliedApplicationKeyword))
&& (!appliedSignatureKeyword || item.name.includes(appliedSignatureKeyword) || (item.purpose ?? '').includes(appliedSignatureKeyword))
&& (!appliedDrainageKeyword || drainageItems.some((drainage) => `${drainage.siteName} ${drainage.url} ${drainage.remark}`.includes(appliedDrainageKeyword)));
}), [appliedApplicationKeyword, appliedDrainageKeyword, appliedEnterpriseKeyword, appliedSignatureKeyword, signatures]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredSignatures.length / pageSize));
const currentPage = Math.min(page, totalPages);
@@ -707,7 +718,7 @@ export function AdminEnterpriseSignaturesPage() {
useEffect(() => {
setPage(1);
}, [appliedEnterpriseKeyword, appliedSignatureKeyword, filteredSignatures.length]);
}, [appliedApplicationKeyword, appliedDrainageKeyword, appliedEnterpriseKeyword, appliedSignatureKeyword, filteredSignatures.length]);
async function saveSignature(state: SignatureFormState) {
const existing = signatureModal && signatureModal !== 'new' ? signatureModal : null;
@@ -746,13 +757,10 @@ export function AdminEnterpriseSignaturesPage() {
if (!signature) {
return;
}
const payload = readDrainagePayload(signature);
const links = payload.links.some((current) => current.id === item.id)
? payload.links.map((current) => current.id === item.id ? item : current)
: [item, ...payload.links];
await adminApi.updateEnterpriseSignature(signatureId, {
drainageInfo: buildDrainagePayload(payload.carrierStatus, links, payload.signatureProfile, payload.signatureReportValues),
});
const exists = readDrainagePayload(signature).links.some((current) => current.id === item.id);
const body = { siteName: item.siteName, url: item.url, remark: item.remark, reportValues: item.reportValues };
if (exists) await adminApi.updateDrainageInfo(item.id, body);
else await adminApi.createDrainageInfo(signatureId, body);
setDrainageModal(null);
setExpandedSignatureId(signatureId);
await loadData();
@@ -765,13 +773,7 @@ export function AdminEnterpriseSignaturesPage() {
if (deleteTarget.kind === 'signature') {
await adminApi.changeEnterpriseSignatureStatus(deleteTarget.id, 'deleted', '运营端删除签名');
} else {
const signature = signatures.find((item) => item.id === deleteTarget.signatureId);
if (signature) {
const payload = readDrainagePayload(signature);
await adminApi.updateEnterpriseSignature(signature.id, {
drainageInfo: buildDrainagePayload(payload.carrierStatus, payload.links.filter((item) => item.id !== deleteTarget.id), payload.signatureProfile, payload.signatureReportValues),
});
}
await adminApi.changeDrainageInfoStatus(deleteTarget.id, 'deleted', '运营端删除引流信息');
}
setDeleteTarget(null);
await loadData();
@@ -781,9 +783,12 @@ export function AdminEnterpriseSignaturesPage() {
<div className="signature-list admin-enterprise-signature-list">
{visibleSignatures.map((signature) => {
const payload = readDrainagePayload(signature);
const visibleDrainageLinks = appliedDrainageKeyword
? payload.links.filter((item) => `${item.siteName} ${item.url} ${item.remark}`.includes(appliedDrainageKeyword))
: payload.links;
const summaryStatuses = Object.values(signature.carrierReportSummary ?? {}).map((summary) => summary.status);
const cardTone = summaryStatuses.includes('failed') ? 'red' : summaryStatuses.length > 0 && summaryStatuses.every((status) => status === 'approved' || status === 'not_applicable') ? 'green' : summaryStatuses.some((status) => status === 'reporting') ? 'blue' : 'gray';
const expanded = expandedSignatureId === signature.id;
const expanded = expandedSignatureId === signature.id || Boolean(appliedDrainageKeyword);
return (
<article className={`signature-card signature-card--${cardTone}`} key={signature.id}>
<div className="signature-summary">
@@ -808,30 +813,32 @@ export function AdminEnterpriseSignaturesPage() {
{expanded ? (
<div className="drainage-panel">
<h2></h2>
{payload.links.length ? (
{visibleDrainageLinks.length ? (
<div className="drainage-table">
<div className="drainage-table__head">
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
<span></span>
</div>
{payload.links.map((item) => {
{visibleDrainageLinks.map((item) => {
const summary = signature.drainageCarrierReportSummary?.[item.id];
return (
<div className="drainage-table__row" key={item.id}>
<strong>{item.siteName}</strong>
<span className="drainage-table__url" title={item.url}>{item.url}</span>
<AuditStatusTag status={item.auditStatus ?? 'pending'} />
<CarrierReportTag summary={summary?.mobile} />
<CarrierReportTag summary={summary?.unicom} />
<CarrierReportTag summary={summary?.telecom} />
<span className="muted">{item.submittedAt}</span>
<span className="drainage-row-actions">
<Button onClick={() => setDrainageReport({ signature, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDrainageStatusTarget({ signature, item })} size="sm" variant="ghost"></Button>
<Button disabled={item.auditStatus !== 'approved'} onClick={() => setDrainageStatusTarget({ signature, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDrainageModal({ signatureId: signature.id, item })} size="sm" variant="ghost"></Button>
<Button onClick={() => setDeleteTarget({ kind: 'drainage', signatureId: signature.id, id: item.id, name: item.siteName })} size="sm" variant="danger"></Button>
</span>
@@ -875,20 +882,28 @@ export function AdminEnterpriseSignaturesPage() {
<div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="签名/应用" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名或应用名称" prefix={<Search size={16} />} value={signatureKeyword} />
<Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} />
<Input label="签名名称" onChange={(event) => setSignatureKeyword(event.target.value)} placeholder="请输入签名名称或用途" prefix={<Search size={16} />} value={signatureKeyword} />
<Input label="引流信息" onChange={(event) => setDrainageKeyword(event.target.value)} placeholder="请输入站名称、URL 或备注" prefix={<Search size={16} />} value={drainageKeyword} />
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => {
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), signatureKeyword: signatureKeyword.trim() };
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), signatureKeyword: signatureKeyword.trim(), drainageKeyword: drainageKeyword.trim() };
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
setAppliedApplicationKeyword(filters.applicationKeyword);
setAppliedSignatureKeyword(filters.signatureKeyword);
setAppliedDrainageKeyword(filters.drainageKeyword);
void loadData(filters);
}}></Button>
<Button onClick={() => {
const filters = { enterpriseKeyword: '', signatureKeyword: '' };
const filters = { enterpriseKeyword: '', applicationKeyword: '', signatureKeyword: '', drainageKeyword: '' };
setEnterpriseKeyword('');
setApplicationKeyword('');
setSignatureKeyword('');
setDrainageKeyword('');
setAppliedEnterpriseKeyword('');
setAppliedApplicationKeyword('');
setAppliedSignatureKeyword('');
setAppliedDrainageKeyword('');
void loadData(filters);
}} variant="ghost"></Button>
</div>
+27 -13
View File
@@ -251,20 +251,24 @@ export function AdminEnterpriseTemplatesPage() {
const [deleteTarget, setDeleteTarget] = useState<ClientSmsTemplate | null>(null);
const [enterpriseKeyword, setEnterpriseKeyword] = useState('');
const [appliedEnterpriseKeyword, setAppliedEnterpriseKeyword] = useState('');
const [applicationKeyword, setApplicationKeyword] = useState('');
const [appliedApplicationKeyword, setAppliedApplicationKeyword] = useState('');
const [error, setError] = useState('');
const [signatureItems, setSignatureItems] = useState<ClientSmsSignature[]>([]);
const [templateModal, setTemplateModal] = useState<ClientSmsTemplate | 'new' | null>(null);
const [templatePreview, setTemplatePreview] = useState<ClientSmsTemplate | null>(null);
const [templates, setTemplates] = useState<ClientSmsTemplate[]>([]);
const [templateKeyword, setTemplateKeyword] = useState('');
const [appliedTemplateKeyword, setAppliedTemplateKeyword] = useState('');
const [templateNameKeyword, setTemplateNameKeyword] = useState('');
const [appliedTemplateNameKeyword, setAppliedTemplateNameKeyword] = useState('');
const [templateContentKeyword, setTemplateContentKeyword] = useState('');
const [appliedTemplateContentKeyword, setAppliedTemplateContentKeyword] = useState('');
const [tenants, setTenants] = useState<TenantOption[]>([]);
const [page, setPage] = useState(1);
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, templateKeyword: appliedTemplateKeyword }) {
async function loadData(filters = { enterpriseKeyword: appliedEnterpriseKeyword, applicationKeyword: appliedApplicationKeyword, nameKeyword: appliedTemplateNameKeyword, contentKeyword: appliedTemplateContentKeyword }) {
try {
const [templateItems, tenantItems, applicationItems, signatureList] = await Promise.all([
adminApi.listEnterpriseTemplates({ keyword: [filters.enterpriseKeyword, filters.templateKeyword].filter(Boolean).join(' ') }),
adminApi.listEnterpriseTemplates(filters),
adminApi.listTenants(),
adminApi.listEnterpriseApplications(),
adminApi.listEnterpriseSignatures(),
@@ -287,8 +291,10 @@ export function AdminEnterpriseTemplatesPage() {
const enterprise = item.tenant?.name ?? item.tenantId;
const application = item.application?.name ?? '';
return (!appliedEnterpriseKeyword || enterprise.includes(appliedEnterpriseKeyword))
&& (!appliedTemplateKeyword || item.name.includes(appliedTemplateKeyword) || item.content.includes(appliedTemplateKeyword) || application.includes(appliedTemplateKeyword));
}), [appliedEnterpriseKeyword, appliedTemplateKeyword, templates]);
&& (!appliedApplicationKeyword || application.includes(appliedApplicationKeyword))
&& (!appliedTemplateNameKeyword || item.name.includes(appliedTemplateNameKeyword))
&& (!appliedTemplateContentKeyword || item.content.includes(appliedTemplateContentKeyword));
}), [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedTemplateContentKeyword, appliedTemplateNameKeyword, templates]);
const pageSize = 10;
const totalPages = Math.max(1, Math.ceil(filteredTemplates.length / pageSize));
const currentPage = Math.min(page, totalPages);
@@ -296,7 +302,7 @@ export function AdminEnterpriseTemplatesPage() {
useEffect(() => {
setPage(1);
}, [appliedEnterpriseKeyword, appliedTemplateKeyword]);
}, [appliedApplicationKeyword, appliedEnterpriseKeyword, appliedTemplateContentKeyword, appliedTemplateNameKeyword]);
async function saveTemplate(state: TemplateFormState) {
const existing = templateModal && templateModal !== 'new' ? templateModal : null;
@@ -349,20 +355,28 @@ export function AdminEnterpriseTemplatesPage() {
{error ? <p className="form-error">{error}</p> : null}
<div className="surface admin-split-filter">
<Input label="企业名称" onChange={(event) => setEnterpriseKeyword(event.target.value)} placeholder="请输入企业名称" prefix={<Search size={16} />} value={enterpriseKeyword} />
<Input label="模板/应用/内容" onChange={(event) => setTemplateKeyword(event.target.value)} placeholder="请输入模板、应用或内容" prefix={<Search size={16} />} value={templateKeyword} />
<Input label="企业应用" onChange={(event) => setApplicationKeyword(event.target.value)} placeholder="请输入企业应用名称" prefix={<Search size={16} />} value={applicationKeyword} />
<Input label="模板名称" onChange={(event) => setTemplateNameKeyword(event.target.value)} placeholder="请输入模板名称" prefix={<Search size={16} />} value={templateNameKeyword} />
<Input label="模板内容" onChange={(event) => setTemplateContentKeyword(event.target.value)} placeholder="请输入模板内容" prefix={<Search size={16} />} value={templateContentKeyword} />
<div className="admin-split-filter__actions">
<Button icon={<Search size={16} />} onClick={() => {
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), templateKeyword: templateKeyword.trim() };
const filters = { enterpriseKeyword: enterpriseKeyword.trim(), applicationKeyword: applicationKeyword.trim(), nameKeyword: templateNameKeyword.trim(), contentKeyword: templateContentKeyword.trim() };
setAppliedEnterpriseKeyword(filters.enterpriseKeyword);
setAppliedTemplateKeyword(filters.templateKeyword);
setAppliedApplicationKeyword(filters.applicationKeyword);
setAppliedTemplateNameKeyword(filters.nameKeyword);
setAppliedTemplateContentKeyword(filters.contentKeyword);
void loadData(filters);
}}></Button>
<Button onClick={() => {
const filters = { enterpriseKeyword: '', templateKeyword: '' };
const filters = { enterpriseKeyword: '', applicationKeyword: '', nameKeyword: '', contentKeyword: '' };
setEnterpriseKeyword('');
setTemplateKeyword('');
setApplicationKeyword('');
setTemplateNameKeyword('');
setTemplateContentKeyword('');
setAppliedEnterpriseKeyword('');
setAppliedTemplateKeyword('');
setAppliedApplicationKeyword('');
setAppliedTemplateNameKeyword('');
setAppliedTemplateContentKeyword('');
void loadData(filters);
}} variant="ghost"></Button>
</div>
+9 -4
View File
@@ -85,7 +85,7 @@ export function AdminHome() {
const todaySpend = (dashboard?.today.spendCents ?? 0) / 100;
const activeConnectionCount = dashboard?.gatewayConnections.reduce((sum, item) => sum + (item._sum.currentConnections ?? 0), 0) ?? 0;
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, total: 0 };
const pendingAudits = dashboard?.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0, total: 0 };
const sendTrendOption = useMemo(
() => createLineOption({
@@ -100,9 +100,9 @@ export function AdminHome() {
const auditTrendOption = useMemo(
() => createBarOption({
labels: ['企业认证', '短信审核', '模板', '签名'],
labels: ['企业认证', '短信审核', '模板', '签名', '引流信息'],
series: [
{ name: '待审', data: [pendingAudits.enterpriseCertifications, pendingAudits.smsAudits, pendingAudits.templates, pendingAudits.signatures] },
{ name: '待审', data: [pendingAudits.enterpriseCertifications, pendingAudits.smsAudits, pendingAudits.templates, pendingAudits.signatures, pendingAudits.drainageInfos] },
],
}),
[pendingAudits],
@@ -246,11 +246,16 @@ export function AdminHome() {
<span></span>
<strong>{pendingAudits.templates} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/enterprise-signatures')} variant="ghost">
<Button className="mini-status-card" onClick={() => navigate('/admin/signatures')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.signatures} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/drainage-audits')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.drainageInfos} </strong>
</Button>
<div className="mini-status-card">
<ShieldCheck size={22} />
<div>
+50 -13
View File
@@ -1,32 +1,63 @@
import { useEffect, useMemo, useState } from 'react';
import { Clock3, Eye, Search } from 'lucide-react';
import { adminApi, type ReportRecord } from '@/api/adminApi';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
import { Breadcrumb, Button, DateRangeInput, Input, Modal, Select, Table, Tag, type DateRangeValue, type TableColumn } from '@/components/ui';
const statusTone: Record<string, 'neutral' | 'info' | 'success' | 'warning' | 'danger'> = {
pending: 'neutral',
waiting_material: 'warning',
waiting_review: 'warning',
reporting: 'warning',
exporting: 'info',
partial: 'warning',
success: 'success',
completed: 'success',
failed: 'danger',
rejected: 'danger',
};
const statusLabel: Record<string, string> = {
pending: '待报备', waiting_material: '待补充资料', waiting_review: '待重新审核', reporting: '报备中', exporting: '导出中', partial: '部分通过', approved: '已通过', success: '成功', completed: '已完成', failed: '失败', rejected: '已驳回', abandoned: '已废弃', imported: '已导入', deleted: '已删除',
};
const actionLabel: Record<string, string> = {
create: '创建报备任务', manual_status_change: '人工修改状态', export: '导出报备资料', receipt_import: '导入回执', audit_approved_create: '引流审核通过后创建', audit_approved_reset: '引流审核通过后重置', audit_resubmit_freeze: '引流修改后冻结', audit_rejected_freeze: '引流审核驳回后冻结', drainage_deleted: '引流信息删除',
};
const sourceEntryLabel: Record<string, string> = {
enterprise_signature: '企业签名修改', report_task: '报备任务修改', channel_report: '通道信息修改', system: '系统自动处理',
};
function translateStatus(value?: string | null) {
if (!value) return '-';
return statusLabel[value] ?? value;
}
function recordSource(record: ReportRecord) {
if (record.sourceEntry) return sourceEntryLabel[record.sourceEntry] ?? record.sourceEntry;
return record.action === 'manual_status_change' ? '历史记录(入口未记录)' : '系统自动处理';
}
function RecordDetailModal({ record, onClose }: { record: ReportRecord; onClose: () => void }) {
const isDrainage = record.task?.reportType === 'drainage';
const target = isDrainage ? record.task?.drainageInfo?.siteName ?? record.task?.drainageInfo?.url : record.task?.signature?.name;
return (
<Modal footer={<Button onClick={onClose}></Button>} onClose={onClose} open size="xl" title={<div className="template-modal-title"><h2></h2><p>{record.id}</p></div>}>
<div className="report-record-detail">
<div className="detail-grid">
<div><span></span><strong>{record.taskId}</strong></div>
<div><span></span><strong>{record.channelId}</strong></div>
<div><span></span><strong>{record.action}</strong></div>
<div><span></span><strong>{record.statusBefore ?? '-'}</strong></div>
<div><span></span><strong>{record.statusAfter ?? '-'}</strong></div>
<div><span></span><strong>{record.channel?.name ?? '-'}</strong></div>
<div><span></span><strong>{isDrainage ? '引流信息' : '签名'}</strong></div>
<div><span></span><strong>{target ?? '-'}</strong></div>
<div><span></span><strong>{actionLabel[record.action] ?? record.action}</strong></div>
<div><span></span><strong>{recordSource(record)}</strong></div>
<div><span></span><strong>{translateStatus(record.statusBefore)}</strong></div>
<div><span></span><strong>{translateStatus(record.statusAfter)}</strong></div>
<div className="detail-grid__wide"><span>/</span><strong>{record.reason ?? '-'}</strong></div>
</div>
<section className="report-history">
<h3><Clock3 size={17} /></h3>
<div><span>{record.createdAt ?? '-'}</span><strong>{record.action}</strong><em>{record.reason ?? '系统记录真实报备状态变化。'}</em></div>
<div><span>{record.createdAt ?? '-'}</span><strong>{actionLabel[record.action] ?? record.action}</strong><em>{record.reason ?? `修改入口:${recordSource(record)}`}</em></div>
</section>
</div>
</Modal>
@@ -37,6 +68,7 @@ export function AdminReportRecordsPage() {
const [records, setRecords] = useState<ReportRecord[]>([]);
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [reportType, setReportType] = useState('all');
const [detail, setDetail] = useState<ReportRecord | null>(null);
const [error, setError] = useState('');
@@ -54,18 +86,22 @@ export function AdminReportRecordsPage() {
}, []);
const filteredRecords = useMemo(() => records.filter((record) => {
const text = `${record.taskId}${record.channelId}${record.action}${record.reason ?? ''}`;
const text = `${record.taskId}${record.channel?.name ?? ''}${record.action}${actionLabel[record.action] ?? ''}${recordSource(record)}${record.reason ?? ''}${record.task?.signature?.name ?? ''}${record.task?.signature?.purpose ?? ''}${record.task?.drainageInfo?.siteName ?? ''}${record.task?.drainageInfo?.url ?? ''}${record.task?.drainageInfo?.remark ?? ''}`;
const date = record.createdAt?.slice(0, 10) ?? '';
return (!keyword || text.includes(keyword))
&& (!dateRange.start || date >= dateRange.start)
&& (!dateRange.end || date <= dateRange.end);
}), [dateRange.end, dateRange.start, keyword, records]);
&& (!dateRange.end || date <= dateRange.end)
&& (reportType === 'all' || record.task?.reportType === reportType);
}), [dateRange.end, dateRange.start, keyword, records, reportType]);
const columns: Array<TableColumn<ReportRecord>> = [
{ key: 'task', title: '任务编号', width: '190px', render: (record) => <strong className="admin-task-id">{record.taskId}</strong> },
{ key: 'channel', title: '通道', width: '230px', render: (record) => record.channelId },
{ key: 'action', title: '动作', width: '150px', render: (record) => record.action },
{ key: 'status', title: '状态变化', width: '180px', render: (record) => <Tag tone={statusTone[record.statusAfter ?? 'pending'] ?? 'info'}>{`${record.statusBefore ?? '-'} -> ${record.statusAfter ?? '-'}`}</Tag> },
{ key: 'channel', title: '通道名称', width: '180px', render: (record) => record.channel?.name ?? '-' },
{ key: 'targetType', title: '变更主体', width: '110px', render: (record) => <Tag tone={record.task?.reportType === 'drainage' ? 'info' : 'neutral'}>{record.task?.reportType === 'drainage' ? '引流信息' : '签名'}</Tag> },
{ key: 'target', title: '主体内容', width: '260px', render: (record) => record.task?.reportType === 'drainage' ? <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong><span>{record.task?.drainageInfo?.siteName ?? '-'}</span><span>{record.task?.drainageInfo?.url ?? '-'}</span>{record.task?.drainageInfo?.remark ? <span>{record.task.drainageInfo.remark}</span> : null}</div> : <div className="admin-task-enterprise"><strong>{record.task?.signature?.name ?? '-'}</strong>{record.task?.signature?.purpose ? <span>{record.task.signature.purpose}</span> : null}</div> },
{ key: 'source', title: '修改入口', width: '150px', render: (record) => recordSource(record) },
{ key: 'action', title: '动作', width: '170px', render: (record) => actionLabel[record.action] ?? record.action },
{ key: 'status', title: '状态变化', width: '210px', render: (record) => <Tag tone={statusTone[record.statusAfter ?? 'pending'] ?? 'info'}>{`${translateStatus(record.statusBefore)}${translateStatus(record.statusAfter)}`}</Tag> },
{ key: 'time', title: '记录时间', width: '190px', render: (record) => record.createdAt ?? '-' },
{ key: 'reason', title: '备注', render: (record) => record.reason ?? '-' },
{ key: 'actions', title: '操作', align: 'right', width: '120px', render: (record) => <Button icon={<Eye size={14} />} onClick={() => setDetail(record)} size="sm" variant="ghost"></Button> },
@@ -83,10 +119,11 @@ export function AdminReportRecordsPage() {
<div className="surface admin-task-filter">
<Input label="任务/通道/动作/备注" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
<DateRangeInput label="提交时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={() => { setKeyword(''); setDateRange({}); }} variant="ghost"></Button>
<Button onClick={() => { setKeyword(''); setDateRange({}); setReportType('all'); }} variant="ghost"></Button>
</div>
</div>
+8 -4
View File
@@ -15,6 +15,7 @@ const statusMeta: Record<string, { label: string; tone: 'neutral' | 'info' | 'su
exporting: { label: '报备中', tone: 'warning' },
abandoned: { label: '已放弃', tone: 'neutral' },
failed: { label: '有失败', tone: 'danger' },
waiting_review: { label: '等待运营审核', tone: 'warning' },
};
type ReceiptImportPayload = {
@@ -27,6 +28,7 @@ type ReceiptImportPayload = {
function taskTargetLabel(task: ReportTask) {
if (task.reportType !== 'drainage') return task.signature?.name ?? task.signatureId;
if (task.drainageInfo) return task.drainageInfo.siteName || task.drainageInfo.url;
const payload = task.signature?.drainageInfo;
const links = payload && Array.isArray(payload.links) ? payload.links as Array<Record<string, unknown>> : [];
const item = links.find((link) => String(link.id ?? '') === task.drainageItemId);
@@ -122,6 +124,7 @@ export function AdminReportTasksPage() {
const [tasks, setTasks] = useState<ReportTask[]>([]);
const [keyword, setKeyword] = useState('');
const [dateRange, setDateRange] = useState<DateRangeValue>({});
const [reportType, setReportType] = useState('all');
const [receiptTask, setReceiptTask] = useState<ReportTask | null>(null);
const [detailTask, setDetailTask] = useState<ReportTask | null>(null);
const [statusTask, setStatusTask] = useState<ReportTask | null>(null);
@@ -130,7 +133,7 @@ export function AdminReportTasksPage() {
const [error, setError] = useState('');
function loadData() {
adminApi.listReportTasks()
adminApi.listReportTasks({ reportType: reportType === 'all' ? undefined : reportType as 'signature' | 'drainage' })
.then((items) => {
setTasks(items);
setError('');
@@ -140,7 +143,7 @@ export function AdminReportTasksPage() {
useEffect(() => {
loadData();
}, []);
}, [reportType]);
const filteredTasks = useMemo(() => tasks.filter((task) => {
const text = `${task.id}${task.channel?.name ?? task.channelId}${task.signature?.name ?? task.signatureId}${task.drainageItemId ?? ''}`;
@@ -174,7 +177,7 @@ export function AdminReportTasksPage() {
function saveTaskStatus() {
if (!statusTask) return;
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason })
adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus }], reason: statusReason, sourceEntry: 'report_task' })
.then(() => { setStatusTask(null); setStatusReason(''); loadData(); })
.catch((failure: Error) => setError(failure.message || '报备状态保存失败'));
}
@@ -212,10 +215,11 @@ export function AdminReportTasksPage() {
<div className="surface admin-task-filter">
<Input label="任务/通道/报备对象" onChange={(event) => setKeyword(event.target.value)} placeholder="请输入关键字" value={keyword} />
<Select label="报备类型" onChange={(event) => setReportType(event.target.value)} options={[{ label: '全部类型', value: 'all' }, { label: '签名报备', value: 'signature' }, { label: '引流信息报备', value: 'drainage' }]} value={reportType} />
<DateRangeInput label="创建时间" onChange={setDateRange} value={dateRange} />
<div className="admin-task-filter__actions">
<Button icon={<Search size={16} />} onClick={loadData}></Button>
<Button onClick={() => { setKeyword(''); setDateRange({}); }} variant="ghost"></Button>
<Button onClick={() => { setKeyword(''); setDateRange({}); setReportType('all'); }} variant="ghost"></Button>
</div>
</div>
+1 -1
View File
@@ -423,7 +423,7 @@ export function AdminSmsRecordsPage() {
<th></th>
<th style={{ width: '170px' }}></th>
<th style={{ width: '300px' }}></th>
<th style={{ textAlign: 'right', width: '120px' }}></th>
<th style={{ width: '120px' }}></th>
</tr>
</thead>
<tbody>
+96 -3
View File
@@ -1,7 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { FilePenLine, Plus, Search, Trash2, Upload } from 'lucide-react';
import { Button, FileActions, Input, Modal, Pagination, Select, Tag } from '@/components/ui';
import { clientApi, type ClientSmsApplication, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
import { Edit3, FilePenLine, Globe2, Plus, Search, Trash2, Upload } from 'lucide-react';
import { Button, FileActions, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui';
import { clientApi, type ApplicationReportField, type ClientSmsApplication, type ClientSmsSignature, type FileRef } from '@/api/adminApi';
const statusTone: Record<string, 'success' | 'info' | 'danger' | 'warning'> = {
approved: 'success',
@@ -26,6 +26,89 @@ function materialToFileRef(material: Record<string, unknown>): FileRef | null {
return { contentType, fileName, fileObjectId };
}
type ClientDrainageInfo = {
id: string;
siteName: string;
url: string;
remark: string;
reportValues: Record<string, unknown>;
auditStatus: string;
rejectReason?: string | null;
submittedAt?: string;
};
function drainageItems(signature: ClientSmsSignature): ClientDrainageInfo[] {
const payload = signature.drainageInfo && typeof signature.drainageInfo === 'object' ? signature.drainageInfo : {};
const links = Array.isArray(payload.links) ? payload.links as Array<Record<string, unknown>> : [];
return links.map((item) => ({
id: String(item.id ?? ''),
siteName: String(item.siteName ?? ''),
url: String(item.url ?? ''),
remark: String(item.remark ?? ''),
reportValues: item.reportValues && typeof item.reportValues === 'object' ? item.reportValues as Record<string, unknown> : {},
auditStatus: String(item.auditStatus ?? 'pending'),
rejectReason: item.rejectReason ? String(item.rejectReason) : null,
submittedAt: item.submittedAt ? String(item.submittedAt) : undefined,
}));
}
function reportFileRef(value: unknown): FileRef | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
const item = value as Record<string, unknown>;
const fileObjectId = String(item.fileObjectId ?? '');
const fileName = String(item.fileName ?? '');
return fileObjectId && fileName ? { fileObjectId, fileName, contentType: item.contentType ? String(item.contentType) : undefined } : null;
}
function ClientDrainageModal({ item, onClose, onSaved, signature }: { item?: ClientDrainageInfo; onClose: () => void; onSaved: () => void; signature: ClientSmsSignature }) {
const [fields, setFields] = useState<ApplicationReportField[]>([]);
const [siteName, setSiteName] = useState(item?.siteName ?? '');
const [url, setUrl] = useState(item?.url ?? '');
const [remark, setRemark] = useState(item?.remark ?? '');
const [values, setValues] = useState<Record<string, unknown>>(item?.reportValues ?? {});
const [saving, setSaving] = useState(false);
const [uploadingCode, setUploadingCode] = useState('');
const [error, setError] = useState('');
useEffect(() => {
if (!signature.applicationId) return;
clientApi.listApplicationReportFields(signature.applicationId).then(setFields).catch((failure: Error) => setError(failure.message || '引流报备字段加载失败'));
}, [signature.applicationId]);
async function upload(field: ApplicationReportField, file?: File) {
if (!file) return;
setUploadingCode(field.code);
try {
const uploaded = await clientApi.uploadFileObject(file, { purpose: 'drainage_report_material', prefix: `drainage-materials/${signature.id}` });
setValues((current) => ({ ...current, [field.code]: { fileObjectId: uploaded.id, fileName: uploaded.fileName, contentType: uploaded.contentType } }));
} catch (failure) { setError(failure instanceof Error ? failure.message : '文件上传失败'); } finally { setUploadingCode(''); }
}
async function save() {
setSaving(true);
setError('');
try {
const body = { siteName, url, remark, reportValues: values };
if (item) await clientApi.updateDrainageInfo(item.id, body);
else await clientApi.createDrainageInfo(signature.id, body);
onSaved();
} catch (failure) { setError(failure instanceof Error ? failure.message : '引流信息提交审核失败'); } finally { setSaving(false); }
}
const missingRequired = fields.some((field) => field.required && !values[field.code]);
return <Modal footer={<><Button onClick={onClose} variant="ghost"></Button><Button disabled={!siteName.trim() || !url.trim() || missingRequired || saving || Boolean(uploadingCode)} onClick={() => void save()}>{saving ? '提交中...' : '提交审核'}</Button></>} onClose={onClose} open size="xl" title={item ? '修改引流信息' : '新增引流信息'}>
<div className="signature-form drainage-edit-form">
<Input label="站名称" onChange={(event) => setSiteName(event.target.value)} required value={siteName} />
<Input label="引流地址" onChange={(event) => setUrl(event.target.value)} placeholder="https://" required value={url} />
<Textarea label="备注" onChange={(event) => setRemark(event.target.value)} rows={3} value={remark} />
<section className="surface" style={{ padding: 16 }}><h3></h3><div className="signature-form-grid" style={{ marginTop: 12 }}>
{fields.map((field) => field.fieldType === 'string' ? <Input key={field.id} label={`${field.required ? '* ' : ''}${field.name}`} onChange={(event) => setValues((current) => ({ ...current, [field.code]: event.target.value }))} value={String(values[field.code] ?? '')} /> : <label className="signature-upload" key={field.id}><Upload size={28} /><strong>{reportFileRef(values[field.code])?.fileName ?? `${field.required ? '* ' : ''}上传${field.name}`}</strong><small>{uploadingCode === field.code ? '上传中...' : field.fieldType === 'image' ? '请选择图片文件' : '请选择文件'}</small><FileActions file={reportFileRef(values[field.code])} /><input accept={field.fieldType === 'image' ? 'image/*' : undefined} onChange={(event) => void upload(field, event.target.files?.[0])} style={{ display: 'none' }} type="file" /></label>)}
</div></section>
{error ? <p className="form-error">{error}</p> : null}
</div>
</Modal>;
}
export function ClientSignaturesPage() {
const [applications, setApplications] = useState<ClientSmsApplication[]>([]);
const [signatures, setSignatures] = useState<ClientSmsSignature[]>([]);
@@ -38,6 +121,7 @@ export function ClientSignaturesPage() {
const [purpose, setPurpose] = useState('');
const [file, setFile] = useState<File | null>(null);
const [page, setPage] = useState(1);
const [drainageModal, setDrainageModal] = useState<{ signature: ClientSmsSignature; item?: ClientDrainageInfo }>();
function loadData() {
setLoading(true);
@@ -99,6 +183,10 @@ export function ClientSignaturesPage() {
.catch((reason: Error) => setError(reason.message || '签名禁用失败'));
}
function deleteDrainage(id: string) {
clientApi.changeDrainageInfoStatus(id, 'deleted').then(loadData).catch((reason: Error) => setError(reason.message || '引流信息删除失败'));
}
return (
<section className="page-stack">
<div className="signature-page-header">
@@ -149,6 +237,10 @@ export function ClientSignaturesPage() {
<Button icon={<Trash2 size={16} />} onClick={() => disableSignature(signature.id)} size="sm" variant="danger"></Button>
</div>
</div>
<div className="drainage-panel">
<div className="section-heading"><div><h2><Globe2 size={17} /> </h2><p className="muted"></p></div><Button disabled={signature.auditStatus !== 'approved'} icon={<Plus size={15} />} onClick={() => setDrainageModal({ signature })} size="sm" variant="ghost"></Button></div>
{drainageItems(signature).length ? drainageItems(signature).map((item) => <div className="surface" key={item.id} style={{ display: 'grid', gap: 12, gridTemplateColumns: '1fr 1.5fr 120px auto', marginTop: 10, padding: 12 }}><strong>{item.siteName}</strong><span className="drainage-table__url">{item.url}</span><Tag tone={statusTone[item.auditStatus] ?? 'info'}>{statusLabel[item.auditStatus] ?? item.auditStatus}</Tag><div className="table-actions"><Button icon={<Edit3 size={14} />} onClick={() => setDrainageModal({ signature, item })} size="sm" variant="ghost"></Button><Button icon={<Trash2 size={14} />} onClick={() => deleteDrainage(item.id)} size="sm" variant="danger"></Button></div>{item.rejectReason ? <p className="form-error" style={{ gridColumn: '1 / -1' }}>{item.rejectReason}</p> : null}</div>) : <p className="muted"></p>}
</div>
</article>
))}
</div>
@@ -197,6 +289,7 @@ export function ClientSignaturesPage() {
</label>
</div>
</Modal>
{drainageModal ? <ClientDrainageModal item={drainageModal.item} onClose={() => setDrainageModal(undefined)} onSaved={() => { setDrainageModal(undefined); loadData(); }} signature={drainageModal.signature} /> : null}
</section>
);
}
+6 -4
View File
@@ -37,16 +37,16 @@ import { AppShell } from '@/layouts/AppShell';
export function AdminLayout() {
const session = readSession();
const [pendingAudits, setPendingAudits] = useState({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0 });
const [pendingAudits, setPendingAudits] = useState({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 });
const [downstreamAlertCount, setDownstreamAlertCount] = useState(0);
const loadPendingAuditCount = useCallback(() => {
adminApi.getDashboard()
.then((dashboard) => {
setPendingAudits(dashboard.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0 });
setPendingAudits(dashboard.pendingAudits ?? { enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 });
setDownstreamAlertCount(dashboard.downstreamDeliverySummary?.alertCount ?? 0);
})
.catch(() => {
setPendingAudits({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0 });
setPendingAudits({ enterpriseCertifications: 0, smsAudits: 0, templates: 0, signatures: 0, drainageInfos: 0 });
setDownstreamAlertCount(0);
});
}, []);
@@ -81,7 +81,8 @@ export function AdminLayout() {
{ label: '企业认证待审', count: pendingAudits.enterpriseCertifications, to: '/admin/enterprise-audit' },
{ label: '短信审核待审', count: pendingAudits.smsAudits, to: '/admin/sms-audit' },
{ label: '模板待审', count: pendingAudits.templates, to: '/admin/templates' },
{ label: '签名待审', count: pendingAudits.signatures, to: '/admin/enterprise-signatures' },
{ label: '签名待审', count: pendingAudits.signatures, to: '/admin/signatures' },
{ label: '引流信息待审', count: pendingAudits.drainageInfos, to: '/admin/drainage-audits' },
{ label: '下游投递告警', count: downstreamAlertCount, to: '/admin/downstream-deliveries' },
]}
navSections={[
@@ -112,6 +113,7 @@ export function AdminLayout() {
{ label: '短信审核', to: '/admin/sms-audit', icon: MessageSquare },
{ label: '短信模板审核', to: '/admin/templates', icon: FileCheck2 },
{ label: '短信签名审核', to: '/admin/signatures', icon: FilePenLine },
{ label: '引流信息审核', to: '/admin/drainage-audits', icon: FilePenLine },
],
},
{
+2
View File
@@ -28,6 +28,7 @@ import { AdminSmsRecordsPage } from '@/apps/admin/AdminSmsRecordsPage';
import { AdminSmsTaskProgressPage } from '@/apps/admin/AdminSmsTaskProgressPage';
import { AdminSmsUplinkRecordsPage } from '@/apps/admin/AdminSmsUplinkRecordsPage';
import { AdminSignatureAuditPage } from '@/apps/admin/AdminSignatureAuditPage';
import { AdminDrainageAuditPage } from '@/apps/admin/AdminDrainageAuditPage';
import { AdminSystemLogsPage } from '@/apps/admin/AdminSystemLogsPage';
import { AdminTemplateAuditPage } from '@/apps/admin/AdminTemplateAuditPage';
import { AdminUsersPage } from '@/apps/admin/AdminUsersPage';
@@ -97,6 +98,7 @@ export function AppRoutes() {
<Route path="enterprise-templates" element={<AdminEnterpriseTemplatesPage />} />
<Route path="templates" element={<AdminTemplateAuditPage />} />
<Route path="signatures" element={<AdminSignatureAuditPage />} />
<Route path="drainage-audits" element={<AdminDrainageAuditPage />} />
<Route path="enterprise-audit" element={<AdminEnterpriseAuditPage />} />
<Route path="sms-audit" element={<AdminSmsAuditPage />} />
<Route path="report-tasks" element={<AdminReportTasksPage />} />
+6 -4
View File
@@ -2783,7 +2783,7 @@ h3 {
align-items: center;
display: grid;
gap: var(--space-4);
grid-template-columns: minmax(96px, 0.9fr) minmax(180px, 1.5fr) repeat(3, minmax(64px, 0.65fr)) minmax(128px, 1fr) minmax(196px, auto);
grid-template-columns: minmax(96px, 0.9fr) minmax(180px, 1.5fr) minmax(80px, 0.7fr) repeat(3, minmax(64px, 0.65fr)) minmax(128px, 1fr) minmax(196px, auto);
min-height: 58px;
}
@@ -3009,7 +3009,7 @@ h3 {
align-items: end;
display: grid;
gap: var(--space-5);
grid-template-columns: minmax(240px, 1fr) minmax(280px, 1.2fr) auto;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.admin-split-filter__actions {
@@ -3025,7 +3025,7 @@ h3 {
}
.admin-application-filter {
grid-template-columns: minmax(320px, 460px) auto;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.cmpp-status-cell {
@@ -8449,6 +8449,7 @@ h3 {
background: var(--color-bg-subtle);
color: var(--color-text-strong);
height: 58px;
text-align: left;
}
.admin-sms-record-table td {
@@ -8882,6 +8883,7 @@ h3 {
color: var(--color-text-muted);
font-size: var(--font-size-sm);
height: 58px;
text-align: left;
}
.admin-recharge-table td {
@@ -8934,7 +8936,7 @@ h3 {
align-items: end;
display: grid;
gap: var(--space-5);
grid-template-columns: minmax(320px, 1fr) auto;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
padding: var(--space-5);
}