fix: 修复上行归属并实现签名质量日报优化
This commit is contained in:
@@ -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")));
|
||||||
@@ -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])
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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|channel,date为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-01~16`,本地执行覆盖及环境未执行项见第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-1,60秒补偿检查;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节。历史正文保留为旧实现说明;尚未推送或部署,线上依然是旧行为。
|
||||||
|
|||||||
@@ -5614,3 +5614,40 @@ RC-01/02/04/08/09/10/11目前仅部分本地证据:三段齐段、重复与双
|
|||||||
### 2026-09-16 五项整改最终测试证据索引
|
### 2026-09-16 五项整改最终测试证据索引
|
||||||
|
|
||||||
TC-RC-20260916-01~12按[测试交付记录](release-20260916-test-completion.md)逐项区分真实目标链路、隔离PG故障验证及未执行场景,不能整组标全部通过。两段/三段/四段、补发成功/失败退款、20HTTP通知及9CMPP目标、503/超时自动恢复、无客户回执目标和无租户通道测试均有真实证据;全部逐断点退出/72小时/历史组合/旧版恢复/等负载性能对照未完成。TC-RC-13修复前真实TCP复现,修复后5000次即时响应及测试环境新增6条18分段无补发通过。五项运营页面检查包含三尺寸、请求失败保留真实快照与恢复、恢复告警不自动消失及人工清除审计。详细计数与测试资源收尾以交付记录为准。
|
TC-RC-20260916-01~12按[测试交付记录](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-01~14:真实隔离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扩展号码及最终客户收取未执行,不沿用此前测试发送授权。
|
||||||
|
|||||||
@@ -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:01~04: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+隔离Redis;Edge/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只读事务(8~15秒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和线上故障恢复仍未验证。
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 },
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
|||||||
@@ -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));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user