From 0b30f8537775f241a5870fbef0dd1d4c195a1049 Mon Sep 17 00:00:00 2001 From: whqtker Date: Sat, 29 Aug 2026 00:02:55 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20=EB=94=94=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20webhook=20=EB=A9=94=EC=8B=9C=EC=A7=80=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 검수 결과를 표시하려면 이미 전송한 알림 메시지를 수정할 수 있어야 하므로 editMessage 를 추가한다. - webhook 은 자신이 보낸 메시지를 봇 권한 없이 편집할 수 있다. - SimpleClientHttpRequestFactory 는 PATCH 를 지원하지 않으므로 webhook 전송용 팩토리를 JDK HttpClient 기반으로 교체한다. - webhook url 에 쿼리스트링이 붙어 있을 수 있어 경로 뒤에 이어붙이지 않는다. Co-Authored-By: Claude Opus 5 (1M context) --- .../config/client/RestTemplateConfig.java | 12 ++-- .../common/discord/DiscordWebhookSender.java | 28 ++++++++- .../discord/DiscordWebhookSenderTest.java | 61 +++++++++++++++++++ 3 files changed, 95 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/example/solidconnection/common/config/client/RestTemplateConfig.java b/src/main/java/com/example/solidconnection/common/config/client/RestTemplateConfig.java index 31e41a295..a4056d01f 100644 --- a/src/main/java/com/example/solidconnection/common/config/client/RestTemplateConfig.java +++ b/src/main/java/com/example/solidconnection/common/config/client/RestTemplateConfig.java @@ -1,6 +1,8 @@ package com.example.solidconnection.common.config.client; import java.time.Duration; +import org.springframework.boot.http.client.ClientHttpRequestFactoryBuilder; +import org.springframework.boot.http.client.ClientHttpRequestFactorySettings; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -24,13 +26,15 @@ public RestTemplate restTemplate(RestTemplateBuilder restTemplateBuilder) { * - Discord webhook url 은 경로에 인증 토큰을 포함한다. * - RestTemplateBuilder 로 만든 RestTemplate 은 observation 이 적용되어 요청 url 이 메트릭 태그로 남을 수 있으므로, * webhook 전송에는 observation 이 붙지 않는 별도 인스턴스를 사용한다. + * - 메시지 편집에 PATCH 를 사용하는데, SimpleClientHttpRequestFactory 는 PATCH 를 지원하지 않으므로 + * JDK HttpClient 기반 팩토리를 사용한다. * */ @Bean public RestTemplate discordWebhookRestTemplate() { - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout((int) TIMEOUT.toMillis()); - requestFactory.setReadTimeout((int) TIMEOUT.toMillis()); - return new RestTemplate(requestFactory); + return new RestTemplate(ClientHttpRequestFactoryBuilder.jdk() + .build(ClientHttpRequestFactorySettings.defaults() + .withConnectTimeout(TIMEOUT) + .withReadTimeout(TIMEOUT))); } @Bean diff --git a/src/main/java/com/example/solidconnection/common/discord/DiscordWebhookSender.java b/src/main/java/com/example/solidconnection/common/discord/DiscordWebhookSender.java index 9446d072c..425b7d735 100644 --- a/src/main/java/com/example/solidconnection/common/discord/DiscordWebhookSender.java +++ b/src/main/java/com/example/solidconnection/common/discord/DiscordWebhookSender.java @@ -6,14 +6,16 @@ import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClientResponseException; import org.springframework.web.client.RestTemplate; /* - * - Discord Webhook 으로 메시지를 전송한다. - * - 알림 전송 실패가 호출한 기능을 실패시키지 않도록 예외를 격리하고, 전송 여부를 반환해 후속 처리를 맡긴다. + * - Discord Webhook 으로 메시지를 전송하고 편집한다. + * - send() 만 예외를 격리하고 전송 여부를 반환한다. + * sendAndGetMessage() 와 editMessage() 는 호출한 기능이 결과를 알아야 하므로 예외를 전파한다. * - 채널별로 webhook url 이 다르므로 url 을 인자로 받는다. * - webhook url 은 경로에 인증 토큰을 포함하므로 로그와 메트릭에 남기지 않는다. * */ @@ -62,6 +64,28 @@ public DiscordMessageResponse sendAndGetMessage(String webhookUrl, String conten ); } + /* + * - webhook 이 보낸 메시지는 봇 권한 없이 webhook 토큰만으로 편집할 수 있다. + * - PATCH 는 content 를 통째로 덮어쓴다. + * */ + public void editMessage(String webhookUrl, String messageId, String content) { + discordWebhookRestTemplate.exchange( + messageUrl(webhookUrl, messageId), + HttpMethod.PATCH, + buildRequest(content, List.of()), + Void.class + ); + } + + // webhook url 에 쿼리스트링이 붙어 있을 수 있으므로 경로 뒤에 이어붙이지 않는다. + private String messageUrl(String webhookUrl, String messageId) { + int queryIndex = webhookUrl.indexOf('?'); + if (queryIndex < 0) { + return webhookUrl + "/messages/" + messageId; + } + return webhookUrl.substring(0, queryIndex) + "/messages/" + messageId + webhookUrl.substring(queryIndex); + } + private HttpEntity> buildRequest(String content, List mentionableRoleIds) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); diff --git a/src/test/java/com/example/solidconnection/common/discord/DiscordWebhookSenderTest.java b/src/test/java/com/example/solidconnection/common/discord/DiscordWebhookSenderTest.java index 2fc1d9b50..0f5158a9d 100644 --- a/src/test/java/com/example/solidconnection/common/discord/DiscordWebhookSenderTest.java +++ b/src/test/java/com/example/solidconnection/common/discord/DiscordWebhookSenderTest.java @@ -1,6 +1,7 @@ package com.example.solidconnection.common.discord; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertAll; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; @@ -18,6 +19,7 @@ import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.web.client.RestClientException; import org.springframework.web.client.RestTemplate; @@ -107,6 +109,65 @@ class 메시지를_전송한다 { } } + @Nested + @DisplayName("메시지 편집") + class 메시지를_편집한다 { + + private static final String MESSAGE_ID = "message-id"; + + private HttpEntity> 편집된_요청(String expectedUrl) { + ArgumentCaptor>> requestCaptor = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange(eq(expectedUrl), eq(HttpMethod.PATCH), requestCaptor.capture(), eq(Void.class)); + return requestCaptor.getValue(); + } + + @Test + void 메시지_경로로_content_를_전송한다() { + // when + discordWebhookSender.editMessage(WEBHOOK_URL, MESSAGE_ID, CONTENT); + + // then + HttpEntity> request = 편집된_요청(WEBHOOK_URL + "/messages/" + MESSAGE_ID); + assertAll( + () -> assertThat(request.getBody()).containsEntry("content", CONTENT), + () -> assertThat(request.getHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON) + ); + } + + @Test + void 멘션_허용_범위를_전송할_때와_동일하게_제한한다() { + // when + discordWebhookSender.editMessage(WEBHOOK_URL, MESSAGE_ID, "@everyone 검수 완료"); + + // then + assertThat(편집된_요청(WEBHOOK_URL + "/messages/" + MESSAGE_ID).getBody()) + .containsEntry("allowed_mentions", Map.of("parse", List.of(), "roles", List.of())); + } + + @Test + void webhook_url_에_쿼리스트링이_있으면_경로_뒤가_아니라_쿼리_앞에_붙인다() { + // given + String urlWithQuery = WEBHOOK_URL + "?thread_id=123"; + + // when + discordWebhookSender.editMessage(urlWithQuery, MESSAGE_ID, CONTENT); + + // then + 편집된_요청(WEBHOOK_URL + "/messages/" + MESSAGE_ID + "?thread_id=123"); + } + + @Test + void 편집이_실패하면_예외를_전파한다() { + // given + when(restTemplate.exchange(anyString(), eq(HttpMethod.PATCH), any(), eq(Void.class))) + .thenThrow(new RestClientException("discord unavailable")); + + // when & then + assertThatThrownBy(() -> discordWebhookSender.editMessage(WEBHOOK_URL, MESSAGE_ID, CONTENT)) + .isInstanceOf(RestClientException.class); + } + } + @Nested @DisplayName("멘션 허용 범위") class 멘션_허용_범위를_제한한다 { From 61b0de9a8f8a17a88dfa0b5f3b530050a28364f4 Mon Sep 17 00:00:00 2001 From: whqtker Date: Sat, 29 Aug 2026 19:36:17 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor:=20DiscordNotifier=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EA=B0=92=EC=9D=84=20=EC=83=9D=EC=84=B1=EC=9E=90?= =?UTF-8?q?=EB=A1=9C=20=EC=A3=BC=EC=9E=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 필드 주입은 테스트에서 인스턴스를 직접 만들 수 없어 리플렉션을 요구한다. 동작은 그대로 두고 주입 방식만 바꾼다. Co-Authored-By: Claude Opus 5 (1M context) --- .../common/discord/DiscordNotifier.java | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java b/src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java index 51e5c5e7e..144726296 100644 --- a/src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java +++ b/src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java @@ -1,14 +1,12 @@ package com.example.solidconnection.common.discord; import com.example.solidconnection.common.discord.service.DiscordNotificationService; -import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.stereotype.Component; @Component -@RequiredArgsConstructor @EnableAsync public class DiscordNotifier { @@ -17,12 +15,22 @@ public class DiscordNotifier { private final DiscordWebhookSender discordWebhookSender; private final DiscordNotificationService discordNotificationService; private final DiscordReactionClient discordReactionClient; + private final String webhookUrl; + private final String environment; - @Value("${discord.webhook-url:}") - private String webhookUrl; - - @Value("${spring.profiles.active:}") - private String environment; + public DiscordNotifier( + DiscordWebhookSender discordWebhookSender, + DiscordNotificationService discordNotificationService, + DiscordReactionClient discordReactionClient, + @Value("${discord.webhook-url:}") String webhookUrl, + @Value("${spring.profiles.active:}") String environment + ) { + this.discordWebhookSender = discordWebhookSender; + this.discordNotificationService = discordNotificationService; + this.discordReactionClient = discordReactionClient; + this.webhookUrl = webhookUrl; + this.environment = environment; + } @Async public void notify(DiscordNotificationType type, String applicantInfo) { From 0ad73338a5329935bdbc77ef407857b67cad4921 Mon Sep 17 00:00:00 2001 From: whqtker Date: Sat, 29 Aug 2026 19:37:17 +0900 Subject: [PATCH 3/5] =?UTF-8?q?fix:=20=EC=84=B1=EC=A0=81=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=20=EC=8B=9C=20=EB=94=94=EC=8A=A4=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=AA=A8=EC=A7=80=20=EC=9A=94=EC=B2=AD=20=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A1=9C=20=EB=B0=9C=EC=83=9D=ED=95=98=EB=8A=94=20500?= =?UTF-8?q?=20=EC=97=90=EB=9F=AC=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 봇이 알림 채널에 초대되어 있지 않아 반응 요청이 403 Missing Access 를 받고, 예외가 검수 트랜잭션 밖으로 전파되어 롤백되면서 500 이 반환되었다. 봇을 초대하지 않기로 하여 표시 방식을 webhook 메시지 편집으로 바꾼다. - 검수 시점 데이터로 본문을 다시 만들어 마커와 함께 덮어쓴다. 본문을 새로 만들므로 재검수해도 마커가 누적되지 않는다. - notify 와 동일하게 webhook url 이 없거나 local 이면 아무것도 하지 않는다. - 편집 대상 메시지가 삭제된 경우(404)는 재시도해도 성공하지 않으므로 검수를 실패시키지 않는다. 그 외 실패는 기존과 같이 전파한다. - 더 이상 호출하지 않는 봇 반응 클라이언트를 제거한다. Fixes #840 Co-Authored-By: Claude Opus 5 (1M context) --- .../admin/service/AdminGpaScoreService.java | 22 ++- .../AdminLanguageTestScoreService.java | 22 ++- .../AdminMentorApplicationService.java | 15 +- .../common/discord/DiscordNotifier.java | 60 ++++-- .../common/discord/DiscordReactionClient.java | 35 ---- .../service/AdminGpaScoreServiceTest.java | 8 + .../AdminLanguageTestScoreServiceTest.java | 8 + .../AdminMentorApplicationServiceTest.java | 8 + .../common/discord/DiscordNotifierTest.java | 178 ++++++++++++++++++ .../discord/DiscordReactionClientTest.java | 41 ---- 10 files changed, 296 insertions(+), 101 deletions(-) delete mode 100644 src/main/java/com/example/solidconnection/common/discord/DiscordReactionClient.java create mode 100644 src/test/java/com/example/solidconnection/common/discord/DiscordNotifierTest.java delete mode 100644 src/test/java/com/example/solidconnection/common/discord/DiscordReactionClientTest.java diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java b/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java index 6e7840e7f..ec6221da9 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java @@ -1,6 +1,7 @@ package com.example.solidconnection.admin.service; import static com.example.solidconnection.common.exception.ErrorCode.GPA_SCORE_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; import com.example.solidconnection.admin.dto.GpaScoreResponse; import com.example.solidconnection.admin.dto.GpaScoreSearchResponse; @@ -14,6 +15,8 @@ import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.score.domain.GpaScore; import com.example.solidconnection.score.repository.GpaScoreRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -25,6 +28,7 @@ public class AdminGpaScoreService { private final GpaScoreRepository gpaScoreRepository; + private final SiteUserRepository siteUserRepository; private final DiscordNotifier discordNotifier; @Transactional(readOnly = true) @@ -45,18 +49,26 @@ public GpaScoreResponse updateGpaScore(Long gpaScoreId, GpaScoreUpdateRequest re request.verifyStatus(), request.verifyStatus() == VerifyStatus.REJECTED ? request.rejectedReason() : null ); - publishReaction(gpaScoreId, request.verifyStatus()); + publishReviewResult(gpaScore, request.verifyStatus()); return GpaScoreResponse.from(gpaScore); } - private void publishReaction(long gpaScoreId, VerifyStatus verifyStatus) { - String emoji = switch (verifyStatus) { + private void publishReviewResult(GpaScore gpaScore, VerifyStatus verifyStatus) { + String marker = switch (verifyStatus) { case APPROVED -> DiscordReactionEmoji.APPROVED.getValue(); case REJECTED -> DiscordReactionEmoji.REJECTED.getValue(); case PENDING -> null; }; - if (emoji != null) { - discordNotifier.addReaction(DiscordNotificationType.GPA_SCORE, gpaScoreId, emoji); + if (marker == null) { + return; } + SiteUser siteUser = siteUserRepository.findById(gpaScore.getSiteUserId()) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + discordNotifier.markReviewResult( + DiscordNotificationType.GPA_SCORE, + gpaScore.getId(), + siteUser.getNickname(), + marker + ); } } diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java b/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java index 159091149..fed887bbb 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java @@ -1,6 +1,7 @@ package com.example.solidconnection.admin.service; import static com.example.solidconnection.common.exception.ErrorCode.LANGUAGE_TEST_SCORE_NOT_FOUND; +import static com.example.solidconnection.common.exception.ErrorCode.USER_NOT_FOUND; import com.example.solidconnection.admin.dto.LanguageTestScoreResponse; import com.example.solidconnection.admin.dto.LanguageTestScoreSearchResponse; @@ -14,6 +15,8 @@ import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.score.domain.LanguageTestScore; import com.example.solidconnection.score.repository.LanguageTestScoreRepository; +import com.example.solidconnection.siteuser.domain.SiteUser; +import com.example.solidconnection.siteuser.repository.SiteUserRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -25,6 +28,7 @@ public class AdminLanguageTestScoreService { private final LanguageTestScoreRepository languageTestScoreRepository; + private final SiteUserRepository siteUserRepository; private final DiscordNotifier discordNotifier; @Transactional(readOnly = true) @@ -45,18 +49,26 @@ public LanguageTestScoreResponse updateLanguageTestScore(Long languageTestScoreI request.verifyStatus(), request.verifyStatus() == VerifyStatus.REJECTED ? request.rejectedReason() : null ); - publishReaction(languageTestScoreId, request.verifyStatus()); + publishReviewResult(languageTestScore, request.verifyStatus()); return LanguageTestScoreResponse.from(languageTestScore); } - private void publishReaction(long languageTestScoreId, VerifyStatus verifyStatus) { - String emoji = switch (verifyStatus) { + private void publishReviewResult(LanguageTestScore languageTestScore, VerifyStatus verifyStatus) { + String marker = switch (verifyStatus) { case APPROVED -> DiscordReactionEmoji.APPROVED.getValue(); case REJECTED -> DiscordReactionEmoji.REJECTED.getValue(); case PENDING -> null; }; - if (emoji != null) { - discordNotifier.addReaction(DiscordNotificationType.LANGUAGE_TEST_SCORE, languageTestScoreId, emoji); + if (marker == null) { + return; } + SiteUser siteUser = siteUserRepository.findById(languageTestScore.getSiteUserId()) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + discordNotifier.markReviewResult( + DiscordNotificationType.LANGUAGE_TEST_SCORE, + languageTestScore.getId(), + siteUser.getNickname(), + marker + ); } } diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java b/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java index 33563faa9..6a9d2bd08 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java @@ -67,7 +67,7 @@ public void approveMentorApplication(Long mentorApplicationId) { ); mentorRepository.save(mentor); - publishReaction(mentorApplicationId, DiscordReactionEmoji.APPROVED.getValue()); + publishReviewResult(mentorApplicationId, siteUser.getNickname(), DiscordReactionEmoji.APPROVED.getValue()); } private void validateUserCanCreateMentor(long siteUserId) { @@ -85,11 +85,18 @@ public void rejectMentorApplication( .orElseThrow(() -> new CustomException(MENTOR_APPLICATION_NOT_FOUND)); mentorApplication.reject(request.rejectedReason()); - publishReaction(mentorApplicationId, DiscordReactionEmoji.REJECTED.getValue()); + SiteUser siteUser = siteUserRepository.findById(mentorApplication.getSiteUserId()) + .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); + publishReviewResult(mentorApplicationId, siteUser.getNickname(), DiscordReactionEmoji.REJECTED.getValue()); } - private void publishReaction(long mentorApplicationId, String emoji) { - discordNotifier.addReaction(DiscordNotificationType.MENTOR_APPLICATION, mentorApplicationId, emoji); + private void publishReviewResult(long mentorApplicationId, String applicantInfo, String marker) { + discordNotifier.markReviewResult( + DiscordNotificationType.MENTOR_APPLICATION, + mentorApplicationId, + applicantInfo, + marker + ); } @Transactional(readOnly = true) diff --git a/src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java b/src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java index 144726296..6c15e24a4 100644 --- a/src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java +++ b/src/main/java/com/example/solidconnection/common/discord/DiscordNotifier.java @@ -1,47 +1,48 @@ package com.example.solidconnection.common.discord; +import com.example.solidconnection.common.discord.domain.DiscordNotification; import com.example.solidconnection.common.discord.service.DiscordNotificationService; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.scheduling.annotation.Async; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; @Component @EnableAsync +@Slf4j public class DiscordNotifier { private static final String ADMIN_PAGE_URL = "https://www.admins.solid-connection.com"; private final DiscordWebhookSender discordWebhookSender; private final DiscordNotificationService discordNotificationService; - private final DiscordReactionClient discordReactionClient; private final String webhookUrl; private final String environment; public DiscordNotifier( DiscordWebhookSender discordWebhookSender, DiscordNotificationService discordNotificationService, - DiscordReactionClient discordReactionClient, @Value("${discord.webhook-url:}") String webhookUrl, @Value("${spring.profiles.active:}") String environment ) { this.discordWebhookSender = discordWebhookSender; this.discordNotificationService = discordNotificationService; - this.discordReactionClient = discordReactionClient; this.webhookUrl = webhookUrl; this.environment = environment; } @Async public void notify(DiscordNotificationType type, String applicantInfo) { - if (webhookUrl.isBlank() || "local".equalsIgnoreCase(environment)) { + if (isNotifyDisabled()) { return; } discordWebhookSender.send(webhookUrl, buildMessage(type, applicantInfo)); } public void notify(DiscordNotificationType type, long reviewId, String applicantInfo) { - if (webhookUrl.isBlank() || "local".equalsIgnoreCase(environment)) { + if (isNotifyDisabled()) { return; } DiscordMessageResponse response = discordWebhookSender.sendAndGetMessage( @@ -51,13 +52,50 @@ public void notify(DiscordNotificationType type, long reviewId, String applicant discordNotificationService.save(type, reviewId, response.channelId(), response.id()); } - public void addReaction(DiscordNotificationType type, long reviewId, String emoji) { + /* + * - 검수 결과를 알림 메시지 앞에 마커로 표시한다. + * - 봇 권한이 필요한 반응 대신, webhook 이 보낸 메시지를 직접 편집한다. + * - 검수 시점 데이터로 본문을 다시 만들어 통째로 덮어쓰므로 마커가 누적되지 않는다. + * */ + public void markReviewResult( + DiscordNotificationType type, + long reviewId, + String applicantInfo, + String marker + ) { + if (isNotifyDisabled()) { + return; + } discordNotificationService.findByReviewTypeAndReviewId(type, reviewId) - .ifPresent(message -> discordReactionClient.addReaction( - message.getDiscordChannelId(), - message.getDiscordMessageId(), - emoji - )); + .ifPresent(notification -> editMessage(notification, type, applicantInfo, marker)); + } + + private void editMessage( + DiscordNotification notification, + DiscordNotificationType type, + String applicantInfo, + String marker + ) { + try { + discordWebhookSender.editMessage( + webhookUrl, + notification.getDiscordMessageId(), + marker + " " + buildMessage(type, applicantInfo) + ); + } catch (HttpClientErrorException.NotFound e) { + /* + * - 편집 대상이 사라진 경우로, 재시도해도 성공하지 않으므로 검수를 실패시키지 않는다. + * - 디스코드는 메시지 부재(10008)와 webhook 부재(10015)를 모두 404 로 응답한다. + * webhook 부재는 설정 사고이므로 구분할 수 있도록 응답 본문을 남긴다. + * - 응답 본문에는 webhook url 이 포함되지 않으므로 토큰이 노출되지 않는다. + * */ + log.warn("검수 결과를 표시하지 못했습니다. reviewType={}, reviewId={}, discordError={}", + type, notification.getReviewId(), e.getResponseBodyAsString()); + } + } + + private boolean isNotifyDisabled() { + return webhookUrl.isBlank() || "local".equalsIgnoreCase(environment); } private String buildMessage(DiscordNotificationType type, String applicantInfo) { diff --git a/src/main/java/com/example/solidconnection/common/discord/DiscordReactionClient.java b/src/main/java/com/example/solidconnection/common/discord/DiscordReactionClient.java deleted file mode 100644 index 7c0033fb9..000000000 --- a/src/main/java/com/example/solidconnection/common/discord/DiscordReactionClient.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.example.solidconnection.common.discord; - -import java.nio.charset.StandardCharsets; -import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; -import org.springframework.web.util.UriUtils; - -@Component -public class DiscordReactionClient { - - private static final String REACTION_URL = - "https://discord.com/api/v10/channels/%s/messages/%s/reactions/%s/@me"; - - private final RestTemplate discordBotRestTemplate; - - @Value("${discord.bot-token:}") - private String botToken; - - public DiscordReactionClient(@Qualifier("discordBotRestTemplate") RestTemplate discordBotRestTemplate) { - this.discordBotRestTemplate = discordBotRestTemplate; - } - - public void addReaction(String channelId, String messageId, String emoji) { - String encodedEmoji = UriUtils.encodePathSegment(emoji, StandardCharsets.UTF_8); - String url = REACTION_URL.formatted(channelId, messageId, encodedEmoji); - HttpHeaders headers = new HttpHeaders(); - headers.set("Authorization", "Bot " + botToken); - discordBotRestTemplate.exchange(url, HttpMethod.PUT, new HttpEntity<>(headers), Void.class); - } -} diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminGpaScoreServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminGpaScoreServiceTest.java index 50c12c4b3..e8f25dbba 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminGpaScoreServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminGpaScoreServiceTest.java @@ -1,5 +1,7 @@ package com.example.solidconnection.admin.service; +import com.example.solidconnection.common.discord.DiscordNotifier; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import static com.example.solidconnection.common.exception.ErrorCode.GPA_SCORE_NOT_FOUND; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.AssertionsForClassTypes.assertThatCode; @@ -34,6 +36,12 @@ class AdminGpaScoreServiceTest { @Autowired private AdminGpaScoreService adminGpaScoreService; + /* + * - 테스트 설정의 discord.webhook-url 이 비어 있지 않아 실제 요청이 나갈 수 있으므로 목으로 대체한다. + * */ + @MockitoBean + private DiscordNotifier discordNotifier; + @Autowired private SiteUserFixture siteUserFixture; diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreServiceTest.java index e0afc9610..c888cf48b 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreServiceTest.java @@ -1,5 +1,7 @@ package com.example.solidconnection.admin.service; +import com.example.solidconnection.common.discord.DiscordNotifier; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import static com.example.solidconnection.common.exception.ErrorCode.LANGUAGE_TEST_SCORE_NOT_FOUND; import static com.example.solidconnection.university.domain.LanguageTestType.TOEIC; import static org.assertj.core.api.Assertions.assertThat; @@ -35,6 +37,12 @@ class AdminLanguageTestScoreServiceTest { @Autowired private AdminLanguageTestScoreService adminLanguageTestScoreService; + /* + * - 테스트 설정의 discord.webhook-url 이 비어 있지 않아 실제 요청이 나갈 수 있으므로 목으로 대체한다. + * */ + @MockitoBean + private DiscordNotifier discordNotifier; + @Autowired private SiteUserFixture siteUserFixture; diff --git a/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java b/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java index a65718443..3c91304e5 100644 --- a/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java +++ b/src/test/java/com/example/solidconnection/admin/service/AdminMentorApplicationServiceTest.java @@ -1,5 +1,7 @@ package com.example.solidconnection.admin.service; +import com.example.solidconnection.common.discord.DiscordNotifier; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_ALREADY_EXISTS; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_ALREADY_CONFIRMED; import static com.example.solidconnection.common.exception.ErrorCode.MENTOR_APPLICATION_NOT_FOUND; @@ -50,6 +52,12 @@ class AdminMentorApplicationServiceTest { @Autowired private AdminMentorApplicationService adminMentorApplicationService; + /* + * - 테스트 설정의 discord.webhook-url 이 비어 있지 않아 실제 요청이 나갈 수 있으므로 목으로 대체한다. + * */ + @MockitoBean + private DiscordNotifier discordNotifier; + @Autowired private SiteUserFixture siteUserFixture; diff --git a/src/test/java/com/example/solidconnection/common/discord/DiscordNotifierTest.java b/src/test/java/com/example/solidconnection/common/discord/DiscordNotifierTest.java new file mode 100644 index 000000000..be4dcb7b4 --- /dev/null +++ b/src/test/java/com/example/solidconnection/common/discord/DiscordNotifierTest.java @@ -0,0 +1,178 @@ +package com.example.solidconnection.common.discord; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; +import static org.mockito.Mockito.never; + +import com.example.solidconnection.common.discord.domain.DiscordNotification; +import com.example.solidconnection.common.discord.service.DiscordNotificationService; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; + +@ExtendWith(MockitoExtension.class) +@DisplayName("DiscordNotifier 테스트") +class DiscordNotifierTest { + + private static final String WEBHOOK_URL = "https://discord.test/webhooks/channel"; + private static final String MESSAGE_ID = "message-id"; + private static final long REVIEW_ID = 1L; + private static final DiscordNotificationType REVIEW_TYPE = DiscordNotificationType.GPA_SCORE; + + @Mock + private DiscordWebhookSender discordWebhookSender; + + @Mock + private DiscordNotificationService discordNotificationService; + + private DiscordNotifier discordNotifier; + + @BeforeEach + void setUp() { + discordNotifier = notifierWith(WEBHOOK_URL, "dev"); + } + + private DiscordNotifier notifierWith(String webhookUrl, String environment) { + return new DiscordNotifier(discordWebhookSender, discordNotificationService, webhookUrl, environment); + } + + private void 알림이_저장되어_있다() { + DiscordNotification notification = DiscordNotification.of(REVIEW_TYPE, REVIEW_ID, "channel-id", MESSAGE_ID); + given(discordNotificationService.findByReviewTypeAndReviewId(REVIEW_TYPE, REVIEW_ID)) + .willReturn(Optional.of(notification)); + } + + @Nested + @DisplayName("검수 결과 표시") + class 검수_결과를_표시한다 { + + @Test + void 마커를_붙인_본문으로_원본_메시지를_한_번만_편집한다() { + // given + 알림이_저장되어_있다(); + ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(String.class); + + // when + discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReactionEmoji.APPROVED.getValue()); + + // then + then(discordWebhookSender).should() + .editMessage(eq(WEBHOOK_URL), eq(MESSAGE_ID), contentCaptor.capture()); + String content = contentCaptor.getValue(); + assertThat(content).startsWith("✅ ").contains("홍길동").contains("[개발 서버 알림입니다]"); + } + + @Test + void 재검수하면_마커가_누적되지_않고_최종_결과만_남는다() { + // given + 알림이_저장되어_있다(); + ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(String.class); + + // when + discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReactionEmoji.REJECTED.getValue()); + + // then + then(discordWebhookSender).should() + .editMessage(anyString(), anyString(), contentCaptor.capture()); + String content = contentCaptor.getValue(); + assertThat(content).startsWith("❌ "); + assertThat(content).doesNotContain("✅"); + } + } + + @Nested + @DisplayName("전송하지 않는 경우") + class 전송하지_않는다 { + + @Test + void webhook_url_이_없으면_조회조차_하지_않는다() { + // given + discordNotifier = notifierWith("", "dev"); + + // when + discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReactionEmoji.APPROVED.getValue()); + + // then + then(discordNotificationService).should(never()).findByReviewTypeAndReviewId(any(), anyLong()); + then(discordWebhookSender).should(never()).editMessage(anyString(), anyString(), anyString()); + } + + @Test + void local_환경이면_조회조차_하지_않는다() { + // given + discordNotifier = notifierWith(WEBHOOK_URL, "local"); + + // when + discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReactionEmoji.APPROVED.getValue()); + + // then + then(discordNotificationService).should(never()).findByReviewTypeAndReviewId(any(), anyLong()); + then(discordWebhookSender).should(never()).editMessage(anyString(), anyString(), anyString()); + } + + @Test + void 저장된_알림이_없으면_편집하지_않는다() { + // given + given(discordNotificationService.findByReviewTypeAndReviewId(REVIEW_TYPE, REVIEW_ID)) + .willReturn(Optional.empty()); + + // when + discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReactionEmoji.APPROVED.getValue()); + + // then + then(discordWebhookSender).should(never()).editMessage(anyString(), anyString(), anyString()); + } + } + + @Nested + @DisplayName("편집 실패 처리") + class 편집_실패를_처리한다 { + + @Test + void 편집_대상이_없으면_검수를_실패시키지_않는다() { + // given + 알림이_저장되어_있다(); + HttpClientErrorException notFound = HttpClientErrorException.create( + HttpStatus.NOT_FOUND, "Not Found", null, null, null); + willThrow(notFound) + .given(discordWebhookSender).editMessage(anyString(), anyString(), anyString()); + + // when & then + assertThatCode(() -> discordNotifier.markReviewResult( + REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReactionEmoji.APPROVED.getValue())) + .doesNotThrowAnyException(); + } + + @Test + void 디스코드_장애는_그대로_전파한다() { + // given + 알림이_저장되어_있다(); + HttpServerErrorException serverError = HttpServerErrorException.create( + HttpStatus.INTERNAL_SERVER_ERROR, "Internal Server Error", null, null, null); + willThrow(serverError) + .given(discordWebhookSender).editMessage(anyString(), anyString(), anyString()); + + // when & then + assertThatThrownBy(() -> discordNotifier.markReviewResult( + REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReactionEmoji.APPROVED.getValue())) + .isInstanceOf(HttpServerErrorException.class); + } + } +} diff --git a/src/test/java/com/example/solidconnection/common/discord/DiscordReactionClientTest.java b/src/test/java/com/example/solidconnection/common/discord/DiscordReactionClientTest.java deleted file mode 100644 index b9281f5f0..000000000 --- a/src/test/java/com/example/solidconnection/common/discord/DiscordReactionClientTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.example.solidconnection.common.discord; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; - -import org.junit.jupiter.api.Test; -import org.mockito.ArgumentCaptor; -import org.springframework.http.HttpEntity; -import org.springframework.http.HttpMethod; -import org.springframework.web.client.RestTemplate; -import org.springframework.test.util.ReflectionTestUtils; - -class DiscordReactionClientTest { - - @Test - void 승인_이모지를_봇_권한으로_추가한다() { - // given - RestTemplate restTemplate = mock(RestTemplate.class); - DiscordReactionClient discordReactionClient = new DiscordReactionClient(restTemplate); - ReflectionTestUtils.setField(discordReactionClient, "botToken", "bot-token"); - ArgumentCaptor> requestCaptor = ArgumentCaptor.forClass(HttpEntity.class); - - // when - discordReactionClient.addReaction( - "channel-id", - "message-id", - DiscordReactionEmoji.APPROVED.getValue() - ); - - // then - verify(restTemplate).exchange( - eq("https://discord.com/api/v10/channels/channel-id/messages/message-id/reactions/%E2%9C%85/@me"), - eq(HttpMethod.PUT), - requestCaptor.capture(), - eq(Void.class) - ); - assertThat(requestCaptor.getValue().getHeaders().getFirst("Authorization")).isEqualTo("Bot bot-token"); - } -} From 1315c641fdf47fa79ee7b22b63c1e334baa9ecb4 Mon Sep 17 00:00:00 2001 From: whqtker Date: Sat, 29 Aug 2026 19:37:30 +0900 Subject: [PATCH 4/5] =?UTF-8?q?refactor:=20DiscordReactionEmoji=20?= =?UTF-8?q?=EB=A5=BC=20DiscordReviewMarker=20=EB=A1=9C=20=EA=B0=9C?= =?UTF-8?q?=EB=AA=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 반응(reaction)이 아니라 알림 메시지에 붙이는 마커이므로 이름을 동작에 맞춘다. Co-Authored-By: Claude Opus 5 (1M context) --- .../admin/service/AdminGpaScoreService.java | 6 +++--- .../service/AdminLanguageTestScoreService.java | 6 +++--- .../service/AdminMentorApplicationService.java | 6 +++--- ...ReactionEmoji.java => DiscordReviewMarker.java} | 4 ++-- .../common/discord/DiscordNotifierTest.java | 14 +++++++------- 5 files changed, 18 insertions(+), 18 deletions(-) rename src/main/java/com/example/solidconnection/common/discord/{DiscordReactionEmoji.java => DiscordReviewMarker.java} (72%) diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java b/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java index ec6221da9..064e07f89 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminGpaScoreService.java @@ -11,7 +11,7 @@ import com.example.solidconnection.common.VerifyStatus; import com.example.solidconnection.common.discord.DiscordNotificationType; import com.example.solidconnection.common.discord.DiscordNotifier; -import com.example.solidconnection.common.discord.DiscordReactionEmoji; +import com.example.solidconnection.common.discord.DiscordReviewMarker; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.score.domain.GpaScore; import com.example.solidconnection.score.repository.GpaScoreRepository; @@ -55,8 +55,8 @@ public GpaScoreResponse updateGpaScore(Long gpaScoreId, GpaScoreUpdateRequest re private void publishReviewResult(GpaScore gpaScore, VerifyStatus verifyStatus) { String marker = switch (verifyStatus) { - case APPROVED -> DiscordReactionEmoji.APPROVED.getValue(); - case REJECTED -> DiscordReactionEmoji.REJECTED.getValue(); + case APPROVED -> DiscordReviewMarker.APPROVED.getValue(); + case REJECTED -> DiscordReviewMarker.REJECTED.getValue(); case PENDING -> null; }; if (marker == null) { diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java b/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java index fed887bbb..d52966f30 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminLanguageTestScoreService.java @@ -11,7 +11,7 @@ import com.example.solidconnection.common.VerifyStatus; import com.example.solidconnection.common.discord.DiscordNotificationType; import com.example.solidconnection.common.discord.DiscordNotifier; -import com.example.solidconnection.common.discord.DiscordReactionEmoji; +import com.example.solidconnection.common.discord.DiscordReviewMarker; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.score.domain.LanguageTestScore; import com.example.solidconnection.score.repository.LanguageTestScoreRepository; @@ -55,8 +55,8 @@ public LanguageTestScoreResponse updateLanguageTestScore(Long languageTestScoreI private void publishReviewResult(LanguageTestScore languageTestScore, VerifyStatus verifyStatus) { String marker = switch (verifyStatus) { - case APPROVED -> DiscordReactionEmoji.APPROVED.getValue(); - case REJECTED -> DiscordReactionEmoji.REJECTED.getValue(); + case APPROVED -> DiscordReviewMarker.APPROVED.getValue(); + case REJECTED -> DiscordReviewMarker.REJECTED.getValue(); case PENDING -> null; }; if (marker == null) { diff --git a/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java b/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java index 6a9d2bd08..a2cbed69a 100644 --- a/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java +++ b/src/main/java/com/example/solidconnection/admin/service/AdminMentorApplicationService.java @@ -11,7 +11,7 @@ import com.example.solidconnection.admin.dto.MentorApplicationSearchResponse; import com.example.solidconnection.common.discord.DiscordNotificationType; import com.example.solidconnection.common.discord.DiscordNotifier; -import com.example.solidconnection.common.discord.DiscordReactionEmoji; +import com.example.solidconnection.common.discord.DiscordReviewMarker; import com.example.solidconnection.common.exception.CustomException; import com.example.solidconnection.mentor.domain.Mentor; import com.example.solidconnection.mentor.domain.MentorApplication; @@ -67,7 +67,7 @@ public void approveMentorApplication(Long mentorApplicationId) { ); mentorRepository.save(mentor); - publishReviewResult(mentorApplicationId, siteUser.getNickname(), DiscordReactionEmoji.APPROVED.getValue()); + publishReviewResult(mentorApplicationId, siteUser.getNickname(), DiscordReviewMarker.APPROVED.getValue()); } private void validateUserCanCreateMentor(long siteUserId) { @@ -87,7 +87,7 @@ public void rejectMentorApplication( mentorApplication.reject(request.rejectedReason()); SiteUser siteUser = siteUserRepository.findById(mentorApplication.getSiteUserId()) .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); - publishReviewResult(mentorApplicationId, siteUser.getNickname(), DiscordReactionEmoji.REJECTED.getValue()); + publishReviewResult(mentorApplicationId, siteUser.getNickname(), DiscordReviewMarker.REJECTED.getValue()); } private void publishReviewResult(long mentorApplicationId, String applicantInfo, String marker) { diff --git a/src/main/java/com/example/solidconnection/common/discord/DiscordReactionEmoji.java b/src/main/java/com/example/solidconnection/common/discord/DiscordReviewMarker.java similarity index 72% rename from src/main/java/com/example/solidconnection/common/discord/DiscordReactionEmoji.java rename to src/main/java/com/example/solidconnection/common/discord/DiscordReviewMarker.java index bd1852fad..6d34fe52c 100644 --- a/src/main/java/com/example/solidconnection/common/discord/DiscordReactionEmoji.java +++ b/src/main/java/com/example/solidconnection/common/discord/DiscordReviewMarker.java @@ -3,7 +3,7 @@ import lombok.Getter; @Getter -public enum DiscordReactionEmoji { +public enum DiscordReviewMarker { APPROVED("✅"), REJECTED("❌"), @@ -11,7 +11,7 @@ public enum DiscordReactionEmoji { private final String value; - DiscordReactionEmoji(String value) { + DiscordReviewMarker(String value) { this.value = value; } } diff --git a/src/test/java/com/example/solidconnection/common/discord/DiscordNotifierTest.java b/src/test/java/com/example/solidconnection/common/discord/DiscordNotifierTest.java index be4dcb7b4..924fcc62b 100644 --- a/src/test/java/com/example/solidconnection/common/discord/DiscordNotifierTest.java +++ b/src/test/java/com/example/solidconnection/common/discord/DiscordNotifierTest.java @@ -70,7 +70,7 @@ class 검수_결과를_표시한다 { ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(String.class); // when - discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReactionEmoji.APPROVED.getValue()); + discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReviewMarker.APPROVED.getValue()); // then then(discordWebhookSender).should() @@ -86,7 +86,7 @@ class 검수_결과를_표시한다 { ArgumentCaptor contentCaptor = ArgumentCaptor.forClass(String.class); // when - discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReactionEmoji.REJECTED.getValue()); + discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReviewMarker.REJECTED.getValue()); // then then(discordWebhookSender).should() @@ -107,7 +107,7 @@ class 전송하지_않는다 { discordNotifier = notifierWith("", "dev"); // when - discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReactionEmoji.APPROVED.getValue()); + discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReviewMarker.APPROVED.getValue()); // then then(discordNotificationService).should(never()).findByReviewTypeAndReviewId(any(), anyLong()); @@ -120,7 +120,7 @@ class 전송하지_않는다 { discordNotifier = notifierWith(WEBHOOK_URL, "local"); // when - discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReactionEmoji.APPROVED.getValue()); + discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReviewMarker.APPROVED.getValue()); // then then(discordNotificationService).should(never()).findByReviewTypeAndReviewId(any(), anyLong()); @@ -134,7 +134,7 @@ class 전송하지_않는다 { .willReturn(Optional.empty()); // when - discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReactionEmoji.APPROVED.getValue()); + discordNotifier.markReviewResult(REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReviewMarker.APPROVED.getValue()); // then then(discordWebhookSender).should(never()).editMessage(anyString(), anyString(), anyString()); @@ -156,7 +156,7 @@ class 편집_실패를_처리한다 { // when & then assertThatCode(() -> discordNotifier.markReviewResult( - REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReactionEmoji.APPROVED.getValue())) + REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReviewMarker.APPROVED.getValue())) .doesNotThrowAnyException(); } @@ -171,7 +171,7 @@ class 편집_실패를_처리한다 { // when & then assertThatThrownBy(() -> discordNotifier.markReviewResult( - REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReactionEmoji.APPROVED.getValue())) + REVIEW_TYPE, REVIEW_ID, "홍길동", DiscordReviewMarker.APPROVED.getValue())) .isInstanceOf(HttpServerErrorException.class); } } From 2e1221152be321236de541b9bc1b165a14a2fef2 Mon Sep 17 00:00:00 2001 From: whqtker Date: Sat, 29 Aug 2026 19:37:38 +0900 Subject: [PATCH 5/5] =?UTF-8?q?refactor:=20=EC=82=AC=EC=9A=A9=ED=95=98?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8A=94=20=EB=94=94=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EB=B4=87=20RestTemplate=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 봇 반응 클라이언트가 사라져 discordBotRestTemplate 을 주입받는 곳이 없다. Co-Authored-By: Claude Opus 5 (1M context) --- .../common/config/client/RestTemplateConfig.java | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/main/java/com/example/solidconnection/common/config/client/RestTemplateConfig.java b/src/main/java/com/example/solidconnection/common/config/client/RestTemplateConfig.java index a4056d01f..893b1976e 100644 --- a/src/main/java/com/example/solidconnection/common/config/client/RestTemplateConfig.java +++ b/src/main/java/com/example/solidconnection/common/config/client/RestTemplateConfig.java @@ -6,7 +6,6 @@ import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.http.client.SimpleClientHttpRequestFactory; import org.springframework.web.client.RestTemplate; @Configuration @@ -36,12 +35,4 @@ public RestTemplate discordWebhookRestTemplate() { .withConnectTimeout(TIMEOUT) .withReadTimeout(TIMEOUT))); } - - @Bean - public RestTemplate discordBotRestTemplate() { - SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); - requestFactory.setConnectTimeout((int) TIMEOUT.toMillis()); - requestFactory.setReadTimeout((int) TIMEOUT.toMillis()); - return new RestTemplate(requestFactory); - } }