feat: 增加通知 outbox 重试器
- notifications: 支持失败 outbox 按配置 nextAttemptAt 重试并达到上限转 dead - settings: 暴露通知 outbox 重试相关环境变量说明
This commit is contained in:
@@ -70,6 +70,11 @@ export const envSchema = z.object({
|
||||
NOTIFICATION_WEBHOOK_URL: optionalUrl,
|
||||
FEISHU_WEBHOOK_URL: optionalUrl,
|
||||
WECOM_WEBHOOK_URL: optionalUrl,
|
||||
NOTIFICATION_OUTBOX_RETRY_ENABLED: booleanFromEnv,
|
||||
NOTIFICATION_OUTBOX_RETRY_INTERVAL_MS: positiveIntegerFromEnv(30000),
|
||||
NOTIFICATION_OUTBOX_RETRY_DELAY_MS: positiveIntegerFromEnv(60000),
|
||||
NOTIFICATION_OUTBOX_MAX_ATTEMPTS: positiveIntegerFromEnv(3),
|
||||
NOTIFICATION_OUTBOX_BATCH_SIZE: positiveIntegerFromEnv(10),
|
||||
LLM_BASE_URL: optionalUrl,
|
||||
LLM_API_KEY: optionalString,
|
||||
LLM_MODEL: optionalString,
|
||||
|
||||
@@ -10,6 +10,7 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
import {
|
||||
CompleteNotificationOutboxInput,
|
||||
CreateNotificationOutboxInput,
|
||||
ListRetryableNotificationOutboxInput,
|
||||
NotificationOutboxMessage,
|
||||
NotificationOutboxStatus,
|
||||
} from './notification-outbox.types';
|
||||
@@ -20,6 +21,7 @@ const statusToPrisma: Record<
|
||||
> = {
|
||||
sent: OutboxStatus.SENT,
|
||||
failed: OutboxStatus.FAILED,
|
||||
dead: OutboxStatus.DEAD,
|
||||
};
|
||||
|
||||
const statusFromPrisma: Record<OutboxStatus, NotificationOutboxStatus> = {
|
||||
@@ -120,8 +122,8 @@ export class NotificationOutboxService {
|
||||
attemptCount: {
|
||||
increment: 1,
|
||||
},
|
||||
lastError: input.lastError,
|
||||
nextAttemptAt: input.nextAttemptAt,
|
||||
lastError: input.lastError ?? null,
|
||||
nextAttemptAt: input.nextAttemptAt ?? null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -145,6 +147,50 @@ export class NotificationOutboxService {
|
||||
return messages.map((message) => this.toSummary(message));
|
||||
}
|
||||
|
||||
async listRetryableMessages(
|
||||
input: ListRetryableNotificationOutboxInput,
|
||||
): Promise<NotificationOutboxMessage[]> {
|
||||
const prisma = this.databaseClient();
|
||||
|
||||
if (!prisma) {
|
||||
return this.memoryMessages
|
||||
.filter((message) => {
|
||||
if (message.status !== 'failed') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (message.attemptCount >= input.maxAttempts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!message.nextAttemptAt) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return new Date(message.nextAttemptAt).getTime() <= input.now.getTime();
|
||||
})
|
||||
.slice(0, input.take);
|
||||
}
|
||||
|
||||
const messages = await prisma.outboxMessage.findMany({
|
||||
where: {
|
||||
status: OutboxStatus.FAILED,
|
||||
attemptCount: {
|
||||
lt: input.maxAttempts,
|
||||
},
|
||||
nextAttemptAt: {
|
||||
lte: input.now,
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
nextAttemptAt: 'asc',
|
||||
},
|
||||
take: input.take,
|
||||
});
|
||||
|
||||
return messages.map((message) => this.toSummary(message));
|
||||
}
|
||||
|
||||
private toSummary(message: OutboxMessage): NotificationOutboxMessage {
|
||||
return {
|
||||
id: message.id,
|
||||
|
||||
@@ -27,7 +27,13 @@ export type CreateNotificationOutboxInput = {
|
||||
};
|
||||
|
||||
export type CompleteNotificationOutboxInput = {
|
||||
status: 'sent' | 'failed';
|
||||
status: 'sent' | 'failed' | 'dead';
|
||||
lastError?: string;
|
||||
nextAttemptAt?: Date;
|
||||
};
|
||||
|
||||
export type ListRetryableNotificationOutboxInput = {
|
||||
now: Date;
|
||||
maxAttempts: number;
|
||||
take: number;
|
||||
};
|
||||
|
||||
@@ -33,6 +33,21 @@ type DeployNotificationHealth = {
|
||||
message?: string;
|
||||
};
|
||||
|
||||
type DeployNotificationPayloadSummary = {
|
||||
event?: string;
|
||||
title?: string;
|
||||
deployRunId?: string;
|
||||
projectKey?: string;
|
||||
environment?: string;
|
||||
ref?: string;
|
||||
status?: string;
|
||||
operator?: string;
|
||||
jenkinsQueueId?: string | number;
|
||||
jenkinsBuildNumber?: number;
|
||||
failureSummary?: string;
|
||||
publicUrl?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一封装发布单通知,保证 webhook 只存在于后端配置边界内。
|
||||
*/
|
||||
@@ -138,6 +153,68 @@ export class DeployNotificationService {
|
||||
}
|
||||
}
|
||||
|
||||
async retryOutboxMessage(
|
||||
message: NotificationOutboxMessage,
|
||||
): Promise<DeployNotificationResult> {
|
||||
const health = this.healthSummary();
|
||||
const channel = health.provider ?? 'none';
|
||||
|
||||
if (health.status === 'not_configured') {
|
||||
await this.completeOutboxFailure(
|
||||
message,
|
||||
health.message ?? '通知平台尚未配置',
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'skipped',
|
||||
channel,
|
||||
reason: health.message ?? '通知平台尚未配置',
|
||||
outboxMessageId: message.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (health.status === 'unavailable' || !health.provider) {
|
||||
await this.completeOutboxFailure(
|
||||
message,
|
||||
health.message ?? '通知平台不可用',
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'failed',
|
||||
channel,
|
||||
reason: health.message ?? '通知平台不可用',
|
||||
outboxMessageId: message.id,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sendOutboxPayloadByProvider(health.provider, message);
|
||||
await this.notificationOutboxService.completeMessage(message.id, {
|
||||
status: 'sent',
|
||||
});
|
||||
|
||||
return {
|
||||
status: 'sent',
|
||||
channel: health.provider,
|
||||
outboxMessageId: message.id,
|
||||
};
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error
|
||||
? redactSensitive({ message: error.message }).message
|
||||
: '未知通知错误';
|
||||
|
||||
await this.completeOutboxFailure(message, reason);
|
||||
|
||||
return {
|
||||
status: 'failed',
|
||||
channel: health.provider,
|
||||
reason,
|
||||
outboxMessageId: message.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async createOutboxMessage(
|
||||
run: DeployRunSummary,
|
||||
event: DeployNotificationEvent,
|
||||
@@ -156,9 +233,25 @@ export class DeployNotificationService {
|
||||
message: NotificationOutboxMessage,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
const nextAttemptCount = message.attemptCount + 1;
|
||||
const maxAttempts = this.config.get('NOTIFICATION_OUTBOX_MAX_ATTEMPTS', {
|
||||
infer: true,
|
||||
});
|
||||
const retryEnabled = this.config.get('NOTIFICATION_OUTBOX_RETRY_ENABLED', {
|
||||
infer: true,
|
||||
});
|
||||
const retryDelayMs = this.config.get('NOTIFICATION_OUTBOX_RETRY_DELAY_MS', {
|
||||
infer: true,
|
||||
});
|
||||
const exhausted = nextAttemptCount >= maxAttempts;
|
||||
|
||||
await this.notificationOutboxService.completeMessage(message.id, {
|
||||
status: 'failed',
|
||||
status: exhausted ? 'dead' : 'failed',
|
||||
lastError: reason,
|
||||
nextAttemptAt:
|
||||
retryEnabled && !exhausted
|
||||
? new Date(Date.now() + retryDelayMs)
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -239,6 +332,62 @@ export class DeployNotificationService {
|
||||
);
|
||||
}
|
||||
|
||||
private async sendOutboxPayloadByProvider(
|
||||
provider: DeployNotificationProvider,
|
||||
message: NotificationOutboxMessage,
|
||||
): Promise<void> {
|
||||
const payload = this.toNotificationPayloadSummary(message.payloadSummary);
|
||||
const title = payload.title || this.titleFor(this.eventFromTemplate(message));
|
||||
const markdown = this.markdownForPayload(payload, title);
|
||||
const plainText = this.plainTextForPayload(payload, title);
|
||||
|
||||
if (provider === 'wecom') {
|
||||
await this.weComClient.sendMarkdown({
|
||||
title,
|
||||
markdown,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (provider === 'feishu') {
|
||||
await this.postWebhook(
|
||||
this.config.get('FEISHU_WEBHOOK_URL', { infer: true }) ?? '',
|
||||
{
|
||||
msg_type: 'text',
|
||||
content: {
|
||||
text: `${title}\n${plainText}`,
|
||||
},
|
||||
},
|
||||
provider,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await this.postWebhook(
|
||||
this.config.get('NOTIFICATION_WEBHOOK_URL', { infer: true }) ?? '',
|
||||
{
|
||||
event: payload.event ?? message.template,
|
||||
title,
|
||||
markdown,
|
||||
run: {
|
||||
id: payload.deployRunId ?? message.deployRunId,
|
||||
projectKey: payload.projectKey,
|
||||
environment: payload.environment,
|
||||
ref: payload.ref,
|
||||
status: payload.status,
|
||||
operator: payload.operator,
|
||||
jenkinsQueueId: payload.jenkinsQueueId,
|
||||
jenkinsBuildNumber: payload.jenkinsBuildNumber,
|
||||
failureSummary: payload.failureSummary,
|
||||
},
|
||||
publicUrl:
|
||||
payload.publicUrl ??
|
||||
this.config.get('DEVOPS_PUBLIC_URL', { infer: true }),
|
||||
},
|
||||
provider,
|
||||
);
|
||||
}
|
||||
|
||||
private async postWebhook(
|
||||
webhookUrl: string,
|
||||
body: unknown,
|
||||
@@ -358,4 +507,95 @@ export class DeployNotificationService {
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private eventFromTemplate(
|
||||
message: NotificationOutboxMessage,
|
||||
): DeployNotificationEvent {
|
||||
const event = message.template.replace(/^deploy\./, '');
|
||||
|
||||
if (
|
||||
event === 'created' ||
|
||||
event === 'queued' ||
|
||||
event === 'succeeded' ||
|
||||
event === 'failed' ||
|
||||
event === 'canceled' ||
|
||||
event === 'retried'
|
||||
) {
|
||||
return event;
|
||||
}
|
||||
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
private toNotificationPayloadSummary(
|
||||
value: Record<string, unknown> | undefined,
|
||||
): DeployNotificationPayloadSummary {
|
||||
return {
|
||||
event: this.optionalString(value?.event),
|
||||
title: this.optionalString(value?.title),
|
||||
deployRunId: this.optionalString(value?.deployRunId),
|
||||
projectKey: this.optionalString(value?.projectKey),
|
||||
environment: this.optionalString(value?.environment),
|
||||
ref: this.optionalString(value?.ref),
|
||||
status: this.optionalString(value?.status),
|
||||
operator: this.optionalString(value?.operator),
|
||||
jenkinsQueueId:
|
||||
typeof value?.jenkinsQueueId === 'string' ||
|
||||
typeof value?.jenkinsQueueId === 'number'
|
||||
? value.jenkinsQueueId
|
||||
: undefined,
|
||||
jenkinsBuildNumber:
|
||||
typeof value?.jenkinsBuildNumber === 'number'
|
||||
? value.jenkinsBuildNumber
|
||||
: undefined,
|
||||
failureSummary: this.optionalString(value?.failureSummary),
|
||||
publicUrl: this.optionalString(value?.publicUrl),
|
||||
};
|
||||
}
|
||||
|
||||
private markdownForPayload(
|
||||
payload: DeployNotificationPayloadSummary,
|
||||
title: string,
|
||||
): string {
|
||||
return [
|
||||
`> 事件:${title}`,
|
||||
payload.projectKey ? `> 项目:${payload.projectKey}` : undefined,
|
||||
payload.environment ? `> 环境:${payload.environment}` : undefined,
|
||||
payload.ref ? `> Ref:${payload.ref}` : undefined,
|
||||
payload.status ? `> 状态:${payload.status}` : undefined,
|
||||
payload.operator ? `> 操作者:${payload.operator}` : undefined,
|
||||
payload.deployRunId ? `> Run ID:${payload.deployRunId}` : undefined,
|
||||
payload.jenkinsQueueId
|
||||
? `> Jenkins Queue:${payload.jenkinsQueueId}`
|
||||
: undefined,
|
||||
payload.jenkinsBuildNumber
|
||||
? `> Jenkins Build:#${payload.jenkinsBuildNumber}`
|
||||
: undefined,
|
||||
payload.failureSummary ? `> 失败摘要:${payload.failureSummary}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private plainTextForPayload(
|
||||
payload: DeployNotificationPayloadSummary,
|
||||
title: string,
|
||||
): string {
|
||||
return [
|
||||
`事件:${title}`,
|
||||
payload.projectKey ? `项目:${payload.projectKey}` : undefined,
|
||||
payload.environment ? `环境:${payload.environment}` : undefined,
|
||||
payload.ref ? `Ref:${payload.ref}` : undefined,
|
||||
payload.status ? `状态:${payload.status}` : undefined,
|
||||
payload.operator ? `操作者:${payload.operator}` : undefined,
|
||||
payload.deployRunId ? `Run ID:${payload.deployRunId}` : undefined,
|
||||
payload.failureSummary ? `失败摘要:${payload.failureSummary}` : undefined,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
private optionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* 通知 outbox 重试器复用当前 provider 投递边界,后续可迁移到 BullMQ worker。
|
||||
*/
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { AuditService } from '../../audit/audit.service';
|
||||
import { redactSensitive } from '../../common/security/redact-sensitive';
|
||||
import { EnvConfig } from '../../config/env.schema';
|
||||
import { NotificationOutboxService } from '../outbox/notification-outbox.service';
|
||||
import { DeployNotificationService } from './deploy-notification.service';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationOutboxRetryService
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
private timer: ReturnType<typeof setInterval> | undefined;
|
||||
private running = false;
|
||||
|
||||
constructor(
|
||||
private readonly config: ConfigService<EnvConfig, true>,
|
||||
private readonly notificationOutboxService: NotificationOutboxService,
|
||||
private readonly deployNotificationService: DeployNotificationService,
|
||||
private readonly auditService: AuditService,
|
||||
) {}
|
||||
|
||||
onModuleInit(): void {
|
||||
if (
|
||||
!this.config.get('NOTIFICATION_OUTBOX_RETRY_ENABLED', { infer: true })
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = this.config.get(
|
||||
'NOTIFICATION_OUTBOX_RETRY_INTERVAL_MS',
|
||||
{ infer: true },
|
||||
);
|
||||
|
||||
this.timer = setInterval(() => {
|
||||
void this.runOnce();
|
||||
}, intervalMs);
|
||||
void this.runOnce();
|
||||
}
|
||||
|
||||
onModuleDestroy(): void {
|
||||
if (this.timer) {
|
||||
clearInterval(this.timer);
|
||||
this.timer = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async runOnce(): Promise<void> {
|
||||
if (this.running) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
|
||||
try {
|
||||
const maxAttempts = this.config.get('NOTIFICATION_OUTBOX_MAX_ATTEMPTS', {
|
||||
infer: true,
|
||||
});
|
||||
const batchSize = this.config.get('NOTIFICATION_OUTBOX_BATCH_SIZE', {
|
||||
infer: true,
|
||||
});
|
||||
const messages = await this.notificationOutboxService.listRetryableMessages(
|
||||
{
|
||||
now: new Date(),
|
||||
maxAttempts,
|
||||
take: batchSize,
|
||||
},
|
||||
);
|
||||
let sentCount = 0;
|
||||
let failedCount = 0;
|
||||
let skippedCount = 0;
|
||||
|
||||
for (const message of messages) {
|
||||
const result =
|
||||
await this.deployNotificationService.retryOutboxMessage(message);
|
||||
|
||||
if (result.status === 'sent') {
|
||||
sentCount += 1;
|
||||
} else if (result.status === 'skipped') {
|
||||
skippedCount += 1;
|
||||
} else {
|
||||
failedCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'NOTIFICATION_OUTBOX_RETRY_TICK_COMPLETED',
|
||||
resourceType: 'notification_outbox',
|
||||
after: {
|
||||
totalCandidates: messages.length,
|
||||
sentCount,
|
||||
skippedCount,
|
||||
failedCount,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : '通知 outbox 自动重试失败';
|
||||
|
||||
await this.auditService.record({
|
||||
action: 'NOTIFICATION_OUTBOX_RETRY_TICK_FAILED',
|
||||
resourceType: 'notification_outbox',
|
||||
after: {
|
||||
message: redactSensitive({ message }).message,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationOutboxModule } from '../outbox/notification-outbox.module';
|
||||
import { DeployNotificationService } from './deploy-notification.service';
|
||||
import { NotificationOutboxRetryService } from './notification-outbox-retry.service';
|
||||
import { WeComClient } from './wecom.client';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationOutboxModule],
|
||||
providers: [WeComClient, DeployNotificationService],
|
||||
providers: [
|
||||
WeComClient,
|
||||
DeployNotificationService,
|
||||
NotificationOutboxRetryService,
|
||||
],
|
||||
exports: [WeComClient, DeployNotificationService],
|
||||
})
|
||||
export class WeComModule {}
|
||||
|
||||
@@ -76,6 +76,11 @@ const INTEGRATION_DEFINITIONS: IntegrationDefinition[] = [
|
||||
'FEISHU_WEBHOOK_URL',
|
||||
'NOTIFICATION_WEBHOOK_URL',
|
||||
'DEVOPS_PUBLIC_URL',
|
||||
'NOTIFICATION_OUTBOX_RETRY_ENABLED',
|
||||
'NOTIFICATION_OUTBOX_RETRY_INTERVAL_MS',
|
||||
'NOTIFICATION_OUTBOX_RETRY_DELAY_MS',
|
||||
'NOTIFICATION_OUTBOX_MAX_ATTEMPTS',
|
||||
'NOTIFICATION_OUTBOX_BATCH_SIZE',
|
||||
],
|
||||
note: '当前不强制配置机器人;后续可按 provider 接入企微、飞书或通用 webhook。',
|
||||
variableHelp: {
|
||||
@@ -102,6 +107,26 @@ const INTEGRATION_DEFINITIONS: IntegrationDefinition[] = [
|
||||
DEVOPS_PUBLIC_URL: variableHelp('通知消息中跳转回 DevOps 平台的公开访问地址。', {
|
||||
example: 'https://devops.mrzhan.top',
|
||||
}),
|
||||
NOTIFICATION_OUTBOX_RETRY_ENABLED: variableHelp(
|
||||
'是否启用轻量通知 outbox 重试器,失败通知会按 nextAttemptAt 自动重投。',
|
||||
{ example: 'true' },
|
||||
),
|
||||
NOTIFICATION_OUTBOX_RETRY_INTERVAL_MS: variableHelp(
|
||||
'通知 outbox 重试扫描间隔,单位毫秒。',
|
||||
{ example: '30000' },
|
||||
),
|
||||
NOTIFICATION_OUTBOX_RETRY_DELAY_MS: variableHelp(
|
||||
'单条通知投递失败后的下次重试延迟,单位毫秒。',
|
||||
{ example: '60000' },
|
||||
),
|
||||
NOTIFICATION_OUTBOX_MAX_ATTEMPTS: variableHelp(
|
||||
'单条通知最大投递次数,达到后标记为 dead,避免无限重试。',
|
||||
{ example: '3' },
|
||||
),
|
||||
NOTIFICATION_OUTBOX_BATCH_SIZE: variableHelp(
|
||||
'每轮 outbox 重试最多处理的消息数量。',
|
||||
{ example: '10' },
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user