diff --git a/api/prisma/migrations/20260917100000_home_dashboard/migration.sql b/api/prisma/migrations/20260917100000_home_dashboard/migration.sql new file mode 100644 index 0000000..9fa5abf --- /dev/null +++ b/api/prisma/migrations/20260917100000_home_dashboard/migration.sql @@ -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(); diff --git a/api/prisma/migrations/20260917101000_home_dirty_owner_changes/migration.sql b/api/prisma/migrations/20260917101000_home_dirty_owner_changes/migration.sql new file mode 100644 index 0000000..c7b2577 --- /dev/null +++ b/api/prisma/migrations/20260917101000_home_dirty_owner_changes/migration.sql @@ -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; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index d1cbd93..80d46a3 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -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]) +} diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 23507f2..070faa9 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -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, diff --git a/api/src/home-dashboard/home-fact.spec.ts b/api/src/home-dashboard/home-fact.spec.ts new file mode 100644 index 0000000..63f3cc9 --- /dev/null +++ b/api/src/home-dashboard/home-fact.spec.ts @@ -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(); + }); +}); diff --git a/api/src/home-dashboard/home-fact.ts b/api/src/home-dashboard/home-fact.ts new file mode 100644 index 0000000..49e4b22 --- /dev/null +++ b/api/src/home-dashboard/home-fact.ts @@ -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(); + 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); diff --git a/api/src/home-dashboard/home-projection.ts b/api/src/home-dashboard/home-projection.ts new file mode 100644 index 0000000..45df288 --- /dev/null +++ b/api/src/home-dashboard/home-projection.ts @@ -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 }, + ); + } +} diff --git a/api/src/home-dashboard/home-read.ts b/api/src/home-dashboard/home-read.ts new file mode 100644 index 0000000..3fd499a --- /dev/null +++ b/api/src/home-dashboard/home-read.ts @@ -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(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 }, + }; +} diff --git a/api/src/home-dashboard/home-retention.ts b/api/src/home-dashboard/home-retention.ts new file mode 100644 index 0000000..9627ee6 --- /dev/null +++ b/api/src/home-dashboard/home-retention.ts @@ -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)`; +} diff --git a/api/src/home-dashboard/home-source.ts b/api/src/home-dashboard/home-source.ts new file mode 100644 index 0000000..8a0caf9 --- /dev/null +++ b/api/src/home-dashboard/home-source.ts @@ -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, + ), + }; + }); +} diff --git a/api/src/home-dashboard/home.module.ts b/api/src/home-dashboard/home.module.ts new file mode 100644 index 0000000..aae2ae3 --- /dev/null +++ b/api/src/home-dashboard/home.module.ts @@ -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 {} diff --git a/api/src/home-dashboard/home.service.ts b/api/src/home-dashboard/home.service.ts new file mode 100644 index 0000000..e3fe7a0 --- /dev/null +++ b/api/src/home-dashboard/home.service.ts @@ -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 }, + ); + } +} diff --git a/docs/designs/homepage-20260917/homepage-implemented-expanded.png b/docs/designs/homepage-20260917/homepage-implemented-expanded.png new file mode 100644 index 0000000..bee7f35 Binary files /dev/null and b/docs/designs/homepage-20260917/homepage-implemented-expanded.png differ diff --git a/docs/designs/homepage-20260917/homepage-implemented-mobile.png b/docs/designs/homepage-20260917/homepage-implemented-mobile.png new file mode 100644 index 0000000..ab553b7 Binary files /dev/null and b/docs/designs/homepage-20260917/homepage-implemented-mobile.png differ diff --git a/docs/designs/homepage-20260917/homepage-implemented.png b/docs/designs/homepage-20260917/homepage-implemented.png new file mode 100644 index 0000000..3312aae Binary files /dev/null and b/docs/designs/homepage-20260917/homepage-implemented.png differ diff --git a/docs/designs/homepage-20260917/homepage-v1.png b/docs/designs/homepage-20260917/homepage-v1.png new file mode 100644 index 0000000..c1a59c6 Binary files /dev/null and b/docs/designs/homepage-20260917/homepage-v1.png differ diff --git a/docs/designs/homepage-20260917/homepage-v2.png b/docs/designs/homepage-20260917/homepage-v2.png new file mode 100644 index 0000000..ad2181d Binary files /dev/null and b/docs/designs/homepage-20260917/homepage-v2.png differ diff --git a/docs/designs/homepage-20260917/prompt-v2.txt b/docs/designs/homepage-20260917/prompt-v2.txt new file mode 100644 index 0000000..2ce333e --- /dev/null +++ b/docs/designs/homepage-20260917/prompt-v2.txt @@ -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. diff --git a/docs/designs/homepage-20260917/prompt.txt b/docs/designs/homepage-20260917/prompt.txt new file mode 100644 index 0000000..971739e --- /dev/null +++ b/docs/designs/homepage-20260917/prompt.txt @@ -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. diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 4049b3b..bfbccb0 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2354,3 +2354,13 @@ Webhook需在当前受支持Node运行时通过真实HTTPS投递;SSRF校验后 ### 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“不增返还列”均被本次明确指令替代。仅本地实现和验收,未部署。 diff --git a/docs/homepage-receipt-metrics-redesign-20260917.md b/docs/homepage-receipt-metrics-redesign-20260917.md new file mode 100644 index 0000000..dcf2074 --- /dev/null +++ b/docs/homepage-receipt-metrics-redesign-20260917.md @@ -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-1~T-3短信的今日回执纳入;T-4排除;北京时间午夜边界正确 | +| HOME0917-02 | 3片仅一个失败回执:总3、成功0;2成功1未回仍成功0 | +| HOME0917-03 | 全3片成功才成功3;message_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/`,不含密码、令牌或浏览器认证状态。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index b981992..c403cdb 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -5651,3 +5651,13 @@ TC-SQA-01~14:真实隔离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-实施与验收结果)。所有真实截图使用本地隔离验收记录,不表示目标环境已上线。 diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 98c3960..b79a75a 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -5139,3 +5139,25 @@ CUA本轮可用,实际后端文档三尺寸1600×1000/1366×768/390×844无页 - 文档:优化方案第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/发布工具等不夹带。用户授权提交推送,不含部署;测试环境、预生产环境均未改动。最终提交与推送结果单独补记。 diff --git a/src/api/admin/home.api.ts b/src/api/admin/home.api.ts new file mode 100644 index 0000000..13c065f --- /dev/null +++ b/src/api/admin/home.api.ts @@ -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('/admin/operations/home/summary', { signal }), + receipts: (snapshotToken: string, signal?: AbortSignal) => + request>(withQuery('/admin/operations/home/receipt-breakdown', { snapshotToken }), { + signal, + }), + revenue: (snapshotToken: string, signal?: AbortSignal) => + request>(withQuery('/admin/operations/home/revenue-breakdown', { snapshotToken }), { + signal, + }), +}; diff --git a/src/api/types/home.ts b/src/api/types/home.ts new file mode 100644 index 0000000..d81c54a --- /dev/null +++ b/src/api/types/home.ts @@ -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 = { snapshotToken: string; businessDate: string; items: T[] }; diff --git a/src/apps/admin/AdminHome.css b/src/apps/admin/AdminHome.css index 3ea1605..653194f 100644 --- a/src/apps/admin/AdminHome.css +++ b/src/apps/admin/AdminHome.css @@ -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; - } } diff --git a/src/apps/admin/AdminHome.tsx b/src/apps/admin/AdminHome.tsx index 69fc297..e567e41 100644 --- a/src/apps/admin/AdminHome.tsx +++ b/src/apps/admin/AdminHome.tsx @@ -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(null); - const [quality, setQuality] = useState(null); + const [dashboard, setDashboard] = useState(null); const [error, setError] = useState(''); const [selectedEnterprise, setSelectedEnterprise] = useState(null); + const [loading, setLoading] = useState(false); + const request = useRef(null); + const dateRef = useRef(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(() => { 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> = [ { key: 'rank', title: '排名', width: '72px', render: (_record, index) => index + 1 }, { key: 'enterprise', + width: '260px', title: '企业名称', render: (record) => (
@@ -129,23 +117,34 @@ export function AdminHome() { }, { key: 'todaySpend', + width: '155px', title: '今日消费(元)', align: 'right', render: (record) => ¥{formatCurrency(record.todaySpend)}, }, + { + key: 'todayReturned', + title: '今日返还金额(元)', + width: '165px', + align: 'right', + render: (record) => ¥{formatCurrency(record.todayReturned)}, + }, { key: 'availableBalance', + width: '140px', title: '可用余额', align: 'right', render: (record) => formatCount(record.availableBalance), }, { key: 'balanceStatus', + width: '110px', title: '余额状态', render: (record) => {record.balanceStatus}, }, { 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 (
-

按业务口径查看平台发送、签名、消费和审核情况。

+

今日提交与今日回执,分别看清业务和收益。

+ @@ -171,114 +202,20 @@ export function AdminHome() {
-
- {[ - { - label: '今日发送总量', - value: formatCount(totalSend), - unit: '条', - note: '业务短信', - group: '发送', - icon: , - }, - { - label: '今日消息分片数', - value: formatCount(dashboard?.today.segmentCount ?? 0), - unit: '片', - note: '实际消息分片', - group: '发送', - }, - { - label: '总体成功率', - value: averageSuccessRate.toFixed(1), - unit: '%', - note: '送达成功 / 今日总量', - group: '质量', - icon: , - }, - { - 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: , - }, - { - 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) => ( -
-
- {metric.label} - - {metric.icon} - {metric.group} - -
-
- {dashboard ? metric.value : '—'} - {metric.unit} -
-

{dashboard ? metric.note : error ? '数据暂不可用' : '正在加载…'}

-
- ))} -
- {error ?
{error}
: null} - -
-
-

今日发送趋势

-

按上海时区逐小时展示业务短信提交总条数和最终成功条数。

- +

+ {dashboard + ? `${dashboard.businessDate} · 北京时间 · 统计更新于 ${new Date(dashboard.dataThrough).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}${dashboard.processing ? ' · 统计处理中,部分数据尚未更新' : ''}${dashboard.timeSourceCoverage.approximate || dashboard.timeSourceCoverage.incomplete ? ' · 含历史近似时间或待核对记录' : ''}` + : loading + ? '正在加载运营数据…' + : '数据暂不可用'} +

+ {error && ( +
+ {error} + {dashboard ? ';仍显示上次成功查询的数据,请注意日期和更新时间。' : ''}
-
-

审核处理速度

-

展示今日各项已处理审核数量,以及从提交到审核完成的平均时长。

- -
-
+ )} +
@@ -286,11 +223,16 @@ export function AdminHome() {

今日企业消费排行

来自真实账户、充值和消息金额聚合。

-
- +
@@ -305,33 +247,33 @@ export function AdminHome() {
平均等待 - {dashboard?.taskCount ?? 0} 任务 + {dashboard ? dashboard.taskCount : '—'} 任务 真实批量任务总数。
@@ -339,7 +281,7 @@ export function AdminHome() {
下游投递告警 - {downstreamAlertCount} 条 + {dashboard ? downstreamAlertCount : '—'} 条 积压过久或近期失败。
@@ -386,6 +328,12 @@ export function AdminHome() { ¥{formatCurrency(selectedEnterprise.todaySpend)} +
+ 今日返还金额 + + ¥{formatCurrency(selectedEnterprise.todayReturned)} + +
可用余额 {formatCount(selectedEnterprise.availableBalance)} diff --git a/src/apps/admin/home/HomeMetrics.tsx b/src/apps/admin/home/HomeMetrics.tsx new file mode 100644 index 0000000..6bab2e5 --- /dev/null +++ b/src/apps/admin/home/HomeMetrics.tsx @@ -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(null); + const [revenue, setRevenue] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const request = useRef(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[] = [ + { 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[] = [ + { key: 'date', title: '原提交日期', width: '300px', render: (r) => `${r.submitDate} 提交 → 今日成功回执` }, + { + key: 'revenue', + title: '今日回执归属营收', + width: '180px', + align: 'right', + render: (r) => {money(r.revenueCents)}, + }, + { + key: 'profit', + title: '今日回执归属利润', + width: '180px', + align: 'right', + render: (r) => {money(r.profitCents)}, + }, + { key: 'rate', title: '对应利润率', width: '150px', align: 'right', render: (r) => rate(r.rate) }, + ]; + return ( + <> +
+ {groups.map((group) => ( +
+
+

{group.title}

+ {group.kind && ( + + )} +
+
+ {group.rows.map(([label, number, unit], index) => ( +
+
{label}
+
+ {number} + {unit && {unit}} +
+
+ ))} +
+
+ ))} +
+

+ 回执及营业指标按今日收到的回执统计,包含近四日提交的短信;长短信全部成功才计入成功分片。 +

+ {open && ( +
+
+
+

{open === 'receipt' ? '今日回执' : '今日营业状况'} · 按原提交日期查看

+

+ 仅统计今日回执 + {open === 'revenue' ? '归属的收益,不是对应提交日的全天营业额。' : ',按短信最初提交日期分组。'} +

+
+ +
+ {loading ? ( +

正在查询日期明细…

+ ) : error ? ( +
+ {error} + +
+ ) : open === 'receipt' ? ( +
+ ) : ( +
+ )} + + )} + + ); +} diff --git a/tools/testing/verify-home-dashboard.mjs b/tools/testing/verify-home-dashboard.mjs new file mode 100644 index 0000000..1b85837 --- /dev/null +++ b/tools/testing/verify-home-dashboard.mjs @@ -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(); +}