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
+350 -42
View File
@@ -1,11 +1,19 @@
import { Logger } from '@nestjs/common';
import { ReportsService } from './reports.service'; import { ReportsService } from './reports.service';
type CapturedSql = { strings: string[]; values: unknown[]; text: string };
function sqlText(query: CapturedSql) {
return query.strings.join(' ');
}
describe('ReportsService', () => { describe('ReportsService', () => {
const tx = { const tx = {
dailyReconciliationReport: { deleteMany: jest.fn() }, dailyReconciliationReport: { deleteMany: jest.fn() },
dailyProfitReport: { deleteMany: jest.fn() }, dailyProfitReport: { deleteMany: jest.fn() },
dailyQualityReport: { deleteMany: jest.fn() }, dailyQualityReport: { deleteMany: jest.fn() },
$executeRaw: jest.fn(), $executeRaw: jest.fn(),
$queryRaw: jest.fn(),
}; };
const prisma = { const prisma = {
dailyReconciliationReport: { findMany: jest.fn(), count: jest.fn(), aggregate: jest.fn() }, 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)), $transaction: jest.fn((callback: (client: typeof tx) => unknown) => callback(tx)),
}; };
let service: ReportsService; 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(() => { 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.dailyReconciliationReport.deleteMany.mockResolvedValue({ count: 0 });
tx.dailyProfitReport.deleteMany.mockResolvedValue({ count: 0 }); tx.dailyProfitReport.deleteMany.mockResolvedValue({ count: 0 });
tx.dailyQualityReport.deleteMany.mockResolvedValue({ count: 0 }); tx.dailyQualityReport.deleteMany.mockResolvedValue({ count: 0 });
tx.$executeRaw.mockResolvedValue(0); tx.$executeRaw.mockResolvedValue(0);
tx.$queryRaw.mockResolvedValue([{ locked: true }]);
prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]); prisma.dailyReconciliationReport.findMany.mockResolvedValue([{ id: 'recon-1' }]);
prisma.dailyReconciliationReport.count.mockResolvedValue(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.findMany.mockResolvedValue([{ id: 'profit-1', refundCents: BigInt(100) }]);
prisma.dailyProfitReport.count.mockResolvedValue(1); 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.findMany.mockResolvedValue([{ id: 'quality-1' }]);
prisma.dailyQualityReport.count.mockResolvedValue(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); 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 () => { 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({ 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'], 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.dailyReconciliationReport.deleteMany).toHaveBeenCalledTimes(4);
expect(tx.dailyProfitReport.deleteMany).toHaveBeenCalledTimes(4); expect(tx.dailyProfitReport.deleteMany).toHaveBeenCalledTimes(4);
expect(tx.dailyQualityReport.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 () => { it('calculates profit cost from channel unit price times delivered fragment count', async () => {
await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z')); await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) => const firstDayQueries = tx.$executeRaw.mock.calls
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query), .slice(1, 8)
); .map(([query]) => (Array.isArray(query?.strings) ? query.strings.join(' ') : String(query)));
const profitQueries = firstDayQueries.slice(1, 3).join('\n'); const profitQueries = firstDayQueries.slice(1, 3).join('\n');
expect(profitQueries).toContain('"SmsMessageSegmentAudit"'); 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 () => { 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')); await service.refreshRollingWindow(new Date('2026-07-15T05:30:00.000Z'));
const firstDayQueries = tx.$executeRaw.mock.calls.slice(0, 7).map(([query]) => const firstDayQueries = tx.$executeRaw.mock.calls
Array.isArray(query?.strings) ? query.strings.join(' ') : String(query), .slice(1, 8)
); .map(([query]) => (Array.isArray(query?.strings) ? query.strings.join(' ') : String(query)));
const profitQueries = firstDayQueries.slice(1, 3).join('\n'); const profitQueries = firstDayQueries.slice(1, 3).join('\n');
expect(profitQueries).toContain('message."billingUnits" * message."unitPrice"'); 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 () => { it('queries reconciliation reports with server-side filters and bounded pagination', async () => {
await expect(service.listReconciliation({ await expect(
service.listReconciliation({
dateFrom: '2026-07-01', dateFrom: '2026-07-01',
dateTo: '2026-07-14', dateTo: '2026-07-14',
tenantId: 'tenant-1', tenantId: 'tenant-1',
applicationId: 'app-1', applicationId: 'app-1',
page: 2, page: 2,
pageSize: 500, 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({ ).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' }), where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1' }),
skip: 100, skip: 100,
take: 100, take: 100,
})); }),
);
}); });
it('keeps application and channel profit filters separate', async () => { 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' }); const result = await service.listProfit({
expect(result).toEqual(expect.objectContaining({ 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 }), summary: expect.objectContaining({ revenueCents: 1000, profitCents: 400, profitRateBps: 4000 }),
})); }),
);
expect(result.summary).not.toHaveProperty('refundCents'); expect(result.summary).not.toHaveProperty('refundCents');
expect(result.items[0]).not.toHaveProperty('refundCents'); expect(result.items[0]).not.toHaveProperty('refundCents');
expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.dailyProfitReport.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ where: expect.objectContaining({
dimensionType: 'channel', dimensionType: 'channel',
tenantId: undefined, tenantId: undefined,
applicationId: undefined, applicationId: undefined,
channelId: 'channel-1', channelId: 'channel-1',
}), }),
})); }),
);
}); });
it('returns zero full-result totals and rates when a filtered report has no rows', async () => { it('returns zero full-result totals and rates when a filtered report has no rows', async () => {
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]); prisma.dailyProfitReport.findMany.mockResolvedValueOnce([]);
prisma.dailyProfitReport.count.mockResolvedValueOnce(0); prisma.dailyProfitReport.count.mockResolvedValueOnce(0);
prisma.dailyProfitReport.aggregate.mockResolvedValueOnce({ 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({ await expect(service.listProfit({ dimensionType: 'application', tenantId: 'missing' })).resolves.toEqual(
expect.objectContaining({
total: 0, total: 0,
summary: { submittedUnits: 0, sentUnits: 0, unknownUnits: 0, successUnits: 0, failedUnits: 0, revenueCents: 0, costCents: 0, profitCents: 0, profitRateBps: 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 () => { it('exports income without refund columns', async () => {
prisma.dailyProfitReport.findMany.mockResolvedValueOnce([{ 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, id: 'profit-export',
revenueCents: BigInt(3500), refundCents: BigInt(200), costCents: BigInt(2100), profitCents: BigInt(1400), reportDate: new Date('2026-07-14'),
profitRateBps: 4000, generatedAt: new Date('2026-07-15T00:00:00Z'), 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' }); const exported = await service.exportProfit({ dimensionType: 'application' });
expect(exported.content).toContain('收入金额(元)'); expect(exported.content).toContain('收入金额(元)');
@@ -131,28 +408,59 @@ describe('ReportsService', () => {
}); });
it('sorts quality reports by send volume and keeps the selected dimension', async () => { 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({ await expect(
items: [{ id: 'quality-1' }], total: 1, page: 1, pageSize: 20, dimensionType: 'drainage', service.listQuality({ dimensionType: 'drainage', tenantId: 'tenant-1', page: 1, pageSize: 20 }),
summary: { submittedUnits: 11, sentUnits: 10, unknownUnits: 2, successUnits: 7, failedUnits: 1, successRateBps: 7000 }, ).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({ expect(prisma.dailyQualityReport.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }), where: expect.objectContaining({ dimensionType: 'drainage', tenantId: 'tenant-1' }),
orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }], orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }],
})); }),
);
}); });
it('exports complete filtered report data as escaped CSV instead of the current page', async () => { it('exports complete filtered report data as escaped CSV instead of the current page', async () => {
prisma.dailyReconciliationReport.findMany.mockResolvedValueOnce([{ 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'), id: 'recon-export',
}]); reportDate: new Date('2026-07-14'),
const exported = await service.exportReconciliation({ tenantId: 'tenant-1', dateFrom: '2026-07-01', dateTo: '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.fileName).toContain('对账单-');
expect(exported.content).toContain('"示例,企业"'); expect(exported.content).toContain('"示例,企业"');
expect(exported.content).toContain('提交条数'); expect(exported.content).toContain('提交条数');
expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(expect.objectContaining({ expect(prisma.dailyReconciliationReport.findMany).toHaveBeenCalledWith(
expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1' }), where: expect.objectContaining({ tenantId: 'tenant-1' }),
orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }], orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }],
})); }),
);
}); });
}); });
+179 -25
View File
@@ -6,6 +6,8 @@ import { PrismaService } from '../prisma/prisma.service';
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000; const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
const DEFAULT_REFRESH_INTERVAL_MS = 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 = { export type ReportListQuery = {
dateFrom?: string; dateFrom?: string;
@@ -21,6 +23,7 @@ export type ReportListQuery = {
@Injectable() @Injectable()
export class ReportsService implements OnModuleInit, OnModuleDestroy { export class ReportsService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(ReportsService.name); private readonly logger = new Logger(ReportsService.name);
private startupTimer?: ReturnType<typeof setTimeout>;
private refreshTimer?: ReturnType<typeof setInterval>; private refreshTimer?: ReturnType<typeof setInterval>;
private refreshRunning = false; private refreshRunning = false;
private lastRefreshBusinessDate?: string; private lastRefreshBusinessDate?: string;
@@ -29,8 +32,8 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
onModuleInit() { onModuleInit() {
if (process.env.REPORT_DAILY_REFRESH_ENABLED === 'false') return; if (process.env.REPORT_DAILY_REFRESH_ENABLED === 'false') return;
const startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000); this.startupTimer = setTimeout(() => void this.runScheduledRefresh(), 15_000);
startupTimer.unref?.(); this.startupTimer.unref?.();
this.refreshTimer = setInterval( this.refreshTimer = setInterval(
() => void this.runScheduledRefresh(), () => void this.runScheduledRefresh(),
positiveInteger(process.env.REPORT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_INTERVAL_MS), positiveInteger(process.env.REPORT_REFRESH_INTERVAL_MS, DEFAULT_REFRESH_INTERVAL_MS),
@@ -39,6 +42,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
} }
onModuleDestroy() { onModuleDestroy() {
if (this.startupTimer) clearTimeout(this.startupTimer);
if (this.refreshTimer) clearInterval(this.refreshTimer); if (this.refreshTimer) clearInterval(this.refreshTimer);
} }
@@ -46,7 +50,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
const { page, pageSize, skip } = pagination(query); const { page, pageSize, skip } = pagination(query);
const where = reconciliationWhere(query); const where = reconciliationWhere(query);
const [items, total, aggregate] = await Promise.all([ 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.count({ where }),
this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }), this.prisma.dailyReconciliationReport.aggregate({ where, _sum: reportVolumeSumSelection }),
]); ]);
@@ -57,7 +66,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
const { page, pageSize, skip } = pagination(query); const { page, pageSize, skip } = pagination(query);
const { dimensionType, where } = profitWhere(query); const { dimensionType, where } = profitWhere(query);
const [storedItems, total, aggregate] = await Promise.all([ 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.count({ where }),
this.prisma.dailyProfitReport.aggregate({ this.prisma.dailyProfitReport.aggregate({
where, where,
@@ -65,7 +79,10 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
}), }),
]); ]);
// refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。 // refundCents 暂留在持久化模型中兼容既有数据和回滚,但利润报表接口不再暴露返还口径。
const items = storedItems.map(({ refundCents: _refundCents, ...item }) => item); const items = storedItems.map(({ refundCents, ...item }) => {
void refundCents;
return item;
});
const summary = { const summary = {
...volumeSummary(aggregate._sum), ...volumeSummary(aggregate._sum),
revenueCents: Number(aggregate._sum.revenueCents ?? 0), revenueCents: Number(aggregate._sum.revenueCents ?? 0),
@@ -81,7 +98,12 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
const { page, pageSize, skip } = pagination(query); const { page, pageSize, skip } = pagination(query);
const { dimensionType, where } = qualityWhere(query); const { dimensionType, where } = qualityWhere(query);
const [items, total, aggregate] = await Promise.all([ 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.count({ where }),
this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }), this.prisma.dailyQualityReport.aggregate({ where, _sum: reportVolumeSumSelection }),
]); ]);
@@ -94,34 +116,136 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
} }
async exportReconciliation(query: ReportListQuery) { async exportReconciliation(query: ReportListQuery) {
const items = await this.prisma.dailyReconciliationReport.findMany({ where: reconciliationWhere(query), orderBy: [{ reportDate: 'desc' }, { tenantName: 'asc' }, { applicationName: 'asc' }] }); const items = await this.prisma.dailyReconciliationReport.findMany({
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)])); 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) { async exportProfit(query: ReportListQuery) {
const { dimensionType, where } = profitWhere(query); const { dimensionType, where } = profitWhere(query);
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] }); const items = await this.prisma.dailyProfitReport.findMany({
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)])); 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) { async exportQuality(query: ReportListQuery) {
const { dimensionType, where } = qualityWhere(query); const { dimensionType, where } = qualityWhere(query);
const items = await this.prisma.dailyQualityReport.findMany({ where, orderBy: [{ sentUnits: 'desc' }, { reportDate: 'desc' }, { dimensionName: 'asc' }] }); const items = await this.prisma.dailyQualityReport.findMany({
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)])); 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()) { async refreshRollingWindow(now = new Date()) {
const days = completedBusinessDays(now, 4); const days = completedBusinessDays(now, 4);
for (const day of days) await this.refreshBusinessDay(day); const refreshedDates: string[] = [];
return { refreshedDates: days.map((day) => day.key) }; 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() { private async runScheduledRefresh() {
const businessDate = shanghaiDateKey(new Date()); const now = new Date();
const businessDate = shanghaiDateKey(now);
if (this.refreshRunning || this.lastRefreshBusinessDate === businessDate) return; if (this.refreshRunning || this.lastRefreshBusinessDate === businessDate) return;
this.refreshRunning = true; this.refreshRunning = true;
try { try {
const result = await this.refreshRollingWindow(); const result = await this.refreshRollingWindow(now);
this.lastRefreshBusinessDate = businessDate; this.lastRefreshBusinessDate = businessDate;
this.logger.log(`Daily reports refreshed for ${result.refreshedDates.join(', ')}`); this.logger.log(`Daily reports refreshed for ${result.refreshedDates.join(', ')}`);
} catch (error) { } catch (error) {
@@ -132,7 +256,17 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
} }
private async refreshBusinessDay(day: BusinessDay) { private async refreshBusinessDay(day: BusinessDay) {
await this.prisma.$transaction(async (tx) => { 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.dailyReconciliationReport.deleteMany({ where: { reportDate: day.reportDate } });
await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } }); await tx.dailyProfitReport.deleteMany({ where: { reportDate: day.reportDate } });
await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } }); await tx.dailyQualityReport.deleteMany({ where: { reportDate: day.reportDate } });
@@ -197,6 +331,9 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
) AS delivered ) AS delivered
) legacy_receipt ON TRUE ) legacy_receipt ON TRUE
WHERE submit."submitStatus" = 'accepted' WHERE submit."submitStatus" = 'accepted'
-- 应用成本归属于原短信日,仍包含该短信全部跨日补发尝试。
AND message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
GROUP BY submit."messageRecordId" GROUP BY submit."messageRecordId"
) )
INSERT INTO "DailyProfitReport" ( INSERT INTO "DailyProfitReport" (
@@ -352,23 +489,28 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
await tx.$executeRaw(qualityByChannelSql(day)); await tx.$executeRaw(qualityByChannelSql(day));
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature')); await tx.$executeRaw(qualityByMessageDimensionSql(day, 'signature'));
await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage')); await tx.$executeRaw(qualityByMessageDimensionSql(day, 'drainage'));
}); },
{ maxWait: 5_000, timeout },
);
} }
} }
function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'application' | 'signature' | 'drainage') { function qualityByMessageDimensionSql(day: BusinessDay, dimensionType: 'application' | 'signature' | 'drainage') {
const dimensionTypeSql = Prisma.raw(`'${dimensionType}'`); const dimensionTypeSql = Prisma.raw(`'${dimensionType}'`);
const dimensionId = dimensionType === 'application' const dimensionId =
dimensionType === 'application'
? Prisma.sql`application.id` ? Prisma.sql`application.id`
: dimensionType === 'signature' : dimensionType === 'signature'
? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))` ? Prisma.sql`COALESCE(signature.id, 'unmatched:' || COALESCE(application.id, tenant.id))`
: Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`; : Prisma.sql`COALESCE(drainage.id, 'unmatched:' || COALESCE(application.id, tenant.id))`;
const dimensionName = dimensionType === 'application' const dimensionName =
dimensionType === 'application'
? Prisma.sql`application.name` ? Prisma.sql`application.name`
: dimensionType === 'signature' : dimensionType === 'signature'
? Prisma.sql`COALESCE(signature.name, '未关联签名')` ? Prisma.sql`COALESCE(signature.name, '未关联签名')`
: Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`; : Prisma.sql`COALESCE(drainage."siteName", '未关联引流信息')`;
const applicationJoin = dimensionType === 'application' const applicationJoin =
dimensionType === 'application'
? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"` ? Prisma.sql`JOIN "SmsApplication" application ON application.id = message."applicationId"`
: Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`; : Prisma.sql`LEFT JOIN "SmsApplication" application ON application.id = message."applicationId"`;
@@ -548,7 +690,13 @@ const reportVolumeSumSelection = {
failedUnits: true, failedUnits: true,
} as const; } 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 { return {
submittedUnits: Number(sum.submittedUnits ?? 0), submittedUnits: Number(sum.submittedUnits ?? 0),
sentUnits: Number(sum.sentUnits ?? 0), sentUnits: Number(sum.sentUnits ?? 0),
@@ -559,11 +707,15 @@ function volumeSummary(sum: { submittedUnits?: number | null; sentUnits?: number
} }
function ratioBps(numerator: number, denominator: 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 { 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) { function profitWhere(query: ReportListQuery) {
@@ -580,7 +732,9 @@ function profitWhere(query: ReportListQuery) {
function qualityWhere(query: ReportListQuery) { function qualityWhere(query: ReportListQuery) {
const allowedDimensions = new Set(['application', 'channel', 'signature', 'drainage']); 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 = { const where: Prisma.DailyQualityReportWhereInput = {
dimensionType, dimensionType,
reportDate: dateFilter(query.dateFrom, query.dateTo), reportDate: dateFilter(query.dateFrom, query.dateTo),
@@ -2232,3 +2232,10 @@
## 2026-09-08 分页交互补充 ## 2026-09-08 分页交互补充
签名质量检测四个Tab的每页数量选择移至各自列表底部,与上一页、下一页和页码跳转放在同一分页区;保留独立日期、筛选和每页10/25/50/100,默认25,空结果也可选择数量。通道报备明细增加相同四档容量,默认25;修改容量回到第一页、清空当页勾选、保留已应用筛选,使用真实服务端分页,较早请求的成功或失败不得覆盖最新查询结果。本节补充前述运营修复要求;不更改后端接口、端口或数据模型,详见[运营修复设计](operations-fixes-20260908.md#分页交互补充2026-09-08)。 签名质量检测四个Tab的每页数量选择移至各自列表底部,与上一页、下一页和页码跳转放在同一分页区;保留独立日期、筛选和每页10/25/50/100,默认25,空结果也可选择数量。通道报备明细增加相同四档容量,默认25;修改容量回到第一页、清空当页勾选、保留已应用筛选,使用真实服务端分页,较早请求的成功或失败不得覆盖最新查询结果。本节补充前述运营修复要求;不更改后端接口、端口或数据模型,详见[运营修复设计](operations-fixes-20260908.md#分页交互补充2026-09-08)。
## 2026-09-08 报表生成可靠性补充
本节明确 5.19.1 中的事务范围为“每个日期独立事务”,四日窗口不共用一个长事务。对账、应用/通道利润及四类质量报表在同一日期内原子重建;失败保留该日旧报表,继续处理窗口内其他日期,只有四日全部成功才标记当天完成,失败下次检查仍可重试。默认启动 15 秒后执行、每小时检查,不承诺固定分钟触发。
应用利润成本只扫描目标日原短信关联的全部 accepted 提交,包括跨日补发;收入、成本快照、分片审计优先和历史成功回执兼容语义不变。通道维度仍按实际提交日统计。报表使用专属有限事务预算和数据库日锁,不调整发送/计费事务。T-5 及更早数据不被正常日任务改写,历史缺口须单独授权补齐;本轮不新增自动历史重算。实现与验收见 [日报生成超时修复](report-generation-reliability-20260908.md)。
@@ -0,0 +1,25 @@
# 日报生成超时修复
维护日期:2026-09-08。适用于对账单、利润报表、发送质量报表的共同生成任务。本文补充[需求 5.19.1](first-version-development-requirements.md#5191-报表对账)的执行与失败恢复机制,不改变收入、成本、计费条数、质量维度及 T+1 / T-4~T-1 业务口径。实施结果见[测试进度](testing-progress.md)。
## 证据与影响
预生产版本 `633ba597754c1b89943ea3a819f45042f41b019a` 在 2026-09-08 16:4016:51 只读核验时,三类报表最新日期均为 9 月 2 日,9 月 3~7 日有短信却缺报表。保留日志有 93 次生成失败,最近 16:12 的错误为 Prisma 事务上限 5000ms、实际已耗时 7510ms。应用利润成本 CTE 未限定原短信日期;9 月 4 日只有 2492 条短信,却扫描 103450 条 accepted 提交。仅执行原 SELECT 的实际计划耗时 9090.976ms;添加原短信日期范围的只读候选耗时 90.515ms。单次对照可能受缓存影响,不代表完整任务提速比例或已经上线。
同日期三类报表共用事务,应用利润耗时使事务过期,在下一条通道利润语句处报错,已插入的对账单也回滚。原滚动任务遇到一个日期失败即退出,阻止后续日期执行。
## 最小修复设计
1. 应用利润的成本 CTE 在关联 `SmsMessageRecord` 后限定 `message.queuedAt >= startAt AND message.queuedAt < endAt`,继续累计这些短信的全部 accepted 提交,包括跨日补发。分片审计优先、无分片审计才兼容明确成功的历史回执;不按当前通道价格倒算,不改通道维度的实际提交日归属。
2. 每个日期仍在一个独立事务内原子重建对账、应用/通道利润及四个质量维度;任何失败都保留该日期旧报表,不能先删后在事务外插入。
3. 报表专用事务默认上限 30000ms、获取连接最长等待 5000ms。`REPORT_REFRESH_TRANSACTION_TIMEOUT_MS` 可设正整数毫秒,非法值回退默认,上限 120000ms;不修改其他业务事务的全局设置。事务内设置同上限的 PostgreSQL `statement_timeout`,避免单条异常 SQL 无界运行。
4. 同日期使用 PostgreSQL 事务级 advisory lock(固定报表命名空间 + YYYYMMDD)。取锁失败视为该日期未完成,不删除报表;事务结束自动释放锁,下一调度周期可重试。日锁同时保护不同 API 实例和同进程手动服务调用。
5. T-4~T-1 逐日执行;记录每个失败日期及错误后继续其他日期,最后汇总失败并向调用者抛错。只有四天全部成功,调度器才记录本日刷新完成;部分失败保持下个小时重试资格,不静默报告成功。启动 15 秒后的首次执行和默认每小时检查保持;销毁服务同时清除启动与周期定时器。
6. 本轮不新增迁移、持久化任务表、API 写入口或自动历史回算。T-5 及更早报表不被日常任务改写;已发现的历史缺口须在修复部署后按明确授权、日期清单单独补齐。进程重启仍按现有四日窗口执行,不能声称历史缺口永久恢复机制已实现。
## 验收与交付边界
- 定向回归覆盖日期边界、成本扫描范围、部分失败继续后续日期、失败重试/成功去重、并发、原子回滚和生命周期停止;真实 PostgreSQL 验证跨日尝试、部分分片成功及历史回执兼容、三类报表及重复生成一致性。
- 真实 SQL 性能与结果对照在预生产只允许 SELECT、限时和只读事务,不调用生成服务或写业务表。完整生成测试使用本机独立 PostgreSQL 测试库,不能以 mock 通过代替真实数据库证据。
- 执行 API 全量、类型/生产构建及现有相关质量门禁。没有前端改动,不改变页面、权限、查询 API、端口或租户过滤。
- 本轮授权修改代码并本地提交;不推送、不部署两环境、不补跑预生产报表、不发送短信或修改业务配置。测试数据仅在隔离本地测试库构造,结果与未验证项记入进度。
+17
View File
@@ -5254,3 +5254,20 @@ OPS0908-01至07已按本轮范围验证;精确证据见testing-progress.md对
| OPS-PAGE0908-03 | 打开通道报备明细,设置筛选,到第2页勾选后依次改变四档容量 | 默认25;真实接口page=1及正确pageSize,总数/页数/行数一致;筛选与URL范围保留,旧勾选清空,不执行状态或发送操作 | | OPS-PAGE0908-03 | 打开通道报备明细,设置筛选,到第2页勾选后依次改变四档容量 | 默认25;真实接口page=1及正确pageSize,总数/页数/行数一致;筛选与URL范围保留,旧勾选清空,不执行状态或发送操作 |
| OPS-PAGE0908-04 | 隔离测试让旧25条查询晚于新100条返回,分别返回成功和失败 | 旧结果和旧错误均不能覆盖最新查询;当前失败显示真实错误;组件卸载后旧请求失效 | | OPS-PAGE0908-04 | 隔离测试让旧25条查询晚于新100条返回,分别返回成功和失败 | 旧结果和旧错误均不能覆盖最新查询;当前失败显示真实错误;组件卸载后旧请求失效 |
| OPS-PAGE0908-05 | 在1600×1000、1366×768、390×844检查两页面,刷新/跨路由,回归原公共分页消费者 | 下拉完整可见、可操作,表格内部滚动不造成页面整体溢出;旧消费者不新增容量控件;跳转输入在真实页码改变后重置,返回旧页不恢复未提交草稿 | | OPS-PAGE0908-05 | 在1600×1000、1366×768、390×844检查两页面,刷新/跨路由,回归原公共分页消费者 | 下拉完整可见、可操作,表格内部滚动不造成页面整体溢出;旧消费者不新增容量控件;跳转输入在真实页码改变后重置,返回旧页不恢复未提交草稿 |
## 2026-09-08 日报生成超时与失败恢复
依据 [日报生成超时修复](report-generation-reliability-20260908.md)。涉及造数和失败注入的用例仅在本地独立 PostgreSQL 测试库执行;预生产仅限时只读核验,短信发送、业务配置变更与历史补跑不包含在验收授权中。
| 编号 | 场景 | 预期结果 |
|---|---|---|
| TC-DAILY-0908-01 | 大量窗口外历史记录下,对照目标日期应用利润原 SELECT 与限定原短信日期后的 SELECT | 除生成时间外结果字段逐项一致;成本扫描受原短信日期限定,记录实际执行计划和耗时,不能仅比较返回行数 |
| TC-DAILY-0908-02 | 原短信日后跨天、跨通道 accepted 补发,最终收入归最新成功提交 | 应用累计所有关联成功分片成本,仍归原短信日;通道按实际提交日归属且收入不重复;不存在按提交日期裁剪应用成本 |
| TC-DAILY-0908-03 | 长短信部分分片成功、审计存在但零成功、无审计但旧回执成功、失败/未知提交 | 审计优先;零成功不退回历史回执计整条成本;只有无审计且明确旧成功回执时按计费分片数兼容 |
| TC-DAILY-0908-04 | 北京时间零点、跨月/跨年、目标日起点及终点边界 | 每次恰好 T-4~T-1,起点包含、终点排除;不生成当天,不改写 T-5 更早报表 |
| TC-DAILY-0908-05 | 某日对账重建后注入真实 SQL 错误,其他三日继续,之后恢复再执行 | 失败日三表旧结果全部保留、无半成品;其余日期成功;返回含失败/成功日期的错误,不标记当天完成;重试成功后相同日期无重复 |
| TC-DAILY-0908-06 | 同实例重叠触发、两个事务/服务实例同时刷新同日 | 同实例定时入口防重入;数据库日锁未获取时不删除报表,明确失败并保留重试资格;锁释放后可重算且无重复 |
| TC-DAILY-0908-07 | 默认/有效/非法/超大事务超时配置,实际 PostgreSQL 限时及事务结束 | 默认 30000ms、maxWait 5000ms;非法值回退默认、最大 120000msstatement_timeout 仅当前事务有效,其他业务事务不改变 |
| TC-DAILY-0908-08 | 失败后次周期、成功后同日重复周期,以及启动 15 秒内销毁服务 | 失败继续重试、全成功后同日跳过;销毁清除启动及周期定时器,无销毁后新任务 |
| TC-DAILY-0908-09 | 真实生成后调用报表查询/汇总/分页及 CSV | 数据与 PostgreSQL 一致;既有日期/租户/维度筛选和金额精度不变,API 无假成功或静态数据 |
+14
View File
@@ -4750,3 +4750,17 @@ git diff --check
- 验收脚本问题与边界:独立Vite开发服务器曾出现本机连接超时,改用同一验收进程创建/关闭生产预览后通过;一次脚本假定Escape关闭Select导致等待超时,按当前触发按钮关闭方式修正后全流程通过,未扩改Select。错误/乱序以隔离测试验证;未报备真实非空、热力图超过100个维度、全部旧分页消费者的逐页人工验收未覆盖。真实API数据来自现有服务及PostgreSQL查询;本轮不修改数据库,未另取得测试受限数据库配置作直接SQL对账。 - 验收脚本问题与边界:独立Vite开发服务器曾出现本机连接超时,改用同一验收进程创建/关闭生产预览后通过;一次脚本假定Escape关闭Select导致等待超时,按当前触发按钮关闭方式修正后全流程通过,未扩改Select。错误/乱序以隔离测试验证;未报备真实非空、热力图超过100个维度、全部旧分页消费者的逐页人工验收未覆盖。真实API数据来自现有服务及PostgreSQL查询;本轮不修改数据库,未另取得测试受限数据库配置作直接SQL对账。
- 证据位于%TEMP%/cmpp-pagination-20260908before-quality/report截图、acceptance.json、filter-acceptance.json、三视口截图、frontend-tests.log、build.log、format.log、lint.log和开工diff快照。截图/脚本/凭据不进入提交。临时预览已关闭。 - 证据位于%TEMP%/cmpp-pagination-20260908before-quality/report截图、acceptance.json、filter-acceptance.json、三视口截图、frontend-tests.log、build.log、format.log、lint.log和开工diff快照。截图/脚本/凭据不进入提交。临时预览已关闭。
- 交付边界:本地代码、需求、设计和用例完成;本节随本轮精确文件/追加hunk作本地提交,提交号见Git记录。不推送、不测试部署、不预生产部署;不发送/补发/重投/入队短信,不变更余额、通道、客户配置或恢复管理员。测试环境仍运行旧2c228a9,本地提交不等于已上线。 - 交付边界:本地代码、需求、设计和用例完成;本节随本轮精确文件/追加hunk作本地提交,提交号见Git记录。不推送、不测试部署、不预生产部署;不发送/补发/重投/入队短信,不变更余额、通道、客户配置或恢复管理员。测试环境仍运行旧2c228a9,本地提交不等于已上线。
## 2026-09-08 17:14 日报生成超时修复与本地提交前验收
- 授权:修改代码并本地提交,不推送、不测试部署、不预生产部署、不补跑历史报表。开工 main/HEAD 为 2a9d03be2edc06dbd9a494e971e6b04a8336619f,暂存空;只读 ls-remote 回读远端 main 为 50ae37242bc33acf1038fb62618fc4cd95409956,本地领先 3 提交。17 个已有脏跟踪文件及原未跟踪发布工具/诊断脚本继续保护;涉及三份旧脏文档仅追加并精确暂存本轮增量。
- 需求/设计:[可靠性补充](first-version-development-requirements.md#2026-09-08-报表生成可靠性补充)、[日报生成超时修复](report-generation-reliability-20260908.md)TC-DAILY-0908-0109。明确单日期三类报表原子事务、T-4~T-1 和既有财务口径,历史缺口不自动扩窗。
- 根因证据:预生产 633ba597754c1b89943ea3a819f45042f41b019a 三类表最新 reportDate 均为 9 月 2 日;9 月 3~7 日源短信非空。保留日志 93 次事务超时,9 月 8 日 16:12 的上限 5000ms、实际 7510ms。成本 CTE 扫全部 accepted 历史提交,单条 SELECT 实测 9090.976ms 已超过整日报表事务预算。
- 实现:应用成本 CTE 按原短信 queuedAt 限定日期,保留全部跨日提交的成功分片成本;日事务默认 30000ms、maxWait 5000msREPORT_REFRESH_TRANSACTION_TIMEOUT_MS 正整数覆盖且最大 120000ms,并设置事务局部 statement_timeout。删除前获取 PostgreSQL 日期 advisory lock;单日失败保留旧报表并继续其他日期,最终汇总抛错,只有全成功才标记本日完成。启动/周期定时器销毁清理,调度日与窗口共用同一时刻。未新增迁移/API 写入口/历史重算队列。两份已修改 TS 按当前 Prettier 门禁格式化,并显式忽略原 refundCents 解构值以修复该文件既有 lint 错误,列表/CSV 语义不变。
- 预生产只读结果一致性:在同一 REPEATABLE READ READ ONLY 事务中、statement_timeout=15000ms9 月 4 日原 SELECT 8919ms、当前候选 SELECT 100ms;两行全部结果列(含金额,按数组避免同名列丢失)摘要均为 576592fc45b7f3137882229321eaf905d1e9bba12631ccfe1a0b2016c759231b。仅移除 INSERT 头部执行 SELECT,防写入检查通过;事务最终 ROLLBACK,不调用线上生成服务。这是单日查询对照,缓存可能影响耗时,不宣称完整任务同比提速或全日期金额对账已完成。
- 代码验收:API 全量 64 套 694 项通过(工作区包含原保护 metrics 的 1 项额外测试,未夹带提交);最终报表定向 1 套 29 项通过。API TypeScript 生产构建、前端 TypeScript(既有 lint 组成)、changed-code Prettier/ESLint、结构检查、Stylelint、CSS 治理及 15 项工具测试、安全/部署静态门禁、git diff --check 通过。npm 当前不在 PATH,执行 package.json 对应 Node CLI;未安装依赖。API 命令为 node node_modules/jest/bin/jest.js --runInBand、node node_modules/typescript/bin/tsc -p tsconfig.build.json。
- 真实数据库/API:新增 tools/testing/verify-report-refresh.mjs,强制独立 REPORT_TEST_DATABASE_URL、loopback 地址及 cmpp_report_test_ 数据库前缀,不使用业务 DATABASE_URL。独立本机 PostgreSQL 8 组通过:7 个报表维度与完整自然日、跨日补发/审计优先/历史兼容、重复幂等、真实 SQL 失败三表回滚且后续日期成功、跨连接日期锁、超过旧 5 秒的成功事务、200ms 限额超时回滚恢复、真实 ReportsController HTTP 筛选/分页/汇总及三类 CSV。10 条消息/10 次提交的应用 9 月 4 日收入 4000、成本 800、利润 3200(0.0001 元整数单位)一致;单次注入 5.2 秒延迟后单日事务实际 5248ms 成功。金额源列采用生产 bigint,报表表完整列/类型/唯一约束,源表只建查询用列,未做全库迁移验收。测试 schema 残留 0,临时 PostgreSQL 已停止;测试资料目录保留。
- 证据:%TEMP%/cmpp-report-fix-20260908/{baseline.json,api-tests.log,report-tests-final.log}%TEMP%/cmpp-report-diagnosis-20260908/{profile-select.cjs.result.jsonl,compare-candidate.cjs.result.jsonl}%TEMP%/cmpp-report-pg-1788858316792/integration.log。脚本、源库结果未输出凭据或短信正文。
- 未执行:预生产完整任务写入验收/自然定时观察、两环境部署、历史缺口补齐、完整应用登录与浏览器。本轮无前端改动;本地 HTTP 是独立真实报表 Controller/Service/PG,不代表完整认证/UI 验收。旧 api/tools/verify-report-recalculation.ts 含过期金额断言和业务造数,不执行也不在本轮改写。T-5 更早缺口仍须部署修复后单独授权补齐。
- 交付:本地报表代码、测试工具、需求/设计/用例和本节一起按精确 7 文件/文档增量提交,提交号以 Git 为准;未推送、未测试部署、未预生产部署、未短信发送/补发/重投/入队,未修改业务余额、通道、客户配置或管理员。提交前保护校验确认全部已有跟踪修改未被覆盖。
+400
View File
@@ -0,0 +1,400 @@
/**
* Real PostgreSQL report integration, isolated from all messaging lifecycles.
* Build API first. Set REPORT_TEST_DATABASE_URL to a disposable local database
* named cmpp_report_test_*. The caller owns database/server creation and shutdown.
* This script creates and drops only its unique schema; it never uses DATABASE_URL.
*/
import assert from 'node:assert/strict';
import { createRequire } from 'node:module';
import { randomUUID } from 'node:crypto';
const require = createRequire(new URL('../../api/package.json', import.meta.url));
const { Pool } = require('pg');
const { PrismaClient } = require('@prisma/client');
const { PrismaPg } = require('@prisma/adapter-pg');
const { ReportsService } = require('./dist/reports/reports.service.js');
const { ReportsController } = require('./dist/reports/reports.controller.js');
const { Module, Logger } = require('@nestjs/common');
const { NestFactory } = require('@nestjs/core');
const connectionString = process.env.REPORT_TEST_DATABASE_URL;
assert.ok(connectionString, 'Set REPORT_TEST_DATABASE_URL explicitly; DATABASE_URL is never used');
const target = new URL(connectionString);
assert.ok(['postgres:', 'postgresql:'].includes(target.protocol), 'PostgreSQL URL required');
assert.ok(['127.0.0.1', 'localhost', '[::1]'].includes(target.hostname), 'Only loopback PostgreSQL is allowed');
assert.match(
decodeURIComponent(target.pathname.slice(1)),
/^cmpp_report_test_[a-z0-9_]+$/,
'Dedicated disposable database name required',
);
assert.equal(target.search, '', 'URL query overrides are forbidden');
process.env.REPORT_DAILY_REFRESH_ENABLED = 'false';
delete process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS;
Logger.overrideLogger(false);
const schema = `report_refresh_${randomUUID().replaceAll('-', '')}`;
const admin = new Pool({ connectionString, max: 2 });
const pool = new Pool({ connectionString, max: 4, options: `-c search_path=${schema} -c timezone=UTC` });
const prisma = new PrismaClient({ adapter: new PrismaPg(pool, { schema, disposeExternalPool: true }) });
const service = new ReportsService(prisma);
const checks = [];
let slowDayElapsedMs;
const now = new Date('2026-09-08T03:00:00Z');
const days = ['2026-09-04', '2026-09-05', '2026-09-06', '2026-09-07'];
const tables = ['DailyReconciliationReport', 'DailyProfitReport', 'DailyQualityReport'];
let app;
let schemaCreated = false;
async function check(name, operation) {
await operation();
checks.push(name);
}
async function snapshot(date, includeTimestamps = false) {
const result = {};
for (const table of tables) {
const expression = includeTimestamps ? 'to_jsonb(row)' : "to_jsonb(row) - 'generatedAt' - 'updatedAt'";
const rows = await pool.query(
`SELECT ${expression} AS value FROM "${table}" row WHERE "reportDate" = $1::date ORDER BY id`,
[date],
);
result[table] = rows.rows.map(({ value }) => value);
}
return result;
}
async function installFailureTrigger(body) {
await pool.query(`CREATE FUNCTION fail_report_test() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN
IF NEW."reportDate" = DATE '2026-09-04' THEN ${body} END IF;
RETURN NEW;
END $$;
CREATE TRIGGER fail_report_test BEFORE INSERT ON "DailyQualityReport" FOR EACH ROW EXECUTE FUNCTION fail_report_test()`);
}
async function removeFailureTrigger() {
await pool.query('DROP TRIGGER fail_report_test ON "DailyQualityReport"; DROP FUNCTION fail_report_test()');
}
async function assertPartialFailure() {
await assert.rejects(service.refreshRollingWindow(now), (error) => {
assert.match(error.message, /failed dates: 2026-09-04/);
assert.match(error.message, /refreshed dates: 2026-09-05, 2026-09-06, 2026-09-07/);
return true;
});
}
try {
const identity = await admin.query('SELECT current_database() AS database, host(inet_server_addr()) AS address');
assert.equal(identity.rows[0].database, decodeURIComponent(target.pathname.slice(1)));
assert.ok(['127.0.0.1', '::1'].includes(identity.rows[0].address), 'Server must actually be loopback');
await admin.query(`CREATE SCHEMA "${schema}"`);
schemaCreated = true;
// Minimal source tables expose exactly the columns read by reporting SQL.
// Report table columns/types and uniqueness mirror the production Prisma model.
await pool.query(`
CREATE TABLE "Tenant" (id text PRIMARY KEY, name text NOT NULL);
CREATE TABLE "SmsApplication" (id text PRIMARY KEY, name text NOT NULL);
CREATE TABLE "SmsChannel" (id text PRIMARY KEY, name text NOT NULL);
CREATE TABLE "SmsSignature" (id text PRIMARY KEY, name text NOT NULL);
CREATE TABLE "SmsDrainageInfo" (id text PRIMARY KEY, "siteName" text NOT NULL);
CREATE TABLE "SmsMessageRecord" (
id text PRIMARY KEY, "tenantId" text NOT NULL, "applicationId" text NOT NULL,
"signatureId" text, "drainageInfoId" text, "billingUnits" integer NOT NULL,
status text, "receiptStatus" text, "queuedAt" timestamp(3) NOT NULL,
"submittedAt" timestamp(3), "deliveredAt" timestamp(3), "unitPrice" bigint NOT NULL, "submitId" text
);
CREATE INDEX ON "SmsMessageRecord" ("queuedAt");
CREATE TABLE "SmsSubmitRecord" (
id text PRIMARY KEY, "messageRecordId" text NOT NULL, "channelId" text NOT NULL,
"gatewayMessageId" text, "submitId" text, "submitStatus" text NOT NULL,
"costUnitPrice" bigint NOT NULL, "submittedAt" timestamp(3), "createdAt" timestamp(3) NOT NULL
);
CREATE INDEX ON "SmsSubmitRecord" ("messageRecordId");
CREATE TABLE "SmsMessageSegmentAudit" (id text PRIMARY KEY, "submitRecordId" text NOT NULL, "receiptStatus" text);
CREATE INDEX ON "SmsMessageSegmentAudit" ("submitRecordId");
CREATE TABLE "SmsReceiptRecord" (id text PRIMARY KEY, "gatewayMessageId" text, "channelId" text, "receiptStatus" text, "deliveredAt" timestamp(3));
CREATE INDEX ON "SmsReceiptRecord" ("channelId", "gatewayMessageId");
CREATE TABLE "DailyReconciliationReport" (
id text PRIMARY KEY, "reportDate" date NOT NULL, "tenantId" text NOT NULL, "tenantName" text NOT NULL,
"applicationId" text NOT NULL, "applicationName" text NOT NULL,
"submittedUnits" integer NOT NULL, "sentUnits" integer NOT NULL, "unknownUnits" integer NOT NULL,
"successUnits" integer NOT NULL, "failedUnits" integer NOT NULL,
"generatedAt" timestamp(3) NOT NULL, "updatedAt" timestamp(3) NOT NULL,
UNIQUE ("reportDate", "tenantId", "applicationId")
);
CREATE TABLE "DailyProfitReport" (
id text PRIMARY KEY, "reportDate" date NOT NULL, "dimensionType" text NOT NULL, "dimensionId" text NOT NULL, "dimensionName" text NOT NULL,
"tenantId" text, "tenantName" text, "applicationId" text, "channelId" text,
"submittedUnits" integer NOT NULL, "sentUnits" integer NOT NULL, "unknownUnits" integer NOT NULL,
"successUnits" integer NOT NULL, "failedUnits" integer NOT NULL,
"revenueCents" bigint NOT NULL, "refundCents" bigint NOT NULL, "costCents" bigint NOT NULL,
"profitCents" bigint NOT NULL, "profitRateBps" integer NOT NULL,
"generatedAt" timestamp(3) NOT NULL, "updatedAt" timestamp(3) NOT NULL,
UNIQUE ("reportDate", "dimensionType", "dimensionId")
);
CREATE TABLE "DailyQualityReport" (
id text PRIMARY KEY, "reportDate" date NOT NULL, "dimensionType" text NOT NULL, "dimensionId" text NOT NULL, "dimensionName" text NOT NULL,
"tenantId" text, "tenantName" text, "applicationId" text, "channelId" text, "signatureId" text, "drainageInfoId" text,
"submittedUnits" integer NOT NULL, "sentUnits" integer NOT NULL, "unknownUnits" integer NOT NULL,
"successUnits" integer NOT NULL, "failedUnits" integer NOT NULL, "successRateBps" integer NOT NULL, "avgArrivalMs" integer,
"generatedAt" timestamp(3) NOT NULL, "updatedAt" timestamp(3) NOT NULL,
UNIQUE ("reportDate", "dimensionType", "dimensionId")
);
INSERT INTO "Tenant" VALUES ('t1', 'Report Test One'), ('t2', 'Report Test Two');
INSERT INTO "SmsApplication" VALUES ('a1', 'Test Application One'), ('a2', 'Test Application Two');
INSERT INTO "SmsChannel" VALUES ('c1', 'Test Channel One'), ('c2', 'Test Channel Two');
INSERT INTO "SmsSignature" VALUES ('sig1', 'Test Signature'), ('sig2', 'Second Test Signature');
INSERT INTO "SmsDrainageInfo" VALUES ('drain1', 'Test Site'), ('drain2', 'Second Test Site');
`);
const messages = [
['cross', '2026-09-03T16:00:00.000Z', 3, 'delivered', 1000, 'cross_retry'],
['legacy', '2026-09-04T02:00:00.000Z', 2, 'delivered', 500, 'legacy_submit'],
['failed', '2026-09-04T03:00:00.000Z', 1, 'failed', 800, 'failed_submit'],
['unknown', '2026-09-04T04:00:00.000Z', 1, 'unknown', 800, 'unknown_submit'],
['before', '2026-09-03T15:59:59.999Z', 7, 'delivered', 900, 'before_submit'],
['next', '2026-09-04T16:00:00.000Z', 1, 'delivered', 1000, 'next_submit'],
['six', '2026-09-06T01:00:00.000Z', 1, 'delivered', 1000, 'six_submit'],
['seven', '2026-09-07T01:00:00.000Z', 1, 'delivered', 1000, 'seven_submit'],
['today', '2026-09-07T16:00:00.000Z', 11, 'delivered', 900, 'today_submit'],
['other', '2026-09-04T02:00:00.000Z', 1, 'delivered', 2000, null],
];
for (const [id, date, units, status, price, submitId] of messages) {
await pool.query(
`INSERT INTO "SmsMessageRecord" VALUES ($1,$2,$3,$9,$10,$4,$5,NULL,$6,$6,$6::timestamp + INTERVAL '1 second',$7,$8)`,
[
id,
id === 'other' ? 't2' : 't1',
id === 'other' ? 'a2' : 'a1',
units,
status,
date,
price,
submitId,
id === 'other' ? 'sig2' : 'sig1',
id === 'other' ? 'drain2' : 'drain1',
],
);
}
const submits = [
['cross_first', 'cross', 'c1', '2026-09-04T01:00:00Z', 100, ['delivered', 'undelivered', 'undelivered'], true],
['cross_retry', 'cross', 'c2', '2026-09-05T01:00:00Z', 200, ['delivered', 'delivered', 'undelivered'], true],
['legacy_submit', 'legacy', 'c1', '2026-09-04T02:00:00Z', 150, [], true],
['failed_submit', 'failed', 'c1', '2026-09-04T03:00:00Z', 50, ['undelivered'], true],
['unknown_submit', 'unknown', 'c1', '2026-09-04T04:00:00Z', 70, ['unknown'], false],
['before_submit', 'before', 'c1', '2026-09-03T15:59:59Z', 99, ['delivered'], true],
['next_submit', 'next', 'c1', '2026-09-04T16:00:00Z', 100, ['delivered'], true],
['six_submit', 'six', 'c1', '2026-09-06T01:00:00Z', 100, ['delivered'], true],
['seven_submit', 'seven', 'c1', '2026-09-07T01:00:00Z', 100, ['delivered'], true],
['today_submit', 'today', 'c1', '2026-09-07T16:00:00Z', 99, ['delivered'], true],
];
for (const [id, messageId, channel, date, price, audits, delivered] of submits) {
await pool.query('INSERT INTO "SmsSubmitRecord" VALUES ($1,$2,$3,$1,$1,\'accepted\',$4,$5,$5)', [
id,
messageId,
channel,
price,
date,
]);
for (const [index, status] of audits.entries()) {
await pool.query('INSERT INTO "SmsMessageSegmentAudit" VALUES ($1,$2,$3)', [`${id}_${index}`, id, status]);
}
if (delivered)
await pool.query(
"INSERT INTO \"SmsReceiptRecord\" VALUES ($1,$1,$2,'delivered',$3::timestamp + INTERVAL '1 second')",
[id, channel, date],
);
}
await pool.query(
'INSERT INTO "SmsReceiptRecord" SELECT \'duplicate_legacy\', "gatewayMessageId", "channelId", "receiptStatus", "deliveredAt" FROM "SmsReceiptRecord" WHERE id=\'legacy_submit\'',
);
await check('seven report dimensions and Beijing complete-day boundaries', async () => {
assert.deepEqual(await service.refreshRollingWindow(now), { refreshedDates: days });
const stored = await snapshot(days[0]);
assert.equal(stored.DailyReconciliationReport.length, 2);
assert.deepEqual([...new Set(stored.DailyProfitReport.map((row) => row.dimensionType))].sort(), [
'application',
'channel',
]);
assert.deepEqual([...new Set(stored.DailyQualityReport.map((row) => row.dimensionType))].sort(), [
'application',
'channel',
'drainage',
'signature',
]);
const appRow = stored.DailyReconciliationReport.find((row) => row.applicationId === 'a1');
assert.deepEqual(
[appRow.submittedUnits, appRow.sentUnits, appRow.successUnits, appRow.failedUnits, appRow.unknownUnits],
[7, 7, 5, 1, 1],
);
for (const table of tables) {
const dates = await pool.query(`SELECT DISTINCT "reportDate"::text AS date FROM "${table}" ORDER BY date`);
assert.deepEqual(
dates.rows.map((row) => row.date),
days,
);
}
});
await check('cross-day retry cost, segment priority, legacy fallback and final-only revenue', async () => {
const appRow = (await snapshot(days[0])).DailyProfitReport.find((row) => row.dimensionId === 'a1');
assert.deepEqual(
[appRow.revenueCents, appRow.costCents, appRow.profitCents, appRow.profitRateBps],
[4000, 800, 3200, 8000],
);
const dayFourChannel = (await snapshot(days[0])).DailyProfitReport.find((row) => row.dimensionId === 'c1');
assert.deepEqual([dayFourChannel.revenueCents, dayFourChannel.costCents], [1000, 400]);
const retryChannel = (await snapshot(days[1])).DailyProfitReport.find((row) => row.dimensionId === 'c2');
assert.deepEqual([retryChannel.revenueCents, retryChannel.costCents], [3000, 400]);
const nextApp = (await snapshot(days[1])).DailyProfitReport.find((row) => row.dimensionId === 'a1');
assert.deepEqual([nextApp.revenueCents, nextApp.costCents, nextApp.submittedUnits], [1000, 100, 1]);
});
await check('repeated refresh is idempotent', async () => {
const before = await Promise.all(days.map((date) => snapshot(date)));
await service.refreshRollingWindow(now);
assert.deepEqual(await Promise.all(days.map((date) => snapshot(date))), before);
});
await check('real SQL failure rolls back all three tables and later days still refresh', async () => {
const before = await snapshot(days[0], true);
const later = await snapshot(days[1], true);
await pool.query('UPDATE "SmsMessageRecord" SET "unitPrice"=1100 WHERE id=\'cross\'');
await installFailureTrigger("RAISE EXCEPTION 'intentional isolated report failure';");
await assertPartialFailure();
assert.deepEqual(await snapshot(days[0], true), before);
assert.notDeepEqual(await snapshot(days[1], true), later);
await removeFailureTrigger();
await service.refreshRollingWindow(now);
const recovered = (await snapshot(days[0])).DailyProfitReport.find((row) => row.dimensionId === 'a1');
assert.equal(recovered.revenueCents, 4300);
await pool.query('UPDATE "SmsMessageRecord" SET "unitPrice"=1000 WHERE id=\'cross\'');
await service.refreshRollingWindow(now);
});
await check('database transaction day lock protects prior reports and permits retry', async () => {
const before = await snapshot(days[0], true);
const holder = await pool.connect();
try {
await holder.query('BEGIN');
await holder.query('SELECT pg_advisory_xact_lock($1::integer, 20260904)', [0x434d5052]);
await assertPartialFailure();
assert.deepEqual(await snapshot(days[0], true), before);
} finally {
await holder.query('ROLLBACK');
holder.release();
}
assert.deepEqual(await service.refreshRollingWindow(now), { refreshedDates: days });
});
await check('day transaction exceeding the old five-second limit completes within the new budget', async () => {
await installFailureTrigger(
'IF NEW."dimensionType" = \'application\' AND NEW."dimensionId" = \'a1\' THEN PERFORM pg_sleep(5.2); END IF;',
);
try {
const start = performance.now();
await service.refreshBusinessDay({
key: days[0],
reportDate: new Date('2026-09-04T00:00:00Z'),
startAt: new Date('2026-09-03T16:00:00Z'),
endAt: new Date('2026-09-04T16:00:00Z'),
});
slowDayElapsedMs = Math.round(performance.now() - start);
assert.ok(slowDayElapsedMs >= 5200);
} finally {
await removeFailureTrigger();
}
});
await check('bounded real statement timeout rolls back and recovers', async () => {
const before = await snapshot(days[0], true);
await installFailureTrigger('PERFORM pg_sleep(0.3);');
process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS = '200';
try {
await assertPartialFailure();
assert.deepEqual(await snapshot(days[0], true), before);
} finally {
delete process.env.REPORT_REFRESH_TRANSACTION_TIMEOUT_MS;
await removeFailureTrigger();
}
assert.deepEqual(await service.refreshRollingWindow(now), { refreshedDates: days });
});
await check('real ReportsController HTTP reads match PostgreSQL filtering pagination and exports', async () => {
// Harness exposes only the real reporting controller on an ephemeral loopback
// port. Full application authentication and UI are outside this integration.
class ReportTestModule {}
Module({ controllers: [ReportsController], providers: [{ provide: ReportsService, useValue: service }] })(
ReportTestModule,
);
app = await NestFactory.create(ReportTestModule, { logger: false });
app.use((_request, response, next) => {
response.app.set('json replacer', (_key, value) => (typeof value === 'bigint' ? Number(value) : value));
next();
});
await app.listen(0, '127.0.0.1');
const base = await app.getUrl();
const read = async (path) => {
const response = await fetch(`${base}/admin/reports/${path}`);
assert.equal(response.status, 200);
return response.json();
};
const recon = await read('reconciliation?dateFrom=2026-09-04&dateTo=2026-09-04&tenantId=t1&pageSize=1');
assert.equal(recon.total, 1);
assert.equal(recon.items[0].applicationId, 'a1');
assert.deepEqual(recon.summary, {
submittedUnits: 7,
sentUnits: 7,
unknownUnits: 1,
successUnits: 5,
failedUnits: 1,
});
const profit = await read('profit?dateFrom=2026-09-04&dateTo=2026-09-04&dimensionType=application&tenantId=t1');
assert.deepEqual([profit.summary.revenueCents, profit.summary.costCents], [4000, 800]);
assert.equal('refundCents' in profit.items[0], false);
for (const dimension of ['application', 'channel', 'signature', 'drainage']) {
const result = await read(`quality?dateFrom=2026-09-04&dateTo=2026-09-04&dimensionType=${dimension}&pageSize=1`);
const expected = await pool.query(
'SELECT count(*)::integer AS total, sum("sentUnits")::integer AS sent FROM "DailyQualityReport" WHERE "reportDate"=\'2026-09-04\' AND "dimensionType"=$1',
[dimension],
);
assert.equal(result.total, expected.rows[0].total);
assert.equal(result.summary.sentUnits, expected.rows[0].sent);
assert.equal(result.items.length, 1);
}
const empty = await read('reconciliation?tenantId=missing');
assert.equal(empty.total, 0);
for (const report of ['reconciliation', 'profit', 'quality']) {
const response = await fetch(
`${base}/admin/reports/${report}/export?dateFrom=2026-09-04&dateTo=2026-09-04&tenantId=t1`,
);
assert.equal(response.status, 200);
assert.match(response.headers.get('content-type'), /text\/csv/);
assert.ok((await response.text()).split('\n').length > 1);
}
});
console.log(
JSON.stringify(
{
passed: checks.length,
checks,
sourceMessages: messages.length,
sourceSubmits: submits.length,
reportDates: days,
slowDayElapsedMs,
expectedApplicationDayFour: { revenue: 4000, cost: 800, profit: 3200 },
schemaCleanup: 'performed in finally',
environment: 'isolated local PostgreSQL; no application or messaging lifecycle',
},
null,
2,
),
);
} finally {
if (app) await app.close();
service.onModuleDestroy();
await prisma.$disconnect();
if (schemaCreated) await admin.query(`DROP SCHEMA "${schema}" CASCADE`);
await admin.end();
}