/** * 发布通知适配服务封装 provider 选择、outbox 记录和后端 webhook 投递。 */ 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 { NotificationOutboxService } from '../outbox/notification-outbox.service'; import { NotificationOutboxMessage } from '../outbox/notification-outbox.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; outboxMessageId?: 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, private readonly notificationOutboxService: NotificationOutboxService, ) {} healthSummary(): DeployNotificationHealth { const provider = this.resolveProvider(); if (!provider) { return { status: 'not_configured', message: '缺少 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: `缺少 ${webhookKey}`, }; } async sendDeployRunEvent( run: DeployRunSummary, event: DeployNotificationEvent, ): Promise { const health = this.healthSummary(); const channel = health.provider ?? 'none'; const outboxMessage = await this.createOutboxMessage(run, event, channel); if (health.status === 'not_configured') { await this.completeOutboxFailure( outboxMessage, health.message ?? '通知平台尚未配置', ); return { status: 'skipped', channel, reason: health.message ?? '通知平台尚未配置', outboxMessageId: outboxMessage.id, }; } if (health.status === 'unavailable' || !health.provider) { await this.completeOutboxFailure( outboxMessage, health.message ?? '通知平台不可用', ); return { status: 'failed', channel, reason: health.message ?? '通知平台不可用', outboxMessageId: outboxMessage.id, }; } try { await this.sendByProvider(health.provider, run, event); await this.notificationOutboxService.completeMessage(outboxMessage.id, { status: 'sent', }); return { status: 'sent', channel: health.provider, outboxMessageId: outboxMessage.id, }; } catch (error) { const reason = error instanceof Error ? redactSensitive({ message: error.message }).message : '未知通知错误'; await this.completeOutboxFailure(outboxMessage, reason); return { status: 'failed', channel: health.provider, reason, outboxMessageId: outboxMessage.id, }; } } private async createOutboxMessage( run: DeployRunSummary, event: DeployNotificationEvent, channel: DeployNotificationProvider | 'none', ): Promise { return this.notificationOutboxService.createMessage({ deployRunId: run.id, channel, template: `deploy.${event}`, payload: this.outboxPayloadFor(run, event), idempotencyKey: this.outboxIdempotencyKey(run, event), }); } private async completeOutboxFailure( message: NotificationOutboxMessage, reason: string, ): Promise { await this.notificationOutboxService.completeMessage(message.id, { status: 'failed', lastError: reason, }); } 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 请求失败:${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 outboxPayloadFor( run: DeployRunSummary, event: DeployNotificationEvent, ): Record { return { event: `deploy.${event}`, title: this.titleFor(event), deployRunId: run.id, projectKey: run.projectKey, environment: run.environment, ref: this.safeRefForOutbox(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 }), }; } private outboxIdempotencyKey( run: DeployRunSummary, event: DeployNotificationEvent, ): string { return [ run.id, `deploy.${event}`, run.status, run.jenkinsBuildNumber ?? run.jenkinsQueueId ?? 'no-jenkins-ref', ].join(':'); } private safeRefForOutbox(ref: string): string { if (/^[a-f0-9]{40}$/i.test(ref)) { return ref.slice(0, 12); } return ref.length > 48 ? `${ref.slice(0, 45)}...` : ref; } 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'); } }