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
+35 -2
View File
@@ -224,9 +224,12 @@ function createPrismaMock() {
findUnique: jest.fn().mockResolvedValue({
id: 'dead-1',
tenantId: 'tenant-1',
channelId: 'channel-1',
streamMessageId: '1710000000000-0',
submitId: 'SUB-1',
messageId: 'MSG-1',
status: 'pending',
manualRetryCount: 0,
commandPayload: {
schemaVersion: 'v1',
messageType: 'SubmitCommand',
@@ -1517,7 +1520,7 @@ describe('SendChainService', () => {
});
});
it('records gateway submit dead letters and allows manual requeue', async () => {
it('records gateway submit exceptions and safely allows manual requeue', async () => {
const { service, prisma } = createService();
service['publishGatewaySubmitCommand'] = jest.fn().mockResolvedValue('1710000001000-0');
@@ -1551,9 +1554,17 @@ describe('SendChainService', () => {
}),
});
await service.requeueGatewaySubmitDeadLetter('dead-1');
await service.requeueGatewaySubmitDeadLetter('dead-1', {
confirmedNotSubmitted: true,
reason: '确认通道连接失败且运营商未收到该短信',
operatorId: 'user-1',
});
expect(service['publishGatewaySubmitCommand']).toHaveBeenCalledWith(expect.objectContaining({ submitId: 'SUB-1' }));
expect(prisma.gatewaySubmitDeadLetter.updateMany).toHaveBeenCalledWith({
where: { id: 'dead-1', status: 'pending' },
data: { status: 'requeueing' },
});
expect(prisma.gatewaySubmitDeadLetter.update).toHaveBeenCalledWith({
where: { id: 'dead-1' },
data: expect.objectContaining({
@@ -1567,10 +1578,32 @@ describe('SendChainService', () => {
action: 'gateway.submit_dead_letter_requeue',
resource: 'gateway_submit_dead_letter',
resourceId: 'dead-1',
userId: 'user-1',
detail: expect.objectContaining({
reason: '确认通道连接失败且运营商未收到该短信',
confirmedNotSubmitted: true,
}),
}),
});
});
it('blocks submit exception requeue when the upstream result may already be accepted', async () => {
const { service, prisma } = createService();
prisma.smsMessageRecord.findUnique.mockResolvedValueOnce({
id: 'record-1',
messageId: 'MSG-1',
status: 'submitted',
submitStatus: 'accepted',
receiptStatus: null,
});
await expect(service.requeueGatewaySubmitDeadLetter('dead-1', {
confirmedNotSubmitted: true,
reason: '尝试重新发送这条短信',
})).rejects.toThrow('为避免重复发送,禁止重新入队');
expect(service['publishGatewaySubmitCommand']).not.toHaveBeenCalled();
});
it('records gateway downstream recovery statuses', async () => {
const { service, prisma } = createService();
+68 -5
View File
@@ -158,6 +158,12 @@ export interface GatewaySubmitDeadLetterDto {
deadLetteredAt?: string;
}
export interface RequeueGatewaySubmitExceptionDto {
confirmedNotSubmitted?: boolean;
reason?: string;
operatorId?: string;
}
export interface GatewayDownstreamRecoveryStatusDto {
account: string;
gatewayInstanceId?: string;
@@ -1244,15 +1250,69 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
return updated;
}
async requeueGatewaySubmitDeadLetter(id: string) {
async requeueGatewaySubmitDeadLetter(id: string, data: RequeueGatewaySubmitExceptionDto = {}) {
const deadLetter = await this.prisma.gatewaySubmitDeadLetter.findUnique({ where: { id } });
if (!deadLetter) {
throw new NotFoundException('Gateway submit dead letter not found');
throw new NotFoundException('Gateway提交异常记录不存在');
}
if (!deadLetter.commandPayload || typeof deadLetter.commandPayload !== 'object') {
throw new BadRequestException('该死信缺少可重放的 SubmitCommand');
if (deadLetter.status !== 'pending') {
throw new BadRequestException('该提交异常当前状态不允许重新入队');
}
if (!data.confirmedNotSubmitted) {
throw new BadRequestException('请确认运营商未接收该短信后再重新入队');
}
const reason = String(data.reason ?? '').trim();
if (reason.length < 5 || reason.length > 500) {
throw new BadRequestException('请填写5至500字的重新入队原因');
}
if (!deadLetter.commandPayload || !isObjectRecord(deadLetter.commandPayload)) {
throw new BadRequestException('该提交异常缺少可重新入队的SubmitCommand');
}
if (deadLetter.manualRetryCount >= 3) {
throw new BadRequestException('该提交异常已达到人工重新入队次数上限');
}
const message = deadLetter.messageId
? await this.prisma.smsMessageRecord.findUnique({ where: { messageId: deadLetter.messageId } })
: null;
if (message && (
message.submitStatus === 'accepted'
|| ['submitted', 'delivered', 'unknown'].includes(message.status)
|| ['delivered', 'unknown'].includes(message.receiptStatus ?? '')
)) {
throw new BadRequestException('该短信已有成功或不确定的上游结果,为避免重复发送,禁止重新入队');
}
const commandChannelId = String(deadLetter.commandPayload.channelId ?? deadLetter.channelId ?? '').trim();
if (!commandChannelId) {
throw new BadRequestException('该提交异常缺少通道信息');
}
const channel = await this.prisma.smsChannel.findUnique({
where: { id: commandChannelId },
include: { connectionStates: true },
});
if (!channel || channel.status !== 'active') {
throw new BadRequestException('原通道不存在或已停用,不能重新入队');
}
if (!channel.connectionStates.some((state) => state.status === 'connected' && state.currentConnections > 0)) {
throw new BadRequestException('原通道当前没有可用CMPP连接,请先恢复通道');
}
const claimed = await this.prisma.gatewaySubmitDeadLetter.updateMany({
where: { id, status: 'pending' },
data: { status: 'requeueing' },
});
if (claimed.count !== 1) {
throw new BadRequestException('该提交异常已被其他操作处理,请刷新后重试');
}
let retryStreamMessageId: string;
try {
const publishedStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload);
if (!publishedStreamMessageId) {
throw new Error('Gateway提交异常重新入队未返回Stream消息编号');
}
retryStreamMessageId = publishedStreamMessageId;
} catch (error) {
await this.prisma.gatewaySubmitDeadLetter.update({ where: { id }, data: { status: 'pending' } });
throw error;
}
const retryStreamMessageId = await this.publishGatewaySubmitCommand(deadLetter.commandPayload);
const updated = await this.prisma.gatewaySubmitDeadLetter.update({
where: { id },
data: {
@@ -1265,6 +1325,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
await this.prisma.operationLog.create({
data: {
tenantId: updated.tenantId ?? undefined,
userId: data.operatorId,
action: 'gateway.submit_dead_letter_requeue',
resource: 'gateway_submit_dead_letter',
resourceId: updated.id,
@@ -1273,6 +1334,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
retryStreamMessageId,
submitId: updated.submitId,
messageId: updated.messageId,
reason,
confirmedNotSubmitted: true,
},
},
});