diff --git a/jace/gradlew b/jace/gradlew old mode 100644 new mode 100755 diff --git a/jace/src/main/java/com/umcstudy/jace/domain/mission/controller/MissionController.java b/jace/src/main/java/com/umcstudy/jace/domain/mission/controller/MissionController.java index 1e1e8b4..f9fb829 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/mission/controller/MissionController.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/mission/controller/MissionController.java @@ -29,11 +29,11 @@ public ApiResponse getHome( @GetMapping("/users/me/missions") public ApiResponse getMyMission( @RequestParam MissionStatus missionCondition, - @RequestParam(required = false) Long cursorId, + @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size ){ BaseSuccessCode code = MissionSuccessCode.MyMissionOK; - return ApiResponse.onSuccess(code, missionService.getMyMission(missionCondition, cursorId, size)); + return ApiResponse.onSuccess(code, missionService.getMyMission(missionCondition, page, size)); } @PatchMapping("/users/me/missions/{missionId}") diff --git a/jace/src/main/java/com/umcstudy/jace/domain/mission/converter/MissionConverter.java b/jace/src/main/java/com/umcstudy/jace/domain/mission/converter/MissionConverter.java index f17984f..8c2748b 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/mission/converter/MissionConverter.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/mission/converter/MissionConverter.java @@ -3,21 +3,22 @@ import com.umcstudy.jace.domain.mission.dto.MissionResDTO; import com.umcstudy.jace.domain.mission.entity.Mission; import com.umcstudy.jace.domain.mission.entity.mapping.MissionUser; +import org.springframework.data.domain.Page; import java.util.List; public class MissionConverter { public static MissionResDTO.MissionItem toMissionItem(Mission mission) { - return new MissionResDTO.MissionItem( - mission.getId().intValue(), - mission.getShop().getId().intValue(), - mission.getShop().getShopName(), - mission.getShop().getShopCategory().getShopCategoryName(), - mission.getMissionPay(), - mission.getMissionPoint(), - mission.getMissionCreateTime() != null ? mission.getMissionCreateTime().toLocalDate() : null - ); + return MissionResDTO.MissionItem.builder() + .missionId(mission.getId().intValue()) + .shopId(mission.getShop().getId().intValue()) + .shopName(mission.getShop().getShopName()) + .shopCategory(mission.getShop().getShopCategory().getShopCategoryName()) + .missionPay(mission.getMissionPay()) + .missionPoint(mission.getMissionPoint()) + .createDate(mission.getMissionCreateTime() != null ? mission.getMissionCreateTime().toLocalDate() : null) + .build(); } public static MissionResDTO.GetHome toGetHome(long clearMissionCnt, List missionList, boolean hasNext) { @@ -30,22 +31,28 @@ public static MissionResDTO.GetHome toGetHome(long clearMissionCnt, List missionList, boolean hasNext) { + public static MissionResDTO.GetMyMission toGetMyMission(Page page) { + List missionList = page.getContent().stream() + .map(MissionConverter::toMyMissionItem) + .toList(); return MissionResDTO.GetMyMission.builder() .missionList(missionList) - .hasNext(hasNext) + .currentPage(page.getNumber()) + .totalPage(page.getTotalPages()) + .totalCount(page.getTotalElements()) + .hasNext(page.hasNext()) .build(); } } diff --git a/jace/src/main/java/com/umcstudy/jace/domain/mission/dto/MissionResDTO.java b/jace/src/main/java/com/umcstudy/jace/domain/mission/dto/MissionResDTO.java index 5c69fe9..c584f92 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/mission/dto/MissionResDTO.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/mission/dto/MissionResDTO.java @@ -8,6 +8,7 @@ public class MissionResDTO { + @Builder public record MissionItem( Integer missionId, Integer shopId, @@ -25,6 +26,7 @@ public record GetHome( Boolean hasNext ){} + @Builder public record MyMissionItem( Long userMissionId, Integer missionId, @@ -39,6 +41,9 @@ public record MyMissionItem( @Builder public record GetMyMission( List missionList, + Integer currentPage, + Integer totalPage, + Long totalCount, Boolean hasNext ){} diff --git a/jace/src/main/java/com/umcstudy/jace/domain/mission/repository/MissionRepository.java b/jace/src/main/java/com/umcstudy/jace/domain/mission/repository/MissionRepository.java index a428153..aae4f0a 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/mission/repository/MissionRepository.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/mission/repository/MissionRepository.java @@ -11,7 +11,9 @@ public interface MissionRepository extends JpaRepository { @Query("SELECT m FROM Mission m " + - "WHERE m.shop.shopAddress LIKE %:region% " + + "JOIN FETCH m.shop s " + + "JOIN FETCH s.shopCategory " + + "WHERE s.shopAddress LIKE %:region% " + "AND (:cursorId IS NULL OR m.id < :cursorId) " + "ORDER BY m.id DESC") List findByRegionWithCursor( diff --git a/jace/src/main/java/com/umcstudy/jace/domain/mission/repository/MissionUserRepository.java b/jace/src/main/java/com/umcstudy/jace/domain/mission/repository/MissionUserRepository.java index e751a5e..0a10d3e 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/mission/repository/MissionUserRepository.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/mission/repository/MissionUserRepository.java @@ -2,26 +2,29 @@ import com.umcstudy.jace.domain.mission.entity.mapping.MissionUser; import com.umcstudy.jace.domain.mission.enums.MissionStatus; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import java.util.List; - public interface MissionUserRepository extends JpaRepository { long countByUser_IdAndMissionCondition(Long userId, MissionStatus missionCondition); - @Query("SELECT mu FROM MissionUser mu " + - "WHERE mu.user.id = :userId " + - "AND mu.missionCondition = :missionCondition " + - "AND (:cursorId IS NULL OR mu.id < :cursorId) " + - "ORDER BY mu.id DESC") - List findByUserIdAndConditionWithCursor( + @Query(value = "SELECT mu FROM MissionUser mu " + + "JOIN FETCH mu.mission m " + + "JOIN FETCH m.shop s " + + "JOIN FETCH s.shopCategory " + + "WHERE mu.user.id = :userId " + + "AND mu.missionCondition = :missionCondition " + + "ORDER BY mu.id DESC", + countQuery = "SELECT COUNT(mu) FROM MissionUser mu " + + "WHERE mu.user.id = :userId " + + "AND mu.missionCondition = :missionCondition") + Page findByUserIdAndCondition( @Param("userId") Long userId, @Param("missionCondition") MissionStatus missionCondition, - @Param("cursorId") Long cursorId, Pageable pageable ); } diff --git a/jace/src/main/java/com/umcstudy/jace/domain/mission/service/MissionService.java b/jace/src/main/java/com/umcstudy/jace/domain/mission/service/MissionService.java index 2910d4a..4435769 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/mission/service/MissionService.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/mission/service/MissionService.java @@ -7,14 +7,14 @@ import com.umcstudy.jace.domain.mission.enums.MissionStatus; import com.umcstudy.jace.domain.mission.repository.MissionRepository; import com.umcstudy.jace.domain.mission.repository.MissionUserRepository; +import com.umcstudy.jace.global.security.SecurityUtils; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -25,7 +25,7 @@ public class MissionService { @Transactional(readOnly = true) public MissionResDTO.GetHome getHome(String region, Long cursorId, int size) { - Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + Long userId = SecurityUtils.getCurrentUserId(); long clearMissionCnt = missionUserRepository.countByUser_IdAndMissionCondition(userId, MissionStatus.SUCCESS); @@ -37,27 +37,19 @@ public MissionResDTO.GetHome getHome(String region, Long cursorId, int size) { List missionList = missions.stream() .map(MissionConverter::toMissionItem) - .collect(Collectors.toList()); + .toList(); return MissionConverter.toGetHome(clearMissionCnt, missionList, hasNext); } @Transactional(readOnly = true) - public MissionResDTO.GetMyMission getMyMission(MissionStatus missionCondition, Long cursorId, int size) { - Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + public MissionResDTO.GetMyMission getMyMission(MissionStatus missionCondition, int page, int size) { + Long userId = SecurityUtils.getCurrentUserId(); - List missionUsers = missionUserRepository.findByUserIdAndConditionWithCursor( - userId, missionCondition, cursorId, PageRequest.of(0, size + 1)); - boolean hasNext = missionUsers.size() > size; - if (hasNext) { - missionUsers = missionUsers.subList(0, size); - } - - List missionList = missionUsers.stream() - .map(MissionConverter::toMyMissionItem) - .collect(Collectors.toList()); + Page missionUsers = missionUserRepository.findByUserIdAndCondition( + userId, missionCondition, PageRequest.of(page, size)); - return MissionConverter.toGetMyMission(missionList, hasNext); + return MissionConverter.toGetMyMission(missionUsers); } public MissionResDTO.PatchMissionSuc patchMissionSuc(Integer missionId) { diff --git a/jace/src/main/java/com/umcstudy/jace/domain/review/controller/ReviewController.java b/jace/src/main/java/com/umcstudy/jace/domain/review/controller/ReviewController.java index c5d5b60..fc8edef 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/review/controller/ReviewController.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/review/controller/ReviewController.java @@ -2,11 +2,13 @@ import com.umcstudy.jace.domain.review.dto.ReviewReqDTO; import com.umcstudy.jace.domain.review.dto.ReviewResDTO; +import com.umcstudy.jace.domain.review.enums.ReviewSortType; import com.umcstudy.jace.domain.review.exception.code.ReviewSuccessCode; import com.umcstudy.jace.domain.review.service.ReviewService; import com.umcstudy.jace.global.apiPayload.ApiResponse; import com.umcstudy.jace.global.apiPayload.code.BaseSuccessCode; import lombok.RequiredArgsConstructor; +import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; @RestController @@ -16,6 +18,16 @@ public class ReviewController { private final ReviewService reviewService; + @GetMapping("/users/me/reviews") + public ApiResponse getMyReviews( + @RequestParam(defaultValue = "ID") ReviewSortType sortBy, + @RequestParam(required = false) Long cursorId, + @RequestParam(defaultValue = "10") int size + ) { + return ApiResponse.onSuccess(ReviewSuccessCode.MY_REVIEW_OK, + reviewService.getMyReviews(sortBy, cursorId, size)); + } + @GetMapping("shops/{shopId}/reviews") public ApiResponse getReviews( @PathVariable Long shopId, @@ -29,7 +41,7 @@ public ApiResponse getReviews( @PostMapping("shops/{shopId}/reviews") public ApiResponse postReviewWrite( @PathVariable Long shopId, - @RequestBody ReviewReqDTO.PostReviewWrite dto + @Valid @RequestBody ReviewReqDTO.PostReviewWrite dto ){ BaseSuccessCode code = ReviewSuccessCode.OK; return ApiResponse.onSuccess(code, reviewService.postReviewWrite(dto, shopId)); diff --git a/jace/src/main/java/com/umcstudy/jace/domain/review/converter/ReviewConverter.java b/jace/src/main/java/com/umcstudy/jace/domain/review/converter/ReviewConverter.java index bcb5edd..41c2275 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/review/converter/ReviewConverter.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/review/converter/ReviewConverter.java @@ -10,7 +10,6 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; -import java.util.stream.Collectors; public class ReviewConverter { @@ -40,17 +39,17 @@ public static ReviewResDTO.PostReviewWrite toPostReviewWrite(Review review) { public static ReviewResDTO.ReviewItem toReviewItem(Review review) { List imageUrls = review.getReviewImages().stream() - .map(image -> image.getReviewImageUrl()) - .collect(Collectors.toList()); - - return new ReviewResDTO.ReviewItem( - review.getId(), - review.getUser().getName(), - review.getReviewContent(), - review.getReviewScore(), - review.getReviewContentTime(), - imageUrls - ); + .map(ReviewImage::getReviewImageUrl) + .toList(); + + return ReviewResDTO.ReviewItem.builder() + .reviewId(review.getId()) + .userName(review.getUser().getName()) + .reviewContent(review.getReviewContent()) + .reviewScore(review.getReviewScore()) + .reviewContentTime(review.getReviewContentTime()) + .reviewImageUrls(imageUrls) + .build(); } public static ReviewResDTO.GetReviews toGetReviews(List reviewList, boolean hasNext) { @@ -59,4 +58,11 @@ public static ReviewResDTO.GetReviews toGetReviews(List .hasNext(hasNext) .build(); } + + public static ReviewResDTO.GetMyReviews toGetMyReviews(List reviewList, boolean hasNext) { + return ReviewResDTO.GetMyReviews.builder() + .reviewList(reviewList) + .hasNext(hasNext) + .build(); + } } diff --git a/jace/src/main/java/com/umcstudy/jace/domain/review/dto/ReviewReqDTO.java b/jace/src/main/java/com/umcstudy/jace/domain/review/dto/ReviewReqDTO.java index fe50eb4..741478f 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/review/dto/ReviewReqDTO.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/review/dto/ReviewReqDTO.java @@ -1,12 +1,17 @@ package com.umcstudy.jace.domain.review.dto; +import jakarta.validation.constraints.DecimalMax; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + import java.util.List; public class ReviewReqDTO { public record PostReviewWrite( - String reviewContents, + @NotBlank(message = "리뷰 내용은 필수입니다") @Size(max = 500, message = "리뷰 내용은 500자 이하여야 합니다") String reviewContents, List reviewImageUrl, - float reviewScore + @DecimalMin(value = "0.5", message = "평점은 0.5 이상이어야 합니다") @DecimalMax(value = "5.0", message = "평점은 5.0 이하여야 합니다") float reviewScore ) {} } diff --git a/jace/src/main/java/com/umcstudy/jace/domain/review/dto/ReviewResDTO.java b/jace/src/main/java/com/umcstudy/jace/domain/review/dto/ReviewResDTO.java index 030e4f1..222f8bd 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/review/dto/ReviewResDTO.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/review/dto/ReviewResDTO.java @@ -13,6 +13,7 @@ public record PostReviewWrite( Integer reviewId ) {} + @Builder public record ReviewItem( Long reviewId, String userName, @@ -27,4 +28,10 @@ public record GetReviews( List reviewList, Boolean hasNext ) {} + + @Builder + public record GetMyReviews( + List reviewList, + Boolean hasNext + ) {} } diff --git a/jace/src/main/java/com/umcstudy/jace/domain/review/entity/Review.java b/jace/src/main/java/com/umcstudy/jace/domain/review/entity/Review.java index 1ac5735..91fb24e 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/review/entity/Review.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/review/entity/Review.java @@ -6,6 +6,7 @@ import com.umcstudy.jace.domain.user.entity.User; import jakarta.persistence.*; import lombok.*; +import org.hibernate.annotations.BatchSize; import java.math.BigDecimal; import java.time.LocalDateTime; @@ -45,6 +46,7 @@ public class Review { @Column(name = "is_disabled", nullable = false) private Boolean isDisabled; + @BatchSize(size = 10) @Builder.Default @OneToMany(mappedBy = "review", cascade = CascadeType.ALL) private List reviewImages = new ArrayList<>(); diff --git a/jace/src/main/java/com/umcstudy/jace/domain/review/enums/ReviewSortType.java b/jace/src/main/java/com/umcstudy/jace/domain/review/enums/ReviewSortType.java new file mode 100644 index 0000000..8b3cb79 --- /dev/null +++ b/jace/src/main/java/com/umcstudy/jace/domain/review/enums/ReviewSortType.java @@ -0,0 +1,5 @@ +package com.umcstudy.jace.domain.review.enums; + +public enum ReviewSortType { + ID, SCORE +} diff --git a/jace/src/main/java/com/umcstudy/jace/domain/review/exception/code/ReviewSuccessCode.java b/jace/src/main/java/com/umcstudy/jace/domain/review/exception/code/ReviewSuccessCode.java index 24d388e..72e2092 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/review/exception/code/ReviewSuccessCode.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/review/exception/code/ReviewSuccessCode.java @@ -9,7 +9,8 @@ @RequiredArgsConstructor public enum ReviewSuccessCode implements BaseSuccessCode { OK(HttpStatus.OK, "REVIEW200_1", "리뷰 작성에 성공했습니다."), - GET_OK(HttpStatus.OK, "REVIEW200_2", "리뷰 목록 조회에 성공했습니다."),; + GET_OK(HttpStatus.OK, "REVIEW200_2", "리뷰 목록 조회에 성공했습니다."), + MY_REVIEW_OK(HttpStatus.OK, "REVIEW200_3", "내 리뷰 목록 조회에 성공했습니다."); private final HttpStatus status; private final String code; diff --git a/jace/src/main/java/com/umcstudy/jace/domain/review/repository/ReviewRepository.java b/jace/src/main/java/com/umcstudy/jace/domain/review/repository/ReviewRepository.java index b63bc70..077cb49 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/review/repository/ReviewRepository.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/review/repository/ReviewRepository.java @@ -11,6 +11,7 @@ public interface ReviewRepository extends JpaRepository { @Query("SELECT r FROM Review r " + + "JOIN FETCH r.user " + "WHERE r.shop.id = :shopId " + "AND r.isDisabled = false " + "AND (:cursorId IS NULL OR r.id < :cursorId) " + @@ -20,4 +21,30 @@ List findByShopIdWithCursor( @Param("cursorId") Long cursorId, Pageable pageable ); + + @Query("SELECT r FROM Review r " + + "JOIN FETCH r.user " + + "WHERE r.user.id = :userId " + + "AND r.isDisabled = false " + + "AND (:cursorId IS NULL OR r.id < :cursorId) " + + "ORDER BY r.id DESC") + List findByUserIdOrderByIdWithCursor( + @Param("userId") Long userId, + @Param("cursorId") Long cursorId, + Pageable pageable + ); + + @Query("SELECT r FROM Review r " + + "JOIN FETCH r.user " + + "WHERE r.user.id = :userId " + + "AND r.isDisabled = false " + + "AND (:cursorId IS NULL " + + " OR r.reviewScore < (SELECT r2.reviewScore FROM Review r2 WHERE r2.id = :cursorId) " + + " OR (r.reviewScore = (SELECT r2.reviewScore FROM Review r2 WHERE r2.id = :cursorId) AND r.id < :cursorId)) " + + "ORDER BY r.reviewScore DESC, r.id DESC") + List findByUserIdOrderByScoreWithCursor( + @Param("userId") Long userId, + @Param("cursorId") Long cursorId, + Pageable pageable + ); } diff --git a/jace/src/main/java/com/umcstudy/jace/domain/review/service/ReviewService.java b/jace/src/main/java/com/umcstudy/jace/domain/review/service/ReviewService.java index a4efbc6..2fe624c 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/review/service/ReviewService.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/review/service/ReviewService.java @@ -6,20 +6,20 @@ import com.umcstudy.jace.domain.review.dto.ReviewReqDTO; import com.umcstudy.jace.domain.review.dto.ReviewResDTO; import com.umcstudy.jace.domain.review.entity.Review; +import com.umcstudy.jace.domain.review.enums.ReviewSortType; import com.umcstudy.jace.domain.review.exception.ReviewException; import com.umcstudy.jace.domain.review.exception.code.ReviewErrorCode; import com.umcstudy.jace.domain.review.repository.ReviewImageRepository; import com.umcstudy.jace.domain.review.repository.ReviewRepository; import com.umcstudy.jace.domain.user.entity.User; import com.umcstudy.jace.domain.user.repository.UserRepository; +import com.umcstudy.jace.global.security.SecurityUtils; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.List; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor @@ -32,7 +32,7 @@ public class ReviewService { @Transactional public ReviewResDTO.PostReviewWrite postReviewWrite(ReviewReqDTO.PostReviewWrite dto, Long shopId) { - Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + Long userId = SecurityUtils.getCurrentUserId(); User user = userRepository.findById(userId) .orElseThrow(() -> new ReviewException(ReviewErrorCode.USER_NOT_FOUND)); @@ -42,15 +42,37 @@ public ReviewResDTO.PostReviewWrite postReviewWrite(ReviewReqDTO.PostReviewWrite Review review = reviewRepository.save(ReviewConverter.toReview(shop, user, dto)); - if (dto.reviewImageUrl() != null) { - dto.reviewImageUrl().forEach(url -> - reviewImageRepository.save(ReviewConverter.toReviewImage(review, url)) + if (dto.reviewImageUrl() != null && !dto.reviewImageUrl().isEmpty()) { + reviewImageRepository.saveAll( + dto.reviewImageUrl().stream() + .map(url -> ReviewConverter.toReviewImage(review, url)) + .toList() ); } return ReviewConverter.toPostReviewWrite(review); } + @Transactional(readOnly = true) + public ReviewResDTO.GetMyReviews getMyReviews(ReviewSortType sortBy, Long cursorId, int size) { + Long userId = SecurityUtils.getCurrentUserId(); + + List reviews = sortBy == ReviewSortType.SCORE + ? reviewRepository.findByUserIdOrderByScoreWithCursor(userId, cursorId, PageRequest.of(0, size + 1)) + : reviewRepository.findByUserIdOrderByIdWithCursor(userId, cursorId, PageRequest.of(0, size + 1)); + + boolean hasNext = reviews.size() > size; + if (hasNext) { + reviews = reviews.subList(0, size); + } + + List reviewList = reviews.stream() + .map(ReviewConverter::toReviewItem) + .toList(); + + return ReviewConverter.toGetMyReviews(reviewList, hasNext); + } + @Transactional(readOnly = true) public ReviewResDTO.GetReviews getReviews(Long shopId, Long cursorId, int size) { List reviews = reviewRepository.findByShopIdWithCursor(shopId, cursorId, PageRequest.of(0, size + 1)); @@ -61,7 +83,7 @@ public ReviewResDTO.GetReviews getReviews(Long shopId, Long cursorId, int size) List reviewList = reviews.stream() .map(ReviewConverter::toReviewItem) - .collect(Collectors.toList()); + .toList(); return ReviewConverter.toGetReviews(reviewList, hasNext); } diff --git a/jace/src/main/java/com/umcstudy/jace/domain/user/controller/UserController.java b/jace/src/main/java/com/umcstudy/jace/domain/user/controller/UserController.java index 349ab8a..f8a978f 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/user/controller/UserController.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/user/controller/UserController.java @@ -8,6 +8,7 @@ import com.umcstudy.jace.global.apiPayload.ApiResponse; import com.umcstudy.jace.global.apiPayload.code.BaseSuccessCode; import lombok.RequiredArgsConstructor; +import jakarta.validation.Valid; import org.springframework.web.bind.annotation.*; @RestController @@ -20,14 +21,14 @@ public class UserController { @PostMapping("/auth/login") public ApiResponse socialLogin( - @RequestBody UserReqDTO.SocialLogin dto + @Valid @RequestBody UserReqDTO.SocialLogin dto ) { return ApiResponse.onSuccess(UserSuccessCode.LoginOK, socialLoginService.login(dto)); } @PostMapping("/auth/signup") public ApiResponse postSignup( - @RequestBody UserReqDTO.PostSignup dto + @Valid @RequestBody UserReqDTO.PostSignup dto ){ BaseSuccessCode code = UserSuccessCode.SignupOK; return ApiResponse.onSuccess(code, userService.postSignup(dto)); diff --git a/jace/src/main/java/com/umcstudy/jace/domain/user/converter/UserConverter.java b/jace/src/main/java/com/umcstudy/jace/domain/user/converter/UserConverter.java index f2de137..4960f6b 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/user/converter/UserConverter.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/user/converter/UserConverter.java @@ -16,9 +16,10 @@ public class UserConverter { public static User toUser(UserReqDTO.PostSignup dto) { + String email = (dto.email() != null && !dto.email().isBlank()) ? dto.email() : null; return User.builder() .name(dto.name()) - .email(dto.email()) + .email(email) .gender(dto.gender()) .birth(dto.birth()) .zipCode(String.valueOf(dto.zipcode())) @@ -79,9 +80,12 @@ public static UserResDTO.PostSignup toPostSignupRes(User user, String token) { } public static UserResDTO.GetMyPage toGetMyPage(User user, int pointBalance) { + String email = user.getEmail(); + boolean isRelayEmail = email != null && email.endsWith("@privaterelay.appleid.com"); return UserResDTO.GetMyPage.builder() .name(user.getName()) - .email(user.getEmail()) + .email(email) + .isRelayEmail(isRelayEmail) .pointBalance(pointBalance) .build(); } diff --git a/jace/src/main/java/com/umcstudy/jace/domain/user/dto/UserReqDTO.java b/jace/src/main/java/com/umcstudy/jace/domain/user/dto/UserReqDTO.java index 39e1aaf..01a76e0 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/user/dto/UserReqDTO.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/user/dto/UserReqDTO.java @@ -2,6 +2,8 @@ import com.umcstudy.jace.domain.user.enums.Gender; import com.umcstudy.jace.domain.user.enums.SocialProvider; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; import java.time.LocalDate; import java.util.List; @@ -9,26 +11,26 @@ public class UserReqDTO { public record PostSignup( - List termsList, - String name, - String email, - Gender gender, - LocalDate birth, - Integer zipcode, - String address, + @Valid @NotEmpty(message = "약관 목록은 필수입니다") List termsList, + @NotBlank(message = "이름은 필수입니다") @Size(max = 50, message = "이름은 50자 이하여야 합니다") String name, + @Email(message = "올바른 이메일 형식이 아닙니다") String email, + @NotNull(message = "성별은 필수입니다") Gender gender, + @NotNull(message = "생년월일은 필수입니다") @Past(message = "생년월일은 과거 날짜여야 합니다") LocalDate birth, + @NotNull(message = "우편번호는 필수입니다") Integer zipcode, + @NotBlank(message = "주소는 필수입니다") String address, String addressDtl, - List favoriteFoodList, - SocialProvider socialProvider, - String socialId + @NotEmpty(message = "선호 음식은 최소 1개 이상 선택해야 합니다") List favoriteFoodList, + @NotNull(message = "소셜 제공자는 필수입니다") SocialProvider socialProvider, + @NotBlank(message = "소셜 ID는 필수입니다") String socialId ){} public record TermsDTO( - Long termsId, - Boolean isAgree + @NotNull(message = "약관 ID는 필수입니다") Long termsId, + @NotNull(message = "약관 동의 여부는 필수입니다") Boolean isAgree ){} public record SocialLogin( - SocialProvider provider, - String socialAccessToken + @NotNull(message = "소셜 제공자는 필수입니다") SocialProvider provider, + @NotBlank(message = "소셜 액세스 토큰은 필수입니다") String socialAccessToken ) {} } diff --git a/jace/src/main/java/com/umcstudy/jace/domain/user/dto/UserResDTO.java b/jace/src/main/java/com/umcstudy/jace/domain/user/dto/UserResDTO.java index b4180b1..db5ec5b 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/user/dto/UserResDTO.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/user/dto/UserResDTO.java @@ -20,7 +20,8 @@ public record PostSignup( @Builder public record GetMyPage( String name, - String email, + String email, // Apple 이메일 숨기기 선택 시 null + Boolean isRelayEmail, // Apple 중계 이메일 여부 (xxx@privaterelay.appleid.com) Integer pointBalance ){} diff --git a/jace/src/main/java/com/umcstudy/jace/domain/user/entity/User.java b/jace/src/main/java/com/umcstudy/jace/domain/user/entity/User.java index dc18ba4..61ec603 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/user/entity/User.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/user/entity/User.java @@ -36,7 +36,7 @@ public class User { @Column(nullable = false) private LocalDate birth; - @Column(nullable = false, length = 50) + @Column(nullable = true, length = 50) private String email; @Column(name = "zip_code", nullable = false, length = 10) diff --git a/jace/src/main/java/com/umcstudy/jace/domain/user/service/UserService.java b/jace/src/main/java/com/umcstudy/jace/domain/user/service/UserService.java index 140eede..9513190 100644 --- a/jace/src/main/java/com/umcstudy/jace/domain/user/service/UserService.java +++ b/jace/src/main/java/com/umcstudy/jace/domain/user/service/UserService.java @@ -4,16 +4,22 @@ import com.umcstudy.jace.domain.user.converter.UserConverter; import com.umcstudy.jace.domain.user.dto.UserReqDTO; import com.umcstudy.jace.domain.user.dto.UserResDTO; +import com.umcstudy.jace.domain.user.entity.Food; +import com.umcstudy.jace.domain.user.entity.Term; import com.umcstudy.jace.domain.user.entity.User; import com.umcstudy.jace.domain.user.exception.UserException; import com.umcstudy.jace.domain.user.exception.code.UserErrorCode; import com.umcstudy.jace.domain.user.repository.*; import com.umcstudy.jace.global.security.JwtTokenProvider; +import com.umcstudy.jace.global.security.SecurityUtils; import lombok.RequiredArgsConstructor; -import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + @Service @RequiredArgsConstructor public class UserService { @@ -34,17 +40,31 @@ public UserResDTO.PostSignup postSignup(UserReqDTO.PostSignup dto) { userSocialRepository.save(UserConverter.toUserSocial(user, dto)); - dto.termsList().forEach(termsDto -> { - var term = termRepository.findById(termsDto.termsId()) - .orElseThrow(() -> new UserException(UserErrorCode.TERMS_NOT_FOUND)); - userTermRepository.save(UserConverter.toUserTerm(user, term, termsDto.isAgree())); - }); + // 약관: 1번 조회 + 1번 일괄 저장 + List termIds = dto.termsList().stream() + .map(UserReqDTO.TermsDTO::termsId) + .toList(); + Map termMap = termRepository.findAllById(termIds).stream() + .collect(Collectors.toMap(Term::getId, t -> t)); + if (termMap.size() != termIds.size()) { + throw new UserException(UserErrorCode.TERMS_NOT_FOUND); + } + userTermRepository.saveAll( + dto.termsList().stream() + .map(termsDto -> UserConverter.toUserTerm(user, termMap.get(termsDto.termsId()), termsDto.isAgree())) + .toList() + ); - dto.favoriteFoodList().forEach(foodId -> { - var food = foodRepository.findById(foodId) - .orElseThrow(() -> new UserException(UserErrorCode.FOOD_NOT_FOUND)); - userFoodRepository.save(UserConverter.toUserFood(user, food)); - }); + // 음식: 1번 조회 + 1번 일괄 저장 + List foods = foodRepository.findAllById(dto.favoriteFoodList()); + if (foods.size() != dto.favoriteFoodList().size()) { + throw new UserException(UserErrorCode.FOOD_NOT_FOUND); + } + userFoodRepository.saveAll( + foods.stream() + .map(food -> UserConverter.toUserFood(user, food)) + .toList() + ); pointRepository.save(UserConverter.toPoint(user)); userSettingRepository.save(UserConverter.toUserSetting(user)); @@ -55,7 +75,7 @@ public UserResDTO.PostSignup postSignup(UserReqDTO.PostSignup dto) { @Transactional(readOnly = true) public UserResDTO.GetMyPage getMyPage() { - Long userId = Long.parseLong(SecurityContextHolder.getContext().getAuthentication().getName()); + Long userId = SecurityUtils.getCurrentUserId(); User user = userRepository.findById(userId) .orElseThrow(() -> new UserException(UserErrorCode.USER_NOT_FOUND)); diff --git a/jace/src/main/java/com/umcstudy/jace/global/apiPayload/code/GeneralErrorCode.java b/jace/src/main/java/com/umcstudy/jace/global/apiPayload/code/GeneralErrorCode.java index b314969..9f4156f 100644 --- a/jace/src/main/java/com/umcstudy/jace/global/apiPayload/code/GeneralErrorCode.java +++ b/jace/src/main/java/com/umcstudy/jace/global/apiPayload/code/GeneralErrorCode.java @@ -8,6 +8,7 @@ @RequiredArgsConstructor public enum GeneralErrorCode implements BaseErrorCode{ BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON430_1","잘못된 요청입니다."), + VALIDATION_ERROR(HttpStatus.BAD_REQUEST, "COMMON400_1", "입력값 검증에 실패했습니다."), UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON401_1","인증되지 않았습니다."), FORBIDDEN(HttpStatus.FORBIDDEN, "COMMON403_1","접근이 금지되었습니다."), NOT_FOUND(HttpStatus.NOT_FOUND, "COMMON404_1","해당 리소스를 찾을 수 없습니다."), diff --git a/jace/src/main/java/com/umcstudy/jace/global/apiPayload/handler/GeneralExceptionAdvice.java b/jace/src/main/java/com/umcstudy/jace/global/apiPayload/handler/GeneralExceptionAdvice.java index a950b8c..ee82c49 100644 --- a/jace/src/main/java/com/umcstudy/jace/global/apiPayload/handler/GeneralExceptionAdvice.java +++ b/jace/src/main/java/com/umcstudy/jace/global/apiPayload/handler/GeneralExceptionAdvice.java @@ -6,16 +6,31 @@ import com.umcstudy.jace.global.apiPayload.exception.ProjectException; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import java.util.stream.Collectors; + @Slf4j @RestControllerAdvice public class GeneralExceptionAdvice { + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException(MethodArgumentNotValidException e) { + String message = e.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining(", ")); + log.warn("Validation failed: {}", message); + BaseErrorCode code = GeneralErrorCode.VALIDATION_ERROR; + return ResponseEntity.status(code.getStatus()) + .body(new ApiResponse<>(code.getCode(), message, null)); + } + @ExceptionHandler(ProjectException.class) public ResponseEntity> handleMemberException(ProjectException e) { BaseErrorCode errorCode = e.getErrorCode(); + log.warn("Business exception: code={}, message={}", errorCode.getCode(), errorCode.getMessage()); return ResponseEntity.status(errorCode.getStatus()) .body(ApiResponse.onFailure(errorCode, null)); } diff --git a/jace/src/main/java/com/umcstudy/jace/global/client/AppleOAuthClient.java b/jace/src/main/java/com/umcstudy/jace/global/client/AppleOAuthClient.java new file mode 100644 index 0000000..57a8387 --- /dev/null +++ b/jace/src/main/java/com/umcstudy/jace/global/client/AppleOAuthClient.java @@ -0,0 +1,84 @@ +package com.umcstudy.jace.global.client; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.umcstudy.jace.domain.user.enums.SocialProvider; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PublicKey; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +@Component +@RequiredArgsConstructor +public class AppleOAuthClient implements OAuthClient { + + private static final String APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private final RestClient restClient; + + @Override + @SuppressWarnings("unchecked") + public SocialUserInfo getUserInfo(String idToken) { + try { + // 1. JWT 헤더에서 kid 추출 (어떤 공개키로 서명됐는지 식별) + String[] parts = idToken.split("\\."); + String headerJson = new String(Base64.getUrlDecoder().decode(parts[0]), StandardCharsets.UTF_8); + Map header = OBJECT_MAPPER.readValue(headerJson, Map.class); + String kid = header.get("kid"); + + // 2. Apple 공개키 목록 가져오기 + Map jwksResponse = restClient.get() + .uri(APPLE_KEYS_URL) + .retrieve() + .body(new ParameterizedTypeReference>() {}); + + if (jwksResponse == null) throw new IllegalStateException("Failed to fetch Apple public keys"); + + List> keys = (List>) jwksResponse.get("keys"); + + // 3. kid가 일치하는 공개키 찾기 + Map matchingKey = keys.stream() + .filter(k -> kid.equals(k.get("kid"))) + .findFirst() + .orElseThrow(() -> new IllegalStateException("No matching Apple public key for kid: " + kid)); + + // 4. RSA 공개키 생성 (n: modulus, e: exponent) + BigInteger modulus = new BigInteger(1, Base64.getUrlDecoder().decode(matchingKey.get("n"))); + BigInteger exponent = new BigInteger(1, Base64.getUrlDecoder().decode(matchingKey.get("e"))); + PublicKey publicKey = KeyFactory.getInstance("RSA") + .generatePublic(new RSAPublicKeySpec(modulus, exponent)); + + // 5. id_token 서명 검증 및 claims 추출 + Claims claims = Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(idToken) + .getPayload(); + + String sub = claims.getSubject(); + String email = claims.get("email", String.class); + + // Apple은 최초 로그인 이후 id_token에 이름을 포함하지 않음 + return new SocialUserInfo(sub, email != null ? email : "", ""); + + } catch (Exception e) { + throw new IllegalStateException("Failed to verify Apple id_token: " + e.getMessage(), e); + } + } + + @Override + public SocialProvider getProvider() { + return SocialProvider.APPLE; + } +} diff --git a/jace/src/main/java/com/umcstudy/jace/global/client/GoogleOAuthClient.java b/jace/src/main/java/com/umcstudy/jace/global/client/GoogleOAuthClient.java index 0a4ecd8..d79960a 100644 --- a/jace/src/main/java/com/umcstudy/jace/global/client/GoogleOAuthClient.java +++ b/jace/src/main/java/com/umcstudy/jace/global/client/GoogleOAuthClient.java @@ -2,12 +2,10 @@ import com.umcstudy.jace.domain.user.enums.SocialProvider; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpEntity; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.RestClient; import java.util.Map; @@ -16,17 +14,18 @@ public class GoogleOAuthClient implements OAuthClient { private static final String USER_INFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"; - private final RestTemplate restTemplate; + private final RestClient restClient; @Override - @SuppressWarnings("unchecked") public SocialUserInfo getUserInfo(String accessToken) { - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(accessToken); - ResponseEntity response = restTemplate.exchange( - USER_INFO_URL, HttpMethod.GET, new HttpEntity<>(headers), Map.class); + Map body = restClient.get() + .uri(USER_INFO_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .body(new ParameterizedTypeReference>() {}); + + if (body == null) throw new IllegalStateException("Google API returned null body"); - Map body = response.getBody(); String id = (String) body.get("sub"); String email = (String) body.getOrDefault("email", ""); String name = (String) body.getOrDefault("name", ""); diff --git a/jace/src/main/java/com/umcstudy/jace/global/client/KakaoOAuthClient.java b/jace/src/main/java/com/umcstudy/jace/global/client/KakaoOAuthClient.java index e56e47f..e7fa472 100644 --- a/jace/src/main/java/com/umcstudy/jace/global/client/KakaoOAuthClient.java +++ b/jace/src/main/java/com/umcstudy/jace/global/client/KakaoOAuthClient.java @@ -2,12 +2,10 @@ import com.umcstudy.jace.domain.user.enums.SocialProvider; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpEntity; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.RestClient; import java.util.Map; @@ -16,19 +14,21 @@ public class KakaoOAuthClient implements OAuthClient { private static final String USER_INFO_URL = "https://kapi.kakao.com/v2/user/me"; - private final RestTemplate restTemplate; + private final RestClient restClient; @Override @SuppressWarnings("unchecked") public SocialUserInfo getUserInfo(String accessToken) { - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(accessToken); - ResponseEntity response = restTemplate.exchange( - USER_INFO_URL, HttpMethod.GET, new HttpEntity<>(headers), Map.class); + Map body = restClient.get() + .uri(USER_INFO_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .body(new ParameterizedTypeReference>() {}); + + if (body == null) throw new IllegalStateException("Kakao API returned null body"); - Map body = response.getBody(); String id = String.valueOf(body.get("id")); - Map kakaoAccount = (Map) body.get("kakao_account"); + Map kakaoAccount = (Map) body.getOrDefault("kakao_account", Map.of()); String email = (String) kakaoAccount.getOrDefault("email", ""); Map profile = (Map) kakaoAccount.get("profile"); String name = profile != null ? (String) profile.getOrDefault("nickname", "") : ""; diff --git a/jace/src/main/java/com/umcstudy/jace/global/client/NaverOAuthClient.java b/jace/src/main/java/com/umcstudy/jace/global/client/NaverOAuthClient.java index 65cbaaa..6b273b2 100644 --- a/jace/src/main/java/com/umcstudy/jace/global/client/NaverOAuthClient.java +++ b/jace/src/main/java/com/umcstudy/jace/global/client/NaverOAuthClient.java @@ -2,12 +2,10 @@ import com.umcstudy.jace.domain.user.enums.SocialProvider; import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpEntity; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; -import org.springframework.http.HttpMethod; -import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Component; -import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.RestClient; import java.util.Map; @@ -16,18 +14,22 @@ public class NaverOAuthClient implements OAuthClient { private static final String USER_INFO_URL = "https://openapi.naver.com/v1/nid/me"; - private final RestTemplate restTemplate; + private final RestClient restClient; @Override @SuppressWarnings("unchecked") public SocialUserInfo getUserInfo(String accessToken) { - HttpHeaders headers = new HttpHeaders(); - headers.setBearerAuth(accessToken); - ResponseEntity response = restTemplate.exchange( - USER_INFO_URL, HttpMethod.GET, new HttpEntity<>(headers), Map.class); + Map body = restClient.get() + .uri(USER_INFO_URL) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .body(new ParameterizedTypeReference>() {}); + + if (body == null) throw new IllegalStateException("Naver API returned null body"); - Map body = response.getBody(); Map responseData = (Map) body.get("response"); + if (responseData == null) throw new IllegalStateException("Naver API response missing 'response' field"); + String id = (String) responseData.get("id"); String email = (String) responseData.getOrDefault("email", ""); String name = (String) responseData.getOrDefault("name", ""); diff --git a/jace/src/main/java/com/umcstudy/jace/global/config/JacksonConfig.java b/jace/src/main/java/com/umcstudy/jace/global/config/JacksonConfig.java new file mode 100644 index 0000000..d74b2bf --- /dev/null +++ b/jace/src/main/java/com/umcstudy/jace/global/config/JacksonConfig.java @@ -0,0 +1,18 @@ +package com.umcstudy.jace.global.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JacksonConfig { + + @Bean + public ObjectMapper objectMapper() { + return new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + } +} diff --git a/jace/src/main/java/com/umcstudy/jace/global/config/RestClientConfig.java b/jace/src/main/java/com/umcstudy/jace/global/config/RestClientConfig.java new file mode 100644 index 0000000..3b65721 --- /dev/null +++ b/jace/src/main/java/com/umcstudy/jace/global/config/RestClientConfig.java @@ -0,0 +1,23 @@ +package com.umcstudy.jace.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +import java.time.Duration; + +@Configuration +public class RestClientConfig { + + @Bean + public RestClient restClient() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(Duration.ofSeconds(3)); + factory.setReadTimeout(Duration.ofSeconds(5)); + + return RestClient.builder() + .requestFactory(factory) + .build(); + } +} diff --git a/jace/src/main/java/com/umcstudy/jace/global/config/RestTemplateConfig.java b/jace/src/main/java/com/umcstudy/jace/global/config/RestTemplateConfig.java index 26c7dcd..f7f7b3e 100644 --- a/jace/src/main/java/com/umcstudy/jace/global/config/RestTemplateConfig.java +++ b/jace/src/main/java/com/umcstudy/jace/global/config/RestTemplateConfig.java @@ -1,14 +1,3 @@ package com.umcstudy.jace.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(); - } -} +// RestClient로 대체됨 - RestClientConfig 참고 diff --git a/jace/src/main/java/com/umcstudy/jace/global/config/SecurityConfig.java b/jace/src/main/java/com/umcstudy/jace/global/config/SecurityConfig.java index 57097cf..6010048 100644 --- a/jace/src/main/java/com/umcstudy/jace/global/config/SecurityConfig.java +++ b/jace/src/main/java/com/umcstudy/jace/global/config/SecurityConfig.java @@ -1,8 +1,9 @@ package com.umcstudy.jace.global.config; +import com.umcstudy.jace.global.security.JwtAccessDeniedHandler; +import com.umcstudy.jace.global.security.JwtAuthenticationEntryPoint; import com.umcstudy.jace.global.security.JwtAuthenticationFilter; import com.umcstudy.jace.global.security.JwtTokenProvider; -import com.umcstudy.jace.global.security.UserDetailsServiceImpl; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -19,7 +20,8 @@ public class SecurityConfig { private final JwtTokenProvider jwtTokenProvider; - private final UserDetailsServiceImpl userDetailsService; + private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; + private final JwtAccessDeniedHandler jwtAccessDeniedHandler; private static final String[] WHITELIST = { "/swagger-ui/**", @@ -34,14 +36,20 @@ public class SecurityConfig { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .csrf(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers(WHITELIST).permitAll() .anyRequest().authenticated() ) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(jwtAuthenticationEntryPoint) + .accessDeniedHandler(jwtAccessDeniedHandler) + ) .addFilterBefore( - new JwtAuthenticationFilter(jwtTokenProvider, userDetailsService), + new JwtAuthenticationFilter(jwtTokenProvider), UsernamePasswordAuthenticationFilter.class ); diff --git a/jace/src/main/java/com/umcstudy/jace/global/security/JwtAccessDeniedHandler.java b/jace/src/main/java/com/umcstudy/jace/global/security/JwtAccessDeniedHandler.java new file mode 100644 index 0000000..2b4a18e --- /dev/null +++ b/jace/src/main/java/com/umcstudy/jace/global/security/JwtAccessDeniedHandler.java @@ -0,0 +1,32 @@ +package com.umcstudy.jace.global.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.umcstudy.jace.global.apiPayload.ApiResponse; +import com.umcstudy.jace.global.apiPayload.code.GeneralErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + response.setStatus(HttpStatus.FORBIDDEN.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8"); + + ApiResponse body = ApiResponse.onFailure(GeneralErrorCode.FORBIDDEN, null); + response.getWriter().write(objectMapper.writeValueAsString(body)); + } +} diff --git a/jace/src/main/java/com/umcstudy/jace/global/security/JwtAuthenticationEntryPoint.java b/jace/src/main/java/com/umcstudy/jace/global/security/JwtAuthenticationEntryPoint.java new file mode 100644 index 0000000..b81c172 --- /dev/null +++ b/jace/src/main/java/com/umcstudy/jace/global/security/JwtAuthenticationEntryPoint.java @@ -0,0 +1,32 @@ +package com.umcstudy.jace.global.security; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.umcstudy.jace.global.apiPayload.ApiResponse; +import com.umcstudy.jace.global.apiPayload.code.GeneralErrorCode; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.setStatus(HttpStatus.UNAUTHORIZED.value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8"); + + ApiResponse body = ApiResponse.onFailure(GeneralErrorCode.UNAUTHORIZED, null); + response.getWriter().write(objectMapper.writeValueAsString(body)); + } +} diff --git a/jace/src/main/java/com/umcstudy/jace/global/security/JwtAuthenticationFilter.java b/jace/src/main/java/com/umcstudy/jace/global/security/JwtAuthenticationFilter.java index 1f7946e..bcf464c 100644 --- a/jace/src/main/java/com/umcstudy/jace/global/security/JwtAuthenticationFilter.java +++ b/jace/src/main/java/com/umcstudy/jace/global/security/JwtAuthenticationFilter.java @@ -6,19 +6,19 @@ import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; +import java.util.List; @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; - private final UserDetailsServiceImpl userDetailsService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) @@ -26,9 +26,15 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse String token = resolveToken(request); if (token != null && jwtTokenProvider.validateToken(token)) { String userId = jwtTokenProvider.getUserId(token); - UserDetails userDetails = userDetailsService.loadUserByUsername(userId); + String role = jwtTokenProvider.getRole(token); + + // DB 조회 없이 토큰 클레임만으로 Authentication 생성 UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); + new UsernamePasswordAuthenticationToken( + userId, + null, + List.of(new SimpleGrantedAuthority("ROLE_" + role)) + ); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authentication); } diff --git a/jace/src/main/java/com/umcstudy/jace/global/security/JwtTokenProvider.java b/jace/src/main/java/com/umcstudy/jace/global/security/JwtTokenProvider.java index daa8882..4c81159 100644 --- a/jace/src/main/java/com/umcstudy/jace/global/security/JwtTokenProvider.java +++ b/jace/src/main/java/com/umcstudy/jace/global/security/JwtTokenProvider.java @@ -1,17 +1,24 @@ package com.umcstudy.jace.global.security; -import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.*; import io.jsonwebtoken.io.Decoders; import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.security.SignatureException; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import javax.crypto.SecretKey; import java.util.Date; +@Slf4j @Component public class JwtTokenProvider { + private static final String TOKEN_TYPE = "ACCESS"; + private static final String CLAIM_TYPE = "type"; + private static final String CLAIM_ROLE = "role"; + @Value("${jwt.secret}") private String secretKey; @@ -25,6 +32,8 @@ private SecretKey getSigningKey() { public String generateToken(Long userId) { return Jwts.builder() .subject(String.valueOf(userId)) + .claim(CLAIM_TYPE, TOKEN_TYPE) + .claim(CLAIM_ROLE, "USER") .issuedAt(new Date()) .expiration(new Date(System.currentTimeMillis() + expirationMs)) .signWith(getSigningKey()) @@ -32,23 +41,34 @@ public String generateToken(Long userId) { } public String getUserId(String token) { - return Jwts.parser() - .verifyWith(getSigningKey()) - .build() - .parseSignedClaims(token) - .getPayload() - .getSubject(); + return getClaims(token).getSubject(); + } + + public String getRole(String token) { + return getClaims(token).get(CLAIM_ROLE, String.class); } public boolean validateToken(String token) { try { - Jwts.parser() - .verifyWith(getSigningKey()) - .build() - .parseSignedClaims(token); - return true; + Claims claims = getClaims(token); + return TOKEN_TYPE.equals(claims.get(CLAIM_TYPE, String.class)); + } catch (ExpiredJwtException e) { + log.warn("JWT token expired"); + } catch (MalformedJwtException e) { + log.warn("Malformed JWT token"); + } catch (SignatureException e) { + log.warn("Invalid JWT signature"); } catch (Exception e) { - return false; + log.warn("Invalid JWT token: {}", e.getMessage()); } + return false; + } + + private Claims getClaims(String token) { + return Jwts.parser() + .verifyWith(getSigningKey()) + .build() + .parseSignedClaims(token) + .getPayload(); } } diff --git a/jace/src/main/java/com/umcstudy/jace/global/security/SecurityUtils.java b/jace/src/main/java/com/umcstudy/jace/global/security/SecurityUtils.java new file mode 100644 index 0000000..f570b54 --- /dev/null +++ b/jace/src/main/java/com/umcstudy/jace/global/security/SecurityUtils.java @@ -0,0 +1,14 @@ +package com.umcstudy.jace.global.security; + +import org.springframework.security.core.context.SecurityContextHolder; + +public class SecurityUtils { + + private SecurityUtils() {} + + public static Long getCurrentUserId() { + return Long.parseLong( + SecurityContextHolder.getContext().getAuthentication().getName() + ); + } +} diff --git a/jace/src/main/java/com/umcstudy/jace/global/security/UserDetailsServiceImpl.java b/jace/src/main/java/com/umcstudy/jace/global/security/UserDetailsServiceImpl.java index bdfca0a..5562b4c 100644 --- a/jace/src/main/java/com/umcstudy/jace/global/security/UserDetailsServiceImpl.java +++ b/jace/src/main/java/com/umcstudy/jace/global/security/UserDetailsServiceImpl.java @@ -1,7 +1,5 @@ package com.umcstudy.jace.global.security; -import com.umcstudy.jace.domain.user.repository.UserRepository; -import lombok.RequiredArgsConstructor; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; @@ -11,15 +9,12 @@ import java.util.Collections; @Service -@RequiredArgsConstructor public class UserDetailsServiceImpl implements UserDetailsService { - private final UserRepository userRepository; - + // JWT 필터에서 DB 조회 없이 직접 Authentication 생성하므로 이 메서드는 호출되지 않음 + // Spring Boot 자동 설정의 임시 계정 생성 방지용으로만 존재 @Override public UserDetails loadUserByUsername(String userId) throws UsernameNotFoundException { - userRepository.findById(Long.parseLong(userId)) - .orElseThrow(() -> new UsernameNotFoundException("User not found: " + userId)); return new User(userId, "", Collections.emptyList()); } }