From a974b6fea4f951d518f1975ba654d7d9b6037101 Mon Sep 17 00:00:00 2001 From: dengqichen Date: Tue, 11 Nov 2025 18:07:29 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E6=9E=84=E5=BB=BA=E9=80=9A?= =?UTF-8?q?=E7=9F=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...nvironmentNotificationConfigConverter.java | 17 + .../deploy/dto/TeamEnvironmentConfigDTO.java | 20 +- .../TeamEnvironmentNotificationConfigDTO.java | 53 ++ .../entity/JenkinsBuildNotification.java | 53 ++ .../TeamEnvironmentNotificationConfig.java | 65 +++ .../IJenkinsBuildNotificationRepository.java | 29 ++ .../repository/IJenkinsBuildRepository.java | 11 + .../ITeamApplicationRepository.java | 8 + ...vironmentNotificationConfigRepository.java | 48 ++ .../service/impl/JenkinsBuildServiceImpl.java | 480 +++++++++++++++++- .../TeamEnvironmentConfigServiceImpl.java | 187 +++++-- .../db/changelog/changes/v1.0.0-schema.sql | 54 +- 12 files changed, 951 insertions(+), 74 deletions(-) create mode 100644 backend/src/main/java/com/qqchen/deploy/backend/deploy/converter/TeamEnvironmentNotificationConfigConverter.java create mode 100644 backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/TeamEnvironmentNotificationConfigDTO.java create mode 100644 backend/src/main/java/com/qqchen/deploy/backend/deploy/entity/JenkinsBuildNotification.java create mode 100644 backend/src/main/java/com/qqchen/deploy/backend/deploy/entity/TeamEnvironmentNotificationConfig.java create mode 100644 backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/IJenkinsBuildNotificationRepository.java create mode 100644 backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/ITeamEnvironmentNotificationConfigRepository.java diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/converter/TeamEnvironmentNotificationConfigConverter.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/converter/TeamEnvironmentNotificationConfigConverter.java new file mode 100644 index 00000000..84230306 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/converter/TeamEnvironmentNotificationConfigConverter.java @@ -0,0 +1,17 @@ +package com.qqchen.deploy.backend.deploy.converter; + +import com.qqchen.deploy.backend.deploy.dto.TeamEnvironmentNotificationConfigDTO; +import com.qqchen.deploy.backend.deploy.entity.TeamEnvironmentNotificationConfig; +import com.qqchen.deploy.backend.framework.converter.BaseConverter; +import org.mapstruct.Mapper; + +/** + * 团队环境通知配置Converter + * + * @author qqchen + * @since 2025-11-11 + */ +@Mapper(componentModel = "spring") +public interface TeamEnvironmentNotificationConfigConverter + extends BaseConverter { +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/TeamEnvironmentConfigDTO.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/TeamEnvironmentConfigDTO.java index 3b3b08c8..1d403edf 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/TeamEnvironmentConfigDTO.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/TeamEnvironmentConfigDTO.java @@ -36,16 +36,6 @@ public class TeamEnvironmentConfigDTO extends BaseDTO { */ private List approverUserIds; - /** - * 通知渠道ID - */ - private Long notificationChannelId; - - /** - * 是否启用部署通知 - */ - private Boolean notificationEnabled; - /** * 是否要求代码审查通过 */ @@ -63,14 +53,14 @@ public class TeamEnvironmentConfigDTO extends BaseDTO { */ private String environmentName; - /** - * 通知渠道名称(扩展字段) - */ - private String notificationChannelName; - /** * 该团队在该环境下关联的应用数量(扩展字段) */ private Long applicationCount; + + /** + * 通知配置(来自 deploy_team_environment_notification_config 表) + */ + private TeamEnvironmentNotificationConfigDTO notificationConfig; } diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/TeamEnvironmentNotificationConfigDTO.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/TeamEnvironmentNotificationConfigDTO.java new file mode 100644 index 00000000..1811bd1e --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/dto/TeamEnvironmentNotificationConfigDTO.java @@ -0,0 +1,53 @@ +package com.qqchen.deploy.backend.deploy.dto; + +import com.qqchen.deploy.backend.framework.dto.BaseDTO; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 团队环境通知配置DTO + * + * @author qqchen + * @since 2025-11-11 + */ +@Data +@EqualsAndHashCode(callSuper = true) +public class TeamEnvironmentNotificationConfigDTO extends BaseDTO { + + /** + * 团队ID + */ + private Long teamId; + + /** + * 环境ID + */ + private Long environmentId; + + /** + * 通知渠道ID + */ + private Long notificationChannelId; + + /** + * 是否启用部署通知 + */ + private Boolean deployNotificationEnabled; + + /** + * 是否启用构建通知 + */ + private Boolean buildNotificationEnabled; + + /** + * 构建失败时是否发送日志文件到企业微信 + */ + private Boolean buildFailureFileEnabled; + + // ===== 扩展字段(非数据库字段) ===== + + /** + * 通知渠道名称(扩展字段,非数据库字段) + */ + private String notificationChannelName; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/entity/JenkinsBuildNotification.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/entity/JenkinsBuildNotification.java new file mode 100644 index 00000000..2e020654 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/entity/JenkinsBuildNotification.java @@ -0,0 +1,53 @@ +package com.qqchen.deploy.backend.deploy.entity; + +import com.qqchen.deploy.backend.framework.domain.Entity; +import jakarta.persistence.Column; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * Jenkins构建通知记录实体 + * + *

记录构建通知的发送状态,防止重复通知 + *

参考 longi-deployment 的设计:使用双标识防重机制 + * + * @author qqchen + * @since 2025-11-11 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@jakarta.persistence.Entity +@Table(name = "deploy_jenkins_build_notification") +public class JenkinsBuildNotification extends Entity { + + /** + * 构建记录ID(关联deploy_jenkins_build) + */ + @Column(name = "build_id", nullable = false) + private Long buildId; + + /** + * 团队ID + */ + @Column(name = "team_id", nullable = false) + private Long teamId; + + /** + * 环境ID + */ + @Column(name = "environment_id", nullable = false) + private Long environmentId; + + /** + * 构建开始是否已通知(0:未通知,1:已通知) + */ + @Column(name = "build_start_notice", nullable = false) + private Boolean buildStartNotice = false; + + /** + * 构建结束是否已通知(0:未通知,1:已通知) + */ + @Column(name = "build_end_notice", nullable = false) + private Boolean buildEndNotice = false; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/entity/TeamEnvironmentNotificationConfig.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/entity/TeamEnvironmentNotificationConfig.java new file mode 100644 index 00000000..37d7d382 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/entity/TeamEnvironmentNotificationConfig.java @@ -0,0 +1,65 @@ +package com.qqchen.deploy.backend.deploy.entity; + +import com.qqchen.deploy.backend.framework.annotation.LogicDelete; +import com.qqchen.deploy.backend.framework.domain.Entity; +import jakarta.persistence.Column; +import jakarta.persistence.Table; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 团队环境通知配置实体 + * + *

管理团队在特定环境下的通知配置: + *

    + *
  • 通知渠道配置
  • + *
  • 部署通知开关
  • + *
  • 构建通知开关
  • + *
+ * + * @author qqchen + * @since 2025-11-11 + */ +@Data +@EqualsAndHashCode(callSuper = true) +@jakarta.persistence.Entity +@Table(name = "deploy_team_environment_notification_config") +@LogicDelete(false) +public class TeamEnvironmentNotificationConfig extends Entity { + + /** + * 团队ID + */ + @Column(name = "team_id", nullable = false) + private Long teamId; + + /** + * 环境ID + */ + @Column(name = "environment_id", nullable = false) + private Long environmentId; + + /** + * 通知渠道ID(关联sys_notification_channel) + */ + @Column(name = "notification_channel_id") + private Long notificationChannelId; + + /** + * 是否启用部署通知 + */ + @Column(name = "deploy_notification_enabled", nullable = false, columnDefinition = "BIT DEFAULT 1") + private Boolean deployNotificationEnabled = true; + + /** + * 是否启用构建通知 + */ + @Column(name = "build_notification_enabled", nullable = false) + private Boolean buildNotificationEnabled = false; + + /** + * 构建失败时是否发送日志文件到企业微信 + */ + @Column(name = "build_failure_file_enabled", nullable = false) + private Boolean buildFailureFileEnabled = false; +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/IJenkinsBuildNotificationRepository.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/IJenkinsBuildNotificationRepository.java new file mode 100644 index 00000000..8562f281 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/IJenkinsBuildNotificationRepository.java @@ -0,0 +1,29 @@ +package com.qqchen.deploy.backend.deploy.repository; + +import com.qqchen.deploy.backend.deploy.entity.JenkinsBuildNotification; +import com.qqchen.deploy.backend.framework.repository.IBaseRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +/** + * Jenkins构建通知记录Repository + * + * @author qqchen + * @since 2025-11-11 + */ +@Repository +public interface IJenkinsBuildNotificationRepository + extends IBaseRepository { + + /** + * 根据构建ID、团队ID和环境ID查询通知记录 + * + * @param buildId 构建记录ID + * @param teamId 团队ID + * @param environmentId 环境ID + * @return 通知记录(如果存在) + */ + Optional findByBuildIdAndTeamIdAndEnvironmentId( + Long buildId, Long teamId, Long environmentId); +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/IJenkinsBuildRepository.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/IJenkinsBuildRepository.java index a54dd15e..fd373fa7 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/IJenkinsBuildRepository.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/IJenkinsBuildRepository.java @@ -6,6 +6,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; import java.util.Collection; import java.util.Optional; import java.util.List; @@ -64,4 +65,14 @@ public interface IJenkinsBuildRepository extends IBaseRepository countByJobIds(@Param("jobIds") Collection jobIds); + + /** + * 查询指定时间之后创建的构建记录(用于构建通知) + * + * @param externalSystemId 外部系统ID + * @param createTime 创建时间 + * @return 构建记录列表 + */ + List findByExternalSystemIdAndCreateTimeAfter( + Long externalSystemId, LocalDateTime createTime); } \ No newline at end of file diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/ITeamApplicationRepository.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/ITeamApplicationRepository.java index e1aa72ed..a306f272 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/ITeamApplicationRepository.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/ITeamApplicationRepository.java @@ -108,5 +108,13 @@ public interface ITeamApplicationRepository extends IBaseRepository teamIds, @Param("environmentId") Long environmentId); + + /** + * 根据部署系统ID查询所有团队应用(用于构建通知) + * + * @param deploySystemId 部署系统ID(Jenkins系统) + * @return 团队应用列表 + */ + List findByDeploySystemId(Long deploySystemId); } diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/ITeamEnvironmentNotificationConfigRepository.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/ITeamEnvironmentNotificationConfigRepository.java new file mode 100644 index 00000000..f3dd4632 --- /dev/null +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/repository/ITeamEnvironmentNotificationConfigRepository.java @@ -0,0 +1,48 @@ +package com.qqchen.deploy.backend.deploy.repository; + +import com.qqchen.deploy.backend.deploy.entity.TeamEnvironmentNotificationConfig; +import com.qqchen.deploy.backend.framework.repository.IBaseRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Set; + +/** + * 团队环境通知配置Repository + * + * @author qqchen + * @since 2025-11-11 + */ +@Repository +public interface ITeamEnvironmentNotificationConfigRepository + extends IBaseRepository { + + /** + * 根据团队ID和环境ID批量查询启用了构建通知的配置 + * + * @param teamIds 团队ID集合 + * @param environmentIds 环境ID集合 + * @return 通知配置列表 + */ + List findByTeamIdInAndEnvironmentIdInAndBuildNotificationEnabledTrue( + Set teamIds, Set environmentIds); + + /** + * 根据团队ID和环境ID批量查询通知配置 + * + * @param teamIds 团队ID集合 + * @param environmentIds 环境ID集合 + * @return 通知配置列表 + */ + List findByTeamIdInAndEnvironmentIdIn( + Set teamIds, Set environmentIds); + + /** + * 根据团队ID和环境ID查询通知配置 + * + * @param teamId 团队ID + * @param environmentId 环境ID + * @return 通知配置(如果存在) + */ + java.util.Optional findByTeamIdAndEnvironmentId(Long teamId, Long environmentId); +} diff --git a/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/JenkinsBuildServiceImpl.java b/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/JenkinsBuildServiceImpl.java index 5de75341..826a6874 100644 --- a/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/JenkinsBuildServiceImpl.java +++ b/backend/src/main/java/com/qqchen/deploy/backend/deploy/service/impl/JenkinsBuildServiceImpl.java @@ -1,8 +1,10 @@ package com.qqchen.deploy.backend.deploy.service.impl; import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.qqchen.deploy.backend.deploy.dto.JenkinsSyncHistoryDTO; +import com.qqchen.deploy.backend.framework.utils.JsonUtils; import com.qqchen.deploy.backend.deploy.entity.*; import com.qqchen.deploy.backend.deploy.dto.JenkinsBuildDTO; import com.qqchen.deploy.backend.deploy.enums.ExternalSystemSyncStatus; @@ -13,6 +15,10 @@ import com.qqchen.deploy.backend.deploy.integration.IJenkinsServiceIntegration; import com.qqchen.deploy.backend.deploy.repository.*; import com.qqchen.deploy.backend.deploy.service.IJenkinsBuildService; import com.qqchen.deploy.backend.deploy.service.IJenkinsSyncHistoryService; +import com.qqchen.deploy.backend.notification.entity.NotificationChannel; +import com.qqchen.deploy.backend.notification.repository.INotificationChannelRepository; +import com.qqchen.deploy.backend.notification.service.INotificationSendService; +import com.qqchen.deploy.backend.notification.dto.NotificationRequest; import com.qqchen.deploy.backend.deploy.dto.sync.JenkinsSyncContext; import com.qqchen.deploy.backend.framework.enums.ResponseCode; import com.qqchen.deploy.backend.framework.exception.BusinessException; @@ -58,6 +64,21 @@ public class JenkinsBuildServiceImpl extends BaseServiceImpl recentBuilds = jenkinsBuildRepository + .findByExternalSystemIdAndCreateTimeAfter(externalSystemId, since); + + if (recentBuilds.isEmpty()) { + log.info("没有新的构建记录"); + return; + } + + // 2. 反查团队绑定关系 + List teamApps = teamApplicationRepository + .findByDeploySystemId(externalSystemId); + + if (teamApps.isEmpty()) { + log.info("没有团队绑定该Jenkins系统: externalSystemId={}", externalSystemId); + return; + } + + // 3. 按 deploy_job 分组 TeamApplication + Map> teamAppsByJob = teamApps.stream() + .collect(Collectors.groupingBy(TeamApplication::getDeployJob)); + + // 4. 查询启用了构建通知的团队环境配置 + Set teamIds = teamApps.stream() + .map(TeamApplication::getTeamId) + .collect(Collectors.toSet()); + + Set envIds = teamApps.stream() + .map(TeamApplication::getEnvironmentId) + .collect(Collectors.toSet()); + + List configs = + teamEnvironmentNotificationConfigRepository + .findByTeamIdInAndEnvironmentIdInAndBuildNotificationEnabledTrue(teamIds, envIds); + + if (configs.isEmpty()) { + log.info("没有团队启用构建通知"); + return; + } + + // 5. 按 team_id + environment_id 分组配置 + Map configMap = configs.stream() + .collect(Collectors.toMap( + cfg -> cfg.getTeamId() + "_" + cfg.getEnvironmentId(), + cfg -> cfg + )); + + // 6. 批量查询通知渠道 + Set channelIds = configs.stream() + .map(TeamEnvironmentNotificationConfig::getNotificationChannelId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + + Map channelMap = + notificationChannelRepository.findAllById(channelIds).stream() + .collect(Collectors.toMap(NotificationChannel::getId, c -> c)); + + // 7. 批量查询 Job + Set jobIds = recentBuilds.stream() + .map(JenkinsBuild::getJobId) + .collect(Collectors.toSet()); + + Map jobMap = jenkinsJobRepository + .findAllById(jobIds).stream() + .collect(Collectors.toMap(JenkinsJob::getId, j -> j)); + + // 8. 按 job_name 分组构建记录 + Map> buildsByJobName = recentBuilds.stream() + .collect(Collectors.groupingBy( + build -> jobMap.get(build.getJobId()).getJobName() + )); + + // 9. 处理每个 Job 的构建通知 + buildsByJobName.forEach((jobName, builds) -> { + List relatedTeamApps = teamAppsByJob.get(jobName); + if (relatedTeamApps == null || relatedTeamApps.isEmpty()) { + return; + } + + for (TeamApplication teamApp : relatedTeamApps) { + String key = teamApp.getTeamId() + "_" + teamApp.getEnvironmentId(); + TeamEnvironmentNotificationConfig config = configMap.get(key); + + if (config == null) continue; + + NotificationChannel channel = channelMap.get(config.getNotificationChannelId()); + if (channel == null) continue; + + JenkinsJob job = jobMap.get(builds.get(0).getJobId()); + + // 处理该团队环境的所有构建通知 + for (JenkinsBuild build : builds) { + processBuildNotification(config, channel, job, build, externalSystem); + } + } + }); + + } catch (Exception e) { + log.error("检查构建通知失败: externalSystemId={}", externalSystemId, e); + } + } + + /** + * 处理单个构建的通知(照搬longi逻辑) + */ + private void processBuildNotification( + TeamEnvironmentNotificationConfig config, + NotificationChannel channel, + JenkinsJob job, + JenkinsBuild build, + ExternalSystem externalSystem) { + + try { + // 1. 查询通知记录 + JenkinsBuildNotification record = jenkinsBuildNotificationRepository + .findByBuildIdAndTeamIdAndEnvironmentId( + build.getId(), + config.getTeamId(), + config.getEnvironmentId() + ) + .orElse(null); + + // 2. 新构建 + if (record == null) { + long minutesAgo = java.time.temporal.ChronoUnit.MINUTES.between( + build.getStarttime(), + LocalDateTime.now() + ); + + record = new JenkinsBuildNotification(); + record.setBuildId(build.getId()); + record.setTeamId(config.getTeamId()); + record.setEnvironmentId(config.getEnvironmentId()); + + if (minutesAgo > 20) { + // 超时,直接标记完成(不发通知) + record.setBuildStartNotice(true); + record.setBuildEndNotice(true); + jenkinsBuildNotificationRepository.save(record); + return; + } else { + // 未超时,标记开始(可选:发送"构建中"通知) + // sendNotification(channel, job, build, "BUILDING"); + record.setBuildStartNotice(true); + jenkinsBuildNotificationRepository.save(record); + return; + } + } + + // 3. 已有记录,检查结束通知 + if (!record.getBuildEndNotice() && isBuildFinished(build)) { + String status = build.getBuildStatus(); + + // 只通知成功和失败 + if ("SUCCESS".equals(status) || "FAILURE".equals(status)) { + sendNotification(config, channel, job, build, status, externalSystem); + } + + record.setBuildEndNotice(true); + jenkinsBuildNotificationRepository.save(record); + } + + } catch (Exception e) { + log.error("处理构建通知失败: teamId={}, envId={}, buildId={}", + config.getTeamId(), config.getEnvironmentId(), build.getId(), e); + } + } + + /** + * 判断构建是否已结束 + */ + private boolean isBuildFinished(JenkinsBuild build) { + String status = build.getBuildStatus(); + return "SUCCESS".equals(status) || + "FAILURE".equals(status) || + "ABORTED".equals(status); + } + + /** + * 发送通知 + */ + private void sendNotification( + TeamEnvironmentNotificationConfig config, + NotificationChannel channel, + JenkinsJob job, + JenkinsBuild build, + String status, + ExternalSystem externalSystem) { + + try { + // 构建通知内容 + StringBuilder content = new StringBuilder(); + content.append("### 构建通知\n"); + content.append(String.format("> 任务: %s\n", job.getJobName())); + content.append(String.format("> 构建号: #%d\n", build.getBuildNumber())); + + // 状态显示 + String statusDisplay; + switch (status) { + case "SUCCESS": + statusDisplay = "✅ 成功"; + break; + case "FAILURE": + statusDisplay = "❌ 失败"; + break; + case "BUILDING": + statusDisplay = "🔄 构建中"; + break; + default: + statusDisplay = status; + } + content.append(String.format("> 状态: %s\n", statusDisplay)); + content.append(String.format("> 时间: %s\n", build.getStarttime())); + + // 耗时(仅结束状态) + if (!"BUILDING".equals(status) && build.getDuration() != null) { + long seconds = build.getDuration() / 1000; + long minutes = seconds / 60; + long secs = seconds % 60; + content.append(String.format("> 耗时: %d分%d秒\n", minutes, secs)); + } + + // 构建失败时,发送日志文件(如果开启) + if ("FAILURE".equals(status) && config.getBuildFailureFileEnabled() != null && config.getBuildFailureFileEnabled()) { + // 先发送文本通知 + NotificationRequest textRequest = NotificationRequest.builder() + .channelId(channel.getId()) + .title("Jenkins构建通知") + .content(content.toString()) + .build(); + notificationSendService.send(textRequest); + + // 然后发送日志文件 + sendBuildFailureLogFile(externalSystem, channel, job.getJobName(), build.getBuildNumber()); + return; // 已发送,直接返回 + } + + // 构建通知请求 + NotificationRequest request = NotificationRequest.builder() + .channelId(channel.getId()) + .title("Jenkins构建通知") + .content(content.toString()) + .build(); + + // 发送通知 + notificationSendService.send(request); + + log.info("已发送构建通知: job={}, build={}, status={}", + job.getJobName(), build.getBuildNumber(), status); + + } catch (Exception e) { + log.error("发送通知失败: job={}, build={}", job.getJobName(), build.getBuildNumber(), e); + } + } + + /** + * 发送构建失败日志文件到企业微信 + */ + private void sendBuildFailureLogFile(ExternalSystem externalSystem, NotificationChannel channel, + String jobName, Integer buildNumber) { + try { + log.info("开始下载构建失败日志: job={}, buildNumber={}", jobName, buildNumber); + + // 1. 下载日志文件到临时目录 + String logFilePath = downloadBuildLogFile(externalSystem, jobName, buildNumber); + if (logFilePath == null) { + log.warn("日志文件下载失败,跳过文件发送"); + return; + } + + // 2. 上传文件到企业微信 + String mediaId = uploadFileToWechat(channel, logFilePath); + if (mediaId == null) { + log.warn("文件上传到企业微信失败"); + return; + } + + // 3. 发送文件消息 + sendWechatFileMessage(channel, mediaId); + + // 4. 清理临时文件 + cleanupTempFile(logFilePath); + + log.info("构建失败日志文件发送成功: job={}, buildNumber={}", jobName, buildNumber); + + } catch (Exception e) { + log.error("发送构建失败日志文件失败: job={}, buildNumber={}", + jobName, buildNumber, e); + } + } + + /** + * 下载构建日志文件 + */ + private String downloadBuildLogFile(ExternalSystem externalSystem, String jobName, Integer buildNumber) { + try { + // 获取完整的控制台输出 + var consoleOutput = jenkinsServiceIntegration.getConsoleOutput( + externalSystem, + jobName, + buildNumber, + 0L + ); + + if (consoleOutput == null || consoleOutput.getLines() == null || consoleOutput.getLines().isEmpty()) { + return null; + } + + // 创建临时文件 + String tempDir = System.getProperty("java.io.tmpdir"); + String fileName = String.format("jenkins-build-%s-%d.log", jobName, buildNumber); + java.nio.file.Path filePath = java.nio.file.Paths.get(tempDir, fileName); + + // 写入日志内容 + java.nio.file.Files.write(filePath, consoleOutput.getLines()); + + log.info("日志文件已下载: {}", filePath.toString()); + return filePath.toString(); + + } catch (Exception e) { + log.error("下载构建日志文件失败: job={}, buildNumber={}", + jobName, buildNumber, e); + return null; + } + } + + /** + * 上传文件到企业微信 + */ + private String uploadFileToWechat(NotificationChannel channel, String filePath) { + try { + // 从 config 中获取 webhook URL + String webhookUrl = (String) channel.getConfig().get("webhookUrl"); + if (webhookUrl == null || !webhookUrl.contains("key=")) { + log.error("无效的企业微信 webhook URL: {}", webhookUrl); + return null; + } + + String key = webhookUrl.substring(webhookUrl.indexOf("key=") + 4); + + // 企业微信文件上传接口 + String uploadUrl = "https://qyapi.weixin.qq.com/cgi-bin/webhook/upload_media?key=" + key + "&type=file"; + + // 构建 multipart/form-data 请求 + org.springframework.core.io.FileSystemResource fileResource = + new org.springframework.core.io.FileSystemResource(filePath); + + org.springframework.util.LinkedMultiValueMap map = + new org.springframework.util.LinkedMultiValueMap<>(); + map.add("media", fileResource); + + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.setContentType(org.springframework.http.MediaType.MULTIPART_FORM_DATA); + + org.springframework.http.HttpEntity> requestEntity = + new org.springframework.http.HttpEntity<>(map, headers); + + org.springframework.web.client.RestTemplate restTemplate = new org.springframework.web.client.RestTemplate(); + org.springframework.http.ResponseEntity response = + restTemplate.postForEntity(uploadUrl, requestEntity, String.class); + + if (response.getStatusCode().is2xxSuccessful() && response.getBody() != null) { + // 解析响应,获取 media_id + JsonNode jsonNode = JsonUtils.parseJson(response.getBody()); + if (jsonNode == null) { + log.error("文件上传响应解析失败"); + return null; + } + + int errcode = jsonNode.get("errcode").asInt(); + if (errcode == 0) { + String mediaId = jsonNode.get("media_id").asText(); + log.info("文件上传成功,media_id: {}", mediaId); + return mediaId; + } else { + log.error("文件上传失败,errcode: {}, errmsg: {}", + errcode, jsonNode.get("errmsg").asText()); + return null; + } + } + + return null; + + } catch (Exception e) { + log.error("上传文件到企业微信失败: filePath={}", filePath, e); + return null; + } + } + + /** + * 发送企业微信文件消息 + */ + private void sendWechatFileMessage(NotificationChannel channel, String mediaId) { + try { + String webhookUrl = (String) channel.getConfig().get("webhookUrl"); + + // 构建文件消息 + Map message = new HashMap<>(); + message.put("msgtype", "file"); + + Map file = new HashMap<>(); + file.put("media_id", mediaId); + message.put("file", file); + + // 发送请求 + org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders(); + headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON); + + org.springframework.http.HttpEntity> requestEntity = + new org.springframework.http.HttpEntity<>(message, headers); + + org.springframework.web.client.RestTemplate restTemplate = new org.springframework.web.client.RestTemplate(); + org.springframework.http.ResponseEntity response = + restTemplate.postForEntity(webhookUrl, requestEntity, String.class); + + if (response.getStatusCode().is2xxSuccessful()) { + log.info("企业微信文件消息发送成功"); + } else { + log.error("企业微信文件消息发送失败: {}", response.getBody()); + } + + } catch (Exception e) { + log.error("发送企业微信文件消息失败: mediaId={}", mediaId, e); + } + } + + /** + * 清理临时文件 + */ + private void cleanupTempFile(String filePath) { + try { + java.nio.file.Files.deleteIfExists(java.nio.file.Paths.get(filePath)); + log.debug("临时文件已清理: {}", filePath); + } catch (Exception e) { + log.warn("清理临时文件失败: {}", filePath, e); + } + } + @Override public Long countByExternalSystemId(Long externalSystemId) { QJenkinsBuild qJenkinsBuild = QJenkinsBuild.jenkinsBuild; @@ -383,4 +861,4 @@ public class JenkinsBuildServiceImpl extends BaseServiceImpl - implements ITeamEnvironmentConfigService { + extends BaseServiceImpl + implements ITeamEnvironmentConfigService { @Resource private ITeamEnvironmentConfigRepository teamEnvironmentConfigRepository; @@ -47,11 +51,17 @@ public class TeamEnvironmentConfigServiceImpl @Resource private ITeamApplicationRepository teamApplicationRepository; + @Resource + private ITeamEnvironmentNotificationConfigRepository teamEnvironmentNotificationConfigRepository; + + @Resource + private TeamEnvironmentNotificationConfigConverter notificationConfigConverter; + @Override public List findByTeamId(Long teamId) { List list = teamEnvironmentConfigRepository.findByTeamId(teamId).stream() - .map(converter::toDto) - .collect(Collectors.toList()); + .map(converter::toDto) + .collect(Collectors.toList()); fillExtendedFields(list); return list; } @@ -59,8 +69,8 @@ public class TeamEnvironmentConfigServiceImpl @Override public TeamEnvironmentConfigDTO findByTeamIdAndEnvironmentId(Long teamId, Long environmentId) { TeamEnvironmentConfigDTO dto = teamEnvironmentConfigRepository.findByTeamIdAndEnvironmentId(teamId, environmentId) - .map(converter::toDto) - .orElse(null); + .map(converter::toDto) + .orElse(null); if (dto != null) { fillExtendedFields(Collections.singletonList(dto)); } @@ -94,7 +104,14 @@ public class TeamEnvironmentConfigServiceImpl public TeamEnvironmentConfigDTO create(TeamEnvironmentConfigDTO dto) { // 执行兜底逻辑:确保数据一致性 applyDataConsistencyRules(dto); - return super.create(dto); + + // 创建主配置 + TeamEnvironmentConfigDTO result = super.create(dto); + + // 保存或更新通知配置 + saveOrUpdateNotificationConfig(dto); + + return result; } /** @@ -105,7 +122,14 @@ public class TeamEnvironmentConfigServiceImpl public TeamEnvironmentConfigDTO update(Long id, TeamEnvironmentConfigDTO dto) { // 执行兜底逻辑:确保数据一致性 applyDataConsistencyRules(dto); - return super.update(id, dto); + + // 更新主配置 + TeamEnvironmentConfigDTO result = super.update(id, dto); + + // 保存或更新通知配置 + saveOrUpdateNotificationConfig(dto); + + return result; } /** @@ -113,9 +137,9 @@ public class TeamEnvironmentConfigServiceImpl *

规则: *

    *
  • 1. 如果不需要审批(approvalRequired = false),清空审批人列表
  • - *
  • 2. 如果不需要通知(notificationEnabled = false),清空通知渠道
  • - *
  • 3. 如果不需要代码审查(requireCodeReview = false),清空代码审查相关配置
  • *
+ * + *

注意:通知相关配置已迁移到 deploy_team_environment_notification_config 表,不在此处理 */ private void applyDataConsistencyRules(TeamEnvironmentConfigDTO dto) { if (dto == null) { @@ -125,32 +149,57 @@ public class TeamEnvironmentConfigServiceImpl // 规则1:不需要审批时,清空审批人 if (dto.getApprovalRequired() != null && !dto.getApprovalRequired()) { if (dto.getApproverUserIds() != null && !dto.getApproverUserIds().isEmpty()) { - log.info("兜底逻辑触发:团队 {} 环境 {} 不需要审批,清空审批人列表 {}", + log.info("兜底逻辑触发:团队 {} 环境 {} 不需要审批,清空审批人列表 {}", dto.getTeamId(), dto.getEnvironmentId(), dto.getApproverUserIds()); dto.setApproverUserIds(null); } } + } - // 规则2:不需要通知时,清空通知渠道 - if (dto.getNotificationEnabled() != null && !dto.getNotificationEnabled()) { - if (dto.getNotificationChannelId() != null) { - log.info("兜底逻辑触发:团队 {} 环境 {} 不需要通知,清空通知渠道 {}", - dto.getTeamId(), dto.getEnvironmentId(), dto.getNotificationChannelId()); - dto.setNotificationChannelId(null); - } + /** + * 保存或更新通知配置 + *

如果 DTO 中包含 notificationConfig,则: + *

    + *
  • 如果数据库中已存在对应记录,则更新
  • + *
  • 如果不存在,则创建新记录
  • + *
+ * + * @param dto 团队环境配置DTO(包含 notificationConfig) + */ + private void saveOrUpdateNotificationConfig(TeamEnvironmentConfigDTO dto) { + if (dto == null || dto.getNotificationConfig() == null) { + return; } - // 规则3:不需要代码审查时,清空相关配置(如果将来有相关字段) - if (dto.getRequireCodeReview() != null && !dto.getRequireCodeReview()) { - // 目前没有代码审查相关的其他配置字段 - // 如果将来有(如 codeReviewerUserIds),在这里清空 - log.debug("团队 {} 环境 {} 不需要代码审查", dto.getTeamId(), dto.getEnvironmentId()); + TeamEnvironmentNotificationConfigDTO notificationDTO = dto.getNotificationConfig(); + + // 确保 teamId 和 environmentId 一致 + notificationDTO.setTeamId(dto.getTeamId()); + notificationDTO.setEnvironmentId(dto.getEnvironmentId()); + + // 查询是否已存在记录 + Optional existingConfig = + teamEnvironmentNotificationConfigRepository.findByTeamIdAndEnvironmentId( + dto.getTeamId(), dto.getEnvironmentId() + ); + + if (existingConfig.isPresent()) { + // 更新现有记录 + TeamEnvironmentNotificationConfig config = existingConfig.get(); + notificationConfigConverter.updateEntity(config, notificationDTO); + teamEnvironmentNotificationConfigRepository.save(config); + log.info("更新通知配置: teamId={}, environmentId={}", dto.getTeamId(), dto.getEnvironmentId()); + } else { + // 创建新记录 + TeamEnvironmentNotificationConfig newConfig = notificationConfigConverter.toEntity(notificationDTO); + teamEnvironmentNotificationConfigRepository.save(newConfig); + log.info("创建通知配置: teamId={}, environmentId={}", dto.getTeamId(), dto.getEnvironmentId()); } } /** - * 批量填充扩展字段:environmentName、notificationChannelName、applicationCount - * + * 批量填充扩展字段:environmentName、notificationConfig、applicationCount + * *

使用批量查询避免N+1问题 */ private void fillExtendedFields(List configs) { @@ -158,34 +207,51 @@ public class TeamEnvironmentConfigServiceImpl return; } - // 1. 收集所有环境ID和通知渠道ID - Set environmentIds = configs.stream() - .map(TeamEnvironmentConfigDTO::getEnvironmentId) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); + // 1. 收集所有团队ID和环境ID + Set teamIds = configs.stream() + .map(TeamEnvironmentConfigDTO::getTeamId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); - Set channelIds = configs.stream() - .map(TeamEnvironmentConfigDTO::getNotificationChannelId) - .filter(Objects::nonNull) - .collect(Collectors.toSet()); + Set environmentIds = configs.stream() + .map(TeamEnvironmentConfigDTO::getEnvironmentId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); // 2. 批量查询环境信息 Map environmentMap = new HashMap<>(); if (!environmentIds.isEmpty()) { environmentRepository.findAllById(environmentIds).forEach(env -> - environmentMap.put(env.getId(), env) + environmentMap.put(env.getId(), env) ); } - // 3. 批量查询通知渠道信息 + // 3. 批量查询通知配置(来自 deploy_team_environment_notification_config 表) + Map notificationConfigMap = new HashMap<>(); + if (!teamIds.isEmpty() && !environmentIds.isEmpty()) { + List notificationConfigs = + teamEnvironmentNotificationConfigRepository.findByTeamIdInAndEnvironmentIdIn(teamIds, environmentIds); + + notificationConfigs.forEach(nc -> { + String key = nc.getTeamId() + "_" + nc.getEnvironmentId(); + notificationConfigMap.put(key, nc); + }); + } + + // 4. 批量查询通知渠道信息 + Set channelIds = notificationConfigMap.values().stream() + .map(TeamEnvironmentNotificationConfig::getNotificationChannelId) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map channelMap = new HashMap<>(); if (!channelIds.isEmpty()) { notificationChannelRepository.findAllById(channelIds).forEach(channel -> - channelMap.put(channel.getId(), channel) + channelMap.put(channel.getId(), channel) ); } - // 4. 填充扩展字段 + // 5. 填充扩展字段 configs.forEach(config -> { // 填充环境名称 if (config.getEnvironmentId() != null) { @@ -195,19 +261,32 @@ public class TeamEnvironmentConfigServiceImpl } } - // 填充通知渠道名称 - if (config.getNotificationChannelId() != null) { - NotificationChannel channel = channelMap.get(config.getNotificationChannelId()); - if (channel != null) { - config.setNotificationChannelName(channel.getName()); + // 填充通知配置 + if (config.getTeamId() != null && config.getEnvironmentId() != null) { + String key = config.getTeamId() + "_" + config.getEnvironmentId(); + TeamEnvironmentNotificationConfig notificationConfig = notificationConfigMap.get(key); + + if (notificationConfig != null) { + TeamEnvironmentNotificationConfigDTO notificationConfigDTO = + notificationConfigConverter.toDto(notificationConfig); + + // 填充通知渠道名称 + if (notificationConfig.getNotificationChannelId() != null) { + NotificationChannel channel = channelMap.get(notificationConfig.getNotificationChannelId()); + if (channel != null) { + notificationConfigDTO.setNotificationChannelName(channel.getName()); + } + } + + config.setNotificationConfig(notificationConfigDTO); } } // 填充应用数量统计 if (config.getTeamId() != null && config.getEnvironmentId() != null) { Long appCount = teamApplicationRepository.countByTeamIdAndEnvironmentId( - config.getTeamId(), - config.getEnvironmentId() + config.getTeamId(), + config.getEnvironmentId() ); config.setApplicationCount(appCount != null ? appCount : 0L); } @@ -216,7 +295,7 @@ public class TeamEnvironmentConfigServiceImpl /** * 重写删除方法,删除前校验该团队环境下是否有绑定的应用 - * + * * @param id 团队环境配置ID */ @Override @@ -224,23 +303,23 @@ public class TeamEnvironmentConfigServiceImpl public void delete(Long id) { // 1. 查询团队环境配置 TeamEnvironmentConfig config = teamEnvironmentConfigRepository.findById(id) - .orElseThrow(() -> new BusinessException(ResponseCode.TEAM_CONFIG_NOT_FOUND, new Object[]{id})); - + .orElseThrow(() -> new BusinessException(ResponseCode.TEAM_CONFIG_NOT_FOUND, new Object[] {id})); + // 2. 校验该环境下是否有绑定的应用 Long appCount = teamApplicationRepository.countByTeamIdAndEnvironmentId( - config.getTeamId(), + config.getTeamId(), config.getEnvironmentId() ); - + if (appCount != null && appCount > 0) { - log.warn("删除团队环境配置失败: 团队 {} 的环境 {} 下存在 {} 个绑定应用", + log.warn("删除团队环境配置失败: 团队 {} 的环境 {} 下存在 {} 个绑定应用", config.getTeamId(), config.getEnvironmentId(), appCount); - throw new BusinessException(ResponseCode.TEAM_ENVIRONMENT_HAS_APPLICATIONS, new Object[]{appCount}); + throw new BusinessException(ResponseCode.TEAM_ENVIRONMENT_HAS_APPLICATIONS, new Object[] {appCount}); } - + // 3. 执行删除(逻辑删除) super.delete(id); - log.info("成功删除团队环境配置: id={}, teamId={}, environmentId={}", + log.info("成功删除团队环境配置: id={}, teamId={}, environmentId={}", id, config.getTeamId(), config.getEnvironmentId()); } } diff --git a/backend/src/main/resources/db/changelog/changes/v1.0.0-schema.sql b/backend/src/main/resources/db/changelog/changes/v1.0.0-schema.sql index ece0b7b7..fed4c164 100644 --- a/backend/src/main/resources/db/changelog/changes/v1.0.0-schema.sql +++ b/backend/src/main/resources/db/changelog/changes/v1.0.0-schema.sql @@ -844,10 +844,6 @@ CREATE TABLE deploy_team_environment_config approval_required BIT NOT NULL DEFAULT 0 COMMENT '是否需要审批', approver_user_ids JSON NULL COMMENT '审批人用户ID列表,如:[1, 4, 7]', - -- 通知配置 - notification_channel_id BIGINT NULL COMMENT '通知渠道ID', - notification_enabled BIT NOT NULL DEFAULT 1 COMMENT '是否启用部署通知', - -- 安全策略 require_code_review BIT NOT NULL DEFAULT 0 COMMENT '是否要求代码审查通过', @@ -862,6 +858,56 @@ CREATE TABLE deploy_team_environment_config CONSTRAINT fk_team_env_config_env FOREIGN KEY (environment_id) REFERENCES deploy_environment (id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='团队环境配置表'; +-- 团队环境通知配置表 +CREATE TABLE deploy_team_environment_notification_config +( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + create_by VARCHAR(100) NULL COMMENT '创建人', + create_time DATETIME(6) NULL COMMENT '创建时间', + update_by VARCHAR(100) NULL COMMENT '更新人', + update_time DATETIME(6) NULL COMMENT '更新时间', + version INT NOT NULL DEFAULT 1 COMMENT '版本号', + deleted BIT NOT NULL DEFAULT 0 COMMENT '是否删除', + + team_id BIGINT NOT NULL COMMENT '团队ID', + environment_id BIGINT NOT NULL COMMENT '环境ID', + + notification_channel_id BIGINT NULL COMMENT '通知渠道ID(关联sys_notification_channel)', + deploy_notification_enabled BIT NOT NULL DEFAULT 1 COMMENT '是否启用部署通知', + build_notification_enabled BIT NOT NULL DEFAULT 0 COMMENT '是否启用构建通知', + build_failure_log_enabled BIT NOT NULL DEFAULT 0 COMMENT '构建失败时是否附加错误日志到通知(0:不附加,1:附加)', + + UNIQUE INDEX uk_team_env (team_id, environment_id), + INDEX idx_team (team_id), + INDEX idx_env (environment_id), + INDEX idx_channel (notification_channel_id), + INDEX idx_deleted (deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='团队环境通知配置表'; + +-- Jenkins构建通知记录表(记录通知状态,防止重复通知) +CREATE TABLE deploy_jenkins_build_notification +( + id BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID', + create_by VARCHAR(100) NULL COMMENT '创建人', + create_time DATETIME(6) NULL COMMENT '创建时间', + update_by VARCHAR(100) NULL COMMENT '更新人', + update_time DATETIME(6) NULL COMMENT '更新时间', + version INT NOT NULL DEFAULT 1 COMMENT '版本号', + deleted BIT NOT NULL DEFAULT 0 COMMENT '是否删除', + + build_id BIGINT NOT NULL COMMENT '构建记录ID(关联deploy_jenkins_build)', + team_id BIGINT NOT NULL COMMENT '团队ID', + environment_id BIGINT NOT NULL COMMENT '环境ID', + + build_start_notice BIT NOT NULL DEFAULT 0 COMMENT '构建开始是否已通知(0:未通知,1:已通知)', + build_end_notice BIT NOT NULL DEFAULT 0 COMMENT '构建结束是否已通知(0:未通知,1:已通知)', + + UNIQUE INDEX uk_build_team_env (build_id, team_id, environment_id), + INDEX idx_build (build_id), + INDEX idx_team_env (team_id, environment_id), + INDEX idx_deleted (deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Jenkins构建通知记录表'; + -- -------------------------------------------------------------------------------------- -- 通知渠道表 -- --------------------------------------------------------------------------------------