From 6421671259186023e89eb9b4dd384ad629275f3e Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Tue, 14 Jul 2026 09:58:18 +0800 Subject: [PATCH] feat: complete drainage review and admin search workflows --- .../migration.sql | 78 ++++ .../migration.sql | 2 + api/prisma/schema.prisma | 31 ++ api/src/channels/channels.controller.ts | 4 +- api/src/channels/channels.service.spec.ts | 13 +- api/src/channels/channels.service.ts | 34 +- .../dictionaries/dictionaries.controller.ts | 4 +- .../dictionaries/dictionaries.service.spec.ts | 4 +- api/src/dictionaries/dictionaries.service.ts | 8 + api/src/operations/operations.service.spec.ts | 4 + api/src/operations/operations.service.ts | 6 +- .../sms-config/admin-sms-config.controller.ts | 44 ++- .../client-sms-config.controller.ts | 27 ++ api/src/sms-config/sms-config.service.spec.ts | 87 +++- api/src/sms-config/sms-config.service.ts | 373 ++++++++++++++---- .../first-version-development-requirements.md | 13 +- docs/system-functional-test-cases.md | 36 +- docs/testing-progress.md | 17 + src/api/adminApi.ts | 63 ++- src/apps/admin/AdminChannelReportPage.tsx | 2 +- src/apps/admin/AdminDrainageAuditPage.tsx | 94 +++++ .../admin/AdminEnterpriseApplicationsPage.tsx | 35 +- .../admin/AdminEnterpriseBlacklistPage.tsx | 61 +-- .../admin/AdminEnterpriseSignaturesPage.tsx | 71 ++-- .../admin/AdminEnterpriseTemplatesPage.tsx | 40 +- src/apps/admin/AdminHome.tsx | 13 +- src/apps/admin/AdminReportRecordsPage.tsx | 63 ++- src/apps/admin/AdminReportTasksPage.tsx | 12 +- src/apps/admin/AdminSmsRecordsPage.tsx | 2 +- src/apps/client/ClientSignaturesPage.tsx | 99 ++++- src/layouts/AdminLayout.tsx | 10 +- src/routes/AppRoutes.tsx | 2 + src/styles/global.css | 10 +- 33 files changed, 1107 insertions(+), 255 deletions(-) create mode 100644 api/prisma/migrations/20260713190000_add_drainage_audit_workflow/migration.sql create mode 100644 api/prisma/migrations/20260714100000_add_report_record_source_entry/migration.sql create mode 100644 src/apps/admin/AdminDrainageAuditPage.tsx diff --git a/api/prisma/migrations/20260713190000_add_drainage_audit_workflow/migration.sql b/api/prisma/migrations/20260713190000_add_drainage_audit_workflow/migration.sql new file mode 100644 index 0000000..42cade6 --- /dev/null +++ b/api/prisma/migrations/20260713190000_add_drainage_audit_workflow/migration.sql @@ -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; diff --git a/api/prisma/migrations/20260714100000_add_report_record_source_entry/migration.sql b/api/prisma/migrations/20260714100000_add_report_record_source_entry/migration.sql new file mode 100644 index 0000000..8bf8f40 --- /dev/null +++ b/api/prisma/migrations/20260714100000_add_report_record_source_entry/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "ChannelSignatureReportRecord" +ADD COLUMN "sourceEntry" TEXT NOT NULL DEFAULT 'system'; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index e69aebf..41469b6 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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) diff --git a/api/src/channels/channels.controller.ts b/api/src/channels/channels.controller.ts index fea2a6a..ea92b54 100644 --- a/api/src/channels/channels.controller.ts +++ b/api/src/channels/channels.controller.ts @@ -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') diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index 04fc56a..1d2f76c 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -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' }), diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index 967bbed..1388a38 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -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; } diff --git a/api/src/dictionaries/dictionaries.controller.ts b/api/src/dictionaries/dictionaries.controller.ts index 723b220..eb7f0ba 100644 --- a/api/src/dictionaries/dictionaries.controller.ts +++ b/api/src/dictionaries/dictionaries.controller.ts @@ -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') diff --git a/api/src/dictionaries/dictionaries.service.spec.ts b/api/src/dictionaries/dictionaries.service.spec.ts index e52b70e..7b36a49 100644 --- a/api/src/dictionaries/dictionaries.service.spec.ts +++ b/api/src/dictionaries/dictionaries.service.spec.ts @@ -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 }, })); }); diff --git a/api/src/dictionaries/dictionaries.service.ts b/api/src/dictionaries/dictionaries.service.ts index beb5422..b49c258 100644 --- a/api/src/dictionaries/dictionaries.service.ts +++ b/api/src/dictionaries/dictionaries.service.ts @@ -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 } }, diff --git a/api/src/operations/operations.service.spec.ts b/api/src/operations/operations.service.spec.ts index 00f43d4..479636b 100644 --- a/api/src/operations/operations.service.spec.ts +++ b/api/src/operations/operations.service.spec.ts @@ -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, }, diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index 1300de4..bf3ce84 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -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, })); } diff --git a/api/src/sms-config/admin-sms-config.controller.ts b/api/src/sms-config/admin-sms-config.controller.ts index d1a05ca..2c5e085 100644 --- a/api/src/sms-config/admin-sms-config.controller.ts +++ b/api/src/sms-config/admin-sms-config.controller.ts @@ -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') diff --git a/api/src/sms-config/client-sms-config.controller.ts b/api/src/sms-config/client-sms-config.controller.ts index bacbb5a..e1e2425 100644 --- a/api/src/sms-config/client-sms-config.controller.ts +++ b/api/src/sms-config/client-sms-config.controller.ts @@ -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); diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index e479043..d1574cb 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -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 }, })); diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 6e7b585..04ebb6b 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -52,6 +52,22 @@ export type UpdateSmsSignatureDto = Partial; +} + +export type UpdateSmsDrainageInfoDto = Partial; + +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 | null)?.links) ? (signature.drainageInfo as Record).links as Array> : []).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 | null)?.links) ? (signature.drainageInfo as Record).links as Array> : []).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) { 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) { @@ -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 = {}) { + 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) { diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index d0f13b5..2822b8d 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -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 风控规则闭环 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index ee0aa0c..f2f940e 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -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 企业签名、通道详情和报备任务状态一致性 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 2bb4c1e..1517909 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1679,3 +1679,20 @@ git diff --check - 本地真实 PostgreSQL 已成功应用 3 条新 migration;API 全量 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;因图形验证码未获授权代解,登录后页面点击验收未执行。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 40df3a1..35ac880 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -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>; }; +export type SmsDrainageInfo = { + id: string; + tenantId: string; + signatureId: string; + applicationId?: string | null; + siteName: string; + url: string; + remark?: string | null; + reportValues?: Record | 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 | null }; + signature?: { id: string; name: string; purpose?: string | null; drainageInfo?: Record | 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 | null }; + signature?: { id: string; name: string; purpose?: string | null; drainageInfo?: Record | 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(withQuery('/admin/billing/manual-recharges', { tenantId })), createManualRecharge: (body: { tenantId: string; amountCents: number; smsUnits?: number; operatorId?: string; remark?: string }) => request('/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(withQuery('/admin/enterprise-applications', query)), getEnterpriseApplication: (id: string) => request(`/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(withQuery('/admin/enterprise-signatures', query)), createEnterpriseSignature: (body: { tenantId: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }) => request('/admin/enterprise-signatures', { method: 'POST', body: JSON.stringify(body) }), @@ -947,7 +972,19 @@ export const adminApi = { request(`/admin/enterprise-signatures/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeEnterpriseSignatureStatus: (id: string, status: string, reason?: string) => request(`/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(withQuery('/admin/drainage-infos', query)), + createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record }) => + request(`/admin/enterprise-signatures/${signatureId}/drainage-infos`, { method: 'POST', body: JSON.stringify(body) }), + updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record }) => + request(`/admin/drainage-infos/${id}`, { method: 'PUT', body: JSON.stringify(body) }), + approveDrainageInfo: (id: string) => + request(`/admin/drainage-infos/${id}/approve`, { method: 'POST', body: JSON.stringify({}) }), + rejectDrainageInfo: (id: string, reason: string) => + request(`/admin/drainage-infos/${id}/reject`, { method: 'POST', body: JSON.stringify({ reason }) }), + changeDrainageInfoStatus: (id: string, status: string, reason?: string) => + request(`/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(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('/admin/enterprise-templates', { method: 'POST', body: JSON.stringify(body) }), @@ -989,10 +1026,10 @@ export const adminApi = { listChannelReportFields: (channelId?: string) => request(withQuery('/admin/channel-report-fields', { channelId })), createChannelReportField: (body: Record) => request('/admin/channel-report-fields', { method: 'POST', body: JSON.stringify(body) }), - listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string } = {}) => request(withQuery('/admin/report-tasks', query)), + listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request(withQuery('/admin/report-tasks', query)), createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) => request('/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 }>>('/admin/report-tasks/status-change', { method: 'POST', body: JSON.stringify(body) }), createReportExport: (id: string, body: { fileObjectId?: string; fileName: string; rowCount?: number }) => request>(`/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('/admin/dictionaries/blacklists/global', { method: 'POST', body: JSON.stringify(body) }), deleteGlobalBlacklist: (id: string) => request(`/admin/dictionaries/blacklists/global/${id}`, { method: 'DELETE' }), - listEnterpriseBlacklist: (query: { tenantId?: string; applicationId?: string; keyword?: string; status?: string } = {}) => request(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(withQuery('/admin/dictionaries/blacklists/enterprise', query)), createEnterpriseBlacklist: (body: { tenantId: string; applicationId: string; phoneNumber: string; reason?: string; status?: string; operatorId?: string }) => request('/admin/dictionaries/blacklists/enterprise', { method: 'POST', body: JSON.stringify(body) }), deleteEnterpriseBlacklist: (id: string) => request(`/admin/dictionaries/blacklists/enterprise/${id}`, { method: 'DELETE' }), @@ -1118,6 +1155,8 @@ export const clientApi = { request('/client/applications', { tenantId }), getApplicationCmppParams: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request(`/client/applications/${applicationId}/cmpp-params`, { tenantId }), + listApplicationReportFields: (applicationId: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/applications/${applicationId}/report-fields`, { tenantId }), listSignatures: (tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => request('/client/signatures', { tenantId }), createSignature: (body: { tenantId?: string; applicationId?: string; name: string; purpose?: string; drainageInfo?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => @@ -1128,6 +1167,12 @@ export const clientApi = { request(`/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>(`/client/signatures/${id}/materials`, { method: 'POST', tenantId, body: JSON.stringify(body) }), + createDrainageInfo: (signatureId: string, body: { siteName: string; url: string; remark?: string; reportValues?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/signatures/${signatureId}/drainage-infos`, { method: 'POST', tenantId, body: JSON.stringify(body) }), + updateDrainageInfo: (id: string, body: { siteName?: string; url?: string; remark?: string; reportValues?: Record }, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/client/drainage-infos/${id}`, { method: 'PUT', tenantId, body: JSON.stringify(body) }), + changeDrainageInfoStatus: (id: string, status: string, tenantId = getSessionTenantId() ?? DEFAULT_CLIENT_TENANT_ID) => + request(`/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(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) => diff --git a/src/apps/admin/AdminChannelReportPage.tsx b/src/apps/admin/AdminChannelReportPage.tsx index 05d2c2f..bfb00d3 100644 --- a/src/apps/admin/AdminChannelReportPage.tsx +++ b/src/apps/admin/AdminChannelReportPage.tsx @@ -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 || '报备状态保存失败')); } diff --git a/src/apps/admin/AdminDrainageAuditPage.tsx b/src/apps/admin/AdminDrainageAuditPage.tsx new file mode 100644 index 0000000..8342fa1 --- /dev/null +++ b/src/apps/admin/AdminDrainageAuditPage.tsx @@ -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 = { + 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; + 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 关闭} onClose={onClose} open size="xl" title="引流信息审核详情"> +
+
+
企业{item.tenant?.name ?? item.tenantId}
+
应用{item.application?.name ?? '-'}
+
所属签名{item.signature?.name ?? item.signatureId}
+
审核状态{meta.label}
+
站名称{item.siteName}
+
引流地址{item.url}
+
提交时间{formatDateTime(item.submittedAt)}
+
备注{item.remark || '-'}
+ {item.rejectReason ?
驳回原因{item.rejectReason}
: null} +
+
+ 通道动态报备资料 +
+ {Object.keys(values).length ? Object.entries(values).map(([key, value]) => { + const file = fileRef(value); + return
{key}{file ? : String(value ?? '-')}
; + }) : } +
+
+
+
; +} + +export function AdminDrainageAuditPage() { + const [items, setItems] = useState([]); + const [keyword, setKeyword] = useState(''); + const [status, setStatus] = useState('pending'); + const [detail, setDetail] = useState(); + const [rejectTarget, setRejectTarget] = useState(); + 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>>(() => [ + { key: 'siteName', title: '站名称/引流地址', render: (record) =>
{record.siteName}{record.url}
}, + { key: 'signature', title: '所属签名', render: (record) => record.signature?.name ?? record.signatureId }, + { key: 'tenant', title: '企业/应用', render: (record) =>
{record.tenant?.name ?? record.tenantId}{record.application?.name ?? '-'}
}, + { 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 {meta.label}; } }, + { key: 'actions', title: '操作', align: 'right', render: (record) =>
}, + ], []); + + return
+

引流信息审核

+ {error ?

{error}

: null} +
setKeyword(event.target.value)} placeholder="搜索企业、应用、签名、站点或网址" prefix={} value={keyword} />