feat: add report material workflows and gateway safeguards

This commit is contained in:
hectorzhao
2026-07-15 18:23:48 +08:00
parent cf9f4ce4cd
commit 7091a8bed4
41 changed files with 3606 additions and 71 deletions
@@ -1,5 +1,7 @@
import { Body, Controller, Get, Param, Post, Query, Res } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CurrentSessionUserId } from '../auth/current-session-user.decorator';
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
import { SendChainService } from '../send-chain/send-chain.service';
import { OperationsService } from './operations.service';
@@ -145,8 +147,13 @@ export class AdminOperationsController {
}
@Post('gateway-submit-dead-letters/:id/requeue')
requeueGatewaySubmitDeadLetter(@Param('id') id: string) {
return this.sendChain.requeueGatewaySubmitDeadLetter(id);
@RequireRecentAuthentication()
requeueGatewaySubmitDeadLetter(
@Param('id') id: string,
@Body() body: { confirmedNotSubmitted?: boolean; reason?: string },
@CurrentSessionUserId() operatorId?: string,
) {
return this.sendChain.requeueGatewaySubmitDeadLetter(id, { ...body, operatorId });
}
@Get('downstream-deliveries')
+17 -1
View File
@@ -77,11 +77,15 @@ function createPrismaMock() {
status: 'pending',
failureCode: 'SUBMIT_PROCESSING_FAILED',
failureMessage: 'network down',
rawPayload: '{"upstream":{"passwordCipher":"secret"}}',
commandPayload: { upstream: { account: 'sp', passwordCipher: 'secret' } },
tenant: { name: '租户A' },
application: { name: '应用A' },
channel: { code: 'CMPP-A' },
}]),
count: jest.fn().mockResolvedValue(1),
groupBy: jest.fn().mockResolvedValue([{ status: 'pending', _count: { _all: 1 } }]),
findFirst: jest.fn().mockResolvedValue({ createdAt: new Date('2026-07-08T12:00:00.000Z') }),
},
gatewayDownstreamRecoveryStatus: {
findMany: jest.fn().mockResolvedValue([{
@@ -408,10 +412,22 @@ describe('OperationsService', () => {
page: 1,
pageSize: 10,
})).resolves.toEqual({
items: [expect.objectContaining({ id: 'dead-1', status: 'pending' })],
items: [expect.objectContaining({
id: 'dead-1',
status: 'pending',
rawPayloadAvailable: true,
commandPayload: { upstream: { account: 'sp', passwordCipher: '[REDACTED]' } },
})],
total: 1,
page: 1,
pageSize: 10,
summary: {
pending: 1,
requeueing: 0,
requeued: 0,
resolved: 0,
oldestPendingAt: new Date('2026-07-08T12:00:00.000Z'),
},
});
expect(prisma.gatewaySubmitDeadLetter.findMany).toHaveBeenCalledWith({
+82 -4
View File
@@ -372,11 +372,10 @@ export class OperationsService {
async listGatewaySubmitDeadLetters(query: GatewaySubmitDeadLetterQuery) {
const page = Math.max(1, Number(query.page ?? 1));
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
const where: Prisma.GatewaySubmitDeadLetterWhereInput = {
const baseWhere: Prisma.GatewaySubmitDeadLetterWhereInput = {
tenantId: query.tenantId,
applicationId: query.applicationId,
channelId: query.channelId,
status: query.status && query.status !== 'all' ? query.status : undefined,
OR: query.keyword ? [
{ streamMessageId: { contains: query.keyword } },
{ traceId: { contains: query.keyword } },
@@ -386,7 +385,11 @@ export class OperationsService {
{ failureMessage: { contains: query.keyword } },
] : undefined,
};
const [items, total] = await Promise.all([
const where: Prisma.GatewaySubmitDeadLetterWhereInput = {
...baseWhere,
status: query.status && query.status !== 'all' ? query.status : undefined,
};
const [items, total, statusGroups, oldestPending] = await Promise.all([
this.prisma.gatewaySubmitDeadLetter.findMany({
where,
include: { tenant: true, application: true, channel: true },
@@ -395,8 +398,39 @@ export class OperationsService {
take: pageSize,
}),
this.prisma.gatewaySubmitDeadLetter.count({ where }),
this.prisma.gatewaySubmitDeadLetter.groupBy({
by: ['status'],
where: baseWhere,
_count: { _all: true },
}),
this.prisma.gatewaySubmitDeadLetter.findFirst({
where: { ...baseWhere, status: 'pending' },
orderBy: { createdAt: 'asc' },
select: { createdAt: true },
}),
]);
return { items, total, page, pageSize };
const statusCounts = new Map(statusGroups.map((item) => [item.status, item._count._all]));
const messageIds = items.map((item) => item.messageId).filter((value): value is string => Boolean(value));
const messageStates = messageIds.length > 0
? await this.prisma.smsMessageRecord.findMany({
where: { messageId: { in: messageIds } },
select: { messageId: true, status: true, submitStatus: true, receiptStatus: true, phoneNumber: true, content: true },
})
: [];
const messageStateById = new Map(messageStates.map((item) => [item.messageId, item]));
return {
items: items.map((item) => sanitizeGatewaySubmitException(item, item.messageId ? messageStateById.get(item.messageId) : undefined)),
total,
page,
pageSize,
summary: {
pending: statusCounts.get('pending') ?? 0,
requeueing: statusCounts.get('requeueing') ?? 0,
requeued: statusCounts.get('requeued') ?? 0,
resolved: statusCounts.get('resolved') ?? 0,
oldestPendingAt: oldestPending?.createdAt ?? null,
},
};
}
async listDownstreamDeliveries(query: DownstreamDeliveryQuery) {
@@ -1119,3 +1153,47 @@ function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { t
userAgent: log.userAgent ?? '',
};
}
function sanitizeGatewaySubmitException(
item: Prisma.GatewaySubmitDeadLetterGetPayload<{ include: { tenant: true; application: true; channel: true } }>,
messageState?: { status: string; submitStatus: string | null; receiptStatus: string | null; phoneNumber: string; content: string },
) {
const { rawPayload, commandPayload, tenant, application, channel, ...record } = item;
return {
...record,
tenant: tenant ? { id: tenant.id, name: tenant.name, code: tenant.code, status: tenant.status } : null,
application: application ? { id: application.id, tenantId: application.tenantId, name: application.name, status: application.status } : null,
channel: channel ? {
id: channel.id,
code: channel.code,
name: channel.name,
status: channel.status,
carrier: channel.carrier,
sendRegion: channel.sendRegion,
rateLimitPerSecond: channel.rateLimitPerSecond,
} : null,
rawPayloadAvailable: Boolean(rawPayload),
commandPayload: redactGatewayCommandValue(commandPayload),
messageState: messageState ?? null,
};
}
function redactGatewayCommandValue(value: Prisma.JsonValue | null): Prisma.JsonValue | null {
if (Array.isArray(value)) {
return value.map((item) => redactGatewayCommandValue(item));
}
if (value && typeof value === 'object') {
const redacted: Record<string, Prisma.JsonValue | null> = {};
for (const [key, child] of Object.entries(value)) {
const normalizedKey = key.toLowerCase();
redacted[key] = [
'password', 'passwordcipher', 'secret', 'secrethash', 'authsource',
'token', 'apikey', 'accesskey', 'secretkey',
].includes(normalizedKey)
? '[REDACTED]'
: redactGatewayCommandValue(child as Prisma.JsonValue);
}
return redacted;
}
return value;
}