feat: 增加通知Provider和Jenkins自动同步
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
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';
|
||||
|
||||
@@ -13,50 +15,191 @@ export type DeployNotificationEvent =
|
||||
|
||||
export type DeployNotificationResult = {
|
||||
status: 'sent' | 'skipped' | 'failed';
|
||||
channel: 'wecom';
|
||||
channel: DeployNotificationProvider | 'none';
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
export type DeployNotificationProvider = 'wecom' | 'feishu' | 'generic';
|
||||
|
||||
type DeployNotificationHealth = {
|
||||
status: 'ok' | 'not_configured' | 'unavailable';
|
||||
provider?: DeployNotificationProvider;
|
||||
message?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一封装发布单企微通知,保证 webhook 只存在于后端配置边界内。
|
||||
* 统一封装发布单通知,保证 webhook 只存在于后端配置边界内。
|
||||
*/
|
||||
@Injectable()
|
||||
export class DeployNotificationService {
|
||||
constructor(private readonly weComClient: WeComClient) {}
|
||||
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.weComClient.healthSummary();
|
||||
const health = this.healthSummary();
|
||||
|
||||
if (health.status === 'not_configured') {
|
||||
return {
|
||||
status: 'skipped',
|
||||
channel: 'wecom',
|
||||
reason: health.message ?? 'WeCom webhook is not configured',
|
||||
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.weComClient.sendMarkdown({
|
||||
title: this.titleFor(event),
|
||||
markdown: this.markdownFor(run, event),
|
||||
});
|
||||
await this.sendByProvider(health.provider, run, event);
|
||||
|
||||
return { status: 'sent', channel: 'wecom' };
|
||||
return { status: 'sent', channel: health.provider };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'failed',
|
||||
channel: 'wecom',
|
||||
channel: health.provider,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? redactSensitive({ message: error.message }).message
|
||||
: 'Unknown WeCom notification error',
|
||||
: '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 发布已取消';
|
||||
@@ -100,4 +243,22 @@ export class DeployNotificationService {
|
||||
.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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user