feat: add report material workflows and gateway safeguards
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user