From 6c96deb7ae2a86b2f4109d75ffcec768b5712858 Mon Sep 17 00:00:00 2001 From: mingmingmon <96719969+mingmingmon@users.noreply.github.com> Date: Wed, 6 Nov 2024 15:41:19 +0900 Subject: [PATCH 01/10] =?UTF-8?q?refactor:=20=EA=B2=8C=EC=8B=9C=ED=8C=90?= =?UTF-8?q?=20=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC=20=EC=9E=AC=EB=B6=84?= =?UTF-8?q?=EB=A5=98=20=EC=99=84=EB=A3=8C=20(#608)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../page/clab/api/domain/community/board/domain/Board.java | 7 ------- .../api/domain/community/board/domain/BoardCategory.java | 4 ++-- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/main/java/page/clab/api/domain/community/board/domain/Board.java b/src/main/java/page/clab/api/domain/community/board/domain/Board.java index 9fb9970fc..c7ab80c84 100644 --- a/src/main/java/page/clab/api/domain/community/board/domain/Board.java +++ b/src/main/java/page/clab/api/domain/community/board/domain/Board.java @@ -50,10 +50,6 @@ public boolean isNotice() { return this.category.equals(BoardCategory.NOTICE); } - public boolean isGraduated() { - return this.category.equals(BoardCategory.GRADUATED); - } - public boolean shouldNotifyForNewBoard(MemberDetailedInfoDto memberInfo) { return memberInfo.isAdminRole() && this.category.equals(BoardCategory.NOTICE); // Assuming 2 is Admin role level } @@ -72,8 +68,5 @@ public void validateAccessPermissionForCreation(MemberDetailedInfoDto currentMem if (this.isNotice() && !currentMemberInfo.isAdminRole()) { throw new PermissionDeniedException("공지사항은 관리자만 작성할 수 있습니다."); } - if (this.isGraduated() && !currentMemberInfo.isGraduated()) { - throw new PermissionDeniedException("졸업생 게시판은 졸업생만 작성할 수 있습니다."); - } } } diff --git a/src/main/java/page/clab/api/domain/community/board/domain/BoardCategory.java b/src/main/java/page/clab/api/domain/community/board/domain/BoardCategory.java index 2783017ba..831bad896 100644 --- a/src/main/java/page/clab/api/domain/community/board/domain/BoardCategory.java +++ b/src/main/java/page/clab/api/domain/community/board/domain/BoardCategory.java @@ -9,8 +9,8 @@ public enum BoardCategory { NOTICE("notice", "공지사항"), FREE("free", "자유 게시판"), - QNA("qna", "질문 게시판"), - GRADUATED("graduated", "졸업생 게시판"), + DEVELOPMENT_QNA("development_qna", "개발 질문 게시판"), + INFORMATION_REVIEWS("information_reviews", "정보 및 후기 게시판"), ORGANIZATION("organization", "동아리 소식"); private final String key; From a15b0c65311a8b3c396b5e4b2e2ab16546df3449 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Nov 2024 18:46:47 +0900 Subject: [PATCH 02/10] chore(deps): bump org.mapstruct:mapstruct-processor from 1.6.2 to 1.6.3 (#612) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 859bea3f6..a19aa7f55 100644 --- a/build.gradle +++ b/build.gradle @@ -76,7 +76,7 @@ dependencies { // MapStruct implementation 'org.mapstruct:mapstruct:1.6.2' - annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.2' + annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' // IPInfo From d2cbe66ff901195011b798ac16dc682bb26a25e7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 12 Nov 2024 18:47:21 +0900 Subject: [PATCH 03/10] chore(deps): bump org.mapstruct:mapstruct from 1.6.2 to 1.6.3 (#613) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: 한관희 <85067003+limehee@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index a19aa7f55..ce60c823a 100644 --- a/build.gradle +++ b/build.gradle @@ -75,7 +75,7 @@ dependencies { implementation 'org.apache.commons:commons-lang3:3.17.0' // Apache Commons Lang // MapStruct - implementation 'org.mapstruct:mapstruct:1.6.2' + implementation 'org.mapstruct:mapstruct:1.6.3' annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.3' annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' From 6826bada9fe7c0aab3933b9c393b9f10b79b5398 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:29:30 +0900 Subject: [PATCH 04/10] chore(deps): bump commons-io:commons-io from 2.17.0 to 2.18.0 (#622) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index ce60c823a..e98760a2f 100644 --- a/build.gradle +++ b/build.gradle @@ -67,7 +67,7 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' // 롬복 implementation 'com.google.code.gson:gson:2.11.0' // JSON 라이브러리 implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' // Swagger - implementation 'commons-io:commons-io:2.17.0' // Apache Commons IO + implementation 'commons-io:commons-io:2.18.0' // Apache Commons IO implementation 'com.google.guava:guava:33.3.1-jre' // Google Core Libraries For Java implementation 'org.springframework.boot:spring-boot-starter-mail' // Spring Mail implementation 'com.google.zxing:core:3.4.1' // QR 코드 @@ -94,7 +94,7 @@ dependencies { implementation 'org.apache.commons:commons-text:1.11.0' // Apache Commons Text // Image - implementation 'commons-io:commons-io:2.17.0' + implementation 'commons-io:commons-io:2.18.0' implementation 'com.drewnoakes:metadata-extractor:2.19.0' implementation 'org.imgscalr:imgscalr-lib:4.2' From 027f662187bd5107bf9cad02e935b215ce37f2a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:30:13 +0900 Subject: [PATCH 05/10] chore(deps): bump com.slack.api:slack-app-backend from 1.44.1 to 1.44.2 (#620) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index e98760a2f..a7fbfdf72 100644 --- a/build.gradle +++ b/build.gradle @@ -86,7 +86,7 @@ dependencies { // Slack implementation 'com.slack.api:slack-api-model:1.44.1' implementation 'com.slack.api:slack-api-client:1.44.1' - implementation 'com.slack.api:slack-app-backend:1.44.1' + implementation 'com.slack.api:slack-app-backend:1.44.2' // XSS Filter implementation 'com.navercorp.lucy:lucy-xss-servlet:2.0.1' // Lucy XSS Servlet Filter From e1fa8bb056cd9e51e864699364cdc97418faff7f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:32:58 +0900 Subject: [PATCH 06/10] chore(deps): bump org.springdoc:springdoc-openapi-starter-webmvc-ui from 2.6.0 to 2.7.0 (#621) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index a7fbfdf72..fb13d4b92 100644 --- a/build.gradle +++ b/build.gradle @@ -66,7 +66,7 @@ dependencies { compileOnly 'org.projectlombok:lombok' // 롬복 annotationProcessor 'org.projectlombok:lombok' // 롬복 implementation 'com.google.code.gson:gson:2.11.0' // JSON 라이브러리 - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' // Swagger + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.7.0' // Swagger implementation 'commons-io:commons-io:2.18.0' // Apache Commons IO implementation 'com.google.guava:guava:33.3.1-jre' // Google Core Libraries For Java implementation 'org.springframework.boot:spring-boot-starter-mail' // Spring Mail From cb487fc70cfaa194bb76e1c4f1b8789999235102 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:35:29 +0900 Subject: [PATCH 07/10] chore(deps): bump com.slack.api:slack-api-model, client from 1.44.1 to 1.44.2 (#619) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: 한관희 <85067003+limehee@users.noreply.github.com> --- build.gradle | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/build.gradle b/build.gradle index fb13d4b92..c336fa5e0 100644 --- a/build.gradle +++ b/build.gradle @@ -84,8 +84,8 @@ dependencies { implementation 'io.ipinfo:ipinfo-api:3.0.0' // IPInfo API // Slack - implementation 'com.slack.api:slack-api-model:1.44.1' - implementation 'com.slack.api:slack-api-client:1.44.1' + implementation 'com.slack.api:slack-api-model:1.44.2' + implementation 'com.slack.api:slack-api-client:1.44.2' implementation 'com.slack.api:slack-app-backend:1.44.2' // XSS Filter From 5b55cf4f2890b1766c7a9bd31c40edf2b50c802c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 26 Nov 2024 13:35:41 +0900 Subject: [PATCH 08/10] chore(deps): bump org.springframework.boot from 3.3.5 to 3.4.0 (#618) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index c336fa5e0..7e692b19d 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.3.5' + id 'org.springframework.boot' version '3.4.0' id 'io.spring.dependency-management' version '1.1.6' } From 3ca00d5397b60fc6f65433fa0667ebd01bd73f14 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EA=B4=80=ED=9D=AC?= <85067003+limehee@users.noreply.github.com> Date: Wed, 27 Nov 2024 23:37:34 +0900 Subject: [PATCH 09/10] =?UTF-8?q?refactor:=20CD=20=ED=8C=8C=EC=9D=B4?= =?UTF-8?q?=ED=94=84=EB=9D=BC=EC=9D=B8=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=B5=9C=EC=A0=81=ED=99=94=20=EB=B0=8F=20=EB=B9=8C=EB=93=9C=20?= =?UTF-8?q?=EC=84=B1=EB=8A=A5=20=EA=B0=9C=EC=84=A0=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20Docker=20Multi-stage=20Build,=20Spring=20Layered=20?= =?UTF-8?q?JAR=20=EB=B0=8F=20BuildKit=20=EB=8F=84=EC=9E=85=20=EC=99=84?= =?UTF-8?q?=EB=A3=8C=20(#617)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 31 ++ build.gradle | 4 + gradle/wrapper/gradle-wrapper.properties | 2 +- jenkins/prod/Dockerfile | 38 +- jenkins/prod/Jenkinsfile | 507 ++++++++++------------- jenkins/stage/Dockerfile | 38 +- jenkins/stage/Jenkinsfile | 448 +++++++++----------- 7 files changed, 510 insertions(+), 558 deletions(-) create mode 100644 .dockerignore diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 000000000..b1bc83f2f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,31 @@ +# Ignore Git-related files +.git +.gitignore + +# Ignore local IDE files +.idea/ +*.iml +*.log + +# Ignore build directories +build/ +out/ +target/ + +# Ignore Docker-related files +Dockerfile +docker-compose.yml + +# Ignore other unnecessary files/directories +*.md +*.tmp +*.bak + +# Ignore specific directories +cloud/ +config/ +images/ +infra/ +jenkins/ +monitoring/ +nginx/ diff --git a/build.gradle b/build.gradle index 7e692b19d..d641c37ad 100644 --- a/build.gradle +++ b/build.gradle @@ -15,6 +15,10 @@ bootJar { archivesBaseName = "clab" archiveFileName = "clab.jar" archiveVersion = "1.0.0" + + layered { + enabled = true + } } java { diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 3499ded5c..4eaec4670 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.11.1-bin.zip networkTimeout=10000 zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/jenkins/prod/Dockerfile b/jenkins/prod/Dockerfile index 054f01fbf..aaa3a9dde 100644 --- a/jenkins/prod/Dockerfile +++ b/jenkins/prod/Dockerfile @@ -1,12 +1,32 @@ -# Use the official OpenJDK 21 image from the Docker Hub -FROM openjdk:21-jdk +# 1. Build Stage +FROM gradle:8.11.1-jdk21 AS build +WORKDIR /app -# Expose port 8080 to the outside world -EXPOSE 8080 +# Copy Gradle files and install dependencies +COPY build.gradle settings.gradle /app/ +RUN gradle dependencies --stacktrace -# Copy the JAR file into the container -COPY build/libs/clab.jar /clab.jar +# Copy source code and build +COPY src /app/src +RUN gradle bootJar --no-daemon --stacktrace -# Set the default active profile to 'stage'. Modify the 'spring.profiles.active' property to match your environment. -# For example, use '-Dspring.profiles.active=production' for production environment. -ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "/clab.jar"] +# Extract layers from JAR file +RUN java -Djarmode=layertools -jar build/libs/*.jar extract \ + && ls -l /app \ + && ls -l /app/dependencies \ + && ls -l /app/spring-boot-loader \ + && ls -l /app/snapshot-dependencies \ + && ls -l /app/application + +# 2. Runtime Stage +FROM eclipse-temurin:21-jre AS runtime +WORKDIR /app + +# Copy each layer +COPY --from=build /app/dependencies/ ./ +COPY --from=build /app/spring-boot-loader/ ./ +COPY --from=build /app/snapshot-dependencies/ ./ +COPY --from=build /app/application/ ./ + +# Run the application +ENTRYPOINT ["java", "-Dspring.profiles.active=prod", "org.springframework.boot.loader.launch.JarLauncher"] diff --git a/jenkins/prod/Jenkinsfile b/jenkins/prod/Jenkinsfile index 49ae770ab..9f0513946 100644 --- a/jenkins/prod/Jenkinsfile +++ b/jenkins/prod/Jenkinsfile @@ -18,7 +18,6 @@ */ def FAILED_STAGE = "" -def BACKUP_FILE = "" pipeline { agent any @@ -35,110 +34,48 @@ pipeline { } withCredentials([file(credentialsId: 'members_prod_config_yml', variable: 'CONFIG_FILE')]) { script { - def config = readYaml(file: env.CONFIG_FILE) - - env.JENKINS_DOMAIN = config.'jenkins-domain' - env.SLACK_WEBHOOK_URL = config.slack.'webhook-url' - env.SLACK_COLOR_SUCCESS = config.slack.'color-success' - env.SLACK_COLOR_FAILURE = config.slack.'color-failure' - - env.PG_USER = config.postgresql.user - env.PG_PASSWORD = config.postgresql.password - env.BACKUP_DIR = config.postgresql.'backup-dir' - - env.STAGING_USER = config.staging.'user' - env.STAGING_HOST = config.staging.'host' - env.STAGING_BACKUP_DIR_PATH = config.staging.'backup-dir-path' - env.STAGING_RESTORE_BACKUP_SCRIPT_PATH = config.staging.'restore-backup-script-path' - env.STAGING_SSH_PORT = config.staging.'ssh-port' - env.STAGING_PG_USER = config.staging.'postgresql-user' - - env.DOCKER_HUB_REPO = config.dockerhub.repo - env.DOCKER_HUB_USER = config.dockerhub.user - env.DOCKER_HUB_PASSWORD = config.dockerhub.password - - env.EXTERNAL_SERVER_CONFIG_PATH = config.'external-server'.'config-path' - env.EXTERNAL_SERVER_CLOUD_PATH = config.'external-server'.'cloud-path' - env.EXTERNAL_SERVER_LOGS_PATH = config.'external-server'.'logs-path' - - env.INTERNAL_SERVER_CONFIG_PATH = config.'internal-server'.'config-path' - env.INTERNAL_SERVER_CLOUD_PATH = config.'internal-server'.'cloud-path' - env.INTERNAL_SERVER_LOGS_PATH = config.'internal-server'.'logs-path' - - env.BLUE_CONTAINER = config.containers.blue - env.GREEN_CONTAINER = config.containers.green - env.BLUE_URL = config.containers.'blue-url' - env.GREEN_URL = config.containers.'green-url' - env.IMAGE_NAME = config.containers.'image-name' - - env.APPLICATION_NETWORK = config.networks.application - env.MONITORING_NETWORK = config.networks.monitoring - - env.PROFILE = config.spring.profile - env.PORT_A = config.spring.'port-a'.toString() - env.PORT_B = config.spring.'port-b'.toString() - - env.WHITELIST_ADMIN_USERNAME = config.admin.username - env.WHITELIST_ADMIN_PASSWORD = config.admin.password - - env.DOCKERFILE_PATH = "${env.WORKSPACE}${config.docker.'dockerfile-path'}" - env.NGINX_CONTAINER_NAME = config.docker.'nginx-container-name' - env.POSTGRESQL_CONTAINER_NAME = config.docker.'postgresql-container-name' + loadEnvironmentVariables(env.CONFIG_FILE) } } } } - stage('Check Java Version') { - steps { - script { - FAILED_STAGE = env.STAGE_NAME - } - sh 'java -version' - } - } - - stage('Get Git Change Log') { - steps { - script { - FAILED_STAGE = env.STAGE_NAME - env.GIT_CHANGELOG = getChangeLog() - } - } - } - - stage('PostgreSQL Backup') { - steps { - script { - FAILED_STAGE = env.STAGE_NAME - BACKUP_FILE = backupPostgres() + stage('Concurrent Pre-Build Steps') { + parallel { + stage('Get Git Change Log') { + steps { + script { + FAILED_STAGE = env.STAGE_NAME + env.GIT_CHANGELOG = getChangeLog() + } + } } - } - } - stage('Docker Hub Login') { - steps { - script { - FAILED_STAGE = env.STAGE_NAME - dockerLogin() + stage('PostgreSQL Backup') { + steps { + script { + FAILED_STAGE = env.STAGE_NAME + backupPostgres() + } + } } - } - } - stage('Determine Containers') { - steps { - script { - FAILED_STAGE = env.STAGE_NAME - determineContainers() + stage('Docker Hub Login') { + steps { + script { + FAILED_STAGE = env.STAGE_NAME + dockerLogin() + } + } } - } - } - stage('Build Application') { - steps { - script { - FAILED_STAGE = env.STAGE_NAME - buildApplication() + stage('Determine Containers') { + steps { + script { + FAILED_STAGE = env.STAGE_NAME + determineContainers() + } + } } } } @@ -170,29 +107,33 @@ pipeline { } } - stage('Transfer Backup to Staging') { - steps { - script { - FAILED_STAGE = env.STAGE_NAME - transferBackupToStaging(BACKUP_FILE) + stage('Backup Transfer and Deployment') { + parallel { + stage('Transfer Backup to Staging') { + steps { + script { + FAILED_STAGE = env.STAGE_NAME + transferBackupToStaging(BACKUP_FILE) + } + } } - } - } - stage('Restore Backup on Staging') { - steps { - script { - FAILED_STAGE = env.STAGE_NAME - restoreBackupOnStaging(BACKUP_FILE) + stage('Restore Backup on Staging') { + steps { + script { + FAILED_STAGE = env.STAGE_NAME + restoreBackupOnStaging(BACKUP_FILE) + } + } } - } - } - stage('Switch Traffic and Cleanup') { - steps { - script { - FAILED_STAGE = env.STAGE_NAME - switchTrafficAndCleanup() + stage('Switch Traffic and Cleanup') { + steps { + script { + FAILED_STAGE = env.STAGE_NAME + switchTrafficAndCleanup() + } + } } } } @@ -217,7 +158,14 @@ def sendSlackBuildNotification(String message, String color) { def jobUrl = "${env.JENKINS_DOMAIN}/job/${env.JOB_NAME}" def consoleOutputUrl = "${jobUrl}/${env.BUILD_NUMBER}/console" - def payload = [ + def payload = createSlackPayload(message, color, jobUrl, consoleOutputUrl) + def payloadJson = groovy.json.JsonOutput.toJson(payload) + + sendHttpPostRequest(env.SLACK_WEBHOOK_URL, payloadJson) +} + +def createSlackPayload(String message, String color, String jobUrl, String consoleOutputUrl) { + return [ blocks: [ [ type: "section", @@ -263,17 +211,74 @@ def sendSlackBuildNotification(String message, String color) { ] ] ] - ].findAll { it != null } + ] ] ] ] +} - withEnv(["SLACK_WEBHOOK_URL=${env.SLACK_WEBHOOK_URL}"]) { - def payloadJson = groovy.json.JsonOutput.toJson(payload) - sh """ - curl -X POST -H 'Content-type: application/json' --data '${payloadJson}' ${SLACK_WEBHOOK_URL} - """ - } +def sendHttpPostRequest(String url, String payload) { + def CONTENT_TYPE_JSON = 'application/json' + def HTTP_POST = 'POST' + + sh """ + curl -X ${HTTP_POST} \\ + -H 'Content-type: ${CONTENT_TYPE_JSON}' \\ + --data '${payload}' \\ + ${url} + """ +} + +def loadEnvironmentVariables(String configFile) { + def config = readYaml(file: configFile) + + env.JENKINS_DOMAIN = config.'jenkins-domain' + env.SLACK_WEBHOOK_URL = config.slack.'webhook-url' + env.SLACK_COLOR_SUCCESS = config.slack.'color-success' + env.SLACK_COLOR_FAILURE = config.slack.'color-failure' + + env.PG_USER = config.postgresql.user + env.PG_PASSWORD = config.postgresql.password + env.BACKUP_DIR = config.postgresql.'backup-dir' + + env.STAGING_USER = config.staging.'user' + env.STAGING_HOST = config.staging.'host' + env.STAGING_BACKUP_DIR_PATH = config.staging.'backup-dir-path' + env.STAGING_RESTORE_BACKUP_SCRIPT_PATH = config.staging.'restore-backup-script-path' + env.STAGING_SSH_PORT = config.staging.'ssh-port' + env.STAGING_PG_USER = config.staging.'postgresql-user' + + env.DOCKER_HUB_REPO = config.dockerhub.repo + env.DOCKER_HUB_USER = config.dockerhub.user + env.DOCKER_HUB_PASSWORD = config.dockerhub.password + + env.EXTERNAL_SERVER_CONFIG_PATH = config.'external-server'.'config-path' + env.EXTERNAL_SERVER_CLOUD_PATH = config.'external-server'.'cloud-path' + env.EXTERNAL_SERVER_LOGS_PATH = config.'external-server'.'logs-path' + + env.INTERNAL_SERVER_CONFIG_PATH = config.'internal-server'.'config-path' + env.INTERNAL_SERVER_CLOUD_PATH = config.'internal-server'.'cloud-path' + env.INTERNAL_SERVER_LOGS_PATH = config.'internal-server'.'logs-path' + + env.BLUE_CONTAINER = config.containers.blue + env.GREEN_CONTAINER = config.containers.green + env.BLUE_URL = config.containers.'blue-url' + env.GREEN_URL = config.containers.'green-url' + env.IMAGE_NAME = config.containers.'image-name' + + env.APPLICATION_NETWORK = config.networks.application + env.MONITORING_NETWORK = config.networks.monitoring + + env.PROFILE = config.spring.profile + env.PORT_A = config.spring.'port-a'.toString() + env.PORT_B = config.spring.'port-b'.toString() + + env.WHITELIST_ADMIN_USERNAME = config.admin.username + env.WHITELIST_ADMIN_PASSWORD = config.admin.password + + env.DOCKERFILE_PATH = "${env.WORKSPACE}${config.docker.'dockerfile-path'}" + env.NGINX_CONTAINER_NAME = config.docker.'nginx-container-name' + env.POSTGRESQL_CONTAINER_NAME = config.docker.'postgresql-container-name' } def getChangeLog() { @@ -295,163 +300,106 @@ def getChangeLog() { def backupPostgres() { def BACKUP_FILE = "postgres_backup_${new Date().format('yyyy-MM-dd_HH-mm-ss')}.sql" - withEnv([ - "BACKUP_DIR=${env.BACKUP_DIR}", - "POSTGRESQL_CONTAINER_NAME=${env.POSTGRESQL_CONTAINER_NAME}", - "PG_PASSWORD=${env.PG_PASSWORD}", - "PG_USER=${env.PG_USER}" - ]) { - sh """ - echo "Backing up PostgreSQL database to ${BACKUP_DIR}/${BACKUP_FILE}..." - echo "Executing as user: \$(whoami)" - docker exec -e PGPASSWORD=${PG_PASSWORD} ${POSTGRESQL_CONTAINER_NAME} sh -c 'pg_dumpall -c -U ${PG_USER} > ${BACKUP_DIR}/${BACKUP_FILE}' - """ - } - return BACKUP_FILE + sh """ + echo "Backing up PostgreSQL database to ${env.BACKUP_DIR}/${BACKUP_FILE}..." + docker exec -e PGPASSWORD=${env.PG_PASSWORD} ${env.POSTGRESQL_CONTAINER_NAME} sh -c 'pg_dumpall -c -U ${env.PG_USER} > ${env.BACKUP_DIR}/${BACKUP_FILE}' + """ } def dockerLogin() { - withEnv(["DOCKER_HUB_PASSWORD=${env.DOCKER_HUB_PASSWORD}", "DOCKER_HUB_USER=${env.DOCKER_HUB_USER}"]) { - sh """ - echo "Logging in to Docker Hub..." - echo "${DOCKER_HUB_PASSWORD}" | docker login -u ${DOCKER_HUB_USER} --password-stdin - """ - } + sh """ + echo "Logging in to Docker Hub..." + echo "${env.DOCKER_HUB_PASSWORD}" | docker login -u "${env.DOCKER_HUB_USER}" --password-stdin + """ } def determineContainers() { script { - withEnv([ - "BLUE_CONTAINER=${env.BLUE_CONTAINER}", - "GREEN_CONTAINER=${env.GREEN_CONTAINER}", - "BLUE_URL=${env.BLUE_URL}", - "GREEN_URL=${env.GREEN_URL}", - "PORT_A=${env.PORT_A}", - "PORT_B=${env.PORT_B}" - ]) { - def blueRunning = sh(script: "docker ps --filter 'name=${BLUE_CONTAINER}' --format '{{.Names}}' | grep -q '${BLUE_CONTAINER}'", returnStatus: true) == 0 - if (blueRunning) { - env.CURRENT_CONTAINER = BLUE_CONTAINER - env.DEPLOY_CONTAINER = GREEN_CONTAINER - env.NEW_TARGET = GREEN_URL - env.NEW_PORT = PORT_B - env.OLD_PORT = PORT_A - } else { - env.CURRENT_CONTAINER = GREEN_CONTAINER - env.DEPLOY_CONTAINER = BLUE_CONTAINER - env.NEW_TARGET = BLUE_URL - env.NEW_PORT = PORT_A - env.OLD_PORT = PORT_B - } - echo "Current container is ${env.CURRENT_CONTAINER}, deploying to ${env.DEPLOY_CONTAINER} on port ${env.NEW_PORT}." + def blueRunning = sh(script: "docker ps --filter 'name=${env.BLUE_CONTAINER}' --format '{{.Names}}' | grep -q '${env.BLUE_CONTAINER}'", returnStatus: true) == 0 + if (blueRunning) { + env.CURRENT_CONTAINER = env.BLUE_CONTAINER + env.DEPLOY_CONTAINER = env.GREEN_CONTAINER + env.NEW_TARGET = env.GREEN_URL + env.NEW_PORT = env.PORT_B + env.OLD_PORT = env.PORT_A + } else { + env.CURRENT_CONTAINER = env.GREEN_CONTAINER + env.DEPLOY_CONTAINER = env.BLUE_CONTAINER + env.NEW_TARGET = env.BLUE_URL + env.NEW_PORT = env.PORT_A + env.OLD_PORT = env.PORT_B } - } -} - -def buildApplication() { - withEnv([ - "PROFILE=${env.PROFILE}" - ]) { - sh """ - echo "Building application with profile ${PROFILE}..." - ./gradlew clean build -Penv=${PROFILE} --stacktrace --info - """ + echo "Current container is ${env.CURRENT_CONTAINER}, deploying to ${env.DEPLOY_CONTAINER} on port ${env.NEW_PORT}." } } def buildAndPushDockerImage() { - withEnv([ - "DOCKER_HUB_REPO=${env.DOCKER_HUB_REPO}", - "DEPLOY_CONTAINER=${env.DEPLOY_CONTAINER}", - "DOCKERFILE_PATH=${env.DOCKERFILE_PATH}", - "IMAGE_NAME=${env.IMAGE_NAME}" - ]) { - sh """ - docker build -f ${DOCKERFILE_PATH} -t ${IMAGE_NAME}:${DEPLOY_CONTAINER} . - docker tag ${IMAGE_NAME}:${DEPLOY_CONTAINER} ${DOCKER_HUB_REPO}:${DEPLOY_CONTAINER} - docker push ${DOCKER_HUB_REPO}:${DEPLOY_CONTAINER} - """ - } + sh """ + DOCKER_BUILDKIT=1 docker build -f ${env.DOCKERFILE_PATH} -t ${env.IMAGE_NAME}:${env.DEPLOY_CONTAINER} . + docker tag ${env.IMAGE_NAME}:${env.DEPLOY_CONTAINER} ${env.DOCKER_HUB_REPO}:${env.DEPLOY_CONTAINER} + docker push ${env.DOCKER_HUB_REPO}:${env.DEPLOY_CONTAINER} + docker logout + """ } def deployNewInstance() { - withEnv([ - "PROFILE=${env.PROFILE}", - "NEW_PORT=${env.NEW_PORT}", - "APPLICATION_NETWORK=${env.APPLICATION_NETWORK}", - "MONITORING_NETWORK=${env.MONITORING_NETWORK}", - "EXTERNAL_SERVER_CONFIG_PATH=${env.EXTERNAL_SERVER_CONFIG_PATH}", - "EXTERNAL_SERVER_CLOUD_PATH=${env.EXTERNAL_SERVER_CLOUD_PATH}", - "EXTERNAL_SERVER_LOGS_PATH=${env.EXTERNAL_SERVER_LOGS_PATH}", - "INTERNAL_SERVER_CONFIG_PATH=${env.INTERNAL_SERVER_CONFIG_PATH}", - "INTERNAL_SERVER_CLOUD_PATH=${env.INTERNAL_SERVER_CLOUD_PATH}", - "INTERNAL_SERVER_LOGS_PATH=${env.INTERNAL_SERVER_LOGS_PATH}", - "DEPLOY_CONTAINER=${env.DEPLOY_CONTAINER}", - "IMAGE_NAME=${env.IMAGE_NAME}" - ]) { - sh """ - echo "Stopping and removing existing container if it exists" - if docker ps | grep -q ${DEPLOY_CONTAINER}; then - docker stop ${DEPLOY_CONTAINER} - docker rm ${DEPLOY_CONTAINER} - fi - - echo "Running new container ${DEPLOY_CONTAINER} with image ${IMAGE_NAME}:${DEPLOY_CONTAINER}" - docker run -d --name ${DEPLOY_CONTAINER} \\ - -p ${NEW_PORT}:8080 \\ - --network ${APPLICATION_NETWORK} \\ - -v ${EXTERNAL_SERVER_CONFIG_PATH}:${INTERNAL_SERVER_CONFIG_PATH} \\ - -v ${EXTERNAL_SERVER_CLOUD_PATH}:${INTERNAL_SERVER_CLOUD_PATH} \\ - -v ${EXTERNAL_SERVER_LOGS_PATH}:${INTERNAL_SERVER_LOGS_PATH} \\ - -e LOG_PATH=${INTERNAL_SERVER_LOGS_PATH} \\ - -e SPRING_PROFILES_ACTIVE=${PROFILE} \\ - ${IMAGE_NAME}:${DEPLOY_CONTAINER} - - echo "Checking if monitoring network ${MONITORING_NETWORK} exists" - if docker network ls --format '{{.Name}}' | grep -q '^${MONITORING_NETWORK}\$'; then - echo "Connecting to monitoring network ${MONITORING_NETWORK}" - docker network connect ${MONITORING_NETWORK} ${DEPLOY_CONTAINER} - else - echo "Monitoring network ${MONITORING_NETWORK} does not exist. Skipping connection." - fi - - echo "Listing all containers" - docker ps -a - """ - } + sh """ + echo "Stopping and removing existing container if it exists" + if docker ps | grep -q ${env.DEPLOY_CONTAINER}; then + docker stop ${env.DEPLOY_CONTAINER} + docker rm ${env.DEPLOY_CONTAINER} + fi + + echo "Running new container ${env.DEPLOY_CONTAINER} with image ${env.IMAGE_NAME}:${env.DEPLOY_CONTAINER}" + docker run -d --name ${env.DEPLOY_CONTAINER} \\ + -p ${env.NEW_PORT}:8080 \\ + --network ${env.APPLICATION_NETWORK} \\ + -v ${env.EXTERNAL_SERVER_CONFIG_PATH}:${env.INTERNAL_SERVER_CONFIG_PATH} \\ + -v ${env.EXTERNAL_SERVER_CLOUD_PATH}:${env.INTERNAL_SERVER_CLOUD_PATH} \\ + -v ${env.EXTERNAL_SERVER_LOGS_PATH}:${env.INTERNAL_SERVER_LOGS_PATH} \\ + -e LOG_PATH=${env.INTERNAL_SERVER_LOGS_PATH} \\ + -e SPRING_PROFILES_ACTIVE=${env.PROFILE} \\ + ${env.IMAGE_NAME}:${env.DEPLOY_CONTAINER} + + echo "Checking if monitoring network ${env.MONITORING_NETWORK} exists" + if docker network ls --format '{{.Name}}' | grep -q '^${env.MONITORING_NETWORK}\$'; then + echo "Connecting to monitoring network ${env.MONITORING_NETWORK}" + docker network connect ${env.MONITORING_NETWORK} ${env.DEPLOY_CONTAINER} + else + echo "Monitoring network ${env.MONITORING_NETWORK} does not exist. Skipping connection." + fi + + echo "Listing all containers" + docker ps -a + """ } def performHealthCheck() { - withEnv([ - "WHITELIST_ADMIN_USERNAME=${env.WHITELIST_ADMIN_USERNAME}", - "WHITELIST_ADMIN_PASSWORD=${env.WHITELIST_ADMIN_PASSWORD}" - ]) { - def PUBLIC_IP = sh(script: "curl -s ifconfig.me", returnStdout: true).trim() - echo "Public IP address: ${PUBLIC_IP}" - - def start_time = System.currentTimeMillis() - def timeout = start_time + 150000 // 2.5 minutes - - while (System.currentTimeMillis() < timeout) { - def elapsed = (System.currentTimeMillis() - start_time) / 1000 - echo "Checking health... ${elapsed} seconds elapsed." - def status = sh( - script: """curl -s -u ${WHITELIST_ADMIN_USERNAME}:${WHITELIST_ADMIN_PASSWORD} \ - http://${PUBLIC_IP}:${env.NEW_PORT}/actuator/health | grep 'UP'""", - returnStatus: true - ) - if (status == 0) { - echo "New application started successfully after ${elapsed} seconds." - return - } - sleep 5 + def PUBLIC_IP = sh(script: "curl -s ifconfig.me", returnStdout: true).trim() + echo "Public IP address: ${PUBLIC_IP}" + + def start_time = System.currentTimeMillis() + def timeout = start_time + 150000 // 2.5 minutes + + while (System.currentTimeMillis() < timeout) { + def elapsed = (System.currentTimeMillis() - start_time) / 1000 + echo "Checking health... ${elapsed} seconds elapsed." + def status = sh( + script: """curl -s -u ${env.WHITELIST_ADMIN_USERNAME}:${env.WHITELIST_ADMIN_PASSWORD} \ + http://${PUBLIC_IP}:${env.NEW_PORT}/actuator/health | grep 'UP'""", + returnStatus: true + ) + if (status == 0) { + echo "New application started successfully after ${elapsed} seconds." + return } + sleep 5 + } - if (System.currentTimeMillis() >= timeout) { - sh "docker stop ${env.DEPLOY_CONTAINER}" - sh "docker rm ${env.DEPLOY_CONTAINER}" - error "Health check failed" - } + if (System.currentTimeMillis() >= timeout) { + sh "docker stop ${env.DEPLOY_CONTAINER}" + sh "docker rm ${env.DEPLOY_CONTAINER}" + error "Health check failed" } } @@ -471,30 +419,21 @@ def restoreBackupOnStaging(String BACKUP_FILE) { } def switchTrafficAndCleanup() { - withEnv([ - "NEW_PORT=${env.NEW_PORT}", - "OLD_PORT=${env.OLD_PORT}", - "NEW_TARGET=${env.NEW_TARGET}", - "CURRENT_CONTAINER=${env.CURRENT_CONTAINER}", - "DEPLOY_CONTAINER=${env.DEPLOY_CONTAINER}", - "NGINX_CONTAINER_NAME=${env.NGINX_CONTAINER_NAME}" - ]) { - sh """ - echo "Switching traffic to ${DEPLOY_CONTAINER} on port ${NEW_PORT}." - docker exec ${NGINX_CONTAINER_NAME} bash -c ' - export BACKEND_URL=${NEW_TARGET} - envsubst "\\\$BACKEND_URL" < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf - ' - docker exec ${NGINX_CONTAINER_NAME} sed -i 's/${OLD_PORT}/${NEW_PORT}/' /etc/nginx/conf.d/default.conf - docker exec ${NGINX_CONTAINER_NAME} nginx -t - docker exec ${NGINX_CONTAINER_NAME} nginx -s reload - - echo "Checking if current container ${CURRENT_CONTAINER} is running..." - if docker ps | grep -q ${CURRENT_CONTAINER}; then - docker stop ${CURRENT_CONTAINER} - docker rm ${CURRENT_CONTAINER} - echo "Removed old container ${CURRENT_CONTAINER}." - fi - """ - } + sh """ + echo "Switching traffic to ${env.DEPLOY_CONTAINER} on port ${env.NEW_PORT}." + docker exec ${env.NGINX_CONTAINER_NAME} bash -c ' + export BACKEND_URL=${env.NEW_TARGET} + envsubst "\\\$BACKEND_URL" < /etc/nginx/conf.d/members.conf.template > /etc/nginx/conf.d/members.conf + ' + docker exec ${env.NGINX_CONTAINER_NAME} sed -i 's/${env.OLD_PORT}/${env.NEW_PORT}/' /etc/nginx/conf.d/members.conf + docker exec ${env.NGINX_CONTAINER_NAME} nginx -t + docker exec ${env.NGINX_CONTAINER_NAME} nginx -s reload + + echo "Checking if current container ${env.CURRENT_CONTAINER} is running..." + if docker ps | grep -q ${env.CURRENT_CONTAINER}; then + docker stop ${env.CURRENT_CONTAINER} + docker rm ${env.CURRENT_CONTAINER} + echo "Removed old container ${env.CURRENT_CONTAINER}." + fi + """ } diff --git a/jenkins/stage/Dockerfile b/jenkins/stage/Dockerfile index bf0236339..b7f76d4a6 100644 --- a/jenkins/stage/Dockerfile +++ b/jenkins/stage/Dockerfile @@ -1,12 +1,32 @@ -# Use the official OpenJDK 21 image from the Docker Hub -FROM openjdk:21-jdk +# 1. Build Stage +FROM gradle:8.11.1-jdk21 AS build +WORKDIR /app -# Expose port 8080 to the outside world -EXPOSE 8080 +# Copy Gradle files and install dependencies +COPY build.gradle settings.gradle /app/ +RUN gradle dependencies --stacktrace -# Copy the JAR file into the container -COPY build/libs/clab.jar /clab.jar +# Copy source code and build +COPY src /app/src +RUN gradle bootJar --no-daemon --build-cache --stacktrace -# Set the default active profile to 'stage'. Modify the 'spring.profiles.active' property to match your environment. -# For example, use '-Dspring.profiles.active=production' for production environment. -ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=stage", "/clab.jar"] +# Extract layers from JAR file +RUN java -Djarmode=layertools -jar build/libs/*.jar extract \ + && ls -l /app \ + && ls -l /app/dependencies \ + && ls -l /app/spring-boot-loader \ + && ls -l /app/snapshot-dependencies \ + && ls -l /app/application + +# 2. Runtime Stage +FROM eclipse-temurin:21-jre AS runtime +WORKDIR /app + +# Copy each layer +COPY --from=build /app/dependencies/ ./ +COPY --from=build /app/spring-boot-loader/ ./ +COPY --from=build /app/snapshot-dependencies/ ./ +COPY --from=build /app/application/ ./ + +# Run the application +ENTRYPOINT ["java", "-Dspring.profiles.active=stage", "org.springframework.boot.loader.launch.JarLauncher"] diff --git a/jenkins/stage/Jenkinsfile b/jenkins/stage/Jenkinsfile index 00d96cce1..d22b26d02 100644 --- a/jenkins/stage/Jenkinsfile +++ b/jenkins/stage/Jenkinsfile @@ -34,103 +34,48 @@ pipeline { } withCredentials([file(credentialsId: 'members_stage_config_yml', variable: 'CONFIG_FILE')]) { script { - def config = readYaml(file: env.CONFIG_FILE) - - env.JENKINS_DOMAIN = config.'jenkins-domain' - env.SLACK_WEBHOOK_URL = config.slack.'webhook-url' - env.SLACK_COLOR_SUCCESS = config.slack.'color-success' - env.SLACK_COLOR_FAILURE = config.slack.'color-failure' - - env.PG_USER = config.postgresql.user - env.PG_PASSWORD = config.postgresql.password - env.BACKUP_DIR = config.postgresql.'backup-dir' - - env.DOCKER_HUB_REPO = config.dockerhub.repo - env.DOCKER_HUB_USER = config.dockerhub.user - env.DOCKER_HUB_PASSWORD = config.dockerhub.password - - env.EXTERNAL_SERVER_CONFIG_PATH = config.'external-server'.'config-path' - env.EXTERNAL_SERVER_CLOUD_PATH = config.'external-server'.'cloud-path' - env.EXTERNAL_SERVER_LOGS_PATH = config.'external-server'.'logs-path' - - env.INTERNAL_SERVER_CONFIG_PATH = config.'internal-server'.'config-path' - env.INTERNAL_SERVER_CLOUD_PATH = config.'internal-server'.'cloud-path' - env.INTERNAL_SERVER_LOGS_PATH = config.'internal-server'.'logs-path' - - env.BLUE_CONTAINER = config.containers.blue - env.GREEN_CONTAINER = config.containers.green - env.BLUE_URL = config.containers.'blue-url' - env.GREEN_URL = config.containers.'green-url' - env.IMAGE_NAME = config.containers.'image-name' - - env.APPLICATION_NETWORK = config.networks.application - env.MONITORING_NETWORK = config.networks.monitoring - - env.PROFILE = config.spring.profile - env.PORT_A = config.spring.'port-a'.toString() - env.PORT_B = config.spring.'port-b'.toString() - - env.WHITELIST_ADMIN_USERNAME = config.admin.username - env.WHITELIST_ADMIN_PASSWORD = config.admin.password - - env.DOCKERFILE_PATH = "${env.WORKSPACE}${config.docker.'dockerfile-path'}" - env.NGINX_CONTAINER_NAME = config.docker.'nginx-container-name' - env.POSTGRESQL_CONTAINER_NAME = config.docker.'postgresql-container-name' + loadEnvironmentVariables(env.CONFIG_FILE) } } } } - stage('Check Java Version') { - steps { - script { - FAILED_STAGE = env.STAGE_NAME - } - sh 'java -version' - } - } - - stage('Get Git Change Log') { - steps { - script { - FAILED_STAGE = env.STAGE_NAME - env.GIT_CHANGELOG = getChangeLog() - } - } - } - - stage('PostgreSQL Backup') { - steps { - script { - FAILED_STAGE = env.STAGE_NAME - backupPostgres() + stage('Concurrent Pre-Build Steps') { + parallel { + stage('Get Git Change Log') { + steps { + script { + FAILED_STAGE = env.STAGE_NAME + env.GIT_CHANGELOG = getChangeLog() + } + } } - } - } - stage('Docker Hub Login') { - steps { - script { - FAILED_STAGE = env.STAGE_NAME - dockerLogin() + stage('PostgreSQL Backup') { + steps { + script { + FAILED_STAGE = env.STAGE_NAME + backupPostgres() + } + } } - } - } - stage('Determine Containers') { - steps { - script { - FAILED_STAGE = env.STAGE_NAME - determineContainers() + stage('Docker Hub Login') { + steps { + script { + FAILED_STAGE = env.STAGE_NAME + dockerLogin() + } + } } - } - } - stage('Build Application') { - steps { - script { - FAILED_STAGE = env.STAGE_NAME - buildApplication() + stage('Determine Containers') { + steps { + script { + FAILED_STAGE = env.STAGE_NAME + determineContainers() + } + } } } } @@ -191,7 +136,14 @@ def sendSlackBuildNotification(String message, String color) { def jobUrl = "${env.JENKINS_DOMAIN}/job/${env.JOB_NAME}" def consoleOutputUrl = "${jobUrl}/${env.BUILD_NUMBER}/console" - def payload = [ + def payload = createSlackPayload(message, color, jobUrl, consoleOutputUrl) + def payloadJson = groovy.json.JsonOutput.toJson(payload) + + sendHttpPostRequest(env.SLACK_WEBHOOK_URL, payloadJson) +} + +def createSlackPayload(String message, String color, String jobUrl, String consoleOutputUrl) { + return [ blocks: [ [ type: "section", @@ -237,17 +189,67 @@ def sendSlackBuildNotification(String message, String color) { ] ] ] - ].findAll { it != null } + ] ] ] ] +} - withEnv(["SLACK_WEBHOOK_URL=${env.SLACK_WEBHOOK_URL}"]) { - def payloadJson = groovy.json.JsonOutput.toJson(payload) - sh """ - curl -X POST -H 'Content-type: application/json' --data '${payloadJson}' ${SLACK_WEBHOOK_URL} - """ - } +def sendHttpPostRequest(String url, String payload) { + def CONTENT_TYPE_JSON = 'application/json' + def HTTP_POST = 'POST' + + sh """ + curl -X ${HTTP_POST} \\ + -H 'Content-type: ${CONTENT_TYPE_JSON}' \\ + --data '${payload}' \\ + ${url} + """ +} + +def loadEnvironmentVariables(String configFile) { + def config = readYaml(file: configFile) + + env.JENKINS_DOMAIN = config.'jenkins-domain' + env.SLACK_WEBHOOK_URL = config.slack.'webhook-url' + env.SLACK_COLOR_SUCCESS = config.slack.'color-success' + env.SLACK_COLOR_FAILURE = config.slack.'color-failure' + + env.PG_USER = config.postgresql.user + env.PG_PASSWORD = config.postgresql.password + env.BACKUP_DIR = config.postgresql.'backup-dir' + + env.DOCKER_HUB_REPO = config.dockerhub.repo + env.DOCKER_HUB_USER = config.dockerhub.user + env.DOCKER_HUB_PASSWORD = config.dockerhub.password + + env.EXTERNAL_SERVER_CONFIG_PATH = config.'external-server'.'config-path' + env.EXTERNAL_SERVER_CLOUD_PATH = config.'external-server'.'cloud-path' + env.EXTERNAL_SERVER_LOGS_PATH = config.'external-server'.'logs-path' + + env.INTERNAL_SERVER_CONFIG_PATH = config.'internal-server'.'config-path' + env.INTERNAL_SERVER_CLOUD_PATH = config.'internal-server'.'cloud-path' + env.INTERNAL_SERVER_LOGS_PATH = config.'internal-server'.'logs-path' + + env.BLUE_CONTAINER = config.containers.blue + env.GREEN_CONTAINER = config.containers.green + env.BLUE_URL = config.containers.'blue-url' + env.GREEN_URL = config.containers.'green-url' + env.IMAGE_NAME = config.containers.'image-name' + + env.APPLICATION_NETWORK = config.networks.application + env.MONITORING_NETWORK = config.networks.monitoring + + env.PROFILE = config.spring.profile + env.PORT_A = config.spring.'port-a'.toString() + env.PORT_B = config.spring.'port-b'.toString() + + env.WHITELIST_ADMIN_USERNAME = config.admin.username + env.WHITELIST_ADMIN_PASSWORD = config.admin.password + + env.DOCKERFILE_PATH = "${env.WORKSPACE}${config.docker.'dockerfile-path'}" + env.NGINX_CONTAINER_NAME = config.docker.'nginx-container-name' + env.POSTGRESQL_CONTAINER_NAME = config.docker.'postgresql-container-name' } def getChangeLog() { @@ -269,189 +271,125 @@ def getChangeLog() { def backupPostgres() { def BACKUP_FILE = "postgres_backup_${new Date().format('yyyy-MM-dd_HH-mm-ss')}.sql" - withEnv([ - "BACKUP_DIR=${env.BACKUP_DIR}", - "POSTGRESQL_CONTAINER_NAME=${env.POSTGRESQL_CONTAINER_NAME}", - "PG_PASSWORD=${env.PG_PASSWORD}", - "PG_USER=${env.PG_USER}" - ]) { - sh """ - echo "Backing up PostgreSQL database to ${BACKUP_DIR}/${BACKUP_FILE}..." - docker exec -e PGPASSWORD=${PG_PASSWORD} ${POSTGRESQL_CONTAINER_NAME} sh -c 'pg_dumpall -c -U ${PG_USER} > ${BACKUP_DIR}/${BACKUP_FILE}' - """ - } + sh """ + echo "Backing up PostgreSQL database to ${env.BACKUP_DIR}/${BACKUP_FILE}..." + docker exec -e PGPASSWORD=${env.PG_PASSWORD} ${env.POSTGRESQL_CONTAINER_NAME} sh -c 'pg_dumpall -c -U ${env.PG_USER} > ${env.BACKUP_DIR}/${BACKUP_FILE}' + """ } def dockerLogin() { - withEnv(["DOCKER_HUB_PASSWORD=${env.DOCKER_HUB_PASSWORD}", "DOCKER_HUB_USER=${env.DOCKER_HUB_USER}"]) { - sh """ - echo "Logging in to Docker Hub..." - echo "${DOCKER_HUB_PASSWORD}" | docker login -u ${DOCKER_HUB_USER} --password-stdin - """ - } + sh """ + echo "Logging in to Docker Hub..." + echo "${env.DOCKER_HUB_PASSWORD}" | docker login -u "${env.DOCKER_HUB_USER}" --password-stdin + """ } def determineContainers() { script { - withEnv([ - "BLUE_CONTAINER=${env.BLUE_CONTAINER}", - "GREEN_CONTAINER=${env.GREEN_CONTAINER}", - "BLUE_URL=${env.BLUE_URL}", - "GREEN_URL=${env.GREEN_URL}", - "PORT_A=${env.PORT_A}", - "PORT_B=${env.PORT_B}" - ]) { - def blueRunning = sh(script: "docker ps --filter 'name=${BLUE_CONTAINER}' --format '{{.Names}}' | grep -q '${BLUE_CONTAINER}'", returnStatus: true) == 0 - if (blueRunning) { - env.CURRENT_CONTAINER = BLUE_CONTAINER - env.DEPLOY_CONTAINER = GREEN_CONTAINER - env.NEW_TARGET = GREEN_URL - env.NEW_PORT = PORT_B - env.OLD_PORT = PORT_A - } else { - env.CURRENT_CONTAINER = GREEN_CONTAINER - env.DEPLOY_CONTAINER = BLUE_CONTAINER - env.NEW_TARGET = BLUE_URL - env.NEW_PORT = PORT_A - env.OLD_PORT = PORT_B - } - echo "Current container is ${env.CURRENT_CONTAINER}, deploying to ${env.DEPLOY_CONTAINER} on port ${env.NEW_PORT}." + def blueRunning = sh(script: "docker ps --filter 'name=${env.BLUE_CONTAINER}' --format '{{.Names}}' | grep -q '${env.BLUE_CONTAINER}'", returnStatus: true) == 0 + if (blueRunning) { + env.CURRENT_CONTAINER = env.BLUE_CONTAINER + env.DEPLOY_CONTAINER = env.GREEN_CONTAINER + env.NEW_TARGET = env.GREEN_URL + env.NEW_PORT = env.PORT_B + env.OLD_PORT = env.PORT_A + } else { + env.CURRENT_CONTAINER = env.GREEN_CONTAINER + env.DEPLOY_CONTAINER = env.BLUE_CONTAINER + env.NEW_TARGET = env.BLUE_URL + env.NEW_PORT = env.PORT_A + env.OLD_PORT = env.PORT_B } - } -} - -def buildApplication() { - withEnv([ - "PROFILE=${env.PROFILE}" - ]) { - sh """ - echo "Building application with profile ${PROFILE}..." - ./gradlew clean build -Penv=${PROFILE} --stacktrace --info - """ + echo "Current container is ${env.CURRENT_CONTAINER}, deploying to ${env.DEPLOY_CONTAINER} on port ${env.NEW_PORT}." } } def buildAndPushDockerImage() { - withEnv([ - "DOCKER_HUB_REPO=${env.DOCKER_HUB_REPO}", - "DEPLOY_CONTAINER=${env.DEPLOY_CONTAINER}", - "DOCKERFILE_PATH=${env.DOCKERFILE_PATH}", - "IMAGE_NAME=${env.IMAGE_NAME}" - ]) { - sh """ - docker build -f ${DOCKERFILE_PATH} -t ${IMAGE_NAME}:${DEPLOY_CONTAINER} . - docker tag ${IMAGE_NAME}:${DEPLOY_CONTAINER} ${DOCKER_HUB_REPO}:${DEPLOY_CONTAINER} - docker push ${DOCKER_HUB_REPO}:${DEPLOY_CONTAINER} - """ - } + sh """ + DOCKER_BUILDKIT=1 docker build -f ${env.DOCKERFILE_PATH} -t ${env.IMAGE_NAME}:${env.DEPLOY_CONTAINER} . + docker tag ${env.IMAGE_NAME}:${env.DEPLOY_CONTAINER} ${env.DOCKER_HUB_REPO}:${env.DEPLOY_CONTAINER} + docker push ${env.DOCKER_HUB_REPO}:${env.DEPLOY_CONTAINER} + docker logout + """ } def deployNewInstance() { - withEnv([ - "PROFILE=${env.PROFILE}", - "NEW_PORT=${env.NEW_PORT}", - "APPLICATION_NETWORK=${env.APPLICATION_NETWORK}", - "MONITORING_NETWORK=${env.MONITORING_NETWORK}", - "EXTERNAL_SERVER_CONFIG_PATH=${env.EXTERNAL_SERVER_CONFIG_PATH}", - "EXTERNAL_SERVER_CLOUD_PATH=${env.EXTERNAL_SERVER_CLOUD_PATH}", - "EXTERNAL_SERVER_LOGS_PATH=${env.EXTERNAL_SERVER_LOGS_PATH}", - "INTERNAL_SERVER_CONFIG_PATH=${env.INTERNAL_SERVER_CONFIG_PATH}", - "INTERNAL_SERVER_CLOUD_PATH=${env.INTERNAL_SERVER_CLOUD_PATH}", - "INTERNAL_SERVER_LOGS_PATH=${env.INTERNAL_SERVER_LOGS_PATH}", - "DEPLOY_CONTAINER=${env.DEPLOY_CONTAINER}", - "IMAGE_NAME=${env.IMAGE_NAME}" - ]) { - sh """ - echo "Stopping and removing existing container if it exists" - if docker ps | grep -q ${DEPLOY_CONTAINER}; then - docker stop ${DEPLOY_CONTAINER} - docker rm ${DEPLOY_CONTAINER} - fi - - echo "Running new container ${DEPLOY_CONTAINER} with image ${IMAGE_NAME}:${DEPLOY_CONTAINER}" - docker run -d --name ${DEPLOY_CONTAINER} \\ - -p ${NEW_PORT}:8080 \\ - --network ${APPLICATION_NETWORK} \\ - -v ${EXTERNAL_SERVER_CONFIG_PATH}:${INTERNAL_SERVER_CONFIG_PATH} \\ - -v ${EXTERNAL_SERVER_CLOUD_PATH}:${INTERNAL_SERVER_CLOUD_PATH} \\ - -v ${EXTERNAL_SERVER_LOGS_PATH}:${INTERNAL_SERVER_LOGS_PATH} \\ - -e LOG_PATH=${INTERNAL_SERVER_LOGS_PATH} \\ - -e SPRING_PROFILES_ACTIVE=${PROFILE} \\ - ${IMAGE_NAME}:${DEPLOY_CONTAINER} - - echo "Checking if monitoring network ${MONITORING_NETWORK} exists" - if docker network ls --format '{{.Name}}' | grep -q '^${MONITORING_NETWORK}\$'; then - echo "Connecting to monitoring network ${MONITORING_NETWORK}" - docker network connect ${MONITORING_NETWORK} ${DEPLOY_CONTAINER} - else - echo "Monitoring network ${MONITORING_NETWORK} does not exist. Skipping connection." - fi - - echo "Listing all containers" - docker ps -a - """ - } + sh """ + echo "Stopping and removing existing container if it exists" + if docker ps | grep -q ${env.DEPLOY_CONTAINER}; then + docker stop ${env.DEPLOY_CONTAINER} + docker rm ${env.DEPLOY_CONTAINER} + fi + + echo "Running new container ${env.DEPLOY_CONTAINER} with image ${env.IMAGE_NAME}:${env.DEPLOY_CONTAINER}" + docker run -d --name ${env.DEPLOY_CONTAINER} \\ + -p ${env.NEW_PORT}:8080 \\ + --network ${env.APPLICATION_NETWORK} \\ + -v ${env.EXTERNAL_SERVER_CONFIG_PATH}:${env.INTERNAL_SERVER_CONFIG_PATH} \\ + -v ${env.EXTERNAL_SERVER_CLOUD_PATH}:${env.INTERNAL_SERVER_CLOUD_PATH} \\ + -v ${env.EXTERNAL_SERVER_LOGS_PATH}:${env.INTERNAL_SERVER_LOGS_PATH} \\ + -e LOG_PATH=${env.INTERNAL_SERVER_LOGS_PATH} \\ + -e SPRING_PROFILES_ACTIVE=${env.PROFILE} \\ + ${env.IMAGE_NAME}:${env.DEPLOY_CONTAINER} + + echo "Checking if monitoring network ${env.MONITORING_NETWORK} exists" + if docker network ls --format '{{.Name}}' | grep -q '^${env.MONITORING_NETWORK}\$'; then + echo "Connecting to monitoring network ${env.MONITORING_NETWORK}" + docker network connect ${env.MONITORING_NETWORK} ${env.DEPLOY_CONTAINER} + else + echo "Monitoring network ${env.MONITORING_NETWORK} does not exist. Skipping connection." + fi + + echo "Listing all containers" + docker ps -a + """ } def performHealthCheck() { - withEnv([ - "WHITELIST_ADMIN_USERNAME=${env.WHITELIST_ADMIN_USERNAME}", - "WHITELIST_ADMIN_PASSWORD=${env.WHITELIST_ADMIN_PASSWORD}" - ]) { - def PUBLIC_IP = sh(script: "curl -s ifconfig.me", returnStdout: true).trim() - echo "Public IP address: ${PUBLIC_IP}" - - def start_time = System.currentTimeMillis() - def timeout = start_time + 150000 // 2.5 minutes - - while (System.currentTimeMillis() < timeout) { - def elapsed = (System.currentTimeMillis() - start_time) / 1000 - echo "Checking health... ${elapsed} seconds elapsed." - def status = sh( - script: """curl -s -u ${WHITELIST_ADMIN_USERNAME}:${WHITELIST_ADMIN_PASSWORD} \ - http://${PUBLIC_IP}:${env.NEW_PORT}/actuator/health | grep 'UP'""", - returnStatus: true - ) - if (status == 0) { - echo "New application started successfully after ${elapsed} seconds." - return - } - sleep 5 + def PUBLIC_IP = sh(script: "curl -s ifconfig.me", returnStdout: true).trim() + echo "Public IP address: ${PUBLIC_IP}" + + def start_time = System.currentTimeMillis() + def timeout = start_time + 150000 // 2.5 minutes + + while (System.currentTimeMillis() < timeout) { + def elapsed = (System.currentTimeMillis() - start_time) / 1000 + echo "Checking health... ${elapsed} seconds elapsed." + def status = sh( + script: """curl -s -u ${env.WHITELIST_ADMIN_USERNAME}:${env.WHITELIST_ADMIN_PASSWORD} \ + http://${PUBLIC_IP}:${env.NEW_PORT}/actuator/health | grep 'UP'""", + returnStatus: true + ) + if (status == 0) { + echo "New application started successfully after ${elapsed} seconds." + return } + sleep 5 + } - if (System.currentTimeMillis() >= timeout) { - sh "docker stop ${env.DEPLOY_CONTAINER}" - sh "docker rm ${env.DEPLOY_CONTAINER}" - error "Health check failed" - } + if (System.currentTimeMillis() >= timeout) { + sh "docker stop ${env.DEPLOY_CONTAINER}" + sh "docker rm ${env.DEPLOY_CONTAINER}" + error "Health check failed" } } def switchTrafficAndCleanup() { - withEnv([ - "NEW_PORT=${env.NEW_PORT}", - "OLD_PORT=${env.OLD_PORT}", - "NEW_TARGET=${env.NEW_TARGET}", - "CURRENT_CONTAINER=${env.CURRENT_CONTAINER}", - "DEPLOY_CONTAINER=${env.DEPLOY_CONTAINER}", - "NGINX_CONTAINER_NAME=${env.NGINX_CONTAINER_NAME}" - ]) { - sh """ - echo "Switching traffic to ${DEPLOY_CONTAINER} on port ${NEW_PORT}." - docker exec ${NGINX_CONTAINER_NAME} bash -c ' - export BACKEND_URL=${NEW_TARGET} - envsubst "\\\$BACKEND_URL" < /etc/nginx/conf.d/default.conf.template > /etc/nginx/conf.d/default.conf - ' - docker exec ${NGINX_CONTAINER_NAME} sed -i 's/${OLD_PORT}/${NEW_PORT}/' /etc/nginx/conf.d/default.conf - docker exec ${NGINX_CONTAINER_NAME} nginx -t - docker exec ${NGINX_CONTAINER_NAME} nginx -s reload - - echo "Checking if current container ${CURRENT_CONTAINER} is running..." - if docker ps | grep -q ${CURRENT_CONTAINER}; then - docker stop ${CURRENT_CONTAINER} - docker rm ${CURRENT_CONTAINER} - echo "Removed old container ${CURRENT_CONTAINER}." - fi - """ - } + sh """ + echo "Switching traffic to ${env.DEPLOY_CONTAINER} on port ${env.NEW_PORT}." + docker exec ${env.NGINX_CONTAINER_NAME} bash -c ' + export BACKEND_URL=${env.NEW_TARGET} + envsubst "\\\$BACKEND_URL" < /etc/nginx/conf.d/members.conf.template > /etc/nginx/conf.d/members.conf + ' + docker exec ${env.NGINX_CONTAINER_NAME} sed -i 's/${env.OLD_PORT}/${env.NEW_PORT}/' /etc/nginx/conf.d/members.conf + docker exec ${env.NGINX_CONTAINER_NAME} nginx -t + docker exec ${env.NGINX_CONTAINER_NAME} nginx -s reload + + echo "Checking if current container ${env.CURRENT_CONTAINER} is running..." + if docker ps | grep -q ${env.CURRENT_CONTAINER}; then + docker stop ${env.CURRENT_CONTAINER} + docker rm ${env.CURRENT_CONTAINER} + echo "Removed old container ${env.CURRENT_CONTAINER}." + fi + """ } From a9ad4e546a0ffb07c8a270bfcdb9fd8cd216b109 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=ED=95=9C=EA=B4=80=ED=9D=AC?= <85067003+limehee@users.noreply.github.com> Date: Wed, 27 Nov 2024 23:53:01 +0900 Subject: [PATCH 10/10] =?UTF-8?q?refactor:=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EC=8B=9C=EC=8A=A4=ED=85=9C=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20?= =?UTF-8?q?=EB=B0=8F=20=EB=A9=80=ED=8B=B0=ED=94=8C=EB=9E=AB=ED=8F=BC=20?= =?UTF-8?q?=EC=A7=80=EC=9B=90=20=EA=B5=AC=ED=98=84=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?(#615)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 2 +- .../application/service/MemberBanService.java | 13 +- .../service/MemberUnbanService.java | 13 +- .../service/BlacklistIpRegisterService.java | 19 +- .../service/BlacklistIpRemoveService.java | 16 +- .../service/BlacklistIpResetService.java | 17 +- .../TwoFactorAuthenticationService.java | 28 +- .../AbnormalAccessIpRemoveService.java | 12 +- .../AbnormalAccessIpsClearService.java | 17 +- .../service/BoardRegisterService.java | 21 +- .../service/ApplicationApplyService.java | 13 +- .../service/BookLoanRequestService.java | 25 +- .../service/MemberRoleManagementService.java | 20 +- .../service/MembershipFeeRegisterService.java | 14 +- .../ExternalAccountLockManagementService.java | 24 +- ...xternalIpAccessMonitorRegisterService.java | 12 +- .../CustomBasicAuthenticationFilter.java | 54 +-- .../filter/InvalidEndpointAccessFilter.java | 25 +- .../auth/filter/JwtAuthenticationFilter.java | 27 +- ...NotificationSettingRetrieveController.java | 30 ++ .../NotificationSettingToggleController.java | 33 ++ ...NotificationSettingPersistenceAdapter.java | 34 ++ .../NotificationSettingRepository.java | 9 +- .../out/webhook/AbstractWebhookClient.java | 17 + .../webhook/DiscordNotificationSender.java | 25 ++ .../out/webhook/DiscordWebhookClient.java | 362 +++++++++++++++++ .../out/webhook/SlackNotificationSender.java | 25 ++ .../out/webhook/SlackWebhookClient.java | 338 ++++++++++++++++ .../mapper/NotificationSettingDtoMapper.java} | 8 +- .../notification/BoardNotificationInfo.java} | 11 +- .../BookLoanRecordNotificationInfo.java} | 8 +- .../MembershipFeeNotificationInfo.java} | 8 +- .../NotificationSettingToggleRequestDto.java} | 4 +- .../NotificationSettingResponseDto.java | 2 +- .../event/ApplicationStartupListener.java | 23 ++ .../application}/event/NotificationEvent.java | 8 +- .../event/NotificationListener.java | 95 +++++ .../exception/AlertTypeNotFoundException.java | 2 +- .../in/ManageNotificationSettingUseCase.java | 11 + .../RetrieveNotificationSettingUseCase.java | 9 + .../port/out/NotificationSender.java | 10 + .../out/RetrieveNotificationSettingPort.java | 13 + .../out/UpdateNotificationSettingPort.java | 8 + .../application/port/out/WebhookClient.java | 23 ++ .../ManageNotificationSettingService.java | 52 +++ .../RetrieveNotificationSettingService.java | 36 ++ .../service/WebhookCommonService.java | 87 +++++ .../config/NotificationConfig.java | 13 + .../config/NotificationConfigProperties.java | 45 +++ .../domain/AlertCategory.java | 8 + .../notificationSetting/domain/AlertType.java | 10 + .../domain/AlertTypeConverter.java | 5 +- .../domain/AlertTypeResolver.java | 4 +- .../domain/ExecutivesAlertType.java | 11 +- .../domain/GeneralAlertType.java | 17 + .../domain/NotificationSetting.java | 2 +- .../domain/PlatformType.java | 14 + .../domain/SecurityAlertType.java | 29 +- .../api/NotificationSettingController.java | 45 --- .../NotificationSettingService.java | 61 --- .../slack/application/SlackService.java | 82 ---- .../slack/application/SlackServiceHelper.java | 368 ------------------ .../global/common/slack/domain/AlertType.java | 8 - .../common/slack/domain/GeneralAlertType.java | 16 - .../slack/listener/NotificationListener.java | 28 -- .../api/global/config/SecurityConfig.java | 22 +- .../clab/api/global/config/SlackConfig.java | 26 -- .../handler/GlobalExceptionHandler.java | 18 +- src/main/resources/application.yml | 50 ++- 69 files changed, 1689 insertions(+), 856 deletions(-) create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/adapter/in/web/NotificationSettingRetrieveController.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/adapter/in/web/NotificationSettingToggleController.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/persistence/NotificationSettingPersistenceAdapter.java rename src/main/java/page/clab/api/global/common/{slack/dao => notificationSetting/adapter/out/persistence}/NotificationSettingRepository.java (52%) create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/AbstractWebhookClient.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/DiscordNotificationSender.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/DiscordWebhookClient.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/SlackNotificationSender.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/SlackWebhookClient.java rename src/main/java/page/clab/api/global/common/{slack/dto/mapper/SlackDtoMapper.java => notificationSetting/application/dto/mapper/NotificationSettingDtoMapper.java} (51%) rename src/main/java/page/clab/api/global/common/{slack/domain/SlackBoardInfo.java => notificationSetting/application/dto/notification/BoardNotificationInfo.java} (58%) rename src/main/java/page/clab/api/global/common/{slack/domain/SlackBookLoanRecordInfo.java => notificationSetting/application/dto/notification/BookLoanRecordNotificationInfo.java} (69%) rename src/main/java/page/clab/api/global/common/{slack/domain/SlackMembershipFeeInfo.java => notificationSetting/application/dto/notification/MembershipFeeNotificationInfo.java} (69%) rename src/main/java/page/clab/api/global/common/{slack/dto/request/NotificationSettingUpdateRequestDto.java => notificationSetting/application/dto/request/NotificationSettingToggleRequestDto.java} (78%) rename src/main/java/page/clab/api/global/common/{slack => notificationSetting/application}/dto/response/NotificationSettingResponseDto.java (67%) create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/application/event/ApplicationStartupListener.java rename src/main/java/page/clab/api/global/common/{slack => notificationSetting/application}/event/NotificationEvent.java (59%) create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/application/event/NotificationListener.java rename src/main/java/page/clab/api/global/common/{slack => notificationSetting/application}/exception/AlertTypeNotFoundException.java (71%) create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/application/port/in/ManageNotificationSettingUseCase.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/application/port/in/RetrieveNotificationSettingUseCase.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/NotificationSender.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/RetrieveNotificationSettingPort.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/UpdateNotificationSettingPort.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/WebhookClient.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/application/service/ManageNotificationSettingService.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/application/service/RetrieveNotificationSettingService.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/application/service/WebhookCommonService.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/config/NotificationConfig.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/config/NotificationConfigProperties.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertCategory.java create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertType.java rename src/main/java/page/clab/api/global/common/{slack => notificationSetting}/domain/AlertTypeConverter.java (88%) rename src/main/java/page/clab/api/global/common/{slack => notificationSetting}/domain/AlertTypeResolver.java (77%) rename src/main/java/page/clab/api/global/common/{slack => notificationSetting}/domain/ExecutivesAlertType.java (54%) create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/domain/GeneralAlertType.java rename src/main/java/page/clab/api/global/common/{slack => notificationSetting}/domain/NotificationSetting.java (94%) create mode 100644 src/main/java/page/clab/api/global/common/notificationSetting/domain/PlatformType.java rename src/main/java/page/clab/api/global/common/{slack => notificationSetting}/domain/SecurityAlertType.java (58%) delete mode 100644 src/main/java/page/clab/api/global/common/slack/api/NotificationSettingController.java delete mode 100644 src/main/java/page/clab/api/global/common/slack/application/NotificationSettingService.java delete mode 100644 src/main/java/page/clab/api/global/common/slack/application/SlackService.java delete mode 100644 src/main/java/page/clab/api/global/common/slack/application/SlackServiceHelper.java delete mode 100644 src/main/java/page/clab/api/global/common/slack/domain/AlertType.java delete mode 100644 src/main/java/page/clab/api/global/common/slack/domain/GeneralAlertType.java delete mode 100644 src/main/java/page/clab/api/global/common/slack/listener/NotificationListener.java delete mode 100644 src/main/java/page/clab/api/global/config/SlackConfig.java diff --git a/build.gradle b/build.gradle index d641c37ad..1ee05e906 100644 --- a/build.gradle +++ b/build.gradle @@ -117,7 +117,7 @@ tasks.named('test') { def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile sourceSets { - main.java.srcDirs += [ querydslDir ] + main.java.srcDirs += [querydslDir] } tasks.withType(JavaCompile).configureEach { diff --git a/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberBanService.java b/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberBanService.java index d83793ba0..a7ca3a616 100644 --- a/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberBanService.java +++ b/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberBanService.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.accountLockInfo.application.port.in.BanMemberUseCase; @@ -11,8 +12,8 @@ import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberBasicInfoDto; import page.clab.api.external.auth.redisToken.application.port.ExternalManageRedisTokenUseCase; import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -22,7 +23,7 @@ public class MemberBanService implements BanMemberUseCase { private final RegisterAccountLockInfoPort registerAccountLockInfoPort; private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; private final ExternalManageRedisTokenUseCase externalManageRedisTokenUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; /** * 멤버를 영구적으로 차단합니다. @@ -30,7 +31,7 @@ public class MemberBanService implements BanMemberUseCase { *

해당 멤버의 계정 잠금 정보를 조회하고, 없으면 새로 생성합니다. * Redis에 저장된 해당 멤버의 인증 토큰을 삭제하며, Slack에 밴 알림을 전송합니다.

* - * @param request 현재 요청 객체 + * @param request 현재 요청 객체 * @param memberId 차단할 멤버의 ID * @return 저장된 계정 잠금 정보의 ID */ @@ -58,6 +59,8 @@ private AccountLockInfo createAccountLockInfo(String memberId) { private void sendSlackBanNotification(HttpServletRequest request, String memberId) { String memberName = externalRetrieveMemberUseCase.getMemberBasicInfoById(memberId).getMemberName(); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.MEMBER_BANNED, "ID: " + memberId + ", Name: " + memberName); + String memberBannedMessage = "ID: " + memberId + ", Name: " + memberName; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.MEMBER_BANNED, request, memberBannedMessage)); } } diff --git a/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberUnbanService.java b/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberUnbanService.java index 391628d8e..1aaf67e4d 100644 --- a/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberUnbanService.java +++ b/src/main/java/page/clab/api/domain/auth/accountLockInfo/application/service/MemberUnbanService.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.accountLockInfo.application.port.in.UnbanMemberUseCase; @@ -10,8 +11,8 @@ import page.clab.api.domain.auth.accountLockInfo.domain.AccountLockInfo; import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberBasicInfoDto; import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -20,7 +21,7 @@ public class MemberUnbanService implements UnbanMemberUseCase { private final RetrieveAccountLockInfoPort retrieveAccountLockInfoPort; private final RegisterAccountLockInfoPort registerAccountLockInfoPort; private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; /** * 차단된 멤버를 해제합니다. @@ -28,7 +29,7 @@ public class MemberUnbanService implements UnbanMemberUseCase { *

해당 멤버의 계정 잠금 정보를 조회하고 해제합니다. * 해제된 정보는 저장되며, Slack에 해제 알림이 전송됩니다.

* - * @param request 현재 요청 객체 + * @param request 현재 요청 객체 * @param memberId 해제할 멤버의 ID * @return 업데이트된 계정 잠금 정보의 ID */ @@ -55,6 +56,8 @@ private AccountLockInfo createAccountLockInfo(String memberId) { private void sendSlackUnbanNotification(HttpServletRequest request, String memberId) { String memberName = externalRetrieveMemberUseCase.getMemberBasicInfoById(memberId).getMemberName(); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.MEMBER_UNBANNED, "ID: " + memberId + ", Name: " + memberName); + String memberUnbannedMessage = "ID: " + memberId + ", Name: " + memberName; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.MEMBER_UNBANNED, request, memberUnbannedMessage)); } } diff --git a/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRegisterService.java b/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRegisterService.java index 6694afdec..42bc014d8 100644 --- a/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRegisterService.java +++ b/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRegisterService.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.blacklistIp.application.dto.mapper.BlacklistIpDtoMapper; @@ -10,8 +11,8 @@ import page.clab.api.domain.auth.blacklistIp.application.port.out.RegisterBlacklistIpPort; import page.clab.api.domain.auth.blacklistIp.application.port.out.RetrieveBlacklistIpPort; import page.clab.api.domain.auth.blacklistIp.domain.BlacklistIp; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -19,17 +20,16 @@ public class BlacklistIpRegisterService implements RegisterBlacklistIpUseCase { private final RegisterBlacklistIpPort registerBlacklistIpPort; private final RetrieveBlacklistIpPort retrieveBlacklistIpPort; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; private final BlacklistIpDtoMapper mapper; /** * 지정된 IP 주소를 블랙리스트에 등록합니다. * *

해당 IP 주소가 이미 블랙리스트에 존재하는지 확인하고, - * 존재하지 않을 경우 새롭게 등록합니다. - * 새로운 IP가 등록되면 Slack을 통해 보안 알림이 전송됩니다.

+ * 존재하지 않을 경우 새롭게 등록합니다. 새로운 IP가 등록되면 Slack을 통해 보안 알림이 전송됩니다.

* - * @param request 현재 요청 객체 + * @param request 현재 요청 객체 * @param requestDto 블랙리스트에 추가할 IP 주소 정보를 담은 DTO * @return 기존에 존재하거나 새로 추가된 블랙리스트 IP 주소 */ @@ -42,7 +42,12 @@ public String registerBlacklistIp(HttpServletRequest request, BlacklistIpRequest .orElseGet(() -> { BlacklistIp blacklistIp = mapper.fromDto(requestDto); registerBlacklistIpPort.save(blacklistIp); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.BLACKLISTED_IP_ADDED, "Added IP: " + ipAddress); + + String blacklistAddedMessage = "Added IP: " + ipAddress; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.BLACKLISTED_IP_ADDED, request, + blacklistAddedMessage)); + return ipAddress; }); } diff --git a/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRemoveService.java b/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRemoveService.java index 5de09b640..26b558adc 100644 --- a/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRemoveService.java +++ b/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpRemoveService.java @@ -2,14 +2,15 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.blacklistIp.application.port.in.RemoveBlacklistIpUseCase; import page.clab.api.domain.auth.blacklistIp.application.port.out.RemoveBlacklistIpPort; import page.clab.api.domain.auth.blacklistIp.application.port.out.RetrieveBlacklistIpPort; import page.clab.api.domain.auth.blacklistIp.domain.BlacklistIp; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -17,7 +18,7 @@ public class BlacklistIpRemoveService implements RemoveBlacklistIpUseCase { private final RetrieveBlacklistIpPort retrieveBlacklistIpPort; private final RemoveBlacklistIpPort removeBlacklistIpPort; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; /** * 지정된 IP 주소를 블랙리스트에서 제거합니다. @@ -25,7 +26,7 @@ public class BlacklistIpRemoveService implements RemoveBlacklistIpUseCase { *

블랙리스트에 등록된 IP 주소 정보를 조회하고 해당 정보를 삭제합니다. * 삭제가 완료되면 Slack을 통해 보안 알림이 전송됩니다.

* - * @param request 현재 요청 객체 + * @param request 현재 요청 객체 * @param ipAddress 제거할 블랙리스트 IP 주소 * @return 삭제된 블랙리스트 IP 주소 */ @@ -34,7 +35,12 @@ public class BlacklistIpRemoveService implements RemoveBlacklistIpUseCase { public String removeBlacklistIp(HttpServletRequest request, String ipAddress) { BlacklistIp blacklistIp = retrieveBlacklistIpPort.getByIpAddress(ipAddress); removeBlacklistIpPort.delete(blacklistIp); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.BLACKLISTED_IP_REMOVED, "Deleted IP: " + ipAddress); + + String blacklistRemovedMessage = "Deleted IP: " + ipAddress; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.BLACKLISTED_IP_REMOVED, request, + blacklistRemovedMessage)); + return blacklistIp.getIpAddress(); } } diff --git a/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpResetService.java b/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpResetService.java index 342a2561f..cdcf60021 100644 --- a/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpResetService.java +++ b/src/main/java/page/clab/api/domain/auth/blacklistIp/application/service/BlacklistIpResetService.java @@ -1,17 +1,17 @@ package page.clab.api.domain.auth.blacklistIp.application.service; import jakarta.servlet.http.HttpServletRequest; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.blacklistIp.application.port.in.ResetBlacklistIpsUseCase; import page.clab.api.domain.auth.blacklistIp.application.port.out.RemoveBlacklistIpPort; import page.clab.api.domain.auth.blacklistIp.application.port.out.RetrieveBlacklistIpPort; import page.clab.api.domain.auth.blacklistIp.domain.BlacklistIp; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; - -import java.util.List; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -19,7 +19,7 @@ public class BlacklistIpResetService implements ResetBlacklistIpsUseCase { private final RetrieveBlacklistIpPort retrieveBlacklistIpPort; private final RemoveBlacklistIpPort removeBlacklistIpPort; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; /** * 블랙리스트에 등록된 모든 IP 주소를 초기화합니다. @@ -38,7 +38,12 @@ public List resetBlacklistIps(HttpServletRequest request) { .map(BlacklistIp::getIpAddress) .toList(); removeBlacklistIpPort.deleteAll(); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.BLACKLISTED_IP_REMOVED, "Deleted IP: ALL"); + + String blacklistRemovedMessage = "Deleted IP: ALL"; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.BLACKLISTED_IP_REMOVED, request, + blacklistRemovedMessage)); + return blacklistedIps; } } diff --git a/src/main/java/page/clab/api/domain/auth/login/application/service/TwoFactorAuthenticationService.java b/src/main/java/page/clab/api/domain/auth/login/application/service/TwoFactorAuthenticationService.java index 655a51a06..402cf4ee7 100644 --- a/src/main/java/page/clab/api/domain/auth/login/application/service/TwoFactorAuthenticationService.java +++ b/src/main/java/page/clab/api/domain/auth/login/application/service/TwoFactorAuthenticationService.java @@ -1,8 +1,10 @@ package page.clab.api.domain.auth.login.application.service; import jakarta.servlet.http.HttpServletRequest; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.accountAccessLog.domain.AccountAccessResult; @@ -21,11 +23,10 @@ import page.clab.api.external.auth.redisToken.application.port.ExternalManageRedisTokenUseCase; import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase; import page.clab.api.global.auth.jwt.JwtTokenProvider; -import page.clab.api.global.common.slack.application.SlackService; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.GeneralAlertType; import page.clab.api.global.util.HttpReqResUtil; -import java.util.List; - @Service @RequiredArgsConstructor @Qualifier("twoFactorAuthenticationService") @@ -36,12 +37,14 @@ public class TwoFactorAuthenticationService implements ManageLoginUseCase { private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; private final ExternalRegisterAccountAccessLogUseCase externalRegisterAccountAccessLogUseCase; private final ExternalManageRedisTokenUseCase externalManageRedisTokenUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; private final JwtTokenProvider jwtTokenProvider; @Transactional @Override - public LoginResult authenticate(HttpServletRequest request, TwoFactorAuthenticationRequestDto twoFactorAuthenticationRequestDto) throws LoginFailedException, MemberLockedException { + public LoginResult authenticate(HttpServletRequest request, + TwoFactorAuthenticationRequestDto twoFactorAuthenticationRequestDto) + throws LoginFailedException, MemberLockedException { String memberId = twoFactorAuthenticationRequestDto.getMemberId(); MemberLoginInfoDto loginMember = externalRetrieveMemberUseCase.getMemberLoginInfoById(memberId); String totp = twoFactorAuthenticationRequestDto.getTotp(); @@ -55,9 +58,11 @@ public LoginResult authenticate(HttpServletRequest request, TwoFactorAuthenticat return LoginResult.create(header, true); } - private void verifyTwoFactorAuthentication(String memberId, String totp, HttpServletRequest request) throws MemberLockedException, LoginFailedException { + private void verifyTwoFactorAuthentication(String memberId, String totp, HttpServletRequest request) + throws MemberLockedException, LoginFailedException { if (!manageAuthenticatorUseCase.isAuthenticatorValid(memberId, totp)) { - externalRegisterAccountAccessLogUseCase.registerAccountAccessLog(request, memberId, AccountAccessResult.FAILURE); + externalRegisterAccountAccessLogUseCase.registerAccountAccessLog(request, memberId, + AccountAccessResult.FAILURE); externalManageAccountLockUseCase.handleLoginFailure(request, memberId); throw new LoginFailedException("잘못된 인증번호입니다."); } @@ -67,18 +72,21 @@ private void verifyTwoFactorAuthentication(String memberId, String totp, HttpSer private TokenInfo generateAndSaveToken(MemberLoginInfoDto memberInfo) { TokenInfo tokenInfo = jwtTokenProvider.generateToken(memberInfo.getMemberId(), memberInfo.getRole()); String clientIpAddress = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); - externalManageRedisTokenUseCase.saveToken(memberInfo.getMemberId(), memberInfo.getRole(), tokenInfo, clientIpAddress); + externalManageRedisTokenUseCase.saveToken(memberInfo.getMemberId(), memberInfo.getRole(), tokenInfo, + clientIpAddress); return tokenInfo; } private void sendAdminLoginNotification(HttpServletRequest request, MemberLoginInfoDto loginMember) { if (loginMember.isSuperAdminRole()) { - slackService.sendAdminLoginNotification(request, loginMember); + eventPublisher.publishEvent( + new NotificationEvent(this, GeneralAlertType.ADMIN_LOGIN, request, loginMember)); } } @Override - public LoginResult login(HttpServletRequest request, LoginRequestDto requestDto) throws LoginFailedException, MemberLockedException { + public LoginResult login(HttpServletRequest request, LoginRequestDto requestDto) + throws LoginFailedException, MemberLockedException { throw new UnsupportedOperationException("Method not implemented"); } diff --git a/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpRemoveService.java b/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpRemoveService.java index 7789853cb..a31e7ee8a 100644 --- a/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpRemoveService.java +++ b/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpRemoveService.java @@ -2,25 +2,29 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.redisIpAccessMonitor.application.port.in.RemoveAbnormalAccessIpUseCase; import page.clab.api.domain.auth.redisIpAccessMonitor.application.port.out.RemoveIpAccessMonitorPort; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor public class AbnormalAccessIpRemoveService implements RemoveAbnormalAccessIpUseCase { private final RemoveIpAccessMonitorPort removeIpAccessMonitorPort; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; @Override @Transactional public String removeAbnormalAccessIp(HttpServletRequest request, String ipAddress) { removeIpAccessMonitorPort.deleteById(ipAddress); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.ABNORMAL_ACCESS_IP_DELETED, "Deleted IP: " + ipAddress); + String abnormalAccessIpDeletedMessage = "Deleted IP: " + ipAddress; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.ABNORMAL_ACCESS_IP_DELETED, request, + abnormalAccessIpDeletedMessage)); return ipAddress; } } diff --git a/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpsClearService.java b/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpsClearService.java index 7a5c7dbcb..f347f2644 100644 --- a/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpsClearService.java +++ b/src/main/java/page/clab/api/domain/auth/redisIpAccessMonitor/application/service/AbnormalAccessIpsClearService.java @@ -1,17 +1,17 @@ package page.clab.api.domain.auth.redisIpAccessMonitor.application.service; import jakarta.servlet.http.HttpServletRequest; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.redisIpAccessMonitor.application.port.in.ClearAbnormalAccessIpsUseCase; import page.clab.api.domain.auth.redisIpAccessMonitor.application.port.out.ClearIpAccessMonitorPort; import page.clab.api.domain.auth.redisIpAccessMonitor.application.port.out.RetrieveIpAccessMonitorPort; import page.clab.api.domain.auth.redisIpAccessMonitor.domain.RedisIpAccessMonitor; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; - -import java.util.List; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -19,14 +19,19 @@ public class AbnormalAccessIpsClearService implements ClearAbnormalAccessIpsUseC private final ClearIpAccessMonitorPort clearIpAccessMonitorPort; private final RetrieveIpAccessMonitorPort retrieveIpAccessMonitorPort; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; @Override @Transactional public List clearAbnormalAccessIps(HttpServletRequest request) { List ipAccessMonitors = retrieveIpAccessMonitorPort.findAll(); clearIpAccessMonitorPort.deleteAll(); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.ABNORMAL_ACCESS_IP_DELETED, "Deleted IP: ALL"); + + String abnormalAccessIpClearedMessage = "Deleted IP: ALL"; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.ABNORMAL_ACCESS_IP_DELETED, request, + abnormalAccessIpClearedMessage)); + return ipAccessMonitors; } } diff --git a/src/main/java/page/clab/api/domain/community/board/application/service/BoardRegisterService.java b/src/main/java/page/clab/api/domain/community/board/application/service/BoardRegisterService.java index f2e487749..cdcd2dee8 100644 --- a/src/main/java/page/clab/api/domain/community/board/application/service/BoardRegisterService.java +++ b/src/main/java/page/clab/api/domain/community/board/application/service/BoardRegisterService.java @@ -1,6 +1,8 @@ package page.clab.api.domain.community.board.application.service; +import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.community.board.application.dto.mapper.BoardDtoMapper; @@ -8,17 +10,16 @@ import page.clab.api.domain.community.board.application.port.in.RegisterBoardUseCase; import page.clab.api.domain.community.board.application.port.out.RegisterBoardPort; import page.clab.api.domain.community.board.domain.Board; -import page.clab.api.global.common.slack.domain.SlackBoardInfo; import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberDetailedInfoDto; import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase; import page.clab.api.external.memberManagement.notification.application.port.ExternalSendNotificationUseCase; import page.clab.api.global.common.file.application.UploadedFileService; import page.clab.api.global.common.file.domain.UploadedFile; -import page.clab.api.global.common.slack.application.SlackService; +import page.clab.api.global.common.notificationSetting.application.dto.notification.BoardNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.ExecutivesAlertType; import page.clab.api.global.exception.PermissionDeniedException; -import java.util.List; - @Service @RequiredArgsConstructor public class BoardRegisterService implements RegisterBoardUseCase { @@ -27,7 +28,7 @@ public class BoardRegisterService implements RegisterBoardUseCase { private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; private final ExternalSendNotificationUseCase externalSendNotificationUseCase; private final UploadedFileService uploadedFileService; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; private final BoardDtoMapper mapper; /** @@ -48,10 +49,14 @@ public String registerBoard(BoardRequestDto requestDto) throws PermissionDeniedE Board board = mapper.fromDto(requestDto, currentMemberInfo.getMemberId(), uploadedFiles); board.validateAccessPermissionForCreation(currentMemberInfo); if (board.shouldNotifyForNewBoard(currentMemberInfo)) { - externalSendNotificationUseCase.sendNotificationToMember(currentMemberInfo.getMemberId(), "[" + board.getTitle() + "] 새로운 공지사항이 등록되었습니다."); + externalSendNotificationUseCase.sendNotificationToMember(currentMemberInfo.getMemberId(), + "[" + board.getTitle() + "] 새로운 공지사항이 등록되었습니다."); } - SlackBoardInfo boardInfo = SlackBoardInfo.create(board, currentMemberInfo); - slackService.sendNewBoardNotification(boardInfo); + + BoardNotificationInfo boardInfo = BoardNotificationInfo.create(board, currentMemberInfo); + eventPublisher.publishEvent(new NotificationEvent(this, ExecutivesAlertType.NEW_BOARD, null, + boardInfo)); + return registerBoardPort.save(board).getCategory().getKey(); } } diff --git a/src/main/java/page/clab/api/domain/hiring/application/application/service/ApplicationApplyService.java b/src/main/java/page/clab/api/domain/hiring/application/application/service/ApplicationApplyService.java index 11c69305c..357a59cde 100644 --- a/src/main/java/page/clab/api/domain/hiring/application/application/service/ApplicationApplyService.java +++ b/src/main/java/page/clab/api/domain/hiring/application/application/service/ApplicationApplyService.java @@ -1,6 +1,7 @@ package page.clab.api.domain.hiring.application.application.service; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.hiring.application.application.dto.mapper.ApplicationDtoMapper; @@ -10,7 +11,8 @@ import page.clab.api.domain.hiring.application.domain.Application; import page.clab.api.external.hiring.application.application.port.ExternalRetrieveRecruitmentUseCase; import page.clab.api.external.memberManagement.notification.application.port.ExternalSendNotificationUseCase; -import page.clab.api.global.common.slack.application.SlackService; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.ExecutivesAlertType; @Service @RequiredArgsConstructor @@ -19,9 +21,9 @@ public class ApplicationApplyService implements ApplyForApplicationUseCase { private final RegisterApplicationPort registerApplicationPort; private final ExternalRetrieveRecruitmentUseCase externalRetrieveRecruitmentUseCase; private final ExternalSendNotificationUseCase externalSendNotificationUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; private final ApplicationDtoMapper mapper; - + @Transactional @Override public String applyForClub(ApplicationRequestDto requestDto) { @@ -30,7 +32,10 @@ public String applyForClub(ApplicationRequestDto requestDto) { String applicationType = application.getApplicationTypeForNotificationPrefix(); externalSendNotificationUseCase.sendNotificationToAdmins(applicationType + requestDto.getStudentId() + " " + requestDto.getName() + "님이 지원하였습니다."); - slackService.sendNewApplicationNotification(requestDto); + + eventPublisher.publishEvent(new NotificationEvent(this, ExecutivesAlertType.NEW_APPLICATION, null, + requestDto)); + return registerApplicationPort.save(application).getStudentId(); } } diff --git a/src/main/java/page/clab/api/domain/library/bookLoanRecord/application/service/BookLoanRequestService.java b/src/main/java/page/clab/api/domain/library/bookLoanRecord/application/service/BookLoanRequestService.java index 1e759332e..9f32d5adb 100644 --- a/src/main/java/page/clab/api/domain/library/bookLoanRecord/application/service/BookLoanRequestService.java +++ b/src/main/java/page/clab/api/domain/library/bookLoanRecord/application/service/BookLoanRequestService.java @@ -1,6 +1,7 @@ package page.clab.api.domain.library.bookLoanRecord.application.service; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,8 +18,9 @@ import page.clab.api.external.library.book.application.port.ExternalRetrieveBookUseCase; import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase; import page.clab.api.external.memberManagement.notification.application.port.ExternalSendNotificationUseCase; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SlackBookLoanRecordInfo; +import page.clab.api.global.common.notificationSetting.application.dto.notification.BookLoanRecordNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.ExecutivesAlertType; import page.clab.api.global.exception.CustomOptimisticLockingFailureException; @Service @@ -30,21 +32,19 @@ public class BookLoanRequestService implements RequestBookLoanUseCase { private final ExternalRetrieveBookUseCase externalRetrieveBookUseCase; private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; private final ExternalSendNotificationUseCase externalSendNotificationUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; /** * 도서 대출 신청을 처리합니다. * *

현재 로그인한 멤버의 대출 상태와 한도를 검증한 후, - * 도서의 대출 신청이 이미 존재하는지 확인합니다. - * 대출 신청이 성공적으로 완료되면 멤버와 Slack에 알림을 전송하고, - * 대출 기록을 저장한 후 그 ID를 반환합니다.

+ * 도서의 대출 신청이 이미 존재하는지 확인합니다. 대출 신청이 성공적으로 완료되면 멤버와 Slack에 알림을 전송하고, 대출 기록을 저장한 후 그 ID를 반환합니다.

* * @param requestDto 도서 대출 신청 요청 정보 DTO * @return 저장된 대출 기록의 ID * @throws CustomOptimisticLockingFailureException 동시에 다른 사용자가 대출을 신청하여 충돌이 발생한 경우 예외 발생 - * @throws MaxBorrowLimitExceededException 대출 한도를 초과한 경우 예외 발생 - * @throws BookAlreadyAppliedForLoanException 이미 신청된 도서일 경우 예외 발생 + * @throws MaxBorrowLimitExceededException 대출 한도를 초과한 경우 예외 발생 + * @throws BookAlreadyAppliedForLoanException 이미 신청된 도서일 경우 예외 발생 */ @Transactional @Override @@ -60,10 +60,13 @@ public Long requestBookLoan(BookLoanRecordRequestDto requestDto) throws CustomOp BookLoanRecord bookLoanRecord = BookLoanRecord.create(book.getId(), borrowerInfo); - externalSendNotificationUseCase.sendNotificationToMember(borrowerInfo.getMemberId(), "[" + book.getTitle() + "] 도서 대출 신청이 완료되었습니다."); + externalSendNotificationUseCase.sendNotificationToMember(borrowerInfo.getMemberId(), + "[" + book.getTitle() + "] 도서 대출 신청이 완료되었습니다."); - SlackBookLoanRecordInfo bookLoanRecordInfo = SlackBookLoanRecordInfo.create(book, borrowerInfo); - slackService.sendNewBookLoanRequestNotification(bookLoanRecordInfo); + BookLoanRecordNotificationInfo bookLoanRecordInfo = BookLoanRecordNotificationInfo.create(book, + borrowerInfo); + eventPublisher.publishEvent(new NotificationEvent(this, ExecutivesAlertType.NEW_BOOK_LOAN_REQUEST, null, + bookLoanRecordInfo)); return registerBookLoanRecordPort.save(bookLoanRecord).getId(); } catch (ObjectOptimisticLockingFailureException e) { diff --git a/src/main/java/page/clab/api/domain/memberManagement/member/application/service/MemberRoleManagementService.java b/src/main/java/page/clab/api/domain/memberManagement/member/application/service/MemberRoleManagementService.java index db2ced3c0..ceed5fb73 100644 --- a/src/main/java/page/clab/api/domain/memberManagement/member/application/service/MemberRoleManagementService.java +++ b/src/main/java/page/clab/api/domain/memberManagement/member/application/service/MemberRoleManagementService.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.memberManagement.member.application.dto.request.ChangeMemberRoleRequest; @@ -11,8 +12,8 @@ import page.clab.api.domain.memberManagement.member.application.port.out.UpdateMemberPort; import page.clab.api.domain.memberManagement.member.domain.Member; import page.clab.api.domain.memberManagement.member.domain.Role; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -20,11 +21,12 @@ public class MemberRoleManagementService implements ManageMemberRoleUseCase { private final RetrieveMemberPort retrieveMemberPort; private final UpdateMemberPort updateMemberPort; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; @Transactional @Override - public String changeMemberRole(HttpServletRequest httpServletRequest, String memberId, ChangeMemberRoleRequest request) { + public String changeMemberRole(HttpServletRequest httpServletRequest, String memberId, + ChangeMemberRoleRequest request) { Member member = retrieveMemberPort.getById(memberId); Role oldRole = member.getRole(); @@ -34,9 +36,13 @@ public String changeMemberRole(HttpServletRequest httpServletRequest, String mem member.changeRole(newRole); updateMemberPort.update(member); - slackService.sendSecurityAlertNotification(httpServletRequest, SecurityAlertType.MEMBER_ROLE_CHANGED, - String.format("[%s] %s: %s -> %s", - member.getId(), member.getName(), oldRole, newRole)); + + String memberRoleChangedMessage = String.format("[%s] %s: %s -> %s", member.getId(), member.getName(), oldRole, + newRole); + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.MEMBER_ROLE_CHANGED, httpServletRequest, + memberRoleChangedMessage)); + return memberId; } diff --git a/src/main/java/page/clab/api/domain/members/membershipFee/application/service/MembershipFeeRegisterService.java b/src/main/java/page/clab/api/domain/members/membershipFee/application/service/MembershipFeeRegisterService.java index 3d50a19e3..533b7d879 100644 --- a/src/main/java/page/clab/api/domain/members/membershipFee/application/service/MembershipFeeRegisterService.java +++ b/src/main/java/page/clab/api/domain/members/membershipFee/application/service/MembershipFeeRegisterService.java @@ -1,6 +1,7 @@ package page.clab.api.domain.members.membershipFee.application.service; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberBasicInfoDto; @@ -11,8 +12,9 @@ import page.clab.api.domain.members.membershipFee.domain.MembershipFee; import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase; import page.clab.api.external.memberManagement.notification.application.port.ExternalSendNotificationUseCase; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SlackMembershipFeeInfo; +import page.clab.api.global.common.notificationSetting.application.dto.notification.MembershipFeeNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.ExecutivesAlertType; @Service @RequiredArgsConstructor @@ -21,7 +23,7 @@ public class MembershipFeeRegisterService implements RegisterMembershipFeeUseCas private final RegisterMembershipFeePort registerMembershipFeePort; private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; private final ExternalSendNotificationUseCase externalSendNotificationUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; private final MembershipFeeDtoMapper mapper; @Transactional @@ -30,8 +32,10 @@ public Long registerMembershipFee(MembershipFeeRequestDto requestDto) { MemberBasicInfoDto memberInfo = externalRetrieveMemberUseCase.getCurrentMemberBasicInfo(); MembershipFee membershipFee = mapper.fromDto(requestDto, memberInfo.getMemberId()); externalSendNotificationUseCase.sendNotificationToAdmins("새로운 회비 내역이 등록되었습니다."); - SlackMembershipFeeInfo membershipFeeInfo = SlackMembershipFeeInfo.create(membershipFee, memberInfo); - slackService.sendNewMembershipFeeNotification(membershipFeeInfo); + MembershipFeeNotificationInfo membershipFeeInfo = MembershipFeeNotificationInfo.create(membershipFee, + memberInfo); + eventPublisher.publishEvent(new NotificationEvent(this, ExecutivesAlertType.NEW_MEMBERSHIP_FEE, null, + membershipFeeInfo)); return registerMembershipFeePort.save(membershipFee).getId(); } } diff --git a/src/main/java/page/clab/api/external/auth/accountLockInfo/port/ExternalAccountLockManagementService.java b/src/main/java/page/clab/api/external/auth/accountLockInfo/port/ExternalAccountLockManagementService.java index 4a9eb6dde..436c68313 100644 --- a/src/main/java/page/clab/api/external/auth/accountLockInfo/port/ExternalAccountLockManagementService.java +++ b/src/main/java/page/clab/api/external/auth/accountLockInfo/port/ExternalAccountLockManagementService.java @@ -3,6 +3,7 @@ import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.accountLockInfo.application.port.out.RegisterAccountLockInfoPort; @@ -13,8 +14,8 @@ import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberDetailedInfoDto; import page.clab.api.external.auth.accountLockInfo.application.ExternalManageAccountLockUseCase; import page.clab.api.external.memberManagement.member.application.port.ExternalRetrieveMemberUseCase; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -23,7 +24,7 @@ public class ExternalAccountLockManagementService implements ExternalManageAccou private final RetrieveAccountLockInfoPort retrieveAccountLockInfoPort; private final RegisterAccountLockInfoPort registerAccountLockInfoPort; private final ExternalRetrieveMemberUseCase externalRetrieveMemberUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; @Value("${security.login-attempt.max-failures}") private int maxLoginFailures; @@ -39,7 +40,7 @@ public class ExternalAccountLockManagementService implements ExternalManageAccou * * @param memberId 잠금 해제하려는 멤버의 ID * @throws MemberLockedException 계정이 현재 잠겨 있을 경우 예외 발생 - * @throws LoginFailedException 멤버가 존재하지 않을 경우 예외 발생 + * @throws LoginFailedException 멤버가 존재하지 않을 경우 예외 발생 */ @Transactional @Override @@ -55,17 +56,17 @@ public void handleAccountLockInfo(String memberId) throws MemberLockedException, * 로그인 실패를 처리하고 계정 잠금을 관리합니다. * *

로그인 실패 시 멤버의 존재 여부와 계정 잠금 상태를 확인합니다. - * 로그인 실패 횟수를 증가시키며, 설정된 최대 실패 횟수에 도달하면 계정을 잠그고 - * Slack에 보안 알림을 전송합니다.

+ * 로그인 실패 횟수를 증가시키며, 설정된 최대 실패 횟수에 도달하면 계정을 잠그고 Slack에 보안 알림을 전송합니다.

* - * @param request 현재 HTTP 요청 객체 + * @param request 현재 HTTP 요청 객체 * @param memberId 로그인 실패를 기록할 멤버의 ID * @throws MemberLockedException 계정이 현재 잠겨 있을 경우 예외 발생 - * @throws LoginFailedException 멤버가 존재하지 않을 경우 예외 발생 + * @throws LoginFailedException 멤버가 존재하지 않을 경우 예외 발생 */ @Transactional @Override - public void handleLoginFailure(HttpServletRequest request, String memberId) throws MemberLockedException, LoginFailedException { + public void handleLoginFailure(HttpServletRequest request, String memberId) + throws MemberLockedException, LoginFailedException { ensureMemberExists(memberId); AccountLockInfo accountLockInfo = ensureAccountLockInfo(memberId); validateAccountLockStatus(accountLockInfo); @@ -99,7 +100,10 @@ private void sendSlackLoginFailureNotification(HttpServletRequest request, Strin String memberName = memberInfo.getMemberName(); if (memberInfo.isAdminRole()) { request.setAttribute("member", memberId + " " + memberName); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.REPEATED_LOGIN_FAILURES, "로그인 실패 횟수 초과로 계정이 잠겼습니다."); + String repeatedLoginFailuresMessage = "로그인 실패 횟수 초과로 계정이 잠겼습니다."; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.REPEATED_LOGIN_FAILURES, request, + repeatedLoginFailuresMessage)); } } } diff --git a/src/main/java/page/clab/api/external/auth/redisIpAccessMonitor/application/service/ExternalIpAccessMonitorRegisterService.java b/src/main/java/page/clab/api/external/auth/redisIpAccessMonitor/application/service/ExternalIpAccessMonitorRegisterService.java index a600c8b3d..61ac9a980 100644 --- a/src/main/java/page/clab/api/external/auth/redisIpAccessMonitor/application/service/ExternalIpAccessMonitorRegisterService.java +++ b/src/main/java/page/clab/api/external/auth/redisIpAccessMonitor/application/service/ExternalIpAccessMonitorRegisterService.java @@ -4,14 +4,15 @@ import lombok.RequiredArgsConstructor; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import page.clab.api.domain.auth.redisIpAccessMonitor.application.port.out.RegisterIpAccessMonitorPort; import page.clab.api.domain.auth.redisIpAccessMonitor.application.port.out.RetrieveIpAccessMonitorPort; import page.clab.api.domain.auth.redisIpAccessMonitor.domain.RedisIpAccessMonitor; import page.clab.api.external.auth.redisIpAccessMonitor.application.port.ExternalRegisterIpAccessMonitorUseCase; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; @Service @RequiredArgsConstructor @@ -19,7 +20,7 @@ public class ExternalIpAccessMonitorRegisterService implements ExternalRegisterI private final RegisterIpAccessMonitorPort registerIpAccessMonitorPort; private final RetrieveIpAccessMonitorPort retrieveIpAccessMonitorPort; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; @Value("${security.ip-attempt.max-attempts}") private int maxAttempts; @@ -29,7 +30,10 @@ public class ExternalIpAccessMonitorRegisterService implements ExternalRegisterI public void registerIpAccessMonitor(HttpServletRequest request, String ipAddress) { RedisIpAccessMonitor redisIpAccessMonitor = getOrCreateRedisIpAccessMonitor(ipAddress); if (redisIpAccessMonitor.isBlocked()) { - slackService.sendSecurityAlertNotification(request, SecurityAlertType.ABNORMAL_ACCESS_IP_BLOCKED, "Blocked IP: " + ipAddress); + String abnormalAccessIpBlockedMessage = "Blocked IP: " + ipAddress; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.ABNORMAL_ACCESS_IP_BLOCKED, request, + abnormalAccessIpBlockedMessage)); } registerIpAccessMonitorPort.save(redisIpAccessMonitor); } diff --git a/src/main/java/page/clab/api/global/auth/filter/CustomBasicAuthenticationFilter.java b/src/main/java/page/clab/api/global/auth/filter/CustomBasicAuthenticationFilter.java index 954d80f94..ed2287c8e 100644 --- a/src/main/java/page/clab/api/global/auth/filter/CustomBasicAuthenticationFilter.java +++ b/src/main/java/page/clab/api/global/auth/filter/CustomBasicAuthenticationFilter.java @@ -4,8 +4,11 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Base64; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -15,21 +18,17 @@ import page.clab.api.external.auth.blacklistIp.application.port.ExternalRetrieveBlacklistIpUseCase; import page.clab.api.external.auth.redisIpAccessMonitor.application.port.ExternalCheckIpBlockedUseCase; import page.clab.api.global.auth.util.IpWhitelistValidator; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; import page.clab.api.global.util.HttpReqResUtil; import page.clab.api.global.util.ResponseUtil; import page.clab.api.global.util.WhitelistPathMatcher; -import java.io.IOException; -import java.util.Base64; - /** * {@code CustomBasicAuthenticationFilter}는 기본 인증 필터를 확장하여 추가적인 보안 기능을 제공합니다. * *

IP 주소 기반 접근 제한, 화이트리스트 경로 검증, 사용자 인증 정보를 바탕으로 - * Slack 보안 알림을 전송하는 기능을 포함합니다. 또한 Swagger 또는 Actuator에 대한 - * 접근이 성공하거나 실패할 경우 이를 Slack에 알립니다.

+ * Slack 보안 알림을 전송하는 기능을 포함합니다. 또한 Swagger 또는 Actuator에 대한 접근이 성공하거나 실패할 경우 이를 Slack에 알립니다.

* *

이 필터는 다음과 같은 추가 검증을 수행합니다:

*
    @@ -47,22 +46,29 @@ public class CustomBasicAuthenticationFilter extends BasicAuthenticationFilter { private final IpWhitelistValidator ipWhitelistValidator; - private final SlackService slackService; private final ExternalCheckIpBlockedUseCase externalCheckIpBlockedUseCase; private final ExternalRetrieveBlacklistIpUseCase externalRetrieveBlacklistIpUseCase; + private final ApplicationEventPublisher eventPublisher; public CustomBasicAuthenticationFilter( AuthenticationManager authenticationManager, IpWhitelistValidator ipWhitelistValidator, - SlackService slackService, ExternalCheckIpBlockedUseCase externalCheckIpBlockedUseCase, - ExternalRetrieveBlacklistIpUseCase externalRetrieveBlacklistIpUseCase + ExternalRetrieveBlacklistIpUseCase externalRetrieveBlacklistIpUseCase, + ApplicationEventPublisher eventPublisher ) { super(authenticationManager); this.externalCheckIpBlockedUseCase = externalCheckIpBlockedUseCase; this.externalRetrieveBlacklistIpUseCase = externalRetrieveBlacklistIpUseCase; this.ipWhitelistValidator = ipWhitelistValidator; - this.slackService = slackService; + this.eventPublisher = eventPublisher; + } + + @NotNull + private static String[] decodeCredentials(String authorizationHeader) { + String base64Credentials = authorizationHeader.substring("Basic ".length()); + String credentials = new String(Base64.getDecoder().decode(base64Credentials)); + return credentials.split(":", 2); } @Override @@ -82,7 +88,8 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse super.doFilterInternal(request, response, chain); } - private boolean authenticateUserCredentials(HttpServletRequest request, HttpServletResponse response) throws IOException { + private boolean authenticateUserCredentials(HttpServletRequest request, HttpServletResponse response) + throws IOException { String authorizationHeader = request.getHeader("Authorization"); if (authorizationHeader == null || !authorizationHeader.startsWith("Basic ")) { response.setHeader("WWW-Authenticate", "Basic realm=\"Please enter your username and password\""); @@ -122,28 +129,29 @@ private boolean verifyIpAddressAccess(HttpServletResponse response) throws IOExc return true; } - @NotNull - private static String[] decodeCredentials(String authorizationHeader) { - String base64Credentials = authorizationHeader.substring("Basic ".length()); - String credentials = new String(Base64.getDecoder().decode(base64Credentials)); - return credentials.split(":", 2); - } - private void sendAuthenticationSuccessAlertSlackMessage(HttpServletRequest request) { String path = request.getRequestURI(); if (WhitelistPathMatcher.isSwaggerIndexEndpoint(path)) { - slackService.sendSecurityAlertNotification(request, SecurityAlertType.API_DOCS_ACCESS,"API 문서에 대한 접근이 허가되었습니다."); + String apiDocsAccessMessage = "API 문서에 대한 접근이 허가되었습니다."; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.API_DOCS_ACCESS, request, apiDocsAccessMessage)); } else if (WhitelistPathMatcher.isActuatorRequest(path)) { - slackService.sendSecurityAlertNotification(request, SecurityAlertType.ACTUATOR_ACCESS,"Actuator에 대한 접근이 허가되었습니다."); + String actuatorAccessMessage = "Actuator에 대한 접근이 허가되었습니다."; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.ACTUATOR_ACCESS, request, actuatorAccessMessage)); } } private void sendAuthenticationFailureAlertSlackMessage(HttpServletRequest request) { String path = request.getRequestURI(); if (WhitelistPathMatcher.isSwaggerIndexEndpoint(path)) { - slackService.sendSecurityAlertNotification(request, SecurityAlertType.API_DOCS_ACCESS,"API 문서에 대한 접근이 거부되었습니다."); + String apiDocsAccessMessage = "API 문서에 대한 접근이 거부되었습니다."; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.API_DOCS_ACCESS, request, apiDocsAccessMessage)); } else if (WhitelistPathMatcher.isActuatorRequest(path)) { - slackService.sendSecurityAlertNotification(request, SecurityAlertType.ACTUATOR_ACCESS,"Actuator에 대한 접근이 거부되었습니다."); + String actuatorAccessMessage = "Actuator에 대한 접근이 거부되었습니다."; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.ACTUATOR_ACCESS, request, actuatorAccessMessage)); } } } diff --git a/src/main/java/page/clab/api/global/auth/filter/InvalidEndpointAccessFilter.java b/src/main/java/page/clab/api/global/auth/filter/InvalidEndpointAccessFilter.java index b8314f9bf..5f7548fd1 100644 --- a/src/main/java/page/clab/api/global/auth/filter/InvalidEndpointAccessFilter.java +++ b/src/main/java/page/clab/api/global/auth/filter/InvalidEndpointAccessFilter.java @@ -6,25 +6,24 @@ import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.GenericFilterBean; import page.clab.api.domain.auth.blacklistIp.domain.BlacklistIp; import page.clab.api.external.auth.blacklistIp.application.port.ExternalRegisterBlacklistIpUseCase; import page.clab.api.external.auth.blacklistIp.application.port.ExternalRetrieveBlacklistIpUseCase; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; import page.clab.api.global.util.HttpReqResUtil; import page.clab.api.global.util.ResponseUtil; import page.clab.api.global.util.SecurityPatternChecker; -import java.io.IOException; - /** - * {@code InvalidEndpointAccessFilter}는 서버 내부 파일 및 디렉토리에 대한 비정상적인 접근을 차단하고 - * 보안 경고를 전송하는 필터입니다. + * {@code InvalidEndpointAccessFilter}는 서버 내부 파일 및 디렉토리에 대한 비정상적인 접근을 차단하고 보안 경고를 전송하는 필터입니다. * *

    특정 패턴을 통해 비정상적인 접근 시도를 탐지하며, 비정상적인 경로로 접근을 시도한 IP를 * 블랙리스트에 등록하고, Slack을 통해 보안 경고 메시지를 전송합니다.

    @@ -44,13 +43,14 @@ @Slf4j public class InvalidEndpointAccessFilter extends GenericFilterBean { - private final SlackService slackService; private final String fileURL; private final ExternalRegisterBlacklistIpUseCase externalRegisterBlacklistIpUseCase; private final ExternalRetrieveBlacklistIpUseCase externalRetrieveBlacklistIpUseCase; + private final ApplicationEventPublisher eventPublisher; @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; String path = httpRequest.getRequestURI(); boolean isUploadedFileAccess = path.startsWith(fileURL); @@ -75,7 +75,8 @@ private void handleSuspiciousAccess(HttpServletRequest request, HttpServletRespo private void logSuspiciousAccess(HttpServletRequest request, String clientIpAddress) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - String id = (authentication == null || authentication.getName() == null) ? "anonymous" : authentication.getName(); + String id = + (authentication == null || authentication.getName() == null) ? "anonymous" : authentication.getName(); String requestUrl = request.getRequestURI(); String httpMethod = request.getMethod(); int statusCode = HttpServletResponse.SC_FORBIDDEN; @@ -97,7 +98,9 @@ private void sendSecurityAlerts(HttpServletRequest request, String clientIpAddre String abnormalAccessMessage = "서버 내부 파일 및 디렉토리에 대한 접근이 감지되었습니다."; String blacklistAddedMessage = "Added IP: " + clientIpAddress; - slackService.sendSecurityAlertNotification(request, SecurityAlertType.ABNORMAL_ACCESS, abnormalAccessMessage); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.BLACKLISTED_IP_ADDED, blacklistAddedMessage); + eventPublisher.publishEvent(new NotificationEvent(this, SecurityAlertType.ABNORMAL_ACCESS, request, + abnormalAccessMessage)); + eventPublisher.publishEvent(new NotificationEvent(this, SecurityAlertType.BLACKLISTED_IP_ADDED, request, + blacklistAddedMessage)); } } diff --git a/src/main/java/page/clab/api/global/auth/filter/JwtAuthenticationFilter.java b/src/main/java/page/clab/api/global/auth/filter/JwtAuthenticationFilter.java index 12bda3af8..fba999b53 100644 --- a/src/main/java/page/clab/api/global/auth/filter/JwtAuthenticationFilter.java +++ b/src/main/java/page/clab/api/global/auth/filter/JwtAuthenticationFilter.java @@ -6,8 +6,10 @@ import jakarta.servlet.ServletResponse; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.filter.GenericFilterBean; @@ -16,14 +18,12 @@ import page.clab.api.external.auth.redisIpAccessMonitor.application.port.ExternalCheckIpBlockedUseCase; import page.clab.api.external.auth.redisToken.application.port.ExternalManageRedisTokenUseCase; import page.clab.api.global.auth.jwt.JwtTokenProvider; -import page.clab.api.global.common.slack.application.SlackService; -import page.clab.api.global.common.slack.domain.SecurityAlertType; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; import page.clab.api.global.util.HttpReqResUtil; import page.clab.api.global.util.ResponseUtil; import page.clab.api.global.util.WhitelistPathMatcher; -import java.io.IOException; - /** * {@code JwtAuthenticationFilter}는 JWT 토큰을 검증하고, IP 주소 기반 접근 제한을 수행하는 필터입니다. * @@ -45,14 +45,15 @@ @Slf4j public class JwtAuthenticationFilter extends GenericFilterBean { - private final SlackService slackService; private final JwtTokenProvider jwtTokenProvider; + private final ApplicationEventPublisher eventPublisher; private final ExternalManageRedisTokenUseCase externalManageRedisTokenUseCase; private final ExternalCheckIpBlockedUseCase externalCheckIpBlockedUseCase; private final ExternalRetrieveBlacklistIpUseCase externalRetrieveBlacklistIpUseCase; @Override - public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { HttpServletRequest httpServletRequest = (HttpServletRequest) request; HttpServletResponse httpServletResponse = (HttpServletResponse) response; String path = httpServletRequest.getRequestURI(); @@ -71,7 +72,8 @@ public void doFilter(ServletRequest request, ServletResponse response, FilterCha } private boolean verifyIpAddressAccess(HttpServletResponse response, String clientIpAddress) throws IOException { - if (externalRetrieveBlacklistIpUseCase.existsByIpAddress(clientIpAddress) || externalCheckIpBlockedUseCase.isIpBlocked(clientIpAddress)) { + if (externalRetrieveBlacklistIpUseCase.existsByIpAddress(clientIpAddress) + || externalCheckIpBlockedUseCase.isIpBlocked(clientIpAddress)) { log.info("[{}] : 서비스 이용이 제한된 IP입니다.", clientIpAddress); ResponseUtil.sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED); return false; @@ -79,12 +81,15 @@ private boolean verifyIpAddressAccess(HttpServletResponse response, String clien return true; } - private boolean authenticateToken(HttpServletRequest request, HttpServletResponse response, String clientIpAddress) throws IOException { + private boolean authenticateToken(HttpServletRequest request, HttpServletResponse response, String clientIpAddress) + throws IOException { String token = jwtTokenProvider.resolveToken(request); // 토큰이 존재하고 유효한 경우 if (token != null && jwtTokenProvider.validateToken(token)) { - RedisToken redisToken = jwtTokenProvider.isRefreshToken(token) ? externalManageRedisTokenUseCase.findByRefreshToken(token) : externalManageRedisTokenUseCase.findByAccessToken(token); + RedisToken redisToken = + jwtTokenProvider.isRefreshToken(token) ? externalManageRedisTokenUseCase.findByRefreshToken(token) + : externalManageRedisTokenUseCase.findByAccessToken(token); if (redisToken == null) { log.warn("존재하지 않는 토큰입니다."); ResponseUtil.sendErrorResponse(response, HttpServletResponse.SC_UNAUTHORIZED); @@ -108,7 +113,9 @@ private boolean authenticateToken(HttpServletRequest request, HttpServletRespons private void sendSecurityAlertSlackMessage(HttpServletRequest request, RedisToken redisToken) { if (redisToken.isAdminToken()) { request.setAttribute("member", redisToken.getId()); - slackService.sendSecurityAlertNotification(request, SecurityAlertType.DUPLICATE_LOGIN, "토큰 발급 IP와 다른 IP에서 접속하여 토큰을 삭제하였습니다."); + String duplicateLoginMessage = "토큰 발급 IP와 다른 IP에서 접속하여 토큰을 삭제하였습니다."; + eventPublisher.publishEvent( + new NotificationEvent(this, SecurityAlertType.DUPLICATE_LOGIN, request, duplicateLoginMessage)); } } } diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/in/web/NotificationSettingRetrieveController.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/in/web/NotificationSettingRetrieveController.java new file mode 100644 index 000000000..00117991b --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/in/web/NotificationSettingRetrieveController.java @@ -0,0 +1,30 @@ +package page.clab.api.global.common.notificationSetting.adapter.in.web; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import page.clab.api.global.common.dto.ApiResponse; +import page.clab.api.global.common.notificationSetting.application.dto.response.NotificationSettingResponseDto; +import page.clab.api.global.common.notificationSetting.application.port.in.RetrieveNotificationSettingUseCase; + +@RestController +@RequestMapping("/api/v1/notification-settings") +@RequiredArgsConstructor +@Tag(name = "Notification Setting", description = "웹훅 알림 설정") +public class NotificationSettingRetrieveController { + + private final RetrieveNotificationSettingUseCase retrieveNotificationSettingUseCase; + + @Operation(summary = "[S] 웹훅 알림 조회", description = "ROLE_SUPER 이상의 권한이 필요함") + @PreAuthorize("hasRole('SUPER')") + @GetMapping("") + public ApiResponse> getNotificationSettings() { + List notificationSettings = retrieveNotificationSettingUseCase.retrieveNotificationSettings(); + return ApiResponse.success(notificationSettings); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/in/web/NotificationSettingToggleController.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/in/web/NotificationSettingToggleController.java new file mode 100644 index 000000000..74023a6bc --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/in/web/NotificationSettingToggleController.java @@ -0,0 +1,33 @@ +package page.clab.api.global.common.notificationSetting.adapter.in.web; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import page.clab.api.global.common.dto.ApiResponse; +import page.clab.api.global.common.notificationSetting.application.dto.request.NotificationSettingToggleRequestDto; +import page.clab.api.global.common.notificationSetting.application.port.in.ManageNotificationSettingUseCase; + +@RestController +@RequestMapping("/api/v1/notification-settings") +@RequiredArgsConstructor +@Tag(name = "Notification Setting", description = "웹훅 알림 설정") +public class NotificationSettingToggleController { + + private final ManageNotificationSettingUseCase manageNotificationSettingUseCase; + + @Operation(summary = "[S] 웹훅 알림 설정 변경", description = "ROLE_SUPER 이상의 권한이 필요함") + @PreAuthorize("hasRole('SUPER')") + @PutMapping("") + public ApiResponse toggleNotificationSetting( + @Valid @RequestBody NotificationSettingToggleRequestDto requestDto + ) { + manageNotificationSettingUseCase.toggleNotificationSetting(requestDto.getAlertType(), requestDto.isEnabled()); + return ApiResponse.success(); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/persistence/NotificationSettingPersistenceAdapter.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/persistence/NotificationSettingPersistenceAdapter.java new file mode 100644 index 000000000..4bae839c3 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/persistence/NotificationSettingPersistenceAdapter.java @@ -0,0 +1,34 @@ +package page.clab.api.global.common.notificationSetting.adapter.out.persistence; + +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import page.clab.api.global.common.notificationSetting.application.port.out.RetrieveNotificationSettingPort; +import page.clab.api.global.common.notificationSetting.application.port.out.UpdateNotificationSettingPort; +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; + +@Component +@RequiredArgsConstructor +public class NotificationSettingPersistenceAdapter implements + RetrieveNotificationSettingPort, + UpdateNotificationSettingPort { + + private final NotificationSettingRepository repository; + + @Override + public List findAll() { + return repository.findAll(); + } + + @Override + public Optional findByAlertType(AlertType alertType) { + return repository.findByAlertType(alertType); + } + + @Override + public NotificationSetting save(NotificationSetting setting) { + return repository.save(setting); + } +} diff --git a/src/main/java/page/clab/api/global/common/slack/dao/NotificationSettingRepository.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/persistence/NotificationSettingRepository.java similarity index 52% rename from src/main/java/page/clab/api/global/common/slack/dao/NotificationSettingRepository.java rename to src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/persistence/NotificationSettingRepository.java index e4cab3c38..4be9efb7b 100644 --- a/src/main/java/page/clab/api/global/common/slack/dao/NotificationSettingRepository.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/persistence/NotificationSettingRepository.java @@ -1,10 +1,9 @@ -package page.clab.api.global.common.slack.dao; - -import org.springframework.data.jpa.repository.JpaRepository; -import page.clab.api.global.common.slack.domain.AlertType; -import page.clab.api.global.common.slack.domain.NotificationSetting; +package page.clab.api.global.common.notificationSetting.adapter.out.persistence; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; public interface NotificationSettingRepository extends JpaRepository { diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/AbstractWebhookClient.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/AbstractWebhookClient.java new file mode 100644 index 000000000..d67f0a17f --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/AbstractWebhookClient.java @@ -0,0 +1,17 @@ +package page.clab.api.global.common.notificationSetting.adapter.out.webhook; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.concurrent.CompletableFuture; +import page.clab.api.global.common.notificationSetting.application.port.out.WebhookClient; +import page.clab.api.global.common.notificationSetting.domain.AlertType; + +/** + * {@code AbstractWebhookClient}는 Discord 및 Slack Webhook 클라이언트의 공통 인터페이스를 정의하는 추상 클래스입니다. + */ +public abstract class AbstractWebhookClient implements WebhookClient { + + @Override + public abstract CompletableFuture sendMessage(String webhookUrl, AlertType alertType, + HttpServletRequest request, + Object additionalData); +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/DiscordNotificationSender.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/DiscordNotificationSender.java new file mode 100644 index 000000000..b6bfedf51 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/DiscordNotificationSender.java @@ -0,0 +1,25 @@ +package page.clab.api.global.common.notificationSetting.adapter.out.webhook; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.application.port.out.NotificationSender; +import page.clab.api.global.common.notificationSetting.domain.PlatformType; + +@Component +@RequiredArgsConstructor +public class DiscordNotificationSender implements NotificationSender { + + private final DiscordWebhookClient discordWebhookClient; + + @Override + public String getPlatformName() { + return PlatformType.DISCORD.getName(); + } + + @Override + public void sendNotification(NotificationEvent event, String webhookUrl) { + discordWebhookClient.sendMessage(webhookUrl, event.getAlertType(), event.getRequest(), + event.getAdditionalData()); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/DiscordWebhookClient.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/DiscordWebhookClient.java new file mode 100644 index 000000000..fb1c443d3 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/DiscordWebhookClient.java @@ -0,0 +1,362 @@ +package page.clab.api.global.common.notificationSetting.adapter.out.webhook; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import page.clab.api.domain.hiring.application.application.dto.request.ApplicationRequestDto; +import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberLoginInfoDto; +import page.clab.api.global.common.notificationSetting.application.dto.notification.BoardNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.dto.notification.BookLoanRecordNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.dto.notification.MembershipFeeNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.service.WebhookCommonService; +import page.clab.api.global.common.notificationSetting.config.NotificationConfigProperties; +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.ExecutivesAlertType; +import page.clab.api.global.common.notificationSetting.domain.GeneralAlertType; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; +import page.clab.api.global.util.HttpReqResUtil; + +/** + * {@code DiscordWebhookClient}는 다양한 알림 유형에 따라 Discord 메시지를 구성하고 전송하는 클래스입니다. + * + *

    주요 기능:

    + *
      + *
    • {@link #sendMessage(String, AlertType, HttpServletRequest, Object)}: Discord에 알림 메시지를 비동기적으로 전송
    • + *
    • {@link #createEmbeds(AlertType, HttpServletRequest, Object)}: 알림 유형에 따라 Discord 메시지 임베드 생성
    • + *
    • 다양한 알림 유형에 맞는 메시지 형식을 생성하는 전용 메서드
    • + *
    + * + *

    Discord Webhook API를 사용하여 웹훅 URL을 통해 메시지를 전송하며, 메시지 전송 실패 시 로그에 오류를 기록합니다.

    + * + *

    AlertType을 기반으로 여러 도메인에서 발생하는 이벤트를 Discord를 통해 모니터링할 수 있도록 지원하며, + * Discord 알림은 주로 서버 이벤트, 보안 경고, 신규 신청, 관리자 로그인 등의 이벤트를 다룹니다.

    + * + * @see HttpClient + * @see HttpRequest + * @see HttpResponse + */ +@Component +@Slf4j +public class DiscordWebhookClient extends AbstractWebhookClient { + + private final HttpClient httpClient; + private final ObjectMapper objectMapper; + private final NotificationConfigProperties.CommonProperties commonProperties; + private final Environment environment; + private final WebhookCommonService webhookCommonService; + + public DiscordWebhookClient( + NotificationConfigProperties notificationConfigProperties, + ObjectMapper objectMapper, + Environment environment, + WebhookCommonService webhookCommonService + ) { + this.httpClient = HttpClient.newHttpClient(); + this.objectMapper = objectMapper; + this.commonProperties = notificationConfigProperties.getCommon(); + this.environment = environment; + this.webhookCommonService = webhookCommonService; + } + + /** + * Discord에 알림 메시지를 비동기적으로 전송합니다. + * + * @param webhookUrl 메시지를 보낼 Discord 웹훅 URL + * @param alertType 알림 유형을 나타내는 {@link AlertType} + * @param request HttpServletRequest 객체, 클라이언트 요청 정보 + * @param additionalData 추가 데이터 + * @return 메시지 전송 성공 여부를 나타내는 CompletableFuture + */ + public CompletableFuture sendMessage(String webhookUrl, AlertType alertType, + HttpServletRequest request, Object additionalData) { + Map payload = createPayload(alertType, request, additionalData); + + return CompletableFuture.supplyAsync(() -> { + try { + String jsonPayload = objectMapper.writeValueAsString(payload); + + HttpRequest httpRequest = HttpRequest.newBuilder() + .uri(URI.create(webhookUrl)) + .header("Content-Type", MediaType.APPLICATION_JSON_VALUE) + .POST(HttpRequest.BodyPublishers.ofString(jsonPayload)) + .build(); + + HttpResponse response = httpClient.send(httpRequest, HttpResponse.BodyHandlers.ofString()); + + if (response.statusCode() == HttpStatus.NO_CONTENT.value()) { + return true; + } else { + log.error("Discord notification failed: {}", response.body()); + return false; + } + } catch (IOException | InterruptedException e) { + log.error("Failed to send Discord message: {}", e.getMessage(), e); + return false; + } + }); + } + + /** + * 알림 유형과 요청 정보, 추가 데이터를 사용하여 Discord 메시지 페이로드를 생성합니다. + * + * @param alertType 알림 유형 + * @param request 클라이언트 요청 정보 + * @param additionalData 추가 데이터 + * @return 생성된 페이로드 맵 + */ + public Map createPayload(AlertType alertType, HttpServletRequest request, Object additionalData) { + List> embeds = createEmbeds(alertType, request, additionalData); + + Map payload = new HashMap<>(); + payload.put("embeds", embeds); + + return payload; + } + + /** + * 특정 알림 유형과 요청 정보 및 추가 데이터를 사용하여 Discord 메시지의 임베드를 생성합니다. + * + * @param alertType 알림 유형 + * @param request HttpServletRequest 객체 + * @param additionalData 추가 데이터 + * @return 생성된 임베드 목록 + */ + public List> createEmbeds(AlertType alertType, HttpServletRequest request, + Object additionalData) { + switch (alertType) { + case SecurityAlertType securityAlertType -> { + return createSecurityAlertEmbeds(request, alertType, additionalData.toString()); + } + case GeneralAlertType generalAlertType -> { + return createGeneralAlertEmbeds(generalAlertType, request, additionalData); + } + case ExecutivesAlertType executivesAlertType -> { + return createExecutivesAlertEmbeds(executivesAlertType, additionalData); + } + case null, default -> { + log.error("Unknown alert type: {}", alertType); + return Collections.emptyList(); + } + } + } + + private List> createGeneralAlertEmbeds(GeneralAlertType alertType, HttpServletRequest request, + Object additionalData) { + switch (alertType) { + case ADMIN_LOGIN: + if (additionalData instanceof MemberLoginInfoDto) { + return createAdminLoginEmbeds(request, (MemberLoginInfoDto) additionalData); + } + break; + case SERVER_START: + return createServerStartEmbeds(); + case SERVER_ERROR: + if (additionalData instanceof Exception) { + return createErrorEmbeds(request, (Exception) additionalData); + } + break; + default: + log.error("Unknown general alert type: {}", alertType); + } + return Collections.emptyList(); + } + + private List> createExecutivesAlertEmbeds(ExecutivesAlertType alertType, + Object additionalData) { + switch (alertType) { + case NEW_APPLICATION: + if (additionalData instanceof ApplicationRequestDto) { + return createApplicationEmbeds((ApplicationRequestDto) additionalData); + } + break; + case NEW_BOARD: + if (additionalData instanceof BoardNotificationInfo) { + return createBoardEmbeds((BoardNotificationInfo) additionalData); + } + break; + case NEW_MEMBERSHIP_FEE: + if (additionalData instanceof MembershipFeeNotificationInfo) { + return createMembershipFeeEmbeds((MembershipFeeNotificationInfo) additionalData); + } + break; + case NEW_BOOK_LOAN_REQUEST: + if (additionalData instanceof BookLoanRecordNotificationInfo) { + return createBookLoanRecordEmbeds((BookLoanRecordNotificationInfo) additionalData); + } + break; + default: + log.error("Unknown executives alert type: {}", alertType); + } + return Collections.emptyList(); + } + + private List> createErrorEmbeds(HttpServletRequest request, Exception e) { + String httpMethod = request.getMethod(); + String fullUrl = webhookCommonService.getFullUrl(request); + String username = webhookCommonService.getUsername(request); + String errorMessage = webhookCommonService.extractMessageAfterException(e); + String stackTrace = webhookCommonService.getStackTraceSummary(e); + + log.error("Server Error: {}", errorMessage); + + Map embed = new HashMap<>(); + embed.put("title", ":firecracker: Server Error"); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("User", username, true), + createField("Endpoint", "[" + httpMethod + "] " + fullUrl, true), + createField("Error Message", errorMessage, false), + createField("Stack Trace", "```" + stackTrace + "```", false) + )); + + return Collections.singletonList(embed); + } + + private List> createSecurityAlertEmbeds(HttpServletRequest request, AlertType alertType, + String additionalMessage) { + String clientIp = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); + String fullUrl = webhookCommonService.getFullUrl(request); + String username = webhookCommonService.getUsername(request); + String location = webhookCommonService.getLocation(request); + + Map embed = new HashMap<>(); + embed.put("title", ":imp: " + alertType.getTitle()); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("User", username, true), + createField("IP Address", clientIp, true), + createField("Location", location, true), + createField("Endpoint", fullUrl, true), + createField("Details", alertType.getDefaultMessage() + "\n" + additionalMessage, false) + )); + + return Collections.singletonList(embed); + } + + private List> createAdminLoginEmbeds(HttpServletRequest request, + MemberLoginInfoDto loginMember) { + String clientIp = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); + String location = webhookCommonService.getLocation(request); + + Map embed = new HashMap<>(); + embed.put("title", ":mechanic: " + loginMember.getRole().getDescription() + " Login"); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("User", loginMember.getMemberId() + " " + loginMember.getMemberName(), true), + createField("IP Address", clientIp, true), + createField("Location", location, true) + )); + + return Collections.singletonList(embed); + } + + private List> createApplicationEmbeds(ApplicationRequestDto requestDto) { + Map embed = new HashMap<>(); + embed.put("title", ":sparkles: 동아리 지원"); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("구분", requestDto.getApplicationType().getDescription(), true), + createField("학번", requestDto.getStudentId(), true), + createField("이름", requestDto.getName(), true), + createField("학년", requestDto.getGrade() + "학년", true), + createField("관심 분야", requestDto.getInterests(), false) + )); + + if (requestDto.getGithubUrl() != null && !requestDto.getGithubUrl().isEmpty()) { + embed.put("description", "[Github](" + requestDto.getGithubUrl() + ")"); + } + + return Collections.singletonList(embed); + } + + private List> createBoardEmbeds(BoardNotificationInfo board) { + Map embed = new HashMap<>(); + embed.put("title", ":writing_hand: 새 게시글"); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("제목", board.getTitle(), true), + createField("분류", board.getCategory(), true), + createField("작성자", board.getUsername(), true) + )); + + return Collections.singletonList(embed); + } + + private List> createMembershipFeeEmbeds(MembershipFeeNotificationInfo data) { + String username = data.getMemberId() + " " + data.getMemberName(); + + Map embed = new HashMap<>(); + embed.put("title", ":dollar: 회비 신청"); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("신청자", username, true), + createField("분류", data.getCategory(), true), + createField("금액", data.getAmount() + "원", true), + createField("Content", data.getContent(), false) + )); + + return Collections.singletonList(embed); + } + + private List> createBookLoanRecordEmbeds(BookLoanRecordNotificationInfo data) { + String username = data.getMemberId() + " " + data.getMemberName(); + + Map embed = new HashMap<>(); + embed.put("title", ":books: 도서 대여 신청"); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("도서명", data.getBookTitle(), true), + createField("분류", data.getCategory(), true), + createField("신청자", username, true), + createField("상태", data.isAvailable() ? "대여 가능" : "대여 중", true) + )); + + return Collections.singletonList(embed); + } + + private List> createServerStartEmbeds() { + String osInfo = webhookCommonService.getOperatingSystemInfo(); + String jdkVersion = webhookCommonService.getJavaRuntimeVersion(); + double cpuUsage = webhookCommonService.getCpuUsage(); + String memoryUsage = webhookCommonService.getMemoryUsage(); + + Map embed = new HashMap<>(); + embed.put("title", ":battery: Server Started"); + embed.put("color", commonProperties.getColorAsInt()); + embed.put("fields", Arrays.asList( + createField("Environment", environment.getProperty("spring.profiles.active"), true), + createField("OS", osInfo, true), + createField("JDK Version", jdkVersion, true), + createField("CPU Usage", String.format("%.2f%%", cpuUsage), true), + createField("Memory Usage", memoryUsage, true) + )); + + embed.put("description", + "[Web](" + commonProperties.getWebUrl() + ") | [API Docs](" + commonProperties.getApiUrl() + ")"); + + return Collections.singletonList(embed); + } + + private Map createField(String name, String value, boolean inline) { + Map field = new HashMap<>(); + field.put("name", name); + field.put("value", value); + field.put("inline", inline); + return field; + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/SlackNotificationSender.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/SlackNotificationSender.java new file mode 100644 index 000000000..3a993dc57 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/SlackNotificationSender.java @@ -0,0 +1,25 @@ +package page.clab.api.global.common.notificationSetting.adapter.out.webhook; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.application.port.out.NotificationSender; +import page.clab.api.global.common.notificationSetting.domain.PlatformType; + +@Component +@RequiredArgsConstructor +public class SlackNotificationSender implements NotificationSender { + + private final SlackWebhookClient slackWebhookClient; + + @Override + public String getPlatformName() { + return PlatformType.SLACK.getName(); + } + + @Override + public void sendNotification(NotificationEvent event, String webhookUrl) { + slackWebhookClient.sendMessage(webhookUrl, event.getAlertType(), event.getRequest(), + event.getAdditionalData()); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/SlackWebhookClient.java b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/SlackWebhookClient.java new file mode 100644 index 000000000..291bfd047 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/adapter/out/webhook/SlackWebhookClient.java @@ -0,0 +1,338 @@ +package page.clab.api.global.common.notificationSetting.adapter.out.webhook; + +import static com.slack.api.model.block.Blocks.actions; +import static com.slack.api.model.block.Blocks.section; +import static com.slack.api.model.block.composition.BlockCompositions.markdownText; +import static com.slack.api.model.block.composition.BlockCompositions.plainText; +import static com.slack.api.model.block.element.BlockElements.asElements; +import static com.slack.api.model.block.element.BlockElements.button; + +import com.slack.api.Slack; +import com.slack.api.model.Attachment; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.webhook.Payload; +import com.slack.api.webhook.WebhookResponse; +import jakarta.servlet.http.HttpServletRequest; +import java.io.IOException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import page.clab.api.domain.hiring.application.application.dto.request.ApplicationRequestDto; +import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberLoginInfoDto; +import page.clab.api.global.common.notificationSetting.application.dto.notification.BoardNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.dto.notification.BookLoanRecordNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.dto.notification.MembershipFeeNotificationInfo; +import page.clab.api.global.common.notificationSetting.application.service.WebhookCommonService; +import page.clab.api.global.common.notificationSetting.config.NotificationConfigProperties; +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.ExecutivesAlertType; +import page.clab.api.global.common.notificationSetting.domain.GeneralAlertType; +import page.clab.api.global.common.notificationSetting.domain.SecurityAlertType; +import page.clab.api.global.util.HttpReqResUtil; + +/** + * {@code SlackWebhookClient}는 다양한 알림 유형에 따라 Slack 메시지를 구성하고 전송하는 클래스입니다. + * + *

    주요 기능:

    + *
      + *
    • {@link #sendMessage(String, AlertType, HttpServletRequest, Object)}: Slack에 알림 메시지를 비동기적으로 전송
    • + *
    • {@link #createBlocks(AlertType, HttpServletRequest, Object)}: 알림 유형에 따라 Slack 메시지 블록 생성
    • + *
    • 다양한 알림 유형에 맞는 메시지 형식을 생성하는 전용 메서드
    • + *
    + * + *

    Slack API와 통합하여 웹훅 URL을 통해 메시지를 전송하며, 메시지 전송 실패 시 로그에 오류를 기록합니다.

    + * + *

    AlertType을 기반으로 여러 도메인에서 발생하는 이벤트를 Slack을 통해 모니터링할 수 있도록 지원하며, + * Slack 알림은 주로 서버 이벤트, 보안 경고, 신규 신청, 관리자 로그인 등의 이벤트를 다룹니다.

    + * + * @see Slack + * @see Payload + * @see LayoutBlock + */ +@Component +@Slf4j +public class SlackWebhookClient extends AbstractWebhookClient { + + private final Slack slack; + private final NotificationConfigProperties.CommonProperties commonProperties; + private final Environment environment; + private final WebhookCommonService webhookCommonService; + + public SlackWebhookClient( + NotificationConfigProperties notificationConfigProperties, + Environment environment, + WebhookCommonService webhookCommonService + ) { + this.slack = Slack.getInstance(); + this.commonProperties = notificationConfigProperties.getCommon(); + this.environment = environment; + this.webhookCommonService = webhookCommonService; + } + + /** + * Slack에 알림 메시지를 전송합니다. + * + *

    주어진 webhookUrl과 alertType, HttpServletRequest 및 추가 데이터(additionalData)를 사용하여 알림 메시지를 + * 비동기적으로 Slack에 전송합니다.

    + * + * @param webhookUrl 메시지를 보낼 Slack 웹훅 URL + * @param alertType 알림 유형을 나타내는 {@link AlertType} + * @param request HttpServletRequest 객체, 클라이언트 요청 정보 + * @param additionalData 추가 데이터 + * @return 메시지 전송 성공 여부를 나타내는 CompletableFuture + */ + public CompletableFuture sendMessage(String webhookUrl, AlertType alertType, + HttpServletRequest request, Object additionalData) { + List blocks = createBlocks(alertType, request, additionalData); + + return CompletableFuture.supplyAsync(() -> { + Payload payload = Payload.builder() + .blocks(Collections.singletonList(blocks.getFirst())) + .attachments(Collections.singletonList( + Attachment.builder() + .color(commonProperties.getColor()) + .blocks(blocks.subList(1, blocks.size())) + .build() + )) + .build(); + + try { + WebhookResponse response = slack.send(webhookUrl, payload); + if (response.getCode() == HttpStatus.OK.value()) { + return true; + } else { + log.error("Slack notification failed: {}", response.getMessage()); + return false; + } + } catch (IOException e) { + log.error("Failed to send Slack message: {}", e.getMessage(), e); + return false; + } + }); + } + + /** + * 특정 알림 유형과 요청 정보 및 추가 데이터를 사용하여 Slack 메시지의 블록을 생성합니다. + * + *

    AlertType에 따라 보안 경고, 일반 알림, 운영진 알림 등 다양한 형식의 메시지를 생성합니다.

    + * + * @param alertType 알림 유형 + * @param request HttpServletRequest 객체 + * @param additionalData 추가 데이터 + * @return 생성된 LayoutBlock 목록 + */ + public List createBlocks(AlertType alertType, HttpServletRequest request, Object additionalData) { + switch (alertType) { + case SecurityAlertType securityAlertType -> { + return createSecurityAlertBlocks(request, alertType, additionalData.toString()); + } + case GeneralAlertType generalAlertType -> { + return createGeneralAlertBlocks(generalAlertType, request, additionalData); + } + case ExecutivesAlertType executivesAlertType -> { + return createExecutivesAlertBlocks(executivesAlertType, additionalData); + } + case null, default -> { + log.error("Unknown alert type: {}", alertType); + return Collections.emptyList(); + } + } + } + + private List createGeneralAlertBlocks(GeneralAlertType alertType, HttpServletRequest request, + Object additionalData) { + switch (alertType) { + case ADMIN_LOGIN: + if (additionalData instanceof MemberLoginInfoDto) { + return createAdminLoginBlocks(request, (MemberLoginInfoDto) additionalData); + } + break; + case SERVER_START: + return createServerStartBlocks(); + case SERVER_ERROR: + if (additionalData instanceof Exception) { + return createErrorBlocks(request, (Exception) additionalData); + } + break; + default: + log.error("Unknown general alert type: {}", alertType); + } + return Collections.emptyList(); + } + + private List createExecutivesAlertBlocks(ExecutivesAlertType alertType, Object additionalData) { + switch (alertType) { + case NEW_APPLICATION: + if (additionalData instanceof ApplicationRequestDto) { + return createApplicationBlocks((ApplicationRequestDto) additionalData); + } + break; + case NEW_BOARD: + if (additionalData instanceof BoardNotificationInfo) { + return createBoardBlocks((BoardNotificationInfo) additionalData); + } + break; + case NEW_MEMBERSHIP_FEE: + if (additionalData instanceof MembershipFeeNotificationInfo) { + return createMembershipFeeBlocks((MembershipFeeNotificationInfo) additionalData); + } + break; + case NEW_BOOK_LOAN_REQUEST: + if (additionalData instanceof BookLoanRecordNotificationInfo) { + return createBookLoanRecordBlocks((BookLoanRecordNotificationInfo) additionalData); + } + break; + default: + log.error("Unknown executives alert type: {}", alertType); + } + return Collections.emptyList(); + } + + private List createErrorBlocks(HttpServletRequest request, Exception e) { + String httpMethod = request.getMethod(); + String fullUrl = webhookCommonService.getFullUrl(request); + String username = webhookCommonService.getUsername(request); + String errorMessage = webhookCommonService.extractMessageAfterException(e); + String stackTrace = webhookCommonService.getStackTraceSummary(e); + + log.error("Server Error: {}", errorMessage); + + return Arrays.asList( + section(s -> s.text(markdownText(":firecracker: *Server Error*"))), + section(s -> s.fields(Arrays.asList( + markdownText("*User:*\n" + username), + markdownText("*Endpoint:*\n[" + httpMethod + "] " + fullUrl) + ))), + section(s -> s.text(markdownText("*Error Message:*\n" + errorMessage))), + section(s -> s.text(markdownText("*Stack Trace:*\n```" + stackTrace + "```"))) + ); + } + + private List createSecurityAlertBlocks(HttpServletRequest request, AlertType alertType, + String additionalMessage) { + String clientIp = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); + String fullUrl = webhookCommonService.getFullUrl(request); + String username = webhookCommonService.getUsername(request); + String location = webhookCommonService.getLocation(request); + + return Arrays.asList( + section(s -> s.text(markdownText(":imp: *" + alertType.getTitle() + "*"))), + section(s -> s.fields(Arrays.asList( + markdownText("*User:*\n" + username), + markdownText("*IP Address:*\n" + clientIp), + markdownText("*Location:*\n" + location), + markdownText("*Endpoint:*\n" + fullUrl) + ))), + section(s -> s.text( + markdownText("*Details:*\n" + alertType.getDefaultMessage() + "\n" + additionalMessage))) + ); + } + + private List createAdminLoginBlocks(HttpServletRequest request, MemberLoginInfoDto loginMember) { + String clientIp = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); + String location = webhookCommonService.getLocation(request); + + return Arrays.asList( + section(s -> s.text(markdownText(":mechanic: *" + loginMember.getRole().getDescription() + " Login*"))), + section(s -> s.fields(Arrays.asList( + markdownText("*User:*\n" + loginMember.getMemberId() + " " + loginMember.getMemberName()), + markdownText("*IP Address:*\n" + clientIp), + markdownText("*Location:*\n" + location) + ))) + ); + } + + private List createApplicationBlocks(ApplicationRequestDto requestDto) { + List blocks = new ArrayList<>(); + + blocks.add(section(s -> s.text(markdownText(":sparkles: *동아리 지원*")))); + blocks.add(section(s -> s.fields(Arrays.asList( + markdownText("*구분:*\n" + requestDto.getApplicationType().getDescription()), + markdownText("*학번:*\n" + requestDto.getStudentId()), + markdownText("*이름:*\n" + requestDto.getName()), + markdownText("*학년:*\n" + requestDto.getGrade() + "학년"), + markdownText("*관심 분야:*\n" + requestDto.getInterests()) + )))); + + if (requestDto.getGithubUrl() != null && !requestDto.getGithubUrl().isEmpty()) { + blocks.add(actions(a -> a.elements(asElements( + button(b -> b.text(plainText(pt -> pt.emoji(true).text("Github"))) + .url(requestDto.getGithubUrl()) + .actionId("click_github")) + )))); + } + + return blocks; + } + + private List createBoardBlocks(BoardNotificationInfo board) { + return Arrays.asList( + section(s -> s.text(markdownText(":writing_hand: *새 게시글*"))), + section(s -> s.fields(Arrays.asList( + markdownText("*제목:*\n" + board.getTitle()), + markdownText("*분류:*\n" + board.getCategory()), + markdownText("*작성자:*\n" + board.getUsername()) + ))) + ); + } + + private List createMembershipFeeBlocks(MembershipFeeNotificationInfo data) { + String username = data.getMemberId() + " " + data.getMemberName(); + + return Arrays.asList( + section(s -> s.text(markdownText(":dollar: *회비 신청*"))), + section(s -> s.fields(Arrays.asList( + markdownText("*신청자:*\n" + username), + markdownText("*분류:*\n" + data.getCategory()), + markdownText("*금액:*\n" + data.getAmount() + "원") + ))), + section(s -> s.text(markdownText("*Content:*\n" + data.getContent()))) + ); + } + + private List createBookLoanRecordBlocks(BookLoanRecordNotificationInfo data) { + String username = data.getMemberId() + " " + data.getMemberName(); + + return Arrays.asList( + section(s -> s.text(markdownText(":books: *도서 대여 신청*"))), + section(s -> s.fields(Arrays.asList( + markdownText("*도서명:*\n" + data.getBookTitle()), + markdownText("*분류:*\n" + data.getCategory()), + markdownText("*신청자:*\n" + username), + markdownText("*상태:*\n" + (data.isAvailable() ? "대여 가능" : "대여 중")) + ))) + ); + } + + private List createServerStartBlocks() { + String osInfo = webhookCommonService.getOperatingSystemInfo(); + String jdkVersion = webhookCommonService.getJavaRuntimeVersion(); + double cpuUsage = webhookCommonService.getCpuUsage(); + String memoryUsage = webhookCommonService.getMemoryUsage(); + + return Arrays.asList( + section(s -> s.text(markdownText(":battery: *Server Started*"))), + section(s -> s.fields(Arrays.asList( + markdownText("*Environment:* \n" + environment.getProperty("spring.profiles.active")), + markdownText("*OS:* \n" + osInfo), + markdownText("*JDK Version:* \n" + jdkVersion), + markdownText("*CPU Usage:* \n" + String.format("%.2f%%", cpuUsage)), + markdownText("*Memory Usage:* \n" + memoryUsage) + ))), + actions(a -> a.elements(asElements( + button(b -> b.text(plainText(pt -> pt.emoji(true).text("Web"))) + .url(commonProperties.getWebUrl()) + .value("click_web")), + button(b -> b.text(plainText(pt -> pt.emoji(true).text("API Docs"))) + .url(commonProperties.getApiUrl()) + .value("click_apiDocs")) + ))) + ); + } +} diff --git a/src/main/java/page/clab/api/global/common/slack/dto/mapper/SlackDtoMapper.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/mapper/NotificationSettingDtoMapper.java similarity index 51% rename from src/main/java/page/clab/api/global/common/slack/dto/mapper/SlackDtoMapper.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/dto/mapper/NotificationSettingDtoMapper.java index f2b75b6a3..4b092100a 100644 --- a/src/main/java/page/clab/api/global/common/slack/dto/mapper/SlackDtoMapper.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/mapper/NotificationSettingDtoMapper.java @@ -1,11 +1,11 @@ -package page.clab.api.global.common.slack.dto.mapper; +package page.clab.api.global.common.notificationSetting.application.dto.mapper; import org.springframework.stereotype.Component; -import page.clab.api.global.common.slack.domain.NotificationSetting; -import page.clab.api.global.common.slack.dto.response.NotificationSettingResponseDto; +import page.clab.api.global.common.notificationSetting.application.dto.response.NotificationSettingResponseDto; +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; @Component -public class SlackDtoMapper { +public class NotificationSettingDtoMapper { public NotificationSettingResponseDto toDto(NotificationSetting setting) { return NotificationSettingResponseDto.builder() diff --git a/src/main/java/page/clab/api/global/common/slack/domain/SlackBoardInfo.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/BoardNotificationInfo.java similarity index 58% rename from src/main/java/page/clab/api/global/common/slack/domain/SlackBoardInfo.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/BoardNotificationInfo.java index a12be3cb8..4cf0a3442 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/SlackBoardInfo.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/BoardNotificationInfo.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.application.dto.notification; import lombok.Builder; import lombok.Getter; @@ -7,17 +7,18 @@ @Getter @Builder -public class SlackBoardInfo { +public class BoardNotificationInfo { private String title; private String category; private String username; - public static SlackBoardInfo create(Board board, MemberDetailedInfoDto memberInfo) { - return SlackBoardInfo.builder() + public static BoardNotificationInfo create(Board board, MemberDetailedInfoDto memberInfo) { + return BoardNotificationInfo.builder() .title(board.getTitle()) .category(board.getCategory().getDescription()) - .username(board.isWantAnonymous() ? board.getNickname() : memberInfo.getMemberId() + " " + memberInfo.getMemberName()) + .username(board.isWantAnonymous() ? board.getNickname() + : memberInfo.getMemberId() + " " + memberInfo.getMemberName()) .build(); } } diff --git a/src/main/java/page/clab/api/global/common/slack/domain/SlackBookLoanRecordInfo.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/BookLoanRecordNotificationInfo.java similarity index 69% rename from src/main/java/page/clab/api/global/common/slack/domain/SlackBookLoanRecordInfo.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/BookLoanRecordNotificationInfo.java index 1f961750f..036f1de32 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/SlackBookLoanRecordInfo.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/BookLoanRecordNotificationInfo.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.application.dto.notification; import lombok.Builder; import lombok.Getter; @@ -7,7 +7,7 @@ @Getter @Builder -public class SlackBookLoanRecordInfo { +public class BookLoanRecordNotificationInfo { private String memberId; private String memberName; @@ -15,8 +15,8 @@ public class SlackBookLoanRecordInfo { private String category; private boolean isAvailable; - public static SlackBookLoanRecordInfo create(Book book, MemberBorrowerInfoDto borrowerInfo) { - return SlackBookLoanRecordInfo.builder() + public static BookLoanRecordNotificationInfo create(Book book, MemberBorrowerInfoDto borrowerInfo) { + return BookLoanRecordNotificationInfo.builder() .memberId(borrowerInfo.getMemberId()) .memberName(borrowerInfo.getMemberName()) .bookTitle(book.getTitle()) diff --git a/src/main/java/page/clab/api/global/common/slack/domain/SlackMembershipFeeInfo.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/MembershipFeeNotificationInfo.java similarity index 69% rename from src/main/java/page/clab/api/global/common/slack/domain/SlackMembershipFeeInfo.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/MembershipFeeNotificationInfo.java index 02461c71d..65b6ed8f3 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/SlackMembershipFeeInfo.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/notification/MembershipFeeNotificationInfo.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.application.dto.notification; import lombok.Builder; import lombok.Getter; @@ -7,7 +7,7 @@ @Getter @Builder -public class SlackMembershipFeeInfo { +public class MembershipFeeNotificationInfo { private String memberId; private String memberName; @@ -15,8 +15,8 @@ public class SlackMembershipFeeInfo { private Long amount; private String content; - public static SlackMembershipFeeInfo create(MembershipFee membershipFee, MemberBasicInfoDto memberInfo) { - return SlackMembershipFeeInfo.builder() + public static MembershipFeeNotificationInfo create(MembershipFee membershipFee, MemberBasicInfoDto memberInfo) { + return MembershipFeeNotificationInfo.builder() .memberId(memberInfo.getMemberId()) .memberName(memberInfo.getMemberName()) .category(membershipFee.getCategory()) diff --git a/src/main/java/page/clab/api/global/common/slack/dto/request/NotificationSettingUpdateRequestDto.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/request/NotificationSettingToggleRequestDto.java similarity index 78% rename from src/main/java/page/clab/api/global/common/slack/dto/request/NotificationSettingUpdateRequestDto.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/dto/request/NotificationSettingToggleRequestDto.java index fcd941924..172cc146d 100644 --- a/src/main/java/page/clab/api/global/common/slack/dto/request/NotificationSettingUpdateRequestDto.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/request/NotificationSettingToggleRequestDto.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.dto.request; +package page.clab.api.global.common.notificationSetting.application.dto.request; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotNull; @@ -7,7 +7,7 @@ @Getter @Setter -public class NotificationSettingUpdateRequestDto { +public class NotificationSettingToggleRequestDto { @NotNull(message = "{notNull.notificationSetting.alertType}") @Schema(description = "알림 타입", example = "서버 시작") diff --git a/src/main/java/page/clab/api/global/common/slack/dto/response/NotificationSettingResponseDto.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/response/NotificationSettingResponseDto.java similarity index 67% rename from src/main/java/page/clab/api/global/common/slack/dto/response/NotificationSettingResponseDto.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/dto/response/NotificationSettingResponseDto.java index ffbbd87b9..8eeaf2256 100644 --- a/src/main/java/page/clab/api/global/common/slack/dto/response/NotificationSettingResponseDto.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/dto/response/NotificationSettingResponseDto.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.dto.response; +package page.clab.api.global.common.notificationSetting.application.dto.response; import lombok.Builder; import lombok.Getter; diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/event/ApplicationStartupListener.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/event/ApplicationStartupListener.java new file mode 100644 index 000000000..0f9f08a6d --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/event/ApplicationStartupListener.java @@ -0,0 +1,23 @@ +package page.clab.api.global.common.notificationSetting.application.event; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import page.clab.api.global.common.notificationSetting.domain.GeneralAlertType; + +@Component +public class ApplicationStartupListener { + + private final ApplicationEventPublisher eventPublisher; + + public ApplicationStartupListener(ApplicationEventPublisher eventPublisher) { + this.eventPublisher = eventPublisher; + } + + @EventListener(ContextRefreshedEvent.class) + public void onApplicationEvent(ContextRefreshedEvent event) { + eventPublisher.publishEvent( + new NotificationEvent(this, GeneralAlertType.SERVER_START, null, null)); + } +} diff --git a/src/main/java/page/clab/api/global/common/slack/event/NotificationEvent.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/event/NotificationEvent.java similarity index 59% rename from src/main/java/page/clab/api/global/common/slack/event/NotificationEvent.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/event/NotificationEvent.java index 59bfb5158..4493c8fa6 100644 --- a/src/main/java/page/clab/api/global/common/slack/event/NotificationEvent.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/event/NotificationEvent.java @@ -1,21 +1,19 @@ -package page.clab.api.global.common.slack.event; +package page.clab.api.global.common.notificationSetting.application.event; import jakarta.servlet.http.HttpServletRequest; import lombok.Getter; import org.springframework.context.ApplicationEvent; -import page.clab.api.global.common.slack.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.AlertType; @Getter public class NotificationEvent extends ApplicationEvent { - private final String webhookUrl; private final AlertType alertType; private final HttpServletRequest request; private final Object additionalData; - public NotificationEvent(Object source, String webhookUrl, AlertType alertType, HttpServletRequest request, Object additionalData) { + public NotificationEvent(Object source, AlertType alertType, HttpServletRequest request, Object additionalData) { super(source); - this.webhookUrl = webhookUrl; this.alertType = alertType; this.request = request; this.additionalData = additionalData; diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/event/NotificationListener.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/event/NotificationListener.java new file mode 100644 index 000000000..56c2eda22 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/event/NotificationListener.java @@ -0,0 +1,95 @@ +package page.clab.api.global.common.notificationSetting.application.event; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; +import page.clab.api.global.common.notificationSetting.application.port.in.ManageNotificationSettingUseCase; +import page.clab.api.global.common.notificationSetting.application.port.out.NotificationSender; +import page.clab.api.global.common.notificationSetting.config.NotificationConfigProperties; +import page.clab.api.global.common.notificationSetting.config.NotificationConfigProperties.PlatformConfig; +import page.clab.api.global.common.notificationSetting.config.NotificationConfigProperties.PlatformMapping; +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; + +@Component +@Slf4j +public class NotificationListener { + + private final ManageNotificationSettingUseCase manageNotificationSettingUseCase; + private final Map notificationSenders; + private final NotificationConfigProperties notificationConfigProperties; + + public NotificationListener( + ManageNotificationSettingUseCase manageNotificationSettingUseCase, + List notificationSenderList, + NotificationConfigProperties notificationConfigProperties) { + this.manageNotificationSettingUseCase = manageNotificationSettingUseCase; + this.notificationConfigProperties = notificationConfigProperties; + this.notificationSenders = notificationSenderList.stream() + .collect(Collectors.toMap(NotificationSender::getPlatformName, Function.identity())); + } + + @EventListener + public void handleNotificationEvent(NotificationEvent event) { + AlertType alertType = event.getAlertType(); + + NotificationSetting setting = manageNotificationSettingUseCase.getOrCreateDefaultSetting(alertType); + if (!setting.isEnabled()) { + return; + } + + List mappings = getMappingsForAlertType(alertType); + if (mappings.isEmpty()) { + return; + } + + mappings.forEach(mapping -> getWebhookUrl(mapping) + .ifPresent(webhookUrl -> sendNotification(mapping.getPlatform(), event, webhookUrl))); + } + + private List getMappingsForAlertType(AlertType alertType) { + String categoryName = alertType.getCategory().name(); + Map> categoryMappings = notificationConfigProperties.getCategoryMappings(); + + return Optional.ofNullable(categoryMappings.get(categoryName)) + .filter(list -> !list.isEmpty()) + .orElseGet(notificationConfigProperties::getDefaultMappings); + } + + private Optional getWebhookUrl(PlatformMapping mapping) { + String platform = mapping.getPlatform(); + String webhookKey = mapping.getWebhook(); + Map platforms = notificationConfigProperties.getPlatforms(); + + return Optional.ofNullable(platforms.get(platform)) + .map(platformConfig -> platformConfig.getWebhooks().get(webhookKey)) + .map(url -> { + log.debug("Found webhook URL for platform '{}', key '{}': {}", platform, webhookKey, url); + return url; + }) + .or(() -> { + log.warn("No webhook URL found for platform '{}', key '{}'", platform, webhookKey); + return Optional.empty(); + }); + } + + private void sendNotification(String platform, NotificationEvent event, String webhookUrl) { + NotificationSender sender = notificationSenders.get(platform); + if (sender == null) { + log.warn("No NotificationSender found for platform: {}", platform); + return; + } + + try { + sender.sendNotification(event, webhookUrl); + log.debug("Notification sent via platform: {}", platform); + } catch (Exception e) { + log.error("Failed to send notification via platform: {}", platform, e); + } + } +} diff --git a/src/main/java/page/clab/api/global/common/slack/exception/AlertTypeNotFoundException.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/exception/AlertTypeNotFoundException.java similarity index 71% rename from src/main/java/page/clab/api/global/common/slack/exception/AlertTypeNotFoundException.java rename to src/main/java/page/clab/api/global/common/notificationSetting/application/exception/AlertTypeNotFoundException.java index c52c39796..94de58360 100644 --- a/src/main/java/page/clab/api/global/common/slack/exception/AlertTypeNotFoundException.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/exception/AlertTypeNotFoundException.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.exception; +package page.clab.api.global.common.notificationSetting.application.exception; public class AlertTypeNotFoundException extends RuntimeException { diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/port/in/ManageNotificationSettingUseCase.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/in/ManageNotificationSettingUseCase.java new file mode 100644 index 000000000..338de0f31 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/in/ManageNotificationSettingUseCase.java @@ -0,0 +1,11 @@ +package page.clab.api.global.common.notificationSetting.application.port.in; + +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; + +public interface ManageNotificationSettingUseCase { + + void toggleNotificationSetting(String alertTypeName, boolean enabled); + + NotificationSetting getOrCreateDefaultSetting(AlertType alertType); +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/port/in/RetrieveNotificationSettingUseCase.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/in/RetrieveNotificationSettingUseCase.java new file mode 100644 index 000000000..33bfde964 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/in/RetrieveNotificationSettingUseCase.java @@ -0,0 +1,9 @@ +package page.clab.api.global.common.notificationSetting.application.port.in; + +import java.util.List; +import page.clab.api.global.common.notificationSetting.application.dto.response.NotificationSettingResponseDto; + +public interface RetrieveNotificationSettingUseCase { + + List retrieveNotificationSettings(); +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/NotificationSender.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/NotificationSender.java new file mode 100644 index 000000000..23b9f55c3 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/NotificationSender.java @@ -0,0 +1,10 @@ +package page.clab.api.global.common.notificationSetting.application.port.out; + +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; + +public interface NotificationSender { + + String getPlatformName(); + + void sendNotification(NotificationEvent event, String webhookUrl); +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/RetrieveNotificationSettingPort.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/RetrieveNotificationSettingPort.java new file mode 100644 index 000000000..9b81d12b2 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/RetrieveNotificationSettingPort.java @@ -0,0 +1,13 @@ +package page.clab.api.global.common.notificationSetting.application.port.out; + +import java.util.List; +import java.util.Optional; +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; + +public interface RetrieveNotificationSettingPort { + + List findAll(); + + Optional findByAlertType(AlertType alertType); +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/UpdateNotificationSettingPort.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/UpdateNotificationSettingPort.java new file mode 100644 index 000000000..144205601 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/UpdateNotificationSettingPort.java @@ -0,0 +1,8 @@ +package page.clab.api.global.common.notificationSetting.application.port.out; + +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; + +public interface UpdateNotificationSettingPort { + + NotificationSetting save(NotificationSetting setting); +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/WebhookClient.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/WebhookClient.java new file mode 100644 index 000000000..c85e5776d --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/port/out/WebhookClient.java @@ -0,0 +1,23 @@ +package page.clab.api.global.common.notificationSetting.application.port.out; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.concurrent.CompletableFuture; +import page.clab.api.global.common.notificationSetting.domain.AlertType; + +/** + * WebhookClient는 외부 시스템(Discord, Slack 등)과 통신하기 위한 포트 인터페이스입니다. + */ +public interface WebhookClient { + + /** + * 특정 알림 유형과 요청 정보, 추가 데이터를 사용하여 메시지를 비동기적으로 전송합니다. + * + * @param webhookUrl 메시지를 보낼 Webhook URL + * @param alertType 알림 유형 + * @param request 클라이언트 요청 정보 + * @param additionalData 추가 데이터 + * @return 메시지 전송 성공 여부를 나타내는 CompletableFuture + */ + CompletableFuture sendMessage(String webhookUrl, AlertType alertType, HttpServletRequest request, + Object additionalData); +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/service/ManageNotificationSettingService.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/service/ManageNotificationSettingService.java new file mode 100644 index 000000000..d42337ddf --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/service/ManageNotificationSettingService.java @@ -0,0 +1,52 @@ +package page.clab.api.global.common.notificationSetting.application.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import page.clab.api.global.common.notificationSetting.application.port.in.ManageNotificationSettingUseCase; +import page.clab.api.global.common.notificationSetting.application.port.out.RetrieveNotificationSettingPort; +import page.clab.api.global.common.notificationSetting.application.port.out.UpdateNotificationSettingPort; +import page.clab.api.global.common.notificationSetting.domain.AlertType; +import page.clab.api.global.common.notificationSetting.domain.AlertTypeResolver; +import page.clab.api.global.common.notificationSetting.domain.NotificationSetting; + +/** + * {@code UpdateNotificationSettingService}는 알림 설정을 업데이트하는 서비스입니다. + * + *

    이 서비스는 주어진 알림 유형에 따라 활성화 또는 비활성화할 수 있는 설정을 업데이트할 수 있습니다. + * 또한, 기본 알림 설정이 존재하지 않으면 생성하여 제공합니다.

    + *

    + * 주요 기능: + *

      + *
    • {@link #toggleNotificationSetting(String, boolean)} - 주어진 알림 유형에 대해 알림 설정을 업데이트합니다.
    • + *
    • {@link #getOrCreateDefaultSetting(AlertType)} - 주어진 알림 유형에 대한 기본 알림 설정을 조회하거나, 존재하지 않으면 생성합니다.
    • + *
    + */ +@Service +@RequiredArgsConstructor +public class ManageNotificationSettingService implements ManageNotificationSettingUseCase { + + private final AlertTypeResolver alertTypeResolver; + private final RetrieveNotificationSettingPort retrieveNotificationSettingPort; + private final UpdateNotificationSettingPort updateNotificationSettingPort; + + @Transactional + @Override + public void toggleNotificationSetting(String alertTypeName, boolean enabled) { + AlertType alertType = alertTypeResolver.resolve(alertTypeName); + NotificationSetting setting = getOrCreateDefaultSetting(alertType); + setting.updateEnabled(enabled); + updateNotificationSettingPort.save(setting); + } + + @Transactional + public NotificationSetting getOrCreateDefaultSetting(AlertType alertType) { + return retrieveNotificationSettingPort.findByAlertType(alertType) + .orElseGet(() -> createAndSaveDefaultSetting(alertType)); + } + + private NotificationSetting createAndSaveDefaultSetting(AlertType alertType) { + NotificationSetting defaultSetting = NotificationSetting.createDefault(alertType); + return updateNotificationSettingPort.save(defaultSetting); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/service/RetrieveNotificationSettingService.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/service/RetrieveNotificationSettingService.java new file mode 100644 index 000000000..3d4d6bc32 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/service/RetrieveNotificationSettingService.java @@ -0,0 +1,36 @@ +package page.clab.api.global.common.notificationSetting.application.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import page.clab.api.global.common.notificationSetting.application.dto.mapper.NotificationSettingDtoMapper; +import page.clab.api.global.common.notificationSetting.application.dto.response.NotificationSettingResponseDto; +import page.clab.api.global.common.notificationSetting.application.port.in.RetrieveNotificationSettingUseCase; +import page.clab.api.global.common.notificationSetting.application.port.out.RetrieveNotificationSettingPort; + +/** + * {@code RetrieveNotificationSettingService}는 알림 설정을 조회하는 서비스입니다. + * + *

    이 서비스는 알림 설정의 전체 목록을 조회할 수 있는 기능을 제공합니다.

    + *

    + * 주요 기능: + *

      + *
    • {@link #retrieveNotificationSettings()} - 모든 알림 설정을 조회합니다.
    • + *
    + */ +@Service +@RequiredArgsConstructor +public class RetrieveNotificationSettingService implements RetrieveNotificationSettingUseCase { + + private final RetrieveNotificationSettingPort retrieveNotificationSettingPort; + private final NotificationSettingDtoMapper mapper; + + @Transactional(readOnly = true) + @Override + public List retrieveNotificationSettings() { + return retrieveNotificationSettingPort.findAll().stream() + .map(mapper::toDto) + .toList(); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/application/service/WebhookCommonService.java b/src/main/java/page/clab/api/global/common/notificationSetting/application/service/WebhookCommonService.java new file mode 100644 index 000000000..0cb574aa0 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/application/service/WebhookCommonService.java @@ -0,0 +1,87 @@ +package page.clab.api.global.common.notificationSetting.application.service; + +import io.ipinfo.api.model.IPResponse; +import io.ipinfo.spring.strategies.attribute.AttributeStrategy; +import jakarta.servlet.http.HttpServletRequest; +import java.lang.management.ManagementFactory; +import java.lang.management.MemoryMXBean; +import java.lang.management.MemoryUsage; +import java.lang.management.OperatingSystemMXBean; +import java.util.Arrays; +import java.util.Optional; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; + +/** + * {@code WebhookCommonService}는 Webhook 클라이언트에서 공통으로 사용되는 로직을 제공합니다. + */ +@Service +@RequiredArgsConstructor +public class WebhookCommonService { + + private final AttributeStrategy attributeStrategy; + + public String getFullUrl(HttpServletRequest request) { + String requestUrl = request.getRequestURI(); + String queryString = request.getQueryString(); + return queryString == null ? requestUrl : requestUrl + "?" + queryString; + } + + public String extractMessageAfterException(Exception e) { + String errorMessage = Optional.ofNullable(e.getMessage()).orElse("No error message provided"); + String exceptionIndicator = "Exception:"; + int index = errorMessage.indexOf(exceptionIndicator); + return index == -1 ? errorMessage : errorMessage.substring(index + exceptionIndicator.length()).trim(); + } + + public String getStackTraceSummary(Exception e) { + return Arrays.stream(e.getStackTrace()) + .limit(10) + .map(StackTraceElement::toString) + .collect(Collectors.joining("\n")); + } + + public String getOperatingSystemInfo() { + String osName = System.getProperty("os.name"); + String osVersion = System.getProperty("os.version"); + return osName + " " + osVersion; + } + + public String getJavaRuntimeVersion() { + return System.getProperty("java.version"); + } + + public double getCpuUsage() { + OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); + int processors = osBean.getAvailableProcessors(); + double systemLoadAverage = osBean.getSystemLoadAverage(); + return (systemLoadAverage / processors) * 100; + } + + public String getMemoryUsage() { + MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean(); + MemoryUsage memoryUsage = memoryMXBean.getHeapMemoryUsage(); + + long used = memoryUsage.getUsed() / (1024 * 1024); + long max = memoryUsage.getMax() / (1024 * 1024); + + return String.format("%dMB / %dMB (%.2f%%)", used, max, ((double) used / max) * 100); + } + + public String getUsername(HttpServletRequest request) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return Optional.ofNullable(request.getAttribute("member")) + .map(Object::toString) + .orElseGet(() -> Optional.ofNullable(auth) + .map(Authentication::getName) + .orElse("anonymous")); + } + + public String getLocation(HttpServletRequest request) { + IPResponse ipResponse = attributeStrategy.getAttribute(request); + return ipResponse == null ? "Unknown" : ipResponse.getCountryName() + ", " + ipResponse.getCity(); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/config/NotificationConfig.java b/src/main/java/page/clab/api/global/common/notificationSetting/config/NotificationConfig.java new file mode 100644 index 000000000..b398d8377 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/config/NotificationConfig.java @@ -0,0 +1,13 @@ +package page.clab.api.global.common.notificationSetting.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class NotificationConfig { + + @Bean + public NotificationConfigProperties notificationConfigProperties() { + return new NotificationConfigProperties(); + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/config/NotificationConfigProperties.java b/src/main/java/page/clab/api/global/common/notificationSetting/config/NotificationConfigProperties.java new file mode 100644 index 000000000..a052ac17e --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/config/NotificationConfigProperties.java @@ -0,0 +1,45 @@ +package page.clab.api.global.common.notificationSetting.config; + +import java.util.List; +import java.util.Map; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "notification") +@Getter +@Setter +public class NotificationConfigProperties { + + private CommonProperties common; + private Map platforms; + private Map> categoryMappings; + private List defaultMappings; + + @Getter + @Setter + public static class CommonProperties { + private String webUrl; + private String apiUrl; + private String color; + + public int getColorAsInt() { + return Integer.parseInt(color.replaceFirst("^#", ""), 16); + } + } + + @Getter + @Setter + public static class PlatformConfig { + private Map webhooks; + } + + @Getter + @Setter + public static class PlatformMapping { + private String platform; + private String webhook; + } +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertCategory.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertCategory.java new file mode 100644 index 000000000..1bbbb3a72 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertCategory.java @@ -0,0 +1,8 @@ +package page.clab.api.global.common.notificationSetting.domain; + +public enum AlertCategory { + + GENERAL, + SECURITY, + EXECUTIVES +} diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertType.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertType.java new file mode 100644 index 000000000..8cb5326ad --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertType.java @@ -0,0 +1,10 @@ +package page.clab.api.global.common.notificationSetting.domain; + +public interface AlertType { + + String getTitle(); + + String getDefaultMessage(); + + AlertCategory getCategory(); +} diff --git a/src/main/java/page/clab/api/global/common/slack/domain/AlertTypeConverter.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertTypeConverter.java similarity index 88% rename from src/main/java/page/clab/api/global/common/slack/domain/AlertTypeConverter.java rename to src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertTypeConverter.java index 1755b529b..dfa397238 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/AlertTypeConverter.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertTypeConverter.java @@ -1,11 +1,10 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.domain; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; -import page.clab.api.global.common.slack.exception.AlertTypeNotFoundException; - import java.util.HashMap; import java.util.Map; +import page.clab.api.global.common.notificationSetting.application.exception.AlertTypeNotFoundException; @Converter(autoApply = true) public class AlertTypeConverter implements AttributeConverter { diff --git a/src/main/java/page/clab/api/global/common/slack/domain/AlertTypeResolver.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertTypeResolver.java similarity index 77% rename from src/main/java/page/clab/api/global/common/slack/domain/AlertTypeResolver.java rename to src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertTypeResolver.java index f2d580962..7fe3cc2dd 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/AlertTypeResolver.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/AlertTypeResolver.java @@ -1,7 +1,7 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.domain; import org.springframework.stereotype.Service; -import page.clab.api.global.common.slack.exception.AlertTypeNotFoundException; +import page.clab.api.global.common.notificationSetting.application.exception.AlertTypeNotFoundException; @Service public class AlertTypeResolver { diff --git a/src/main/java/page/clab/api/global/common/slack/domain/ExecutivesAlertType.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/ExecutivesAlertType.java similarity index 54% rename from src/main/java/page/clab/api/global/common/slack/domain/ExecutivesAlertType.java rename to src/main/java/page/clab/api/global/common/notificationSetting/domain/ExecutivesAlertType.java index 703b2974f..cba124e18 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/ExecutivesAlertType.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/ExecutivesAlertType.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.domain; import lombok.AllArgsConstructor; import lombok.Getter; @@ -7,11 +7,12 @@ @AllArgsConstructor public enum ExecutivesAlertType implements AlertType { - NEW_APPLICATION("새 지원서", "New application has been submitted."), - NEW_BOARD("새 게시글", "New board has been posted."), - NEW_MEMBERSHIP_FEE("새 회비 신청", "New membership fee has been submitted."), - NEW_BOOK_LOAN_REQUEST("도서 대출 신청", "New book loan request has been submitted."); + NEW_APPLICATION("새 지원서", "New application has been submitted.", AlertCategory.EXECUTIVES), + NEW_BOARD("새 게시글", "New board has been posted.", AlertCategory.EXECUTIVES), + NEW_MEMBERSHIP_FEE("새 회비 신청", "New membership fee has been submitted.", AlertCategory.EXECUTIVES), + NEW_BOOK_LOAN_REQUEST("도서 대출 신청", "New book loan request has been submitted.", AlertCategory.EXECUTIVES); private final String title; private final String defaultMessage; + private final AlertCategory category; } diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/domain/GeneralAlertType.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/GeneralAlertType.java new file mode 100644 index 000000000..f8854c56e --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/GeneralAlertType.java @@ -0,0 +1,17 @@ +package page.clab.api.global.common.notificationSetting.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public enum GeneralAlertType implements AlertType { + + ADMIN_LOGIN("관리자 로그인", "Admin login.", AlertCategory.GENERAL), + SERVER_START("서버 시작", "Server has been started.", AlertCategory.GENERAL), + SERVER_ERROR("서버 에러", "Server error occurred.", AlertCategory.GENERAL); + + private final String title; + private final String defaultMessage; + private final AlertCategory category; +} diff --git a/src/main/java/page/clab/api/global/common/slack/domain/NotificationSetting.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/NotificationSetting.java similarity index 94% rename from src/main/java/page/clab/api/global/common/slack/domain/NotificationSetting.java rename to src/main/java/page/clab/api/global/common/notificationSetting/domain/NotificationSetting.java index c352008a2..d9bd8f47f 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/NotificationSetting.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/NotificationSetting.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.domain; import jakarta.persistence.Convert; import jakarta.persistence.Entity; diff --git a/src/main/java/page/clab/api/global/common/notificationSetting/domain/PlatformType.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/PlatformType.java new file mode 100644 index 000000000..5ed4c6d32 --- /dev/null +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/PlatformType.java @@ -0,0 +1,14 @@ +package page.clab.api.global.common.notificationSetting.domain; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PlatformType { + + SLACK("slack"), + DISCORD("discord"); + + private final String name; +} diff --git a/src/main/java/page/clab/api/global/common/slack/domain/SecurityAlertType.java b/src/main/java/page/clab/api/global/common/notificationSetting/domain/SecurityAlertType.java similarity index 58% rename from src/main/java/page/clab/api/global/common/slack/domain/SecurityAlertType.java rename to src/main/java/page/clab/api/global/common/notificationSetting/domain/SecurityAlertType.java index 4aa4d1b59..5a6fb8800 100644 --- a/src/main/java/page/clab/api/global/common/slack/domain/SecurityAlertType.java +++ b/src/main/java/page/clab/api/global/common/notificationSetting/domain/SecurityAlertType.java @@ -1,4 +1,4 @@ -package page.clab.api.global.common.slack.domain; +package page.clab.api.global.common.notificationSetting.domain; import lombok.AllArgsConstructor; import lombok.Getter; @@ -7,20 +7,21 @@ @AllArgsConstructor public enum SecurityAlertType implements AlertType { - ABNORMAL_ACCESS("비정상적인 접근", "Unexpected access pattern detected."), - REPEATED_LOGIN_FAILURES("지속된 로그인 실패", "Multiple consecutive failed login attempts."), - DUPLICATE_LOGIN("중복 로그인", "Duplicate login attempt."), - API_DOCS_ACCESS("API 문서 접근", "API Documentation access attempt."), - ACTUATOR_ACCESS("Actuator 접근", "Actuator endpoint access attempt."), - UNAUTHORIZED_ACCESS("인가되지 않은 접근", "Unauthorized access attempt."), - BLACKLISTED_IP_ADDED("블랙리스트 IP 등록", "IP address has been added to the blacklist."), - BLACKLISTED_IP_REMOVED("블랙리스트 IP 해제", "IP address has been removed from the blacklist."), - ABNORMAL_ACCESS_IP_BLOCKED("비정상적인 접근 IP 차단", "Abnormal access IP has been blocked."), - ABNORMAL_ACCESS_IP_DELETED("비정상적인 접근 IP 삭제", "Abnormal access IP has been deleted."), - MEMBER_BANNED("멤버 밴 등록", "Member has been banned."), - MEMBER_UNBANNED("멤버 밴 해제", "Member has been unbanned."), - MEMBER_ROLE_CHANGED("멤버 권한 변경", "Member role has been changed."); + ABNORMAL_ACCESS("비정상적인 접근", "Unexpected access pattern detected.", AlertCategory.SECURITY), + REPEATED_LOGIN_FAILURES("지속된 로그인 실패", "Multiple consecutive failed login attempts.", AlertCategory.SECURITY), + DUPLICATE_LOGIN("중복 로그인", "Duplicate login attempt.", AlertCategory.SECURITY), + API_DOCS_ACCESS("API 문서 접근", "API Documentation access attempt.", AlertCategory.SECURITY), + ACTUATOR_ACCESS("Actuator 접근", "Actuator endpoint access attempt.", AlertCategory.SECURITY), + UNAUTHORIZED_ACCESS("인가되지 않은 접근", "Unauthorized access attempt.", AlertCategory.SECURITY), + BLACKLISTED_IP_ADDED("블랙리스트 IP 등록", "IP address has been added to the blacklist.", AlertCategory.SECURITY), + BLACKLISTED_IP_REMOVED("블랙리스트 IP 해제", "IP address has been removed from the blacklist.", AlertCategory.SECURITY), + ABNORMAL_ACCESS_IP_BLOCKED("비정상적인 접근 IP 차단", "Abnormal access IP has been blocked.", AlertCategory.SECURITY), + ABNORMAL_ACCESS_IP_DELETED("비정상적인 접근 IP 삭제", "Abnormal access IP has been deleted.", AlertCategory.SECURITY), + MEMBER_BANNED("멤버 밴 등록", "Member has been banned.", AlertCategory.SECURITY), + MEMBER_UNBANNED("멤버 밴 해제", "Member has been unbanned.", AlertCategory.SECURITY), + MEMBER_ROLE_CHANGED("멤버 권한 변경", "Member role has been changed.", AlertCategory.SECURITY); private final String title; private final String defaultMessage; + private final AlertCategory category; } diff --git a/src/main/java/page/clab/api/global/common/slack/api/NotificationSettingController.java b/src/main/java/page/clab/api/global/common/slack/api/NotificationSettingController.java deleted file mode 100644 index f242289d1..000000000 --- a/src/main/java/page/clab/api/global/common/slack/api/NotificationSettingController.java +++ /dev/null @@ -1,45 +0,0 @@ -package page.clab.api.global.common.slack.api; - -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import page.clab.api.global.common.dto.ApiResponse; -import page.clab.api.global.common.slack.application.NotificationSettingService; -import page.clab.api.global.common.slack.dto.request.NotificationSettingUpdateRequestDto; -import page.clab.api.global.common.slack.dto.response.NotificationSettingResponseDto; - -import java.util.List; - -@RestController -@RequestMapping("/api/v1/notification-settings") -@RequiredArgsConstructor -@Tag(name = "Notification Setting", description = "알림 설정") -public class NotificationSettingController { - - private final NotificationSettingService notificationSettingService; - - @Operation(summary = "[S] 슬랙 알림 조회", description = "ROLE_SUPER 이상의 권한이 필요함") - @PreAuthorize("hasRole('SUPER')") - @GetMapping("") - public ApiResponse> getNotificationSettings() { - List notificationSettings = notificationSettingService.getNotificationSettings(); - return ApiResponse.success(notificationSettings); - } - - @Operation(summary = "[S] 슬랙 알림 설정 변경", description = "ROLE_SUPER 이상의 권한이 필요함") - @PreAuthorize("hasRole('SUPER')") - @PutMapping("") - public ApiResponse updateNotificationSetting( - @Valid @RequestBody NotificationSettingUpdateRequestDto requestDto - ) { - notificationSettingService.updateNotificationSetting(requestDto.getAlertType(), requestDto.isEnabled()); - return ApiResponse.success(); - } -} diff --git a/src/main/java/page/clab/api/global/common/slack/application/NotificationSettingService.java b/src/main/java/page/clab/api/global/common/slack/application/NotificationSettingService.java deleted file mode 100644 index 4ce06b79b..000000000 --- a/src/main/java/page/clab/api/global/common/slack/application/NotificationSettingService.java +++ /dev/null @@ -1,61 +0,0 @@ -package page.clab.api.global.common.slack.application; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import page.clab.api.global.common.slack.dao.NotificationSettingRepository; -import page.clab.api.global.common.slack.domain.AlertType; -import page.clab.api.global.common.slack.domain.AlertTypeResolver; -import page.clab.api.global.common.slack.domain.NotificationSetting; -import page.clab.api.global.common.slack.dto.mapper.SlackDtoMapper; -import page.clab.api.global.common.slack.dto.response.NotificationSettingResponseDto; - -import java.util.List; - -/** - * {@code NotificationSettingService}는 알림 설정을 조회 및 업데이트하는 서비스입니다. - * - *

    이 서비스는 알림 유형에 따라 활성화 또는 비활성화할 수 있는 설정 기능을 제공하며, - * 기본 알림 설정을 생성하거나 조회할 수 있습니다.

    - * - * 주요 기능: - *
      - *
    • {@link #getNotificationSettings()} - 모든 알림 설정을 조회합니다.
    • - *
    • {@link #updateNotificationSetting(String, boolean)} - 주어진 알림 유형에 대해 알림 설정을 업데이트합니다.
    • - *
    • {@link #getOrCreateDefaultSetting(AlertType)} - 주어진 알림 유형에 대한 기본 알림 설정을 조회하거나, 존재하지 않으면 생성합니다.
    • - *
    - */ -@Service -@RequiredArgsConstructor -public class NotificationSettingService { - - private final AlertTypeResolver alertTypeResolver; - private final NotificationSettingRepository settingRepository; - private final SlackDtoMapper mapper; - - @Transactional(readOnly = true) - public List getNotificationSettings() { - return settingRepository.findAll().stream() - .map(mapper::toDto) - .toList(); - } - - @Transactional - public void updateNotificationSetting(String alertTypeName, boolean enabled) { - AlertType alertType = alertTypeResolver.resolve(alertTypeName); - NotificationSetting setting = getOrCreateDefaultSetting(alertType); - setting.updateEnabled(enabled); - settingRepository.save(setting); - } - - @Transactional - public NotificationSetting getOrCreateDefaultSetting(AlertType alertType) { - return settingRepository.findByAlertType(alertType) - .orElseGet(() -> createAndSaveDefaultSetting(alertType)); - } - - private NotificationSetting createAndSaveDefaultSetting(AlertType alertType) { - NotificationSetting defaultSetting = NotificationSetting.createDefault(alertType); - return settingRepository.save(defaultSetting); - } -} diff --git a/src/main/java/page/clab/api/global/common/slack/application/SlackService.java b/src/main/java/page/clab/api/global/common/slack/application/SlackService.java deleted file mode 100644 index dca15b3a9..000000000 --- a/src/main/java/page/clab/api/global/common/slack/application/SlackService.java +++ /dev/null @@ -1,82 +0,0 @@ -package page.clab.api.global.common.slack.application; - -import jakarta.servlet.http.HttpServletRequest; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.context.event.ContextRefreshedEvent; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Service; -import page.clab.api.domain.hiring.application.application.dto.request.ApplicationRequestDto; -import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberLoginInfoDto; -import page.clab.api.global.common.slack.domain.ExecutivesAlertType; -import page.clab.api.global.common.slack.domain.GeneralAlertType; -import page.clab.api.global.common.slack.domain.SecurityAlertType; -import page.clab.api.global.common.slack.domain.SlackBoardInfo; -import page.clab.api.global.common.slack.domain.SlackBookLoanRecordInfo; -import page.clab.api.global.common.slack.domain.SlackMembershipFeeInfo; -import page.clab.api.global.common.slack.event.NotificationEvent; -import page.clab.api.global.config.SlackConfig; - -/** - * {@code SlackService}는 다양한 알림 유형에 따라 Slack 알림을 전송하는 서비스입니다. - * - *

    이 서비스는 `NotificationEvent`를 통해 Slack 알림을 발송하며, - * 서버 시작, 서버 오류, 보안 경고, 관리자 로그인 등의 알림 유형을 제공합니다.

    - * - * 주요 기능: - *
      - *
    • {@link #sendServerErrorNotification(HttpServletRequest, Exception)} - 서버 오류 발생 시 Slack으로 알림을 전송합니다.
    • - *
    • {@link #sendSecurityAlertNotification(HttpServletRequest, SecurityAlertType, String)} - 보안 경고 발생 시 알림을 전송합니다.
    • - *
    • {@link #sendAdminLoginNotification(HttpServletRequest, MemberLoginInfoDto)} - 관리자 로그인 시 Slack으로 알림을 전송합니다.
    • - *
    • {@link #sendNewApplicationNotification(ApplicationRequestDto)} - 신규 지원 정보가 있을 때 Slack으로 알림을 전송합니다.
    • - *
    • {@link #sendNewBoardNotification(SlackBoardInfo)} - 새 게시글이 등록되었을 때 알림을 전송합니다.
    • - *
    • {@link #sendNewMembershipFeeNotification(SlackMembershipFeeInfo)} - 신규 회비 신청 시 알림을 전송합니다.
    • - *
    • {@link #sendNewBookLoanRequestNotification(SlackBookLoanRecordInfo)} - 도서 대여 신청이 있을 때 알림을 전송합니다.
    • - *
    • {@link #sendServerStartNotification()} - 서버 시작 시 알림을 전송합니다.
    • - *
    - */ -@Service -public class SlackService { - - private final ApplicationEventPublisher eventPublisher; - private final String coreTeamWebhookUrl; - private final String executivesWebhookUrl; - - public SlackService(ApplicationEventPublisher eventPublisher, SlackConfig slackConfig) { - this.eventPublisher = eventPublisher; - this.coreTeamWebhookUrl = slackConfig.getCoreTeamWebhookUrl(); - this.executivesWebhookUrl = slackConfig.getExecutivesWebhookUrl(); - } - - public void sendServerErrorNotification(HttpServletRequest request, Exception e) { - eventPublisher.publishEvent(new NotificationEvent(this, coreTeamWebhookUrl, GeneralAlertType.SERVER_ERROR, request, e)); - } - - public void sendSecurityAlertNotification(HttpServletRequest request, SecurityAlertType alertType, String additionalMessage) { - eventPublisher.publishEvent(new NotificationEvent(this, coreTeamWebhookUrl, alertType, request, additionalMessage)); - } - - public void sendAdminLoginNotification(HttpServletRequest request, MemberLoginInfoDto loginMember) { - eventPublisher.publishEvent(new NotificationEvent(this, coreTeamWebhookUrl, GeneralAlertType.ADMIN_LOGIN, request, loginMember)); - } - - public void sendNewApplicationNotification(ApplicationRequestDto applicationRequestDto) { - eventPublisher.publishEvent(new NotificationEvent(this, executivesWebhookUrl, ExecutivesAlertType.NEW_APPLICATION, null, applicationRequestDto)); - } - - public void sendNewBoardNotification(SlackBoardInfo board) { - eventPublisher.publishEvent(new NotificationEvent(this, executivesWebhookUrl, ExecutivesAlertType.NEW_BOARD, null, board)); - } - - public void sendNewMembershipFeeNotification(SlackMembershipFeeInfo membershipFee) { - eventPublisher.publishEvent(new NotificationEvent(this, executivesWebhookUrl, ExecutivesAlertType.NEW_MEMBERSHIP_FEE, null, membershipFee)); - } - - public void sendNewBookLoanRequestNotification(SlackBookLoanRecordInfo bookLoanRecord) { - eventPublisher.publishEvent(new NotificationEvent(this, executivesWebhookUrl, ExecutivesAlertType.NEW_BOOK_LOAN_REQUEST, null, bookLoanRecord)); - } - - @EventListener(ContextRefreshedEvent.class) - public void sendServerStartNotification() { - eventPublisher.publishEvent(new NotificationEvent(this, coreTeamWebhookUrl, GeneralAlertType.SERVER_START, null, null)); - } -} diff --git a/src/main/java/page/clab/api/global/common/slack/application/SlackServiceHelper.java b/src/main/java/page/clab/api/global/common/slack/application/SlackServiceHelper.java deleted file mode 100644 index a3b2c3edf..000000000 --- a/src/main/java/page/clab/api/global/common/slack/application/SlackServiceHelper.java +++ /dev/null @@ -1,368 +0,0 @@ -package page.clab.api.global.common.slack.application; - -import com.slack.api.Slack; -import com.slack.api.model.Attachment; -import static com.slack.api.model.block.Blocks.actions; -import static com.slack.api.model.block.Blocks.section; -import com.slack.api.model.block.LayoutBlock; -import static com.slack.api.model.block.composition.BlockCompositions.markdownText; -import static com.slack.api.model.block.composition.BlockCompositions.plainText; -import static com.slack.api.model.block.element.BlockElements.asElements; -import static com.slack.api.model.block.element.BlockElements.button; -import com.slack.api.webhook.Payload; -import com.slack.api.webhook.WebhookResponse; -import io.ipinfo.api.model.IPResponse; -import io.ipinfo.spring.strategies.attribute.AttributeStrategy; -import jakarta.servlet.http.HttpServletRequest; -import lombok.extern.slf4j.Slf4j; -import org.jetbrains.annotations.NotNull; -import org.springframework.core.env.Environment; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import page.clab.api.domain.hiring.application.application.dto.request.ApplicationRequestDto; -import page.clab.api.domain.memberManagement.member.application.dto.shared.MemberLoginInfoDto; -import page.clab.api.global.common.slack.domain.AlertType; -import page.clab.api.global.common.slack.domain.ExecutivesAlertType; -import page.clab.api.global.common.slack.domain.GeneralAlertType; -import page.clab.api.global.common.slack.domain.SecurityAlertType; -import page.clab.api.global.common.slack.domain.SlackBoardInfo; -import page.clab.api.global.common.slack.domain.SlackBookLoanRecordInfo; -import page.clab.api.global.common.slack.domain.SlackMembershipFeeInfo; -import page.clab.api.global.config.SlackConfig; -import page.clab.api.global.util.HttpReqResUtil; - -import java.io.IOException; -import java.lang.management.ManagementFactory; -import java.lang.management.MemoryMXBean; -import java.lang.management.MemoryUsage; -import java.lang.management.OperatingSystemMXBean; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.Optional; -import java.util.concurrent.CompletableFuture; -import java.util.stream.Collectors; - -/** - * {@code SlackServiceHelper}는 다양한 알림 유형에 따라 Slack 메시지를 구성하고 전송하는 클래스입니다. - * - *

    주요 기능:

    - *
      - *
    • {@link #sendSlackMessage(String, AlertType, HttpServletRequest, Object)}: Slack에 알림 메시지를 비동기적으로 전송
    • - *
    • {@link #createBlocks(AlertType, HttpServletRequest, Object)}: 알림 유형에 따라 Slack 메시지 블록 생성
    • - *
    • 다양한 알림 유형에 맞는 메시지 형식을 생성하는 전용 메서드
    • - *
    - * - *

    Slack API와 통합하여 웹훅 URL을 통해 메시지를 전송하며, 메시지 전송 실패 시 로그에 오류를 기록합니다.

    - * - *

    AlertType을 기반으로 여러 도메인에서 발생하는 이벤트를 Slack을 통해 모니터링할 수 있도록 지원하며, - * Slack 알림은 주로 서버 이벤트, 보안 경고, 신규 신청, 관리자 로그인 등의 이벤트를 다룹니다.

    - * - * @see Slack - * @see Payload - * @see LayoutBlock - */ -@Component -@Slf4j -public class SlackServiceHelper { - - private final Slack slack; - private final String webUrl; - private final String apiUrl; - private final String color; - private final Environment environment; - private final AttributeStrategy attributeStrategy; - - public SlackServiceHelper(SlackConfig slackConfig, Environment environment, AttributeStrategy attributeStrategy) { - this.slack = slackConfig.slack(); - this.webUrl = slackConfig.getWebUrl(); - this.apiUrl = slackConfig.getApiUrl(); - this.color = slackConfig.getColor(); - this.environment = environment; - this.attributeStrategy = attributeStrategy; - } - - /** - * Slack에 알림 메시지를 전송합니다. - * - *

    주어진 webhookUrl과 alertType, HttpServletRequest 및 추가 데이터(additionalData)를 사용하여 알림 메시지를 - * 비동기적으로 Slack에 전송합니다.

    - * - * @param webhookUrl 메시지를 보낼 Slack 웹훅 URL - * @param alertType 알림 유형을 나타내는 {@link AlertType} - * @param request HttpServletRequest 객체, 클라이언트 요청 정보 - * @param additionalData 추가 데이터 - * @return 메시지 전송 성공 여부를 나타내는 CompletableFuture - */ - public CompletableFuture sendSlackMessage(String webhookUrl, AlertType alertType, HttpServletRequest request, Object additionalData) { - List blocks = createBlocks(alertType, request, additionalData); - return CompletableFuture.supplyAsync(() -> { - Payload payload = Payload.builder() - .blocks(List.of(blocks.getFirst())) - .attachments(Collections.singletonList( - Attachment.builder() - .color(color) - .blocks(blocks.subList(1, blocks.size())) - .build() - )).build(); - try { - WebhookResponse response = slack.send(webhookUrl, payload); - if (response.getCode() == 200) { - return true; - } else { - log.error("Slack notification failed: {}", response.getMessage()); - return false; - } - } catch (IOException e) { - log.error("Failed to send Slack message: {}", e.getMessage(), e); - return false; - } - }); - } - - /** - * 특정 알림 유형과 요청 정보 및 추가 데이터를 사용하여 Slack 메시지의 블록을 생성합니다. - * - *

    AlertType에 따라 보안 경고, 일반 알림, 운영진 알림 등 다양한 형식의 메시지를 생성합니다.

    - * - * @param alertType 알림 유형 - * @param request HttpServletRequest 객체 - * @param additionalData 추가 데이터 - * @return 생성된 LayoutBlock 목록 - */ - public List createBlocks(AlertType alertType, HttpServletRequest request, Object additionalData) { - if (alertType instanceof SecurityAlertType) { - return createSecurityAlertBlocks(request, alertType, additionalData.toString()); - } else if (alertType instanceof GeneralAlertType) { - switch ((GeneralAlertType) alertType) { - case ADMIN_LOGIN: - if (additionalData instanceof MemberLoginInfoDto) { - return createAdminLoginBlocks(request, (MemberLoginInfoDto) additionalData); - } - break; - case SERVER_START: - return createServerStartBlocks(); - case SERVER_ERROR: - if (additionalData instanceof Exception) { - return createErrorBlocks(request, (Exception) additionalData); - } - break; - default: - log.error("Unknown alert type: {}", alertType); - return List.of(); - } - } else if (alertType instanceof ExecutivesAlertType) { - switch ((ExecutivesAlertType) alertType) { - case NEW_APPLICATION: - if (additionalData instanceof ApplicationRequestDto) { - return createApplicationBlocks((ApplicationRequestDto) additionalData); - } - break; - case NEW_BOARD: - if (additionalData instanceof SlackBoardInfo) { - return createBoardBlocks((SlackBoardInfo) additionalData); - } - break; - case NEW_MEMBERSHIP_FEE: - if (additionalData instanceof SlackMembershipFeeInfo) { - return createMembershipFeeBlocks((SlackMembershipFeeInfo) additionalData); - } - break; - case NEW_BOOK_LOAN_REQUEST: - if (additionalData instanceof SlackBookLoanRecordInfo) { - return createBookLoanRecordBlocks((SlackBookLoanRecordInfo) additionalData); - } - break; - default: - log.error("Unknown alert type: {}", alertType); - return List.of(); - } - } - return List.of(); - } - - private List createErrorBlocks(HttpServletRequest request, Exception e) { - String httpMethod = request.getMethod(); - String requestUrl = request.getRequestURI(); - String queryString = request.getQueryString(); - String fullUrl = queryString == null ? requestUrl : requestUrl + "?" + queryString; - String username = getUsername(request); - - String errorMessage = e.getMessage() == null ? "No error message provided" : e.getMessage(); - String detailedMessage = extractMessageAfterException(errorMessage); - log.error("Server Error: {}", detailedMessage); - return Arrays.asList( - section(section -> section.text(markdownText(":firecracker: *Server Error*"))), - section(section -> section.fields(Arrays.asList( - markdownText("*User:*\n" + username), - markdownText("*Endpoint:*\n[" + httpMethod + "] " + fullUrl) - ))), - section(section -> section.text(markdownText("*Error Message:*\n" + detailedMessage))), - section(section -> section.text(markdownText("*Stack Trace:*\n```" + getStackTraceSummary(e) + "```"))) - ); - } - - private List createSecurityAlertBlocks(HttpServletRequest request, AlertType alertType, String additionalMessage) { - String clientIpAddress = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); - String requestUrl = request.getRequestURI(); - String queryString = request.getQueryString(); - String fullUrl = queryString == null ? requestUrl : requestUrl + "?" + queryString; - String username = getUsername(request); - String location = getLocation(request); - - return Arrays.asList( - section(section -> section.text(markdownText(String.format(":imp: *%s*", alertType.getTitle())))), - section(section -> section.fields(Arrays.asList( - markdownText("*User:*\n" + username), - markdownText("*IP Address:*\n" + clientIpAddress), - markdownText("*Location:*\n" + location), - markdownText("*Endpoint:*\n" + fullUrl) - ))), - section(section -> section.text(markdownText("*Details:*\n" + alertType.getDefaultMessage() + "\n" + additionalMessage))) - ); - } - - private List createAdminLoginBlocks(HttpServletRequest request, MemberLoginInfoDto loginMember) { - String clientIpAddress = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); - String location = getLocation(request); - - return Arrays.asList( - section(section -> section.text(markdownText(String.format(":mechanic: *%s Login*", loginMember.getRole().getDescription())))), - section(section -> section.fields(Arrays.asList( - markdownText("*User:*\n" + loginMember.getMemberId() + " " + loginMember.getMemberName()), - markdownText("*IP Address:*\n" + clientIpAddress), - markdownText("*Location:*\n" + location) - ))) - ); - } - - private List createApplicationBlocks(ApplicationRequestDto requestDto) { - List blocks = new ArrayList<>(); - - blocks.add(section(section -> section.text(markdownText(":sparkles: *동아리 지원*")))); - blocks.add(section(section -> section.fields(Arrays.asList( - markdownText("*구분:*\n" + requestDto.getApplicationType().getDescription()), - markdownText("*학번:*\n" + requestDto.getStudentId()), - markdownText("*이름:*\n" + requestDto.getName()), - markdownText("*학년:*\n" + requestDto.getGrade() + "학년"), - markdownText("*관심 분야:*\n" + requestDto.getInterests()) - )))); - - if (requestDto.getGithubUrl() != null && !requestDto.getGithubUrl().isEmpty()) { - blocks.add(actions(actions -> actions.elements(asElements( - button(b -> b.text(plainText(pt -> pt.emoji(true).text("Github"))) - .url(requestDto.getGithubUrl()) - .actionId("click_github")) - )))); - } - return blocks; - } - - private List createBoardBlocks(SlackBoardInfo board) { - List blocks = new ArrayList<>(); - - blocks.add(section(section -> section.text(markdownText(":writing_hand: *새 게시글*")))); - blocks.add(section(section -> section.fields(Arrays.asList( - markdownText("*제목:*\n" + board.getTitle()), - markdownText("*분류:*\n" + board.getCategory()), - markdownText("*작성자:*\n" + board.getUsername()) - )))); - return blocks; - } - - private List createMembershipFeeBlocks(SlackMembershipFeeInfo additionalData) { - String username = additionalData.getMemberId() + " " + additionalData.getMemberName(); - - return Arrays.asList( - section(section -> section.text(markdownText(":dollar: *회비 신청*"))), - section(section -> section.fields(Arrays.asList( - markdownText("*신청자:*\n" + username), - markdownText("*분류:*\n" + additionalData.getCategory()), - markdownText("*금액:*\n" + additionalData.getAmount() + "원") - ))), - section(section -> section.text(markdownText("*Content:*\n" + additionalData.getContent()))) - ); - } - - private List createBookLoanRecordBlocks(SlackBookLoanRecordInfo additionalData) { - String username = additionalData.getMemberId() + " " + additionalData.getMemberName(); - - return Arrays.asList( - section(section -> section.text(markdownText(":books: *도서 대여 신청*"))), - section(section -> section.fields(Arrays.asList( - markdownText("*도서명:*\n" + additionalData.getBookTitle()), - markdownText("*분류:*\n" + additionalData.getCategory()), - markdownText("*신청자:*\n" + username), - markdownText("*상태:*\n" + (additionalData.isAvailable() ? "대여 가능" : "대여 중")) - ))) - ); - } - - private List createServerStartBlocks() { - String osInfo = System.getProperty("os.name") + " " + System.getProperty("os.version"); - String jdkVersion = System.getProperty("java.version"); - - OperatingSystemMXBean osBean = ManagementFactory.getOperatingSystemMXBean(); - int availableProcessors = osBean.getAvailableProcessors(); - double systemLoadAverage = osBean.getSystemLoadAverage(); - double cpuUsage = ((systemLoadAverage / availableProcessors) * 100); - - MemoryMXBean memoryMXBean = ManagementFactory.getMemoryMXBean(); - MemoryUsage heapMemoryUsage = memoryMXBean.getHeapMemoryUsage(); - String memoryInfo = formatMemoryUsage(heapMemoryUsage); - - return Arrays.asList( - section(section -> section.text(markdownText("*:battery: Server Started*"))), - section(section -> section.fields(Arrays.asList( - markdownText("*Environment:* \n" + environment.getProperty("spring.profiles.active")), - markdownText("*OS:* \n" + osInfo), - markdownText("*JDK Version:* \n" + jdkVersion), - markdownText("*CPU Usage:* \n" + String.format("%.2f%%", cpuUsage)), - markdownText("*Memory Usage:* \n" + memoryInfo) - ))), - actions(actions -> actions.elements(asElements( - button(b -> b.text(plainText(pt -> pt.emoji(true).text("Web"))) - .url(webUrl) - .value("click_web")), - button(b -> b.text(plainText(pt -> pt.emoji(true).text("Swagger"))) - .url(apiUrl) - .value("click_swagger")) - ))) - ); - } - - private String extractMessageAfterException(String message) { - String exceptionIndicator = "Exception:"; - int exceptionIndex = message.indexOf(exceptionIndicator); - return exceptionIndex == -1 ? message : message.substring(exceptionIndex + exceptionIndicator.length()).trim(); - } - - private String getStackTraceSummary(Exception e) { - return Arrays.stream(e.getStackTrace()) - .limit(10) - .map(StackTraceElement::toString) - .collect(Collectors.joining("\n")); - } - - private String formatMemoryUsage(MemoryUsage memoryUsage) { - long usedMemory = memoryUsage.getUsed() / (1024 * 1024); - long maxMemory = memoryUsage.getMax() / (1024 * 1024); - return String.format("%dMB / %dMB (%.2f%%)", usedMemory, maxMemory, ((double) usedMemory / maxMemory) * 100); - } - - private @NotNull String getUsername(HttpServletRequest request) { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - return Optional.ofNullable(request.getAttribute("member")) - .map(Object::toString) - .orElseGet(() -> Optional.ofNullable(authentication) - .map(Authentication::getName) - .orElse("anonymous")); - } - - private @NotNull String getLocation(HttpServletRequest request) { - IPResponse ipResponse = attributeStrategy.getAttribute(request); - return ipResponse == null ? "Unknown" : ipResponse.getCountryName() + ", " + ipResponse.getCity(); - } -} diff --git a/src/main/java/page/clab/api/global/common/slack/domain/AlertType.java b/src/main/java/page/clab/api/global/common/slack/domain/AlertType.java deleted file mode 100644 index 99c52fd8f..000000000 --- a/src/main/java/page/clab/api/global/common/slack/domain/AlertType.java +++ /dev/null @@ -1,8 +0,0 @@ -package page.clab.api.global.common.slack.domain; - -public interface AlertType { - - String getTitle(); - - String getDefaultMessage(); -} diff --git a/src/main/java/page/clab/api/global/common/slack/domain/GeneralAlertType.java b/src/main/java/page/clab/api/global/common/slack/domain/GeneralAlertType.java deleted file mode 100644 index 4b564bde7..000000000 --- a/src/main/java/page/clab/api/global/common/slack/domain/GeneralAlertType.java +++ /dev/null @@ -1,16 +0,0 @@ -package page.clab.api.global.common.slack.domain; - -import lombok.AllArgsConstructor; -import lombok.Getter; - -@Getter -@AllArgsConstructor -public enum GeneralAlertType implements AlertType { - - ADMIN_LOGIN("관리자 로그인", "Admin login."), - SERVER_START("서버 시작", "Server has been started."), - SERVER_ERROR("서버 에러", "Server error occurred."); - - private final String title; - private final String defaultMessage; -} diff --git a/src/main/java/page/clab/api/global/common/slack/listener/NotificationListener.java b/src/main/java/page/clab/api/global/common/slack/listener/NotificationListener.java deleted file mode 100644 index e8463a03a..000000000 --- a/src/main/java/page/clab/api/global/common/slack/listener/NotificationListener.java +++ /dev/null @@ -1,28 +0,0 @@ -package page.clab.api.global.common.slack.listener; - -import lombok.RequiredArgsConstructor; -import org.springframework.context.event.EventListener; -import org.springframework.stereotype.Component; -import page.clab.api.global.common.slack.application.NotificationSettingService; -import page.clab.api.global.common.slack.application.SlackServiceHelper; -import page.clab.api.global.common.slack.domain.AlertType; -import page.clab.api.global.common.slack.domain.NotificationSetting; -import page.clab.api.global.common.slack.event.NotificationEvent; - -@Component -@RequiredArgsConstructor -public class NotificationListener { - - private final NotificationSettingService settingService; - private final SlackServiceHelper slackServiceHelper; - - @EventListener - public void handleNotificationEvent(NotificationEvent event) { - AlertType alertType = event.getAlertType(); - NotificationSetting setting = settingService.getOrCreateDefaultSetting(alertType); - - if (setting.isEnabled()) { - slackServiceHelper.sendSlackMessage(event.getWebhookUrl(), alertType, event.getRequest(), event.getAdditionalData()); - } - } -} diff --git a/src/main/java/page/clab/api/global/config/SecurityConfig.java b/src/main/java/page/clab/api/global/config/SecurityConfig.java index 0002f24be..d31e1ce21 100644 --- a/src/main/java/page/clab/api/global/config/SecurityConfig.java +++ b/src/main/java/page/clab/api/global/config/SecurityConfig.java @@ -2,10 +2,12 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jetbrains.annotations.NotNull; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -34,14 +36,11 @@ import page.clab.api.global.auth.jwt.JwtTokenProvider; import page.clab.api.global.auth.util.IpWhitelistValidator; import page.clab.api.global.common.file.application.FileService; -import page.clab.api.global.common.slack.application.SlackService; import page.clab.api.global.filter.IPinfoSpringFilter; import page.clab.api.global.util.ApiLogger; import page.clab.api.global.util.HttpReqResUtil; import page.clab.api.global.util.ResponseUtil; -import java.io.IOException; - @Configuration @EnableWebSecurity @EnableMethodSecurity @@ -54,7 +53,7 @@ public class SecurityConfig { private final ExternalCheckIpBlockedUseCase externalCheckIpBlockedUseCase; private final ExternalRegisterBlacklistIpUseCase externalRegisterBlacklistIpUseCase; private final ExternalRetrieveBlacklistIpUseCase externalRetrieveBlacklistIpUseCase; - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; private final IpWhitelistValidator ipWhitelistValidator; private final WhitelistAccountProperties whitelistAccountProperties; private final WhitelistPatternsProperties whitelistPatternsProperties; @@ -92,15 +91,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { UsernamePasswordAuthenticationFilter.class ) .addFilterBefore( - new InvalidEndpointAccessFilter(slackService, fileURL, externalRegisterBlacklistIpUseCase, externalRetrieveBlacklistIpUseCase), + new InvalidEndpointAccessFilter(fileURL, externalRegisterBlacklistIpUseCase, + externalRetrieveBlacklistIpUseCase, eventPublisher), UsernamePasswordAuthenticationFilter.class ) .addFilterBefore( - new CustomBasicAuthenticationFilter(authenticationManager, ipWhitelistValidator, slackService, externalCheckIpBlockedUseCase, externalRetrieveBlacklistIpUseCase), + new CustomBasicAuthenticationFilter(authenticationManager, ipWhitelistValidator, + externalCheckIpBlockedUseCase, externalRetrieveBlacklistIpUseCase, eventPublisher), UsernamePasswordAuthenticationFilter.class ) .addFilterBefore( - new JwtAuthenticationFilter(slackService, jwtTokenProvider, externalManageRedisTokenUseCase, externalCheckIpBlockedUseCase, externalRetrieveBlacklistIpUseCase), + new JwtAuthenticationFilter(jwtTokenProvider, eventPublisher, externalManageRedisTokenUseCase, + externalCheckIpBlockedUseCase, externalRetrieveBlacklistIpUseCase), UsernamePasswordAuthenticationFilter.class ) // .addFilterBefore( @@ -120,11 +122,13 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(SecurityConstants.PERMIT_ALL).permitAll() .requestMatchers(HttpMethod.GET, SecurityConstants.PERMIT_ALL_API_ENDPOINTS_GET).permitAll() .requestMatchers(HttpMethod.POST, SecurityConstants.PERMIT_ALL_API_ENDPOINTS_POST).permitAll() - .requestMatchers(whitelistPatternsProperties.getWhitelistPatterns()).hasRole(whitelistAccountProperties.getRole()) + .requestMatchers(whitelistPatternsProperties.getWhitelistPatterns()) + .hasRole(whitelistAccountProperties.getRole()) .anyRequest().authenticated(); } - private void handleException(HttpServletRequest request, HttpServletResponse response, Exception exception) throws IOException { + private void handleException(HttpServletRequest request, HttpServletResponse response, Exception exception) + throws IOException { String clientIpAddress = HttpReqResUtil.getClientIpAddressIfServletRequestExist(); String message; int statusCode; diff --git a/src/main/java/page/clab/api/global/config/SlackConfig.java b/src/main/java/page/clab/api/global/config/SlackConfig.java deleted file mode 100644 index 31f1f6d63..000000000 --- a/src/main/java/page/clab/api/global/config/SlackConfig.java +++ /dev/null @@ -1,26 +0,0 @@ -package page.clab.api.global.config; - -import com.slack.api.Slack; -import lombok.Getter; -import lombok.Setter; -import org.springframework.boot.context.properties.ConfigurationProperties; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -@Setter -@Getter -@Configuration -@ConfigurationProperties(prefix = "slack") -public class SlackConfig { - - private String coreTeamWebhookUrl; - private String executivesWebhookUrl; - private String webUrl; - private String apiUrl; - private String color; - - @Bean - public Slack slack() { - return Slack.getInstance(); - } -} diff --git a/src/main/java/page/clab/api/global/handler/GlobalExceptionHandler.java b/src/main/java/page/clab/api/global/handler/GlobalExceptionHandler.java index ac27a3774..ac269d054 100644 --- a/src/main/java/page/clab/api/global/handler/GlobalExceptionHandler.java +++ b/src/main/java/page/clab/api/global/handler/GlobalExceptionHandler.java @@ -7,9 +7,14 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.ConstraintViolationException; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.util.NoSuchElementException; +import java.util.concurrent.CompletionException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.hibernate.query.sqm.UnknownPathException; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.dao.IncorrectResultSizeDataAccessException; import org.springframework.dao.InvalidDataAccessApiUsageException; @@ -75,7 +80,8 @@ import page.clab.api.global.common.file.exception.FileUploadFailException; import page.clab.api.global.common.file.exception.InvalidFileAttributeException; import page.clab.api.global.common.file.exception.InvalidPathVariableException; -import page.clab.api.global.common.slack.application.SlackService; +import page.clab.api.global.common.notificationSetting.application.event.NotificationEvent; +import page.clab.api.global.common.notificationSetting.domain.GeneralAlertType; import page.clab.api.global.exception.CustomOptimisticLockingFailureException; import page.clab.api.global.exception.DecryptionException; import page.clab.api.global.exception.EncryptionException; @@ -87,17 +93,12 @@ import page.clab.api.global.exception.PermissionDeniedException; import page.clab.api.global.exception.SortingArgumentException; -import java.io.FileNotFoundException; -import java.io.IOException; -import java.util.NoSuchElementException; -import java.util.concurrent.CompletionException; - @RestControllerAdvice(basePackages = "page.clab.api") @RequiredArgsConstructor @Slf4j public class GlobalExceptionHandler { - private final SlackService slackService; + private final ApplicationEventPublisher eventPublisher; @ExceptionHandler({ InvalidInformationException.class, @@ -231,7 +232,8 @@ public ErrorResponse conflictException(HttpServletResponse response, Exception.class }) public ApiResponse serverException(HttpServletRequest request, HttpServletResponse response, Exception e) { - slackService.sendServerErrorNotification(request, e); + eventPublisher.publishEvent( + new NotificationEvent(this, GeneralAlertType.SERVER_ERROR, request, e)); log.warn(e.getMessage()); response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR); return ApiResponse.failure(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5ae527332..68c4e082c 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -195,13 +195,49 @@ resource: ipinfo: access-token: ${IPINFO_ACCESS_TOKEN} # Register at https://ipinfo.io/ -# Slack webhook configuration -slack: - core-team-webhook-url: ${SLACK_WEBHOOK_URL} # Create a Slack channel and get a webhook URL - executives-webhook-url: ${SLACK_WEBHOOK_URL} # Create a Slack channel and get a webhook URL - web-url: ${WEB_URL} # Your web URL - api-url: ${API_URL} # Your API docs URL - color: "#FF968A" # Slack message color +# Messaging configuration +notification: + common: + web-url: "${WEB_URL}" # Your web URL + api-url: "${API_URL}" # Your API documentation URL + color: "#FF968A" # Message color used in notifications + platforms: + slack: + webhooks: + # Replace the placeholders with your actual Slack webhook URLs + core-team: "${SLACK_CORE_TEAM_WEBHOOK_URL}" # Slack webhook URL for core team notifications + executives: "${SLACK_EXECUTIVES_WEBHOOK_URL}" # Slack webhook URL for executive team notifications + discord: + webhooks: + # Replace the placeholders with your actual Discord webhook URLs + release: "${DISCORD_RELEASE_WEBHOOK_URL}" # Discord webhook URL for release notifications + notifications: "${DISCORD_NOTIFICATIONS_WEBHOOK_URL}" # Discord webhook URL for general notifications + executives: "${DISCORD_EXECUTIVES_WEBHOOK_URL}" # Discord webhook URL for executive team notifications + # The category-mappings section defines how notifications are routed based on their category. + # The category names should match those specified in "page.clab.api.global.common.notificationSetting.domain.AlertCategory". + # By specifying multiple platforms and webhooks under each category, you can configure messages to be sent to multiple platforms and multiple webhooks simultaneously. + category-mappings: + GENERAL: + - platform: slack + webhook: core-team + - platform: discord + webhook: notifications + SECURITY: + - platform: slack + webhook: core-team + - platform: discord + webhook: notifications + EXECUTIVES: + - platform: slack + webhook: executives + - platform: discord + webhook: executives + # If a notification category is not explicitly mapped in category-mappings, it will use the default-mappings to determine where to send messages. + default-mappings: + - platform: slack + webhook: core-team + - platform: discord + webhook: notifications # Configure Swagger UI and generate OpenAPI documentation springdoc: