diff --git a/build.gradle b/build.gradle index ee064e0..4eb072b 100644 --- a/build.gradle +++ b/build.gradle @@ -32,8 +32,34 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + // QueryDSL : OpenFeign + implementation "io.github.openfeign.querydsl:querydsl-jpa:7.0" + implementation "io.github.openfeign.querydsl:querydsl-core:7.0" + annotationProcessor "io.github.openfeign.querydsl:querydsl-apt:7.0:jpa" + annotationProcessor "jakarta.persistence:jakarta.persistence-api" + annotationProcessor "jakarta.annotation:jakarta.annotation-api" } tasks.named('test') { useJUnitPlatform() } + +// QueryDSL 관련 설정 +// generated/querydsl 폴더 생성 & 삽입 +def querydslDir = layout.buildDirectory.dir("generated/querydsl").get().asFile + +// 소스 세트에 생성 경로 추가 (구체적인 경로 지정) +sourceSets { + main.java.srcDirs += [ querydslDir ] +} + +// 컴파일 시 생성 경로 지정 +tasks.withType(JavaCompile).configureEach { + options.generatedSourceOutputDirectory.set(querydslDir) +} + +// clean 태스크에 생성 폴더 삭제 로직 추가 +clean.doLast { + file(querydslDir).deleteDir() +} diff --git a/src/main/java/com/example/umc9th/domain/review/controller/ReviewController.java b/src/main/java/com/example/umc9th/domain/review/controller/ReviewController.java new file mode 100644 index 0000000..31a08e5 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/controller/ReviewController.java @@ -0,0 +1,38 @@ +package com.example.umc9th.domain.review.controller; + +import com.example.umc9th.domain.review.dto.ReviewMyReviewResponse; +import com.example.umc9th.domain.review.service.ReviewService; +import com.example.umc9th.global.apiPayload.ApiResponse; +import com.example.umc9th.global.apiPayload.code.GeneralSuccessCode; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1") +public class ReviewController { + + private final ReviewService reviewService; + + @GetMapping("/users/{userId}/reviews") + public ApiResponse> getMyReviews(@PathVariable Long userId, + @RequestParam(required = false) String restaurantName, + @RequestParam(required = false) Integer ratingFloor, + @PageableDefault(size = 10) Pageable pageable) { + Page response = reviewService.getMyReviews(userId, restaurantName, ratingFloor, pageable); + return ApiResponse.onSuccess(GeneralSuccessCode.SUCCESS, response); + } +} + + + + + + diff --git a/src/main/java/com/example/umc9th/domain/review/converter/ReviewConverter.java b/src/main/java/com/example/umc9th/domain/review/converter/ReviewConverter.java new file mode 100644 index 0000000..8ef617b --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/converter/ReviewConverter.java @@ -0,0 +1,21 @@ +package com.example.umc9th.domain.review.converter; + +import com.example.umc9th.domain.review.dto.ReviewMyReviewResponse; +import com.example.umc9th.domain.review.repository.result.ReviewSummaryProjection; + +public final class ReviewConverter { + + private ReviewConverter() { + } + + public static ReviewMyReviewResponse toMyReviewResponse(ReviewSummaryProjection projection) { + return new ReviewMyReviewResponse( + projection.reviewId(), + projection.restaurantName(), + projection.reviewStar(), + projection.body(), + projection.createdAt() + ); + } +} + diff --git a/src/main/java/com/example/umc9th/domain/review/dto/ReviewMyReviewResponse.java b/src/main/java/com/example/umc9th/domain/review/dto/ReviewMyReviewResponse.java new file mode 100644 index 0000000..a9ccb52 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/dto/ReviewMyReviewResponse.java @@ -0,0 +1,13 @@ +package com.example.umc9th.domain.review.dto; + +import java.time.LocalDateTime; + +public record ReviewMyReviewResponse( + Long reviewId, + String restaurantName, + Integer reviewStar, + String body, + LocalDateTime createdAt +) { +} + diff --git a/src/main/java/com/example/umc9th/domain/review/enums/ReviewRatingGroup.java b/src/main/java/com/example/umc9th/domain/review/enums/ReviewRatingGroup.java new file mode 100644 index 0000000..a373c05 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/enums/ReviewRatingGroup.java @@ -0,0 +1,44 @@ +package com.example.umc9th.domain.review.enums; + +import java.util.Arrays; + +public enum ReviewRatingGroup { + + FIVE(5, 5), + FOUR(4, 4), + THREE(3, 3), + TWO(2, 2), + ONE(1, 1); + + private final int minInclusive; + private final int maxInclusive; + + ReviewRatingGroup(int minInclusive, int maxInclusive) { + this.minInclusive = minInclusive; + this.maxInclusive = maxInclusive; + } + + public int getMinInclusive() { + return minInclusive; + } + + public int getMaxInclusive() { + return maxInclusive; + } + + public static ReviewRatingGroup fromValue(Integer value) { + if (value == null) { + return null; + } + return Arrays.stream(values()) + .filter(group -> group.minInclusive == value) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("Unsupported rating group value: " + value)); + } +} + + + + + + diff --git a/src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryRepository.java b/src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryRepository.java new file mode 100644 index 0000000..f819f16 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryRepository.java @@ -0,0 +1,18 @@ +package com.example.umc9th.domain.review.repository; + +import com.example.umc9th.domain.review.enums.ReviewRatingGroup; +import com.example.umc9th.domain.review.repository.result.ReviewSummaryProjection; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; + +public interface ReviewQueryRepository { + + Page findMyReviews(Long userId, + String restaurantName, + ReviewRatingGroup ratingGroup, + Pageable pageable); +} + + + + diff --git a/src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryRepositoryImpl.java b/src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryRepositoryImpl.java new file mode 100644 index 0000000..740c450 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/repository/ReviewQueryRepositoryImpl.java @@ -0,0 +1,80 @@ +package com.example.umc9th.domain.review.repository; + +import com.example.umc9th.domain.restaurant.entity.QRestaurant; +import com.example.umc9th.domain.review.entity.QReview; +import com.example.umc9th.domain.review.enums.ReviewRatingGroup; +import com.example.umc9th.domain.review.repository.result.ReviewSummaryProjection; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.types.Projections; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class ReviewQueryRepositoryImpl implements ReviewQueryRepository { + + private final JPAQueryFactory queryFactory; + + private static final QReview review = QReview.review; + private static final QRestaurant restaurant = QRestaurant.restaurant; + + @Override + public Page findMyReviews(Long userId, + String restaurantName, + ReviewRatingGroup ratingGroup, + Pageable pageable) { + BooleanBuilder builder = new BooleanBuilder() + .and(review.user.id.eq(userId)); + + if (StringUtils.hasText(restaurantName)) { + builder.and(restaurant.name.eq(restaurantName)); + } + + BooleanExpression ratingCondition = ratingGroupCondition(ratingGroup); + if (ratingCondition != null) { + builder.and(ratingCondition); + } + + List content = queryFactory + .select(Projections.constructor(ReviewSummaryProjection.class, + review.id, + restaurant.name, + review.reviewStar, + review.body, + review.createdAt + )) + .from(review) + .join(review.restaurant, restaurant) + .where(builder) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .orderBy(review.createdAt.desc()) + .fetch(); + + Long total = queryFactory + .select(review.count()) + .from(review) + .join(review.restaurant, restaurant) + .where(builder) + .fetchOne(); + + long totalElements = total != null ? total : 0L; + return new PageImpl<>(content, pageable, totalElements); + } + + private BooleanExpression ratingGroupCondition(ReviewRatingGroup ratingGroup) { + if (ratingGroup == null) { + return null; + } + return review.reviewStar.between(ratingGroup.getMinInclusive(), ratingGroup.getMaxInclusive()); + } +} + diff --git a/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java b/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java index 7a7a4ac..c2d2221 100644 --- a/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java +++ b/src/main/java/com/example/umc9th/domain/review/repository/ReviewRepository.java @@ -3,6 +3,6 @@ import com.example.umc9th.domain.review.entity.Review; import org.springframework.data.jpa.repository.JpaRepository; -public interface ReviewRepository extends JpaRepository { +public interface ReviewRepository extends JpaRepository, ReviewQueryRepository { } diff --git a/src/main/java/com/example/umc9th/domain/review/repository/result/ReviewSummaryProjection.java b/src/main/java/com/example/umc9th/domain/review/repository/result/ReviewSummaryProjection.java new file mode 100644 index 0000000..2c6799d --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/repository/result/ReviewSummaryProjection.java @@ -0,0 +1,18 @@ +package com.example.umc9th.domain.review.repository.result; + +import java.time.LocalDateTime; + +public record ReviewSummaryProjection( + Long reviewId, + String restaurantName, + Integer reviewStar, + String body, + LocalDateTime createdAt +) { +} + + + + + + diff --git a/src/main/java/com/example/umc9th/domain/review/service/ReviewService.java b/src/main/java/com/example/umc9th/domain/review/service/ReviewService.java new file mode 100644 index 0000000..cceface --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/review/service/ReviewService.java @@ -0,0 +1,42 @@ +package com.example.umc9th.domain.review.service; + +import com.example.umc9th.domain.review.converter.ReviewConverter; +import com.example.umc9th.domain.review.dto.ReviewMyReviewResponse; +import com.example.umc9th.domain.review.enums.ReviewRatingGroup; +import com.example.umc9th.domain.review.repository.ReviewRepository; +import com.example.umc9th.domain.review.repository.result.ReviewSummaryProjection; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReviewService { + + private final ReviewRepository reviewRepository; + + public Page getMyReviews(Long userId, + String restaurantName, + Integer ratingFloor, + Pageable pageable) { + ReviewRatingGroup ratingGroup = ReviewRatingGroup.fromValue(ratingFloor); + + Page projectionPage = reviewRepository.findMyReviews( + userId, + restaurantName, + ratingGroup, + pageable + ); + + return projectionPage.map(ReviewConverter::toMyReviewResponse); + } +} + + + + + + diff --git a/src/main/java/com/example/umc9th/domain/test/controller/TestController.java b/src/main/java/com/example/umc9th/domain/test/controller/TestController.java new file mode 100644 index 0000000..8b9b8a3 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/controller/TestController.java @@ -0,0 +1,49 @@ +package com.example.umc9th.domain.test.controller; + +import com.example.umc9th.domain.test.converter.TestConverter; +import com.example.umc9th.domain.test.dto.res.TestResDTO; +import com.example.umc9th.domain.test.service.query.TestQueryService; +import com.example.umc9th.global.apiPayload.ApiResponse; +import com.example.umc9th.global.apiPayload.code.GeneralSuccessCode; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/temp") +public class TestController { + + private final TestQueryService testQueryService; + + @GetMapping("/test") + public ApiResponse test() { + // 응답 코드 정의 + GeneralSuccessCode code = GeneralSuccessCode.SUCCESS; + + return ApiResponse.onSuccess( + code, + TestConverter.toTestingDTO("This is Test!") + ); + } + + // 예외 상황 + @GetMapping("/exception") + public ApiResponse exception( + @RequestParam Long flag + ) { + + testQueryService.checkFlag(flag); + + // 응답 코드 정의 + GeneralSuccessCode code = GeneralSuccessCode.SUCCESS; + return ApiResponse.onSuccess(code, TestConverter.toExceptionDTO("This is Test!")); + } + + @GetMapping("/error") + public ApiResponse error() { + throw new RuntimeException("500 Error Test!"); + } +} diff --git a/src/main/java/com/example/umc9th/domain/test/converter/TestConverter.java b/src/main/java/com/example/umc9th/domain/test/converter/TestConverter.java new file mode 100644 index 0000000..af81183 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/converter/TestConverter.java @@ -0,0 +1,24 @@ +package com.example.umc9th.domain.test.converter; + +import com.example.umc9th.domain.test.dto.res.TestResDTO; + +public class TestConverter { + + // 객체 -> DTO + public static TestResDTO.Testing toTestingDTO( + String testing + ) { + return TestResDTO.Testing.builder() + .testString(testing) + .build(); + } + + // 객체 -> DTO + public static TestResDTO.Exception toExceptionDTO( + String testing + ){ + return TestResDTO.Exception.builder() + .testString(testing) + .build(); + } +} diff --git a/src/main/java/com/example/umc9th/domain/test/dto/res/TestResDTO.java b/src/main/java/com/example/umc9th/domain/test/dto/res/TestResDTO.java new file mode 100644 index 0000000..9f13f52 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/dto/res/TestResDTO.java @@ -0,0 +1,19 @@ +package com.example.umc9th.domain.test.dto.res; + +import lombok.Builder; +import lombok.Getter; + +public class TestResDTO { + + @Builder + @Getter + public static class Testing { + private String testString; + } + + @Builder + @Getter + public static class Exception { + private String testString; + } +} diff --git a/src/main/java/com/example/umc9th/domain/test/exception/TestException.java b/src/main/java/com/example/umc9th/domain/test/exception/TestException.java new file mode 100644 index 0000000..1faf200 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/exception/TestException.java @@ -0,0 +1,10 @@ +package com.example.umc9th.domain.test.exception; + +import com.example.umc9th.global.apiPayload.code.BaseErrorCode; +import com.example.umc9th.global.apiPayload.exception.GeneralException; + +public class TestException extends GeneralException { + public TestException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/example/umc9th/domain/test/exception/code/TestErrorCode.java b/src/main/java/com/example/umc9th/domain/test/exception/code/TestErrorCode.java new file mode 100644 index 0000000..7264f21 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/exception/code/TestErrorCode.java @@ -0,0 +1,19 @@ +package com.example.umc9th.domain.test.exception.code; + +import com.example.umc9th.global.apiPayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum TestErrorCode implements BaseErrorCode { + + // For test + TEST_EXCEPTION(HttpStatus.BAD_REQUEST, "TEST400_1", "이거는 테스트"), + ; + + private final HttpStatus status; + private final String code; + private final String message; +} \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/domain/test/service/command/TestCommandService.java b/src/main/java/com/example/umc9th/domain/test/service/command/TestCommandService.java new file mode 100644 index 0000000..9b92691 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/service/command/TestCommandService.java @@ -0,0 +1,5 @@ +package com.example.umc9th.domain.test.service.command; + +public interface TestCommandService { + +} diff --git a/src/main/java/com/example/umc9th/domain/test/service/command/TestCommandServiceImpl.java b/src/main/java/com/example/umc9th/domain/test/service/command/TestCommandServiceImpl.java new file mode 100644 index 0000000..93d6607 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/service/command/TestCommandServiceImpl.java @@ -0,0 +1,5 @@ +package com.example.umc9th.domain.test.service.command; + +public class TestCommandServiceImpl { + +} diff --git a/src/main/java/com/example/umc9th/domain/test/service/query/TestQueryService.java b/src/main/java/com/example/umc9th/domain/test/service/query/TestQueryService.java new file mode 100644 index 0000000..a6419ab --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/service/query/TestQueryService.java @@ -0,0 +1,5 @@ +package com.example.umc9th.domain.test.service.query; + +public interface TestQueryService { + void checkFlag(Long flag); +} \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/domain/test/service/query/TestQueryServiceImpl.java b/src/main/java/com/example/umc9th/domain/test/service/query/TestQueryServiceImpl.java new file mode 100644 index 0000000..a6190fa --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/test/service/query/TestQueryServiceImpl.java @@ -0,0 +1,18 @@ +package com.example.umc9th.domain.test.service.query; + +import com.example.umc9th.domain.test.exception.TestException; +import com.example.umc9th.domain.test.exception.code.TestErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class TestQueryServiceImpl implements TestQueryService { + + @Override + public void checkFlag(Long flag){ + if (flag == 1){ + throw new TestException(TestErrorCode.TEST_EXCEPTION); + } + } +} diff --git a/src/main/java/com/example/umc9th/global/apiPayload/ApiResponse.java b/src/main/java/com/example/umc9th/global/apiPayload/ApiResponse.java new file mode 100644 index 0000000..83f3cd3 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/ApiResponse.java @@ -0,0 +1,35 @@ +package com.example.umc9th.global.apiPayload; + +import com.example.umc9th.global.apiPayload.code.BaseErrorCode; +import com.example.umc9th.global.apiPayload.code.BaseSuccessCode; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@JsonPropertyOrder({"isSuccess", "code", "message", "result"}) +public class ApiResponse { + + @JsonProperty("isSuccess") + private final Boolean isSuccess; + + @JsonProperty("code") + private final String code; + + @JsonProperty("message") + private final String message; + + @JsonProperty("result") + private T result; + + // 성공한 경우 (result 포함) + public static ApiResponse onSuccess(BaseSuccessCode code, T result) { + return new ApiResponse<>(true, code.getCode(), code.getMessage(), result); + } + // 실패한 경우 (result 포함) + public static ApiResponse onFailure(BaseErrorCode code, T result) { + return new ApiResponse<>(false, code.getCode(), code.getMessage(), result); + } +} diff --git a/src/main/java/com/example/umc9th/global/apiPayload/code/BaseErrorCode.java b/src/main/java/com/example/umc9th/global/apiPayload/code/BaseErrorCode.java new file mode 100644 index 0000000..979b46f --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/BaseErrorCode.java @@ -0,0 +1,11 @@ +package com.example.umc9th.global.apiPayload.code; + +import org.springframework.http.HttpStatus; + +public interface BaseErrorCode { + + HttpStatus getStatus(); + String getCode(); + String getMessage(); +} + \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/global/apiPayload/code/BaseSuccessCode.java b/src/main/java/com/example/umc9th/global/apiPayload/code/BaseSuccessCode.java new file mode 100644 index 0000000..57bf5bc --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/BaseSuccessCode.java @@ -0,0 +1,11 @@ +package com.example.umc9th.global.apiPayload.code; + +import org.springframework.http.HttpStatus; + +public interface BaseSuccessCode { + + String getCode(); + String getMessage(); + HttpStatus getStatus(); +} + \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralErrorCode.java b/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralErrorCode.java new file mode 100644 index 0000000..8c0faa8 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralErrorCode.java @@ -0,0 +1,31 @@ +package com.example.umc9th.global.apiPayload.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum GeneralErrorCode implements BaseErrorCode{ + + BAD_REQUEST(HttpStatus.BAD_REQUEST, + "COMMON400_1", + "잘못된 요청입니다."), + UNAUTHORIZED(HttpStatus.UNAUTHORIZED, + "AUTH401_1", + "인증이 필요합니다."), + FORBIDDEN(HttpStatus.FORBIDDEN, + "AUTH403_1", + "요청이 거부되었습니다."), + NOT_FOUND(HttpStatus.NOT_FOUND, + "COMMON404_1", + "요청한 리소스를 찾을 수 없습니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, + "COMMON500_1", + "예기치 않은 서버 에러가 발생했습니다."), + ; + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralSuccessCode.java b/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralSuccessCode.java new file mode 100644 index 0000000..967615c --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/code/GeneralSuccessCode.java @@ -0,0 +1,19 @@ +package com.example.umc9th.global.apiPayload.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum GeneralSuccessCode implements BaseSuccessCode{ + + SUCCESS(HttpStatus.OK, + "SUCCESS", + "Success"), + ; + + private final HttpStatus status; + private final String code; + private final String message; +} diff --git a/src/main/java/com/example/umc9th/global/apiPayload/exception/GeneralException.java b/src/main/java/com/example/umc9th/global/apiPayload/exception/GeneralException.java new file mode 100644 index 0000000..bd7517f --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/exception/GeneralException.java @@ -0,0 +1,12 @@ +package com.example.umc9th.global.apiPayload.exception; + +import com.example.umc9th.global.apiPayload.code.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class GeneralException extends RuntimeException { + + private final BaseErrorCode code; +} diff --git a/src/main/java/com/example/umc9th/global/apiPayload/handler/GeneralExceptionAdvice.java b/src/main/java/com/example/umc9th/global/apiPayload/handler/GeneralExceptionAdvice.java new file mode 100644 index 0000000..a0849cd --- /dev/null +++ b/src/main/java/com/example/umc9th/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -0,0 +1,86 @@ +package com.example.umc9th.global.apiPayload.handler; + +import com.example.umc9th.global.apiPayload.ApiResponse; +import com.example.umc9th.global.apiPayload.code.BaseErrorCode; +import com.example.umc9th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc9th.global.apiPayload.exception.GeneralException; +import com.example.umc9th.global.notification.dto.DiscordMessage; +import com.example.umc9th.global.notification.service.DiscordNotificationService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; + +@RestControllerAdvice +@RequiredArgsConstructor +public class GeneralExceptionAdvice { + + private final DiscordNotificationService discordNotificationService; + + // 애플리케이션에서 발생하는 커스텀 예외를 처리 + @ExceptionHandler(GeneralException.class) + public ResponseEntity> handleException( + GeneralException ex + ) { + + return ResponseEntity.status(ex.getCode().getStatus()) + .body(ApiResponse.onFailure( + ex.getCode(), + null + ) + ); + } + + // 그 외의 정의되지 않은 모든 예외 처리 + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException( + Exception ex, + HttpServletRequest request + ) { + + sendDiscordAlert(ex, request); + + BaseErrorCode code = GeneralErrorCode.INTERNAL_SERVER_ERROR; + return ResponseEntity.status(code.getStatus()) + .body(ApiResponse.onFailure( + code, + ex.getMessage() + ) + ); + } + + private void sendDiscordAlert(Exception ex, HttpServletRequest request) { + String alertTime = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + String exceptionName = ex.getClass().getSimpleName(); + String exceptionMessage = ex.getMessage(); + String requestUri = request.getRequestURI(); + String requestMethod = request.getMethod(); + + String description = String.format( + "## 🚨 500 Internal Server Error 🚨\n\n" + + "**- 발생 시각**: %s\n" + + "**- 요청 URI**: %s\n" + + "**- HTTP 메서드**: %s\n" + + "**- 예외 클래스**: %s\n" + + "**- 예외 메시지**: %s\n", + alertTime, requestUri, requestMethod, exceptionName, exceptionMessage + ); + + DiscordMessage.Embed embed = DiscordMessage.Embed.builder() + .title("🔥 서버 에러 발생 🔥") + .description(description) + .color(15158332) // Red color + .build(); + + DiscordMessage discordMessage = DiscordMessage.builder() + .content("서버 에러가 발생했습니다.") + .embeds(new DiscordMessage.Embed[]{embed}) + .build(); + + discordNotificationService.sendMessage(discordMessage); + } +} diff --git a/src/main/java/com/example/umc9th/global/config/QuerydslConfig.java b/src/main/java/com/example/umc9th/global/config/QuerydslConfig.java new file mode 100644 index 0000000..3a83a32 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/config/QuerydslConfig.java @@ -0,0 +1,25 @@ +package com.example.umc9th.global.config; + +import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class QuerydslConfig { + + @PersistenceContext + private EntityManager entityManager; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(entityManager); + } +} + + + + + + diff --git a/src/main/java/com/example/umc9th/global/config/RestTemplateConfig.java b/src/main/java/com/example/umc9th/global/config/RestTemplateConfig.java new file mode 100644 index 0000000..3c6b9d2 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package com.example.umc9th.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} diff --git a/src/main/java/com/example/umc9th/global/notification/dto/DiscordMessage.java b/src/main/java/com/example/umc9th/global/notification/dto/DiscordMessage.java new file mode 100644 index 0000000..4aed1fb --- /dev/null +++ b/src/main/java/com/example/umc9th/global/notification/dto/DiscordMessage.java @@ -0,0 +1,22 @@ +package com.example.umc9th.global.notification.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class DiscordMessage { + private String content; + + @JsonProperty("embeds") + private Embed[] embeds; + + @Getter + @Builder + public static class Embed { + private String title; + private String description; + private int color; + } +} diff --git a/src/main/java/com/example/umc9th/global/notification/service/DiscordNotificationService.java b/src/main/java/com/example/umc9th/global/notification/service/DiscordNotificationService.java new file mode 100644 index 0000000..6bde6a9 --- /dev/null +++ b/src/main/java/com/example/umc9th/global/notification/service/DiscordNotificationService.java @@ -0,0 +1,7 @@ +package com.example.umc9th.global.notification.service; + +import com.example.umc9th.global.notification.dto.DiscordMessage; + +public interface DiscordNotificationService { + void sendMessage(DiscordMessage message); +} diff --git a/src/main/java/com/example/umc9th/global/notification/service/DiscordNotificationServiceImpl.java b/src/main/java/com/example/umc9th/global/notification/service/DiscordNotificationServiceImpl.java new file mode 100644 index 0000000..74c476d --- /dev/null +++ b/src/main/java/com/example/umc9th/global/notification/service/DiscordNotificationServiceImpl.java @@ -0,0 +1,29 @@ +package com.example.umc9th.global.notification.service; + +import com.example.umc9th.global.notification.dto.DiscordMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestTemplate; + +@Service +@RequiredArgsConstructor +@Slf4j +public class DiscordNotificationServiceImpl implements DiscordNotificationService { + + @Value("${discord.webhook.url}") + private String discordWebhookUrl; + + private final RestTemplate restTemplate; + + @Override + public void sendMessage(DiscordMessage message) { + try { + log.info("Sending Discord notification."); + restTemplate.postForObject(discordWebhookUrl, message, String.class); + } catch (Exception e) { + log.error("Failed to send Discord notification.", e); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 29ca312..0063717 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,4 +16,8 @@ spring: ddl-auto: update # 애플리케이션 실행 시 데이터베이스 스키마의 상태를 설정 properties: hibernate: - format_sql: true # 출력되는 SQL 쿼리를 보기 좋게 포맷팅 \ No newline at end of file + format_sql: true # 출력되는 SQL 쿼리를 보기 좋게 포맷팅 + +discord: + webhook: + url: "https://discord.com/api/webhooks/1438720476204498987/7NE_rXydx9r2fy0HKRtGMmYiNoiDrGcy_9aQbh24XMOG3x3kWYPbqbJ7s4OHfBOv2YnM" \ No newline at end of file