feat: 持久化通知 outbox 记录

- notifications: 通知发送前写入 outbox,并在成功、失败或未配置时回写状态

- deploy-runs: 审计通知结果时带上 outboxMessageId

- outbox: 新增超级管理员查询最近通知投递摘要接口
This commit is contained in:
湛兮
2026-06-12 03:22:38 +08:00
parent 36c22eb00d
commit 4597dcaa38
9 changed files with 406 additions and 10 deletions
@@ -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,