diff --git a/build.gradle b/build.gradle index f35eb41..7ce7b5a 100644 --- a/build.gradle +++ b/build.gradle @@ -27,6 +27,7 @@ dependencies { implementation 'org.projectlombok:lombok:1.18.26' developmentOnly 'org.springframework.boot:spring-boot-devtools' implementation 'mysql:mysql-connector-java' + implementation 'com.slack.api:slack-api-client:1.29.2' annotationProcessor 'org.projectlombok:lombok' testImplementation "com.github.gavlyukovskiy:p6spy-spring-boot-starter:1.8.0" testImplementation 'org.springframework.boot:spring-boot-starter-test' diff --git a/src/main/java/com/posomo/saltit/domain/exception/SlackMessageException.java b/src/main/java/com/posomo/saltit/domain/exception/SlackMessageException.java new file mode 100644 index 0000000..aebf5dd --- /dev/null +++ b/src/main/java/com/posomo/saltit/domain/exception/SlackMessageException.java @@ -0,0 +1,17 @@ +package com.posomo.saltit.domain.exception; + +import com.posomo.saltit.global.constant.ErrorMessage; + +public class SlackMessageException extends RuntimeException { + public SlackMessageException(String message, Throwable cause) { + super(message, cause); + } + + public SlackMessageException(String message) { + super(message); + } + + public SlackMessageException() { + super(ErrorMessage.SLACK_ERROR); + } +} diff --git a/src/main/java/com/posomo/saltit/global/aop/ErrorMessageSenderAdvice.java b/src/main/java/com/posomo/saltit/global/aop/ErrorMessageSenderAdvice.java new file mode 100644 index 0000000..665ea53 --- /dev/null +++ b/src/main/java/com/posomo/saltit/global/aop/ErrorMessageSenderAdvice.java @@ -0,0 +1,8 @@ +package com.posomo.saltit.global.aop; + +import org.aspectj.lang.ProceedingJoinPoint; + +public interface ErrorMessageSenderAdvice { + + public Object sendError(ProceedingJoinPoint joinPoint) throws Throwable; +} diff --git a/src/main/java/com/posomo/saltit/global/aop/SlackMessageAdvice.java b/src/main/java/com/posomo/saltit/global/aop/SlackMessageAdvice.java new file mode 100644 index 0000000..e9ed68f --- /dev/null +++ b/src/main/java/com/posomo/saltit/global/aop/SlackMessageAdvice.java @@ -0,0 +1,51 @@ +package com.posomo.saltit.global.aop; + +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.jetbrains.annotations.NotNull; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; + +import com.posomo.saltit.service.MessageSender; + +import lombok.RequiredArgsConstructor; + +@Aspect +@Component +@RequiredArgsConstructor +public class SlackMessageAdvice implements ErrorMessageSenderAdvice { + + private final MessageSender messageSender; + + @Override + @Around(value = "execution(public * com.posomo.saltit.global.aop.ControllerAdvice.*(..))") + public Object sendError(ProceedingJoinPoint joinPoint) throws Throwable { + Object[] args = joinPoint.getArgs(); + if (args.length == 1 && args[0] instanceof Throwable exception) { + StringBuilder stringBuilder = buildMessage(exception); + + messageSender.sendMessage(stringBuilder.toString()); + } + return joinPoint.proceed(); + } + + @NotNull + private static StringBuilder buildMessage(Throwable exception) { + StringBuilder stringBuilder = new StringBuilder(); + stringBuilder.append("Transaction Id\n"); + stringBuilder.append(MDC.get("transactionId")); + stringBuilder.append("\n\n"); + + stringBuilder.append("Error Message\n"); + stringBuilder.append(exception.getMessage()); + stringBuilder.append("\n\n"); + + stringBuilder.append("Stack Trace\n"); + for (StackTraceElement element : exception.getStackTrace()) { + stringBuilder.append(element.toString()); + stringBuilder.append("\n"); + } + return stringBuilder; + } +} diff --git a/src/main/java/com/posomo/saltit/global/constant/ErrorMessage.java b/src/main/java/com/posomo/saltit/global/constant/ErrorMessage.java index 3ad441a..0aff516 100644 --- a/src/main/java/com/posomo/saltit/global/constant/ErrorMessage.java +++ b/src/main/java/com/posomo/saltit/global/constant/ErrorMessage.java @@ -6,4 +6,5 @@ private ErrorMessage() { public static final String UNKNOWN_ERROR = "알 수 없는 에러가 발생했습니다."; public static final String RECORD_NOT_FOUND = "해당 데이터를 찾을 수 없습니다."; + public static final String SLACK_ERROR = "Slack 메시지 전송중 에러가 발생했습니다."; } diff --git a/src/main/java/com/posomo/saltit/global/filter/RequestAndResponseLoggingFilter.java b/src/main/java/com/posomo/saltit/global/filter/RequestAndResponseLoggingFilter.java index 97c65b8..4d335fb 100644 --- a/src/main/java/com/posomo/saltit/global/filter/RequestAndResponseLoggingFilter.java +++ b/src/main/java/com/posomo/saltit/global/filter/RequestAndResponseLoggingFilter.java @@ -1,32 +1,35 @@ package com.posomo.saltit.global.filter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.stream.Stream; +import org.slf4j.MDC; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; -import org.springframework.jmx.export.annotation.ManagedOperation; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.util.ContentCachingRequestWrapper; import org.springframework.web.util.ContentCachingResponseWrapper; -import java.io.IOException; -import java.io.UnsupportedEncodingException; -import java.nio.charset.StandardCharsets; -import java.util.Arrays; -import java.util.Collections; -import java.util.List; -import java.util.stream.Stream; +import com.posomo.saltit.service.MessageSender; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import lombok.extern.log4j.Log4j2; @Component +@RequiredArgsConstructor @Log4j2 public class RequestAndResponseLoggingFilter extends OncePerRequestFilter { + private final MessageSender messageSender; private static final List VISIBLE_TYPES = Arrays.asList( MediaType.valueOf("text/*"), @@ -62,13 +65,16 @@ protected void doFilterWrapped(ContentCachingRequestWrapper request, ContentCach try { beforeRequest(request, msg); filterChain.doFilter(request, response); - } - finally { + } finally { afterRequest(request, response, msg); - if(log.isInfoEnabled()) { - log.info(msg.toString()); + String msgString = msg.toString(); + if (log.isInfoEnabled()) { + log.info(msgString); } response.copyBodyToResponse(); + if (response.getStatus() >= 400) { + messageSender.sendMessage("Transaction Id\n" + MDC.get("transactionId") + "\n\n" + msgString); + } } } @@ -98,10 +104,9 @@ private static void logRequestHeader(ContentCachingRequestWrapper request, Strin .forEach(headerName -> Collections.list(request.getHeaders(headerName)) .forEach(headerValue -> { - if(isSensitiveHeader(headerName)) { + if (isSensitiveHeader(headerName)) { msg.append(String.format("%s %s: %s", prefix, headerName, "*******")).append("\n"); - } - else { + } else { msg.append(String.format("%s %s: %s", prefix, headerName, headerValue)).append("\n"); } })); @@ -123,10 +128,9 @@ private static void logResponse(ContentCachingResponseWrapper response, String p response.getHeaders(headerName) .forEach(headerValue -> { - if(isSensitiveHeader(headerName)) { + if (isSensitiveHeader(headerName)) { msg.append(String.format("%s %s: %s", prefix, headerName, "*******")).append("\n"); - } - else { + } else { msg.append(String.format("%s %s: %s", prefix, headerName, headerValue)).append("\n"); } })); @@ -158,16 +162,16 @@ private static boolean isSensitiveHeader(String headerName) { } private static ContentCachingRequestWrapper wrapRequest(HttpServletRequest request) { - if (request instanceof ContentCachingRequestWrapper) { - return (ContentCachingRequestWrapper) request; + if (request instanceof ContentCachingRequestWrapper requestWrapper) { + return requestWrapper; } else { return new ContentCachingRequestWrapper(request); } } private static ContentCachingResponseWrapper wrapResponse(HttpServletResponse response) { - if (response instanceof ContentCachingResponseWrapper) { - return (ContentCachingResponseWrapper) response; + if (response instanceof ContentCachingResponseWrapper responseWrapper) { + return responseWrapper; } else { return new ContentCachingResponseWrapper(response); } diff --git a/src/main/java/com/posomo/saltit/service/MessageSender.java b/src/main/java/com/posomo/saltit/service/MessageSender.java new file mode 100644 index 0000000..bd0b106 --- /dev/null +++ b/src/main/java/com/posomo/saltit/service/MessageSender.java @@ -0,0 +1,11 @@ +package com.posomo.saltit.service; + +import com.posomo.saltit.domain.exception.SlackMessageException; + +public interface MessageSender { + void sendMessage(String channel, String text) throws SlackMessageException; + + void sendMessage(String text) throws SlackMessageException; + + void setToken(String token); +} diff --git a/src/main/java/com/posomo/saltit/service/SlackMessageSender.java b/src/main/java/com/posomo/saltit/service/SlackMessageSender.java new file mode 100644 index 0000000..49c7c9a --- /dev/null +++ b/src/main/java/com/posomo/saltit/service/SlackMessageSender.java @@ -0,0 +1,45 @@ +package com.posomo.saltit.service; + +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import com.posomo.saltit.domain.exception.SlackMessageException; +import com.slack.api.Slack; +import com.slack.api.methods.SlackApiException; +import com.slack.api.methods.response.chat.ChatPostMessageResponse; + +@Service +public class SlackMessageSender implements MessageSender { + + @Value("${slack.bot.token}") + private String token; + + @Value("${slack.bot.default-channel}") + private String channel; + + @Override + public void sendMessage(String channel, String text) throws SlackMessageException { + try { + ChatPostMessageResponse response = Slack.getInstance().methods() + .chatPostMessage(req -> req.token(token).channel(channel).text(text)); + if (!response.isOk()) { + String errorCode = response.getError(); + throw new SlackMessageException(errorCode); + } + } catch (SlackApiException | IOException e) { + throw new SlackMessageException(e.getMessage(), e); + } + } + + @Override + public void sendMessage(String text) throws SlackMessageException{ + sendMessage(this.channel, text); + } + + @Override + public void setToken(String token) { + this.token = token; + } +} diff --git a/src/test/java/com/posomo/saltit/controller/RestaurantControllerV1Test.java b/src/test/java/com/posomo/saltit/controller/RestaurantControllerV1Test.java index 3ed4fe2..236d005 100644 --- a/src/test/java/com/posomo/saltit/controller/RestaurantControllerV1Test.java +++ b/src/test/java/com/posomo/saltit/controller/RestaurantControllerV1Test.java @@ -20,6 +20,7 @@ import com.posomo.saltit.domain.exception.NoRecordException; import com.posomo.saltit.domain.restaurant.dto.RestaurantDetailResponse; import com.posomo.saltit.global.constant.ResponseMessage; +import com.posomo.saltit.service.MessageSender; import com.posomo.saltit.service.RestaurantService; @WebMvcTest(RestaurantControllerV1.class) @@ -31,6 +32,9 @@ class RestaurantControllerV1Test { @MockBean RestaurantService restaurantService; + @MockBean + MessageSender messageSender; + @BeforeEach void setRestaurantServiceStub() { List mainMenus = new ArrayList<>(); @@ -45,8 +49,7 @@ void setRestaurantServiceStub() { RestaurantDetailResponse.Classification main = new RestaurantDetailResponse.Classification(3, mainMenus); RestaurantDetailResponse.Classification side = new RestaurantDetailResponse.Classification(2, sideMenus); RestaurantDetailResponse restaurantDetailResponse = new RestaurantDetailResponse(1L, "testUrl", - 5, "test store", 100, "phone", "address", new ArrayList<>(), main, side,"testImageUrl"); - + 5, "test store", 100, "phone", "address", new ArrayList<>(), main, side, "testImageUrl"); when(restaurantService.getRestaurantDetail(1L)).thenReturn(restaurantDetailResponse); when(restaurantService.getRestaurantDetail(2L)).thenThrow(new NoRecordException(String.format("restaurantId = %d record not found", 2L))); diff --git a/src/test/java/com/posomo/saltit/service/SlackMessageSenderTest.java b/src/test/java/com/posomo/saltit/service/SlackMessageSenderTest.java new file mode 100644 index 0000000..4f1a3b2 --- /dev/null +++ b/src/test/java/com/posomo/saltit/service/SlackMessageSenderTest.java @@ -0,0 +1,55 @@ +package com.posomo.saltit.service; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +import com.posomo.saltit.domain.exception.SlackMessageException; + +@SpringBootTest +class SlackMessageSenderTest { + + @Autowired + SlackMessageSender slackMessageSender; + + + @DisplayName("Slack에 메시지 보내기 테스트") + @Nested + class sendMessage { + @DisplayName("성공 케이스") + @Test + void ok() { + Assertions.assertDoesNotThrow(() -> slackMessageSender.sendMessage("testMessage")); + } + + @DisplayName("잘못된 토큰 사용") + @Test + void wrongToken() { + slackMessageSender.setToken("wrong_token"); + Assertions.assertThrows(SlackMessageException.class, () -> slackMessageSender.sendMessage("wrong_token_test")); + try { + slackMessageSender.sendMessage("wrong_token_test"); + } catch (SlackMessageException e) { + assertThat(e.getMessage()).isEqualTo("invalid_auth"); + } + } + + @DisplayName("잘못된 채널로 메시지 전송") + @Test + void wrongChannel() { + Assertions.assertThrows(SlackMessageException.class, () -> slackMessageSender.sendMessage("wrong_channel_test1", "wrong_channel_test")); + try { + slackMessageSender.sendMessage("wrong_channel_test1", "wrong_channel_test"); + } catch (SlackMessageException e) { + System.out.println(e.getMessage()); + assertThat(e.getMessage()).isEqualTo("channel_not_found"); + } + } + } + +}