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) work SmsAttemptCompletionWork @relation(fields: [workId], references: [id], onDelete: Restrict)
@@index([workId, processedAt, createdAt]) @@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 { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { AuditModule } from './audit/audit.module'; import { AuditModule } from './audit/audit.module';
@@ -57,6 +58,7 @@ import { SendingMonitorModule } from './sending-monitor/sending-monitor.module';
InfrastructureMonitoringModule, InfrastructureMonitoringModule,
OpenApiModule, OpenApiModule,
SignatureRetirementModule, SignatureRetirementModule,
SignatureAnalyticsModule,
SecurityDetectionModule, SecurityDetectionModule,
MetricsModule, MetricsModule,
ReportNotificationsModule, ReportNotificationsModule,
@@ -178,8 +178,8 @@ export class AdminOperationsController {
return this.operations.signatureQuality({ return this.operations.signatureQuality({
date, date,
keyword, keyword,
page: Number(page), page: page === undefined ? 1 : Number(page),
pageSize: Number(pageSize), pageSize: pageSize === undefined ? 25 : Number(pageSize),
}); });
} }
@@ -1,3 +1,4 @@
import { OperationsQualityQueries } from './queries/quality.queries';
import { OperationsService } from './operations.service'; import { OperationsService } from './operations.service';
function createPrismaMock() { function createPrismaMock() {
@@ -936,10 +937,8 @@ describe('OperationsService', () => {
averageArrivalMs: 1800, averageArrivalMs: 1800,
}, },
]); ]);
const service = new OperationsService(prisma as never);
await expect( await expect(
service.signatureQuality({ new OperationsQualityQueries(prisma as never).signatureQualityLive({
date: '2026-07-24', date: '2026-07-24',
keyword: '测试', keyword: '测试',
page: 2, page: 2,
@@ -987,9 +986,9 @@ describe('OperationsService', () => {
it('does not query channel details when the selected date has no registered signatures', async () => { it('does not query channel details when the selected date has no registered signatures', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
prisma.$queryRaw.mockResolvedValueOnce([]); prisma.$queryRaw.mockResolvedValueOnce([]);
const service = new OperationsService(prisma as never); await expect(
new OperationsQualityQueries(prisma as never).signatureQualityLive({ date: '2026-07-24' }),
await expect(service.signatureQuality({ date: '2026-07-24' })).resolves.toEqual({ ).resolves.toEqual({
date: '2026-07-24', date: '2026-07-24',
items: [], items: [],
total: 0, total: 0,
+74 -19
View File
@@ -1,4 +1,6 @@
import { Prisma } from '@prisma/client'; 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 { PrismaService } from '../../prisma/prisma.service';
import type { SignatureQualityQuery } from '../operations.contracts'; import type { SignatureQualityQuery } from '../operations.contracts';
@@ -321,9 +323,30 @@ export class OperationsQualityQueries {
return { date: day.key, summary, channels, signatures, drainageSignatures, applications }; return { date: day.key, summary, channels, signatures, drainageSignatures, applications };
} }
async signatureQuality(query: SignatureQualityQuery) { 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 day = qualityBusinessDay(query.date);
const page = positiveInteger(query.page, 1); 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 keyword = query.keyword?.trim() || null;
const keywordPattern = keyword ? `%${keyword}%` : null; const keywordPattern = keyword ? `%${keyword}%` : null;
const summaries = await this.prisma.$queryRaw< const summaries = await this.prisma.$queryRaw<
@@ -341,6 +364,8 @@ export class OperationsQualityQueries {
failureCount: number; failureCount: number;
successRate: number; successRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
rowCount: number; rowCount: number;
}> }>
>(Prisma.sql` >(Prisma.sql`
@@ -361,6 +386,15 @@ export class OperationsQualityQueries {
WHERE message."signatureId" IS NOT NULL WHERE message."signatureId" IS NOT NULL
AND message."queuedAt" >= ${day.startAt} AND message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt} 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 SELECT
signature.id AS "signatureId", signature.id AS "signatureId",
@@ -368,37 +402,37 @@ export class OperationsQualityQueries {
tenant.id AS "tenantId", tenant.id AS "tenantId",
tenant.name AS "tenantName", tenant.name AS "tenantName",
STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames", STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames",
COUNT(*)::integer AS total, COUNT(base.signature_id)::integer AS total,
COUNT(*) FILTER ( COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed' WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
)::integer AS "acceptedCount", )::integer AS "acceptedCount",
COUNT(*) FILTER ( COUNT(base.signature_id) FILTER (
WHERE base.status = 'submit_failed' WHERE base.status = 'submit_failed'
OR base.submit_status IN ('rejected', 'timeout') OR base.submit_status IN ('rejected', 'timeout')
)::integer AS "submitFailureCount", )::integer AS "submitFailureCount",
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount", COUNT(base.signature_id) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
COUNT(*) FILTER ( COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed' WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') 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 = '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)) AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "unknownCount", )::integer AS "unknownCount",
COUNT(*) FILTER ( COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed' WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') 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 = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false)) AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "failureCount", )::integer AS "failureCount",
CASE CASE
WHEN COUNT(*) FILTER ( WHEN COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed' WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
) = 0 THEN 0 ) = 0 THEN 0
ELSE ROUND( 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 * 100.0
/ COUNT(*) FILTER ( / COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed' WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout') AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
), ),
@@ -407,10 +441,11 @@ export class OperationsQualityQueries {
END AS "successRate", END AS "successRate",
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs", ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs",
COUNT(*) OVER()::integer AS "rowCount" COUNT(*) OVER()::integer AS "rowCount"
FROM base FROM dimensions
JOIN "SmsSignature" signature ON signature.id = base.signature_id 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" 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 ( WHERE (
${keyword}::text IS NULL ${keyword}::text IS NULL
OR signature.name ILIKE ${keywordPattern} OR signature.name ILIKE ${keywordPattern}
@@ -441,6 +476,8 @@ export class OperationsQualityQueries {
failureCount: number; failureCount: number;
successRate: number; successRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
}> }>
>(Prisma.sql` >(Prisma.sql`
WITH base AS ( WITH base AS (
@@ -457,12 +494,12 @@ export class OperationsQualityQueries {
submit."submitStatus" AS submit_status, submit."submitStatus" AS submit_status,
receipt."deliveredAt" AS delivered_at, receipt."deliveredAt" AS delivered_at,
failed_receipt."failedAt" AS failed_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.delivered_count, 0) AS segment_delivered_count,
COALESCE(segment_summary.failure_count, 0) AS segment_failure_count, COALESCE(segment_summary.failure_count, 0) AS segment_failure_count,
CASE CASE
WHEN segment_summary.segment_count > 0 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") AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt")
THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000 THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
WHEN segment_summary.segment_count = 0 WHEN segment_summary.segment_count = 0
@@ -474,6 +511,7 @@ export class OperationsQualityQueries {
JOIN "SmsChannel" channel ON channel.id = submit."channelId" JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT 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(*)::integer AS segment_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count, COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count, COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
@@ -532,6 +570,8 @@ export class OperationsQualityQueries {
1 1
)::double precision )::double precision
END AS "successRate", 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" ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
FROM classified FROM classified
GROUP BY signature_id, channel_id, carrier, drainage_state GROUP BY signature_id, channel_id, carrier, drainage_state
@@ -548,6 +588,8 @@ export class OperationsQualityQueries {
finalSuccessCount: number; finalSuccessCount: number;
finalSuccessRate: number; finalSuccessRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
}> }>
>(Prisma.sql` >(Prisma.sql`
SELECT SELECT
@@ -620,6 +662,8 @@ type SignatureSplitRow = {
failureCount: number; failureCount: number;
successRate: number; successRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
}; };
function aggregateSignatureRows(rows: SignatureSplitRow[]) { function aggregateSignatureRows(rows: SignatureSplitRow[]) {
@@ -632,7 +676,7 @@ function aggregateSignatureRows(rows: SignatureSplitRow[]) {
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0); const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0); const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
const arrivalWeight = parts.reduce( 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, 0,
); );
return { return {
@@ -650,7 +694,10 @@ function aggregateSignatureRows(rows: SignatureSplitRow[]) {
arrivalWeight === 0 arrivalWeight === 0
? null ? null
: Math.round( : 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; failureCount: number;
successRate: number; successRate: number;
averageArrivalMs: number | null; averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
}; };
function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) { function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
@@ -688,7 +737,10 @@ function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
const first = parts[0]; const first = parts[0];
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0); const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 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 { return {
signatureId: first.signatureId, signatureId: first.signatureId,
channelId: first.channelId, channelId: first.channelId,
@@ -705,7 +757,10 @@ function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
arrivalWeight === 0 arrivalWeight === 0
? null ? null
: Math.round( : 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' }), update: jest.fn().mockResolvedValue({ id: 'candidate-1', status: 'claimed' }),
}, },
cmppDownstreamDelivery: { cmppDownstreamDelivery: {
createMany: jest.fn().mockResolvedValue({ count: 1 }),
findUniqueOrThrow: jest
.fn()
.mockResolvedValue({ id: 'delivery-1', messageRecordId: 'record-1', applicationId: 'app-1' }),
create: jest create: jest
.fn() .fn()
.mockImplementation(({ data }) => .mockImplementation(({ data }) =>
@@ -4455,6 +4459,7 @@ describe('SendChainService', () => {
it('records ambiguous uplink match candidates for shared access numbers', async () => { it('records ambiguous uplink match candidates for shared access numbers', async () => {
const { service, prisma } = createService(); const { service, prisma } = createService();
prisma.smsMessageRecord.findMany.mockResolvedValue([]);
prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }, { applicationId: 'app-2' }]); prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }, { applicationId: 'app-2' }]);
prisma.smsApplication.findMany.mockResolvedValue([ prisma.smsApplication.findMany.mockResolvedValue([
{ id: 'app-1', tenantId: 'tenant-1', name: '应用A' }, { id: 'app-1', tenantId: 'tenant-1', name: '应用A' },
@@ -4475,7 +4480,7 @@ describe('SendChainService', () => {
tenantId: undefined, tenantId: undefined,
applicationId: undefined, applicationId: undefined,
matchStatus: 'ambiguous', matchStatus: 'ambiguous',
matchReason: '接入号匹配多个应用', matchReason: '应用归属仍有多候选或接入号与发送事实冲突',
}), }),
}); });
expect(prisma.smsUplinkMatchCandidate.createMany).toHaveBeenCalledWith({ expect(prisma.smsUplinkMatchCandidate.createMany).toHaveBeenCalledWith({
@@ -4517,15 +4522,18 @@ describe('SendChainService', () => {
where: { uplinkMessageId: 'uplink-1', id: { not: 'candidate-1' }, status: 'pending' }, where: { uplinkMessageId: 'uplink-1', id: { not: 'candidate-1' }, status: 'pending' },
data: { status: 'rejected' }, data: { status: 'rejected' },
}); });
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({ expect(prisma.cmppDownstreamDelivery.createMany).toHaveBeenCalledWith({
data: expect.objectContaining({ data: [
tenantId: 'tenant-1', expect.objectContaining({
applicationId: 'app-1', tenantId: 'tenant-1',
messageRecordId: 'record-1', applicationId: 'app-1',
messageId: 'MSG-1', messageRecordId: 'record-1',
deliveryType: 'uplink', messageId: 'MSG-1',
status: 'pending', deliveryType: 'uplink',
}), status: 'pending',
}),
],
skipDuplicates: true,
}); });
}); });
+1
View File
@@ -698,6 +698,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
tenantId?: string; tenantId?: string;
applicationId?: string; applicationId?: string;
messageRecordId?: string; messageRecordId?: string;
messageId?: string;
matchStatus: string; matchStatus: string;
matchReason: string; matchReason: string;
candidates: UplinkMatchCandidateInput[]; candidates: UplinkMatchCandidateInput[];
+38 -9
View File
@@ -1,7 +1,21 @@
import { BillingService } from '../billing/billing.service'; import { BillingService } from '../billing/billing.service';
import type { OpenApiService } from '../open-api/open-api.service'; import type { OpenApiService } from '../open-api/open-api.service';
import { PrismaService } from '../prisma/prisma.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 { downstreamPendingTimeoutHours } from './send-chain.helpers';
import type { SendSubmissionService } from './send-submission.service'; import type { SendSubmissionService } from './send-submission.service';
import { SendAccountingService } from './send-accounting.service'; import { SendAccountingService } from './send-accounting.service';
@@ -13,7 +27,6 @@ import { SendRetryService } from './send-retry.service';
import { SendTimeoutService } from './send-timeout.service'; import { SendTimeoutService } from './send-timeout.service';
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets'; import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
export type SendCompletionCallbacks = Record<string, never>; export type SendCompletionCallbacks = Record<string, never>;
export type SendCompletionFacade = SendCompletionService & SendSubmissionService; export type SendCompletionFacade = SendCompletionService & SendSubmissionService;
@@ -49,10 +62,7 @@ export class SendCompletionService {
return this.gatewayResult.handleSubmitSegmentResult(data); return this.gatewayResult.handleSubmitSegmentResult(data);
} }
async resolveSubmitRecordForGatewaySegmentResult( async resolveSubmitRecordForGatewaySegmentResult(messageRecordId: string, data: GatewaySubmitSegmentResultDto) {
messageRecordId: string,
data: GatewaySubmitSegmentResultDto,
) {
return this.gatewayResult.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data); return this.gatewayResult.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data);
} }
@@ -109,7 +119,13 @@ export class SendCompletionService {
async handleReceipt( async handleReceipt(
data: GatewayReceiptEventDto, 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); return this.receipt.handleReceipt(data, incomingIdentity);
} }
@@ -145,7 +161,13 @@ export class SendCompletionService {
async resolveReceiptMessage( async resolveReceiptMessage(
data: GatewayReceiptEventDto, 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); return this.receipt.resolveReceiptMessage(data, incomingIdentity);
} }
@@ -206,7 +228,13 @@ export class SendCompletionService {
} }
async releaseMessageReservation( 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, remark: string,
) { ) {
return this.accounting.releaseMessageReservation(message, remark); return this.accounting.releaseMessageReservation(message, remark);
@@ -279,6 +307,7 @@ export class SendCompletionService {
tenantId?: string; tenantId?: string;
applicationId?: string; applicationId?: string;
messageRecordId?: string; messageRecordId?: string;
messageId?: string;
matchStatus: string; matchStatus: string;
matchReason: string; matchReason: string;
candidates: UplinkMatchCandidateInput[]; candidates: UplinkMatchCandidateInput[];
@@ -1,4 +1,5 @@
import { completionContext } from './completion-context'; import { completionContext } from './completion-context';
import { resolveUplinkMatch } from './uplink-matching';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common'; import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
@@ -32,7 +33,19 @@ export class SendDownstreamDeliveryService {
) {} ) {}
async handleUplink(data: GatewayUplinkEventDto) { 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) { 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 } }); const existing = await this.prisma.smsUplinkMessage.findUnique({ where: { eventId: data.eventId } });
if (existing) return existing; if (existing) return existing;
} }
@@ -48,7 +61,7 @@ export class SendDownstreamDeliveryService {
applicationId: match.applicationId, applicationId: match.applicationId,
messageRecordId: match.messageRecordId, messageRecordId: match.messageRecordId,
channelId: data.channelId, channelId: data.channelId,
messageId: data.messageId, messageId: match.messageId,
gatewayMessageId: data.gatewayMessageId, gatewayMessageId: data.gatewayMessageId,
sequenceId: data.sequenceId, sequenceId: data.sequenceId,
phoneNumber: data.phoneNumber, phoneNumber: data.phoneNumber,
@@ -78,10 +91,10 @@ export class SendDownstreamDeliveryService {
tenantId: match.tenantId, tenantId: match.tenantId,
applicationId: match.applicationId, applicationId: match.applicationId,
messageRecordId: match.messageRecordId, messageRecordId: match.messageRecordId,
messageId: data.messageId, messageId: match.messageId,
deliveryType: 'uplink', deliveryType: 'uplink',
payload: { payload: {
messageId: data.messageId, messageId: match.messageId,
applicationId: match.applicationId, applicationId: match.applicationId,
phoneNumber: data.phoneNumber, phoneNumber: data.phoneNumber,
destId: data.destId, destId: data.destId,
@@ -95,6 +108,21 @@ export class SendDownstreamDeliveryService {
} }
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) { 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({ const candidate = await this.prisma.smsUplinkMatchCandidate.findFirst({
where: { id: candidateId, uplinkMessageId }, where: { id: candidateId, uplinkMessageId },
include: { include: {
@@ -112,56 +140,55 @@ export class SendDownstreamDeliveryService {
if (candidate.uplinkMessage.matchStatus === 'matched' && candidate.status !== 'claimed') { if (candidate.uplinkMessage.matchStatus === 'matched' && candidate.status !== 'claimed') {
throw new BadRequestException('该上行记录已完成匹配,不能重复认领'); throw new BadRequestException('该上行记录已完成匹配,不能重复认领');
} }
if (candidate.status === 'claimed') return candidate.uplinkMessage;
const claimedAt = new Date(); const claimedAt = new Date();
const messageId = candidate.uplinkMessage.messageId ?? candidate.messageRecord?.messageId ?? null; const messageId = candidate.messageRecord?.messageId ?? null;
const [updatedUplink] = await this.prisma.$transaction([ const updatedUplink = await this.prisma.smsUplinkMessage.update({
this.prisma.smsUplinkMessage.update({ where: { id: uplinkMessageId },
where: { id: uplinkMessageId }, data: {
data: { tenantId: candidate.tenantId,
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, applicationId: candidate.applicationId,
applicationName: candidate.application.name,
messageRecordId: candidate.messageRecordId, messageRecordId: candidate.messageRecordId,
messageId, messageId,
matchStatus: 'matched', matchSource: candidate.matchSource,
matchReason: `人工认领:${candidate.reason ?? 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({ await this.facade.queueAndTryDownstreamDelivery({
tenantId: candidate.tenantId, tenantId: candidate.tenantId,
@@ -278,7 +305,10 @@ export class SendDownstreamDeliveryService {
skipDuplicates: true, skipDuplicates: true,
}); });
const retained = await this.prisma.cmppDownstreamDelivery.findUniqueOrThrow({ where: { dedupeKey } }); 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'); throw new Error('completion_notification_identity_mismatch');
return retained; return retained;
} }
@@ -377,107 +407,12 @@ export class SendDownstreamDeliveryService {
tenantId?: string; tenantId?: string;
applicationId?: string; applicationId?: string;
messageRecordId?: string; messageRecordId?: string;
messageId?: string;
matchStatus: string; matchStatus: string;
matchReason: string; matchReason: string;
candidates: UplinkMatchCandidateInput[]; candidates: UplinkMatchCandidateInput[];
}> { }> {
if (data.messageId) { return resolveUplinkMatch(this.prisma, data, channel);
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: [] };
} }
async recordCmppFailureReceipt( 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}`, notificationContent: `冻结正文${item.id}`,
})); }));
const prisma = { const prisma = {
signatureAnalyticsRun: { findUnique: jest.fn().mockResolvedValue({ state: 'succeeded' }) },
signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue(detections) }, signatureRetirementDetection: { findMany: jest.fn().mockResolvedValue(detections) },
smsApplication: { smsApplication: {
findMany: jest.fn().mockResolvedValue([ findMany: jest.fn().mockResolvedValue([
@@ -60,6 +61,7 @@ describe('daily application messages and date formatting', () => {
['2026-09-01', '2026-08-31'], ['2026-09-01', '2026-08-31'],
])('keeps T-1 semantics for %s without constructing formatters per row', async (date, expected) => { ])('keeps T-1 semantics for %s without constructing formatters per row', async (date, expected) => {
const prisma = { const prisma = {
signatureAnalyticsRun: { findUnique: jest.fn().mockResolvedValue({ state: 'succeeded' }) },
signatureRetirementDetection: { signatureRetirementDetection: {
findMany: jest.fn().mockResolvedValue( findMany: jest.fn().mockResolvedValue(
Array.from({ length: 100 }, (_, i) => ({ 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 { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger'; import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator'; import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.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'; import { SignatureRetirementService } from './signature-retirement.service';
@ApiTags('signature-retirement') @ApiTags('signature-retirement')
@Controller('admin/signature-retirement') @Controller('admin/signature-retirement')
export class SignatureRetirementController { export class SignatureRetirementController {
constructor(private readonly service: SignatureRetirementService) {} constructor(
private readonly service: SignatureRetirementService,
private readonly prisma: PrismaService,
) {}
@Get('configuration') @Get('configuration')
getConfiguration() { getConfiguration() {
@@ -45,7 +57,17 @@ export class SignatureRetirementController {
@Query('page') page?: string, @Query('page') page?: string,
@Query('pageSize') pageSize?: 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); return this.service.listMessages(query);
} }
@@ -66,7 +88,11 @@ export class SignatureRetirementController {
@Post('messages/:id/suppress') @Post('messages/:id/suppress')
@RequireRecentAuthentication() @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); return this.service.suppressMessage(id, body, operatorId);
} }
@@ -77,10 +103,35 @@ export class SignatureRetirementController {
@Post('suppressions/:id/cancel') @Post('suppressions/:id/cancel')
@RequireRecentAuthentication() @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); 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') @Get('heatmap')
heatmap(@Query('date') date?: string) { heatmap(@Query('date') date?: string) {
return this.service.heatmap(date); return this.service.heatmap(date);
@@ -93,8 +144,12 @@ export class SignatureRetirementController {
@Query('page') page?: string, @Query('page') page?: string,
@Query('pageSize') pageSize?: 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); return this.service.unreportedSignatures(query);
} }
} }
@@ -1,57 +1,6 @@
import { millisecondsUntilShanghaiHour, SignatureRetirementService } from './signature-retirement.service'; import { millisecondsUntilShanghaiHour, SignatureRetirementService } from './signature-retirement.service';
describe('SignatureRetirementService dimensions', () => { 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', () => { 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-09T19:00:00.000Z'), 4)).toBe(60 * 60_000);
expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T21:00:00.000Z'), 4)).toBe(23 * 60 * 60_000); expect(millisecondsUntilShanghaiHour(new Date('2026-08-09T21:00:00.000Z'), 4)).toBe(23 * 60 * 60_000);
@@ -69,6 +18,7 @@ describe('SignatureRetirementService dimensions', () => {
notificationContent: '冻结后的预警正文', notificationContent: '冻结后的预警正文',
}; };
const prisma = { const prisma = {
signatureAnalyticsRun: { findUnique: jest.fn().mockResolvedValue({ state: 'succeeded' }) },
signatureRetirementDetection: { signatureRetirementDetection: {
findMany: jest.fn().mockResolvedValueOnce([detection]).mockResolvedValueOnce([detection]), 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' })); expect(result.items[0]).toEqual(expect.objectContaining({ activityDate: '2026-08-09' }));
}); });
it('persists daily observing snapshots without opening alert cycles', async () => { it('waits for a complete daily report before retirement detection', async () => {
const prisma = { const prisma = { signatureAnalyticsDay: { findUnique: jest.fn().mockResolvedValue(null) } };
signatureRetirementSuppression: { const date = new Intl.DateTimeFormat('en-CA', {
updateMany: jest.fn().mockResolvedValue({ count: 0 }), timeZone: 'Asia/Shanghai',
findUnique: jest.fn().mockResolvedValue(null), year: 'numeric',
}, month: '2-digit',
signatureRetirementRule: { day: '2-digit',
findMany: jest.fn().mockResolvedValue([rule('enterprise_global', ''), rule('channel_global', '')]), }).format(new Date());
}, await expect(new SignatureRetirementService(prisma as never).runDetection(date)).rejects.toThrow(
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('maps the real unreported-signature aggregation to an independent page', async () => { 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); const unreportedService = new SignatureRetirementService(prisma as never);
await expect( 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({ ).resolves.toEqual({
date: '2026-08-10', date: '2026-08-10',
items: [ 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 { import {
BadRequestException, BadRequestException,
Injectable, Injectable,
@@ -37,28 +40,6 @@ const shanghaiHourFormatter = new Intl.DateTimeFormat('en-GB', {
const DAY_MS = 86_400_000; const DAY_MS = 86_400_000;
const CARRIERS = ['mobile', 'unicom', 'telecom'] as const; const CARRIERS = ['mobile', 'unicom', 'telecom'] as const;
const DEFAULT_DELIVERY_INTERVAL_MS = 60_000; 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() @Injectable()
export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy { export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy {
@@ -66,6 +47,8 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
private detectionTimer?: ReturnType<typeof setTimeout>; private detectionTimer?: ReturnType<typeof setTimeout>;
private notificationTimer?: ReturnType<typeof setTimeout>; private notificationTimer?: ReturnType<typeof setTimeout>;
private deliveryTimer?: ReturnType<typeof setInterval>; private deliveryTimer?: ReturnType<typeof setInterval>;
private compensationRunning = false;
private publishedDate?: string;
private startupTimer?: ReturnType<typeof setTimeout>; private startupTimer?: ReturnType<typeof setTimeout>;
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -78,7 +61,7 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
this.scheduleDetection(); this.scheduleDetection();
this.scheduleNotification(); this.scheduleNotification();
this.deliveryTimer = setInterval( this.deliveryTimer = setInterval(
() => void this.deliverPendingWebhooks(), () => void this.runStartupCompensation(),
positiveIntegerEnv('SIGNATURE_RETIREMENT_WEBHOOK_INTERVAL_MS', DEFAULT_DELIVERY_INTERVAL_MS), positiveIntegerEnv('SIGNATURE_RETIREMENT_WEBHOOK_INTERVAL_MS', DEFAULT_DELIVERY_INTERVAL_MS),
); );
this.deliveryTimer.unref?.(); this.deliveryTimer.unref?.();
@@ -540,6 +523,30 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
} }
async unreportedSignatures(query: UnreportedSignatureQuery) { 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 date = assertDateKey(query.date || shanghaiDateKey());
const page = positiveInteger(query.page, 1); const page = positiveInteger(query.page, 1);
const pageSize = Math.min(100, positiveInteger(query.pageSize, 25)); const pageSize = Math.min(100, positiveInteger(query.pageSize, 25));
@@ -630,68 +637,16 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
} }
async runDetection(date?: string) { async runDetection(date?: string) {
const detectionKey = assertDateKey(date || shanghaiDateKey()); return detectRetirement(this.prisma, analyticsDate(date));
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 };
} }
async publishNotifications(date?: string) { 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({ const detections = await this.prisma.signatureRetirementDetection.findMany({
where: { where: {
detectionDate: databaseDate(notificationKey), detectionDate: databaseDate(notificationKey),
@@ -749,10 +704,13 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
} }
} }
await this.enqueueWebhookSummaries(notificationKey); await this.enqueueWebhookSummaries(notificationKey);
this.publishedDate = notificationKey;
return { notificationDate: notificationKey, created }; return { notificationDate: notificationKey, created };
} }
private async runStartupCompensation() { private async runStartupCompensation() {
if (this.compensationRunning) return;
this.compensationRunning = true;
const now = new Date(); const now = new Date();
const hour = shanghaiHour(now); const hour = shanghaiHour(now);
try { try {
@@ -765,6 +723,8 @@ export class SignatureRetirementService implements OnModuleInit, OnModuleDestroy
this.logger.error( this.logger.error(
`Signature retirement startup compensation failed: ${error instanceof Error ? error.message : String(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?.(); 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) { private async enqueueWebhookSummaries(dateKey: string) {
const detectionDate = databaseDate(dateKey); const detectionDate = databaseDate(dateKey);
const [webhooks, messages] = await Promise.all([ 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()) { function shanghaiDateKey(date = new Date()) {
return shanghaiDayFormatter.format(date); return shanghaiDayFormatter.format(date);
} }
@@ -2339,3 +2339,18 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
5. 监控刷新失败显示真实上一快照和过期提示,超时/切换请求取消,恢复后刷新;不同查询范围隔离。 5. 监控刷新失败显示真实上一快照和过期提示,超时/切换请求取消,恢复后刷新;不同查询范围隔离。
授权交付:代码、定向/全量测试、精确提交、推送、测试环境标准发布及隔离模拟短信验收;不操作预生产,不向真实运营商发送。 授权交付:代码、定向/全量测试、精确提交、推送、测试环境标准发布及隔离模拟短信验收;不操作预生产,不向真实运营商发送。
## 2026-09-17 签名质量独立查询与日报优化(本地实现,待发布)
本节对应用户本轮方案要求,权威设计见[签名退网检测与四页查询优化方案](signature-quality-optimization-plan-20260917.md),本地实施状态见下方补充,线上尚未发布。
1. 四TAB的日期、筛选、分页、请求、错误及结果独立;企业/通道活跃度改为带dimensionType的独立服务端查询与分页,不再各自获取两类全量数据。保留默认25条和10/25/50/100档;同TAB顶部和卡片查询应用同一组草稿条件。
2. T为服务器北京时间今天,实际统计日为d:d=T查询时实时查业务库;T-1~T-3每天凌晨同时刷新、查询只读日报;T-4及以前只读冻结日报,普通查询、调度和重启不得重算。缺报表明确显示未生成,不回退明细或伪装零。
3. 用户确认热力图保持所选日D之前30天,即D-1~D-30,不包含当天列;每格按实际日期相对真实T判断数据来源,不按页面D重新开放历史刷新。
4. 日报历史名称、归属和报备适用性快照保留;冻结后迟到回执、补登记和状态变化不覆盖旧报表。预警判断/通知与可刷新的活动日报分开,刷新不得重发历史通知。
5. 退网检测采用批量聚合、提前跳过已完成、任务原子认领与故障接续,候选索引由真实执行计划决定。保留业务短信/提交尝试/计费片数区别、长短信完整分段判定和跨通道/跨日窗口去重;现有最长365天规则不缩短。
6. 原方案阶段为只读;本轮本地实现与隔离验收见下方补充,线上历史补建、推送、两环境部署与真实发送均未执行。本节不改变其他财务/质量报表T-4~T-1刷新规则。
### 2026-09-17 本轮本地实现
签名质量日报按专项优化方案实现:T实时、T-1~T-3每日刷新、T-4冻结;热力图D-1~D-30且企业/通道请求独立,失败保留本TAB上次真实数据并标明日期。退网窗口批量去重、规则快照/租约/fence/失败恢复与日报解耦。上行按应用唯一性而非短信条数归属,同通道接收前accepted作为证据;入库、候选、认领、通知意图保证事务一致。历史待认领不自动处理。本轮仅本地修改和提交,线上发布/补建及实际短信/通知验收另按授权执行。
@@ -0,0 +1,191 @@
# 签名退网检测与签名质量四页查询优化方案
维护日期:2026-09-17。状态:**本地实现及隔离验收完成,待发布**。用户于本轮授权修改并本地提交;没有授权本轮推送、部署、历史上行认领或真实短信/通知发送。第1节保留实施前取证;本轮实现与验收以第8节为准。
本文是本次三项需求的统一实施设计,补充[签名清退设计](signature-retirement-alert-design.md)。实施后替代该设计第6节、第7节第15步中“热力图直接读检测快照、按当前报备维度展示、前端筛选分页”的实现方式,以及[运营修复设计](operations-fixes-20260908.md)第4项中的客户端热力图分页;保留四TAB独立状态、默认25条、10/25/50/100档、D-1至D-30列序和原业务统计单位。现阶段旧代码仍在运行,不能把本方案描述为已上线功能。
本方案仅针对“签名质量检测”四TAB与签名退网检测,不改变“报表对账/发送质量报表”的既有T-4~T-1刷新任务,不改变短信门禁、补发、计费、余额或报备状态。
## 1. 核查结论与证据
### 1.1 基线
- 本地main与实际远端main均为`4eb7b16d122da14f921093716d4ca1ed390d9e4c`,暂存区空;已有版本、metrics、发布工具、部署脚本及文档草稿保留。
- 2026-09-17 09:37北京时间只读核验预生产运行`010ba3216889032a6160cdb14d8536b616ae7102`。上述两个提交间本方案涉及的页面、质量查询和退网检测源码没有差异。
- 证据:`src/apps/admin/AdminAnalyticsPage.tsx``src/api/admin/signature-retirement.api.ts``api/src/operations/queries/quality.queries.ts``api/src/signature-retirement/signature-retirement.service.ts`、对应controller及`api/prisma/schema.prisma`
- 本机原始取证在忽略目录`.local-data/cpu-20260917/``prom.json``detail.jsonl``design-audit.json`及只读脚本。线上DB使用只读事务与8秒语句超时,未启动应用调度器。此次未执行登录后的四TAB真实浏览器/HTTP复测;以下交互结论来自当前源码,不冒称已复现浏览器串数据。
### 1.2 凌晨CPU与检测成本
今天检测日2026-09-17的结果从04:00:01.620写到04:04:07.792,共5,831条(企业1,431、通道4,400)。前一天4,280条。Prometheus五分钟CPU均值峰值58.79%04:06:45为32.81%04:06最近一分钟忙碌率约2.54%,趋势包含此前负载。日报刷新日志完成于00:33:09。
同窗口`SmsMessageRecord``SmsSubmitRecord`顺序扫描行速率峰值分别约362万、536万行/秒;这是数据库重复访问行的计数,不是短信量或TPS。IO等待较低。检测时段、真实快照与数据库扫描高度吻合,是主要负载线索;缺少历史业务进程CPU与SQL耗时采样,不能量化各进程占比或断言唯一原因。
现有`runDetection`逐个维度调用`activityCounts`:至少一次单日查询,完成观察期后再查一次规则窗口;每次关联消息、提交、分段/回执。已有检测是否存在的判断在这些计算之后的`persistDetection`中,重启补偿仍可能先重复计算再退出;周期、抑制、检测记录还存在逐行读写。现有唯一键保证部分去重,不等于任务已具备全程原子认领和完整事务恢复。
### 1.3 四TAB是否关联
| TAB | 当前请求与数据来源 | 当前关联/独立性 | 与目标的差距 |
|---|---|---|---|
| 签名通道发送质量 | `GET /admin/operations/signature-quality`;所有日期实时聚合消息、提交、分段与回执 | 独立日期、关键字、分页、请求序号;详情使用本TAB结果 | 历史也查明细,无近三天报表/冻结分支 |
| 企业签名活跃度 | `GET /admin/signature-retirement/heatmap?date=D` | 独立组件状态,但接口返回企业和通道全部数据,再在前端选企业、过滤、排序分页 | 使用次日检测快照而非可刷新的活动日报;行集合依赖当前通过任务 |
| 通道签名活跃度 | 同上 | 独立发起请求,但再次拉取相同两类数据,再在前端选通道 | 同上,整月全量传输和处理重复 |
| 未报备签名 | `GET /admin/signature-retirement/unreported-signatures`;所有日期查消息正文及当前有效签名库 | 独立日期、关键字、分页与结果 | 历史实时重算;补登记/删除签名会改变旧日期结果 |
结论:没有发现四TAB共用筛选状态或一个TAB直接改写另一个结果的实现;存在两个活跃度TAB共用全量接口与数据源,且活跃度展示依赖退网检测任务。底层业务事实本来有关联,不能为“页面独立”复制出四套互相矛盾的短信事实;应解除请求、状态、失败和生成触发之间的耦合。
另需同步修复的本TAB日期歧义:未报备TAB编辑顶部日期后,卡片内“查询”仍按`appliedDate`请求,须先点顶部“查询统计”才能应用新日期。这不是跨TAB串数据,但会造成查询条件似乎未生效。
### 1.4 三档取数规则当前未实现
质量与未报备页面全部日期实时查库;活跃度使用`SignatureRetirementDetection`快照,`persistDetection`已存在即返回,不会连续刷新最近三天。历史展示又拼接当前签名名称和当前报备维度,因此既不是完整的实时统计,也不是稳定冻结日报。
已有`DailyQualityReport`服务另一个报表模块,按计费单位统计,维度缺少本页完整的运营商、通道、引流拆分、去重业务数与到达耗时分子分母,不能直接拿来替代本页。既有财务/质量报表T-4~T-1策略也不自动等同本次要求。
## 2. 已确认业务规则
### 2.1 日期定义和数据来源
T始终指服务器按`Asia/Shanghai`确定的真实今天;D指页面所选日期;d指实际统计自然日,均不依赖浏览器所在时区。用户已确认:**每天凌晨同时刷新T-1、T-2、T-3;热力图保留所选日之前30天。**
| 实际统计日d | 查询行为 | 后台生成/更新 |
|---|---|---|
| d=T | 本次查询访问真实业务库,在一致性读视图中聚合 | 不将昨日缓存冒充当天数据,不写退网检测或通知 |
| T-3≤d≤T-1 | 只读已完整发布的日报 | 每天凌晨刷新最近三个完整自然日,吸收迟到回执 |
| d≤T-4 | 只读冻结日报 | 普通调度、页面查询、进程重启均不得自动重算或覆盖 |
例:今天9月17日,9月17日实时,9月14~16日报表可刷新,9月13日及以前冻结。9月18日可刷新范围成为9月15~17日;9月14日报表保留9月17日最后成功发布的版本,不在成为T-4后再额外重算。刷新判断相对真实今天,不能相对用户选择的历史日期重新开放冻结区。
热力图选D=9月17日仍展示9月16日至8月18日(D-1至D-30),没有当天列;每一列按该格子的d判断可刷新/冻结。单日质量与未报备TAB选今天时采用实时路径。禁止选择未来D来变相让热力图出现今天列。
冻结意味着“固定统计截面”,不意味着所有短信此时都已有最终回执。冻结后迟到回执继续正常更新短信业务事实,不改已冻结报表,不把未知自动改成失败;报表与当前详单存在截面差异时明确显示截止时间。跨日补发也不得使已冻结日的历史报表被普通任务改写。
### 2.2 独立查询的边界
- 四TAB各有日期草稿/已应用日期、过滤草稿/已应用过滤、排序、分页、pageSize、请求取消/序号、loading/error和结果,任何查询只更新本TAB。
- 首次打开TAB只请求该TAB;切回保留自己的条件及已取得结果,用户查询/刷新只重查当前TAB。隐藏TAB不自动重查,不用另一TAB成功响应填补失败。
- 企业、通道活跃度请求明确携带不同`dimensionType`,服务端只处理所选维度,按过滤后的完整30日合计排序再分页,只返回当页维度及其30格。
- 日期与搜索“查询”统一应用本TAB当前全部草稿条件并回到第一页;翻页只使用已应用条件。特别修正未报备卡片查询沿用旧日期的问题。
- 底层只读聚合器、数据库连接池和已发布日报可以复用;页面查询不触发其他TAB请求、不触发报表重建、不运行退网检测、不创建预警。
### 2.3 指标和历史维度
- 质量列表/运营商概览以业务短信为单位;通道矩阵以真实发送尝试为单位;保留现有不同成功率分母并在契约逐字段登记,不把计费片数换成业务条数。
- 业务统计按`SmsMessageRecord.queuedAt`归日;通道尝试及活跃度按现有`COALESCE(submittedAt,createdAt)`归日。跨日补发可能使列表与矩阵日期归属不同,应分别生成,不能先用当天业务列表裁掉当天实际提交、但原消息属于其他日期的事实。
- 企业活跃度在统计范围内按messageRecordId去重,通道按messageRecordId+channelId去重;不直接累加通道值得到企业值。规则窗口跨日也须重新去重,不能把每日distinct数简单相加。热力图“30日合计”保持每日展示值之和并标注为日活跃量合计,区别于预警窗口去重人数/条数。
- 成功必须按发送尝试完整分段事实判断,缺片/缺回执仍为未知;长短信不能因已到的几个分段都成功而提前成功。旧记录无分段事实时才按既有明确回执兼容;不把上游接受当送达。
- 平均到达时长持久化成功耗时总和与样本数,展示时相除;不能平均各组平均值。列表、抽屉、运营商概览及引流三类拆分使用相同generation版本。
- 日报保存当次统计维度名称、企业应用归属、运营商、报备适用状态/通过时间与统计规则版本。历史筛选使用报表快照字段,不能inner join当前通过任务导致历史行消失。活动日报逐日记录适用性;尚未通过、不适用、已通过且零发送、未生成必须区分。
- 未报备仍指规范正文签名在该企业应用有效签名库中不存在,且原消息无signatureId,不等于缺少通道报备。实时用查询时签名库;近三日用本轮生成时签名库;冻结日保留最后发布判定及名称。此后补登记不得追溯抹除冻结历史。
## 3. 性能优化方案
### 3.1 优先消除重复计算
1. 将逐个签名×通道×运营商反复扫描,改为按统计日有界批次聚合;先限定日期与候选消息/提交,再对这些submitId批量归集分段/回执,避免每个维度重新读取相同事实。
2. 一次读取参与检测的规则、通过任务、既有检测键与抑制摘要。已经完成的检测批次直接退出;部分完成只恢复缺失维度,避免昂贵计算后才查存在性。
3. 单日活动日报与质量日报复用经核验的尝试分类逻辑,按各自归日维度生成结果。退网“是否活跃”的窗口只需要accepted去重,不为判断阈值重复关联所有回执;送达率由日报单独计算。
4. 预警窗口最长支持现有365天,按实际生效规则的最大窗口限制扫描,批量JOIN规则维度后按message/channel去重。企业跨通道、跨日不能直接SUM日报;在测试库比较批量COUNT DISTINCT与适量窗口分组方案后选定。禁止以改成30天窗口偷换规则。超大窗口必要时分阶段物化紧凑去重键,但须先盘点容量,不能本轮默认复制全量短信正文或全部历史明细。
5. 批次发布后退网任务引用已完成单日活动版本,并独立保存当时窗口判断及规则版本。刷新日报不更新既有检测决策、不推进旧周期、不重新生成通知。
### 3.2 索引和SQL计划
预生产真实索引已有message.queuedAt、submit.messageRecordId,但未见消息的signatureId+carrier组合索引,也未见submit的`COALESCE(submittedAt,createdAt)`表达式时间索引。不能仅因没有索引就断言某一SQL必然全表扫描。
候选包括消息(signatureId,carrier,id)、提交(COALESCE(submittedAt,createdAt),messageRecordId)、按实际计划需要的通道+有效提交时间,以及日报日期/维度/排序组合。保留表达式原语义,不能直接用createdAt替代submittedAt。候选须通过有代表性数据的`EXPLAIN (ANALYZE, BUFFERS)`验证选择性、loops、临时文件和写入成本后决定,不盲目全部加索引;本轮未执行重查询或建索引。
大表索引上线采用平台支持的在线建索引流程,明确非事务DDL、失败无效索引检查及重试;具体迁移方式在实施时结合Prisma发布门禁验证,禁止把不允许在事务中的DDL塞进普通事务而绕过错误。不全局扩大work_mem,不默认增加SQL/worker并发。
### 3.3 页面成本
- 活跃度改服务端过滤、排序、分页,只传单类维度的当页30格;四TAB仍各自独立,不将两个活跃度结果捆绑返回。
- 查询快照元数据和维度使用Map/数据库JOIN,不逐条Array.find造成大集合反复查找;保留已有模块级日期格式化器。
- 首屏与列表查询仅取所需字段;若详情改独立请求,必须携带列表generationId以保证详情一致,不能点击详情又扫原始历史短信。
- 当天实时查询采用有界日期、分页和超时;失败保留当前TAB最后真实结果并标注其日期/时间,不能把旧值作为新查询成功。跨午夜请求由服务端固定T并回传,前端不自行混合两个自然日结果。
## 4. 日报生成、冻结和恢复
建议签名日报任务北京时间03:00启动,依次处理T-3、T-2、T-1,与已存在财务日报错峰;04:00退网检测、08:00通知时点保留。固定时间是拟实施配置,尚未上线;也必须检查与其他后台任务是否重叠。
生成任务具备耐久状态`pending/running/succeeded/retry_wait/failed`、租约、递增fence和有限重试;按scope+businessDate唯一认领,旧worker过期后不能发布。每个日期在一致性数据库快照下生成候选版本,完成四类校验后短事务切换publishedGenerationId。部分失败保留完整旧版;其他日期可继续,但整轮不得虚报全成功。发布必须再次验证fence、日期是否已冻结和候选水位。
页面只读published版本;生成中有旧版则显示旧版及刷新中/上次成功时间,没有旧版显示“报表尚未生成”,失败显示失败,不能返回空数组冒充零数据或回退扫描明细。合法零数据日期也必须生成完成清单与0行标记。
日界线进入T-4后自动禁止覆盖;跨午夜仍在运行的旧任务在发布校验时被拒绝。若T-3最后刷新失败,冻结上一成功版本并显示未达到预期截止时间;若从未成功则保留缺口,不伪造冻结完整。错过整个三日窗口的缺口只能进入显式历史补建流程,不能借“启动补偿”无限回算。
04:00所需日报未完成时,检测等待该日完成且告警;08:00只发送已完整完成检测的结果,不把半批维度当完整预警。后续恢复沿用当日补偿与幂等,不补发旧日期通知。周期变更、检测行、通知意图在维度事务中一致提交;全批完成标记必须在全部维度校验后置位。通知仍按日期+企业应用聚合及现有抑制规则,重试不能重复创建消息/Webhook。
日报刷新三天不等于重新做三天的预警判断。`SignatureRetirementDetection`保留历史判断审计,页面活动日报是另一投影,两者截止时间可能不同,页面标明统计口径而不是互相覆盖。
## 5. 拟新增模型与接口
以下名称仅为设计,不表示Prisma已有模型:
| 模型 | 主键/唯一维度与作用 |
|---|---|
| SignatureAnalyticsDay | businessDate唯一;publishedGenerationId、state、generatedAt、sourceAsOf、frozenAt、schemaVersion、coverage/provenance、rowCounts/checksum;成功零行也记录 |
| SignatureAnalyticsRun | scope+businessDate+generationId唯一;owner、leaseUntil、fence、attempt、checkpoint、startedAt/finishedAt、error摘要;每scope/date最多一个有效运行者 |
| SignatureQualityDaily | generationId+metricKind+tenant/application/signature+carrier/channel/drainage规范化维度键唯一;区分business与attempt,计数、耗时分子分母、名称快照 |
| SignatureActivityDaily | generationId+dimensionType+tenant/application/signature/channelKey/carrier唯一;单日提交、accepted业务数、送达数、适用性及报备快照 |
| UnreportedSignatureDaily | generationId+tenantId+applicationId+规范正文签名键唯一;业务数、生成时判定及名称快照 |
空channel/application使用无碰撞规范键,不能依赖可空字段唯一约束防重复。行均有真实businessDate和generationId外键;普通查询仅取发布版本。预警任务可复用耐久Run机制但scope独立,不与日报共用完成状态。旧版本保留期限和空间预算须在实施/发布阶段核验,不自动清理现有业务记录。
API建议:
- 质量与未报备保留现有GET路径和date/keyword/page/pageSize,增加`dataSource=live|report``reportState=ready|refreshing|failed|missing``mutable/frozen`、businessDate、serverBusinessDate、generatedAt/sourceAsOf、generationId和schemaVersion。数据源由服务器决定,客户端不能要求重算冻结日。
- 热力图提供独立版本路径`GET /admin/signature-retirement/activity`,必填dimensionType=enterprise|channeldate为D;企业/应用/签名/通道过滤分别传入,pageSize只接受10/25/50/100。返回单类当页维度、total和D-1至D-30;逐日期带版本/完整性元数据。保留旧heatmap GET只作兼容,最新页面不再调用;记录旧调用后再决定移除,不让旧前端接收截断结果冒充完整数据。
- 活跃度筛选、30日排序、总数与当页结果在同一个一致性读事务获取;先在过滤全集排序再LIMIT。缺失日期不可当成0参与已完整合计,须展示“部分日期缺失”。冻日前名称和维度只来自报表。
- 日期严格验证真实日历、拒绝未来日;页码为正整数、pageSize有上限,排序白名单。请求取消和旧响应隔离按TAB独立实现。
- 沿用运营端鉴权及数据可见范围,在数据库查询前约束tenant/application;不新增匿名查询/生成接口,不以报表缓存绕过权限。真实HTTP401/403、越权筛选和缓存键隔离须专项验收。
## 6. 历史切换、发布和恢复
1. 在真实数据规模的隔离测试库建立旧口径对照,包括长短信、补发、跨日和未报备;冻结字段契约,修正差异而非迎合旧错误。
2. 增量新增模型/索引与后台生成器,旧接口继续运行;候选生成器先影子计算,不产生通知,完成准确性与负载对照后切换。
3. 盘点可支持历史日期的源数据覆盖、报备轨迹、消息/回执保留和磁盘空间。一次性按明确日期清单补建历史日报,先满足热力图至少30日与用户常用查询区间;预警规则窗口另覆盖实际最大365日需求。没有可信历史事实就标记缺口,不把当前报备状态当作过去状态。
4. 旧检测快照可作为活动量迁移证据,但不能冒充补齐迟到回执后的完整日报。用原始事实重建的历史报表须记录backfilledAt/sourceAsOf/provenance,不能伪造为当年T+3冻结结果。完整性不足的日期不宣称已完成,历史可查询范围明确展示。
5. 迁移期不复制历史周期、不重发历史消息。生成和读取按schemaVersion切换;切换后禁用旧逐维度检测调度入口,防止双跑。先测试环境验收,再另按授权发布预生产。
6. 应用回退保留新增表和已发布冻结版本;异常优先暂停新任务、保留只读已发布日报。旧应用会恢复历史实时查询行为,不能称之为满足新冻结规则的等价回退,须在恢复说明中明确影响。不自动恢复数据库或删除候选/旧报表。
## 7. 验收、观察指标与实施顺序
详见[功能用例](system-functional-test-cases.md)中`TC-SQA-20260917-0116`,本地执行覆盖及环境未执行项见第8节。
- 准确性:真实PostgreSQL逐字段对账;短/长短信、缺片、重复乱序、失败补发、跨通道/跨日去重、未知和迟到回执;旧历史无分段兼容;成功率分母及耗时加权正确。
- 冻结:T、T-1、T-3、T-4、跨午夜/跨月/闰日;刷新原子性、版本一致、补登记不改变冻结未报备、名称/通过状态变更不让历史行消失;失败与零数据可区分。
- 独立性:四TAB首次/切换/查询/分页/详情;一个TAB失败、慢响应或修改日期不影响另一个;企业请求不含通道维度;真实HTTP与三尺寸浏览器核验,不仅用mock证明功能。
- 可靠性:双worker抢占、租约超时接管、旧fence发布被拒、批次部分失败、任务重启、错过冻结窗口、08:00依赖未完成、重复通知去重。报表刷新不写短信/余额/报备/通知状态。
- 性能记录:整机及PG/API进程CPU、数据库扫描行/块、SQL耗时/p95、任务耗时、锁等待/临时文件、响应字节、API内存、短信队列延迟;分别标注冷/热缓存和相同业务规模,不把一次结果当容量承诺。
- 拟验收目标:相同数据规模,退网原始明细扫描行数下降至少80%,任务耗时下降至少50%;单类热力图每页最多100×30个格,响应规模不随未选维度总数增长。此为测试目标而非已实测收益;不能单凭CPU目标判定成功。若准确性/业务队列恶化立即停止候选压测,不在预生产重新跑整套重任务验证猜测。
实施分三批:①独立接口/页面查询及精确统计契约;②日报三日刷新/冻结/历史补建与版本化读取;③退网批量聚合/原子任务/依赖编排及性能对照。批次①不宣称已完成报表化,③完成前不宣称CPU问题已解决。
这是前端、查询后端、数据库模型和后台调度的跨模块改造,主要成本在历史回填、口径对账和故障恢复验收,不能按简单索引补丁处理。实施需API/前端定向与全量回归、类型/构建/质量门禁、真实PG/接口/浏览器;若不修改Gateway无需冒充重做Gateway发布。真实短信、外部通知及压测只在后续明确授权的环境和范围执行。
## 8. 2026-09-17 本地实现与验收
### 8.1 实现及设计落地
- `api/src/signature-analytics/`提供北京时间日期校验、日报聚合、版本读取、后台调度和独立退网批次。03:00后依次刷新T-3/T-2/T-160秒补偿检查;04:00检测与08:00通知保留。日报失败不降级实时查询,未生成与合法零结果分开;热力图继续D-1~D-30。
- 新增六表:Day发布清单、Generation不可变版本、Run耐久任务、QualityDaily、ActivityDaily、UnreportedDaily。日报行和发布指针以generationId+businessDate复合外键保证日期/版本一致。质量嵌套矩阵和概览保存在同一JSON快照,检索字段独立列化;引流分组保存耗时总和/有效样本数并加权合并,不平均组均值。
- 与原建议“候选后短事务切指针”相比,本期采用**每个自然日一个RepeatableRead事务**,聚合、分批250行写入、发布与fence验证一起提交。理由是保证同日各口径取自同一源快照,避免未持久化源水位的断点续算混入不同截面。单SQL90秒、事务120秒、租约300秒;失败重算该日,成功日期不重算,最多5次指数退避。事务上限短于租约,不需要保持长事务跨租约续期。超过此规模应先扩展分阶段水位设计,不能直接无限增大超时。
- 退网窗口只扫描accepted提交,批量按实际规则窗口(最长365天)去重,单日送达数复用活动日报。规则与所引用日报版本在首次认领后持久化为checkpoint,重试不改变;检测、周期及冻结通知正文同事务。完成检查前移;日报刷新不修改已有检测/消息。缺昨日当日刷新版本时等待,不消耗检测重试次数。通知沿用原耐久消息/Webhook去重,同进程完成后不每分钟重扫全部消息;重启可补偿,禁止自动补发历史检测。
- 两个活动TAB改用`/admin/signature-retirement/activity`,明确dimensionType;服务器过滤、排序、分页和完整性读取处于一致性事务。每页只返回所选维度,最多100×30格;空的越界页仍返回正确总数。旧heatmap接口保留兼容旧页面,当前页面不调用。四TAB查询/取消/错误/日期/筛选各自独立,未报备卡片查询应用新日期。
- 普通历史读取只访问日报快照,无当前报备/名称JOIN。报备轨迹可还原时取历史状态与通过时间,旧数据仅有明确approvedAt时保留兼容证据;缺完整历史轨迹不宣称重建了过去所有状态。无报备资格的格子N/A,缺日报的格子显示缺口。旧记录完全无分段审计时保留明确回执兼容;存在分段时按预期总片数判断,缺片不提前成功。
- 迁移`20260917030000_signature_analytics`新增表;`20260917031000_signature_submit_effective_at`单独以非事务`CREATE INDEX CONCURRENTLY`建立有效提交时间表达式索引,避免将所有历史源扫描混进三日任务。108项完整迁移已在独立PostgreSQL库执行。线上失败须检查`pg_index.indisvalid`及迁移状态,按发布恢复流程处理;不自动DROP/resolve失败迁移,不绕过门禁。
### 8.2 历史补建与发布边界
离线入口`tools/testing/backfill-signature-analytics.mjs`使用已构建API,要求环境变量DATABASE_URL和匹配目标主机的`--host`,逐个明确`--dates=YYYY-MM-DD,...`;默认只检查,明确`--execute=yes`才生成。禁止未来/当天、隐式范围和覆盖已有发布版本。最多366个显式日期,逐日顺序生成;不初始化应用调度、不运行检测/通知,结果标记`backfill-current-source`与实际sourceAsOf,页面标明“事后补建”。本轮只在隔离库验证,线上补建未执行。
发布时需先核对源数据覆盖、实际报备历史与存储空间,生成影子日报并对账,至少为常用D准备此前30日,再切换页面;缺失日期保持可见缺口。新版本/旧版本表行均保留,不自动清理。回退应用保留新增表与冻结版本,但旧应用将恢复旧历史查询行为,不是业务等价回退。本轮没有推送或部署,也没有声称线上CPU已下降。
### 8.3 已执行证据及限度
- 真实PostgreSQL16隔离集群127.0.0.1:16435,空库完整迁移;`verify-signature-analytics.mjs`覆盖日报三日、冻结、跨日去重、2/3/4片、缺片、旧无分段回执、耗时加权、超出页码、重复认领、旧fence拒绝、失败回滚/重试及规则快照。验收数据只在本机专用`cmpp_qa_signature_*`库,脚本拒绝其他主机/库名。
- `verify-uplink-matching.mjs`真实事务验证重复事件、应用归属、供应商MO ID保留、并发人工认领和通知存储故障回滚。故障注入仅模拟存储错误;实际数据/事务/通知意图均为PostgreSQL,未调用外部Gateway发送。
- 真实完整Nest应用、PostgreSQL和隔离Redis,生产构建页面在1600×1000、1366×768、390×844打开/切TAB/查询/刷新,页面异常0;HTTP正常查询200、匿名401、非法维度/分页400。浏览器认证会话仅内存传递,不保存密码、令牌或storageState。Redis本机为5.0.14.1,有BullMQ建议6.2警告;本轮未将其视为生产队列能力验收。
- 前端全量35文件/163项、API全量78套/842项;类型检查、构建、代码结构、格式、ESLint、CSS治理、样式和Bundle门禁记录在testing-progress。Gateway代码未改,不重复冒称实发/协议验收。
- `benchmark-signature-analytics.mjs`对照4eb7b16原活动聚合:30000条消息、600维度,本地热缓存旧逐维度10343.912ms,新批量624.940ms,减少93.96%accepted计数逐维度相等。EXPLAIN原单维度实际源表访问30113行,按600维度估算约1806.78万;新批量60039行。旧总行数是样本外推,不是全批逐SQL实测;均为热缓存,未产生临时磁盘块。这里只证明活动聚合环节,整个日报+365日检测批次的CPU、p95、内存、线上队列影响、真实高密度回执规模仍须部署前/后专项验收,不能宣称全部性能目标已达标。
- 本机原始日志、截图和故障证据在忽略目录`.local-data/signature-optimization-20260917/`。初次迁移目录未生成SQL、测试通道缺carriers、断言漏计样本、临时账号缺email等失败已修正并保留原日志,不用最后成功覆盖失败历史。
@@ -193,3 +193,11 @@
- 第4至10步可回滚到能够读取运营商集合和历史任务的兼容版本;出现双运营商通道后不得回滚到只识别旧单值的版本。 - 第4至10步可回滚到能够读取运营商集合和历史任务的兼容版本;出现双运营商通道后不得回滚到只识别旧单值的版本。
- 第11步严格门禁启用后,如出现真实发送资格异常,优先切回兼容双读版本,不回滚或覆盖已经产生的新业务数据。 - 第11步严格门禁启用后,如出现真实发送资格异常,优先切回兼容双读版本,不回滚或覆盖已经产生的新业务数据。
- 任一阶段不得为验收发送、补发或重投真实短信,不修改真实通道账号、密码、启停状态、企业余额或客户连接,除非另获明确授权。 - 任一阶段不得为验收发送、补发或重投真实短信,不修改真实通道账号、密码、启停状态、企业余额或客户连接,除非另获明确授权。
## 9. 2026-09-17 性能与签名质量日报改造(本地实现,待发布)
权威增量设计见[签名退网检测与四页查询优化方案](signature-quality-optimization-plan-20260917.md)。实施后替代本文第6节、第7节第15步中检测快照直接作为活动查询、当前通过任务决定历史行及前端全量筛选分页的方式;保留D-1~D-30日期列、原去重单位、规则/抑制和预警历史。四TAB独立查询,日报T实时/T-1~T-3凌晨刷新/T-4起冻结;活动日报与当时预警判断分离。原正文是历史实现描述,本节及专项方案的本地实施状态见下方补充,线上未发布。
### 2026-09-17 实施状态补充
上述专项方案已完成本地代码与隔离验收,具体表结构、03:00三日刷新、事务边界、恢复、离线补建及性能测量见专项方案第8节。历史正文保留为旧实现说明;尚未推送或部署,线上依然是旧行为。
+37
View File
@@ -5614,3 +5614,40 @@ RC-01/02/04/08/09/10/11目前仅部分本地证据:三段齐段、重复与双
### 2026-09-16 五项整改最终测试证据索引 ### 2026-09-16 五项整改最终测试证据索引
TC-RC-20260916-0112按[测试交付记录](release-20260916-test-completion.md)逐项区分真实目标链路、隔离PG故障验证及未执行场景,不能整组标全部通过。两段/三段/四段、补发成功/失败退款、20HTTP通知及9CMPP目标、503/超时自动恢复、无客户回执目标和无租户通道测试均有真实证据;全部逐断点退出/72小时/历史组合/旧版恢复/等负载性能对照未完成。TC-RC-13修复前真实TCP复现,修复后5000次即时响应及测试环境新增6条18分段无补发通过。五项运营页面检查包含三尺寸、请求失败保留真实快照与恢复、恢复告警不自动消失及人工清除审计。详细计数与测试资源收尾以交付记录为准。 TC-RC-20260916-0112按[测试交付记录](release-20260916-test-completion.md)逐项区分真实目标链路、隔离PG故障验证及未执行场景,不能整组标全部通过。两段/三段/四段、补发成功/失败退款、20HTTP通知及9CMPP目标、503/超时自动恢复、无客户回执目标和无租户通道测试均有真实证据;全部逐断点退出/72小时/历史组合/旧版恢复/等负载性能对照未完成。TC-RC-13修复前真实TCP复现,修复后5000次即时响应及测试环境新增6条18分段无补发通过。五项运营页面检查包含三尺寸、请求失败保留真实快照与恢复、恢复告警不自动消失及人工清除审计。详细计数与测试资源收尾以交付记录为准。
## 2026-09-17 签名质量日报与退网检测优化验收(本地覆盖,环境项待执行)
设计见[优化方案](signature-quality-optimization-plan-20260917.md)。以下全部为计划用例,本轮只读取证不等于用例通过;不沿用此前短信测试授权。既有TC-ANALYTICS、TC-SIGNATURE-RETIREMENT保留历史记录,与本轮替代口径冲突时以本节和方案为实施目标。
| 编号 | 场景 | 验收要求 |
|---|---|---|
| TC-SQA-20260917-01 | 四TAB切换、不同日期/过滤/分页、慢响应及失败 | 仅当前TAB查询;条件与结果互不覆盖;企业请求不返回通道集合,反之亦然;隐藏TAB不重查 |
| TC-SQA-20260917-02 | 未报备TAB编辑日期后分别点顶部/卡片查询,再翻页 | 两个查询入口均应用当前全部草稿条件;翻页只用已应用条件,不串旧日期 |
| TC-SQA-20260917-03 | 今天查询质量和未报备 | 真实业务库一致性聚合,响应标记live及截止时间;不触发生成/检测/通知;过午夜由服务端确定自然日 |
| TC-SQA-20260917-04 | T-1/T-2/T-3迟到回执后再次凌晨生成 | 三天均更新日报,页面只读发布版本;失败保留旧版并标记;同一版本列表、概览、抽屉一致 |
| TC-SQA-20260917-05 | T-4及更早日收到迟到回执/补登记/更名 | 原短信事实可按原业务规则更新,冻结报表内容摘要不变;历史行和名称不依赖当前报备状态 |
| TC-SQA-20260917-06 | 热力图D为今天/历史日,跨月年及闰日 | 严格显示D-1~D-30,不出现D当天;每格按真实T判断冻结;未来D拒绝 |
| TC-SQA-20260917-07 | 有效零数据、缺报表、无报备资格、部分日期失败 | 四种状态明确区分;缺口不能计作已确认零或触发回查明细;部分30日合计明确不完整 |
| TC-SQA-20260917-08 | 短信跨日/跨通道补发,同通道多次accepted | 企业窗口按业务消息去重、通道按消息+通道去重;窗口不简单累加每日distinct数;质量业务日与尝试日分别核对 |
| TC-SQA-20260917-09 | 2/3/4段、缺片、乱序重复、失败与历史无分段记录 | 完整成功才算送达;未知保持未知;去重和旧回执兼容正确,耗时分子分母及成功率口径不漂移 |
| TC-SQA-20260917-10 | 两worker、租约失效、旧worker恢复、重复执行 | 同scope/date原子认领,旧fence不能发布;批次断点恢复,已完成不重新扫描,周期/快照/意图事务一致 |
| TC-SQA-20260917-11 | 候选生成部分失败、发布时跨入T-4、漏跑三日 | 旧版原子保留,跨冻结边界拒绝覆盖;缺口进入明确历史补建流程,不自动无限回算 |
| TC-SQA-20260917-12 | 04:00日报未好、08:00检测未完成、历史日报刷新 | 检测/通知按依赖等待且告警,不用半批发通知;恢复幂等,不重发历史消息/Webhook,不改已发正文 |
| TC-SQA-20260917-13 | 热力图大数据过滤排序分页及一类TAB失败 | 数据库先过滤并按完整30日合计排序再分页;只返回当页单类维度;另一TAB可独立查询;响应最多100×30格 |
| TC-SQA-20260917-14 | 真实接口鉴权/越权、非法日期和分页参数 | 401/403正确,tenant/application可见范围生效;无匿名生成入口;缓存不越权;SQL参数化 |
| TC-SQA-20260917-15 | 同规模新旧方案性能与准确性对照 | 所有指标对账;记录扫描行/块、任务耗时、进程CPU、API内存/响应量与队列影响;目标扫描下降80%、耗时下降50%,未达到不得冒称达标 |
| TC-SQA-20260917-16 | 历史补建、切换/回退和真实浏览器 | 历史覆盖/来源标明,无伪造冻结时间;不重发预警;三尺寸1600×1000/1366×768/390×844覆盖进入、刷新、路由、筛选、分页、详情、失败与权限;发布按后续授权执行 |
### 2026-09-17 本地执行覆盖与线上未执行项
TC-SQA-0114:真实隔离PG覆盖核心日期/日报/长短信/事务/分页路径,真实HTTP覆盖200/401/400,组件回归与三尺寸浏览器覆盖独立TAB。详细断言见`tools/testing/verify-signature-analytics.mjs`,不能将各用例全部边界都标成通过:真实08:00外部Webhook、旧回退版本、跨午夜运行过程、线上权限配置与365日大批次仍未执行。TC-SQA-15只完成30000消息/600维度活动聚合对照,整机CPU/内存/队列未测;TC-SQA-16只完成隔离历史补建、三尺寸页面,真实环境切换/回退未执行。
| 用例 | 操作 | 验证结果 |
|---|---|---|
| TC-MO-20260917-01 | 同手机号同应用多条accepted、共享接入号多应用 | 按应用归并;有唯一发送证据可匹配应用,不强填原短信 |
| TC-MO-20260917-02 | 第三条消息属另一应用、其他通道、接收后发送 | 保留真实歧义;通道/时间边界参与过滤,messageId不能跳过证据 |
| TC-MO-20260917-03 | 重复事件并发入库 | 真实PG仅一个上行和一份通知意图,供应商ID保留 |
| TC-MO-20260917-04 | 两个候选同时认领 | 真实PG仅一个应用成功,另一个拒绝,通知仅一份 |
| TC-MO-20260917-05 | 通知存储失败后重试 | 真实PG整笔回滚,事件重试恢复;未执行外部发送 |
历史159条认领/重新投递、真实供应商MO扩展号码及最终客户收取未执行,不沿用此前测试发送授权。
+28
View File
@@ -5109,3 +5109,31 @@ CUA本轮可用,实际后端文档三尺寸1600×1000/1366×768/390×844无页
标准工具最终deployed-needs-review仅提示业务数量变化:119597/130835→119603/130841,精确对应补测6条/6次,已人工对账;版本/资源/服务及日志检查通过。工具businessAcceptance未自动更新,不改报告伪造完成。全部证据、原失败、耗时、恢复点、磁盘增量/治理未完成项及矩阵未覆盖项见[交付验收记录](release-20260916-test-completion.md)。预生产未操作,未做性能容量对照、真实72小时等待或全部逐断点/旧版恢复演练。 标准工具最终deployed-needs-review仅提示业务数量变化:119597/130835→119603/130841,精确对应补测6条/6次,已人工对账;版本/资源/服务及日志检查通过。工具businessAcceptance未自动更新,不改报告伪造完成。全部证据、原失败、耗时、恢复点、磁盘增量/治理未完成项及矩阵未覆盖项见[交付验收记录](release-20260916-test-completion.md)。预生产未操作,未做性能容量对照、真实72小时等待或全部逐断点/旧版恢复演练。
本轮专用应用/接口/凭据/Webhook和两个模拟通道已停用,模拟器/隧道/监听器/临时hosts/本地隔离PG停止或撤销;短信账务及审计数据保留。61项旧工作保护核对完成,共享文档/metrics只增本轮内容。最终收尾文档及测试日志标签修正单独提交推送,不重启应用。密码及会话不进入Git。 本轮专用应用/接口/凭据/Webhook和两个模拟通道已停用,模拟器/隧道/监听器/临时hosts/本地隔离PG停止或撤销;短信账务及审计数据保留。61项旧工作保护核对完成,共享文档/metrics只增本轮内容。最终收尾文档及测试日志标签修正单独提交推送,不重启应用。密码及会话不进入Git。
## 2026-09-17 签名退网与四TAB查询优化方案(未实施)
- 授权:只读核查并编写方案。main与实际远端4eb7b16、暂存区空;09:37预生产实际010ba32。原50项已有修改/草稿继续保护,不纳入本轮交付。
- 已核查代码、线上编译入口、PG检测结果/真实索引及本会话CPU取证:04:00:0104:04:07产生5831条检测,逐维度重复扫描与CPU窗口高度吻合,缺历史进程CPU/SQL采样不能唯一归因。当前TAB状态独立,但企业/通道各自调用同一全量heatmap;质量及未报备所有日期实时查库;活跃度检测快照不做近三日刷新,历史又受当前报备维度影响。
- 用户确认每天凌晨刷新T-1~T-3;热力图保留所选日D之前30天,不新增当天列。方案明确实际T和D、T-4冻结、迟到回执、历史维度/未报备判定冻结、生成失败/缺口、批量聚合/索引验证、任务租约/fence、跨日去重及预警通知分离。
- 新增[优化方案](signature-quality-optimization-plan-20260917.md),向既有签名清退设计追加替代关系,需求及TC-SQA-20260917-01~16同步,全部实现验收用例待执行。取证脚本与结果在忽略目录.local-data/cpu-20260917,不含认证秘密。
- 本轮仅执行文档路径/编号/规则一致性及diff检查;未执行业务回归、迁移、重算、登录后四TAB真实HTTP/浏览器验收或新旧SQL性能对照。没有把既有组件mock测试当本轮真实功能通过。
- 本地修改:五份文档;业务代码无改动。本地提交、推送、测试部署、预生产部署:均未执行;未发送/补发/重投短信、未改余额/通道/客户配置或触发外部通知。
## 2026-09-17 上行待认领只读诊断(未修复)
- 预生产010ba32共253条上行:159待认领、92已匹配、2未匹配。147条共享接入号多应用直接返回ambiguous,其中143条接收前72小时实际仅有一个候选应用的同通道accepted发送;另12条手机号多记录全部同应用。已用当前线上编译匹配函数与真实PG只读事务复现两个分支,未启动调度/写入/投递。
- 明确应用归属与原短信唯一关联不能混为一谈;155条可进一步收敛不等于可直接自动认领。另发现窗口按处理时刻、无上界/通道限定、路由take10先截断等风险;现有89条自动关联只读边界核对未见手机号不符/未来关联/缺同通道accepted证据。
- 详见[上行匹配诊断](uplink-matching-diagnosis-20260917.md),区分已证实根因和未发生证据的风险;取证在.local-data/cpu-20260917/uplink-*。未执行HTTP/浏览器验收、写入故障注入或代码修复,未认领/发送/重推上行。
- main/实际远端4eb7b16、暂存区空;已有保护项及前轮签名质量方案保留。新增诊断、追加进度,执行文档路径和diff检查;本地提交、推送、测试部署、预生产部署均未执行。
## 2026-09-17 上行归属修复与签名质量日报优化(本地实现)
- 授权:修复上行待认领问题、执行`signature-quality-optimization-plan-20260917.md`并本地提交;本轮不推送、不部署,不操作历史上行认领/重推,不执行真实短信和外部通知。开始及提交前main/实际远端均4eb7b16,暂存区原空;65项已有修改/草稿已做摘要及副本保护。
- 上行:共享接入号不再提前判歧义;receivedAt前72小时同通道accepted记录按应用归并,原短信不唯一则不填原短信编号。供应商MO编号独立保存;事件入库/候选/通知、人工认领/通知分别事务化,重复事件和并发认领串行核验,通知只创建耐久意图。既有159条待认领没有自动认领/重投。
- 日报:六个新增表和版本/日期复合外键;03:00三日刷新,T-4起冻结,历史页面不回查明细;缺口、刷新/失败、截止时间和事后补建来源可见。企业/通道新接口只返回各自分页维度;四TAB查询/日期/筛选/错误独立,保留D-1~D-30。退网批量accepted窗口去重,规则/日报版本首次认领冻结,失败重试沿用;检测与通知等待完整上游批次,不重复生成历史预警。
- 真实验收:本机独立PostgreSQL16端口16435,新库108项迁移通过(含单独非事务并发索引)。`verify-signature-analytics.mjs`12组验收通过:当天/历史数据源、跨日去重、2/3/4段、缺片、旧无分段明确回执、加权耗时、三日刷新/冻结、旧版本保留、页码越界总数、失败回滚/重试、双worker/fence与规则快照。`verify-uplink-matching.mjs`3组通过:重复事件一份CMPP及一份HTTP通知意图、并发认领、存储故障整笔回滚并恢复;没有启动外部通知消费者。
- 前端:生产构建,完整真实Nest应用+PG+隔离RedisEdge/Playwright在1600×1000、1366×768、390×844检查首次、四TAB查询、刷新,补充历史日报、抽屉、企业服务端筛选、TAB日期独立和请求失败仍显示上次真实结果;28个相关请求、页面异常0。匿名401、非法分页/维度400、正常查询200。Browser插件/技能未提供,沿用用户已授权的独立Playwright;会话只在内存传递。
- 自动门禁:API78套842项、前端35文件163项通过;API/前端类型与生产构建、结构检查、ESLint、Prettier、CSS治理/样式、Bundle预算通过。ESLint仍有发送链路既存测试27项any警告,无error;构建既有chunk提示在预算内。覆盖率门禁见下方补充;Gateway代码未变,无本轮Go/实发/性能容量结论。
- 性能:30000消息/600维度活动聚合,旧10343.912ms,新624.940ms,热缓存下降93.96%accepted逐维度一致。原EXPLAIN单维度源访问30113行×600外推18067800,新批量60039;样本外推不冒称全批实测。未测整机CPU、365日真实大批次、峰值内存、p95和短信队列;不能据此宣布预生产CPU问题已完全解决。
- 原始证据保留于忽略目录`.local-data/signature-optimization-20260917/`integration-6、uplink-real-3、browser-5、performance-1及最终门禁日志。前面失败(迁移SQL未生成、缺carriers、测试断言漏计新增样本、账号缺email、浏览器按钮名称/模糊标签定位)已修正后重跑;不删除原失败日志。Redis5.0.14.1的BullMQ版本建议及PG驱动在嵌套关系读取时的并发query弃用提示保留,未改依赖版本。
- 文档:优化方案第8节、上行诊断第6节、需求/测试用例/清退设计同步。本地提交仅纳入本轮代码、两项迁移、验收脚本及相关文档精确新增段落;已有metrics、版本、部署和发布工具修改保留。推送:未执行;测试部署:未执行;预生产部署:未执行。历史日报补建、真实外部MO投递、线上权限/供应商扩展码、午夜冻结过程、现场恢复/回退及完整容量指标未验证,按后续授权实施。
@@ -0,0 +1,76 @@
# 上行短信待认领只读诊断
核查日期:2026-09-17,北京时间。状态:诊断完成;2026-09-17本地修复与隔离验收见第6节,线上未认领、未投递。范围为预生产既存上行和当前匹配代码,不沿用测试环境发送/补发授权。
## 1. 基线与结论
本地main及实际远端均为`4eb7b16d122da14f921093716d4ca1ed390d9e4c`,暂存区为空。预生产实际运行`010ba3216889032a6160cdb14d8536b616ae7102`。已有版本、metrics、发布工具、部署及前轮优化方案等修改全部保留。
当前共253条上行:159条ambiguous(页面“待认领”)、92条matched、2条unmatched。“待认领”不是接收失败:上行已入库,但没有确定应用归属,当前不会按正常已匹配路径推送给客户。
| 待认领原因 | 数量 | 真实数据复核 |
|---|---:|---|
| 接入号匹配多个应用 | 147 | 143条在接收前72小时仅有一个发送应用,且属于原候选应用、具有同上行通道accepted提交;4条在此限定口径下无下发记录 |
| 手机号窗口有多条下发 | 12 | 保存的两个候选全属同一应用/企业;完整时间窗口复核也只有一个应用,且有同通道accepted提交 |
因此155/159条具有“应用归属可以进一步收敛”的真实证据,不代表155条均能唯一确定被回复的具体业务短信,也不构成直接历史认领/投递授权。4条无证据记录保持未决,不因为某条路由现在有效就推断历史归属。历史源记录可能变化或被治理,当前回查不能替代完整历史快照。
接入号多应用147条分别位于移动物业-富泷78、三网物业-百信互动52、三网物业-铁布衫15、赛邮行业-王斯评中转2;它们的destId均等于当前通道srcId。12条手机号多记录位于三网物业-百信互动10、联电物业-富泷2。9月16日有31条待认领,9月15日12条。
## 2. 已确认根因
匹配实现:`api/src/send-chain/send-downstream-delivery.service.ts``resolveUplinkMatch`。页面`src/apps/admin/AdminSmsUplinkRecordsPage.tsx`将ambiguous直接映射为待认领,不是前端计算错误。
### 2.1 共享接入号过早返回
当前顺序为messageId精确匹配→接入号路由查应用。接入号得到多个active应用后,立即返回ambiguous与应用候选,完全不执行后面的手机号时间窗查询。
这与需求中“仍无唯一应用时按手机号和最近下发时间窗口匹配”的描述存在差距。共享通道接入号只表示有多个可能客户,不足以否定“该手机近期仅被其中一个客户发送”的进一步证据。
真实只读复现记录`cmu48o30s00fo34nkyp3phwt9`:运行中的编译类只调用`channelRouteRule.findMany``smsApplication.findMany`,返回3个应用候选;没有查询短信记录。同手机号接收前72小时实际只有一个应用的同通道accepted发送。
### 2.2 将短信记录歧义等同应用归属歧义
手机号分支取最近两条消息,按记录数量判断唯一或歧义,没有先按tenantId/applicationId归并。一个应用给同一手机发送两次,也被阻断客户归属。
真实只读复现`cmtmq3vef0ioyeankps3d80yl`:运行类返回两条phone_window候选,但distinct application数为1。12条同类存量均符合这一情况。可以有唯一客户归属而没有唯一原短信,应分别表达,不能为了绑定客户随意填最近一条messageRecordId/messageId。
## 3. 代码边界风险(不等于已经发生错误投递)
1. 时间窗下限由`Date.now()`计算而非事件receivedAt,且没有`submittedAt <= receivedAt`上限。延迟消费/故障恢复可能错过真实历史下发,或把上行之后的发送纳入。
2. 手机号回查未限定实际发送通道/供应商边界,也未校验真实accepted提交;仅按message.submittedAt查。不能在修复时直接扩大自动匹配,必须先收紧证据范围。确需同供应商跨物理连接兼容时,另核对账号、主机、端口、协议等边界,不能跨任意通道匹配。
3. 路由查询`take:10`发生在应用去重之前、没有稳定排序:前10条若同属一个应用可能漏掉其他应用,造成假唯一或候选遗漏。手机号`take:2`也只适于证明多条记录,不能据此证明窗口内只有一个应用。
4. 接入号只与通道静态srcId精确比较,没有完整核对实际发送号码/应用扩展码。其差异会进入宽泛手机号兜底,不能用当前路由代替历史发送证据。
5. messageId精确分支未同时验证手机号/通道来源;Gateway普通MO通过packet Msg_Id尝试查本地command。供应商上行ID应继续独立保存为gatewayMessageId,不能把它当已证明的原下发ID。本轮未发现碰撞或错误关联实例。
6. 上行入库、候选写入与投递分别进行;eventId已存在即返回,部分失败恢复可能留缺候选/缺投递。人工认领是先读状态再更新,未见条件更新原子竞争保护。属于附带发现的可靠性风险,本轮未做故障注入或并发写入复现。
对现有85条“手机号窗口唯一匹配”和4条“messageId精确匹配”执行只读边界核对:关联消息均存在、手机号一致、关联提交时间不晚于上行,均存在此前同通道accepted提交。未在这89条中检出上述明显异常;此结果不证明所有潜在风险不存在。
## 4. 建议修复方向(未实施)
- 分开“应用归属”与“原短信关联”。限定证据范围后应用唯一即可确定客户;具体消息仍多条时messageRecordId/messageId留空,并保留真实候选及原因。
- 共享接入号多应用时继续在候选应用中,结合手机号、上行接收时刻之前的72小时以及真实发送通道/接入号收敛;候选间有冲突仍待认领,不按最后一条或最高分随意选。
- 查询完整的distinct应用集合或以安全的唯一性检测查询证明唯一;展示候选可以分页,唯一性判断不能先截断。时间窗配置须校验正数有限值,上行receivedAt须校验合法性。
- 原始供应商MO ID、平台业务ID保持不同语义;无可信原短信关联不得伪造业务messageId。
- 配套原子入库/认领、通知意图与故障接续;客户查询仍严格隔离应用,ambiguous不得泄露给候选客户。
- 历史159条先生成只读建议清单,核验保留数据、有效应用、完整号码/通道证据及投递历史,再在明确授权后决定是否认领及是否推送。修复代码不自动批量认领、不自动把旧上行重新入队或投递。
## 5. 证据、复现限制与后续验证
忽略目录`.local-data/cpu-20260917/`中的`uplink-audit.json``uplink-correlation.json``uplink-reproduce.json``uplink-boundaries.json`及同名mjs为本机证据;输出只含统计、内部记录ID和必要通道标签,不导出手机号、正文或凭据。
复现直接加载预生产当前编译的`SendDownstreamDeliveryService.resolveUplinkMatch`,使用真实PG只读事务(815秒statement_timeout),不启动Nest生命周期,不调用handleUplink/claim/投递方法。仅在该独立诊断进程将Date.now固定到被查上行的receivedAt以复现旧事件分支,配置仍是当前数据库路由,不能宣称重建了当时全部配置。未执行管理HTTP或浏览器交互验收。
后续修复验收至少覆盖共享接入号+唯一应用、同应用多消息、多应用真实冲突、无记录、超过10条路由/超过2条消息、延迟消费/跨日、扩展码、跨供应商隔离、MO ID不冒充MT ID、事务中断、重复事件、并发认领、无重复投递及租户隔离。真实发送/推送只能在后续明确授权的隔离范围执行。
本轮业务代码、数据库和线上配置均未修改;仅新增诊断及追加进度记录,未提交、推送或部署。
## 6. 2026-09-17 本地修复
用户授权修复并本地提交。新增`api/src/send-chain/uplink-matching.ts`,共享接入号继续核对手机号在事件receivedAt前72小时的同通道accepted记录;按tenant/application归并,不以消息条数制造歧义;无take10/take2提前截断。messageId分支同时验证手机号和通道发送事实;收到的供应商gatewayMessageId独立保存。只能确定应用时不填写原短信ID,多应用/配置与事实冲突继续待认领。独占接入号无发送证据的原兼容规则保留;供应商扩展号码改写与跨物理通道归并没有扩大自动归属规则,需有真实协议证据后另行处理。
上行记录、候选和CMPP/HTTP通知意图纳入同一事务,沿用发送链路事务适配器;按事件ID事务锁和唯一键去重。人工认领按上行ID串行核验,只有一个候选可成功,认领与通知意图同事务;不在事务里执行外部推送,后续沿用已有耐久队列消费。已有事件直接返回,不擅自修补或重推历史半完成事件。
真实隔离PG验证:同应用多消息、第三条不同应用、接收前窗口、其他通道拒绝、原短信为空、重复事件只入一次/通知一次、两候选并发仅一次成功、通知存储失败回滚并重试恢复。自动化入口`tools/testing/verify-uplink-matching.mjs`及签名验收脚本;API全量回归见testing-progress。
原159条待认领保持原状;155条可收敛是取证结果,不是已经认领/推送。本轮未连接线上执行认领、重投或修配置,未推送/部署。历史建议清单、实际供应商扩展码、客户端最终收到MO和线上故障恢复仍未验证。
+4 -2
View File
@@ -43,8 +43,10 @@ export const adminOperationsApi = {
request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId }), { signal }), request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId }), { signal }),
getSendQuality: (date?: string) => getSendQuality: (date?: string) =>
request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })), request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) => getSignatureQuality: (
request<SignatureChannelQualityResponse>(withQuery('/admin/operations/signature-quality', query)), query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {},
signal?: AbortSignal,
) => request<SignatureChannelQualityResponse>(withQuery('/admin/operations/signature-quality', query), { signal }),
listSystemLogs: (query: { listSystemLogs: (query: {
tenantId?: string; tenantId?: string;
keyword?: string; keyword?: string;
+21 -2
View File
@@ -1,6 +1,8 @@
import { request, withQuery } from '../core/httpClient'; import { request, withQuery } from '../core/httpClient';
import type { import type {
PagedResult, PagedResult,
SignatureActivityResponse,
SignatureAnalyticsMetadata,
SignatureRetirementHeatmapItem, SignatureRetirementHeatmapItem,
SignatureRetirementHeatmapDimension, SignatureRetirementHeatmapDimension,
SignatureRetirementMessage, SignatureRetirementMessage,
@@ -12,6 +14,19 @@ import type {
} from '../types'; } from '../types';
export const adminSignatureRetirementApi = { export const adminSignatureRetirementApi = {
getSignatureActivity: (
query: {
date: string;
dimensionType: 'enterprise' | 'channel';
page: number;
pageSize: number;
tenantName?: string;
applicationName?: string;
signatureName?: string;
channelName?: string;
},
signal?: AbortSignal,
) => request<SignatureActivityResponse>(withQuery('/admin/signature-retirement/activity', query), { signal }),
getSignatureRetirementConfiguration: () => getSignatureRetirementConfiguration: () =>
request<{ rules: SignatureRetirementRule[]; webhooks: SignatureRetirementWebhook[] }>( request<{ rules: SignatureRetirementRule[]; webhooks: SignatureRetirementWebhook[] }>(
'/admin/signature-retirement/configuration', '/admin/signature-retirement/configuration',
@@ -79,8 +94,12 @@ export const adminSignatureRetirementApi = {
dimensions: SignatureRetirementHeatmapDimension[]; dimensions: SignatureRetirementHeatmapDimension[];
items: SignatureRetirementHeatmapItem[]; items: SignatureRetirementHeatmapItem[];
}>(withQuery('/admin/signature-retirement/heatmap', { date })), }>(withQuery('/admin/signature-retirement/heatmap', { date })),
getUnreportedSignatures: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) => getUnreportedSignatures: (
request<PagedResult<UnreportedSignatureItem> & { date: string }>( query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {},
signal?: AbortSignal,
) =>
request<PagedResult<UnreportedSignatureItem> & { date: string } & SignatureAnalyticsMetadata>(
withQuery('/admin/signature-retirement/unreported-signatures', query), withQuery('/admin/signature-retirement/unreported-signatures', query),
{ signal },
), ),
}; };
+2 -1
View File
@@ -1,3 +1,4 @@
import type { SignatureAnalyticsMetadata } from './signature-retirement';
// R1 compatibility types. Keep public names re-exported from src/api/adminApi.ts. // R1 compatibility types. Keep public names re-exported from src/api/adminApi.ts.
import type { AdminChannel } from './channels-reports'; import type { AdminChannel } from './channels-reports';
@@ -85,7 +86,7 @@ export type SignatureChannelQualityItem = {
drainageBreakdowns: SignatureChannelCarrierDrainageQualityStat[]; drainageBreakdowns: SignatureChannelCarrierDrainageQualityStat[];
}; };
export type SignatureChannelQualityResponse = { export type SignatureChannelQualityResponse = SignatureAnalyticsMetadata & {
date: string; date: string;
items: SignatureChannelQualityItem[]; items: SignatureChannelQualityItem[];
total: number; total: number;
+39
View File
@@ -105,3 +105,42 @@ export type UnreportedSignatureItem = {
applicationName?: string | null; applicationName?: string | null;
messageCount: number; messageCount: number;
}; };
export type SignatureAnalyticsMetadata = {
dataSource?: 'live' | 'report';
reportState?: string;
frozen?: boolean;
provenance?: string;
sourceAsOf?: string | null;
generatedAt?: string | null;
generationId?: string | null;
};
export type SignatureActivityItem = Pick<
SignatureRetirementHeatmapItem,
| 'id'
| 'dimensionType'
| 'signatureId'
| 'channelId'
| 'carrier'
| 'activityDate'
| 'submittedAttempts'
| 'acceptedBusinessCount'
| 'deliveredBusinessCount'
| 'status'
>;
export type SignatureActivityResponse = {
date: string;
items: SignatureActivityItem[];
dimensions: (SignatureRetirementHeatmapDimension & { total: number })[];
page: number;
pageSize: number;
total: number;
complete: boolean;
coverage: {
date: string;
generationId: string | null;
reportState: string;
frozen: boolean;
sourceAsOf: string | null;
}[];
};
+48 -42
View File
@@ -3,49 +3,33 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AdminAnalyticsPage } from './AdminAnalyticsPage'; import { AdminAnalyticsPage } from './AdminAnalyticsPage';
const { api } = vi.hoisted(() => ({ const { api } = vi.hoisted(() => ({
api: { getSignatureQuality: vi.fn(), getSignatureRetirementHeatmap: vi.fn(), getUnreportedSignatures: vi.fn() }, api: { getSignatureQuality: vi.fn(), getSignatureActivity: vi.fn(), getUnreportedSignatures: vi.fn() },
})); }));
vi.mock('@/api/adminApi', () => ({ adminApi: api })); vi.mock('@/api/adminApi', () => ({ adminApi: api }));
describe('independent analytics tabs', () => { describe('independent analytics tabs', () => {
it('combines separate activity search fields with AND and preserves them across tabs', async () => { it('submits independent activity fields to server and preserves drafts across tabs', async () => {
api.getSignatureRetirementHeatmap.mockResolvedValue({
items: [],
dimensions: [
{
dimensionType: 'channel',
signatureId: 'a',
signatureName: '签名甲',
tenantName: '企业甲',
applicationName: '应用甲',
channelName: '通道甲',
channelId: 'c',
carrier: 'mobile',
approvedAt: '2026-08-01',
},
{
dimensionType: 'channel',
signatureId: 'b',
signatureName: '签名乙',
tenantName: '企业甲',
applicationName: '应用乙',
channelName: '通道乙',
channelId: 'd',
carrier: 'unicom',
approvedAt: '2026-08-01',
},
],
});
render(<AdminAnalyticsPage />); render(<AdminAnalyticsPage />);
fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' })); fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' }));
const panel = screen.getByRole('region', { name: '通道签名活跃度' }); const panel = screen.getByRole('region', { name: '通道签名活跃度' });
await within(panel).findByText('签名甲'); await waitFor(() => expect(api.getSignatureActivity).toHaveBeenCalledTimes(1));
fireEvent.change(within(panel).getByLabelText('企业'), { target: { value: '企业甲' } }); fireEvent.change(within(panel).getByLabelText('企业'), { target: { value: '企业甲' } });
fireEvent.change(within(panel).getByLabelText('企业应用'), { target: { value: '应用甲' } }); fireEvent.change(within(panel).getByLabelText('企业应用'), { target: { value: '应用甲' } });
await waitFor(() => expect(within(panel).queryByText('签名乙')).not.toBeInTheDocument()); expect(api.getSignatureActivity).toHaveBeenCalledTimes(1);
fireEvent.change(within(panel).getByLabelText('通道'), { target: { value: '通道乙' } }); fireEvent.click(within(panel).getByRole('button', { name: '查询统计' }));
await waitFor(() => expect(within(panel).queryByText('签名甲')).not.toBeInTheDocument()); await waitFor(() =>
expect(api.getSignatureActivity).toHaveBeenLastCalledWith(
expect.objectContaining({ dimensionType: 'channel', tenantName: '企业甲', applicationName: '应用甲', page: 1 }),
expect.any(AbortSignal),
),
);
fireEvent.click(screen.getByRole('tab', { name: '企业签名活跃度' })); fireEvent.click(screen.getByRole('tab', { name: '企业签名活跃度' }));
await waitFor(() =>
expect(api.getSignatureActivity).toHaveBeenLastCalledWith(
expect.objectContaining({ dimensionType: 'enterprise', tenantName: '', applicationName: '' }),
expect.any(AbortSignal),
),
);
fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' })); fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' }));
expect(within(panel).getByLabelText('企业应用')).toHaveValue('应用甲'); expect(within(panel).getByLabelText('企业应用')).toHaveValue('应用甲');
}); });
@@ -53,19 +37,33 @@ describe('independent analytics tabs', () => {
vi.resetAllMocks(); vi.resetAllMocks();
api.getSignatureQuality.mockImplementation(async (query) => ({ ...query, total: 0, items: [] })); api.getSignatureQuality.mockImplementation(async (query) => ({ ...query, total: 0, items: [] }));
api.getUnreportedSignatures.mockImplementation(async (query) => ({ ...query, total: 0, items: [] })); api.getUnreportedSignatures.mockImplementation(async (query) => ({ ...query, total: 0, items: [] }));
api.getSignatureRetirementHeatmap.mockResolvedValue({ items: [], dimensions: [] }); api.getSignatureActivity.mockResolvedValue({
items: [],
dimensions: [],
total: 0,
page: 1,
pageSize: 25,
coverage: [],
complete: false,
});
}); });
it('loads only the visited tab and preserves independent dates when switching back', async () => { it('loads only the visited tab and preserves independent dates when switching back', async () => {
render(<AdminAnalyticsPage />); render(<AdminAnalyticsPage />);
await waitFor(() => await waitFor(() =>
expect(api.getSignatureQuality).toHaveBeenCalledWith(expect.objectContaining({ pageSize: 25 })), expect(api.getSignatureQuality).toHaveBeenCalledWith(
expect.objectContaining({ pageSize: 25 }),
expect.any(AbortSignal),
),
); );
expect(api.getSignatureRetirementHeatmap).not.toHaveBeenCalled(); expect(api.getSignatureActivity).not.toHaveBeenCalled();
const quality = screen.getByRole('region', { name: '签名通道发送质量' }); const quality = screen.getByRole('region', { name: '签名通道发送质量' });
fireEvent.change(within(quality).getByLabelText('统计日期'), { target: { value: '2026-08-20' } }); fireEvent.change(within(quality).getByLabelText('统计日期'), { target: { value: '2026-08-20' } });
fireEvent.click(within(quality).getByRole('button', { name: '查询统计' })); fireEvent.click(within(quality).getByRole('button', { name: '查询统计' }));
await waitFor(() => await waitFor(() =>
expect(api.getSignatureQuality).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-08-20' })), expect(api.getSignatureQuality).toHaveBeenLastCalledWith(
expect.objectContaining({ date: '2026-08-20' }),
expect.any(AbortSignal),
),
); );
fireEvent.click(screen.getByRole('tab', { name: '未报备签名' })); fireEvent.click(screen.getByRole('tab', { name: '未报备签名' }));
await waitFor(() => expect(api.getUnreportedSignatures).toHaveBeenCalledTimes(1)); await waitFor(() => expect(api.getUnreportedSignatures).toHaveBeenCalledTimes(1));
@@ -75,7 +73,7 @@ describe('independent analytics tabs', () => {
expect(within(quality).getByLabelText('统计日期')).toHaveValue('2026-08-20'); expect(within(quality).getByLabelText('统计日期')).toHaveValue('2026-08-20');
fireEvent.click(screen.getByRole('tab', { name: '未报备签名' })); fireEvent.click(screen.getByRole('tab', { name: '未报备签名' }));
expect(within(unreported).getByLabelText('统计日期')).toHaveValue('2026-08-25'); expect(within(unreported).getByLabelText('统计日期')).toHaveValue('2026-08-25');
expect(api.getSignatureRetirementHeatmap).not.toHaveBeenCalled(); expect(api.getSignatureActivity).not.toHaveBeenCalled();
}); });
it.each([ it.each([
@@ -89,9 +87,16 @@ describe('independent analytics tabs', () => {
await waitFor(() => expect(within(panel).getByRole('button', { name: '下一页' })).toBeEnabled()); await waitFor(() => expect(within(panel).getByRole('button', { name: '下一页' })).toBeEnabled());
fireEvent.change(within(panel).getByLabelText('统计日期'), { target: { value: '2026-08-20' } }); fireEvent.change(within(panel).getByLabelText('统计日期'), { target: { value: '2026-08-20' } });
fireEvent.click(within(panel).getByRole('button', { name: '查询统计' })); fireEvent.click(within(panel).getByRole('button', { name: '查询统计' }));
await waitFor(() => expect(api[method]).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-08-20' }))); await waitFor(() =>
expect(api[method]).toHaveBeenLastCalledWith(
expect.objectContaining({ date: '2026-08-20' }),
expect.any(AbortSignal),
),
);
fireEvent.click(within(panel).getByRole('button', { name: '下一页' })); fireEvent.click(within(panel).getByRole('button', { name: '下一页' }));
await waitFor(() => expect(api[method]).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }))); await waitFor(() =>
expect(api[method]).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }), expect.any(AbortSignal)),
);
// An unsubmitted date must not alter a pagination request. // An unsubmitted date must not alter a pagination request.
fireEvent.change(within(panel).getByLabelText('统计日期'), { target: { value: '2026-08-21' } }); fireEvent.change(within(panel).getByLabelText('统计日期'), { target: { value: '2026-08-21' } });
const size = within(panel).getByLabelText(/^每页数量/); const size = within(panel).getByLabelText(/^每页数量/);
@@ -108,6 +113,7 @@ describe('independent analytics tabs', () => {
await waitFor(() => await waitFor(() =>
expect(api[method]).toHaveBeenLastCalledWith( expect(api[method]).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1, pageSize: 100, date: '2026-08-20' }), expect.objectContaining({ page: 1, pageSize: 100, date: '2026-08-20' }),
expect.any(AbortSignal),
), ),
); );
await waitFor(() => expect(within(panel).getByLabelText('跳转页码')).toHaveValue(1)); await waitFor(() => expect(within(panel).getByLabelText('跳转页码')).toHaveValue(1));
@@ -118,7 +124,7 @@ describe('independent analytics tabs', () => {
async (tab) => { async (tab) => {
render(<AdminAnalyticsPage />); render(<AdminAnalyticsPage />);
fireEvent.click(screen.getByRole('tab', { name: tab })); fireEvent.click(screen.getByRole('tab', { name: tab }));
await waitFor(() => expect(api.getSignatureRetirementHeatmap).toHaveBeenCalledTimes(1)); await waitFor(() => expect(api.getSignatureActivity).toHaveBeenCalledTimes(1));
const panel = screen.getByRole('region', { name: tab }); const panel = screen.getByRole('region', { name: tab });
const size = within(panel).getByLabelText(/^每页数量/); const size = within(panel).getByLabelText(/^每页数量/);
expect(size.closest('.ui-pagination')).not.toBeNull(); expect(size.closest('.ui-pagination')).not.toBeNull();
@@ -131,7 +137,7 @@ describe('independent analytics tabs', () => {
).toHaveTextContent('25 条/页'); ).toHaveTextContent('25 条/页');
fireEvent.click(screen.getByRole('tab', { name: tab })); fireEvent.click(screen.getByRole('tab', { name: tab }));
expect(size).toHaveTextContent('50 条/页'); expect(size).toHaveTextContent('50 条/页');
expect(api.getSignatureRetirementHeatmap).toHaveBeenCalledTimes(1); await waitFor(() => expect(api.getSignatureActivity).toHaveBeenCalledTimes(2));
}, },
); );
}); });
+114 -63
View File
@@ -1,11 +1,13 @@
import { useDeferredValue, useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { BarChart3, Eye, Search, X } from 'lucide-react'; import { BarChart3, Eye, Search, X } from 'lucide-react';
import { import {
adminApi, adminApi,
type SignatureChannelCarrierQualityStat, type SignatureChannelCarrierQualityStat,
type SignatureChannelQualityItem, type SignatureChannelQualityItem,
type SignatureActivityResponse,
type SignatureAnalyticsMetadata,
type SignatureChannelQualityResponse, type SignatureChannelQualityResponse,
type SignatureRetirementHeatmapItem, type SignatureActivityItem,
type SignatureRetirementHeatmapDimension, type SignatureRetirementHeatmapDimension,
type UnreportedSignatureItem, type UnreportedSignatureItem,
type PagedResult, type PagedResult,
@@ -59,9 +61,19 @@ function AnalyticsPanel({ kind }: { kind: string }) {
const [pageSize, setPageSize] = useState(25); const [pageSize, setPageSize] = useState(25);
const [appliedDate, setAppliedDate] = useState(() => shanghaiDateKey()); const [appliedDate, setAppliedDate] = useState(() => shanghaiDateKey());
const requestId = useRef(0); const requestId = useRef(0);
const abort = useRef<AbortController | null>(null);
const [activity, setActivity] = useState<SignatureActivityResponse | null>(null);
const [activityFilters, setActivityFilters] = useState({
tenantName: '',
applicationName: '',
signatureName: '',
channelName: '',
});
const [appliedActivityFilters, setAppliedActivityFilters] = useState(activityFilters);
const [metadata, setMetadata] = useState<SignatureAnalyticsMetadata | null>(null);
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey()); const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
const [signatureQuality, setSignatureQuality] = useState<SignatureChannelQualityResponse | null>(null); const [signatureQuality, setSignatureQuality] = useState<SignatureChannelQualityResponse | null>(null);
const [retirementHeatmap, setRetirementHeatmap] = useState<SignatureRetirementHeatmapItem[]>([]); const [retirementHeatmap, setRetirementHeatmap] = useState<SignatureActivityItem[]>([]);
const [retirementDimensions, setRetirementDimensions] = useState<SignatureRetirementHeatmapDimension[]>([]); const [retirementDimensions, setRetirementDimensions] = useState<SignatureRetirementHeatmapDimension[]>([]);
const [unreportedSignatures, setUnreportedSignatures] = useState< const [unreportedSignatures, setUnreportedSignatures] = useState<
(PagedResult<UnreportedSignatureItem> & { date: string }) | null (PagedResult<UnreportedSignatureItem> & { date: string }) | null
@@ -80,30 +92,47 @@ function AnalyticsPanel({ kind }: { kind: string }) {
unreported = appliedUnreportedKeyword, unreported = appliedUnreportedKeyword,
date = statisticsDate, date = statisticsDate,
size = pageSize, size = pageSize,
filters = appliedActivityFilters,
) { ) {
abort.current?.abort();
const controller = new AbortController();
abort.current = controller;
const id = ++requestId.current; const id = ++requestId.current;
setLoading(true); setLoading(true);
setError(''); setError('');
try { try {
if (kind === 'quality') { if (kind === 'quality') {
const data = await adminApi.getSignatureQuality({ date, keyword: keyword || undefined, page, pageSize: size }); const data = await adminApi.getSignatureQuality(
{ date, keyword: keyword || undefined, page, pageSize: size },
controller.signal,
);
if (id !== requestId.current) return; if (id !== requestId.current) return;
setSignatureQuality(data); setSignatureQuality(data);
setMetadata(data);
setAppliedKeyword(keyword); setAppliedKeyword(keyword);
setSelectedSignature(null); setSelectedSignature(null);
} else if (kind === 'unreported') { } else if (kind === 'unreported') {
const data = await adminApi.getUnreportedSignatures({ const data = await adminApi.getUnreportedSignatures(
date, {
keyword: unreported || undefined, date,
page, keyword: unreported || undefined,
pageSize: size, page,
}); pageSize: size,
},
controller.signal,
);
if (id !== requestId.current) return; if (id !== requestId.current) return;
setUnreportedSignatures(data); setUnreportedSignatures(data);
setMetadata(data);
setAppliedUnreportedKeyword(unreported); setAppliedUnreportedKeyword(unreported);
} else { } else {
const data = await adminApi.getSignatureRetirementHeatmap(date); const data = await adminApi.getSignatureActivity(
{ date, dimensionType: kind as 'enterprise' | 'channel', page, pageSize: size, ...filters },
controller.signal,
);
if (id !== requestId.current) return; if (id !== requestId.current) return;
setActivity(data);
setAppliedActivityFilters(filters);
setRetirementHeatmap(data.items); setRetirementHeatmap(data.items);
setRetirementDimensions(data.dimensions); setRetirementDimensions(data.dimensions);
} }
@@ -115,9 +144,11 @@ function AnalyticsPanel({ kind }: { kind: string }) {
} }
} }
const initialLoad = useRef(loadData);
useEffect(() => { useEffect(() => {
void loadData(1, ''); void initialLoad.current(1, '');
return () => { return () => {
abort.current?.abort();
requestId.current += 1; requestId.current += 1;
}; };
}, []); }, []);
@@ -221,7 +252,7 @@ function AnalyticsPanel({ kind }: { kind: string }) {
]; ];
function queryStatistics() { function queryStatistics() {
void loadData(1, signatureKeyword.trim()); void loadData(1, signatureKeyword.trim(), unreportedKeyword.trim(), statisticsDate, pageSize, activityFilters);
} }
function changeSignaturePage(page: number) { function changeSignaturePage(page: number) {
@@ -233,8 +264,7 @@ function AnalyticsPanel({ kind }: { kind: string }) {
function changePageSize(size: number) { function changePageSize(size: number) {
setPageSize(size); setPageSize(size);
if (kind === 'quality' || kind === 'unreported') void loadData(1, appliedKeyword, appliedUnreportedKeyword, appliedDate, size);
void loadData(1, appliedKeyword, appliedUnreportedKeyword, appliedDate, size);
} }
return ( return (
@@ -256,7 +286,28 @@ function AnalyticsPanel({ kind }: { kind: string }) {
</Button> </Button>
</div> </div>
</div> </div>
{error ? <p className="form-error">{error}</p> : null} {error ? (
<p className="form-error">
{error}
{signatureQuality || activity || unreportedSignatures
? `;仍显示上次成功查询的数据,日期为 ${appliedDate}`
: ';尚无成功查询的数据。'}
</p>
) : null}
{metadata ? (
<p className="muted" role="status">
{metadata.dataSource === 'live' ? '当天实时查询' : metadata.frozen ? '已冻结日报' : '近三日可刷新日报'} ·{' '}
{metadata.reportState === 'missing'
? '报表尚未生成'
: metadata.reportState === 'failed'
? '生成失败,保留上次完整日报'
: metadata.reportState === 'refreshing'
? '正在刷新,显示上次完整日报'
: '查询完成'}
{metadata.provenance === 'backfill-current-source' ? ' · 事后补建' : ''}
{metadata.sourceAsOf ? ` · 数据截止 ${new Date(metadata.sourceAsOf).toLocaleString('zh-CN')}` : ''}
</p>
) : null}
{kind === 'quality' ? ( {kind === 'quality' ? (
<div className="surface signature-quality-card"> <div className="surface signature-quality-card">
@@ -323,6 +374,11 @@ function AnalyticsPanel({ kind }: { kind: string }) {
onPageSizeChange={changePageSize} onPageSizeChange={changePageSize}
date={appliedDate} date={appliedDate}
dimensionType="enterprise" dimensionType="enterprise"
activity={activity}
filters={activityFilters}
onFilterChange={setActivityFilters}
onSearch={queryStatistics}
onPageChange={(page) => void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate)}
dimensions={retirementDimensions} dimensions={retirementDimensions}
items={retirementHeatmap} items={retirementHeatmap}
title="企业签名活跃度热力图" title="企业签名活跃度热力图"
@@ -334,6 +390,11 @@ function AnalyticsPanel({ kind }: { kind: string }) {
onPageSizeChange={changePageSize} onPageSizeChange={changePageSize}
date={appliedDate} date={appliedDate}
dimensionType="channel" dimensionType="channel"
activity={activity}
filters={activityFilters}
onFilterChange={setActivityFilters}
onSearch={queryStatistics}
onPageChange={(page) => void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate)}
dimensions={retirementDimensions} dimensions={retirementDimensions}
items={retirementHeatmap} items={retirementHeatmap}
title="通道签名活跃度热力图" title="通道签名活跃度热力图"
@@ -349,7 +410,7 @@ function AnalyticsPanel({ kind }: { kind: string }) {
loading={loading} loading={loading}
onKeywordChange={setUnreportedKeyword} onKeywordChange={setUnreportedKeyword}
onPageChange={(page) => loadUnreportedSignatures(page)} onPageChange={(page) => loadUnreportedSignatures(page)}
onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())} onSearch={queryStatistics}
/> />
) : null} ) : null}
@@ -365,6 +426,11 @@ function AnalyticsPanel({ kind }: { kind: string }) {
} }
function RetirementHeatmap({ function RetirementHeatmap({
activity,
filters,
onFilterChange,
onSearch,
onPageChange,
pageSize, pageSize,
onPageSizeChange, onPageSizeChange,
date, date,
@@ -378,12 +444,14 @@ function RetirementHeatmap({
date: string; date: string;
dimensionType: 'enterprise' | 'channel'; dimensionType: 'enterprise' | 'channel';
dimensions: SignatureRetirementHeatmapDimension[]; dimensions: SignatureRetirementHeatmapDimension[];
items: SignatureRetirementHeatmapItem[]; items: SignatureActivityItem[];
title: string; title: string;
activity: SignatureActivityResponse | null;
filters: { tenantName: string; applicationName: string; signatureName: string; channelName: string };
onFilterChange: (value: typeof filters) => void;
onSearch: () => void;
onPageChange: (page: number) => void;
}) { }) {
const [pageState, setPageState] = useState({ key: '', page: 1 });
const [filters, setFilters] = useState({ tenantName: '', applicationName: '', signatureName: '', channelName: '' });
const deferredFilters = useDeferredValue(filters);
const visible = items.filter((item) => item.dimensionType === dimensionType); const visible = items.filter((item) => item.dimensionType === dimensionType);
const dates = previousDateKeys(date, 30); const dates = previousDateKeys(date, 30);
const cellMap = new Map( const cellMap = new Map(
@@ -392,40 +460,17 @@ function RetirementHeatmap({
item, item,
]), ]),
); );
const rows = dimensions const rows = dimensions.map((item) => ({
.filter((item) => item.dimensionType === dimensionType) ...item,
.filter((item) => key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
Object.entries(deferredFilters).every( approvedAt: item.approvedAt?.slice(0, 10) ?? '',
([key, value]) => total: (item as SignatureRetirementHeatmapDimension & { total: number }).total ?? 0,
!value.trim() || }));
(item[key as keyof typeof deferredFilters] ?? '') const totalPages = Math.max(1, Math.ceil((activity?.total ?? 0) / pageSize));
.toLocaleLowerCase('zh-CN') const currentPage = activity?.page ?? 1;
.includes(value.trim().toLocaleLowerCase('zh-CN')), const pagedRows = rows;
), const setPage = onPageChange;
) const coverage = new Map(activity?.coverage.map((day) => [day.date, day]));
.map((item) => ({
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
signatureName: item.signatureName,
channelName: item.channelName,
tenantName: item.tenantName,
applicationName: item.applicationName,
carrier: item.carrier,
approvedAt: item.approvedAt.slice(0, 10),
total: dates.reduce(
(sum, dateKey) =>
sum +
(cellMap.get(`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${dateKey}`)
?.acceptedBusinessCount ?? 0),
0,
),
}))
.sort((left, right) => right.total - left.total || left.signatureName.localeCompare(right.signatureName, 'zh-CN'));
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
const paginationKey = JSON.stringify([date, deferredFilters, dimensionType, dimensions.length, pageSize]);
const page = pageState.key === paginationKey ? pageState.page : 1;
const setPage = (value: number) => setPageState({ key: paginationKey, page: value });
const currentPage = Math.min(page, totalPages);
const pagedRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
return ( return (
<div className="surface signature-retirement-heatmap"> <div className="surface signature-retirement-heatmap">
@@ -448,12 +493,18 @@ function RetirementHeatmap({
label={label} label={label}
placeholder={`搜索${label}`} placeholder={`搜索${label}`}
value={filters[key]} value={filters[key]}
onChange={(event) => setFilters((current) => ({ ...current, [key]: event.target.value }))} onChange={(event) => onFilterChange({ ...filters, [key]: event.target.value })}
/> />
))} ))}
<Tag tone="info">T-1 T-30</Tag> <Button onClick={onSearch} icon={<Search size={16} />}>
</Button>
<Tag tone="info">30</Tag>
</div> </div>
</div> </div>
{activity && !activity.complete ? (
<p className="form-error">30</p>
) : null}
{rows.length ? ( {rows.length ? (
<> <>
<div className="signature-retirement-heatmap__scroll"> <div className="signature-retirement-heatmap__scroll">
@@ -482,7 +533,7 @@ function RetirementHeatmap({
<td className="signature-retirement-heatmap__total">{row.total.toLocaleString('zh-CN')}</td> <td className="signature-retirement-heatmap__total">{row.total.toLocaleString('zh-CN')}</td>
{dates.map((dateKey) => { {dates.map((dateKey) => {
const item = cellMap.get(`${row.key}:${dateKey}`); const item = cellMap.get(`${row.key}:${dateKey}`);
const beforeApproval = dateKey < row.approvedAt; const beforeApproval = Boolean(coverage.get(dateKey)?.generationId && !item);
const successRate = item?.acceptedBusinessCount const successRate = item?.acceptedBusinessCount
? (item.deliveredBusinessCount / item.acceptedBusinessCount) * 100 ? (item.deliveredBusinessCount / item.acceptedBusinessCount) * 100
: 0; : 0;
@@ -494,10 +545,10 @@ function RetirementHeatmap({
? 'is-zero' ? 'is-zero'
: `is-rate-${successRateTone(successRate)}`; : `is-rate-${successRateTone(successRate)}`;
const titleText = beforeApproval const titleText = beforeApproval
? '报备前,不适用' ? '当日报备维度不适用'
: item : item
? `提交条数:${item.submittedAttempts}\n上游接受条数:${item.acceptedBusinessCount}\n发送成功条数:${item.deliveredBusinessCount}\n发送成功率:${successRate.toFixed(1)}%\n检测状态${item.status === 'observing' ? '观察中(不预警)' : item.status === 'alert' ? '预警' : '正常'}\n预警阈值:${item.threshold}` ? `提交条数:${item.submittedAttempts}\n上游接受条数:${item.acceptedBusinessCount}\n发送成功条数:${item.deliveredBusinessCount}\n发送成功率:${successRate.toFixed(1)}%\n数据来源${coverage.get(dateKey)?.frozen ? '冻结日报' : '可刷新日报'}`
: '当日无检测快照'; : '当日报表尚未生成';
return ( return (
<td className={className} key={dateKey} title={titleText}> <td className={className} key={dateKey} title={titleText}>
{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'} {beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}
@@ -512,7 +563,7 @@ function RetirementHeatmap({
</> </>
) : ( ) : (
<p className="empty-state"> <p className="empty-state">
{Object.values(deferredFilters).some((value) => value.trim()) {Object.values(filters).some((value) => value.trim())
? '没有匹配企业、企业应用或签名的热力图维度。' ? '没有匹配企业、企业应用或签名的热力图维度。'
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'} : '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
</p> </p>
@@ -526,7 +577,7 @@ function RetirementHeatmap({
onPrevious={() => setPage(currentPage - 1)} onPrevious={() => setPage(currentPage - 1)}
page={currentPage} page={currentPage}
previousDisabled={currentPage <= 1} previousDisabled={currentPage <= 1}
total={rows.length} total={activity?.total ?? 0}
totalPages={totalPages} totalPages={totalPages}
/> />
</div> </div>
@@ -0,0 +1,41 @@
// Offline operator entry point. No SMS, detection, notification or history overwrite is performed.
import { createRequire } from 'node:module';
import assert from 'node:assert/strict';
const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, '').split('=')));
const url = new URL(process.env.DATABASE_URL || '');
assert(
args.host && args.host === url.hostname,
'Supply --host matching DATABASE_URL and an explicitly authorized target',
);
assert(args.dates, 'Supply --dates=YYYY-MM-DD,YYYY-MM-DD; no implicit historical range');
const dates = [...new Set(args.dates.split(','))];
assert(dates.length <= 366, 'At most 366 explicitly selected days per invocation');
process.env.NODE_ENV = 'test'; // Disable all automatic schedulers; this script only creates the report writer.
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { SignatureAnalyticsService } = require('./dist/signature-analytics/signature-analytics.service');
const { analyticsDate, databaseDay, todayKey } = require('./dist/signature-analytics/analytics-date');
const db = new PrismaService();
try {
for (const day of dates) {
analyticsDate(day);
assert(day < todayKey(), 'Only complete natural days may be backfilled');
const current = await db.signatureAnalyticsDay.findUnique({ where: { businessDate: databaseDay(day) } });
assert(!current?.publishedGenerationId, `${day}: published report exists; overwriting is forbidden`);
}
console.log(
JSON.stringify({
host: url.hostname,
dates,
execute: args.execute === 'yes',
provenance: 'backfill-current-source',
}),
);
if (args.execute === 'yes') {
const writer = new SignatureAnalyticsService(db);
for (const day of dates) console.log(JSON.stringify(await writer.generate(day, true)));
}
} finally {
await db.onModuleDestroy();
}
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { execFileSync } from 'node:child_process';
import Module from 'node:module';
import { randomUUID } from 'node:crypto';
const require = createRequire(new URL('../../api/package.json', import.meta.url));
const url = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || '');
assert(url.hostname === '127.0.0.1' && url.pathname.startsWith('/cmpp_qa_signature_'));
process.env.DATABASE_URL = url.toString();
process.env.NODE_ENV = 'test';
require('reflect-metadata');
const { PrismaService } = require('./dist/prisma/prisma.service'),
{ Prisma } = require('@prisma/client');
const { activityCounts } = require('./dist/signature-analytics/analytics-aggregate');
const { todayKey, addDays, startOfDay } = require('./dist/signature-analytics/analytics-date');
const oldSource = execFileSync(
'git',
['show', '4eb7b16d122da14f921093716d4ca1ed390d9e4c:api/src/signature-retirement/signature-retirement.service.ts'],
{ encoding: 'utf8' },
);
const compiled = require('typescript').transpileModule(oldSource, {
compilerOptions: { module: 1, target: 9, experimentalDecorators: true, emitDecoratorMetadata: true },
}).outputText;
const oldModule = new Module(require.resolve('./dist/signature-retirement/signature-retirement.service'));
oldModule.filename = require.resolve('./dist/signature-retirement/signature-retirement.service');
oldModule.paths = require.resolve.paths('@prisma/client');
oldModule._compile(compiled, oldModule.filename);
const db = new PrismaService(),
prefix = 'bench' + randomUUID().slice(0, 8),
N = 300,
K = 100,
D = addDays(todayKey(), -1),
start = startOfDay(D),
end = startOfDay(todayKey());
let last;
const traced = new Proxy(db, {
get(t, k) {
if (k === '$queryRaw')
return async (q) => {
last = q;
return db.$queryRaw(q);
};
const v = t[k];
return typeof v === 'function' ? v.bind(t) : v;
},
});
try {
const tenant = await db.tenant.create({ data: { name: prefix, code: prefix } }),
app = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: prefix,
cmppAccount: prefix,
cmppEnterpriseCode: '000001',
secretHash: 'isolated',
interfaceEnabled: false,
},
}),
channel = await db.smsChannel.create({
data: {
name: prefix,
code: prefix,
srcId: '1069',
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: 'isolated',
passwordCipher: 'not-a-secret',
carriers: ['mobile'],
status: 'disabled',
},
});
await db.$executeRaw`INSERT INTO "SmsSignature"(id,"tenantId","applicationId",name,"updatedAt") SELECT ${prefix}||n,${tenant.id},${app.id},${prefix}||n,NOW() FROM generate_series(1,${N}) n`;
await db.$executeRaw`INSERT INTO "SmsMessageRecord"(id,"messageId","tenantId","applicationId","signatureId","phoneNumber",content,carrier,"queuedAt","billingUnits","updatedAt") SELECT ${prefix}||n||'-'||k,${prefix}||n||'-'||k,${tenant.id},${app.id},${prefix}||n,'13800000200','isolated','mobile',${start},1,NOW() FROM generate_series(1,${N}) n CROSS JOIN generate_series(1,${K}) k`;
await db.$executeRaw`INSERT INTO "SmsSubmitRecord"(id,"messageRecordId","submitId","channelId","submitStatus","submittedAt","createdAt","updatedAt") SELECT id,id,id,${channel.id},'accepted',${start},${start},NOW() FROM "SmsMessageRecord" WHERE "tenantId"=${tenant.id}`;
await db.$executeRawUnsafe('ANALYZE "SmsMessageRecord"');
await db.$executeRawUnsafe('ANALYZE "SmsSubmitRecord"');
const dims = Array.from({ length: N }, (_, i) =>
['enterprise', 'channel'].map((type) => ({
dimensionKey: JSON.stringify([type, prefix + (i + 1)]),
dimensionType: type,
tenantId: tenant.id,
applicationId: app.id,
signatureId: prefix + (i + 1),
channelKey: type === 'channel' ? channel.id : '',
channelId: type === 'channel' ? channel.id : null,
carrier: 'mobile',
approvedAt: startOfDay(addDays(D, -60)),
signatureName: prefix + (i + 1),
tenantName: prefix,
applicationName: prefix,
channelName: prefix,
})),
).flat();
const old = new oldModule.exports.SignatureRetirementService(traced);
const before = performance.now();
for (const d of dims) {
const r = await old.activityCounts(d, start, end);
assert.equal(r.acceptedBusinessCount, K);
}
const oldMs = performance.now() - before;
const sampleOld = last;
const now = performance.now(),
counts = await activityCounts(traced, dims, D),
newMs = performance.now() - now,
sampleNew = last;
for (const d of dims) assert.equal(counts.get(d.dimensionKey).acceptedBusinessCount, K);
const explain = async (q) =>
(await db.$queryRaw(Prisma.sql`EXPLAIN (ANALYZE,BUFFERS,FORMAT JSON) ${q}`))[0]['QUERY PLAN'][0];
const oldPlan = await explain(sampleOld),
newPlan = await explain(sampleNew);
const scanRows = (p) => {
let sum = 0;
const walk = (n) => {
if (
n['Node Type']?.includes('Scan') &&
['SmsSubmitRecord', 'SmsMessageRecord', 'SmsReceiptRecord', 'SmsMessageSegmentAudit'].includes(
n['Relation Name'],
)
)
sum += (n['Actual Rows'] + (n['Rows Removed by Filter'] || 0)) * (n['Actual Loops'] || 0);
for (const c of n.Plans || []) walk(c);
};
walk(p.Plan);
return sum;
};
console.log(
JSON.stringify({
messages: N * K,
dimensions: dims.length,
oldMs,
newMs,
reduction: 1 - newMs / oldMs,
oldSampleRows: scanRows(oldPlan),
estimatedOldRows: scanRows(oldPlan) * dims.length,
newRows: scanRows(newPlan),
note: 'warm cache, per-dimension sample extrapolation; not production CPU or whole job capacity',
oldPlan,
newPlan,
}),
);
} finally {
await db.onModuleDestroy();
}
@@ -0,0 +1,400 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
const url = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || '');
assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_signature_'));
process.env.DATABASE_URL = url.toString();
process.env.NODE_ENV = 'test';
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { SignatureAnalyticsService } = require('./dist/signature-analytics/signature-analytics.service');
const { SignatureAnalyticsRead } = require('./dist/signature-analytics/analytics-read');
const { OperationsQualityQueries } = require('./dist/operations/queries/quality.queries');
const { analyticsJob } = require('./dist/signature-analytics/analytics-job');
const { detectRetirement } = require('./dist/signature-analytics/retirement-batch');
const { resolveUplinkMatch } = require('./dist/send-chain/uplink-matching');
const { todayKey, addDays, startOfDay, databaseDay } = require('./dist/signature-analytics/analytics-date');
const db = new PrismaService(),
writer = new SignatureAnalyticsService(db),
reader = new SignatureAnalyticsRead(db);
const prefix = randomUUID().slice(0, 8),
T = todayKey(),
D = addDays(T, -1),
old = addDays(T, -5);
const pass = (name) => console.log('PASS', name);
const at = (date, seconds = 3600) => new Date(startOfDay(date).getTime() + seconds * 1000);
try {
const tenant = await db.tenant.create({ data: { name: `签名日报验收${prefix}`, code: prefix } });
const app = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '应用甲',
cmppAccount: prefix,
cmppEnterpriseCode: '000001',
secretHash: 'isolated-no-login',
interfaceEnabled: false,
},
});
const channel = await db.smsChannel.create({
data: {
name: '隔离通道',
code: prefix,
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: 'isolated',
passwordCipher: 'not-a-secret',
srcId: '10690000',
carriers: ['mobile'],
status: 'disabled',
},
});
const signature = await db.smsSignature.create({
data: { tenantId: tenant.id, applicationId: app.id, name: `【验收${prefix}`, auditStatus: 'approved' },
});
await db.channelSignatureReportTask.create({
data: {
tenantId: tenant.id,
signatureId: signature.id,
channelId: channel.id,
reportType: 'signature',
carrier: 'mobile',
approvalScope: 'carrier_specific',
status: 'approved',
approvedAt: at(addDays(T, -60)),
createdAt: at(addDays(T, -60)),
},
});
async function message({
date = D,
submitDate = date,
units = 1,
segments = units,
delivered = true,
phone = '13800000001',
sig = signature.id,
content = signature.name + '隔离数据',
} = {}) {
const m = await db.smsMessageRecord.create({
data: {
messageId: randomUUID(),
tenantId: tenant.id,
applicationId: app.id,
signatureId: sig,
phoneNumber: phone,
content,
carrier: 'mobile',
queuedAt: at(date),
submittedAt: at(submitDate),
billingUnits: units,
status: delivered && segments === units ? 'delivered' : 'submitted',
submitStatus: 'accepted',
receiptStatus: delivered && segments === units ? 'delivered' : null,
deliveredAt: delivered && segments === units ? at(submitDate, 3602) : null,
},
});
const s = await db.smsSubmitRecord.create({
data: {
messageRecordId: m.id,
tenantId: tenant.id,
channelId: channel.id,
submitId: randomUUID(),
submitStatus: 'accepted',
submittedAt: at(submitDate),
gatewayMessageId: randomUUID(),
createdAt: at(submitDate),
},
});
for (let index = 1; index <= segments; index++)
await db.smsMessageSegmentAudit.create({
data: {
messageRecordId: m.id,
submitRecordId: s.id,
channelId: channel.id,
submitId: s.submitId,
segmentTotal: units,
segmentIndex: index,
submitStatus: 'accepted',
receiptStatus: delivered ? 'delivered' : null,
deliveredAt: delivered ? at(submitDate, 3600 + index) : null,
},
});
return { m, s };
}
const complete = await message({ units: 3, phone: '13800000011' });
const partial = await message({ units: 3, segments: 2, phone: '13800000012' });
await message({ date: addDays(D, -1), submitDate: D, phone: '13800000013' });
await message({ sig: null, content: `【未登记${prefix}】隔离`, phone: '13800000014' });
await message({ date: T, phone: '13800000015' });
const oldMessage = await message({ date: old, phone: '13800000016' });
// A rerun uses an isolated database; do not reset or touch any real tenant environment.
await writer.generate(D);
const quality = await reader.quality({ date: D, keyword: prefix });
const item = quality.items.find((x) => x.signatureId === signature.id);
assert(item);
assert.equal(item.total, 2);
assert.equal(item.channelSubmitTotal, 3);
assert.equal(item.breakdowns[0].successCount, 2);
assert.equal(item.breakdowns[0].unknownCount, 1);
pass('日报业务/尝试归日分离、三段成功与缺片未知');
const activity = await reader.activity({ date: T, dimensionType: 'enterprise', signatureName: prefix });
assert.equal(activity.dimensions.length, 1);
assert.equal(activity.items.length, 1);
assert.equal(activity.items[0].acceptedBusinessCount, 3);
assert.equal(activity.items[0].deliveredBusinessCount, 2);
assert.equal(activity.coverage[0].date, D);
assert.equal(activity.complete, false);
pass('企业/通道服务端分页与D-1至D-30缺口元数据');
assert.equal((await reader.unreported({ date: D, keyword: prefix })).total, 1);
const live = await new OperationsQualityQueries(db).signatureQuality({ date: T, keyword: prefix });
assert.equal(live.dataSource, 'live');
assert.equal(live.items[0].total, 1);
pass('当天真实查询、历史只读日报、未报备日报');
await writer.generate(old, true);
const before = await reader.quality({ date: old, keyword: prefix });
await db.smsMessageRecord.update({
where: { id: oldMessage.m.id },
data: { status: 'failed', receiptStatus: 'undelivered' },
});
assert.equal((await writer.generate(old)).skipped, true);
assert.deepEqual((await reader.quality({ date: old, keyword: prefix })).items, before.items);
await assert.rejects(writer.generate(old, true), /覆盖/);
pass('T-4及更早冻结、显式补建不覆盖');
await db.signatureRetirementRule.create({
data: {
ruleType: 'enterprise_global',
targetKey: '',
mobileWindowDays: 7,
mobileThreshold: 100,
unicomWindowDays: 7,
unicomThreshold: 100,
telecomWindowDays: 7,
telecomThreshold: 100,
},
});
await db.smsSubmitRecord.create({
data: {
messageRecordId: oldMessage.m.id,
channelId: channel.id,
submitId: randomUUID(),
submitStatus: 'accepted',
submittedAt: at(addDays(old, 1)),
},
});
const detection = await detectRetirement(db, T);
const decision = await db.signatureRetirementDetection.findFirst({
where: { signatureId: signature.id, dimensionType: 'enterprise', detectionDate: databaseDay(T) },
});
assert(decision.notificationContent.includes('发送4条'));
assert(detection.alerted >= 1);
const count = await db.signatureRetirementDetection.count({ where: { signatureId: signature.id } });
assert.equal((await detectRetirement(db, T)).skipped, true);
assert.equal(await db.signatureRetirementDetection.count({ where: { signatureId: signature.id } }), count);
pass('批量退网窗口聚合、重复任务提前退出');
await message({ phone: '13800000021' });
await message({ phone: '13800000021' });
const matched = await resolveUplinkMatch(
db,
{ channelId: channel.id, phoneNumber: '13800000021', destId: '1069000099', receivedAt: at(D, 7200).toISOString() },
channel,
);
assert.equal(matched.matchStatus, 'matched');
assert.equal(matched.applicationId, app.id);
assert.equal(matched.messageRecordId, undefined);
const beforeSending = await resolveUplinkMatch(
db,
{ channelId: channel.id, phoneNumber: '13800000021', destId: '1069000099', receivedAt: at(D, 3000).toISOString() },
channel,
);
assert.equal(beforeSending.matchStatus, 'unmatched');
pass('上行同应用多短信仍归属唯一、未来下发不参与');
const channel2 = await db.smsChannel.create({
data: {
name: '隔离通道乙',
code: prefix + 'b',
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: 'isolated',
passwordCipher: 'not-a-secret',
srcId: '10690001',
carriers: ['mobile'],
status: 'disabled',
},
});
const wrong = await resolveUplinkMatch(
db,
{
channelId: channel2.id,
messageId: complete.m.messageId,
phoneNumber: complete.m.phoneNumber,
destId: '1069000199',
receivedAt: at(D, 7200).toISOString(),
},
channel2,
);
assert.equal(wrong.matchStatus, 'unmatched');
assert.equal(wrong.messageId, undefined);
const app2 = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '应用乙',
cmppAccount: prefix + 'b',
cmppEnterpriseCode: '000002',
secretHash: 'isolated-no-login',
interfaceEnabled: false,
},
});
const other = await message({ phone: '13800000021' });
await db.smsMessageRecord.update({ where: { id: other.m.id }, data: { applicationId: app2.id } });
const multi = await resolveUplinkMatch(
db,
{ channelId: channel.id, phoneNumber: '13800000021', destId: '1069000099', receivedAt: at(D, 7200).toISOString() },
channel,
);
assert.equal(multi.matchStatus, 'ambiguous');
assert.equal(multi.candidates.length, 2);
pass('错误messageId/其他通道拒绝归属、第三条不同应用不会被截断漏掉');
// Fault injection is confined to this disposable QA database.
const late = await db.smsMessageSegmentAudit.create({
data: {
messageRecordId: partial.m.id,
submitRecordId: partial.s.id,
channelId: channel.id,
submitId: partial.s.submitId,
segmentTotal: 3,
segmentIndex: 3,
submitStatus: 'accepted',
receiptStatus: 'delivered',
deliveredAt: at(D, 3610),
},
});
await db.signatureAnalyticsRun.update({
where: { scope_businessDate: { scope: 'daily', businessDate: databaseDay(D) } },
data: { refreshFor: databaseDay(D) },
});
const prior = quality.generationId;
await writer.generate(D);
const refreshed = await reader.quality({ date: D, keyword: prefix });
assert.notEqual(refreshed.generationId, prior);
assert.equal(refreshed.items[0].breakdowns[0].successCount, 6);
assert((await db.signatureQualityDaily.count({ where: { generationId: prior } })) > 0);
const secondDay = addDays(T, -2);
await message({ date: secondDay, units: 2 });
await message({ date: secondDay, units: 4 });
const legacy = await message({ date: secondDay, units: 4, segments: 0 });
await db.smsReceiptRecord.create({
data: {
receiptKey: randomUUID(),
messageRecordId: legacy.m.id,
messageId: legacy.m.messageId,
channelId: channel.id,
gatewayMessageId: legacy.s.gatewayMessageId,
receiptStatus: 'delivered',
rawStatus: 'DELIVRD',
deliveredAt: at(secondDay, 3605),
},
});
await writer.generate(secondDay);
const multiLength = await reader.quality({ date: secondDay, keyword: prefix });
assert.equal(multiLength.items[0].breakdowns[0].successCount, 3);
assert.equal(multiLength.items[0].breakdowns[0].averageArrivalMs, 3667);
const outOfRange = await reader.activity({ date: T, dimensionType: 'enterprise', signatureName: prefix, page: 99 });
assert.equal(outOfRange.total, 1);
assert.equal(outOfRange.dimensions.length, 0);
pass('两段/四段、历史无分段明确回执兼容、耗时加权、超出页码仍有正确总数');
await writer.generate(addDays(T, -3));
pass('最近三日生成、迟到长短信分段刷新、旧版本保留');
const failedScope = 'qa-failure-' + prefix,
rollbackGeneration = randomUUID();
await assert.rejects(
analyticsJob(db, failedScope, D, async (tx) => {
await tx.signatureAnalyticsGeneration.create({
data: { id: rollbackGeneration, businessDate: databaseDay(D), sourceAsOf: new Date() },
});
throw new Error('injected');
}),
/injected/,
);
assert.equal(await db.signatureAnalyticsGeneration.count({ where: { id: rollbackGeneration } }), 0);
const failed = await db.signatureAnalyticsRun.findUnique({
where: { scope_businessDate: { scope: failedScope, businessDate: databaseDay(D) } },
});
assert.equal(failed.state, 'retry_wait');
await db.signatureAnalyticsRun.update({ where: { id: failed.id }, data: { nextAttemptAt: new Date(0) } });
assert.equal((await analyticsJob(db, failedScope, D, async () => 42)).result, 42);
pass('中途失败原子回滚、持久重试与恢复');
const scope = 'qa-concurrent-' + prefix;
let release, entered;
const gate = new Promise((r) => (release = r)),
ready = new Promise((r) => (entered = r));
const owner = analyticsJob(db, scope, D, async () => {
entered();
await gate;
return 1;
});
await ready;
assert.equal((await analyticsJob(db, scope, D, async () => 2)).skipped, true);
release();
assert.equal((await owner).result, 1);
const fencedScope = 'qa-fenced-' + prefix,
fencedGeneration = randomUUID();
await assert.rejects(
analyticsJob(db, fencedScope, D, async (tx) => {
await tx.signatureAnalyticsGeneration.create({
data: { id: fencedGeneration, businessDate: databaseDay(D), sourceAsOf: new Date() },
});
await db.signatureAnalyticsRun.update({
where: { scope_businessDate: { scope: fencedScope, businessDate: databaseDay(D) } },
data: { fence: { increment: 1 }, owner: 'replacement' },
});
}),
);
assert.equal(await db.signatureAnalyticsGeneration.count({ where: { id: fencedGeneration } }), 0);
pass('双worker唯一认领、旧fence禁止发布并回滚候选版本');
const checkpointScope = 'qa-checkpoint-' + prefix;
await assert.rejects(
analyticsJob(
db,
checkpointScope,
D,
async () => {
throw new Error('checkpoint fault');
},
new Date(),
async () => ({ version: 1 }),
),
/checkpoint fault/,
);
await db.signatureAnalyticsRun.update({
where: { scope_businessDate: { scope: checkpointScope, businessDate: databaseDay(D) } },
data: { nextAttemptAt: new Date(0) },
});
const resumed = await analyticsJob(
db,
checkpointScope,
D,
async (_tx, _generation, checkpoint) => checkpoint,
new Date(),
async () => ({ version: 2 }),
);
assert.deepEqual(resumed.result, { version: 1 });
pass('重试沿用首轮冻结规则快照');
assert(late.id);
console.log(
JSON.stringify({
fixturePrefix: prefix,
tenantId: tenant.id,
applicationId: app.id,
signatureId: signature.id,
partial: partial.s.id,
complete: complete.s.id,
}),
);
} finally {
await db.onModuleDestroy();
}
+143
View File
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
const url = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || '');
assert(['localhost', '127.0.0.1'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_signature_'));
process.env.DATABASE_URL = url.toString();
process.env.NODE_ENV = 'test';
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { completionDatabase } = require('./dist/send-chain/completion-context');
const { SendDownstreamDeliveryService } = require('./dist/send-chain/send-downstream-delivery.service');
const { OpenApiService } = require('./dist/open-api/open-api.service');
const { resolveUplinkMatch } = require('./dist/send-chain/uplink-matching');
const db = new PrismaService(),
proxy = completionDatabase(db),
prefix = randomUUID().slice(0, 8);
function delivery(openApi) {
let service;
const facade = {
resolveUplinkMatch: (event, channel) => resolveUplinkMatch(proxy, event, channel),
queueAndTryDownstreamDelivery: (data) => service.queueAndTryDownstreamDelivery(data),
postGatewayControl: () => {
throw new Error('External delivery is forbidden in QA');
},
};
service = new SendDownstreamDeliveryService(proxy, undefined, openApi, facade, {});
return service;
}
try {
const tenant = await db.tenant.create({ data: { name: '上行验收', code: prefix } });
const apps = [];
for (const suffix of ['a', 'b'])
apps.push(
await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '应用' + suffix,
cmppAccount: prefix + suffix,
cmppEnterpriseCode: '000001',
secretHash: 'isolated',
interfaceEnabled: true,
},
}),
);
const channel = await db.smsChannel.create({
data: {
name: '仅入库隔离通道',
code: prefix,
srcId: '1069',
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: 'isolated',
passwordCipher: 'not-a-secret',
carriers: ['mobile'],
status: 'disabled',
},
});
const received = new Date(),
sent = new Date(received.getTime() - 60_000);
for (const [phone, index] of [
['13800000101', 0],
['13800000101', 0],
['13800000102', 0],
['13800000102', 1],
]) {
const m = await db.smsMessageRecord.create({
data: {
messageId: randomUUID(),
tenantId: tenant.id,
applicationId: apps[index].id,
phoneNumber: phone,
content: '隔离上行匹配测试',
},
});
await db.smsSubmitRecord.create({
data: {
messageRecordId: m.id,
channelId: channel.id,
submitId: randomUUID(),
submitStatus: 'accepted',
submittedAt: sent,
},
});
}
for (const app of apps) {
await db.smsApplicationHttpConfig.create({ data: { applicationId: app.id, enabled: true, sendEnabled: false } });
await db.httpWebhookEndpoint.create({
data: {
applicationId: app.id,
eventType: 'uplink',
url: 'http://127.0.0.1:1/never-called',
secretEncrypted: 'isolated-no-delivery',
secretLast4: 'test',
},
});
}
const service = delivery(new OpenApiService(db, undefined));
const event = {
eventId: randomUUID(),
channelId: channel.id,
phoneNumber: '13800000101',
destId: '10690001',
content: 'TD',
receivedAt: received.toISOString(),
gatewayMessageId: 'supplier-mo-id',
};
const copies = await Promise.all([service.handleUplink(event), service.handleUplink(event)]);
assert.equal(copies[0].id, copies[1].id);
assert.equal(copies[0].messageId, null);
assert.equal(copies[0].messageRecordId, null);
assert.equal(copies[0].gatewayMessageId, 'supplier-mo-id');
assert.equal(await db.cmppDownstreamDelivery.count({ where: { dedupeKey: 'uplink:' + copies[0].id } }), 1);
assert.equal(await db.httpWebhookEvent.count({ where: { uplinkMessageId: copies[0].id } }), 1);
console.log('PASS 重复事件原子入库、无唯一原短信不伪造编号、CMPP与HTTP通知意图各一份');
const ambiguous = await service.handleUplink({ ...event, eventId: randomUUID(), phoneNumber: '13800000102' });
assert.equal(ambiguous.matchStatus, 'ambiguous');
const candidates = await db.smsUplinkMatchCandidate.findMany({ where: { uplinkMessageId: ambiguous.id } });
assert.equal(candidates.length, 2);
const claims = await Promise.allSettled(candidates.map((c) => service.claimUplinkMatchCandidate(ambiguous.id, c.id)));
assert.equal(claims.filter((r) => r.status === 'fulfilled').length, 1);
assert.equal(await db.cmppDownstreamDelivery.count({ where: { dedupeKey: 'uplink:' + ambiguous.id } }), 1);
assert.equal(
await db.smsUplinkMatchCandidate.count({ where: { uplinkMessageId: ambiguous.id, status: 'claimed' } }),
1,
);
console.log('PASS 并发认领仅一个应用成功、候选及通知同事务');
const failedEvent = { ...event, eventId: randomUUID() };
await assert.rejects(
delivery({
queueWebhookEvent: async () => {
throw new Error('injected notification storage failure');
},
}).handleUplink(failedEvent),
/injected/,
);
assert.equal(await db.smsUplinkMessage.count({ where: { eventId: failedEvent.eventId } }), 0);
await service.handleUplink(failedEvent);
assert.equal(await db.smsUplinkMessage.count({ where: { eventId: failedEvent.eventId } }), 1);
console.log('PASS 通知存储故障回滚上行、重试恢复,不调用外部发送');
} finally {
await db.onModuleDestroy();
}