feat: 实现发送质量监控与报备状态消息通知
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-06 19:22:49 +08:00
parent 69e3d7368d
commit 457319e627
66 changed files with 6992 additions and 489 deletions
@@ -46,8 +46,10 @@ export class AdminOperationsController {
@Query('hasDrainage') hasDrainage?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('monitorSnapshotId') monitorSnapshotId?: string,
) {
return this.operations.listMessagesPage({
monitorSnapshotId,
tenantId,
applicationId,
channelId,
@@ -80,8 +82,10 @@ export class AdminOperationsController {
@Query('status') status: string | undefined,
@Query('hasDrainage') hasDrainage: string | undefined,
@Res() response: DownloadResponse,
@Query('monitorSnapshotId') monitorSnapshotId?: string,
) {
const exported = await this.operations.exportMessages({
monitorSnapshotId,
tenantId,
applicationId,
channelId,
@@ -105,25 +109,37 @@ export class AdminOperationsController {
}
@Get('message-segment-audits')
messageSegmentAudits(
@Query('messageId') messageId?: string,
@Query('messageRecordId') messageRecordId?: string,
) {
messageSegmentAudits(@Query('messageId') messageId?: string, @Query('messageRecordId') messageRecordId?: string) {
return this.operations.listMessageSegmentAudits({ messageId, messageRecordId });
}
@Get('uplink-messages')
listUplinkMessages(@Query('tenantId') tenantId?: string, @Query('channelId') channelId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
listUplinkMessages(
@Query('tenantId') tenantId?: string,
@Query('channelId') channelId?: string,
@Query('phoneNumber') phoneNumber?: string,
@Query('keyword') keyword?: string,
@Query('startTime') startTime?: string,
@Query('endTime') endTime?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return page || pageSize
? this.operations.listUplinkMessagesPage({ tenantId, channelId, phoneNumber, keyword, startTime, endTime, page: Number(page), pageSize: Number(pageSize) })
? this.operations.listUplinkMessagesPage({
tenantId,
channelId,
phoneNumber,
keyword,
startTime,
endTime,
page: Number(page),
pageSize: Number(pageSize),
})
: this.operations.listUplinkMessages({ tenantId, channelId, phoneNumber, keyword, startTime, endTime });
}
@Post('uplink-messages/:id/claim')
claimUplinkMatchCandidate(
@Param('id') id: string,
@Body() body: { candidateId?: string; operatorId?: string },
) {
claimUplinkMatchCandidate(@Param('id') id: string, @Body() body: { candidateId?: string; operatorId?: string }) {
return this.sendChain.claimUplinkMatchCandidate(id, String(body.candidateId ?? ''), body.operatorId);
}
@@ -231,10 +247,7 @@ export class AdminOperationsController {
}
@Post('gateway-submit-dead-letters/:id/resolve')
resolveGatewaySubmitDeadLetter(
@Param('id') id: string,
@CurrentSessionUserId() operatorId?: string,
) {
resolveGatewaySubmitDeadLetter(@Param('id') id: string, @CurrentSessionUserId() operatorId?: string) {
return this.sendChain.resolveGatewaySubmitDeadLetter(id, operatorId);
}
@@ -381,16 +394,23 @@ export class AdminOperationsController {
@Body() body: { previewToken?: string; reason?: string; ratePerSecond?: number; consecutiveFailureLimit?: number },
@CurrentSessionUserId() operatorId?: string,
) {
return this.sendChain.createDownstreamRequeueTask({
previewToken: body.previewToken ?? '',
reason: body.reason ?? '',
ratePerSecond: body.ratePerSecond,
consecutiveFailureLimit: body.consecutiveFailureLimit,
}, operatorId);
return this.sendChain.createDownstreamRequeueTask(
{
previewToken: body.previewToken ?? '',
reason: body.reason ?? '',
ratePerSecond: body.ratePerSecond,
consecutiveFailureLimit: body.consecutiveFailureLimit,
},
operatorId,
);
}
@Get('downstream-requeue-tasks')
listDownstreamRequeueTasks(@Query('status') status?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
listDownstreamRequeueTasks(
@Query('status') status?: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.sendChain.listDownstreamRequeueTasks({ status, page: Number(page), pageSize: Number(pageSize) });
}
@@ -407,7 +427,12 @@ export class AdminOperationsController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.sendChain.listDownstreamRequeueTaskItems(id, { status, keyword, page: Number(page), pageSize: Number(pageSize) });
return this.sendChain.listDownstreamRequeueTaskItems(id, {
status,
keyword,
page: Number(page),
pageSize: Number(pageSize),
});
}
@Post('downstream-requeue-tasks/:id/:action')
@@ -441,7 +466,18 @@ export class AdminSystemLogsController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.protocolLogs.list({ protocol, direction, eventType, status, keyword, range, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) });
return this.protocolLogs.list({
protocol,
direction,
eventType,
status,
keyword,
range,
createdAtFrom,
createdAtTo,
page: Number(page),
pageSize: Number(pageSize),
});
}
@Get()
@@ -457,11 +493,34 @@ export class AdminSystemLogsController {
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
) {
return this.operations.systemLogs({ tenantId, userId, keyword, level, module, range, createdAtFrom, createdAtTo, page: Number(page), pageSize: Number(pageSize) });
return this.operations.systemLogs({
tenantId,
userId,
keyword,
level,
module,
range,
createdAtFrom,
createdAtTo,
page: Number(page),
pageSize: Number(pageSize),
});
}
@Post('exports')
export(@Body() body: { tenantId?: string; userId?: string; keyword?: string; level?: string; module?: string; range?: string; createdAtFrom?: string; createdAtTo?: string }) {
export(
@Body()
body: {
tenantId?: string;
userId?: string;
keyword?: string;
level?: string;
module?: string;
range?: string;
createdAtFrom?: string;
createdAtTo?: string;
},
) {
return this.operations.exportSystemLogs(body);
}
}
@@ -1,6 +1,7 @@
// Stable controller/query contracts extracted in R2.
export interface MessageQuery {
monitorSnapshotId?: string;
tenantId?: string;
applicationId?: string;
channelId?: string;
+64 -17
View File
@@ -1,27 +1,32 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomUUID } from 'node:crypto';
import { moneyToNumber } from '../../common/money';
import { PrismaService } from '../../prisma/prisma.service';
import type { MessageQuery, TraceQuery, OperationLogQuery, GatewaySubmitDeadLetterQuery, DownstreamDeliveryQuery, DownstreamDeliveryDashboardQuery, DownstreamRecoveryStatusQuery, MessageSegmentAuditQuery, SignatureQualityQuery } from '../operations.contracts';
import { messageWhere, recognizedCarrierValues, carrierWhere, startOfShanghaiDay, endOfShanghaiDay, qualityBusinessDay, shanghaiDateKey, normalizeGroupBy, returnedTransactionWhere, createdAtRange, downstreamAlertPendingMinutes, downstreamAlertRecentFailedHours, downstreamAlertWindows, downstreamAlertWhere, stalledPendingWhere, downstreamDeliveryScopedWhere, parseDateBoundary, downstreamRecoveryStatusWhere, escapeCsvCell, formatCsvDate, formatExportTimestamp, clientApplicationView, clientReceiptView, clientMessageView, clientBatchTaskView, clientUplinkView, clientAccountView, clientRechargeView, summarizeMessageGroups, groupDownstreamByType, groupDownstreamByApplication, positiveInteger, operationLogLevelWhere, normalizeOperationLog, sanitizeGatewaySubmitException, redactGatewayCommandValue } from '../operations.helpers';
import type { MessageQuery } from '../operations.contracts';
import {
messageWhere,
escapeCsvCell,
formatExportTimestamp,
clientMessageView,
clientBatchTaskView,
} from '../operations.helpers';
// R2 messages query domain. Method bodies are preserved byte-for-byte from the facade baseline.
// Message queries share the same bounded monitor sample filter for lists and exports.
export class OperationsMessageQueries {
constructor(private readonly prisma: PrismaService) {}
listBatchTasks(query: { tenantId?: string; status?: string }) {
listBatchTasks(query: { tenantId?: string; status?: string }) {
return this.prisma.smsBatchTask.findMany({
where: { tenantId: query.tenantId, status: query.status, sourceType: 'client' },
include: { apiRequests: true },
orderBy: { createdAt: 'desc' },
});
}
async listClientBatchTasks(query: { tenantId?: string; status?: string }) {
async listClientBatchTasks(query: { tenantId?: string; status?: string }) {
const items = await this.listBatchTasks(query);
return items.map(clientBatchTaskView);
}
listMessages(query: MessageQuery) {
listMessages(query: MessageQuery) {
return this.prisma.smsMessageRecord.findMany({
where: messageWhere(query),
include: {
@@ -38,10 +43,34 @@ listMessages(query: MessageQuery) {
orderBy: { queuedAt: 'desc' },
});
}
async listMessagesPage(query: MessageQuery) {
private async messageFilter(query: MessageQuery) {
const where = messageWhere(query);
if (query.monitorSnapshotId) {
const snapshot = await this.prisma.sendingMonitorSnapshot.findUnique({ where: { id: query.monitorSnapshotId } });
if (
!snapshot ||
(query.tenantId &&
(snapshot.dimensions as { tenantId?: string }).tenantId &&
(snapshot.dimensions as { tenantId?: string }).tenantId !== query.tenantId)
)
throw new NotFoundException('监控快照不存在');
if (snapshot.windowFrom.getTime() < Date.now() - 72 * 3600000)
throw new BadRequestException('该窗口已超过72小时样本保留期,历史快照仍可查询');
where.monitorFacts = {
some: {
kind: snapshot.type === 'industry' ? 'attempt' : 'business',
dimensionKey: snapshot.dimensionKey,
submittedAt: { gte: snapshot.windowFrom, lt: snapshot.evaluationAt },
...(snapshot.type === 'verification' ? { verification: true } : {}),
},
};
}
return where;
}
async listMessagesPage(query: MessageQuery) {
const page = Math.max(1, Math.floor(Number(query.page) || 1));
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 25)));
const where = messageWhere(query);
const where = await this.messageFilter(query);
const [items, total] = await Promise.all([
this.prisma.smsMessageRecord.findMany({
where,
@@ -75,7 +104,7 @@ async listMessagesPage(query: MessageQuery) {
return { items, total, page, pageSize };
}
async getMessage(id: string) {
async getMessage(id: string) {
const item = await this.prisma.smsMessageRecord.findUnique({
where: { id },
include: {
@@ -121,9 +150,10 @@ async getMessage(id: string) {
if (!item) throw new NotFoundException('Message record not found');
return item;
}
async exportMessages(query: MessageQuery) {
async exportMessages(query: MessageQuery) {
const where = await this.messageFilter(query);
const items = await this.prisma.smsMessageRecord.findMany({
where: messageWhere(query),
where,
select: {
messageId: true,
queuedAt: true,
@@ -144,7 +174,22 @@ async exportMessages(query: MessageQuery) {
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
});
const rows = [
['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '是否含引流', '回执时间', '短信内容'],
[
'消息编号',
'企业',
'应用',
'提交时间',
'手机号',
'地区',
'运营商',
'计费条数',
'金额',
'通道',
'状态',
'是否含引流',
'回执时间',
'短信内容',
],
...items.map((item) => [
item.messageId,
item.tenant?.name ?? '',
@@ -156,7 +201,9 @@ async exportMessages(query: MessageQuery) {
String(item.billingUnits),
String(moneyToNumber(item.amountCents)),
item.channel?.name ?? '',
item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '') ? 'submit_failed' : item.status,
item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '')
? 'submit_failed'
: item.status,
item.hasDrainageContent === true ? '是' : item.hasDrainageContent === false ? '否' : '未检测',
item.deliveredAt?.toISOString() ?? '',
item.content,
@@ -167,11 +214,11 @@ async exportMessages(query: MessageQuery) {
content: rows.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(',')).join('\n'),
};
}
async listClientMessages(query: MessageQuery) {
async listClientMessages(query: MessageQuery) {
const items = await this.listMessages(query);
return items.map(clientMessageView);
}
async listClientMessagesPage(query: MessageQuery) {
async listClientMessagesPage(query: MessageQuery) {
const result = await this.listMessagesPage(query);
return { ...result, items: result.items.map(clientMessageView) };
}