fix: validate signatures and restore report metrics
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1071,7 +1071,7 @@ describe('SmsConfigService', () => {
|
||||
expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'admin_create_approved', statusAfter: 'approved' }) });
|
||||
});
|
||||
|
||||
it.each(['未带括号', '[英文括号]', '【【重复括号】】', '【 】'])(
|
||||
it.each(['未带括号', '[英文括号]', '【【重复括号】】'])(
|
||||
'rejects a signature name without exactly one complete Chinese black bracket pair: %s',
|
||||
async (name) => {
|
||||
const prisma = createPrismaMock();
|
||||
@@ -1092,6 +1092,23 @@ describe('SmsConfigService', () => {
|
||||
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
'【带 空格】',
|
||||
' 【外部空格】',
|
||||
'【不换\n行】',
|
||||
'【零宽\u200B字符】',
|
||||
'【不换行空格\u00A0】',
|
||||
'【字节顺序标记\uFEFF】',
|
||||
'【变体选择符\uFE0F】',
|
||||
])('rejects spaces, controls, and invisible characters in signature names: %s', async (name) => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.createSignature({ tenantId: 'tenant-1', name }))
|
||||
.rejects.toThrow('短信签名不能包含空格、换行或不可见字符');
|
||||
expect(prisma.smsSignature.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('updates enterprise signature drainage info through the admin API path', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
@@ -1990,9 +1990,12 @@ function normalizeSmsSignature(name: string) {
|
||||
}
|
||||
|
||||
function validateCompleteSmsSignature(name: string) {
|
||||
const value = name.trim();
|
||||
const value = name;
|
||||
if (/[\p{White_Space}\p{Cc}\p{Default_Ignorable_Code_Point}]/u.test(value)) {
|
||||
throw new BadRequestException('短信签名不能包含空格、换行或不可见字符');
|
||||
}
|
||||
const match = value.match(/^【([^【】]+)】$/);
|
||||
if (!match || match[1] !== match[1].trim()) {
|
||||
if (!match) {
|
||||
throw new BadRequestException('短信签名必须包含完整中文黑括号,例如:【某某科技】');
|
||||
}
|
||||
return value;
|
||||
|
||||
Reference in New Issue
Block a user