feat: 支持通知 outbox 手动重发
This commit is contained in:
@@ -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,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],
|
||||||
|
|||||||
Reference in New Issue
Block a user