From 7aa882a8f9829bf5f117c69add4ecb515a16e432 Mon Sep 17 00:00:00 2001 From: kjh0620 Date: Mon, 14 Sep 2026 02:26:35 +0900 Subject: [PATCH] =?UTF-8?q?[FIX]=20=EB=8F=85=EC=84=9C=20=ED=9E=88=EC=8A=A4?= =?UTF-8?q?=ED=86=A0=EB=A6=AC=20=EC=97=B0=EB=8F=84=EB=A5=BC=20=ED=95=B4?= =?UTF-8?q?=EB=8B=B9=20=EC=97=B0=EB=8F=84=EC=9D=98=20=EC=B2=AB=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=20=EB=82=A0=EC=A7=9C=EC=97=90=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/docs/asciidoc/timeline.adoc | 4 +- .../repository/TimelineRepository.java | 11 ++++ .../service/TimelineQueryService.java | 20 +++++--- .../timeline/TimelineControllerTest.java | 4 +- .../repository/TimelineRepositoryTest.java | 22 ++++++++ .../service/TimelineQueryServiceTest.java | 51 ++++++++++++++----- 6 files changed, 90 insertions(+), 22 deletions(-) diff --git a/src/docs/asciidoc/timeline.adoc b/src/docs/asciidoc/timeline.adoc index 3eabee8..d1cf27b 100644 --- a/src/docs/asciidoc/timeline.adoc +++ b/src/docs/asciidoc/timeline.adoc @@ -2,6 +2,7 @@ 독서 이력 요약 조회는 포커스 요약, 기록 요약, 그리고 최신 독서 이력 preview를 함께 반환합니다. + ``timelinePreview``는 서버에서 최신 5개의 타임라인 아이템을 기준으로 날짜별 그룹으로 묶어 반환합니다. +``showYear``는 전체 조회와 동일하게 해당 서재의 연도별 첫 기록(발생 시각이 가장 이른 기록) 날짜에만 true입니다. 해당 날짜가 최신 5건에 포함되지 않으면 해당 연도의 모든 그룹은 false입니다. 요약·전체·상세 조회에서 연결된 포커스 또는 기록이 다른 서재에 속하면 원본이 없는 경우와 동일하게 처리합니다. 타임라인과 저장된 미리보기 문구는 유지하며, 해당 원본의 시간·페이지·본문·감정·이미지는 응답에 사용하지 않습니다. @@ -22,7 +23,8 @@ include::{snippets}/success/get-timeline-summary/response-fields.adoc[] 응답의 ``hasNext``가 true이면 ``nextCursor``를 다음 요청의 ``cursor``로 그대로 전달합니다. 마지막 페이지 또는 빈 결과는 ``hasNext=false``, ``nextCursor=null``입니다. 잘못된 커서는 400 응답을 반환합니다. -``showYear``는 연도 표시 여부입니다. 첫 페이지 첫 그룹과 이전 그룹에서 연도가 바뀌는 그룹은 true이며, 같은 연도가 이어지면 페이지가 바뀌어도 false입니다. +``showYear``는 연도 표시 여부입니다. 해당 서재의 연도별 첫 기록(발생 시각이 가장 이른 기록) 날짜 그룹만 true이며, 나머지는 false입니다. +최신순 정렬은 유지되며, 현재 페이지나 커서의 연도가 아닌 전체 이력을 기준으로 판단합니다. 같은 날짜가 여러 페이지에 나뉘어도 동일한 ``showYear``를 반환합니다. 같은 날짜의 이력이 여러 페이지에 나뉠 수 있습니다. 이전 페이지의 마지막 그룹과 다음 페이지의 첫 그룹의 ``year``, ``monthDay``가 같으면 ``items``를 합치고 이전 그룹의 ``showYear``를 유지합니다. 날짜가 다르면 새 그룹과 해당 그룹의 ``showYear``를 그대로 사용합니다. diff --git a/src/main/java/app/nook/timeline/repository/TimelineRepository.java b/src/main/java/app/nook/timeline/repository/TimelineRepository.java index 6babc8c..64f9626 100644 --- a/src/main/java/app/nook/timeline/repository/TimelineRepository.java +++ b/src/main/java/app/nook/timeline/repository/TimelineRepository.java @@ -12,6 +12,7 @@ import java.util.List; import java.util.Optional; +import java.util.Set; public interface TimelineRepository extends JpaRepository { void deleteByLibrary(Library library); @@ -22,6 +23,16 @@ public interface TimelineRepository extends JpaRepository { List findByLibraryOrderByOccurredAtDescIdDesc(Library library, Pageable pageable); + @Query(""" + SELECT MIN(t.occurredAt) FROM Timeline t + WHERE t.library = :library AND YEAR(t.occurredAt) IN :years + GROUP BY YEAR(t.occurredAt) + """) + List findFirstOccurredAtByLibraryAndYears( + @Param("library") Library library, + @Param("years") Set years + ); + @Query(""" SELECT t FROM Timeline t WHERE t.library = :library diff --git a/src/main/java/app/nook/timeline/service/TimelineQueryService.java b/src/main/java/app/nook/timeline/service/TimelineQueryService.java index 2ce864c..7f8e6f7 100644 --- a/src/main/java/app/nook/timeline/service/TimelineQueryService.java +++ b/src/main/java/app/nook/timeline/service/TimelineQueryService.java @@ -66,7 +66,7 @@ public TimelineResponseDto.TimelineSummaryDto getTimelineSummary(User user, Long List previewTimelines = timelineRepository.findTop5ByLibraryOrderByOccurredAtDescIdDesc(library); TimelineResponseDto.TimelinePreviewDto timelinePreview = TimelineResponseConverter.toTimelinePreview( - toTimelineDateGroups(previewTimelines, null) + toTimelineDateGroups(library, previewTimelines) ); return TimelineResponseConverter.toTimelineSummary( @@ -93,7 +93,7 @@ public TimelineResponseDto.TimelinePageDto getTimelinePreview( nextCursor = TimelineCursorCodec.encode(new TimelineCursor(last.getOccurredAt(), last.getId())); } return new TimelineResponseDto.TimelinePageDto( - toTimelineDateGroups(page, cursor == null ? null : cursor.occurredAt().getYear()), + toTimelineDateGroups(library, page), nextCursor, hasNext ); @@ -133,8 +133,12 @@ private Library getOwnedLibrary(User user, Long libraryId) { } private List toTimelineDateGroups( - List timelines, Integer previousYear + Library library, List timelines ) { + if (timelines.isEmpty()) { + return List.of(); + } + // FOCUS/RECORD 원본은 타입별로 미리 묶어서 조회해 item 조립 시 N+1을 피한다. Map focusMap = getFocusMap(timelines); Map recordMap = getRecordMap(timelines); @@ -146,14 +150,18 @@ private List toTimelineDateGroups( .add(toTimelineItem(timeline, focusMap, recordMap)); } + Set years = grouped.keySet().stream() + .map(LocalDate::getYear) + .collect(Collectors.toSet()); + Set firstDates = timelineRepository.findFirstOccurredAtByLibraryAndYears(library, years).stream() + .map(occurredAt -> occurredAt.toLocalDate()) + .collect(Collectors.toSet()); List dateGroups = new ArrayList<>(); for (Map.Entry> entry : grouped.entrySet()) { LocalDate date = entry.getKey(); - // 같은 연도 그룹이 연속되면 첫 그룹만 연도를 노출한다. - boolean showYear = previousYear == null || previousYear != date.getYear(); + boolean showYear = firstDates.contains(date); dateGroups.add(TimelineResponseConverter.toTimelineDateGroup(date, showYear, entry.getValue())); - previousYear = date.getYear(); } return dateGroups; diff --git a/src/test/java/app/nook/controller/timeline/TimelineControllerTest.java b/src/test/java/app/nook/controller/timeline/TimelineControllerTest.java index 686d306..d1b4065 100644 --- a/src/test/java/app/nook/controller/timeline/TimelineControllerTest.java +++ b/src/test/java/app/nook/controller/timeline/TimelineControllerTest.java @@ -140,7 +140,7 @@ void getTimelineSummary() throws Exception { fieldWithPath("result.timelinePreview.dateGroups").type(JsonFieldType.ARRAY).description("날짜별 독서 이력 preview 그룹"), fieldWithPath("result.timelinePreview.dateGroups[].year").type(JsonFieldType.NUMBER).description("연도"), fieldWithPath("result.timelinePreview.dateGroups[].monthDay").type(JsonFieldType.STRING).description("월/일"), - fieldWithPath("result.timelinePreview.dateGroups[].showYear").type(JsonFieldType.BOOLEAN).description("연도 표시 여부"), + fieldWithPath("result.timelinePreview.dateGroups[].showYear").type(JsonFieldType.BOOLEAN).description("해당 서재에서 해당 연도의 첫 기록 날짜이면 true"), fieldWithPath("result.timelinePreview.dateGroups[].items").type(JsonFieldType.ARRAY).description("해당 날짜의 독서 이력 아이템 목록"), fieldWithPath("result.timelinePreview.dateGroups[].items[].timelineId").type(JsonFieldType.NUMBER).description("타임라인 ID"), fieldWithPath("result.timelinePreview.dateGroups[].items[].type").type(JsonFieldType.STRING).description("타임라인 타입"), @@ -253,7 +253,7 @@ void getTimelinePreview() throws Exception { fieldWithPath("result.dateGroups").type(JsonFieldType.ARRAY).description("날짜별 독서 이력 그룹"), fieldWithPath("result.dateGroups[].year").type(JsonFieldType.NUMBER).description("연도"), fieldWithPath("result.dateGroups[].monthDay").type(JsonFieldType.STRING).description("월/일"), - fieldWithPath("result.dateGroups[].showYear").type(JsonFieldType.BOOLEAN).description("연도 표시 여부"), + fieldWithPath("result.dateGroups[].showYear").type(JsonFieldType.BOOLEAN).description("해당 서재에서 해당 연도의 첫 기록 날짜이면 true"), fieldWithPath("result.dateGroups[].items").type(JsonFieldType.ARRAY).description("해당 날짜의 독서 이력 아이템 목록"), fieldWithPath("result.dateGroups[].items[].timelineId").type(JsonFieldType.NUMBER).description("타임라인 ID"), fieldWithPath("result.dateGroups[].items[].type").type(JsonFieldType.STRING).description("타임라인 타입"), diff --git a/src/test/java/app/nook/timeline/repository/TimelineRepositoryTest.java b/src/test/java/app/nook/timeline/repository/TimelineRepositoryTest.java index 7a68c3e..d07a406 100644 --- a/src/test/java/app/nook/timeline/repository/TimelineRepositoryTest.java +++ b/src/test/java/app/nook/timeline/repository/TimelineRepositoryTest.java @@ -23,6 +23,7 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Optional; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; @@ -131,6 +132,27 @@ private Timeline saveTimeline( .contains(TimelineType.STATUS); } + @Test + void findFirstOccurredAtByLibraryAndYears_해당서재와_연도의_첫기록을_조회한다() { + User user = saveUser("timeline-first@test.com", "provider-first"); + Library library = saveLibrary(user, saveBook("3333333333334", "도서1")); + Library otherLibrary = saveLibrary(user, saveBook("3333333333335", "도서2")); + LocalDateTime first2025 = LocalDateTime.of(2025, 12, 31, 10, 0); + LocalDateTime first2026 = LocalDateTime.of(2026, 1, 1, 10, 0); + + saveTimeline(library, TimelineType.RECORD, 9001L, first2026.plusDays(1), "최근 기록"); + saveTimeline(library, TimelineType.FOCUS, 7001L, first2026.plusHours(1), "포커스"); + saveTimeline(library, TimelineType.STATUS, library.getId(), first2026, "독서 상태 변경"); + saveTimeline(library, TimelineType.REGISTER, library.getId(), first2025, "등록"); + saveTimeline(library, TimelineType.RECORD, 9002L, first2025.minusYears(1), "조회 대상 연도 제외"); + saveTimeline(otherLibrary, TimelineType.REGISTER, otherLibrary.getId(), first2025.minusDays(1), "다른 서재"); + + List result = timelineRepository.findFirstOccurredAtByLibraryAndYears( + library, Set.of(2025, 2026)); + + assertThat(result).containsExactlyInAnyOrder(first2025, first2026); + } + @Test void findTop5ByLibraryOrderByOccurredAtDescIdDesc_최신_5건만_조회한다() { User user = saveUser("timeline-top5@test.com", "provider-top5"); diff --git a/src/test/java/app/nook/timeline/service/TimelineQueryServiceTest.java b/src/test/java/app/nook/timeline/service/TimelineQueryServiceTest.java index 59a9a78..8134d33 100644 --- a/src/test/java/app/nook/timeline/service/TimelineQueryServiceTest.java +++ b/src/test/java/app/nook/timeline/service/TimelineQueryServiceTest.java @@ -39,6 +39,7 @@ import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.Set; import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -239,6 +240,9 @@ class GetTimelineSummary { given(timelineRepository.findTop5ByLibraryOrderByOccurredAtDescIdDesc(library)) .willReturn(timelines); + given(timelineRepository.findFirstOccurredAtByLibraryAndYears(library, Set.of(2026))) + .willReturn(List.of(timelines.get(1).getOccurredAt())); + TimelineResponseDto.TimelineSummaryDto result = timelineQueryService.getTimelineSummary(user, 12L); @@ -247,7 +251,9 @@ class GetTimelineSummary { assertThat(result.focusSummary().focusCount()).isEqualTo(39); assertThat(result.recordSummary().recordCount()).isEqualTo(1); assertThat(result.recordSummary().latestRecordPreview()).isEqualTo("기록 preview"); - assertThat(result.timelinePreview().dateGroups()).hasSize(2); + assertThat(result.timelinePreview().dateGroups()) + .extracting(TimelineResponseDto.TimelineDateGroupDto::showYear) + .containsExactly(false, true); } @Test @@ -297,6 +303,9 @@ class GetTimelineSummary { given(timelineRepository.findTop5ByLibraryOrderByOccurredAtDescIdDesc(library)) .willReturn(timelines); + given(timelineRepository.findFirstOccurredAtByLibraryAndYears(library, Set.of(2026))) + .willReturn(List.of(LocalDateTime.of(2026, 1, 1, 12, 0))); + TimelineResponseDto.TimelineSummaryDto result = timelineQueryService.getTimelineSummary(user, 12L); @@ -305,6 +314,9 @@ class GetTimelineSummary { .sum(); assertThat(itemCount).isEqualTo(5); + assertThat(result.timelinePreview().dateGroups()) + .extracting(TimelineResponseDto.TimelineDateGroupDto::showYear) + .containsOnly(false); } @Test @@ -356,6 +368,9 @@ class GetTimelinePreview { library, PageRequest.of(0, 3))) .willReturn(List.of(first, second, extra)); + given(timelineRepository.findFirstOccurredAtByLibraryAndYears(library, Set.of(2026))) + .willReturn(List.of(time)); + TimelineResponseDto.TimelinePageDto page = timelineQueryService.getTimelinePreview(user, 12L, null, 2); assertThat(page.dateGroups()).hasSize(1); @@ -370,7 +385,7 @@ class GetTimelinePreview { } @Test - @DisplayName("같은 날짜의 다음 페이지는 연도를 다시 표시하지 않는다") + @DisplayName("연도 첫 기록 날짜가 다음 페이지에 이어져도 연도 표시를 유지한다") void getTimelinePreview_같은날짜_다음페이지() { User user = user(1L); Library library = library(user, 12L); @@ -381,33 +396,40 @@ class GetTimelinePreview { given(timelineRepository.findBeforeCursor(library, time, 104L, PageRequest.of(0, 3))).willReturn(List.of(extra)); given(recordRepository.findAllById(List.of(9001L))).willReturn(List.of()); + given(timelineRepository.findFirstOccurredAtByLibraryAndYears(library, Set.of(2026))) + .willReturn(List.of(time)); + TimelineResponseDto.TimelinePageDto nextPage = timelineQueryService.getTimelinePreview(user, 12L, cursor, 2); assertThat(nextPage.dateGroups().get(0).items()).extracting(TimelineResponseDto.TimelineItemDto::timelineId) .containsExactly(103L); - assertThat(nextPage.dateGroups().get(0).showYear()).isFalse(); + assertThat(nextPage.dateGroups().get(0).showYear()).isTrue(); assertThat(nextPage.hasNext()).isFalse(); assertThat(nextPage.nextCursor()).isNull(); } @ParameterizedTest - @ValueSource(ints = {2025, 2026}) - @DisplayName("다음 페이지의 연도 표시는 커서의 연도와 비교한다") - void getTimelinePreview_커서연도_표시여부(int cursorYear) { + @CsvSource({"2025, 29", "2025, 30", "2026, 29", "2026, 30"}) + @DisplayName("커서 연도와 관계없이 해당 연도의 첫 기록 날짜에만 연도를 표시한다") + void getTimelinePreview_커서연도_표시여부(int cursorYear, int firstDay) { User user = user(1L); Library library = library(user, 12L); TimelineCursor cursor = new TimelineCursor(LocalDateTime.of(cursorYear, 12, 31, 23, 0), 20L); Timeline item = timeline(19L, library, TimelineType.REGISTER, LocalDateTime.of(2025, 12, 30, 12, 0), "등록", 12L); + Timeline first = timeline(18L, library, TimelineType.REGISTER, + LocalDateTime.of(2025, 12, firstDay, 10, 0), "등록", 12L); given(libraryRepository.findById(12L)).willReturn(Optional.of(library)); given(timelineRepository.findBeforeCursor(library, cursor.occurredAt(), cursor.timelineId(), - PageRequest.of(0, 2))).willReturn(List.of(item)); + PageRequest.of(0, 2))).willReturn(List.of(item, first)); + given(timelineRepository.findFirstOccurredAtByLibraryAndYears(library, Set.of(2025))) + .willReturn(List.of(first.getOccurredAt())); TimelineResponseDto.TimelinePageDto page = timelineQueryService.getTimelinePreview(user, 12L, cursor, 1); - assertThat(page.dateGroups().get(0).showYear()).isEqualTo(cursorYear != 2025); - assertThat(page.hasNext()).isFalse(); - assertThat(page.nextCursor()).isNull(); + assertThat(page.dateGroups().get(0).showYear()).isEqualTo(firstDay == 30); + assertThat(page.hasNext()).isTrue(); + assertThat(page.nextCursor()).isNotNull(); } @Test @@ -469,7 +491,7 @@ class GetTimelinePreview { } @Test - @DisplayName("연도가 바뀌는 그룹만 showYear가 true다") + @DisplayName("각 연도의 첫 기록 날짜 그룹만 showYear가 true다") void getTimelinePreview_showYear_그룹기준() { User user = user(1L); Library library = library(user, 12L); @@ -489,12 +511,15 @@ class GetTimelinePreview { given(focusRepository.findAllById(List.of(7001L))).willReturn(Collections.emptyList()); given(recordRepository.findAllById(List.of(9001L))).willReturn(List.of(record(9001L, library, "기록 preview"))); + given(timelineRepository.findFirstOccurredAtByLibraryAndYears(library, Set.of(2025, 2026))) + .willReturn(List.of(timelines.get(1).getOccurredAt(), timelines.get(2).getOccurredAt())); + TimelineResponseDto.TimelinePageDto result = timelineQueryService.getTimelinePreview(user, 12L, null, 20); assertThat(result.dateGroups()).hasSize(3); - assertThat(result.dateGroups().get(0).showYear()).isTrue(); - assertThat(result.dateGroups().get(1).showYear()).isFalse(); + assertThat(result.dateGroups().get(0).showYear()).isFalse(); + assertThat(result.dateGroups().get(1).showYear()).isTrue(); assertThat(result.dateGroups().get(2).showYear()).isTrue(); }