diff --git a/build.gradle b/build.gradle index 2f171866..507516ff 100644 --- a/build.gradle +++ b/build.gradle @@ -105,6 +105,9 @@ dependencies { // webflux implementation 'org.springframework.boot:spring-boot-starter-webflux' + // Slack Webhook + implementation "net.gpedro.integrations.slack:slack-webhook:1.4.0" + // Swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' } diff --git a/infra/mysql/initdb.d/item-data.sql b/infra/mysql/initdb.d/item-data.sql index 7243750c..568e8f0f 100644 --- a/infra/mysql/initdb.d/item-data.sql +++ b/infra/mysql/initdb.d/item-data.sql @@ -38,11 +38,11 @@ insert into item (type, category, name, awake_image, sleep_image, bug_price, gol values ('NIGHT', 'SKIN', '산타 부엉이', 'https://image.moabam.com/moabam/skins/owl/santa/eyes-opened.png', 'https://image.moabam.com/moabam/skins/owl/santa/eyes-closed.png', 30, 15, 15, current_time()); -insert into product (id, type, name, price, quantity, created_at, updated_at) -values (null, 'BUG', '황금벌레x5', 3000, 5, current_time(), null); +insert into product (type, name, price, quantity, created_at) +values ('BUG', '황금벌레 5', 3000, 5, current_time()); -insert into product (id, type, name, price, quantity, created_at, updated_at) -values (null, 'BUG', '황금벌레x10', 7000, 10, current_time(), null); +insert into product (type, name, price, quantity, created_at) +values ('BUG', '황금벌레 15', 7000, 15, current_time()); -insert into product (id, type, name, price, quantity, created_at, updated_at) -values (null, 'BUG', '황금벌레x25', 9900, 25, current_time(), null); +insert into product (type, name, price, quantity, created_at) +values ('BUG', '황금벌레 25', 9900, 25, current_time()); diff --git a/src/main/java/com/moabam/api/infrastructure/slack/SlackMessageFactory.java b/src/main/java/com/moabam/api/infrastructure/slack/SlackMessageFactory.java new file mode 100644 index 00000000..82e94548 --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/slack/SlackMessageFactory.java @@ -0,0 +1,74 @@ +package com.moabam.api.infrastructure.slack; + +import static java.util.stream.Collectors.*; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.time.LocalDateTime; +import java.util.Arrays; +import java.util.List; + +import org.springframework.stereotype.Component; + +import net.gpedro.integrations.slack.SlackAttachment; +import net.gpedro.integrations.slack.SlackField; +import net.gpedro.integrations.slack.SlackMessage; + +import com.moabam.global.common.util.DateUtils; + +import jakarta.servlet.http.HttpServletRequest; + +@Component +public class SlackMessageFactory { + + private static final String ERROR_TITLE = "에러가 발생했습니다 🚨"; + + public SlackMessage generateErrorMessage(HttpServletRequest request, Exception exception) throws IOException { + return new SlackMessage() + .setAttachments(generateAttachments(request, exception)) + .setText(ERROR_TITLE); + } + + private List generateAttachments(HttpServletRequest request, Exception exception) throws + IOException { + return List.of(new SlackAttachment() + .setFallback("Error") + .setColor("danger") + .setTitleLink(request.getContextPath()) + .setText(formatException(exception)) + .setColor("danger") + .setFields(generateFields(request))); + } + + private String formatException(Exception exception) { + return String.format("📍 Exception Class%n%s%n📍 Exception Message%n%s%n%s", + exception.getClass().getName(), + exception.getMessage(), + Arrays.toString(exception.getStackTrace())); + } + + private List generateFields(HttpServletRequest request) throws IOException { + return List.of( + new SlackField().setTitle("✅ Request Method").setValue(request.getMethod()), + new SlackField().setTitle("✅ Request URL").setValue(request.getRequestURL().toString()), + new SlackField().setTitle("✅ Request Time").setValue(DateUtils.format(LocalDateTime.now())), + new SlackField().setTitle("✅ Request IP").setValue(request.getRemoteAddr()), + new SlackField().setTitle("✅ Request Headers").setValue(request.toString()), + new SlackField().setTitle("✅ Request Body").setValue(getRequestBody(request)) + ); + } + + private String getRequestBody(HttpServletRequest request) throws IOException { + String body; + + try ( + InputStream inputStream = request.getInputStream(); + BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream)) + ) { + body = bufferedReader.lines().collect(joining(System.lineSeparator())); + } + return body; + } +} diff --git a/src/main/java/com/moabam/api/infrastructure/slack/SlackService.java b/src/main/java/com/moabam/api/infrastructure/slack/SlackService.java new file mode 100644 index 00000000..a5295d1a --- /dev/null +++ b/src/main/java/com/moabam/api/infrastructure/slack/SlackService.java @@ -0,0 +1,26 @@ +package com.moabam.api.infrastructure.slack; + +import java.io.IOException; + +import org.springframework.core.task.TaskExecutor; +import org.springframework.stereotype.Service; + +import net.gpedro.integrations.slack.SlackApi; +import net.gpedro.integrations.slack.SlackMessage; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class SlackService { + + private final SlackApi slackApi; + private final SlackMessageFactory slackMessageFactory; + private final TaskExecutor taskExecutor; + + public void send(HttpServletRequest request, Exception exception) throws IOException { + SlackMessage slackMessage = slackMessageFactory.generateErrorMessage(request, exception); + taskExecutor.execute(() -> slackApi.call(slackMessage)); + } +} diff --git a/src/main/java/com/moabam/global/common/util/DateUtils.java b/src/main/java/com/moabam/global/common/util/DateUtils.java index 38fa97b4..33e896fa 100644 --- a/src/main/java/com/moabam/global/common/util/DateUtils.java +++ b/src/main/java/com/moabam/global/common/util/DateUtils.java @@ -9,7 +9,7 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public final class DateUtils { - private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"); + private static final DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); public static String format(LocalDateTime dateTime) { return dateTime.format(formatter); diff --git a/src/main/java/com/moabam/global/common/util/GlobalConstant.java b/src/main/java/com/moabam/global/common/util/GlobalConstant.java index 259c3d13..7b62d447 100644 --- a/src/main/java/com/moabam/global/common/util/GlobalConstant.java +++ b/src/main/java/com/moabam/global/common/util/GlobalConstant.java @@ -7,8 +7,6 @@ public class GlobalConstant { public static final String BLANK = ""; - public static final String COMMA = ","; - public static final String UNDER_BAR = "_"; public static final String DELIMITER = "/"; public static final String CHARSET_UTF_8 = ";charset=UTF-8"; public static final String SPACE = " "; @@ -19,6 +17,5 @@ public class GlobalConstant { public static final int ROOM_FIXED_SEARCH_SIZE = 10; public static final int LEVEL_DIVISOR = 10; - public static final int DEFAULT_SKIN_SIZE = 2; public static final String IMAGE_EXTENSION = ".png"; } diff --git a/src/main/java/com/moabam/global/config/SlackConfig.java b/src/main/java/com/moabam/global/config/SlackConfig.java new file mode 100644 index 00000000..20ec8805 --- /dev/null +++ b/src/main/java/com/moabam/global/config/SlackConfig.java @@ -0,0 +1,19 @@ +package com.moabam.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import net.gpedro.integrations.slack.SlackApi; + +@Configuration +public class SlackConfig { + + @Value("${webhook.slack.url}") + private String webhookUrl; + + @Bean + public SlackApi slackApi() { + return new SlackApi(webhookUrl); + } +} diff --git a/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java index 4d68a475..1b2c6540 100644 --- a/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/moabam/global/error/handler/GlobalExceptionHandler.java @@ -1,8 +1,6 @@ package com.moabam.global.error.handler; -import static com.moabam.global.error.model.ErrorMessage.INVALID_REQUEST_FIELD; -import static com.moabam.global.error.model.ErrorMessage.INVALID_REQUEST_VALUE_TYPE_FORMAT; -import static com.moabam.global.error.model.ErrorMessage.S3_INVALID_IMAGE_SIZE; +import static com.moabam.global.error.model.ErrorMessage.*; import java.util.HashMap; import java.util.List; @@ -62,7 +60,10 @@ protected ErrorResponse handleBadRequestException(MoabamException moabamExceptio } @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) - @ExceptionHandler({FcmException.class, TossPaymentException.class}) + @ExceptionHandler({ + FcmException.class, + TossPaymentException.class + }) protected ErrorResponse handleFcmException(MoabamException moabamException) { return new ErrorResponse(moabamException.getMessage(), null); } diff --git a/src/main/java/com/moabam/global/error/handler/SlackExceptionHandler.java b/src/main/java/com/moabam/global/error/handler/SlackExceptionHandler.java new file mode 100644 index 00000000..3751dc5f --- /dev/null +++ b/src/main/java/com/moabam/global/error/handler/SlackExceptionHandler.java @@ -0,0 +1,24 @@ +package com.moabam.global.error.handler; + +import org.springframework.context.annotation.Profile; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.moabam.api.infrastructure.slack.SlackService; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; + +@RestControllerAdvice +@Profile({"dev", "prod"}) +@RequiredArgsConstructor +public class SlackExceptionHandler { + + private final SlackService slackService; + + @ExceptionHandler(Exception.class) + void handleException(HttpServletRequest request, Exception exception) throws Exception { + slackService.send(request, exception); + throw exception; + } +} diff --git a/src/main/resources/config b/src/main/resources/config index c9c58dc4..296e5a5e 160000 --- a/src/main/resources/config +++ b/src/main/resources/config @@ -1 +1 @@ -Subproject commit c9c58dc4c9fbcd88ca2ae51e09b883e296bd3db9 +Subproject commit 296e5a5e54dca447e6230e5538bbb96772221052 diff --git a/src/test/java/com/moabam/api/application/ranking/RankingServiceTest.java b/src/test/java/com/moabam/api/application/ranking/RankingServiceTest.java index d246c5df..1dbb1843 100644 --- a/src/test/java/com/moabam/api/application/ranking/RankingServiceTest.java +++ b/src/test/java/com/moabam/api/application/ranking/RankingServiceTest.java @@ -6,6 +6,7 @@ import java.util.List; import java.util.Set; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -38,6 +39,11 @@ public class RankingServiceTest { @Autowired RankingService rankingService; + @BeforeEach + void init() { + redisTemplate.delete("Ranking"); + } + @DisplayName("redis에 추가") @Nested class Add { diff --git a/src/test/java/com/moabam/api/presentation/RankingControllerTest.java b/src/test/java/com/moabam/api/presentation/RankingControllerTest.java index 9e124cb2..f9b866d4 100644 --- a/src/test/java/com/moabam/api/presentation/RankingControllerTest.java +++ b/src/test/java/com/moabam/api/presentation/RankingControllerTest.java @@ -6,6 +6,7 @@ import java.util.ArrayList; import java.util.List; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -40,6 +41,11 @@ class RankingControllerTest extends WithoutFilterSupporter { @Autowired RedisTemplate redisTemplate; + @BeforeEach + void init() { + redisTemplate.delete("Ranking"); + } + @DisplayName("") @WithMember @Test diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 8090fb64..976ad494 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -77,3 +77,8 @@ payment: toss: base-url: "https://api.tosspayments.com" secret-key: "test_sk_4yKeq5bgrpWk4XYdDoBxVGX0lzW6:" + +# Webhook +webhook: + slack: + url: test