From 55aa054005d07eef04891ce6ee0700ae629aee3f Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Mon, 10 Aug 2026 20:54:05 +0800 Subject: [PATCH] feat: add carrier-aware signature retirement alerts --- .../migration.sql | 203 +++++ api/prisma/schema.prisma | 146 ++++ api/src/app.module.ts | 2 + .../channels/channel-configuration.service.ts | 28 +- api/src/channels/channel-copy.service.ts | 1 + .../channels/channel-group-routing.service.ts | 2 +- api/src/channels/channel-reporting.service.ts | 59 +- api/src/channels/channels.contracts.ts | 4 +- api/src/channels/channels.helpers.spec.ts | 22 + api/src/channels/channels.helpers.ts | 30 +- api/src/channels/channels.service.spec.ts | 25 +- .../deletion-governance.service.ts | 9 +- .../channel-export.service.ts | 19 +- api/src/send-chain/send-chain.helpers.ts | 6 +- api/src/send-chain/send-chain.service.ts | 3 +- .../send-chain/send-gateway-submit.service.ts | 27 +- api/src/send-chain/send-submission.service.ts | 3 +- .../signature-retirement.contracts.ts | 58 ++ .../signature-retirement.controller.ts | 110 +++ .../signature-retirement.module.ts | 10 + .../signature-retirement.service.spec.ts | 152 ++++ .../signature-retirement.service.ts | 738 ++++++++++++++++++ api/src/sms-config/signature.service.ts | 21 +- .../first-version-development-requirements.md | 32 +- docs/signature-retirement-alert-design.md | 190 +++++ docs/system-functional-test-cases.md | 61 +- docs/testing-progress.md | 53 ++ docs/ui-design-guidelines.md | 1 + src/api/admin/channels-reports.api.ts | 4 +- src/api/admin/signature-retirement.api.ts | 41 + src/api/adminApi.ts | 2 + src/api/types/channels-reports.ts | 6 +- src/api/types/identity-config.ts | 2 +- src/api/types/index.ts | 1 + src/api/types/signature-retirement.ts | 108 +++ src/apps/admin/AdminAnalyticsPage.tsx | 270 +++++-- src/apps/admin/AdminChannelGroupFormPage.tsx | 10 +- src/apps/admin/AdminChannelReportPage.tsx | 6 +- src/apps/admin/AdminReportRecordsPage.tsx | 2 +- src/apps/admin/AdminReportTasksPage.tsx | 7 +- .../admin/AdminSignatureRetirementPage.tsx | 291 +++++++ src/apps/admin/channels/ChannelFormModal.tsx | 23 +- src/apps/admin/channels/ChannelTable.tsx | 2 +- src/apps/admin/channels/channelModel.ts | 16 +- src/apps/admin/channels/channelTypes.ts | 2 + .../SignatureReportModals.tsx | 8 +- src/layouts/AdminLayout.tsx | 15 +- src/layouts/AppShell.tsx | 16 +- src/routes/AppRoutes.tsx | 2 + src/styles/global.css | 170 ++++ tools/local/replay-signature-retirement.mjs | 57 ++ tools/local/seed-signature-retirement.mjs | 150 ++++ 52 files changed, 3074 insertions(+), 152 deletions(-) create mode 100644 api/prisma/migrations/20260810143000_add_signature_retirement_alerts/migration.sql create mode 100644 api/src/channels/channels.helpers.spec.ts create mode 100644 api/src/signature-retirement/signature-retirement.contracts.ts create mode 100644 api/src/signature-retirement/signature-retirement.controller.ts create mode 100644 api/src/signature-retirement/signature-retirement.module.ts create mode 100644 api/src/signature-retirement/signature-retirement.service.spec.ts create mode 100644 api/src/signature-retirement/signature-retirement.service.ts create mode 100644 docs/signature-retirement-alert-design.md create mode 100644 src/api/admin/signature-retirement.api.ts create mode 100644 src/api/types/signature-retirement.ts create mode 100644 src/apps/admin/AdminSignatureRetirementPage.tsx create mode 100644 tools/local/replay-signature-retirement.mjs create mode 100644 tools/local/seed-signature-retirement.mjs diff --git a/api/prisma/migrations/20260810143000_add_signature_retirement_alerts/migration.sql b/api/prisma/migrations/20260810143000_add_signature_retirement_alerts/migration.sql new file mode 100644 index 0000000..522cd9c --- /dev/null +++ b/api/prisma/migrations/20260810143000_add_signature_retirement_alerts/migration.sql @@ -0,0 +1,203 @@ +ALTER TABLE "SmsChannel" +ADD COLUMN "carriers" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[]; + +UPDATE "SmsChannel" +SET "carriers" = CASE + WHEN "carrier" = 'mobile' THEN ARRAY['mobile']::TEXT[] + WHEN "carrier" = 'unicom' THEN ARRAY['unicom']::TEXT[] + WHEN "carrier" = 'telecom' THEN ARRAY['telecom']::TEXT[] + WHEN "carrier" = 'all' THEN ARRAY['mobile', 'unicom', 'telecom']::TEXT[] + -- 旧页面和旧发送链对空/未知carrier一直按移动处理,迁移保持原业务语义且保证至少一项。 + ELSE ARRAY['mobile']::TEXT[] +END; + +ALTER TABLE "SmsChannel" +ADD CONSTRAINT "SmsChannel_carriers_supported_check" +CHECK ( + cardinality("carriers") BETWEEN 1 AND 3 + AND "carriers" <@ ARRAY['mobile', 'unicom', 'telecom']::TEXT[] +); + +ALTER TABLE "ChannelSignatureReportTask" +ADD COLUMN "carrier" TEXT, +ADD COLUMN "approvedAt" TIMESTAMP(3), +ADD COLUMN "approvalScope" TEXT NOT NULL DEFAULT 'legacy_channel'; + +-- The current approved timestamp is reconstructed from the latest transition +-- into approved. updatedAt is deliberately not used because unrelated edits +-- can change it and would incorrectly restart the grace period. +UPDATE "ChannelSignatureReportTask" task +SET "approvedAt" = approved_record."approvedAt" +FROM ( + SELECT "taskId", MAX("createdAt") AS "approvedAt" + FROM "ChannelSignatureReportRecord" + WHERE "statusAfter" = 'approved' + GROUP BY "taskId" +) approved_record +WHERE task.id = approved_record."taskId" + AND task.status = 'approved'; + +CREATE INDEX "ChannelSignatureReportTask_signatureId_channelId_carrier_idx" +ON "ChannelSignatureReportTask"("signatureId", "channelId", "carrier"); + +-- 旧索引把运营商排除在唯一维度外,会阻止同一签名/通道建立多运营商事实。 +-- 拆成三类条件索引,在升级维度的同时继续保护历史任务和引流任务不重复。 +DROP INDEX IF EXISTS "ChannelSignatureReportTask_target_key"; + +CREATE UNIQUE INDEX "ChannelSignatureReportTask_signature_channel_carrier_key" +ON "ChannelSignatureReportTask"("signatureId", "channelId", "carrier") +WHERE "reportType" = 'signature' AND "carrier" IS NOT NULL AND "drainageItemId" IS NULL; + +CREATE UNIQUE INDEX "ChannelSignatureReportTask_legacy_signature_channel_key" +ON "ChannelSignatureReportTask"("signatureId", "channelId") +WHERE "reportType" = 'signature' AND "carrier" IS NULL AND "drainageItemId" IS NULL; + +CREATE UNIQUE INDEX "ChannelSignatureReportTask_drainage_target_key" +ON "ChannelSignatureReportTask"("signatureId", "drainageItemId", "channelId") +WHERE "reportType" = 'drainage' AND "drainageItemId" IS NOT NULL; + +CREATE TABLE "SignatureRetirementRule" ( + "id" TEXT NOT NULL, + "ruleType" TEXT NOT NULL, + "targetId" TEXT, + "targetKey" TEXT NOT NULL DEFAULT '', + "enabled" BOOLEAN NOT NULL DEFAULT true, + "mobileWindowDays" INTEGER NOT NULL DEFAULT 30, + "mobileThreshold" INTEGER NOT NULL DEFAULT 1, + "unicomWindowDays" INTEGER NOT NULL DEFAULT 30, + "unicomThreshold" INTEGER NOT NULL DEFAULT 1, + "telecomWindowDays" INTEGER NOT NULL DEFAULT 30, + "telecomThreshold" INTEGER NOT NULL DEFAULT 1, + "messageTemplate" TEXT, + "version" INTEGER NOT NULL DEFAULT 1, + "createdById" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "SignatureRetirementRule_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "SignatureRetirementRule_ruleType_targetKey_key" ON "SignatureRetirementRule"("ruleType", "targetKey"); +CREATE INDEX "SignatureRetirementRule_ruleType_enabled_idx" ON "SignatureRetirementRule"("ruleType", "enabled"); + +CREATE TABLE "SignatureRetirementWebhook" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "platform" TEXT NOT NULL, + "urlEncrypted" TEXT NOT NULL, + "urlMasked" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'active', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "SignatureRetirementWebhook_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "SignatureRetirementWebhook_status_createdAt_idx" ON "SignatureRetirementWebhook"("status", "createdAt"); + +CREATE TABLE "SignatureRetirementCycle" ( + "id" TEXT NOT NULL, + "dimensionType" TEXT NOT NULL, + "signatureId" TEXT NOT NULL, + "channelId" TEXT, + "channelKey" TEXT NOT NULL DEFAULT '', + "carrier" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'open', + "startedOn" DATE NOT NULL, + "lastDetectedOn" DATE NOT NULL, + "resolvedOn" DATE, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "SignatureRetirementCycle_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "SignatureRetirementCycle_dimension_status_idx" ON "SignatureRetirementCycle"("dimensionType", "signatureId", "channelKey", "carrier", "status"); +CREATE INDEX "SignatureRetirementCycle_status_lastDetectedOn_idx" ON "SignatureRetirementCycle"("status", "lastDetectedOn"); +-- 同一监控维度只能存在一个开放周期,数据库约束用于兜住并发检测实例。 +CREATE UNIQUE INDEX "SignatureRetirementCycle_open_dimension_key" +ON "SignatureRetirementCycle"("dimensionType", "signatureId", "channelKey", "carrier") +WHERE "status" = 'open'; + +CREATE TABLE "SignatureRetirementDetection" ( + "id" TEXT NOT NULL, + "detectionDate" DATE NOT NULL, + "dimensionType" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "applicationId" TEXT, + "signatureId" TEXT NOT NULL, + "channelId" TEXT, + "channelKey" TEXT NOT NULL DEFAULT '', + "carrier" TEXT NOT NULL, + "windowDays" INTEGER NOT NULL, + "threshold" INTEGER NOT NULL, + "submittedAttempts" INTEGER NOT NULL DEFAULT 0, + "acceptedBusinessCount" INTEGER NOT NULL DEFAULT 0, + "deliveredBusinessCount" INTEGER NOT NULL DEFAULT 0, + "approvedAt" TIMESTAMP(3) NOT NULL, + "ruleId" TEXT, + "ruleVersion" INTEGER NOT NULL DEFAULT 1, + "status" TEXT NOT NULL, + "cycleId" TEXT, + "suppressed" BOOLEAN NOT NULL DEFAULT false, + "notificationTitle" TEXT, + "notificationContent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "SignatureRetirementDetection_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "SignatureRetirementDetection_dimension_key" ON "SignatureRetirementDetection"("detectionDate", "dimensionType", "signatureId", "channelKey", "carrier"); +CREATE INDEX "SignatureRetirementDetection_date_type_status_idx" ON "SignatureRetirementDetection"("detectionDate", "dimensionType", "status"); +CREATE INDEX "SignatureRetirementDetection_signature_carrier_date_idx" ON "SignatureRetirementDetection"("signatureId", "carrier", "detectionDate"); +CREATE INDEX "SignatureRetirementDetection_channel_carrier_date_idx" ON "SignatureRetirementDetection"("channelId", "carrier", "detectionDate"); + +CREATE TABLE "SignatureRetirementMessage" ( + "id" TEXT NOT NULL, + "detectionId" TEXT NOT NULL, + "cycleId" TEXT NOT NULL, + "tenantId" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "isRead" BOOLEAN NOT NULL DEFAULT false, + "suppressed" BOOLEAN NOT NULL DEFAULT false, + "readAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "SignatureRetirementMessage_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "SignatureRetirementMessage_detectionId_key" ON "SignatureRetirementMessage"("detectionId"); +CREATE INDEX "SignatureRetirementMessage_created_read_suppressed_idx" ON "SignatureRetirementMessage"("createdAt", "isRead", "suppressed"); +CREATE INDEX "SignatureRetirementMessage_tenant_createdAt_idx" ON "SignatureRetirementMessage"("tenantId", "createdAt"); + +CREATE TABLE "SignatureRetirementSuppression" ( + "id" TEXT NOT NULL, + "dimensionType" TEXT NOT NULL, + "signatureId" TEXT NOT NULL, + "channelId" TEXT, + "channelKey" TEXT NOT NULL DEFAULT '', + "carrier" TEXT NOT NULL, + "mode" TEXT NOT NULL, + "muteUntil" DATE, + "active" BOOLEAN NOT NULL DEFAULT true, + "reason" TEXT, + "operatorId" TEXT, + "cancelledAt" TIMESTAMP(3), + "cancelledById" TEXT, + "cancelReason" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "SignatureRetirementSuppression_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "SignatureRetirementSuppression_dimension_key" ON "SignatureRetirementSuppression"("dimensionType", "signatureId", "channelKey", "carrier"); +CREATE INDEX "SignatureRetirementSuppression_active_muteUntil_idx" ON "SignatureRetirementSuppression"("active", "muteUntil"); + +CREATE TABLE "SignatureRetirementWebhookDelivery" ( + "id" TEXT NOT NULL, + "detectionDate" DATE NOT NULL, + "webhookId" TEXT NOT NULL, + "groupKey" TEXT NOT NULL, + "payload" JSONB NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "attemptCount" INTEGER NOT NULL DEFAULT 0, + "nextRetryAt" TIMESTAMP(3), + "lastHttpStatus" INTEGER, + "lastError" TEXT, + "deliveredAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + CONSTRAINT "SignatureRetirementWebhookDelivery_pkey" PRIMARY KEY ("id") +); +CREATE UNIQUE INDEX "SignatureRetirementWebhookDelivery_key" ON "SignatureRetirementWebhookDelivery"("webhookId", "detectionDate", "groupKey"); +CREATE INDEX "SignatureRetirementWebhookDelivery_status_retry_idx" ON "SignatureRetirementWebhookDelivery"("status", "nextRetryAt"); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 393ea28..1137f85 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -806,6 +806,7 @@ model SmsChannel { code String @unique name String carrier String? + carriers String[] @default([]) sendRegion String @default("全国") protocol String @default("CMPP") gatewayHost String @@ -1059,6 +1060,9 @@ model ChannelSignatureReportTask { tenantId String signatureId String channelId String + carrier String? + approvedAt DateTime? + approvalScope String @default("legacy_channel") reportType String @default("signature") drainageItemId String? status String @default("pending") @@ -1078,10 +1082,152 @@ model ChannelSignatureReportTask { @@index([tenantId, status]) @@index([status, createdAt]) @@index([signatureId, channelId]) + @@index([signatureId, channelId, carrier]) @@index([signatureId, drainageItemId, channelId]) @@index([reportType, status]) } +model SignatureRetirementRule { + id String @id @default(cuid()) + ruleType String + targetId String? + targetKey String @default("") + enabled Boolean @default(true) + mobileWindowDays Int @default(30) + mobileThreshold Int @default(1) + unicomWindowDays Int @default(30) + unicomThreshold Int @default(1) + telecomWindowDays Int @default(30) + telecomThreshold Int @default(1) + messageTemplate String? + version Int @default(1) + createdById String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([ruleType, targetKey]) + @@index([ruleType, enabled]) +} + +model SignatureRetirementWebhook { + id String @id @default(cuid()) + name String + platform String + urlEncrypted String + urlMasked String + status String @default("active") + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([status, createdAt]) +} + +model SignatureRetirementCycle { + id String @id @default(cuid()) + dimensionType String + signatureId String + channelId String? + channelKey String @default("") + carrier String + status String @default("open") + startedOn DateTime @db.Date + lastDetectedOn DateTime @db.Date + resolvedOn DateTime? @db.Date + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([dimensionType, signatureId, channelKey, carrier, status]) + @@index([status, lastDetectedOn]) +} + +model SignatureRetirementDetection { + id String @id @default(cuid()) + detectionDate DateTime @db.Date + dimensionType String + tenantId String + applicationId String? + signatureId String + channelId String? + channelKey String @default("") + carrier String + windowDays Int + threshold Int + submittedAttempts Int @default(0) + acceptedBusinessCount Int @default(0) + deliveredBusinessCount Int @default(0) + approvedAt DateTime + ruleId String? + ruleVersion Int @default(1) + status String + cycleId String? + suppressed Boolean @default(false) + notificationTitle String? + notificationContent String? + createdAt DateTime @default(now()) + + @@unique([detectionDate, dimensionType, signatureId, channelKey, carrier]) + @@index([detectionDate, dimensionType, status]) + @@index([signatureId, carrier, detectionDate]) + @@index([channelId, carrier, detectionDate]) +} + +model SignatureRetirementMessage { + id String @id @default(cuid()) + detectionId String @unique + cycleId String + tenantId String + title String + content String + isRead Boolean @default(false) + suppressed Boolean @default(false) + readAt DateTime? + createdAt DateTime @default(now()) + + @@index([createdAt, isRead, suppressed]) + @@index([tenantId, createdAt]) +} + +model SignatureRetirementSuppression { + id String @id @default(cuid()) + dimensionType String + signatureId String + channelId String? + channelKey String @default("") + carrier String + mode String + muteUntil DateTime? @db.Date + active Boolean @default(true) + reason String? + operatorId String? + cancelledAt DateTime? + cancelledById String? + cancelReason String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([dimensionType, signatureId, channelKey, carrier]) + @@index([active, muteUntil]) +} + +model SignatureRetirementWebhookDelivery { + id String @id @default(cuid()) + detectionDate DateTime @db.Date + webhookId String + groupKey String + payload Json + status String @default("pending") + attemptCount Int @default(0) + nextRetryAt DateTime? + lastHttpStatus Int? + lastError String? + deliveredAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([webhookId, detectionDate, groupKey]) + @@index([status, nextRetryAt]) +} + model ChannelSignatureReportRecord { id String @id @default(cuid()) taskId String diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 4d65d58..e63bbcf 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -23,6 +23,7 @@ import { SendChainModule } from './send-chain/send-chain.module'; import { SmsConfigModule } from './sms-config/sms-config.module'; import { TenantsModule } from './tenants/tenants.module'; import { UsersModule } from './users/users.module'; +import { SignatureRetirementModule } from './signature-retirement/signature-retirement.module'; @Module({ imports: [ @@ -49,6 +50,7 @@ import { UsersModule } from './users/users.module'; SendChainModule, OperationsModule, OpenApiModule, + SignatureRetirementModule, ], controllers: [HealthController], providers: [RequestContextMiddleware, SessionValidationMiddleware, ManualOperationAuditMiddleware], diff --git a/api/src/channels/channel-configuration.service.ts b/api/src/channels/channel-configuration.service.ts index 5dc4d58..6f41e75 100644 --- a/api/src/channels/channel-configuration.service.ts +++ b/api/src/channels/channel-configuration.service.ts @@ -6,7 +6,7 @@ import { randomUUID } from 'crypto'; import { assertMoneyUnits, moneyToNumber } from '../common/money'; import { PrismaService } from '../prisma/prisma.service'; import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; -import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; +import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, legacyCarrierFromCapabilities, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; import { ChannelConnectionService } from './channel-connection.service'; /** R5 channel domain service composed behind ChannelsService. */ @@ -25,7 +25,7 @@ export class ChannelConfigurationService { const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10))); const where: Prisma.SmsChannelWhereInput = { status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' }, - carrier: query.carrier && query.carrier !== 'all' ? query.carrier : undefined, + carriers: query.carrier && query.carrier !== 'all' ? { has: normalizeBusinessCarrier(query.carrier) } : undefined, name: query.keyword?.trim() ? { contains: query.keyword.trim() } : undefined, }; const [items, total] = await Promise.all([ @@ -64,11 +64,13 @@ export class ChannelConfigurationService { data.heartbeatMissThreshold, ); const rateLimitPerSecond = normalizeChannelRateLimit(data.rateLimitPerSecond); + const carriers = normalizeChannelCarriers(data.carriers, data.carrier); const channel = await this.prisma.smsChannel.create({ data: { code: data.code, name: data.name, - carrier: data.carrier, + carrier: legacyCarrierFromCapabilities(carriers), + carriers, sendRegion: data.sendRegion ?? '全国', protocol: 'CMPP', gatewayHost: data.gatewayHost, @@ -120,6 +122,22 @@ export class ChannelConfigurationService { const rateLimitPerSecond = data.rateLimitPerSecond === undefined ? undefined : normalizeChannelRateLimit(data.rateLimitPerSecond); + const existingCarriers = normalizeChannelCarriers(channel.carriers, channel.carrier); + const carriers = data.carriers !== undefined || data.carrier !== undefined + ? normalizeChannelCarriers(data.carriers, data.carrier) + : existingCarriers; + if (data.carriers !== undefined || data.carrier !== undefined) { + const removed = existingCarriers.filter((carrier) => !carriers.includes(carrier)); + if (removed.length) { + const blockingGroups = await this.prisma.smsChannelGroupItem.findMany({ + where: { channelId, group: { status: 'active', carrier: { in: removed } } }, + include: { group: true }, + }); + if (blockingGroups.length) { + throw new BadRequestException(`请先解除以下活动通道组引用:${blockingGroups.map((item) => item.group.name).join('、')}`); + } + } + } const connectionConfigChanged = channelConnectionSettingsChanged(channel, { gatewayHost: data.gatewayHost ?? channel.gatewayHost, gatewayPort: gatewayPort ?? channel.gatewayPort, @@ -133,7 +151,8 @@ export class ChannelConfigurationService { data: { code: data.code, name: data.name, - carrier: data.carrier, + carrier: data.carriers !== undefined || data.carrier !== undefined ? legacyCarrierFromCapabilities(carriers) : undefined, + carriers: data.carriers !== undefined || data.carrier !== undefined ? carriers : undefined, sendRegion: data.sendRegion, protocol: 'CMPP', gatewayHost: data.gatewayHost, @@ -159,6 +178,7 @@ export class ChannelConfigurationService { code: channel.code, name: channel.name, carrier: channel.carrier, + carriers: channel.carriers, sendRegion: channel.sendRegion, gatewayHost: channel.gatewayHost, gatewayPort: channel.gatewayPort, diff --git a/api/src/channels/channel-copy.service.ts b/api/src/channels/channel-copy.service.ts index d9a06cc..54e864d 100644 --- a/api/src/channels/channel-copy.service.ts +++ b/api/src/channels/channel-copy.service.ts @@ -32,6 +32,7 @@ export class ChannelCopyService { code: nextCode, name: nextName, carrier: source.carrier, + carriers: source.carriers, protocol: source.protocol, gatewayHost: source.gatewayHost, gatewayPort: source.gatewayPort, diff --git a/api/src/channels/channel-group-routing.service.ts b/api/src/channels/channel-group-routing.service.ts index 18cde63..964a469 100644 --- a/api/src/channels/channel-group-routing.service.ts +++ b/api/src/channels/channel-group-routing.service.ts @@ -52,7 +52,7 @@ export class ChannelGroupRoutingService { if (!channel) { throw new NotFoundException('Channel not found'); } - if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) { + if (!isChannelCarrierCompatible(channel.carrier, groupCarrier, channel.carriers)) { throw new BadRequestException('Channel carrier is not compatible with the channel group carrier'); } if (data.province && !isRegionCompatible(channel.sendRegion, data.province)) { diff --git a/api/src/channels/channel-reporting.service.ts b/api/src/channels/channel-reporting.service.ts index 578f015..a2d4b9a 100644 --- a/api/src/channels/channel-reporting.service.ts +++ b/api/src/channels/channel-reporting.service.ts @@ -6,7 +6,7 @@ import { randomUUID } from 'crypto'; import { assertMoneyUnits, moneyToNumber } from '../common/money'; import { PrismaService } from '../prisma/prisma.service'; import type { CreateChannelDto, UpdateChannelDto, CreateChannelGroupDto, CreateChannelGroupItemDto, UpdateChannelGroupDto, CreateRouteRuleDto, CreateReportFieldDto, ReplaceReportFieldsDto, CreateReportMaterialDto, CreateReportTaskDto, ChangeReportTaskStatusesDto, CreateReportExportDto, CreateReceiptImportDto, UpsertConnectionStateDto, ChangeChannelStatusDto, CopyChannelDto, TestChannelDto } from './channels.contracts'; -import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; +import { GATEWAY_CONNECTION_QUEUE, GATEWAY_SUBMIT_QUEUE, GATEWAY_SUBMIT_STREAM, DEFAULT_GATEWAY_CONTROL_URL, DEFAULT_CHANNEL_CONNECTION_ID, DEFAULT_CONNECTING_TIMEOUT_MS, DEFAULT_CONNECTING_TIMEOUT_SCAN_MS, DEFAULT_GATEWAY_STARTUP_RECONNECT_DELAY_MS, DEFAULT_GATEWAY_RECONCILE_INTERVAL_MS, DEFAULT_GATEWAY_CONTROL_TIMEOUT_MS, DEFAULT_HEARTBEAT_INTERVAL_SECONDS, DEFAULT_HEARTBEAT_MISS_THRESHOLD, HEARTBEAT_AUDIT_INTERVAL_MS, CONNECTING_TIMEOUT_ERROR, DEFAULT_CMPP_VERSION, normalizeTestPhones, normalizeTestContent, calculateBillingUnits, buildChannelTestSubmitCommand, getConfigValue, getStringConfigValue, normalizeConnectionAction, normalizeCmppVersion, normalizeGatewayConnectionStatus, defaultChannelConnectionId, getDesiredConnections, ChannelConnectionSettings, getRuntimeConfigInteger, channelConnectionSettingsChanged, channelGroupAuditSnapshot, normalizeChannelRuntimeConfig, normalizeCmppServiceId, normalizeChannelRateLimit, normalizeExtensionDigits, getPositiveRuntimeInteger, bullmqConnection, getPositiveIntegerEnv, parseReceiptContent, splitReceiptLine, stripReceiptCell, findReceiptStatusIndex, normalizeReceiptStatus, deriveReceiptStatus, ChannelReportDeliveryRow, summarizeChannelReportDelivery, sumReportDelivery, percentage, latestDate, currentShanghaiDayRange, normalizeRetryTimeLimitMinutes, normalizeSpreadsheetSize, normalizeBusinessCarrier, normalizeChannelCarrier, normalizeChannelCarriers, isChannelCarrierCompatible, normalizeRegion, isRegionCompatible, validateGroupItems, normalizeReportType, summarizeReportStatuses, normalizeLinkEvent } from './channels.helpers'; /** R5 channel domain service composed behind ChannelsService. */ @@ -325,11 +325,24 @@ export class ChannelReportingService { if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备'); throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成'); } + const channel = await this.prisma.smsChannel.findUnique({ where: { id: data.channelId } }); + if (!channel) throw new NotFoundException('Channel not found'); + if (!data.carrier) throw new BadRequestException('签名报备任务必须指定运营商'); + const carrier = normalizeBusinessCarrier(data.carrier); + if (!normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) { + throw new BadRequestException('报备运营商不在通道支持范围内'); + } + const existing = await this.prisma.channelSignatureReportTask.findFirst({ + where: { signatureId: data.signatureId, channelId: data.channelId, carrier, reportType: 'signature', drainageItemId: null }, + }); + if (existing) throw new BadRequestException('该签名在当前通道和运营商下已存在报备任务'); const task = await this.prisma.channelSignatureReportTask.create({ data: { tenantId: data.tenantId, signatureId: data.signatureId, channelId: data.channelId, + carrier, + approvalScope: 'carrier_specific', reportType, drainageItemId: undefined, createdById: data.createdById, @@ -364,11 +377,25 @@ export class ChannelReportingService { 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 } }); + const carrier = reportType === 'signature' && item.carrier ? normalizeBusinessCarrier(item.carrier) : null; + if (carrier && !normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)) { + throw new BadRequestException('报备运营商不在通道支持范围内'); + } + const existing = await tx.channelSignatureReportTask.findFirst({ where: { + signatureId: item.signatureId, + channelId: item.channelId, + reportType, + drainageItemId: reportType === 'drainage' ? item.drainageItemId : null, + carrier: reportType === 'signature' ? carrier : null, + } }); if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核'); + if (reportType === 'signature' && !carrier && !existing) throw new BadRequestException('签名报备状态必须指定运营商'); + const approvedAt = item.status === 'approved' + ? existing?.status === 'approved' ? existing.approvedAt ?? new Date() : new Date() + : null; 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.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason, ...(reportType === 'signature' ? { approvedAt } : {}) } }) + : await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, carrier, approvalScope: 'carrier_specific', approvedAt, 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, sourceEntry } }); if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status }); } @@ -389,13 +416,21 @@ export class ChannelReportingService { const tasks = await tx.channelSignatureReportTask.findMany({ where: { signatureId, reportType: 'signature' }, include: { channel: true } }); const channels = configuredChannels.length ? configuredChannels : tasks.map((task) => task.channel); const uniqueChannels = [...new Map(channels.map((channel) => [channel.id, channel])).values()]; - const taskByChannel = new Map(tasks.map((task) => [task.channelId, task])); const carrierReportSummary = Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { - const targets = uniqueChannels.filter((channel) => channel.carrier === carrier || channel.carrier === 'all'); - const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending'); + const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)); + const statuses = targets.map((channel) => { + const task = tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier) + ?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel'); + return task?.status ?? 'pending'; + }); return [carrier, summarizeReportStatuses(statuses)]; })); - const allStatuses = uniqueChannels.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending'); + const allStatuses = ['mobile', 'unicom', 'telecom'].flatMap((carrier) => { + const targets = uniqueChannels.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)); + return targets.map((channel) => tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier)?.status + ?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel')?.status + ?? 'pending'); + }); const reportStatus = summarizeReportStatuses(allStatuses).status; await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } }); return { signatureId, reportStatus, carrierReportSummary }; @@ -514,7 +549,13 @@ export class ChannelReportingService { ) { await this.prisma.channelSignatureReportTask.update({ where: { id: taskId }, - data: { status: statusAfter, reason }, + data: { + status: statusAfter, + reason, + ...((await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, select: { reportType: true, status: true, approvedAt: true } }))?.reportType === 'signature' + ? { approvedAt: statusAfter === 'approved' ? statusBefore === 'approved' ? undefined : new Date() : null } + : {}), + }, }); await this.recordReportTask(taskId, channelId, action, statusBefore, statusAfter, reason); } diff --git a/api/src/channels/channels.contracts.ts b/api/src/channels/channels.contracts.ts index 65f0368..c3a9b52 100644 --- a/api/src/channels/channels.contracts.ts +++ b/api/src/channels/channels.contracts.ts @@ -4,6 +4,7 @@ export interface CreateChannelDto { code: string; name: string; carrier?: string; + carriers?: string[]; sendRegion?: string; protocol?: string; gatewayHost: string; @@ -104,13 +105,14 @@ export interface CreateReportTaskDto { tenantId: string; signatureId: string; channelId: string; + carrier?: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string; } export interface ChangeReportTaskStatusesDto { - items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; + items: Array<{ signatureId: string; channelId: string; carrier?: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>; reason?: string; operatorId?: string; sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report'; diff --git a/api/src/channels/channels.helpers.spec.ts b/api/src/channels/channels.helpers.spec.ts new file mode 100644 index 0000000..3e84527 --- /dev/null +++ b/api/src/channels/channels.helpers.spec.ts @@ -0,0 +1,22 @@ +import { + isChannelCarrierCompatible, + legacyCarrierFromCapabilities, + normalizeChannelCarriers, +} from './channels.helpers'; + +describe('channel carrier capabilities', () => { + it('preserves the legacy default when an old caller omits carrier fields', () => { + expect(normalizeChannelCarriers()).toEqual(['mobile']); + }); + + it('expands a historical three-network channel without inventing data for partial capabilities', () => { + expect(normalizeChannelCarriers(undefined, 'all')).toEqual(['mobile', 'unicom', 'telecom']); + expect(normalizeChannelCarriers(['telecom', 'mobile'], 'all')).toEqual(['mobile', 'telecom']); + expect(legacyCarrierFromCapabilities(['mobile', 'telecom'])).toBe('multi'); + }); + + it('checks a group carrier against the new multi-select capability list', () => { + expect(isChannelCarrierCompatible('multi', 'mobile', ['mobile', 'telecom'])).toBe(true); + expect(isChannelCarrierCompatible('multi', 'unicom', ['mobile', 'telecom'])).toBe(false); + }); +}); diff --git a/api/src/channels/channels.helpers.ts b/api/src/channels/channels.helpers.ts index 8050df7..c57e348 100644 --- a/api/src/channels/channels.helpers.ts +++ b/api/src/channels/channels.helpers.ts @@ -598,9 +598,29 @@ export function normalizeChannelCarrier(carrier?: string | null) { return value; } -export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string) { - const normalized = normalizeChannelCarrier(channelCarrier); - return normalized === 'all' || normalized === groupCarrier; +export const SUPPORTED_CHANNEL_CARRIERS = ['mobile', 'unicom', 'telecom'] as const; + +export function normalizeChannelCarriers(carriers?: string[] | null, legacyCarrier?: string | null): string[] { + const source = carriers?.length + ? carriers + : normalizeChannelCarrier(legacyCarrier ?? 'mobile') === 'all' + ? [...SUPPORTED_CHANNEL_CARRIERS] + : [normalizeChannelCarrier(legacyCarrier ?? 'mobile')]; + const normalized = [...new Set(source.map((carrier) => normalizeBusinessCarrier(carrier)))]; + if (normalized.length === 0) throw new BadRequestException('至少选择一个运营商'); + return SUPPORTED_CHANNEL_CARRIERS.filter((carrier) => normalized.includes(carrier)); +} + +export function legacyCarrierFromCapabilities(carriers: string[]) { + if (carriers.length === 1) return carriers[0]; + if (carriers.length === SUPPORTED_CHANNEL_CARRIERS.length) return 'all'; + // Old readers must fail closed for a two-carrier channel instead of treating + // it as three-network capable and accidentally routing unsupported traffic. + return 'multi'; +} + +export function isChannelCarrierCompatible(channelCarrier: string | null | undefined, groupCarrier: string, carriers?: string[] | null) { + return normalizeChannelCarriers(carriers, channelCarrier).includes(normalizeBusinessCarrier(groupCarrier)); } export function normalizeRegion(region?: string | null) { @@ -614,7 +634,7 @@ export function isRegionCompatible(channelRegion: string | null | undefined, ite export function validateGroupItems( groupCarrier: string, items: Array>, - channels: Map, + channels: Map, ) { const channelIds = new Set(); const provinces = new Set(); @@ -632,7 +652,7 @@ export function validateGroupItems( throw new BadRequestException('通道组内不能重复配置同一通道'); } channelIds.add(item.channelId); - if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) { + if (!isChannelCarrierCompatible(channel.carrier, groupCarrier, channel.carriers)) { throw new BadRequestException('Channel carrier is not compatible with the channel group carrier'); } if (item.province) { diff --git a/api/src/channels/channels.service.spec.ts b/api/src/channels/channels.service.spec.ts index f0bf95d..2762278 100644 --- a/api/src/channels/channels.service.spec.ts +++ b/api/src/channels/channels.service.spec.ts @@ -28,12 +28,13 @@ jest.mock('ioredis', () => jest.fn().mockImplementation(() => ({ }))); function createPrismaMock() { - const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'pending' }; + const reportTask = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', reportType: 'signature', status: 'pending' }; const channel = { id: 'channel-1', code: 'CMPP-A', name: '主通道', carrier: 'mobile', + carriers: ['mobile'], protocol: 'CMPP', gatewayHost: '127.0.0.1', gatewayPort: 17890, @@ -88,6 +89,7 @@ function createPrismaMock() { smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn(), + findMany: jest.fn().mockResolvedValue([]), findFirst: jest.fn().mockResolvedValue(null), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })), }, @@ -115,6 +117,7 @@ function createPrismaMock() { channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([{ ...reportTask, status: 'partial', channel }]), create: jest.fn().mockResolvedValue(reportTask), + findFirst: jest.fn().mockResolvedValue(null), findUnique: jest.fn().mockResolvedValue(reportTask), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })), }, @@ -311,13 +314,13 @@ describe('ChannelsService', () => { findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1' }), update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }), }, - smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) }, + smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', carriers: ['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' }), + findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'reporting' }), + update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved' }), create: jest.fn(), - findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }]), + findMany: jest.fn().mockResolvedValue([{ id: 'task-1', channelId: 'channel-1', carrier: 'mobile', approvalScope: 'carrier_specific', status: 'approved', channel: { id: 'channel-1', carrier: 'mobile', carriers: ['mobile'], status: 'active' } }]), }, channelSignatureReportRecord: { create: jest.fn().mockResolvedValue({ id: 'record-1' }) }, channelRouteRule: { findMany: jest.fn().mockResolvedValue([{ group: { items: [{ channel: { id: 'channel-1', carrier: 'mobile', status: 'active' } }] } }]) }, @@ -325,7 +328,7 @@ 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: '运营商确认', sourceEntry: 'enterprise_signature' })).resolves.toEqual([ + await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', 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', sourceEntry: 'enterprise_signature' }) }); @@ -355,7 +358,7 @@ describe('ChannelsService', () => { items: [{ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'approved' }], reason: '引流信息已报备', })).resolves.toEqual([{ signatureId: 'sig-1', reportType: 'drainage', drainageItemId: 'drain-1', channelId: 'channel-1', status: 'approved' }]); - expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1' } }); + expect(tx.channelSignatureReportTask.findFirst).toHaveBeenCalledWith({ where: { signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', carrier: null } }); expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) }); expect(tx.smsSignature.update).not.toHaveBeenCalled(); }); @@ -947,7 +950,7 @@ describe('ChannelsService', () => { const prisma = createPrismaMock(); const service = new ChannelsService(prisma as never); - await service.createReportTask({ tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', createdById: 'user-1' }); + await service.createReportTask({ tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', carrier: 'mobile', createdById: 'user-1' }); await service.createReportExport('report-task-1', { fileName: 'export.csv', rowCount: 10 }); await service.importReportReceipt('report-task-1', { fileName: 'receipt.csv', @@ -962,11 +965,11 @@ describe('ChannelsService', () => { }); expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({ where: { id: 'report-task-1' }, - data: { status: 'exporting', reason: undefined }, + data: expect.objectContaining({ status: 'exporting', reason: undefined, approvedAt: null }), }); expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({ where: { id: 'report-task-1' }, - data: { status: 'partial', reason: 'one rejected' }, + data: expect.objectContaining({ status: 'partial', reason: 'one rejected', approvedAt: null }), }); expect(prisma.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, @@ -997,7 +1000,7 @@ describe('ChannelsService', () => { }); expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({ where: { id: 'report-task-1' }, - data: { status: 'partial', reason: 'carrier receipt' }, + data: expect.objectContaining({ status: 'partial', reason: 'carrier receipt', approvedAt: null }), }); expect(prisma.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, diff --git a/api/src/deletion-governance/deletion-governance.service.ts b/api/src/deletion-governance/deletion-governance.service.ts index b25aa1b..26f066d 100644 --- a/api/src/deletion-governance/deletion-governance.service.ts +++ b/api/src/deletion-governance/deletion-governance.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } import { Prisma } from '@prisma/client'; import { summarizeReportStatuses } from '../common/report-status'; import { PrismaService } from '../prisma/prisma.service'; +import { normalizeChannelCarriers } from '../channels/channels.helpers'; export type DeletionTargetType = 'channel' | 'signature' | 'template'; export type DeletionResolutionAction = 'delete_associated_templates' | 'delete_associated_drainage' | 'abandon_associated_report_tasks'; @@ -334,8 +335,12 @@ export class DeletionGovernanceService { const configuredChannels = routes.flatMap((route) => route.group.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted'); const fallbackChannels = tasks.map((task) => task.channel).filter((channel) => channel.status !== 'deleted'); const uniqueChannels = [...new Map((configuredChannels.length ? configuredChannels : fallbackChannels).map((channel) => [channel.id, channel])).values()]; - const taskByChannel = new Map(tasks.map((task) => [task.channelId, task])); - const reportStatus = summarizeReportStatuses(uniqueChannels.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending')).status; + const statuses = uniqueChannels.flatMap((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => ( + tasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status + ?? tasks.find((task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel')?.status + ?? 'pending' + ))); + const reportStatus = summarizeReportStatuses(statuses).status; await tx.smsSignature.update({ where: { id: signatureId }, data: { reportStatus } }); } diff --git a/api/src/report-materials/channel-export.service.ts b/api/src/report-materials/channel-export.service.ts index 6f9db37..2451230 100644 --- a/api/src/report-materials/channel-export.service.ts +++ b/api/src/report-materials/channel-export.service.ts @@ -9,6 +9,7 @@ import { SmsConfigService } from '../sms-config/sms-config.service'; import type { AnalyzeImportOptions, CreateImportProfileDto, CreateReportBatchDto, EmbeddedImage, ImportCommitDto, ImportMapping, PagedQuery, ReportBatchInspection, ReportBatchTarget, ReviewImportItemsDto } from './report-materials.contracts'; import { profileData, validateProfile, loadWorkbook, assertSafeWorkbook, safeSpreadsheetText, readEmbeddedImages, suggestMappings, remapProfileColumns, signatureCoreMapping, drainageCoreMapping, normalizeHeader, normalizeFieldCode, clamp, normalizePage, normalizePageSize, dateRange, cellText, transformValue, mappedCoreValue, dynamicValues, jsonRecord, hasValue, isFileRef, resolveExportValue, applyExportTransform, styleHeader, normalizeImageExtension, imageContentType, safeFileName, normalizeBatchIdempotencyKey, jsonStringArray, jsonSafe } from './report-materials.helpers'; import type { ReportBatchGenerationService } from './batch-generation.service'; +import { normalizeChannelCarriers } from '../channels/channels.helpers'; /** R4 report-materials domain service composed behind ReportMaterialsService. */ export class ReportChannelExportService { @@ -32,13 +33,19 @@ export class ReportChannelExportService { const values = fields.map((field) => resolveExportValue(item.snapshot, field.code, field.name) ?? field.defaultValue ?? ''); const missing = fields.filter((field, index) => field.required && !hasValue(values[index])); const missingReason = fields.length === 0 ? '通道未配置当前资料类型的报备字段' : missing.length ? `缺少字段:${missing.map((field) => field.exportName || field.name).join('、')}` : null; - const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null } }); - const task = existingTask - ? await this.prisma.channelSignatureReportTask.update({ where: { id: existingTask.id }, data: { status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } }) - : await this.prisma.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } }); + const reportCarriers = reportType === 'signature' ? normalizeChannelCarriers(channel.carriers, channel.carrier) : [null]; + const tasks: Array<{ task: { id: string; reason: string | null }; existingTask: { status: string } | null }> = []; + for (const carrier of reportCarriers) { + const existingTask = await this.prisma.channelSignatureReportTask.findFirst({ where: { signatureId: item.signature.id, channelId, carrier, reportType, drainageItemId: reportType === 'drainage' ? item.drainageInfo!.id : null } }); + const task = existingTask + ? await this.prisma.channelSignatureReportTask.update({ where: { id: existingTask.id }, data: { status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason, ...(reportType === 'signature' ? { approvedAt: null } : {}) } }) + : await this.prisma.channelSignatureReportTask.create({ data: { tenantId: item.signature.tenantId, signatureId: item.signature.id, channelId, carrier, approvalScope: reportType === 'signature' ? 'carrier_specific' : 'legacy_channel', reportType, drainageItemId: item.drainageInfo?.id, status: missingReason ? 'waiting_material' : 'exporting', reason: missingReason } }); + tasks.push({ task, existingTask }); + } + const task = tasks[0].task; if (missingReason) { incompleteBatchItemIds.push(item.batchItem.id); - await this.recordTask(task.id, channelId, existingTask?.status, 'waiting_material', task.reason ?? undefined); + for (const entry of tasks) await this.recordTask(entry.task.id, channelId, entry.existingTask?.status, 'waiting_material', task.reason ?? undefined); continue; } const row = sheet.addRow(values.map((value, index) => isFileRef(value) ? value.fileName : applyExportTransform(value, fields[index]?.transform))); @@ -58,7 +65,7 @@ export class ReportChannelExportService { } row.height = targetHeight; fileRows.push({ item, taskId: task.id, rowNumber: row.number }); - await this.recordTask(task.id, channelId, existingTask?.status, 'exporting'); + for (const entry of tasks) await this.recordTask(entry.task.id, channelId, entry.existingTask?.status, 'exporting'); } } if (workbook.worksheets.every((sheet) => sheet.rowCount <= 1)) { diff --git a/api/src/send-chain/send-chain.helpers.ts b/api/src/send-chain/send-chain.helpers.ts index 3915283..a55b278 100644 --- a/api/src/send-chain/send-chain.helpers.ts +++ b/api/src/send-chain/send-chain.helpers.ts @@ -226,7 +226,8 @@ export function getNonNegativeConfigInteger(config: unknown, key: string, fallba return fallback; } -export function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string) { +export function isCarrierCompatible(channelCarrier: string | null | undefined, targetCarrier: string, channelCarriers?: string[] | null) { + if (channelCarriers?.length) return channelCarriers.map(normalizeCarrier).includes(normalizeCarrier(targetCarrier)); const normalized = normalizeCarrier(channelCarrier); return normalized === 'all' || normalized === targetCarrier; } @@ -448,6 +449,7 @@ export type ChannelCandidate = { province?: string | null; channel: { carrier?: string | null; + carriers?: string[] | null; sendRegion?: string | null; status: string; connectionStates?: Array<{ status: string; currentConnections: number; desiredConnections: number }>; @@ -481,7 +483,7 @@ export function selectChannelCandidate( !options.excludedChannelIds.has(item.channelId) && options.approvedChannelIds.has(item.channelId) && normalizeCarrier(item.carrier) === options.carrier - && isCarrierCompatible(item.channel.carrier, options.carrier), + && isCarrierCompatible(item.channel.carrier, options.carrier, item.channel.carriers), ); const provinceCandidates = options.forceNational ? [] diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 39ebbea..7a9c39c 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -770,8 +770,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy { signature?: { id?: string | null; name?: string | null } | null; }, channelId: string, + carrier: string, ) { - return this.submission.ensureSignatureReportedForChannel(message, channelId); + return this.submission.ensureSignatureReportedForChannel(message, channelId, carrier); } private async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) { diff --git a/api/src/send-chain/send-gateway-submit.service.ts b/api/src/send-chain/send-gateway-submit.service.ts index f3bf62b..948d9d3 100644 --- a/api/src/send-chain/send-gateway-submit.service.ts +++ b/api/src/send-chain/send-gateway-submit.service.ts @@ -162,7 +162,7 @@ async submitMessageToGateway( ) { const channel = routed.channel; const upstreamSrcId = composeUpstreamSrcId(channel.srcId, message.applicationExtension); - await this.facade.ensureSignatureReportedForChannel(message, channel.id); + await this.facade.ensureSignatureReportedForChannel(message, channel.id, routed.carrier); await this.facade.waitForChannelRateLimit(channel.id, channel.rateLimitPerSecond); const submitId = `SUB-${randomUUID()}`; try { @@ -321,7 +321,13 @@ async selectChannelForMessage( const signatureId = await this.facade.resolveMessageSignatureId(message); if (!signatureId) throw new BadRequestException('短信签名未配置,无法选择已报备通道'); const approvedTasks = await this.prisma.channelSignatureReportTask.findMany({ - where: { signatureId, reportType: 'signature', status: 'approved', channelId: { in: route.group.items.map((item) => item.channelId) } }, + where: { + signatureId, + reportType: 'signature', + status: 'approved', + channelId: { in: route.group.items.map((item) => item.channelId) }, + OR: signatureReportApprovalScopes(carrier), + }, select: { channelId: true }, }); const approvedChannelIds = new Set(approvedTasks.map((task) => task.channelId)); @@ -386,13 +392,20 @@ async ensureSignatureReportedForChannel( signature?: { id?: string | null; name?: string | null } | null; }, channelId: string, + carrier: string, ) { const signatureId = await this.facade.resolveMessageSignatureId(message); if (!signatureId) { throw new BadRequestException('短信签名未配置,不能提交到通道'); } const reportTask = await this.prisma.channelSignatureReportTask.findFirst({ - where: { signatureId, channelId, reportType: 'signature', status: 'approved' }, + where: { + signatureId, + channelId, + reportType: 'signature', + status: 'approved', + OR: signatureReportApprovalScopes(carrier), + }, select: { id: true }, }); if (!reportTask) { @@ -489,3 +502,11 @@ return streamId`, return typeof result === 'string' ? result : String(result ?? ''); } } + +function signatureReportApprovalScopes(carrier: string) { + const exact = { carrier, approvalScope: 'carrier_specific' }; + // 迁移期默认双读;全部历史数据人工拆分后可通过环境开关进入严格运营商口径。 + return process.env.SIGNATURE_REPORT_STRICT_CARRIER === 'true' + ? [exact] + : [exact, { carrier: null, approvalScope: 'legacy_channel' }]; +} diff --git a/api/src/send-chain/send-submission.service.ts b/api/src/send-chain/send-submission.service.ts index ab943c4..9227552 100644 --- a/api/src/send-chain/send-submission.service.ts +++ b/api/src/send-chain/send-submission.service.ts @@ -277,8 +277,9 @@ async ensureSignatureReportedForChannel( signature?: { id?: string | null; name?: string | null } | null; }, channelId: string, + carrier: string, ) { - return this.gatewaySubmit.ensureSignatureReportedForChannel(message, channelId); + return this.gatewaySubmit.ensureSignatureReportedForChannel(message, channelId, carrier); } async resolveMessageSignatureId(message: { templateId?: string | null; signatureId?: string | null; template?: { signature?: { id?: string | null } | null } | null; signature?: { id?: string | null } | null }) { diff --git a/api/src/signature-retirement/signature-retirement.contracts.ts b/api/src/signature-retirement/signature-retirement.contracts.ts new file mode 100644 index 0000000..50103b8 --- /dev/null +++ b/api/src/signature-retirement/signature-retirement.contracts.ts @@ -0,0 +1,58 @@ +export type RetirementRuleType = 'enterprise_global' | 'enterprise_application' | 'channel_global' | 'channel'; + +export interface UpsertRetirementRuleDto { + ruleType: RetirementRuleType; + targetId?: string; + enabled?: boolean; + mobileWindowDays: number; + mobileThreshold: number; + unicomWindowDays: number; + unicomThreshold: number; + telecomWindowDays: number; + telecomThreshold: number; + messageTemplate?: string; +} + +export interface CreateRetirementWebhookDto { + name: string; + platform: 'wecom' | 'feishu'; + url: string; +} + +export interface SuppressRetirementMessageDto { + mode: 'temporary' | 'permanent'; + days?: number; + reason?: string; +} + +export interface CancelRetirementSuppressionDto { + reason: string; +} + +export interface UnreportedSignatureQuery { + date?: string; + keyword?: string; + page?: number; + pageSize?: number; +} + +export interface RetirementMessageQuery { + dateFrom?: string; + dateTo?: string; + dimensionType?: string; + tenantId?: string; + applicationId?: string; + signatureKeyword?: string; + channelId?: string; + page?: number; + pageSize?: number; +} + +export interface ConfirmLegacyReportDto { + results: Array<{ + carrier: 'mobile' | 'unicom' | 'telecom'; + status: 'pending' | 'waiting_material' | 'reporting' | 'approved' | 'failed' | 'rejected' | 'abandoned'; + approvedAt?: string; + }>; + reason?: string; +} diff --git a/api/src/signature-retirement/signature-retirement.controller.ts b/api/src/signature-retirement/signature-retirement.controller.ts new file mode 100644 index 0000000..e5c6535 --- /dev/null +++ b/api/src/signature-retirement/signature-retirement.controller.ts @@ -0,0 +1,110 @@ +import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; +import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator'; +import type { CancelRetirementSuppressionDto, ConfirmLegacyReportDto, CreateRetirementWebhookDto, RetirementMessageQuery, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts'; +import { SignatureRetirementService } from './signature-retirement.service'; + +@ApiTags('signature-retirement') +@Controller('admin/signature-retirement') +export class SignatureRetirementController { + constructor(private readonly service: SignatureRetirementService) {} + + @Get('configuration') + getConfiguration() { + return this.service.getConfiguration(); + } + + @Put('rules') + @RequireRecentAuthentication() + upsertRule(@Body() body: UpsertRetirementRuleDto, @CurrentSessionUserId() operatorId?: string) { + return this.service.upsertRule(body, operatorId); + } + + @Post('webhooks') + @RequireRecentAuthentication() + createWebhook(@Body() body: CreateRetirementWebhookDto) { + return this.service.createWebhook(body); + } + + @Delete('webhooks/:id') + @RequireRecentAuthentication() + deleteWebhook(@Param('id') id: string) { + return this.service.deleteWebhook(id); + } + + @Get('messages') + listMessages( + @Query('dateFrom') dateFrom?: string, + @Query('dateTo') dateTo?: string, + @Query('dimensionType') dimensionType?: string, + @Query('tenantId') tenantId?: string, + @Query('applicationId') applicationId?: string, + @Query('signatureKeyword') signatureKeyword?: string, + @Query('channelId') channelId?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + const query: RetirementMessageQuery = { dateFrom, dateTo, dimensionType, tenantId, applicationId, signatureKeyword, channelId, page: Number(page), pageSize: Number(pageSize) }; + return this.service.listMessages(query); + } + + @Get('unread-count') + unreadCount() { + return this.service.unreadCount(); + } + + @Post('messages/:id/read') + markRead(@Param('id') id: string) { + return this.service.markRead(id); + } + + @Post('messages/read-all-today') + markAllTodayRead() { + return this.service.markAllTodayRead(); + } + + @Post('messages/:id/suppress') + @RequireRecentAuthentication() + suppress(@Param('id') id: string, @Body() body: SuppressRetirementMessageDto, @CurrentSessionUserId() operatorId?: string) { + return this.service.suppressMessage(id, body, operatorId); + } + + @Get('suppressions') + listSuppressions() { + return this.service.listSuppressions(); + } + + @Post('suppressions/:id/cancel') + @RequireRecentAuthentication() + cancelSuppression(@Param('id') id: string, @Body() body: CancelRetirementSuppressionDto, @CurrentSessionUserId() operatorId?: string) { + return this.service.cancelSuppression(id, body, operatorId); + } + + @Get('heatmap') + heatmap(@Query('date') date?: string) { + return this.service.heatmap(date); + } + + @Get('unreported-signatures') + unreportedSignatures( + @Query('date') date?: string, + @Query('keyword') keyword?: string, + @Query('page') page?: string, + @Query('pageSize') pageSize?: string, + ) { + const query: UnreportedSignatureQuery = { date, keyword, page: Number(page), pageSize: Number(pageSize) }; + return this.service.unreportedSignatures(query); + } + + @Get('legacy-report-tasks') + listLegacyReportTasks() { + return this.service.listLegacyReportTasks(); + } + + @Post('legacy-report-tasks/:id/confirm') + @RequireRecentAuthentication() + confirmLegacyReport(@Param('id') id: string, @Body() body: ConfirmLegacyReportDto, @CurrentSessionUserId() operatorId?: string) { + return this.service.confirmLegacyReport(id, body, operatorId); + } +} diff --git a/api/src/signature-retirement/signature-retirement.module.ts b/api/src/signature-retirement/signature-retirement.module.ts new file mode 100644 index 0000000..104ad34 --- /dev/null +++ b/api/src/signature-retirement/signature-retirement.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { SignatureRetirementController } from './signature-retirement.controller'; +import { SignatureRetirementService } from './signature-retirement.service'; + +@Module({ + controllers: [SignatureRetirementController], + providers: [SignatureRetirementService], + exports: [SignatureRetirementService], +}) +export class SignatureRetirementModule {} diff --git a/api/src/signature-retirement/signature-retirement.service.spec.ts b/api/src/signature-retirement/signature-retirement.service.spec.ts new file mode 100644 index 0000000..f05e15b --- /dev/null +++ b/api/src/signature-retirement/signature-retirement.service.spec.ts @@ -0,0 +1,152 @@ +import { millisecondsUntilShanghaiHour, SignatureRetirementService } from './signature-retirement.service'; +import { BadRequestException } from '@nestjs/common'; + +describe('SignatureRetirementService dimensions', () => { + const service = new SignatureRetirementService({} as never); + + it('builds enterprise dimensions once and channel dimensions per approved channel and carrier', () => { + const rules = [ + rule('enterprise_global', ''), + rule('enterprise_application', 'app-1'), + rule('channel_global', ''), + rule('channel', 'channel-2'), + ]; + const tasks = [ + task('channel-1', '移动一号', 'mobile', '2026-06-01T00:00:00Z'), + task('channel-2', '移动二号', 'mobile', '2026-06-05T00:00:00Z'), + task('channel-2', '移动二号', 'unicom', '2026-06-05T00:00:00Z'), + ]; + + const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => Array<{ dimensionType: string; carrier: string; approvedAt: Date; rule: { ruleType: string } }> }).buildDimensions(rules, tasks); + + expect(dimensions.filter((item) => item.dimensionType === 'enterprise')).toHaveLength(2); + expect(dimensions.filter((item) => item.dimensionType === 'channel')).toHaveLength(3); + expect(dimensions.find((item) => item.dimensionType === 'enterprise' && item.carrier === 'mobile')?.approvedAt.toISOString()).toBe('2026-06-01T00:00:00.000Z'); + expect(dimensions.filter((item) => item.dimensionType === 'enterprise').every((item) => item.rule.ruleType === 'enterprise_application')).toBe(true); + expect(dimensions.find((item) => item.dimensionType === 'channel' && item.carrier === 'unicom')?.rule.ruleType).toBe('channel'); + }); + + it('does not monitor legacy carrier-null reporting facts', () => { + const dimensions = (service as unknown as { buildDimensions: (inputRules: unknown[], inputTasks: unknown[]) => unknown[] }).buildDimensions( + [rule('enterprise_global', ''), rule('channel_global', '')], + [task('channel-1', '三网旧通道', null, '2026-06-01T00:00:00Z')], + ); + expect(dimensions).toEqual([]); + }); + + it('rejects an approved legacy carrier confirmation without an explicit valid approval time', async () => { + await expect(service.confirmLegacyReport('legacy-1', { + results: [{ carrier: 'mobile', status: 'approved' }], + })).rejects.toBeInstanceOf(BadRequestException); + await expect(service.confirmLegacyReport('legacy-1', { + results: [{ carrier: 'mobile', status: 'approved', approvedAt: 'not-a-date' }], + })).rejects.toBeInstanceOf(BadRequestException); + }); + + it('schedules the next Beijing 04:00 and 08:00 precisely', () => { + expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T19:00:00.000Z'), 4)).toBe(60 * 60_000); + expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T21:00:00.000Z'), 4)).toBe(23 * 60 * 60_000); + expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T23:30:00.000Z'), 8)).toBe(30 * 60_000); + }); + + it('publishes frozen alert content only in the notification phase', async () => { + const detection = { + id: 'detection-1', detectionDate: new Date('2026-08-10T00:00:00.000Z'), dimensionType: 'enterprise', tenantId: 'tenant-1', + cycleId: 'cycle-1', notificationTitle: '企业签名清退预警', notificationContent: '冻结后的预警正文', + }; + const prisma = { + signatureRetirementDetection: { findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]) }, + signatureRetirementMessage: { create: jest.fn().mockResolvedValue({ id: 'message-1' }), findMany: jest.fn().mockResolvedValue([{ detectionId: 'detection-1', content: '冻结后的预警正文' }]) }, + signatureRetirementWebhook: { findMany: jest.fn().mockResolvedValue([]) }, + signatureRetirementWebhookDelivery: { upsert: jest.fn() }, + }; + const notificationService = new SignatureRetirementService(prisma as never); + + await expect(notificationService.publishNotifications('2026-08-10')).resolves.toEqual({ notificationDate: '2026-08-10', created: 1 }); + expect(prisma.signatureRetirementMessage.create).toHaveBeenCalledWith({ data: expect.objectContaining({ detectionId: 'detection-1', content: '冻结后的预警正文' }) }); + }); + + it('returns enterprise application metadata for heatmap hover and search', async () => { + const prisma = { + signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([]) }, + smsSignature: { findMany: jest.fn().mockResolvedValue([]) }, + smsChannel: { findMany: jest.fn().mockResolvedValue([]) }, + tenant: { findMany: jest.fn().mockResolvedValue([]) }, + channelSignatureReportTask: { findMany: jest.fn().mockResolvedValue([{ + signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile', approvedAt: new Date('2026-06-01T00:00:00Z'), + signature: { name: '测试签名', tenant: { name: '测试企业' }, application: { name: '测试应用' } }, + channel: { name: '移动通道' }, + }]) }, + }; + const heatmapService = new SignatureRetirementService(prisma as never); + + const result = await heatmapService.heatmap('2026-08-10'); + + expect(result.dimensions).toEqual(expect.arrayContaining([ + expect.objectContaining({ dimensionType: 'enterprise', signatureName: '测试签名', applicationName: '测试应用' }), + expect.objectContaining({ dimensionType: 'channel', channelName: '移动通道', applicationName: '测试应用' }), + ])); + }); + + it('maps the real unreported-signature aggregation to an independent page', async () => { + const prisma = { + $queryRaw: jest.fn().mockResolvedValue([{ + signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业', + applicationId: 'app-1', applicationName: '测试应用', messageCount: 7, rowCount: 3, + }]), + }; + const unreportedService = new SignatureRetirementService(prisma as never); + + await expect(unreportedService.unreportedSignatures({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 })).resolves.toEqual({ + date: '2026-08-10', + items: [{ signatureId: 'signature-1', signatureName: '未报备签名', tenantId: 'tenant-1', tenantName: '测试企业', applicationId: 'app-1', applicationName: '测试应用', messageCount: 7 }], + total: 3, + page: 2, + pageSize: 10, + }); + }); + + it('returns a filtered historical message page with application metadata', async () => { + const message = { id: 'message-1', detectionId: 'detection-1', createdAt: new Date('2026-08-09T00:00:00Z') }; + const detection = { id: 'detection-1', tenantId: 'tenant-1', applicationId: 'app-1', signatureId: 'signature-1', channelId: 'channel-1', carrier: 'mobile' }; + const prisma = { + $queryRaw: jest.fn().mockResolvedValue([{ id: 'message-1', totalCount: 21 }]), + signatureRetirementMessage: { findMany: jest.fn().mockResolvedValue([message]) }, + signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue([detection]) }, + smsSignature: { findMany: jest.fn().mockResolvedValue([{ id: 'signature-1', name: '测试签名' }]) }, + smsChannel: { findMany: jest.fn().mockResolvedValue([{ id: 'channel-1', name: '测试通道' }]) }, + tenant: { findMany: jest.fn().mockResolvedValue([{ id: 'tenant-1', name: '测试企业' }]) }, + smsApplication: { findMany: jest.fn().mockResolvedValue([{ id: 'app-1', name: '测试应用' }]) }, + }; + const messageService = new SignatureRetirementService(prisma as never); + + await expect(messageService.listMessages({ dateFrom: '2026-08-01', dateTo: '2026-08-10', tenantId: 'tenant-1', applicationId: 'app-1', signatureKeyword: '测试', channelId: 'channel-1', page: 2, pageSize: 10 })).resolves.toEqual({ + items: [expect.objectContaining({ id: 'message-1', tenantName: '测试企业', applicationName: '测试应用', signatureName: '测试签名', channelName: '测试通道' })], + total: 21, + page: 2, + pageSize: 10, + }); + }); + + it('requires a reason for temporary and permanent suppression', async () => { + const prisma = { + signatureRetirementMessage: { + findUnique: jest.fn().mockResolvedValue({ id: 'message-1', cycleId: 'cycle-1', detectionId: 'detection-1', createdAt: new Date() }), + findFirst: jest.fn().mockResolvedValue(null), + }, + signatureRetirementDetection: { findUnique: jest.fn().mockResolvedValue({ id: 'detection-1' }) }, + }; + const suppressionService = new SignatureRetirementService(prisma as never); + + await expect(suppressionService.suppressMessage('message-1', { mode: 'temporary', days: 7, reason: ' ' })).rejects.toThrow('抑制原因不能为空'); + await expect(suppressionService.suppressMessage('message-1', { mode: 'permanent' })).rejects.toThrow('抑制原因不能为空'); + }); +}); + +function rule(ruleType: string, targetKey: string) { + return { id: `${ruleType}-${targetKey}`, ruleType, targetId: targetKey || null, targetKey, enabled: true, mobileWindowDays: 30, mobileThreshold: 1, unicomWindowDays: 30, unicomThreshold: 1, telecomWindowDays: 30, telecomThreshold: 1, messageTemplate: null, version: 1 }; +} + +function task(channelId: string, channelName: string, carrier: string | null, approvedAt: string) { + return { signatureId: 'signature-1', channelId, carrier, approvedAt: new Date(approvedAt), signature: { tenantId: 'tenant-1', applicationId: 'app-1', name: '测试签名', tenant: { name: '测试企业' } }, channel: { name: channelName } }; +} diff --git a/api/src/signature-retirement/signature-retirement.service.ts b/api/src/signature-retirement/signature-retirement.service.ts new file mode 100644 index 0000000..e83eae8 --- /dev/null +++ b/api/src/signature-retirement/signature-retirement.service.ts @@ -0,0 +1,738 @@ +import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import { lookup } from 'node:dns/promises'; +import { decryptSecret, encryptSecret } from '../open-api/open-api.crypto'; +import { PrismaService } from '../prisma/prisma.service'; +import { shanghaiDateRange } from '../common/shanghai-date-range'; +import { normalizeChannelCarriers } from '../channels/channels.helpers'; +import type { CancelRetirementSuppressionDto, ConfirmLegacyReportDto, CreateRetirementWebhookDto, RetirementMessageQuery, RetirementRuleType, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts'; + +const DAY_MS = 86_400_000; +const CARRIERS = ['mobile', 'unicom', 'telecom'] as const; +const REPORT_STATUSES = new Set(['pending', 'waiting_material', 'reporting', 'approved', 'failed', 'rejected', 'abandoned']); +const DEFAULT_DELIVERY_INTERVAL_MS = 60_000; +const carrierLabels: Record = { mobile: '移动', unicom: '联通', telecom: '电信' }; + +type RuleRecord = Awaited>; +type DetectionDimension = { + dimensionType: 'enterprise' | 'channel'; + tenantId: string; + applicationId: string | null; + signatureId: string; + signatureName: string; + tenantName: string; + channelId: string | null; + channelName: string | null; + carrier: string; + approvedAt: Date; + rule: NonNullable; +}; + +type ActivityCounts = { + submittedAttempts: number; + acceptedBusinessCount: number; + deliveredBusinessCount: number; +}; + +@Injectable() +export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(SignatureRetirementService.name); + private detectionTimer?: ReturnType; + private notificationTimer?: ReturnType; + private deliveryTimer?: ReturnType; + private startupTimer?: ReturnType; + + constructor(private readonly prisma: PrismaService) {} + + onModuleInit() { + if (process.env.NODE_ENV === 'test') return; + // 04:00检测、08:00发消息分别调度;启动补偿与数据库唯一键共同保证当天不漏、不重。 + this.startupTimer = setTimeout(() => void this.runStartupCompensation(), 10_000); + this.startupTimer.unref?.(); + this.scheduleDetection(); + this.scheduleNotification(); + this.deliveryTimer = setInterval(() => void this.deliverPendingWebhooks(), positiveIntegerEnv('SIGNATURE_RETIREMENT_WEBHOOK_INTERVAL_MS', DEFAULT_DELIVERY_INTERVAL_MS)); + this.deliveryTimer.unref?.(); + } + + onModuleDestroy() { + if (this.startupTimer) clearTimeout(this.startupTimer); + if (this.detectionTimer) clearTimeout(this.detectionTimer); + if (this.notificationTimer) clearTimeout(this.notificationTimer); + if (this.deliveryTimer) clearInterval(this.deliveryTimer); + } + + async getConfiguration() { + const [rules, webhooks] = await Promise.all([ + this.prisma.signatureRetirementRule.findMany({ orderBy: [{ ruleType: 'asc' }, { targetKey: 'asc' }] }), + this.prisma.signatureRetirementWebhook.findMany({ orderBy: { createdAt: 'asc' } }), + ]); + return { rules, webhooks }; + } + + async upsertRule(data: UpsertRetirementRuleDto, operatorId?: string) { + assertRuleType(data.ruleType); + if (['enterprise_application', 'channel'].includes(data.ruleType) && !data.targetId?.trim()) { + throw new BadRequestException('特殊规则必须选择目标'); + } + const values = CARRIERS.flatMap((carrier) => [ + Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]), + Number(data[`${carrier}Threshold` as keyof UpsertRetirementRuleDto]), + ]); + if (values.some((value) => !Number.isInteger(value) || value < 0)) throw new BadRequestException('检测天数和阈值必须为非负整数'); + if (CARRIERS.some((carrier) => Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]) < 1 || Number(data[`${carrier}WindowDays` as keyof UpsertRetirementRuleDto]) > 365)) { + throw new BadRequestException('检测天数必须在1至365天之间'); + } + const targetId = data.targetId?.trim() || null; + const targetKey = targetId ?? ''; + const existing = await this.prisma.signatureRetirementRule.findUnique({ where: { ruleType_targetKey: { ruleType: data.ruleType, targetKey } } }); + const rule = await this.prisma.signatureRetirementRule.upsert({ + where: { ruleType_targetKey: { ruleType: data.ruleType, targetKey } }, + create: { ...data, targetId, targetKey, createdById: operatorId }, + update: { ...data, targetId, targetKey, version: { increment: 1 } }, + }); + await this.prisma.operationLog.create({ data: { userId: operatorId, action: existing ? 'signature_retirement.rule_updated' : 'signature_retirement.rule_created', resource: 'signature_retirement_rule', resourceId: rule.id, detail: { ruleType: rule.ruleType, targetId, version: rule.version } as Prisma.InputJsonValue } }); + return rule; + } + + async createWebhook(data: CreateRetirementWebhookDto) { + if (!data.name?.trim()) throw new BadRequestException('Webhook名称不能为空'); + if (!['wecom', 'feishu'].includes(data.platform)) throw new BadRequestException('仅支持企业微信或飞书'); + await assertSafeWebhookUrl(data.url); + return this.prisma.signatureRetirementWebhook.create({ + data: { name: data.name.trim(), platform: data.platform, urlEncrypted: encryptSecret(data.url.trim()), urlMasked: maskWebhookUrl(data.url.trim()) }, + }); + } + + async deleteWebhook(id: string) { + const webhook = await this.prisma.signatureRetirementWebhook.findUnique({ where: { id } }); + if (!webhook) throw new NotFoundException('Webhook不存在'); + return this.prisma.signatureRetirementWebhook.update({ where: { id }, data: { status: 'deleted' } }); + } + + async listMessages(query: RetirementMessageQuery) { + const page = Math.max(1, Math.floor(query.page || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(query.pageSize || 10))); + const range = shanghaiDateRange(query.dateFrom || shanghaiDateKey(), query.dateTo || query.dateFrom || shanghaiDateKey()); + const dimensionType = query.dimensionType && query.dimensionType !== 'all' ? query.dimensionType : null; + if (dimensionType && !['enterprise', 'channel'].includes(dimensionType)) throw new BadRequestException('不支持的预警类型'); + const tenantId = query.tenantId?.trim() || null; + const applicationId = query.applicationId?.trim() || null; + const signatureKeyword = query.signatureKeyword?.trim() || null; + const signaturePattern = signatureKeyword ? `%${signatureKeyword}%` : null; + const channelId = query.channelId?.trim() || null; + const messageRows = await this.prisma.$queryRaw>(Prisma.sql` + SELECT message.id, COUNT(*) OVER()::integer AS "totalCount" + FROM "SignatureRetirementMessage" message + JOIN "SignatureRetirementDetection" detection ON detection.id = message."detectionId" + JOIN "SmsSignature" signature ON signature.id = detection."signatureId" + LEFT JOIN "SmsApplication" application ON application.id = detection."applicationId" + WHERE message."createdAt" >= ${range?.gte} + AND message."createdAt" <= ${range?.lte} + AND (${dimensionType}::text IS NULL OR detection."dimensionType" = ${dimensionType}) + AND (${tenantId}::text IS NULL OR detection."tenantId" = ${tenantId}) + AND (${applicationId}::text IS NULL OR application.id = ${applicationId}) + AND (${signatureKeyword}::text IS NULL OR signature.name ILIKE ${signaturePattern}) + AND (${channelId}::text IS NULL OR detection."channelId" = ${channelId}) + ORDER BY message."createdAt" DESC, message.id DESC + LIMIT ${pageSize} + OFFSET ${(page - 1) * pageSize} + `); + const orderedIds = messageRows.map((item) => item.id); + const unorderedItems = orderedIds.length ? await this.prisma.signatureRetirementMessage.findMany({ where: { id: { in: orderedIds } } }) : []; + const itemMap = new Map(unorderedItems.map((item) => [item.id, item])); + const items = orderedIds.flatMap((id) => itemMap.has(id) ? [itemMap.get(id)!] : []); + const total = messageRows[0]?.totalCount ?? 0; + const detections = await this.prisma.signatureRetirementDetection.findMany({ where: { id: { in: items.map((item) => item.detectionId) } } }); + const detectionMap = new Map(detections.map((item) => [item.id, item])); + const [signatures, channels, tenants, applications] = await Promise.all([ + this.prisma.smsSignature.findMany({ where: { id: { in: detections.map((item) => item.signatureId) } }, select: { id: true, name: true } }), + this.prisma.smsChannel.findMany({ where: { id: { in: detections.flatMap((item) => item.channelId ? [item.channelId] : []) } }, select: { id: true, name: true } }), + this.prisma.tenant.findMany({ where: { id: { in: detections.map((item) => item.tenantId) } }, select: { id: true, name: true } }), + this.prisma.smsApplication.findMany({ where: { id: { in: detections.flatMap((item) => item.applicationId ? [item.applicationId] : []) } }, select: { id: true, name: true } }), + ]); + const signatureMap = new Map(signatures.map((item) => [item.id, item.name])); + const channelMap = new Map(channels.map((item) => [item.id, item.name])); + const tenantMap = new Map(tenants.map((item) => [item.id, item.name])); + const applicationMap = new Map(applications.map((item) => [item.id, item.name])); + return { + items: items.map((item) => { + const detection = detectionMap.get(item.detectionId); + return { ...item, detection, signatureName: detection ? signatureMap.get(detection.signatureId) : undefined, channelName: detection?.channelId ? channelMap.get(detection.channelId) : undefined, tenantName: detection ? tenantMap.get(detection.tenantId) : undefined, applicationName: detection?.applicationId ? applicationMap.get(detection.applicationId) : undefined }; + }), + total, + page, + pageSize, + }; + } + + async unreadCount() { + const range = shanghaiDateRange(shanghaiDateKey(), shanghaiDateKey()); + const count = await this.prisma.signatureRetirementMessage.count({ where: { createdAt: range, isRead: false, suppressed: false } }); + return { count }; + } + + async markRead(id: string) { + return this.prisma.signatureRetirementMessage.update({ where: { id }, data: { isRead: true, readAt: new Date() } }); + } + + async markAllTodayRead() { + const range = shanghaiDateRange(shanghaiDateKey(), shanghaiDateKey()); + const result = await this.prisma.signatureRetirementMessage.updateMany({ where: { createdAt: range, isRead: false }, data: { isRead: true, readAt: new Date() } }); + return { count: result.count }; + } + + async suppressMessage(id: string, data: SuppressRetirementMessageDto, operatorId?: string) { + const message = await this.prisma.signatureRetirementMessage.findUnique({ where: { id } }); + if (!message) throw new NotFoundException('预警消息不存在'); + const newerMessage = await this.prisma.signatureRetirementMessage.findFirst({ where: { cycleId: message.cycleId, createdAt: { gt: message.createdAt } }, select: { id: true } }); + if (newerMessage) throw new BadRequestException('只能从当前预警周期的最新消息设置抑制'); + const detection = await this.prisma.signatureRetirementDetection.findUnique({ where: { id: message.detectionId } }); + if (!detection) throw new NotFoundException('预警检测记录不存在'); + if (!['temporary', 'permanent'].includes(data.mode)) throw new BadRequestException('不支持的抑制类型'); + if (!data.reason?.trim()) throw new BadRequestException('抑制原因不能为空'); + const days = data.mode === 'temporary' ? Math.floor(Number(data.days)) : undefined; + if (data.mode === 'temporary' && (!days || days < 1 || days > 3650)) throw new BadRequestException('临时抑制天数必须在1至3650之间'); + const muteUntil = days ? databaseDate(addDays(shanghaiDateKey(), days)) : null; + const suppression = await this.prisma.signatureRetirementSuppression.upsert({ + where: { dimensionType_signatureId_channelKey_carrier: { dimensionType: detection.dimensionType, signatureId: detection.signatureId, channelKey: detection.channelKey, carrier: detection.carrier } }, + create: { dimensionType: detection.dimensionType, signatureId: detection.signatureId, channelId: detection.channelId, channelKey: detection.channelKey, carrier: detection.carrier, mode: data.mode, muteUntil, reason: data.reason?.trim(), operatorId }, + update: { channelId: detection.channelId, mode: data.mode, muteUntil, active: true, reason: data.reason?.trim(), operatorId, cancelledAt: null, cancelledById: null, cancelReason: null }, + }); + await Promise.all([ + this.prisma.signatureRetirementMessage.update({ where: { id }, data: { suppressed: true } }), + this.prisma.operationLog.create({ data: { userId: operatorId, action: 'signature_retirement.suppressed', resource: 'signature_retirement_suppression', resourceId: suppression.id, detail: { mode: data.mode, days, reason: data.reason } as Prisma.InputJsonValue } }), + ]); + return suppression; + } + + listSuppressions() { + return this.prisma.signatureRetirementSuppression.findMany({ + where: { active: true, OR: [{ mode: 'permanent' }, { muteUntil: { gte: databaseDate(shanghaiDateKey()) } }] }, + orderBy: { updatedAt: 'desc' }, + }); + } + + async cancelSuppression(id: string, data: CancelRetirementSuppressionDto, operatorId?: string) { + if (!data.reason?.trim()) throw new BadRequestException('取消抑制原因不能为空'); + const suppression = await this.prisma.signatureRetirementSuppression.findUnique({ where: { id } }); + if (!suppression) throw new NotFoundException('抑制记录不存在'); + const updated = await this.prisma.signatureRetirementSuppression.update({ where: { id }, data: { active: false, cancelledAt: new Date(), cancelledById: operatorId, cancelReason: data.reason.trim() } }); + await this.prisma.operationLog.create({ data: { userId: operatorId, action: 'signature_retirement.suppression_cancelled', resource: 'signature_retirement_suppression', resourceId: id, detail: { reason: data.reason.trim() } as Prisma.InputJsonValue } }); + return updated; + } + + async heatmap(date?: string) { + const endKey = assertDateKey(date || shanghaiDateKey()); + const startKey = addDays(endKey, -30); + const detections = await this.prisma.signatureRetirementDetection.findMany({ + where: { detectionDate: { gte: databaseDate(startKey), lt: databaseDate(endKey) } }, + orderBy: [{ dimensionType: 'asc' }, { signatureId: 'asc' }, { channelKey: 'asc' }, { carrier: 'asc' }, { detectionDate: 'desc' }], + }); + const [signatures, channels, tenants, approvedTasks] = await Promise.all([ + this.prisma.smsSignature.findMany({ where: { id: { in: detections.map((item) => item.signatureId) } }, select: { id: true, name: true } }), + this.prisma.smsChannel.findMany({ where: { id: { in: detections.flatMap((item) => item.channelId ? [item.channelId] : []) } }, select: { id: true, name: true } }), + this.prisma.tenant.findMany({ where: { id: { in: detections.map((item) => item.tenantId) } }, select: { id: true, name: true } }), + this.prisma.channelSignatureReportTask.findMany({ + where: { reportType: 'signature', status: 'approved', carrier: { not: null }, approvalScope: 'carrier_specific', approvedAt: { not: null }, signature: { auditStatus: { not: 'deleted' } }, channel: { status: { not: 'deleted' } } }, + include: { signature: { include: { tenant: true, application: true } }, channel: true }, + }), + ]); + const dimensionMap = new Map(); + for (const task of approvedTasks) { + if (!task.carrier || !task.approvedAt) continue; + const channelDimension = { dimensionType: 'channel' as const, signatureId: task.signatureId, channelId: task.channelId, carrier: task.carrier, approvedAt: task.approvedAt, signatureName: task.signature.name, channelName: task.channel.name, tenantName: task.signature.tenant.name, applicationName: task.signature.application?.name ?? null }; + dimensionMap.set(`channel:${task.signatureId}:${task.channelId}:${task.carrier}`, channelDimension); + const enterpriseKey = `enterprise:${task.signatureId}::${task.carrier}`; + const current = dimensionMap.get(enterpriseKey); + if (!current || task.approvedAt < current.approvedAt) dimensionMap.set(enterpriseKey, { ...channelDimension, dimensionType: 'enterprise', channelId: null, channelName: null }); + } + return { + date: endKey, + dimensions: [...dimensionMap.values()], + items: detections.map((item) => ({ ...item, signatureName: signatures.find((entry) => entry.id === item.signatureId)?.name, channelName: item.channelId ? channels.find((entry) => entry.id === item.channelId)?.name : null, tenantName: tenants.find((entry) => entry.id === item.tenantId)?.name })), + }; + } + + async unreportedSignatures(query: UnreportedSignatureQuery) { + const date = assertDateKey(query.date || shanghaiDateKey()); + const page = positiveInteger(query.page, 1); + const pageSize = Math.min(50, positiveInteger(query.pageSize, 10)); + const keyword = query.keyword?.trim() || null; + const keywordPattern = keyword ? `%${keyword}%` : null; + const rows = await this.prisma.$queryRaw>(Prisma.sql` + WITH unreported AS ( + SELECT + signature.id AS signature_id, + signature.name AS signature_name, + tenant.id AS tenant_id, + tenant.name AS tenant_name, + application.id AS application_id, + application.name AS application_name, + COUNT(*)::integer AS message_count + FROM "SmsMessageRecord" message + JOIN "SmsSignature" signature ON signature.id = message."signatureId" + JOIN "Tenant" tenant ON tenant.id = signature."tenantId" + LEFT JOIN "SmsApplication" application ON application.id = message."applicationId" + WHERE message."queuedAt" >= ${shanghaiStart(date)} + AND message."queuedAt" < ${shanghaiStart(addDays(date, 1))} + AND NOT EXISTS ( + SELECT 1 + FROM "ChannelSignatureReportTask" report + JOIN "SmsChannel" channel ON channel.id = report."channelId" + WHERE report."signatureId" = message."signatureId" + AND report."reportType" = 'signature' + AND report.status = 'approved' + AND channel.status <> 'deleted' + AND ( + report."approvalScope" = 'legacy_channel' + OR ( + report."approvalScope" = 'carrier_specific' + AND report.carrier = CASE + WHEN LOWER(COALESCE(message.carrier, '')) IN ('mobile', 'cmcc', '移动', '中国移动') THEN 'mobile' + WHEN LOWER(COALESCE(message.carrier, '')) IN ('unicom', 'cucc', '联通', '中国联通') THEN 'unicom' + WHEN LOWER(COALESCE(message.carrier, '')) IN ('telecom', 'ctcc', '电信', '中国电信') THEN 'telecom' + ELSE '__unknown__' + END + ) + ) + ) + AND ( + ${keyword}::text IS NULL + OR signature.name ILIKE ${keywordPattern} + OR tenant.name ILIKE ${keywordPattern} + OR application.name ILIKE ${keywordPattern} + ) + GROUP BY signature.id, signature.name, tenant.id, tenant.name, application.id, application.name + ) + SELECT + signature_id AS "signatureId", + signature_name AS "signatureName", + tenant_id AS "tenantId", + tenant_name AS "tenantName", + application_id AS "applicationId", + application_name AS "applicationName", + message_count AS "messageCount", + COUNT(*) OVER()::integer AS "rowCount" + FROM unreported + ORDER BY message_count DESC, signature_name, application_name NULLS LAST + LIMIT ${pageSize} + OFFSET ${(page - 1) * pageSize} + `); + return { + date, + items: rows.map(({ rowCount: _rowCount, ...item }) => item), + total: rows[0]?.rowCount ?? 0, + page, + pageSize, + }; + } + + async runDetection(date?: string) { + const detectionKey = assertDateKey(date || shanghaiDateKey()); + await this.prisma.signatureRetirementSuppression.updateMany({ + where: { active: true, mode: 'temporary', muteUntil: { lt: databaseDate(detectionKey) } }, + data: { active: false }, + }); + const [rules, approvedTasks] = await Promise.all([ + this.prisma.signatureRetirementRule.findMany({ where: { enabled: true } }), + this.prisma.channelSignatureReportTask.findMany({ + where: { reportType: 'signature', status: 'approved', carrier: { not: null }, approvalScope: 'carrier_specific', approvedAt: { not: null }, signature: { auditStatus: { not: 'deleted' } }, channel: { status: { not: 'deleted' } } }, + include: { signature: { include: { tenant: true, application: true } }, channel: true }, + }), + ]); + const dimensions = this.buildDimensions(rules, approvedTasks); + let alerted = 0; + let healthy = 0; + let ineligible = 0; + for (const dimension of dimensions) { + const { windowDays, threshold } = carrierRule(dimension.rule, dimension.carrier); + const windowStartKey = addDays(detectionKey, -windowDays); + const windowStart = shanghaiStart(windowStartKey); + if (dimension.approvedAt > windowStart) { + ineligible += 1; + continue; + } + const counts = await this.activityCounts(dimension, windowStart, shanghaiStart(detectionKey)); + const isAlert = counts.acceptedBusinessCount < threshold; + await this.persistDetection(detectionKey, dimension, windowDays, threshold, counts, isAlert); + if (isAlert) alerted += 1; + else healthy += 1; + } + return { detectionDate: detectionKey, dimensions: dimensions.length, alerted, healthy, ineligible }; + } + + async publishNotifications(date?: string) { + const notificationKey = assertDateKey(date || shanghaiDateKey()); + const detections = await this.prisma.signatureRetirementDetection.findMany({ + where: { + detectionDate: databaseDate(notificationKey), status: 'alert', suppressed: false, + cycleId: { not: null }, notificationTitle: { not: null }, notificationContent: { not: null }, + }, + }); + let created = 0; + for (const detection of detections) { + if (!detection.cycleId || !detection.notificationTitle || !detection.notificationContent) continue; + try { + await this.prisma.signatureRetirementMessage.create({ + data: { detectionId: detection.id, cycleId: detection.cycleId, tenantId: detection.tenantId, title: detection.notificationTitle, content: detection.notificationContent }, + }); + created += 1; + } catch (error) { + // 多实例08:00并发发布时,检测ID唯一键保证只产生一条站内消息。 + if (!isPrismaUniqueError(error)) throw error; + } + } + await this.enqueueWebhookSummaries(notificationKey); + return { notificationDate: notificationKey, created }; + } + + async listLegacyReportTasks() { + return this.prisma.channelSignatureReportTask.findMany({ + where: { reportType: 'signature', carrier: null, approvalScope: 'legacy_channel', signature: { auditStatus: { not: 'deleted' } }, channel: { status: { not: 'deleted' } } }, + include: { signature: { include: { tenant: true, application: true } }, channel: true, records: { orderBy: { createdAt: 'desc' }, take: 5 } }, + orderBy: { createdAt: 'desc' }, + }); + } + + async confirmLegacyReport(id: string, data: ConfirmLegacyReportDto, operatorId?: string) { + if (!data.results?.length) throw new BadRequestException('至少确认一个运营商结果'); + const carriers = new Set(data.results.map((item) => item.carrier)); + if (carriers.size !== data.results.length) throw new BadRequestException('运营商结果不能重复'); + for (const result of data.results) { + if (!REPORT_STATUSES.has(result.status)) throw new BadRequestException('报备状态无效'); + // 历史通道级通过时间不能代替运营商通过时间,否则仍是在自动伪造运营商事实。 + if (result.status === 'approved' && !parseApprovedAt(result.approvedAt)) throw new BadRequestException(`${carrierLabels[result.carrier]}通过时间必填且必须有效`); + } + return this.prisma.$transaction(async (tx) => { + const legacy = await tx.channelSignatureReportTask.findUnique({ where: { id }, include: { channel: true } }); + if (!legacy || legacy.reportType !== 'signature' || legacy.carrier !== null || legacy.approvalScope !== 'legacy_channel') throw new NotFoundException('历史通道级任务不存在'); + const supported = normalizeChannelCarriers(legacy.channel.carriers, legacy.channel.carrier); + const resultTasks = []; + for (const result of data.results) { + if (!supported.includes(result.carrier)) throw new BadRequestException('确认运营商不在通道支持范围内'); + const approvedAt = result.status === 'approved' ? parseApprovedAt(result.approvedAt) : null; + const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: legacy.signatureId, channelId: legacy.channelId, carrier: result.carrier, reportType: 'signature', drainageItemId: null } }); + const task = existing + ? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: result.status, approvedAt, reason: data.reason, approvalScope: 'carrier_specific' } }) + : await tx.channelSignatureReportTask.create({ data: { tenantId: legacy.tenantId, signatureId: legacy.signatureId, channelId: legacy.channelId, carrier: result.carrier, reportType: 'signature', status: result.status, approvedAt, reason: data.reason, approvalScope: 'carrier_specific', createdById: operatorId } }); + await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'legacy_carrier_confirmed', statusBefore: existing?.status, statusAfter: result.status, reason: data.reason, operatorId, sourceEntry: 'report_task' } }); + resultTasks.push(task); + } + await tx.channelSignatureReportRecord.create({ data: { taskId: legacy.id, channelId: legacy.channelId, action: 'legacy_scope_split', statusBefore: legacy.status, statusAfter: legacy.status, reason: data.reason, operatorId, sourceEntry: 'report_task' } }); + if (supported.every((carrier) => carriers.has(carrier))) { + // 全部适用运营商均已人工确认后,旧通道级事实退出发送链兼容读取,避免长期双口径。 + await tx.channelSignatureReportTask.update({ where: { id: legacy.id }, data: { approvalScope: 'legacy_split' } }); + } + await tx.operationLog.create({ data: { userId: operatorId, action: 'signature_report.legacy_carriers_confirmed', resource: 'channel_signature_report_task', resourceId: legacy.id, detail: { results: data.results, reason: data.reason } as Prisma.InputJsonValue } }); + return { legacyTaskId: legacy.id, tasks: resultTasks }; + }); + } + + private async runStartupCompensation() { + const now = new Date(); + const hour = shanghaiHour(now); + try { + if (hour >= 4) await this.runDetection(shanghaiDateKey(now)); + if (hour >= 8) { + await this.publishNotifications(shanghaiDateKey(now)); + await this.deliverPendingWebhooks(); + } + } catch (error) { + this.logger.error(`Signature retirement startup compensation failed: ${error instanceof Error ? error.message : String(error)}`); + } + } + + private scheduleDetection() { + this.detectionTimer = setTimeout(() => { + void this.runDetection(shanghaiDateKey()) + .catch((error) => this.logger.error(`Signature retirement 04:00 detection failed: ${error instanceof Error ? error.message : String(error)}`)) + .finally(() => this.scheduleDetection()); + }, millisecondsUntilShanghaiHour(new Date(), 4)); + this.detectionTimer.unref?.(); + } + + private scheduleNotification() { + this.notificationTimer = setTimeout(() => { + void this.publishNotifications(shanghaiDateKey()) + .then(() => this.deliverPendingWebhooks()) + .catch((error) => this.logger.error(`Signature retirement 08:00 notification failed: ${error instanceof Error ? error.message : String(error)}`)) + .finally(() => this.scheduleNotification()); + }, millisecondsUntilShanghaiHour(new Date(), 8)); + this.notificationTimer.unref?.(); + } + + private buildDimensions(rules: Array>, tasks: Array<{ signatureId: string; channelId: string; carrier: string | null; approvedAt: Date | null; signature: { tenantId: string; applicationId: string | null; name: string; tenant: { name: string } }; channel: { name: string } }>) { + const dimensions: DetectionDimension[] = []; + const enterprise = new Map(); + for (const task of tasks) { + if (!task.carrier || !task.approvedAt) continue; + const channelRule = selectRule(rules, 'channel', task.channelId); + if (channelRule) dimensions.push({ dimensionType: 'channel', tenantId: task.signature.tenantId, applicationId: task.signature.applicationId, signatureId: task.signatureId, signatureName: task.signature.name, tenantName: task.signature.tenant.name, channelId: task.channelId, channelName: task.channel.name, carrier: task.carrier, approvedAt: task.approvedAt, rule: channelRule }); + const enterpriseRule = selectRule(rules, 'enterprise', task.signature.applicationId); + if (!enterpriseRule) continue; + const key = `${task.signatureId}:${task.carrier}`; + const current = enterprise.get(key); + if (!current || task.approvedAt < current.approvedAt) enterprise.set(key, { dimensionType: 'enterprise', tenantId: task.signature.tenantId, applicationId: task.signature.applicationId, signatureId: task.signatureId, signatureName: task.signature.name, tenantName: task.signature.tenant.name, channelId: null, channelName: null, carrier: task.carrier, approvedAt: task.approvedAt, rule: enterpriseRule }); + } + return [...enterprise.values(), ...dimensions]; + } + + private async activityCounts(dimension: DetectionDimension, startAt: Date, endAt: Date): Promise { + const channelFilter = dimension.channelId ? Prisma.sql`AND submit."channelId" = ${dimension.channelId}` : Prisma.empty; + const rows = await this.prisma.$queryRaw>(Prisma.sql` + WITH attempts AS ( + SELECT + submit.id, + submit."messageRecordId" AS message_id, + submit."submitStatus" AS submit_status, + CASE + WHEN EXISTS (SELECT 1 FROM "SmsMessageSegmentAudit" segment WHERE segment."submitRecordId" = submit.id) + THEN NOT EXISTS ( + SELECT 1 FROM "SmsMessageSegmentAudit" segment + WHERE segment."submitRecordId" = submit.id AND segment."receiptStatus" IS DISTINCT FROM 'delivered' + ) + ELSE EXISTS ( + SELECT 1 FROM "SmsReceiptRecord" receipt + WHERE receipt."gatewayMessageId" = submit."gatewayMessageId" + AND receipt."channelId" = submit."channelId" + AND receipt."receiptStatus" = 'delivered' + ) + END AS delivery_success + FROM "SmsSubmitRecord" submit + JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId" + WHERE message."signatureId" = ${dimension.signatureId} + AND message.carrier = ${dimension.carrier} + AND COALESCE(submit."submittedAt", submit."createdAt") >= ${startAt} + AND COALESCE(submit."submittedAt", submit."createdAt") < ${endAt} + ${channelFilter} + ) + SELECT + COUNT(id)::integer AS "submittedAttempts", + COUNT(DISTINCT message_id) FILTER (WHERE submit_status = 'accepted')::integer AS "acceptedBusinessCount", + COUNT(DISTINCT message_id) FILTER (WHERE submit_status = 'accepted' AND delivery_success)::integer AS "deliveredBusinessCount" + FROM attempts + `); + return rows[0] ?? { submittedAttempts: 0, acceptedBusinessCount: 0, deliveredBusinessCount: 0 }; + } + + private async persistDetection(dateKey: string, dimension: DetectionDimension, windowDays: number, threshold: number, counts: ActivityCounts, isAlert: boolean) { + const detectionDate = databaseDate(dateKey); + const channelKey = dimension.channelId ?? ''; + const existingDetection = await this.prisma.signatureRetirementDetection.findUnique({ + where: { detectionDate_dimensionType_signatureId_channelKey_carrier: { detectionDate, dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier } }, + select: { id: true }, + }); + // 同一检测日的结果冻结规则版本;规则变更只在下一检测日生效。 + if (existingDetection) return; + const suppression = await this.prisma.signatureRetirementSuppression.findUnique({ where: { dimensionType_signatureId_channelKey_carrier: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier } } }); + const suppressed = Boolean(suppression?.active && (suppression.mode === 'permanent' || !suppression.muteUntil || suppression.muteUntil >= detectionDate)); + let cycle = await this.prisma.signatureRetirementCycle.findFirst({ where: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier, status: 'open' } }); + if (isAlert) { + if (!cycle) { + try { + cycle = await this.prisma.signatureRetirementCycle.create({ data: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, startedOn: detectionDate, lastDetectedOn: detectionDate } }); + } catch (error) { + if (!isPrismaUniqueError(error)) throw error; + cycle = await this.prisma.signatureRetirementCycle.findFirst({ where: { dimensionType: dimension.dimensionType, signatureId: dimension.signatureId, channelKey, carrier: dimension.carrier, status: 'open' } }); + } + } else { + cycle = await this.prisma.signatureRetirementCycle.update({ where: { id: cycle.id }, data: { lastDetectedOn: detectionDate } }); + } + } else if (cycle) { + await this.prisma.signatureRetirementCycle.update({ where: { id: cycle.id }, data: { status: 'resolved', resolvedOn: detectionDate, lastDetectedOn: detectionDate } }); + cycle = null; + } + const notificationTitle = isAlert && cycle ? (dimension.dimensionType === 'enterprise' ? '企业签名清退预警' : '通道签名清退预警') : null; + const notificationContent = isAlert && cycle ? renderMessage(dimension.rule.messageTemplate, dimension, windowDays, threshold, counts.acceptedBusinessCount) : null; + try { + await this.prisma.signatureRetirementDetection.create({ + data: { detectionDate, dimensionType: dimension.dimensionType, tenantId: dimension.tenantId, applicationId: dimension.applicationId, signatureId: dimension.signatureId, channelId: dimension.channelId, channelKey, carrier: dimension.carrier, windowDays, threshold, ...counts, approvedAt: dimension.approvedAt, ruleId: dimension.rule.id, ruleVersion: dimension.rule.version, status: isAlert ? 'alert' : 'healthy', cycleId: cycle?.id, suppressed, notificationTitle, notificationContent }, + }); + } catch (error) { + // 两个检测实例可能同时越过前置查询;唯一快照已由另一实例生成时直接结束本维度。 + if (isPrismaUniqueError(error)) return; + throw error; + } + } + + private async enqueueWebhookSummaries(dateKey: string) { + const detectionDate = databaseDate(dateKey); + const detections = await this.prisma.signatureRetirementDetection.findMany({ where: { detectionDate, status: 'alert', suppressed: false } }); + if (!detections.length) return; + const [webhooks, messages] = await Promise.all([ + this.prisma.signatureRetirementWebhook.findMany({ where: { status: 'active' } }), + this.prisma.signatureRetirementMessage.findMany({ where: { detectionId: { in: detections.map((item) => item.id) }, suppressed: false } }), + ]); + const messageMap = new Map(messages.map((item) => [item.detectionId, item.content])); + const groups = new Map(); + for (const detection of detections) { + const key = detection.dimensionType === 'enterprise' ? `enterprise:${detection.tenantId}` : 'channel:all'; + const values = groups.get(key) ?? []; + const content = messageMap.get(detection.id); + if (content) values.push(content); + groups.set(key, values); + } + for (const webhook of webhooks) { + for (const [groupKey, contents] of groups) { + await this.prisma.signatureRetirementWebhookDelivery.upsert({ + where: { webhookId_detectionDate_groupKey: { webhookId: webhook.id, detectionDate, groupKey } }, + create: { webhookId: webhook.id, detectionDate, groupKey, payload: { content: contents.join('\n') } }, + update: {}, + }); + } + } + } + + private async deliverPendingWebhooks() { + await this.prisma.signatureRetirementWebhookDelivery.updateMany({ + where: { status: 'sending', updatedAt: { lt: new Date(Date.now() - 5 * 60_000) } }, + data: { status: 'retrying', nextRetryAt: new Date() }, + }); + const deliveries = await this.prisma.signatureRetirementWebhookDelivery.findMany({ where: { status: { in: ['pending', 'retrying'] }, OR: [{ nextRetryAt: null }, { nextRetryAt: { lte: new Date() } }] }, orderBy: { createdAt: 'asc' }, take: 20 }); + for (const delivery of deliveries) { + const claimed = await this.prisma.signatureRetirementWebhookDelivery.updateMany({ where: { id: delivery.id, status: { in: ['pending', 'retrying'] }, attemptCount: delivery.attemptCount }, data: { status: 'sending', attemptCount: { increment: 1 } } }); + if (!claimed.count) continue; + const webhook = await this.prisma.signatureRetirementWebhook.findUnique({ where: { id: delivery.webhookId } }); + if (!webhook || webhook.status !== 'active') { + await this.prisma.signatureRetirementWebhookDelivery.update({ where: { id: delivery.id }, data: { status: 'failed', lastError: 'Webhook已停用' } }); + continue; + } + try { + const url = decryptSecret(webhook.urlEncrypted); + await assertSafeWebhookUrl(url); + const content = String((delivery.payload as { content?: unknown }).content ?? ''); + const body = webhook.platform === 'feishu' ? { msg_type: 'text', content: { text: content } } : { msgtype: 'text', text: { content } }; + const response = await fetch(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), signal: AbortSignal.timeout(10_000) }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + const responseBody = await response.json().catch(() => null) as { errcode?: number; code?: number } | null; + if ((typeof responseBody?.errcode === 'number' && responseBody.errcode !== 0) || (typeof responseBody?.code === 'number' && responseBody.code !== 0)) { + throw new Error(`Webhook业务响应失败:${responseBody.errcode ?? responseBody.code}`); + } + await this.prisma.signatureRetirementWebhookDelivery.update({ where: { id: delivery.id }, data: { status: 'delivered', deliveredAt: new Date(), lastHttpStatus: response.status, lastError: null } }); + } catch (error) { + const attempts = delivery.attemptCount + 1; + await this.prisma.signatureRetirementWebhookDelivery.update({ where: { id: delivery.id }, data: { status: attempts >= 5 ? 'failed' : 'retrying', nextRetryAt: attempts >= 5 ? null : new Date(Date.now() + Math.min(60 * 60_000, 2 ** attempts * 60_000)), lastError: error instanceof Error ? error.message.slice(0, 500) : String(error).slice(0, 500) } }); + } + } + } + +} + +function selectRule(rules: Array>, dimension: 'enterprise' | 'channel', targetId: string | null) { + const specialType = dimension === 'enterprise' ? 'enterprise_application' : 'channel'; + const globalType = dimension === 'enterprise' ? 'enterprise_global' : 'channel_global'; + return (targetId ? rules.find((rule) => rule.ruleType === specialType && rule.targetId === targetId) : undefined) + ?? rules.find((rule) => rule.ruleType === globalType && rule.targetKey === ''); +} + +function carrierRule(rule: NonNullable, carrier: string) { + if (carrier === 'mobile') return { windowDays: rule.mobileWindowDays, threshold: rule.mobileThreshold }; + if (carrier === 'unicom') return { windowDays: rule.unicomWindowDays, threshold: rule.unicomThreshold }; + return { windowDays: rule.telecomWindowDays, threshold: rule.telecomThreshold }; +} + +function renderMessage(template: string | null, dimension: DetectionDimension, windowDays: number, threshold: number, actual: number) { + const fallback = dimension.dimensionType === 'enterprise' + ? '请通知 {enterprise}:{signature}在{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时进行保签名发送。' + : '{signature}在通道{channel}的{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时通知客户或进行保签名发送。'; + return (template?.trim() || fallback) + .replaceAll('{enterprise}', dimension.tenantName) + .replaceAll('{signature}', dimension.signatureName) + .replaceAll('{channel}', dimension.channelName ?? '-') + .replaceAll('{carrier}', carrierLabels[dimension.carrier] ?? dimension.carrier) + .replaceAll('{days}', String(windowDays)) + .replaceAll('{threshold}', String(threshold)) + .replaceAll('{actual}', String(actual)); +} + +function shanghaiDateKey(date = new Date()) { + return new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit' }).format(date); +} + +export function shanghaiHour(date = new Date()) { + return Number(new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', hour: '2-digit', hour12: false }).format(date)); +} + +export function millisecondsUntilShanghaiHour(now: Date, targetHour: number) { + const target = new Date(`${shanghaiDateKey(now)}T${String(targetHour).padStart(2, '0')}:00:00+08:00`); + if (target.getTime() <= now.getTime()) target.setUTCDate(target.getUTCDate() + 1); + return target.getTime() - now.getTime(); +} + +function assertDateKey(value: string) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(value) || Number.isNaN(new Date(`${value}T00:00:00+08:00`).getTime())) throw new BadRequestException('日期格式必须为YYYY-MM-DD'); + return value; +} + +function addDays(value: string, days: number) { + const date = new Date(`${assertDateKey(value)}T12:00:00+08:00`); + return shanghaiDateKey(new Date(date.getTime() + days * DAY_MS)); +} + +function shanghaiStart(value: string) { + return new Date(`${assertDateKey(value)}T00:00:00+08:00`); +} + +function databaseDate(value: string) { + return new Date(`${assertDateKey(value)}T00:00:00.000Z`); +} + +function parseApprovedAt(value?: string) { + if (!value) return null; + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) throw new BadRequestException('报备通过时间无效'); + return parsed; +} + +function assertRuleType(value: string): asserts value is RetirementRuleType { + if (!['enterprise_global', 'enterprise_application', 'channel_global', 'channel'].includes(value)) throw new BadRequestException('不支持的规则类型'); +} + +function positiveIntegerEnv(name: string, fallback: number) { + const value = Number(process.env[name]); + return Number.isInteger(value) && value > 0 ? value : fallback; +} + +function positiveInteger(value: number | undefined, fallback: number) { + return Number.isInteger(value) && Number(value) > 0 ? Number(value) : fallback; +} + +function isPrismaUniqueError(error: unknown) { + return error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002'; +} + +async function assertSafeWebhookUrl(value: string) { + let url: URL; + try { url = new URL(value); } catch { throw new BadRequestException('Webhook地址无效'); } + if (url.protocol !== 'https:') throw new BadRequestException('Webhook必须使用HTTPS'); + if (url.username || url.password) throw new BadRequestException('Webhook地址不能包含用户名或密码'); + if (url.hostname === 'localhost' || url.hostname.endsWith('.local')) throw new BadRequestException('Webhook地址不能指向本地网络'); + const addresses = await lookup(url.hostname, { all: true }).catch(() => []); + if (!addresses.length) throw new BadRequestException('Webhook域名无法解析'); + if (addresses.some((entry) => isPrivateAddress(entry.address))) throw new BadRequestException('Webhook地址不能指向内网'); +} + +function isPrivateAddress(address: string) { + const normalized = address.toLowerCase(); + if (normalized === '::1' || normalized.startsWith('fe80:') || normalized.startsWith('fc') || normalized.startsWith('fd')) return true; + const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); + if (!match) return false; + const [a, b] = [Number(match[1]), Number(match[2])]; + return a === 10 || a === 127 || a === 0 || (a === 169 && b === 254) || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168); +} + +function maskWebhookUrl(value: string) { + const url = new URL(value); + const suffix = url.pathname.slice(-6); + return `${url.origin}/***${suffix}`; +} diff --git a/api/src/sms-config/signature.service.ts b/api/src/sms-config/signature.service.ts index 6726a78..6bc540e 100644 --- a/api/src/sms-config/signature.service.ts +++ b/api/src/sms-config/signature.service.ts @@ -11,6 +11,7 @@ import { SmsReportValidationService } from './report-validation.service'; import { SmsAuditService } from './audit.service'; import { shanghaiDateRange } from '../common/shanghai-date-range'; import { summarizeReportStatuses } from '../common/report-status'; +import { normalizeChannelCarriers } from '../channels/channels.helpers'; /** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */ export class SmsSignatureService { @@ -84,8 +85,14 @@ export class SmsSignatureService { 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 })); + const tasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature'); + return [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => ( + normalizeChannelCarriers(channel.carriers, channel.carrier).map((carrier) => { + const task = tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === carrier) + ?? tasks.find((candidate) => candidate.channelId === channel.id && candidate.carrier === null && candidate.approvalScope === 'legacy_channel'); + return { channel, channelId: channel.id, carrier, status: task?.status ?? 'pending', taskId: task?.id, approvedAt: task?.approvedAt, approvalScope: task?.approvalScope ?? 'carrier_specific' }; + }) + )); })(), drainageReportTargets: Object.fromEntries(signature.drainageItems.map((drainageItem) => { const drainageItemId = drainageItem.id; @@ -108,16 +115,18 @@ export class SmsSignatureService { const targets = [...new Map(channels.map((channel) => [channel.id, channel])).values()]; 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 carrierTargets = targets.filter((channel) => normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)); const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []); return [carrier, summarizeReportStatuses(statuses)]; }))]; })), carrierReportSummary: Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => { - const configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && (channel.carrier === carrier || channel.carrier === 'all')); + const configured = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted' && normalizeChannelCarriers(channel.carriers, channel.carrier).includes(carrier)); const targets = [...new Map(configured.map((channel) => [channel.id, channel])).values()]; - const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task])); - const statuses = targets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending'); + const signatureTasks = (signature.reportTasks ?? []).filter((task) => task.reportType === 'signature'); + const statuses = targets.map((channel) => signatureTasks.find((task) => task.channelId === channel.id && task.carrier === carrier)?.status + ?? signatureTasks.find((task) => task.channelId === channel.id && task.carrier === null && task.approvalScope === 'legacy_channel')?.status + ?? 'pending'); return [carrier, summarizeReportStatuses(statuses)]; })), }; diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index da50c53..6dd42bd 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -1982,16 +1982,36 @@ - 上述连接请求日志必须在认证响应返回前持久化,日志写入失败时不得把未经审计的连接当作认证成功。系统与操作日志列表继续直接显示`ipAddress`,并为`cmpp_connection.connect_requested`提供“查看详情”按钮,展示上述结构化参数。 - 供应商通道的既有连接操作日志仍保留;客户入站连接使用`cmpp_downstream_connection`资源区分方向。本功能不改变CMPP认证算法、IP白名单、最大连接数或客户连接状态。 -## 暂缓需求:通道支持运营商多选(2026-08-09) +## 通道运营商多选与运营商级签名报备(2026-08-10,本地实现完成、待验收与分阶段发布) -- 本需求当前只记录、不实施,不改变现行`SmsChannel.carrier`单值模型、通道创建/编辑交互、通道组校验、发送选路、报备或生产数据;后续重新启动时必须另行完成影响评估、生产数据迁移方案和兼容发布计划。 +- 2026-08-09暂缓需求已重新纳入“签名清退预警”的前置设计,并已在本地完成兼容实现:`SmsChannel.carriers`保存运营商能力集合,通道管理、通道组校验、发送选路和签名报备均兼容运营商维度。当前修改仍未提交、未推送、未部署,生产数据和生产行为未改变;完整实施步骤和发布门禁见`docs/signature-retirement-alert-design.md`。 - 目标交互为取消“移动、联通、电信、三网”四选一,将通道能力改为“移动、联通、电信”三个复选项,至少选择一个;同时勾选三个运营商等价于现行“三网”,允许只勾选其中两个运营商。 - 该变化只作用于通道本体的运营商能力集合。`SmsChannelGroup.carrier`、`SmsChannelGroupItem.carrier`、`ChannelRouteRule.carrier`及短信号码实际运营商仍保持移动/联通/电信单值;一个通道只有在能力集合包含对应运营商时,才允许加入该运营商通道组并参与选路。 - 同一通道勾选多个运营商时继续共用一个通道单价,不增加分运营商单价;如未来出现分运营商计价需求,必须另立需求并升级为通道运营商明细模型,不能在本需求中隐式扩展。 -- 完整、理想的报备模型为“签名 × 通道 × 运营商”,用于分别记录同一通道在不同运营商下的报备状态;该模型本期明确暂不设计、不实施,现有“签名 × 通道”报备任务及历史记录保持不变。未来启动运营商多选实施前,必须再次确认是否同步升级报备粒度,不得把一个运营商的报备结果无依据地复制为其他运营商结果。 -- 未来生产数据迁移原则为:`mobile→[mobile]`、`unicom→[unicom]`、`telecom→[telecom]`、`all→[mobile,unicom,telecom]`,已删除通道也要保留并迁移历史能力;不得根据当前通道组关联、通道名称或近期流量自动缩减旧`all`通道的能力范围。 -- 未来取消某个已勾选运营商时,如果该通道仍被对应运营商的活动通道组引用,后端必须返回真实影响并阻止保存,不得自动删除通道组成员、路由、报备任务或历史发送记录;新增运营商能力也不得自动加入通道组或自动视为报备通过。 -- 未来迁移必须采用向前兼容的分阶段发布:先增加新能力集合、回填并让后端兼容读取,再开放多选写入。出现两个运营商组合后,旧单值代码无法无损解释该数据,回滚下限必须是已经支持新集合的兼容版本,不能直接回滚到仅识别`mobile/unicom/telecom/all`的旧版本。 +- 签名报备模型同步从“签名 × 通道”升级为“签名 × 通道 × 运营商”。继续以`ChannelSignatureReportTask`保存当前事实、以`ChannelSignatureReportRecord`保存状态轨迹,不另建重复事实表;任务进入`approved`时记录当前连续通过时间,离开通过状态时结束该连续周期。`reportType`和`drainageItemId`只是共享表技术字段,不属于本需求维度;本需求不改造引流信息报备。 +- 历史通道级任务进入人工拆分弹窗时,三个运营商状态必须默认未选择,不得继承原通道级状态;操作人必须逐项选择真实状态,选择“已通过”时必须填写该运营商真实通过时间,前后端都要阻止缺失或非法时间提交。 +- 生产数据迁移原则为:`mobile→[mobile]`、`unicom→[unicom]`、`telecom→[telecom]`、`all→[mobile,unicom,telecom]`,已删除通道也要保留并迁移历史能力;不得根据当前通道组关联、通道名称或近期流量自动缩减旧`all`通道的能力范围。对历史空值使用旧系统实际兼容口径回填为移动,禁止回填为空集合或伪造为三网。 +- 取消某个已勾选运营商时,如果该通道仍被对应运营商的活动通道组引用,后端必须返回真实影响并阻止保存,不得自动删除通道组成员、路由、报备任务或历史发送记录;新增运营商能力也不得自动加入通道组或自动视为报备通过。 +- 发布迁移必须采用向前兼容的分阶段顺序:先增加新能力集合、回填并让后端兼容读取,再开放多选写入。出现两个运营商组合后,旧单值代码无法无损解释该数据,回滚下限必须是已经支持新集合的兼容版本,不能直接回滚到仅识别`mobile/unicom/telecom/all`的旧版本。 + +## 签名清退预警(2026-08-10,本地实现完成、待验收与分阶段发布) + +- 企业预警按“企业签名 × 运营商”每天检测,通道预警按“签名 × 通道 × 运营商”每天检测。运营商只要存在当前真实报备通过任务就进入对应监控名单,不等待三网全部成功;历史通道级结果未按运营商确认前不得伪造运营商通过事实。 +- 企业预警支持移动、联通、电信通用X天/Y条规则和企业应用特殊规则,特殊规则优先;通道预警支持通用规则和通道特殊规则,特殊规则优先。规则修改从下一检测日生效,预警快照保存命中的规则版本和阈值。 +- 清退活跃量按至少有一次上游接受的业务短信去重统计。企业维度同一业务短信只计一次;通道维度按`messageRecordId + channelId`去重,同一通道断连、超时或重试产生多次提交只计一次,切换到不同通道后各通道分别计一次。提交尝试、上游接受和最终送达必须分开展示,不把`SubmitResp status=0`称为最终送达成功。 +- 每天按北京时间完整自然日检测`T-X`至`T-1`。当前连续报备通过时间不足X个完整日时不预警;恢复达标后关闭当前预警周期,以后再次低于阈值形成新周期。每日检测必须以数据库唯一维度保证幂等,多实例或重启不得重复生成消息或Webhook。 +- 自动任务每天北京时间04:00生成检测快照并冻结当日规则版本、标题和消息正文,北京时间08:00才生成站内消息并进入Webhook投递。服务在04:00或08:00之后启动时必须按当前时点补偿对应阶段,仍由数据库唯一键保证幂等;运营页面不提供“执行今日检测”按钮,管理API也不暴露手动检测入口,避免人为提前发消息或混淆自动任务口径。 +- 临时抑制支持常用天数和自定义天数;永久抑制可在“抑制管理”中取消,取消必须二次确认、填写原因并写操作日志。抑制只停止站内提醒和Webhook,不停止每日检测快照;取消后从下一检测日恢复,不补发历史通知。 +- 右上角新增预警铃铛,数字只统计今日未读且未抑制消息;原待审核铃铛更换为任务图标,但原有计数、弹层和跳转不得丢失。安全控制新增“签名清退预警”,展示规则、今日企业/通道预警数量、分页消息列表、日期范围和抑制管理。 +- “今日预警”页签和区块统一更名为“预警消息”。消息列表包含历史消息,默认日期区间的开始、结束均为北京时间今日并只查询今日;支持修改日期区间,并分别按企业、企业应用、签名名称和通道查询。所有条件由真实后端共同作用于分页结果和总数,默认及筛选变化后回到第1页,每页10条,不得前端全量截取伪分页。 +- 预警消息“抑制”必须使用平台自研弹窗,在同一弹窗中选择临时或永久抑制:临时抑制直接选择截止日期,永久抑制不显示日期;两种方式都必须填写原因。抑制管理的“取消抑制”也必须使用自研确认弹窗并填写取消原因,禁止调用浏览器`confirm`或`prompt`。 +- 企业微信和飞书可配置多个Webhook。地址必须加密保存、脱敏显示并经过安全目标校验;投递使用异步队列、幂等键、有限重试和投递日志,企业预警按企业汇总,通道预警按检测批次汇总,不逐条轰炸。 +- “签名质量检测”增加企业“企业签名 × 运营商”和通道“签名 × 通道 × 运营商”近30日方格。页面日期为T,展示`T-1`至`T-30`的提交尝试数、上游接受业务短信数、最终成功数和成功率;尚未报备或早于当前连续通过时间显示“不适用”,无真实提交显示灰色,其余复用现有六档色阶。 +- 页面模块顺序固定为“签名通道发送质量”在最上方,其后依次为企业、通道热力图。两张热力图按维度行各自独立分页,每页10行;翻动其中一张不得改变另一张页码,30日日期列继续在各自表格内横向滚动。 +- 两张热力图的日期列从左到右按日期由大到小展示,即从`T-1`依次到`T-30`。行首主信息只展示签名名称;通道热力图保留识别维度所必需的通道名称和运营商标签,企业名称与企业应用名称不在行内常驻,鼠标悬停签名时再展示。每张热力图内部提供独立搜索框,可按企业名称、企业应用名称或签名名称筛选,并在筛选后回到第一页,不影响另一张热力图。 +- 热力图有真实检测快照的发送量格子悬停文案必须明确区分“提交条数”和“发送成功条数”,同时可补充上游接受条数、成功率和阈值;不得把上游接受或`SubmitResp status=0`写成发送成功。报备前仍显示“不适用”,没有检测快照仍显示当日无快照。 +- 页面最下方增加“未报备签名”模块,按所选北京时间自然日统计已提交到平台且有签名的真实`SmsMessageRecord`。当短信号码运营商没有匹配到该签名当前可用的运营商级报备成功事实,且也没有仍处于通过状态的历史通道级兼容报备事实时,计入未报备短信;运营商级事实可位于任一未删除通道,历史兼容事实仅用于避免把旧系统真实通过误报为未报备。结果按“签名 × 实际企业应用”聚合业务短信条数,展示签名、企业、企业应用,支持按三者搜索和每页10行独立分页;无签名的异常消息不在该模块伪造成签名。 +- 数据统计菜单改名为“签名质量检测”,并删除企业应用发送排行、通道占比、当天发送量和当天成功率模块。本项删除已确认,不作为后续可选项保留。 ## 通道组按通道筛选(2026-08-09) diff --git a/docs/signature-retirement-alert-design.md b/docs/signature-retirement-alert-design.md new file mode 100644 index 0000000..27b73bb --- /dev/null +++ b/docs/signature-retirement-alert-design.md @@ -0,0 +1,190 @@ +# 签名清退预警与运营商级报备改造设计(本地实现稿) + +> 状态:2026-08-10已按确认口径完成本地兼容实现、迁移演练和自动化验证,修改仍未提交、未推送、未部署。严格运营商级发送门禁保持默认关闭;预生产迁移、历史确认、门禁切换和预警启用仍必须分阶段执行。 + +## 0. 实施状态(2026-08-10) + +- 第1至15步已完成本地代码和文档实现;第16步已完成本地PostgreSQL迁移、专项与全量测试、真实聚合SQL、TypeScript、生产构建及授权后的真实验证码登录浏览器验收。 +- 本地迁移保留旧报备任务为`legacy_channel`,不自动复制为三个运营商通过;发送链优先使用精确运营商任务,并由`SIGNATURE_REPORT_STRICT_CARRIER`控制从兼容双读切换到严格门禁。 +- 本地实现未发送真实短信,未修改生产通道、账号、密码、启停状态、企业余额或客户连接;也未提交、推送或部署。 + +## 1. 目标与范围 + +本项目解决运营商因签名长期无真实发送而清退的问题,并将通道能力和签名报备状态细化到真实运营商。最终形成以下闭环: + +1. 通道支持移动、联通、电信多选,三个全选等价于现行“三网”。 +2. 签名报备事实由“签名 × 通道”升级为“签名 × 通道 × 运营商”。 +3. 发送选路只使用支持目标运营商且该签名在该运营商报备通过的通道。 +4. 每日按“企业签名 × 运营商”和“签名 × 通道 × 运营商”检测清退风险。 +5. 提供站内消息、临时/永久抑制、企业微信/飞书 Webhook 和近30日检测数据。 + +本项目不改造引流信息报备。引流信息任务、任务详情、状态汇总和历史记录继续保持现有“签名 × 引流信息 × 通道”口径,不新增运营商维度。 + +## 2. 已确认业务口径 + +- 多运营商通道继续共用一个通道单价,不增加分运营商价格。 +- 一个签名最多关联一个企业应用;未绑定应用的签名只使用通用规则。 +- “部分成功”按运营商分别判断:任一运营商存在报备通过,就只将该运营商纳入监控,不等待三网全部成功。 +- 清退活跃量使用“至少有一次上游接受的去重业务短信数”。企业维度按业务短信去重;通道维度按`messageRecordId + channelId`去重。同一通道因断连、超时或重试产生多次提交只计一个活跃量;切换到另一个通道后,两个通道各计一次。 +- 提交尝试数、上游接受业务短信数和最终送达成功数分别展示,不把`SubmitResp status=0`描述为最终送达成功。 +- 每天按北京时间完整自然日检测`T-X`至`T-1`,当天数据不参与。当前连续报备通过时间未满X个完整日时不预警。 +- 规则修改从下一检测日生效;恢复正常后再次低于阈值形成新的预警周期。 +- 临时抑制天数可配置;永久抑制可以在“抑制管理”中取消。取消后从下一检测日恢复提醒,不补发历史通知。 +- 右上角预警数字为“今日未读且未抑制数”。抑制只影响提醒和Webhook,不停止检测快照生成。 +- 到达率颜色复用现有统一六档色阶。 +- “签名质量检测”页面删除企业应用发送排行、通道占比、当天发送量和当天成功率模块。 + +## 3. 数据模型与事实来源 + +### 3.1 通道运营商能力 + +通道能力从旧单值`mobile/unicom/telecom/all`升级为非空运营商集合。通道组、通道组成员、路由规则和短信号码识别结果继续保持单运营商;只有通道能力集合包含目标运营商时,才能加入对应通道组并参与选路。 + +### 3.2 签名报备任务 + +继续以`ChannelSignatureReportTask`作为当前报备事实,以`ChannelSignatureReportRecord`作为不可变状态轨迹,不另建与任务重复的报备事实表。签名任务的业务唯一维度升级为: + +```text +签名 × 通道 × 运营商 +``` + +签名任务增加运营商、当前连续通过时间和历史兼容范围。任务每次从非通过状态进入`approved`时写入新的`approvedAt`;离开`approved`时清空。记录表继续保存每次状态变化、操作人、时间、来源和原因。 + +`reportType`和`drainageItemId`只是现有共享表的技术字段,不属于本需求业务维度;引流信息任务不增加运营商。 + +### 3.3 企业运营商报备汇总 + +“企业签名 × 运营商”不是另一套可编辑事实,而是运营商级签名任务的只读汇总: + +- 至少一个当前运营商级任务为`approved`时,该签名的该运营商进入监控。 +- 监控起点取当前仍有效的通过任务中最早的`approvedAt`。 +- 当前有效任务全部退出通过时,该运营商退出监控;后续重新通过形成新的连续周期。 + +## 4. 现有生产数据兼容 + +旧单运营商通道能力可无损迁移为单元素集合;旧`all`只表示通道能力覆盖三网,不能证明该签名已在三个运营商分别报备通过。 + +历史签名任务先保留为“历史通道级结果”,不得自动复制为三条运营商级`approved`: + +```text +carrier = null +approvalScope = legacy_channel +页面状态 = 历史通道级通过(运营商未拆分) +``` + +运营端提供“拆分并确认运营商报备结果”,由操作员依据供应商真实信息分别确认移动、联通、电信的状态和通过时间,并记录操作人、依据和备注。供应商明确一次报备三网同时生效时,可以人工批量确认三个运营商并使用相同时间,但系统不得自动推断。 + +兼容期内旧历史任务继续维持现有发送资格,避免上线新字段后中断真实发送;只有全部活动签名和活动通道完成运营商确认并通过数据门禁后,发送链才切换为严格运营商级校验。 + +## 5. 预警规则与生命周期 + +### 5.1 规则优先级 + +- 企业预警:企业应用特殊规则优先于通用规则。 +- 通道预警:通道特殊规则优先于通用规则。 +- 每个规则分别配置移动、联通、电信的X天和Y条,并可启停或继承通用规则。 +- 规则修改保存后记录版本,下一检测日读取新版本;预警快照保存命中的规则版本和阈值,历史结果不随规则变化漂移。 + +### 5.2 预警周期 + +同一检测维度在连续低于阈值期间属于同一个预警周期;每天保存检测快照,但不创建重复周期。恢复达标后关闭当前周期;以后再次低于阈值创建新周期。 + +每日快照必须使用唯一检测日和唯一维度保证幂等。多实例或任务重启不得重复生成预警、未读消息或Webhook。 + +### 5.3 抑制与已读 + +- 临时抑制支持常用天数和自定义天数,到期后的下一检测日自动恢复。 +- 永久抑制在“抑制管理”集中展示,取消时必须二次确认、填写原因并写操作日志。 +- 取消抑制不补发历史站内消息或Webhook。 +- 旧预警只读;最新预警可以进入抑制操作。抑制管理始终可以取消当前抑制。 +- 未读状态与抑制状态独立;右上角只统计今日未读且未抑制的消息。 + +## 6. 30日检测数据 + +- 企业区按“企业签名 × 运营商”展示。 +- 通道区按“签名 × 通道 × 运营商”展示。 +- 日期T由页面日期控件决定,展示`T-1`至`T-30`。 +- 每日展示提交尝试数、上游接受业务短信数、最终送达成功数和最终成功率。 +- 无真实提交显示灰色;尚未报备通过或早于当前连续通过时间显示“不适用”,不得伪装成零发送。 +- 其他非零数据复用现有红、橙、黄、蓝、绿、深绿六档色阶。 + +## 7. 全部实施步骤(共16步) + +### 第1步:设计、需求与测试文档 + +先完成本文、需求文档、规划测试用例和测试进度同步并交由用户评审;用户确认后才进入代码和数据库实现。本步骤已完成。 + +### 第2步:用户评审与口径冻结 + +由用户评审字段、交互、统计、历史兼容、抑制和发布顺序。用户已确认按步骤实施,本步骤已完成。 + +### 第3步:发布基线与真实数据盘点 + +重新核对Git、运行提交、migration、活动通道、通道组、路由、签名任务、历史记录和近30日业务短信数据;制作数据库与代码恢复方案。只读盘点,不发送短信、不修改通道。 + +### 第4步:兼容数据库底座 + +新增通道运营商能力集合;为签名报备任务增加`carrier/approvedAt/approvalScope`等兼容字段和条件唯一约束。旧字段继续可读,迁移只增加能力,不切换发送行为。 + +### 第5步:通道历史能力回填 + +将旧单运营商值回填为单元素集合,将`all`回填为三元素能力集合;已删除通道同样保留并迁移。核对记录数和所有外键关联,不改变账号、密码、状态或单价。 + +### 第6步:通道运营商多选管理 + +改造通道创建、编辑、复制、详情、列表筛选和审计;后端强制至少选择一个运营商。取消仍被活动通道组使用的能力时返回真实影响并阻止保存。 + +### 第7步:运营商级签名报备后端 + +升级签名任务创建、批量生成、状态修改、查询、删除治理和汇总逻辑。运营商必须属于通道能力集合;同一签名、通道、运营商只存在一个当前任务。 + +### 第8步:报备任务与签名状态页面 + +在签名页、报备任务页和通道报备详情页展示运营商级状态;同一通道可按三行或可展开三运营商展示。三网摘要只汇总真实运营商任务,不从通道级状态推断。 + +### 第9步:历史报备拆分确认 + +上线“历史通道级通过(运营商未拆分)”清单和人工拆分流程。完成活动数据确认,保留原任务和全部操作轨迹,禁止自动伪造三网通过。 + +### 第10步:发送链兼容双读 + +发送链优先读取运营商级任务;未完成拆分的历史任务暂时使用受控旧资格。记录每次使用旧资格的可观测指标,为严格切换提供清零门禁。 + +### 第11步:严格运营商级发送门禁 + +只有当活动历史未拆分数、旧资格命中数和异常数据全部为零后,才切换为“签名 × 通道 × 运营商”严格校验。专项验证断连、超时、补发和通道切换,避免误阻断真实业务。 + +### 第12步:预警规则与通知配置 + +实现通用规则、企业应用特殊规则、通道特殊规则、规则版本和多Webhook配置。Webhook地址加密保存、脱敏显示,并限制安全目标。 + +### 第13步:每日检测、预警周期与快照 + +实现北京时间每日幂等任务、企业和通道两类检测、连续周期、恢复关闭、规则快照和近30日聚合。不得以Mock、静态数据或前端计算代替真实数据库结果。 + +调度固定拆为两个阶段:北京时间04:00只完成检测、周期推进和快照落库,同时冻结预警标题与正文;北京时间08:00再按快照创建站内消息、聚合Webhook并开始投递。服务晚启动时按所处时点顺序补偿,04:00前不检测、08:00前不发消息。由于自动调度、启动补偿和数据库幂等已覆盖日常及故障恢复,运营端不再保留手动检测按钮和对外管理接口。 + +### 第14步:抑制、已读和Webhook投递 + +实现临时/永久抑制、抑制管理、取消审计、未读状态、异步Webhook、幂等、失败重试和投递日志。抑制期间继续生成检测快照。 + +### 第15步:预警页面和签名质量检测改版 + +在安全控制增加“签名清退预警”,增加消息列表、今日企业/通道数量和顶部预警铃铛;原待审核铃铛更换为任务图标但保留全部功能。签名质量检测页先展示“签名通道发送质量”,其后增加企业、通道两类30日热力图;两张热力图按维度行各自独立分页、每页10行,日期列仍横向滚动。删除已确认不保留的四个统计模块。 + +预警页签命名为“预警消息”,后端按消息创建时间的北京时间日期区间查询历史消息,并在同一查询中关联检测维度、企业、企业应用、签名和通道完成筛选、计数及每页10条分页;默认区间为今日至今日。抑制和取消抑制复用平台`Modal`,临时抑制以截止日期换算为后端天数,永久抑制不传天数,两者均要求原因;取消也要求原因,不使用浏览器原生弹窗。 + +热力图日期列按`T-1`至`T-30`由新到旧排列;行首只常驻签名以及通道维度必要的通道/运营商标识,企业和企业应用通过签名悬停提示查看。企业、通道模块分别在前端对后端返回的真实维度按企业、企业应用、签名过滤并独立分页。格子悬停明确展示提交条数和最终发送成功条数,不混淆上游接受。 + +页面底部增加独立的未报备签名查询。后端按所选北京时间自然日,以`SmsMessageRecord`为业务短信事实,按短信运营商检查该签名是否存在任一未删除通道上的当前运营商级`approved`任务;仍处于`approved`的历史通道级兼容任务视为已有真实旧报备,避免迁移期误报。其余按签名和消息实际企业应用聚合,后端完成关键字过滤、总数和分页,前端不使用静态数据或全量拉取伪分页。 + +### 第16步:完整验证与分阶段预生产发布 + +依次执行migration演练、真实PostgreSQL聚合、API专项、发送链、长短信、重试/补发、权限、Webhook安全、TypeScript、构建、结构契约和`git diff --check`。发布必须分别设置“兼容底座”“历史确认”“严格发送”“预警启用”门禁,不在一次发布中同时迁移、切换发送和启用预警。 + +## 8. 暂停与回滚门禁 + +- 第2步用户未确认:停止,不实施。 +- 第4至10步可回滚到能够读取运营商集合和历史任务的兼容版本;出现双运营商通道后不得回滚到只识别旧单值的版本。 +- 第11步严格门禁启用后,如出现真实发送资格异常,优先切回兼容双读版本,不回滚或覆盖已经产生的新业务数据。 +- 任一阶段不得为验收发送、补发或重投真实短信,不修改真实通道账号、密码、启停状态、企业余额或客户连接,除非另获明确授权。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 5ffa592..e9ac0ee 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -4488,9 +4488,9 @@ npm run verify:phase8 | TC-ENTERPRISE-REGION-003 | 编辑一个已存省市值暂未出现在当前号段字典的历史企业 | 页面将档案原值补入当前选项并正常显示,未主动修改时不会被清空 | | TC-ENTERPRISE-REGION-004 | 断开字典API后打开新建企业 | 页面明确提示省市字典加载失败,不显示Mock、localStorage或旧的写死选项 | -## 2026-08-09 通道支持运营商多选规划用例(需求暂缓、未执行) +## 2026-08-10 通道运营商多选验收用例(本地自动化与浏览器验收完成) -> 本节仅保存未来验收口径。当前版本不实现运营商多选,以下用例状态均为“暂缓、未执行”,不得据此判定现有系统缺陷或功能已完成。 +> 2026-08-09暂缓需求已重新纳入签名清退预警前置设计并完成本地兼容实现。以下仍是完整验收口径;自动化、真实本地数据库、构建和授权后的本地浏览器结果见本节末尾,未发送真实短信或向外部Webhook投递验收消息。 | 用例编号 | 操作 | 未来预期结果 | | --- | --- | --- | @@ -4499,9 +4499,64 @@ npm run verify:phase8 | TC-CHANNEL-CARRIER-MULTI-003 | 将支持移动和联通但不支持电信的通道分别加入三类通道组并发送对应运营商短信 | 仅允许加入移动、联通通道组;电信组前后端均拒绝;发送链不会把电信短信选到该通道,通道组、路由规则和短信实际运营商仍为单值 | | TC-CHANNEL-CARRIER-MULTI-004 | 取消通道已被活动通道组引用的运营商,再尝试保存 | 后端返回对应真实通道组及影响并阻止保存,不自动删除成员、路由、报备任务或历史数据;解除活动引用后才允许取消 | | TC-CHANNEL-CARRIER-MULTI-005 | 多运营商通道参与移动、联通、电信发送及成本统计 | 三个运营商继续共用通道唯一单价,客户计费和平台成本不因多选被重复计算;本需求不产生分运营商价格 | -| TC-CHANNEL-CARRIER-MULTI-006 | 检查签名报备任务、报备记录和三网汇总 | 当前暂缓方案不得伪造“签名 × 通道 × 运营商”结果;未来实施前必须重新确定报备升级范围,现有“签名 × 通道”历史记录不得删除或复制成多个虚假运营商结果 | +| TC-CHANNEL-CARRIER-MULTI-006 | 检查签名报备任务、报备记录和三网汇总 | 签名任务按“签名 × 通道 × 运营商”保存和汇总;现有“签名 × 通道”历史记录保留为`legacy_channel`范围,不删除、不自动复制为多个运营商通过结果;引流信息报备维度和页面保持不变 | | TC-CHANNEL-CARRIER-MULTI-007 | 分阶段部署兼容底座后写入仅支持两个运营商的通道,再执行回滚演练 | 只能回滚到能够读取运营商集合的兼容版本;仅识别旧单值的代码不得重新上线并将双运营商数据误判为三网或单网 | +## 2026-08-10 运营商级签名报备验收用例(本地自动化与浏览器验收完成) + +| 用例编号 | 操作 | 未来预期结果 | +| --- | --- | --- | +| TC-SIGNATURE-CARRIER-REPORT-001 | 为支持移动和联通的同一通道创建同一签名的报备任务 | 分别产生移动、联通两个独立任务;不产生电信任务;同一签名、通道、运营商不能重复创建当前任务 | +| TC-SIGNATURE-CARRIER-REPORT-002 | 分别修改三个运营商任务状态 | 只改变目标运营商状态并写对应任务记录;签名三网摘要按真实任务分别汇总,不由通道级状态复制 | +| TC-SIGNATURE-CARRIER-REPORT-003 | 任务首次通过、退出通过、再次通过 | `approvedAt`分别记录每次连续通过周期的开始时间;退出通过时旧时间不再作为当前监控起点,全部历史变化保留在记录表 | +| TC-SIGNATURE-CARRIER-REPORT-004 | 打开签名页、任务页和通道报备详情 | 三个入口展示并操作同一份运营商级任务;运营商、状态、当前通过时间、操作轨迹一致 | +| TC-SIGNATURE-CARRIER-REPORT-005 | 查看旧三网通道的历史通过任务 | 显示“历史通道级通过(运营商未拆分)”,不显示为移动/联通/电信分别通过,不进入运营商级清退监控 | +| TC-SIGNATURE-CARRIER-REPORT-006 | 使用“拆分并确认运营商报备结果”分别确认三网状态和时间 | 只按人工确认创建或更新运营商任务,记录操作人、依据和备注;原历史任务及轨迹不删除 | +| TC-SIGNATURE-CARRIER-REPORT-006A | 打开历史通道级已通过任务的“按运营商确认”弹窗,不进行选择 | 三个运营商均默认“请选择”,不得继承为“已通过”;逐项选择前“确认拆分”不可用,选择“已通过”但未填写有效通过时间时前端不可提交且后端直接拒绝 | +| TC-SIGNATURE-CARRIER-REPORT-007 | 兼容期发送一条目标运营商短信 | 优先使用运营商级通过任务;只有未拆分历史任务才走受控兼容资格并留下可统计命中记录 | +| TC-SIGNATURE-CARRIER-REPORT-008 | 历史未拆分数和兼容资格命中数不为0时尝试启用严格门禁 | 后端或发布门禁阻止切换;清零后才允许严格按“签名 × 通道 × 运营商”选路 | +| TC-SIGNATURE-CARRIER-REPORT-009 | 通道断连、提交超时、补发并切换通道 | 每次重新选路都校验目标运营商报备;不选择未报备通道,也不因其他运营商已通过而放行 | +| TC-SIGNATURE-CARRIER-REPORT-010 | 回归引流信息报备创建、状态修改、详情和历史 | 继续按“签名 × 引流信息 × 通道”工作,不出现运营商字段或新增运营商任务,历史数据不变 | + +## 2026-08-10 签名清退预警验收用例(本地自动化与浏览器验收完成) + +| 用例编号 | 操作 | 未来预期结果 | +| --- | --- | --- | +| TC-SIGNATURE-RETIREMENT-001 | 配置三网通用X/Y规则,并为一个企业应用或通道配置特殊规则 | 特殊规则优先于通用规则;保存规则版本,修改后的规则从下一检测日生效 | +| TC-SIGNATURE-RETIREMENT-002 | 同一签名只有移动运营商报备通过 | 只生成移动监控维度;联通、电信不进入名单,不要求三网全部通过 | +| TC-SIGNATURE-RETIREMENT-003 | 报备通过不足X个完整自然日后执行检测 | 不预警;达到X个北京时间完整自然日后才按`T-X`至`T-1`判断 | +| TC-SIGNATURE-RETIREMENT-004 | 同一业务短信在同一通道因断连或超时产生多次提交 | 提交尝试如实展示多次,通道清退活跃量按`messageRecordId + channelId`只计一次,企业活跃量也只计一次 | +| TC-SIGNATURE-RETIREMENT-005 | 同一业务短信从通道A补发到通道B | 企业活跃量只计一次;A、B各自通道活跃量分别计一次;最终成功仍按真实最终回执展示 | +| TC-SIGNATURE-RETIREMENT-006 | 同一检测维度连续多日低于阈值,随后恢复,再次低于阈值 | 连续低量属于同一预警周期并保存每日快照;恢复后关闭周期;再次低量创建新周期 | +| TC-SIGNATURE-RETIREMENT-007 | 重复执行同一检测日任务或两个实例并发执行 | 数据库唯一约束保证快照、周期、未读消息和Webhook均不重复 | +| TC-SIGNATURE-RETIREMENT-008 | 设置自定义临时抑制并跨越到期日 | 抑制期间继续生成检测快照但不产生未抑制提醒或Webhook;到期后的下一检测日自动恢复 | +| TC-SIGNATURE-RETIREMENT-009 | 设置永久抑制后从“抑制管理”取消 | 二次确认、原因和操作日志完整;下一检测日恢复,不补发被抑制期间的历史通知 | +| TC-SIGNATURE-RETIREMENT-010 | 检查右上角计数并将消息标记已读 | 数字只等于今日未读且未抑制数;已读、已抑制及历史日期消息不计入 | +| TC-SIGNATURE-RETIREMENT-011 | 配置多个企业微信和飞书Webhook并触发企业、通道预警 | 企业预警按企业汇总,通道预警按检测批次汇总;地址加密、脱敏,投递异步、幂等、有限重试并保留投递日志 | +| TC-SIGNATURE-RETIREMENT-012 | 配置非法协议、内网地址或不可达Webhook | 非安全目标被阻止;合法但不可达目标按上限重试并终结失败,不阻塞检测事务,也不伪造成功 | +| TC-SIGNATURE-RETIREMENT-013 | 切换页面日期并检查两类30日方格 | T随日期变化,展示`T-1`至`T-30`;企业按签名×运营商,通道按签名×通道×运营商,数据来自真实后端 | +| TC-SIGNATURE-RETIREMENT-014 | 查看尚未报备、报备前、零提交和非零成功率日期 | 尚未报备或报备前显示“不适用”,零提交显示灰色,其他数据按现有六档色阶,悬停数量和成功率与真实聚合一致 | +| TC-SIGNATURE-RETIREMENT-015 | 打开改版后的签名质量检测页面 | 企业应用排行、通道占比、当天发送量和当天成功率已删除,其余保留模块和真实查询不回归 | +| TC-SIGNATURE-RETIREMENT-016 | 检查顶部任务入口和预警入口 | 原待审核入口改为任务图标但计数、弹层和跳转完整;新增预警铃铛进入预警列表,两个计数互不混用 | +| TC-SIGNATURE-RETIREMENT-017 | 分别在北京时间04:00前后、08:00前后运行自动任务,并模拟服务跨过两个时点后重启 | 04:00只生成幂等检测快照且冻结规则版本和消息正文,不产生站内消息/Webhook;08:00才幂等创建站内消息并生成Webhook投递;晚启动按时点顺序补偿且不重复;页面和管理API均不存在手动检测入口 | +| TC-SIGNATURE-RETIREMENT-018 | 打开签名质量检测页,并分别翻动企业、通道热力图 | “签名通道发送质量”位于两张热力图之前;两张热力图各按10个维度分页,页码相互独立,翻页不改变另一张页码,30日列仍可横向滚动且数据与真实API一致 | +| TC-SIGNATURE-RETIREMENT-019 | 检查两张热力图日期、行首和悬停信息 | 日期从左到右为`T-1`至`T-30`;行首不常驻企业和企业应用,悬停签名可看到企业、企业应用;通道维度仍能识别通道和运营商 | +| TC-SIGNATURE-RETIREMENT-020 | 分别在企业、通道热力图搜索企业、企业应用、签名并清空 | 每张热力图只过滤自身真实维度并回到第一页;三类关键字均可命中,清空恢复,另一张热力图的关键字和页码不变 | +| TC-SIGNATURE-RETIREMENT-021 | 悬停报备前、无快照、零量和非零量格子 | 报备前显示不适用;无快照说明当日无检测;真实快照明确显示提交条数、上游接受条数、发送成功条数和成功率,发送成功等于真实最终送达而非受理成功 | +| TC-SIGNATURE-RETIREMENT-022 | 所选日期构造有签名短信:运营商级已通过、历史通道级已通过、无通过任务、仅其他运营商通过、无签名 | 前两类不进入未报备模块;无通过任务和仅其他运营商通过按签名×实际应用计入;无签名记录不伪造成签名行;总条数与真实`SmsMessageRecord`一致 | +| TC-SIGNATURE-RETIREMENT-023 | 在未报备签名模块按企业、企业应用、签名搜索并翻页 | 后端搜索、总数、每页10行和分页结果一致;列表展示签名、企业、实际企业应用和未报备短信条数,修改主统计日期后按新的北京时间自然日重新查询 | +| TC-SIGNATURE-RETIREMENT-024 | 首次打开预警页面,随后选择历史日期区间 | 页签和区块标题均为“预警消息”;默认开始、结束均为今日且只返回今日消息,历史区间返回对应历史消息,每页10条并显示真实总数 | +| TC-SIGNATURE-RETIREMENT-025 | 分别或组合选择企业、企业应用、签名关键字、通道及日期区间并翻页 | 后端同时应用全部条件,列表、总数和页码一致;条件变化查询后回到第1页,企业应用选项受企业筛选约束 | +| TC-SIGNATURE-RETIREMENT-026 | 点击消息“抑制”,分别选择临时截止日期和永久抑制并填写原因 | 只出现平台自研弹窗;临时模式要求未来截止日期,永久模式不显示日期,两种模式原因必填,保存调用真实抑制接口且刷新当前筛选页 | +| TC-SIGNATURE-RETIREMENT-027 | 在抑制管理点击“取消抑制”,填写或不填写原因 | 只出现平台自研弹窗;未填原因不能确认,填写后调用真实取消接口并刷新消息及抑制列表,不出现浏览器`prompt/confirm` | +| TC-REPORT-RECORD-LAYOUT-001 | 在报备记录页面查看长备注和短备注 | 备注列桌面宽度不小于320px,使用统一长文本换行样式;宽表允许内部横向滚动,备注不被其他固定列挤成窄竖列,详情仍展示全文 | + +### 2026-08-10 本地执行状态 + +- 已通过真实本地PostgreSQL迁移和数据约束检查、Prisma校验及84条迁移状态、API全量35个suite/444项测试、发送链112项测试、专项服务测试、API TypeScript构建、前端生产构建、4份Gateway队列结构契约、Gateway全量Go测试和`git diff --check`;检测统计SQL已对真实本地数据库执行,不使用Mock、静态数据或localStorage。 +- 通道能力集合、通道组兼容、运营商级报备、历史任务人工确认、发送资格兼容双读、每日检测幂等、规则版本、预警周期、抑制和Webhook安全边界已有自动化或数据库证据;严格运营商级发送门禁默认不启用,必须在历史未拆分和兼容命中清零后另行切换。 +- 未向外部Webhook投递验收消息,未发送、补发或重投真实短信。经用户授权使用本地专用平台管理员和真实算术验证码登录:预警5个页签、规则及Webhook弹窗、历史拆分、顶部独立计数、通道三运营商复选及零选拦截、运营商级报备文案、两类热力图与已删除统计模块均完成可见验收,控制台日志为0。页面已显示04:00自动检测、08:00发消息口径且不存在手动检测按钮。浏览器发现的历史三网默认全通过问题已修复并复验为三个“请选择”、确认按钮禁用。 + ## 2026-08-09 通道组按通道筛选用例 | 用例编号 | 操作 | 预期结果 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 16c4283..a114484 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -3382,3 +3382,56 @@ git diff --check - Git使用`git revert --no-commit`反向撤销上述两个提交,不使用`reset`或`checkout`覆盖工作区。恢复后的业务源码、需求文档、测试用例和结构契约与`608662a`(即`78b839f4`功能版本加其部署记录)一致,仅追加本回滚记录。 - 回滚后运营统计专项1 suite / 28 tests通过,API正式TypeScript构建、前端TypeScript和Vite v8.1.5生产构建通过(2535 modules,仅既有约2.04MB单chunk提示),依赖安全缓解门禁及`git diff --check`通过。`operations-r2`字节哈希门禁在完全恢复上一版本内容后仍受该文件历史混合CRLF/LF行尾影响而误报,Git归一化内容与`608662a`无差异;未为通过门禁改写上一版结构契约哈希。 - 回滚没有发送、补发或重投真实短信,没有修改数据库记录、企业余额、通道账号、密码、启停状态或客户连接;受保护的构建缓存、`outputs/`和空文件`=`继续不提交、不删除。 + +## 2026-08-10 签名清退预警与运营商级报备设计(待评审、未实施) + +- 已完整阅读用户提供的`C:\Users\hectorzhao\Downloads\签名清退预警.md`,并结合当前真实代码模型和预生产只读聚合重新评估。当前`ChannelSignatureReportTask`事实粒度为“签名 × 通道”,任务和记录均没有运营商字段;现有69条报备任务涉及28个签名、13个通道,38条当前通过任务都能找到通过轨迹,但不能据此自动拆成三网分别通过。 +- 新增评审稿`docs/signature-retirement-alert-design.md`,将完整实施拆为16步,固定先完成设计、需求和规划用例,再经用户评审后进入兼容数据底座。后续必须依次经过通道能力回填、运营商级签名任务、历史人工确认、发送链兼容双读、严格门禁、预警规则、检测快照、抑制/Webhook、页面改版和分阶段发布;不得在一次发布中同时迁移、切换发送和启用预警。 +- 原“通道运营商多选”暂缓需求重新纳入清退预警前置设计,但当前仍为“方案评审中、未实施”。通道目标能力为移动、联通、电信多选且共用一个单价;签名报备计划升级为“签名 × 通道 × 运营商”,继续复用`ChannelSignatureReportTask/Record`,不另建重复事实表。本需求明确不改造引流信息报备,`reportType/drainageItemId`不属于本需求业务维度。 +- 历史`all`通道只迁移为三网能力集合,旧报备任务保留为`legacy_channel`范围并显示“历史通道级通过(运营商未拆分)”;必须由运营人员依据供应商真实结果人工拆分确认,系统不得自动复制为三条运营商通过。严格运营商级发送门禁只能在活动历史未拆分数和兼容资格命中数清零后启用。 +- 已确认清退活跃量口径:企业按上游至少接受一次的业务短信去重,通道按`messageRecordId + channelId`去重;同一通道断连、超时或重试只计一个活跃量,切换到其他通道后各通道分别计一次。提交尝试、上游接受和最终送达分开展示,不把`SubmitResp status=0`称为最终送达成功。 +- 已确认规则下一检测日生效,恢复后再次低量形成新预警周期;临时抑制天数可配置,永久抑制从“抑制管理”取消且不补发历史通知;右上角只展示今日未读且未抑制数;颜色复用现有六档色阶;签名质量检测页面删除企业应用排行、通道占比、当天发送量和当天成功率。 +- `docs/system-functional-test-cases.md`已将原7条运营商多选用例调整为“规划、未执行”,并新增`TC-SIGNATURE-CARRIER-REPORT-001`至`010`、`TC-SIGNATURE-RETIREMENT-001`至`016`。这些用例当前不计入现版本通过率,也不得被解释为代码已完成或当前系统Bug。 +- 本步骤只修改设计、需求、规划测试用例和测试进度文档;未修改源码、Prisma schema或migration,未连接或修改预生产数据,未发送、补发或重投真实短信,未修改通道账号、密码、启停状态、企业余额或客户连接。文件保持未提交、未推送、未部署,等待用户先行评审。 + +## 2026-08-10 签名清退预警与运营商级报备本地实现(未提交、未发布) + +- 用户确认设计后已按16步顺序完成本地最小充分实现:通道运营商多选、通道组及选路能力校验、运营商级签名报备、历史通道级任务人工拆分确认、兼容双读发送资格、清退规则/周期/检测快照、临时与永久抑制、已读消息、Webhook安全投递、顶部独立预警入口和两类30日热力图。引流信息报备保持原维度,未纳入本需求。 +- 修改继续复用`ChannelSignatureReportTask/Record`作为报备事实与轨迹;历史任务保持`legacy_channel`,不得自动伪造三网通过。严格运营商级门禁由`SIGNATURE_REPORT_STRICT_CARRIER=true`显式启用,当前默认关闭,后续必须等活动历史未拆分数和兼容资格命中数清零再分阶段切换。 +- 本地PostgreSQL迁移前已备份到`C:\cmpp-platform-local\backups\cmpp-platform-before-signature-retirement-20260810-165721.dump`(435753字节)。本地已完成84条migration;5条通道中3条历史空运营商按旧系统实际兼容口径回填为`mobile`,迁移后空集合0条、非法集合0条,并增加非空且只允许移动/联通/电信的数据库约束。历史报备任务1条,运营商级任务0条,未自动拆分;检测和开放周期唯一索引已核对。 +- 本地真实PostgreSQL上的清退统计SQL执行成功,返回提交尝试0、上游接受业务短信0、最终送达业务短信0,证明查询可由真实表执行;最终送达按真实分片审计和回执表归属目标通道,不把其他通道补发成功记到原通道,也不把`SubmitResp status=0`当作最终送达。 +- API全量测试35个suite/444项通过;真实Redis启动后发送链112/112通过;通道与清退专项、报备/发送配置/删除治理及发送链相关专项均通过。Prisma schema校验及84条迁移状态、API TypeScript构建、前端Vite生产构建、4份Gateway队列结构契约、Gateway全量Go测试和`git diff --check`通过;前端仅保留既有约2.05MB单chunk提示,差异检查仅输出既有LF/CRLF提示。 +- 本地PostgreSQL 5432、Redis 6379、API 3000和前端4173已启动供验收。API启动时尝试恢复本地活动通道,因未启动Gateway而记录连接失败;未修改任何生产配置,也未发送、补发或重投真实短信。经用户授权重置既有本地专用`codex_local_admin`临时密码、读取真实算术验证码并登录,未新建重复账号。 +- 全部代码、migration和文档均保持未提交、未推送、未部署;受保护的`api/tsconfig.build.tsbuildinfo`、`tsconfig.tsbuildinfo`、`outputs/`和空文件`=`不删除、不提交、不归因于本需求。 +- 登录后浏览器验收发现并修复历史三网任务弹窗默认把移动、联通、电信全部设为“已通过”的问题。修复后所有运营商默认“请选择”,必须逐项确认,且“已通过”必须填写真实有效通过时间;前端禁用不完整提交,后端不再回退使用历史通道级时间或当前时间伪造运营商通过事实。同步新增`TC-SIGNATURE-CARRIER-REPORT-006A`。 +- 修复后重新完成API专项3项、API TypeScript和前端生产构建,并精确重启本轮API/前端进程。浏览器复验历史拆分三项均为“请选择”且确认按钮禁用;通道编辑展示移动/联通/电信三个复选框,三项清空后显示“至少选择一个运营商”且未写入;报备明细显示“历史通道级(未拆分)”;清退预警5个页签、规则/Webhook弹窗、今日检测、顶部独立0条计数和两类30日热力图均正常,已确认删除的四个统计模块未出现,控制台日志为0。没有保存规则、Webhook或历史拆分,没有发送短信。 + +## 2026-08-10 签名清退自动调度与本地验收数据(未提交、未发布) + +- 用户确认改为每天北京时间04:00自动检测、08:00发消息。检测阶段现只推进周期、写幂等快照并冻结规则版本、预警标题和正文;08:00通知阶段才创建站内消息、聚合Webhook并立即触发投递。服务晚启动时按04:00、08:00两个时点顺序补偿,两个阶段均依赖数据库唯一键防重。运营页面“执行今日检测”按钮和`POST /api/admin/signature-retirement/detect`管理接口已删除,内部`runDetection`仅供调度和测试。 +- 本地真实造数新增2家验收企业、2个应用、3条停用验收通道(华东三网、移动联通、电信专线)、4个审核通过签名、6条运营商级报备通过任务和42条历史消息记录,覆盖稳定活跃、低量、零量及跨通道失败后补发四类场景。数据脚本为`tools/local/seed-signature-retirement.mjs`,使用固定`qa-retirement-*`标识,重跑前只清理自身数据,不进入发送队列、不连接真实通道。 +- 真实造数首次暴露旧`ChannelSignatureReportTask_target_key`仍按“签名×通道”唯一、会阻止同通道多运营商事实。migration现明确删除旧索引,并分别建立运营商级签名、历史通道级签名和引流任务三个条件唯一索引;本地数据库已同步调整,成功保存同一签名/通道的移动和联通两条任务。 +- 使用正式`SignatureRetirementService`按时间顺序回放`T-30`至T共31个检测日,生成341条真实检测快照;今天11个维度中9个预警、2个正常,08:00通知阶段幂等生成9条未读站内消息。浏览器真实API显示右上角9条、今日预警9条,列表包含4/8/0等活动量;企业和通道热力图均展示07-11至08-09共30列的真实渐进数据,页面文案明确“04:00自动检测,08:00生成站内消息并发送Webhook”,手动按钮已消失。 +- 分阶段真实数据库复核先删除今天9条验收消息,再重复运行04:00检测,消息数保持0;随后运行08:00通知阶段才恢复9条。最终API全量35个suite/444项、Prisma validate及84条migration状态、前后端生产构建和`git diff --check`通过;三个新条件唯一索引均存在、旧`ChannelSignatureReportTask_target_key`已不存在,同一签名/通道的移动与联通任务可同时保存。造数后浏览器控制台日志为0。 + +## 2026-08-10 签名质量检测模块顺序与热力图分页(未提交、未发布) + +- 按验收反馈将“签名通道发送质量”调整到页面最上方,企业、通道两张30日热力图依次下移;统计接口和真实数据口径不变。 +- 两张热力图分别增加独立的维度行分页,每页10行;各自页码互不影响,30日日期列继续保留表格内横向滚动。同步新增`TC-SIGNATURE-RETIREMENT-018`。 +- 使用Node.js 24.14.0完成前端TypeScript与Vite 8.1.5生产构建(2538 modules,仅既有大chunk提示),`git diff --check`通过且只有既有行尾提示。精确重启本轮本地Vite预览后,以已登录运营账号和真实本地API验收:页面模块顺序为发送质量、企业热力图、通道热力图;两张热力图各自显示一套上一页/下一页和页码输入控件,当前真实造数分别为5、6个维度,均为第1/1页,控制台日志为0。 + +## 2026-08-10 热力图交互优化与未报备签名(未提交、未发布) + +- 企业、通道热力图日期列已调整为从左到右`T-1`至`T-30`;行首只常驻签名、运营商及通道维度必要的通道名称,企业和企业应用改为签名悬停文案。两张热力图分别增加企业、企业应用、签名即时搜索,筛选后各自回到第一页且互不影响;格子悬停明确展示提交、上游接受、发送成功、成功率和阈值。 +- 新增真实后端`GET /api/admin/signature-retirement/unreported-signatures`,按所选北京时间自然日和`SmsMessageRecord`统计。短信实际运营商在任一未删除通道存在当前运营商级通过事实,或仍存在历史通道级通过事实时不计入;其余按签名×消息实际企业应用聚合,后端完成关键字、总数和分页。 +- 本地自清理造数扩展为2家企业、2个应用、3条停用通道、6个签名、7条运营商级报备通过任务和52条消息,新增“完全未报备”和“仅移动报备但提交电信”两类场景;未进入发送队列且未连接真实通道。正式服务回放31个检测日后生成403条检测快照,今天13个维度中11个预警、2个正常,重复通知阶段新增0条,幂等保持。 +- 真实PostgreSQL聚合返回“完全未报备”6条、“仅移动已报备但提交电信”4条;浏览器按企业B搜索后只显示后者4条。企业热力图按企业B搜索只保留3个相关维度,通道热力图仍保留全部7个维度;签名悬停属性显示真实企业和应用,格子悬停属性显示五项明确口径,日期首列为08-09、末列为07-11,控制台error/warn为0。 +- 清退专项7/7、API全量35个suite/446项通过,API与前端TypeScript、API正式构建、Vite 8.1.5生产构建通过(2538 modules,仅既有大chunk提示)。API全量用例本身12.573秒完成,但既有异步句柄使Jest不自行退出,本次使用`--forceExit`收尾并保留该提示;两次外层超时遗留的本轮Jest进程已按精确命令行确认后停止,未影响API、前端、PostgreSQL或Redis。 + +## 2026-08-10 预警消息检索分页、抑制弹窗与备注列宽(未提交、未发布) + +- “今日预警”已调整为“预警消息”,后端按预警日期、企业、企业应用、签名和通道执行真实PostgreSQL筛选及分页;页面默认选中北京时间今日,仅查询今日,支持历史日期区间并固定每页10条。本地回放最近5个检测日后,今日共11条:浏览器验收第1页10条、第2页1条;选择近7天并按“跨通道”签名查询返回15条、2页,可见`2026/8/9 08:00:00`历史消息及真实企业应用名称。 +- 抑制操作已改为自研弹窗,在同一弹窗内选择临时抑制截止日期或永久抑制并填写必填原因;切换永久抑制后截止日期隐藏。取消抑制也使用自研弹窗并要求填写取消原因。浏览器只验证弹窗打开、模式切换和未填原因时确认按钮禁用,没有确认保存或取消任何抑制。 +- 报备记录“备注”列统一使用长文本列规范,桌面端设置为320px并允许表格内部横向滚动;`docs/ui-design-guidelines.md`新增全局约束:长文本列最小240px、建议280–360px并使用`.ui-table__long-text`。浏览器读取“备注”表头计算宽度及最小宽度均为320px。 +- 清退专项9/9、API全量35个suite/448项通过;API与前端TypeScript、API正式构建、Vite 8.1.5生产构建通过(2538 modules,仅既有约2.06MB单chunk提示),`git diff --check`通过且仅输出既有LF/CRLF提示。真实查询回放脚本重复执行新增0条,证明QA消息生成幂等;未发送短信、Webhook,未保存抑制,未修改生产或预生产数据。 +- 本轮代码、测试和文档继续保持未提交、未推送、未部署;受保护的`api/tsconfig.build.tsbuildinfo`、`tsconfig.tsbuildinfo`、`outputs/`和空文件`=`不删除、不提交、不归因于本需求。 diff --git a/docs/ui-design-guidelines.md b/docs/ui-design-guidelines.md index 8fc7c5a..72d8e32 100644 --- a/docs/ui-design-guidelines.md +++ b/docs/ui-design-guidelines.md @@ -63,6 +63,7 @@ - 表头使用浅灰背景,字号 13-14px,字体 600。 - 行高常规 64-86px,复杂两行信息可增加,但避免超过 110px。 - 操作按钮采用文字或图标加文字,危险操作使用红色。 +- 备注、原因、说明、失败信息等不可预测长度的业务文本列必须显式设置列宽:桌面端最小240px,常规建议280-360px;不得省略`TableColumn.width`后任由其被固定信息列挤窄。长文本使用全局`.ui-table__long-text`样式正常换行并允许在任意长单词处断行,完整内容仍应可通过详情查看。宽表因此超过容器时使用表格内部横向滚动,不压缩长文本列到不可读宽度。 ## 表单和弹窗 diff --git a/src/api/admin/channels-reports.api.ts b/src/api/admin/channels-reports.api.ts index 4931039..4cfdfa9 100644 --- a/src/api/admin/channels-reports.api.ts +++ b/src/api/admin/channels-reports.api.ts @@ -81,9 +81,9 @@ export const adminChannelsReportsApi = { listReportTasks: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage' } = {}) => request(withQuery('/admin/report-tasks', query)), listReportTasksPage: (query: { tenantId?: string; status?: string; channelId?: string; reportType?: 'signature' | 'drainage'; keyword?: string; createdAtFrom?: string; createdAtTo?: string; page: number; pageSize: number }) => request>(withQuery('/admin/report-tasks', query)), - createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string; createdById?: string }) => + createReportTask: (body: { tenantId: string; signatureId: string; channelId: string; carrier?: 'mobile' | 'unicom' | 'telecom'; 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; sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report' }) => + changeReportTaskStatuses: (body: { items: Array<{ signatureId: string; channelId: string; carrier?: 'mobile' | 'unicom' | 'telecom'; 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) }), diff --git a/src/api/admin/signature-retirement.api.ts b/src/api/admin/signature-retirement.api.ts new file mode 100644 index 0000000..dd5908e --- /dev/null +++ b/src/api/admin/signature-retirement.api.ts @@ -0,0 +1,41 @@ +import { request, withQuery } from '../core/httpClient'; +import type { + LegacySignatureReportTask, + PagedResult, + SignatureRetirementHeatmapItem, + SignatureRetirementHeatmapDimension, + SignatureRetirementMessage, + SignatureRetirementRule, + SignatureRetirementRuleType, + SignatureRetirementSuppression, + SignatureRetirementWebhook, + UnreportedSignatureItem, +} from '../types'; + +export const adminSignatureRetirementApi = { + getSignatureRetirementConfiguration: () => request<{ rules: SignatureRetirementRule[]; webhooks: SignatureRetirementWebhook[] }>('/admin/signature-retirement/configuration'), + saveSignatureRetirementRule: (body: { + ruleType: SignatureRetirementRuleType; targetId?: string; enabled: boolean; + mobileWindowDays: number; mobileThreshold: number; unicomWindowDays: number; unicomThreshold: number; + telecomWindowDays: number; telecomThreshold: number; messageTemplate?: string; + }) => request('/admin/signature-retirement/rules', { method: 'PUT', body: JSON.stringify(body) }), + createSignatureRetirementWebhook: (body: { name: string; platform: 'wecom' | 'feishu'; url: string }) => + request('/admin/signature-retirement/webhooks', { method: 'POST', body: JSON.stringify(body) }), + deleteSignatureRetirementWebhook: (id: string) => request(`/admin/signature-retirement/webhooks/${id}`, { method: 'DELETE' }), + listSignatureRetirementMessages: (query: { dateFrom?: string; dateTo?: string; dimensionType?: string; tenantId?: string; applicationId?: string; signatureKeyword?: string; channelId?: string; page?: number; pageSize?: number } = {}) => + request>(withQuery('/admin/signature-retirement/messages', query)), + getSignatureRetirementUnreadCount: () => request<{ count: number }>('/admin/signature-retirement/unread-count'), + readSignatureRetirementMessage: (id: string) => request(`/admin/signature-retirement/messages/${id}/read`, { method: 'POST' }), + readAllSignatureRetirementMessagesToday: () => request<{ count: number }>('/admin/signature-retirement/messages/read-all-today', { method: 'POST' }), + suppressSignatureRetirementMessage: (id: string, body: { mode: 'temporary' | 'permanent'; days?: number; reason?: string }) => + request(`/admin/signature-retirement/messages/${id}/suppress`, { method: 'POST', body: JSON.stringify(body) }), + listSignatureRetirementSuppressions: () => request('/admin/signature-retirement/suppressions'), + cancelSignatureRetirementSuppression: (id: string, reason: string) => + request(`/admin/signature-retirement/suppressions/${id}/cancel`, { method: 'POST', body: JSON.stringify({ reason }) }), + getSignatureRetirementHeatmap: (date?: string) => request<{ date: string; dimensions: SignatureRetirementHeatmapDimension[]; items: SignatureRetirementHeatmapItem[] }>(withQuery('/admin/signature-retirement/heatmap', { date })), + getUnreportedSignatures: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) => + request & { date: string }>(withQuery('/admin/signature-retirement/unreported-signatures', query)), + listLegacySignatureReportTasks: () => request('/admin/signature-retirement/legacy-report-tasks'), + confirmLegacySignatureReportTask: (id: string, body: { results: Array<{ carrier: 'mobile' | 'unicom' | 'telecom'; status: string; approvedAt?: string }>; reason?: string }) => + request<{ legacyTaskId: string }>(`/admin/signature-retirement/legacy-report-tasks/${id}/confirm`, { method: 'POST', body: JSON.stringify(body) }), +}; diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index d1e4f6f..36389b5 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -9,6 +9,7 @@ import { adminChannelsReportsApi } from './admin/channels-reports.api'; import { adminOperationsApi } from './admin/operations.api'; import { adminGovernanceApi } from './admin/governance.api'; import { adminFilesApi } from './admin/files.api'; +import { adminSignatureRetirementApi } from './admin/signature-retirement.api'; export const adminApi = { ...adminIdentityApi, @@ -16,4 +17,5 @@ export const adminApi = { ...adminOperationsApi, ...adminGovernanceApi, ...adminFilesApi, + ...adminSignatureRetirementApi, }; diff --git a/src/api/types/channels-reports.ts b/src/api/types/channels-reports.ts index 8b50136..0d5f3ff 100644 --- a/src/api/types/channels-reports.ts +++ b/src/api/types/channels-reports.ts @@ -7,6 +7,7 @@ export type AdminChannel = { code: string; name: string; carrier?: string | null; + carriers?: Array<'mobile' | 'unicom' | 'telecom'>; sendRegion?: string | null; gatewayHost: string; gatewayPort: number; @@ -261,6 +262,9 @@ export type ReportTask = DictionaryItem & { tenantId: string; signatureId: string; channelId: string; + carrier?: 'mobile' | 'unicom' | 'telecom' | null; + approvedAt?: string | null; + approvalScope?: 'carrier_specific' | 'legacy_channel'; reportType?: 'signature' | 'drainage'; drainageItemId?: string | null; status: string; @@ -273,7 +277,7 @@ export type ReportTask = DictionaryItem & { application?: { id: string; name: string } | null; }; drainageInfo?: SmsDrainageInfo | null; - channel?: { id: string; name: string; code: string }; + channel?: { id: string; name: string; code: string; carrier?: string | null; carriers?: Array<'mobile' | 'unicom' | 'telecom'> }; reason?: string | null; createdAt?: string; updatedAt?: string; diff --git a/src/api/types/identity-config.ts b/src/api/types/identity-config.ts index 6c22519..5b01a39 100644 --- a/src/api/types/identity-config.ts +++ b/src/api/types/identity-config.ts @@ -238,7 +238,7 @@ export type ClientSmsSignature = { application?: ClientSmsApplication | null; reportStatus?: string; reportTasks?: Array; - reportTargets?: Array<{ channel: AdminChannel; channelId: string; status: string; taskId?: string }>; + reportTargets?: Array<{ channel: AdminChannel; channelId: string; carrier: 'mobile' | 'unicom' | 'telecom'; status: string; taskId?: string; approvedAt?: string | null; approvalScope?: 'carrier_specific' | 'legacy_channel' }>; carrierReportSummary?: Record<'mobile' | 'unicom' | 'telecom', { status: string; approved: number; total: number }>; drainageReportTargets?: Record>; drainageCarrierReportSummary?: Record>; diff --git a/src/api/types/index.ts b/src/api/types/index.ts index cd332eb..57035a6 100644 --- a/src/api/types/index.ts +++ b/src/api/types/index.ts @@ -3,3 +3,4 @@ export * from './identity-config'; export * from './channels-reports'; export * from './operations'; export * from './governance'; +export * from './signature-retirement'; diff --git a/src/api/types/signature-retirement.ts b/src/api/types/signature-retirement.ts new file mode 100644 index 0000000..2e23b62 --- /dev/null +++ b/src/api/types/signature-retirement.ts @@ -0,0 +1,108 @@ +import type { AdminChannel, ReportTask } from './channels-reports'; + +export type SignatureRetirementRuleType = 'enterprise_global' | 'enterprise_application' | 'channel_global' | 'channel'; +export type SignatureRetirementCarrier = 'mobile' | 'unicom' | 'telecom'; + +export type SignatureRetirementRule = { + id: string; + ruleType: SignatureRetirementRuleType; + targetId?: string | null; + targetKey: string; + enabled: boolean; + mobileWindowDays: number; + mobileThreshold: number; + unicomWindowDays: number; + unicomThreshold: number; + telecomWindowDays: number; + telecomThreshold: number; + messageTemplate?: string | null; + version: number; + updatedAt: string; +}; + +export type SignatureRetirementWebhook = { + id: string; + name: string; + platform: 'wecom' | 'feishu'; + urlMasked: string; + status: string; + createdAt: string; +}; + +export type SignatureRetirementDetection = { + id: string; + detectionDate: string; + dimensionType: 'enterprise' | 'channel'; + tenantId: string; + applicationId?: string | null; + signatureId: string; + channelId?: string | null; + carrier: SignatureRetirementCarrier; + windowDays: number; + threshold: number; + submittedAttempts: number; + acceptedBusinessCount: number; + deliveredBusinessCount: number; + approvedAt: string; + status: 'alert' | 'healthy'; + suppressed: boolean; +}; + +export type SignatureRetirementMessage = { + id: string; + detectionId: string; + cycleId: string; + tenantId: string; + title: string; + content: string; + isRead: boolean; + suppressed: boolean; + createdAt: string; + detection?: SignatureRetirementDetection; + signatureName?: string; + channelName?: string | null; + tenantName?: string; + applicationName?: string; +}; + +export type SignatureRetirementSuppression = { + id: string; + dimensionType: 'enterprise' | 'channel'; + signatureId: string; + channelId?: string | null; + carrier: SignatureRetirementCarrier; + mode: 'temporary' | 'permanent'; + muteUntil?: string | null; + reason?: string | null; + updatedAt: string; +}; + +export type SignatureRetirementHeatmapItem = SignatureRetirementDetection & { + signatureName?: string; + channelName?: string | null; + tenantName?: string; +}; + +export type SignatureRetirementHeatmapDimension = { + dimensionType: 'enterprise' | 'channel'; + signatureId: string; + channelId: string | null; + carrier: SignatureRetirementCarrier; + approvedAt: string; + signatureName: string; + channelName: string | null; + tenantName: string; + applicationName?: string | null; +}; + +export type UnreportedSignatureItem = { + signatureId: string; + signatureName: string; + tenantId: string; + tenantName: string; + applicationId?: string | null; + applicationName?: string | null; + messageCount: number; +}; + +export type LegacySignatureReportTask = ReportTask & { channel: AdminChannel }; diff --git a/src/apps/admin/AdminAnalyticsPage.tsx b/src/apps/admin/AdminAnalyticsPage.tsx index 7a1d68e..203d4c0 100644 --- a/src/apps/admin/AdminAnalyticsPage.tsx +++ b/src/apps/admin/AdminAnalyticsPage.tsx @@ -1,15 +1,17 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useDeferredValue, useEffect, useState } from 'react'; import { BarChart3, Eye, Search, X } from 'lucide-react'; import { adminApi, - type SendQualityResponse, type SignatureChannelCarrierQualityStat, type SignatureChannelQualityItem, type SignatureChannelQualityResponse, + type SignatureRetirementHeatmapItem, + type SignatureRetirementHeatmapDimension, + type UnreportedSignatureItem, + type PagedResult, } from '@/api/adminApi'; -import { Breadcrumb, Button, Chart, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui'; -import { createBarOption, createPieOption } from '@/theme/chartOptions'; -import { successRateClassName } from '@/utils/successRate'; +import { Breadcrumb, Button, Input, Pagination, Table, Tag, type TableColumn } from '@/components/ui'; +import { successRateClassName, successRateTone } from '@/utils/successRate'; const carrierOrder = ['mobile', 'unicom', 'telecom', 'unknown']; const majorCarrierOrder = ['mobile', 'unicom', 'telecom'] as const; @@ -27,10 +29,14 @@ const carrierLabels: Record = { export function AdminAnalyticsPage() { const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey()); - const [quality, setQuality] = useState(null); const [signatureQuality, setSignatureQuality] = useState(null); + const [retirementHeatmap, setRetirementHeatmap] = useState([]); + const [retirementDimensions, setRetirementDimensions] = useState([]); + const [unreportedSignatures, setUnreportedSignatures] = useState<(PagedResult & { date: string }) | null>(null); const [signatureKeyword, setSignatureKeyword] = useState(''); const [appliedKeyword, setAppliedKeyword] = useState(''); + const [unreportedKeyword, setUnreportedKeyword] = useState(''); + const [appliedUnreportedKeyword, setAppliedUnreportedKeyword] = useState(''); const [selectedSignature, setSelectedSignature] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); @@ -38,17 +44,20 @@ export function AdminAnalyticsPage() { async function loadData(page = 1, keyword = appliedKeyword) { setLoading(true); try { - const [qualityData, signatureData] = await Promise.all([ - adminApi.getSendQuality(statisticsDate), + const [signatureData, heatmapData, unreportedData] = await Promise.all([ adminApi.getSignatureQuality({ date: statisticsDate, keyword: keyword || undefined, page, pageSize: 10, }), + adminApi.getSignatureRetirementHeatmap(statisticsDate), + adminApi.getUnreportedSignatures({ date: statisticsDate, keyword: appliedUnreportedKeyword || undefined, page: 1, pageSize: 10 }), ]); - setQuality(qualityData); setSignatureQuality(signatureData); + setRetirementHeatmap(heatmapData.items); + setRetirementDimensions(heatmapData.dimensions); + setUnreportedSignatures(unreportedData); setAppliedKeyword(keyword); setSelectedSignature((current) => current ? signatureData.items.find((item) => item.signatureId === current.signatureId) ?? null @@ -79,15 +88,7 @@ export function AdminAnalyticsPage() { }; }, [selectedSignature]); - const applicationOption = useMemo(() => createBarOption({ - labels: quality?.applications.map((item) => item.applicationName) ?? [], - series: [{ name: '发送量', data: quality?.applications.map((item) => item.total) ?? [] }], - }), [quality]); - - const channelOption = useMemo(() => createPieOption({ - data: quality?.channels.map((item) => ({ name: item.channelName || item.channelId, value: item.total })) ?? [], - }), [quality]); - const effectiveDate = quality?.date ?? statisticsDate; + const effectiveDate = signatureQuality?.date ?? statisticsDate; const signatureColumns: Array> = [ { @@ -168,14 +169,34 @@ export function AdminAnalyticsPage() { } function changeSignaturePage(page: number) { - void loadData(page, appliedKeyword); + setLoading(true); + void adminApi.getSignatureQuality({ date: statisticsDate, keyword: appliedKeyword || undefined, page, pageSize: 10 }) + .then((data) => { + setSignatureQuality(data); + setError(''); + }) + .catch((failure: unknown) => setError(failure instanceof Error ? failure.message : '签名发送质量加载失败')) + .finally(() => setLoading(false)); + } + + function loadUnreportedSignatures(page: number, keyword = appliedUnreportedKeyword) { + setLoading(true); + void adminApi.getUnreportedSignatures({ date: statisticsDate, keyword: keyword || undefined, page, pageSize: 10 }) + .then((data) => { + setUnreportedSignatures(data); + setAppliedUnreportedKeyword(keyword); + setError(''); + }) + .catch((failure: unknown) => setError(failure instanceof Error ? failure.message : '未报备签名加载失败')) + .finally(() => setLoading(false)); } return (
- + +

签名质量检测

{error ?

{error}

: null} -
-
- {effectiveDate} 发送量 - {quality?.summary.total.toLocaleString('zh-CN') ?? 0} - 所选日期真实消息记录 -
-
- {effectiveDate} 成功率 - {(quality?.summary.successRate ?? 0).toFixed(1)}% - {quality?.summary.successCount.toLocaleString('zh-CN') ?? 0} 条已送达 / {quality?.summary.total.toLocaleString('zh-CN') ?? 0} 条发送 -
-
- -
-
-
-
-

企业应用发送排行

-

{effectiveDate} 当天按企业应用名称聚合真实短信消息记录。

-
- 企业应用 -
- -
-
-
-
-

通道占比

-

{effectiveDate} 当天按真实通道提交及回执聚合。

-
- 通道 -
- -
-
-
@@ -277,6 +262,18 @@ export function AdminAnalyticsPage() { ) : null}
+ + + + loadUnreportedSignatures(page)} + onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())} + /> + {selectedSignature ? ( item.dimensionType === dimensionType); + const dates = previousDateKeys(date, 30); + const rows = dimensions + .filter((item) => item.dimensionType === dimensionType) + .filter((item) => !deferredKeyword || [item.tenantName, item.applicationName, item.signatureName] + .some((value) => value?.toLocaleLowerCase('zh-CN').includes(deferredKeyword))) + .map((item) => ({ + key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`, + signatureName: item.signatureName, + channelName: item.channelName, + tenantName: item.tenantName, + applicationName: item.applicationName, + carrier: item.carrier, + approvedAt: item.approvedAt.slice(0, 10), + })); + const totalPages = Math.max(1, Math.ceil(rows.length / pageSize)); + const currentPage = Math.min(page, totalPages); + const pagedRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize); + const cellMap = new Map(visible.map((item) => [`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${item.detectionDate.slice(0, 10)}`, item])); + + useEffect(() => { + setPage(1); + }, [date, deferredKeyword, dimensionType, dimensions.length]); + + return ( +
+
+
+

{title}

+

数字为真实受理业务短信数;零提交为灰色,非零按现有六档成功率色阶展示。

+
+
+ setKeyword(event.target.value)} + placeholder="搜索企业、企业应用或签名" + value={keyword} + /> + T-1 至 T-30 +
+
+ {rows.length ? ( + <> +
+ + + {dates.map((dateKey) => )} + + + {pagedRows.map((row) => ( + + + {dates.map((dateKey) => { + const item = cellMap.get(`${row.key}:${dateKey}`); + const beforeApproval = dateKey < row.approvedAt; + const successRate = item?.acceptedBusinessCount ? item.deliveredBusinessCount / item.acceptedBusinessCount * 100 : 0; + const className = beforeApproval ? 'is-inapplicable' : !item ? '' : item.acceptedBusinessCount === 0 ? 'is-zero' : `is-rate-${successRateTone(successRate)}`; + const titleText = beforeApproval ? '报备前,不适用' : item ? `提交条数:${item.submittedAttempts} 条\n上游接受条数:${item.acceptedBusinessCount} 条\n发送成功条数:${item.deliveredBusinessCount} 条\n发送成功率:${successRate.toFixed(1)}%\n预警阈值:${item.threshold} 条` : '当日无检测快照'; + return ; + })} + + ))} + +
签名维度{dateKey.slice(5)}
+ + {row.signatureName} + {row.channelName ? {row.channelName} : null} + + {carrierLabels[row.carrier] ?? row.carrier} + {beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}
+
+ = totalPages} + onNext={() => setPage(currentPage + 1)} + onPageChange={setPage} + onPrevious={() => setPage(currentPage - 1)} + page={currentPage} + previousDisabled={currentPage <= 1} + total={rows.length} + totalPages={totalPages} + /> + + ) :

{deferredKeyword ? '没有匹配企业、企业应用或签名的热力图维度。' : '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}

} +
+ ); +} + +function UnreportedSignaturesCard({ + data, + keyword, + loading, + onKeywordChange, + onPageChange, + onSearch, +}: { + data: (PagedResult & { date: string }) | null; + keyword: string; + loading: boolean; + onKeywordChange: (value: string) => void; + onPageChange: (page: number) => void; + onSearch: () => void; +}) { + const columns: Array> = [ + { key: 'signatureName', title: '短信签名', render: (record) => {record.signatureName} }, + { key: 'tenantName', title: '企业名称', render: (record) => record.tenantName }, + { key: 'applicationName', title: '企业应用', render: (record) => record.applicationName || '未关联企业应用' }, + { key: 'messageCount', title: '未报备短信', align: 'right', width: '150px', render: (record) => `${record.messageCount.toLocaleString('zh-CN')} 条` }, + ]; + const totalPages = Math.max(1, Math.ceil((data?.total ?? 0) / (data?.pageSize ?? 10))); + return ( +
+
+
+

未报备签名

待处理
+

{data?.date ?? '所选日期'} 已进入平台、但短信运营商没有匹配报备成功事实的业务短信。

+
+
+ onKeywordChange(event.target.value)} + onKeyDown={(event) => { if (event.key === 'Enter') onSearch(); }} + placeholder="搜索签名、企业或企业应用" + value={keyword} + /> + +
+
+
统计说明:每条业务短信只计一次;运营商级或仍有效的历史兼容报备已通过时不计入。
+ `${record.signatureId}:${record.applicationId ?? ''}`} + /> + {(data?.total ?? 0) > 0 ? ( + = totalPages} + onNext={() => onPageChange((data?.page ?? 1) + 1)} + onPageChange={onPageChange} + onPrevious={() => onPageChange((data?.page ?? 1) - 1)} + page={data?.page ?? 1} + previousDisabled={(data?.page ?? 1) <= 1} + total={data?.total ?? 0} + totalPages={totalPages} + /> + ) : null} + + ); +} + function SignatureQualityDrawer({ date, item, @@ -507,3 +660,8 @@ function shanghaiDateKey(value = new Date()) { const byType = new Map(parts.map((part) => [part.type, part.value])); return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`; } + +function previousDateKeys(endKey: string, days: number) { + const end = new Date(`${endKey}T12:00:00+08:00`); + return Array.from({ length: days }, (_, index) => shanghaiDateKey(new Date(end.getTime() - (index + 1) * 86_400_000))); +} diff --git a/src/apps/admin/AdminChannelGroupFormPage.tsx b/src/apps/admin/AdminChannelGroupFormPage.tsx index 0d3fddd..c29a1ab 100644 --- a/src/apps/admin/AdminChannelGroupFormPage.tsx +++ b/src/apps/admin/AdminChannelGroupFormPage.tsx @@ -53,8 +53,8 @@ function normalizeRegion(region?: string | null) { return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim(); } -function isCarrierCompatible(channelCarrier: string | null | undefined, carrier: Carrier) { - return !channelCarrier || channelCarrier === 'all' || channelCarrier === carrier; +function isCarrierCompatible(channel: AdminChannel, carrier: Carrier) { + return channel.carriers?.length ? channel.carriers.includes(carrier) : !channel.carrier || channel.carrier === 'all' || channel.carrier === carrier; } function getChannelStatus(channel?: AdminChannel): ChannelStatus { @@ -95,14 +95,14 @@ function RouteConfigModal({ const provinceOptions = [ { label: '请选择省份', value: '' }, ...Array.from(new Set(channels - .filter((channel) => isCarrierCompatible(channel.carrier, carrier)) + .filter((channel) => isCarrierCompatible(channel, carrier)) .map((channel) => channel.sendRegion) .filter((region): region is string => Boolean(region && normalizeRegion(region) !== '全国')), )).sort().map((region) => ({ label: region, value: region })), ]; const selectableChannels = channels.filter((channel) => { - if (!isCarrierCompatible(channel.carrier, carrier)) return false; + if (!isCarrierCompatible(channel, carrier)) return false; if (channel.id !== modal.route?.channelId && occupiedChannelIds.includes(channel.id)) return false; if (modal.type === 'province' && province) { return normalizeRegion(channel.sendRegion) === normalizeRegion(province); @@ -112,7 +112,7 @@ function RouteConfigModal({ const channelOptions = [ { label: '请选择', value: '' }, ...selectableChannels.map((channel) => ({ - label: `${channel.name}(${channel.code}) / ${channel.carrier ?? '未标记'} / ${channel.sendRegion ?? '全国'}`, + label: `${channel.name}(${channel.code}) / ${(channel.carriers?.length ? channel.carriers : [channel.carrier ?? '未标记']).join('、')} / ${channel.sendRegion ?? '全国'}`, value: channel.id, })), ]; diff --git a/src/apps/admin/AdminChannelReportPage.tsx b/src/apps/admin/AdminChannelReportPage.tsx index e2b2026..97b358b 100644 --- a/src/apps/admin/AdminChannelReportPage.tsx +++ b/src/apps/admin/AdminChannelReportPage.tsx @@ -147,7 +147,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, sourceEntry: 'channel_report' }) + adminApi.changeReportTaskStatuses({ items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, carrier: statusTask.carrier ?? undefined, 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 || '报备状态保存失败')); } @@ -182,10 +182,10 @@ export function AdminChannelReportPage() { {visibleTasks.length === 0 ?
当前通道暂无真实报备任务
: visibleTasks.map((task) => { const signature = signatureMap.get(task.signatureId); const drainage = task.reportType === 'drainage' ? drainageItems(signature).find((item) => String(item.id) === task.drainageItemId) : undefined; - const reportedAt = approvedRecord(task.id)?.createdAt; + const reportedAt = task.approvedAt ?? approvedRecord(task.id)?.createdAt; return
-
{drainage ? : null}{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : signature?.tenant?.name ?? task.tenantId}
+
{drainage ? : null}{drainage ? String(drainage.siteName || drainage.url || '引流信息') : formatSignatureName(signature?.name ?? task.signature?.name)}{drainage ? `${String(drainage.url ?? '')} · ${formatSignatureName(signature?.name ?? task.signature?.name)}` : `${signature?.tenant?.name ?? task.tenantId} · ${task.carrier ? ({ mobile: '移动', unicom: '联通', telecom: '电信' } as const)[task.carrier] : '历史通道级(未拆分)'}`}
diff --git a/src/apps/admin/AdminReportRecordsPage.tsx b/src/apps/admin/AdminReportRecordsPage.tsx index aacdf39..296209a 100644 --- a/src/apps/admin/AdminReportRecordsPage.tsx +++ b/src/apps/admin/AdminReportRecordsPage.tsx @@ -105,7 +105,7 @@ export function AdminReportRecordsPage() { { key: 'action', title: '动作', width: '170px', render: (record) => actionLabel[record.action] ?? record.action }, { key: 'status', title: '状态变化', width: '210px', render: (record) => {`${translateStatus(record.statusBefore)} → ${translateStatus(record.statusAfter)}`} }, { key: 'time', title: '记录时间', width: '190px', render: (record) => record.createdAt ?? '-' }, - { key: 'reason', title: '备注', render: (record) => record.reason ?? '-' }, + { key: 'reason', title: '备注', width: '320px', render: (record) => {record.reason ?? '-'} }, { key: 'actions', title: '操作', align: 'right', width: '120px', render: (record) => }, ]; diff --git a/src/apps/admin/AdminReportTasksPage.tsx b/src/apps/admin/AdminReportTasksPage.tsx index 733ffc0..bca09c1 100644 --- a/src/apps/admin/AdminReportTasksPage.tsx +++ b/src/apps/admin/AdminReportTasksPage.tsx @@ -39,6 +39,8 @@ function TaskDetailModal({ task, onClose }: { task: ReportTask; onClose: () => v
企业{task.signature?.tenant?.name ?? task.tenantId}
企业应用{task.signature?.application?.name ?? '未指定应用'}
通道{task.channel?.name ?? task.channelId}
+ {task.reportType !== 'drainage' ?
运营商{task.carrier ? ({ mobile: '移动', unicom: '联通', telecom: '电信' } as const)[task.carrier] : '历史通道级(未拆分)'}
: null} + {task.reportType !== 'drainage' ?
当前通过时间{task.approvedAt ? formatDateTime(task.approvedAt) : '-'}
: null}
当前状态{status.label}
创建时间{formatDateTime(task.createdAt)}
最后更新时间{formatDateTime(task.updatedAt)}
@@ -107,6 +109,7 @@ export function AdminReportTasksPage() { items: [{ signatureId: statusTask.signatureId, channelId: statusTask.channelId, + carrier: statusTask.carrier ?? undefined, reportType: statusTask.reportType, drainageItemId: statusTask.drainageItemId ?? undefined, status: nextStatus, @@ -127,7 +130,7 @@ export function AdminReportTasksPage() { const columns: Array> = [ { key: 'target', title: '报备对象', render: (record) =>
{taskTargetLabel(record)}
{record.reportType === 'drainage' ? '引流信息' : '签名'} · {record.signature?.tenant?.name ?? record.tenantId}
}, { key: 'application', title: '企业应用', render: (record) => record.signature?.application?.name ?? '未指定应用' }, - { key: 'channel', title: '通道', render: (record) => record.channel?.name ?? record.channelId }, + { key: 'channel', title: '通道/运营商', render: (record) =>
{record.channel?.name ?? record.channelId}{record.reportType !== 'drainage' ?
{record.carrier ? ({ mobile: '移动', unicom: '联通', telecom: '电信' } as const)[record.carrier] : '历史通道级(未拆分)'}
: null}
}, { key: 'batch', title: '批次/版本', render: (record) => { const source = record.exportItems?.[0]; return source ?
{source.batchItem.batch.batchNo}
V{source.batchItem.materialVersion} · 第{source.rowNumber}行
: '-'; @@ -142,7 +145,7 @@ export function AdminReportTasksPage() { ]; return
-

签名与引流信息报备明细

一条明细对应一个签名或引流信息在一个具体通道上的当前报备状态。

+

签名与引流信息报备明细

签名明细对应一个签名在具体通道和运营商下的当前状态;引流信息继续按具体通道展示。

{error ?

{error}

: null}
setKeyword(event.target.value)} placeholder="搜索报备明细" value={keyword} /> diff --git a/src/apps/admin/AdminSignatureRetirementPage.tsx b/src/apps/admin/AdminSignatureRetirementPage.tsx new file mode 100644 index 0000000..d271988 --- /dev/null +++ b/src/apps/admin/AdminSignatureRetirementPage.tsx @@ -0,0 +1,291 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Check, Plus, RefreshCw, Search, Settings2, ShieldOff, Trash2 } from 'lucide-react'; +import { + adminApi, + type AdminChannel, + type EnterpriseApplication, + type LegacySignatureReportTask, + type SignatureRetirementMessage, + type SignatureRetirementRule, + type SignatureRetirementRuleType, + type SignatureRetirementSuppression, + type SignatureRetirementWebhook, + type TenantOption, +} from '@/api/adminApi'; +import { Breadcrumb, Button, DateRangeInput, Input, Modal, Pagination, Select, Table, Tabs, Tag, Textarea, type DateRangeValue, type TableColumn } from '@/components/ui'; + +const carriers = ['mobile', 'unicom', 'telecom'] as const; +const carrierLabels = { mobile: '移动', unicom: '联通', telecom: '电信' }; +const ruleTypeLabels: Record = { + enterprise_global: '企业全局规则', enterprise_application: '企业应用特殊规则', channel_global: '通道全局规则', channel: '通道特殊规则', +}; +const statusOptions = [ + { value: 'pending', label: '待处理' }, { value: 'waiting_material', label: '待材料' }, { value: 'reporting', label: '报备中' }, + { value: 'approved', label: '已通过' }, { value: 'failed', label: '失败' }, { value: 'rejected', label: '驳回' }, { value: 'abandoned', label: '已放弃' }, +]; + +type RuleDraft = { + ruleType: SignatureRetirementRuleType; targetId: string; enabled: boolean; + mobileWindowDays: string; mobileThreshold: string; unicomWindowDays: string; unicomThreshold: string; + telecomWindowDays: string; telecomThreshold: string; messageTemplate: string; +}; + +const emptyRule: RuleDraft = { + ruleType: 'enterprise_global', targetId: '', enabled: true, + mobileWindowDays: '30', mobileThreshold: '1', unicomWindowDays: '30', unicomThreshold: '1', + telecomWindowDays: '30', telecomThreshold: '1', messageTemplate: '', +}; + +type MessageFilters = { + dateRange: DateRangeValue; + tenantId: string; + applicationId: string; + signatureKeyword: string; + channelId: string; +}; + +type SuppressionDraft = { + messageId: string; + mode: 'temporary' | 'permanent'; + muteUntil: string; + reason: string; +}; + +function defaultMessageFilters(): MessageFilters { + const today = shanghaiDateKey(); + return { dateRange: { start: today, end: today }, tenantId: '', applicationId: '', signatureKeyword: '', channelId: '' }; +} + +export function AdminSignatureRetirementPage() { + const [rules, setRules] = useState([]); + const [webhooks, setWebhooks] = useState([]); + const [messages, setMessages] = useState([]); + const [suppressions, setSuppressions] = useState([]); + const [legacyTasks, setLegacyTasks] = useState([]); + const [applications, setApplications] = useState([]); + const [channels, setChannels] = useState([]); + const [tenants, setTenants] = useState([]); + const [messageTotal, setMessageTotal] = useState(0); + const [messagePage, setMessagePage] = useState(1); + const [messageFilters, setMessageFilters] = useState(defaultMessageFilters); + const [appliedMessageFilters, setAppliedMessageFilters] = useState(defaultMessageFilters); + const [suppressionDraft, setSuppressionDraft] = useState(null); + const [cancelSuppressionDraft, setCancelSuppressionDraft] = useState<{ id: string; reason: string } | null>(null); + const [actionError, setActionError] = useState(''); + const [ruleDraft, setRuleDraft] = useState(null); + const [webhookOpen, setWebhookOpen] = useState(false); + const [webhookDraft, setWebhookDraft] = useState({ name: '', platform: 'wecom' as 'wecom' | 'feishu', url: '' }); + const [legacyDraft, setLegacyDraft] = useState(null); + const [legacyResults, setLegacyResults] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + + const loadData = useCallback(async (targetPage = 1, filters = defaultMessageFilters()) => { + setLoading(true); + try { + const [configuration, messageResult, activeSuppressions, history, applicationRows, channelRows, tenantRows] = await Promise.all([ + adminApi.getSignatureRetirementConfiguration(), + adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters)), + adminApi.listSignatureRetirementSuppressions(), adminApi.listLegacySignatureReportTasks(), + adminApi.listEnterpriseApplications(), adminApi.listChannels(), adminApi.listTenants(), + ]); + setRules(configuration.rules); setWebhooks(configuration.webhooks.filter((item) => item.status === 'active')); + setMessages(messageResult.items); setMessageTotal(messageResult.total); setMessagePage(messageResult.page); + setSuppressions(activeSuppressions); setLegacyTasks(history); + setApplications(applicationRows); setChannels(channelRows); setTenants(tenantRows); setError(''); + } catch (failure) { + setError(failure instanceof Error ? failure.message : '签名清退预警数据加载失败'); + } finally { setLoading(false); } + }, []); + + useEffect(() => { void loadData(1, defaultMessageFilters()); }, [loadData]); + + async function loadMessages(targetPage: number, filters: MessageFilters) { + setLoading(true); + try { + const result = await adminApi.listSignatureRetirementMessages(messageQuery(targetPage, filters)); + setMessages(result.items); setMessageTotal(result.total); setMessagePage(result.page); setError(''); + } catch (failure) { + setError(errorMessage(failure, '预警消息加载失败')); + } finally { setLoading(false); } + } + + const ruleColumns: Array> = [ + { key: 'type', title: '规则范围', render: (item) => <>{ruleTypeLabels[item.ruleType]}
{targetName(item, applications, channels)} }, + { key: 'mobile', title: '移动', render: (item) => `${item.mobileWindowDays}天 / ${item.mobileThreshold}条` }, + { key: 'unicom', title: '联通', render: (item) => `${item.unicomWindowDays}天 / ${item.unicomThreshold}条` }, + { key: 'telecom', title: '电信', render: (item) => `${item.telecomWindowDays}天 / ${item.telecomThreshold}条` }, + { key: 'version', title: '版本', render: (item) => `v${item.version}` }, + { key: 'actions', title: '操作', align: 'right', render: (item) => }, + ]; + const messageColumns: Array> = [ + { key: 'title', title: '预警', width: '340px', render: (item) =>
{item.title}
{item.content}
}, + { key: 'dimension', title: '维度', width: '240px', render: (item) => <>{item.tenantName ?? '-'}
{item.applicationName ?? '未关联企业应用'} / {item.signatureName ?? '-'}
{item.channelName ?? '企业维度'} }, + { key: 'carrier', title: '运营商', width: '90px', render: (item) => {carrierLabels[item.detection?.carrier ?? 'mobile']} }, + { key: 'count', title: '活动量', width: '130px', render: (item) => item.detection ? `${item.detection.acceptedBusinessCount} / 阈值${item.detection.threshold}` : '-' }, + { key: 'time', title: '消息时间', width: '170px', render: (item) => formatDateTime(item.createdAt) }, + { key: 'state', title: '状态', width: '90px', render: (item) => item.suppressed ? 已抑制 : item.isRead ? 已读 : 未读 }, + { key: 'actions', title: '操作', align: 'right', width: '190px', render: (item) =>
{!item.isRead ? : null}{!item.suppressed ? : null}
}, + ]; + const suppressionColumns: Array> = [ + { key: 'dimension', title: '维度', render: (item) => `${item.dimensionType === 'enterprise' ? '企业' : '通道'} / ${carrierLabels[item.carrier]}` }, + { key: 'mode', title: '方式', render: (item) => item.mode === 'permanent' ? '永久抑制' : `临时至 ${formatDate(item.muteUntil)}` }, + { key: 'reason', title: '原因', render: (item) => item.reason || '-' }, + { key: 'actions', title: '操作', align: 'right', render: (item) => }, + ]; + const legacyColumns: Array> = [ + { key: 'signature', title: '签名', render: (item) => <>{item.signature?.name}
{item.signature?.tenant?.name} }, + { key: 'channel', title: '历史通道', render: (item) => <>{item.channel?.name}
{supportedCarriers(item).map((carrier) => carrierLabels[carrier]).join('、')} }, + { key: 'status', title: '原状态', render: (item) => {statusLabel(item.status)} }, + { key: 'actions', title: '操作', align: 'right', render: (item) => }, + ]; + + async function saveRule() { + if (!ruleDraft) return; + setLoading(true); + try { + await adminApi.saveSignatureRetirementRule({ + ...ruleDraft, targetId: ruleDraft.targetId || undefined, + mobileWindowDays: Number(ruleDraft.mobileWindowDays), mobileThreshold: Number(ruleDraft.mobileThreshold), + unicomWindowDays: Number(ruleDraft.unicomWindowDays), unicomThreshold: Number(ruleDraft.unicomThreshold), + telecomWindowDays: Number(ruleDraft.telecomWindowDays), telecomThreshold: Number(ruleDraft.telecomThreshold), + }); + setRuleDraft(null); await loadData(messagePage, appliedMessageFilters); + } catch (failure) { setError(errorMessage(failure, '规则保存失败')); } finally { setLoading(false); } + } + async function saveWebhook() { + try { await adminApi.createSignatureRetirementWebhook(webhookDraft); setWebhookOpen(false); setWebhookDraft({ name: '', platform: 'wecom', url: '' }); await loadData(messagePage, appliedMessageFilters); } + catch (failure) { setError(errorMessage(failure, 'Webhook保存失败')); } + } + async function readMessage(id: string) { await adminApi.readSignatureRetirementMessage(id); await loadMessages(messagePage, appliedMessageFilters); window.dispatchEvent(new Event('cmpp-retirement-count-refresh')); } + function openSuppression(messageId: string) { + setActionError(''); + setSuppressionDraft({ messageId, mode: 'temporary', muteUntil: addDateKey(shanghaiDateKey(), 7), reason: '' }); + } + async function saveSuppression() { + if (!suppressionDraft) return; + const reason = suppressionDraft.reason.trim(); + const days = suppressionDraft.mode === 'temporary' ? differenceInDateKeys(shanghaiDateKey(), suppressionDraft.muteUntil) : undefined; + if (!reason) { setActionError('请输入抑制原因'); return; } + if (suppressionDraft.mode === 'temporary' && (!days || days < 1)) { setActionError('临时抑制截止日期必须晚于今天'); return; } + setLoading(true); + try { + await adminApi.suppressSignatureRetirementMessage(suppressionDraft.messageId, suppressionDraft.mode === 'permanent' ? { mode: 'permanent', reason } : { mode: 'temporary', days, reason }); + setSuppressionDraft(null); setActionError(''); + await loadData(messagePage, appliedMessageFilters); + window.dispatchEvent(new Event('cmpp-retirement-count-refresh')); + } catch (failure) { setActionError(errorMessage(failure, '抑制失败')); } finally { setLoading(false); } + } + async function confirmCancelSuppression() { + if (!cancelSuppressionDraft) return; + const reason = cancelSuppressionDraft.reason.trim(); + if (!reason) { setActionError('请输入取消抑制原因'); return; } + setLoading(true); + try { + await adminApi.cancelSignatureRetirementSuppression(cancelSuppressionDraft.id, reason); + setCancelSuppressionDraft(null); setActionError(''); await loadData(messagePage, appliedMessageFilters); + } catch (failure) { setActionError(errorMessage(failure, '取消抑制失败')); } finally { setLoading(false); } + } + async function deleteWebhook(id: string) { if (!window.confirm('确认停用该Webhook?')) return; await adminApi.deleteSignatureRetirementWebhook(id); await loadData(messagePage, appliedMessageFilters); } + async function readAll() { await adminApi.readAllSignatureRetirementMessagesToday(); await loadMessages(messagePage, appliedMessageFilters); window.dispatchEvent(new Event('cmpp-retirement-count-refresh')); } + function openLegacy(task: LegacySignatureReportTask) { + setLegacyDraft(task); + // 历史通道级结果不能推导任何运营商状态;留空迫使操作人逐项依据供应商结果确认。 + setLegacyResults(Object.fromEntries(supportedCarriers(task).map((carrier) => [carrier, { status: '', approvedAt: '' }]))); + } + async function confirmLegacy() { + if (!legacyDraft) return; + await adminApi.confirmLegacySignatureReportTask(legacyDraft.id, { + results: supportedCarriers(legacyDraft).map((carrier) => ({ carrier, status: legacyResults[carrier]?.status ?? 'pending', approvedAt: legacyResults[carrier]?.status === 'approved' && legacyResults[carrier]?.approvedAt ? new Date(legacyResults[carrier].approvedAt).toISOString() : undefined })), + reason: '历史通道级报备按运营商人工确认', + }); + setLegacyDraft(null); await loadData(messagePage, appliedMessageFilters); + } + + const tenantOptions = tenants.map((item) => ({ value: item.id, label: item.name })); + const applicationOptions = applications + .filter((item) => !messageFilters.tenantId || item.tenantId === messageFilters.tenantId) + .map((item) => ({ value: item.id, label: `${item.tenant?.name ?? '企业'} / ${item.name}` })); + function applyMessageQuery() { + const filters = { ...messageFilters, dateRange: normalizeMessageDateRange(messageFilters.dateRange) }; + setMessageFilters(filters); setAppliedMessageFilters(filters); setMessagePage(1); void loadMessages(1, filters); + } + function resetMessageQuery() { + const filters = defaultMessageFilters(); + setMessageFilters(filters); setAppliedMessageFilters(filters); setMessagePage(1); void loadMessages(1, filters); + } + + const tabs = [ + { value: 'messages', label: `预警消息(${messageTotal})`, content:

预警消息

默认查询今日,可按历史日期区间和业务维度检索真实预警消息。

setMessageFilters((value) => ({ ...value, applicationId: event.target.value }))} options={[{ value: '', label: '全部企业应用' }, ...applicationOptions]} searchable value={messageFilters.applicationId} /> setMessageFilters((value) => ({ ...value, signatureKeyword: event.target.value }))} placeholder="请输入签名名称" value={messageFilters.signatureKeyword} />
{messageTotal > 0 ? = Math.ceil(messageTotal / 10)} onNext={() => { const page = messagePage + 1; setMessagePage(page); void loadMessages(page, appliedMessageFilters); }} onPageChange={(page) => { setMessagePage(page); void loadMessages(page, appliedMessageFilters); }} onPrevious={() => { const page = Math.max(1, messagePage - 1); setMessagePage(page); void loadMessages(page, appliedMessageFilters); }} page={messagePage} previousDisabled={messagePage <= 1} total={messageTotal} totalPages={Math.max(1, Math.ceil(messageTotal / 10))} /> : null} }, + { value: 'rules', label: '检测规则', content:

检测规则

特殊规则优先于全局规则;修改后从下一检测日生效。

}, + { value: 'webhooks', label: 'Webhook', content:

企业微信 / 飞书通知

地址加密保存,发送失败自动退避重试。

{webhooks.map((item) =>
{item.name}

{item.platform === 'wecom' ? '企业微信' : '飞书'} · {item.urlMasked}

)}{!webhooks.length ?

暂无Webhook

: null}
}, + { value: 'suppressions', label: `抑制管理(${suppressions.length})`, content:
}, + { value: 'legacy', label: `历史待确认(${legacyTasks.length})`, content:

历史运营商待确认

不自动把三网通道解释为三个运营商均已通过;由人工按真实供应商结果拆分。

}, + ]; + + return
+

签名清退预警

每天北京时间04:00自动检测,08:00生成站内消息并发送Webhook。

+ {error ?

{error}

: null} + setRuleDraft(null)} onSave={() => void saveRule()} /> + setWebhookOpen(false)} footer={<>}>
setWebhookDraft((value) => ({ ...value, name: event.target.value }))} value={webhookDraft.name} /> setWebhookDraft((value) => ({ ...value, url: event.target.value }))} value={webhookDraft.url} />
+ { setSuppressionDraft(null); setActionError(''); }} footer={<>}> + {suppressionDraft ?

抑制只停止站内提醒和Webhook,每日检测仍会继续。

{suppressionDraft.mode === 'temporary' ? setSuppressionDraft((value) => value ? { ...value, muteUntil: event.target.value } : value)} type="date" value={suppressionDraft.muteUntil} /> : null}