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],