From 6bf17218c6cc14dc60cd4be39a108f69db3191a7 Mon Sep 17 00:00:00 2001 From: rhkr8521 Date: Tue, 17 Feb 2026 11:55:19 +0900 Subject: [PATCH] =?UTF-8?q?[FEAT]=20=EC=9D=BD=EC=9D=80=EC=B1=85=20?= =?UTF-8?q?=EC=BA=98=EB=A6=B0=EB=8D=94=20=EC=A1=B0=ED=9A=8C=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DoneReadBookshelfController.java | 24 ++++- .../bookshelf/dto/DoneReadCalendarDayDTO.java | 18 ++++ .../dto/DoneReadCalendarResponseDTO.java | 18 ++++ .../service/DoneReadBookshelfService.java | 96 ++++++++++++++++++- .../api/post/repository/PostRepository.java | 11 +++ .../common/response/SuccessStatus.java | 1 + 6 files changed, 166 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/moongeul/backend/api/bookshelf/dto/DoneReadCalendarDayDTO.java create mode 100644 src/main/java/com/moongeul/backend/api/bookshelf/dto/DoneReadCalendarResponseDTO.java 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 4a55786..6452c13 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,5 +1,6 @@ package com.moongeul.backend.api.bookshelf.controller; +import com.moongeul.backend.api.bookshelf.dto.DoneReadCalendarResponseDTO; import com.moongeul.backend.api.bookshelf.dto.DoneReadBookshelfResponseDTO; import com.moongeul.backend.api.bookshelf.service.DoneReadBookshelfService; import com.moongeul.backend.common.response.ApiResponse; @@ -7,6 +8,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -42,5 +44,25 @@ public ResponseEntity> getDoneReadBook DoneReadBookshelfResponseDTO doneReadBookshelfResponseDTO = doneReadBookshelfService.getDoneReadBooks(userDetails.getUsername(), page, size); return ApiResponse.success(SuccessStatus.GET_DONE_READ_BOOKS_SUCCESS, doneReadBookshelfResponseDTO); } -} + @Operation( + summary = "읽은 책 캘린더 조회 API", + description = "입력한 연/월 기준으로 일자별 읽은 책 정보를 조회합니다. " + + "같은 날짜에 여러 권이 있으면 가장 최근에 작성된 기록의 표지를 대표로 반환하고 count로 개수를 제공합니다." + ) + @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("/calendar") + public ResponseEntity> getDoneReadCalendar( + @AuthenticationPrincipal UserDetails userDetails, + @RequestParam @Min(value = 1, message = "연도는 1 이상이어야 합니다.") Integer year, + @RequestParam @Min(value = 1, message = "월은 1 이상이어야 합니다.") @Max(value = 12, message = "월은 12 이하여야 합니다.") Integer month) { + + DoneReadCalendarResponseDTO doneReadCalendar = doneReadBookshelfService.getDoneReadCalendar( + userDetails.getUsername(), year, month); + return ApiResponse.success(SuccessStatus.GET_DONE_READ_CALENDAR_SUCCESS, doneReadCalendar); + } +} diff --git a/src/main/java/com/moongeul/backend/api/bookshelf/dto/DoneReadCalendarDayDTO.java b/src/main/java/com/moongeul/backend/api/bookshelf/dto/DoneReadCalendarDayDTO.java new file mode 100644 index 0000000..4be3c08 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/bookshelf/dto/DoneReadCalendarDayDTO.java @@ -0,0 +1,18 @@ +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 DoneReadCalendarDayDTO { + private Integer day; // 일자 + private Long postId; // 대표 기록 ID + private String isbn; // 대표 도서 ISBN + private String bookImage; // 대표 도서 표지 + private Integer count; // 해당 일자의 읽은 책 수 +} diff --git a/src/main/java/com/moongeul/backend/api/bookshelf/dto/DoneReadCalendarResponseDTO.java b/src/main/java/com/moongeul/backend/api/bookshelf/dto/DoneReadCalendarResponseDTO.java new file mode 100644 index 0000000..7d7443b --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/bookshelf/dto/DoneReadCalendarResponseDTO.java @@ -0,0 +1,18 @@ +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 DoneReadCalendarResponseDTO { + private Integer year; // 조회 연도 + private Integer month; // 조회 월 + private List data; // 일자별 캘린더 데이터 +} 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 f75142f..c005b1a 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 @@ -1,12 +1,16 @@ package com.moongeul.backend.api.bookshelf.service; import com.moongeul.backend.api.book.entity.Book; +import com.moongeul.backend.api.bookshelf.dto.DoneReadCalendarDayDTO; +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.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.entity.Post; +import com.moongeul.backend.api.post.repository.PostRepository; import com.moongeul.backend.common.exception.NotFoundException; import com.moongeul.backend.common.response.ErrorStatus; import lombok.RequiredArgsConstructor; @@ -17,7 +21,12 @@ 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.LinkedHashMap; import java.util.List; +import java.util.Map; @Slf4j @Service @@ -26,6 +35,7 @@ public class DoneReadBookshelfService { private final DoneReadBookshelfRepository doneReadBookshelfRepository; private final MemberRepository memberRepository; + private final PostRepository postRepository; @Transactional(readOnly = true) public DoneReadBookshelfResponseDTO getDoneReadBooks(String email, Integer page, Integer size) { @@ -58,6 +68,75 @@ public DoneReadBookshelfResponseDTO getDoneReadBooks(String email, Integer page, .build(); } + // 읽은 책 캘린더 조회 + @Transactional(readOnly = true) + public DoneReadCalendarResponseDTO getDoneReadCalendar(String email, Integer year, Integer month) { + + Member member = memberRepository.findByEmail(email) + .orElseThrow(() -> new NotFoundException(ErrorStatus.USER_NOTFOUND_EXCEPTION.getMessage())); + + YearMonth yearMonth = YearMonth.of(year, month); + LocalDate startDate = yearMonth.atDay(1); + LocalDate endDate = yearMonth.atEndOfMonth(); + + List posts = postRepository.findCalendarPostsByMemberAndReadDateBetweenOrderByReadDateAscCreatedAtDesc( + member, startDate, endDate); + + Map calendarMap = new LinkedHashMap<>(); + + for (Post post : posts) { + LocalDate readDate = post.getReadDate(); + if (readDate == null) { + continue; + } + + CalendarDayAggregate aggregate = calendarMap.get(readDate); + if (aggregate == null) { + calendarMap.put(readDate, new CalendarDayAggregate( + post.getId(), + post.getBook().getIsbn(), + post.getBook().getBookImage(), + post.getCreatedAt(), + 1 + )); + continue; + } + + aggregate.count++; + boolean isMoreRecent = aggregate.latestCreatedAt == null + || (post.getCreatedAt() != null && post.getCreatedAt().isAfter(aggregate.latestCreatedAt)); + boolean isSameTimeButLargerId = post.getCreatedAt() != null + && aggregate.latestCreatedAt != null + && post.getCreatedAt().isEqual(aggregate.latestCreatedAt) + && aggregate.postId != null + && post.getId() != null + && post.getId() > aggregate.postId; + + if (isMoreRecent || isSameTimeButLargerId) { + aggregate.postId = post.getId(); + aggregate.isbn = post.getBook().getIsbn(); + aggregate.bookImage = post.getBook().getBookImage(); + aggregate.latestCreatedAt = post.getCreatedAt(); + } + } + + List calendarData = calendarMap.entrySet().stream() + .map(entry -> DoneReadCalendarDayDTO.builder() + .day(entry.getKey().getDayOfMonth()) + .postId(entry.getValue().postId) + .isbn(entry.getValue().isbn) + .bookImage(entry.getValue().bookImage) + .count(entry.getValue().count) + .build()) + .toList(); + + return DoneReadCalendarResponseDTO.builder() + .year(year) + .month(month) + .data(calendarData) + .build(); + } + private DoneReadBookshelfItemDTO convertToItemDTO(DoneReadBookshelf doneReadBookshelf) { Book book = doneReadBookshelf.getArticle().getBook(); @@ -72,5 +151,20 @@ private DoneReadBookshelfItemDTO convertToItemDTO(DoneReadBookshelf doneReadBook .postCount(doneReadBookshelf.getPostCount()) .build(); } -} + private static class CalendarDayAggregate { + private Long postId; + private String isbn; + private String bookImage; + private LocalDateTime latestCreatedAt; + private Integer count; + + private CalendarDayAggregate(Long postId, String isbn, String bookImage, LocalDateTime latestCreatedAt, Integer count) { + this.postId = postId; + this.isbn = isbn; + this.bookImage = bookImage; + this.latestCreatedAt = latestCreatedAt; + this.count = count; + } + } +} 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 c8a1028..1e8c301 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 @@ -1,6 +1,7 @@ package com.moongeul.backend.api.post.repository; import com.moongeul.backend.api.book.entity.Book; +import com.moongeul.backend.api.member.entity.Member; import com.moongeul.backend.api.member.entity.ReadingTasteType; import com.moongeul.backend.api.post.entity.Post; import org.springframework.data.domain.Page; @@ -9,6 +10,7 @@ import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -68,4 +70,13 @@ List findWeeklyRecommendationByReadingTasteType( // 카테고리별 기록 조회 (평점 낮은순) @Query("SELECT p FROM Post p WHERE p.category.id = :categoryId ORDER BY p.rating ASC, p.createdAt DESC") Page findByCategoryIdOrderByRatingAsc(@Param("categoryId") Long categoryId, Pageable pageable); + + // 읽은 날짜 기준 월별 게시글 조회 (일자 오름차순, 같은 일자 내 최신 작성순) + @Query("SELECT p FROM Post p JOIN FETCH p.book " + + "WHERE p.member = :member AND p.readDate BETWEEN :startDate AND :endDate " + + "ORDER BY p.readDate ASC, p.createdAt DESC") + List findCalendarPostsByMemberAndReadDateBetweenOrderByReadDateAscCreatedAtDesc( + @Param("member") Member member, + @Param("startDate") LocalDate startDate, + @Param("endDate") LocalDate endDate); } 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 88ea67e..359c79f 100644 --- a/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java +++ b/src/main/java/com/moongeul/backend/common/response/SuccessStatus.java @@ -44,6 +44,7 @@ public enum SuccessStatus { REMOVE_WISH_READ_BOOK_SUCCESS(HttpStatus.OK, "읽고 싶은 책 삭제 성공"), GET_WISH_READ_BOOKS_SUCCESS(HttpStatus.OK, "읽고 싶은 책장 조회 성공"), GET_DONE_READ_BOOKS_SUCCESS(HttpStatus.OK, "읽은 책장 조회 성공"), + GET_DONE_READ_CALENDAR_SUCCESS(HttpStatus.OK, "읽은 책 캘린더 조회 성공"), /* POST */ GET_ALL_POST_SUCCESS(HttpStatus.OK, "기록(게시글) 전체 조회 성공"),