feat: 支持通知 outbox 手动重发

This commit is contained in:
湛兮
2026-06-12 03:53:19 +08:00
parent 0d60820c93
commit 3a1b0b65bd
4 changed files with 151 additions and 0 deletions
@@ -147,6 +147,22 @@ export class NotificationOutboxService {
return messages.map((message) => this.toSummary(message)); return messages.map((message) => this.toSummary(message));
} }
async getMessage(
id: string,
): Promise<NotificationOutboxMessage | undefined> {
const prisma = this.databaseClient();
if (!prisma) {
return this.memoryMessages.find((message) => message.id === id);
}
const message = await prisma.outboxMessage.findUnique({
where: { id },
});
return message ? this.toSummary(message) : undefined;
}
async listRetryableMessages( async listRetryableMessages(
input: ListRetryableNotificationOutboxInput, input: ListRetryableNotificationOutboxInput,
): Promise<NotificationOutboxMessage[]> { ): Promise<NotificationOutboxMessage[]> {
@@ -0,0 +1,32 @@
/**
* 通知 outbox 手动重发入口只允许超级管理员触发真实 provider 投递。
*/
import { Controller, Param, Post, UseGuards } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AdminOnlyMessage } from '../../auth/admin-only-message.decorator';
import { AuthGuard } from '../../auth/auth.guard';
import { AuthenticatedUser } from '../../auth/auth.types';
import { CurrentUser } from '../../auth/current-user.decorator';
import { PasswordChangeGuard } from '../../auth/password-change.guard';
import { SuperAdminGuard } from '../../auth/super-admin.guard';
import { NotificationOutboxMessage } from '../outbox/notification-outbox.types';
import { NotificationOutboxManualRetryService } from './notification-outbox-manual-retry.service';
@ApiTags('通知 outbox')
@Controller('notification-outbox')
@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard)
@AdminOnlyMessage('无权限操作通知 outbox')
export class NotificationOutboxManualRetryController {
constructor(
private readonly manualRetryService: NotificationOutboxManualRetryService,
) {}
@Post(':id/retry')
@ApiOkResponse({ description: '手动重发失败或已终止的通知 outbox。' })
async retryMessage(
@Param('id') id: string,
@CurrentUser() user: AuthenticatedUser,
): Promise<NotificationOutboxMessage> {
return this.manualRetryService.retryMessage(id, user);
}
}
@@ -0,0 +1,99 @@
/**
* 通知 outbox 手动重发服务用于管理员在配置 webhook 后补投历史失败消息。
*/
import { Injectable } from '@nestjs/common';
import { AuditService } from '../../audit/audit.service';
import { AuthenticatedUser } from '../../auth/auth.types';
import { AppError } from '../../common/errors/app-error';
import { redactSensitive } from '../../common/security/redact-sensitive';
import { NotificationOutboxService } from '../outbox/notification-outbox.service';
import { NotificationOutboxMessage } from '../outbox/notification-outbox.types';
import { DeployNotificationService } from './deploy-notification.service';
@Injectable()
export class NotificationOutboxManualRetryService {
constructor(
private readonly notificationOutboxService: NotificationOutboxService,
private readonly deployNotificationService: DeployNotificationService,
private readonly auditService: AuditService,
) {}
async retryMessage(
id: string,
operator: AuthenticatedUser,
): Promise<NotificationOutboxMessage> {
const message = await this.notificationOutboxService.getMessage(id);
if (!message) {
throw new AppError('RESOURCE_NOT_FOUND', '通知 outbox 记录不存在', 404, {
id,
});
}
if (message.status !== 'failed' && message.status !== 'dead') {
throw new AppError(
'VALIDATION_FAILED',
'只有失败或已终止的通知 outbox 可以手动重发',
400,
{
id,
status: message.status,
},
);
}
try {
const result =
await this.deployNotificationService.retryOutboxMessage(message);
const updated =
(await this.notificationOutboxService.getMessage(id)) ?? message;
await this.auditService.record({
action: `NOTIFICATION_OUTBOX_MANUAL_RETRY_${result.status.toUpperCase()}`,
resourceType: 'notification_outbox',
resourceId: id,
actorId: operator.id,
actorName: operator.account,
before: this.auditSnapshot(message),
after: {
...this.auditSnapshot(updated),
channel: result.channel,
reason: result.reason,
},
});
return updated;
} catch (error) {
const messageText =
error instanceof Error ? error.message : '通知 outbox 手动重发失败';
await this.auditService.record({
action: 'NOTIFICATION_OUTBOX_MANUAL_RETRY_FAILED',
resourceType: 'notification_outbox',
resourceId: id,
actorId: operator.id,
actorName: operator.account,
before: this.auditSnapshot(message),
after: {
message: redactSensitive({ message: messageText }).message,
},
});
throw error;
}
}
private auditSnapshot(
message: NotificationOutboxMessage,
): Record<string, unknown> {
return {
status: message.status,
attemptCount: message.attemptCount,
nextAttemptAt: message.nextAttemptAt,
lastError: message.lastError,
channel: message.channel,
template: message.template,
deployRunId: message.deployRunId,
};
}
}
+4
View File
@@ -4,14 +4,18 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { NotificationOutboxModule } from '../outbox/notification-outbox.module'; import { NotificationOutboxModule } from '../outbox/notification-outbox.module';
import { DeployNotificationService } from './deploy-notification.service'; import { DeployNotificationService } from './deploy-notification.service';
import { NotificationOutboxManualRetryController } from './notification-outbox-manual-retry.controller';
import { NotificationOutboxManualRetryService } from './notification-outbox-manual-retry.service';
import { NotificationOutboxRetryService } from './notification-outbox-retry.service'; import { NotificationOutboxRetryService } from './notification-outbox-retry.service';
import { WeComClient } from './wecom.client'; import { WeComClient } from './wecom.client';
@Module({ @Module({
imports: [NotificationOutboxModule], imports: [NotificationOutboxModule],
controllers: [NotificationOutboxManualRetryController],
providers: [ providers: [
WeComClient, WeComClient,
DeployNotificationService, DeployNotificationService,
NotificationOutboxManualRetryService,
NotificationOutboxRetryService, NotificationOutboxRetryService,
], ],
exports: [WeComClient, DeployNotificationService], exports: [WeComClient, DeployNotificationService],