diff --git a/src/main/java/com/moongeul/backend/api/bookshelf/controller/DoneReadBookshelfController.java b/src/main/java/com/moongeul/backend/api/bookshelf/controller/DoneReadBookshelfController.java index 6452c13..a9ff51c 100644 --- a/src/main/java/com/moongeul/backend/api/bookshelf/controller/DoneReadBookshelfController.java +++ b/src/main/java/com/moongeul/backend/api/bookshelf/controller/DoneReadBookshelfController.java @@ -1,8 +1,10 @@ package com.moongeul.backend.api.bookshelf.controller; import com.moongeul.backend.api.bookshelf.dto.DoneReadCalendarResponseDTO; +import com.moongeul.backend.api.bookshelf.dto.DoneReadRatingSummaryResponseDTO; import com.moongeul.backend.api.bookshelf.dto.DoneReadBookshelfResponseDTO; import com.moongeul.backend.api.bookshelf.service.DoneReadBookshelfService; +import com.moongeul.backend.api.post.dto.CategoryPostListResponseDTO; import com.moongeul.backend.common.response.ApiResponse; import com.moongeul.backend.common.response.SuccessStatus; import io.swagger.v3.oas.annotations.Operation; @@ -65,4 +67,49 @@ public ResponseEntity> getDoneReadCalen userDetails.getUsername(), year, month); return ApiResponse.success(SuccessStatus.GET_DONE_READ_CALENDAR_SUCCESS, doneReadCalendar); } + + @Operation( + summary = "읽은 책 별점 요약 조회 API", + description = "사용자가 기록한 총 책 수와 별점 구간(1.0~1.4, 1.5~1.9, ... , 4.5~5.0)별 기록 수를 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "읽은 책 별점 요약 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없습니다.") + }) + @GetMapping("/rating-summary") + public ResponseEntity> getDoneReadRatingSummary( + @AuthenticationPrincipal UserDetails userDetails) { + + DoneReadRatingSummaryResponseDTO doneReadRatingSummaryResponseDTO = doneReadBookshelfService.getDoneReadRatingSummary(userDetails.getUsername()); + return ApiResponse.success(SuccessStatus.GET_DONE_READ_RATING_SUMMARY_SUCCESS, doneReadRatingSummaryResponseDTO); + } + + @Operation( + summary = "읽은 책 별점 구간 상세 조회 API", + description = "별점 구간(range)에 해당하는 기록 리스트를 조회합니다. " + + "응답 형식은 카테고리별 기록 리스트 조회와 동일합니다." + + "

예시 range: 1.0~1.4, 1.5~1.9, ... , 4.5~5.0" + + "

[enum] 정렬 옵션 (sortBy):" + + "
- LATEST: 최신순 (기본값)" + + "
- OLDEST: 오래된순" + + "
- RATING_HIGH: 평점 높은순" + + "
- RATING_LOW: 평점 낮은순" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "읽은 책 별점 구간 상세 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "유효하지 않은 별점 구간입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자를 찾을 수 없습니다.") + }) + @GetMapping("/rating-summary/details") + public ResponseEntity> getDoneReadRatingDetail( + @AuthenticationPrincipal UserDetails userDetails, + @RequestParam String range, + @RequestParam(defaultValue = "LATEST") String sortBy, + @RequestParam(defaultValue = "1") @Min(value = 1, message = "페이지는 1 이상이어야 합니다.") Integer page, + @RequestParam(defaultValue = "10") @Min(value = 1, message = "한 페이지당 개수는 1 이상이어야 합니다.") Integer size) { + + CategoryPostListResponseDTO categoryPostListResponseDTO = + doneReadBookshelfService.getDoneReadRatingDetail(userDetails.getUsername(), range, sortBy, page, size); + return ApiResponse.success(SuccessStatus.GET_DONE_READ_RATING_DETAIL_SUCCESS, categoryPostListResponseDTO); + } } diff --git a/src/main/java/com/moongeul/backend/api/bookshelf/dto/DoneReadRatingRangeCountDTO.java b/src/main/java/com/moongeul/backend/api/bookshelf/dto/DoneReadRatingRangeCountDTO.java new file mode 100644 index 0000000..e59c24d --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/bookshelf/dto/DoneReadRatingRangeCountDTO.java @@ -0,0 +1,15 @@ +package com.moongeul.backend.api.bookshelf.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DoneReadRatingRangeCountDTO { + private String range; // 별점 구간 (예: 1.0~1.4) + private Integer count; // 구간별 기록 수 +} diff --git a/src/main/java/com/moongeul/backend/api/bookshelf/dto/DoneReadRatingSummaryResponseDTO.java b/src/main/java/com/moongeul/backend/api/bookshelf/dto/DoneReadRatingSummaryResponseDTO.java new file mode 100644 index 0000000..9582def --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/bookshelf/dto/DoneReadRatingSummaryResponseDTO.java @@ -0,0 +1,17 @@ +package com.moongeul.backend.api.bookshelf.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class DoneReadRatingSummaryResponseDTO { + private Long totalBooks; // 사용자가 기록한 총 책 수 + private List data; // 별점 구간별 기록 수 +} diff --git a/src/main/java/com/moongeul/backend/api/bookshelf/repository/DoneReadBookshelfRepository.java b/src/main/java/com/moongeul/backend/api/bookshelf/repository/DoneReadBookshelfRepository.java index cd897df..8dd4383 100644 --- a/src/main/java/com/moongeul/backend/api/bookshelf/repository/DoneReadBookshelfRepository.java +++ b/src/main/java/com/moongeul/backend/api/bookshelf/repository/DoneReadBookshelfRepository.java @@ -17,5 +17,6 @@ public interface DoneReadBookshelfRepository extends JpaRepository findByMemberAndBook(@Param("member") Member member, @Param("book") Book book); -} + long countByMember(Member member); +} diff --git a/src/main/java/com/moongeul/backend/api/bookshelf/service/DoneReadBookshelfService.java b/src/main/java/com/moongeul/backend/api/bookshelf/service/DoneReadBookshelfService.java index c005b1a..7edaa62 100644 --- a/src/main/java/com/moongeul/backend/api/bookshelf/service/DoneReadBookshelfService.java +++ b/src/main/java/com/moongeul/backend/api/bookshelf/service/DoneReadBookshelfService.java @@ -5,12 +5,19 @@ import com.moongeul.backend.api.bookshelf.dto.DoneReadCalendarResponseDTO; import com.moongeul.backend.api.bookshelf.dto.DoneReadBookshelfItemDTO; import com.moongeul.backend.api.bookshelf.dto.DoneReadBookshelfResponseDTO; +import com.moongeul.backend.api.bookshelf.dto.DoneReadRatingRangeCountDTO; +import com.moongeul.backend.api.bookshelf.dto.DoneReadRatingSummaryResponseDTO; import com.moongeul.backend.api.bookshelf.entity.DoneReadBookshelf; import com.moongeul.backend.api.bookshelf.repository.DoneReadBookshelfRepository; import com.moongeul.backend.api.member.entity.Member; import com.moongeul.backend.api.member.repository.MemberRepository; +import com.moongeul.backend.api.post.dto.CategoryPostListResponseDTO; +import com.moongeul.backend.api.post.dto.PostDTO; import com.moongeul.backend.api.post.entity.Post; +import com.moongeul.backend.api.post.entity.Quote; import com.moongeul.backend.api.post.repository.PostRepository; +import com.moongeul.backend.api.post.repository.QuoteRepository; +import com.moongeul.backend.common.exception.BadRequestException; import com.moongeul.backend.common.exception.NotFoundException; import com.moongeul.backend.common.response.ErrorStatus; import lombok.RequiredArgsConstructor; @@ -18,15 +25,18 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.time.LocalDate; import java.time.LocalDateTime; import java.time.YearMonth; +import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; @Slf4j @Service @@ -36,6 +46,13 @@ public class DoneReadBookshelfService { private final DoneReadBookshelfRepository doneReadBookshelfRepository; private final MemberRepository memberRepository; private final PostRepository postRepository; + private final QuoteRepository quoteRepository; + private static final String[] RATING_RANGES = { + "1.0~1.4", "1.5~1.9", "2.0~2.4", "2.5~2.9", + "3.0~3.4", "3.5~3.9", "4.0~4.4", "4.5~5.0" + }; + private static final double[] RANGE_STARTS = {1.0, 1.5, 2.0, 2.5, 3.0, 3.5, 4.0, 4.5}; + private static final double[] RANGE_ENDS = {1.4, 1.9, 2.4, 2.9, 3.4, 3.9, 4.4, 5.0}; @Transactional(readOnly = true) public DoneReadBookshelfResponseDTO getDoneReadBooks(String email, Integer page, Integer size) { @@ -137,6 +154,75 @@ public DoneReadCalendarResponseDTO getDoneReadCalendar(String email, Integer yea .build(); } + // 읽은 책 별점 요약 조회 + @Transactional(readOnly = true) + public DoneReadRatingSummaryResponseDTO getDoneReadRatingSummary(String email) { + + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundException(ErrorStatus.USER_NOTFOUND_EXCEPTION.getMessage())); + + long totalBooks = doneReadBookshelfRepository.countByMember(member); + List ratings = postRepository.findRatingsByMember(member); + + int[] counts = new int[RATING_RANGES.length]; + for (Double rating : ratings) { + if (rating == null) { + continue; + } + + for (int i = 0; i < RATING_RANGES.length; i++) { + if (rating >= RANGE_STARTS[i] && rating <= RANGE_ENDS[i] + 1e-9) { + counts[i]++; + break; + } + } + } + + List data = new ArrayList<>(); + for (int i = 0; i < RATING_RANGES.length; i++) { + data.add(DoneReadRatingRangeCountDTO.builder() + .range(RATING_RANGES[i]) + .count(counts[i]) + .build()); + } + + return DoneReadRatingSummaryResponseDTO.builder() + .totalBooks(totalBooks) + .data(data) + .build(); + } + + // 읽은 책 별점 구간 상세 조회 + @Transactional(readOnly = true) + public CategoryPostListResponseDTO getDoneReadRatingDetail(String email, String range, String sortBy, Integer page, Integer size) { + + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundException(ErrorStatus.USER_NOTFOUND_EXCEPTION.getMessage())); + + int rangeIndex = findRangeIndex(range); + Pageable pageable = PageRequest.of(page - 1, size, resolveSort(sortBy)); + + Page postPage = postRepository.findByMemberAndRatingBetween( + member, + RANGE_STARTS[rangeIndex], + RANGE_ENDS[rangeIndex], + pageable + ); + + List postList = postPage.getContent().stream() + .map(this::convertToPostDTO) + .collect(Collectors.toList()); + + return CategoryPostListResponseDTO.builder() + .total(postPage.getTotalElements()) + .page(page) + .size(size) + .totalPages(postPage.getTotalPages()) + .isLast(postPage.isLast()) + .data(postList) + .build(); + } + private DoneReadBookshelfItemDTO convertToItemDTO(DoneReadBookshelf doneReadBookshelf) { Book book = doneReadBookshelf.getArticle().getBook(); @@ -152,6 +238,86 @@ private DoneReadBookshelfItemDTO convertToItemDTO(DoneReadBookshelf doneReadBook .build(); } + private PostDTO convertToPostDTO(Post post) { + + Book book = post.getBook(); + + PostDTO.MemberInfo memberInfo = PostDTO.MemberInfo.builder() + .memberId(post.getMember().getId()) + .nickname(post.getMember().getNickname()) + .profileImage(post.getMember().getProfileImage()) + .readingTasteType(post.getMember().getReadingTasteType()) + .build(); + + PostDTO.BookInfo bookInfo = PostDTO.BookInfo.builder() + .isbn(book.getIsbn()) + .bookImage(book.getBookImage()) + .title(book.getTitle()) + .author(book.getAuthor()) + .publisher(book.getPublisher()) + .pubdate(book.getPubdate()) + .ratingAverage(book.getRatingAverage()) + .build(); + + List quotes = quoteRepository.findByPostId(post.getId()); + List quoteDTOList = quotes.stream() + .map(quote -> PostDTO.QuoteDTO.builder() + .quoteContent(quote.getQuoteContent()) + .pageNumber(quote.getPageNumber()) + .build()) + .collect(Collectors.toList()); + + PostDTO.LikesCnt likesCnt = PostDTO.LikesCnt.builder() + .relatableCount(post.getRelatableCount()) + .sameTasteCount(post.getSameTasteCount()) + .impressiveExpressionCount(post.getImpressiveExpressionCount()) + .wantToReadCount(post.getWantToReadCount()) + .helpfulCount(post.getHelpfulCount()) + .build(); + + return PostDTO.builder() + .postId(post.getId()) + .memberInfo(memberInfo) + .created(post.getCreatedAt()) + .bookInfo(bookInfo) + .rating(post.getRating()) + .content(post.getContent()) + .readDate(post.getReadDate()) + .quotesCnt(quoteDTOList.size()) + .quotes(quoteDTOList) + .likesCnt(likesCnt) + .build(); + } + + private Sort resolveSort(String sortBy) { + if (sortBy == null) { + return Sort.by(Sort.Order.desc("createdAt")); + } + + return switch (sortBy.toUpperCase()) { + case "OLDEST" -> Sort.by(Sort.Order.asc("createdAt")); + case "RATING_HIGH" -> Sort.by(Sort.Order.desc("rating"), Sort.Order.desc("createdAt")); + case "RATING_LOW" -> Sort.by(Sort.Order.asc("rating"), Sort.Order.desc("createdAt")); + case "LATEST" -> Sort.by(Sort.Order.desc("createdAt")); + default -> Sort.by(Sort.Order.desc("createdAt")); + }; + } + + private int findRangeIndex(String range) { + if (range == null) { + throw new BadRequestException(ErrorStatus.INVALID_RATING_RANGE_EXCEPTION.getMessage()); + } + + String normalizedRange = range.replace(" ", ""); + for (int i = 0; i < RATING_RANGES.length; i++) { + if (RATING_RANGES[i].equals(normalizedRange)) { + return i; + } + } + + throw new BadRequestException(ErrorStatus.INVALID_RATING_RANGE_EXCEPTION.getMessage()); + } + private static class CalendarDayAggregate { private Long postId; private String isbn; diff --git a/src/main/java/com/moongeul/backend/api/post/repository/PostRepository.java b/src/main/java/com/moongeul/backend/api/post/repository/PostRepository.java index 1e8c301..a7464aa 100644 --- a/src/main/java/com/moongeul/backend/api/post/repository/PostRepository.java +++ b/src/main/java/com/moongeul/backend/api/post/repository/PostRepository.java @@ -79,4 +79,10 @@ List findCalendarPostsByMemberAndReadDateBetweenOrderByReadDateAscCreatedA @Param("member") Member member, @Param("startDate") LocalDate startDate, @Param("endDate") LocalDate endDate); + + // 사용자 별점 목록 조회 (null 제외) + @Query("SELECT p.rating FROM Post p WHERE p.member = :member AND p.rating IS NOT NULL") + List findRatingsByMember(@Param("member") Member member); + + Page findByMemberAndRatingBetween(Member member, Double startRating, Double endRating, Pageable pageable); } diff --git a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java index c032245..ee489d7 100644 --- a/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/ErrorStatus.java @@ -62,6 +62,7 @@ public enum ErrorStatus { NO_FOLLOW_RELATIONSHIP(HttpStatus.BAD_REQUEST, "팔로우 관계가 존재하지 않습니다."), EXISTS_FOLLOW_ACCEPTED(HttpStatus.BAD_REQUEST, "이미 팔로우하고 있는 사용자입니다."), EXISTS_FOLLOW_PENDING(HttpStatus.BAD_REQUEST, "이미 팔로우 요청을 보냈습니다. 승인을 기다려주세요."), + INVALID_RATING_RANGE_EXCEPTION(HttpStatus.BAD_REQUEST, "유효하지 않은 별점 구간입니다."), BAD_FOLLOW_PROCESS_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 팔로우 처리 요청입니다."), PRIVACY_FORBIDDEN_EXCEPTION(HttpStatus.FORBIDDEN, "해당 사용자의 정보는 공개되지 않습니다."), diff --git a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java index 99f63ae..eb483df 100644 --- a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java @@ -46,6 +46,8 @@ public enum SuccessStatus { GET_WISH_READ_BOOKS_SUCCESS(HttpStatus.OK, "읽고 싶은 책장 조회 성공"), GET_DONE_READ_BOOKS_SUCCESS(HttpStatus.OK, "읽은 책장 조회 성공"), GET_DONE_READ_CALENDAR_SUCCESS(HttpStatus.OK, "읽은 책 캘린더 조회 성공"), + GET_DONE_READ_RATING_SUMMARY_SUCCESS(HttpStatus.OK, "읽은 책 별점 요약 조회 성공"), + GET_DONE_READ_RATING_DETAIL_SUCCESS(HttpStatus.OK, "읽은 책 별점 구간 상세 조회 성공"), /* POST */ GET_ALL_POST_SUCCESS(HttpStatus.OK, "기록(게시글) 전체 조회 성공"),