diff --git a/linkmind/build.gradle b/linkmind/build.gradle index 7f83b4e3..3b1007c4 100644 --- a/linkmind/build.gradle +++ b/linkmind/build.gradle @@ -8,6 +8,7 @@ plugins { id 'java' id 'org.springframework.boot' version '3.2.0' id 'io.spring.dependency-management' version '1.1.4' + id "io.sentry.jvm.gradle" version "4.1.1" } group = 'com.app' @@ -64,8 +65,25 @@ dependencies { // JSoup implementation 'org.jsoup:jsoup:1.15.3' + // slack + implementation 'com.slack.api:slack-api-client:1.28.0' + implementation 'com.google.code.gson:gson:2.10.1' + implementation 'com.slack.api:slack-app-backend:1.28.0' + implementation 'com.slack.api:slack-api-model:1.28.0' + } tasks.named('test') { useJUnitPlatform() } + +sentry { + // Generates a JVM (Java, Kotlin, etc.) source bundle and uploads your source code to Sentry. + // This enables source context, allowing you to see your source + // code as part of your stack traces in Sentry. + includeSourceContext = true + + org = "linkmind" + projectName = "java-spring-boot" + authToken = System.getenv("SENTRY_AUTH_TOKEN") +} diff --git a/linkmind/src/main/java/com/app/toaster/common/advice/ControllerExceptionAdvice.java b/linkmind/src/main/java/com/app/toaster/common/advice/ControllerExceptionAdvice.java index 2d251117..bb7024d1 100644 --- a/linkmind/src/main/java/com/app/toaster/common/advice/ControllerExceptionAdvice.java +++ b/linkmind/src/main/java/com/app/toaster/common/advice/ControllerExceptionAdvice.java @@ -1,5 +1,6 @@ package com.app.toaster.common.advice; +import java.io.IOException; import java.net.MalformedURLException; import org.springframework.http.HttpStatus; @@ -15,19 +16,26 @@ import com.app.toaster.common.dto.ApiResponse; import com.app.toaster.exception.Error; import com.app.toaster.exception.model.CustomException; +import com.app.toaster.external.client.slack.SlackApi; +import io.sentry.Sentry; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.ConstraintDefinitionException; import lombok.NoArgsConstructor; +import lombok.RequiredArgsConstructor; @RestControllerAdvice @Component -@NoArgsConstructor +@RequiredArgsConstructor public class ControllerExceptionAdvice { + private final SlackApi slackApi; + /** * custom error */ @ExceptionHandler(CustomException.class) protected ResponseEntity handleCustomException(CustomException e) { + Sentry.captureException(e); return ResponseEntity.status(e.getHttpStatus()) .body(ApiResponse.error(e.getError(), e.getMessage())); } @@ -40,12 +48,26 @@ protected ResponseEntity handleCustomException(CustomException e) { @ExceptionHandler(MethodArgumentNotValidException.class) protected ResponseEntity handleConstraintDefinitionException(final MethodArgumentNotValidException e) { FieldError fieldError = e.getBindingResult().getFieldError(); + Sentry.captureException(e); return ResponseEntity.status(e.getStatusCode()) .body(ApiResponse.error(Error.BAD_REQUEST_VALIDATION, fieldError.getDefaultMessage())); } @ExceptionHandler(MalformedURLException.class) protected ApiResponse handleConstraintDefinitionException(final MalformedURLException e) { + Sentry.captureException(e); return ApiResponse.error(Error.MALFORMED_URL_EXEPTION, Error.MALFORMED_URL_EXEPTION.getMessage()); } + /** + * 500 Internal Server Error + */ + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(Exception.class) + protected ApiResponse handleException(final Exception error, final HttpServletRequest request) throws + IOException { + slackApi.sendAlert(error, request); + Sentry.captureException(error); + return ApiResponse.error(Error.INTERNAL_SERVER_ERROR); + } + } diff --git a/linkmind/src/main/java/com/app/toaster/controller/AuthController.java b/linkmind/src/main/java/com/app/toaster/controller/AuthController.java index 83fddb6f..b2545dfa 100644 --- a/linkmind/src/main/java/com/app/toaster/controller/AuthController.java +++ b/linkmind/src/main/java/com/app/toaster/controller/AuthController.java @@ -1,5 +1,7 @@ package com.app.toaster.controller; +import java.io.IOException; + import org.springframework.http.HttpStatus; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -31,7 +33,7 @@ public class AuthController { public ApiResponse signIn( @RequestHeader("Authorization") String socialAccessToken, @RequestBody SignInRequestDto requestDto - ) { + ) throws IOException { return ApiResponse.success(Success.LOGIN_SUCCESS, authService.signIn(socialAccessToken, requestDto)); } diff --git a/linkmind/src/main/java/com/app/toaster/external/client/slack/SlackApi.java b/linkmind/src/main/java/com/app/toaster/external/client/slack/SlackApi.java new file mode 100644 index 00000000..6c352db8 --- /dev/null +++ b/linkmind/src/main/java/com/app/toaster/external/client/slack/SlackApi.java @@ -0,0 +1,130 @@ +package com.app.toaster.external.client.slack; + +import static com.slack.api.model.block.composition.BlockCompositions.*; + +import java.io.IOException; +import java.util.Date; +import java.util.List; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import com.app.toaster.exception.Success; +import com.app.toaster.infrastructure.UserRepository; +import com.slack.api.Slack; +import com.slack.api.model.block.Blocks; +import com.slack.api.model.block.LayoutBlock; +import com.slack.api.model.block.composition.BlockCompositions; +import com.slack.api.webhook.WebhookPayloads; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class SlackApi { + + @Value("${slack.webhook.error}") + private String errorUrl; + @Value("${slack.webhook.success}") + private String successUrl; + private final static String NEW_LINE = "\n"; + private final static String DOUBLE_NEW_LINE = "\n\n"; + + private final UserRepository userRepository; + + private StringBuilder sb = new StringBuilder(); + + public void sendAlert(Exception error, HttpServletRequest request) throws IOException { + + List layoutBlocks = generateLayoutBlock(error, request); + + Slack.getInstance().send(errorUrl, WebhookPayloads + .payload(p -> + p.username("Exception is detected 🚨") + .iconUrl("https://yt3.googleusercontent.com/ytc/AGIKgqMVUzRrhoo1gDQcqvPo0PxaJz7e0gqDXT0D78R5VQ=s900-c-k-c0x00ffffff-no-rj") + .blocks(layoutBlocks))); + } + + private List generateLayoutBlock(Exception error, HttpServletRequest request) { + return Blocks.asBlocks( + getHeader("μ„œλ²„ μΈ‘ 였λ₯˜λ‘œ μ˜ˆμƒλ˜λŠ” μ˜ˆμ™Έ 상황이 λ°œμƒν•˜μ˜€μŠ΅λ‹ˆλ‹€."), + Blocks.divider(), + getSection(generateErrorMessage(error)), + Blocks.divider(), + getSection(generateErrorPointMessage(request)), + Blocks.divider(), + getSection("") + ); + } + + private List generateSuccessBlock(Success success) { + return Blocks.asBlocks( + getHeader("πŸ˜νšŒμ›κ°€μž… μ΄λ²€νŠΈκ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€."), + Blocks.divider(), + getSection(generateSuccessMessage(success)), + Blocks.divider(), + getSection(generateSignUpMessage()), + Blocks.divider() + ); + } + + private String generateErrorMessage(Exception error) { + sb.setLength(0); + sb.append("*[πŸ”₯ Exception]*" + NEW_LINE + error.toString() + DOUBLE_NEW_LINE); + sb.append("*[πŸ“© From]*" + NEW_LINE + readRootStackTrace(error) + DOUBLE_NEW_LINE); + + return sb.toString(); + } + + private String generateSuccessMessage(Success success) { + sb.setLength(0); + sb.append("*[πŸ”₯ μΆ•ν•˜ν•©λ‹ˆλ‹€!]*" + NEW_LINE + "ν† μŠ€νŠΈ κ΅½λŠ” μ†Œλ¦¬κ°€ λ“€λ €μš”~!" + DOUBLE_NEW_LINE); + + return sb.toString(); + } + + private String generateErrorPointMessage(HttpServletRequest request) { + sb.setLength(0); + sb.append("*[πŸ§Ύμ„ΈλΆ€μ •λ³΄]*" + NEW_LINE); + sb.append("Request URL : " + request.getRequestURL().toString() + NEW_LINE); + sb.append("Request Method : " + request.getMethod() + NEW_LINE); + sb.append("Request Time : " + new Date() + NEW_LINE); + + return sb.toString(); + } + private String generateSignUpMessage() { + sb.setLength(0); + sb.append("*[πŸ§Ύμœ μ € κ°€μž… 정보]*" + NEW_LINE); + sb.append("ν† μŠ€ν„°μ˜ " + userRepository.count() + "번째 μœ μ €κ°€ μƒμ„±λ˜μ—ˆμŠ΅λ‹ˆλ‹€!!❀️"); + return sb.toString(); + } + + private String readRootStackTrace(Exception error) { + return error.getStackTrace()[0].toString(); + } + + private LayoutBlock getHeader(String text) { + return Blocks.header(h -> h.text( + plainText(pt -> pt.emoji(true) + .text(text)))); + } + + private LayoutBlock getSection(String message) { + return Blocks.section(s -> + s.text(BlockCompositions.markdownText(message))); + } + + public void sendSuccess(Success success) throws IOException { + + List layoutBlocks = generateSuccessBlock(success); + + Slack.getInstance().send(successUrl, WebhookPayloads + .payload(p -> + p.username("Exception is detected 🚨") + .iconUrl("https://yt3.googleusercontent.com/ytc/AGIKgqMVUzRrhoo1gDQcqvPo0PxaJz7e0gqDXT0D78R5VQ=s900-c-k-c0x00ffffff-no-rj") + .blocks(layoutBlocks))); + } +} diff --git a/linkmind/src/main/java/com/app/toaster/service/auth/AuthService.java b/linkmind/src/main/java/com/app/toaster/service/auth/AuthService.java index 76deeca3..45cf9b52 100644 --- a/linkmind/src/main/java/com/app/toaster/service/auth/AuthService.java +++ b/linkmind/src/main/java/com/app/toaster/service/auth/AuthService.java @@ -1,5 +1,7 @@ package com.app.toaster.service.auth; +import java.io.IOException; + import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -11,9 +13,11 @@ import com.app.toaster.domain.SocialType; import com.app.toaster.domain.User; import com.app.toaster.exception.Error; +import com.app.toaster.exception.Success; import com.app.toaster.exception.model.BadRequestException; import com.app.toaster.exception.model.NotFoundException; import com.app.toaster.exception.model.UnprocessableEntityException; +import com.app.toaster.external.client.slack.SlackApi; import com.app.toaster.infrastructure.UserRepository; import com.app.toaster.service.auth.apple.AppleSignInService; import com.app.toaster.service.auth.kakao.KakaoSignInService; @@ -30,6 +34,8 @@ public class AuthService { private final UserRepository userRepository; + private final SlackApi slackApi; + private final Long TOKEN_EXPIRATION_TIME_ACCESS = 24 * 60 * 60 * 1000L; //1일 private final Long TOKEN_EXPIRATION_TIME_REFRESH = 3 * 24 * 60 * 60 * 1000L; //3일 @@ -41,12 +47,11 @@ public class AuthService { @Transactional - public SignInResponseDto signIn(String socialAccessToken, SignInRequestDto requestDto) { + public SignInResponseDto signIn(String socialAccessToken, SignInRequestDto requestDto) throws IOException { SocialType socialType = SocialType.valueOf(requestDto.socialType()); LoginResult loginResult = login(socialType, socialAccessToken); String socialId = loginResult.id(); String profileImage = loginResult.profile(); - System.out.println(profileImage); Boolean isRegistered = userRepository.existsBySocialIdAndSocialType(socialId, socialType); if (!isRegistered) { @@ -56,6 +61,7 @@ public SignInResponseDto signIn(String socialAccessToken, SignInRequestDto reque .socialType(socialType).build(); newUser.updateFcmIsAllowed(true); //μ‹ κ·œ μœ μ €λ©΄ trueλ°•κ³  userRepository.save(newUser); + slackApi.sendSuccess(Success.LOGIN_SUCCESS); } User user = userRepository.findBySocialIdAndSocialType(socialId, socialType) diff --git a/linkmind/src/main/java/com/app/toaster/service/auth/kakao/KakaoSignInService.java b/linkmind/src/main/java/com/app/toaster/service/auth/kakao/KakaoSignInService.java index d0b418ba..01fbd6e3 100644 --- a/linkmind/src/main/java/com/app/toaster/service/auth/kakao/KakaoSignInService.java +++ b/linkmind/src/main/java/com/app/toaster/service/auth/kakao/KakaoSignInService.java @@ -30,7 +30,6 @@ public LoginResult getKaKaoId(String accessToken) { responseData = restTemplate.postForEntity(KAKAO_URL,httpEntity,Object.class); ObjectMapper objectMapper = new ObjectMapper(); HashMap profileResponse = (HashMap)objectMapper.convertValue( responseData.getBody(),Map.class).get("properties"); - System.out.println(profileResponse.get("profile_image").toString()); - return LoginResult.of(objectMapper.convertValue(responseData.getBody(), Map.class).get("id").toString(), profileResponse.get("profile_image").toString()); //μ†Œμ…œ id만 κ°€μ Έμ˜€λŠ”λ“―. + return LoginResult.of(objectMapper.convertValue(responseData.getBody(), Map.class).get("id").toString(), profileResponse==null?null:profileResponse.get("profile_image").toString()); //ν”„λ‘œν•„ 이미지 ν—ˆμš© xμ‹œ null둜 λ„˜κΈ°κΈ°. } }