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