feat: 初始化DevOps平台后端
This commit is contained in:
@@ -0,0 +1,58 @@
|
||||
import { WeComClient } from './wecom.client';
|
||||
import { DeployNotificationService } from './deploy-notification.service';
|
||||
import { DeployRunSummary } from '../../deploy-runs/deploy-run.types';
|
||||
|
||||
/**
|
||||
* 覆盖企微通知的安全边界:缺少 webhook 时跳过,配置后才发送。
|
||||
*/
|
||||
describe('DeployNotificationService', () => {
|
||||
const run: DeployRunSummary = {
|
||||
id: 'run_001',
|
||||
projectKey: 'access-manage',
|
||||
environment: 'test',
|
||||
ref: 'develop',
|
||||
status: 'pending',
|
||||
trigger: 'manual',
|
||||
operator: 'ops-admin',
|
||||
createdAt: '2026-06-11T00:00:00.000Z',
|
||||
steps: [],
|
||||
};
|
||||
|
||||
it('skips notification when WeCom is not configured', async () => {
|
||||
const weCom = {
|
||||
healthSummary: jest.fn(() => ({
|
||||
status: 'not_configured' as const,
|
||||
message: 'Missing WECOM_WEBHOOK_URL',
|
||||
})),
|
||||
sendMarkdown: jest.fn(),
|
||||
} satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>;
|
||||
const service = new DeployNotificationService(
|
||||
weCom as unknown as WeComClient,
|
||||
);
|
||||
|
||||
const result = await service.sendDeployRunEvent(run, 'created');
|
||||
|
||||
expect(result.status).toBe('skipped');
|
||||
expect(weCom.sendMarkdown).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('sends markdown when WeCom 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(
|
||||
weCom as unknown as WeComClient,
|
||||
);
|
||||
|
||||
const result = await service.sendDeployRunEvent(run, 'created');
|
||||
|
||||
expect(result.status).toBe('sent');
|
||||
expect(weCom.sendMarkdown).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
title: 'DevOps 发布已创建',
|
||||
markdown: expect.stringContaining('access-manage'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { redactSensitive } from '../../common/security/redact-sensitive';
|
||||
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: 'wecom';
|
||||
reason?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 统一封装发布单企微通知,保证 webhook 只存在于后端配置边界内。
|
||||
*/
|
||||
@Injectable()
|
||||
export class DeployNotificationService {
|
||||
constructor(private readonly weComClient: WeComClient) {}
|
||||
|
||||
async sendDeployRunEvent(
|
||||
run: DeployRunSummary,
|
||||
event: DeployNotificationEvent,
|
||||
): Promise<DeployNotificationResult> {
|
||||
const health = this.weComClient.healthSummary();
|
||||
|
||||
if (health.status === 'not_configured') {
|
||||
return {
|
||||
status: 'skipped',
|
||||
channel: 'wecom',
|
||||
reason: health.message ?? 'WeCom webhook is not configured',
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
await this.weComClient.sendMarkdown({
|
||||
title: this.titleFor(event),
|
||||
markdown: this.markdownFor(run, event),
|
||||
});
|
||||
|
||||
return { status: 'sent', channel: 'wecom' };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: 'failed',
|
||||
channel: 'wecom',
|
||||
reason:
|
||||
error instanceof Error
|
||||
? redactSensitive({ message: error.message }).message
|
||||
: 'Unknown WeCom notification error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { IntegrationRequestError, IntegrationUnavailableError } from '../../common/errors/integration-error';
|
||||
import { redactSensitive } from '../../common/security/redact-sensitive';
|
||||
import { EnvConfig } from '../../config/env.schema';
|
||||
import { WeComMarkdownMessage, WeComSendResult } from './wecom.types';
|
||||
|
||||
@Injectable()
|
||||
export class WeComClient {
|
||||
constructor(private readonly config: ConfigService<EnvConfig, true>) {}
|
||||
|
||||
healthSummary(): { status: 'ok' | 'not_configured'; message?: string } {
|
||||
return this.config.get('WECOM_WEBHOOK_URL', { infer: true })
|
||||
? { status: 'ok' }
|
||||
: { status: 'not_configured', message: 'Missing WECOM_WEBHOOK_URL' };
|
||||
}
|
||||
|
||||
async sendMarkdown(message: WeComMarkdownMessage): Promise<WeComSendResult> {
|
||||
this.assertConfigured();
|
||||
const webhookUrl = this.config.get('WECOM_WEBHOOK_URL', { infer: true }) ?? '';
|
||||
const response = await fetch(webhookUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'content-type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
msgtype: 'markdown',
|
||||
markdown: {
|
||||
content: `# ${message.title}\n\n${message.markdown}`,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new IntegrationRequestError('wecom', 'WeCom webhook request failed', {
|
||||
status: response.status,
|
||||
message: redactSensitive(message),
|
||||
});
|
||||
}
|
||||
|
||||
return { accepted: true };
|
||||
}
|
||||
|
||||
private assertConfigured(): void {
|
||||
if (!this.config.get('WECOM_WEBHOOK_URL', { infer: true })) {
|
||||
throw new IntegrationUnavailableError('wecom', ['WECOM_WEBHOOK_URL']);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DeployNotificationService } from './deploy-notification.service';
|
||||
import { WeComClient } from './wecom.client';
|
||||
|
||||
@Module({
|
||||
providers: [WeComClient, DeployNotificationService],
|
||||
exports: [WeComClient, DeployNotificationService],
|
||||
})
|
||||
export class WeComModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type WeComMarkdownMessage = {
|
||||
title: string;
|
||||
markdown: string;
|
||||
};
|
||||
|
||||
export type WeComSendResult = {
|
||||
accepted: boolean;
|
||||
message?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user