265 lines
6.7 KiB
TypeScript
265 lines
6.7 KiB
TypeScript
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');
|
||
}
|
||
}
|