fix: validate signatures and restore report metrics

This commit is contained in:
hectorzhao
2026-07-27 20:51:14 +08:00
parent 04a497c791
commit 94aeacd3a2
19 changed files with 438 additions and 42 deletions
+65
View File
@@ -51,6 +51,7 @@ function createPrismaMock() {
reportFields: [{ code: 'license', name: '营业执照', fieldType: 'file', required: true, description: null, sortOrder: 1, status: 'active' }],
};
return {
$queryRaw: jest.fn().mockResolvedValue([]),
$transaction: jest.fn((callback) => callback({
smsChannel: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })),
@@ -218,6 +219,70 @@ describe('ChannelsService', () => {
});
});
it('adds real today delivery statistics and the latest successful send time to report tasks', async () => {
const prisma = createPrismaMock();
const task = { id: 'report-task-1', tenantId: 'tenant-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' };
const taskChannel = { id: 'channel-1', code: 'CMPP-A', name: '主通道' };
prisma.channelSignatureReportTask.findMany.mockResolvedValue([
{ ...task, reportType: 'signature', drainageItemId: null, channel: taskChannel },
{ ...task, id: 'report-task-2', reportType: 'drainage', drainageItemId: 'drain-1', channel: taskChannel },
]);
prisma.$queryRaw.mockResolvedValue([
{
channelId: 'channel-1',
signatureId: 'sig-1',
drainageInfoId: null,
total: 5,
acceptedCount: 4,
submitFailureCount: 1,
successCount: 2,
unknownCount: 1,
failureCount: 1,
lastSuccessfulSentAt: new Date('2026-07-27T01:00:00.000Z'),
},
{
channelId: 'channel-1',
signatureId: 'sig-1',
drainageInfoId: 'drain-1',
total: 3,
acceptedCount: 3,
submitFailureCount: 0,
successCount: 3,
unknownCount: 0,
failureCount: 0,
lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'),
},
]);
const service = new ChannelsService(prisma as never);
const result = await service.listReportTasks(undefined, undefined, 'channel-1');
expect(result[0]).toEqual(expect.objectContaining({
deliveryStats: {
total: 8,
acceptedCount: 7,
submitFailureCount: 1,
submitFailureRate: 12.5,
successCount: 5,
successRate: 71.4,
unknownCount: 1,
unknownRate: 14.3,
failureCount: 1,
failureRate: 14.3,
},
lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'),
}));
expect(result[1]).toEqual(expect.objectContaining({
deliveryStats: expect.objectContaining({
total: 3,
submitFailureCount: 0,
successCount: 3,
successRate: 100,
}),
lastSuccessfulSentAt: new Date('2026-07-27T02:00:00.000Z'),
}));
});
it('changes channel report status and recomputes the signature summary atomically', async () => {
const prisma = createPrismaMock();
const tx = {
+163 -2
View File
@@ -1154,12 +1154,116 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
});
}
listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) {
return this.prisma.channelSignatureReportTask.findMany({
async listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) {
const tasks = await this.prisma.channelSignatureReportTask.findMany({
where: { tenantId, status, channelId, reportType },
include: { signature: true, channel: true, drainageInfo: true },
orderBy: { createdAt: 'desc' },
});
if (tasks.length === 0) {
return tasks;
}
const channelIds = [...new Set(tasks.map((task) => task.channelId))];
const signatureIds = [...new Set(tasks.map((task) => task.signatureId))];
const day = currentShanghaiDayRange();
const rows = await this.prisma.$queryRaw<ChannelReportDeliveryRow[]>(Prisma.sql`
WITH base AS (
SELECT
submit."channelId" AS channel_id,
message."signatureId" AS signature_id,
message."drainageInfoId" AS drainage_info_id,
submit."submitStatus" AS submit_status,
COALESCE(submit."submittedAt", submit."createdAt") AS attempted_at,
CASE
WHEN segment_summary.segment_count > 0
AND segment_summary.delivered_count = segment_summary.segment_count
THEN segment_summary.completed_at
WHEN segment_summary.segment_count = 0 THEN delivered_receipt.delivered_at
END AS successful_at,
CASE
WHEN submit."submitStatus" <> 'accepted' THEN 'submit_failed'
WHEN segment_summary.segment_count > 0 AND segment_summary.failure_count > 0 THEN 'failure'
WHEN segment_summary.segment_count > 0
AND segment_summary.delivered_count = segment_summary.segment_count THEN 'success'
WHEN segment_summary.segment_count = 0 AND failed_receipt.failed_at IS NOT NULL THEN 'failure'
WHEN segment_summary.segment_count = 0 AND delivered_receipt.delivered_at IS NOT NULL THEN 'success'
ELSE 'unknown'
END AS delivery_status
FROM "SmsSubmitRecord" submit
JOIN "SmsMessageRecord" message ON message.id = submit."messageRecordId"
LEFT JOIN LATERAL (
SELECT
COUNT(*)::integer AS segment_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'delivered')::integer AS delivered_count,
COUNT(*) FILTER (WHERE segment."receiptStatus" = 'undelivered')::integer AS failure_count,
MAX(segment."deliveredAt") FILTER (WHERE segment."receiptStatus" = 'delivered') AS completed_at
FROM "SmsMessageSegmentAudit" segment
WHERE segment."submitRecordId" = submit.id
) segment_summary ON TRUE
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS delivered_at
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'delivered'
) delivered_receipt ON TRUE
LEFT JOIN LATERAL (
SELECT MIN(receipt."deliveredAt") AS failed_at
FROM "SmsReceiptRecord" receipt
WHERE receipt."gatewayMessageId" = submit."gatewayMessageId"
AND receipt."channelId" = submit."channelId"
AND receipt."receiptStatus" = 'undelivered'
) failed_receipt ON TRUE
WHERE submit."submitStatus" IN ('accepted', 'rejected', 'timeout')
AND submit."channelId" IN (${Prisma.join(channelIds)})
AND message."signatureId" IN (${Prisma.join(signatureIds)})
)
SELECT
channel_id AS "channelId",
signature_id AS "signatureId",
drainage_info_id AS "drainageInfoId",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
)::integer AS total,
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
AND submit_status = 'accepted'
)::integer AS "acceptedCount",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
AND delivery_status = 'submit_failed'
)::integer AS "submitFailureCount",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
AND delivery_status = 'success'
)::integer AS "successCount",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
AND delivery_status = 'unknown'
)::integer AS "unknownCount",
COUNT(*) FILTER (
WHERE attempted_at >= ${day.startAt} AND attempted_at < ${day.endAt}
AND delivery_status = 'failure'
)::integer AS "failureCount",
MAX(successful_at) FILTER (WHERE delivery_status = 'success') AS "lastSuccessfulSentAt"
FROM base
GROUP BY channel_id, signature_id, drainage_info_id
`);
return tasks.map((task) => {
const taskRows = rows.filter((row) => (
row.channelId === task.channelId
&& row.signatureId === task.signatureId
&& ((task.reportType ?? 'signature') === 'signature' || row.drainageInfoId === task.drainageItemId)
));
const deliveryStats = summarizeChannelReportDelivery(taskRows);
return {
...task,
deliveryStats,
lastSuccessfulSentAt: latestDate(taskRows.map((row) => row.lastSuccessfulSentAt)),
};
});
}
async createReportTask(data: CreateReportTaskDto) {
@@ -2143,6 +2247,63 @@ function deriveReceiptStatus(rowCount: number, successCount: number, failedCount
return 'completed';
}
type ChannelReportDeliveryRow = {
channelId: string;
signatureId: string;
drainageInfoId: string | null;
total: number;
acceptedCount: number;
submitFailureCount: number;
successCount: number;
unknownCount: number;
failureCount: number;
lastSuccessfulSentAt: Date | null;
};
function summarizeChannelReportDelivery(rows: ChannelReportDeliveryRow[]) {
const total = sumReportDelivery(rows, 'total');
const acceptedCount = sumReportDelivery(rows, 'acceptedCount');
const submitFailureCount = sumReportDelivery(rows, 'submitFailureCount');
const successCount = sumReportDelivery(rows, 'successCount');
const unknownCount = sumReportDelivery(rows, 'unknownCount');
const failureCount = sumReportDelivery(rows, 'failureCount');
return {
total,
acceptedCount,
submitFailureCount,
submitFailureRate: percentage(submitFailureCount, total),
successCount,
successRate: percentage(successCount, acceptedCount),
unknownCount,
unknownRate: percentage(unknownCount, acceptedCount),
failureCount,
failureRate: percentage(failureCount, acceptedCount),
};
}
function sumReportDelivery(rows: ChannelReportDeliveryRow[], key: keyof Pick<
ChannelReportDeliveryRow,
'total' | 'acceptedCount' | 'submitFailureCount' | 'successCount' | 'unknownCount' | 'failureCount'
>) {
return rows.reduce((total, row) => total + Number(row[key] ?? 0), 0);
}
function percentage(count: number, total: number) {
return total > 0 ? Number(((count * 100) / total).toFixed(1)) : 0;
}
function latestDate(values: Array<Date | null>) {
const timestamps = values.filter((value): value is Date => Boolean(value)).map((value) => value.getTime());
return timestamps.length > 0 ? new Date(Math.max(...timestamps)) : null;
}
function currentShanghaiDayRange(now = new Date()) {
const shifted = new Date(now.getTime() + 8 * 60 * 60 * 1_000);
const localDate = shifted.toISOString().slice(0, 10);
const startAt = new Date(`${localDate}T00:00:00+08:00`);
return { startAt, endAt: new Date(startAt.getTime() + 24 * 60 * 60 * 1_000) };
}
function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) {
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {