3 Commits
Author SHA1 Message Date
hectorzhao 5e4d644788 feat: 重构首页回执营业统计并增加企业返还金额
CSS quality / css-quality (push) Has been cancelled
2026-09-17 13:11:35 +08:00
hectorzhao 627fa7ec97 fix: 固定跨午夜日报刷新基准并补充验收记录 2026-09-17 11:14:21 +08:00
hectorzhao 572290308c fix: 修复上行归属并实现签名质量日报优化 2026-09-17 11:12:58 +08:00
64 changed files with 4547 additions and 1009 deletions
@@ -0,0 +1,130 @@
-- CreateTable
CREATE TABLE "SignatureAnalyticsGeneration" (
"id" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"sourceAsOf" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SignatureAnalyticsGeneration_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SignatureAnalyticsDay" (
"businessDate" DATE NOT NULL,
"publishedGenerationId" TEXT,
"generatedAt" TIMESTAMP(3),
"sourceAsOf" TIMESTAMP(3),
"refreshFor" DATE,
"state" TEXT NOT NULL DEFAULT 'missing',
"error" TEXT,
"provenance" TEXT NOT NULL DEFAULT 'daily',
"schemaVersion" INTEGER NOT NULL DEFAULT 1,
"rowCounts" JSONB,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SignatureAnalyticsDay_pkey" PRIMARY KEY ("businessDate")
);
-- CreateTable
CREATE TABLE "SignatureAnalyticsRun" (
"id" TEXT NOT NULL,
"scope" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"refreshFor" DATE NOT NULL,
"generationId" TEXT NOT NULL,
"state" TEXT NOT NULL DEFAULT 'pending',
"owner" TEXT,
"fence" INTEGER NOT NULL DEFAULT 0,
"leaseUntil" TIMESTAMP(3),
"attempt" INTEGER NOT NULL DEFAULT 0,
"nextAttemptAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"checkpoint" JSONB,
"error" TEXT,
"startedAt" TIMESTAMP(3),
"finishedAt" TIMESTAMP(3),
CONSTRAINT "SignatureAnalyticsRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SignatureQualityDaily" (
"generationId" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"signatureId" TEXT NOT NULL,
"signatureName" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"tenantName" TEXT NOT NULL,
"applicationNames" TEXT NOT NULL,
"total" INTEGER NOT NULL,
"payload" JSONB NOT NULL,
CONSTRAINT "SignatureQualityDaily_pkey" PRIMARY KEY ("generationId","signatureId")
);
-- CreateTable
CREATE TABLE "SignatureActivityDaily" (
"generationId" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"dimensionKey" TEXT NOT NULL,
"dimensionType" TEXT NOT NULL,
"signatureId" TEXT NOT NULL,
"channelKey" TEXT NOT NULL,
"carrier" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT,
"signatureName" TEXT NOT NULL,
"tenantName" TEXT NOT NULL,
"applicationName" TEXT NOT NULL,
"channelName" TEXT NOT NULL,
"approvedAt" TIMESTAMP(3),
"submittedAttempts" INTEGER NOT NULL,
"acceptedBusinessCount" INTEGER NOT NULL,
"deliveredBusinessCount" INTEGER NOT NULL,
"applicability" TEXT NOT NULL,
CONSTRAINT "SignatureActivityDaily_pkey" PRIMARY KEY ("generationId","dimensionKey")
);
-- CreateTable
CREATE TABLE "UnreportedSignatureDaily" (
"generationId" TEXT NOT NULL,
"businessDate" DATE NOT NULL,
"dimensionKey" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"applicationId" TEXT NOT NULL,
"signatureName" TEXT NOT NULL,
"tenantName" TEXT NOT NULL,
"applicationName" TEXT NOT NULL,
"messageCount" INTEGER NOT NULL,
CONSTRAINT "UnreportedSignatureDaily_pkey" PRIMARY KEY ("generationId","dimensionKey")
);
-- CreateIndex
CREATE UNIQUE INDEX "SignatureAnalyticsGeneration_id_businessDate_key" ON "SignatureAnalyticsGeneration"("id", "businessDate");
-- CreateIndex
CREATE INDEX "SignatureAnalyticsRun_state_nextAttemptAt_idx" ON "SignatureAnalyticsRun"("state", "nextAttemptAt");
-- CreateIndex
CREATE UNIQUE INDEX "SignatureAnalyticsRun_scope_businessDate_key" ON "SignatureAnalyticsRun"("scope", "businessDate");
-- CreateIndex
CREATE INDEX "SignatureQualityDaily_businessDate_generationId_total_idx" ON "SignatureQualityDaily"("businessDate", "generationId", "total");
-- CreateIndex
CREATE INDEX "SignatureActivityDaily_businessDate_generationId_dimensionT_idx" ON "SignatureActivityDaily"("businessDate", "generationId", "dimensionType", "acceptedBusinessCount");
-- CreateIndex
CREATE INDEX "UnreportedSignatureDaily_businessDate_generationId_messageC_idx" ON "UnreportedSignatureDaily"("businessDate", "generationId", "messageCount");
-- AddForeignKey
ALTER TABLE "SignatureAnalyticsDay" ADD CONSTRAINT "SignatureAnalyticsDay_publishedGenerationId_businessDate_fkey" FOREIGN KEY ("publishedGenerationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SignatureQualityDaily" ADD CONSTRAINT "SignatureQualityDaily_generationId_businessDate_fkey" FOREIGN KEY ("generationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SignatureActivityDaily" ADD CONSTRAINT "SignatureActivityDaily_generationId_businessDate_fkey" FOREIGN KEY ("generationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UnreportedSignatureDaily" ADD CONSTRAINT "UnreportedSignatureDaily_generationId_businessDate_fkey" FOREIGN KEY ("generationId", "businessDate") REFERENCES "SignatureAnalyticsGeneration"("id", "businessDate") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,4 @@
-- Expression index matches the actual report/retirement time predicate, including legacy NULL submittedAt.
-- Deliberately outside a transaction: online construction must not block SMS writes.
CREATE INDEX CONCURRENTLY "SmsSubmitRecord_effective_at_idx"
ON "SmsSubmitRecord" ((COALESCE("submittedAt", "createdAt")));
@@ -0,0 +1,24 @@
CREATE TABLE "HomeProjectionState" (id TEXT PRIMARY KEY, version INTEGER NOT NULL DEFAULT 0, "seededDay" TEXT, initialized BOOLEAN NOT NULL DEFAULT false, "lastError" TEXT, "updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE "HomeProjectionDirty" ("messageRecordId" TEXT PRIMARY KEY,"enqueuedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP);
CREATE TABLE "HomeMessageFact" ("messageRecordId" TEXT NOT NULL,"fromVersion" INTEGER NOT NULL,"toVersion" INTEGER,"queuedDay" TEXT NOT NULL,payload JSONB NOT NULL,PRIMARY KEY("messageRecordId","fromVersion"));
CREATE INDEX "HomeMessageFact_queuedDay_toVersion_idx" ON "HomeMessageFact"("queuedDay","toVersion");
CREATE TABLE "HomeSnapshot" (id TEXT PRIMARY KEY,"userId" TEXT NOT NULL,"businessDate" TEXT NOT NULL,version INTEGER NOT NULL,"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,"expiresAt" TIMESTAMP(3) NOT NULL,summary JSONB NOT NULL);
CREATE INDEX "HomeSnapshot_expiresAt_idx" ON "HomeSnapshot"("expiresAt");
INSERT INTO "HomeProjectionState"(id) VALUES ('home');
-- A durable, transactional invalidation only: no business state or external effects.
CREATE FUNCTION home_mark_dirty() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE mid TEXT;
BEGIN
IF TG_TABLE_NAME = 'SmsMessageRecord' THEN mid := COALESCE(NEW.id,OLD.id);
ELSIF TG_TABLE_NAME = 'UpstreamReceiptInbox' THEN mid := COALESCE(NEW."matchedMessageRecordId",OLD."matchedMessageRecordId");
ELSE mid := COALESCE(NEW."messageRecordId",OLD."messageRecordId"); END IF;
IF mid IS NOT NULL THEN
INSERT INTO "HomeProjectionDirty"("messageRecordId") VALUES(mid) ON CONFLICT ("messageRecordId") DO UPDATE SET "enqueuedAt"=CURRENT_TIMESTAMP;
END IF;
RETURN NULL;
END $$;
CREATE TRIGGER home_message_dirty AFTER INSERT OR UPDATE OR DELETE ON "SmsMessageRecord" FOR EACH ROW EXECUTE FUNCTION home_mark_dirty();
CREATE TRIGGER home_submit_dirty AFTER INSERT OR UPDATE OR DELETE ON "SmsSubmitRecord" FOR EACH ROW EXECUTE FUNCTION home_mark_dirty();
CREATE TRIGGER home_segment_dirty AFTER INSERT OR UPDATE OR DELETE ON "SmsMessageSegmentAudit" FOR EACH ROW EXECUTE FUNCTION home_mark_dirty();
CREATE TRIGGER home_inbox_dirty AFTER INSERT OR UPDATE OR DELETE ON "UpstreamReceiptInbox" FOR EACH ROW EXECUTE FUNCTION home_mark_dirty();
CREATE TRIGGER home_receipt_dirty AFTER INSERT OR UPDATE OR DELETE ON "SmsReceiptRecord" FOR EACH ROW EXECUTE FUNCTION home_mark_dirty();
@@ -0,0 +1,24 @@
-- Re-association invalidates both owners; source writes and the durable invalidation are atomic.
CREATE OR REPLACE FUNCTION home_mark_dirty() RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE mid TEXT; previous_mid TEXT;
BEGIN
IF TG_TABLE_NAME = 'SmsMessageRecord' THEN
mid := COALESCE(NEW.id,OLD.id); previous_mid := OLD.id;
ELSIF TG_TABLE_NAME = 'UpstreamReceiptInbox' THEN
mid := COALESCE(NEW."matchedMessageRecordId",OLD."matchedMessageRecordId"); previous_mid := OLD."matchedMessageRecordId";
ELSE
mid := COALESCE(NEW."messageRecordId",OLD."messageRecordId"); previous_mid := OLD."messageRecordId";
END IF;
IF mid IS NOT NULL THEN
INSERT INTO "HomeProjectionDirty"("messageRecordId") VALUES(mid)
ON CONFLICT ("messageRecordId") DO UPDATE SET "enqueuedAt"=CURRENT_TIMESTAMP;
END IF;
IF previous_mid IS NOT NULL AND previous_mid IS DISTINCT FROM mid THEN
INSERT INTO "HomeProjectionDirty"("messageRecordId") VALUES(previous_mid)
ON CONFLICT ("messageRecordId") DO UPDATE SET "enqueuedAt"=CURRENT_TIMESTAMP;
END IF;
RETURN NULL;
END $$;
CREATE INDEX "HomeProjectionDirty_enqueuedAt_idx" ON "HomeProjectionDirty"("enqueuedAt");
CREATE INDEX "HomeMessageFact_toVersion_idx" ON "HomeMessageFact"("toVersion");
CREATE UNIQUE INDEX "HomeMessageFact_current_key" ON "HomeMessageFact"("messageRecordId") WHERE "toVersion" IS NULL;
+135
View File
@@ -2896,3 +2896,138 @@ 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])
}
model HomeProjectionState {
id String @id
version Int @default(0)
seededDay String?
initialized Boolean @default(false)
lastError String?
updatedAt DateTime @default(now())
}
model HomeProjectionDirty {
messageRecordId String @id
enqueuedAt DateTime @default(now())
@@index([enqueuedAt])
}
model HomeMessageFact {
messageRecordId String
fromVersion Int
toVersion Int?
queuedDay String
payload Json
@@id([messageRecordId, fromVersion])
@@index([queuedDay, toVersion])
@@index([toVersion])
}
model HomeSnapshot {
id String @id
userId String
businessDate String
version Int
createdAt DateTime @default(now())
expiresAt DateTime
summary Json
@@index([expiresAt])
}
+4
View File
@@ -1,3 +1,5 @@
import { SignatureAnalyticsModule } from './signature-analytics/signature-analytics.module';
import { HomeModule } from './home-dashboard/home.module';
import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { AuditModule } from './audit/audit.module';
@@ -57,6 +59,8 @@ import { SendingMonitorModule } from './sending-monitor/sending-monitor.module';
InfrastructureMonitoringModule,
OpenApiModule,
SignatureRetirementModule,
SignatureAnalyticsModule,
HomeModule,
SecurityDetectionModule,
MetricsModule,
ReportNotificationsModule,
+108
View File
@@ -0,0 +1,108 @@
import { homeFact, safeMoney, type HomeAttempt, type HomeEvent } from './home-fact';
const at = (day: number, hour = 1) => new Date(`2026-09-${day}T${String(hour).padStart(2, '0')}:00:00+08:00`);
const attempt = (statuses: string[], total = 3): HomeAttempt => ({
id: 'a',
accepted: true,
costUnitPrice: 300n,
gatewayId: 'g1',
segments: statuses.map((status, i) => ({ index: i + 1, total, gatewayId: `g${i + 1}`, status, inferred: false })),
});
const event = (gatewayId: string, status: string, day = 17): HomeEvent => ({
attemptId: 'a',
gatewayId,
status,
at: at(day),
approximate: false,
});
const message = { billingUnits: 3, unitPrice: 500n, status: 'delivered' };
describe('homepage business receipt projection', () => {
it.each([1, 2, 3, 4])('recognizes only a complete %i-fragment attempt', (size) => {
const a = attempt(Array(size).fill('delivered'), size);
const events = Array.from({ length: size }, (_, i) => event(`g${i + 1}`, 'delivered'));
const result = homeFact({ ...message, billingUnits: size }, [a], events);
expect(result.successDay).toBe('2026-09-17');
expect(result.revenue).toBe(String(size * 500));
});
it('includes paid successful fragments of prior failed attempts once', () => {
const earlier = attempt(['delivered', 'failed', 'failed']);
const final = { ...attempt(['delivered', 'delivered', 'delivered']), id: 'b' };
const result = homeFact(
message,
[earlier, final],
[
event('g1', 'delivered'),
event('g2', 'failed'),
event('g3', 'failed'),
...[1, 2, 3].map((n) => ({ ...event(`g${n}`, 'delivered'), attemptId: 'b' })),
],
);
expect(result.units).toBe(3);
expect(result.revenue).toBe('1500');
expect(result.cost).toBe('1200');
});
it('accounts for all expected units when only one failure arrives', () => {
const f = homeFact({ ...message, status: 'failed' }, [attempt(['failed'])], [event('g1', 'failed')]);
expect(f.units).toBe(3);
expect(f.successDay).toBeNull();
expect(f.receiptDays).toEqual(['2026-09-17']);
});
it('rejects partial success and missing parts even if message says delivered', () => {
const f = homeFact(
message,
[attempt(['delivered', 'delivered'])],
[event('g1', 'delivered'), event('g2', 'delivered')],
);
expect(f.successDay).toBeNull();
expect(f.incomplete).toBe(true);
});
it('attributes whole success to the last required arrival and ignores duplicate packets', () => {
const f = homeFact(
message,
[attempt(['delivered', 'delivered', 'delivered'])],
[
event('g1', 'delivered', 16),
event('g2', 'delivered', 16),
event('g3', 'delivered'),
event('g3', 'delivered', 18),
],
);
expect(f.successDay).toBe('2026-09-17');
expect(f.receiptDays).toEqual(['2026-09-16', '2026-09-17']);
expect(f.revenue).toBe('1500');
expect(f.cost).toBe('900');
});
it('does not combine different attempts into a complete message', () => {
const f = homeFact(
message,
[attempt(['delivered']), { ...attempt(['delivered', 'delivered']), id: 'b' }],
[event('g1', 'delivered'), { ...event('g2', 'delivered'), attemptId: 'b' }],
);
expect(f.successDay).toBeNull();
});
it('allows an explicit contractual whole-message receipt only for inferred parts', () => {
const a = attempt(['delivered', 'delivered', 'delivered']);
a.segments[1].inferred = a.segments[2].inferred = true;
expect(homeFact(message, [a], [event('g1', 'delivered')]).successDay).toBe('2026-09-17');
a.segments[1].inferred = false;
expect(homeFact(message, [a], [event('g1', 'delivered')]).successDay).toBeNull();
});
it('supports auditable legacy whole receipts and flags approximate/unmatched evidence', () => {
const f = homeFact(
message,
[{ ...attempt([]), gatewayId: 'g1' }],
[
{ ...event('g1', 'delivered'), approximate: true },
{ ...event('bad', 'failed'), attemptId: null },
],
);
expect(f.successDay).toBe('2026-09-17');
expect(f.approximate).toBe(true);
expect(f.incomplete).toBe(true);
});
it('rejects invalid units and unsafe money without rounding', () => {
expect(homeFact({ ...message, billingUnits: 0 }, [], []).incomplete).toBe(true);
expect(safeMoney('12345')).toBe(12345);
expect(() => safeMoney(9007199254740992n)).toThrow();
});
});
+96
View File
@@ -0,0 +1,96 @@
import { todayKey } from '../signature-analytics/analytics-date';
export type HomeEvent = { attemptId: string | null; gatewayId: string; status: string; at: Date; approximate: boolean };
export type HomeAttempt = {
id: string;
accepted: boolean;
gatewayId: string | null;
costUnitPrice: bigint;
segments: Array<{ index: number; total: number; gatewayId: string | null; status: string | null; inferred: boolean }>;
};
export type HomeFact = {
units: number;
delivered: boolean;
successDay: string | null;
successAt: string | null;
receiptDays: string[];
revenue: string;
cost: string;
approximate: boolean;
incomplete: boolean;
};
/** Pure projection: never writes a message status or invents missing supplier receipts. */
export function homeFact(
message: { billingUnits: number; unitPrice: bigint; status: string },
attempts: HomeAttempt[],
incoming: HomeEvent[],
): HomeFact {
const units = Number.isInteger(message.billingUnits) && message.billingUnits > 0 ? message.billingUnits : 0;
const events = new Map<string, HomeEvent>();
for (const event of [...incoming].sort((a, b) => a.at.getTime() - b.at.getTime())) {
if (!event.attemptId || !Number.isFinite(event.at.getTime())) continue;
const key = JSON.stringify([event.attemptId, event.gatewayId, event.status]);
if (!events.has(key)) events.set(key, event);
}
const successes: Date[] = [];
let cost = 0n;
let incomplete = !units || incoming.some((e) => !e.attemptId);
for (const attempt of attempts) {
if (!attempt.accepted) continue;
const receipts = [...events.values()].filter((e) => e.attemptId === attempt.id);
const successFor = (id: string | null) =>
receipts.find((e) => id && e.gatewayId === id && e.status === 'delivered');
if (!attempt.segments.length) {
const success = successFor(attempt.gatewayId);
if (success && units) {
successes.push(success.at);
cost += BigInt(units) * attempt.costUnitPrice;
} else if (receipts.length) incomplete = true;
continue;
}
const parts = new Map(attempt.segments.map((s) => [s.index, s]));
const expected = Math.max(...attempt.segments.map((s) => s.total));
const times: Date[] = [];
for (const part of parts.values()) {
// A contractual message-level receipt can account for the explicitly inferred parts only.
const received =
successFor(part.gatewayId) ?? (part.inferred ? receipts.find((e) => e.status === 'delivered') : undefined);
if (part.status === 'delivered' && received) {
times.push(received.at);
cost += attempt.costUnitPrice;
}
}
const complete =
expected > 0 &&
parts.size === expected &&
Array.from({ length: expected }, (_, i) => i + 1).every((i) => parts.has(i));
if (complete && times.length === expected) successes.push(new Date(Math.max(...times.map((t) => t.getTime()))));
if (!complete) incomplete = true;
}
const success =
message.status === 'delivered' && successes.length
? new Date(Math.min(...successes.map((s) => s.getTime())))
: null;
if (message.status === 'delivered' && !success) incomplete = true;
const effective = [...events.values()].filter((e) => !success || e.at <= success);
return {
units,
delivered: message.status === 'delivered',
successDay: success ? todayKey(success) : null,
successAt: success?.toISOString() ?? null,
receiptDays: [...new Set(effective.map((e) => todayKey(e.at)))].sort(),
revenue: success ? (BigInt(units) * message.unitPrice).toString() : '0',
cost: success ? cost.toString() : '0',
approximate: effective.some((e) => e.approximate),
incomplete,
};
}
export function safeMoney(value: bigint | string | number) {
const integer = BigInt(value);
if (integer > BigInt(Number.MAX_SAFE_INTEGER) || integer < BigInt(Number.MIN_SAFE_INTEGER))
throw new Error('金额超过安全展示范围');
return Number(integer);
}
export const percentage = (value: number, total: number) => (total ? Number(((value / total) * 100).toFixed(1)) : 0);
+97
View File
@@ -0,0 +1,97 @@
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { addDays, startOfDay, todayKey } from '../signature-analytics/analytics-date';
import { sourceFacts } from './home-source';
import { pruneHomeVersions } from './home-retention';
@Injectable()
export class HomeProjection implements OnModuleInit, OnModuleDestroy {
private timer?: NodeJS.Timeout;
private readonly logger = new Logger(HomeProjection.name);
constructor(private readonly db: PrismaService) {}
onModuleInit() {
if (
process.env.NODE_ENV === 'test' ||
process.env.HOME_DASHBOARD_ENABLED === 'false' ||
(process.env.CMPP_PROCESS_ROLE && process.env.CMPP_PROCESS_ROLE !== 'api')
)
return;
this.timer = setInterval(() => void this.run(), 10_000);
this.timer.unref();
void this.run();
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
private async run() {
try {
await this.tick();
} catch (error) {
this.logger.error('首页统计投影失败', error instanceof Error ? error.stack : String(error));
await this.db.homeProjectionState
.update({ where: { id: 'home' }, data: { lastError: '统计更新失败,等待重试' } })
.catch(() => undefined);
}
}
async tick(now = new Date()) {
return this.db.$transaction(
async (tx) => {
const [lock] = await tx.$queryRaw<
Array<{ locked: boolean }>
>`SELECT pg_try_advisory_xact_lock(17100917) AS locked`;
if (!lock.locked) return { busy: true };
const date = todayKey(now),
first = addDays(date, -3);
const state = await tx.homeProjectionState.findUniqueOrThrow({ where: { id: 'home' } });
if (state.seededDay !== date) {
await tx.$executeRaw`INSERT INTO "HomeProjectionDirty"("messageRecordId") SELECT id FROM "SmsMessageRecord"
WHERE "queuedAt">=${startOfDay(first)} AND "queuedAt"<${startOfDay(addDays(date, 1))} ON CONFLICT DO NOTHING`;
}
const work = await tx.$queryRaw<
Array<{ messageRecordId: string }>
>`SELECT "messageRecordId" FROM "HomeProjectionDirty" ORDER BY "enqueuedAt","messageRecordId" LIMIT 500 FOR UPDATE SKIP LOCKED`;
const ids = work.map((w) => w.messageRecordId),
version = state.version + 1;
const sources = await sourceFacts(tx, ids);
const prior = await tx.homeMessageFact.findMany({ where: { messageRecordId: { in: ids }, toVersion: null } });
for (const id of ids) {
const next = sources.find((s) => s.message.id === id);
const old = prior.find((p) => p.messageRecordId === id);
const day = next ? todayKey(next.message.queuedAt) : '';
const payload = next && day >= first && day <= date ? next.fact : null;
if (old && old.queuedDay === day && JSON.stringify(old.payload) === JSON.stringify(payload)) continue;
if (old)
await tx.homeMessageFact.update({
where: { messageRecordId_fromVersion: { messageRecordId: id, fromVersion: old.fromVersion } },
data: { toVersion: version },
});
if (payload)
await tx.homeMessageFact.create({
data: {
messageRecordId: id,
fromVersion: version,
queuedDay: day,
payload: payload as unknown as Prisma.InputJsonValue,
},
});
}
await tx.homeProjectionDirty.deleteMany({ where: { messageRecordId: { in: ids } } });
const remaining = await tx.homeProjectionDirty.count();
await tx.homeProjectionState.update({
where: { id: 'home' },
data: {
version,
seededDay: date,
initialized: (state.seededDay === date && state.initialized) || remaining === 0,
updatedAt: now,
lastError: null,
},
});
if (remaining === 0) await pruneHomeVersions(tx, now, addDays(first, -1), version);
return { remaining, version };
},
{ timeout: 60_000, isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
);
}
}
+105
View File
@@ -0,0 +1,105 @@
import { Prisma } from '@prisma/client';
import { addDays, startOfDay } from '../signature-analytics/analytics-date';
import { downstreamAlertWindows, stalledPendingWhere } from '../operations/operations.helpers';
import { percentage, safeMoney } from './home-fact';
type MetricRow = {
queuedDay: string;
total: bigint;
delivered: bigint;
units: bigint;
successUnits: bigint;
successMessages: bigint;
revenue: bigint;
cost: bigint;
approximate: bigint;
incomplete: bigint;
};
export function metrics(row?: MetricRow) {
const total = Number(row?.total ?? 0),
delivered = Number(row?.delivered ?? 0);
const units = Number(row?.units ?? 0),
success = Number(row?.successUnits ?? 0);
const revenue = safeMoney(row?.revenue ?? 0n),
cost = safeMoney(row?.cost ?? 0n);
return {
sent: total,
delivered: Number(row?.successMessages ?? 0),
successRate: percentage(delivered, total),
receiptUnits: units,
successUnits: success,
receiptSuccessRate: percentage(success, units),
revenueCents: revenue,
profitCents: revenue - cost,
profitRate: percentage(revenue - cost, revenue),
approximate: Number(row?.approximate ?? 0),
incomplete: Number(row?.incomplete ?? 0),
};
}
export async function aggregateHome(tx: Prisma.TransactionClient, date: string, version: number, grouped = false) {
return tx.$queryRaw<MetricRow[]>(Prisma.sql`
SELECT ${grouped ? Prisma.sql`"queuedDay"` : Prisma.sql`''::text`} AS "queuedDay",
COUNT(*) FILTER (WHERE "queuedDay"=${date}) AS total,
COUNT(*) FILTER (WHERE "queuedDay"=${date} AND (payload->>'delivered')::boolean) AS delivered,
COALESCE(SUM((payload->>'units')::bigint) FILTER (WHERE jsonb_exists(payload->'receiptDays',${date})),0)::bigint AS units,
COALESCE(SUM((payload->>'units')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS "successUnits",
COUNT(*) FILTER (WHERE "queuedDay"=${date} AND payload->>'successDay'=${date}) AS "successMessages",
COALESCE(SUM((payload->>'revenue')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS revenue,
COALESCE(SUM((payload->>'cost')::bigint) FILTER (WHERE payload->>'successDay'=${date}),0)::bigint AS cost,
COUNT(*) FILTER (WHERE (payload->>'approximate')::boolean) AS approximate,
COUNT(*) FILTER (WHERE (payload->>'incomplete')::boolean) AS incomplete
FROM "HomeMessageFact" WHERE "queuedDay">=${addDays(date, -3)} AND "queuedDay"<=${date}
AND "fromVersion"<=${version} AND ("toVersion" IS NULL OR "toVersion">${version})
${grouped ? Prisma.sql`GROUP BY "queuedDay"` : Prisma.empty}`);
}
export async function enterpriseRanks(tx: Prisma.TransactionClient, date: string) {
const start = startOfDay(date),
end = startOfDay(addDays(date, 1));
const rows = await tx.$queryRaw<
Array<{
tenantId: string;
tenantName: string;
todaySpendCents: bigint;
todayReturnedCents: bigint;
balanceCents: bigint;
creditCents: bigint;
}>
>`
WITH spend AS (SELECT "tenantId",SUM("amountCents")::bigint amount FROM "SmsBillingRecord"
WHERE "createdAt">=${start} AND "createdAt"<${end} AND "billingStatus"='charged' GROUP BY "tenantId"),
returned AS (SELECT "tenantId",SUM("amountCents")::bigint amount FROM "AccountTransaction"
WHERE "createdAt">=${start} AND "createdAt"<${end} AND ("transactionType"='refunded' OR ("transactionType"='released' AND "relatedType"='sms_message_record')) GROUP BY "tenantId")
SELECT t.id AS "tenantId",t.name AS "tenantName",COALESCE(s.amount,0)::bigint AS "todaySpendCents",
COALESCE(r.amount,0)::bigint AS "todayReturnedCents",a."balanceCents",a."creditCents"
FROM "TenantAccount" a JOIN "Tenant" t ON t.id=a."tenantId" LEFT JOIN spend s ON s."tenantId"=t.id LEFT JOIN returned r ON r."tenantId"=t.id
WHERE t.status<>'deleted' ORDER BY "todaySpendCents" DESC,t.name,t.id`;
return rows.map((r) => ({
...r,
todaySpendCents: safeMoney(r.todaySpendCents),
todayReturnedCents: safeMoney(r.todayReturnedCents),
balanceCents: safeMoney(r.balanceCents),
creditCents: safeMoney(r.creditCents),
}));
}
export async function operationStatus(tx: Prisma.TransactionClient) {
const window = downstreamAlertWindows();
const [enterpriseCertifications, smsAudits, templates, signatures, drainageInfos, taskCount, stalled, ack, failed] =
await Promise.all([
tx.enterpriseCertification.count({ where: { status: 'pending' } }),
tx.smsSendTask.count({ where: { status: 'pending_review' } }),
tx.smsTemplate.count({ where: { auditStatus: 'pending' } }),
tx.smsSignature.count({ where: { auditStatus: 'pending' } }),
tx.smsDrainageInfo.count({ where: { auditStatus: 'pending' } }),
tx.smsBatchTask.count(),
tx.cmppDownstreamDelivery.count({ where: stalledPendingWhere(window.stalledPendingAt) }),
tx.cmppDownstreamDelivery.count({ where: { status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } } }),
tx.cmppDownstreamDelivery.count({
where: { status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } },
}),
]);
return {
taskCount,
pendingAudits: { enterpriseCertifications, smsAudits, templates, signatures, drainageInfos },
downstreamDeliverySummary: { alertCount: stalled + ack + failed },
};
}
+14
View File
@@ -0,0 +1,14 @@
import { Prisma } from '@prisma/client';
/** Only disposable dashboard projections; never source SMS, receipt or accounting records. */
export async function pruneHomeVersions(tx: Prisma.TransactionClient, now: Date, firstDay: string, version: number) {
const active = await tx.homeSnapshot.aggregate({ where: { expiresAt: { gt: now } }, _min: { version: true } });
const minimum = active._min.version ?? version;
await tx.$executeRaw`DELETE FROM "HomeMessageFact" WHERE ("messageRecordId","fromVersion") IN
(SELECT "messageRecordId","fromVersion" FROM "HomeMessageFact" WHERE "toVersion"<=${minimum} LIMIT 1000)`;
await tx.$executeRaw`DELETE FROM "HomeMessageFact" WHERE ("messageRecordId","fromVersion") IN
(SELECT "messageRecordId","fromVersion" FROM "HomeMessageFact" WHERE "queuedDay"<${firstDay}
AND "fromVersion"<${minimum} LIMIT 1000)`;
await tx.$executeRaw`DELETE FROM "HomeSnapshot" WHERE id IN (SELECT id FROM "HomeSnapshot"
WHERE "expiresAt"<${new Date(now.getTime() - 86400000)} LIMIT 1000)`;
}
+83
View File
@@ -0,0 +1,83 @@
import { Prisma } from '@prisma/client';
import { homeFact, type HomeEvent } from './home-fact';
export async function sourceFacts(tx: Prisma.TransactionClient, ids: string[]) {
const [messages, inbox, legacy] = await Promise.all([
tx.smsMessageRecord.findMany({
where: { id: { in: ids } },
include: { submitRecords: true, segmentAudits: true },
}),
tx.upstreamReceiptInbox.findMany({ where: { matchedMessageRecordId: { in: ids }, status: 'matched' } }),
tx.smsReceiptRecord.findMany({ where: { messageRecordId: { in: ids } } }),
]);
const ownedReceiptKeys = new Set(
(
await tx.upstreamReceiptInbox.findMany({
where: { receiptKey: { in: legacy.map((r) => r.receiptKey) } },
select: { receiptKey: true },
})
).map((r) => r.receiptKey),
);
return messages.map((message) => {
const parts = (id: string, submitId: string) =>
message.segmentAudits.filter((p) => p.submitRecordId === id || (!p.submitRecordId && p.submitId === submitId));
const candidates = (gatewayId: string, channelId: string | null) =>
message.submitRecords.filter(
(s) =>
s.channelId === channelId &&
(s.gatewayMessageId === gatewayId || parts(s.id, s.submitId).some((p) => p.gatewayMessageId === gatewayId)),
);
const events: HomeEvent[] = inbox
.filter((i) => i.matchedMessageRecordId === message.id)
.map((i) => {
const direct = message.submitRecords.find((s) => s.id === i.matchedSubmitRecordId);
const possible = candidates(i.gatewayMessageId, i.matchedChannelId ?? i.incomingChannelId);
return {
attemptId: direct?.id ?? (possible.length === 1 ? possible[0].id : null),
gatewayId: i.gatewayMessageId,
status: i.receiptStatus,
at: i.gatewayReceivedAt ?? i.receivedAt,
approximate: !i.gatewayReceivedAt,
};
});
for (const r of legacy.filter((r) => r.messageRecordId === message.id)) {
// An Inbox event (including unresolved/re-associated events) is not a legacy fallback.
if (ownedReceiptKeys.has(r.receiptKey)) continue;
const possible = candidates(r.gatewayMessageId, r.channelId);
const attemptId = possible.length === 1 ? possible[0].id : null;
if (
events.some(
(e) => e.attemptId === attemptId && e.gatewayId === r.gatewayMessageId && e.status === r.receiptStatus,
)
)
continue;
events.push({
attemptId,
gatewayId: r.gatewayMessageId,
status: r.receiptStatus,
at: r.createdAt,
approximate: true,
});
}
return {
message,
fact: homeFact(
message,
message.submitRecords.map((s) => ({
id: s.id,
accepted: s.submitStatus === 'accepted',
gatewayId: s.gatewayMessageId,
costUnitPrice: s.costUnitPrice,
segments: parts(s.id, s.submitId).map((p) => ({
index: p.segmentIndex,
total: p.segmentTotal,
gatewayId: p.gatewayMessageId,
status: p.receiptStatus,
inferred: p.compensationType === 'supplier_message_level_receipt',
})),
})),
events,
),
};
});
}
+24
View File
@@ -0,0 +1,24 @@
import { Controller, Get, Module, Query, Req } from '@nestjs/common';
import type { SessionRequest } from '../auth/session-validation.middleware';
import { PrismaModule } from '../prisma/prisma.module';
import { HomeProjection } from './home-projection';
import { HomeService } from './home.service';
@Controller('admin/operations/home')
export class HomeController {
constructor(private readonly home: HomeService) {}
@Get('summary')
async summary(@Req() req: SessionRequest) {
return this.home.summary(await this.home.authorize(req));
}
@Get('receipt-breakdown')
async receipts(@Req() req: SessionRequest, @Query('snapshotToken') token?: string) {
return this.home.breakdown(await this.home.authorize(req), token, 'receipt');
}
@Get('revenue-breakdown')
async revenue(@Req() req: SessionRequest, @Query('snapshotToken') token?: string) {
return this.home.breakdown(await this.home.authorize(req), token, 'revenue');
}
}
@Module({ imports: [PrismaModule], controllers: [HomeController], providers: [HomeService, HomeProjection] })
export class HomeModule {}
+116
View File
@@ -0,0 +1,116 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
ServiceUnavailableException,
} from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import type { SessionRequest } from '../auth/session-validation.middleware';
import { addDays, startOfDay, todayKey } from '../signature-analytics/analytics-date';
import { aggregateHome, enterpriseRanks, metrics, operationStatus } from './home-read';
@Injectable()
export class HomeService {
constructor(private readonly db: PrismaService) {}
async authorize(req: SessionRequest) {
const user =
req.authSession?.portal === 'admin' &&
req.sessionUserId &&
(await this.db.user.findFirst({
where: {
id: req.sessionUserId,
status: 'active',
deletedAt: null,
roles: { some: { role: { code: 'platform_admin' } } },
},
select: { id: true },
}));
if (!user) throw new ForbiddenException('无运营首页查看权限');
return user.id;
}
async summary(userId: string, now = new Date()) {
return this.db.$transaction(
async (tx) => {
// Read committed after this lock: no token may reference an already pruned version.
await tx.$executeRaw`SELECT pg_advisory_xact_lock_shared(17100917)`;
const date = todayKey(now);
const state = await tx.homeProjectionState.findUniqueOrThrow({ where: { id: 'home' } });
if (!state.initialized || state.seededDay !== date)
throw new ServiceUnavailableException('今日统计正在初始化,请稍后刷新');
const [rows, ranks, status, pending, unresolved] = await Promise.all([
aggregateHome(tx, date, state.version),
enterpriseRanks(tx, date),
operationStatus(tx),
tx.homeProjectionDirty.count(),
tx.upstreamReceiptInbox.count({
where: {
status: { not: 'matched' },
receivedAt: { gte: startOfDay(addDays(date, -3)), lt: startOfDay(addDays(date, 1)) },
},
}),
]);
const values = metrics(rows[0]);
const summary = {
businessDate: date,
asOf: now.toISOString(),
dataThrough: state.updatedAt.toISOString(),
definitionVersion: 1,
processing:
pending > 0 ||
unresolved > 0 ||
Boolean(state.lastError) ||
now.getTime() - state.updatedAt.getTime() > 60_000,
timeSourceCoverage: { approximate: values.approximate, incomplete: values.incomplete },
today: values,
enterpriseSpendRanks: ranks,
...status,
};
const snapshot = await tx.homeSnapshot.create({
data: {
id: randomUUID(),
userId,
businessDate: date,
version: state.version,
createdAt: now,
expiresAt: new Date(now.getTime() + 15 * 60_000),
summary,
},
});
return { ...summary, snapshotToken: snapshot.id };
},
{ isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted, timeout: 30_000 },
);
}
async breakdown(userId: string, token: string | undefined, kind: 'receipt' | 'revenue', now = new Date()) {
if (!token || !/^[0-9a-f-]{36}$/i.test(token)) throw new BadRequestException('统计快照参数无效');
return this.db.$transaction(
async (tx) => {
const snapshot = await tx.homeSnapshot.findUnique({ where: { id: token } });
if (!snapshot || snapshot.userId !== userId) throw new ForbiddenException('统计快照不可访问');
if (snapshot.expiresAt <= now || snapshot.businessDate !== todayKey(now))
throw new ConflictException('统计快照已过期,请刷新首页后重试');
const rows = await aggregateHome(tx, snapshot.businessDate, snapshot.version, true);
return {
snapshotToken: token,
businessDate: snapshot.businessDate,
items: Array.from({ length: 4 }, (_, offset) => {
const submitDate = addDays(snapshot.businessDate, -offset),
value = metrics(rows.find((r) => r.queuedDay === submitDate));
return kind === 'receipt'
? { submitDate, total: value.receiptUnits, success: value.successUnits, rate: value.receiptSuccessRate }
: {
submitDate,
revenueCents: value.revenueCents,
profitCents: value.profitCents,
rate: value.profitRate,
};
}),
};
},
{ isolationLevel: Prisma.TransactionIsolationLevel.RepeatableRead },
);
}
}
@@ -178,8 +178,8 @@ export class AdminOperationsController {
return this.operations.signatureQuality({
date,
keyword,
page: Number(page),
pageSize: Number(pageSize),
page: page === undefined ? 1 : Number(page),
pageSize: pageSize === undefined ? 25 : Number(pageSize),
});
}
@@ -1,3 +1,4 @@
import { OperationsQualityQueries } from './queries/quality.queries';
import { OperationsService } from './operations.service';
function createPrismaMock() {
@@ -936,10 +937,8 @@ describe('OperationsService', () => {
averageArrivalMs: 1800,
},
]);
const service = new OperationsService(prisma as never);
await expect(
service.signatureQuality({
new OperationsQualityQueries(prisma as never).signatureQualityLive({
date: '2026-07-24',
keyword: '测试',
page: 2,
@@ -987,9 +986,9 @@ describe('OperationsService', () => {
it('does not query channel details when the selected date has no registered signatures', async () => {
const prisma = createPrismaMock();
prisma.$queryRaw.mockResolvedValueOnce([]);
const service = new OperationsService(prisma as never);
await expect(service.signatureQuality({ date: '2026-07-24' })).resolves.toEqual({
await expect(
new OperationsQualityQueries(prisma as never).signatureQualityLive({ date: '2026-07-24' }),
).resolves.toEqual({
date: '2026-07-24',
items: [],
total: 0,
+74 -19
View File
@@ -1,4 +1,6 @@
import { Prisma } from '@prisma/client';
import { SignatureAnalyticsRead } from '../../signature-analytics/analytics-read';
import { analyticsDate, analyticsPage, todayKey } from '../../signature-analytics/analytics-date';
import { PrismaService } from '../../prisma/prisma.service';
import type { SignatureQualityQuery } from '../operations.contracts';
@@ -321,9 +323,30 @@ export class OperationsQualityQueries {
return { date: day.key, summary, channels, signatures, drainageSignatures, applications };
}
async signatureQuality(query: SignatureQualityQuery) {
const date = analyticsDate(query.date);
analyticsPage(query.page, query.pageSize);
if (date !== todayKey()) return new SignatureAnalyticsRead(this.prisma).quality({ ...query, date });
return this.prisma.$transaction(
async (tx) => {
await tx.$executeRawUnsafe("SET LOCAL statement_timeout='12s'");
const result = await new OperationsQualityQueries(tx as PrismaService).signatureQualityLive({ ...query, date });
return {
...result,
dataSource: 'live',
reportState: 'ready',
frozen: false,
sourceAsOf: new Date(),
serverBusinessDate: date,
};
},
{ isolationLevel: 'RepeatableRead', timeout: 15000 },
);
}
async signatureQualityLive(query: SignatureQualityQuery, snapshot = false) {
const day = qualityBusinessDay(query.date);
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(100, positiveInteger(query.pageSize, 25));
const pageSize = snapshot ? 2147483647 : Math.min(100, positiveInteger(query.pageSize, 25));
const keyword = query.keyword?.trim() || null;
const keywordPattern = keyword ? `%${keyword}%` : null;
const summaries = await this.prisma.$queryRaw<
@@ -341,6 +364,8 @@ export class OperationsQualityQueries {
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
rowCount: number;
}>
>(Prisma.sql`
@@ -361,6 +386,15 @@ export class OperationsQualityQueries {
WHERE message."signatureId" IS NOT NULL
AND message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
), dimensions AS (
SELECT signature_id FROM base
UNION
SELECT message."signatureId" FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id=submit."messageRecordId"
WHERE message."signatureId" IS NOT NULL
AND COALESCE(submit."submittedAt",submit."createdAt")>=${day.startAt}
AND COALESCE(submit."submittedAt",submit."createdAt")<${day.endAt}
AND submit."submitStatus" IN ('accepted','rejected','timeout')
)
SELECT
signature.id AS "signatureId",
@@ -368,37 +402,37 @@ export class OperationsQualityQueries {
tenant.id AS "tenantId",
tenant.name AS "tenantName",
STRING_AGG(DISTINCT application.name, '、') FILTER (WHERE application.name IS NOT NULL) AS "applicationNames",
COUNT(*)::integer AS total,
COUNT(*) FILTER (
COUNT(base.signature_id)::integer AS total,
COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
)::integer AS "acceptedCount",
COUNT(*) FILTER (
COUNT(base.signature_id) FILTER (
WHERE base.status = 'submit_failed'
OR base.submit_status IN ('rejected', 'timeout')
)::integer AS "submitFailureCount",
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
COUNT(*) FILTER (
COUNT(base.signature_id) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND NOT (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "unknownCount",
COUNT(*) FILTER (
COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND (COALESCE(base.status IN ('failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "failureCount",
CASE
WHEN COUNT(*) FILTER (
WHEN COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
) = 0 THEN 0
ELSE ROUND(
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
COUNT(base.signature_id) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
* 100.0
/ COUNT(*) FILTER (
/ COUNT(base.signature_id) FILTER (
WHERE COALESCE(base.status, '') <> 'submit_failed'
AND COALESCE(base.submit_status, '') NOT IN ('rejected', 'timeout')
),
@@ -407,10 +441,11 @@ export class OperationsQualityQueries {
END AS "successRate",
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs",
COUNT(*) OVER()::integer AS "rowCount"
FROM base
JOIN "SmsSignature" signature ON signature.id = base.signature_id
FROM dimensions
JOIN "SmsSignature" signature ON signature.id = dimensions.signature_id
LEFT JOIN base ON base.signature_id=signature.id
JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
LEFT JOIN "SmsApplication" application ON application.id = base.application_id
LEFT JOIN "SmsApplication" application ON application.id = COALESCE(base.application_id,signature."applicationId")
WHERE (
${keyword}::text IS NULL
OR signature.name ILIKE ${keywordPattern}
@@ -441,6 +476,8 @@ export class OperationsQualityQueries {
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
}>
>(Prisma.sql`
WITH base AS (
@@ -457,12 +494,12 @@ export class OperationsQualityQueries {
submit."submitStatus" AS submit_status,
receipt."deliveredAt" AS delivered_at,
failed_receipt."failedAt" AS failed_at,
COALESCE(segment_summary.segment_count, 0) AS segment_count,
COALESCE(segment_summary.expected_count, 0) AS segment_count,
COALESCE(segment_summary.delivered_count, 0) AS segment_delivered_count,
COALESCE(segment_summary.failure_count, 0) AS segment_failure_count,
CASE
WHEN segment_summary.segment_count > 0
AND segment_summary.delivered_count = segment_summary.segment_count
AND segment_summary.delivered_count = segment_summary.expected_count
AND segment_summary.completed_at >= COALESCE(submit."submittedAt", submit."createdAt")
THEN EXTRACT(EPOCH FROM (segment_summary.completed_at - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
WHEN segment_summary.segment_count = 0
@@ -474,6 +511,7 @@ export class OperationsQualityQueries {
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN LATERAL (
SELECT
CASE WHEN COUNT(*) > 0 THEN GREATEST(MAX(segment."segmentTotal"), message."billingUnits") ELSE 0 END::integer AS expected_count,
COUNT(*)::integer AS segment_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
@@ -532,6 +570,8 @@ export class OperationsQualityQueries {
1
)::double precision
END AS "successRate",
COALESCE(SUM(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL),0)::double precision AS "arrivalMsSum",
COUNT(arrival_ms) FILTER (WHERE delivery_status = 'success')::integer AS "arrivalSamples",
ROUND(AVG(arrival_ms) FILTER (WHERE delivery_status = 'success' AND arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
FROM classified
GROUP BY signature_id, channel_id, carrier, drainage_state
@@ -548,6 +588,8 @@ export class OperationsQualityQueries {
finalSuccessCount: number;
finalSuccessRate: number;
averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
}>
>(Prisma.sql`
SELECT
@@ -620,6 +662,8 @@ type SignatureSplitRow = {
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
};
function aggregateSignatureRows(rows: SignatureSplitRow[]) {
@@ -632,7 +676,7 @@ function aggregateSignatureRows(rows: SignatureSplitRow[]) {
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
const arrivalWeight = parts.reduce(
(sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount),
(sum, item) => sum + (item.arrivalSamples ?? (item.averageArrivalMs == null ? 0 : item.successCount)),
0,
);
return {
@@ -650,7 +694,10 @@ function aggregateSignatureRows(rows: SignatureSplitRow[]) {
arrivalWeight === 0
? null
: Math.round(
parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight,
parts.reduce(
(sum, item) => sum + (item.arrivalMsSum ?? (item.averageArrivalMs ?? 0) * item.successCount),
0,
) / arrivalWeight,
),
};
})
@@ -676,6 +723,8 @@ type DrainageBreakdownRow = {
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
arrivalMsSum?: number;
arrivalSamples?: number;
};
function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
@@ -688,7 +737,10 @@ function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
const first = parts[0];
const acceptedCount = parts.reduce((sum, item) => sum + item.acceptedCount, 0);
const successCount = parts.reduce((sum, item) => sum + item.successCount, 0);
const arrivalWeight = parts.reduce((sum, item) => sum + (item.averageArrivalMs == null ? 0 : item.successCount), 0);
const arrivalWeight = parts.reduce(
(sum, item) => sum + (item.arrivalSamples ?? (item.averageArrivalMs == null ? 0 : item.successCount)),
0,
);
return {
signatureId: first.signatureId,
channelId: first.channelId,
@@ -705,7 +757,10 @@ function aggregateChannelCarrierRows(rows: DrainageBreakdownRow[]) {
arrivalWeight === 0
? null
: Math.round(
parts.reduce((sum, item) => sum + (item.averageArrivalMs ?? 0) * item.successCount, 0) / arrivalWeight,
parts.reduce(
(sum, item) => sum + (item.arrivalMsSum ?? (item.averageArrivalMs ?? 0) * item.successCount),
0,
) / arrivalWeight,
),
};
});
+18 -10
View File
@@ -298,6 +298,10 @@ function createPrismaMock() {
update: jest.fn().mockResolvedValue({ id: 'candidate-1', status: 'claimed' }),
},
cmppDownstreamDelivery: {
createMany: jest.fn().mockResolvedValue({ count: 1 }),
findUniqueOrThrow: jest
.fn()
.mockResolvedValue({ id: 'delivery-1', messageRecordId: 'record-1', applicationId: 'app-1' }),
create: jest
.fn()
.mockImplementation(({ data }) =>
@@ -4455,6 +4459,7 @@ describe('SendChainService', () => {
it('records ambiguous uplink match candidates for shared access numbers', async () => {
const { service, prisma } = createService();
prisma.smsMessageRecord.findMany.mockResolvedValue([]);
prisma.channelRouteRule.findMany.mockResolvedValue([{ applicationId: 'app-1' }, { applicationId: 'app-2' }]);
prisma.smsApplication.findMany.mockResolvedValue([
{ id: 'app-1', tenantId: 'tenant-1', name: '应用A' },
@@ -4475,7 +4480,7 @@ describe('SendChainService', () => {
tenantId: undefined,
applicationId: undefined,
matchStatus: 'ambiguous',
matchReason: '接入号匹配多个应用',
matchReason: '应用归属仍有多候选或接入号与发送事实冲突',
}),
});
expect(prisma.smsUplinkMatchCandidate.createMany).toHaveBeenCalledWith({
@@ -4517,15 +4522,18 @@ describe('SendChainService', () => {
where: { uplinkMessageId: 'uplink-1', id: { not: 'candidate-1' }, status: 'pending' },
data: { status: 'rejected' },
});
expect(prisma.cmppDownstreamDelivery.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
applicationId: 'app-1',
messageRecordId: 'record-1',
messageId: 'MSG-1',
deliveryType: 'uplink',
status: 'pending',
}),
expect(prisma.cmppDownstreamDelivery.createMany).toHaveBeenCalledWith({
data: [
expect.objectContaining({
tenantId: 'tenant-1',
applicationId: 'app-1',
messageRecordId: 'record-1',
messageId: 'MSG-1',
deliveryType: 'uplink',
status: 'pending',
}),
],
skipDuplicates: true,
});
});
+1
View File
@@ -698,6 +698,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
tenantId?: string;
applicationId?: string;
messageRecordId?: string;
messageId?: string;
matchStatus: string;
matchReason: string;
candidates: UplinkMatchCandidateInput[];
+38 -9
View File
@@ -1,7 +1,21 @@
import { BillingService } from '../billing/billing.service';
import type { OpenApiService } from '../open-api/open-api.service';
import { PrismaService } from '../prisma/prisma.service';
import type { GatewaySubmitResultDto, GatewaySubmitSegmentResultDto, GatewayReceiptEventDto, GatewayUplinkEventDto, UplinkMatchCandidateInput, GatewayPendingDeliveryQueryDto, GatewayDownstreamSentDto, GatewayDownstreamAcknowledgedDto, GatewayDownstreamFailureType, GatewaySubmitDeadLetterDto, RequeueGatewaySubmitExceptionDto, GatewayDownstreamRecoveryStatusDto, TimeoutUnknownDto } from './send-chain.contracts';
import type {
GatewaySubmitResultDto,
GatewaySubmitSegmentResultDto,
GatewayReceiptEventDto,
GatewayUplinkEventDto,
UplinkMatchCandidateInput,
GatewayPendingDeliveryQueryDto,
GatewayDownstreamSentDto,
GatewayDownstreamAcknowledgedDto,
GatewayDownstreamFailureType,
GatewaySubmitDeadLetterDto,
RequeueGatewaySubmitExceptionDto,
GatewayDownstreamRecoveryStatusDto,
TimeoutUnknownDto,
} from './send-chain.contracts';
import { downstreamPendingTimeoutHours } from './send-chain.helpers';
import type { SendSubmissionService } from './send-submission.service';
import { SendAccountingService } from './send-accounting.service';
@@ -13,7 +27,6 @@ import { SendRetryService } from './send-retry.service';
import { SendTimeoutService } from './send-timeout.service';
import type { DownstreamDeliveryQueueRequest } from './downstream-receipt-targets';
export type SendCompletionCallbacks = Record<string, never>;
export type SendCompletionFacade = SendCompletionService & SendSubmissionService;
@@ -49,10 +62,7 @@ export class SendCompletionService {
return this.gatewayResult.handleSubmitSegmentResult(data);
}
async resolveSubmitRecordForGatewaySegmentResult(
messageRecordId: string,
data: GatewaySubmitSegmentResultDto,
) {
async resolveSubmitRecordForGatewaySegmentResult(messageRecordId: string, data: GatewaySubmitSegmentResultDto) {
return this.gatewayResult.resolveSubmitRecordForGatewaySegmentResult(messageRecordId, data);
}
@@ -109,7 +119,13 @@ export class SendCompletionService {
async handleReceipt(
data: GatewayReceiptEventDto,
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
incomingIdentity?: {
account: string;
gatewayHost: string;
gatewayPort: number;
protocol: string;
cmppVersion: string;
},
) {
return this.receipt.handleReceipt(data, incomingIdentity);
}
@@ -145,7 +161,13 @@ export class SendCompletionService {
async resolveReceiptMessage(
data: GatewayReceiptEventDto,
incomingIdentity?: { account: string; gatewayHost: string; gatewayPort: number; protocol: string; cmppVersion: string },
incomingIdentity?: {
account: string;
gatewayHost: string;
gatewayPort: number;
protocol: string;
cmppVersion: string;
},
) {
return this.receipt.resolveReceiptMessage(data, incomingIdentity);
}
@@ -206,7 +228,13 @@ export class SendCompletionService {
}
async releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
message: {
tenantId: string;
batchTaskId: string;
messageId: string;
amountCents: number | bigint;
billingUnits: number;
},
remark: string,
) {
return this.accounting.releaseMessageReservation(message, remark);
@@ -279,6 +307,7 @@ export class SendCompletionService {
tenantId?: string;
applicationId?: string;
messageRecordId?: string;
messageId?: string;
matchStatus: string;
matchReason: string;
candidates: UplinkMatchCandidateInput[];
@@ -1,4 +1,5 @@
import { completionContext } from './completion-context';
import { resolveUplinkMatch } from './uplink-matching';
import { BadRequestException, Logger, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { createHash, randomUUID } from 'node:crypto';
@@ -32,7 +33,19 @@ export class SendDownstreamDeliveryService {
) {}
async handleUplink(data: GatewayUplinkEventDto) {
if (!completionContext.getStore()) {
return this.prisma.$transaction(
(tx) => completionContext.run({ tx, messageRecordId: '' }, () => this.persistUplink(data)),
{ timeout: 15_000 },
);
}
return this.persistUplink(data);
}
private async persistUplink(data: GatewayUplinkEventDto) {
if (data.eventId) {
await this.prisma
.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`uplink-event:${data.eventId}`},0))`;
const existing = await this.prisma.smsUplinkMessage.findUnique({ where: { eventId: data.eventId } });
if (existing) return existing;
}
@@ -48,7 +61,7 @@ export class SendDownstreamDeliveryService {
applicationId: match.applicationId,
messageRecordId: match.messageRecordId,
channelId: data.channelId,
messageId: data.messageId,
messageId: match.messageId,
gatewayMessageId: data.gatewayMessageId,
sequenceId: data.sequenceId,
phoneNumber: data.phoneNumber,
@@ -78,10 +91,10 @@ export class SendDownstreamDeliveryService {
tenantId: match.tenantId,
applicationId: match.applicationId,
messageRecordId: match.messageRecordId,
messageId: data.messageId,
messageId: match.messageId,
deliveryType: 'uplink',
payload: {
messageId: data.messageId,
messageId: match.messageId,
applicationId: match.applicationId,
phoneNumber: data.phoneNumber,
destId: data.destId,
@@ -95,6 +108,21 @@ export class SendDownstreamDeliveryService {
}
async claimUplinkMatchCandidate(uplinkMessageId: string, candidateId: string, operatorId?: string) {
if (!completionContext.getStore()) {
return this.prisma.$transaction(
(tx) =>
completionContext.run({ tx, messageRecordId: '' }, () =>
this.persistUplinkClaim(uplinkMessageId, candidateId, operatorId),
),
{ timeout: 15_000 },
);
}
return this.persistUplinkClaim(uplinkMessageId, candidateId, operatorId);
}
private async persistUplinkClaim(uplinkMessageId: string, candidateId: string, operatorId?: string) {
await this.prisma
.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`uplink-claim:${uplinkMessageId}`},0))`;
const candidate = await this.prisma.smsUplinkMatchCandidate.findFirst({
where: { id: candidateId, uplinkMessageId },
include: {
@@ -112,56 +140,55 @@ export class SendDownstreamDeliveryService {
if (candidate.uplinkMessage.matchStatus === 'matched' && candidate.status !== 'claimed') {
throw new BadRequestException('该上行记录已完成匹配,不能重复认领');
}
if (candidate.status === 'claimed') return candidate.uplinkMessage;
const claimedAt = new Date();
const messageId = candidate.uplinkMessage.messageId ?? candidate.messageRecord?.messageId ?? null;
const [updatedUplink] = await this.prisma.$transaction([
this.prisma.smsUplinkMessage.update({
where: { id: uplinkMessageId },
data: {
tenantId: candidate.tenantId,
const messageId = candidate.messageRecord?.messageId ?? null;
const updatedUplink = await this.prisma.smsUplinkMessage.update({
where: { id: uplinkMessageId },
data: {
tenantId: candidate.tenantId,
applicationId: candidate.applicationId,
messageRecordId: candidate.messageRecordId,
messageId,
matchStatus: 'matched',
matchReason: `人工认领:${candidate.reason ?? candidate.matchSource}`,
},
});
await this.prisma.smsUplinkMatchCandidate.updateMany({
where: {
uplinkMessageId,
id: { not: candidate.id },
status: 'pending',
},
data: { status: 'rejected' },
});
await this.prisma.smsUplinkMatchCandidate.update({
where: { id: candidate.id },
data: {
status: 'claimed',
claimedAt,
claimedById: operatorId,
},
});
await this.prisma.operationLog.create({
data: {
tenantId: candidate.tenantId,
userId: operatorId,
action: 'gateway.uplink_manual_claim',
resource: 'sms_uplink_message',
resourceId: uplinkMessageId,
detail: {
candidateId: candidate.id,
applicationId: candidate.applicationId,
applicationName: candidate.application.name,
messageRecordId: candidate.messageRecordId,
messageId,
matchStatus: 'matched',
matchReason: `人工认领:${candidate.reason ?? candidate.matchSource}`,
matchSource: candidate.matchSource,
phoneNumber: candidate.uplinkMessage.phoneNumber,
destId: candidate.uplinkMessage.destId,
},
}),
this.prisma.smsUplinkMatchCandidate.updateMany({
where: {
uplinkMessageId,
id: { not: candidate.id },
status: 'pending',
},
data: { status: 'rejected' },
}),
this.prisma.smsUplinkMatchCandidate.update({
where: { id: candidate.id },
data: {
status: 'claimed',
claimedAt,
claimedById: operatorId,
},
}),
this.prisma.operationLog.create({
data: {
tenantId: candidate.tenantId,
userId: operatorId,
action: 'gateway.uplink_manual_claim',
resource: 'sms_uplink_message',
resourceId: uplinkMessageId,
detail: {
candidateId: candidate.id,
applicationId: candidate.applicationId,
applicationName: candidate.application.name,
messageRecordId: candidate.messageRecordId,
messageId,
matchSource: candidate.matchSource,
phoneNumber: candidate.uplinkMessage.phoneNumber,
destId: candidate.uplinkMessage.destId,
},
},
}),
]);
},
});
await this.facade.queueAndTryDownstreamDelivery({
tenantId: candidate.tenantId,
@@ -278,7 +305,10 @@ export class SendDownstreamDeliveryService {
skipDuplicates: true,
});
const retained = await this.prisma.cmppDownstreamDelivery.findUniqueOrThrow({ where: { dedupeKey } });
if (retained.messageRecordId !== data.messageRecordId || retained.applicationId !== data.applicationId)
if (
(retained.messageRecordId ?? null) !== (data.messageRecordId ?? null) ||
retained.applicationId !== data.applicationId
)
throw new Error('completion_notification_identity_mismatch');
return retained;
}
@@ -377,107 +407,12 @@ export class SendDownstreamDeliveryService {
tenantId?: string;
applicationId?: string;
messageRecordId?: string;
messageId?: string;
matchStatus: string;
matchReason: string;
candidates: UplinkMatchCandidateInput[];
}> {
if (data.messageId) {
const message = await this.prisma.smsMessageRecord.findUnique({ where: { messageId: data.messageId } });
if (message?.tenantId) {
return {
tenantId: message.tenantId,
applicationId: message.applicationId ?? undefined,
messageRecordId: message.id,
matchStatus: message.applicationId ? 'matched' : 'unmatched',
matchReason: message.applicationId ? 'messageId 精确匹配' : 'messageId 匹配到下发记录但无应用',
candidates: [],
};
}
}
const accessNumber = data.destId || channel.srcId || '';
const accessRoutes = accessNumber
? await this.prisma.channelRouteRule.findMany({
where: {
applicationId: { not: null },
status: 'active',
group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } },
},
select: { applicationId: true },
take: 10,
})
: [];
const accessApplicationIds = [
...new Set(accessRoutes.map((route) => route.applicationId).filter((value): value is string => Boolean(value))),
];
const accessApplications =
accessApplicationIds.length > 0
? await this.prisma.smsApplication.findMany({
where: { id: { in: accessApplicationIds }, status: 'active' },
select: { id: true, tenantId: true, name: true },
})
: [];
if (accessApplications.length === 1) {
return {
tenantId: accessApplications[0].tenantId,
applicationId: accessApplications[0].id,
matchStatus: 'matched',
matchReason: '接入号唯一匹配应用',
candidates: [],
};
}
if (accessApplications.length > 1) {
return {
matchStatus: 'ambiguous',
matchReason: '接入号匹配多个应用',
candidates: accessApplications.map((application) => ({
tenantId: application.tenantId,
applicationId: application.id,
matchSource: 'access_number',
confidence: 70,
reason: `接入号 ${accessNumber} 可匹配应用 ${application.name}`,
})),
};
}
const windowHours = Number(process.env.UPLINK_MATCH_WINDOW_HOURS ?? 72);
const since = new Date(Date.now() - Math.max(1, windowHours) * 60 * 60 * 1000);
const recentMessages = await this.prisma.smsMessageRecord.findMany({
where: {
phoneNumber: data.phoneNumber,
tenantId: { not: null },
applicationId: { not: null },
submittedAt: { gte: since },
},
orderBy: { submittedAt: 'desc' },
take: 2,
});
const matchableRecentMessages = recentMessages.filter((message) => message.tenantId && message.applicationId);
if (matchableRecentMessages.length === 1) {
return {
tenantId: matchableRecentMessages[0].tenantId ?? undefined,
applicationId: matchableRecentMessages[0].applicationId ?? undefined,
messageRecordId: matchableRecentMessages[0].id,
matchStatus: 'matched',
matchReason: `手机号 ${windowHours} 小时窗口唯一匹配`,
candidates: [],
};
}
if (matchableRecentMessages.length > 1) {
return {
matchStatus: 'ambiguous',
matchReason: `手机号 ${windowHours} 小时窗口匹配多条下发记录`,
candidates: matchableRecentMessages.map((message) => ({
tenantId: String(message.tenantId),
applicationId: String(message.applicationId),
messageRecordId: message.id,
matchSource: 'phone_window',
confidence: 55,
reason: `手机号 ${windowHours} 小时窗口候选下发 ${message.messageId}`,
})),
};
}
return { matchStatus: 'unmatched', matchReason: '未匹配到应用或下发记录', candidates: [] };
return resolveUplinkMatch(this.prisma, data, channel);
}
async recordCmppFailureReceipt(
+146
View File
@@ -0,0 +1,146 @@
import { BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import type { GatewayUplinkEventDto, UplinkMatchCandidateInput } from './send-chain.contracts';
export type UplinkMatch = {
tenantId?: string;
applicationId?: string;
messageRecordId?: string;
messageId?: string;
matchStatus: string;
matchReason: string;
candidates: UplinkMatchCandidateInput[];
};
/** Attribution is application-level; a reply need not identify one original SMS. */
export async function resolveUplinkMatch(
db: PrismaService,
data: GatewayUplinkEventDto,
channel: { id: string; srcId?: string | null },
): Promise<UplinkMatch> {
const receivedAt = data.receivedAt ? new Date(data.receivedAt) : new Date();
if (!Number.isFinite(receivedAt.getTime())) throw new BadRequestException('上行接收时间无效');
const configuredHours = Number(process.env.UPLINK_MATCH_WINDOW_HOURS ?? 72);
const hours =
Number.isFinite(configuredHours) && configuredHours >= 1 && configuredHours <= 8760 ? configuredHours : 72;
const since = new Date(receivedAt.getTime() - hours * 3_600_000);
const channelEvidence = {
channelId: channel.id,
submitStatus: 'accepted',
submittedAt: { gte: since, lte: receivedAt },
};
if (data.messageId) {
const message = await db.smsMessageRecord.findFirst({
where: {
messageId: data.messageId,
phoneNumber: data.phoneNumber,
tenantId: { not: null },
applicationId: { not: null },
submitRecords: { some: channelEvidence },
},
select: { id: true, messageId: true, tenantId: true, applicationId: true },
});
if (message?.tenantId && message.applicationId)
return {
tenantId: message.tenantId,
applicationId: message.applicationId,
messageRecordId: message.id,
messageId: message.messageId,
matchStatus: 'matched',
matchReason: 'messageId 与手机号、通道发送事实一致',
candidates: [],
};
}
const accessNumber = data.destId || channel.srcId || '';
// Do not truncate routes before deduplicating applications: it can manufacture uniqueness.
const routes = accessNumber
? await db.channelRouteRule.findMany({
where: {
applicationId: { not: null },
status: 'active',
group: { items: { some: { channelId: channel.id, channel: { srcId: accessNumber } } } },
},
select: { applicationId: true },
distinct: ['applicationId'],
})
: [];
const ids = routes.flatMap((r) => (r.applicationId ? [r.applicationId] : []));
const applications = ids.length
? await db.smsApplication.findMany({
where: { id: { in: ids }, status: 'active' },
select: { id: true, tenantId: true, name: true },
})
: [];
// Read only attribution columns, but inspect the complete window, not its last two SMS.
const messages = await db.smsMessageRecord.findMany({
where: {
phoneNumber: data.phoneNumber,
tenantId: { not: null },
applicationId: { not: null },
submitRecords: { some: channelEvidence },
},
select: { id: true, messageId: true, tenantId: true, applicationId: true },
orderBy: { id: 'asc' },
});
const groups = new Map<string, typeof messages>();
for (const message of messages) {
if (!message.tenantId || !message.applicationId) continue;
const key = JSON.stringify([message.tenantId, message.applicationId]);
const group = groups.get(key) ?? [];
group.push(message);
groups.set(key, group);
}
const accessCandidates: UplinkMatchCandidateInput[] = applications.map((a) => ({
tenantId: a.tenantId,
applicationId: a.id,
matchSource: 'access_number',
confidence: 70,
reason: '共享接入号应用候选,尚无唯一发送证据',
}));
if (groups.size === 1) {
const records = [...groups.values()][0];
const message = records[0];
// A conflicting configured access number is evidence against automatic assignment.
if (!applications.length || applications.some((a) => a.id === message.applicationId))
return {
tenantId: message.tenantId!,
applicationId: message.applicationId!,
messageRecordId: records.length === 1 ? message.id : undefined,
messageId: records.length === 1 ? message.messageId : undefined,
matchStatus: 'matched',
matchReason:
records.length === 1
? `手机号、通道及接收前 ${hours} 小时唯一匹配`
: `手机号、通道及接收前 ${hours} 小时应用唯一;原短信不唯一`,
candidates: [],
};
}
// No sending evidence: retain the existing unique-access application attribution.
if (!groups.size && applications.length === 1)
return {
tenantId: applications[0].tenantId,
applicationId: applications[0].id,
matchStatus: 'matched',
matchReason: '接入号唯一匹配应用,无唯一原短信',
candidates: [],
};
const candidates = new Map(accessCandidates.map((c) => [c.applicationId, c]));
for (const records of groups.values()) {
const m = records[0];
candidates.set(m.applicationId!, {
tenantId: m.tenantId!,
applicationId: m.applicationId!,
messageRecordId: records.length === 1 ? m.id : undefined,
matchSource: 'phone_window',
confidence: 70,
reason: `同通道接收前 ${hours} 小时有 ${records.length} 条下发;须确认应用归属`,
});
}
return candidates.size
? {
matchStatus: 'ambiguous',
matchReason: '应用归属仍有多候选或接入号与发送事实冲突',
candidates: [...candidates.values()],
}
: { matchStatus: 'unmatched', matchReason: '未匹配到接入号应用或时间窗内同通道发送事实', candidates: [] };
}
@@ -0,0 +1,124 @@
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { startOfDay, addDays } from './analytics-date';
export type ActivityDimension = {
dimensionKey: string;
dimensionType: string;
tenantId: string;
applicationId: string | null;
signatureId: string;
channelKey: string;
carrier: string;
approvedAt: Date;
signatureName: string;
tenantName: string;
applicationName: string;
channelName: string;
};
export type ActivityCount = {
submittedAttempts: number;
acceptedBusinessCount: number;
deliveredBusinessCount: number;
};
/** Reconstruct membership at the end of the activity day, not from today's active routes. */
export async function activityDimensions(db: PrismaService, date: string): Promise<ActivityDimension[]> {
const end = startOfDay(addDays(date, 1));
const tasks = await db.$queryRaw<Array<Omit<ActivityDimension, 'dimensionKey' | 'dimensionType'>>>(Prisma.sql`
SELECT t."signatureId",t."channelId" AS "channelKey",t.carrier,
s."tenantId",s."applicationId",s.name AS "signatureName",c.name AS "channelName",
tenant.name AS "tenantName",COALESCE(a.name,'') AS "applicationName",
COALESCE(approved."createdAt",t."approvedAt") AS "approvedAt"
FROM "ChannelSignatureReportTask" t
JOIN "SmsSignature" s ON s.id=t."signatureId" JOIN "Tenant" tenant ON tenant.id=s."tenantId"
JOIN "SmsChannel" c ON c.id=t."channelId" LEFT JOIN "SmsApplication" a ON a.id=s."applicationId"
LEFT JOIN LATERAL (SELECT r."statusAfter" FROM "ChannelSignatureReportRecord" r
WHERE r."taskId"=t.id AND r."createdAt"<${end} ORDER BY r."createdAt" DESC,r.id DESC LIMIT 1) history ON TRUE
LEFT JOIN LATERAL (SELECT r."createdAt" FROM "ChannelSignatureReportRecord" r
WHERE r."taskId"=t.id AND r."createdAt"<${end} AND r."statusAfter"='approved'
AND r."statusBefore" IS DISTINCT FROM 'approved' ORDER BY r."createdAt" DESC,r.id DESC LIMIT 1) approved ON TRUE
WHERE t."reportType"='signature' AND t."approvalScope"='carrier_specific' AND t.carrier IS NOT NULL
AND t."createdAt"<${end}
AND COALESCE(history."statusAfter",CASE WHEN t."approvedAt"<${end} THEN t.status END)='approved'
AND COALESCE(approved."createdAt",t."approvedAt")<${end}`);
const dimensions = new Map<string, ActivityDimension>();
for (const t of tasks) {
const channelKey = JSON.stringify(['channel', t.tenantId, t.applicationId, t.signatureId, t.channelKey, t.carrier]);
dimensions.set(channelKey, { ...t, dimensionKey: channelKey, dimensionType: 'channel' });
const enterpriseKey = JSON.stringify(['enterprise', t.tenantId, t.applicationId, t.signatureId, '', t.carrier]);
const existing = dimensions.get(enterpriseKey);
if (!existing || existing.approvedAt > t.approvedAt)
dimensions.set(enterpriseKey, {
...t,
dimensionKey: enterpriseKey,
dimensionType: 'enterprise',
channelKey: '',
channelName: '',
});
}
return [...dimensions.values()];
}
/** One bounded source scan for all dimensions; no per-signature correlated receipt scan. */
export async function activityCounts(db: PrismaService, dimensions: ActivityDimension[], date: string) {
if (!dimensions.length) return new Map<string, ActivityCount>();
const start = startOfDay(date),
end = startOfDay(addDays(date, 1));
const json = JSON.stringify(
dimensions.map((d) => ({
key: d.dimensionKey,
signature: d.signatureId,
channel: d.channelKey,
carrier: d.carrier,
approved: d.approvedAt.toISOString(),
})),
);
const rows = await db.$queryRaw<Array<ActivityCount & { key: string }>>(Prisma.sql`
WITH dims AS (SELECT * FROM jsonb_to_recordset(${json}::jsonb) AS d(key text,signature text,channel text,carrier text,approved timestamptz)),
attempts AS MATERIALIZED (
SELECT s.id,s."messageRecordId",s."channelId",s."gatewayMessageId",s."submitStatus",m."signatureId",m.carrier,m."billingUnits",
COALESCE(s."submittedAt",s."createdAt") AS at
FROM "SmsSubmitRecord" s JOIN "SmsMessageRecord" m ON m.id=s."messageRecordId"
WHERE COALESCE(s."submittedAt",s."createdAt")>=${start} AND COALESCE(s."submittedAt",s."createdAt")<${end}
AND m."signatureId" IS NOT NULL
), segments AS (
SELECT a.id,COUNT(g.id)::int AS present,GREATEST(MAX(g."segmentTotal"),MAX(a."billingUnits")) AS expected,
COUNT(g.id) FILTER(WHERE g."receiptStatus"='delivered') AS delivered
FROM attempts a LEFT JOIN "SmsMessageSegmentAudit" g ON g."submitRecordId"=a.id GROUP BY a.id
), delivered AS (
SELECT DISTINCT a.id FROM attempts a JOIN segments g ON g.id=a.id
WHERE (g.present>0 AND g.delivered=g.expected AND g.present=g.expected)
OR (g.present=0 AND EXISTS(SELECT 1 FROM "SmsReceiptRecord" r
WHERE r."channelId"=a."channelId" AND r."gatewayMessageId"=a."gatewayMessageId" AND r."receiptStatus"='delivered'))
)
SELECT d.key,COUNT(a.id)::int AS "submittedAttempts",
COUNT(DISTINCT a."messageRecordId") FILTER(WHERE a."submitStatus"='accepted')::int AS "acceptedBusinessCount",
COUNT(DISTINCT a."messageRecordId") FILTER(WHERE a."submitStatus"='accepted' AND delivered.id IS NOT NULL)::int AS "deliveredBusinessCount"
FROM dims d LEFT JOIN attempts a ON a."signatureId"=d.signature AND a.carrier=d.carrier
AND (d.channel='' OR a."channelId"=d.channel) AND a.at>=d.approved AT TIME ZONE 'UTC'
LEFT JOIN delivered ON delivered.id=a.id GROUP BY d.key`);
return new Map(rows.map(({ key, ...counts }) => [key, counts]));
}
export async function unreportedRows(db: PrismaService, date: string) {
return db.$queryRaw<
Array<{
dimensionKey: string;
tenantId: string;
applicationId: string;
signatureName: string;
tenantName: string;
applicationName: string;
messageCount: number;
}>
>(Prisma.sql`
WITH extracted AS (
SELECT m."tenantId",m."applicationId",SUBSTRING(m.content FROM '^【[^【】]+】') AS name
FROM "SmsMessageRecord" m WHERE m."queuedAt">=${startOfDay(date)} AND m."queuedAt"<${startOfDay(addDays(date, 1))} AND m."signatureId" IS NULL
) SELECT jsonb_build_array(e."tenantId",e."applicationId",e.name)::text AS "dimensionKey",
e."tenantId",e."applicationId",e.name AS "signatureName",t.name AS "tenantName",a.name AS "applicationName",COUNT(*)::int AS "messageCount"
FROM extracted e JOIN "Tenant" t ON t.id=e."tenantId" JOIN "SmsApplication" a ON a.id=e."applicationId"
WHERE e.name IS NOT NULL AND NOT EXISTS(SELECT 1 FROM "SmsSignature" s WHERE s."tenantId"=e."tenantId" AND s."applicationId"=e."applicationId" AND s.name=e.name AND s."auditStatus"<>'deleted')
GROUP BY e."tenantId",e."applicationId",e.name,t.name,a.name`);
}
@@ -0,0 +1,23 @@
import { addDays, analyticsDate, analyticsPage, mutableDay, todayKey } from './analytics-date';
describe('signature analytics business-day contract', () => {
const now = new Date('2026-09-16T16:00:00Z');
it('uses Shanghai midnight and freezes T-4', () => {
expect(todayKey(now)).toBe('2026-09-17');
expect(mutableDay('2026-09-14', now)).toBe(true);
expect(mutableDay('2026-09-13', now)).toBe(false);
expect(mutableDay('2026-09-17', now)).toBe(false);
expect(addDays('2024-03-01', -1)).toBe('2024-02-29');
});
it.each(['2026-02-29', '2026-09-18', '2026-13-01', '2026-9-1'])('rejects invalid or future date %s', (date) => {
expect(() => analyticsDate(date, now)).toThrow();
});
it.each([
[0, 25],
[1, 1000],
[Number.NaN, 25],
[1.5, 25],
])('rejects invalid pagination', (page, size) => {
expect(() => analyticsPage(page, size)).toThrow();
});
});
@@ -0,0 +1,31 @@
import { BadRequestException } from '@nestjs/common';
const dayFormatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
});
export const todayKey = (now = new Date()) => dayFormatter.format(now);
export const databaseDay = (key: string) => new Date(`${key}T00:00:00.000Z`);
export const startOfDay = (key: string) => new Date(`${key}T00:00:00+08:00`);
export const addDays = (key: string, days: number) =>
new Date(databaseDay(key).getTime() + days * 86_400_000).toISOString().slice(0, 10);
export function analyticsDate(value?: string, now = new Date()) {
const key = value || todayKey(now);
if (
!/^\d{4}-\d{2}-\d{2}$/.test(key) ||
!Number.isFinite(databaseDay(key).getTime()) ||
databaseDay(key).toISOString().slice(0, 10) !== key ||
key > todayKey(now)
) {
throw new BadRequestException('统计日期必须为有效的北京时间日期,不能晚于今天');
}
return key;
}
export function analyticsPage(page = 1, pageSize = 25) {
if (!Number.isInteger(page) || page < 1 || ![10, 25, 50, 100].includes(pageSize))
throw new BadRequestException('分页参数无效');
return { page, pageSize };
}
export const mutableDay = (day: string, now = new Date()) => day < todayKey(now) && day >= addDays(todayKey(now), -3);
@@ -0,0 +1,92 @@
import { randomUUID } from 'node:crypto';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { databaseDay, todayKey } from './analytics-date';
/** Lease acquisition is outside the source snapshot. Publishing always rechecks its fencing token. */
export async function analyticsJob<T>(
db: PrismaService,
scope: string,
date: string,
work: (tx: PrismaService, generation: string, checkpoint: Prisma.JsonValue | null) => Promise<T>,
now = new Date(),
prepare?: () => Promise<Prisma.InputJsonValue>,
): Promise<{ skipped: boolean; result?: T }> {
const businessDate = databaseDay(date),
refreshFor = databaseDay(todayKey(now));
await db.signatureAnalyticsRun.createMany({
data: [{ scope, businessDate, refreshFor, nextAttemptAt: now, generationId: randomUUID() }],
skipDuplicates: true,
});
const owner = randomUUID(),
generationId = randomUUID();
const claimed = await db.signatureAnalyticsRun.updateMany({
where: {
scope,
businessDate,
AND: [
{ OR: [{ leaseUntil: null }, { leaseUntil: { lt: now } }] },
{
OR: [
{ refreshFor: { lt: refreshFor } },
{ state: { not: 'succeeded' }, attempt: { lt: 5 }, nextAttemptAt: { lte: now } },
],
},
],
},
data: {
owner,
generationId,
leaseUntil: new Date(now.getTime() + 300_000),
fence: { increment: 1 },
state: 'running',
startedAt: now,
error: null,
},
});
if (!claimed.count) return { skipped: true };
const run = await db.signatureAnalyticsRun.findUniqueOrThrow({
where: { scope_businessDate: { scope, businessDate } },
});
const attempt = run.refreshFor < refreshFor ? 1 : run.attempt + 1;
await db.signatureAnalyticsRun.update({ where: { id: run.id }, data: { refreshFor, attempt } });
try {
// Preserve the first decision's rule/report snapshot across retries. New-day runs take a new snapshot.
const checkpoint = run.refreshFor < refreshFor ? null : run.checkpoint;
const prepared = checkpoint ?? (prepare ? await prepare() : null);
if (prepared !== null && checkpoint === null) {
const saved = await db.signatureAnalyticsRun.updateMany({
where: { id: run.id, owner, fence: run.fence, state: 'running' },
data: { checkpoint: prepared as Prisma.InputJsonValue },
});
if (saved.count !== 1) throw new Error('签名统计任务认领已失效');
}
const result = await db.$transaction(
async (tx) => {
await tx.$executeRawUnsafe("SET LOCAL statement_timeout='90s'");
const result = await work(tx as PrismaService, generationId, prepared as Prisma.JsonValue | null);
const current = new Date();
const fenced = await tx.signatureAnalyticsRun.updateMany({
where: { id: run.id, owner, fence: run.fence, state: 'running', leaseUntil: { gt: current } },
data: { state: 'succeeded', owner: null, leaseUntil: null, finishedAt: current, error: null },
});
if (fenced.count !== 1) throw new Error('签名统计任务租约已失效,拒绝发布');
return result;
},
{ isolationLevel: 'RepeatableRead', timeout: 120_000, maxWait: 5000 },
);
return { skipped: false, result };
} catch (error) {
await db.signatureAnalyticsRun.updateMany({
where: { id: run.id, owner, fence: run.fence },
data: {
state: attempt >= 5 ? 'failed' : 'retry_wait',
owner: null,
leaseUntil: null,
nextAttemptAt: new Date(Date.now() + Math.min(900_000, 60_000 * 2 ** (attempt - 1))),
error: '签名统计生成失败,请查看服务日志',
},
});
throw error;
}
}
@@ -0,0 +1,217 @@
import { BadRequestException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { addDays, analyticsDate, analyticsPage, databaseDay, todayKey } from './analytics-date';
export interface ActivityQuery {
date?: string;
dimensionType: string;
page?: number;
pageSize?: number;
tenantName?: string;
applicationName?: string;
signatureName?: string;
channelName?: string;
}
export class SignatureAnalyticsRead {
constructor(private readonly db: PrismaService) {}
async metadata(date: string, now = new Date()) {
const record = await this.db.signatureAnalyticsDay.findUnique({ where: { businessDate: databaseDay(date) } });
const run = await this.db.signatureAnalyticsRun.findUnique({
where: { scope_businessDate: { scope: 'daily', businessDate: databaseDay(date) } },
});
const reportState =
run?.state === 'running'
? 'refreshing'
: ['retry_wait', 'failed'].includes(run?.state ?? '')
? 'failed'
: (record?.state ?? 'missing');
return {
dataSource: 'report' as const,
businessDate: date,
serverBusinessDate: todayKey(now),
reportState,
frozen: date <= addDays(todayKey(now), -4),
generatedAt: record?.generatedAt ?? null,
sourceAsOf: record?.sourceAsOf ?? null,
generationId: record?.publishedGenerationId ?? null,
schemaVersion: record?.schemaVersion ?? 1,
provenance: record?.provenance ?? null,
};
}
async quality(query: { date?: string; keyword?: string; page?: number; pageSize?: number }) {
const date = analyticsDate(query.date);
const { page, pageSize } = analyticsPage(query.page, query.pageSize);
return this.db.$transaction(
async (tx) => {
const meta = await new SignatureAnalyticsRead(tx as PrismaService).metadata(date);
if (!meta.generationId) return { date, items: [], total: 0, page, pageSize, ...meta };
const where: Prisma.SignatureQualityDailyWhereInput = {
generationId: meta.generationId,
...(query.keyword?.trim()
? {
OR: ['signatureName', 'tenantName', 'applicationNames'].map((field) => ({
[field]: { contains: query.keyword!.trim(), mode: 'insensitive' },
})),
}
: {}),
};
const total = await tx.signatureQualityDaily.count({ where });
const rows = await tx.signatureQualityDaily.findMany({
where,
orderBy: [{ total: 'desc' }, { signatureName: 'asc' }, { signatureId: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
});
return { date, items: rows.map((r) => r.payload), total, page, pageSize, ...meta };
},
{ isolationLevel: 'RepeatableRead', timeout: 15_000 },
);
}
async unreported(query: { date?: string; keyword?: string; page?: number; pageSize?: number }) {
const date = analyticsDate(query.date);
const { page, pageSize } = analyticsPage(query.page, query.pageSize);
return this.db.$transaction(
async (tx) => {
const meta = await new SignatureAnalyticsRead(tx as PrismaService).metadata(date);
if (!meta.generationId) return { date, items: [], total: 0, page, pageSize, ...meta };
const where: Prisma.UnreportedSignatureDailyWhereInput = {
generationId: meta.generationId,
...(query.keyword?.trim()
? {
OR: ['signatureName', 'tenantName', 'applicationName'].map((field) => ({
[field]: { contains: query.keyword!.trim(), mode: 'insensitive' },
})),
}
: {}),
};
const total = await tx.unreportedSignatureDaily.count({ where });
const rows = await tx.unreportedSignatureDaily.findMany({
where,
orderBy: [{ messageCount: 'desc' }, { dimensionKey: 'asc' }],
skip: (page - 1) * pageSize,
take: pageSize,
});
return {
date,
items: rows.map((r) => ({ ...r, signatureId: r.dimensionKey })),
total,
page,
pageSize,
...meta,
};
},
{ isolationLevel: 'RepeatableRead', timeout: 15_000 },
);
}
async activity(query: ActivityQuery) {
const date = analyticsDate(query.date);
if (!['enterprise', 'channel'].includes(query.dimensionType))
throw new BadRequestException('必须指定企业或通道维度');
const { page, pageSize } = analyticsPage(query.page, query.pageSize);
const dates = Array.from({ length: 30 }, (_, i) => addDays(date, -i - 1));
return this.db.$transaction(
async (tx) => {
await tx.$executeRawUnsafe("SET LOCAL statement_timeout='12s'");
const manifests = await tx.signatureAnalyticsDay.findMany({
where: { businessDate: { in: dates.map(databaseDay) } },
});
const runs = await tx.signatureAnalyticsRun.findMany({
where: { scope: 'daily', businessDate: { in: dates.map(databaseDay) } },
});
const runStates = new Map(runs.map((r) => [r.businessDate.toISOString().slice(0, 10), r.state]));
const byDate = new Map(manifests.map((r) => [r.businessDate.toISOString().slice(0, 10), r]));
const coverage = dates.map((d) => {
const r = byDate.get(d);
return {
date: d,
generationId: r?.publishedGenerationId ?? null,
reportState:
runStates.get(d) === 'running'
? 'refreshing'
: ['failed', 'retry_wait'].includes(runStates.get(d) ?? '')
? 'failed'
: (r?.state ?? 'missing'),
generatedAt: r?.generatedAt ?? null,
sourceAsOf: r?.sourceAsOf ?? null,
frozen: d <= addDays(todayKey(), -4),
};
});
const generations = coverage.flatMap((c) => (c.generationId ? [c.generationId] : []));
if (!generations.length)
return { date, items: [], dimensions: [], total: 0, page, pageSize, coverage, complete: false };
const filters = [
['tenantName', query.tenantName],
['applicationName', query.applicationName],
['signatureName', query.signatureName],
['channelName', query.channelName],
]
.filter(([, value]) => value?.trim())
.map(([field, value]) => Prisma.sql`AND r.${Prisma.raw(`"${field}"`)} ILIKE ${`%${value!.trim()}%`}`);
const dimensions = await tx.$queryRaw<
Array<{
dimensionKey: string;
dimensionType: string;
signatureId: string;
channelKey: string;
carrier: string;
signatureName: string;
channelName: string;
tenantName: string;
applicationName: string;
approvedAt: Date | null;
total: number;
rowCount: number;
}>
>(Prisma.sql`
WITH selected AS (
SELECT * FROM "SignatureActivityDaily" WHERE "generationId" IN (${Prisma.join(generations)}) AND "dimensionType"=${query.dimensionType}
), latest AS (
SELECT DISTINCT ON ("dimensionKey") * FROM selected ORDER BY "dimensionKey","businessDate" DESC
), sums AS (SELECT "dimensionKey",SUM("acceptedBusinessCount")::integer AS total FROM selected GROUP BY 1)
SELECT r.*,s.total,COUNT(*) OVER()::integer AS "rowCount" FROM latest r JOIN sums s USING("dimensionKey")
WHERE TRUE ${filters.length ? Prisma.join(filters, ' ') : Prisma.empty} ORDER BY s.total DESC,r."signatureName",r."dimensionKey"
LIMIT ${pageSize} OFFSET ${(page - 1) * pageSize}`);
// An empty out-of-range page still reports the filtered total.
const emptyPageCount = dimensions.length
? []
: await tx.$queryRaw<Array<{ total: number }>>(Prisma.sql`
WITH latest AS (
SELECT DISTINCT ON ("dimensionKey") * FROM "SignatureActivityDaily"
WHERE "generationId" IN (${Prisma.join(generations)}) AND "dimensionType"=${query.dimensionType}
ORDER BY "dimensionKey","businessDate" DESC
) SELECT COUNT(*)::integer AS total FROM latest r WHERE TRUE ${filters.length ? Prisma.join(filters, ' ') : Prisma.empty}`);
const items = dimensions.length
? await tx.signatureActivityDaily.findMany({
where: {
generationId: { in: generations },
dimensionType: query.dimensionType,
dimensionKey: { in: dimensions.map((d) => d.dimensionKey) },
},
})
: [];
return {
date,
dimensions: dimensions.map((d) => ({ ...d, channelId: d.channelKey || null })),
items: items.map((r) => ({
...r,
id: `${r.generationId}:${r.dimensionKey}`,
channelId: r.channelKey || null,
activityDate: r.businessDate.toISOString().slice(0, 10),
status: r.applicability,
})),
total: dimensions[0]?.rowCount ?? emptyPageCount[0]?.total ?? 0,
page,
pageSize,
coverage,
complete: coverage.every((c) => Boolean(c.generationId)),
};
},
{ isolationLevel: 'RepeatableRead', timeout: 15_000 },
);
}
}
@@ -0,0 +1,210 @@
import { Prisma, SignatureRetirementRule } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { addDays, databaseDay, startOfDay, todayKey } from './analytics-date';
import { analyticsJob } from './analytics-job';
const keyOf = (d: { dimensionType: string; signatureId: string; channelKey: string; carrier: string }) =>
JSON.stringify([d.dimensionType, d.signatureId, d.channelKey, d.carrier]);
const carrierNames: Record<string, string> = { mobile: '移动', unicom: '联通', telecom: '电信' };
export async function detectRetirement(db: PrismaService, date: string) {
if (date !== todayKey()) throw new Error('自动退网检测仅处理当天,不补发历史预警');
const dependency = await db.signatureAnalyticsDay.findUnique({
where: { businessDate: databaseDay(addDays(date, -1)) },
});
if (
!dependency?.publishedGenerationId ||
dependency.state !== 'ready' ||
dependency.refreshFor?.getTime() !== databaseDay(date).getTime()
)
throw new Error('签名退网检测等待昨日活动日报完成');
const job = await analyticsJob(
db,
'retirement',
date,
async (tx, _generation, checkpoint) => {
const report = await tx.signatureAnalyticsDay.findUnique({
where: { businessDate: databaseDay(addDays(date, -1)) },
});
if (
!report?.publishedGenerationId ||
report.state !== 'ready' ||
report.refreshFor?.getTime() !== databaseDay(date).getTime()
)
throw new Error('签名退网检测等待昨日活动日报完成');
const snapshot = checkpoint as unknown as { generationId: string; rules: SignatureRetirementRule[] };
const daily = await tx.signatureActivityDaily.findMany({ where: { generationId: snapshot.generationId } });
const rules = snapshot.rules;
const existing = new Set(
(await tx.signatureRetirementDetection.findMany({ where: { detectionDate: databaseDay(date) } })).map(keyOf),
);
const dimensions = daily.flatMap((d) => {
if (existing.has(keyOf(d)) || !d.approvedAt) return [];
const enterprise = d.dimensionType === 'enterprise';
const special = enterprise ? 'enterprise_application' : 'channel';
const global = enterprise ? 'enterprise_global' : 'channel_global';
const target = enterprise ? d.applicationId : d.channelKey;
const rule =
rules.find((r) => r.ruleType === special && r.targetId === target) ??
rules.find((r) => r.ruleType === global && r.targetKey === '');
if (!rule) return [];
const [windowDays, threshold] =
d.carrier === 'mobile'
? [rule.mobileWindowDays, rule.mobileThreshold]
: d.carrier === 'unicom'
? [rule.unicomWindowDays, rule.unicomThreshold]
: [rule.telecomWindowDays, rule.telecomThreshold];
const windowStart = startOfDay(addDays(date, -windowDays));
return [
{
...d,
approvedAt: d.approvedAt,
rule,
windowDays,
threshold,
windowStart,
observing: d.approvedAt > windowStart,
},
];
});
const eligible = dimensions.filter((d) => !d.observing);
const windowCounts = new Map<string, number>();
if (eligible.length) {
const earliest = new Date(Math.min(...eligible.map((d) => d.windowStart.getTime())));
const defs = JSON.stringify(
eligible.map((d) => ({
key: d.dimensionKey,
signature: d.signatureId,
carrier: d.carrier,
channel: d.channelKey,
start: d.windowStart.toISOString(),
})),
);
const rows = await tx.$queryRaw<Array<{ key: string; count: number }>>(Prisma.sql`
WITH dimensions AS (SELECT * FROM jsonb_to_recordset(${defs}::jsonb) AS d(key text,signature text,carrier text,channel text,start timestamptz)),
accepted AS MATERIALIZED (
SELECT s."messageRecordId",s."channelId",m."signatureId",m.carrier,COALESCE(s."submittedAt",s."createdAt") AS at
FROM "SmsSubmitRecord" s JOIN "SmsMessageRecord" m ON m.id=s."messageRecordId"
WHERE s."submitStatus"='accepted' AND COALESCE(s."submittedAt",s."createdAt")>=${earliest}
AND COALESCE(s."submittedAt",s."createdAt")<${startOfDay(date)} AND m."signatureId" IS NOT NULL
) SELECT d.key,COUNT(DISTINCT a."messageRecordId")::int AS count FROM dimensions d
LEFT JOIN accepted a ON a."signatureId"=d.signature AND a.carrier=d.carrier
AND (d.channel='' OR a."channelId"=d.channel) AND a.at>=d.start AT TIME ZONE 'UTC'
GROUP BY d.key`);
for (const row of rows) windowCounts.set(row.key, row.count);
}
const suppressions = new Map(
(await tx.signatureRetirementSuppression.findMany({ where: { active: true } })).map((r) => [keyOf(r), r]),
);
const cycles = new Map(
(await tx.signatureRetirementCycle.findMany({ where: { status: 'open' } })).map((r) => [keyOf(r), r]),
);
const rows: Prisma.SignatureRetirementDetectionCreateManyInput[] = [];
const continued: string[] = [],
resolved: string[] = [];
const newCycles: Prisma.SignatureRetirementCycleCreateManyInput[] = [];
let alerted = 0,
healthy = 0,
ineligible = 0;
for (const d of dimensions) {
const key = keyOf(d),
count = windowCounts.get(d.dimensionKey) ?? 0;
const alert = !d.observing && count < d.threshold;
let cycleId: string | null = null;
const cycle = cycles.get(key);
if (d.observing) ineligible++;
else if (alert) {
alerted++;
cycleId = cycle?.id ?? randomUUID();
if (cycle) continued.push(cycle.id);
else
newCycles.push({
id: cycleId,
dimensionType: d.dimensionType,
signatureId: d.signatureId,
channelId: d.channelKey || null,
channelKey: d.channelKey,
carrier: d.carrier,
startedOn: databaseDay(date),
lastDetectedOn: databaseDay(date),
});
} else {
healthy++;
if (cycle) resolved.push(cycle.id);
}
const suppression = suppressions.get(key);
const suppressed = Boolean(
suppression &&
(suppression.mode === 'permanent' || !suppression.muteUntil || suppression.muteUntil >= databaseDay(date)),
);
const fallback =
d.dimensionType === 'enterprise'
? '请通知 {enterprise}{signature}在{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时进行保签名发送。'
: '{signature}在通道{channel}的{carrier}过去{days}天发送{actual}条,低于{threshold}条,请及时通知客户或进行保签名发送。';
let content = d.rule.messageTemplate?.trim() || fallback;
for (const [name, value] of Object.entries({
enterprise: d.tenantName,
signature: d.signatureName,
carrier: carrierNames[d.carrier] ?? d.carrier,
days: d.windowDays,
actual: count,
threshold: d.threshold,
channel: d.channelName || '-',
}))
content = content.replaceAll(`{${name}}`, String(value));
rows.push({
detectionDate: databaseDay(date),
dimensionType: d.dimensionType,
tenantId: d.tenantId,
applicationId: d.applicationId,
signatureId: d.signatureId,
channelId: d.channelKey || null,
channelKey: d.channelKey,
carrier: d.carrier,
windowDays: d.windowDays,
threshold: d.threshold,
submittedAttempts: d.submittedAttempts,
acceptedBusinessCount: d.acceptedBusinessCount,
deliveredBusinessCount: d.deliveredBusinessCount,
approvedAt: d.approvedAt,
ruleId: d.rule.id,
ruleVersion: d.rule.version,
status: d.observing ? 'observing' : alert ? 'alert' : 'healthy',
cycleId,
suppressed,
notificationTitle: alert
? d.dimensionType === 'enterprise'
? '企业签名清退预警'
: '通道签名清退预警'
: null,
notificationContent: alert ? content : null,
});
}
for (let i = 0; i < newCycles.length; i += 250)
await tx.signatureRetirementCycle.createMany({ data: newCycles.slice(i, i + 250) });
if (continued.length)
await tx.signatureRetirementCycle.updateMany({
where: { id: { in: continued } },
data: { lastDetectedOn: databaseDay(date) },
});
if (resolved.length)
await tx.signatureRetirementCycle.updateMany({
where: { id: { in: resolved } },
data: { status: 'resolved', resolvedOn: databaseDay(date), lastDetectedOn: databaseDay(date) },
});
for (let i = 0; i < rows.length; i += 250)
await tx.signatureRetirementDetection.createMany({ data: rows.slice(i, i + 250) });
return { detectionDate: date, dimensions: dimensions.length, alerted, healthy, ineligible };
},
new Date(),
async () =>
JSON.parse(
JSON.stringify({
generationId: dependency.publishedGenerationId,
rules: await db.signatureRetirementRule.findMany({ where: { enabled: true } }),
}),
) as Prisma.InputJsonValue,
);
return job.result ?? { detectionDate: date, skipped: true };
}
@@ -0,0 +1,5 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../prisma/prisma.module';
import { SignatureAnalyticsService } from './signature-analytics.service';
@Module({ imports: [PrismaModule], providers: [SignatureAnalyticsService], exports: [SignatureAnalyticsService] })
export class SignatureAnalyticsModule {}
@@ -0,0 +1,129 @@
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 startedAt = new Date();
const date = analyticsDate(value, startedAt);
if (!mutableDay(date, startedAt) && !backfill) return { skipped: true };
if (date >= todayKey(startedAt)) 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(startedAt)),
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,
};
},
startedAt,
);
}
}
@@ -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);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

@@ -0,0 +1,7 @@
Edit the supplied Chinese admin homepage UI into V2. Keep the same brand 聆界短信平台, white sidebar, palette, typography quality, example data, selected menu and header. This is an exact UI redesign, crisp text.
CRITICAL: shrink TOTAL area of the three top metric sections to ONE THIRD of the original combined area. Achieve this by placing THREE compact panels SIDE BY SIDE IN A SINGLE ROW immediately below header, each one third of content width and about 180px tall. NOT three stacked wide rows. Each panel has three compact rows with label LEFT, value RIGHT, no verbose descriptions under every metric. This single row replaces the former three huge stacked panels. The entire 9-metric band should occupy only about 180px vertical space versus original about 550px. Do not shrink text to illegibility.
Left panel heading 今日业务; rows 今日业务短信数量 120,000 条 ; 今日发送成功数量 108,000 条 ; 总体成功率 90.0%.
Middle panel heading 今日回执 with compact outlined button 按提交日查看; rows 今日回执分片总数 240,000 片 ; 今日回执成功分片数 216,000 片 ; 今日回执成功率 90.0%. Emphasize success row with pale green background and green bold number, strongest number on page but still compact.
Right panel heading EXACTLY 今日营业状况 (rename former 今日回执收益) and outlined button 按提交日查看; rows 今日营收金额 ¥10,800 ; 今日利润 ¥2,160 ; 今日利润率 20.0%. One shared tiny note below whole metric band: 回执及营业指标按今日收到的回执统计,包含近四日提交的短信.
Restore ORIGINAL enterprise SPEND RANKING, not refunds. Remove entire 今日返还金额 panel and refund values. Directly below compact top band place a FULL CONTENT WIDTH table panel headed 今日企业消费排行, subtitle 来自真实账户、充值和消息金额聚合。, top right outlined 导出排行. Six columns exactly 排名 | 企业名称 | 今日消费(元) | 可用余额 | 余额状态 | 操作. 5 rows of example data: 1 示例企业 A / ent_demo_a / ¥6,280 / 28,500 / 充足 green / 查看详情 blue; 2 示例企业 B ent_demo_b ¥3,120 12,000 充足 查看详情; 3 示例企业 C ent_demo_c ¥980 85 紧张 amber 查看详情; 4 示例企业 D ent_demo_d ¥320 6,200 充足 查看详情; 5 示例企业 E ent_demo_e ¥100 0 欠费 red 查看详情. Enterprise IDs displayed small grey on second line in enterprise cell. Ranking row heights 64px, genuine enterprise admin table with discreet horizontal separators, no cards replacing table. No refund column.
Below this full-width ranking table place full-width 运营状态 panel. Preserve seven original indicators in compact grid: 企业认证待审 2条, 短信审核待审 8条, 模板待审 3条, 签名待审 12条, 引流信息待审 1条, 平均等待 24任务 with small 批量任务总数 note, 下游投递告警 0条. Keep clickable-looking status items. Do not omit any of these. All content should fit naturally into a 1600x1000 style desktop canvas without excessive whitespace. No charts. Keep UI设计稿 · 示例数据 label. This is a compact professional operational dashboard, not a marketing layout. Main result must visibly show three small metric panels arranged horizontally, a wide original spend ranking table below, operational status below that.
@@ -0,0 +1,9 @@
Create a high fidelity Chinese enterprise SMS admin homepage UI mockup, a single clean straight-on desktop screen, 1600x1100 landscape, crisp legible Simplified Chinese typography. Product brand 聆界短信平台. White sidebar width 220px, pale grey #F6F7F9 canvas, white panels, subtle #E5E7EB borders, radius 8px, restrained blue #2563EB for controls only, success green #16A34A for successful receipts. No gradients, no illustrations, no charts, no extra KPIs.
Sidebar top blue outline abstract logo + 聆界短信平台. Sidebar items 数据概览 (selected pale blue), 短信发送, 短信记录, 上行记录, 签名质量, 通道管理, 财务管理, 系统监控. Header: 数据概览, right "2026-09-17 · 北京时间" and small outlined "刷新". Subtitle "今日提交与今日回执,分别看清业务和收益". A small clearly visible label "UI设计稿 · 示例数据".
Content consists of three generous but compact horizontally aligned three-column metric rows. Each row in white panel, title left and 3 metric columns separated subtle vertical lines:
Row1 title 今日业务. Metrics exactly 今日业务短信数量 / 120,000 条 / 今日提交的业务短信 ; 今日发送成功数量 / 108,000 条 / 今日提交且今日成功 ; 总体成功率 / 90.0% / 沿用业务短信口径.
Row2 title 今日回执; subtitle "今日收到 · 原提交日期 09-14 至 09-17". Top right outlined button "按提交日查看 ▾". Metrics 今日回执分片总数 / 240,000 片 / 按业务短信去重,包含应计未回分片 ; 今日回执成功分片数 / 216,000 片 / 长短信全部成功才计入 ; 今日回执成功率 / 90.0% / 成功分片数 ÷ 回执分片总数. Make CENTER 216,000 the strongest number on the whole page in green, slightly larger than others, pale green center background, not full saturated card.
Row3 title 今日回执收益; subtitle "仅统计今日成功回执归属的收益"; top right outlined "按提交日查看 ▾". Metrics 今日营收金额 / ¥10,800 / 今日成功业务短信收入 ; 今日利润 / ¥2,160 / 对应收入减通道成本 ; 今日利润率 / 20.0% / 今日利润 ÷ 今日营收.
Bottom two columns, left wider 60% panel "今日返还金额" bold amount "¥328.56", caption "按今日实际返还流水统计"; simple table 企业 / 今日返还金额 with 3 fictional clearly generic enterprise names 示例企业 A ¥168.56, 示例企业 B ¥100, 示例企业 C ¥60; footer small 查看返还流水.
Right 40% panel "运营状态" compact 2-column grid: 企业认证待审 2 条, 短信审核待审 8 条, 模板待审 3 条, 签名待审 12 条, 引流信息待审 1 条, 平均等待 24 任务 (tiny subtitle 批量任务总数), 下游投递告警 0 条.
No expanded breakdown visible on main screen. Footer unobtrusive "日期明细默认收起,点击按钮后查询". All displayed numbers are design examples, not live data. Polished utilitarian B2B design with excellent alignment and Chinese legibility, not a marketing landing page.
@@ -2339,3 +2339,28 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后
5. 监控刷新失败显示真实上一快照和过期提示,超时/切换请求取消,恢复后刷新;不同查询范围隔离。
授权交付:代码、定向/全量测试、精确提交、推送、测试环境标准发布及隔离模拟短信验收;不操作预生产,不向真实运营商发送。
## 2026-09-17 签名质量独立查询与日报优化(本地实现,待发布)
本节对应用户本轮方案要求,权威设计见[签名退网检测与四页查询优化方案](signature-quality-optimization-plan-20260917.md),本地实施状态见下方补充,线上尚未发布。
1. 四TAB的日期、筛选、分页、请求、错误及结果独立;企业/通道活跃度改为带dimensionType的独立服务端查询与分页,不再各自获取两类全量数据。保留默认25条和10/25/50/100档;同TAB顶部和卡片查询应用同一组草稿条件。
2. T为服务器北京时间今天,实际统计日为d:d=T查询时实时查业务库;T-1~T-3每天凌晨同时刷新、查询只读日报;T-4及以前只读冻结日报,普通查询、调度和重启不得重算。缺报表明确显示未生成,不回退明细或伪装零。
3. 用户确认热力图保持所选日D之前30天,即D-1~D-30,不包含当天列;每格按实际日期相对真实T判断数据来源,不按页面D重新开放历史刷新。
4. 日报历史名称、归属和报备适用性快照保留;冻结后迟到回执、补登记和状态变化不覆盖旧报表。预警判断/通知与可刷新的活动日报分开,刷新不得重发历史通知。
5. 退网检测采用批量聚合、提前跳过已完成、任务原子认领与故障接续,候选索引由真实执行计划决定。保留业务短信/提交尝试/计费片数区别、长短信完整分段判定和跨通道/跨日窗口去重;现有最长365天规则不缩短。
6. 原方案阶段为只读;本轮本地实现与隔离验收见下方补充,线上历史补建、推送、两环境部署与真实发送均未执行。本节不改变其他财务/质量报表T-4~T-1刷新规则。
### 2026-09-17 本轮本地实现
签名质量日报按专项优化方案实现:T实时、T-1~T-3每日刷新、T-4冻结;热力图D-1~D-30且企业/通道请求独立,失败保留本TAB上次真实数据并标明日期。退网窗口批量去重、规则快照/租约/fence/失败恢复与日报解耦。上行按应用唯一性而非短信条数归属,同通道接收前accepted作为证据;入库、候选、认领、通知意图保证事务一致。历史待认领不自动处理。本轮仅本地修改和提交,线上发布/补建及实际短信/通知验收另按授权执行。
## 2026-09-17 首页按今日回执整改(方案,待实施)
按[首页整改方案](homepage-receipt-metrics-redesign-20260917.md)保留今日业务三项、今日回执三项、今日回执收益三项及返还区域和运营状态。回执及收益纳入T-3~T提交消息,依网关接收日归属;业务短信去重,billingUnits补齐应计片,长短信整条成功才计成功片及一次收入。两个提交日期明细独立点击后查询,每组4行3指标;文案明确为“今日回执归属”,不冒充原提交日全天业绩。总体成功率保持旧公式。其他首页指标与两张趋势图移除;消费排行暂按替换为返还区域设计,见方案第2节解释。该方案实施后替代此前首页10指标及旧趋势/排行展示要求,其他页面不变。当前只有方案及UI,不代表上线。
## 2026-09-17 首页V2实施修订
用户最终要求执行[首页方案](homepage-receipt-metrics-redesign-20260917.md)并提交推送:一行三块“今日业务/今日回执/今日营业状况”,原企业消费排行保留,在消费后增加今日返还金额列,不替换排行;企业详情、导出包含返还。统计按今日有效接收回执及原提交日T-3~T,长短信完整成功、缺片补计、业务去重与旧总体成功率按方案执行。此前V1返还区域替换解释与V2“不增返还列”均被本次明确指令替代。仅本地实现和验收,未部署。
@@ -0,0 +1,217 @@
# 首页运营数据整改方案与 UI V1
## 2026-09-17 实施规格(优先于下方设计历史)
用户已授权实施、提交及推送;不部署。采用V2一行三块紧凑布局,营业区标题“今日营业状况”。保留今日企业消费排行原排序、余额、详情和导出入口,在消费后增加“今日返还金额(元)”列,详情/导出同步包含返还。此前“不增返还列”被本次要求替代。
实现将三个逻辑投影合为业务消息版本事实 `HomeMessageFact`:包含原提交日、有效回执日集合、唯一成功日及金额快照;用 `HomeProjectionState``HomeProjectionDirty``HomeSnapshot` 保存游标、耐久工作及发布版本。源表事务触发器仅登记待投影消息,不改发送/账务;投影按500条批次,事务级 advisory lock 和工作行锁认领,事实版本和工作删除同事务,进程故障整批回滚。旧版本事实按有效版本区间保留供快照读取;完成初始化前不发布,增量积压显示处理状态。源变化触发耐久重算,消除只用updatedAt水位漏掉晚提交事务的风险。API快照绑定平台用户与日期,15分钟有效,明细只在点击后聚合。
当前权限模型只有平台管理员/企业管理员,未发现独立财务角色;本次在全部新接口重新校验平台管理员角色及有效用户,不新增角色体系。客户端会话不能调用。钱使用整数万分之一元,服务层越出JS安全整数范围明确报错,不静默舍入。原排行只列未删除企业,返还列遵循同一企业范围;不再另设平台返还总额,保持现有排行语义。
新增统计表/触发器随迁移创建,开关 `HOME_DASHBOARD_ENABLED=false` 可暂停后台投影;页面首次无可读快照提示初始化中,保留重试入口。只在API进程后台消费;测试通过显式调用投影方法控制进度,不启动短信消费者。错误写入投影状态并记录日志,旧已发布版本可读并提示数据滞后。数据库回退保留统计资产,不回写原账務。
> **2026-09-17 用户修订:以UI V2为当前版本。** 顶部“今日业务”“今日回执”“今日营业状况”改为一行三块紧凑布局,整体占用压缩至V1约三分之一;“今日回执收益”更名为“今日营业状况”。**今日企业消费排行保持原样**:排名、企业名称、今日消费(元)、可用余额、余额状态、查看详情及导出排行保留,不替换为返还区域、不增返还列。运营状态保留。下文V1关于替换消费排行、返还区域及三排大卡布局的描述已被本修订替代;其他统计规则和按需查询保持。当前[UI V2](designs/homepage-20260917/homepage-v2.png)[修订提示词](designs/homepage-20260917/prompt-v2.txt)。仅改设计,未改业务代码。
日期:2026-09-17。状态:**设计稿,待实施**。本轮交付方案及效果图,不修改业务代码,不提交、推送或部署。
## 1. 范围与现状证据
适用运营端首页,不改变客户端首页、计费动作、供应商协议、短信发送或补发规则。实现涉及运营查询、回执统计事实及首页展示;如需增加投影表及索引,按迁移执行,不能直接在线补写业务状态。
核验本地 main`627fa7ec97a656731244f5d1a7fa93b27806cd16`;实际远端 main`4eb7b16d122da14f921093716d4ca1ed390d9e4c`,本地领先两次提交,暂存区为空。已有版本、metrics、发布工具、部署脚本和文档草稿继续保护。本轮未连接目标环境或查询线上数据库,以下是当前源码证据,不能视为线上字段覆盖率证明。
| 当前实现 | 与本次要求的差异 |
| --- | --- |
| [首页](../src/apps/admin/AdminHome.tsx)同时请求 dashboard 和 sendQuality,显示10项指标、发送趋势、审核速度、消费排行、运营状态 | 改为9项主指标、返还区域、运营状态;删除无需展示的数据及对应首页请求 |
| [dashboard查询](../api/src/operations/queries/dashboard.queries.ts)按 `SmsMessageRecord.queuedAt` 限定今日业务短信 | 收到回执的当天和原提交日期必须分开,不可继续用今日提交条件过滤所有指标 |
| 总体成功率为今日业务消息 `status=delivered` 数量 / 今日业务消息总量,保留1位小数,零分母为0 | 按用户要求保持现有口径,不改为分片成功率或全历史成功率 |
| 分片成功直接统计 `SmsMessageSegmentAudit` 成功行数 | 部分成功、跨尝试重复及缺片不满足本次业务短信整体成功口径 |
| 今日计收按今日提交且最终成功消息的 `billingUnits × unitPrice`;成本按 accepted 尝试成功分片和成本快照统计 | 新收益按今日完整成功回执归属,需要跨提交日统计并防止重复确认收入 |
| `clientDashboard()`复用旧 `dashboard()` | 不宜原地破坏旧接口;运营首页另建明确契约,客户端继续保持原行为 |
设计入口:[设计开发规范](design-development-guidelines.md)、[UI规范](ui-design-guidelines.md)、[CSS规范](css-development-guidelines.md)、[测试计划](testing-plan.md)。此方案实施后,替代运营首页旧的10指标顺序、到达率、活跃签名、消费/计收卡、两张趋势图及消费排行展示;不替代其他页面、历史测试记录和底层账务规则。与[签名质量方案](signature-quality-optimization-plan-20260917.md)共用时间/长短信约定,但不能直接复用按提交日生成的签名日报来冒充今日回执数据。
## 2. 首页信息结构
| 区域 | 保留内容 | 交互 |
| --- | --- | --- |
| 今日业务 | 今日业务短信数量、今日发送成功数量、总体成功率 | 3列,单位为条、条、% |
| 今日回执 | 今日回执分片总数、今日回执成功分片数、今日回执成功率 | 成功分片数以绿色和较大字号突出;“按提交日查看”按需展开 |
| 今日回执收益 | 今日营收金额、今日利润、今日利润率 | 单位元、元、%;独立的“按提交日查看” |
| 今日返还金额 | 今日返还总额,企业返还明细入口 | 暂按替换整个消费排行区域设计,不保留消费、余额和排行列;这是第6点的设计解释,待用户反馈可局部调整 |
| 运营状态 | 原企业认证待审、短信审核待审、模板待审、签名待审、引流信息待审、平均等待、下游投递告警 | 保留原入口和统计,不借本次任务修改规则 |
代码中“平均等待”实际上显示批量任务总数,本次按“运营状态保留”维持,同时显示“批量任务总数”说明;这是已发现的命名遗留问题,不冒称等待时长。
删除今日消息分片数、今日到达率、今日活跃签名、今日消费、今日计收等旧卡,以及今日发送趋势、审核处理速度。首页不再为了活跃签名调用全量发送质量查询。全局导航、通知、权限保留;图中导航只示意,实施不删实际菜单。
## 3. 时间、去重与分片口径
### 3.1 三个不同的时间
- T为服务器当前上海自然日,所有窗口使用 `[当日00:00, 次日00:00)`,统一 `Asia/Shanghai`,不是滚动24/72小时。
- 原提交日期沿用旧“发送总量”的 `SmsMessageRecord.queuedAt`,即业务短信进入平台的日期;不以补发时间、最新 `submittedAt` 或供应商 Submit 日期重新归组。
- 回执日期优先取 `UpstreamReceiptInbox.gatewayReceivedAt`。当前 [Gateway](../gateway/internal/upstream/deliver.go)生产事件的 `DeliveredAt` 为网关当时 `time.Now().UTC()`[intake](../api/src/send-chain/send-receipt.service.ts)将其原样存入 `gatewayReceivedAt`。不能仅凭字段名把它当运营商实际送达时刻,也不能用业务记录 `updatedAt` 代替接收时间。
- Inbox `receivedAt` 是API持久接收时间;`SmsReceiptRecord.createdAt` 是后续处理入库时间。发生积压或跨午夜处理时,二者不一定是网关接收日。旧记录缺网关时间时允许显式标注 `timeSource=inbox_received/legacy_created` 的近似兼容,不混称精确;无可靠关联/时间记录列入覆盖缺口。正式上线前只读核验各入口和历史覆盖率,不能静默默认0。
- 同一响应带 `businessDate``asOf``dataThrough``timeSourceCoverage``definitionVersion``asOf` 是查询快照时刻,`dataThrough` 才是已处理到的进度,不能将二者混淆。
### 3.2 总数:计的是业务分片当量,不是原始回执包数
先选择:原提交日期位于 T-3~T、在T收到至少一次**新的、有效关联到该业务消息的回执**。按 `messageRecordId` 去重,一条业务短信在同一天只贡献一次分母,不按手机号、通道、供应商 Msg_Id 或发送尝试计多次。
每条业务短信贡献的分片数 N 使用其冻结的 `SmsMessageRecord.billingUnits`。这是业务短信计费分片单位,与“业务短信维度去重”的要求一致,不能求和多次补发的分片。`SmsMessageSegmentAudit.segmentTotal``segmentIndex` 用于核对对应发送尝试的物理分段和完整性,不能用收到的行数替代预期总量。若不同通道实际分片数不同,首页保持业务分片当量N,成本按该尝试真实分片;两者不要混用。
历史 N 无效或与证据明显矛盾时,按真实发送时的分段记录核对并登记缺口,不能用当前正文重新分片后伪装历史事实。正常 N=3 的长短信,即使只回一个失败片,分母也加3;无需伪造剩余2片的原始回执或修改业务状态。界面说明为“按业务短信去重,包含应计未回分片”。
### 3.3 成功:整条业务短信完成成功后才计入
某条业务短信首次形成可信的整条成功结论,且其形成成功结论所需的最后一条有效回执于T被网关收到,才在T贡献N个成功分片。成功判定沿用可靠的发送尝试级收尾结果,并核对该成功尝试的全部预期分段;不能跨通道/尝试拼接成功片,不能因为 `message.status` 当前成功就把它计到每个曾收到回执的日期。
- 3片中2成功1失败,或2成功1未回:成功分片为0。
- 昨天2片成功,今天最后1片成功:今天分母3、成功3;昨天不因今天成功而补增“昨天成功分片”。
- 通道约定整条回执 `message_level`:仅在现有明确协议及 `supplier_message_level_receipt` 补偿证据支持整条成功时计N;普通分片通道单条成功不得推断其余成功。
- 首次失败后补发全成功:同一天分母只计N,成功也只计N;客户收入只确认一次。采用既有有效收尾结果,不由统计代码触发补发。
- 输送重放、同一分片相同状态重复包应全局幂等去重,不能因重新入库或重试跨日再次贡献分母。需结合原事件键、尝试、分段和状态迁移,不能只依赖含不同接收时刻的包键。
- 已最终成功后,无效的旧尝试迟到失败、重复成功只作审计,不再增加业务回执数量或撤回历史收入;真实冲突交由既有异常处理机制,禁止统计模块自行决定业务终态。
“有新有效回执的日分母”允许一条跨日长短信在两个不同接收日分别进入分母,因此**各天分母不能相加称为不重复短信总量**。成功及收入只在首次形成整条成功的接收日确认一次。页面今日比率 = 今日成功分片 / 今日总分片,禁止超过100%;零分母显示0%,提示“今日暂无有效回执”。这是对跨日未回齐场景的明确建议规则,实施测试必须覆盖。
## 4. 九项指标及财务规则
| 指标 | 计算 |
| --- | --- |
| 今日业务短信数量 | `queuedAt` 属于T的唯一业务短信数量,沿用旧 `today.sent` |
| 今日发送成功数量 | 上述消息中,首次整条成功的回执接收日也为T的消息数;不是分片数 |
| 总体成功率 | 保持旧 `today.successRate`:今日业务消息当前 `status=delivered` / 今日业务消息总数,不改精度与零分母规则 |
| 今日回执分片总数 | 第3节今日有效回执消息集合的N之和,仅T-3~T提交 |
| 今日回执成功分片数 | 第3节今日首次整条成功集合的N之和,仅T-3~T提交 |
| 今日回执成功率 | 成功分片数 / 回执分片总数 ×100% |
| 今日营收金额 | 今日首次整条成功集合的 `billingUnits × unitPrice` 快照之和;只计一次,不取充值、不按现行应用单价重算 |
| 今日利润 | 上述成功消息收入减其对应已发生通道计费成本,包括该消息此前尝试已产生的计费成本,不因只看最后成功尝试漏成本 |
| 今日利润率 | 今日利润 / 今日营收 ×100%;营收为0时显示0%及无营收说明,负利润正常显示负数 |
成本兼容现有“成功分片计费”口径:同一业务消息下所有 accepted 尝试,按各自成功的唯一物理分片乘其 `SmsSubmitRecord.costUnitPrice` 快照;失败尝试中已成功且需要付费的片也计入成本。`costAmountCents` 不能未核验语义就当最终结算金额;不能以客户N乘最后一次通道价概括多次尝试。缺审计的旧尝试,只能按明确关联的整条成功回执及可证明的分片数兼容,不以相同供应商ID跨通道串联。
本区是**今日成功回执对应的短信毛利**,不是公司净利润,也不是今日账户现金流;不包括尚未整体成功的其他业务短信亏损。若实际通道存在按提交收费等不同结算规则,应复用已有真实成本事实并扩大相关验收,不擅自修改结算方式。后续发现成功消息有额外真实费用,应有可追溯的更正版本,不能重复确认收入或无记录覆盖冻结成本。
金额内部用整数万分之一元(虽字段名为Cents,实际1元=10000单位),API使用十进制字符串或既有有界安全金额契约,禁止浮点累计;展示复用 `MoneyText/formatAmount` 最多4位、去尾零。比例在汇总后计算,不平均各行比例。
### 今日返还金额
沿用真实 `AccountTransaction` 返还语义:`refunded`,以及 `released AND relatedType=sms_message_record`,追加严格的今日上界与租户权限条件。按唯一流水统计,不同时再叠加账单状态推算退款;若同一业务退款与解冻代表两笔实际不同资金动作,以账务证据为准,不按消息ID盲目去掉一笔。
返还依今日流水日期,**不限制原短信必须在最近4天提交**;不将历史返还移到今天。总额与明细分别按相同过滤规则读取。移除消费排行后拟展示企业名称、今日返还金额及返还流水入口;企业归档/删除不得使实际返还从平台总额消失,名称使用可追溯快照或历史企业标记。保留“返还”称谓,避免把解冻全部称作已扣费退款。该模块不重复扣减成功回执营收。
## 5. 按需展开与文案
两个按钮相互独立。首次进入不请求、不预取、不后台生成这两组按提交日期的响应数据;点击才查。主卡总值仍需聚合,同源总值不等于提前查询并隐藏四行明细。
回执展开标题:**今日回执 · 按原提交日期查看**。说明:“以下只统计今天收到的回执,按短信最初提交日期分组。”
| 原提交日期 | 今日回执分片总数 | 今日回执成功分片数 | 今日回执成功率 |
| --- | ---: | ---: | ---: |
| 09-17 提交 → 今日回执(T | 180000 | 162000 | 90.0% |
| 09-16 提交 → 今日回执(T-1 | 40000 | 36000 | 90.0% |
| 09-15 提交 → 今日回执(T-2 | 15000 | 13500 | 90.0% |
| 09-14 提交 → 今日回执(T-3 | 5000 | 4500 | 90.0% |
收益展开标题:**今日回执收益 · 按原提交日期查看**。说明:“以下是这些批次今天成功回执带来的收益,不是对应提交日的全天营业额。”
| 原提交日期 | 今日回执归属营收 | 今日回执归属利润 | 对应利润率 |
| --- | ---: | ---: | ---: |
| 09-17 提交 → 今日成功回执(T | ¥8100 | ¥1620 | 20.0% |
| 09-16 提交 → 今日成功回执(T-1 | ¥1800 | ¥360 | 20.0% |
| 09-15 提交 → 今日成功回执(T-2 | ¥675 | ¥135 | 20.0% |
| 09-14 提交 → 今日成功回执(T-3 | ¥225 | ¥45 | 20.0% |
以上均为示例,与图中总数一致。每表4行、每行3指标,共12数据,不新增其他指标;分母和金额各行合计应等于同一快照主卡。比例依总分子/总分母计算。T-4及以前不混入这两个模块。
默认收起,展开处分别有加载、空态、失败重试与显式收起按钮。一次请求失败不清空其他区域;成功缓存限当前会话内存,保留时间标签,刷新失败展示“上次成功数据,更新失败”,不能回退假数据。跨午夜将旧数标为昨日,重新取当天总值;已收起明细失效但不发请求。页面刷新重新收起;快速点击去重在途请求,离开页面取消/忽略旧响应。
## 6. 建议实现
### 6.1 API与一致性
建议新增运营专用 `GET /operations/home/summary``GET /operations/home/receipt-breakdown``GET /operations/home/revenue-breakdown`,路径在实现时按既有控制器风格落地;不改变旧 dashboard/clientDashboard。总览只返回本页需要的三个汇总组、返还和运营状态。两个明细接口一次返回4行,不循环发4个请求。
明细请求携带总览签发的 `snapshotToken`;token绑定服务端日期、投影版本、数据截止时间、查询租户范围及权限,不接受前端任意扩大租户。主卡聚合及延迟查询都读同一发布版本;版本失效返回明确刷新要求,先刷新主卡再查明细,避免不同时间查询后四行与卡片不一致。不能只传 `asOf` 却查询可变的当前终态。
认证和运营财务权限分别校验;无财务权限返回不可见状态,不返回金额并仅前端隐藏。匿名401、越权403、非法日期/token400或明确失效状态;响应不暴露号码、短信内容、凭据。客户端不能通过复用接口取得其他企业数据。
### 6.2 耐久统计事实与性能
现有表足以找到原始字段,但缺少专门表达“某日首次形成完整成功及其金额版本”的稳定首页事实。建议增加只读用途的投影,而不是每次首页扫描全部历史发送/回执并依当前状态推断历史。
建议逻辑模型(均为待新增,不是当前已存在功能):
1. `HomeReceiptMessageDay`:接收日期、业务消息ID、原提交日期、租户、N快照、首次有效事件、成功认定事件、接收时间来源、revision;唯一键为接收日+业务消息ID。
2. `HomeMessageSuccessFact`:业务消息ID唯一、成功尝试ID、完成成功所需最后回执接收时间、收入/成本快照及更正版本;解决跨日收入一次确认。
3. `HomeProjectionRun/Version`:发布版本、截止游标、覆盖缺口、租约/fence/失败状态。分批投影更新先写不可变版本或版本化事实,完整提交后原子发布;保留页面token有效期覆盖的版本,不能用单行可变upsert假冒一致快照。
消费既有持久回执/收尾事件增量生成事实,按业务消息串行化或revision比较,事务中写事实与消费游标。崩溃重试不重复计入;并发worker需要租约/fence;成功事实必须在整条收尾提交后才能发布。投影消费者只写统计表,不发送短信、不改变收尾、账务、通知行为。乱序先按事件时间和可靠收尾结果重建该业务消息,不能直接累加包数。
迟到处理保持原网关接收日,源事件先到而发送关联后到需可重试;pending/unmatched、缺时间、缺分段不能静默当0。汇总元数据提示“统计处理中/部分数据待核对”,不额外增加用户未要求的首页业务KPI。
建议索引以真实EXPLAIN决定:Inbox网关接收时间+状态+关联消息,消息queuedAt范围,投影接收日+租户+原提交日,成功事实唯一业务消息与接收日。现有Inbox按receivedAt索引不等于按gatewayReceivedAt过滤可直接高效命中。避免OR大范围扫描,旧时间来源分支可UNION ALL后去重。生产建索引需评估写放大与并发索引迁移,不未经现场测量宣称提速。
默认总值和两个展开分别缓存、按日期/租户/定义版本隔离;投影可以生成业务事实,但不得预执行四行明细聚合。初步目标为代表性四日数据下首页API p95≤1秒、展开p95≤1秒,具体SLA以真实数据基线确定;测试同时记录CPU、扫描行数、内存、队列延迟,不只报告一个平均耗时。
### 6.3 历史与恢复
上线前先只读核验4日范围内时间字段覆盖、分段/计费单位差异、协议整条回执比例和补发成本,再使用分页、限流、可暂停的投影补建。无历史完整证据须标注覆盖缺口,不追造网关时间或假装完整成功。用旧/新查询影子对账解释差异,不能要求新口径总值强行等于旧口径。
新增表为可重建统计资产,原始收尾、Inbox及账务不可改写。功能开关默认保持旧首页,迁移与补建完整验收后切换;回退只回退读路径并保留投影,不删数据、不回滚客户账务。发布、补建执行及真实短信验收遵循独立授权。
## 7. UI设计稿与适配
![首页 UI V1,示例数据](designs/homepage-20260917/homepage-v1.png)
图为默认收起状态。白色侧栏、浅灰底、三组横向指标;成功回执使用浅绿背景和绿色数字,其余金额沿用普通深色,负利润用红色。金额不采用整数/小数两种字号。页头显示上海日期、刷新入口与数据截止提示;设计图示例标识只用于设计交付,不作为上线UI固定文案。
实施复用现有Breadcrumb、Button、Table、MoneyText及原AppShell;页面CSS仅归 `AdminHome.css` 且限定页面根类。图中间距/字体为视觉意向,实施严格采用现有字号、8px圆角及248px侧栏规范。1600×1000三列;1366×768允许内容纵向滚动;390×844侧栏折叠、指标单列、两张明细表各自横向滚动,不允许整个页面横向溢出。
生成方式:内置imagegen,主提示词见 [UI提示词](designs/homepage-20260917/prompt.txt)。本轮仅视觉设计审阅,不是浏览器或真实API验收。
## 8. 实施顺序、验证与交付边界
1. 字段覆盖和真实四日数据只读基线;确认旧成本与通道协议事实。
2. 时间/去重/完整成功纯规则及投影迁移、恢复机制;真实PG校验后再接查询。
3. 运营专用API与快照token,四日数据独立对账;客户端回归。
4. 首页9项指标、返还区域、两个按需展开;删除多余查询,保留运营状态。
5. 真实后端页面三尺寸与网络断言、定向及全量回归、类型/生产构建/格式/样式/安全等现有门禁;如改Gateway,补Go测试和vet。
验证成本主要在跨日长短信、重复/补发、价格快照、迟到与故障恢复,而不是卡片布局。实现需完整覆盖下列验收,并同步需求/测试进度;发送/补发测试须另有明确环境、数据和负载授权,本方案本身不授权触发短信。
| 用例 | 待验证预期 |
| --- | --- |
| HOME0917-01 | T短信、T-1T-3短信的今日回执纳入;T-4排除;北京时间午夜边界正确 |
| HOME0917-02 | 3片仅一个失败回执:总3、成功0;2成功1未回仍成功0 |
| HOME0917-03 | 全3片成功才成功3message_level有协议证据才计整条成功;不同尝试不得拼片 |
| HOME0917-04 | 同日重复包/乱序/重放/补发按业务去重;只确认一次成功和收入 |
| HOME0917-05 | 昨日2成功、今日末片成功,今日计总3成功3,昨日成功不追增;已成功后的重复包不再计入 |
| HOME0917-06 | 网关23:59接收、API次日处理仍归原接收日;缺时间字段显示覆盖说明 |
| HOME0917-07 | 今日发送成功限今日提交且今日形成成功;总体成功率旧公式保持 |
| HOME0917-08 | 收入使用历史价格,成本包含对应消息之前尝试已计费成功片;多通道成本、负利润、零营收、万分之一元精度正确 |
| HOME0917-09 | 返还按今日唯一流水,与企业明细汇总一致;历史提交退款可纳入,已删除企业不漏账 |
| HOME0917-10 | 首屏零明细请求;单击仅请求所属组,四行12值,同一版本与总卡勾稽;缓存/快速点击/午夜失效正确 |
| HOME0917-11 | API错误保留带时标的真实旧值,首次失败不显示假0;登录和财务/租户权限隔离 |
| HOME0917-12 | 投影并发、崩溃、重试、过期worker、乱序与补建完整性;发布半成品不可见,token过期不混版本 |
| HOME0917-13 | 三尺寸、刷新/跨路由、展开表格、键盘焦点、空态/失败及控制台;删除旧模块但保留运营状态和客户端行为 |
| HOME0917-14 | 代表性数据EXPLAIN、p50/p95、CPU/内存与队列影响;索引/迁移/回退只读路径验证 |
当前完成:代码字段核查、规则方案、UI图和文档一致性检查。未完成:业务代码、迁移、真实API/PG验收、浏览器三尺寸、性能基线、提交、推送、测试部署及预生产部署。
## 9. 2026-09-17 实施与验收结果
当前实现已完成V2紧凑三栏、9指标及独立按需明细;保留原企业消费排行,新增今日返还金额列,详情和CSV同步。入口为运营专用 `/admin/operations/home/*`,客户端旧dashboard不变。四个投影表、两项迁移、源变化事务触发器和500条批次投影已实现;重关联同步失效新旧消息,版本事实/消费确认同事务。首次未就绪返回初始化状态,增量/未匹配回执/失败/时间近似均明确提示。已使用快照绑定用户、日期和15分钟期限;总览创建与版本回收通过数据库读写锁协调,明细读取不可变版本。仅过期首页快照和不被活跃快照使用的投影版本限量回收,源短信/回执/账务不删除。
实际截图:[桌面完整页面](designs/homepage-20260917/homepage-implemented.png)、[营业明细展开](designs/homepage-20260917/homepage-implemented-expanded.png)、[窄屏](designs/homepage-20260917/homepage-implemented-mobile.png)。来自本机真实Nest API、独立PostgreSQL16与隔离Redis中的验收数据,不是设计图或线上数据。Browser插件技能未提供,按既有授权使用Edge/Playwright。三尺寸1600×1000、1366×768、390×844及1600×1120完整截图通过;首次无明细请求、不调用旧dashboard/send-quality;分别点击才加载、刷新收起、错误保留真值、详情及含返还列CSV通过,页面异常0。
自动测试API79套854项、前端35文件163项通过;API覆盖率67.89%/53.10%/68.77%/70.65%,增量87.98%/77.03%/95.91%/91.22%,前端88.48%/85.09%/84%/88.19%,均通过各自门槛。类型、生产构建、定向ESLint、全量CSS样式、CSS治理及结构/包体检查通过。后续微调已补定向真实验收;不以历史测试数字冒充线上发布证据。
真实PG验收见 `tools/testing/verify-home-dashboard.mjs`,新库110项迁移通过;四日、缺片、重复、跨日、快照旧值、退款边界、归属/失效、事务回滚和认领已验收。附加真实故障注入确认投影写失败整批回滚、重试恢复,源更新与确认竞态不丢待处理记录。1/2/3/4片及旧无审计、整条协议回执、补发之前已成功片成本有定向规则测试。副作用测试仅在本地隔离库,不发送短信或向供应商/客户投递。
本地2020消息、增加6000片样本:2000条增量投影约4915ms,总览p50约46.8ms/p95约54.5ms,展开p95约20.1ms。未验证线上四日大数据、持续吞吐、整机CPU/峰值内存和外部供应商链路;HOME0917-14生产规模部分保留待验证,不作为容量承诺。初次数据库端口、Redis旧RDB格式以及浏览器关闭按钮/窄屏隐藏表头定位失败均留日志,修正后通过。Redis5.0版本建议保留,未改依赖。
本轮授权为提交、推送;不部署。两项迁移及首次投影初始化需在后续授权发布时执行,线上仍是旧首页。原始日志在忽略目录 `.local-data/homepage-implementation-20260917/`,不含密码、令牌或浏览器认证状态。
@@ -0,0 +1,191 @@
# 签名退网检测与签名质量四页查询优化方案
维护日期:2026-09-17。状态:**本地实现及隔离验收完成,待发布**。用户于本轮授权修改并本地提交;没有授权本轮推送、部署、历史上行认领或真实短信/通知发送。第1节保留实施前取证;本轮实现与验收以第8节为准。
本文是本次三项需求的统一实施设计,补充[签名清退设计](signature-retirement-alert-design.md)。实施后替代该设计第6节、第7节第15步中“热力图直接读检测快照、按当前报备维度展示、前端筛选分页”的实现方式,以及[运营修复设计](operations-fixes-20260908.md)第4项中的客户端热力图分页;保留四TAB独立状态、默认25条、10/25/50/100档、D-1至D-30列序和原业务统计单位。现阶段旧代码仍在运行,不能把本方案描述为已上线功能。
本方案仅针对“签名质量检测”四TAB与签名退网检测,不改变“报表对账/发送质量报表”的既有T-4~T-1刷新任务,不改变短信门禁、补发、计费、余额或报备状态。
## 1. 核查结论与证据
### 1.1 基线
- 本地main与实际远端main均为`4eb7b16d122da14f921093716d4ca1ed390d9e4c`,暂存区空;已有版本、metrics、发布工具、部署脚本及文档草稿保留。
- 2026-09-17 09:37北京时间只读核验预生产运行`010ba3216889032a6160cdb14d8536b616ae7102`。上述两个提交间本方案涉及的页面、质量查询和退网检测源码没有差异。
- 证据:`src/apps/admin/AdminAnalyticsPage.tsx``src/api/admin/signature-retirement.api.ts``api/src/operations/queries/quality.queries.ts``api/src/signature-retirement/signature-retirement.service.ts`、对应controller及`api/prisma/schema.prisma`
- 本机原始取证在忽略目录`.local-data/cpu-20260917/``prom.json``detail.jsonl``design-audit.json`及只读脚本。线上DB使用只读事务与8秒语句超时,未启动应用调度器。此次未执行登录后的四TAB真实浏览器/HTTP复测;以下交互结论来自当前源码,不冒称已复现浏览器串数据。
### 1.2 凌晨CPU与检测成本
今天检测日2026-09-17的结果从04:00:01.620写到04:04:07.792,共5,831条(企业1,431、通道4,400)。前一天4,280条。Prometheus五分钟CPU均值峰值58.79%04:06:45为32.81%04:06最近一分钟忙碌率约2.54%,趋势包含此前负载。日报刷新日志完成于00:33:09。
同窗口`SmsMessageRecord``SmsSubmitRecord`顺序扫描行速率峰值分别约362万、536万行/秒;这是数据库重复访问行的计数,不是短信量或TPS。IO等待较低。检测时段、真实快照与数据库扫描高度吻合,是主要负载线索;缺少历史业务进程CPU与SQL耗时采样,不能量化各进程占比或断言唯一原因。
现有`runDetection`逐个维度调用`activityCounts`:至少一次单日查询,完成观察期后再查一次规则窗口;每次关联消息、提交、分段/回执。已有检测是否存在的判断在这些计算之后的`persistDetection`中,重启补偿仍可能先重复计算再退出;周期、抑制、检测记录还存在逐行读写。现有唯一键保证部分去重,不等于任务已具备全程原子认领和完整事务恢复。
### 1.3 四TAB是否关联
| TAB | 当前请求与数据来源 | 当前关联/独立性 | 与目标的差距 |
|---|---|---|---|
| 签名通道发送质量 | `GET /admin/operations/signature-quality`;所有日期实时聚合消息、提交、分段与回执 | 独立日期、关键字、分页、请求序号;详情使用本TAB结果 | 历史也查明细,无近三天报表/冻结分支 |
| 企业签名活跃度 | `GET /admin/signature-retirement/heatmap?date=D` | 独立组件状态,但接口返回企业和通道全部数据,再在前端选企业、过滤、排序分页 | 使用次日检测快照而非可刷新的活动日报;行集合依赖当前通过任务 |
| 通道签名活跃度 | 同上 | 独立发起请求,但再次拉取相同两类数据,再在前端选通道 | 同上,整月全量传输和处理重复 |
| 未报备签名 | `GET /admin/signature-retirement/unreported-signatures`;所有日期查消息正文及当前有效签名库 | 独立日期、关键字、分页与结果 | 历史实时重算;补登记/删除签名会改变旧日期结果 |
结论:没有发现四TAB共用筛选状态或一个TAB直接改写另一个结果的实现;存在两个活跃度TAB共用全量接口与数据源,且活跃度展示依赖退网检测任务。底层业务事实本来有关联,不能为“页面独立”复制出四套互相矛盾的短信事实;应解除请求、状态、失败和生成触发之间的耦合。
另需同步修复的本TAB日期歧义:未报备TAB编辑顶部日期后,卡片内“查询”仍按`appliedDate`请求,须先点顶部“查询统计”才能应用新日期。这不是跨TAB串数据,但会造成查询条件似乎未生效。
### 1.4 三档取数规则当前未实现
质量与未报备页面全部日期实时查库;活跃度使用`SignatureRetirementDetection`快照,`persistDetection`已存在即返回,不会连续刷新最近三天。历史展示又拼接当前签名名称和当前报备维度,因此既不是完整的实时统计,也不是稳定冻结日报。
已有`DailyQualityReport`服务另一个报表模块,按计费单位统计,维度缺少本页完整的运营商、通道、引流拆分、去重业务数与到达耗时分子分母,不能直接拿来替代本页。既有财务/质量报表T-4~T-1策略也不自动等同本次要求。
## 2. 已确认业务规则
### 2.1 日期定义和数据来源
T始终指服务器按`Asia/Shanghai`确定的真实今天;D指页面所选日期;d指实际统计自然日,均不依赖浏览器所在时区。用户已确认:**每天凌晨同时刷新T-1、T-2、T-3;热力图保留所选日之前30天。**
| 实际统计日d | 查询行为 | 后台生成/更新 |
|---|---|---|
| d=T | 本次查询访问真实业务库,在一致性读视图中聚合 | 不将昨日缓存冒充当天数据,不写退网检测或通知 |
| T-3≤d≤T-1 | 只读已完整发布的日报 | 每天凌晨刷新最近三个完整自然日,吸收迟到回执 |
| d≤T-4 | 只读冻结日报 | 普通调度、页面查询、进程重启均不得自动重算或覆盖 |
例:今天9月17日,9月17日实时,9月14~16日报表可刷新,9月13日及以前冻结。9月18日可刷新范围成为9月15~17日;9月14日报表保留9月17日最后成功发布的版本,不在成为T-4后再额外重算。刷新判断相对真实今天,不能相对用户选择的历史日期重新开放冻结区。
热力图选D=9月17日仍展示9月16日至8月18日(D-1至D-30),没有当天列;每一列按该格子的d判断可刷新/冻结。单日质量与未报备TAB选今天时采用实时路径。禁止选择未来D来变相让热力图出现今天列。
冻结意味着“固定统计截面”,不意味着所有短信此时都已有最终回执。冻结后迟到回执继续正常更新短信业务事实,不改已冻结报表,不把未知自动改成失败;报表与当前详单存在截面差异时明确显示截止时间。跨日补发也不得使已冻结日的历史报表被普通任务改写。
### 2.2 独立查询的边界
- 四TAB各有日期草稿/已应用日期、过滤草稿/已应用过滤、排序、分页、pageSize、请求取消/序号、loading/error和结果,任何查询只更新本TAB。
- 首次打开TAB只请求该TAB;切回保留自己的条件及已取得结果,用户查询/刷新只重查当前TAB。隐藏TAB不自动重查,不用另一TAB成功响应填补失败。
- 企业、通道活跃度请求明确携带不同`dimensionType`,服务端只处理所选维度,按过滤后的完整30日合计排序再分页,只返回当页维度及其30格。
- 日期与搜索“查询”统一应用本TAB当前全部草稿条件并回到第一页;翻页只使用已应用条件。特别修正未报备卡片查询沿用旧日期的问题。
- 底层只读聚合器、数据库连接池和已发布日报可以复用;页面查询不触发其他TAB请求、不触发报表重建、不运行退网检测、不创建预警。
### 2.3 指标和历史维度
- 质量列表/运营商概览以业务短信为单位;通道矩阵以真实发送尝试为单位;保留现有不同成功率分母并在契约逐字段登记,不把计费片数换成业务条数。
- 业务统计按`SmsMessageRecord.queuedAt`归日;通道尝试及活跃度按现有`COALESCE(submittedAt,createdAt)`归日。跨日补发可能使列表与矩阵日期归属不同,应分别生成,不能先用当天业务列表裁掉当天实际提交、但原消息属于其他日期的事实。
- 企业活跃度在统计范围内按messageRecordId去重,通道按messageRecordId+channelId去重;不直接累加通道值得到企业值。规则窗口跨日也须重新去重,不能把每日distinct数简单相加。热力图“30日合计”保持每日展示值之和并标注为日活跃量合计,区别于预警窗口去重人数/条数。
- 成功必须按发送尝试完整分段事实判断,缺片/缺回执仍为未知;长短信不能因已到的几个分段都成功而提前成功。旧记录无分段事实时才按既有明确回执兼容;不把上游接受当送达。
- 平均到达时长持久化成功耗时总和与样本数,展示时相除;不能平均各组平均值。列表、抽屉、运营商概览及引流三类拆分使用相同generation版本。
- 日报保存当次统计维度名称、企业应用归属、运营商、报备适用状态/通过时间与统计规则版本。历史筛选使用报表快照字段,不能inner join当前通过任务导致历史行消失。活动日报逐日记录适用性;尚未通过、不适用、已通过且零发送、未生成必须区分。
- 未报备仍指规范正文签名在该企业应用有效签名库中不存在,且原消息无signatureId,不等于缺少通道报备。实时用查询时签名库;近三日用本轮生成时签名库;冻结日保留最后发布判定及名称。此后补登记不得追溯抹除冻结历史。
## 3. 性能优化方案
### 3.1 优先消除重复计算
1. 将逐个签名×通道×运营商反复扫描,改为按统计日有界批次聚合;先限定日期与候选消息/提交,再对这些submitId批量归集分段/回执,避免每个维度重新读取相同事实。
2. 一次读取参与检测的规则、通过任务、既有检测键与抑制摘要。已经完成的检测批次直接退出;部分完成只恢复缺失维度,避免昂贵计算后才查存在性。
3. 单日活动日报与质量日报复用经核验的尝试分类逻辑,按各自归日维度生成结果。退网“是否活跃”的窗口只需要accepted去重,不为判断阈值重复关联所有回执;送达率由日报单独计算。
4. 预警窗口最长支持现有365天,按实际生效规则的最大窗口限制扫描,批量JOIN规则维度后按message/channel去重。企业跨通道、跨日不能直接SUM日报;在测试库比较批量COUNT DISTINCT与适量窗口分组方案后选定。禁止以改成30天窗口偷换规则。超大窗口必要时分阶段物化紧凑去重键,但须先盘点容量,不能本轮默认复制全量短信正文或全部历史明细。
5. 批次发布后退网任务引用已完成单日活动版本,并独立保存当时窗口判断及规则版本。刷新日报不更新既有检测决策、不推进旧周期、不重新生成通知。
### 3.2 索引和SQL计划
预生产真实索引已有message.queuedAt、submit.messageRecordId,但未见消息的signatureId+carrier组合索引,也未见submit的`COALESCE(submittedAt,createdAt)`表达式时间索引。不能仅因没有索引就断言某一SQL必然全表扫描。
候选包括消息(signatureId,carrier,id)、提交(COALESCE(submittedAt,createdAt),messageRecordId)、按实际计划需要的通道+有效提交时间,以及日报日期/维度/排序组合。保留表达式原语义,不能直接用createdAt替代submittedAt。候选须通过有代表性数据的`EXPLAIN (ANALYZE, BUFFERS)`验证选择性、loops、临时文件和写入成本后决定,不盲目全部加索引;本轮未执行重查询或建索引。
大表索引上线采用平台支持的在线建索引流程,明确非事务DDL、失败无效索引检查及重试;具体迁移方式在实施时结合Prisma发布门禁验证,禁止把不允许在事务中的DDL塞进普通事务而绕过错误。不全局扩大work_mem,不默认增加SQL/worker并发。
### 3.3 页面成本
- 活跃度改服务端过滤、排序、分页,只传单类维度的当页30格;四TAB仍各自独立,不将两个活跃度结果捆绑返回。
- 查询快照元数据和维度使用Map/数据库JOIN,不逐条Array.find造成大集合反复查找;保留已有模块级日期格式化器。
- 首屏与列表查询仅取所需字段;若详情改独立请求,必须携带列表generationId以保证详情一致,不能点击详情又扫原始历史短信。
- 当天实时查询采用有界日期、分页和超时;失败保留当前TAB最后真实结果并标注其日期/时间,不能把旧值作为新查询成功。跨午夜请求由服务端固定T并回传,前端不自行混合两个自然日结果。
## 4. 日报生成、冻结和恢复
建议签名日报任务北京时间03:00启动,依次处理T-3、T-2、T-1,与已存在财务日报错峰;04:00退网检测、08:00通知时点保留。固定时间是拟实施配置,尚未上线;也必须检查与其他后台任务是否重叠。
生成任务具备耐久状态`pending/running/succeeded/retry_wait/failed`、租约、递增fence和有限重试;按scope+businessDate唯一认领,旧worker过期后不能发布。每个日期在一致性数据库快照下生成候选版本,完成四类校验后短事务切换publishedGenerationId。部分失败保留完整旧版;其他日期可继续,但整轮不得虚报全成功。发布必须再次验证fence、日期是否已冻结和候选水位。
页面只读published版本;生成中有旧版则显示旧版及刷新中/上次成功时间,没有旧版显示“报表尚未生成”,失败显示失败,不能返回空数组冒充零数据或回退扫描明细。合法零数据日期也必须生成完成清单与0行标记。
日界线进入T-4后自动禁止覆盖;跨午夜仍在运行的旧任务在发布校验时被拒绝。若T-3最后刷新失败,冻结上一成功版本并显示未达到预期截止时间;若从未成功则保留缺口,不伪造冻结完整。错过整个三日窗口的缺口只能进入显式历史补建流程,不能借“启动补偿”无限回算。
04:00所需日报未完成时,检测等待该日完成且告警;08:00只发送已完整完成检测的结果,不把半批维度当完整预警。后续恢复沿用当日补偿与幂等,不补发旧日期通知。周期变更、检测行、通知意图在维度事务中一致提交;全批完成标记必须在全部维度校验后置位。通知仍按日期+企业应用聚合及现有抑制规则,重试不能重复创建消息/Webhook。
日报刷新三天不等于重新做三天的预警判断。`SignatureRetirementDetection`保留历史判断审计,页面活动日报是另一投影,两者截止时间可能不同,页面标明统计口径而不是互相覆盖。
## 5. 拟新增模型与接口
以下名称仅为设计,不表示Prisma已有模型:
| 模型 | 主键/唯一维度与作用 |
|---|---|
| SignatureAnalyticsDay | businessDate唯一;publishedGenerationId、state、generatedAt、sourceAsOf、frozenAt、schemaVersion、coverage/provenance、rowCounts/checksum;成功零行也记录 |
| SignatureAnalyticsRun | scope+businessDate+generationId唯一;owner、leaseUntil、fence、attempt、checkpoint、startedAt/finishedAt、error摘要;每scope/date最多一个有效运行者 |
| SignatureQualityDaily | generationId+metricKind+tenant/application/signature+carrier/channel/drainage规范化维度键唯一;区分business与attempt,计数、耗时分子分母、名称快照 |
| SignatureActivityDaily | generationId+dimensionType+tenant/application/signature/channelKey/carrier唯一;单日提交、accepted业务数、送达数、适用性及报备快照 |
| UnreportedSignatureDaily | generationId+tenantId+applicationId+规范正文签名键唯一;业务数、生成时判定及名称快照 |
空channel/application使用无碰撞规范键,不能依赖可空字段唯一约束防重复。行均有真实businessDate和generationId外键;普通查询仅取发布版本。预警任务可复用耐久Run机制但scope独立,不与日报共用完成状态。旧版本保留期限和空间预算须在实施/发布阶段核验,不自动清理现有业务记录。
API建议:
- 质量与未报备保留现有GET路径和date/keyword/page/pageSize,增加`dataSource=live|report``reportState=ready|refreshing|failed|missing``mutable/frozen`、businessDate、serverBusinessDate、generatedAt/sourceAsOf、generationId和schemaVersion。数据源由服务器决定,客户端不能要求重算冻结日。
- 热力图提供独立版本路径`GET /admin/signature-retirement/activity`,必填dimensionType=enterprise|channeldate为D;企业/应用/签名/通道过滤分别传入,pageSize只接受10/25/50/100。返回单类当页维度、total和D-1至D-30;逐日期带版本/完整性元数据。保留旧heatmap GET只作兼容,最新页面不再调用;记录旧调用后再决定移除,不让旧前端接收截断结果冒充完整数据。
- 活跃度筛选、30日排序、总数与当页结果在同一个一致性读事务获取;先在过滤全集排序再LIMIT。缺失日期不可当成0参与已完整合计,须展示“部分日期缺失”。冻日前名称和维度只来自报表。
- 日期严格验证真实日历、拒绝未来日;页码为正整数、pageSize有上限,排序白名单。请求取消和旧响应隔离按TAB独立实现。
- 沿用运营端鉴权及数据可见范围,在数据库查询前约束tenant/application;不新增匿名查询/生成接口,不以报表缓存绕过权限。真实HTTP401/403、越权筛选和缓存键隔离须专项验收。
## 6. 历史切换、发布和恢复
1. 在真实数据规模的隔离测试库建立旧口径对照,包括长短信、补发、跨日和未报备;冻结字段契约,修正差异而非迎合旧错误。
2. 增量新增模型/索引与后台生成器,旧接口继续运行;候选生成器先影子计算,不产生通知,完成准确性与负载对照后切换。
3. 盘点可支持历史日期的源数据覆盖、报备轨迹、消息/回执保留和磁盘空间。一次性按明确日期清单补建历史日报,先满足热力图至少30日与用户常用查询区间;预警规则窗口另覆盖实际最大365日需求。没有可信历史事实就标记缺口,不把当前报备状态当作过去状态。
4. 旧检测快照可作为活动量迁移证据,但不能冒充补齐迟到回执后的完整日报。用原始事实重建的历史报表须记录backfilledAt/sourceAsOf/provenance,不能伪造为当年T+3冻结结果。完整性不足的日期不宣称已完成,历史可查询范围明确展示。
5. 迁移期不复制历史周期、不重发历史消息。生成和读取按schemaVersion切换;切换后禁用旧逐维度检测调度入口,防止双跑。先测试环境验收,再另按授权发布预生产。
6. 应用回退保留新增表和已发布冻结版本;异常优先暂停新任务、保留只读已发布日报。旧应用会恢复历史实时查询行为,不能称之为满足新冻结规则的等价回退,须在恢复说明中明确影响。不自动恢复数据库或删除候选/旧报表。
## 7. 验收、观察指标与实施顺序
详见[功能用例](system-functional-test-cases.md)中`TC-SQA-20260917-0116`,本地执行覆盖及环境未执行项见第8节。
- 准确性:真实PostgreSQL逐字段对账;短/长短信、缺片、重复乱序、失败补发、跨通道/跨日去重、未知和迟到回执;旧历史无分段兼容;成功率分母及耗时加权正确。
- 冻结:T、T-1、T-3、T-4、跨午夜/跨月/闰日;刷新原子性、版本一致、补登记不改变冻结未报备、名称/通过状态变更不让历史行消失;失败与零数据可区分。
- 独立性:四TAB首次/切换/查询/分页/详情;一个TAB失败、慢响应或修改日期不影响另一个;企业请求不含通道维度;真实HTTP与三尺寸浏览器核验,不仅用mock证明功能。
- 可靠性:双worker抢占、租约超时接管、旧fence发布被拒、批次部分失败、任务重启、错过冻结窗口、08:00依赖未完成、重复通知去重。报表刷新不写短信/余额/报备/通知状态。
- 性能记录:整机及PG/API进程CPU、数据库扫描行/块、SQL耗时/p95、任务耗时、锁等待/临时文件、响应字节、API内存、短信队列延迟;分别标注冷/热缓存和相同业务规模,不把一次结果当容量承诺。
- 拟验收目标:相同数据规模,退网原始明细扫描行数下降至少80%,任务耗时下降至少50%;单类热力图每页最多100×30个格,响应规模不随未选维度总数增长。此为测试目标而非已实测收益;不能单凭CPU目标判定成功。若准确性/业务队列恶化立即停止候选压测,不在预生产重新跑整套重任务验证猜测。
实施分三批:①独立接口/页面查询及精确统计契约;②日报三日刷新/冻结/历史补建与版本化读取;③退网批量聚合/原子任务/依赖编排及性能对照。批次①不宣称已完成报表化,③完成前不宣称CPU问题已解决。
这是前端、查询后端、数据库模型和后台调度的跨模块改造,主要成本在历史回填、口径对账和故障恢复验收,不能按简单索引补丁处理。实施需API/前端定向与全量回归、类型/构建/质量门禁、真实PG/接口/浏览器;若不修改Gateway无需冒充重做Gateway发布。真实短信、外部通知及压测只在后续明确授权的环境和范围执行。
## 8. 2026-09-17 本地实现与验收
### 8.1 实现及设计落地
- `api/src/signature-analytics/`提供北京时间日期校验、日报聚合、版本读取、后台调度和独立退网批次。03:00后依次刷新T-3/T-2/T-160秒补偿检查;04:00检测与08:00通知保留。日报失败不降级实时查询,未生成与合法零结果分开;热力图继续D-1~D-30。
- 新增六表:Day发布清单、Generation不可变版本、Run耐久任务、QualityDaily、ActivityDaily、UnreportedDaily。日报行和发布指针以generationId+businessDate复合外键保证日期/版本一致。质量嵌套矩阵和概览保存在同一JSON快照,检索字段独立列化;引流分组保存耗时总和/有效样本数并加权合并,不平均组均值。
- 与原建议“候选后短事务切指针”相比,本期采用**每个自然日一个RepeatableRead事务**,聚合、分批250行写入、发布与fence验证一起提交。理由是保证同日各口径取自同一源快照,避免未持久化源水位的断点续算混入不同截面。单SQL90秒、事务120秒、租约300秒;失败重算该日,成功日期不重算,最多5次指数退避。事务上限短于租约,不需要保持长事务跨租约续期。超过此规模应先扩展分阶段水位设计,不能直接无限增大超时。
- 退网窗口只扫描accepted提交,批量按实际规则窗口(最长365天)去重,单日送达数复用活动日报。规则与所引用日报版本在首次认领后持久化为checkpoint,重试不改变;检测、周期及冻结通知正文同事务。完成检查前移;日报刷新不修改已有检测/消息。缺昨日当日刷新版本时等待,不消耗检测重试次数。通知沿用原耐久消息/Webhook去重,同进程完成后不每分钟重扫全部消息;重启可补偿,禁止自动补发历史检测。
- 两个活动TAB改用`/admin/signature-retirement/activity`,明确dimensionType;服务器过滤、排序、分页和完整性读取处于一致性事务。每页只返回所选维度,最多100×30格;空的越界页仍返回正确总数。旧heatmap接口保留兼容旧页面,当前页面不调用。四TAB查询/取消/错误/日期/筛选各自独立,未报备卡片查询应用新日期。
- 普通历史读取只访问日报快照,无当前报备/名称JOIN。报备轨迹可还原时取历史状态与通过时间,旧数据仅有明确approvedAt时保留兼容证据;缺完整历史轨迹不宣称重建了过去所有状态。无报备资格的格子N/A,缺日报的格子显示缺口。旧记录完全无分段审计时保留明确回执兼容;存在分段时按预期总片数判断,缺片不提前成功。
- 迁移`20260917030000_signature_analytics`新增表;`20260917031000_signature_submit_effective_at`单独以非事务`CREATE INDEX CONCURRENTLY`建立有效提交时间表达式索引,避免将所有历史源扫描混进三日任务。108项完整迁移已在独立PostgreSQL库执行。线上失败须检查`pg_index.indisvalid`及迁移状态,按发布恢复流程处理;不自动DROP/resolve失败迁移,不绕过门禁。
### 8.2 历史补建与发布边界
离线入口`tools/testing/backfill-signature-analytics.mjs`使用已构建API,要求环境变量DATABASE_URL和匹配目标主机的`--host`,逐个明确`--dates=YYYY-MM-DD,...`;默认只检查,明确`--execute=yes`才生成。禁止未来/当天、隐式范围和覆盖已有发布版本。最多366个显式日期,逐日顺序生成;不初始化应用调度、不运行检测/通知,结果标记`backfill-current-source`与实际sourceAsOf,页面标明“事后补建”。本轮只在隔离库验证,线上补建未执行。
发布时需先核对源数据覆盖、实际报备历史与存储空间,生成影子日报并对账,至少为常用D准备此前30日,再切换页面;缺失日期保持可见缺口。新版本/旧版本表行均保留,不自动清理。回退应用保留新增表与冻结版本,但旧应用将恢复旧历史查询行为,不是业务等价回退。本轮没有推送或部署,也没有声称线上CPU已下降。
### 8.3 已执行证据及限度
- 真实PostgreSQL16隔离集群127.0.0.1:16435,空库完整迁移;`verify-signature-analytics.mjs`覆盖日报三日、冻结、跨日去重、2/3/4片、缺片、旧无分段回执、耗时加权、超出页码、重复认领、旧fence拒绝、失败回滚/重试及规则快照。验收数据只在本机专用`cmpp_qa_signature_*`库,脚本拒绝其他主机/库名。
- `verify-uplink-matching.mjs`真实事务验证重复事件、应用归属、供应商MO ID保留、并发人工认领和通知存储故障回滚。故障注入仅模拟存储错误;实际数据/事务/通知意图均为PostgreSQL,未调用外部Gateway发送。
- 真实完整Nest应用、PostgreSQL和隔离Redis,生产构建页面在1600×1000、1366×768、390×844打开/切TAB/查询/刷新,页面异常0;HTTP正常查询200、匿名401、非法维度/分页400。浏览器认证会话仅内存传递,不保存密码、令牌或storageState。Redis本机为5.0.14.1,有BullMQ建议6.2警告;本轮未将其视为生产队列能力验收。
- 前端全量35文件/163项、API全量78套/842项;类型检查、构建、代码结构、格式、ESLint、CSS治理、样式和Bundle门禁记录在testing-progress。Gateway代码未改,不重复冒称实发/协议验收。
- `benchmark-signature-analytics.mjs`对照4eb7b16原活动聚合:30000条消息、600维度,本地热缓存旧逐维度10343.912ms,新批量624.940ms,减少93.96%accepted计数逐维度相等。EXPLAIN原单维度实际源表访问30113行,按600维度估算约1806.78万;新批量60039行。旧总行数是样本外推,不是全批逐SQL实测;均为热缓存,未产生临时磁盘块。这里只证明活动聚合环节,整个日报+365日检测批次的CPU、p95、内存、线上队列影响、真实高密度回执规模仍须部署前/后专项验收,不能宣称全部性能目标已达标。
- 本机原始日志、截图和故障证据在忽略目录`.local-data/signature-optimization-20260917/`。初次迁移目录未生成SQL、测试通道缺carriers、断言漏计样本、临时账号缺email等失败已修正并保留原日志,不用最后成功覆盖失败历史。
@@ -193,3 +193,11 @@
- 第4至10步可回滚到能够读取运营商集合和历史任务的兼容版本;出现双运营商通道后不得回滚到只识别旧单值的版本。
- 第11步严格门禁启用后,如出现真实发送资格异常,优先切回兼容双读版本,不回滚或覆盖已经产生的新业务数据。
- 任一阶段不得为验收发送、补发或重投真实短信,不修改真实通道账号、密码、启停状态、企业余额或客户连接,除非另获明确授权。
## 9. 2026-09-17 性能与签名质量日报改造(本地实现,待发布)
权威增量设计见[签名退网检测与四页查询优化方案](signature-quality-optimization-plan-20260917.md)。实施后替代本文第6节、第7节第15步中检测快照直接作为活动查询、当前通过任务决定历史行及前端全量筛选分页的方式;保留D-1~D-30日期列、原去重单位、规则/抑制和预警历史。四TAB独立查询,日报T实时/T-1~T-3凌晨刷新/T-4起冻结;活动日报与当时预警判断分离。原正文是历史实现描述,本节及专项方案的本地实施状态见下方补充,线上未发布。
### 2026-09-17 实施状态补充
上述专项方案已完成本地代码与隔离验收,具体表结构、03:00三日刷新、事务边界、恢复、离线补建及性能测量见专项方案第8节。历史正文保留为旧实现说明;尚未推送或部署,线上依然是旧行为。
+47
View File
@@ -5614,3 +5614,50 @@ RC-01/02/04/08/09/10/11目前仅部分本地证据:三段齐段、重复与双
### 2026-09-16 五项整改最终测试证据索引
TC-RC-20260916-0112按[测试交付记录](release-20260916-test-completion.md)逐项区分真实目标链路、隔离PG故障验证及未执行场景,不能整组标全部通过。两段/三段/四段、补发成功/失败退款、20HTTP通知及9CMPP目标、503/超时自动恢复、无客户回执目标和无租户通道测试均有真实证据;全部逐断点退出/72小时/历史组合/旧版恢复/等负载性能对照未完成。TC-RC-13修复前真实TCP复现,修复后5000次即时响应及测试环境新增6条18分段无补发通过。五项运营页面检查包含三尺寸、请求失败保留真实快照与恢复、恢复告警不自动消失及人工清除审计。详细计数与测试资源收尾以交付记录为准。
## 2026-09-17 签名质量日报与退网检测优化验收(本地覆盖,环境项待执行)
设计见[优化方案](signature-quality-optimization-plan-20260917.md)。以下全部为计划用例,本轮只读取证不等于用例通过;不沿用此前短信测试授权。既有TC-ANALYTICS、TC-SIGNATURE-RETIREMENT保留历史记录,与本轮替代口径冲突时以本节和方案为实施目标。
| 编号 | 场景 | 验收要求 |
|---|---|---|
| TC-SQA-20260917-01 | 四TAB切换、不同日期/过滤/分页、慢响应及失败 | 仅当前TAB查询;条件与结果互不覆盖;企业请求不返回通道集合,反之亦然;隐藏TAB不重查 |
| TC-SQA-20260917-02 | 未报备TAB编辑日期后分别点顶部/卡片查询,再翻页 | 两个查询入口均应用当前全部草稿条件;翻页只用已应用条件,不串旧日期 |
| TC-SQA-20260917-03 | 今天查询质量和未报备 | 真实业务库一致性聚合,响应标记live及截止时间;不触发生成/检测/通知;过午夜由服务端确定自然日 |
| TC-SQA-20260917-04 | T-1/T-2/T-3迟到回执后再次凌晨生成 | 三天均更新日报,页面只读发布版本;失败保留旧版并标记;同一版本列表、概览、抽屉一致 |
| TC-SQA-20260917-05 | T-4及更早日收到迟到回执/补登记/更名 | 原短信事实可按原业务规则更新,冻结报表内容摘要不变;历史行和名称不依赖当前报备状态 |
| TC-SQA-20260917-06 | 热力图D为今天/历史日,跨月年及闰日 | 严格显示D-1~D-30,不出现D当天;每格按真实T判断冻结;未来D拒绝 |
| TC-SQA-20260917-07 | 有效零数据、缺报表、无报备资格、部分日期失败 | 四种状态明确区分;缺口不能计作已确认零或触发回查明细;部分30日合计明确不完整 |
| TC-SQA-20260917-08 | 短信跨日/跨通道补发,同通道多次accepted | 企业窗口按业务消息去重、通道按消息+通道去重;窗口不简单累加每日distinct数;质量业务日与尝试日分别核对 |
| TC-SQA-20260917-09 | 2/3/4段、缺片、乱序重复、失败与历史无分段记录 | 完整成功才算送达;未知保持未知;去重和旧回执兼容正确,耗时分子分母及成功率口径不漂移 |
| TC-SQA-20260917-10 | 两worker、租约失效、旧worker恢复、重复执行 | 同scope/date原子认领,旧fence不能发布;批次断点恢复,已完成不重新扫描,周期/快照/意图事务一致 |
| TC-SQA-20260917-11 | 候选生成部分失败、发布时跨入T-4、漏跑三日 | 旧版原子保留,跨冻结边界拒绝覆盖;缺口进入明确历史补建流程,不自动无限回算 |
| TC-SQA-20260917-12 | 04:00日报未好、08:00检测未完成、历史日报刷新 | 检测/通知按依赖等待且告警,不用半批发通知;恢复幂等,不重发历史消息/Webhook,不改已发正文 |
| TC-SQA-20260917-13 | 热力图大数据过滤排序分页及一类TAB失败 | 数据库先过滤并按完整30日合计排序再分页;只返回当页单类维度;另一TAB可独立查询;响应最多100×30格 |
| TC-SQA-20260917-14 | 真实接口鉴权/越权、非法日期和分页参数 | 401/403正确,tenant/application可见范围生效;无匿名生成入口;缓存不越权;SQL参数化 |
| TC-SQA-20260917-15 | 同规模新旧方案性能与准确性对照 | 所有指标对账;记录扫描行/块、任务耗时、进程CPU、API内存/响应量与队列影响;目标扫描下降80%、耗时下降50%,未达到不得冒称达标 |
| TC-SQA-20260917-16 | 历史补建、切换/回退和真实浏览器 | 历史覆盖/来源标明,无伪造冻结时间;不重发预警;三尺寸1600×1000/1366×768/390×844覆盖进入、刷新、路由、筛选、分页、详情、失败与权限;发布按后续授权执行 |
### 2026-09-17 本地执行覆盖与线上未执行项
TC-SQA-0114:真实隔离PG覆盖核心日期/日报/长短信/事务/分页路径,真实HTTP覆盖200/401/400,组件回归与三尺寸浏览器覆盖独立TAB。详细断言见`tools/testing/verify-signature-analytics.mjs`,不能将各用例全部边界都标成通过:真实08:00外部Webhook、旧回退版本、跨午夜运行过程、线上权限配置与365日大批次仍未执行。TC-SQA-15只完成30000消息/600维度活动聚合对照,整机CPU/内存/队列未测;TC-SQA-16只完成隔离历史补建、三尺寸页面,真实环境切换/回退未执行。
| 用例 | 操作 | 验证结果 |
|---|---|---|
| TC-MO-20260917-01 | 同手机号同应用多条accepted、共享接入号多应用 | 按应用归并;有唯一发送证据可匹配应用,不强填原短信 |
| TC-MO-20260917-02 | 第三条消息属另一应用、其他通道、接收后发送 | 保留真实歧义;通道/时间边界参与过滤,messageId不能跳过证据 |
| TC-MO-20260917-03 | 重复事件并发入库 | 真实PG仅一个上行和一份通知意图,供应商ID保留 |
| TC-MO-20260917-04 | 两个候选同时认领 | 真实PG仅一个应用成功,另一个拒绝,通知仅一份 |
| TC-MO-20260917-05 | 通知存储失败后重试 | 真实PG整笔回滚,事件重试恢复;未执行外部发送 |
历史159条认领/重新投递、真实供应商MO扩展号码及最终客户收取未执行,不沿用此前测试发送授权。
## 2026-09-17 首页今日回执与收益整改(待执行)
权威验收矩阵见[首页方案第8节](homepage-receipt-metrics-redesign-20260917.md#8-实施顺序验证与交付边界)HOME0917-01~14均待执行,覆盖四日归组、缺片、整条成功、跨日/重复/补发、网关接收时间、旧业务成功率、价格/成本/返还、按需查询、一致快照、权限、故障恢复、三尺寸和性能。实施后替代旧首页指标顺序、到达率/计收、发送趋势/审核速度及消费排行对应预期;保留历史执行记录。UI图只使用示例,不作为真实API/数据库验收。
### HOME0917 实施验收更新
最终UI保留企业消费排行并增加今日返还列,替代前述返还模块假设。HOME0917-01~12已通过对应本地真实PG/规则测试与故障注入;HOME0917-13三尺寸真实Nest/PG/Redis页面、按需请求、导出返还、详情、刷新和请求失败真值保留通过。HOME0917-14仅本地2020消息/6000新增片查询样本及110迁移通过,生产规模、线上队列影响和现场回退未执行。证据及边界见[方案第9节](homepage-receipt-metrics-redesign-20260917.md#9-2026-09-17-实施与验收结果)。所有真实截图使用本地隔离验收记录,不表示目标环境已上线。
+52
View File
@@ -5109,3 +5109,55 @@ CUA本轮可用,实际后端文档三尺寸1600×1000/1366×768/390×844无页
标准工具最终deployed-needs-review仅提示业务数量变化:119597/130835→119603/130841,精确对应补测6条/6次,已人工对账;版本/资源/服务及日志检查通过。工具businessAcceptance未自动更新,不改报告伪造完成。全部证据、原失败、耗时、恢复点、磁盘增量/治理未完成项及矩阵未覆盖项见[交付验收记录](release-20260916-test-completion.md)。预生产未操作,未做性能容量对照、真实72小时等待或全部逐断点/旧版恢复演练。
本轮专用应用/接口/凭据/Webhook和两个模拟通道已停用,模拟器/隧道/监听器/临时hosts/本地隔离PG停止或撤销;短信账务及审计数据保留。61项旧工作保护核对完成,共享文档/metrics只增本轮内容。最终收尾文档及测试日志标签修正单独提交推送,不重启应用。密码及会话不进入Git。
## 2026-09-17 签名退网与四TAB查询优化方案(未实施)
- 授权:只读核查并编写方案。main与实际远端4eb7b16、暂存区空;09:37预生产实际010ba32。原50项已有修改/草稿继续保护,不纳入本轮交付。
- 已核查代码、线上编译入口、PG检测结果/真实索引及本会话CPU取证:04:00:0104:04:07产生5831条检测,逐维度重复扫描与CPU窗口高度吻合,缺历史进程CPU/SQL采样不能唯一归因。当前TAB状态独立,但企业/通道各自调用同一全量heatmap;质量及未报备所有日期实时查库;活跃度检测快照不做近三日刷新,历史又受当前报备维度影响。
- 用户确认每天凌晨刷新T-1~T-3;热力图保留所选日D之前30天,不新增当天列。方案明确实际T和D、T-4冻结、迟到回执、历史维度/未报备判定冻结、生成失败/缺口、批量聚合/索引验证、任务租约/fence、跨日去重及预警通知分离。
- 新增[优化方案](signature-quality-optimization-plan-20260917.md),向既有签名清退设计追加替代关系,需求及TC-SQA-20260917-01~16同步,全部实现验收用例待执行。取证脚本与结果在忽略目录.local-data/cpu-20260917,不含认证秘密。
- 本轮仅执行文档路径/编号/规则一致性及diff检查;未执行业务回归、迁移、重算、登录后四TAB真实HTTP/浏览器验收或新旧SQL性能对照。没有把既有组件mock测试当本轮真实功能通过。
- 本地修改:五份文档;业务代码无改动。本地提交、推送、测试部署、预生产部署:均未执行;未发送/补发/重投短信、未改余额/通道/客户配置或触发外部通知。
## 2026-09-17 上行待认领只读诊断(未修复)
- 预生产010ba32共253条上行:159待认领、92已匹配、2未匹配。147条共享接入号多应用直接返回ambiguous,其中143条接收前72小时实际仅有一个候选应用的同通道accepted发送;另12条手机号多记录全部同应用。已用当前线上编译匹配函数与真实PG只读事务复现两个分支,未启动调度/写入/投递。
- 明确应用归属与原短信唯一关联不能混为一谈;155条可进一步收敛不等于可直接自动认领。另发现窗口按处理时刻、无上界/通道限定、路由take10先截断等风险;现有89条自动关联只读边界核对未见手机号不符/未来关联/缺同通道accepted证据。
- 详见[上行匹配诊断](uplink-matching-diagnosis-20260917.md),区分已证实根因和未发生证据的风险;取证在.local-data/cpu-20260917/uplink-*。未执行HTTP/浏览器验收、写入故障注入或代码修复,未认领/发送/重推上行。
- main/实际远端4eb7b16、暂存区空;已有保护项及前轮签名质量方案保留。新增诊断、追加进度,执行文档路径和diff检查;本地提交、推送、测试部署、预生产部署均未执行。
## 2026-09-17 上行归属修复与签名质量日报优化(本地实现)
- 授权:修复上行待认领问题、执行`signature-quality-optimization-plan-20260917.md`并本地提交;本轮不推送、不部署,不操作历史上行认领/重推,不执行真实短信和外部通知。开始及提交前main/实际远端均4eb7b16,暂存区原空;65项已有修改/草稿已做摘要及副本保护。
- 上行:共享接入号不再提前判歧义;receivedAt前72小时同通道accepted记录按应用归并,原短信不唯一则不填原短信编号。供应商MO编号独立保存;事件入库/候选/通知、人工认领/通知分别事务化,重复事件和并发认领串行核验,通知只创建耐久意图。既有159条待认领没有自动认领/重投。
- 日报:六个新增表和版本/日期复合外键;03:00三日刷新,T-4起冻结,历史页面不回查明细;缺口、刷新/失败、截止时间和事后补建来源可见。企业/通道新接口只返回各自分页维度;四TAB查询/日期/筛选/错误独立,保留D-1~D-30。退网批量accepted窗口去重,规则/日报版本首次认领冻结,失败重试沿用;检测与通知等待完整上游批次,不重复生成历史预警。
- 真实验收:本机独立PostgreSQL16端口16435,新库108项迁移通过(含单独非事务并发索引)。`verify-signature-analytics.mjs`12组验收通过:当天/历史数据源、跨日去重、2/3/4段、缺片、旧无分段明确回执、加权耗时、三日刷新/冻结、旧版本保留、页码越界总数、失败回滚/重试、双worker/fence与规则快照。`verify-uplink-matching.mjs`3组通过:重复事件一份CMPP及一份HTTP通知意图、并发认领、存储故障整笔回滚并恢复;没有启动外部通知消费者。
- 前端:生产构建,完整真实Nest应用+PG+隔离RedisEdge/Playwright在1600×1000、1366×768、390×844检查首次、四TAB查询、刷新,补充历史日报、抽屉、企业服务端筛选、TAB日期独立和请求失败仍显示上次真实结果;28个相关请求、页面异常0。匿名401、非法分页/维度400、正常查询200。Browser插件/技能未提供,沿用用户已授权的独立Playwright;会话只在内存传递。
- 自动门禁:API78套842项、前端35文件163项通过;API/前端类型与生产构建、结构检查、ESLint、Prettier、CSS治理/样式、Bundle预算通过。ESLint仍有发送链路既存测试27项any警告,无error;构建既有chunk提示在预算内。覆盖率门禁见下方补充;Gateway代码未变,无本轮Go/实发/性能容量结论。
- 性能:30000消息/600维度活动聚合,旧10343.912ms,新624.940ms,热缓存下降93.96%accepted逐维度一致。原EXPLAIN单维度源访问30113行×600外推18067800,新批量60039;样本外推不冒称全批实测。未测整机CPU、365日真实大批次、峰值内存、p95和短信队列;不能据此宣布预生产CPU问题已完全解决。
- 原始证据保留于忽略目录`.local-data/signature-optimization-20260917/`integration-6、uplink-real-3、browser-5、performance-1及最终门禁日志。前面失败(迁移SQL未生成、缺carriers、测试断言漏计新增样本、账号缺email、浏览器按钮名称/模糊标签定位)已修正后重跑;不删除原失败日志。Redis5.0.14.1的BullMQ版本建议及PG驱动在嵌套关系读取时的并发query弃用提示保留,未改依赖版本。
- 文档:优化方案第8节、上行诊断第6节、需求/测试用例/清退设计同步。本地提交仅纳入本轮代码、两项迁移、验收脚本及相关文档精确新增段落;已有metrics、版本、部署和发布工具修改保留。推送:未执行;测试部署:未执行;预生产部署:未执行。历史日报补建、真实外部MO投递、线上权限/供应商扩展码、午夜冻结过程、现场恢复/回退及完整容量指标未验证,按后续授权实施。
- 最终补充:API全量覆盖率语句67.76%/分支52.88%/函数68.62%/行70.54%,增量覆盖率87.98%/77.03%/95.91%/91.22%,均通过各自门槛;前端覆盖率88.48%/85.09%/84%/88.19%163项通过。固定日报refreshFor为任务启动日后,类型检查、9项日期测试及真实PG模拟跨午夜通过:T-1跨日仍记录原启动日,T-3跨入T-4拒绝发布且回滚。最终browser-final仍为三尺寸28请求、页面异常0。最后停止本轮本机PG/Redis/预览服务,保留验收库及日志资产。测试基于受保护工作区,发布时仍由标准工具核验精确提交证据;未把本轮本地检查冒称部署验收。
## 2026-09-17 首页运营数据整改方案与 UI V1(未实施)
- 当前授权仅编写方案和设计图。main为627fa7e,实际远端main为4eb7b16,本地领先2提交,暂存区为空;已有修改保留。本轮未连接线上环境或改业务代码。
- 已核对AdminHome、dashboard查询、Prisma字段、Gateway回执时间、intake、分片补偿、货币单位和现有规范。旧统计按queuedAt,而新需求按今日收到回执;billingUnits适合作为去重后的业务分片当量,segmentTotal用于完整性核验;gatewayReceivedAt保留当前Gateway实际接收时刻。线上时间覆盖和成本数据仍待核验。
- 新增[方案](homepage-receipt-metrics-redesign-20260917.md)及[UI V1](designs/homepage-20260917/homepage-v1.png),内置imagegen生成,提示词同目录保留。明确9指标、两个点击才查询的日期明细、跨日整条成功/一次收入、成本快照、返还和运营状态。消费排行暂按替换为返还区域设计,待用户反馈。
- 需求和HOME0917-01~14待执行用例入口已同步。完成图中文字/层级和示例加总检查、文档相对路径与diff检查;这不是业务、浏览器或性能验收。未运行回归/类型/构建,因为未修改业务代码或CSS。
- 本地修改:方案、UI图/提示词及三份文档追加。本地提交、推送、测试部署、预生产部署:均未执行。未发送/补发/重投短信,未改账务、客户或通道配置。
## 2026-09-17 首页 UI V2 修订(仅设计)
用户要求顶部三组缩小至原约三分之一,改为一行三块紧凑指标;“今日回执收益”改为“今日营业状况”,今日企业消费排行按现有六列及详情/导出入口保留,不替换为返还模块,运营状态保留。此修订优先于前轮方案的返还区域解释;方案顶部已标明替代关系。内置imagegen基于V1重绘[UI V2](designs/homepage-20260917/homepage-v2.png),原图保留,提示词同目录。已视觉核对指标、排行列和运营状态;全部数字为示例,无业务代码/CSS修改,无业务验收、提交、推送或部署。
## 2026-09-17 首页整改实施(提交推送前核验)
- 按V2完成紧凑三栏、9指标、两个按需日期明细;保留企业消费排行并增加今日返还金额列,详情/CSV同步,运营状态保留。新增独立home接口,旧客户端不变;长短信以业务单位补齐分母、完整成功才计成功片/收入,跨日与重复回执、通道成功片成本分别核算。
- 四个统计表、两项迁移(本地新库110迁移通过),耐久触发失效、原子批次投影、版本快照、15分钟用户绑定与安全回收;原始短信/回执/账务不改写。本地真实PG、Nest API、Redis验收通过,三尺寸和完整桌面截图见[实施方案第9节](homepage-receipt-metrics-redesign-20260917.md#9-2026-09-17-实施与验收结果)。无Browser技能,使用既有授权Edge/Playwright。无短信发送或外部投递。
- API854、前端163及覆盖率门禁通过;类型/生产构建/定向ESLint/样式/结构/CSS治理/包体检查通过。真实数据库故障与并发认领、四日及缺片/跨日/重复/退款边界通过;页面无明细预请求,错误保留真值、CSV/详情通过,页面异常0。本地样本2020消息/6000新增片,总览p95约55ms、明细约20ms,不推断线上CPU/容量。
- 原始证据 `.local-data/homepage-implementation-20260917/`。数据库端口、Redis旧RDB、浏览器两个定位失败已修正并保留原日志;Redis版本建议保留。尚未验证线上源时间覆盖、真实大数据与现场回退、完整供应商短信闭环。
- 起点main627fa7e、实际远端4eb7b16、暂存区空;67项原工作已备份并核对保护。仅本轮代码、迁移、方案/UI/截图、验收脚本和文档精确追加进入提交;版本/metrics/发布工具等不夹带。用户授权提交推送,不含部署;测试环境、预生产环境均未改动。最终提交与推送结果单独补记。
@@ -0,0 +1,76 @@
# 上行短信待认领只读诊断
核查日期:2026-09-17,北京时间。状态:诊断完成;2026-09-17本地修复与隔离验收见第6节,线上未认领、未投递。范围为预生产既存上行和当前匹配代码,不沿用测试环境发送/补发授权。
## 1. 基线与结论
本地main及实际远端均为`4eb7b16d122da14f921093716d4ca1ed390d9e4c`,暂存区为空。预生产实际运行`010ba3216889032a6160cdb14d8536b616ae7102`。已有版本、metrics、发布工具、部署及前轮优化方案等修改全部保留。
当前共253条上行:159条ambiguous(页面“待认领”)、92条matched、2条unmatched。“待认领”不是接收失败:上行已入库,但没有确定应用归属,当前不会按正常已匹配路径推送给客户。
| 待认领原因 | 数量 | 真实数据复核 |
|---|---:|---|
| 接入号匹配多个应用 | 147 | 143条在接收前72小时仅有一个发送应用,且属于原候选应用、具有同上行通道accepted提交;4条在此限定口径下无下发记录 |
| 手机号窗口有多条下发 | 12 | 保存的两个候选全属同一应用/企业;完整时间窗口复核也只有一个应用,且有同通道accepted提交 |
因此155/159条具有“应用归属可以进一步收敛”的真实证据,不代表155条均能唯一确定被回复的具体业务短信,也不构成直接历史认领/投递授权。4条无证据记录保持未决,不因为某条路由现在有效就推断历史归属。历史源记录可能变化或被治理,当前回查不能替代完整历史快照。
接入号多应用147条分别位于移动物业-富泷78、三网物业-百信互动52、三网物业-铁布衫15、赛邮行业-王斯评中转2;它们的destId均等于当前通道srcId。12条手机号多记录位于三网物业-百信互动10、联电物业-富泷2。9月16日有31条待认领,9月15日12条。
## 2. 已确认根因
匹配实现:`api/src/send-chain/send-downstream-delivery.service.ts``resolveUplinkMatch`。页面`src/apps/admin/AdminSmsUplinkRecordsPage.tsx`将ambiguous直接映射为待认领,不是前端计算错误。
### 2.1 共享接入号过早返回
当前顺序为messageId精确匹配→接入号路由查应用。接入号得到多个active应用后,立即返回ambiguous与应用候选,完全不执行后面的手机号时间窗查询。
这与需求中“仍无唯一应用时按手机号和最近下发时间窗口匹配”的描述存在差距。共享通道接入号只表示有多个可能客户,不足以否定“该手机近期仅被其中一个客户发送”的进一步证据。
真实只读复现记录`cmu48o30s00fo34nkyp3phwt9`:运行中的编译类只调用`channelRouteRule.findMany``smsApplication.findMany`,返回3个应用候选;没有查询短信记录。同手机号接收前72小时实际只有一个应用的同通道accepted发送。
### 2.2 将短信记录歧义等同应用归属歧义
手机号分支取最近两条消息,按记录数量判断唯一或歧义,没有先按tenantId/applicationId归并。一个应用给同一手机发送两次,也被阻断客户归属。
真实只读复现`cmtmq3vef0ioyeankps3d80yl`:运行类返回两条phone_window候选,但distinct application数为1。12条同类存量均符合这一情况。可以有唯一客户归属而没有唯一原短信,应分别表达,不能为了绑定客户随意填最近一条messageRecordId/messageId。
## 3. 代码边界风险(不等于已经发生错误投递)
1. 时间窗下限由`Date.now()`计算而非事件receivedAt,且没有`submittedAt <= receivedAt`上限。延迟消费/故障恢复可能错过真实历史下发,或把上行之后的发送纳入。
2. 手机号回查未限定实际发送通道/供应商边界,也未校验真实accepted提交;仅按message.submittedAt查。不能在修复时直接扩大自动匹配,必须先收紧证据范围。确需同供应商跨物理连接兼容时,另核对账号、主机、端口、协议等边界,不能跨任意通道匹配。
3. 路由查询`take:10`发生在应用去重之前、没有稳定排序:前10条若同属一个应用可能漏掉其他应用,造成假唯一或候选遗漏。手机号`take:2`也只适于证明多条记录,不能据此证明窗口内只有一个应用。
4. 接入号只与通道静态srcId精确比较,没有完整核对实际发送号码/应用扩展码。其差异会进入宽泛手机号兜底,不能用当前路由代替历史发送证据。
5. messageId精确分支未同时验证手机号/通道来源;Gateway普通MO通过packet Msg_Id尝试查本地command。供应商上行ID应继续独立保存为gatewayMessageId,不能把它当已证明的原下发ID。本轮未发现碰撞或错误关联实例。
6. 上行入库、候选写入与投递分别进行;eventId已存在即返回,部分失败恢复可能留缺候选/缺投递。人工认领是先读状态再更新,未见条件更新原子竞争保护。属于附带发现的可靠性风险,本轮未做故障注入或并发写入复现。
对现有85条“手机号窗口唯一匹配”和4条“messageId精确匹配”执行只读边界核对:关联消息均存在、手机号一致、关联提交时间不晚于上行,均存在此前同通道accepted提交。未在这89条中检出上述明显异常;此结果不证明所有潜在风险不存在。
## 4. 建议修复方向(未实施)
- 分开“应用归属”与“原短信关联”。限定证据范围后应用唯一即可确定客户;具体消息仍多条时messageRecordId/messageId留空,并保留真实候选及原因。
- 共享接入号多应用时继续在候选应用中,结合手机号、上行接收时刻之前的72小时以及真实发送通道/接入号收敛;候选间有冲突仍待认领,不按最后一条或最高分随意选。
- 查询完整的distinct应用集合或以安全的唯一性检测查询证明唯一;展示候选可以分页,唯一性判断不能先截断。时间窗配置须校验正数有限值,上行receivedAt须校验合法性。
- 原始供应商MO ID、平台业务ID保持不同语义;无可信原短信关联不得伪造业务messageId。
- 配套原子入库/认领、通知意图与故障接续;客户查询仍严格隔离应用,ambiguous不得泄露给候选客户。
- 历史159条先生成只读建议清单,核验保留数据、有效应用、完整号码/通道证据及投递历史,再在明确授权后决定是否认领及是否推送。修复代码不自动批量认领、不自动把旧上行重新入队或投递。
## 5. 证据、复现限制与后续验证
忽略目录`.local-data/cpu-20260917/`中的`uplink-audit.json``uplink-correlation.json``uplink-reproduce.json``uplink-boundaries.json`及同名mjs为本机证据;输出只含统计、内部记录ID和必要通道标签,不导出手机号、正文或凭据。
复现直接加载预生产当前编译的`SendDownstreamDeliveryService.resolveUplinkMatch`,使用真实PG只读事务(815秒statement_timeout),不启动Nest生命周期,不调用handleUplink/claim/投递方法。仅在该独立诊断进程将Date.now固定到被查上行的receivedAt以复现旧事件分支,配置仍是当前数据库路由,不能宣称重建了当时全部配置。未执行管理HTTP或浏览器交互验收。
后续修复验收至少覆盖共享接入号+唯一应用、同应用多消息、多应用真实冲突、无记录、超过10条路由/超过2条消息、延迟消费/跨日、扩展码、跨供应商隔离、MO ID不冒充MT ID、事务中断、重复事件、并发认领、无重复投递及租户隔离。真实发送/推送只能在后续明确授权的隔离范围执行。
本轮业务代码、数据库和线上配置均未修改;仅新增诊断及追加进度记录,未提交、推送或部署。
## 6. 2026-09-17 本地修复
用户授权修复并本地提交。新增`api/src/send-chain/uplink-matching.ts`,共享接入号继续核对手机号在事件receivedAt前72小时的同通道accepted记录;按tenant/application归并,不以消息条数制造歧义;无take10/take2提前截断。messageId分支同时验证手机号和通道发送事实;收到的供应商gatewayMessageId独立保存。只能确定应用时不填写原短信ID,多应用/配置与事实冲突继续待认领。独占接入号无发送证据的原兼容规则保留;供应商扩展号码改写与跨物理通道归并没有扩大自动归属规则,需有真实协议证据后另行处理。
上行记录、候选和CMPP/HTTP通知意图纳入同一事务,沿用发送链路事务适配器;按事件ID事务锁和唯一键去重。人工认领按上行ID串行核验,只有一个候选可成功,认领与通知意图同事务;不在事务里执行外部推送,后续沿用已有耐久队列消费。已有事件直接返回,不擅自修补或重推历史半完成事件。
真实隔离PG验证:同应用多消息、第三条不同应用、接收前窗口、其他通道拒绝、原短信为空、重复事件只入一次/通知一次、两候选并发仅一次成功、通知存储失败回滚并重试恢复。自动化入口`tools/testing/verify-uplink-matching.mjs`及签名验收脚本;API全量回归见testing-progress。
原159条待认领保持原状;155条可收敛是取证结果,不是已经认领/推送。本轮未连接线上执行认领、重投或修配置,未推送/部署。历史建议清单、实际供应商扩展码、客户端最终收到MO和线上故障恢复仍未验证。
+14
View File
@@ -0,0 +1,14 @@
import { request, withQuery } from '../core/httpClient';
import type { HomeSummary, HomeBreakdown, HomeReceiptRow, HomeRevenueRow } from '../types/home';
export const homeApi = {
summary: (signal?: AbortSignal) => request<HomeSummary>('/admin/operations/home/summary', { signal }),
receipts: (snapshotToken: string, signal?: AbortSignal) =>
request<HomeBreakdown<HomeReceiptRow>>(withQuery('/admin/operations/home/receipt-breakdown', { snapshotToken }), {
signal,
}),
revenue: (snapshotToken: string, signal?: AbortSignal) =>
request<HomeBreakdown<HomeRevenueRow>>(withQuery('/admin/operations/home/revenue-breakdown', { snapshotToken }), {
signal,
}),
};
+4 -2
View File
@@ -43,8 +43,10 @@ export const adminOperationsApi = {
request<PendingAuditCounts>(withQuery('/admin/operations/pending-audits', { tenantId }), { signal }),
getSendQuality: (date?: string) =>
request<SendQualityResponse>(withQuery('/admin/operations/send-quality', { date })),
getSignatureQuality: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<SignatureChannelQualityResponse>(withQuery('/admin/operations/signature-quality', query)),
getSignatureQuality: (
query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {},
signal?: AbortSignal,
) => request<SignatureChannelQualityResponse>(withQuery('/admin/operations/signature-quality', query), { signal }),
listSystemLogs: (query: {
tenantId?: string;
keyword?: string;
+21 -2
View File
@@ -1,6 +1,8 @@
import { request, withQuery } from '../core/httpClient';
import type {
PagedResult,
SignatureActivityResponse,
SignatureAnalyticsMetadata,
SignatureRetirementHeatmapItem,
SignatureRetirementHeatmapDimension,
SignatureRetirementMessage,
@@ -12,6 +14,19 @@ import type {
} from '../types';
export const adminSignatureRetirementApi = {
getSignatureActivity: (
query: {
date: string;
dimensionType: 'enterprise' | 'channel';
page: number;
pageSize: number;
tenantName?: string;
applicationName?: string;
signatureName?: string;
channelName?: string;
},
signal?: AbortSignal,
) => request<SignatureActivityResponse>(withQuery('/admin/signature-retirement/activity', query), { signal }),
getSignatureRetirementConfiguration: () =>
request<{ rules: SignatureRetirementRule[]; webhooks: SignatureRetirementWebhook[] }>(
'/admin/signature-retirement/configuration',
@@ -79,8 +94,12 @@ export const adminSignatureRetirementApi = {
dimensions: SignatureRetirementHeatmapDimension[];
items: SignatureRetirementHeatmapItem[];
}>(withQuery('/admin/signature-retirement/heatmap', { date })),
getUnreportedSignatures: (query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {}) =>
request<PagedResult<UnreportedSignatureItem> & { date: string }>(
getUnreportedSignatures: (
query: { date?: string; keyword?: string; page?: number; pageSize?: number } = {},
signal?: AbortSignal,
) =>
request<PagedResult<UnreportedSignatureItem> & { date: string } & SignatureAnalyticsMetadata>(
withQuery('/admin/signature-retirement/unreported-signatures', query),
{ signal },
),
};
+39
View File
@@ -0,0 +1,39 @@
export type HomeSummary = {
snapshotToken: string;
businessDate: string;
asOf: string;
dataThrough: string;
processing: boolean;
timeSourceCoverage: { approximate: number; incomplete: number };
today: {
sent: number;
delivered: number;
successRate: number;
receiptUnits: number;
successUnits: number;
receiptSuccessRate: number;
revenueCents: number;
profitCents: number;
profitRate: number;
};
taskCount: number;
pendingAudits: {
enterpriseCertifications: number;
smsAudits: number;
templates: number;
signatures: number;
drainageInfos: number;
};
downstreamDeliverySummary: { alertCount: number };
enterpriseSpendRanks: Array<{
tenantId: string;
tenantName: string;
todaySpendCents: number;
todayReturnedCents: number;
balanceCents: number;
creditCents: number;
}>;
};
export type HomeReceiptRow = { submitDate: string; total: number; success: number; rate: number };
export type HomeRevenueRow = { submitDate: string; revenueCents: number; profitCents: number; rate: number };
export type HomeBreakdown<T> = { snapshotToken: string; businessDate: string; items: T[] };
+2 -1
View File
@@ -1,3 +1,4 @@
import type { SignatureAnalyticsMetadata } from './signature-retirement';
// R1 compatibility types. Keep public names re-exported from src/api/adminApi.ts.
import type { AdminChannel } from './channels-reports';
@@ -85,7 +86,7 @@ export type SignatureChannelQualityItem = {
drainageBreakdowns: SignatureChannelCarrierDrainageQualityStat[];
};
export type SignatureChannelQualityResponse = {
export type SignatureChannelQualityResponse = SignatureAnalyticsMetadata & {
date: string;
items: SignatureChannelQualityItem[];
total: number;
+39
View File
@@ -105,3 +105,42 @@ export type UnreportedSignatureItem = {
applicationName?: string | null;
messageCount: number;
};
export type SignatureAnalyticsMetadata = {
dataSource?: 'live' | 'report';
reportState?: string;
frozen?: boolean;
provenance?: string;
sourceAsOf?: string | null;
generatedAt?: string | null;
generationId?: string | null;
};
export type SignatureActivityItem = Pick<
SignatureRetirementHeatmapItem,
| 'id'
| 'dimensionType'
| 'signatureId'
| 'channelId'
| 'carrier'
| 'activityDate'
| 'submittedAttempts'
| 'acceptedBusinessCount'
| 'deliveredBusinessCount'
| 'status'
>;
export type SignatureActivityResponse = {
date: string;
items: SignatureActivityItem[];
dimensions: (SignatureRetirementHeatmapDimension & { total: number })[];
page: number;
pageSize: number;
total: number;
complete: boolean;
coverage: {
date: string;
generationId: string | null;
reportState: string;
frozen: boolean;
sourceAsOf: string | null;
}[];
};
+48 -42
View File
@@ -3,49 +3,33 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AdminAnalyticsPage } from './AdminAnalyticsPage';
const { api } = vi.hoisted(() => ({
api: { getSignatureQuality: vi.fn(), getSignatureRetirementHeatmap: vi.fn(), getUnreportedSignatures: vi.fn() },
api: { getSignatureQuality: vi.fn(), getSignatureActivity: vi.fn(), getUnreportedSignatures: vi.fn() },
}));
vi.mock('@/api/adminApi', () => ({ adminApi: api }));
describe('independent analytics tabs', () => {
it('combines separate activity search fields with AND and preserves them across tabs', async () => {
api.getSignatureRetirementHeatmap.mockResolvedValue({
items: [],
dimensions: [
{
dimensionType: 'channel',
signatureId: 'a',
signatureName: '签名甲',
tenantName: '企业甲',
applicationName: '应用甲',
channelName: '通道甲',
channelId: 'c',
carrier: 'mobile',
approvedAt: '2026-08-01',
},
{
dimensionType: 'channel',
signatureId: 'b',
signatureName: '签名乙',
tenantName: '企业甲',
applicationName: '应用乙',
channelName: '通道乙',
channelId: 'd',
carrier: 'unicom',
approvedAt: '2026-08-01',
},
],
});
it('submits independent activity fields to server and preserves drafts across tabs', async () => {
render(<AdminAnalyticsPage />);
fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' }));
const panel = screen.getByRole('region', { name: '通道签名活跃度' });
await within(panel).findByText('签名甲');
await waitFor(() => expect(api.getSignatureActivity).toHaveBeenCalledTimes(1));
fireEvent.change(within(panel).getByLabelText('企业'), { target: { value: '企业甲' } });
fireEvent.change(within(panel).getByLabelText('企业应用'), { target: { value: '应用甲' } });
await waitFor(() => expect(within(panel).queryByText('签名乙')).not.toBeInTheDocument());
fireEvent.change(within(panel).getByLabelText('通道'), { target: { value: '通道乙' } });
await waitFor(() => expect(within(panel).queryByText('签名甲')).not.toBeInTheDocument());
expect(api.getSignatureActivity).toHaveBeenCalledTimes(1);
fireEvent.click(within(panel).getByRole('button', { name: '查询统计' }));
await waitFor(() =>
expect(api.getSignatureActivity).toHaveBeenLastCalledWith(
expect.objectContaining({ dimensionType: 'channel', tenantName: '企业甲', applicationName: '应用甲', page: 1 }),
expect.any(AbortSignal),
),
);
fireEvent.click(screen.getByRole('tab', { name: '企业签名活跃度' }));
await waitFor(() =>
expect(api.getSignatureActivity).toHaveBeenLastCalledWith(
expect.objectContaining({ dimensionType: 'enterprise', tenantName: '', applicationName: '' }),
expect.any(AbortSignal),
),
);
fireEvent.click(screen.getByRole('tab', { name: '通道签名活跃度' }));
expect(within(panel).getByLabelText('企业应用')).toHaveValue('应用甲');
});
@@ -53,19 +37,33 @@ describe('independent analytics tabs', () => {
vi.resetAllMocks();
api.getSignatureQuality.mockImplementation(async (query) => ({ ...query, total: 0, items: [] }));
api.getUnreportedSignatures.mockImplementation(async (query) => ({ ...query, total: 0, items: [] }));
api.getSignatureRetirementHeatmap.mockResolvedValue({ items: [], dimensions: [] });
api.getSignatureActivity.mockResolvedValue({
items: [],
dimensions: [],
total: 0,
page: 1,
pageSize: 25,
coverage: [],
complete: false,
});
});
it('loads only the visited tab and preserves independent dates when switching back', async () => {
render(<AdminAnalyticsPage />);
await waitFor(() =>
expect(api.getSignatureQuality).toHaveBeenCalledWith(expect.objectContaining({ pageSize: 25 })),
expect(api.getSignatureQuality).toHaveBeenCalledWith(
expect.objectContaining({ pageSize: 25 }),
expect.any(AbortSignal),
),
);
expect(api.getSignatureRetirementHeatmap).not.toHaveBeenCalled();
expect(api.getSignatureActivity).not.toHaveBeenCalled();
const quality = screen.getByRole('region', { name: '签名通道发送质量' });
fireEvent.change(within(quality).getByLabelText('统计日期'), { target: { value: '2026-08-20' } });
fireEvent.click(within(quality).getByRole('button', { name: '查询统计' }));
await waitFor(() =>
expect(api.getSignatureQuality).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-08-20' })),
expect(api.getSignatureQuality).toHaveBeenLastCalledWith(
expect.objectContaining({ date: '2026-08-20' }),
expect.any(AbortSignal),
),
);
fireEvent.click(screen.getByRole('tab', { name: '未报备签名' }));
await waitFor(() => expect(api.getUnreportedSignatures).toHaveBeenCalledTimes(1));
@@ -75,7 +73,7 @@ describe('independent analytics tabs', () => {
expect(within(quality).getByLabelText('统计日期')).toHaveValue('2026-08-20');
fireEvent.click(screen.getByRole('tab', { name: '未报备签名' }));
expect(within(unreported).getByLabelText('统计日期')).toHaveValue('2026-08-25');
expect(api.getSignatureRetirementHeatmap).not.toHaveBeenCalled();
expect(api.getSignatureActivity).not.toHaveBeenCalled();
});
it.each([
@@ -89,9 +87,16 @@ describe('independent analytics tabs', () => {
await waitFor(() => expect(within(panel).getByRole('button', { name: '下一页' })).toBeEnabled());
fireEvent.change(within(panel).getByLabelText('统计日期'), { target: { value: '2026-08-20' } });
fireEvent.click(within(panel).getByRole('button', { name: '查询统计' }));
await waitFor(() => expect(api[method]).toHaveBeenLastCalledWith(expect.objectContaining({ date: '2026-08-20' })));
await waitFor(() =>
expect(api[method]).toHaveBeenLastCalledWith(
expect.objectContaining({ date: '2026-08-20' }),
expect.any(AbortSignal),
),
);
fireEvent.click(within(panel).getByRole('button', { name: '下一页' }));
await waitFor(() => expect(api[method]).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 })));
await waitFor(() =>
expect(api[method]).toHaveBeenLastCalledWith(expect.objectContaining({ page: 2 }), expect.any(AbortSignal)),
);
// An unsubmitted date must not alter a pagination request.
fireEvent.change(within(panel).getByLabelText('统计日期'), { target: { value: '2026-08-21' } });
const size = within(panel).getByLabelText(/^每页数量/);
@@ -108,6 +113,7 @@ describe('independent analytics tabs', () => {
await waitFor(() =>
expect(api[method]).toHaveBeenLastCalledWith(
expect.objectContaining({ page: 1, pageSize: 100, date: '2026-08-20' }),
expect.any(AbortSignal),
),
);
await waitFor(() => expect(within(panel).getByLabelText('跳转页码')).toHaveValue(1));
@@ -118,7 +124,7 @@ describe('independent analytics tabs', () => {
async (tab) => {
render(<AdminAnalyticsPage />);
fireEvent.click(screen.getByRole('tab', { name: tab }));
await waitFor(() => expect(api.getSignatureRetirementHeatmap).toHaveBeenCalledTimes(1));
await waitFor(() => expect(api.getSignatureActivity).toHaveBeenCalledTimes(1));
const panel = screen.getByRole('region', { name: tab });
const size = within(panel).getByLabelText(/^每页数量/);
expect(size.closest('.ui-pagination')).not.toBeNull();
@@ -131,7 +137,7 @@ describe('independent analytics tabs', () => {
).toHaveTextContent('25 条/页');
fireEvent.click(screen.getByRole('tab', { name: tab }));
expect(size).toHaveTextContent('50 条/页');
expect(api.getSignatureRetirementHeatmap).toHaveBeenCalledTimes(1);
await waitFor(() => expect(api.getSignatureActivity).toHaveBeenCalledTimes(2));
},
);
});
+114 -63
View File
@@ -1,11 +1,13 @@
import { useDeferredValue, useEffect, useRef, useState } from 'react';
import { useEffect, useRef, useState } from 'react';
import { BarChart3, Eye, Search, X } from 'lucide-react';
import {
adminApi,
type SignatureChannelCarrierQualityStat,
type SignatureChannelQualityItem,
type SignatureActivityResponse,
type SignatureAnalyticsMetadata,
type SignatureChannelQualityResponse,
type SignatureRetirementHeatmapItem,
type SignatureActivityItem,
type SignatureRetirementHeatmapDimension,
type UnreportedSignatureItem,
type PagedResult,
@@ -59,9 +61,19 @@ function AnalyticsPanel({ kind }: { kind: string }) {
const [pageSize, setPageSize] = useState(25);
const [appliedDate, setAppliedDate] = useState(() => shanghaiDateKey());
const requestId = useRef(0);
const abort = useRef<AbortController | null>(null);
const [activity, setActivity] = useState<SignatureActivityResponse | null>(null);
const [activityFilters, setActivityFilters] = useState({
tenantName: '',
applicationName: '',
signatureName: '',
channelName: '',
});
const [appliedActivityFilters, setAppliedActivityFilters] = useState(activityFilters);
const [metadata, setMetadata] = useState<SignatureAnalyticsMetadata | null>(null);
const [statisticsDate, setStatisticsDate] = useState(() => shanghaiDateKey());
const [signatureQuality, setSignatureQuality] = useState<SignatureChannelQualityResponse | null>(null);
const [retirementHeatmap, setRetirementHeatmap] = useState<SignatureRetirementHeatmapItem[]>([]);
const [retirementHeatmap, setRetirementHeatmap] = useState<SignatureActivityItem[]>([]);
const [retirementDimensions, setRetirementDimensions] = useState<SignatureRetirementHeatmapDimension[]>([]);
const [unreportedSignatures, setUnreportedSignatures] = useState<
(PagedResult<UnreportedSignatureItem> & { date: string }) | null
@@ -80,30 +92,47 @@ function AnalyticsPanel({ kind }: { kind: string }) {
unreported = appliedUnreportedKeyword,
date = statisticsDate,
size = pageSize,
filters = appliedActivityFilters,
) {
abort.current?.abort();
const controller = new AbortController();
abort.current = controller;
const id = ++requestId.current;
setLoading(true);
setError('');
try {
if (kind === 'quality') {
const data = await adminApi.getSignatureQuality({ date, keyword: keyword || undefined, page, pageSize: size });
const data = await adminApi.getSignatureQuality(
{ date, keyword: keyword || undefined, page, pageSize: size },
controller.signal,
);
if (id !== requestId.current) return;
setSignatureQuality(data);
setMetadata(data);
setAppliedKeyword(keyword);
setSelectedSignature(null);
} else if (kind === 'unreported') {
const data = await adminApi.getUnreportedSignatures({
date,
keyword: unreported || undefined,
page,
pageSize: size,
});
const data = await adminApi.getUnreportedSignatures(
{
date,
keyword: unreported || undefined,
page,
pageSize: size,
},
controller.signal,
);
if (id !== requestId.current) return;
setUnreportedSignatures(data);
setMetadata(data);
setAppliedUnreportedKeyword(unreported);
} else {
const data = await adminApi.getSignatureRetirementHeatmap(date);
const data = await adminApi.getSignatureActivity(
{ date, dimensionType: kind as 'enterprise' | 'channel', page, pageSize: size, ...filters },
controller.signal,
);
if (id !== requestId.current) return;
setActivity(data);
setAppliedActivityFilters(filters);
setRetirementHeatmap(data.items);
setRetirementDimensions(data.dimensions);
}
@@ -115,9 +144,11 @@ function AnalyticsPanel({ kind }: { kind: string }) {
}
}
const initialLoad = useRef(loadData);
useEffect(() => {
void loadData(1, '');
void initialLoad.current(1, '');
return () => {
abort.current?.abort();
requestId.current += 1;
};
}, []);
@@ -221,7 +252,7 @@ function AnalyticsPanel({ kind }: { kind: string }) {
];
function queryStatistics() {
void loadData(1, signatureKeyword.trim());
void loadData(1, signatureKeyword.trim(), unreportedKeyword.trim(), statisticsDate, pageSize, activityFilters);
}
function changeSignaturePage(page: number) {
@@ -233,8 +264,7 @@ function AnalyticsPanel({ kind }: { kind: string }) {
function changePageSize(size: number) {
setPageSize(size);
if (kind === 'quality' || kind === 'unreported')
void loadData(1, appliedKeyword, appliedUnreportedKeyword, appliedDate, size);
void loadData(1, appliedKeyword, appliedUnreportedKeyword, appliedDate, size);
}
return (
@@ -256,7 +286,28 @@ function AnalyticsPanel({ kind }: { kind: string }) {
</Button>
</div>
</div>
{error ? <p className="form-error">{error}</p> : null}
{error ? (
<p className="form-error">
{error}
{signatureQuality || activity || unreportedSignatures
? `;仍显示上次成功查询的数据,日期为 ${appliedDate}`
: ';尚无成功查询的数据。'}
</p>
) : null}
{metadata ? (
<p className="muted" role="status">
{metadata.dataSource === 'live' ? '当天实时查询' : metadata.frozen ? '已冻结日报' : '近三日可刷新日报'} ·{' '}
{metadata.reportState === 'missing'
? '报表尚未生成'
: metadata.reportState === 'failed'
? '生成失败,保留上次完整日报'
: metadata.reportState === 'refreshing'
? '正在刷新,显示上次完整日报'
: '查询完成'}
{metadata.provenance === 'backfill-current-source' ? ' · 事后补建' : ''}
{metadata.sourceAsOf ? ` · 数据截止 ${new Date(metadata.sourceAsOf).toLocaleString('zh-CN')}` : ''}
</p>
) : null}
{kind === 'quality' ? (
<div className="surface signature-quality-card">
@@ -323,6 +374,11 @@ function AnalyticsPanel({ kind }: { kind: string }) {
onPageSizeChange={changePageSize}
date={appliedDate}
dimensionType="enterprise"
activity={activity}
filters={activityFilters}
onFilterChange={setActivityFilters}
onSearch={queryStatistics}
onPageChange={(page) => void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate)}
dimensions={retirementDimensions}
items={retirementHeatmap}
title="企业签名活跃度热力图"
@@ -334,6 +390,11 @@ function AnalyticsPanel({ kind }: { kind: string }) {
onPageSizeChange={changePageSize}
date={appliedDate}
dimensionType="channel"
activity={activity}
filters={activityFilters}
onFilterChange={setActivityFilters}
onSearch={queryStatistics}
onPageChange={(page) => void loadData(page, appliedKeyword, appliedUnreportedKeyword, appliedDate)}
dimensions={retirementDimensions}
items={retirementHeatmap}
title="通道签名活跃度热力图"
@@ -349,7 +410,7 @@ function AnalyticsPanel({ kind }: { kind: string }) {
loading={loading}
onKeywordChange={setUnreportedKeyword}
onPageChange={(page) => loadUnreportedSignatures(page)}
onSearch={() => loadUnreportedSignatures(1, unreportedKeyword.trim())}
onSearch={queryStatistics}
/>
) : null}
@@ -365,6 +426,11 @@ function AnalyticsPanel({ kind }: { kind: string }) {
}
function RetirementHeatmap({
activity,
filters,
onFilterChange,
onSearch,
onPageChange,
pageSize,
onPageSizeChange,
date,
@@ -378,12 +444,14 @@ function RetirementHeatmap({
date: string;
dimensionType: 'enterprise' | 'channel';
dimensions: SignatureRetirementHeatmapDimension[];
items: SignatureRetirementHeatmapItem[];
items: SignatureActivityItem[];
title: string;
activity: SignatureActivityResponse | null;
filters: { tenantName: string; applicationName: string; signatureName: string; channelName: string };
onFilterChange: (value: typeof filters) => void;
onSearch: () => void;
onPageChange: (page: number) => void;
}) {
const [pageState, setPageState] = useState({ key: '', page: 1 });
const [filters, setFilters] = useState({ tenantName: '', applicationName: '', signatureName: '', channelName: '' });
const deferredFilters = useDeferredValue(filters);
const visible = items.filter((item) => item.dimensionType === dimensionType);
const dates = previousDateKeys(date, 30);
const cellMap = new Map(
@@ -392,40 +460,17 @@ function RetirementHeatmap({
item,
]),
);
const rows = dimensions
.filter((item) => item.dimensionType === dimensionType)
.filter((item) =>
Object.entries(deferredFilters).every(
([key, value]) =>
!value.trim() ||
(item[key as keyof typeof deferredFilters] ?? '')
.toLocaleLowerCase('zh-CN')
.includes(value.trim().toLocaleLowerCase('zh-CN')),
),
)
.map((item) => ({
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
signatureName: item.signatureName,
channelName: item.channelName,
tenantName: item.tenantName,
applicationName: item.applicationName,
carrier: item.carrier,
approvedAt: item.approvedAt.slice(0, 10),
total: dates.reduce(
(sum, dateKey) =>
sum +
(cellMap.get(`${item.signatureId}:${item.channelId ?? ''}:${item.carrier}:${dateKey}`)
?.acceptedBusinessCount ?? 0),
0,
),
}))
.sort((left, right) => right.total - left.total || left.signatureName.localeCompare(right.signatureName, 'zh-CN'));
const totalPages = Math.max(1, Math.ceil(rows.length / pageSize));
const paginationKey = JSON.stringify([date, deferredFilters, dimensionType, dimensions.length, pageSize]);
const page = pageState.key === paginationKey ? pageState.page : 1;
const setPage = (value: number) => setPageState({ key: paginationKey, page: value });
const currentPage = Math.min(page, totalPages);
const pagedRows = rows.slice((currentPage - 1) * pageSize, currentPage * pageSize);
const rows = dimensions.map((item) => ({
...item,
key: `${item.signatureId}:${item.channelId ?? ''}:${item.carrier}`,
approvedAt: item.approvedAt?.slice(0, 10) ?? '',
total: (item as SignatureRetirementHeatmapDimension & { total: number }).total ?? 0,
}));
const totalPages = Math.max(1, Math.ceil((activity?.total ?? 0) / pageSize));
const currentPage = activity?.page ?? 1;
const pagedRows = rows;
const setPage = onPageChange;
const coverage = new Map(activity?.coverage.map((day) => [day.date, day]));
return (
<div className="surface signature-retirement-heatmap">
@@ -448,12 +493,18 @@ function RetirementHeatmap({
label={label}
placeholder={`搜索${label}`}
value={filters[key]}
onChange={(event) => setFilters((current) => ({ ...current, [key]: event.target.value }))}
onChange={(event) => onFilterChange({ ...filters, [key]: event.target.value })}
/>
))}
<Tag tone="info">T-1 T-30</Tag>
<Button onClick={onSearch} icon={<Search size={16} />}>
</Button>
<Tag tone="info">30</Tag>
</div>
</div>
{activity && !activity.complete ? (
<p className="form-error">30</p>
) : null}
{rows.length ? (
<>
<div className="signature-retirement-heatmap__scroll">
@@ -482,7 +533,7 @@ function RetirementHeatmap({
<td className="signature-retirement-heatmap__total">{row.total.toLocaleString('zh-CN')}</td>
{dates.map((dateKey) => {
const item = cellMap.get(`${row.key}:${dateKey}`);
const beforeApproval = dateKey < row.approvedAt;
const beforeApproval = Boolean(coverage.get(dateKey)?.generationId && !item);
const successRate = item?.acceptedBusinessCount
? (item.deliveredBusinessCount / item.acceptedBusinessCount) * 100
: 0;
@@ -494,10 +545,10 @@ function RetirementHeatmap({
? 'is-zero'
: `is-rate-${successRateTone(successRate)}`;
const titleText = beforeApproval
? '报备前,不适用'
? '当日报备维度不适用'
: item
? `提交条数:${item.submittedAttempts}\n上游接受条数:${item.acceptedBusinessCount}\n发送成功条数:${item.deliveredBusinessCount}\n发送成功率:${successRate.toFixed(1)}%\n检测状态${item.status === 'observing' ? '观察中(不预警)' : item.status === 'alert' ? '预警' : '正常'}\n预警阈值:${item.threshold}`
: '当日无检测快照';
? `提交条数:${item.submittedAttempts}\n上游接受条数:${item.acceptedBusinessCount}\n发送成功条数:${item.deliveredBusinessCount}\n发送成功率:${successRate.toFixed(1)}%\n数据来源${coverage.get(dateKey)?.frozen ? '冻结日报' : '可刷新日报'}`
: '当日报表尚未生成';
return (
<td className={className} key={dateKey} title={titleText}>
{beforeApproval ? 'N/A' : item ? item.acceptedBusinessCount : '—'}
@@ -512,7 +563,7 @@ function RetirementHeatmap({
</>
) : (
<p className="empty-state">
{Object.values(deferredFilters).some((value) => value.trim())
{Object.values(filters).some((value) => value.trim())
? '没有匹配企业、企业应用或签名的热力图维度。'
: '暂无已确认到运营商的报备事实,尚未形成检测热力图。'}
</p>
@@ -526,7 +577,7 @@ function RetirementHeatmap({
onPrevious={() => setPage(currentPage - 1)}
page={currentPage}
previousDisabled={currentPage <= 1}
total={rows.length}
total={activity?.total ?? 0}
totalPages={totalPages}
/>
</div>
+113 -61
View File
@@ -1,87 +1,139 @@
.admin-dashboard .admin-dashboard-hero {
padding: 16px 20px;
}
.admin-dashboard .home-metrics {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
}
.admin-dashboard .home-metric {
padding: 18px;
border: 1px solid var(--color-border, #e5e7eb);
border-radius: 8px;
background: #fff;
.admin-dashboard .overview-grid--three {
grid-template-columns: repeat(7, minmax(0, 1fr));
gap: 12px;
}
.admin-dashboard .mini-status-card {
height: auto;
min-height: 96px;
flex-direction: column;
align-items: center;
justify-content: flex-start;
gap: 6px;
padding: 8px;
text-align: center;
white-space: normal;
}
.admin-dashboard .mini-status-card div {
justify-items: center;
}
.admin-dashboard .home-metric-panel {
min-width: 0;
padding: 12px 16px;
border: 1px solid var(--color-border);
border-radius: 8px;
background: var(--color-surface);
}
.admin-dashboard .home-metric__heading {
.admin-dashboard .home-panel-heading {
display: flex;
justify-content: space-between;
align-items: start;
gap: 8px;
font-size: 13px;
color: #6b7280;
}
.admin-dashboard .home-metric__category {
display: flex;
align-items: center;
gap: 4px;
color: #2563eb;
font-size: 12px;
white-space: nowrap;
min-height: 32px;
margin-bottom: 8px;
}
.admin-dashboard .home-metric__number {
.admin-dashboard .home-panel-heading h2 {
margin: 0;
font-size: 16px;
font-weight: 600;
}
.admin-dashboard .home-metric-panel dl {
margin: 0;
}
.admin-dashboard .home-metric-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 40px;
padding: 5px 0;
border-top: 1px solid var(--color-border);
}
.admin-dashboard .home-metric-row dt {
font-size: 13px;
color: var(--color-text-muted);
}
.admin-dashboard .home-metric-row dd {
display: flex;
align-items: baseline;
gap: 6px;
margin: 16px 0 10px;
color: #111827;
font-variant-numeric: tabular-nums;
justify-content: flex-end;
flex-wrap: wrap;
overflow-wrap: anywhere;
}
.admin-dashboard .home-metric__number strong {
font-size: 26px;
font-weight: 600;
line-height: 1.2;
}
.admin-dashboard .home-metric__number span {
font-size: 12px;
color: #6b7280;
}
.admin-dashboard .home-metric__number.is-negative {
color: #dc2626;
}
.admin-dashboard .home-metric p {
gap: 4px;
margin: 0;
font-size: 12px;
color: #6b7280;
min-width: 0;
overflow-wrap: anywhere;
font-variant-numeric: tabular-nums;
}
@media (width <= 1400px) {
.admin-dashboard .home-metrics {
grid-template-columns: repeat(3, minmax(0, 1fr));
.admin-dashboard .home-metric-row strong {
font-size: 22px;
font-weight: 600;
line-height: 1.25;
}
.admin-dashboard .home-metric-row dd span {
font-size: 12px;
}
.admin-dashboard .home-metric-row.is-success {
padding-inline: 6px;
border-radius: 4px;
background: var(--color-success-soft);
color: var(--color-success);
}
.admin-dashboard .home-metric-row.is-success dt {
color: var(--color-success);
}
.admin-dashboard .home-metric-row.is-negative {
color: var(--color-danger);
}
.admin-dashboard .home-metrics-note {
margin: 0;
font-size: 13px;
color: var(--color-text-muted);
}
@media (width <= 1200px) {
.admin-dashboard .home-metric-panel {
padding: 12px;
}
.admin-dashboard .home-panel-heading {
flex-wrap: wrap;
}
.admin-dashboard .home-metric-row strong {
font-size: 20px;
}
}
@media (width <= 700px) {
@media (width <= 900px) {
.admin-dashboard .home-metrics {
grid-template-columns: 1fr;
}
.admin-dashboard .overview-grid--three {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-dashboard .home-metric {
padding: 14px;
}
.admin-dashboard .home-metric__category {
display: none;
}
.admin-dashboard .home-metric__number strong {
font-size: 23px;
}
}
+118 -170
View File
@@ -1,17 +1,18 @@
import { useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { BarChart3, DollarSign, FileCheck2, ShieldCheck } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { Breadcrumb, Button, Modal, MoneyText, Table, Tag, type TableColumn } from '@/components/ui';
import './AdminHome.css';
import { Chart } from '@/components/ui/Chart';
import { adminApi, type DashboardResponse, type SendQualityResponse } from '@/api/adminApi';
import { createDualAxisBarLineOption, createLineOption } from '@/theme/chartOptions';
import { HomeMetrics } from './home/HomeMetrics';
import { homeApi } from '@/api/admin/home.api';
import type { HomeSummary } from '@/api/types/home';
import { formatAmount, moneyUnitsToYuan } from '@/utils/currency';
type EnterpriseSpendRank = {
id: string;
enterprise: string;
todaySpend: number;
todayReturned: number;
balanceStatus: '充足' | '紧张' | '欠费';
availableBalance: number;
};
@@ -32,22 +33,45 @@ function formatCount(value: number) {
export function AdminHome() {
const navigate = useNavigate();
const [dashboard, setDashboard] = useState<DashboardResponse | null>(null);
const [quality, setQuality] = useState<SendQualityResponse | null>(null);
const [dashboard, setDashboard] = useState<HomeSummary | null>(null);
const [error, setError] = useState('');
const [selectedEnterprise, setSelectedEnterprise] = useState<EnterpriseSpendRank | null>(null);
const [loading, setLoading] = useState(false);
const request = useRef<AbortController | null>(null);
const dateRef = useRef<string | null>(null);
useEffect(() => {
Promise.all([adminApi.getDashboard(), adminApi.getSendQuality()])
.then(([nextDashboard, nextQuality]) => {
setDashboard(nextDashboard);
setQuality(nextQuality);
})
.catch((err) => {
setError(err instanceof Error ? err.message : '运营看板加载失败');
setDashboard(null);
});
dateRef.current = dashboard?.businessDate ?? null;
}, [dashboard]);
const refresh = useCallback(async () => {
request.current?.abort();
const controller = new AbortController();
request.current = controller;
setLoading(true);
setError('');
try {
const result = await homeApi.summary(controller.signal);
if (!controller.signal.aborted) setDashboard(result);
} catch (e) {
if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '运营看板加载失败');
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}, []);
useEffect(() => {
void refresh();
const timer = setInterval(() => {
if (
dateRef.current &&
new Intl.DateTimeFormat('sv-SE', { timeZone: 'Asia/Shanghai' }).format(new Date()) !== dateRef.current
)
void refresh();
}, 30_000);
return () => {
clearInterval(timer);
request.current?.abort();
};
}, [refresh]);
const enterpriseSpendRanks = useMemo<EnterpriseSpendRank[]>(() => {
return (dashboard?.enterpriseSpendRanks ?? []).map((account) => {
@@ -57,6 +81,7 @@ export function AdminHome() {
id: account.tenantId,
enterprise: account.tenantName,
todaySpend,
todayReturned: moneyUnitsToYuan(account.todayReturnedCents),
availableBalance,
balanceStatus: (availableBalance <= 0
? '欠费'
@@ -67,13 +92,6 @@ export function AdminHome() {
});
}, [dashboard]);
const totalSend = dashboard?.today.sent ?? 0;
const averageSuccessRate = dashboard?.today.successRate ?? 0;
const todaySpend = moneyUnitsToYuan(dashboard?.today.spendCents);
const todayReturned = moneyUnitsToYuan(dashboard?.today.returnedCents);
const todayBilled = moneyUnitsToYuan(dashboard?.today.billedCents);
const todayProfit = moneyUnitsToYuan(dashboard?.today.profitCents);
const activeSignatureCount = new Set(quality?.signatures.map((item) => item.signatureId) ?? []).size;
const downstreamAlertCount = dashboard?.downstreamDeliverySummary?.alertCount ?? 0;
const pendingAudits = dashboard?.pendingAudits ?? {
enterpriseCertifications: 0,
@@ -84,41 +102,11 @@ export function AdminHome() {
total: 0,
};
const sendTrendOption = useMemo(
() =>
createLineOption({
labels: dashboard?.hourlySendTrend.map((item) => item.label) ?? [],
series: [
{ name: '提交总条数', data: dashboard?.hourlySendTrend.map((item) => item.submittedCount) ?? [] },
{ name: '成功条数', data: dashboard?.hourlySendTrend.map((item) => item.successCount) ?? [] },
],
}),
[dashboard],
);
const auditSpeedOption = useMemo(
() =>
createDualAxisBarLineOption({
labels: dashboard?.auditProcessingSpeed.map((item) => item.label) ?? [],
bar: {
name: '审核数量',
data: dashboard?.auditProcessingSpeed.map((item) => item.count) ?? [],
},
line: {
name: '平均处理时长(分钟)',
data:
dashboard?.auditProcessingSpeed.map((item) =>
item.averageProcessingMs == null ? null : Number((item.averageProcessingMs / 60_000).toFixed(1)),
) ?? [],
},
}),
[dashboard],
);
const enterpriseColumns: Array<TableColumn<EnterpriseSpendRank>> = [
{ key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 },
{
key: 'enterprise',
width: '260px',
title: '企业名称',
render: (record) => (
<div>
@@ -129,23 +117,34 @@ export function AdminHome() {
},
{
key: 'todaySpend',
width: '155px',
title: '今日消费(元)',
align: 'right',
render: (record) => <MoneyText>¥{formatCurrency(record.todaySpend)}</MoneyText>,
},
{
key: 'todayReturned',
title: '今日返还金额(元)',
width: '165px',
align: 'right',
render: (record) => <MoneyText>¥{formatCurrency(record.todayReturned)}</MoneyText>,
},
{
key: 'availableBalance',
width: '140px',
title: '可用余额',
align: 'right',
render: (record) => formatCount(record.availableBalance),
},
{
key: 'balanceStatus',
width: '110px',
title: '余额状态',
render: (record) => <Tag tone={balanceTone[record.balanceStatus]}>{record.balanceStatus}</Tag>,
},
{
key: 'actions',
width: '100px',
title: '操作',
align: 'right',
render: (record) => (
@@ -156,14 +155,46 @@ export function AdminHome() {
},
];
function exportRanks() {
const escape = (value: string | number) =>
'"' +
String(value)
.replace(/^(?:\s*[=+\-@]|[\t\r\n])/, "'$&")
.replace(/"/g, '""') +
'"';
const rows = [
['排名', '企业名称', '今日消费(元)', '今日返还金额(元)', '可用余额', '余额状态'],
...enterpriseSpendRanks.map((r, i) => [
i + 1,
r.enterprise,
formatCurrency(r.todaySpend),
formatCurrency(r.todayReturned),
formatCurrency(r.availableBalance),
r.balanceStatus,
]),
];
const url = URL.createObjectURL(
new Blob(['\uFEFF' + rows.map((row) => row.map(escape).join(',')).join('\r\n')], {
type: 'text/csv;charset=utf-8',
}),
);
const link = document.createElement('a');
link.href = url;
link.download = `企业消费排行-${dashboard?.businessDate}.csv`;
link.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
return (
<section className="page-stack admin-dashboard">
<div className="overview-hero admin-dashboard-hero">
<div>
<Breadcrumb items={['数据概览']} />
<p className="muted"></p>
<p className="muted"></p>
</div>
<div className="page-actions">
<Button onClick={() => void refresh()} disabled={loading}>
{loading ? '刷新中…' : '刷新'}
</Button>
<Button icon={<FileCheck2 size={16} />} onClick={() => navigate('/admin/templates')} variant="ghost">
</Button>
@@ -171,114 +202,20 @@ export function AdminHome() {
</div>
</div>
<div className="home-metrics" aria-busy={!dashboard && !error}>
{[
{
label: '今日发送总量',
value: formatCount(totalSend),
unit: '',
note: '业务短信',
group: '发送',
icon: <BarChart3 size={18} />,
},
{
label: '今日消息分片数',
value: formatCount(dashboard?.today.segmentCount ?? 0),
unit: '片',
note: '实际消息分片',
group: '发送',
},
{
label: '总体成功率',
value: averageSuccessRate.toFixed(1),
unit: '%',
note: '送达成功 / 今日总量',
group: '质量',
icon: <ShieldCheck size={18} />,
},
{
label: '今日到达率',
value: (dashboard?.today.arrivalRate ?? 0).toFixed(1),
unit: '%',
note: '到达分片 / 发送总分片',
group: '质量',
},
{
label: '今日活跃签名',
value: formatCount(activeSignatureCount),
unit: '个',
note: '今日有真实发送记录',
group: '发送',
},
{
label: '今日消费金额',
value: formatCurrency(todaySpend),
unit: '元',
note: '今日消息消费',
group: '经营',
icon: <DollarSign size={18} />,
},
{
label: '今日返还金额',
value: formatCurrency(todayReturned),
unit: '元',
note: '今日返还流水',
group: '经营',
},
{
label: '今日计收金额',
value: formatCurrency(todayBilled),
unit: '元',
note: '成功计费条数 × 客户价',
group: '经营',
},
{
label: '今日利润',
value: formatCurrency(todayProfit),
unit: '元',
note: '计收金额 − 成功分片通道成本',
group: '经营',
danger: todayProfit < 0,
},
{
label: '今日利润率',
value: (dashboard?.today.profitRate ?? 0).toFixed(1),
unit: '%',
note: '今日利润 / 今日计收金额',
group: '经营',
danger: (dashboard?.today.profitRate ?? 0) < 0,
},
].map((metric) => (
<article className="home-metric" key={metric.label}>
<div className="home-metric__heading">
<span>{metric.label}</span>
<span className="home-metric__category">
{metric.icon}
{metric.group}
</span>
</div>
<div className={metric.danger ? 'home-metric__number is-negative' : 'home-metric__number'}>
<strong>{dashboard ? metric.value : '—'}</strong>
<span>{metric.unit}</span>
</div>
<p>{dashboard ? metric.note : error ? '数据暂不可用' : '正在加载…'}</p>
</article>
))}
</div>
{error ? <div className="surface ui-table__empty">{error}</div> : null}
<div className="chart-grid">
<div className="surface chart-card">
<h2></h2>
<p className="muted"></p>
<Chart height={300} option={sendTrendOption} />
<p className="home-metrics-note" role="status">
{dashboard
? `${dashboard.businessDate} · 北京时间 · 统计更新于 ${new Date(dashboard.dataThrough).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}${dashboard.processing ? ' · 统计处理中,部分数据尚未更新' : ''}${dashboard.timeSourceCoverage.approximate || dashboard.timeSourceCoverage.incomplete ? ' · 含历史近似时间或待核对记录' : ''}`
: loading
? '正在加载运营数据…'
: '数据暂不可用'}
</p>
{error && (
<div className="surface" role="alert">
{error}
{dashboard ? ';仍显示上次成功查询的数据,请注意日期和更新时间。' : ''}
</div>
<div className="surface chart-card">
<h2></h2>
<p className="muted"></p>
<Chart height={300} option={auditSpeedOption} />
</div>
</div>
)}
<HomeMetrics key={dashboard?.snapshotToken ?? 'empty'} data={dashboard} />
<div className="surface section-stack">
<div className="section-heading">
@@ -286,11 +223,16 @@ export function AdminHome() {
<h2></h2>
<p className="muted"></p>
</div>
<Button icon={<DollarSign size={16} />} size="sm" variant="ghost">
<Button icon={<DollarSign size={16} />} size="sm" variant="ghost" disabled={!dashboard} onClick={exportRanks}>
</Button>
</div>
<Table columns={enterpriseColumns} data={enterpriseSpendRanks} rowKey="id" />
<Table
columns={enterpriseColumns}
data={enterpriseSpendRanks}
rowKey="id"
emptyText={dashboard ? '暂无企业消费记录' : '企业消费数据暂不可用'}
/>
</div>
<div className="surface section-stack">
@@ -305,33 +247,33 @@ export function AdminHome() {
<Button className="mini-status-card" onClick={() => navigate('/admin/enterprise-audit')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.enterpriseCertifications} </strong>
<strong>{dashboard ? pendingAudits.enterpriseCertifications : '—'} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/sms-audit')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.smsAudits} </strong>
<strong>{dashboard ? pendingAudits.smsAudits : '—'} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/templates')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.templates} </strong>
<strong>{dashboard ? pendingAudits.templates : '—'} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/signatures')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.signatures} </strong>
<strong>{dashboard ? pendingAudits.signatures : '—'} </strong>
</Button>
<Button className="mini-status-card" onClick={() => navigate('/admin/drainage-audits')} variant="ghost">
<FileCheck2 size={22} />
<span></span>
<strong>{pendingAudits.drainageInfos} </strong>
<strong>{dashboard ? pendingAudits.drainageInfos : '—'} </strong>
</Button>
<div className="mini-status-card">
<ShieldCheck size={22} />
<div>
<span></span>
<strong>{dashboard?.taskCount ?? 0} </strong>
<strong>{dashboard ? dashboard.taskCount : '—'} </strong>
<small></small>
</div>
</div>
@@ -339,7 +281,7 @@ export function AdminHome() {
<ShieldCheck size={22} />
<div>
<span></span>
<strong>{downstreamAlertCount} </strong>
<strong>{dashboard ? downstreamAlertCount : '—'} </strong>
<small></small>
</div>
</div>
@@ -386,6 +328,12 @@ export function AdminHome() {
<MoneyText>¥{formatCurrency(selectedEnterprise.todaySpend)}</MoneyText>
</strong>
</div>
<div className="ui-detail-info-grid__item">
<span></span>
<strong>
<MoneyText>¥{formatCurrency(selectedEnterprise.todayReturned)}</MoneyText>
</strong>
</div>
<div className="ui-detail-info-grid__item">
<span></span>
<strong>{formatCount(selectedEnterprise.availableBalance)}</strong>
+187
View File
@@ -0,0 +1,187 @@
import { useEffect, useRef, useState } from 'react';
import { Button, MoneyText, Table, type TableColumn } from '@/components/ui';
import { homeApi } from '@/api/admin/home.api';
import type { HomeSummary, HomeReceiptRow, HomeRevenueRow } from '@/api/types/home';
import { formatCents } from '@/utils/currency';
const count = (value?: number) => (value === undefined ? '—' : value.toLocaleString('zh-CN'));
const rate = (value?: number) => (value === undefined ? '—' : `${value.toFixed(1)}%`);
const money = (value?: number) => (value === undefined ? '—' : `¥${formatCents(value)}`);
export function HomeMetrics({ data }: { data: HomeSummary | null }) {
const [open, setOpen] = useState<'receipt' | 'revenue' | null>(null);
const [receipts, setReceipts] = useState<HomeReceiptRow[] | null>(null);
const [revenue, setRevenue] = useState<HomeRevenueRow[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const request = useRef<AbortController | null>(null);
useEffect(() => () => request.current?.abort(), []);
async function expand(kind: 'receipt' | 'revenue', retry = false) {
if (!data) return;
request.current?.abort();
setError('');
if (open === kind && !retry) {
setOpen(null);
setLoading(false);
return;
}
setOpen(kind);
if (kind === 'receipt' ? receipts : revenue) {
setLoading(false);
return;
}
const controller = new AbortController();
request.current = controller;
setLoading(true);
try {
if (kind === 'receipt') {
const result = await homeApi.receipts(data.snapshotToken, controller.signal);
if (!controller.signal.aborted) setReceipts(result.items);
} else {
const result = await homeApi.revenue(data.snapshotToken, controller.signal);
if (!controller.signal.aborted) setRevenue(result.items);
}
} catch (e) {
if (!controller.signal.aborted) setError(e instanceof Error ? e.message : '日期明细加载失败');
} finally {
if (!controller.signal.aborted) setLoading(false);
}
}
const value = data?.today;
const groups = [
{
title: '今日业务',
rows: [
['今日业务短信数量', count(value?.sent), '条'],
['今日发送成功数量', count(value?.delivered), '条'],
['总体成功率', rate(value?.successRate), ''],
],
},
{
title: '今日回执',
kind: 'receipt' as const,
rows: [
['今日回执分片总数', count(value?.receiptUnits), '片'],
['今日回执成功分片数', count(value?.successUnits), '片'],
['今日回执成功率', rate(value?.receiptSuccessRate), ''],
],
},
{
title: '今日营业状况',
kind: 'revenue' as const,
rows: [
['今日营收金额', money(value?.revenueCents), ''],
['今日利润', money(value?.profitCents), ''],
['今日利润率', rate(value?.profitRate), ''],
],
},
];
const receiptColumns: TableColumn<HomeReceiptRow>[] = [
{ key: 'date', title: '原提交日期', width: '300px', render: (r) => `${r.submitDate} 提交 → 今日回执` },
{ key: 'total', title: '今日回执分片总数', width: '180px', align: 'right', render: (r) => `${count(r.total)}` },
{
key: 'success',
title: '今日回执成功分片数',
width: '190px',
align: 'right',
render: (r) => `${count(r.success)}`,
},
{ key: 'rate', title: '今日回执成功率', width: '150px', align: 'right', render: (r) => rate(r.rate) },
];
const revenueColumns: TableColumn<HomeRevenueRow>[] = [
{ key: 'date', title: '原提交日期', width: '300px', render: (r) => `${r.submitDate} 提交 → 今日成功回执` },
{
key: 'revenue',
title: '今日回执归属营收',
width: '180px',
align: 'right',
render: (r) => <MoneyText>{money(r.revenueCents)}</MoneyText>,
},
{
key: 'profit',
title: '今日回执归属利润',
width: '180px',
align: 'right',
render: (r) => <MoneyText>{money(r.profitCents)}</MoneyText>,
},
{ key: 'rate', title: '对应利润率', width: '150px', align: 'right', render: (r) => rate(r.rate) },
];
return (
<>
<div className="home-metrics" aria-busy={!data}>
{groups.map((group) => (
<section key={group.title} className="home-metric-panel" aria-label={group.title}>
<div className="home-panel-heading">
<h2>{group.title}</h2>
{group.kind && (
<Button
size="sm"
variant="ghost"
disabled={!data}
aria-expanded={open === group.kind}
onClick={() => void expand(group.kind)}
>
{open === group.kind ? '收起明细' : '按提交日查看'}
</Button>
)}
</div>
<dl>
{group.rows.map(([label, number, unit], index) => (
<div
key={label}
className={`home-metric-row${group.kind === 'receipt' && index === 1 ? ' is-success' : ''}${number.startsWith('-') || number.startsWith('¥-') ? ' is-negative' : ''}`}
>
<dt>{label}</dt>
<dd>
<strong>{number}</strong>
{unit && <span>{unit}</span>}
</dd>
</div>
))}
</dl>
</section>
))}
</div>
<p className="home-metrics-note">
</p>
{open && (
<section className="surface section-stack" aria-label={open === 'receipt' ? '回执日期明细' : '营业日期明细'}>
<div className="section-heading">
<div>
<h2>{open === 'receipt' ? '今日回执' : '今日营业状况'} · </h2>
<p className="muted">
{open === 'revenue' ? '归属的收益,不是对应提交日的全天营业额。' : ',按短信最初提交日期分组。'}
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => {
request.current?.abort();
setOpen(null);
}}
>
</Button>
</div>
{loading ? (
<p role="status"></p>
) : error ? (
<div role="alert">
{error}
<Button size="sm" onClick={() => void expand(open, true)}>
</Button>
</div>
) : open === 'receipt' ? (
<Table columns={receiptColumns} data={receipts ?? []} rowKey="submitDate" pagination={false} />
) : (
<Table columns={revenueColumns} data={revenue ?? []} rowKey="submitDate" pagination={false} />
)}
</section>
)}
</>
);
}
@@ -0,0 +1,41 @@
// Offline operator entry point. No SMS, detection, notification or history overwrite is performed.
import { createRequire } from 'node:module';
import assert from 'node:assert/strict';
const args = Object.fromEntries(process.argv.slice(2).map((a) => a.replace(/^--/, '').split('=')));
const url = new URL(process.env.DATABASE_URL || '');
assert(
args.host && args.host === url.hostname,
'Supply --host matching DATABASE_URL and an explicitly authorized target',
);
assert(args.dates, 'Supply --dates=YYYY-MM-DD,YYYY-MM-DD; no implicit historical range');
const dates = [...new Set(args.dates.split(','))];
assert(dates.length <= 366, 'At most 366 explicitly selected days per invocation');
process.env.NODE_ENV = 'test'; // Disable all automatic schedulers; this script only creates the report writer.
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { SignatureAnalyticsService } = require('./dist/signature-analytics/signature-analytics.service');
const { analyticsDate, databaseDay, todayKey } = require('./dist/signature-analytics/analytics-date');
const db = new PrismaService();
try {
for (const day of dates) {
analyticsDate(day);
assert(day < todayKey(), 'Only complete natural days may be backfilled');
const current = await db.signatureAnalyticsDay.findUnique({ where: { businessDate: databaseDay(day) } });
assert(!current?.publishedGenerationId, `${day}: published report exists; overwriting is forbidden`);
}
console.log(
JSON.stringify({
host: url.hostname,
dates,
execute: args.execute === 'yes',
provenance: 'backfill-current-source',
}),
);
if (args.execute === 'yes') {
const writer = new SignatureAnalyticsService(db);
for (const day of dates) console.log(JSON.stringify(await writer.generate(day, true)));
}
} finally {
await db.onModuleDestroy();
}
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { execFileSync } from 'node:child_process';
import Module from 'node:module';
import { randomUUID } from 'node:crypto';
const require = createRequire(new URL('../../api/package.json', import.meta.url));
const url = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || '');
assert(url.hostname === '127.0.0.1' && url.pathname.startsWith('/cmpp_qa_signature_'));
process.env.DATABASE_URL = url.toString();
process.env.NODE_ENV = 'test';
require('reflect-metadata');
const { PrismaService } = require('./dist/prisma/prisma.service'),
{ Prisma } = require('@prisma/client');
const { activityCounts } = require('./dist/signature-analytics/analytics-aggregate');
const { todayKey, addDays, startOfDay } = require('./dist/signature-analytics/analytics-date');
const oldSource = execFileSync(
'git',
['show', '4eb7b16d122da14f921093716d4ca1ed390d9e4c:api/src/signature-retirement/signature-retirement.service.ts'],
{ encoding: 'utf8' },
);
const compiled = require('typescript').transpileModule(oldSource, {
compilerOptions: { module: 1, target: 9, experimentalDecorators: true, emitDecoratorMetadata: true },
}).outputText;
const oldModule = new Module(require.resolve('./dist/signature-retirement/signature-retirement.service'));
oldModule.filename = require.resolve('./dist/signature-retirement/signature-retirement.service');
oldModule.paths = require.resolve.paths('@prisma/client');
oldModule._compile(compiled, oldModule.filename);
const db = new PrismaService(),
prefix = 'bench' + randomUUID().slice(0, 8),
N = 300,
K = 100,
D = addDays(todayKey(), -1),
start = startOfDay(D),
end = startOfDay(todayKey());
let last;
const traced = new Proxy(db, {
get(t, k) {
if (k === '$queryRaw')
return async (q) => {
last = q;
return db.$queryRaw(q);
};
const v = t[k];
return typeof v === 'function' ? v.bind(t) : v;
},
});
try {
const tenant = await db.tenant.create({ data: { name: prefix, code: prefix } }),
app = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: prefix,
cmppAccount: prefix,
cmppEnterpriseCode: '000001',
secretHash: 'isolated',
interfaceEnabled: false,
},
}),
channel = await db.smsChannel.create({
data: {
name: prefix,
code: prefix,
srcId: '1069',
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: 'isolated',
passwordCipher: 'not-a-secret',
carriers: ['mobile'],
status: 'disabled',
},
});
await db.$executeRaw`INSERT INTO "SmsSignature"(id,"tenantId","applicationId",name,"updatedAt") SELECT ${prefix}||n,${tenant.id},${app.id},${prefix}||n,NOW() FROM generate_series(1,${N}) n`;
await db.$executeRaw`INSERT INTO "SmsMessageRecord"(id,"messageId","tenantId","applicationId","signatureId","phoneNumber",content,carrier,"queuedAt","billingUnits","updatedAt") SELECT ${prefix}||n||'-'||k,${prefix}||n||'-'||k,${tenant.id},${app.id},${prefix}||n,'13800000200','isolated','mobile',${start},1,NOW() FROM generate_series(1,${N}) n CROSS JOIN generate_series(1,${K}) k`;
await db.$executeRaw`INSERT INTO "SmsSubmitRecord"(id,"messageRecordId","submitId","channelId","submitStatus","submittedAt","createdAt","updatedAt") SELECT id,id,id,${channel.id},'accepted',${start},${start},NOW() FROM "SmsMessageRecord" WHERE "tenantId"=${tenant.id}`;
await db.$executeRawUnsafe('ANALYZE "SmsMessageRecord"');
await db.$executeRawUnsafe('ANALYZE "SmsSubmitRecord"');
const dims = Array.from({ length: N }, (_, i) =>
['enterprise', 'channel'].map((type) => ({
dimensionKey: JSON.stringify([type, prefix + (i + 1)]),
dimensionType: type,
tenantId: tenant.id,
applicationId: app.id,
signatureId: prefix + (i + 1),
channelKey: type === 'channel' ? channel.id : '',
channelId: type === 'channel' ? channel.id : null,
carrier: 'mobile',
approvedAt: startOfDay(addDays(D, -60)),
signatureName: prefix + (i + 1),
tenantName: prefix,
applicationName: prefix,
channelName: prefix,
})),
).flat();
const old = new oldModule.exports.SignatureRetirementService(traced);
const before = performance.now();
for (const d of dims) {
const r = await old.activityCounts(d, start, end);
assert.equal(r.acceptedBusinessCount, K);
}
const oldMs = performance.now() - before;
const sampleOld = last;
const now = performance.now(),
counts = await activityCounts(traced, dims, D),
newMs = performance.now() - now,
sampleNew = last;
for (const d of dims) assert.equal(counts.get(d.dimensionKey).acceptedBusinessCount, K);
const explain = async (q) =>
(await db.$queryRaw(Prisma.sql`EXPLAIN (ANALYZE,BUFFERS,FORMAT JSON) ${q}`))[0]['QUERY PLAN'][0];
const oldPlan = await explain(sampleOld),
newPlan = await explain(sampleNew);
const scanRows = (p) => {
let sum = 0;
const walk = (n) => {
if (
n['Node Type']?.includes('Scan') &&
['SmsSubmitRecord', 'SmsMessageRecord', 'SmsReceiptRecord', 'SmsMessageSegmentAudit'].includes(
n['Relation Name'],
)
)
sum += (n['Actual Rows'] + (n['Rows Removed by Filter'] || 0)) * (n['Actual Loops'] || 0);
for (const c of n.Plans || []) walk(c);
};
walk(p.Plan);
return sum;
};
console.log(
JSON.stringify({
messages: N * K,
dimensions: dims.length,
oldMs,
newMs,
reduction: 1 - newMs / oldMs,
oldSampleRows: scanRows(oldPlan),
estimatedOldRows: scanRows(oldPlan) * dims.length,
newRows: scanRows(newPlan),
note: 'warm cache, per-dimension sample extrapolation; not production CPU or whole job capacity',
oldPlan,
newPlan,
}),
);
} finally {
await db.onModuleDestroy();
}
+210
View File
@@ -0,0 +1,210 @@
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { randomUUID } from 'node:crypto';
const url = new URL(process.env.HOME_TEST_DATABASE_URL || '');
assert(
['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_home_'),
'isolated local database required',
);
Object.assign(process.env, { DATABASE_URL: url.toString(), NODE_ENV: 'test' });
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { HomeProjection } = require('./dist/home-dashboard/home-projection');
const { HomeService } = require('./dist/home-dashboard/home.service');
const { todayKey, addDays, startOfDay } = require('./dist/signature-analytics/analytics-date');
const db = new PrismaService(),
projector = new HomeProjection(db),
read = new HomeService(db);
const T = todayKey(),
now = new Date(),
stamp = randomUUID().slice(0, 8),
pass = (s) => console.log('PASS', s);
const at = (day, hour = 1) => new Date(startOfDay(day).getTime() + hour * 3600000);
try {
assert.equal(
await db.smsMessageRecord.count(),
0,
'use a fresh disposable test database; existing data is never cleared',
);
const tenant = await db.tenant.create({ data: { name: '首页验收企业甲', code: stamp } });
const second = await db.tenant.create({ data: { name: '首页验收企业乙', code: stamp + 'b' } });
for (const t of [tenant, second])
await db.tenantAccount.create({ data: { tenantId: t.id, balanceCents: 185675000n } });
const channel = await db.smsChannel.create({
data: {
name: '首页隔离通道',
code: stamp,
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: 'no-send',
passwordCipher: 'isolated',
srcId: '1069',
carriers: ['mobile'],
status: 'disabled',
},
});
let serial = 0;
async function sms(day, states, units = 3, receiveDays = [], total = units) {
const id = `${stamp}-${++serial}`;
const delivered = states.length === total && states.every((s) => s === 'delivered');
const m = await db.smsMessageRecord.create({
data: {
tenantId: tenant.id,
messageId: id,
phoneNumber: '13800000000',
content: '隔离数据库验收,不发送',
billingUnits: units,
unitPrice: 500n,
amountCents: BigInt(units) * 500n,
status: delivered ? 'delivered' : 'failed',
queuedAt: at(day),
},
});
const attempt = await db.smsSubmitRecord.create({
data: {
messageRecordId: m.id,
channelId: channel.id,
submitId: id,
submitStatus: 'accepted',
costUnitPrice: 300n,
gatewayMessageId: id + '-0',
},
});
for (let i = 0; i < states.length; i++) {
await db.smsMessageSegmentAudit.create({
data: {
messageRecordId: m.id,
submitRecordId: attempt.id,
channelId: channel.id,
submitId: id,
segmentIndex: i + 1,
segmentTotal: total,
gatewayMessageId: id + '-' + i,
receiptStatus: states[i],
submitStatus: 'accepted',
},
});
await receipt(m, attempt, id + '-' + i, states[i], receiveDays[i] ?? T);
}
return { m, attempt };
}
async function receipt(m, attempt, gatewayId, status, date, receivedAt = at(date)) {
return db.upstreamReceiptInbox.create({
data: {
receiptKey: randomUUID(),
incomingChannelId: channel.id,
upstreamAccount: 'no-send',
upstreamHost: '127.0.0.1',
upstreamPort: 1,
protocol: 'cmpp',
protocolVersion: '3.0',
gatewayMessageId: gatewayId,
receiptStatus: status,
rawStatus: status,
deliveredAt: receivedAt,
gatewayReceivedAt: receivedAt,
receivedAt: now,
status: 'matched',
matchedMessageRecordId: m.id,
matchedSubmitRecordId: attempt.id,
matchedChannelId: channel.id,
},
});
}
for (let offset = 0; offset < 4; offset++) {
const day = addDays(T, -offset);
await sms(day, ['delivered', 'delivered', 'delivered']);
await sms(day, ['failed']);
await sms(day, ['delivered', 'delivered']);
await sms(day, ['delivered'], 1);
}
await sms(addDays(T, -4), ['delivered', 'delivered', 'delivered']);
const cross = await sms(addDays(T, -2), ['delivered', 'delivered', 'delivered'], 3, [
addDays(T, -1),
addDays(T, -1),
T,
]);
await receipt(cross.m, cross.attempt, cross.attempt.submitId + '-2', 'delivered', T, at(T, 2));
// Already completed yesterday: today's duplicate must not count.
const yesterday = await sms(addDays(T, -1), ['delivered'], 1, [addDays(T, -1)]);
await receipt(yesterday.m, yesterday.attempt, yesterday.attempt.submitId + '-0', 'delivered', T);
for (const [type, amount, day, relatedType] of [
['refunded', 123456n, T, 'sms_message_record'],
['released', 100n, T, 'sms_message_record'],
['released', 999n, T, 'other'],
['refunded', 999n, addDays(T, -1), 'sms_message_record'],
])
await db.accountTransaction.create({
data: { tenantId: tenant.id, transactionType: type, amountCents: amount, relatedType, createdAt: at(day) },
});
await db.smsBillingRecord.create({
data: {
tenantId: tenant.id,
contentLength: 5,
billingUnits: 10,
unitPrice: 500n,
amountCents: 5000n,
billingStatus: 'charged',
createdAt: at(T),
},
});
await assert.rejects(read.summary('qa'), /初始化/);
await projector.tick();
const s = await read.summary('qa');
assert.equal(s.today.sent, 4);
assert.equal(s.today.delivered, 2);
assert.equal(s.today.successRate, 50);
assert.equal(s.today.receiptUnits, 43);
assert.equal(s.today.successUnits, 19);
assert.equal(s.today.revenueCents, 9500);
assert.equal(s.today.profitCents, 3800);
assert.equal(s.enterpriseSpendRanks[0].todayReturnedCents, 123556);
assert.equal(s.enterpriseSpendRanks[0].todaySpendCents, 5000);
pass('four-day cohorts, long missing/partial success, cross-midnight completion, global replay dedup, refund ledger');
const r = await read.breakdown('qa', s.snapshotToken, 'receipt'),
f = await read.breakdown('qa', s.snapshotToken, 'revenue');
assert.equal(r.items.length, 4);
assert.equal(f.items.length, 4);
assert.equal(
r.items.reduce((a, b) => a + b.total, 0),
s.today.receiptUnits,
);
assert.equal(
f.items.reduce((a, b) => a + b.revenueCents, 0),
s.today.revenueCents,
);
await assert.rejects(read.breakdown('other', s.snapshotToken, 'receipt'), /不可访问/);
await assert.rejects(read.breakdown('qa', 'invalid', 'receipt'), /无效/);
await assert.rejects(read.breakdown('qa', s.snapshotToken, 'receipt', new Date(now.getTime() + 3600000)), /过期/);
pass('snapshot total reconciliation, ownership, validation, expiry');
await sms(T, ['delivered'], 1);
await projector.tick();
assert.deepEqual(await read.breakdown('qa', s.snapshotToken, 'receipt'), r);
assert.equal((await read.summary('qa')).today.successUnits, 20);
pass('old immutable snapshot remains consistent after new receipt publication');
// Transaction rollback must leave both source and pending work unchanged.
const before = await db.homeProjectionDirty.count();
await assert.rejects(
db.$transaction(async (tx) => {
await tx.smsMessageRecord.update({ where: { id: cross.m.id }, data: { unitPrice: 999n } });
throw new Error('injected rollback');
}),
/injected/,
);
assert.equal(await db.homeProjectionDirty.count(), before);
assert.equal((await db.smsMessageRecord.findUnique({ where: { id: cross.m.id } })).unitPrice, 500n);
const lock = db.$transaction(async (tx) => {
await tx.$executeRaw`SELECT pg_advisory_xact_lock(17100917)`;
await new Promise((r) => setTimeout(r, 500));
});
await new Promise((r) => setTimeout(r, 100));
assert.equal((await projector.tick()).busy, true);
await lock;
pass('source/dirty atomic rollback and independent worker atomic claim');
console.log(
JSON.stringify({ database: url.pathname, fixtureMessages: await db.smsMessageRecord.count(), status: 'PASS' }),
);
} finally {
await db.$disconnect();
}
@@ -0,0 +1,400 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
const url = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || '');
assert(['127.0.0.1', 'localhost'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_signature_'));
process.env.DATABASE_URL = url.toString();
process.env.NODE_ENV = 'test';
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { SignatureAnalyticsService } = require('./dist/signature-analytics/signature-analytics.service');
const { SignatureAnalyticsRead } = require('./dist/signature-analytics/analytics-read');
const { OperationsQualityQueries } = require('./dist/operations/queries/quality.queries');
const { analyticsJob } = require('./dist/signature-analytics/analytics-job');
const { detectRetirement } = require('./dist/signature-analytics/retirement-batch');
const { resolveUplinkMatch } = require('./dist/send-chain/uplink-matching');
const { todayKey, addDays, startOfDay, databaseDay } = require('./dist/signature-analytics/analytics-date');
const db = new PrismaService(),
writer = new SignatureAnalyticsService(db),
reader = new SignatureAnalyticsRead(db);
const prefix = randomUUID().slice(0, 8),
T = todayKey(),
D = addDays(T, -1),
old = addDays(T, -5);
const pass = (name) => console.log('PASS', name);
const at = (date, seconds = 3600) => new Date(startOfDay(date).getTime() + seconds * 1000);
try {
const tenant = await db.tenant.create({ data: { name: `签名日报验收${prefix}`, code: prefix } });
const app = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '应用甲',
cmppAccount: prefix,
cmppEnterpriseCode: '000001',
secretHash: 'isolated-no-login',
interfaceEnabled: false,
},
});
const channel = await db.smsChannel.create({
data: {
name: '隔离通道',
code: prefix,
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: 'isolated',
passwordCipher: 'not-a-secret',
srcId: '10690000',
carriers: ['mobile'],
status: 'disabled',
},
});
const signature = await db.smsSignature.create({
data: { tenantId: tenant.id, applicationId: app.id, name: `【验收${prefix}`, auditStatus: 'approved' },
});
await db.channelSignatureReportTask.create({
data: {
tenantId: tenant.id,
signatureId: signature.id,
channelId: channel.id,
reportType: 'signature',
carrier: 'mobile',
approvalScope: 'carrier_specific',
status: 'approved',
approvedAt: at(addDays(T, -60)),
createdAt: at(addDays(T, -60)),
},
});
async function message({
date = D,
submitDate = date,
units = 1,
segments = units,
delivered = true,
phone = '13800000001',
sig = signature.id,
content = signature.name + '隔离数据',
} = {}) {
const m = await db.smsMessageRecord.create({
data: {
messageId: randomUUID(),
tenantId: tenant.id,
applicationId: app.id,
signatureId: sig,
phoneNumber: phone,
content,
carrier: 'mobile',
queuedAt: at(date),
submittedAt: at(submitDate),
billingUnits: units,
status: delivered && segments === units ? 'delivered' : 'submitted',
submitStatus: 'accepted',
receiptStatus: delivered && segments === units ? 'delivered' : null,
deliveredAt: delivered && segments === units ? at(submitDate, 3602) : null,
},
});
const s = await db.smsSubmitRecord.create({
data: {
messageRecordId: m.id,
tenantId: tenant.id,
channelId: channel.id,
submitId: randomUUID(),
submitStatus: 'accepted',
submittedAt: at(submitDate),
gatewayMessageId: randomUUID(),
createdAt: at(submitDate),
},
});
for (let index = 1; index <= segments; index++)
await db.smsMessageSegmentAudit.create({
data: {
messageRecordId: m.id,
submitRecordId: s.id,
channelId: channel.id,
submitId: s.submitId,
segmentTotal: units,
segmentIndex: index,
submitStatus: 'accepted',
receiptStatus: delivered ? 'delivered' : null,
deliveredAt: delivered ? at(submitDate, 3600 + index) : null,
},
});
return { m, s };
}
const complete = await message({ units: 3, phone: '13800000011' });
const partial = await message({ units: 3, segments: 2, phone: '13800000012' });
await message({ date: addDays(D, -1), submitDate: D, phone: '13800000013' });
await message({ sig: null, content: `【未登记${prefix}】隔离`, phone: '13800000014' });
await message({ date: T, phone: '13800000015' });
const oldMessage = await message({ date: old, phone: '13800000016' });
// A rerun uses an isolated database; do not reset or touch any real tenant environment.
await writer.generate(D);
const quality = await reader.quality({ date: D, keyword: prefix });
const item = quality.items.find((x) => x.signatureId === signature.id);
assert(item);
assert.equal(item.total, 2);
assert.equal(item.channelSubmitTotal, 3);
assert.equal(item.breakdowns[0].successCount, 2);
assert.equal(item.breakdowns[0].unknownCount, 1);
pass('日报业务/尝试归日分离、三段成功与缺片未知');
const activity = await reader.activity({ date: T, dimensionType: 'enterprise', signatureName: prefix });
assert.equal(activity.dimensions.length, 1);
assert.equal(activity.items.length, 1);
assert.equal(activity.items[0].acceptedBusinessCount, 3);
assert.equal(activity.items[0].deliveredBusinessCount, 2);
assert.equal(activity.coverage[0].date, D);
assert.equal(activity.complete, false);
pass('企业/通道服务端分页与D-1至D-30缺口元数据');
assert.equal((await reader.unreported({ date: D, keyword: prefix })).total, 1);
const live = await new OperationsQualityQueries(db).signatureQuality({ date: T, keyword: prefix });
assert.equal(live.dataSource, 'live');
assert.equal(live.items[0].total, 1);
pass('当天真实查询、历史只读日报、未报备日报');
await writer.generate(old, true);
const before = await reader.quality({ date: old, keyword: prefix });
await db.smsMessageRecord.update({
where: { id: oldMessage.m.id },
data: { status: 'failed', receiptStatus: 'undelivered' },
});
assert.equal((await writer.generate(old)).skipped, true);
assert.deepEqual((await reader.quality({ date: old, keyword: prefix })).items, before.items);
await assert.rejects(writer.generate(old, true), /覆盖/);
pass('T-4及更早冻结、显式补建不覆盖');
await db.signatureRetirementRule.create({
data: {
ruleType: 'enterprise_global',
targetKey: '',
mobileWindowDays: 7,
mobileThreshold: 100,
unicomWindowDays: 7,
unicomThreshold: 100,
telecomWindowDays: 7,
telecomThreshold: 100,
},
});
await db.smsSubmitRecord.create({
data: {
messageRecordId: oldMessage.m.id,
channelId: channel.id,
submitId: randomUUID(),
submitStatus: 'accepted',
submittedAt: at(addDays(old, 1)),
},
});
const detection = await detectRetirement(db, T);
const decision = await db.signatureRetirementDetection.findFirst({
where: { signatureId: signature.id, dimensionType: 'enterprise', detectionDate: databaseDay(T) },
});
assert(decision.notificationContent.includes('发送4条'));
assert(detection.alerted >= 1);
const count = await db.signatureRetirementDetection.count({ where: { signatureId: signature.id } });
assert.equal((await detectRetirement(db, T)).skipped, true);
assert.equal(await db.signatureRetirementDetection.count({ where: { signatureId: signature.id } }), count);
pass('批量退网窗口聚合、重复任务提前退出');
await message({ phone: '13800000021' });
await message({ phone: '13800000021' });
const matched = await resolveUplinkMatch(
db,
{ channelId: channel.id, phoneNumber: '13800000021', destId: '1069000099', receivedAt: at(D, 7200).toISOString() },
channel,
);
assert.equal(matched.matchStatus, 'matched');
assert.equal(matched.applicationId, app.id);
assert.equal(matched.messageRecordId, undefined);
const beforeSending = await resolveUplinkMatch(
db,
{ channelId: channel.id, phoneNumber: '13800000021', destId: '1069000099', receivedAt: at(D, 3000).toISOString() },
channel,
);
assert.equal(beforeSending.matchStatus, 'unmatched');
pass('上行同应用多短信仍归属唯一、未来下发不参与');
const channel2 = await db.smsChannel.create({
data: {
name: '隔离通道乙',
code: prefix + 'b',
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: 'isolated',
passwordCipher: 'not-a-secret',
srcId: '10690001',
carriers: ['mobile'],
status: 'disabled',
},
});
const wrong = await resolveUplinkMatch(
db,
{
channelId: channel2.id,
messageId: complete.m.messageId,
phoneNumber: complete.m.phoneNumber,
destId: '1069000199',
receivedAt: at(D, 7200).toISOString(),
},
channel2,
);
assert.equal(wrong.matchStatus, 'unmatched');
assert.equal(wrong.messageId, undefined);
const app2 = await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '应用乙',
cmppAccount: prefix + 'b',
cmppEnterpriseCode: '000002',
secretHash: 'isolated-no-login',
interfaceEnabled: false,
},
});
const other = await message({ phone: '13800000021' });
await db.smsMessageRecord.update({ where: { id: other.m.id }, data: { applicationId: app2.id } });
const multi = await resolveUplinkMatch(
db,
{ channelId: channel.id, phoneNumber: '13800000021', destId: '1069000099', receivedAt: at(D, 7200).toISOString() },
channel,
);
assert.equal(multi.matchStatus, 'ambiguous');
assert.equal(multi.candidates.length, 2);
pass('错误messageId/其他通道拒绝归属、第三条不同应用不会被截断漏掉');
// Fault injection is confined to this disposable QA database.
const late = await db.smsMessageSegmentAudit.create({
data: {
messageRecordId: partial.m.id,
submitRecordId: partial.s.id,
channelId: channel.id,
submitId: partial.s.submitId,
segmentTotal: 3,
segmentIndex: 3,
submitStatus: 'accepted',
receiptStatus: 'delivered',
deliveredAt: at(D, 3610),
},
});
await db.signatureAnalyticsRun.update({
where: { scope_businessDate: { scope: 'daily', businessDate: databaseDay(D) } },
data: { refreshFor: databaseDay(D) },
});
const prior = quality.generationId;
await writer.generate(D);
const refreshed = await reader.quality({ date: D, keyword: prefix });
assert.notEqual(refreshed.generationId, prior);
assert.equal(refreshed.items[0].breakdowns[0].successCount, 6);
assert((await db.signatureQualityDaily.count({ where: { generationId: prior } })) > 0);
const secondDay = addDays(T, -2);
await message({ date: secondDay, units: 2 });
await message({ date: secondDay, units: 4 });
const legacy = await message({ date: secondDay, units: 4, segments: 0 });
await db.smsReceiptRecord.create({
data: {
receiptKey: randomUUID(),
messageRecordId: legacy.m.id,
messageId: legacy.m.messageId,
channelId: channel.id,
gatewayMessageId: legacy.s.gatewayMessageId,
receiptStatus: 'delivered',
rawStatus: 'DELIVRD',
deliveredAt: at(secondDay, 3605),
},
});
await writer.generate(secondDay);
const multiLength = await reader.quality({ date: secondDay, keyword: prefix });
assert.equal(multiLength.items[0].breakdowns[0].successCount, 3);
assert.equal(multiLength.items[0].breakdowns[0].averageArrivalMs, 3667);
const outOfRange = await reader.activity({ date: T, dimensionType: 'enterprise', signatureName: prefix, page: 99 });
assert.equal(outOfRange.total, 1);
assert.equal(outOfRange.dimensions.length, 0);
pass('两段/四段、历史无分段明确回执兼容、耗时加权、超出页码仍有正确总数');
await writer.generate(addDays(T, -3));
pass('最近三日生成、迟到长短信分段刷新、旧版本保留');
const failedScope = 'qa-failure-' + prefix,
rollbackGeneration = randomUUID();
await assert.rejects(
analyticsJob(db, failedScope, D, async (tx) => {
await tx.signatureAnalyticsGeneration.create({
data: { id: rollbackGeneration, businessDate: databaseDay(D), sourceAsOf: new Date() },
});
throw new Error('injected');
}),
/injected/,
);
assert.equal(await db.signatureAnalyticsGeneration.count({ where: { id: rollbackGeneration } }), 0);
const failed = await db.signatureAnalyticsRun.findUnique({
where: { scope_businessDate: { scope: failedScope, businessDate: databaseDay(D) } },
});
assert.equal(failed.state, 'retry_wait');
await db.signatureAnalyticsRun.update({ where: { id: failed.id }, data: { nextAttemptAt: new Date(0) } });
assert.equal((await analyticsJob(db, failedScope, D, async () => 42)).result, 42);
pass('中途失败原子回滚、持久重试与恢复');
const scope = 'qa-concurrent-' + prefix;
let release, entered;
const gate = new Promise((r) => (release = r)),
ready = new Promise((r) => (entered = r));
const owner = analyticsJob(db, scope, D, async () => {
entered();
await gate;
return 1;
});
await ready;
assert.equal((await analyticsJob(db, scope, D, async () => 2)).skipped, true);
release();
assert.equal((await owner).result, 1);
const fencedScope = 'qa-fenced-' + prefix,
fencedGeneration = randomUUID();
await assert.rejects(
analyticsJob(db, fencedScope, D, async (tx) => {
await tx.signatureAnalyticsGeneration.create({
data: { id: fencedGeneration, businessDate: databaseDay(D), sourceAsOf: new Date() },
});
await db.signatureAnalyticsRun.update({
where: { scope_businessDate: { scope: fencedScope, businessDate: databaseDay(D) } },
data: { fence: { increment: 1 }, owner: 'replacement' },
});
}),
);
assert.equal(await db.signatureAnalyticsGeneration.count({ where: { id: fencedGeneration } }), 0);
pass('双worker唯一认领、旧fence禁止发布并回滚候选版本');
const checkpointScope = 'qa-checkpoint-' + prefix;
await assert.rejects(
analyticsJob(
db,
checkpointScope,
D,
async () => {
throw new Error('checkpoint fault');
},
new Date(),
async () => ({ version: 1 }),
),
/checkpoint fault/,
);
await db.signatureAnalyticsRun.update({
where: { scope_businessDate: { scope: checkpointScope, businessDate: databaseDay(D) } },
data: { nextAttemptAt: new Date(0) },
});
const resumed = await analyticsJob(
db,
checkpointScope,
D,
async (_tx, _generation, checkpoint) => checkpoint,
new Date(),
async () => ({ version: 2 }),
);
assert.deepEqual(resumed.result, { version: 1 });
pass('重试沿用首轮冻结规则快照');
assert(late.id);
console.log(
JSON.stringify({
fixturePrefix: prefix,
tenantId: tenant.id,
applicationId: app.id,
signatureId: signature.id,
partial: partial.s.id,
complete: complete.s.id,
}),
);
} finally {
await db.onModuleDestroy();
}
+143
View File
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict';
import { randomUUID } from 'node:crypto';
import { createRequire } from 'node:module';
const url = new URL(process.env.SIGNATURE_TEST_DATABASE_URL || '');
assert(['localhost', '127.0.0.1'].includes(url.hostname) && url.pathname.startsWith('/cmpp_qa_signature_'));
process.env.DATABASE_URL = url.toString();
process.env.NODE_ENV = 'test';
const require = createRequire(new URL('../../api/package.json', import.meta.url));
require('reflect-metadata');
const { PrismaService } = require('./dist/prisma/prisma.service');
const { completionDatabase } = require('./dist/send-chain/completion-context');
const { SendDownstreamDeliveryService } = require('./dist/send-chain/send-downstream-delivery.service');
const { OpenApiService } = require('./dist/open-api/open-api.service');
const { resolveUplinkMatch } = require('./dist/send-chain/uplink-matching');
const db = new PrismaService(),
proxy = completionDatabase(db),
prefix = randomUUID().slice(0, 8);
function delivery(openApi) {
let service;
const facade = {
resolveUplinkMatch: (event, channel) => resolveUplinkMatch(proxy, event, channel),
queueAndTryDownstreamDelivery: (data) => service.queueAndTryDownstreamDelivery(data),
postGatewayControl: () => {
throw new Error('External delivery is forbidden in QA');
},
};
service = new SendDownstreamDeliveryService(proxy, undefined, openApi, facade, {});
return service;
}
try {
const tenant = await db.tenant.create({ data: { name: '上行验收', code: prefix } });
const apps = [];
for (const suffix of ['a', 'b'])
apps.push(
await db.smsApplication.create({
data: {
tenantId: tenant.id,
name: '应用' + suffix,
cmppAccount: prefix + suffix,
cmppEnterpriseCode: '000001',
secretHash: 'isolated',
interfaceEnabled: true,
},
}),
);
const channel = await db.smsChannel.create({
data: {
name: '仅入库隔离通道',
code: prefix,
srcId: '1069',
gatewayHost: '127.0.0.1',
gatewayPort: 1,
account: 'isolated',
passwordCipher: 'not-a-secret',
carriers: ['mobile'],
status: 'disabled',
},
});
const received = new Date(),
sent = new Date(received.getTime() - 60_000);
for (const [phone, index] of [
['13800000101', 0],
['13800000101', 0],
['13800000102', 0],
['13800000102', 1],
]) {
const m = await db.smsMessageRecord.create({
data: {
messageId: randomUUID(),
tenantId: tenant.id,
applicationId: apps[index].id,
phoneNumber: phone,
content: '隔离上行匹配测试',
},
});
await db.smsSubmitRecord.create({
data: {
messageRecordId: m.id,
channelId: channel.id,
submitId: randomUUID(),
submitStatus: 'accepted',
submittedAt: sent,
},
});
}
for (const app of apps) {
await db.smsApplicationHttpConfig.create({ data: { applicationId: app.id, enabled: true, sendEnabled: false } });
await db.httpWebhookEndpoint.create({
data: {
applicationId: app.id,
eventType: 'uplink',
url: 'http://127.0.0.1:1/never-called',
secretEncrypted: 'isolated-no-delivery',
secretLast4: 'test',
},
});
}
const service = delivery(new OpenApiService(db, undefined));
const event = {
eventId: randomUUID(),
channelId: channel.id,
phoneNumber: '13800000101',
destId: '10690001',
content: 'TD',
receivedAt: received.toISOString(),
gatewayMessageId: 'supplier-mo-id',
};
const copies = await Promise.all([service.handleUplink(event), service.handleUplink(event)]);
assert.equal(copies[0].id, copies[1].id);
assert.equal(copies[0].messageId, null);
assert.equal(copies[0].messageRecordId, null);
assert.equal(copies[0].gatewayMessageId, 'supplier-mo-id');
assert.equal(await db.cmppDownstreamDelivery.count({ where: { dedupeKey: 'uplink:' + copies[0].id } }), 1);
assert.equal(await db.httpWebhookEvent.count({ where: { uplinkMessageId: copies[0].id } }), 1);
console.log('PASS 重复事件原子入库、无唯一原短信不伪造编号、CMPP与HTTP通知意图各一份');
const ambiguous = await service.handleUplink({ ...event, eventId: randomUUID(), phoneNumber: '13800000102' });
assert.equal(ambiguous.matchStatus, 'ambiguous');
const candidates = await db.smsUplinkMatchCandidate.findMany({ where: { uplinkMessageId: ambiguous.id } });
assert.equal(candidates.length, 2);
const claims = await Promise.allSettled(candidates.map((c) => service.claimUplinkMatchCandidate(ambiguous.id, c.id)));
assert.equal(claims.filter((r) => r.status === 'fulfilled').length, 1);
assert.equal(await db.cmppDownstreamDelivery.count({ where: { dedupeKey: 'uplink:' + ambiguous.id } }), 1);
assert.equal(
await db.smsUplinkMatchCandidate.count({ where: { uplinkMessageId: ambiguous.id, status: 'claimed' } }),
1,
);
console.log('PASS 并发认领仅一个应用成功、候选及通知同事务');
const failedEvent = { ...event, eventId: randomUUID() };
await assert.rejects(
delivery({
queueWebhookEvent: async () => {
throw new Error('injected notification storage failure');
},
}).handleUplink(failedEvent),
/injected/,
);
assert.equal(await db.smsUplinkMessage.count({ where: { eventId: failedEvent.eventId } }), 0);
await service.handleUplink(failedEvent);
assert.equal(await db.smsUplinkMessage.count({ where: { eventId: failedEvent.eventId } }), 1);
console.log('PASS 通知存储故障回滚上行、重试恢复,不调用外部发送');
} finally {
await db.onModuleDestroy();
}