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