This commit is contained in:
@@ -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;
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -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)`;
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user