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
+2
View File
@@ -17,6 +17,8 @@ REDIS_URL=redis://127.0.0.1:6379/0
JENKINS_BASE_URL=https://jenkins.example.internal JENKINS_BASE_URL=https://jenkins.example.internal
JENKINS_USERNAME=devops-bot JENKINS_USERNAME=devops-bot
JENKINS_API_TOKEN=replace-with-secret-manager-value JENKINS_API_TOKEN=replace-with-secret-manager-value
JENKINS_AUTO_SYNC_ENABLED=false
JENKINS_AUTO_SYNC_INTERVAL_MS=30000
# Gitea integration, server-side only. # Gitea integration, server-side only.
GITEA_BASE_URL=https://gitea.example.internal GITEA_BASE_URL=https://gitea.example.internal
+22
View File
@@ -38,6 +38,7 @@ pnpm prisma:migrate:dev --name init
- `GET /deploy-runs`: latest deploy runs. - `GET /deploy-runs`: latest deploy runs.
- `POST /deploy-runs`: create a deploy run. Missing Jenkins config uses a local dry-run path; configured Jenkins calls `buildWithParameters` and returns `queued/running`. - `POST /deploy-runs`: create a deploy run. Missing Jenkins config uses a local dry-run path; configured Jenkins calls `buildWithParameters` and returns `queued/running`.
- `POST /deploy-runs/:id/sync-jenkins`: synchronize Jenkins queue/build/log status and advance success or failure. - `POST /deploy-runs/:id/sync-jenkins`: synchronize Jenkins queue/build/log status and advance success or failure.
- `POST /deploy-runs/sync-jenkins`: synchronize all queued/running runs that have Jenkins queue/build identifiers.
- `POST /deploy-runs/:id/cancel`: cancel a deploy run. - `POST /deploy-runs/:id/cancel`: cancel a deploy run.
- `POST /deploy-runs/:id/retry`: create a retry deploy run. - `POST /deploy-runs/:id/retry`: create a retry deploy run.
- `POST /webhooks/gitea`: accept Gitea push webhooks and trigger test deploy runs. - `POST /webhooks/gitea`: accept Gitea push webhooks and trigger test deploy runs.
@@ -71,3 +72,24 @@ The Agent module is not a generic chat entrypoint. It can only support DevOps wo
- incident review drafts. - incident review drafts.
Agent calls are read-only. They must not trigger deploys, mutate Jenkins/Gitea, or expose raw secrets. When `LLM_BASE_URL`, `LLM_API_KEY`, and `LLM_MODEL` are configured, the backend proxies DevOps-scoped prompts to `/chat/completions`; otherwise it returns local mock results with the same API shape. Agent calls are read-only. They must not trigger deploys, mutate Jenkins/Gitea, or expose raw secrets. When `LLM_BASE_URL`, `LLM_API_KEY`, and `LLM_MODEL` are configured, the backend proxies DevOps-scoped prompts to `/chat/completions`; otherwise it returns local mock results with the same API shape.
## Notification Providers
`DeployNotificationService` supports the first provider boundary for:
- `NOTIFICATION_PROVIDER=wecom` with `WECOM_WEBHOOK_URL`
- `NOTIFICATION_PROVIDER=feishu` with `FEISHU_WEBHOOK_URL`
- `NOTIFICATION_PROVIDER=generic` with `NOTIFICATION_WEBHOOK_URL`
Missing provider or webhook configuration returns a skipped notification result and does not block deploy status transitions.
## Jenkins Auto Sync
Manual sync is always available through `POST /deploy-runs/sync-jenkins`. To enable a lightweight in-process poller before Redis/BullMQ is introduced:
```env
JENKINS_AUTO_SYNC_ENABLED=true
JENKINS_AUTO_SYNC_INTERVAL_MS=30000
```
The poller only scans queued/running runs that already have a Jenkins queue id or build number.
+15 -1
View File
@@ -1,5 +1,9 @@
const SENSITIVE_KEY_PATTERN = const SENSITIVE_KEY_PATTERN =
/(token|secret|password|passwd|authorization|api[-_]?key|webhook|cookie|credential|private[-_]?key)/i; /(token|secret|password|passwd|authorization|api[-_]?key|webhook|cookie|credential|private[-_]?key)/i;
const INLINE_SECRET_PATTERN =
/\b(token|secret|password|passwd|authorization|api[-_]?key|webhook|cookie|credential|private[-_]?key)=([^\s&]+)/gi;
const INLINE_URL_WITH_SECRET_PATTERN =
/https?:\/\/[^\s]*(?:token|secret|password|passwd|api[-_]?key|webhook|key=)[^\s]*/gi;
export function redactSensitive<T>(value: T): T { export function redactSensitive<T>(value: T): T {
return redactValue(value) as T; return redactValue(value) as T;
@@ -19,9 +23,19 @@ function redactValue(value: unknown): unknown {
); );
} }
if (typeof value === 'string' && looksLikeSecret(value)) { if (typeof value === 'string') {
const redacted = value
.replace(INLINE_URL_WITH_SECRET_PATTERN, '[REDACTED_URL]')
.replace(INLINE_SECRET_PATTERN, '$1=[REDACTED]');
if (redacted !== value) {
return redacted;
}
if (looksLikeSecret(value)) {
return '[REDACTED]'; return '[REDACTED]';
} }
}
return value; return value;
} }
+11
View File
@@ -34,6 +34,15 @@ const booleanFromEnv = z.preprocess((value) => {
return false; return false;
}, z.boolean().default(false)); }, z.boolean().default(false));
const positiveIntegerFromEnv = (defaultValue: number) =>
z.preprocess((value) => {
if (value === undefined || value === null || value === '') {
return undefined;
}
return value;
}, z.coerce.number().int().positive().default(defaultValue));
export const envSchema = z.object({ export const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'), NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(4300), PORT: z.coerce.number().int().positive().default(4300),
@@ -46,6 +55,8 @@ export const envSchema = z.object({
JENKINS_BASE_URL: optionalUrl, JENKINS_BASE_URL: optionalUrl,
JENKINS_USERNAME: optionalString, JENKINS_USERNAME: optionalString,
JENKINS_API_TOKEN: optionalString, JENKINS_API_TOKEN: optionalString,
JENKINS_AUTO_SYNC_ENABLED: booleanFromEnv,
JENKINS_AUTO_SYNC_INTERVAL_MS: positiveIntegerFromEnv(30000),
GITEA_BASE_URL: optionalUrl, GITEA_BASE_URL: optionalUrl,
GITEA_TOKEN: optionalString, GITEA_TOKEN: optionalString,
GITEA_WEBHOOK_SECRET: optionalString, GITEA_WEBHOOK_SECRET: optionalString,
+2 -2
View File
@@ -329,7 +329,7 @@ export class DeployExecutionService {
); );
this.auditService.record({ this.auditService.record({
action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`, action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`,
resourceType: 'deploy_run', resourceType: 'deploy_run',
resourceId: run.id, resourceId: run.id,
actorName: run.operator, actorName: run.operator,
@@ -501,7 +501,7 @@ export class DeployExecutionService {
await this.deployNotificationService.sendDeployRunEvent(run, event); await this.deployNotificationService.sendDeployRunEvent(run, event);
this.auditService.record({ this.auditService.record({
action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`, action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`,
resourceType: 'deploy_run', resourceType: 'deploy_run',
resourceId: run.id, resourceId: run.id,
actorName: run.operator, actorName: run.operator,
+7 -1
View File
@@ -7,11 +7,17 @@ import { DeployExecutionService } from './deploy-execution.service';
import { DeployRunRepository } from './deploy-run.repository'; import { DeployRunRepository } from './deploy-run.repository';
import { DeployRunsController } from './deploy-runs.controller'; import { DeployRunsController } from './deploy-runs.controller';
import { DeployRunsService } from './deploy-runs.service'; import { DeployRunsService } from './deploy-runs.service';
import { JenkinsSyncSchedulerService } from './jenkins-sync-scheduler.service';
@Module({ @Module({
imports: [AuditModule, JenkinsModule, ProjectsModule, WeComModule], imports: [AuditModule, JenkinsModule, ProjectsModule, WeComModule],
controllers: [DeployRunsController], controllers: [DeployRunsController],
providers: [DeployExecutionService, DeployRunRepository, DeployRunsService], providers: [
DeployExecutionService,
DeployRunRepository,
DeployRunsService,
JenkinsSyncSchedulerService,
],
exports: [DeployExecutionService, DeployRunRepository, DeployRunsService], exports: [DeployExecutionService, DeployRunRepository, DeployRunsService],
}) })
export class DeployRunsModule {} export class DeployRunsModule {}
+2 -2
View File
@@ -171,7 +171,7 @@ export class DeployRunsService {
await this.deployNotificationService.sendDeployRunEvent(run, event); await this.deployNotificationService.sendDeployRunEvent(run, event);
this.auditService.record({ this.auditService.record({
action: `WECOM_NOTIFICATION_${notification.status.toUpperCase()}`, action: `DEPLOY_NOTIFICATION_${notification.status.toUpperCase()}`,
resourceType: 'deploy_run', resourceType: 'deploy_run',
resourceId: run.id, resourceId: run.id,
after: { after: {
@@ -193,6 +193,6 @@ export class DeployRunsService {
const message = const message =
error instanceof Error ? error.message : 'Jenkins sync failed'; error instanceof Error ? error.message : 'Jenkins sync failed';
return redactSensitive(message); return redactSensitive({ message }).message;
} }
} }
@@ -0,0 +1,106 @@
import { ConfigService } from '@nestjs/config';
import { AuditService } from '../audit/audit.service';
import { EnvConfig } from '../config/env.schema';
import { DeployRunsService } from './deploy-runs.service';
import { JenkinsSyncSchedulerService } from './jenkins-sync-scheduler.service';
describe('JenkinsSyncSchedulerService', () => {
afterEach(() => {
jest.useRealTimers();
});
it('does not start interval when auto sync is disabled', () => {
jest.useFakeTimers();
const sync = {
syncJenkinsRuns: jest.fn(),
} satisfies Pick<DeployRunsService, 'syncJenkinsRuns'>;
const service = new JenkinsSyncSchedulerService(
createConfig({
JENKINS_AUTO_SYNC_ENABLED: false,
JENKINS_AUTO_SYNC_INTERVAL_MS: 1000,
}),
sync as unknown as DeployRunsService,
createAudit(),
);
service.onModuleInit();
jest.advanceTimersByTime(3000);
expect(sync.syncJenkinsRuns).not.toHaveBeenCalled();
});
it('runs one sweep and records aggregate audit data', async () => {
const sync = {
syncJenkinsRuns: jest.fn(() =>
Promise.resolve({
checkedAt: '2026-06-11T00:00:00.000Z',
totalCandidates: 2,
syncedRuns: [{} as never],
failedRuns: [{} as never],
}),
),
} satisfies Pick<DeployRunsService, 'syncJenkinsRuns'>;
const audit = createAudit();
const service = new JenkinsSyncSchedulerService(
createConfig({
JENKINS_AUTO_SYNC_ENABLED: true,
JENKINS_AUTO_SYNC_INTERVAL_MS: 1000,
}),
sync as unknown as DeployRunsService,
audit,
);
await service.runOnce();
expect(sync.syncJenkinsRuns).toHaveBeenCalledTimes(1);
expect(audit.recordMock).toHaveBeenCalledWith(
expect.objectContaining({
action: 'JENKINS_AUTO_SYNC_TICK_COMPLETED',
after: {
totalCandidates: 2,
syncedCount: 1,
failedCount: 1,
},
}),
);
});
it('records redacted failure audit when sweep fails', async () => {
const sync = {
syncJenkinsRuns: jest.fn(() =>
Promise.reject(
new Error('token=secret-value https://jenkins.internal failed'),
),
),
} satisfies Pick<DeployRunsService, 'syncJenkinsRuns'>;
const audit = createAudit();
const service = new JenkinsSyncSchedulerService(
createConfig({}),
sync as unknown as DeployRunsService,
audit,
);
await service.runOnce();
const payload = audit.recordMock.mock.calls[0][0];
expect(payload.action).toBe('JENKINS_AUTO_SYNC_TICK_FAILED');
expect(JSON.stringify(payload)).not.toContain('secret-value');
});
});
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>;
}
function createAudit(): AuditService & { recordMock: jest.Mock } {
const recordMock = jest.fn();
return {
record: recordMock,
recordMock,
} as unknown as AuditService & { recordMock: jest.Mock };
}
@@ -0,0 +1,81 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AuditService } from '../audit/audit.service';
import { redactSensitive } from '../common/security/redact-sensitive';
import { EnvConfig } from '../config/env.schema';
import { DeployRunsService } from './deploy-runs.service';
/**
* 轻量自动同步器,先复用内存发布单和现有 Jenkins 同步逻辑。
* 后续接 Redis/BullMQ 后,这里会迁移成队列 worker。
*/
@Injectable()
export class JenkinsSyncSchedulerService
implements OnModuleInit, OnModuleDestroy
{
private timer: ReturnType<typeof setInterval> | undefined;
private running = false;
constructor(
private readonly config: ConfigService<EnvConfig, true>,
private readonly deployRunsService: DeployRunsService,
private readonly auditService: AuditService,
) {}
onModuleInit(): void {
if (!this.config.get('JENKINS_AUTO_SYNC_ENABLED', { infer: true })) {
return;
}
const intervalMs = this.config.get('JENKINS_AUTO_SYNC_INTERVAL_MS', {
infer: true,
});
this.timer = setInterval(() => {
void this.runOnce();
}, intervalMs);
void this.runOnce();
}
onModuleDestroy(): void {
if (this.timer) {
clearInterval(this.timer);
this.timer = undefined;
}
}
async runOnce(): Promise<void> {
if (this.running) {
return;
}
this.running = true;
try {
const summary = await this.deployRunsService.syncJenkinsRuns();
this.auditService.record({
action: 'JENKINS_AUTO_SYNC_TICK_COMPLETED',
resourceType: 'deploy_run',
after: {
totalCandidates: summary.totalCandidates,
syncedCount: summary.syncedRuns.length,
failedCount: summary.failedRuns.length,
},
});
} catch (error) {
const message =
error instanceof Error ? error.message : 'Jenkins auto sync failed';
this.auditService.record({
action: 'JENKINS_AUTO_SYNC_TICK_FAILED',
resourceType: 'deploy_run',
after: {
message: redactSensitive({ message }).message,
},
});
} finally {
this.running = false;
}
}
}
+5 -4
View File
@@ -3,7 +3,7 @@ import { LlmClient } from '../agent/llm.client';
import { EnvConfig } from '../config/env.schema'; import { EnvConfig } from '../config/env.schema';
import { GiteaClient } from '../integrations/gitea/gitea.client'; import { GiteaClient } from '../integrations/gitea/gitea.client';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client'; import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import { WeComClient } from '../notifications/wecom/wecom.client'; import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { HealthService } from './health.service'; import { HealthService } from './health.service';
@@ -26,9 +26,9 @@ describe('HealthService', () => {
const gitea = { const gitea = {
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })), healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
} satisfies Pick<GiteaClient, 'healthSummary'>; } satisfies Pick<GiteaClient, 'healthSummary'>;
const weCom = { const notification = {
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })), healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
} satisfies Pick<WeComClient, 'healthSummary'>; } satisfies Pick<DeployNotificationService, 'healthSummary'>;
const llm = { const llm = {
healthSummary: jest.fn(() => ({ status: 'not_configured' as const })), healthSummary: jest.fn(() => ({ status: 'not_configured' as const })),
} satisfies Pick<LlmClient, 'healthSummary'>; } satisfies Pick<LlmClient, 'healthSummary'>;
@@ -37,7 +37,7 @@ describe('HealthService', () => {
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
jenkins as unknown as JenkinsClient, jenkins as unknown as JenkinsClient,
gitea as unknown as GiteaClient, gitea as unknown as GiteaClient,
weCom as unknown as WeComClient, notification as unknown as DeployNotificationService,
llm as unknown as LlmClient, llm as unknown as LlmClient,
); );
@@ -46,5 +46,6 @@ describe('HealthService', () => {
expect(health.status).toBe('ok'); expect(health.status).toBe('ok');
expect(health.dependencies.database.status).toBe('not_configured'); expect(health.dependencies.database.status).toBe('not_configured');
expect(health.dependencies.jenkins.status).toBe('not_configured'); expect(health.dependencies.jenkins.status).toBe('not_configured');
expect(health.dependencies.notification.status).toBe('not_configured');
}); });
}); });
+3 -3
View File
@@ -4,7 +4,7 @@ import { LlmClient } from '../agent/llm.client';
import { EnvConfig } from '../config/env.schema'; import { EnvConfig } from '../config/env.schema';
import { GiteaClient } from '../integrations/gitea/gitea.client'; import { GiteaClient } from '../integrations/gitea/gitea.client';
import { JenkinsClient } from '../integrations/jenkins/jenkins.client'; import { JenkinsClient } from '../integrations/jenkins/jenkins.client';
import { WeComClient } from '../notifications/wecom/wecom.client'; import { DeployNotificationService } from '../notifications/wecom/deploy-notification.service';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
type HealthStatus = 'ok' | 'not_configured' | 'unavailable'; type HealthStatus = 'ok' | 'not_configured' | 'unavailable';
@@ -16,7 +16,7 @@ export class HealthService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly jenkins: JenkinsClient, private readonly jenkins: JenkinsClient,
private readonly gitea: GiteaClient, private readonly gitea: GiteaClient,
private readonly weCom: WeComClient, private readonly notification: DeployNotificationService,
private readonly llm: LlmClient, private readonly llm: LlmClient,
) {} ) {}
@@ -32,7 +32,7 @@ export class HealthService {
: { status: 'not_configured' as const }, : { status: 'not_configured' as const },
jenkins: this.jenkins.healthSummary(), jenkins: this.jenkins.healthSummary(),
gitea: this.gitea.healthSummary(), gitea: this.gitea.healthSummary(),
wecom: this.weCom.healthSummary(), notification: this.notification.healthSummary(),
llm: this.llm.healthSummary(), llm: this.llm.healthSummary(),
}; };
const hasUnavailable = Object.values(dependencies).some( const hasUnavailable = Object.values(dependencies).some(
@@ -1,9 +1,11 @@
import { ConfigService } from '@nestjs/config';
import { EnvConfig } from '../../config/env.schema';
import { WeComClient } from './wecom.client'; import { WeComClient } from './wecom.client';
import { DeployNotificationService } from './deploy-notification.service'; import { DeployNotificationService } from './deploy-notification.service';
import { DeployRunSummary } from '../../deploy-runs/deploy-run.types'; import { DeployRunSummary } from '../../deploy-runs/deploy-run.types';
/** /**
* 覆盖企微通知的安全边界:缺少 webhook 时跳过,配置后才发送。 * 覆盖通知 provider 的安全边界:缺少配置时跳过,配置后才发送。
*/ */
describe('DeployNotificationService', () => { describe('DeployNotificationService', () => {
const run: DeployRunSummary = { const run: DeployRunSummary = {
@@ -18,7 +20,11 @@ describe('DeployNotificationService', () => {
steps: [], steps: [],
}; };
it('skips notification when WeCom is not configured', async () => { afterEach(() => {
jest.restoreAllMocks();
});
it('skips notification when provider is not configured', async () => {
const weCom = { const weCom = {
healthSummary: jest.fn(() => ({ healthSummary: jest.fn(() => ({
status: 'not_configured' as const, status: 'not_configured' as const,
@@ -27,27 +33,31 @@ describe('DeployNotificationService', () => {
sendMarkdown: jest.fn(), sendMarkdown: jest.fn(),
} satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>; } satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>;
const service = new DeployNotificationService( const service = new DeployNotificationService(
createConfig({}),
weCom as unknown as WeComClient, weCom as unknown as WeComClient,
); );
const result = await service.sendDeployRunEvent(run, 'created'); const result = await service.sendDeployRunEvent(run, 'created');
expect(result.status).toBe('skipped'); expect(result.status).toBe('skipped');
expect(result.channel).toBe('none');
expect(weCom.sendMarkdown).not.toHaveBeenCalled(); expect(weCom.sendMarkdown).not.toHaveBeenCalled();
}); });
it('sends markdown when WeCom is configured', async () => { it('sends markdown when WeCom provider is configured', async () => {
const weCom = { const weCom = {
healthSummary: jest.fn(() => ({ status: 'ok' as const })), healthSummary: jest.fn(() => ({ status: 'ok' as const })),
sendMarkdown: jest.fn(() => Promise.resolve({ accepted: true })), sendMarkdown: jest.fn(() => Promise.resolve({ accepted: true })),
} satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>; } satisfies Pick<WeComClient, 'healthSummary' | 'sendMarkdown'>;
const service = new DeployNotificationService( const service = new DeployNotificationService(
createConfig({ NOTIFICATION_PROVIDER: 'wecom' }),
weCom as unknown as WeComClient, weCom as unknown as WeComClient,
); );
const result = await service.sendDeployRunEvent(run, 'created'); const result = await service.sendDeployRunEvent(run, 'created');
expect(result.status).toBe('sent'); expect(result.status).toBe('sent');
expect(result.channel).toBe('wecom');
expect(weCom.sendMarkdown).toHaveBeenCalledWith( expect(weCom.sendMarkdown).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
title: 'DevOps 发布已创建', 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 { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { redactSensitive } from '../../common/security/redact-sensitive'; import { redactSensitive } from '../../common/security/redact-sensitive';
import { EnvConfig } from '../../config/env.schema';
import { DeployRunSummary } from '../../deploy-runs/deploy-run.types'; import { DeployRunSummary } from '../../deploy-runs/deploy-run.types';
import { WeComClient } from './wecom.client'; import { WeComClient } from './wecom.client';
@@ -13,50 +15,191 @@ export type DeployNotificationEvent =
export type DeployNotificationResult = { export type DeployNotificationResult = {
status: 'sent' | 'skipped' | 'failed'; status: 'sent' | 'skipped' | 'failed';
channel: 'wecom'; channel: DeployNotificationProvider | 'none';
reason?: string; reason?: string;
}; };
export type DeployNotificationProvider = 'wecom' | 'feishu' | 'generic';
type DeployNotificationHealth = {
status: 'ok' | 'not_configured' | 'unavailable';
provider?: DeployNotificationProvider;
message?: string;
};
/** /**
* 统一封装发布单企微通知,保证 webhook 只存在于后端配置边界内。 * 统一封装发布单通知,保证 webhook 只存在于后端配置边界内。
*/ */
@Injectable() @Injectable()
export class DeployNotificationService { 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( async sendDeployRunEvent(
run: DeployRunSummary, run: DeployRunSummary,
event: DeployNotificationEvent, event: DeployNotificationEvent,
): Promise<DeployNotificationResult> { ): Promise<DeployNotificationResult> {
const health = this.weComClient.healthSummary(); const health = this.healthSummary();
if (health.status === 'not_configured') { if (health.status === 'not_configured') {
return { return {
status: 'skipped', status: 'skipped',
channel: 'wecom', channel: health.provider ?? 'none',
reason: health.message ?? 'WeCom webhook is not configured', 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 { try {
await this.weComClient.sendMarkdown({ await this.sendByProvider(health.provider, run, event);
title: this.titleFor(event),
markdown: this.markdownFor(run, event),
});
return { status: 'sent', channel: 'wecom' }; return { status: 'sent', channel: health.provider };
} catch (error) { } catch (error) {
return { return {
status: 'failed', status: 'failed',
channel: 'wecom', channel: health.provider,
reason: reason:
error instanceof Error error instanceof Error
? redactSensitive({ message: error.message }).message ? 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 { private titleFor(event: DeployNotificationEvent): string {
if (event === 'canceled') { if (event === 'canceled') {
return 'DevOps 发布已取消'; return 'DevOps 发布已取消';
@@ -100,4 +243,22 @@ export class DeployNotificationService {
.filter(Boolean) .filter(Boolean)
.join('\n'); .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');
}
} }
+59 -1
View File
@@ -28,6 +28,7 @@ const INTEGRATION_DEFINITIONS: IntegrationDefinition[] = [
key: 'jenkins', key: 'jenkins',
name: 'Jenkins', name: 'Jenkins',
required: ['JENKINS_BASE_URL', 'JENKINS_USERNAME', 'JENKINS_API_TOKEN'], required: ['JENKINS_BASE_URL', 'JENKINS_USERNAME', 'JENKINS_API_TOKEN'],
optional: ['JENKINS_AUTO_SYNC_ENABLED', 'JENKINS_AUTO_SYNC_INTERVAL_MS'],
}, },
{ {
key: 'gitea', key: 'gitea',
@@ -40,7 +41,12 @@ const INTEGRATION_DEFINITIONS: IntegrationDefinition[] = [
key: 'notification', key: 'notification',
name: '通知平台', name: '通知平台',
required: ['NOTIFICATION_PROVIDER'], required: ['NOTIFICATION_PROVIDER'],
optional: ['WECOM_WEBHOOK_URL', 'FEISHU_WEBHOOK_URL', 'NOTIFICATION_WEBHOOK_URL'], optional: [
'WECOM_WEBHOOK_URL',
'FEISHU_WEBHOOK_URL',
'NOTIFICATION_WEBHOOK_URL',
'DEVOPS_PUBLIC_URL',
],
note: '当前不强制配置机器人;后续可按 provider 接入企微、飞书或通用 webhook。', note: '当前不强制配置机器人;后续可按 provider 接入企微、飞书或通用 webhook。',
}, },
{ {
@@ -87,6 +93,10 @@ export class SettingsService {
private toIntegrationStatus( private toIntegrationStatus(
definition: IntegrationDefinition, definition: IntegrationDefinition,
): IntegrationConfigStatus { ): IntegrationConfigStatus {
if (definition.key === 'notification') {
return this.toNotificationIntegrationStatus(definition);
}
const configured = definition.required.filter((key) => const configured = definition.required.filter((key) =>
this.isConfiguredValue(this.getConfigValue(key)), this.isConfiguredValue(this.getConfigValue(key)),
); );
@@ -106,6 +116,54 @@ export class SettingsService {
}; };
} }
private toNotificationIntegrationStatus(
definition: IntegrationDefinition,
): IntegrationConfigStatus {
const provider = this.getConfigValue('NOTIFICATION_PROVIDER')?.toLowerCase();
const providerWebhook = this.notificationWebhookKey(provider);
const required: EnvStringKey[] = providerWebhook
? ['NOTIFICATION_PROVIDER', providerWebhook]
: ['NOTIFICATION_PROVIDER'];
const configured = required.filter((key) =>
this.isConfiguredValue(this.getConfigValue(key)),
);
const missing = required.filter(
(key) => !this.isConfiguredValue(this.getConfigValue(key)),
);
return {
key: definition.key,
name: definition.name,
status: this.resolveStatus(configured.length, missing.length),
required,
configured,
missing,
...(definition.optional ? { optional: [...definition.optional] } : {}),
note:
provider && !providerWebhook
? `NOTIFICATION_PROVIDER=${provider} 暂不支持;可选 wecom、feishu、generic。`
: definition.note,
};
}
private notificationWebhookKey(
provider: string | undefined,
): EnvStringKey | undefined {
if (provider === 'wecom') {
return 'WECOM_WEBHOOK_URL';
}
if (provider === 'feishu') {
return 'FEISHU_WEBHOOK_URL';
}
if (provider === 'generic') {
return 'NOTIFICATION_WEBHOOK_URL';
}
return undefined;
}
private getConfigValue(key: EnvStringKey): string | undefined { private getConfigValue(key: EnvStringKey): string | undefined {
const value = this.config.get(key, { infer: true }); const value = this.config.get(key, { infer: true });