diff --git a/src/main/java/com/daramg/server/composer/application/ComposerQueryService.java b/src/main/java/com/daramg/server/composer/application/ComposerQueryService.java index 0932c3d..0721ae8 100644 --- a/src/main/java/com/daramg/server/composer/application/ComposerQueryService.java +++ b/src/main/java/com/daramg/server/composer/application/ComposerQueryService.java @@ -6,6 +6,8 @@ import com.daramg.server.composer.dto.ComposerResponseDto; import com.daramg.server.composer.repository.ComposerLikeRepository; import com.daramg.server.composer.repository.ComposerRepository; +import com.daramg.server.post.dto.StoryPostStatsDto; +import com.daramg.server.post.repository.PostQueryRepository; import com.daramg.server.user.domain.User; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -14,6 +16,7 @@ import java.util.Collections; import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.Set; import java.util.stream.Collectors; @@ -24,9 +27,11 @@ public class ComposerQueryService { private final ComposerRepository composerRepository; private final ComposerLikeRepository composerLikeRepository; + private final PostQueryRepository postQueryRepository; public List getAllComposers(User user, List eras, List continents) { Set likedComposerIds = getLikedComposerIds(user); + Map statsMap = postQueryRepository.findStoryPostStatsByAllComposers(); List allComposers = composerRepository.findAll(); return allComposers.stream() @@ -34,7 +39,8 @@ public List getAllComposers(User user, List eras, List .filter(composer -> continents == null || continents.isEmpty() || continents.contains(composer.getContinent())) .map(composer -> ComposerResponseDto.from( composer, - likedComposerIds.contains(composer.getId()) + likedComposerIds.contains(composer.getId()), + statsMap.get(composer.getId()) )) .sorted( Comparator.comparing(ComposerResponseDto::isLiked).reversed() diff --git a/src/main/java/com/daramg/server/composer/dto/ComposerResponseDto.java b/src/main/java/com/daramg/server/composer/dto/ComposerResponseDto.java index c7cfaed..5f2f8d2 100644 --- a/src/main/java/com/daramg/server/composer/dto/ComposerResponseDto.java +++ b/src/main/java/com/daramg/server/composer/dto/ComposerResponseDto.java @@ -1,6 +1,10 @@ package com.daramg.server.composer.dto; import com.daramg.server.composer.domain.Composer; +import com.daramg.server.post.dto.StoryPostStatsDto; +import com.fasterxml.jackson.annotation.JsonInclude; + +import java.time.Instant; public record ComposerResponseDto( long composerId, @@ -12,9 +16,12 @@ public record ComposerResponseDto( Short birthYear, Short deathYear, String bio, - boolean isLiked + boolean isLiked, + long storyPostCount, + @JsonInclude(JsonInclude.Include.NON_NULL) + Instant lastStoryPostAt ) { - public static ComposerResponseDto from(Composer composer, boolean isLiked) { + public static ComposerResponseDto from(Composer composer, boolean isLiked, StoryPostStatsDto stats) { return new ComposerResponseDto( composer.getId(), composer.getKoreanName(), @@ -25,7 +32,9 @@ public static ComposerResponseDto from(Composer composer, boolean isLiked) { composer.getBirthYear(), composer.getDeathYear(), composer.getBio(), - isLiked + isLiked, + stats != null ? stats.storyPostCount() : 0L, + stats != null ? stats.lastStoryPostAt() : null ); } } diff --git a/src/main/java/com/daramg/server/post/application/PostQueryService.java b/src/main/java/com/daramg/server/post/application/PostQueryService.java index 60b6149..d856f8d 100644 --- a/src/main/java/com/daramg/server/post/application/PostQueryService.java +++ b/src/main/java/com/daramg/server/post/application/PostQueryService.java @@ -141,7 +141,7 @@ public PostDetailResponse getPostById(Long postId, User user) { public ComposerWithPostsResponseDto getComposerWithPosts(Long composerId, PageRequestDto pageRequest, User user) { Composer composer = entityUtils.getEntity(composerId, Composer.class); boolean isLiked = user != null && composerLikeRepository.existsByComposerIdAndUserId(composerId, user.getId()); - ComposerResponseDto composerDto = ComposerResponseDto.from(composer, isLiked); + ComposerResponseDto composerDto = ComposerResponseDto.from(composer, isLiked, null); List posts = postQueryRepository.getPostsByComposerIdWithPaging(composerId, pageRequest); diff --git a/src/main/java/com/daramg/server/post/dto/StoryPostStatsDto.java b/src/main/java/com/daramg/server/post/dto/StoryPostStatsDto.java new file mode 100644 index 0000000..87734de --- /dev/null +++ b/src/main/java/com/daramg/server/post/dto/StoryPostStatsDto.java @@ -0,0 +1,5 @@ +package com.daramg.server.post.dto; + +import java.time.Instant; + +public record StoryPostStatsDto(long storyPostCount, Instant lastStoryPostAt) {} diff --git a/src/main/java/com/daramg/server/post/repository/PostQueryRepository.java b/src/main/java/com/daramg/server/post/repository/PostQueryRepository.java index bfbbc40..c6a31a3 100644 --- a/src/main/java/com/daramg/server/post/repository/PostQueryRepository.java +++ b/src/main/java/com/daramg/server/post/repository/PostQueryRepository.java @@ -7,8 +7,10 @@ import com.daramg.server.post.domain.FreePost; import com.daramg.server.post.domain.Post; import com.daramg.server.post.domain.StoryPost; +import com.daramg.server.post.dto.StoryPostStatsDto; import java.util.List; +import java.util.Map; public interface PostQueryRepository { List getAllFreePostsWithPaging(PageRequestDto pageRequest); @@ -18,4 +20,5 @@ public interface PostQueryRepository { List getUserDraftPostsWithPaging(Long userId, PageRequestDto pageRequest); List getUserScrappedPostsWithPaging(Long userId, PageRequestDto pageRequest); List getPostsByComposerIdWithPaging(Long composerId, PageRequestDto pageRequest); + Map findStoryPostStatsByAllComposers(); } diff --git a/src/main/java/com/daramg/server/post/repository/PostQueryRepositoryImpl.java b/src/main/java/com/daramg/server/post/repository/PostQueryRepositoryImpl.java index 5213513..3e56375 100644 --- a/src/main/java/com/daramg/server/post/repository/PostQueryRepositoryImpl.java +++ b/src/main/java/com/daramg/server/post/repository/PostQueryRepositoryImpl.java @@ -10,8 +10,10 @@ import com.daramg.server.post.domain.PostStatus; import com.daramg.server.post.domain.QPost; import com.daramg.server.post.domain.StoryPost; +import com.daramg.server.post.dto.StoryPostStatsDto; import com.daramg.server.composer.domain.QComposer; import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.Tuple; import com.querydsl.core.types.dsl.EntityPathBase; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQuery; @@ -21,6 +23,8 @@ import java.util.ArrayList; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import static com.daramg.server.post.domain.QPost.post; import static com.daramg.server.post.domain.QPostScrap.postScrap; @@ -183,6 +187,31 @@ public List getPostsByComposerIdWithPaging(Long composerId, PageRequestDto ); } + @Override + public Map findStoryPostStatsByAllComposers() { + List results = queryFactory + .select( + storyPost.primaryComposer.id, + storyPost._super.id.count(), + storyPost._super.createdAt.max() + ) + .from(storyPost) + .where( + storyPost._super.isBlocked.isFalse() + .and(storyPost._super.postStatus.eq(PostStatus.PUBLISHED)) + ) + .groupBy(storyPost.primaryComposer.id) + .fetch(); + + return results.stream().collect(Collectors.toMap( + tuple -> tuple.get(storyPost.primaryComposer.id), + tuple -> new StoryPostStatsDto( + tuple.get(storyPost._super.id.count()), + tuple.get(storyPost._super.createdAt.max()) + ) + )); + } + private List getAllPostsWithPaging( PageRequestDto pageRequest, EntityPathBase qEntity, diff --git a/src/test/java/com/daramg/server/composer/application/ComposerQueryServiceTest.java b/src/test/java/com/daramg/server/composer/application/ComposerQueryServiceTest.java index 2e72f48..3517bf1 100644 --- a/src/test/java/com/daramg/server/composer/application/ComposerQueryServiceTest.java +++ b/src/test/java/com/daramg/server/composer/application/ComposerQueryServiceTest.java @@ -8,6 +8,10 @@ import com.daramg.server.composer.dto.ComposerResponseDto; import com.daramg.server.composer.repository.ComposerLikeRepository; import com.daramg.server.composer.repository.ComposerRepository; +import com.daramg.server.post.domain.PostStatus; +import com.daramg.server.post.domain.StoryPost; +import com.daramg.server.post.domain.vo.PostCreateVo; +import com.daramg.server.post.repository.PostRepository; import com.daramg.server.testsupport.support.ServiceTestSupport; import com.daramg.server.user.domain.User; import com.daramg.server.user.repository.UserRepository; @@ -35,6 +39,9 @@ public class ComposerQueryServiceTest extends ServiceTestSupport { @Autowired private UserRepository userRepository; + @Autowired + private PostRepository postRepository; + private User user; private Composer corelli; // 코렐리 private Composer vitali; // 비탈리 @@ -180,6 +187,33 @@ void filters_limitResultsByEraAndContinent() { assertThat(result.getFirst().isLiked()).isFalse(); } + @Test + @DisplayName("스토리 게시글이 있는 작곡가는 storyPostCount와 lastStoryPostAt이 반환된다") + void storyPostStats_returnedCorrectly() { + // given - 비발디에 PUBLISHED 스토리 2개, 코렐리에 1개 저장 + postRepository.save(StoryPost.from(new PostCreateVo.Story(user, "비발디 글1", "내용", PostStatus.PUBLISHED, List.of(), null, List.of(), vivaldi))); + postRepository.save(StoryPost.from(new PostCreateVo.Story(user, "비발디 글2", "내용", PostStatus.PUBLISHED, List.of(), null, List.of(), vivaldi))); + postRepository.save(StoryPost.from(new PostCreateVo.Story(user, "코렐리 글1", "내용", PostStatus.PUBLISHED, List.of(), null, List.of(), corelli))); + // DRAFT는 집계 제외 + postRepository.save(StoryPost.from(new PostCreateVo.Story(user, "비탈리 초안", "내용", PostStatus.DRAFT, List.of(), null, List.of(), vitali))); + + // when + List result = composerQueryService.getAllComposers(null, null, null); + + // then + ComposerResponseDto vivaldResult = result.stream().filter(r -> r.koreanName().equals("비발디")).findFirst().orElseThrow(); + assertThat(vivaldResult.storyPostCount()).isEqualTo(2L); + assertThat(vivaldResult.lastStoryPostAt()).isNotNull(); + + ComposerResponseDto corelliResult = result.stream().filter(r -> r.koreanName().equals("코렐리")).findFirst().orElseThrow(); + assertThat(corelliResult.storyPostCount()).isEqualTo(1L); + assertThat(corelliResult.lastStoryPostAt()).isNotNull(); + + ComposerResponseDto vitaliResult = result.stream().filter(r -> r.koreanName().equals("비탈리")).findFirst().orElseThrow(); + assertThat(vitaliResult.storyPostCount()).isEqualTo(0L); + assertThat(vitaliResult.lastStoryPostAt()).isNull(); + } + @Test @DisplayName("빈 리스트 필터는 미적용으로 간주한다") void emptyLists_areTreatedAsNoFilter() { diff --git a/src/test/java/com/daramg/server/composer/presentation/ComposerQueryControllerTest.java b/src/test/java/com/daramg/server/composer/presentation/ComposerQueryControllerTest.java index 95a87f0..3abb0f1 100644 --- a/src/test/java/com/daramg/server/composer/presentation/ComposerQueryControllerTest.java +++ b/src/test/java/com/daramg/server/composer/presentation/ComposerQueryControllerTest.java @@ -42,14 +42,14 @@ public class ComposerQueryControllerTest extends ControllerTestSupport { void 작곡가_목록을_조회한다() throws Exception { // given List response = List.of( - new ComposerResponseDto(1L, "코렐리", "Arcangelo Corelli", "Arcangelo Corelli", "이탈리아", "MALE", (short) 1653, (short) 1713, "“바이올린의 따스한 숨결이 서로를 감싸는 밤”", true), - new ComposerResponseDto(2L, "비탈리", "Tomaso Antonio Vitali", "Tomaso Antonio Vitali", "이탈리아", "MALE", (short) 1663, (short) 1745, "“서정이 흐르는 현의 떨림, 조용히 마음을 울리는 음악”", false), - new ComposerResponseDto(3L, "A. 스카를라티", "Alessandro Scarlatti", "Alessandro Scarlatti", "이탈리아", "MALE", (short) 1660, (short) 1725, "“수많은 이야기 속에 피어나는 이탈리아의 정열”", true), - new ComposerResponseDto(4L, "D. 스카를라티", "Domenico Scarlatti", "Domenico Scarlatti", "이탈리아", "MALE", (short) 1685, (short) 1757, "“하늘을 나는 건반 위 상상, 자유로운 영혼의 소나타”", false), - new ComposerResponseDto(5L, "비발디", "Antonio Vivaldi", "Antonio Vivaldi", "이탈리아", "MALE", (short) 1678, (short) 1741, "“빨간 머리의 계절처럼 쏟아지는 생명과 빛”", true), - new ComposerResponseDto(6L, "타르티니", "Giuseppe Tartini", "Giuseppe Tartini", "이탈리아", "MALE", (short) 1692, (short) 1770, "“악마도 울릴 만큼 깊은 꿈결, 신비로운 선율의 마법”", false), - new ComposerResponseDto(7L, "파헬벨", "Johann Pachelbel", "Johann Pachelbel", "독일", "MALE", (short) 1653, (short) 1706, "“시간 너머의 따뜻한 안식, 평화로운 하루의 시작”", true), - new ComposerResponseDto(8L, "마테존", "Johann Mattheson", "Johann Mattheson", "독일", "MALE", (short) 1681, (short) 1764, "“생각과 음악이 나란히 걷는 길, 새로움을 질문하는 순간”", false) + new ComposerResponseDto(1L, "코렐리", "Arcangelo Corelli", "Arcangelo Corelli", "이탈리아", "MALE", (short) 1653, (short) 1713, "바이올린의 따스한 숨결이 서로를 감싸는 밤", true, 3L, Instant.parse("2024-03-01T12:00:00Z")), + new ComposerResponseDto(2L, "비탈리", "Tomaso Antonio Vitali", "Tomaso Antonio Vitali", "이탈리아", "MALE", (short) 1663, (short) 1745, "서정이 흐르는 현의 떨림, 조용히 마음을 울리는 음악", false, 0L, null), + new ComposerResponseDto(3L, "A. 스카를라티", "Alessandro Scarlatti", "Alessandro Scarlatti", "이탈리아", "MALE", (short) 1660, (short) 1725, "수많은 이야기 속에 피어나는 이탈리아의 정열", true, 1L, Instant.parse("2024-02-15T09:00:00Z")), + new ComposerResponseDto(4L, "D. 스카를라티", "Domenico Scarlatti", "Domenico Scarlatti", "이탈리아", "MALE", (short) 1685, (short) 1757, "하늘을 나는 건반 위 상상, 자유로운 영혼의 소나타", false, 0L, null), + new ComposerResponseDto(5L, "비발디", "Antonio Vivaldi", "Antonio Vivaldi", "이탈리아", "MALE", (short) 1678, (short) 1741, "빨간 머리의 계절처럼 쏟아지는 생명과 빛", true, 5L, Instant.parse("2024-03-10T18:30:00Z")), + new ComposerResponseDto(6L, "타르티니", "Giuseppe Tartini", "Giuseppe Tartini", "이탈리아", "MALE", (short) 1692, (short) 1770, "악마도 울릴 만큼 깊은 꿈결, 신비로운 선율의 마법", false, 2L, Instant.parse("2024-01-20T14:00:00Z")), + new ComposerResponseDto(7L, "파헬벨", "Johann Pachelbel", "Johann Pachelbel", "독일", "MALE", (short) 1653, (short) 1706, "시간 너머의 따뜻한 안식, 평화로운 하루의 시작", true, 0L, null), + new ComposerResponseDto(8L, "마테존", "Johann Mattheson", "Johann Mattheson", "독일", "MALE", (short) 1681, (short) 1764, "생각과 음악이 나란히 걷는 길, 새로움을 질문하는 순간", false, 4L, Instant.parse("2024-03-05T10:00:00Z")) ); when(composerQueryService.getAllComposers(any(), any(), any())).thenReturn(response); @@ -81,7 +81,9 @@ public class ComposerQueryControllerTest extends ControllerTestSupport { fieldWithPath("[].birthYear").type(JsonFieldType.NUMBER).description("작곡가 출생년도").optional(), fieldWithPath("[].deathYear").type(JsonFieldType.NUMBER).description("작곡가 사망년도").optional(), fieldWithPath("[].bio").type(JsonFieldType.STRING).description("작곡가 소개").optional(), - fieldWithPath("[].isLiked").type(JsonFieldType.BOOLEAN).description("현재 유저의 좋아요 여부 (비로그인 시 false)") + fieldWithPath("[].isLiked").type(JsonFieldType.BOOLEAN).description("현재 유저의 좋아요 여부 (비로그인 시 false)"), + fieldWithPath("[].storyPostCount").type(JsonFieldType.NUMBER).description("스토리 게시글 수"), + fieldWithPath("[].lastStoryPostAt").type(JsonFieldType.STRING).description("가장 최근 스토리 게시글 작성 시각 (게시글 없으면 null)").optional() ) .build() ) @@ -94,7 +96,8 @@ public class ComposerQueryControllerTest extends ControllerTestSupport { Long composerId = 1L; ComposerResponseDto composerDto = new ComposerResponseDto( 1L, "코렐리", "Arcangelo Corelli", "Arcangelo Corelli", "이탈리아", "MALE", - (short) 1653, (short) 1713, "“바이올린의 따스한 숨결이 서로를 감싸는 밤”", true + (short) 1653, (short) 1713, "바이올린의 따스한 숨결이 서로를 감싸는 밤", true, + 3L, Instant.parse("2024-01-15T10:30:00Z") ); PostResponseDto post1 = new PostResponseDto( @@ -176,6 +179,8 @@ public class ComposerQueryControllerTest extends ControllerTestSupport { fieldWithPath("composer.deathYear").type(JsonFieldType.NUMBER).description("작곡가 사망년도").optional(), fieldWithPath("composer.bio").type(JsonFieldType.STRING).description("작곡가 소개").optional(), fieldWithPath("composer.isLiked").type(JsonFieldType.BOOLEAN).description("현재 유저의 좋아요 여부 (비로그인 시 false)"), + fieldWithPath("composer.storyPostCount").type(JsonFieldType.NUMBER).description("스토리 게시글 수"), + fieldWithPath("composer.lastStoryPostAt").type(JsonFieldType.STRING).description("가장 최근 스토리 게시글 작성 시각 (게시글 없으면 null)").optional(), fieldWithPath("posts").type(JsonFieldType.OBJECT).description("포스트 목록 페이징 정보"), fieldWithPath("posts.content").type(JsonFieldType.ARRAY).description("포스트 목록"), fieldWithPath("posts.content[].id").type(JsonFieldType.NUMBER).description("포스트 ID"),