feat: 增加通知Provider和Jenkins自动同步
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user