From 26a0e49ee1c64254861098ea828c19b6d568c847 Mon Sep 17 00:00:00 2001 From: U-hee Date: Mon, 23 Feb 2026 18:24:53 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20thumbnail=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B0=8F=20=ED=83=80=EC=9E=84=EB=9D=BC=EC=9D=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A0=AC=20=EC=9A=B0=EC=84=A0=EC=88=9C=EC=9C=84=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/dokdok/book/api/BookApi.java | 5 + .../book/api/PersonalBookRecordApi.java | 4 +- .../book/controller/BookController.java | 5 + .../dto/response/ReadingTimelineItem.java | 17 +- .../dto/response/ReadingTimelineType.java | 5 +- .../repository/PersonalBookRepository.java | 8 +- .../repository/ReadingTimelineRepository.java | 19 ++- .../book/service/PersonalBookService.java | 111 +++++++++++-- .../book/service/ReadingTimelineService.java | 41 +++++ .../book/service/PersonalBookServiceTest.java | 152 ++++++++++++++++++ 10 files changed, 343 insertions(+), 24 deletions(-) diff --git a/src/main/java/com/dokdok/book/api/BookApi.java b/src/main/java/com/dokdok/book/api/BookApi.java index 463dfc9..8f00421 100644 --- a/src/main/java/com/dokdok/book/api/BookApi.java +++ b/src/main/java/com/dokdok/book/api/BookApi.java @@ -218,6 +218,7 @@ ResponseEntity> getMyBooks( @RequestParam(required = false) PersonalBookSortBy sortBy, @Parameter(description = "정렬 방향 (DESC | ASC)", example = "DESC") @RequestParam(required = false) PersonalBookSortOrder sortOrder, + @Parameter(description = "별점 하한 (포함, 0.0~5.0)", example = "3.0") + @RequestParam(required = false) BigDecimal minRating, + @Parameter(description = "별점 상한 (포함, 0.0~5.0)", example = "4.5") + @RequestParam(required = false) BigDecimal maxRating, @Parameter(description = "커서 - 마지막 아이템 rating (RATING 정렬 시 사용, null 가능)", example = "4.5") @RequestParam(required = false) BigDecimal cursorRating, @Parameter( diff --git a/src/main/java/com/dokdok/book/api/PersonalBookRecordApi.java b/src/main/java/com/dokdok/book/api/PersonalBookRecordApi.java index 8bce1d2..a7bce27 100644 --- a/src/main/java/com/dokdok/book/api/PersonalBookRecordApi.java +++ b/src/main/java/com/dokdok/book/api/PersonalBookRecordApi.java @@ -613,7 +613,7 @@ ResponseEntity> getMyTopicAnswer @Operation( summary = "독서 타임라인 조회 (developer: 권우희)", description = """ - 독서 기록/사전 의견/개인 회고를 하나의 타임라인으로 커서 기반 조회합니다. + 독서 기록/사전 의견/개인 회고/공동 회고를 하나의 타임라인으로 커서 기반 조회합니다. - personalBook의 gatheringId가 null이면 사전 의견/회고는 제외됩니다. - 사전 의견(PRE_OPINION)은 **내 답변이 있는 미팅만** 포함합니다. - PRE_OPINION의 preOpinion 객체에는 gatheringId/meetingId가 포함됩니다. @@ -667,7 +667,7 @@ ResponseEntity> getMyBooks( Long gatheringId, PersonalBookSortBy sortBy, PersonalBookSortOrder sortOrder, + @RequestParam(required = false) BigDecimal minRating, + @RequestParam(required = false) BigDecimal maxRating, @RequestParam(required = false) BigDecimal cursorRating, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) @@ -66,6 +68,9 @@ public ResponseEntity> getMyBooks( gatheringId, sortBy, sortOrder, + minRating, + maxRating, + cursorRating, cursorAddedAt, cursorBookId, size diff --git a/src/main/java/com/dokdok/book/dto/response/ReadingTimelineItem.java b/src/main/java/com/dokdok/book/dto/response/ReadingTimelineItem.java index 1091354..c1ad51c 100644 --- a/src/main/java/com/dokdok/book/dto/response/ReadingTimelineItem.java +++ b/src/main/java/com/dokdok/book/dto/response/ReadingTimelineItem.java @@ -17,7 +17,7 @@ public record ReadingTimelineItem( Long sourceId, @Schema(description = "독서 기록 데이터 (type=READING_RECORD)") PersonalReadingRecordListResponse readingRecord, - @Schema(description = "개인 회고 데이터 (type=PERSONAL_RETROSPECTIVE)") + @Schema(description = "회고 데이터 (type=PERSONAL_RETROSPECTIVE | GROUP_RETROSPECTIVE)") RetrospectiveRecordResponse retrospective, @Schema(description = "사전 의견 데이터 (type=PRE_OPINION)") ReadingTimelinePreOpinionResponse preOpinion @@ -52,6 +52,21 @@ public static ReadingTimelineItem retrospective( ); } + public static ReadingTimelineItem groupRetrospective( + LocalDateTime eventAt, + Long sourceId, + RetrospectiveRecordResponse retrospective + ) { + return new ReadingTimelineItem( + ReadingTimelineType.GROUP_RETROSPECTIVE, + eventAt, + sourceId, + null, + retrospective, + null + ); + } + public static ReadingTimelineItem preOpinion( LocalDateTime eventAt, Long sourceId, diff --git a/src/main/java/com/dokdok/book/dto/response/ReadingTimelineType.java b/src/main/java/com/dokdok/book/dto/response/ReadingTimelineType.java index 4a23196..a4d4c2e 100644 --- a/src/main/java/com/dokdok/book/dto/response/ReadingTimelineType.java +++ b/src/main/java/com/dokdok/book/dto/response/ReadingTimelineType.java @@ -1,8 +1,9 @@ package com.dokdok.book.dto.response; public enum ReadingTimelineType { - READING_RECORD(3), - PERSONAL_RETROSPECTIVE(2), + READING_RECORD(4), + PERSONAL_RETROSPECTIVE(3), + GROUP_RETROSPECTIVE(2), PRE_OPINION(1); private final int order; diff --git a/src/main/java/com/dokdok/book/repository/PersonalBookRepository.java b/src/main/java/com/dokdok/book/repository/PersonalBookRepository.java index 1c1120b..9c24e2c 100644 --- a/src/main/java/com/dokdok/book/repository/PersonalBookRepository.java +++ b/src/main/java/com/dokdok/book/repository/PersonalBookRepository.java @@ -24,7 +24,7 @@ public interface PersonalBookRepository extends JpaRepository findPersonalBooksByUserIdReadingStatusAndGather b.publisher as publisher, b.author as authors, (array_agg(pb.reading_status order by pb.added_at desc, pb.personal_book_id desc))[1] as bookReadingStatus, - b.book_image_url as thumbnail, + b.thumbnail as thumbnail, max(br.rating) as rating, coalesce( json_agg(distinct jsonb_build_object('gatheringId', g.gathering_id, 'gatheringName', g.gathering_name)) @@ -96,7 +96,7 @@ Page findPersonalBooksByUserIdReadingStatusAndGather and pb.deleted_at is null and (:gatheringId is null or g.gathering_id = :gatheringId) and (:readingStatus is null or pb.reading_status = :readingStatus) - group by b.book_id, b.book_name, b.publisher, b.author, b.book_image_url + group by b.book_id, b.book_name, b.publisher, b.author, b.thumbnail """, nativeQuery = true ) diff --git a/src/main/java/com/dokdok/book/repository/ReadingTimelineRepository.java b/src/main/java/com/dokdok/book/repository/ReadingTimelineRepository.java index 38027aa..11f2573 100644 --- a/src/main/java/com/dokdok/book/repository/ReadingTimelineRepository.java +++ b/src/main/java/com/dokdok/book/repository/ReadingTimelineRepository.java @@ -33,7 +33,7 @@ WITH timeline AS ( prr.created_at AS event_at, 'READING_RECORD' AS type, prr.record_id AS source_id, - 3 AS type_order + 4 AS type_order FROM personal_reading_record prr WHERE prr.personal_book_id = :personalBookId AND prr.user_id = :userId @@ -45,7 +45,7 @@ WITH timeline AS ( pmr.created_at AS event_at, 'PERSONAL_RETROSPECTIVE' AS type, pmr.personal_meeting_retrospective_id AS source_id, - 2 AS type_order + 3 AS type_order FROM personal_meeting_retrospective pmr JOIN meeting m ON m.meeting_id = pmr.meeting_id WHERE pmr.user_id = :userId @@ -57,6 +57,21 @@ AND CAST(:gatheringId AS bigint) IS NOT NULL UNION ALL + SELECT + m.retrospective_published_at AS event_at, + 'GROUP_RETROSPECTIVE' AS type, + m.meeting_id AS source_id, + 2 AS type_order + FROM meeting m + WHERE m.deleted_at IS NULL + AND CAST(:gatheringId AS bigint) IS NOT NULL + AND m.gathering_id = :gatheringId + AND m.book_id = :bookId + AND m.retrospective_published = true + AND m.retrospective_published_at IS NOT NULL + + UNION ALL + SELECT pre.event_at AS event_at, 'PRE_OPINION' AS type, diff --git a/src/main/java/com/dokdok/book/service/PersonalBookService.java b/src/main/java/com/dokdok/book/service/PersonalBookService.java index 0382959..12a8b43 100644 --- a/src/main/java/com/dokdok/book/service/PersonalBookService.java +++ b/src/main/java/com/dokdok/book/service/PersonalBookService.java @@ -28,6 +28,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; import java.time.LocalDateTime; import java.time.OffsetDateTime; import java.util.Comparator; @@ -108,6 +109,9 @@ public PersonalBookCursorPageResponse getPersonalBookListCursor( Long gatheringId, PersonalBookSortBy sortBy, PersonalBookSortOrder sortOrder, + BigDecimal minRating, + BigDecimal maxRating, + BigDecimal cursorRating, OffsetDateTime cursorAddedAt, Long cursorBookId, Integer size @@ -116,22 +120,30 @@ public PersonalBookCursorPageResponse getPersonalBookListCursor( String readingStatus = bookReadingStatus != null ? bookReadingStatus.name() : null; PersonalBookSortBy resolvedSortBy = sortBy != null ? sortBy : PersonalBookSortBy.TIME; PersonalBookSortOrder resolvedSortOrder = sortOrder != null ? sortOrder : PersonalBookSortOrder.DESC; + Comparator comparator = resolveComparator(resolvedSortBy, resolvedSortOrder); int pageSize = resolvePageSize(size); LocalDateTime cursorAddedAtValue = cursorAddedAt != null ? cursorAddedAt.toLocalDateTime() : null; - List sorted = personalBookRepository + List filtered = personalBookRepository .findPersonalBookAggregatesByUserIdAndGatheringIdAndReadingStatus( userEntity.getId(), gatheringId, readingStatus ) .stream() - .sorted(resolveComparator(resolvedSortBy, resolvedSortOrder)) + .filter(item -> isWithinRatingRange(item.getRating(), minRating, maxRating)) + .toList(); + + List sorted = filtered.stream() + .sorted(comparator) .toList(); long totalCount = sorted.size(); List afterCursor = applyCursor( sorted, + comparator, + resolvedSortBy, + cursorRating, cursorAddedAtValue, cursorBookId ); @@ -243,6 +255,9 @@ private Comparator resolveRatingComparator(PersonalB private List applyCursor( List sorted, + Comparator comparator, + PersonalBookSortBy sortBy, + BigDecimal cursorRating, LocalDateTime cursorAddedAt, Long cursorBookId ) { @@ -250,21 +265,36 @@ private List applyCursor( return sorted; } - for (int i = 0; i < sorted.size(); i++) { - PersonalBookListProjection item = sorted.get(i); - if (isCursorMatch(item, cursorAddedAt, cursorBookId)) { - return sorted.subList(i + 1, sorted.size()); - } + BigDecimal resolvedCursorRating = cursorRating; + if (sortBy == PersonalBookSortBy.RATING && resolvedCursorRating == null) { + resolvedCursorRating = sorted.stream() + .filter(item -> cursorAddedAt.equals(item.getAddedAt()) && cursorBookId.equals(item.getBookId())) + .map(PersonalBookListProjection::getRating) + .findFirst() + .orElse(null); } - return List.of(); + + PersonalBookListProjection cursor = CursorProjection.of(cursorBookId, cursorAddedAt, resolvedCursorRating); + return sorted.stream() + .filter(item -> comparator.compare(item, cursor) > 0) + .toList(); } - private boolean isCursorMatch( - PersonalBookListProjection item, - LocalDateTime cursorAddedAt, - Long cursorBookId + private boolean isWithinRatingRange( + BigDecimal rating, + BigDecimal minRating, + BigDecimal maxRating ) { - return cursorAddedAt.equals(item.getAddedAt()) && cursorBookId.equals(item.getBookId()); + if (minRating == null && maxRating == null) { + return true; + } + if (rating == null) { + return false; + } + + boolean passMin = minRating == null || rating.compareTo(minRating) >= 0; + boolean passMax = maxRating == null || rating.compareTo(maxRating) <= 0; + return passMin && passMax; } private PersonalBookStatusCountsResponse buildStatusCounts(Long userId, Long gatheringId) { @@ -293,4 +323,59 @@ private PersonalBookStatusCountsResponse buildStatusCounts(Long userId, Long gat .total(total) .build(); } + + private record CursorProjection( + Long bookId, + LocalDateTime addedAt, + BigDecimal rating + ) implements PersonalBookListProjection { + private static CursorProjection of(Long bookId, LocalDateTime addedAt, BigDecimal rating) { + return new CursorProjection(bookId, addedAt, rating); + } + + @Override + public Long getBookId() { + return bookId; + } + + @Override + public LocalDateTime getAddedAt() { + return addedAt; + } + + @Override + public BigDecimal getRating() { + return rating; + } + + @Override + public String getTitle() { + return null; + } + + @Override + public String getPublisher() { + return null; + } + + @Override + public String getAuthors() { + return null; + } + + @Override + public BookReadingStatus getBookReadingStatus() { + return null; + } + + @Override + public String getThumbnail() { + return null; + } + + @Override + public String getGatherings() { + return null; + } + } } diff --git a/src/main/java/com/dokdok/book/service/ReadingTimelineService.java b/src/main/java/com/dokdok/book/service/ReadingTimelineService.java index 099ec6a..cbb63bd 100644 --- a/src/main/java/com/dokdok/book/service/ReadingTimelineService.java +++ b/src/main/java/com/dokdok/book/service/ReadingTimelineService.java @@ -2,6 +2,7 @@ import com.dokdok.book.dto.request.PreOpinionTimeType; import com.dokdok.book.dto.response.*; +import com.dokdok.book.entity.ReflectionRecordType; import com.dokdok.book.entity.PersonalBook; import com.dokdok.book.entity.PersonalReadingRecord; import com.dokdok.book.repository.PersonalReadingRecordRepository; @@ -108,6 +109,10 @@ public CursorResponse getTimeline( .filter(row -> ReadingTimelineType.PERSONAL_RETROSPECTIVE.name().equals(row.type())) .map(ReadingTimelineIndexRow::sourceId) .toList(); + List groupRetrospectiveMeetingIds = pageRows.stream() + .filter(row -> ReadingTimelineType.GROUP_RETROSPECTIVE.name().equals(row.type())) + .map(ReadingTimelineIndexRow::sourceId) + .toList(); List meetingIds = pageRows.stream() .filter(row -> ReadingTimelineType.PRE_OPINION.name().equals(row.type())) .map(ReadingTimelineIndexRow::sourceId) @@ -117,6 +122,8 @@ public CursorResponse getTimeline( fetchReadingRecords(readingRecordIds, personalBookId, userId); Map retrospectiveMap = fetchRetrospectives(retrospectiveIds, userId); + Map groupRetrospectiveMap = + fetchGroupRetrospectives(groupRetrospectiveMeetingIds); Map preOpinionMap = fetchPreOpinions(meetingIds, userId); @@ -134,6 +141,11 @@ public CursorResponse getTimeline( row.sourceId(), retrospectiveMap.get(row.sourceId()) ); + case GROUP_RETROSPECTIVE -> ReadingTimelineItem.groupRetrospective( + row.eventAt(), + row.sourceId(), + groupRetrospectiveMap.get(row.sourceId()) + ); case PRE_OPINION -> ReadingTimelineItem.preOpinion( row.eventAt(), row.sourceId(), @@ -223,6 +235,35 @@ private Map fetchRetrospectives( return map; } + private Map fetchGroupRetrospectives(List meetingIds) { + if (meetingIds.isEmpty()) { + return Map.of(); + } + + List meetings = meetingRepository.findByIdInWithGathering(meetingIds); + Map map = new HashMap<>(); + + for (Meeting meeting : meetings) { + if (!meeting.isRetrospectivePublished() || meeting.getRetrospectivePublishedAt() == null) { + continue; + } + + map.put( + meeting.getId(), + RetrospectiveRecordResponse.of( + meeting.getId(), + meeting.getGathering().getGatheringName(), + ReflectionRecordType.MEETING_RETROSPECTIVE, + meeting.getRetrospectivePublishedAt(), + List.of(), + List.of() + ) + ); + } + + return map; + } + private Map fetchPreOpinions( List meetingIds, Long userId diff --git a/src/test/java/com/dokdok/book/service/PersonalBookServiceTest.java b/src/test/java/com/dokdok/book/service/PersonalBookServiceTest.java index 494ad97..a667533 100644 --- a/src/test/java/com/dokdok/book/service/PersonalBookServiceTest.java +++ b/src/test/java/com/dokdok/book/service/PersonalBookServiceTest.java @@ -1,6 +1,9 @@ package com.dokdok.book.service; import com.dokdok.book.dto.request.BookCreateRequest; +import com.dokdok.book.dto.request.PersonalBookSortBy; +import com.dokdok.book.dto.request.PersonalBookSortOrder; +import com.dokdok.book.dto.response.PersonalBookCursorPageResponse; import com.dokdok.book.dto.response.PersonalBookCreateResponse; import com.dokdok.book.dto.response.PersonalBookDetailResponse; import com.dokdok.book.dto.response.PersonalBookListResponse; @@ -32,7 +35,10 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import java.math.BigDecimal; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; import java.util.List; import java.util.Optional; @@ -625,4 +631,150 @@ void deleteBooks_DeduplicateIds() { verify(bookValidator, times(1)).validateInBookShelf(userId, 10L); verify(personalBookRepository, times(1)).delete(personalBook); } + + @Test + @DisplayName("커서 목록 조회 시 별점 범위 필터가 적용된다") + void getPersonalBookListCursor_FilterByRatingRange() { + // given + Long userId = 1L; + User user = User.builder() + .id(userId) + .kakaoId(12345L) + .nickname("tester") + .build(); + + LocalDateTime now = LocalDateTime.now(); + PersonalBookListProjection high = projection(3L, "별점 5점", new BigDecimal("5.0"), now.minusDays(1)); + PersonalBookListProjection mid = projection(2L, "별점 3.5점", new BigDecimal("3.5"), now.minusDays(2)); + PersonalBookListProjection low = projection(1L, "별점 2점", new BigDecimal("2.0"), now.minusDays(3)); + PersonalBookListProjection unrated = projection(4L, "별점 없음", null, now.minusDays(4)); + + securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId); + when(userValidator.findUserOrThrow(userId)).thenReturn(user); + when(personalBookRepository.findPersonalBookAggregatesByUserIdAndGatheringIdAndReadingStatus(userId, null, null)) + .thenReturn(List.of(high, mid, low, unrated)); + when(personalBookRepository.countPersonalBookStatusByUserIdAndGatheringId(userId, null)).thenReturn(List.of()); + + // when + PersonalBookCursorPageResponse response = personalBookService.getPersonalBookListCursor( + null, + null, + PersonalBookSortBy.RATING, + PersonalBookSortOrder.DESC, + new BigDecimal("3.0"), + new BigDecimal("4.0"), + null, + null, + null, + 10 + ); + + // then + assertThat(response.getItems()).hasSize(1); + assertThat(response.getItems().getFirst().bookId()).isEqualTo(2L); + assertThat(response.getItems().getFirst().rating()).isEqualByComparingTo("3.5"); + assertThat(response.getTotalCount()).isEqualTo(1L); + assertThat(response.isHasNext()).isFalse(); + } + + @Test + @DisplayName("RATING 정렬에서 cursorRating/cursorAddedAt/cursorBookId 기준으로 다음 페이지를 조회한다") + void getPersonalBookListCursor_ApplyRatingCursor() { + // given + Long userId = 1L; + User user = User.builder() + .id(userId) + .kakaoId(12345L) + .nickname("tester") + .build(); + + LocalDateTime firstAddedAt = LocalDateTime.of(2026, 2, 1, 10, 0); + LocalDateTime secondAddedAt = LocalDateTime.of(2026, 1, 25, 10, 0); + LocalDateTime thirdAddedAt = LocalDateTime.of(2026, 1, 20, 10, 0); + + PersonalBookListProjection first = projection(30L, "별점 5점", new BigDecimal("5.0"), firstAddedAt); + PersonalBookListProjection second = projection(20L, "별점 4점", new BigDecimal("4.0"), secondAddedAt); + PersonalBookListProjection third = projection(10L, "별점 3점", new BigDecimal("3.0"), thirdAddedAt); + + securityUtilMock.when(SecurityUtil::getCurrentUserId).thenReturn(userId); + when(userValidator.findUserOrThrow(userId)).thenReturn(user); + when(personalBookRepository.findPersonalBookAggregatesByUserIdAndGatheringIdAndReadingStatus(userId, null, null)) + .thenReturn(List.of(first, second, third)); + when(personalBookRepository.countPersonalBookStatusByUserIdAndGatheringId(userId, null)).thenReturn(List.of()); + + // when + PersonalBookCursorPageResponse response = personalBookService.getPersonalBookListCursor( + null, + null, + PersonalBookSortBy.RATING, + PersonalBookSortOrder.DESC, + null, + null, + new BigDecimal("4.0"), + OffsetDateTime.of(secondAddedAt, ZoneOffset.UTC), + 20L, + 10 + ); + + // then + assertThat(response.getItems()).hasSize(1); + assertThat(response.getItems().getFirst().bookId()).isEqualTo(10L); + assertThat(response.getItems().getFirst().rating()).isEqualByComparingTo("3.0"); + assertThat(response.getTotalCount()).isEqualTo(3L); + assertThat(response.isHasNext()).isFalse(); + } + + private PersonalBookListProjection projection( + Long bookId, + String title, + BigDecimal rating, + LocalDateTime addedAt + ) { + return new PersonalBookListProjection() { + @Override + public Long getBookId() { + return bookId; + } + + @Override + public String getTitle() { + return title; + } + + @Override + public String getPublisher() { + return "출판사"; + } + + @Override + public String getAuthors() { + return "저자"; + } + + @Override + public BookReadingStatus getBookReadingStatus() { + return BookReadingStatus.READING; + } + + @Override + public String getThumbnail() { + return "thumbnail"; + } + + @Override + public BigDecimal getRating() { + return rating; + } + + @Override + public String getGatherings() { + return "[]"; + } + + @Override + public LocalDateTime getAddedAt() { + return addedAt; + } + }; + } }