diff --git a/build.gradle b/build.gradle index 4f43d1200..451c26b64 100644 --- a/build.gradle +++ b/build.gradle @@ -92,6 +92,12 @@ dependencies { // Firebase implementation 'com.google.firebase:firebase-admin:9.3.0' + + // Webflux + implementation 'org.springframework.boot:spring-boot-starter-webflux' + + // LogStash + implementation 'net.logstash.logback:logstash-logback-encoder:7.4' } def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile diff --git a/src/main/java/konkuk/thip/TestTokenController.java b/src/main/java/konkuk/thip/TestController.java similarity index 80% rename from src/main/java/konkuk/thip/TestTokenController.java rename to src/main/java/konkuk/thip/TestController.java index 3d4399b23..b91df45ec 100644 --- a/src/main/java/konkuk/thip/TestTokenController.java +++ b/src/main/java/konkuk/thip/TestController.java @@ -10,7 +10,7 @@ @RestController @RequiredArgsConstructor @ConditionalOnProperty(name = "thip.test-api.enabled", havingValue = "true") -public class TestTokenController { +public class TestController { private final JwtUtil jwtUtil; @@ -18,4 +18,9 @@ public class TestTokenController { public String generateAccessToken(@RequestParam Long userId) { return jwtUtil.createAccessToken(userId); } + + @GetMapping("/api/test/error") + public String throwError() { + throw new RuntimeException("테스트 500 에러"); + } } diff --git a/src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiUtil.java b/src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiUtil.java index 7a63ac018..45d936d64 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiUtil.java +++ b/src/main/java/konkuk/thip/book/adapter/out/api/aladin/AladinApiUtil.java @@ -2,7 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.ExternalApiException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -54,14 +54,15 @@ public Integer getPageCount(String isbn) { // TODO : 알라딘으로부터 page 정보가 없으면 ?? // 보상 시나리오 : 유저에게 "page 정보를 찾을 수 없는 책입니다. 직접 page 정보를 입력하세요" 라고 안내 // 일단 지금은 exception throw 만 진행 - throw new BusinessException(BOOK_ALADIN_API_ISBN_NOT_FOUND); + throw new ExternalApiException(BOOK_ALADIN_API_ISBN_NOT_FOUND); } JsonNode subInfo = items.get(0).path(SUB_INFO_PARSING_KEY.getValue()); return subInfo.path(PAGE_COUNT_PARSING_KEY.getValue()).asInt(); } catch (IOException e) { - throw new BusinessException(BOOK_ALADIN_API_PARSING_ERROR); + throw new ExternalApiException(BOOK_ALADIN_API_PARSING_ERROR); } } } + diff --git a/src/main/java/konkuk/thip/book/adapter/out/api/naver/NaverApiUtil.java b/src/main/java/konkuk/thip/book/adapter/out/api/naver/NaverApiUtil.java index b89786c48..685aa0568 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/api/naver/NaverApiUtil.java +++ b/src/main/java/konkuk/thip/book/adapter/out/api/naver/NaverApiUtil.java @@ -1,6 +1,7 @@ package konkuk.thip.book.adapter.out.api.naver; -import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.ExternalApiException; +import konkuk.thip.common.exception.InternalServerException; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -63,7 +64,7 @@ private String keywordToEncoding(String keyword) { try { text = URLEncoder.encode(keyword, "UTF-8"); } catch (UnsupportedEncodingException e) { - throw new BusinessException(BOOK_KEYWORD_ENCODING_FAILED); + throw new InternalServerException(BOOK_KEYWORD_ENCODING_FAILED); } return text; } @@ -84,7 +85,7 @@ String get(String apiUrl, Map requestHeaders){ return readBody(con.getErrorStream()); } } catch (IOException e) { - throw new BusinessException(BOOK_NAVER_API_REQUEST_ERROR); + throw new ExternalApiException(BOOK_NAVER_API_REQUEST_ERROR); } finally { con.disconnect(); } @@ -96,9 +97,9 @@ private HttpURLConnection connect(String apiUrl){ URL url = new URL(apiUrl); return (HttpURLConnection)url.openConnection(); } catch (MalformedURLException e) { - throw new BusinessException(BOOK_NAVER_API_URL_ERROR); + throw new InternalServerException(BOOK_NAVER_API_URL_ERROR); } catch (IOException e) { - throw new BusinessException(BOOK_NAVER_API_URL_HTTP_CONNECT_FAILED); + throw new InternalServerException(BOOK_NAVER_API_URL_HTTP_CONNECT_FAILED); } } @@ -116,7 +117,7 @@ private String readBody(InputStream body){ return responseBody.toString(); } catch (IOException e) { - throw new BusinessException(BOOK_NAVER_API_RESPONSE_ERROR); + throw new ExternalApiException(BOOK_NAVER_API_RESPONSE_ERROR); } } diff --git a/src/main/java/konkuk/thip/book/adapter/out/persistence/BookRedisAdapter.java b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookRedisAdapter.java index 66f762426..5185f8b07 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/persistence/BookRedisAdapter.java +++ b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookRedisAdapter.java @@ -6,7 +6,7 @@ import konkuk.thip.book.application.port.in.dto.BookMostSearchResult; import konkuk.thip.book.application.port.out.BookRedisCommandPort; import konkuk.thip.book.application.port.out.BookRedisQueryPort; -import konkuk.thip.common.exception.ExternalApiException; +import konkuk.thip.common.exception.InternalServerException; import konkuk.thip.common.exception.code.ErrorCode; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -80,7 +80,7 @@ public List getYesterdayBookRankInfos(LocalDa new TypeReference>() {} ); } catch (JsonProcessingException e) { - throw new ExternalApiException(ErrorCode.JSON_PROCESSING_ERROR); + throw new InternalServerException(ErrorCode.JSON_PROCESSING_ERROR); } } @@ -106,7 +106,7 @@ public void saveBookSearchRankDetail(List boo try { detailJson = objectMapper.writeValueAsString(bookRankDetails); } catch (JsonProcessingException e) { - throw new ExternalApiException(JSON_PROCESSING_ERROR); + throw new InternalServerException(JSON_PROCESSING_ERROR); } redisTemplate.opsForValue().set(redisKey, detailJson); } diff --git a/src/main/java/konkuk/thip/common/aop/StatusFilterAspect.java b/src/main/java/konkuk/thip/common/aop/StatusFilterAspect.java index 916165723..0c00fab6e 100644 --- a/src/main/java/konkuk/thip/common/aop/StatusFilterAspect.java +++ b/src/main/java/konkuk/thip/common/aop/StatusFilterAspect.java @@ -2,7 +2,7 @@ import jakarta.persistence.EntityManager; import konkuk.thip.common.entity.StatusType; -import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.exception.InternalServerException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; @@ -30,7 +30,7 @@ public class StatusFilterAspect { */ private Session currentTxSession() { if (!TransactionSynchronizationManager.isActualTransactionActive()) { - throw new InvalidStateException(PERSISTENCE_TRANSACTION_REQUIRED); + throw new InternalServerException(PERSISTENCE_TRANSACTION_REQUIRED); } return session(); } diff --git a/src/main/java/konkuk/thip/common/discord/DiscordClient.java b/src/main/java/konkuk/thip/common/discord/DiscordClient.java new file mode 100644 index 000000000..302037859 --- /dev/null +++ b/src/main/java/konkuk/thip/common/discord/DiscordClient.java @@ -0,0 +1,64 @@ +package konkuk.thip.common.discord; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Component +public class DiscordClient { + + @Value("${discord.env}") + private String env; + + @Value("${discord.webhook-url}") + private String webhookUrl; + + public void sendErrorMessage(String message, String stackTrace, String requestId, String userId) { + if("test".equals(env)) return; + + WebClient webClient = WebClient.create(); + + Map embedData = new HashMap<>(); + embedData.put("title", "THIP 서버 500 에러 발생"); + + Map field1 = new HashMap<>(); + field1.put("name", "발생시각"); + field1.put("value", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + + Map field2 = new HashMap<>(); + field2.put("name", "에러 명"); + field2.put("value", message); + + Map field3 = new HashMap<>(); + field3.put("name", "스택 트레이스"); + field3.put("value", stackTrace); + + Map field4 = new HashMap<>(); + field4.put("name", "Request ID"); + field4.put("value", requestId != null ? requestId : "N/A"); + + Map field5 = new HashMap<>(); + field5.put("name", "User ID"); + field5.put("value", userId != null ? userId : "N/A"); + + embedData.put("fields", List.of(field1, field2, field3, field4, field5)); + + Map payload = new HashMap<>(); + payload.put("embeds", new Object[]{embedData}); + + webClient.post() + .uri(webhookUrl) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(payload) + .retrieve() + .bodyToMono(Void.class) + .block(); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/common/dto/BaseResponse.java b/src/main/java/konkuk/thip/common/dto/BaseResponse.java index 1d4718d61..ba20eff2d 100644 --- a/src/main/java/konkuk/thip/common/dto/BaseResponse.java +++ b/src/main/java/konkuk/thip/common/dto/BaseResponse.java @@ -3,9 +3,14 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import lombok.Getter; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import static konkuk.thip.common.logging.LoggingConstant.REQUEST_ID; + +@Slf4j @Getter -@JsonPropertyOrder({"success", "code", "message", "data"}) +@JsonPropertyOrder({"success", "code", "message", "requestId", "data"}) public class BaseResponse { @JsonProperty("isSuccess") @@ -15,17 +20,20 @@ public class BaseResponse { private final String message; + private final String requestId; + private final T data; - private BaseResponse(boolean success, int code, String message, T data) { + private BaseResponse(boolean success, int code, String message, String requestId, T data) { this.success = success; this.code = code; this.message = message; + this.requestId = requestId; this.data = data; } private BaseResponse(ResponseCode response, T data) { - this(response.isSuccess(), response.getCode(), response.getMessage(), data); + this(response.isSuccess(), response.getCode(), response.getMessage(), MDC.get(REQUEST_ID.getValue()), data); } public static BaseResponse ok(T data) { diff --git a/src/main/java/konkuk/thip/common/dto/ErrorResponse.java b/src/main/java/konkuk/thip/common/dto/ErrorResponse.java index 6b983a124..c0b1b17e2 100644 --- a/src/main/java/konkuk/thip/common/dto/ErrorResponse.java +++ b/src/main/java/konkuk/thip/common/dto/ErrorResponse.java @@ -3,9 +3,12 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import lombok.Getter; +import org.slf4j.MDC; + +import static konkuk.thip.common.logging.LoggingConstant.REQUEST_ID; @Getter -@JsonPropertyOrder({"success", "code", "message"}) +@JsonPropertyOrder({"success", "code", "message", "requestId"}) public class ErrorResponse { @JsonProperty("isSuccess") @@ -15,14 +18,17 @@ public class ErrorResponse { private final String message; - private ErrorResponse(boolean success, int code, String message) { + private final String requestId; + + private ErrorResponse(boolean success, int code, String message, String requestId) { this.success = success; this.code = code; this.message = message; + this.requestId = requestId; } private ErrorResponse(ResponseCode response) { - this(response.isSuccess(), response.getCode(), response.getMessage()); + this(response.isSuccess(), response.getCode(), response.getMessage(), MDC.get(REQUEST_ID.getValue())); } public static ErrorResponse of(ResponseCode response) { @@ -32,6 +38,6 @@ public static ErrorResponse of(ResponseCode response) { public static ErrorResponse of(ResponseCode response, String message) { StringBuilder sb = new StringBuilder(); sb.append(response.getMessage()).append(" ").append(message); - return new ErrorResponse(response.isSuccess(), response.getCode(), sb.toString()); + return new ErrorResponse(response.isSuccess(), response.getCode(), sb.toString(), MDC.get(REQUEST_ID.getValue())); } } diff --git a/src/main/java/konkuk/thip/common/exception/FirebaseException.java b/src/main/java/konkuk/thip/common/exception/FirebaseException.java index a4d97a046..027083741 100644 --- a/src/main/java/konkuk/thip/common/exception/FirebaseException.java +++ b/src/main/java/konkuk/thip/common/exception/FirebaseException.java @@ -2,20 +2,16 @@ import konkuk.thip.common.exception.code.ErrorCode; -public class FirebaseException extends BusinessException { - public FirebaseException(ErrorCode errorCode) { - super(errorCode); - } +public class FirebaseException extends RuntimeException { - public FirebaseException(ErrorCode errorCode, Exception e) { - super(errorCode, e); - } + private final ErrorCode errorCode; - public FirebaseException(Exception e) { - super(ErrorCode.FIREBASE_SEND_ERROR, e); + public FirebaseException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; } - - public FirebaseException() { - super(ErrorCode.FIREBASE_SEND_ERROR); + public FirebaseException(ErrorCode errorCode, Exception e) { + super(errorCode.getMessage(), e); + this.errorCode = errorCode; } } diff --git a/src/main/java/konkuk/thip/common/exception/InternalServerException.java b/src/main/java/konkuk/thip/common/exception/InternalServerException.java new file mode 100644 index 000000000..35062bbb6 --- /dev/null +++ b/src/main/java/konkuk/thip/common/exception/InternalServerException.java @@ -0,0 +1,17 @@ +package konkuk.thip.common.exception; + +import konkuk.thip.common.exception.code.ErrorCode; + +public class InternalServerException extends RuntimeException { + + private final ErrorCode errorCode; + + public InternalServerException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + public InternalServerException(ErrorCode errorCode, Exception e) { + super(errorCode.getMessage(), e); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index 0d7f37207..3bd6ee7b7 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -78,11 +78,10 @@ public enum ErrorCode implements ResponseCode { BOOK_NAVER_API_ISBN_NOT_FOUND(HttpStatus.BAD_REQUEST, 80009, "네이버 API 에서 ISBN으로 검색한 결과가 존재하지 않습니다."), BOOK_NOT_FOUND(HttpStatus.NOT_FOUND, 80010, "존재하지 않는 BOOK 입니다."), BOOK_ALREADY_SAVED(HttpStatus.BAD_REQUEST, 80011, "사용자가 이미 저장한 책입니다."), - DUPLICATED_BOOKS_IN_COLLECTION(HttpStatus.INTERNAL_SERVER_ERROR, 80012, "중복된 책이 존재합니다."), BOOK_NOT_SAVED_CANNOT_DELETE(HttpStatus.BAD_REQUEST, 80013, "사용자가 저장하지 않은 책은 저장삭제 할 수 없습니다."), BOOK_NOT_SAVED_DB_CANNOT_DELETE(HttpStatus.BAD_REQUEST, 80014, "DB에 존재하지 않은 책은 저장삭제 할 수 없습니다."), BOOK_ALADIN_API_PARSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 80015, "알라딘 API 응답 파싱에 실패하였습니다."), - BOOK_ALADIN_API_ISBN_NOT_FOUND(HttpStatus.BAD_REQUEST, 80016, "알라딘 API 에서 ISBN으로 검색한 결과가 존재하지 않습니다."), + BOOK_ALADIN_API_ISBN_NOT_FOUND(HttpStatus.INTERNAL_SERVER_ERROR, 80016, "알라딘 API 에서 ISBN으로 검색한 결과가 존재하지 않습니다."), /** * 90000 : recentSearch error @@ -225,6 +224,7 @@ public enum ErrorCode implements ResponseCode { FCM_TOKEN_ENABLED_STATE_ALREADY(HttpStatus.BAD_REQUEST, 200001, "요청한 상태로 이미 푸쉬 알림 여부가 설정되어 있습니다."), FCM_TOKEN_ACCESS_FORBIDDEN(HttpStatus.FORBIDDEN, 200002, "토큰을 소유하고 있는 계정이 아닙니다."), FIREBASE_SEND_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 200003, "FCM 푸쉬 알림 전송에 실패했습니다."), + FCM_TOKEN_DEVICE_ARRAY_MISMATCH(HttpStatus.INTERNAL_SERVER_ERROR, 200004, "메시지, FCM 토큰, 디바이스 ID 리스트의 크기는 같아야 합니다."), /** diff --git a/src/main/java/konkuk/thip/common/exception/handler/GlobalExceptionHandler.java b/src/main/java/konkuk/thip/common/exception/handler/GlobalExceptionHandler.java index 6fdf69a56..2fba5aed8 100644 --- a/src/main/java/konkuk/thip/common/exception/handler/GlobalExceptionHandler.java +++ b/src/main/java/konkuk/thip/common/exception/handler/GlobalExceptionHandler.java @@ -2,11 +2,12 @@ import jakarta.validation.ConstraintViolation; import jakarta.validation.ConstraintViolationException; +import konkuk.thip.common.discord.DiscordClient; import konkuk.thip.common.dto.ErrorResponse; -import konkuk.thip.common.exception.AuthException; -import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; import org.springframework.http.ResponseEntity; import org.springframework.web.HttpRequestMethodNotSupportedException; import org.springframework.web.bind.MethodArgumentNotValidException; @@ -19,12 +20,17 @@ import java.util.Optional; import static konkuk.thip.common.exception.code.ErrorCode.*; +import static konkuk.thip.common.logging.LoggingConstant.REQUEST_ID; +import static konkuk.thip.common.logging.LoggingConstant.USER_ID; +import static org.apache.commons.lang3.exception.ExceptionUtils.getStackTrace; @Slf4j @RestControllerAdvice @RequiredArgsConstructor public class GlobalExceptionHandler { + private final DiscordClient discordClient; + // 요청한 API가 없는 경우 @ExceptionHandler(NoHandlerFoundException.class) public ResponseEntity noHandlerExceptionHandler(NoHandlerFoundException e) { @@ -102,19 +108,36 @@ public ResponseEntity businessExceptionHandler(BusinessException .body(ErrorResponse.of(e.getErrorCode(), detail)); } - // 서버 내부 오류 예외 처리 - @ExceptionHandler(RuntimeException.class) - public ResponseEntity runtimeExceptionHandler(RuntimeException e) { - log.error("[RuntimeExceptionHandler] {}", e.getMessage(), e); // 메시지와 스택트레이스 출력 - return ResponseEntity - .status(API_SERVER_ERROR.getHttpStatus()) - .body(ErrorResponse.of(API_SERVER_ERROR)); - } + // 서버 내부 오류 예외 (500) 처리 + @ExceptionHandler({RuntimeException.class, IllegalStateException.class, + FirebaseException.class, InternalServerException.class, ExternalApiException.class}) + public ResponseEntity handleServerErrors(Exception e) { + log.error("[ServerErrorHandler] {}", e.getMessage(), e); + + String exceptionClassName = e.getClass().getSimpleName(); // 예외 클래스명 + String combinedMessage = "[" + exceptionClassName + "] " + e.getMessage(); // 메시지에 예외 클래스명 포함 + String stackTrace = getStackTrace(e); + + // 스택트레이스 요약: 두,세번째 줄 + 마지막줄 + String[] lines = stackTrace.split("\n"); + String stackSummary; + if (lines.length <= 3) { + stackSummary = stackTrace; // 짧으면 다 보여줌 + } else { + stackSummary = lines[1] + "\n" + lines[2] + "\n" + lines[lines.length - 1]; + } + + // MDC에서 requestId, userId 추출 + String requestId = MDC.get(REQUEST_ID.getValue()); + String userId = MDC.get(USER_ID.getValue()); + + // Discord 웹훅 전송 + try { + discordClient.sendErrorMessage(combinedMessage, stackSummary, requestId, userId); + } catch (Exception sendException) { + log.error("[ServerErrorHandler] -> [Discord] 전송 실패", sendException); + } - // IllegalStateException 예외 처리 - @ExceptionHandler(IllegalStateException.class) - public ResponseEntity illegalStateExceptionHandler(IllegalStateException e) { - log.error("[IllegalStateExceptionHandler] {}", e.getMessage()); return ResponseEntity .status(API_SERVER_ERROR.getHttpStatus()) .body(ErrorResponse.of(API_SERVER_ERROR)); diff --git a/src/main/java/konkuk/thip/common/logging/LoggingConstant.java b/src/main/java/konkuk/thip/common/logging/LoggingConstant.java new file mode 100644 index 000000000..155485e00 --- /dev/null +++ b/src/main/java/konkuk/thip/common/logging/LoggingConstant.java @@ -0,0 +1,16 @@ +package konkuk.thip.common.logging; + +public enum LoggingConstant { + REQUEST_ID("request_id"), + USER_ID("user_id"); + + private final String value; + + LoggingConstant(String value) { + this.value = value; + } + + public String getValue() { + return value; + } +} diff --git a/src/main/java/konkuk/thip/common/logging/MdcLoggingFilter.java b/src/main/java/konkuk/thip/common/logging/MdcLoggingFilter.java new file mode 100644 index 000000000..ef15bdd05 --- /dev/null +++ b/src/main/java/konkuk/thip/common/logging/MdcLoggingFilter.java @@ -0,0 +1,35 @@ +package konkuk.thip.common.logging; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.UUID; + +import static konkuk.thip.common.logging.LoggingConstant.REQUEST_ID; + +@Component +public class MdcLoggingFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + try { + String rawRequestId = request.getHeader("X-Request-ID"); + String requestId = (rawRequestId == null || rawRequestId.trim().isEmpty()) + ? UUID.randomUUID().toString() + : rawRequestId; + + MDC.put(REQUEST_ID.getValue(), requestId); + filterChain.doFilter(request, response); + } finally { + MDC.clear(); + } + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/common/security/filter/JwtAuthenticationFilter.java b/src/main/java/konkuk/thip/common/security/filter/JwtAuthenticationFilter.java index 51070cf47..3c07d2bee 100644 --- a/src/main/java/konkuk/thip/common/security/filter/JwtAuthenticationFilter.java +++ b/src/main/java/konkuk/thip/common/security/filter/JwtAuthenticationFilter.java @@ -13,6 +13,7 @@ import konkuk.thip.user.application.port.UserTokenBlacklistQueryPort; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; import org.springframework.http.server.PathContainer; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -26,6 +27,7 @@ import java.util.List; import static konkuk.thip.common.exception.code.ErrorCode.*; +import static konkuk.thip.common.logging.LoggingConstant.USER_ID; import static konkuk.thip.common.security.constant.AuthParameters.*; @Slf4j @@ -67,24 +69,12 @@ protected void doFilterInternal(HttpServletRequest request, FilterChain filterChain) throws ServletException, IOException { try { String token = extractToken(request); - if (token == null) { - throw new AuthException(AUTH_TOKEN_NOT_FOUND); - } - - if (userTokenBlacklistQueryPort.isTokenBlacklisted(token)) { - throw new AuthException(AUTH_BLACKLIST_TOKEN); - } - - if (!jwtUtil.validateToken(token)) { - throw new AuthException(AUTH_INVALID_TOKEN); - } - if (jwtUtil.isExpired(token)) { - throw new AuthException(AUTH_EXPIRED_TOKEN); - } + validateToken(token); request.setAttribute(JWT_TOKEN_ATTRIBUTE.getValue(), token); LoginUser loginUser = jwtUtil.getLoginUser(token); + MDC.put(USER_ID.getValue(), String.valueOf(loginUser.userId())); if (loginUser.userId() != null) { request.setAttribute(JWT_ACCESS_TOKEN_KEY.getValue(), loginUser.userId()); @@ -105,6 +95,24 @@ protected void doFilterInternal(HttpServletRequest request, } } + private void validateToken(String token) { + if (token == null) { + throw new AuthException(AUTH_TOKEN_NOT_FOUND); + } + + if (userTokenBlacklistQueryPort.isTokenBlacklisted(token)) { + throw new AuthException(AUTH_BLACKLIST_TOKEN); + } + + if (jwtUtil.isExpired(token)) { + throw new AuthException(AUTH_EXPIRED_TOKEN); + } + + if (!jwtUtil.validateToken(token)) { + throw new AuthException(AUTH_INVALID_TOKEN); + } + } + private String extractToken(HttpServletRequest request) { String authorization = request.getHeader(JWT_HEADER_KEY.getValue()); if (authorization != null && authorization.startsWith(JWT_PREFIX.getValue())) { diff --git a/src/main/java/konkuk/thip/common/security/oauth2/CustomSuccessHandler.java b/src/main/java/konkuk/thip/common/security/oauth2/CustomSuccessHandler.java index b0008a60e..49daa6368 100644 --- a/src/main/java/konkuk/thip/common/security/oauth2/CustomSuccessHandler.java +++ b/src/main/java/konkuk/thip/common/security/oauth2/CustomSuccessHandler.java @@ -3,7 +3,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import konkuk.thip.common.exception.AuthException; +import konkuk.thip.common.exception.InternalServerException; import konkuk.thip.common.exception.code.ErrorCode; import konkuk.thip.common.security.oauth2.tokenstorage.LoginTokenStorage; import konkuk.thip.common.security.util.JwtUtil; @@ -51,7 +51,7 @@ public void onAuthenticationSuccess( if (!webDomainProperties.isAllowed(Objects.toString(webRedirectDomain, ""))) { List origins = webDomainProperties.getWebDomainUrls(); if (origins == null || origins.isEmpty()) { - throw new AuthException(ErrorCode.WEB_DOMAIN_ORIGIN_EMPTY); + throw new InternalServerException(ErrorCode.WEB_DOMAIN_ORIGIN_EMPTY); } webRedirectDomain = origins.get(0); } diff --git a/src/main/java/konkuk/thip/common/security/oauth2/tokenstorage/RedisLoginTokenStorage.java b/src/main/java/konkuk/thip/common/security/oauth2/tokenstorage/RedisLoginTokenStorage.java index 689dd2133..6fd15371f 100644 --- a/src/main/java/konkuk/thip/common/security/oauth2/tokenstorage/RedisLoginTokenStorage.java +++ b/src/main/java/konkuk/thip/common/security/oauth2/tokenstorage/RedisLoginTokenStorage.java @@ -1,6 +1,6 @@ package konkuk.thip.common.security.oauth2.tokenstorage; -import konkuk.thip.common.exception.AuthException; +import konkuk.thip.common.exception.InternalServerException; import konkuk.thip.common.exception.code.ErrorCode; import konkuk.thip.common.security.oauth2.TokenType; import lombok.RequiredArgsConstructor; @@ -39,7 +39,7 @@ public Entry consume(String key) { return entry; } - throw new AuthException(ErrorCode.JSON_PROCESSING_ERROR); + throw new InternalServerException(ErrorCode.JSON_PROCESSING_ERROR); } private String toRedisKey(String key) { diff --git a/src/main/java/konkuk/thip/config/AwsS3ImageUrlInitializer.java b/src/main/java/konkuk/thip/config/AwsS3ImageUrlInitializer.java index 3b31abaa0..377472ec4 100644 --- a/src/main/java/konkuk/thip/config/AwsS3ImageUrlInitializer.java +++ b/src/main/java/konkuk/thip/config/AwsS3ImageUrlInitializer.java @@ -1,7 +1,7 @@ package konkuk.thip.config; import jakarta.annotation.PostConstruct; -import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.InternalServerException; import konkuk.thip.config.properties.AwsS3Properties; import konkuk.thip.room.domain.value.Category; import konkuk.thip.user.domain.value.Alias; @@ -20,7 +20,7 @@ public class AwsS3ImageUrlInitializer { void bindCloudFrontBaseUrl() { String baseUrl = awsS3Properties.cloudFrontBaseUrl(); if (baseUrl == null || baseUrl.isEmpty()) { - throw new BusinessException(AWS_BUCKET_BASE_URL_NOT_CONFIGURED); + throw new InternalServerException(AWS_BUCKET_BASE_URL_NOT_CONFIGURED); } Alias.registerBaseUrlSupplier(awsS3Properties::cloudFrontBaseUrl); diff --git a/src/main/java/konkuk/thip/config/SecurityConfig.java b/src/main/java/konkuk/thip/config/SecurityConfig.java index 4bfac31bc..daec669c8 100644 --- a/src/main/java/konkuk/thip/config/SecurityConfig.java +++ b/src/main/java/konkuk/thip/config/SecurityConfig.java @@ -1,5 +1,6 @@ package konkuk.thip.config; +import konkuk.thip.common.logging.MdcLoggingFilter; import konkuk.thip.common.security.constant.SecurityWhitelist; import konkuk.thip.common.security.filter.JwtAuthenticationEntryPoint; import konkuk.thip.common.security.filter.JwtAuthenticationFilter; @@ -46,6 +47,7 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CustomOAuth2UserService customOAuth2UserService; private final CustomSuccessHandler customSuccessHandler; + private final MdcLoggingFilter mdcLoggingFilter; private final ClientRegistrationRepository clientRegistrationRepository; private final WebDomainProperties webDomainProperties; @@ -69,6 +71,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(mdcLoggingFilter, JwtAuthenticationFilter.class) .oauth2Login((oauth2) -> oauth2 .authorizationEndpoint(authorizationEndpointConfig -> authorizationEndpointConfig .authorizationRequestResolver(resolver) diff --git a/src/main/java/konkuk/thip/message/adapter/out/firebase/FirebaseAdapter.java b/src/main/java/konkuk/thip/message/adapter/out/firebase/FirebaseAdapter.java index f907ceb6a..c1a0ae2dc 100644 --- a/src/main/java/konkuk/thip/message/adapter/out/firebase/FirebaseAdapter.java +++ b/src/main/java/konkuk/thip/message/adapter/out/firebase/FirebaseAdapter.java @@ -11,6 +11,9 @@ import java.util.List; +import static konkuk.thip.common.exception.code.ErrorCode.FCM_TOKEN_DEVICE_ARRAY_MISMATCH; +import static konkuk.thip.common.exception.code.ErrorCode.FIREBASE_SEND_ERROR; + @Slf4j @Component @Profile("!test & !local") @@ -29,14 +32,14 @@ public void send(Message message, String fcmToken, String deviceId) { log.debug("[FCM:SEND] ok id={} token={} device={}", messageId, maskDependingProfile(fcmToken), maskDependingProfile(deviceId)); } catch (FirebaseMessagingException e) { log.warn("[FCM:SEND] fail token={} device={} code={} msg={}", maskDependingProfile(fcmToken), maskDependingProfile(deviceId), e.getMessagingErrorCode(), e.getMessage()); - throw new FirebaseException(e); + throw new FirebaseException(FIREBASE_SEND_ERROR); } } @Override public void sendBatch(List messages, List fcmTokens, List deviceIds) { if (messages.size() != fcmTokens.size() || messages.size() != deviceIds.size()) { - throw new FirebaseException(new IllegalArgumentException("메시지, FCM 토큰, 디바이스 ID 리스트의 크기는 같아야 합니다.")); + throw new FirebaseException(FCM_TOKEN_DEVICE_ARRAY_MISMATCH); } try { @@ -62,11 +65,11 @@ public void sendBatch(List messages, List fcmTokens, List 0) { log.warn("[FCM:BATCH] 일부 메시지 전송 실패: {}/{}", batchResponse.getFailureCount(), messages.size()); - throw new FirebaseException(); + throw new FirebaseException(FIREBASE_SEND_ERROR); } } catch (FirebaseMessagingException e) { log.warn("[FCM:BATCH] 메시지 전송 실패: code={} msg={}", e.getMessagingErrorCode(), e.getMessage()); - throw new FirebaseException(e); + throw new FirebaseException(FIREBASE_SEND_ERROR); } } diff --git a/src/main/java/konkuk/thip/notification/adapter/in/web/NotificationQueryController.java b/src/main/java/konkuk/thip/notification/adapter/in/web/NotificationQueryController.java index a8ace6dab..dec6a9e67 100644 --- a/src/main/java/konkuk/thip/notification/adapter/in/web/NotificationQueryController.java +++ b/src/main/java/konkuk/thip/notification/adapter/in/web/NotificationQueryController.java @@ -8,6 +8,8 @@ import konkuk.thip.common.swagger.annotation.ExceptionDescription; import konkuk.thip.notification.adapter.in.web.response.NotificationShowEnableStateResponse; import konkuk.thip.notification.adapter.in.web.response.NotificationShowResponse; +import konkuk.thip.notification.adapter.in.web.response.NotificationUncheckedExistsResponse; +import konkuk.thip.notification.application.port.in.NotificationExistsUncheckedUseCase; import konkuk.thip.notification.application.port.in.NotificationShowEnableStateUseCase; import konkuk.thip.notification.application.port.in.NotificationShowUseCase; import konkuk.thip.notification.application.port.in.dto.NotificationType; @@ -26,6 +28,7 @@ public class NotificationQueryController { private final NotificationShowEnableStateUseCase notificationShowEnableStateUseCase; private final NotificationShowUseCase notificationShowUseCase; + private final NotificationExistsUncheckedUseCase notificationExistsUncheckedUseCase; @Operation( summary = "사용자 푸시알림 수신여부 조회 (마이페이지 -> 알림설정)", @@ -56,4 +59,14 @@ public BaseResponse showNotifications( ) { return BaseResponse.ok(notificationShowUseCase.showNotifications(userId, cursor, NotificationType.from(type))); } + + @Operation( + summary = "유저의 안읽은 알림 존재 여부 확인", + description = "유저가 읽지 않은 알림이 존재하는지 여부를 확인합니다." + ) + @GetMapping("/notifications/exists-unchecked") + public BaseResponse existsUnchecked(@Parameter(hidden = true) @UserId final Long userId) { + return BaseResponse.ok(NotificationUncheckedExistsResponse.of( + notificationExistsUncheckedUseCase.existsUnchecked(userId))); + } } diff --git a/src/main/java/konkuk/thip/notification/adapter/in/web/response/NotificationUncheckedExistsResponse.java b/src/main/java/konkuk/thip/notification/adapter/in/web/response/NotificationUncheckedExistsResponse.java new file mode 100644 index 000000000..f48dd55f5 --- /dev/null +++ b/src/main/java/konkuk/thip/notification/adapter/in/web/response/NotificationUncheckedExistsResponse.java @@ -0,0 +1,9 @@ +package konkuk.thip.notification.adapter.in.web.response; + +public record NotificationUncheckedExistsResponse( + boolean exists +) { + public static NotificationUncheckedExistsResponse of(boolean exists) { + return new NotificationUncheckedExistsResponse(exists); + } +} diff --git a/src/main/java/konkuk/thip/notification/adapter/out/persistence/NotificationQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/notification/adapter/out/persistence/NotificationQueryPersistenceAdapter.java index 289428136..ab90570a3 100644 --- a/src/main/java/konkuk/thip/notification/adapter/out/persistence/NotificationQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/notification/adapter/out/persistence/NotificationQueryPersistenceAdapter.java @@ -40,6 +40,11 @@ public CursorBasedList findFeedAndRoomNotificationsByUserI )); } + @Override + public boolean existsUnchecked(Long userId) { + return notificationJpaRepository.existsByUserIdAndIsCheckedFalse(userId); + } + private CursorBasedList findNotificationsByPrimaryKeyCursor(Cursor cursor, PrimaryKeyNotificationQueryFunction queryFunction) { Long lastNotificationId = cursor.isFirstRequest() ? null : cursor.getLong(0); int pageSize = cursor.getPageSize(); diff --git a/src/main/java/konkuk/thip/notification/adapter/out/persistence/repository/NotificationQueryRepository.java b/src/main/java/konkuk/thip/notification/adapter/out/persistence/repository/NotificationQueryRepository.java index ccf540952..836899999 100644 --- a/src/main/java/konkuk/thip/notification/adapter/out/persistence/repository/NotificationQueryRepository.java +++ b/src/main/java/konkuk/thip/notification/adapter/out/persistence/repository/NotificationQueryRepository.java @@ -11,4 +11,6 @@ public interface NotificationQueryRepository { List findRoomNotificationsOrderByCreatedAtDesc(Long userId, Long lastNotificationId, int pageSize); List findFeedAndRoomNotificationsOrderByCreatedAtDesc(Long userId, Long lastNotificationId, int pageSize); + + boolean existsByUserIdAndIsCheckedFalse(Long userId); } diff --git a/src/main/java/konkuk/thip/notification/adapter/out/persistence/repository/NotificationQueryRepositoryImpl.java b/src/main/java/konkuk/thip/notification/adapter/out/persistence/repository/NotificationQueryRepositoryImpl.java index c0fd7b310..5cf78fa3a 100644 --- a/src/main/java/konkuk/thip/notification/adapter/out/persistence/repository/NotificationQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/notification/adapter/out/persistence/repository/NotificationQueryRepositoryImpl.java @@ -47,6 +47,17 @@ public List findFeedAndRoomNotificationsOrderByCreatedAtDe return getNotificationQueryDtos(pageSize, notification, where); } + @Override + public boolean existsByUserIdAndIsCheckedFalse(Long userId) { + Integer result = queryFactory.selectOne() + .from(notification) + .where(notification.userJpaEntity.userId.eq(userId) + .and(notification.isChecked.eq(false))) + .fetchFirst(); + + return result != null; + } + private static BooleanExpression applyCursor(Long lastNotificationId, BooleanExpression where, QNotificationJpaEntity notification) { if (lastNotificationId != null) { where = where.and(notification.notificationId.lt(lastNotificationId)); diff --git a/src/main/java/konkuk/thip/notification/application/port/in/NotificationExistsUncheckedUseCase.java b/src/main/java/konkuk/thip/notification/application/port/in/NotificationExistsUncheckedUseCase.java new file mode 100644 index 000000000..3b34473df --- /dev/null +++ b/src/main/java/konkuk/thip/notification/application/port/in/NotificationExistsUncheckedUseCase.java @@ -0,0 +1,6 @@ +package konkuk.thip.notification.application.port.in; + +public interface NotificationExistsUncheckedUseCase { + + boolean existsUnchecked(Long userId); +} diff --git a/src/main/java/konkuk/thip/notification/application/port/out/NotificationQueryPort.java b/src/main/java/konkuk/thip/notification/application/port/out/NotificationQueryPort.java index e1af77781..c718817c6 100644 --- a/src/main/java/konkuk/thip/notification/application/port/out/NotificationQueryPort.java +++ b/src/main/java/konkuk/thip/notification/application/port/out/NotificationQueryPort.java @@ -11,4 +11,6 @@ public interface NotificationQueryPort { CursorBasedList findRoomNotificationsByUserId(Long userId, Cursor cursor); CursorBasedList findFeedAndRoomNotificationsByUserId(Long userId, Cursor cursor); + + boolean existsUnchecked(Long userId); } diff --git a/src/main/java/konkuk/thip/notification/application/service/NotificationExistsUncheckedService.java b/src/main/java/konkuk/thip/notification/application/service/NotificationExistsUncheckedService.java new file mode 100644 index 000000000..66783b6ea --- /dev/null +++ b/src/main/java/konkuk/thip/notification/application/service/NotificationExistsUncheckedService.java @@ -0,0 +1,20 @@ +package konkuk.thip.notification.application.service; + +import konkuk.thip.notification.application.port.in.NotificationExistsUncheckedUseCase; +import konkuk.thip.notification.application.port.out.NotificationQueryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class NotificationExistsUncheckedService implements NotificationExistsUncheckedUseCase { + + private final NotificationQueryPort notificationQueryPort; + + @Override + @Transactional(readOnly = true) + public boolean existsUnchecked(Long userId) { + return notificationQueryPort.existsUnchecked(userId); + } +} diff --git a/src/main/java/konkuk/thip/notification/domain/value/NotificationRedirectSpecConverter.java b/src/main/java/konkuk/thip/notification/domain/value/NotificationRedirectSpecConverter.java index 59f203628..1c2564208 100644 --- a/src/main/java/konkuk/thip/notification/domain/value/NotificationRedirectSpecConverter.java +++ b/src/main/java/konkuk/thip/notification/domain/value/NotificationRedirectSpecConverter.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.persistence.AttributeConverter; import jakarta.persistence.Converter; -import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.exception.InternalServerException; import java.io.IOException; @@ -22,7 +22,7 @@ public String convertToDatabaseColumn(NotificationRedirectSpec attribute) { try { return objectMapper.writeValueAsString(attribute); } catch (JsonProcessingException e) { - throw new InvalidStateException(NOTIFICATION_REDIRECT_DATA_SERIALIZE_FAILED); + throw new InternalServerException(NOTIFICATION_REDIRECT_DATA_SERIALIZE_FAILED); } } @@ -32,7 +32,7 @@ public NotificationRedirectSpec convertToEntityAttribute(String dbData) { try { return objectMapper.readValue(dbData, NotificationRedirectSpec.class); } catch (IOException e) { - throw new InvalidStateException(NOTIFICATION_REDIRECT_DATA_DESERIALIZE_FAILED); + throw new InternalServerException(NOTIFICATION_REDIRECT_DATA_DESERIALIZE_FAILED); } } } diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/UserTokenBlacklistRedisAdapter.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/UserTokenBlacklistRedisAdapter.java index 0c2a72949..b39b0f4e1 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/UserTokenBlacklistRedisAdapter.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/UserTokenBlacklistRedisAdapter.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; -import konkuk.thip.common.exception.ExternalApiException; +import konkuk.thip.common.exception.InternalServerException; import konkuk.thip.common.security.oauth2.LoginUser; import konkuk.thip.common.security.util.JwtUtil; import konkuk.thip.user.application.port.UserTokenBlacklistCommandPort; @@ -52,7 +52,7 @@ public void addTokenToBlacklist(String token) { mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); valueJson = mapper.writeValueAsString(valueMap); } catch (JsonProcessingException e) { - throw new ExternalApiException(JSON_PROCESSING_ERROR); + throw new InternalServerException(JSON_PROCESSING_ERROR); } redisTemplate.opsForValue().set(key, valueJson); log.info("블랙리스트에 탈퇴한 회원 토큰 및 관련 정보 추가 - userId: {}, withdrawalTime: {}, expiration: {}", diff --git a/src/main/java/konkuk/thip/user/domain/User.java b/src/main/java/konkuk/thip/user/domain/User.java index ca2b08f28..e9115062a 100644 --- a/src/main/java/konkuk/thip/user/domain/User.java +++ b/src/main/java/konkuk/thip/user/domain/User.java @@ -1,6 +1,7 @@ package konkuk.thip.user.domain; import konkuk.thip.common.entity.BaseDomainEntity; +import konkuk.thip.common.exception.InternalServerException; import konkuk.thip.common.exception.InvalidStateException; import konkuk.thip.common.exception.code.ErrorCode; import konkuk.thip.user.domain.value.Alias; @@ -80,7 +81,7 @@ private void validateCanUpdateNickname(String nickname) { public void markAsDeleted() { if (this.oauth2Id == null) { - throw new InvalidStateException(USER_OAUTH2ID_CANNOT_BE_NULL); + throw new InternalServerException(USER_OAUTH2ID_CANNOT_BE_NULL); } if (this.oauth2Id.startsWith("deleted:")) { throw new InvalidStateException(USER_ALREADY_DELETED); diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml new file mode 100644 index 000000000..f03dda54d --- /dev/null +++ b/src/main/resources/logback-spring.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + ${console.format} + + + + + + ${LOG_PATH}/info/info.log + + ${file.format} + + + INFO + ACCEPT + DENY + + + ${LOG_PATH}/info/info.%d{yyyy-MM-dd}.%i.log + 10MB + 10 + + + + + + ${LOG_PATH}/warn/warn.log + + ${file.format} + + + WARN + ACCEPT + DENY + + + ${LOG_PATH}/warn/warn.%d{yyyy-MM-dd}.%i.log + 10MB + 10 + + + + + + ${LOG_PATH}/error/error.log + + ${file.format} + + + ERROR + ACCEPT + DENY + + + ${LOG_PATH}/error/error.%d{yyyy-MM-dd}.%i.log + 10MB + 10 + + + + + + + + + + \ No newline at end of file diff --git a/src/test/java/konkuk/thip/notification/adapter/in/web/NotificationExistsUncheckedApiTest.java b/src/test/java/konkuk/thip/notification/adapter/in/web/NotificationExistsUncheckedApiTest.java new file mode 100644 index 000000000..ad2ce70e1 --- /dev/null +++ b/src/test/java/konkuk/thip/notification/adapter/in/web/NotificationExistsUncheckedApiTest.java @@ -0,0 +1,72 @@ +package konkuk.thip.notification.adapter.in.web; + +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.notification.adapter.out.jpa.NotificationJpaEntity; +import konkuk.thip.notification.adapter.out.persistence.repository.NotificationJpaRepository; +import konkuk.thip.notification.domain.value.NotificationCategory; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Transactional; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 안읽은 알림 존재 여부 확인 api 통합 테스트") +class NotificationExistsUncheckedApiTest { + + @Autowired private MockMvc mockMvc; + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private NotificationJpaRepository notificationJpaRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + @Test + @DisplayName("유저가 읽지 않은 알림이 있을 경우, true 를 반환한다.") + void notification_exists_unchecked_true() throws Exception { + //given + UserJpaEntity user = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER)); + NotificationJpaEntity n1 = notificationJpaRepository.save(TestEntityFactory.createNotification(user, "알림1", NotificationCategory.FEED)); + + //when + ResultActions result = mockMvc.perform(get("/notifications/exists-unchecked") + .requestAttr("userId", user.getUserId())); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.exists").value(true)); + } + + @Test + @DisplayName("유저가 읽지 않은 알림이 없을 경우, false 를 반환한다.") + void notification_exists_unchecked_false() throws Exception { + //given + UserJpaEntity user = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER)); + NotificationJpaEntity n1 = notificationJpaRepository.save(TestEntityFactory.createNotification(user, "알림1", NotificationCategory.FEED)); + jdbcTemplate.update( + "UPDATE notifications SET is_checked = TRUE WHERE notification_id = ?", + n1.getNotificationId() + ); + + //when + ResultActions result = mockMvc.perform(get("/notifications/exists-unchecked") + .requestAttr("userId", user.getUserId())); + + //then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.exists").value(false)); + } +}