feat: add carrier-aware signature retirement alerts
This commit is contained in:
@@ -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");
|
||||
@@ -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
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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<Omit<CreateChannelGroupItemDto, 'groupId'>>,
|
||||
channels: Map<string, { id: string; carrier?: string | null; sendRegion?: string | null }>,
|
||||
channels: Map<string, { id: string; carrier?: string | null; carriers?: string[] | null; sendRegion?: string | null }>,
|
||||
) {
|
||||
const channelIds = new Set<string>();
|
||||
const provinces = new Set<string>();
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' },
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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<T extends ChannelCandidate>(
|
||||
!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
|
||||
? []
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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' }];
|
||||
}
|
||||
|
||||
@@ -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 }) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 } };
|
||||
}
|
||||
@@ -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<string, string> = { mobile: '移动', unicom: '联通', telecom: '电信' };
|
||||
|
||||
type RuleRecord = Awaited<ReturnType<PrismaService['signatureRetirementRule']['findFirst']>>;
|
||||
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<RuleRecord>;
|
||||
};
|
||||
|
||||
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<typeof setTimeout>;
|
||||
private notificationTimer?: ReturnType<typeof setTimeout>;
|
||||
private deliveryTimer?: ReturnType<typeof setInterval>;
|
||||
private startupTimer?: ReturnType<typeof setTimeout>;
|
||||
|
||||
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<Array<{ id: string; totalCount: number }>>(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<string, { dimensionType: 'enterprise' | 'channel'; signatureId: string; channelId: string | null; carrier: string; approvedAt: Date; signatureName: string; channelName: string | null; tenantName: string; applicationName: string | null }>();
|
||||
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<Array<{
|
||||
signatureId: string;
|
||||
signatureName: string;
|
||||
tenantId: string;
|
||||
tenantName: string;
|
||||
applicationId: string | null;
|
||||
applicationName: string | null;
|
||||
messageCount: number;
|
||||
rowCount: number;
|
||||
}>>(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<string>(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<NonNullable<RuleRecord>>, 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<string, DetectionDimension>();
|
||||
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<ActivityCounts> {
|
||||
const channelFilter = dimension.channelId ? Prisma.sql`AND submit."channelId" = ${dimension.channelId}` : Prisma.empty;
|
||||
const rows = await this.prisma.$queryRaw<Array<ActivityCounts>>(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<string, string[]>();
|
||||
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<NonNullable<RuleRecord>>, 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<RuleRecord>, 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}`;
|
||||
}
|
||||
@@ -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)];
|
||||
})),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user