Files
devops-platform-api/src/notifications/wecom/deploy-notification.service.ts
T
湛兮 4597dcaa38 feat: 持久化通知 outbox 记录
- notifications: 通知发送前写入 outbox,并在成功、失败或未配置时回写状态

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

- outbox: 新增超级管理员查询最近通知投递摘要接口
2026-06-12 03:22:38 +08:00

362 lines
9.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 发布通知适配服务封装 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 =
| 'created'
| 'queued'
| 'succeeded'
| 'failed'
| 'canceled'
| 'retried';
export type DeployNotificationResult = {
status: 'sent' | 'skipped' | 'failed';
channel: DeployNotificationProvider | 'none';
reason?: string;
outboxMessageId?: string;
};
export type DeployNotificationProvider = 'wecom' | 'feishu' | 'generic';
type DeployNotificationHealth = {
status: 'ok' | 'not_configured' | 'unavailable';
provider?: DeployNotificationProvider;
message?: string;
};
/**
* 统一封装发布单通知,保证 webhook 只存在于后端配置边界内。
*/
@Injectable()
export class DeployNotificationService {
constructor(
private readonly config: ConfigService<EnvConfig, true>,
private readonly weComClient: WeComClient,
private readonly notificationOutboxService: NotificationOutboxService,
) {}
healthSummary(): DeployNotificationHealth {
const provider = this.resolveProvider();
if (!provider) {
return {
status: 'not_configured',
message: '缺少 NOTIFICATION_PROVIDER',
};
}
if (provider === 'wecom') {
const health = this.weComClient.healthSummary();
return {
...health,
provider,
};
}
const webhookKey = this.webhookKeyFor(provider);
return this.config.get(webhookKey, { infer: true })
? { status: 'ok', provider }
: {
status: 'not_configured',
provider,
message: `缺少 ${webhookKey}`,
};
}
async sendDeployRunEvent(
run: DeployRunSummary,
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,
reason: health.message ?? '通知平台尚未配置',
outboxMessageId: outboxMessage.id,
};
}
if (health.status === 'unavailable' || !health.provider) {
await this.completeOutboxFailure(
outboxMessage,
health.message ?? '通知平台不可用',
);
return {
status: 'failed',
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,
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,
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 })
?.toLowerCase();
if (
provider === 'wecom' ||
provider === 'feishu' ||
provider === 'generic'
) {
return provider;
}
return undefined;
}
private webhookKeyFor(
provider: Exclude<DeployNotificationProvider, 'wecom'>,
): 'FEISHU_WEBHOOK_URL' | 'NOTIFICATION_WEBHOOK_URL' {
return provider === 'feishu'
? 'FEISHU_WEBHOOK_URL'
: 'NOTIFICATION_WEBHOOK_URL';
}
private async sendByProvider(
provider: DeployNotificationProvider,
run: DeployRunSummary,
event: DeployNotificationEvent,
): Promise<void> {
const title = this.titleFor(event);
const markdown = this.markdownFor(run, event);
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${this.plainTextFor(run, event)}`,
},
},
provider,
);
return;
}
await this.postWebhook(
this.config.get('NOTIFICATION_WEBHOOK_URL', { infer: true }) ?? '',
{
event: `deploy.${event}`,
title,
markdown,
run: {
id: run.id,
projectKey: run.projectKey,
environment: run.environment,
ref: 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 }),
},
provider,
);
}
private async postWebhook(
webhookUrl: string,
body: unknown,
provider: DeployNotificationProvider,
): Promise<void> {
const response = await fetch(webhookUrl, {
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${provider} webhook 请求失败:${response.status}`);
}
}
private titleFor(event: DeployNotificationEvent): string {
if (event === 'canceled') {
return 'DevOps 发布已取消';
}
if (event === 'queued') {
return 'DevOps 发布已进入 Jenkins 队列';
}
if (event === 'succeeded') {
return 'DevOps 发布成功';
}
if (event === 'failed') {
return 'DevOps 发布失败';
}
if (event === 'retried') {
return 'DevOps 发布已重试';
}
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,
): string {
return [
`> 事件:${this.titleFor(event)}`,
`> 项目:${run.projectKey}`,
`> 环境:${run.environment}`,
`> Ref${run.ref}`,
`> 状态:${run.status}`,
`> 操作者:${run.operator}`,
`> Run ID${run.id}`,
run.jenkinsQueueId ? `> Jenkins Queue${run.jenkinsQueueId}` : undefined,
run.jenkinsBuildNumber ? `> Jenkins Build#${run.jenkinsBuildNumber}` : undefined,
run.failureSummary ? `> 失败摘要:${run.failureSummary}` : undefined,
]
.filter(Boolean)
.join('\n');
}
private plainTextFor(
run: DeployRunSummary,
event: DeployNotificationEvent,
): string {
return [
`事件:${this.titleFor(event)}`,
`项目:${run.projectKey}`,
`环境:${run.environment}`,
`Ref${run.ref}`,
`状态:${run.status}`,
`操作者:${run.operator}`,
`Run ID${run.id}`,
run.failureSummary ? `失败摘要:${run.failureSummary}` : undefined,
]
.filter(Boolean)
.join('\n');
}
}