fix: prevent daily report refresh timeouts
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-08 17:16:43 +08:00
parent 2a9d03be2e
commit ebb185b22b
7 changed files with 1031 additions and 106 deletions
+369 -61
View File
@@ -1,11 +1,19 @@
import { Logger } from '@nestjs/common';
import { ReportsService } from './reports.service';
type CapturedSql = { strings: string[]; values: unknown[]; text: string };
function sqlText(query: CapturedSql) {
return query.strings.join(' ');
}
describe('ReportsService', () => {
const tx = {
dailyReconciliationReport: { deleteMany: jest.fn() },
dailyProfitReport: { deleteMany: jest.fn() },
dailyQualityReport: { deleteMany: jest.fn() },
$executeRaw: jest.fn(),
$queryRaw: jest.fn(),
};
const prisma = {
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() },
@@ -14,25 +22,62 @@ describe('ReportsService', () => {
$transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
};
let service: ReportsService;
const environmentKeys = [
'REPORT_DAILY_REFRESH_ENABLED',
'REPORT_REFRESH_INTERVAL_MS',
'REPORT_REFRESH_TRANSACTION_TIMEOUT_MS',
] as const;
const originalEnvironment = Object.fromEntries(environmentKeys.map((key) => [key, process.env[key]]));
beforeEach(() => {
jest.clearAllMocks();
jest.resetAllMocks();
for (const key of environmentKeys) delete process.env[key];
jest.spyOn(Logger.prototype, 'error').mockImplementation(() => undefined);
jest.spyOn(Logger.prototype, 'log').mockImplementation(() => undefined);
prisma.$transaction.mockImplementation((callback: (client: typeof tx) => unknown) => callback(tx));
tx.dailyReconciliationReport.deleteMany.mockResolvedValue({ count: 0 });
tx.dailyProfitReport.deleteMany.mockResolvedValue({ count: 0 });
tx.dailyQualityReport.deleteMany.mockResolvedValue({ count: 0 });
tx.$executeRaw.mockResolvedValue(0);
tx.$queryRaw.mockResolvedValue([{ locked: true }]);
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
prisma.dailyReconciliationReport.count.mockResolvedValue(1);
prisma.dailyReconciliationReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
prisma.dailyReconciliationReport.aggregate.mockResolvedValue({
_sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 },
});
prisma.dailyProfitReport.findMany.mockResolvedValue([{ id: 'profit-1', refundCents: BigInt(100) }]);
prisma.dailyProfitReport.count.mockResolvedValue(1);
prisma.dailyProfitReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, revenueCents: BigInt(1000), costCents: BigInt(600), profitCents: BigInt(400) } });
prisma.dailyProfitReport.aggregate.mockResolvedValue({
_sum: {
submittedUnits: 11,
sentUnits: 10,
unknownUnits: 2,
successUnits: 7,
failedUnits: 1,
revenueCents: BigInt(1000),
costCents: BigInt(600),
profitCents: BigInt(400),
},
});
prisma.dailyQualityReport.findMany.mockResolvedValue([{ id: 'quality-1' }]);
prisma.dailyQualityReport.count.mockResolvedValue(1);
prisma.dailyQualityReport.aggregate.mockResolvedValue({ _sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
prisma.dailyQualityReport.aggregate.mockResolvedValue({
_sum: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 },
});
service = new ReportsService(prisma as never);
});
afterEach(() => {
service.onModuleDestroy();
jest.useRealTimers();
jest.restoreAllMocks();
for (const key of environmentKeys) {
const value = originalEnvironment[key];
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
});
it('rebuilds exactly T-4 through T-1 in independent transactions', async () => {
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).resolves.toEqual({
refreshedDates: ['2026-07-11', '2026-07-12', '2026-07-13', '2026-07-14'],
@@ -41,14 +86,193 @@ describe('ReportsService', () => {
expect(tx.dailyReconciliationReport.deleteMany).toHaveBeenCalledTimes(4);
expect(tx.dailyProfitReport.deleteMany).toHaveBeenCalledTimes(4);
expect(tx.dailyQualityReport.deleteMany).toHaveBeenCalledTimes(4);
expect(tx.$executeRaw).toHaveBeenCalledTimes(28);
expect(tx.$executeRaw).toHaveBeenCalledTimes(32);
expect(tx.$queryRaw).toHaveBeenCalledTimes(4);
});
it.each([
['2026-12-31T15:59:59.999Z', ['2026-12-27', '2026-12-28', '2026-12-29', '2026-12-30']],
['2026-12-31T16:00:00.000Z', ['2026-12-28', '2026-12-29', '2026-12-30', '2026-12-31']],
['2026-02-28T16:00:00.000Z', ['2026-02-25', '2026-02-26', '2026-02-27', '2026-02-28']],
])('uses completed Shanghai dates at boundary %s', async (now, expectedDates) => {
await expect(service.refreshRollingWindow(new Date(now))).resolves.toEqual({ refreshedDates: expectedDates });
expect(
tx.dailyReconciliationReport.deleteMany.mock.calls.map(([query]) =>
query.where.reportDate.toISOString().slice(0, 10),
),
).toEqual(expectedDates);
});
it('bounds application costs by the original message day while retaining cross-day submits', async () => {
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
const applicationProfit = tx.$executeRaw.mock.calls
.map(([query]) => query as CapturedSql)
.find((query) => sqlText(query).includes('WITH costs AS'))!;
const costs = applicationProfit.text.split('INSERT INTO "DailyProfitReport"')[0];
expect(costs).toMatch(/message\."queuedAt" >= \$1/);
expect(costs).toMatch(/message\."queuedAt" < \$2/);
expect(applicationProfit.values.slice(0, 2)).toEqual([
new Date('2026-07-10T16:00:00.000Z'),
new Date('2026-07-11T16:00:00.000Z'),
]);
expect(costs).toContain('submit."submitStatus" = \'accepted\'');
expect(costs).not.toContain('submit."submittedAt"');
expect(costs).not.toContain('submit."createdAt"');
expect(costs).toContain('WHEN segment_receipts.audit_count > 0 THEN segment_receipts.delivered_count');
expect(costs).toContain('WHEN legacy_receipt.delivered THEN message."billingUnits"');
const channelProfit = tx.$executeRaw.mock.calls
.map(([query]) => sqlText(query))
.find((query) => query.includes("CONCAT('profit-channel-'"))!;
expect(channelProfit).toContain('COALESCE(submit."submittedAt", submit."createdAt") >=');
expect(channelProfit).toContain('COALESCE(submit."submittedAt", submit."createdAt") <');
});
it.each([
[undefined, 30_000],
['45000', 45_000],
['200000', 120_000],
['0', 30_000],
['-1', 30_000],
['1.5', 30_000],
['invalid', 30_000],
['Infinity', 30_000],
])('uses a bounded report transaction timeout for %s', async (configured, expected) => {
if (configured !== undefined) process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS = configured;
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
expect(prisma.$transaction).toHaveBeenCalledWith(expect.any(Function), { maxWait: 5_000, timeout: expected });
const timeoutQueries = tx.$executeRaw.mock.calls
.map(([query]) => query as CapturedSql)
.filter((query) => sqlText(query).includes('statement_timeout'));
expect(timeoutQueries).toHaveLength(4);
for (const query of timeoutQueries) {
expect(sqlText(query)).toContain("set_config('statement_timeout',");
expect(sqlText(query)).toContain(', true)');
expect(query.values).toEqual([`${expected}ms`]);
}
});
it('continues later dates after one transaction fails and reports partial completion', async () => {
const failure = new Error('database transaction expired');
tx.$executeRaw.mockResolvedValueOnce(0).mockRejectedValueOnce(failure);
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).rejects.toThrow(
'failed dates: 2026-07-11; refreshed dates: 2026-07-12, 2026-07-13, 2026-07-14',
);
expect(prisma.$transaction).toHaveBeenCalledTimes(4);
expect(
tx.dailyQualityReport.deleteMany.mock.calls.map(([query]) => query.where.reportDate.toISOString().slice(0, 10)),
).toEqual(['2026-07-11', '2026-07-12', '2026-07-13', '2026-07-14']);
expect(Logger.prototype.error).toHaveBeenCalledWith('Daily report refresh failed for 2026-07-11', failure.stack);
});
it('does not delete reports for a date whose lock is owned by another transaction', async () => {
tx.$queryRaw.mockResolvedValueOnce([{ locked: false }]);
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).rejects.toThrow(
'failed dates: 2026-07-11',
);
for (const model of [tx.dailyReconciliationReport, tx.dailyProfitReport, tx.dailyQualityReport]) {
expect(model.deleteMany.mock.calls.map(([query]) => query.where.reportDate.toISOString().slice(0, 10))).toEqual([
'2026-07-12',
'2026-07-13',
'2026-07-14',
]);
}
expect(tx.$queryRaw.mock.calls.map(([query]) => query.values[1])).toEqual([20260711, 20260712, 20260713, 20260714]);
expect(sqlText(tx.$queryRaw.mock.calls[0][0])).toContain('pg_try_advisory_xact_lock');
expect(tx.$queryRaw.mock.invocationCallOrder[1]).toBeLessThan(
tx.dailyReconciliationReport.deleteMany.mock.invocationCallOrder[0],
);
});
it('fails safely when the transaction lock query returns no result', async () => {
tx.$queryRaw.mockResolvedValue([]);
await expect(service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'))).rejects.toThrow(
'refreshed dates: none',
);
expect(tx.dailyReconciliationReport.deleteMany).not.toHaveBeenCalled();
expect(tx.dailyProfitReport.deleteMany).not.toHaveBeenCalled();
expect(tx.dailyQualityReport.deleteMany).not.toHaveBeenCalled();
});
it('retries partial failures and skips the business day only after full success', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-07-15T05:30:00.000Z'));
tx.$executeRaw.mockRejectedValueOnce(new Error('database unavailable'));
await service['runScheduledRefresh']();
expect(prisma.$transaction).toHaveBeenCalledTimes(4);
expect(Logger.prototype.log).not.toHaveBeenCalled();
await service['runScheduledRefresh']();
expect(prisma.$transaction).toHaveBeenCalledTimes(8);
expect(Logger.prototype.log).toHaveBeenCalledTimes(1);
await service['runScheduledRefresh']();
expect(prisma.$transaction).toHaveBeenCalledTimes(8);
jest.setSystemTime(new Date('2026-07-15T16:00:00.000Z'));
await service['runScheduledRefresh']();
expect(prisma.$transaction).toHaveBeenCalledTimes(12);
});
it('prevents overlapping scheduled refreshes in the same service instance', async () => {
let finishRefresh!: (result: { refreshedDates: string[] }) => void;
const refresh = jest.spyOn(service, 'refreshRollingWindow').mockImplementation(
() =>
new Promise((resolve) => {
finishRefresh = resolve;
}),
);
const running = service['runScheduledRefresh']();
await service['runScheduledRefresh']();
expect(refresh).toHaveBeenCalledTimes(1);
finishRefresh({ refreshedDates: ['2026-07-14'] });
await running;
});
it('cancels startup and interval timers when destroyed before startup refresh', async () => {
jest.useFakeTimers();
const refresh = jest.spyOn(service, 'refreshRollingWindow');
service.onModuleInit();
expect(jest.getTimerCount()).toBe(2);
service.onModuleDestroy();
expect(jest.getTimerCount()).toBe(0);
await jest.advanceTimersByTimeAsync(2 * 60 * 60 * 1000);
expect(refresh).not.toHaveBeenCalled();
});
it('starts after 15 seconds and retries a failed scheduled run on the configured interval', async () => {
jest.useFakeTimers().setSystemTime(new Date('2026-07-15T05:30:00.000Z'));
process.env.REPORT_REFRESH_INTERVAL_MS = '60000';
const refresh = jest
.spyOn(service, 'refreshRollingWindow')
.mockRejectedValueOnce(new Error('temporarily unavailable'));
service.onModuleInit();
await jest.advanceTimersByTimeAsync(14_999);
expect(refresh).not.toHaveBeenCalled();
await jest.advanceTimersByTimeAsync(1);
expect(refresh).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(45_000);
expect(refresh).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(60_000);
expect(refresh).toHaveBeenCalledTimes(2);
});
it('does not schedule reports when daily refresh is disabled', () => {
jest.useFakeTimers();
process.env.REPORT_DAILY_REFRESH_ENABLED = 'false';
service.onModuleInit();
expect(jest.getTimerCount()).toBe(0);
});
it('calculates profit cost from channel unit price times delivered fragment count', async () => {
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) =>
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query),
);
const firstDayQueries = tx.$executeRaw.mock.calls
.slice(1, 8)
.map(([query]) => (Array.isArray(query?.strings) ? query.strings.join(' ') : String(query)));
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
expect(profitQueries).toContain('"SmsMessageSegmentAudit"');
@@ -59,9 +283,9 @@ describe('ReportsService', () => {
it('calculates income from successful billing units and the message unit price snapshot without refund status', async () => {
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) =>
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query),
);
const firstDayQueries = tx.$executeRaw.mock.calls
.slice(1, 8)
.map(([query]) => (Array.isArray(query?.strings) ? query.strings.join(' ') : String(query)));
const profitQueries = firstDayQueries.slice(1, 3).join('\n');
expect(profitQueries).toContain('message."billingUnits" * message."unitPrice"');
@@ -71,58 +295,111 @@ describe('ReportsService', () => {
});
it('queries reconciliation reports with server-side filters and bounded pagination', async () => {
await expect(service.listReconciliation({
dateFrom: '2026-07-01',
dateTo: '2026-07-14',
tenantId: 'tenant-1',
applicationId: 'app-1',
await expect(
service.listReconciliation({
dateFrom: '2026-07-01',
dateTo: '2026-07-14',
tenantId: 'tenant-1',
applicationId: 'app-1',
page: 2,
pageSize: 500,
}),
).resolves.toEqual({
items: [{ id: 'recon-1' }],
total: 1,
page: 2,
pageSize: 500,
})).resolves.toEqual({ items: [{ id: 'recon-1' }], total: 1, page: 2, pageSize: 100, summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 } });
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
skip: 100,
take: 100,
}));
pageSize: 100,
summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1 },
});
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
skip: 100,
take: 100,
}),
);
});
it('keeps application and channel profit filters separate', async () => {
const result = await service.listProfit({ dimensionType: 'channel', tenantId: 'tenant-1', applicationId: 'app-1', channelId: 'channel-1' });
expect(result).toEqual(expect.objectContaining({
summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }),
}));
const result = await service.listProfit({
dimensionType: 'channel',
tenantId: 'tenant-1',
applicationId: 'app-1',
channelId: 'channel-1',
});
expect(result).toEqual(
expect.objectContaining({
summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }),
}),
);
expect(result.summary).not.toHaveProperty('refundCents');
expect(result.items[0]).not.toHaveProperty('refundCents');
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
dimensionType: 'channel',
tenantId: undefined,
applicationId: undefined,
channelId: 'channel-1',
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({
dimensionType: 'channel',
tenantId: undefined,
applicationId: undefined,
channelId: 'channel-1',
}),
}),
}));
);
});
it('returns zero full-result totals and rates when a filtered report has no rows', async () => {
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]);
prisma.dailyProfitReport.count.mockResolvedValueOnce(0);
prisma.dailyProfitReport.aggregate.mockResolvedValueOnce({
_sum: { submittedUnits: null, sentUnits: null, unknownUnits: null, successUnits: null, failedUnits: null, revenueCents: null, costCents: null, profitCents: null },
_sum: {
submittedUnits: null,
sentUnits: null,
unknownUnits: null,
successUnits: null,
failedUnits: null,
revenueCents: null,
costCents: null,
profitCents: null,
},
});
await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(expect.objectContaining({
total: 0,
summary: { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, costCents: 0, profitCents: 0, profitRateBps: 0 },
}));
await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(
expect.objectContaining({
total: 0,
summary: {
submittedUnits: 0,
sentUnits: 0,
unknownUnits: 0,
successUnits: 0,
failedUnits: 0,
revenueCents: 0,
costCents: 0,
profitCents: 0,
profitRateBps: 0,
},
}),
);
});
it('exports income without refund columns', async () => {
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([{
id: 'profit-export', reportDate: new Date('2026-07-14'), dimensionName: '应用A', tenantName: '示例企业',
submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1,
revenueCents: BigInt(3500), refundCents: BigInt(200), costCents: BigInt(2100), profitCents: BigInt(1400),
profitRateBps: 4000, generatedAt: new Date('2026-07-15T00:00:00Z'),
}]);
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([
{
id: 'profit-export',
reportDate: new Date('2026-07-14'),
dimensionName: '应用A',
tenantName: '示例企业',
submittedUnits: 14,
sentUnits: 12,
unknownUnits: 1,
successUnits: 10,
failedUnits: 1,
revenueCents: BigInt(3500),
refundCents: BigInt(200),
costCents: BigInt(2100),
profitCents: BigInt(1400),
profitRateBps: 4000,
generatedAt: new Date('2026-07-15T00:00:00Z'),
},
]);
const exported = await service.exportProfit({ dimensionType: 'application' });
expect(exported.content).toContain('收入金额(元)');
@@ -131,28 +408,59 @@ describe('ReportsService', () => {
});
it('sorts quality reports by send volume and keeps the selected dimension', async () => {
await expect(service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 })).resolves.toEqual({
items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage',
summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, successRateBps: 7000 },
await expect(
service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 }),
).resolves.toEqual({
items: [{ id: 'quality-1' }],
total: 1,
page: 1,
pageSize: 20,
dimensionType: 'drainage',
summary: {
submittedUnits: 11,
sentUnits: 10,
unknownUnits: 2,
successUnits: 7,
failedUnits: 1,
successRateBps: 7000,
},
});
expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
}));
expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
}),
);
});
it('exports complete filtered report data as escaped CSV instead of the current page', async () => {
prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([{
id: 'recon-export', reportDate: new Date('2026-07-14'), tenantName: '示例,企业', applicationName: '应用A',
submittedUnits: 14, sentUnits: 12, unknownUnits: 1, successUnits: 10, failedUnits: 1, generatedAt: new Date('2026-07-15T00:00:00Z'),
}]);
const exported = await service.exportReconciliation({ tenantId: 'tenant-1', dateFrom: '2026-07-01', dateTo: '2026-07-14' });
prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([
{
id: 'recon-export',
reportDate: new Date('2026-07-14'),
tenantName: '示例,企业',
applicationName: '应用A',
submittedUnits: 14,
sentUnits: 12,
unknownUnits: 1,
successUnits: 10,
failedUnits: 1,
generatedAt: new Date('2026-07-15T00:00:00Z'),
},
]);
const exported = await service.exportReconciliation({
tenantId: 'tenant-1',
dateFrom: '2026-07-01',
dateTo: '2026-07-14',
});
expect(exported.fileName).toContain('对账单-');
expect(exported.content).toContain('"示例,企业"');
expect(exported.content).toContain('提交条数');
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1' }),
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
}));
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1' }),
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
}),
);
});
});
+199 -45
View File
@@ -6,6 +6,8 @@ import { PrismaService } from '../prisma/prisma.service';
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
const DEFAULT_REFRESH_INTERVAL_MS = 60 * 60 * 1000;
const DEFAULT_REFRESH_TRANSACTION_TIMEOUT_MS = 30_000;
const REPORT_LOCK_NAMESPACE = 0x434d5052;
export type ReportListQuery = {
dateFrom?: string;
@@ -21,6 +23,7 @@ export type ReportListQuery = {
@Injectable()
export class ReportsService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(ReportsService.name);
private startupTimer?: ReturnType<typeof setTimeout>;
private refreshTimer?: ReturnType<typeof setInterval>;
private refreshRunning = false;
private lastRefreshBusinessDate?: string;
@@ -29,8 +32,8 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
onModuleInit() {
if (process.env.REPORT_DAILY_REFRESH_ENABLED === 'false') return;
const startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000);
startupTimer.unref?.();
this.startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000);
this.startupTimer.unref?.();
this.refreshTimer = setInterval(
() => void this.runScheduledRefresh(),
positiveInteger(process.env.REPORT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_INTERVAL_MS),
@@ -39,6 +42,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
}
onModuleDestroy() {
if (this.startupTimer) clearTimeout(this.startupTimer);
if (this.refreshTimer) clearInterval(this.refreshTimer);
}
@@ -46,7 +50,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
const { page, pageSize, skip } = pagination(query);
const where = reconciliationWhere(query);
const [items, total, aggregate] = await Promise.all([
this.prisma.dailyReconciliationReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyReconciliationReport.findMany({
where,
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
skip,
take: pageSize,
}),
this.prisma.dailyReconciliationReport.count({ where }),
this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }),
]);
@@ -57,7 +66,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
const { page, pageSize, skip } = pagination(query);
const { dimensionType, where } = profitWhere(query);
const [storedItems, total, aggregate] = await Promise.all([
this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyProfitReport.findMany({
where,
orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }],
skip,
take: pageSize,
}),
this.prisma.dailyProfitReport.count({ where }),
this.prisma.dailyProfitReport.aggregate({
where,
@@ -65,7 +79,10 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
}),
]);
// refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。
const items = storedItems.map(({ refundCents: _refundCents, ...item }) => item);
const items = storedItems.map(({ refundCents, ...item }) => {
void refundCents;
return item;
});
const summary = {
...volumeSummary(aggregate._sum),
revenueCents: Number(aggregate._sum.revenueCents ?? 0),
@@ -81,7 +98,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
const { page, pageSize, skip } = pagination(query);
const { dimensionType, where } = qualityWhere(query);
const [items, total, aggregate] = await Promise.all([
this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], skip, take: pageSize }),
this.prisma.dailyQualityReport.findMany({
where,
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
skip,
take: pageSize,
}),
this.prisma.dailyQualityReport.count({ where }),
this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }),
]);
@@ -94,34 +116,136 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
}
async exportReconciliation(query: ReportListQuery) {
const items = await this.prisma.dailyReconciliationReport.findMany({ where: reconciliationWhere(query), orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }] });
return csvExport('对账单', ['发送日期', '企业', '企业应用', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.tenantName, item.applicationName, item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, formatCsvDate(item.generatedAt)]));
const items = await this.prisma.dailyReconciliationReport.findMany({
where: reconciliationWhere(query),
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
});
return csvExport(
'对账单',
['发送日期', '企业', '企业应用', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '生成时间'],
items.map((item) => [
dateKey(item.reportDate),
item.tenantName,
item.applicationName,
item.submittedUnits,
item.sentUnits,
item.unknownUnits,
item.successUnits,
item.failedUnits,
formatCsvDate(item.generatedAt),
]),
);
}
async exportProfit(query: ReportListQuery) {
const { dimensionType, where } = profitWhere(query);
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] });
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '收入金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
const items = await this.prisma.dailyProfitReport.findMany({
where,
orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }],
});
return csvExport(
`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`,
[
'发送日期',
'统计维度',
'企业',
'提交条数',
'发送条数',
'未知条数',
'成功条数',
'失败条数',
'收入金额(元)',
'成本金额(元)',
'利润(元)',
'利润率(%)',
'生成时间',
],
items.map((item) => [
dateKey(item.reportDate),
item.dimensionName,
item.tenantName ?? '',
item.submittedUnits,
item.sentUnits,
item.unknownUnits,
item.successUnits,
item.failedUnits,
moneyUnitsToFixedYuan(item.revenueCents),
moneyUnitsToFixedYuan(item.costCents),
moneyUnitsToFixedYuan(item.profitCents),
(item.profitRateBps / 100).toFixed(2),
formatCsvDate(item.generatedAt),
]),
);
}
async exportQuality(query: ReportListQuery) {
const { dimensionType, where } = qualityWhere(query);
const items = await this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }] });
return csvExport(`发送质量报表-${dimensionType}`, ['发送日期', '统计对象', '企业', '提交条数', '发送条数', '未知条数', '成功条数', '失败条数', '成功率(%)', '平均到达时长(毫秒)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.submittedUnits, item.sentUnits, item.unknownUnits, item.successUnits, item.failedUnits, (item.successRateBps / 100).toFixed(2), item.avgArrivalMs ?? '', formatCsvDate(item.generatedAt)]));
const items = await this.prisma.dailyQualityReport.findMany({
where,
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
});
return csvExport(
`发送质量报表-${dimensionType}`,
[
'发送日期',
'统计对象',
'企业',
'提交条数',
'发送条数',
'未知条数',
'成功条数',
'失败条数',
'成功率(%)',
'平均到达时长(毫秒)',
'生成时间',
],
items.map((item) => [
dateKey(item.reportDate),
item.dimensionName,
item.tenantName ?? '',
item.submittedUnits,
item.sentUnits,
item.unknownUnits,
item.successUnits,
item.failedUnits,
(item.successRateBps / 100).toFixed(2),
item.avgArrivalMs ?? '',
formatCsvDate(item.generatedAt),
]),
);
}
async refreshRollingWindow(now = new Date()) {
const days = completedBusinessDays(now, 4);
for (const day of days) await this.refreshBusinessDay(day);
return { refreshedDates: days.map((day) => day.key) };
const refreshedDates: string[] = [];
const failedDates: string[] = [];
for (const day of days) {
try {
await this.refreshBusinessDay(day);
refreshedDates.push(day.key);
} catch (error) {
failedDates.push(day.key);
this.logger.error(
`Daily report refresh failed for ${day.key}`,
error instanceof Error ? error.stack : String(error),
);
}
}
if (failedDates.length) {
throw new Error(
`Daily report refresh incomplete; failed dates: ${failedDates.join(', ')}; refreshed dates: ${refreshedDates.join(', ') || 'none'}`,
);
}
return { refreshedDates };
}
private async runScheduledRefresh() {
const businessDate = shanghaiDateKey(new Date());
const now = new Date();
const businessDate = shanghaiDateKey(now);
if (this.refreshRunning || this.lastRefreshBusinessDate === businessDate) return;
this.refreshRunning = true;
try {
const result = await this.refreshRollingWindow();
const result = await this.refreshRollingWindow(now);
this.lastRefreshBusinessDate = businessDate;
this.logger.log(`Daily reports refreshed for ${result.refreshedDates.join(', ')}`);
} catch (error) {
@@ -132,12 +256,22 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
}
private async refreshBusinessDay(day: BusinessDay) {
await this.prisma.$transaction(async (tx) => {
await tx.dailyReconciliationReport.deleteMany({ where: { reportDate: day.reportDate } });
await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } });
await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } });
const timeout = Math.min(
120_000,
positiveInteger(process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS, DEFAULT_REFRESH_TRANSACTION_TIMEOUT_MS),
);
await this.prisma.$transaction(
async (tx) => {
await tx.$executeRaw(Prisma.sql`SELECT set_config('statement_timeout', ${`${timeout}ms`}, true)`);
const [lock] = await tx.$queryRaw<{ locked: boolean }[]>(Prisma.sql`
SELECT pg_try_advisory_xact_lock(${REPORT_LOCK_NAMESPACE}::integer, ${Number(day.key.replaceAll('-', ''))}::integer) AS locked
`);
if (!lock?.locked) throw new Error(`Daily reports for ${day.key} are being refreshed by another transaction`);
await tx.dailyReconciliationReport.deleteMany({ where: { reportDate: day.reportDate } });
await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } });
await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } });
await tx.$executeRaw(Prisma.sql`
await tx.$executeRaw(Prisma.sql`
INSERT INTO "DailyReconciliationReport" (
"id", "reportDate", "tenantId", "tenantName", "applicationId", "applicationName",
"submittedUnits", "sentUnits", "unknownUnits", "successUnits", "failedUnits", "generatedAt", "updatedAt"
@@ -169,7 +303,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
GROUP BY tenant.id, tenant.name, application.id, application.name
`);
await tx.$executeRaw(Prisma.sql`
await tx.$executeRaw(Prisma.sql`
WITH costs AS (
SELECT
submit."messageRecordId",
@@ -197,6 +331,9 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
) AS delivered
) legacy_receipt ON TRUE
WHERE submit."submitStatus" = 'accepted'
-- 应用成本归属于原短信日,仍包含该短信全部跨日补发尝试。
AND message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
GROUP BY submit."messageRecordId"
)
INSERT INTO "DailyProfitReport" (
@@ -249,7 +386,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
GROUP BY tenant.id, tenant.name, application.id, application.name
`);
await tx.$executeRaw(Prisma.sql`
await tx.$executeRaw(Prisma.sql`
INSERT INTO "DailyProfitReport" (
"id", "reportDate", "dimensionType", "dimensionId", "dimensionName",
"tenantId", "tenantName", "applicationId", "channelId",
@@ -348,29 +485,34 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
GROUP BY channel.id, channel.name
`);
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'application'));
await tx.$executeRaw(qualityByChannelSql(day));
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature'));
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage'));
});
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'application'));
await tx.$executeRaw(qualityByChannelSql(day));
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature'));
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage'));
},
{ maxWait: 5_000, timeout },
);
}
}
function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'application' | 'signature' | 'drainage') {
const dimensionTypeSql = Prisma.raw(`'${dimensionType}'`);
const dimensionId = dimensionType === 'application'
? Prisma.sql`application.id`
: dimensionType === 'signature'
? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))`
: Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`;
const dimensionName = dimensionType === 'application'
? Prisma.sql`application.name`
: dimensionType === 'signature'
? Prisma.sql`COALESCE(signature.name, '未关联签名')`
: Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`;
const applicationJoin = dimensionType === 'application'
? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"`
: Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`;
const dimensionId =
dimensionType === 'application'
? Prisma.sql`application.id`
: dimensionType === 'signature'
? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))`
: Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`;
const dimensionName =
dimensionType === 'application'
? Prisma.sql`application.name`
: dimensionType === 'signature'
? Prisma.sql`COALESCE(signature.name, '未关联签名')`
: Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`;
const applicationJoin =
dimensionType === 'application'
? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"`
: Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`;
return Prisma.sql`
WITH base AS (
@@ -548,7 +690,13 @@ const reportVolumeSumSelection = {
failedUnits: true,
} as const;
function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number | null; unknownUnits?: number | null; successUnits?: number | null; failedUnits?: number | null }) {
function volumeSummary(sum: {
submittedUnits?: number | null;
sentUnits?: number | null;
unknownUnits?: number | null;
successUnits?: number | null;
failedUnits?: number | null;
}) {
return {
submittedUnits: Number(sum.submittedUnits ?? 0),
sentUnits: Number(sum.sentUnits ?? 0),
@@ -559,11 +707,15 @@ function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number
}
function ratioBps(numerator: number, denominator: number) {
return denominator === 0 ? 0 : Math.round(numerator * 10_000 / denominator);
return denominator === 0 ? 0 : Math.round((numerator * 10_000) / denominator);
}
function reconciliationWhere(query: ReportListQuery): Prisma.DailyReconciliationReportWhereInput {
return { reportDate: dateFilter(query.dateFrom, query.dateTo), tenantId: query.tenantId || undefined, applicationId: query.applicationId || undefined };
return {
reportDate: dateFilter(query.dateFrom, query.dateTo),
tenantId: query.tenantId || undefined,
applicationId: query.applicationId || undefined,
};
}
function profitWhere(query: ReportListQuery) {
@@ -580,7 +732,9 @@ function profitWhere(query: ReportListQuery) {
function qualityWhere(query: ReportListQuery) {
const allowedDimensions = new Set(['application', 'channel', 'signature', 'drainage']);
const dimensionType = allowedDimensions.has(String(query.dimensionType)) ? String(query.dimensionType) : 'application';
const dimensionType = allowedDimensions.has(String(query.dimensionType))
? String(query.dimensionType)
: 'application';
const where: Prisma.DailyQualityReportWhereInput = {
dimensionType,
reportDate: dateFilter(query.dateFrom, query.dateTo),