From 3a1b0b65bd87e2aadf4f5b4146671daf879f5e10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Fri, 12 Jun 2026 03:53:19 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E9=80=9A=E7=9F=A5=20?= =?UTF-8?q?outbox=20=E6=89=8B=E5=8A=A8=E9=87=8D=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../outbox/notification-outbox.service.ts | 16 +++ ...fication-outbox-manual-retry.controller.ts | 32 ++++++ ...otification-outbox-manual-retry.service.ts | 99 +++++++++++++++++++ src/notifications/wecom/wecom.module.ts | 4 + 4 files changed, 151 insertions(+) create mode 100644 src/notifications/wecom/notification-outbox-manual-retry.controller.ts create mode 100644 src/notifications/wecom/notification-outbox-manual-retry.service.ts diff --git a/src/notifications/outbox/notification-outbox.service.ts b/src/notifications/outbox/notification-outbox.service.ts index ef2d6db..1be8b21 100644 --- a/src/notifications/outbox/notification-outbox.service.ts +++ b/src/notifications/outbox/notification-outbox.service.ts @@ -147,6 +147,22 @@ export class NotificationOutboxService { return messages.map((message) => this.toSummary(message)); } + async getMessage( + id: string, + ): Promise { + 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( input: ListRetryableNotificationOutboxInput, ): Promise { diff --git a/src/notifications/wecom/notification-outbox-manual-retry.controller.ts b/src/notifications/wecom/notification-outbox-manual-retry.controller.ts new file mode 100644 index 0000000..d969fdb --- /dev/null +++ b/src/notifications/wecom/notification-outbox-manual-retry.controller.ts @@ -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 { + return this.manualRetryService.retryMessage(id, user); + } +} diff --git a/src/notifications/wecom/notification-outbox-manual-retry.service.ts b/src/notifications/wecom/notification-outbox-manual-retry.service.ts new file mode 100644 index 0000000..48c34ae --- /dev/null +++ b/src/notifications/wecom/notification-outbox-manual-retry.service.ts @@ -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 { + 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 { + return { + status: message.status, + attemptCount: message.attemptCount, + nextAttemptAt: message.nextAttemptAt, + lastError: message.lastError, + channel: message.channel, + template: message.template, + deployRunId: message.deployRunId, + }; + } +} diff --git a/src/notifications/wecom/wecom.module.ts b/src/notifications/wecom/wecom.module.ts index bbef3f2..d0a6a5b 100644 --- a/src/notifications/wecom/wecom.module.ts +++ b/src/notifications/wecom/wecom.module.ts @@ -4,14 +4,18 @@ import { Module } from '@nestjs/common'; import { NotificationOutboxModule } from '../outbox/notification-outbox.module'; 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 { WeComClient } from './wecom.client'; @Module({ imports: [NotificationOutboxModule], + controllers: [NotificationOutboxManualRetryController], providers: [ WeComClient, DeployNotificationService, + NotificationOutboxManualRetryService, NotificationOutboxRetryService, ], exports: [WeComClient, DeployNotificationService],