feat: 持久化通知 outbox 记录
- notifications: 通知发送前写入 outbox,并在成功、失败或未配置时回写状态 - deploy-runs: 审计通知结果时带上 outboxMessageId - outbox: 新增超级管理员查询最近通知投递摘要接口
This commit is contained in:
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* 发布通知适配服务封装 provider 选择、outbox 记录和后端 webhook 投递。
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { redactSensitive } from '../../common/security/redact-sensitive';
|
||||
import { EnvConfig } from '../../config/env.schema';
|
||||
import { DeployRunSummary } from '../../deploy-runs/deploy-run.types';
|
||||
import { NotificationOutboxService } from '../outbox/notification-outbox.service';
|
||||
import { NotificationOutboxMessage } from '../outbox/notification-outbox.types';
|
||||
import { WeComClient } from './wecom.client';
|
||||
|
||||
export type DeployNotificationEvent =
|
||||
@@ -17,6 +22,7 @@ export type DeployNotificationResult = {
|
||||
status: 'sent' | 'skipped' | 'failed';
|
||||
channel: DeployNotificationProvider | 'none';
|
||||
reason?: string;
|
||||
outboxMessageId?: string;
|
||||
};
|
||||
|
||||
export type DeployNotificationProvider = 'wecom' | 'feishu' | 'generic';
|
||||
@@ -35,6 +41,7 @@ export class DeployNotificationService {
|
||||
constructor(
|
||||
private readonly config: ConfigService<EnvConfig, true>,
|
||||
private readonly weComClient: WeComClient,
|
||||
private readonly notificationOutboxService: NotificationOutboxService,
|
||||
) {}
|
||||
|
||||
healthSummary(): DeployNotificationHealth {
|
||||
@@ -72,39 +79,89 @@ export class DeployNotificationService {
|
||||
event: DeployNotificationEvent,
|
||||
): Promise<DeployNotificationResult> {
|
||||
const health = this.healthSummary();
|
||||
const channel = health.provider ?? 'none';
|
||||
const outboxMessage = await this.createOutboxMessage(run, event, channel);
|
||||
|
||||
if (health.status === 'not_configured') {
|
||||
await this.completeOutboxFailure(
|
||||
outboxMessage,
|
||||
health.message ?? '通知平台尚未配置',
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'skipped',
|
||||
channel: health.provider ?? 'none',
|
||||
channel,
|
||||
reason: health.message ?? '通知平台尚未配置',
|
||||
outboxMessageId: outboxMessage.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (health.status === 'unavailable' || !health.provider) {
|
||||
await this.completeOutboxFailure(
|
||||
outboxMessage,
|
||||
health.message ?? '通知平台不可用',
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'failed',
|
||||
channel: health.provider ?? 'none',
|
||||
channel,
|
||||
reason: health.message ?? '通知平台不可用',
|
||||
outboxMessageId: outboxMessage.id,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await this.sendByProvider(health.provider, run, event);
|
||||
await this.notificationOutboxService.completeMessage(outboxMessage.id, {
|
||||
status: 'sent',
|
||||
});
|
||||
|
||||
return { status: 'sent', channel: health.provider };
|
||||
return {
|
||||
status: 'sent',
|
||||
channel: health.provider,
|
||||
outboxMessageId: outboxMessage.id,
|
||||
};
|
||||
} catch (error) {
|
||||
const reason =
|
||||
error instanceof Error
|
||||
? redactSensitive({ message: error.message }).message
|
||||
: '未知通知错误';
|
||||
|
||||
await this.completeOutboxFailure(outboxMessage, reason);
|
||||
|
||||
return {
|
||||
status: 'failed',
|
||||
channel: health.provider,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? redactSensitive({ message: error.message }).message
|
||||
: '未知通知错误',
|
||||
reason,
|
||||
outboxMessageId: outboxMessage.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private async createOutboxMessage(
|
||||
run: DeployRunSummary,
|
||||
event: DeployNotificationEvent,
|
||||
channel: DeployNotificationProvider | 'none',
|
||||
): Promise<NotificationOutboxMessage> {
|
||||
return this.notificationOutboxService.createMessage({
|
||||
deployRunId: run.id,
|
||||
channel,
|
||||
template: `deploy.${event}`,
|
||||
payload: this.outboxPayloadFor(run, event),
|
||||
idempotencyKey: this.outboxIdempotencyKey(run, event),
|
||||
});
|
||||
}
|
||||
|
||||
private async completeOutboxFailure(
|
||||
message: NotificationOutboxMessage,
|
||||
reason: string,
|
||||
): Promise<void> {
|
||||
await this.notificationOutboxService.completeMessage(message.id, {
|
||||
status: 'failed',
|
||||
lastError: reason,
|
||||
});
|
||||
}
|
||||
|
||||
private resolveProvider(): DeployNotificationProvider | undefined {
|
||||
const provider = this.config
|
||||
.get('NOTIFICATION_PROVIDER', { infer: true })
|
||||
@@ -224,6 +281,46 @@ export class DeployNotificationService {
|
||||
return 'DevOps 发布已创建';
|
||||
}
|
||||
|
||||
private outboxPayloadFor(
|
||||
run: DeployRunSummary,
|
||||
event: DeployNotificationEvent,
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
event: `deploy.${event}`,
|
||||
title: this.titleFor(event),
|
||||
deployRunId: run.id,
|
||||
projectKey: run.projectKey,
|
||||
environment: run.environment,
|
||||
ref: this.safeRefForOutbox(run.ref),
|
||||
status: run.status,
|
||||
operator: run.operator,
|
||||
jenkinsQueueId: run.jenkinsQueueId,
|
||||
jenkinsBuildNumber: run.jenkinsBuildNumber,
|
||||
failureSummary: run.failureSummary,
|
||||
publicUrl: this.config.get('DEVOPS_PUBLIC_URL', { infer: true }),
|
||||
};
|
||||
}
|
||||
|
||||
private outboxIdempotencyKey(
|
||||
run: DeployRunSummary,
|
||||
event: DeployNotificationEvent,
|
||||
): string {
|
||||
return [
|
||||
run.id,
|
||||
`deploy.${event}`,
|
||||
run.status,
|
||||
run.jenkinsBuildNumber ?? run.jenkinsQueueId ?? 'no-jenkins-ref',
|
||||
].join(':');
|
||||
}
|
||||
|
||||
private safeRefForOutbox(ref: string): string {
|
||||
if (/^[a-f0-9]{40}$/i.test(ref)) {
|
||||
return ref.slice(0, 12);
|
||||
}
|
||||
|
||||
return ref.length > 48 ? `${ref.slice(0, 45)}...` : ref;
|
||||
}
|
||||
|
||||
private markdownFor(
|
||||
run: DeployRunSummary,
|
||||
event: DeployNotificationEvent,
|
||||
|
||||
Reference in New Issue
Block a user