feat: 增加通知Provider和Jenkins自动同步

This commit is contained in:
湛兮
2026-06-11 21:07:54 +08:00
parent caec5a618d
commit 163f411485
14 changed files with 568 additions and 31 deletions
@@ -1,9 +1,11 @@
import { ConfigService } from '@nestjs/config';
import { EnvConfig } from '../../config/env.schema';
import { WeComClient } from './wecom.client';
import { DeployNotificationService } from './deploy-notification.service';
import { DeployRunSummary } from '../../deploy-runs/deploy-run.types';
/**
* 覆盖企微通知的安全边界:缺少 webhook 时跳过,配置后才发送。
* 覆盖通知 provider 的安全边界:缺少配置时跳过,配置后才发送。
*/
describe('DeployNotificationService', () => {
const run: DeployRunSummary = {
@@ -18,7 +20,11 @@ describe('DeployNotificationService', () => {
steps: [],
};
it('skips notification when WeCom is not configured', async () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('skips notification when provider is not configured', async () => {
const weCom = {
healthSummary: jest.fn(() => ({
status: 'not_configured' as const,
@@ -27,27 +33,31 @@ describe('DeployNotificationService', () => {
sendMarkdown: jest.fn(),
} satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>;
const service = new DeployNotificationService(
createConfig({}),
weCom as unknown as WeComClient,
);
const result = await service.sendDeployRunEvent(run, 'created');
expect(result.status).toBe('skipped');
expect(result.channel).toBe('none');
expect(weCom.sendMarkdown).not.toHaveBeenCalled();
});
it('sends markdown when WeCom is configured', async () => {
it('sends markdown when WeCom provider is configured', async () => {
const weCom = {
healthSummary: jest.fn(() => ({ status: 'ok' as const })),
sendMarkdown: jest.fn(() => Promise.resolve({ accepted: true })),
} satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>;
const service = new DeployNotificationService(
createConfig({ NOTIFICATION_PROVIDER: 'wecom' }),
weCom as unknown as WeComClient,
);
const result = await service.sendDeployRunEvent(run, 'created');
expect(result.status).toBe('sent');
expect(result.channel).toBe('wecom');
expect(weCom.sendMarkdown).toHaveBeenCalledWith(
expect.objectContaining({
title: 'DevOps 发布已创建',
@@ -55,4 +65,69 @@ describe('DeployNotificationService', () => {
}),
);
});
it('sends text payload when Feishu provider is configured', async () => {
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
} as Response);
const weCom = {
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
sendMarkdown: jest.fn(),
} satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>;
const service = new DeployNotificationService(
createConfig({
NOTIFICATION_PROVIDER: 'feishu',
FEISHU_WEBHOOK_URL: 'https://open.feishu.cn/open-apis/bot/v2/hook/test',
}),
weCom as unknown as WeComClient,
);
const result = await service.sendDeployRunEvent(run, 'failed');
expect(result).toMatchObject({ status: 'sent', channel: 'feishu' });
expect(fetchMock).toHaveBeenCalledWith(
'https://open.feishu.cn/open-apis/bot/v2/hook/test',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('DevOps 发布失败'),
}),
);
});
it('sends structured payload when generic provider is configured', async () => {
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
} as Response);
const weCom = {
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
sendMarkdown: jest.fn(),
} satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>;
const service = new DeployNotificationService(
createConfig({
NOTIFICATION_PROVIDER: 'generic',
NOTIFICATION_WEBHOOK_URL: 'https://notification.internal/webhook/test',
DEVOPS_PUBLIC_URL: 'https://devops.mrzhan.top',
}),
weCom as unknown as WeComClient,
);
const result = await service.sendDeployRunEvent(run, 'succeeded');
expect(result).toMatchObject({ status: 'sent', channel: 'generic' });
expect(fetchMock).toHaveBeenCalledWith(
'https://notification.internal/webhook/test',
expect.objectContaining({
method: 'POST',
body: expect.stringContaining('devops.mrzhan.top'),
}),
);
});
});
function createConfig(
values: Partial<Record<keyof EnvConfig, string | number | boolean | undefined>>,
): ConfigService<EnvConfig, true> {
return {
get: jest.fn((key: keyof EnvConfig) => values[key]),
} as unknown as ConfigService<EnvConfig, true>;
}
@@ -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');
}
}