Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/main/java/com/dokdok/book/api/BookApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ ResponseEntity<ApiResponse<CursorPageResponse<KakaoBookResponse.Document, BookSe
내 책장에 등록된 책을 커서 기반으로 조회합니다.
- 로그인한 사용자 기준으로 조회합니다.
- 독서 상태 필터 (ENUM: READING/COMPLETED/PENDING)
- 별점 범위 필터: minRating/maxRating (포함 범위)
- 정렬 파라미터: sortBy(TIME|RATING), sortOrder(DESC|ASC)
- 커서 파라미터: cursorRating/cursorAddedAt/cursorBookId
- 책이 없는 경우에도 200 응답이며 items는 빈 배열입니다.
Expand Down Expand Up @@ -331,6 +332,10 @@ ResponseEntity<ApiResponse<PersonalBookCursorPageResponse>> 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(
Expand Down
4 changes: 2 additions & 2 deletions src/main/java/com/dokdok/book/api/PersonalBookRecordApi.java
Original file line number Diff line number Diff line change
Expand Up @@ -613,7 +613,7 @@ ResponseEntity<ApiResponse<PersonalReadingTopicAnswerResponse>> getMyTopicAnswer
@Operation(
summary = "독서 타임라인 조회 (developer: 권우희)",
description = """
독서 기록/사전 의견/개인 회고를 하나의 타임라인으로 커서 기반 조회합니다.
독서 기록/사전 의견/개인 회고/공동 회고를 하나의 타임라인으로 커서 기반 조회합니다.
- personalBook의 gatheringId가 null이면 사전 의견/회고는 제외됩니다.
- 사전 의견(PRE_OPINION)은 **내 답변이 있는 미팅만** 포함합니다.
- PRE_OPINION의 preOpinion 객체에는 gatheringId/meetingId가 포함됩니다.
Expand Down Expand Up @@ -667,7 +667,7 @@ ResponseEntity<ApiResponse<CursorResponse<ReadingTimelineItem, ReadingTimelineCu
@RequestParam(required = false)
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
LocalDateTime cursorEventAt,
@Parameter(description = "커서 - 마지막 이벤트 타입 (READING_RECORD | PERSONAL_RETROSPECTIVE | PRE_OPINION)")
@Parameter(description = "커서 - 마지막 이벤트 타입 (READING_RECORD | PERSONAL_RETROSPECTIVE | GROUP_RETROSPECTIVE | PRE_OPINION)")
@RequestParam(required = false) ReadingTimelineType cursorType,
@Parameter(description = "커서 - 마지막 이벤트 원본 ID")
@RequestParam(required = false) Long cursorSourceId,
Expand Down
5 changes: 5 additions & 0 deletions src/main/java/com/dokdok/book/controller/BookController.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ public ResponseEntity<ApiResponse<PersonalBookCursorPageResponse>> 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)
Expand All @@ -66,6 +68,9 @@ public ResponseEntity<ApiResponse<PersonalBookCursorPageResponse>> getMyBooks(
gatheringId,
sortBy,
sortOrder,
minRating,
maxRating,
cursorRating,
cursorAddedAt,
cursorBookId,
size
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ public interface PersonalBookRepository extends JpaRepository<PersonalBook, Long
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))
Expand All @@ -45,7 +45,7 @@ public interface PersonalBookRepository extends JpaRepository<PersonalBook, Long
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
""",
countQuery = """
select count(distinct pb.book_id)
Expand Down Expand Up @@ -75,7 +75,7 @@ Page<PersonalBookListProjection> 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))
Expand All @@ -96,7 +96,7 @@ Page<PersonalBookListProjection> 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
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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,
Expand Down
111 changes: 98 additions & 13 deletions src/main/java/com/dokdok/book/service/PersonalBookService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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<PersonalBookListProjection> comparator = resolveComparator(resolvedSortBy, resolvedSortOrder);
int pageSize = resolvePageSize(size);
LocalDateTime cursorAddedAtValue = cursorAddedAt != null ? cursorAddedAt.toLocalDateTime() : null;

List<PersonalBookListProjection> sorted = personalBookRepository
List<PersonalBookListProjection> filtered = personalBookRepository
.findPersonalBookAggregatesByUserIdAndGatheringIdAndReadingStatus(
userEntity.getId(),
gatheringId,
readingStatus
)
.stream()
.sorted(resolveComparator(resolvedSortBy, resolvedSortOrder))
.filter(item -> isWithinRatingRange(item.getRating(), minRating, maxRating))
.toList();

List<PersonalBookListProjection> sorted = filtered.stream()
.sorted(comparator)
.toList();
long totalCount = sorted.size();

List<PersonalBookListProjection> afterCursor = applyCursor(
sorted,
comparator,
resolvedSortBy,
cursorRating,
cursorAddedAtValue,
cursorBookId
);
Expand Down Expand Up @@ -243,28 +255,46 @@ private Comparator<PersonalBookListProjection> resolveRatingComparator(PersonalB

private List<PersonalBookListProjection> applyCursor(
List<PersonalBookListProjection> sorted,
Comparator<PersonalBookListProjection> comparator,
PersonalBookSortBy sortBy,
BigDecimal cursorRating,
LocalDateTime cursorAddedAt,
Long cursorBookId
) {
if (cursorAddedAt == null || cursorBookId == null) {
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) {
Expand Down Expand Up @@ -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;
}
}
}
Loading