feat: 初始化DevOps平台后端

This commit is contained in:
湛兮
2026-06-11 20:49:59 +08:00
commit caec5a618d
88 changed files with 11893 additions and 0 deletions
@@ -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');
}
}
+49
View File
@@ -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']);
}
}
}
+9
View File
@@ -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 {}
+9
View File
@@ -0,0 +1,9 @@
export type WeComMarkdownMessage = {
title: string;
markdown: string;
};
export type WeComSendResult = {
accepted: boolean;
message?: string;
};