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'; export type DeployNotificationEvent = | 'created' | 'queued' | 'succeeded' | 'failed' | 'canceled' | 'retried'; export type DeployNotificationResult = { status: 'sent' | 'skipped' | 'failed'; channel: DeployNotificationProvider | 'none'; reason?: string; }; export type DeployNotificationProvider = 'wecom' | 'feishu' | 'generic'; type DeployNotificationHealth = { status: 'ok' | 'not_configured' | 'unavailable'; provider?: DeployNotificationProvider; message?: string; }; /** * 统一封装发布单通知,保证 webhook 只存在于后端配置边界内。 */ @Injectable() export class DeployNotificationService { constructor( private readonly config: ConfigService, 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 { const health = this.healthSummary(); if (health.status === 'not_configured') { return { status: 'skipped', 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.sendByProvider(health.provider, run, event); return { status: 'sent', channel: health.provider }; } catch (error) { return { status: 'failed', channel: health.provider, reason: error instanceof Error ? redactSensitive({ message: error.message }).message : '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, ): '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 { 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 { 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 发布已取消'; } 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'); } 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'); } }