fix: 修复上行归属并实现签名质量日报优化

This commit is contained in:
hectorzhao
2026-09-17 11:12:58 +08:00
parent 4eb7b16d12
commit 572290308c
40 changed files with 2854 additions and 778 deletions
@@ -0,0 +1,130 @@
-- CreateTable
CREATE TABLE "SignatureAnalyticsGeneration" (
"id" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"sourceAsOf" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SignatureAnalyticsGeneration_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SignatureAnalyticsDay" (
"businessDate" DATE NOT NULL,
"publishedGenerationId" TEXT,
"generatedAt" TIMESTAMP(3),
"sourceAsOf" TIMESTAMP(3),
"refreshFor" DATE,
"state" TEXT NOT NULL DEFAULT 'missing',
"error" TEXT,
"provenance" TEXT NOT NULL DEFAULT 'daily',
"schemaVersion" INTEGER NOT NULL DEFAULT 1,
"rowCounts" JSONB,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SignatureAnalyticsDay_pkey" PRIMARY KEY ("businessDate")
);
-- CreateTable
CREATE TABLE "SignatureAnalyticsRun" (
"id" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"refreshFor" DATE NOT NULL,
"generationId" TEXT NOT NULL,
"state" TEXT NOT NULL DEFAULT 'pending',
"owner" TEXT,
"fence" INTEGER NOT NULL DEFAULT 0,
"leaseUntil" TIMESTAMP(3),
"attempt" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"checkpoint" JSONB,
"error" TEXT,
"startedAt" TIMESTAMP(3),
"finishedAt" TIMESTAMP(3),
CONSTRAINT "SignatureAnalyticsRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SignatureQualityDaily" (
"generationId" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"signatureId" TEXT NOT NULL,
"signatureName" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"tenantName" TEXT NOT NULL,
"applicationNames" TEXT NOT NULL,
"total" INTEGER NOT NULL,
"payload" JSONB NOT NULL,
CONSTRAINT "SignatureQualityDaily_pkey" PRIMARY KEY ("generationId","signatureId")
);
-- CreateTable
CREATE TABLE "SignatureActivityDaily" (
"generationId" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"dimensionKey" TEXT NOT NULL,
"dimensionType" TEXT NOT NULL,
"signatureId" TEXT NOT NULL,
"channelKey" TEXT NOT NULL,
"carrier" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT,
"signatureName" TEXT NOT NULL,
"tenantName" TEXT NOT NULL,
"applicationName" TEXT NOT NULL,
"channelName" TEXT NOT NULL,
"approvedAt" TIMESTAMP(3),
"submittedAttempts" INTEGER NOT NULL,
"acceptedBusinessCount" INTEGER NOT NULL,
"deliveredBusinessCount" INTEGER NOT NULL,
"applicability" TEXT NOT NULL,
CONSTRAINT "SignatureActivityDaily_pkey" PRIMARY KEY ("generationId","dimensionKey")
);
-- CreateTable
CREATE TABLE "UnreportedSignatureDaily" (
"generationId" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"dimensionKey" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"signatureName" TEXT NOT NULL,
"tenantName" TEXT NOT NULL,
"applicationName" TEXT NOT NULL,
"messageCount" INTEGER NOT NULL,
CONSTRAINT "UnreportedSignatureDaily_pkey" PRIMARY KEY ("generationId","dimensionKey")
);
-- CreateIndex
CREATE UNIQUE INDEX "SignatureAnalyticsGeneration_id_businessDate_key" ON "SignatureAnalyticsGeneration"("id", "businessDate");
-- CreateIndex
CREATE INDEX "SignatureAnalyticsRun_state_nextAttemptAt_idx" ON "SignatureAnalyticsRun"("state", "nextAttemptAt");
-- CreateIndex
CREATE UNIQUE INDEX "SignatureAnalyticsRun_scope_businessDate_key" ON "SignatureAnalyticsRun"("scope", "businessDate");
-- CreateIndex
CREATE INDEX "SignatureQualityDaily_businessDate_generationId_total_idx" ON "SignatureQualityDaily"("businessDate", "generationId", "total");
-- CreateIndex
CREATE INDEX "SignatureActivityDaily_businessDate_generationId_dimensionT_idx" ON "SignatureActivityDaily"("businessDate", "generationId", "dimensionType", "acceptedBusinessCount");
-- CreateIndex
CREATE INDEX "UnreportedSignatureDaily_businessDate_generationId_messageC_idx" ON "UnreportedSignatureDaily"("businessDate", "generationId", "messageCount");
-- AddForeignKey
ALTER TABLE "SignatureAnalyticsDay" ADD CONSTRAINT "SignatureAnalyticsDay_publishedGenerationId_businessDate_fkey" FOREIGN KEY ("publishedGenerationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SignatureQualityDaily" ADD CONSTRAINT "SignatureQualityDaily_generationId_businessDate_fkey" FOREIGN KEY ("generationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SignatureActivityDaily" ADD CONSTRAINT "SignatureActivityDaily_generationId_businessDate_fkey" FOREIGN KEY ("generationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UnreportedSignatureDaily" ADD CONSTRAINT "UnreportedSignatureDaily_generationId_businessDate_fkey" FOREIGN KEY ("generationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,4 @@
-- Expression index matches the actual report/retirement time predicate, including legacy NULL submittedAt.
-- Deliberately outside a transaction: online construction must not block SMS writes.
CREATE INDEX CONCURRENTLY "SmsSubmitRecord_effective_at_idx"
ON "SmsSubmitRecord" ((COALESCE("submittedAt", "createdAt")));
+100
View File
@@ -2896,3 +2896,103 @@ model SmsCompletionEvent {
work SmsAttemptCompletionWork @relation(fields: [workId], references: [id], onDelete: Restrict)
@@index([workId, processedAt, createdAt])
}
model SignatureAnalyticsGeneration {
id String @id
businessDate DateTime @db.Date
sourceAsOf DateTime
days SignatureAnalyticsDay[]
quality SignatureQualityDaily[]
activity SignatureActivityDaily[]
unreported UnreportedSignatureDaily[]
@@unique([id, businessDate])
}
model SignatureAnalyticsDay {
businessDate DateTime @id @db.Date
publishedGenerationId String?
publishedGeneration SignatureAnalyticsGeneration? @relation(fields: [publishedGenerationId, businessDate], references: [id, businessDate], onDelete: Restrict)
generatedAt DateTime?
sourceAsOf DateTime?
refreshFor DateTime? @db.Date
state String @default("missing")
error String?
provenance String @default("daily")
schemaVersion Int @default(1)
rowCounts Json?
updatedAt DateTime @updatedAt
}
model SignatureAnalyticsRun {
id String @id @default(cuid())
scope String
businessDate DateTime @db.Date
refreshFor DateTime @db.Date
generationId String
state String @default("pending")
owner String?
fence Int @default(0)
leaseUntil DateTime?
attempt Int @default(0)
nextAttemptAt DateTime @default(now())
checkpoint Json?
error String?
startedAt DateTime?
finishedAt DateTime?
@@unique([scope, businessDate])
@@index([state, nextAttemptAt])
}
model SignatureQualityDaily {
generation SignatureAnalyticsGeneration @relation(fields: [generationId, businessDate], references: [id, businessDate], onDelete: Restrict)
generationId String
businessDate DateTime @db.Date
signatureId String
signatureName String
tenantId String
tenantName String
applicationNames String
total Int
payload Json
@@id([generationId, signatureId])
@@index([businessDate, generationId, total])
}
model SignatureActivityDaily {
generation SignatureAnalyticsGeneration @relation(fields: [generationId, businessDate], references: [id, businessDate], onDelete: Restrict)
generationId String
businessDate DateTime @db.Date
dimensionKey String
dimensionType String
signatureId String
channelKey String
carrier String
tenantId String
applicationId String?
signatureName String
tenantName String
applicationName String
channelName String
approvedAt DateTime?
submittedAttempts Int
acceptedBusinessCount Int
deliveredBusinessCount Int
applicability String
@@id([generationId, dimensionKey])
@@index([businessDate, generationId, dimensionType, acceptedBusinessCount])
}
model UnreportedSignatureDaily {
generation SignatureAnalyticsGeneration @relation(fields: [generationId, businessDate], references: [id, businessDate], onDelete: Restrict)
generationId String
businessDate DateTime @db.Date
dimensionKey String
tenantId String
applicationId String
signatureName String
tenantName String
applicationName String
messageCount Int
@@id([generationId, dimensionKey])
@@index([businessDate, generationId, messageCount])
}
+2
View File
@@ -1,3 +1,4 @@
import { SignatureAnalyticsModule } from './signature-analytics/signature-analytics.module';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuditModule } from './audit/audit.module';
@@ -57,6 +58,7 @@ import { SendingMonitorModule } from './sending-monitor/sending-monitor.module';
InfrastructureMonitoringModule,
OpenApiModule,
SignatureRetirementModule,
SignatureAnalyticsModule,
SecurityDetectionModule,
MetricsModule,
ReportNotificationsModule,
@@ -178,8 +178,8 @@ export class AdminOperationsController {
return this.operations.signatureQuality({
date,
keyword,
page: Number(page),
pageSize: Number(pageSize),
page: page === undefined ? 1 : Number(page),
pageSize: pageSize === undefined ? 25 : Number(pageSize),
});
}
@@ -1,3 +1,4 @@
import { OperationsQualityQueries } from './queries/quality.queries';
import { OperationsService } from './operations.service';
function createPrismaMock() {
@@ -936,10 +937,8 @@ describe('OperationsService', () => {
averageArrivalMs: 1800,
},
]);
const service = new OperationsService(prisma as never);
await expect(
service.signatureQuality({
new OperationsQualityQueries(prisma as never).signatureQualityLive({
date: '2026-07-24',
keyword: '测试',
page: 2,
@@ -987,9 +986,9 @@ describe('OperationsService', () => {
it('does not query channel details when the selected date has no registered signatures', async () => {
const prisma = createPrismaMock();
prisma.$queryRaw.mockResolvedValueOnce([]);
const service = new OperationsService(prisma as never);
await expect(service.signatureQuality({ date: '2026-07-24' })).resolves.toEqual({
await expect(
new OperationsQualityQueries(prisma as never).signatureQualityLive({ date: '2026-07-24' }),
).resolves.toEqual({
date: '2026-07-24',
items: [],
total: 0,
+74 -19
View File
@@ -1,4 +1,6 @@
import { Prisma } from '@prisma/client';
import { SignatureAnalyticsRead } from '../../signature-analytics/analytics-read';
import { analyticsDate, analyticsPage, todayKey } from '../../signature-analytics/analytics-date';
import { PrismaService } from '../../prisma/prisma.service';
import type { SignatureQualityQuery } from '../operations.contracts';
@@ -321,9 +323,30 @@ export class OperationsQualityQueries {
return { date: day.key, summary, channels, signatures, drainageSignatures, applications };
}
async signatureQuality(query: SignatureQualityQuery) {
const date = analyticsDate(query.date);
analyticsPage(query.page, query.pageSize);
if (date !== todayKey()) return new SignatureAnalyticsRead(this.prisma).quality({ ...query, date });
return this.prisma.$transaction(
async (tx) => {
await tx.$executeRawUnsafe("SET LOCAL statement_timeout='12s'");
const result = await new OperationsQualityQueries(tx as PrismaService).signatureQualityLive({ ...query, date });
return {
...result,
dataSource: 'live',
reportState: 'ready',
frozen: false,
sourceAsOf: new Date(),
serverBusinessDate: date,
};
},
{ isolationLevel: 'RepeatableRead', timeout: 15000 },
);
}
async signatureQualityLive(query: SignatureQualityQuery, snapshot = false) {
const day = qualityBusinessDay(query.date);
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(100, positiveInteger(query.pageSize, 25));
const pageSize = snapshot ? 2147483647 : Math.min(100, positiveInteger(query.pageSize, 25));
const keyword = query.keyword?.trim() || null;
const keywordPattern = keyword ? `%${keyword}%` : null;
const summaries = await this.prisma.$queryRaw<
@@ -341,6 +364,8 @@ export class OperationsQualityQueries {
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
rowCount: number;
}>
>(Prisma.sql`
@@ -361,6 +386,15 @@ export class OperationsQualityQueries {
WHERE message."signatureId" IS NOT NULL
AND message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
), dimensions AS (
SELECT signature_id FROM base
UNION
SELECT message."signatureId" FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id=submit."messageRecordId"
WHERE message."signatureId" IS NOT NULL
AND COALESCE(submit."submittedAt",submit."createdAt")>=${day.startAt}
AND COALESCE(submit."submittedAt",submit."createdAt")<${day.endAt}
AND submit."submitStatus" IN ('accepted','rejected','timeout')
)
SELECT
signature.id AS "signatureId",
@@ -368,37 +402,37 @@ export class OperationsQualityQueries {
tenant.id AS "tenantId",
tenant.name AS "tenantName",
STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames",
COUNT(*)::integer AS total,
COUNT(*) FILTER (
COUNT(base.signature_id)::integer AS total,
COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
)::integer AS "acceptedCount",
COUNT(*) FILTER (
COUNT(base.signature_id) FILTER (
WHERE base.status = 'submit_failed'
OR base.submit_status IN ('rejected', 'timeout')
)::integer AS "submitFailureCount",
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
COUNT(*) FILTER (
COUNT(base.signature_id) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "unknownCount",
COUNT(*) FILTER (
COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "failureCount",
CASE
WHEN COUNT(*) FILTER (
WHEN COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
) = 0 THEN 0
ELSE ROUND(
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
COUNT(base.signature_id) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
* 100.0
/ COUNT(*) FILTER (
/ COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
),
@@ -407,10 +441,11 @@ export class OperationsQualityQueries {
END AS "successRate",
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs",
COUNT(*) OVER()::integer AS "rowCount"
FROM base
JOIN "SmsSignature" signature ON signature.id = base.signature_id
FROM dimensions
JOIN "SmsSignature" signature ON signature.id = dimensions.signature_id
LEFT JOIN base ON base.signature_id=signature.id
JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
LEFT JOIN "SmsApplication" application ON application.id = base.application_id
LEFT JOIN "SmsApplication" application ON application.id = COALESCE(base.application_id,signature."applicationId")
WHERE (
${keyword}::text IS NULL
OR signature.name ILIKE ${keywordPattern}
@@ -441,6 +476,8 @@ export class OperationsQualityQueries {
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
}>
>(Prisma.sql`
WITH base AS (
@@ -457,12 +494,12 @@ export class OperationsQualityQueries {
submit."submitStatus" AS submit_status,
receipt."deliveredAt" AS delivered_at,
failed_receipt."failedAt" AS failed_at,
COALESCE(segment_summary.segment_count, 0) AS segment_count,
COALESCE(segment_summary.expected_count, 0) AS segment_count,
COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count,
COALESCE(segment_summary.failure_count, 0) AS segment_failure_count,
CASE
WHEN segment_summary.segment_count > 0
AND segment_summary.delivered_count = segment_summary.segment_count
AND segment_summary.delivered_count = segment_summary.expected_count
AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt")
THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
WHEN segment_summary.segment_count = 0
@@ -474,6 +511,7 @@ export class OperationsQualityQueries {
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN LATERAL (
SELECT
CASE WHEN COUNT(*) > 0 THEN GREATEST(MAX(segment."segmentTotal"), message."billingUnits") ELSE 0 END::integer AS expected_count,
COUNT(*)::integer AS segment_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
@@ -532,6 +570,8 @@ export class OperationsQualityQueries {
1
)::double precision
END AS "successRate",
COALESCE(SUM(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL),0)::double precision AS "arrivalMsSum",
COUNT(arrival_ms) FILTER (WHERE delivery_status = 'success')::integer AS "arrivalSamples",
ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
FROM classified
GROUP BY signature_id, channel_id, carrier, drainage_state
@@ -548,6 +588,8 @@ export class OperationsQualityQueries {
finalSuccessCount: number;
finalSuccessRate: number;
averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
}>
>(Prisma.sql`
SELECT
@@ -620,6 +662,8 @@ type SignatureSplitRow = {
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
};
function aggregateSignatureRows(rows: SignatureSplitRow[]) {
@@ -632,7 +676,7 @@ function aggregateSignatureRows(rows: SignatureSplitRow[]) {
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
const arrivalWeight = parts.reduce(
(sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount),
(sum, item) => sum + (item.arrivalSamples ?? (item.averageArrivalMs == null ? 0 : item.successCount)),
0,
);
return {
@@ -650,7 +694,10 @@ function aggregateSignatureRows(rows: SignatureSplitRow[]) {
arrivalWeight === 0
? null
: Math.round(
parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight,
parts.reduce(
(sum, item) => sum + (item.arrivalMsSum ?? (item.averageArrivalMs ?? 0) * item.successCount),
0,
) / arrivalWeight,
),
};
})
@@ -676,6 +723,8 @@ type DrainageBreakdownRow = {
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
};
function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
@@ -688,7 +737,10 @@ function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
const first = parts[0];
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
const arrivalWeight = parts.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0);
const arrivalWeight = parts.reduce(
(sum, item) => sum + (item.arrivalSamples ?? (item.averageArrivalMs == null ? 0 : item.successCount)),
0,
);
return {
signatureId: first.signatureId,
channelId: first.channelId,
@@ -705,7 +757,10 @@ function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
arrivalWeight === 0
? null
: Math.round(
parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight,
parts.reduce(
(sum, item) => sum + (item.arrivalMsSum ?? (item.averageArrivalMs ?? 0) * item.successCount),
0,
) / arrivalWeight,
),
};
});
+18 -10
View File
@@ -298,6 +298,10 @@ function createPrismaMock() {
update: jest.fn().mockResolvedValue({ id: 'candidate-1', status: 'claimed' }),
},
cmppDownstreamDelivery: {
createMany: jest.fn().mockResolvedValue({ count: 1 }),
findUniqueOrThrow: jest
.fn()
.mockResolvedValue({ id: 'delivery-1', messageRecordId: 'record-1', applicationId: 'app-1' }),
create: jest
.fn()
.mockImplementation(({ data }) =>
@@ -4455,6 +4459,7 @@ describe('SendChainService', () => {
it('records ambiguous uplink match candidates for shared access numbers', async () => {
const { service, prisma } = createService();
prisma.smsMessageRecord.findMany.mockResolvedValue([]);
prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }, { applicationId: 'app-2' }]);
prisma.smsApplication.findMany.mockResolvedValue([
{ id: 'app-1', tenantId: 'tenant-1', name: '应用A' },
@@ -4475,7 +4480,7 @@ describe('SendChainService', () => {
tenantId: undefined,
applicationId: undefined,
matchStatus: 'ambiguous',
matchReason: '接入号匹配多个应用',
matchReason: '应用归属仍有多候选或接入号与发送事实冲突',
}),
});
expect(prisma.smsUplinkMatchCandidate.createMany).toHaveBeenCalledWith({
@@ -4517,15 +4522,18 @@ describe('SendChainService', () => {
where: { uplinkMessageId: 'uplink-1', id: { not: 'candidate-1' }, status: 'pending' },
data: { status: 'rejected' },
});
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
applicationId: 'app-1',
messageRecordId: 'record-1',
messageId: 'MSG-1',
deliveryType: 'uplink',
status: 'pending',
}),
expect(prisma.cmppDownstreamDelivery.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
tenantId: 'tenant-1',
applicationId: 'app-1',
messageRecordId: 'record-1',
messageId: 'MSG-1',
deliveryType: 'uplink',
status: 'pending',
}),
],
skipDuplicates: true,
});
});
+1
View File
@@ -698,6 +698,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
tenantId?: string;
applicationId?: string;
messageRecordId?: string;
messageId?: string;
matchStatus: string;
matchReason: string;
candidates: UplinkMatchCandidateInput[];
+38 -9
View File
@@ -1,7 +1,21 @@
import { BillingService } from '../billing/billing.service';
import type { OpenApiService } from '../open-api/open-api.service';
import { PrismaService } from '../prisma/prisma.service';
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
import type {
GatewaySubmitResultDto,
GatewaySubmitSegmentResultDto,
GatewayReceiptEventDto,
GatewayUplinkEventDto,
UplinkMatchCandidateInput,
GatewayPendingDeliveryQueryDto,
GatewayDownstreamSentDto,
GatewayDownstreamAcknowledgedDto,
GatewayDownstreamFailureType,
GatewaySubmitDeadLetterDto,
RequeueGatewaySubmitExceptionDto,
GatewayDownstreamRecoveryStatusDto,
TimeoutUnknownDto,
} from './send-chain.contracts';
import { downstreamPendingTimeoutHours } from './send-chain.helpers';
import type { SendSubmissionService } from './send-submission.service';
import { SendAccountingService } from './send-accounting.service';
@@ -13,7 +27,6 @@ import { SendRetryService } from './send-retry.service';
import { SendTimeoutService } from './send-timeout.service';
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
export type SendCompletionCallbacks = Record<string, never>;
export type SendCompletionFacade = SendCompletionService & SendSubmissionService;
@@ -49,10 +62,7 @@ export class SendCompletionService {
return this.gatewayResult.handleSubmitSegmentResult(data);
}
async resolveSubmitRecordForGatewaySegmentResult(
messageRecordId: string,
data: GatewaySubmitSegmentResultDto,
) {
async resolveSubmitRecordForGatewaySegmentResult(messageRecordId: string, data: GatewaySubmitSegmentResultDto) {
return this.gatewayResult.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data);
}
@@ -109,7 +119,13 @@ export class SendCompletionService {
async handleReceipt(
data: GatewayReceiptEventDto,
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
incomingIdentity?: {
account: string;
gatewayHost: string;
gatewayPort: number;
protocol: string;
cmppVersion: string;
},
) {
return this.receipt.handleReceipt(data, incomingIdentity);
}
@@ -145,7 +161,13 @@ export class SendCompletionService {
async resolveReceiptMessage(
data: GatewayReceiptEventDto,
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
incomingIdentity?: {
account: string;
gatewayHost: string;
gatewayPort: number;
protocol: string;
cmppVersion: string;
},
) {
return this.receipt.resolveReceiptMessage(data, incomingIdentity);
}
@@ -206,7 +228,13 @@ export class SendCompletionService {
}
async releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
message: {
tenantId: string;
batchTaskId: string;
messageId: string;
amountCents: number | bigint;
billingUnits: number;
},
remark: string,
) {
return this.accounting.releaseMessageReservation(message, remark);
@@ -279,6 +307,7 @@ export class SendCompletionService {
tenantId?: string;
applicationId?: string;
messageRecordId?: string;
messageId?: string;
matchStatus: string;
matchReason: string;
candidates: UplinkMatchCandidateInput[];
@@ -1,4 +1,5 @@
import { completionContext } from './completion-context';
import { resolveUplinkMatch } from './uplink-matching';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto';
@@ -32,7 +33,19 @@ export class SendDownstreamDeliveryService {
) {}
async handleUplink(data: GatewayUplinkEventDto) {
if (!completionContext.getStore()) {
return this.prisma.$transaction(
(tx) => completionContext.run({ tx, messageRecordId: '' }, () => this.persistUplink(data)),
{ timeout: 15_000 },
);
}
return this.persistUplink(data);
}
private async persistUplink(data: GatewayUplinkEventDto) {
if (data.eventId) {
await this.prisma
.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`uplink-event:${data.eventId}`},0))`;
const existing = await this.prisma.smsUplinkMessage.findUnique({ where: { eventId: data.eventId } });
if (existing) return existing;
}
@@ -48,7 +61,7 @@ export class SendDownstreamDeliveryService {
applicationId: match.applicationId,
messageRecordId: match.messageRecordId,
channelId: data.channelId,
messageId: data.messageId,
messageId: match.messageId,
gatewayMessageId: data.gatewayMessageId,
sequenceId: data.sequenceId,
phoneNumber: data.phoneNumber,
@@ -78,10 +91,10 @@ export class SendDownstreamDeliveryService {
tenantId: match.tenantId,
applicationId: match.applicationId,
messageRecordId: match.messageRecordId,
messageId: data.messageId,
messageId: match.messageId,
deliveryType: 'uplink',
payload: {
messageId: data.messageId,
messageId: match.messageId,
applicationId: match.applicationId,
phoneNumber: data.phoneNumber,
destId: data.destId,
@@ -95,6 +108,21 @@ export class SendDownstreamDeliveryService {
}
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
if (!completionContext.getStore()) {
return this.prisma.$transaction(
(tx) =>
completionContext.run({ tx, messageRecordId: '' }, () =>
this.persistUplinkClaim(uplinkMessageId, candidateId, operatorId),
),
{ timeout: 15_000 },
);
}
return this.persistUplinkClaim(uplinkMessageId, candidateId, operatorId);
}
private async persistUplinkClaim(uplinkMessageId: string, candidateId: string, operatorId?: string) {
await this.prisma
.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`uplink-claim:${uplinkMessageId}`},0))`;
const candidate = await this.prisma.smsUplinkMatchCandidate.findFirst({
where: { id: candidateId, uplinkMessageId },
include: {
@@ -112,56 +140,55 @@ export class SendDownstreamDeliveryService {
if (candidate.uplinkMessage.matchStatus === 'matched' && candidate.status !== 'claimed') {
throw new BadRequestException('该上行记录已完成匹配,不能重复认领');
}
if (candidate.status === 'claimed') return candidate.uplinkMessage;
const claimedAt = new Date();
const messageId = candidate.uplinkMessage.messageId ?? candidate.messageRecord?.messageId ?? null;
const [updatedUplink] = await this.prisma.$transaction([
this.prisma.smsUplinkMessage.update({
where: { id: uplinkMessageId },
data: {
tenantId: candidate.tenantId,
const messageId = candidate.messageRecord?.messageId ?? null;
const updatedUplink = await this.prisma.smsUplinkMessage.update({
where: { id: uplinkMessageId },
data: {
tenantId: candidate.tenantId,
applicationId: candidate.applicationId,
messageRecordId: candidate.messageRecordId,
messageId,
matchStatus: 'matched',
matchReason: `人工认领:${candidate.reason ?? candidate.matchSource}`,
},
});
await this.prisma.smsUplinkMatchCandidate.updateMany({
where: {
uplinkMessageId,
id: { not: candidate.id },
status: 'pending',
},
data: { status: 'rejected' },
});
await this.prisma.smsUplinkMatchCandidate.update({
where: { id: candidate.id },
data: {
status: 'claimed',
claimedAt,
claimedById: operatorId,
},
});
await this.prisma.operationLog.create({
data: {
tenantId: candidate.tenantId,
userId: operatorId,
action: 'gateway.uplink_manual_claim',
resource: 'sms_uplink_message',
resourceId: uplinkMessageId,
detail: {
candidateId: candidate.id,
applicationId: candidate.applicationId,
applicationName: candidate.application.name,
messageRecordId: candidate.messageRecordId,
messageId,
matchStatus: 'matched',
matchReason: `人工认领:${candidate.reason ?? candidate.matchSource}`,
matchSource: candidate.matchSource,
phoneNumber: candidate.uplinkMessage.phoneNumber,
destId: candidate.uplinkMessage.destId,
},
}),
this.prisma.smsUplinkMatchCandidate.updateMany({
where: {
uplinkMessageId,
id: { not: candidate.id },
status: 'pending',
},
data: { status: 'rejected' },
}),
this.prisma.smsUplinkMatchCandidate.update({
where: { id: candidate.id },
data: {
status: 'claimed',
claimedAt,
claimedById: operatorId,
},
}),
this.prisma.operationLog.create({
data: {
tenantId: candidate.tenantId,
userId: operatorId,
action: 'gateway.uplink_manual_claim',
resource: 'sms_uplink_message',
resourceId: uplinkMessageId,
detail: {
candidateId: candidate.id,
applicationId: candidate.applicationId,
applicationName: candidate.application.name,
messageRecordId: candidate.messageRecordId,
messageId,
matchSource: candidate.matchSource,
phoneNumber: candidate.uplinkMessage.phoneNumber,
destId: candidate.uplinkMessage.destId,
},
},
}),
]);
},
});
await this.facade.queueAndTryDownstreamDelivery({
tenantId: candidate.tenantId,
@@ -278,7 +305,10 @@ export class SendDownstreamDeliveryService {
skipDuplicates: true,
});
const retained = await this.prisma.cmppDownstreamDelivery.findUniqueOrThrow({ where: { dedupeKey } });
if (retained.messageRecordId !== data.messageRecordId || retained.applicationId !== data.applicationId)
if (
(retained.messageRecordId ?? null) !== (data.messageRecordId ?? null) ||
retained.applicationId !== data.applicationId
)
throw new Error('completion_notification_identity_mismatch');
return retained;
}
@@ -377,107 +407,12 @@ export class SendDownstreamDeliveryService {
tenantId?: string;
applicationId?: string;
messageRecordId?: string;
messageId?: string;
matchStatus: string;
matchReason: string;
candidates: UplinkMatchCandidateInput[];
}> {
if (data.messageId) {
const message = await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } });
if (message?.tenantId) {
return {
tenantId: message.tenantId,
applicationId: message.applicationId ?? undefined,
messageRecordId: message.id,
matchStatus: message.applicationId ? 'matched' : 'unmatched',
matchReason: message.applicationId ? 'messageId 精确匹配' : 'messageId 匹配到下发记录但无应用',
candidates: [],
};
}
}
const accessNumber = data.destId || channel.srcId || '';
const accessRoutes = accessNumber
? await this.prisma.channelRouteRule.findMany({
where: {
applicationId: { not: null },
status: 'active',
group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } },
},
select: { applicationId: true },
take: 10,
})
: [];
const accessApplicationIds = [
...new Set(accessRoutes.map((route) => route.applicationId).filter((value): value is string => Boolean(value))),
];
const accessApplications =
accessApplicationIds.length > 0
? await this.prisma.smsApplication.findMany({
where: { id: { in: accessApplicationIds }, status: 'active' },
select: { id: true, tenantId: true, name: true },
})
: [];
if (accessApplications.length === 1) {
return {
tenantId: accessApplications[0].tenantId,
applicationId: accessApplications[0].id,
matchStatus: 'matched',
matchReason: '接入号唯一匹配应用',
candidates: [],
};
}
if (accessApplications.length > 1) {
return {
matchStatus: 'ambiguous',
matchReason: '接入号匹配多个应用',
candidates: accessApplications.map((application) => ({
tenantId: application.tenantId,
applicationId: application.id,
matchSource: 'access_number',
confidence: 70,
reason: `接入号 ${accessNumber} 可匹配应用 ${application.name}`,
})),
};
}
const windowHours = Number(process.env.UPLINK_MATCH_WINDOW_HOURS ?? 72);
const since = new Date(Date.now() - Math.max(1, windowHours) * 60 * 60 * 1000);
const recentMessages = await this.prisma.smsMessageRecord.findMany({
where: {
phoneNumber: data.phoneNumber,
tenantId: { not: null },
applicationId: { not: null },
submittedAt: { gte: since },
},
orderBy: { submittedAt: 'desc' },
take: 2,
});
const matchableRecentMessages = recentMessages.filter((message) => message.tenantId && message.applicationId);
if (matchableRecentMessages.length === 1) {
return {
tenantId: matchableRecentMessages[0].tenantId ?? undefined,
applicationId: matchableRecentMessages[0].applicationId ?? undefined,
messageRecordId: matchableRecentMessages[0].id,
matchStatus: 'matched',
matchReason: `手机号 ${windowHours} 小时窗口唯一匹配`,
candidates: [],
};
}
if (matchableRecentMessages.length > 1) {
return {
matchStatus: 'ambiguous',
matchReason: `手机号 ${windowHours} 小时窗口匹配多条下发记录`,
candidates: matchableRecentMessages.map((message) => ({
tenantId: String(message.tenantId),
applicationId: String(message.applicationId),
messageRecordId: message.id,
matchSource: 'phone_window',
confidence: 55,
reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`,
})),
};
}
return { matchStatus: 'unmatched', matchReason: '未匹配到应用或下发记录', candidates: [] };
return resolveUplinkMatch(this.prisma, data, channel);
}
async recordCmppFailureReceipt(
+146
View File
@@ -0,0 +1,146 @@
import { BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import type { GatewayUplinkEventDto, UplinkMatchCandidateInput } from './send-chain.contracts';
export type UplinkMatch = {
tenantId?: string;
applicationId?: string;
messageRecordId?: string;
messageId?: string;
matchStatus: string;
matchReason: string;
candidates: UplinkMatchCandidateInput[];
};
/** Attribution is application-level; a reply need not identify one original SMS. */
export async function resolveUplinkMatch(
db: PrismaService,
data: GatewayUplinkEventDto,
channel: { id: string; srcId?: string | null },
): Promise<UplinkMatch> {
const receivedAt = data.receivedAt ? new Date(data.receivedAt) : new Date();
if (!Number.isFinite(receivedAt.getTime())) throw new BadRequestException('上行接收时间无效');
const configuredHours = Number(process.env.UPLINK_MATCH_WINDOW_HOURS ?? 72);
const hours =
Number.isFinite(configuredHours) && configuredHours >= 1 && configuredHours <= 8760 ? configuredHours : 72;
const since = new Date(receivedAt.getTime() - hours * 3_600_000);
const channelEvidence = {
channelId: channel.id,
submitStatus: 'accepted',
submittedAt: { gte: since, lte: receivedAt },
};
if (data.messageId) {
const message = await db.smsMessageRecord.findFirst({
where: {
messageId: data.messageId,
phoneNumber: data.phoneNumber,
tenantId: { not: null },
applicationId: { not: null },
submitRecords: { some: channelEvidence },
},
select: { id: true, messageId: true, tenantId: true, applicationId: true },
});
if (message?.tenantId && message.applicationId)
return {
tenantId: message.tenantId,
applicationId: message.applicationId,
messageRecordId: message.id,
messageId: message.messageId,
matchStatus: 'matched',
matchReason: 'messageId 与手机号、通道发送事实一致',
candidates: [],
};
}
const accessNumber = data.destId || channel.srcId || '';
// Do not truncate routes before deduplicating applications: it can manufacture uniqueness.
const routes = accessNumber
? await db.channelRouteRule.findMany({
where: {
applicationId: { not: null },
status: 'active',
group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } },
},
select: { applicationId: true },
distinct: ['applicationId'],
})
: [];
const ids = routes.flatMap((r) => (r.applicationId ? [r.applicationId] : []));
const applications = ids.length
? await db.smsApplication.findMany({
where: { id: { in: ids }, status: 'active' },
select: { id: true, tenantId: true, name: true },
})
: [];
// Read only attribution columns, but inspect the complete window, not its last two SMS.
const messages = await db.smsMessageRecord.findMany({
where: {
phoneNumber: data.phoneNumber,
tenantId: { not: null },
applicationId: { not: null },
submitRecords: { some: channelEvidence },
},
select: { id: true, messageId: true, tenantId: true, applicationId: true },
orderBy: { id: 'asc' },
});
const groups = new Map<string, typeof messages>();
for (const message of messages) {
if (!message.tenantId || !message.applicationId) continue;
const key = JSON.stringify([message.tenantId, message.applicationId]);
const group = groups.get(key) ?? [];
group.push(message);
groups.set(key, group);
}
const accessCandidates: UplinkMatchCandidateInput[] = applications.map((a) => ({
tenantId: a.tenantId,
applicationId: a.id,
matchSource: 'access_number',
confidence: 70,
reason: '共享接入号应用候选,尚无唯一发送证据',
}));
if (groups.size === 1) {
const records = [...groups.values()][0];
const message = records[0];
// A conflicting configured access number is evidence against automatic assignment.
if (!applications.length || applications.some((a) => a.id === message.applicationId))
return {
tenantId: message.tenantId!,
applicationId: message.applicationId!,
messageRecordId: records.length === 1 ? message.id : undefined,
messageId: records.length === 1 ? message.messageId : undefined,
matchStatus: 'matched',
matchReason:
records.length === 1
? `手机号、通道及接收前 ${hours} 小时唯一匹配`
: `手机号、通道及接收前 ${hours} 小时应用唯一;原短信不唯一`,
candidates: [],
};
}
// No sending evidence: retain the existing unique-access application attribution.
if (!groups.size && applications.length === 1)
return {
tenantId: applications[0].tenantId,
applicationId: applications[0].id,
matchStatus: 'matched',
matchReason: '接入号唯一匹配应用,无唯一原短信',
candidates: [],
};
const candidates = new Map(accessCandidates.map((c) => [c.applicationId, c]));
for (const records of groups.values()) {
const m = records[0];
candidates.set(m.applicationId!, {
tenantId: m.tenantId!,
applicationId: m.applicationId!,
messageRecordId: records.length === 1 ? m.id : undefined,
matchSource: 'phone_window',
confidence: 70,
reason: `同通道接收前 ${hours} 小时有 ${records.length} 条下发;须确认应用归属`,
});
}
return candidates.size
? {
matchStatus: 'ambiguous',
matchReason: '应用归属仍有多候选或接入号与发送事实冲突',
candidates: [...candidates.values()],
}
: { matchStatus: 'unmatched', matchReason: '未匹配到接入号应用或时间窗内同通道发送事实', candidates: [] };
}
@@ -0,0 +1,124 @@
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { startOfDay, addDays } from './analytics-date';
export type ActivityDimension = {
dimensionKey: string;
dimensionType: string;
tenantId: string;
applicationId: string | null;
signatureId: string;
channelKey: string;
carrier: string;
approvedAt: Date;
signatureName: string;
tenantName: string;
applicationName: string;
channelName: string;
};
export type ActivityCount = {
submittedAttempts: number;
acceptedBusinessCount: number;
deliveredBusinessCount: number;
};
/** Reconstruct membership at the end of the activity day, not from today's active routes. */
export async function activityDimensions(db: PrismaService, date: string): Promise<ActivityDimension[]> {
const end = startOfDay(addDays(date, 1));
const tasks = await db.$queryRaw<Array<Omit<ActivityDimension, 'dimensionKey' | 'dimensionType'>>>(Prisma.sql`
SELECT t."signatureId",t."channelId" AS "channelKey",t.carrier,
s."tenantId",s."applicationId",s.name AS "signatureName",c.name AS "channelName",
tenant.name AS "tenantName",COALESCE(a.name,'') AS "applicationName",
COALESCE(approved."createdAt",t."approvedAt") AS "approvedAt"
FROM "ChannelSignatureReportTask" t
JOIN "SmsSignature" s ON s.id=t."signatureId" JOIN "Tenant" tenant ON tenant.id=s."tenantId"
JOIN "SmsChannel" c ON c.id=t."channelId" LEFT JOIN "SmsApplication" a ON a.id=s."applicationId"
LEFT JOIN LATERAL (SELECT r."statusAfter" FROM "ChannelSignatureReportRecord" r
WHERE r."taskId"=t.id AND r."createdAt"<${end} ORDER BY r."createdAt" DESC,r.id DESC LIMIT 1) history ON TRUE
LEFT JOIN LATERAL (SELECT r."createdAt" FROM "ChannelSignatureReportRecord" r
WHERE r."taskId"=t.id AND r."createdAt"<${end} AND r."statusAfter"='approved'
AND r."statusBefore" IS DISTINCT FROM 'approved' ORDER BY r."createdAt" DESC,r.id DESC LIMIT 1) approved ON TRUE
WHERE t."reportType"='signature' AND t."approvalScope"='carrier_specific' AND t.carrier IS NOT NULL
AND t."createdAt"<${end}
AND COALESCE(history."statusAfter",CASE WHEN t."approvedAt"<${end} THEN t.status END)='approved'
AND COALESCE(approved."createdAt",t."approvedAt")<${end}`);
const dimensions = new Map<string, ActivityDimension>();
for (const t of tasks) {
const channelKey = JSON.stringify(['channel', t.tenantId, t.applicationId, t.signatureId, t.channelKey, t.carrier]);
dimensions.set(channelKey, { ...t, dimensionKey: channelKey, dimensionType: 'channel' });
const enterpriseKey = JSON.stringify(['enterprise', t.tenantId, t.applicationId, t.signatureId, '', t.carrier]);
const existing = dimensions.get(enterpriseKey);
if (!existing || existing.approvedAt > t.approvedAt)
dimensions.set(enterpriseKey, {
...t,
dimensionKey: enterpriseKey,
dimensionType: 'enterprise',
channelKey: '',
channelName: '',
});
}
return [...dimensions.values()];
}
/** One bounded source scan for all dimensions; no per-signature correlated receipt scan. */
export async function activityCounts(db: PrismaService, dimensions: ActivityDimension[], date: string) {
if (!dimensions.length) return new Map<string, ActivityCount>();
const start = startOfDay(date),
end = startOfDay(addDays(date, 1));
const json = JSON.stringify(
dimensions.map((d) => ({
key: d.dimensionKey,
signature: d.signatureId,
channel: d.channelKey,
carrier: d.carrier,
approved: d.approvedAt.toISOString(),
})),
);
const rows = await db.$queryRaw<Array<ActivityCount & { key: string }>>(Prisma.sql`
WITH dims AS (SELECT * FROM jsonb_to_recordset(${json}::jsonb) AS d(key text,signature text,channel text,carrier text,approved timestamptz)),
attempts AS MATERIALIZED (
SELECT s.id,s."messageRecordId",s."channelId",s."gatewayMessageId",s."submitStatus",m."signatureId",m.carrier,m."billingUnits",
COALESCE(s."submittedAt",s."createdAt") AS at
FROM "SmsSubmitRecord" s JOIN "SmsMessageRecord" m ON m.id=s."messageRecordId"
WHERE COALESCE(s."submittedAt",s."createdAt")>=${start} AND COALESCE(s."submittedAt",s."createdAt")<${end}
AND m."signatureId" IS NOT NULL
), segments AS (
SELECT a.id,COUNT(g.id)::int AS present,GREATEST(MAX(g."segmentTotal"),MAX(a."billingUnits")) AS expected,
COUNT(g.id) FILTER(WHERE g."receiptStatus"='delivered') AS delivered
FROM attempts a LEFT JOIN "SmsMessageSegmentAudit" g ON g."submitRecordId"=a.id GROUP BY a.id
), delivered AS (
SELECT DISTINCT a.id FROM attempts a JOIN segments g ON g.id=a.id
WHERE (g.present>0 AND g.delivered=g.expected AND g.present=g.expected)
OR (g.present=0 AND EXISTS(SELECT 1 FROM "SmsReceiptRecord" r
WHERE r."channelId"=a."channelId" AND r."gatewayMessageId"=a."gatewayMessageId" AND r."receiptStatus"='delivered'))
)
SELECT d.key,COUNT(a.id)::int AS "submittedAttempts",
COUNT(DISTINCT a."messageRecordId") FILTER(WHERE a."submitStatus"='accepted')::int AS "acceptedBusinessCount",
COUNT(DISTINCT a."messageRecordId") FILTER(WHERE a."submitStatus"='accepted' AND delivered.id IS NOT NULL)::int AS "deliveredBusinessCount"
FROM dims d LEFT JOIN attempts a ON a."signatureId"=d.signature AND a.carrier=d.carrier
AND (d.channel='' OR a."channelId"=d.channel) AND a.at>=d.approved AT TIME ZONE 'UTC'
LEFT JOIN delivered ON delivered.id=a.id GROUP BY d.key`);
return new Map(rows.map(({ key, ...counts }) => [key, counts]));
}
export async function unreportedRows(db: PrismaService, date: string) {
return db.$queryRaw<
Array<{
dimensionKey: string;
tenantId: string;
applicationId: string;
signatureName: string;
tenantName: string;
applicationName: string;
messageCount: number;
}>
>(Prisma.sql`
WITH extracted AS (
SELECT m."tenantId",m."applicationId",SUBSTRING(m.content FROM '^【[^【】]+】') AS name
FROM "SmsMessageRecord" m WHERE m."queuedAt">=${startOfDay(date)} AND m."queuedAt"<${startOfDay(addDays(date, 1))} AND m."signatureId" IS NULL
) SELECT jsonb_build_array(e."tenantId",e."applicationId",e.name)::text AS "dimensionKey",
e."tenantId",e."applicationId",e.name AS "signatureName",t.name AS "tenantName",a.name AS "applicationName",COUNT(*)::int AS "messageCount"
FROM extracted e JOIN "Tenant" t ON t.id=e."tenantId" JOIN "SmsApplication" a ON a.id=e."applicationId"
WHERE e.name IS NOT NULL AND NOT EXISTS(SELECT 1 FROM "SmsSignature" s WHERE s."tenantId"=e."tenantId" AND s."applicationId"=e."applicationId" AND s.name=e.name AND s."auditStatus"<>'deleted')
GROUP BY e."tenantId",e."applicationId",e.name,t.name,a.name`);
}
@@ -0,0 +1,23 @@
import { addDays, analyticsDate, analyticsPage, mutableDay, todayKey } from './analytics-date';
describe('signature analytics business-day contract', () => {
const now = new Date('2026-09-16T16:00:00Z');
it('uses Shanghai midnight and freezes T-4', () => {
expect(todayKey(now)).toBe('2026-09-17');
expect(mutableDay('2026-09-14', now)).toBe(true);
expect(mutableDay('2026-09-13', now)).toBe(false);
expect(mutableDay('2026-09-17', now)).toBe(false);
expect(addDays('2024-03-01', -1)).toBe('2024-02-29');
});
it.each(['2026-02-29', '2026-09-18', '2026-13-01', '2026-9-1'])('rejects invalid or future date %s', (date) => {
expect(() => analyticsDate(date, now)).toThrow();
});
it.each([
[0, 25],
[1, 1000],
[Number.NaN, 25],
[1.5, 25],
])('rejects invalid pagination', (page, size) => {
expect(() => analyticsPage(page, size)).toThrow();
});
});
@@ -0,0 +1,31 @@
import { BadRequestException } from '@nestjs/common';
const dayFormatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
export const todayKey = (now = new Date()) => dayFormatter.format(now);
export const databaseDay = (key: string) => new Date(`${key}T00:00:00.000Z`);
export const startOfDay = (key: string) => new Date(`${key}T00:00:00+08:00`);
export const addDays = (key: string, days: number) =>
new Date(databaseDay(key).getTime() + days * 86_400_000).toISOString().slice(0, 10);
export function analyticsDate(value?: string, now = new Date()) {
const key = value || todayKey(now);
if (
!/^\d{4}-\d{2}-\d{2}$/.test(key) ||
!Number.isFinite(databaseDay(key).getTime()) ||
databaseDay(key).toISOString().slice(0, 10) !== key ||
key > todayKey(now)
) {
throw new BadRequestException('统计日期必须为有效的北京时间日期,不能晚于今天');
}
return key;
}
export function analyticsPage(page = 1, pageSize = 25) {
if (!Number.isInteger(page) || page < 1 || ![10, 25, 50, 100].includes(pageSize))
throw new BadRequestException('分页参数无效');
return { page, pageSize };
}
export const mutableDay = (day: string, now = new Date()) => day < todayKey(now) && day >= addDays(todayKey(now), -3);
@@ -0,0 +1,92 @@
import { randomUUID } from 'node:crypto';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { databaseDay, todayKey } from './analytics-date';
/** Lease acquisition is outside the source snapshot. Publishing always rechecks its fencing token. */
export async function analyticsJob<T>(
db: PrismaService,
scope: string,
date: string,
work: (tx: PrismaService, generation: string, checkpoint: Prisma.JsonValue | null) => Promise<T>,
now = new Date(),
prepare?: () => Promise<Prisma.InputJsonValue>,
): Promise<{ skipped: boolean; result?: T }> {
const businessDate = databaseDay(date),
refreshFor = databaseDay(todayKey(now));
await db.signatureAnalyticsRun.createMany({
data: [{ scope, businessDate, refreshFor, nextAttemptAt: now, generationId: randomUUID() }],
skipDuplicates: true,
});
const owner = randomUUID(),
generationId = randomUUID();
const claimed = await db.signatureAnalyticsRun.updateMany({
where: {
scope,
businessDate,
AND: [
{ OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }] },
{
OR: [
{ refreshFor: { lt: refreshFor } },
{ state: { not: 'succeeded' }, attempt: { lt: 5 }, nextAttemptAt: { lte: now } },
],
},
],
},
data: {
owner,
generationId,
leaseUntil: new Date(now.getTime() + 300_000),
fence: { increment: 1 },
state: 'running',
startedAt: now,
error: null,
},
});
if (!claimed.count) return { skipped: true };
const run = await db.signatureAnalyticsRun.findUniqueOrThrow({
where: { scope_businessDate: { scope, businessDate } },
});
const attempt = run.refreshFor < refreshFor ? 1 : run.attempt + 1;
await db.signatureAnalyticsRun.update({ where: { id: run.id }, data: { refreshFor, attempt } });
try {
// Preserve the first decision's rule/report snapshot across retries. New-day runs take a new snapshot.
const checkpoint = run.refreshFor < refreshFor ? null : run.checkpoint;
const prepared = checkpoint ?? (prepare ? await prepare() : null);
if (prepared !== null && checkpoint === null) {
const saved = await db.signatureAnalyticsRun.updateMany({
where: { id: run.id, owner, fence: run.fence, state: 'running' },
data: { checkpoint: prepared as Prisma.InputJsonValue },
});
if (saved.count !== 1) throw new Error('签名统计任务认领已失效');
}
const result = await db.$transaction(
async (tx) => {
await tx.$executeRawUnsafe("SET LOCAL statement_timeout='90s'");
const result = await work(tx as PrismaService, generationId, prepared as Prisma.JsonValue | null);
const current = new Date();
const fenced = await tx.signatureAnalyticsRun.updateMany({
where: { id: run.id, owner, fence: run.fence, state: 'running', leaseUntil: { gt: current } },
data: { state: 'succeeded', owner: null, leaseUntil: null, finishedAt: current, error: null },
});
if (fenced.count !== 1) throw new Error('签名统计任务租约已失效,拒绝发布');
return result;
},
{ isolationLevel: 'RepeatableRead', timeout: 120_000, maxWait: 5000 },
);
return { skipped: false, result };
} catch (error) {
await db.signatureAnalyticsRun.updateMany({
where: { id: run.id, owner, fence: run.fence },
data: {
state: attempt >= 5 ? 'failed' : 'retry_wait',
owner: null,
leaseUntil: null,
nextAttemptAt: new Date(Date.now() + Math.min(900_000, 60_000 * 2 ** (attempt - 1))),
error: '签名统计生成失败,请查看服务日志',
},
});
throw error;
}
}
@@ -0,0 +1,217 @@
import { BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { addDays, analyticsDate, analyticsPage, databaseDay, todayKey } from './analytics-date';
export interface ActivityQuery {
date?: string;
dimensionType: string;
page?: number;
pageSize?: number;
tenantName?: string;
applicationName?: string;
signatureName?: string;
channelName?: string;
}
export class SignatureAnalyticsRead {
constructor(private readonly db: PrismaService) {}
async metadata(date: string, now = new Date()) {
const record = await this.db.signatureAnalyticsDay.findUnique({ where: { businessDate: databaseDay(date) } });
const run = await this.db.signatureAnalyticsRun.findUnique({
where: { scope_businessDate: { scope: 'daily', businessDate: databaseDay(date) } },
});
const reportState =
run?.state === 'running'
? 'refreshing'
: ['retry_wait', 'failed'].includes(run?.state ?? '')
? 'failed'
: (record?.state ?? 'missing');
return {
dataSource: 'report' as const,
businessDate: date,
serverBusinessDate: todayKey(now),
reportState,
frozen: date <= addDays(todayKey(now), -4),
generatedAt: record?.generatedAt ?? null,
sourceAsOf: record?.sourceAsOf ?? null,
generationId: record?.publishedGenerationId ?? null,
schemaVersion: record?.schemaVersion ?? 1,
provenance: record?.provenance ?? null,
};
}
async quality(query: { date?: string; keyword?: string; page?: number; pageSize?: number }) {
const date = analyticsDate(query.date);
const { page, pageSize } = analyticsPage(query.page, query.pageSize);
return this.db.$transaction(
async (tx) => {
const meta = await new SignatureAnalyticsRead(tx as PrismaService).metadata(date);
if (!meta.generationId) return { date, items: [], total: 0, page, pageSize, ...meta };
const where: Prisma.SignatureQualityDailyWhereInput = {
generationId: meta.generationId,
...(query.keyword?.trim()
? {
OR: ['signatureName', 'tenantName', 'applicationNames'].map((field) => ({
[field]: { contains: query.keyword!.trim(), mode: 'insensitive' },
})),
}
: {}),
};
const total = await tx.signatureQualityDaily.count({ where });
const rows = await tx.signatureQualityDaily.findMany({
where,
orderBy: [{ total: 'desc' }, { signatureName: 'asc' }, { signatureId: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
});
return { date, items: rows.map((r) => r.payload), total, page, pageSize, ...meta };
},
{ isolationLevel: 'RepeatableRead', timeout: 15_000 },
);
}
async unreported(query: { date?: string; keyword?: string; page?: number; pageSize?: number }) {
const date = analyticsDate(query.date);
const { page, pageSize } = analyticsPage(query.page, query.pageSize);
return this.db.$transaction(
async (tx) => {
const meta = await new SignatureAnalyticsRead(tx as PrismaService).metadata(date);
if (!meta.generationId) return { date, items: [], total: 0, page, pageSize, ...meta };
const where: Prisma.UnreportedSignatureDailyWhereInput = {
generationId: meta.generationId,
...(query.keyword?.trim()
? {
OR: ['signatureName', 'tenantName', 'applicationName'].map((field) => ({
[field]: { contains: query.keyword!.trim(), mode: 'insensitive' },
})),
}
: {}),
};
const total = await tx.unreportedSignatureDaily.count({ where });
const rows = await tx.unreportedSignatureDaily.findMany({
where,
orderBy: [{ messageCount: 'desc' }, { dimensionKey: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
date,
items: rows.map((r) => ({ ...r, signatureId: r.dimensionKey })),
total,
page,
pageSize,
...meta,
};
},
{ isolationLevel: 'RepeatableRead', timeout: 15_000 },
);
}
async activity(query: ActivityQuery) {
const date = analyticsDate(query.date);
if (!['enterprise', 'channel'].includes(query.dimensionType))
throw new BadRequestException('必须指定企业或通道维度');
const { page, pageSize } = analyticsPage(query.page, query.pageSize);
const dates = Array.from({ length: 30 }, (_, i) => addDays(date, -i - 1));
return this.db.$transaction(
async (tx) => {
await tx.$executeRawUnsafe("SET LOCAL statement_timeout='12s'");
const manifests = await tx.signatureAnalyticsDay.findMany({
where: { businessDate: { in: dates.map(databaseDay) } },
});
const runs = await tx.signatureAnalyticsRun.findMany({
where: { scope: 'daily', businessDate: { in: dates.map(databaseDay) } },
});
const runStates = new Map(runs.map((r) => [r.businessDate.toISOString().slice(0, 10), r.state]));
const byDate = new Map(manifests.map((r) => [r.businessDate.toISOString().slice(0, 10), r]));
const coverage = dates.map((d) => {
const r = byDate.get(d);
return {
date: d,
generationId: r?.publishedGenerationId ?? null,
reportState:
runStates.get(d) === 'running'
? 'refreshing'
: ['failed', 'retry_wait'].includes(runStates.get(d) ?? '')
? 'failed'
: (r?.state ?? 'missing'),
generatedAt: r?.generatedAt ?? null,
sourceAsOf: r?.sourceAsOf ?? null,
frozen: d <= addDays(todayKey(), -4),
};
});
const generations = coverage.flatMap((c) => (c.generationId ? [c.generationId] : []));
if (!generations.length)
return { date, items: [], dimensions: [], total: 0, page, pageSize, coverage, complete: false };
const filters = [
['tenantName', query.tenantName],
['applicationName', query.applicationName],
['signatureName', query.signatureName],
['channelName', query.channelName],
]
.filter(([, value]) => value?.trim())
.map(([field, value]) => Prisma.sql`AND r.${Prisma.raw(`"${field}"`)} ILIKE ${`%${value!.trim()}%`}`);
const dimensions = await tx.$queryRaw<
Array<{
dimensionKey: string;
dimensionType: string;
signatureId: string;
channelKey: string;
carrier: string;
signatureName: string;
channelName: string;
tenantName: string;
applicationName: string;
approvedAt: Date | null;
total: number;
rowCount: number;
}>
>(Prisma.sql`
WITH selected AS (
SELECT * FROM "SignatureActivityDaily" WHERE "generationId" IN (${Prisma.join(generations)}) AND "dimensionType"=${query.dimensionType}
), latest AS (
SELECT DISTINCT ON ("dimensionKey") * FROM selected ORDER BY "dimensionKey","businessDate" DESC
), sums AS (SELECT "dimensionKey",SUM("acceptedBusinessCount")::integer AS total FROM selected GROUP BY 1)
SELECT r.*,s.total,COUNT(*) OVER()::integer AS "rowCount" FROM latest r JOIN sums s USING("dimensionKey")
WHERE TRUE ${filters.length ? Prisma.join(filters, ' ') : Prisma.empty} ORDER BY s.total DESC,r."signatureName",r."dimensionKey"
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`);
// An empty out-of-range page still reports the filtered total.
const emptyPageCount = dimensions.length
? []
: await tx.$queryRaw<Array<{ total: number }>>(Prisma.sql`
WITH latest AS (
SELECT DISTINCT ON ("dimensionKey") * FROM "SignatureActivityDaily"
WHERE "generationId" IN (${Prisma.join(generations)}) AND "dimensionType"=${query.dimensionType}
ORDER BY "dimensionKey","businessDate" DESC
) SELECT COUNT(*)::integer AS total FROM latest r WHERE TRUE ${filters.length ? Prisma.join(filters, ' ') : Prisma.empty}`);
const items = dimensions.length
? await tx.signatureActivityDaily.findMany({
where: {
generationId: { in: generations },
dimensionType: query.dimensionType,
dimensionKey: { in: dimensions.map((d) => d.dimensionKey) },
},
})
: [];
return {
date,
dimensions: dimensions.map((d) => ({ ...d, channelId: d.channelKey || null })),
items: items.map((r) => ({
...r,
id: `${r.generationId}:${r.dimensionKey}`,
channelId: r.channelKey || null,
activityDate: r.businessDate.toISOString().slice(0, 10),
status: r.applicability,
})),
total: dimensions[0]?.rowCount ?? emptyPageCount[0]?.total ?? 0,
page,
pageSize,
coverage,
complete: coverage.every((c) => Boolean(c.generationId)),
};
},
{ isolationLevel: 'RepeatableRead', timeout: 15_000 },
);
}
}
@@ -0,0 +1,210 @@
import { Prisma, SignatureRetirementRule } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { addDays, databaseDay, startOfDay, todayKey } from './analytics-date';
import { analyticsJob } from './analytics-job';
const keyOf = (d: { dimensionType: string; signatureId: string; channelKey: string; carrier: string }) =>
JSON.stringify([d.dimensionType, d.signatureId, d.channelKey, d.carrier]);
const carrierNames: Record<string, string> = { mobile: '移动', unicom: '联通', telecom: '电信' };
export async function detectRetirement(db: PrismaService, date: string) {
if (date !== todayKey()) throw new Error('自动退网检测仅处理当天,不补发历史预警');
const dependency = await db.signatureAnalyticsDay.findUnique({
where: { businessDate: databaseDay(addDays(date, -1)) },
});
if (
!dependency?.publishedGenerationId ||
dependency.state !== 'ready' ||
dependency.refreshFor?.getTime() !== databaseDay(date).getTime()
)
throw new Error('签名退网检测等待昨日活动日报完成');
const job = await analyticsJob(
db,
'retirement',
date,
async (tx, _generation, checkpoint) => {
const report = await tx.signatureAnalyticsDay.findUnique({
where: { businessDate: databaseDay(addDays(date, -1)) },
});
if (
!report?.publishedGenerationId ||
report.state !== 'ready' ||
report.refreshFor?.getTime() !== databaseDay(date).getTime()
)
throw new Error('签名退网检测等待昨日活动日报完成');
const snapshot = checkpoint as unknown as { generationId: string; rules: SignatureRetirementRule[] };
const daily = await tx.signatureActivityDaily.findMany({ where: { generationId: snapshot.generationId } });
const rules = snapshot.rules;
const existing = new Set(
(await tx.signatureRetirementDetection.findMany({ where: { detectionDate: databaseDay(date) } })).map(keyOf),
);
const dimensions = daily.flatMap((d) => {
if (existing.has(keyOf(d)) || !d.approvedAt) return [];
const enterprise = d.dimensionType === 'enterprise';
const special = enterprise ? 'enterprise_application' : 'channel';
const global = enterprise ? 'enterprise_global' : 'channel_global';
const target = enterprise ? d.applicationId : d.channelKey;
const rule =
rules.find((r) => r.ruleType === special && r.targetId === target) ??
rules.find((r) => r.ruleType === global && r.targetKey === '');
if (!rule) return [];
const [windowDays, threshold] =
d.carrier === 'mobile'
? [rule.mobileWindowDays, rule.mobileThreshold]
: d.carrier === 'unicom'
? [rule.unicomWindowDays, rule.unicomThreshold]
: [rule.telecomWindowDays, rule.telecomThreshold];
const windowStart = startOfDay(addDays(date, -windowDays));
return [
{
...d,
approvedAt: d.approvedAt,
rule,
windowDays,
threshold,
windowStart,
observing: d.approvedAt > windowStart,
},
];
});
const eligible = dimensions.filter((d) => !d.observing);
const windowCounts = new Map<string, number>();
if (eligible.length) {
const earliest = new Date(Math.min(...eligible.map((d) => d.windowStart.getTime())));
const defs = JSON.stringify(
eligible.map((d) => ({
key: d.dimensionKey,
signature: d.signatureId,
carrier: d.carrier,
channel: d.channelKey,
start: d.windowStart.toISOString(),
})),
);
const rows = await tx.$queryRaw<Array<{ key: string; count: number }>>(Prisma.sql`
WITH dimensions AS (SELECT * FROM jsonb_to_recordset(${defs}::jsonb) AS d(key text,signature text,carrier text,channel text,start timestamptz)),
accepted AS MATERIALIZED (
SELECT s."messageRecordId",s."channelId",m."signatureId",m.carrier,COALESCE(s."submittedAt",s."createdAt") AS at
FROM "SmsSubmitRecord" s JOIN "SmsMessageRecord" m ON m.id=s."messageRecordId"
WHERE s."submitStatus"='accepted' AND COALESCE(s."submittedAt",s."createdAt")>=${earliest}
AND COALESCE(s."submittedAt",s."createdAt")<${startOfDay(date)} AND m."signatureId" IS NOT NULL
) SELECT d.key,COUNT(DISTINCT a."messageRecordId")::int AS count FROM dimensions d
LEFT JOIN accepted a ON a."signatureId"=d.signature AND a.carrier=d.carrier
AND (d.channel='' OR a."channelId"=d.channel) AND a.at>=d.start AT TIME ZONE 'UTC'
GROUP BY d.key`);
for (const row of rows) windowCounts.set(row.key, row.count);
}
const suppressions = new Map(
(await tx.signatureRetirementSuppression.findMany({ where: { active: true } })).map((r) => [keyOf(r), r]),
);
const cycles = new Map(
(await tx.signatureRetirementCycle.findMany({ where: { status: 'open' } })).map((r) => [keyOf(r), r]),
);
const rows: Prisma.SignatureRetirementDetectionCreateManyInput[] = [];
const continued: string[] = [],
resolved: string[] = [];
const newCycles: Prisma.SignatureRetirementCycleCreateManyInput[] = [];
let alerted = 0,
healthy = 0,
ineligible = 0;
for (const d of dimensions) {
const key = keyOf(d),
count = windowCounts.get(d.dimensionKey) ?? 0;
const alert = !d.observing && count < d.threshold;
let cycleId: string | null = null;
const cycle = cycles.get(key);
if (d.observing) ineligible++;
else if (alert) {
alerted++;
cycleId = cycle?.id ?? randomUUID();
if (cycle) continued.push(cycle.id);
else
newCycles.push({
id: cycleId,
dimensionType: d.dimensionType,
signatureId: d.signatureId,
channelId: d.channelKey || null,
channelKey: d.channelKey,
carrier: d.carrier,
startedOn: databaseDay(date),
lastDetectedOn: databaseDay(date),
});
} else {
healthy++;
if (cycle) resolved.push(cycle.id);
}
const suppression = suppressions.get(key);
const suppressed = Boolean(
suppression &&
(suppression.mode === 'permanent' || !suppression.muteUntil || suppression.muteUntil >= databaseDay(date)),
);
const fallback =
d.dimensionType === 'enterprise'
? '请通知 {enterprise}{signature}在{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时进行保签名发送。'
: '{signature}在通道{channel}的{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时通知客户或进行保签名发送。';
let content = d.rule.messageTemplate?.trim() || fallback;
for (const [name, value] of Object.entries({
enterprise: d.tenantName,
signature: d.signatureName,
carrier: carrierNames[d.carrier] ?? d.carrier,
days: d.windowDays,
actual: count,
threshold: d.threshold,
channel: d.channelName || '-',
}))
content = content.replaceAll(`{${name}}`, String(value));
rows.push({
detectionDate: databaseDay(date),
dimensionType: d.dimensionType,
tenantId: d.tenantId,
applicationId: d.applicationId,
signatureId: d.signatureId,
channelId: d.channelKey || null,
channelKey: d.channelKey,
carrier: d.carrier,
windowDays: d.windowDays,
threshold: d.threshold,
submittedAttempts: d.submittedAttempts,
acceptedBusinessCount: d.acceptedBusinessCount,
deliveredBusinessCount: d.deliveredBusinessCount,
approvedAt: d.approvedAt,
ruleId: d.rule.id,
ruleVersion: d.rule.version,
status: d.observing ? 'observing' : alert ? 'alert' : 'healthy',
cycleId,
suppressed,
notificationTitle: alert
? d.dimensionType === 'enterprise'
? '企业签名清退预警'
: '通道签名清退预警'
: null,
notificationContent: alert ? content : null,
});
}
for (let i = 0; i < newCycles.length; i += 250)
await tx.signatureRetirementCycle.createMany({ data: newCycles.slice(i, i + 250) });
if (continued.length)
await tx.signatureRetirementCycle.updateMany({
where: { id: { in: continued } },
data: { lastDetectedOn: databaseDay(date) },
});
if (resolved.length)
await tx.signatureRetirementCycle.updateMany({
where: { id: { in: resolved } },
data: { status: 'resolved', resolvedOn: databaseDay(date), lastDetectedOn: databaseDay(date) },
});
for (let i = 0; i < rows.length; i += 250)
await tx.signatureRetirementDetection.createMany({ data: rows.slice(i, i + 250) });
return { detectionDate: date, dimensions: dimensions.length, alerted, healthy, ineligible };
},
new Date(),
async () =>
JSON.parse(
JSON.stringify({
generationId: dependency.publishedGenerationId,
rules: await db.signatureRetirementRule.findMany({ where: { enabled: true } }),
}),
) as Prisma.InputJsonValue,
);
return job.result ?? { detectionDate: date, skipped: true };
}
@@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { SignatureAnalyticsService } from './signature-analytics.service';
@Module({ imports: [PrismaModule], providers: [SignatureAnalyticsService], exports: [SignatureAnalyticsService] })
export class SignatureAnalyticsModule {}
@@ -0,0 +1,122 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { OperationsQualityQueries } from '../operations/queries/quality.queries';
import { activityCounts, activityDimensions, unreportedRows } from './analytics-aggregate';
import { addDays, analyticsDate, databaseDay, mutableDay, todayKey } from './analytics-date';
import { analyticsJob } from './analytics-job';
@Injectable()
export class SignatureAnalyticsService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(SignatureAnalyticsService.name);
private timer?: NodeJS.Timeout;
private running = false;
constructor(private readonly db: PrismaService) {}
onModuleInit() {
if (process.env.NODE_ENV === 'test' || process.env.SIGNATURE_ANALYTICS_ENABLED === 'false') return;
this.timer = setInterval(() => void this.tick(), 60_000);
this.timer.unref();
void this.tick();
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
async tick(now = new Date()) {
const hour = Number(
new Intl.DateTimeFormat('en-GB', { timeZone: 'Asia/Shanghai', hour: '2-digit', hour12: false }).format(now),
);
if (hour < 3 || this.running) return;
this.running = true;
try {
for (const offset of [-3, -2, -1]) {
try {
await this.generate(addDays(todayKey(now), offset));
} catch (error) {
this.logger.error(
`signature_analytics_failed date=${addDays(todayKey(now), offset)}`,
error instanceof Error ? error.stack : String(error),
);
}
}
} finally {
this.running = false;
}
}
/** Explicit offline backfill only; no controller exposes this write operation. Existing frozen days cannot be overwritten. */
async generate(value: string, backfill = false) {
const date = analyticsDate(value);
if (!mutableDay(date) && !backfill) return { skipped: true };
if (date >= todayKey()) throw new Error('日报只生成完整自然日');
const businessDate = databaseDay(date);
const existing = await this.db.signatureAnalyticsDay.findUnique({ where: { businessDate } });
if (backfill && existing?.publishedGenerationId) throw new Error('历史补建不得覆盖已发布日报');
await this.db.signatureAnalyticsDay.upsert({ where: { businessDate }, create: { businessDate }, update: {} });
return await analyticsJob(this.db, 'daily', date, async (tx, generationId) => {
const sourceAsOf = new Date();
await tx.signatureAnalyticsGeneration.create({ data: { id: generationId, businessDate, sourceAsOf } });
const quality = await new OperationsQualityQueries(tx).signatureQualityLive({ date }, true);
const dimensions = await activityDimensions(tx, date);
const counts = await activityCounts(tx, dimensions, date);
const unreported = await unreportedRows(tx, date);
// All candidate rows and manifest publication share this transaction: readers never see half a day.
for (let i = 0; i < quality.items.length; i += 250)
await tx.signatureQualityDaily.createMany({
data: quality.items.slice(i, i + 250).map((row) => ({
businessDate,
generationId,
signatureId: row.signatureId,
signatureName: row.signatureName,
tenantId: row.tenantId,
tenantName: row.tenantName,
applicationNames: row.applicationNames ?? '',
total: row.total,
payload: JSON.parse(JSON.stringify(row)) as Prisma.InputJsonValue,
})),
});
for (let i = 0; i < dimensions.length; i += 250)
await tx.signatureActivityDaily.createMany({
data: dimensions.slice(i, i + 250).map((d) => ({
...d,
businessDate,
generationId,
...(counts.get(d.dimensionKey) ?? {
submittedAttempts: 0,
acceptedBusinessCount: 0,
deliveredBusinessCount: 0,
}),
applicability: 'applicable',
})),
});
for (let i = 0; i < unreported.length; i += 250)
await tx.unreportedSignatureDaily.createMany({
data: unreported.slice(i, i + 250).map((r) => ({ ...r, businessDate, generationId })),
});
if (!backfill && !mutableDay(date)) throw new Error('日报已进入冻结区,拒绝跨日发布');
if (backfill) {
const current = await tx.signatureAnalyticsDay.findUniqueOrThrow({ where: { businessDate } });
if (current.publishedGenerationId) throw new Error('已有发布版本,拒绝覆盖');
}
await tx.signatureAnalyticsDay.update({
where: { businessDate },
data: {
publishedGenerationId: generationId,
state: 'ready',
error: null,
generatedAt: new Date(),
sourceAsOf,
refreshFor: databaseDay(todayKey()),
provenance: backfill ? 'backfill-current-source' : 'daily',
rowCounts: { quality: quality.items.length, activity: dimensions.length, unreported: unreported.length },
},
});
return {
date,
generationId,
quality: quality.items.length,
activity: dimensions.length,
unreported: unreported.length,
};
});
}
}
@@ -15,6 +15,7 @@ describe('daily application messages and date formatting', () => {
notificationContent: `冻结正文${item.id}`,
}));
const prisma = {
signatureAnalyticsRun: { findUnique: jest.fn().mockResolvedValue({ state: 'succeeded' }) },
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue(detections) },
smsApplication: {
findMany: jest.fn().mockResolvedValue([
@@ -60,6 +61,7 @@ describe('daily application messages and date formatting', () => {
['2026-09-01', '2026-08-31'],
])('keeps T-1 semantics for %s without constructing formatters per row', async (date, expected) => {
const prisma = {
signatureAnalyticsRun: { findUnique: jest.fn().mockResolvedValue({ state: 'succeeded' }) },
signatureRetirementDetection: {
findMany: jest.fn().mockResolvedValue(
Array.from({ length: 100 }, (_, i) => ({
@@ -1,14 +1,26 @@
import { SignatureAnalyticsRead } from '../signature-analytics/analytics-read';
import { PrismaService } from '../prisma/prisma.service';
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, CreateRetirementWebhookDto, RetirementMessageQuery, SuppressRetirementMessageDto, UnreportedSignatureQuery, UpsertRetirementRuleDto } from './signature-retirement.contracts';
import type {
CancelRetirementSuppressionDto,
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) {}
constructor(
private readonly service: SignatureRetirementService,
private readonly prisma: PrismaService,
) {}
@Get('configuration')
getConfiguration() {
@@ -45,7 +57,17 @@ export class SignatureRetirementController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const query: RetirementMessageQuery = { dateFrom, dateTo, dimensionType, tenantId, applicationId, signatureKeyword, channelId, page: Number(page), pageSize: Number(pageSize) };
const query: RetirementMessageQuery = {
dateFrom,
dateTo,
dimensionType,
tenantId,
applicationId,
signatureKeyword,
channelId,
page: Number(page),
pageSize: Number(pageSize),
};
return this.service.listMessages(query);
}
@@ -66,7 +88,11 @@ export class SignatureRetirementController {
@Post('messages/:id/suppress')
@RequireRecentAuthentication()
suppress(@Param('id') id: string, @Body() body: SuppressRetirementMessageDto, @CurrentSessionUserId() operatorId?: string) {
suppress(
@Param('id') id: string,
@Body() body: SuppressRetirementMessageDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.service.suppressMessage(id, body, operatorId);
}
@@ -77,10 +103,35 @@ export class SignatureRetirementController {
@Post('suppressions/:id/cancel')
@RequireRecentAuthentication()
cancelSuppression(@Param('id') id: string, @Body() body: CancelRetirementSuppressionDto, @CurrentSessionUserId() operatorId?: string) {
cancelSuppression(
@Param('id') id: string,
@Body() body: CancelRetirementSuppressionDto,
@CurrentSessionUserId() operatorId?: string,
) {
return this.service.cancelSuppression(id, body, operatorId);
}
@Get('activity')
activity(
@Query()
query: {
date?: string;
dimensionType: string;
page?: string;
pageSize?: string;
tenantName?: string;
applicationName?: string;
signatureName?: string;
channelName?: string;
},
) {
return new SignatureAnalyticsRead(this.prisma).activity({
...query,
page: query.page === undefined ? 1 : Number(query.page),
pageSize: query.pageSize === undefined ? 25 : Number(query.pageSize),
});
}
@Get('heatmap')
heatmap(@Query('date') date?: string) {
return this.service.heatmap(date);
@@ -93,8 +144,12 @@ export class SignatureRetirementController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
const query: UnreportedSignatureQuery = { date, keyword, page: Number(page), pageSize: Number(pageSize) };
const query: UnreportedSignatureQuery = {
date,
keyword,
page: page === undefined ? 1 : Number(page),
pageSize: pageSize === undefined ? 25 : Number(pageSize),
};
return this.service.unreportedSignatures(query);
}
}
@@ -1,57 +1,6 @@
import { millisecondsUntilShanghaiHour, SignatureRetirementService } from './signature-retirement.service';
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('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);
@@ -69,6 +18,7 @@ describe('SignatureRetirementService dimensions', () => {
notificationContent: '冻结后的预警正文',
};
const prisma = {
signatureAnalyticsRun: { findUnique: jest.fn().mockResolvedValue({ state: 'succeeded' }) },
signatureRetirementDetection: {
findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]),
},
@@ -138,47 +88,17 @@ describe('SignatureRetirementService dimensions', () => {
expect(result.items[0]).toEqual(expect.objectContaining({ activityDate: '2026-08-09' }));
});
it('persists daily observing snapshots without opening alert cycles', async () => {
const prisma = {
signatureRetirementSuppression: {
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
findUnique: jest.fn().mockResolvedValue(null),
},
signatureRetirementRule: {
findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]),
},
channelSignatureReportTask: {
findMany: jest.fn().mockResolvedValue([task('channel-1', '移动一号', 'mobile', '2026-08-09T00:00:00Z')]),
},
signatureRetirementDetection: {
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockResolvedValue({ id: 'detection-1' }),
},
signatureRetirementCycle: { findFirst: jest.fn(), create: jest.fn(), update: jest.fn() },
$queryRaw: jest
.fn()
.mockResolvedValue([{ submittedAttempts: 12, acceptedBusinessCount: 10, deliveredBusinessCount: 9 }]),
};
const observingService = new SignatureRetirementService(prisma as never);
await expect(observingService.runDetection('2026-08-10')).resolves.toEqual({
detectionDate: '2026-08-10',
dimensions: 2,
alerted: 0,
healthy: 0,
ineligible: 2,
});
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledTimes(2);
expect(prisma.signatureRetirementDetection.create).toHaveBeenCalledWith({
data: expect.objectContaining({
status: 'observing',
acceptedBusinessCount: 10,
cycleId: undefined,
notificationTitle: null,
notificationContent: null,
}),
});
expect(prisma.signatureRetirementCycle.create).not.toHaveBeenCalled();
it('waits for a complete daily report before retirement detection', async () => {
const prisma = { signatureAnalyticsDay: { findUnique: jest.fn().mockResolvedValue(null) } };
const date = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
await expect(new SignatureRetirementService(prisma as never).runDetection(date)).rejects.toThrow(
'等待昨日活动日报',
);
});
it('maps the real unreported-signature aggregation to an independent page', async () => {
@@ -199,7 +119,7 @@ describe('SignatureRetirementService dimensions', () => {
const unreportedService = new SignatureRetirementService(prisma as never);
await expect(
unreportedService.unreportedSignatures({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 }),
unreportedService.unreportedSignaturesLive({ date: '2026-08-10', keyword: '测试', page: 2, pageSize: 10 }),
).resolves.toEqual({
date: '2026-08-10',
items: [
@@ -311,32 +231,3 @@ describe('SignatureRetirementService dimensions', () => {
);
});
});
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 },
};
}
@@ -1,3 +1,6 @@
import { SignatureAnalyticsRead } from '../signature-analytics/analytics-read';
import { analyticsDate, analyticsPage, todayKey } from '../signature-analytics/analytics-date';
import { detectRetirement } from '../signature-analytics/retirement-batch';
import {
BadRequestException,
Injectable,
@@ -37,28 +40,6 @@ const shanghaiHourFormatter = new Intl.DateTimeFormat('en-GB', {
const DAY_MS = 86_400_000;
const CARRIERS = ['mobile', 'unicom', 'telecom'] as const;
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 {
@@ -66,6 +47,8 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
private detectionTimer?: ReturnType<typeof setTimeout>;
private notificationTimer?: ReturnType<typeof setTimeout>;
private deliveryTimer?: ReturnType<typeof setInterval>;
private compensationRunning = false;
private publishedDate?: string;
private startupTimer?: ReturnType<typeof setTimeout>;
constructor(private readonly prisma: PrismaService) {}
@@ -78,7 +61,7 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
this.scheduleDetection();
this.scheduleNotification();
this.deliveryTimer = setInterval(
() => void this.deliverPendingWebhooks(),
() => void this.runStartupCompensation(),
positiveIntegerEnv('SIGNATURE_RETIREMENT_WEBHOOK_INTERVAL_MS', DEFAULT_DELIVERY_INTERVAL_MS),
);
this.deliveryTimer.unref?.();
@@ -540,6 +523,30 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
}
async unreportedSignatures(query: UnreportedSignatureQuery) {
analyticsPage(query.page, query.pageSize);
const date = analyticsDate(query.date);
if (date !== todayKey()) return new SignatureAnalyticsRead(this.prisma).unreported({ ...query, date });
return this.prisma.$transaction(
async (tx) => {
await tx.$executeRawUnsafe("SET LOCAL statement_timeout='12s'");
const data = await new SignatureRetirementService(tx as PrismaService).unreportedSignaturesLive({
...query,
date,
});
return {
...data,
dataSource: 'live',
reportState: 'ready',
frozen: false,
sourceAsOf: new Date(),
serverBusinessDate: date,
};
},
{ isolationLevel: 'RepeatableRead', timeout: 15000 },
);
}
async unreportedSignaturesLive(query: UnreportedSignatureQuery) {
const date = assertDateKey(query.date || shanghaiDateKey());
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(100, positiveInteger(query.pageSize, 25));
@@ -630,68 +637,16 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
}
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);
const activityStart = shanghaiStart(addDays(detectionKey, -1));
const activityEnd = shanghaiStart(detectionKey);
const effectiveActivityStart = dimension.approvedAt > activityStart ? dimension.approvedAt : activityStart;
if (effectiveActivityStart >= activityEnd) {
ineligible += 1;
continue;
}
const dailyCounts = await this.activityCounts(dimension, effectiveActivityStart, activityEnd);
if (dimension.approvedAt > windowStart) {
// 观察期只禁止预警,不能吞掉真实发送快照,否则热力图会错误显示无数据。
await this.persistDetection(detectionKey, dimension, windowDays, threshold, dailyCounts, false, true);
ineligible += 1;
continue;
}
const windowCounts = await this.activityCounts(dimension, windowStart, activityEnd);
const isAlert = windowCounts.acceptedBusinessCount < threshold;
await this.persistDetection(
detectionKey,
dimension,
windowDays,
threshold,
dailyCounts,
isAlert,
false,
windowCounts,
);
if (isAlert) alerted += 1;
else healthy += 1;
}
return { detectionDate: detectionKey, dimensions: dimensions.length, alerted, healthy, ineligible };
return detectRetirement(this.prisma, analyticsDate(date));
}
async publishNotifications(date?: string) {
const notificationKey = assertDateKey(date || shanghaiDateKey());
const notificationKey = analyticsDate(date);
if (this.publishedDate === notificationKey) return { notificationDate: notificationKey, created: 0 };
const completed = await this.prisma.signatureAnalyticsRun.findUnique({
where: { scope_businessDate: { scope: 'retirement', businessDate: databaseDate(notificationKey) } },
});
if (completed?.state !== 'succeeded') throw new Error('签名退网检测尚未完整完成,暂不发布通知');
const detections = await this.prisma.signatureRetirementDetection.findMany({
where: {
detectionDate: databaseDate(notificationKey),
@@ -749,10 +704,13 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
}
}
await this.enqueueWebhookSummaries(notificationKey);
this.publishedDate = notificationKey;
return { notificationDate: notificationKey, created };
}
private async runStartupCompensation() {
if (this.compensationRunning) return;
this.compensationRunning = true;
const now = new Date();
const hour = shanghaiHour(now);
try {
@@ -765,6 +723,8 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
this.logger.error(
`Signature retirement startup compensation failed: ${error instanceof Error ? error.message : String(error)}`,
);
} finally {
this.compensationRunning = false;
}
}
@@ -801,231 +761,6 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
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,
observing = false,
alertCounts = counts,
) {
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 (observing) {
cycle = null;
} else 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,
alertCounts.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: observing ? 'observing' : 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 [webhooks, messages] = await Promise.all([
@@ -1119,46 +854,6 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
}
}
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 shanghaiDayFormatter.format(date);
}