feat: 重构首页回执营业统计并增加企业返还金额
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-17 13:11:35 +08:00
parent 627fa7ec97
commit 5e4d644788
29 changed files with 1684 additions and 231 deletions
@@ -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;
+35
View File
@@ -2996,3 +2996,38 @@ model UnreportedSignatureDaily {
@@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])
}
+2
View File
@@ -1,4 +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';
@@ -59,6 +60,7 @@ import { SendingMonitorModule } from './sending-monitor/sending-monitor.module';
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 },
);
}
}