fix: restore daily operations quality metrics

This commit is contained in:
hectorzhao
2026-07-24 18:13:22 +08:00
parent 91f04f5288
commit e12bdf010e
10 changed files with 364 additions and 65 deletions
@@ -94,6 +94,11 @@ export class AdminOperationsController {
return this.operations.statistics({ tenantId, groupBy });
}
@Get('send-quality')
sendQuality(@Query('date') date?: string) {
return this.operations.sendQuality(date);
}
@Get('audit-logs')
auditLogs(
@Query('tenantId') tenantId?: string,
@@ -2,6 +2,7 @@ import { OperationsService } from './operations.service';
function createPrismaMock() {
return {
$queryRaw: jest.fn().mockResolvedValue([]),
user: {
findFirst: jest.fn().mockResolvedValue({ tenantId: 'tenant-1' }),
},
@@ -391,6 +392,50 @@ describe('OperationsService', () => {
});
});
it('returns real daily channel and signature quality for the selected Shanghai date', async () => {
const prisma = createPrismaMock();
prisma.$queryRaw
.mockResolvedValueOnce([{
channelId: 'channel-1',
channelName: '通道一',
total: 5,
successCount: 3,
unknownCount: 1,
failureCount: 1,
successRate: 60,
unknownRate: 20,
failureRate: 20,
averageArrivalMs: 1200,
}])
.mockResolvedValueOnce([{
id: 'signature-1:plain',
signatureId: 'signature-1',
signatureName: '【测试签名】',
tenantId: 'tenant-1',
tenantName: '租户A',
hasDrainage: false,
total: 5,
successCount: 3,
unknownCount: 1,
failureCount: 1,
successRate: 60,
averageArrivalMs: 1200,
}]);
const service = new OperationsService(prisma as never);
await expect(service.sendQuality('2026-07-24')).resolves.toEqual({
date: '2026-07-24',
channels: [expect.objectContaining({ channelId: 'channel-1', total: 5, successRate: 60 })],
signatures: [expect.objectContaining({ signatureId: 'signature-1', signatureName: '【测试签名】', hasDrainage: false })],
});
expect(prisma.$queryRaw).toHaveBeenCalledTimes(2);
});
it('rejects invalid send quality dates', async () => {
const service = new OperationsService(createPrismaMock() as never);
await expect(service.sendQuality('2026-02-31')).rejects.toThrow('统计日期无效');
});
it('returns trace details and reconciliation diffs', async () => {
const prisma = createPrismaMock();
const service = new OperationsService(prisma as never);
+157 -1
View File
@@ -1,4 +1,4 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { moneyToNumber } from '../common/money';
@@ -354,6 +354,135 @@ export class OperationsService {
});
}
async sendQuality(date?: string) {
const day = qualityBusinessDay(date);
const [channels, signatures] = await Promise.all([
this.prisma.$queryRaw<Array<{
channelId: string;
channelName: string;
total: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
unknownRate: number;
failureRate: number;
averageArrivalMs: number | null;
}>>(Prisma.sql`
WITH base AS (
SELECT
submit."channelId" AS channel_id,
channel.name AS channel_name,
receipt."deliveredAt" AS delivered_at,
failed_receipt."failedAt" AS failed_at,
CASE
WHEN receipt."deliveredAt" >= COALESCE(submit."submittedAt", submit."createdAt")
THEN EXTRACT(EPOCH FROM (receipt."deliveredAt" - COALESCE(submit."submittedAt", submit."createdAt"))) * 1000
END AS arrival_ms
FROM "SmsSubmitRecord" submit
JOIN "SmsChannel" channel ON channel.id = submit."channelId"
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS "deliveredAt"
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
) receipt ON TRUE
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS "failedAt"
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'undelivered'
) failed_receipt ON TRUE
WHERE submit."submitStatus" = 'accepted'
AND COALESCE(submit."submittedAt", submit."createdAt") >= ${day.startAt}
AND COALESCE(submit."submittedAt", submit."createdAt") < ${day.endAt}
)
SELECT
channel_id AS "channelId",
MAX(channel_name) AS "channelName",
COUNT(*)::integer AS total,
COUNT(*) FILTER (WHERE delivered_at IS NOT NULL)::integer AS "successCount",
COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NULL)::integer AS "unknownCount",
COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NOT NULL)::integer AS "failureCount",
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivered_at IS NOT NULL) * 100.0 / COUNT(*), 1)::double precision END AS "successRate",
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NULL) * 100.0 / COUNT(*), 1)::double precision END AS "unknownRate",
CASE WHEN COUNT(*) = 0 THEN 0 ELSE ROUND(COUNT(*) FILTER (WHERE delivered_at IS NULL AND failed_at IS NOT NULL) * 100.0 / COUNT(*), 1)::double precision END AS "failureRate",
ROUND(AVG(arrival_ms) FILTER (WHERE arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
FROM base
GROUP BY channel_id
ORDER BY COUNT(*) DESC, channel_id
`),
this.prisma.$queryRaw<Array<{
id: string;
signatureId: string;
signatureName: string;
tenantId: string;
tenantName: string;
hasDrainage: boolean;
total: number;
successCount: number;
unknownCount: number;
failureCount: number;
successRate: number;
averageArrivalMs: number | null;
}>>(Prisma.sql`
WITH base AS (
SELECT
message."signatureId" AS signature_id,
(message."drainageInfoId" IS NOT NULL) AS has_drainage,
message.status,
message."receiptStatus" AS receipt_status,
CASE
WHEN (message.status = 'delivered' OR message."receiptStatus" = 'delivered')
AND message."submittedAt" IS NOT NULL
AND message."deliveredAt" >= message."submittedAt"
THEN EXTRACT(EPOCH FROM (message."deliveredAt" - message."submittedAt")) * 1000
END AS arrival_ms
FROM "SmsMessageRecord" message
WHERE message."signatureId" IS NOT NULL
AND message."queuedAt" >= ${day.startAt}
AND message."queuedAt" < ${day.endAt}
)
SELECT
signature.id || ':' || CASE WHEN base.has_drainage THEN 'drainage' ELSE 'plain' END AS id,
signature.id AS "signatureId",
signature.name AS "signatureName",
tenant.id AS "tenantId",
tenant.name AS "tenantName",
base.has_drainage AS "hasDrainage",
COUNT(*) FILTER (WHERE COALESCE(base.status, '') <> 'rejected')::integer AS total,
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')::integer AS "successCount",
COUNT(*) FILTER (
WHERE COALESCE(base.status, '') <> 'rejected'
AND NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND NOT (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "unknownCount",
COUNT(*) FILTER (
WHERE NOT (COALESCE(base.status = 'delivered', false) OR COALESCE(base.receipt_status = 'delivered', false))
AND (COALESCE(base.status IN ('submit_failed', 'failed', 'timeout'), false) OR COALESCE(base.receipt_status = 'undelivered', false))
)::integer AS "failureCount",
CASE
WHEN COUNT(*) FILTER (WHERE COALESCE(base.status, '') <> 'rejected') = 0 THEN 0
ELSE ROUND(
COUNT(*) FILTER (WHERE base.status = 'delivered' OR base.receipt_status = 'delivered')
* 100.0
/ COUNT(*) FILTER (WHERE COALESCE(base.status, '') <> 'rejected'),
1
)::double precision
END AS "successRate",
ROUND(AVG(base.arrival_ms) FILTER (WHERE base.arrival_ms IS NOT NULL))::integer AS "averageArrivalMs"
FROM base
JOIN "SmsSignature" signature ON signature.id = base.signature_id
JOIN "Tenant" tenant ON tenant.id = signature."tenantId"
GROUP BY signature.id, signature.name, tenant.id, tenant.name, base.has_drainage
ORDER BY "successCount" DESC, total DESC, signature.name
`),
]);
return { date: day.key, channels, signatures };
}
async auditLogs(query: { tenantId?: string; userId?: string; page?: number; pageSize?: number }) {
const page = positiveInteger(query.page, 1);
const pageSize = Math.min(100, positiveInteger(query.pageSize, 20));
@@ -955,6 +1084,33 @@ function endOfShanghaiDay(value: string) {
return new Date(`${value}T23:59:59.999+08:00`);
}
function qualityBusinessDay(value?: string) {
const key = value || shanghaiDateKey();
if (!/^\d{4}-\d{2}-\d{2}$/.test(key)) {
throw new BadRequestException('统计日期格式必须为 YYYY-MM-DD');
}
const startAt = startOfShanghaiDay(key);
if (Number.isNaN(startAt.getTime()) || shanghaiDateKey(startAt) !== key) {
throw new BadRequestException('统计日期无效');
}
return {
key,
startAt,
endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1000),
};
}
function shanghaiDateKey(value = new Date()) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(value);
const byType = new Map(parts.map((part) => [part.type, part.value]));
return `${byType.get('year')}-${byType.get('month')}-${byType.get('day')}`;
}
function normalizeGroupBy(groupBy?: string) {
if (groupBy === 'tenant' || groupBy === 'tenantId') {
return 'tenantId';