Files
devops-platform-api/src/notifications/wecom/deploy-notification.service.ts
T

265 lines
6.7 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.
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 { 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;
};
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,
) {}
healthSummary(): DeployNotificationHealth {
const provider = this.resolveProvider();
if (!provider) {
return {
status: 'not_configured',
message: 'Missing 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: `Missing ${webhookKey}`,
};
}
async sendDeployRunEvent(
run: DeployRunSummary,
event: DeployNotificationEvent,
): Promise<DeployNotificationResult> {
const health = this.healthSummary();
if (health.status === 'not_configured') {
return {
status: 'skipped',
channel: health.provider ?? 'none',
reason: health.message ?? 'Notification provider is not configured',
};
}
if (health.status === 'unavailable' || !health.provider) {
return {
status: 'failed',
channel: health.provider ?? 'none',
reason: health.message ?? 'Notification provider is unavailable',
};
}
try {
await this.sendByProvider(health.provider, run, event);
return { status: 'sent', channel: health.provider };
} catch (error) {
return {
status: 'failed',
channel: health.provider,
reason:
error instanceof Error
? redactSensitive({ message: error.message }).message
: 'Unknown notification error',
};
}
}
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 request failed: ${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 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');
}
}