Skip to content

Commit c9409df

Browse files
authored
[feat] 작곡가 목록 응답에 storyPostCount, lastStoryPostAt 추가 (#92)
1 parent 7065792 commit c9409df

8 files changed

Lines changed: 106 additions & 15 deletions

File tree

src/main/java/com/daramg/server/composer/application/ComposerQueryService.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66
import com.daramg.server.composer.dto.ComposerResponseDto;
77
import com.daramg.server.composer.repository.ComposerLikeRepository;
88
import com.daramg.server.composer.repository.ComposerRepository;
9+
import com.daramg.server.post.dto.StoryPostStatsDto;
10+
import com.daramg.server.post.repository.PostQueryRepository;
911
import com.daramg.server.user.domain.User;
1012
import lombok.RequiredArgsConstructor;
1113
import org.springframework.stereotype.Service;
@@ -14,6 +16,7 @@
1416
import java.util.Collections;
1517
import java.util.Comparator;
1618
import java.util.List;
19+
import java.util.Map;
1720
import java.util.Set;
1821
import java.util.stream.Collectors;
1922

@@ -24,17 +27,20 @@ public class ComposerQueryService {
2427

2528
private final ComposerRepository composerRepository;
2629
private final ComposerLikeRepository composerLikeRepository;
30+
private final PostQueryRepository postQueryRepository;
2731

2832
public List<ComposerResponseDto> getAllComposers(User user, List<Era> eras, List<Continent> continents) {
2933
Set<Long> likedComposerIds = getLikedComposerIds(user);
34+
Map<Long, StoryPostStatsDto> statsMap = postQueryRepository.findStoryPostStatsByAllComposers();
3035
List<Composer> allComposers = composerRepository.findAll();
3136

3237
return allComposers.stream()
3338
.filter(composer -> eras == null || eras.isEmpty() || eras.contains(composer.getEra()))
3439
.filter(composer -> continents == null || continents.isEmpty() || continents.contains(composer.getContinent()))
3540
.map(composer -> ComposerResponseDto.from(
3641
composer,
37-
likedComposerIds.contains(composer.getId())
42+
likedComposerIds.contains(composer.getId()),
43+
statsMap.get(composer.getId())
3844
))
3945
.sorted(
4046
Comparator.comparing(ComposerResponseDto::isLiked).reversed()

src/main/java/com/daramg/server/composer/dto/ComposerResponseDto.java

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
package com.daramg.server.composer.dto;
22

33
import com.daramg.server.composer.domain.Composer;
4+
import com.daramg.server.post.dto.StoryPostStatsDto;
5+
import com.fasterxml.jackson.annotation.JsonInclude;
6+
7+
import java.time.Instant;
48

59
public record ComposerResponseDto(
610
long composerId,
@@ -12,9 +16,12 @@ public record ComposerResponseDto(
1216
Short birthYear,
1317
Short deathYear,
1418
String bio,
15-
boolean isLiked
19+
boolean isLiked,
20+
long storyPostCount,
21+
@JsonInclude(JsonInclude.Include.NON_NULL)
22+
Instant lastStoryPostAt
1623
) {
17-
public static ComposerResponseDto from(Composer composer, boolean isLiked) {
24+
public static ComposerResponseDto from(Composer composer, boolean isLiked, StoryPostStatsDto stats) {
1825
return new ComposerResponseDto(
1926
composer.getId(),
2027
composer.getKoreanName(),
@@ -25,7 +32,9 @@ public static ComposerResponseDto from(Composer composer, boolean isLiked) {
2532
composer.getBirthYear(),
2633
composer.getDeathYear(),
2734
composer.getBio(),
28-
isLiked
35+
isLiked,
36+
stats != null ? stats.storyPostCount() : 0L,
37+
stats != null ? stats.lastStoryPostAt() : null
2938
);
3039
}
3140
}

src/main/java/com/daramg/server/post/application/PostQueryService.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,7 @@ public PostDetailResponse getPostById(Long postId, User user) {
141141
public ComposerWithPostsResponseDto getComposerWithPosts(Long composerId, PageRequestDto pageRequest, User user) {
142142
Composer composer = entityUtils.getEntity(composerId, Composer.class);
143143
boolean isLiked = user != null && composerLikeRepository.existsByComposerIdAndUserId(composerId, user.getId());
144-
ComposerResponseDto composerDto = ComposerResponseDto.from(composer, isLiked);
144+
ComposerResponseDto composerDto = ComposerResponseDto.from(composer, isLiked, null);
145145

146146
List<Post> posts = postQueryRepository.getPostsByComposerIdWithPaging(composerId, pageRequest);
147147

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
package com.daramg.server.post.dto;
2+
3+
import java.time.Instant;
4+
5+
public record StoryPostStatsDto(long storyPostCount, Instant lastStoryPostAt) {}

src/main/java/com/daramg/server/post/repository/PostQueryRepository.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@
77
import com.daramg.server.post.domain.FreePost;
88
import com.daramg.server.post.domain.Post;
99
import com.daramg.server.post.domain.StoryPost;
10+
import com.daramg.server.post.dto.StoryPostStatsDto;
1011

1112
import java.util.List;
13+
import java.util.Map;
1214

1315
public interface PostQueryRepository {
1416
List<FreePost> getAllFreePostsWithPaging(PageRequestDto pageRequest);
@@ -18,4 +20,5 @@ public interface PostQueryRepository {
1820
List<Post> getUserDraftPostsWithPaging(Long userId, PageRequestDto pageRequest);
1921
List<Post> getUserScrappedPostsWithPaging(Long userId, PageRequestDto pageRequest);
2022
List<Post> getPostsByComposerIdWithPaging(Long composerId, PageRequestDto pageRequest);
23+
Map<Long, StoryPostStatsDto> findStoryPostStatsByAllComposers();
2124
}

src/main/java/com/daramg/server/post/repository/PostQueryRepositoryImpl.java

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
import com.daramg.server.post.domain.PostStatus;
1111
import com.daramg.server.post.domain.QPost;
1212
import com.daramg.server.post.domain.StoryPost;
13+
import com.daramg.server.post.dto.StoryPostStatsDto;
1314
import com.daramg.server.composer.domain.QComposer;
1415
import com.querydsl.core.BooleanBuilder;
16+
import com.querydsl.core.Tuple;
1517
import com.querydsl.core.types.dsl.EntityPathBase;
1618
import com.querydsl.jpa.JPAExpressions;
1719
import com.querydsl.jpa.impl.JPAQuery;
@@ -21,6 +23,8 @@
2123

2224
import java.util.ArrayList;
2325
import java.util.List;
26+
import java.util.Map;
27+
import java.util.stream.Collectors;
2428

2529
import static com.daramg.server.post.domain.QPost.post;
2630
import static com.daramg.server.post.domain.QPostScrap.postScrap;
@@ -183,6 +187,31 @@ public List<Post> getPostsByComposerIdWithPaging(Long composerId, PageRequestDto
183187
);
184188
}
185189

190+
@Override
191+
public Map<Long, StoryPostStatsDto> findStoryPostStatsByAllComposers() {
192+
List<Tuple> results = queryFactory
193+
.select(
194+
storyPost.primaryComposer.id,
195+
storyPost._super.id.count(),
196+
storyPost._super.createdAt.max()
197+
)
198+
.from(storyPost)
199+
.where(
200+
storyPost._super.isBlocked.isFalse()
201+
.and(storyPost._super.postStatus.eq(PostStatus.PUBLISHED))
202+
)
203+
.groupBy(storyPost.primaryComposer.id)
204+
.fetch();
205+
206+
return results.stream().collect(Collectors.toMap(
207+
tuple -> tuple.get(storyPost.primaryComposer.id),
208+
tuple -> new StoryPostStatsDto(
209+
tuple.get(storyPost._super.id.count()),
210+
tuple.get(storyPost._super.createdAt.max())
211+
)
212+
));
213+
}
214+
186215
private <T extends Post> List<T> getAllPostsWithPaging(
187216
PageRequestDto pageRequest,
188217
EntityPathBase<T> qEntity,

src/test/java/com/daramg/server/composer/application/ComposerQueryServiceTest.java

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
import com.daramg.server.composer.dto.ComposerResponseDto;
99
import com.daramg.server.composer.repository.ComposerLikeRepository;
1010
import com.daramg.server.composer.repository.ComposerRepository;
11+
import com.daramg.server.post.domain.PostStatus;
12+
import com.daramg.server.post.domain.StoryPost;
13+
import com.daramg.server.post.domain.vo.PostCreateVo;
14+
import com.daramg.server.post.repository.PostRepository;
1115
import com.daramg.server.testsupport.support.ServiceTestSupport;
1216
import com.daramg.server.user.domain.User;
1317
import com.daramg.server.user.repository.UserRepository;
@@ -35,6 +39,9 @@ public class ComposerQueryServiceTest extends ServiceTestSupport {
3539
@Autowired
3640
private UserRepository userRepository;
3741

42+
@Autowired
43+
private PostRepository postRepository;
44+
3845
private User user;
3946
private Composer corelli; // 코렐리
4047
private Composer vitali; // 비탈리
@@ -180,6 +187,33 @@ void filters_limitResultsByEraAndContinent() {
180187
assertThat(result.getFirst().isLiked()).isFalse();
181188
}
182189

190+
@Test
191+
@DisplayName("스토리 게시글이 있는 작곡가는 storyPostCount와 lastStoryPostAt이 반환된다")
192+
void storyPostStats_returnedCorrectly() {
193+
// given - 비발디에 PUBLISHED 스토리 2개, 코렐리에 1개 저장
194+
postRepository.save(StoryPost.from(new PostCreateVo.Story(user, "비발디 글1", "내용", PostStatus.PUBLISHED, List.of(), null, List.of(), vivaldi)));
195+
postRepository.save(StoryPost.from(new PostCreateVo.Story(user, "비발디 글2", "내용", PostStatus.PUBLISHED, List.of(), null, List.of(), vivaldi)));
196+
postRepository.save(StoryPost.from(new PostCreateVo.Story(user, "코렐리 글1", "내용", PostStatus.PUBLISHED, List.of(), null, List.of(), corelli)));
197+
// DRAFT는 집계 제외
198+
postRepository.save(StoryPost.from(new PostCreateVo.Story(user, "비탈리 초안", "내용", PostStatus.DRAFT, List.of(), null, List.of(), vitali)));
199+
200+
// when
201+
List<ComposerResponseDto> result = composerQueryService.getAllComposers(null, null, null);
202+
203+
// then
204+
ComposerResponseDto vivaldResult = result.stream().filter(r -> r.koreanName().equals("비발디")).findFirst().orElseThrow();
205+
assertThat(vivaldResult.storyPostCount()).isEqualTo(2L);
206+
assertThat(vivaldResult.lastStoryPostAt()).isNotNull();
207+
208+
ComposerResponseDto corelliResult = result.stream().filter(r -> r.koreanName().equals("코렐리")).findFirst().orElseThrow();
209+
assertThat(corelliResult.storyPostCount()).isEqualTo(1L);
210+
assertThat(corelliResult.lastStoryPostAt()).isNotNull();
211+
212+
ComposerResponseDto vitaliResult = result.stream().filter(r -> r.koreanName().equals("비탈리")).findFirst().orElseThrow();
213+
assertThat(vitaliResult.storyPostCount()).isEqualTo(0L);
214+
assertThat(vitaliResult.lastStoryPostAt()).isNull();
215+
}
216+
183217
@Test
184218
@DisplayName("빈 리스트 필터는 미적용으로 간주한다")
185219
void emptyLists_areTreatedAsNoFilter() {

src/test/java/com/daramg/server/composer/presentation/ComposerQueryControllerTest.java

Lines changed: 15 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,14 @@ public class ComposerQueryControllerTest extends ControllerTestSupport {
4242
void 작곡가_목록을_조회한다() throws Exception {
4343
// given
4444
List<ComposerResponseDto> response = List.of(
45-
new ComposerResponseDto(1L, "코렐리", "Arcangelo Corelli", "Arcangelo Corelli", "이탈리아", "MALE", (short) 1653, (short) 1713, "바이올린의 따스한 숨결이 서로를 감싸는 밤", true),
46-
new ComposerResponseDto(2L, "비탈리", "Tomaso Antonio Vitali", "Tomaso Antonio Vitali", "이탈리아", "MALE", (short) 1663, (short) 1745, "서정이 흐르는 현의 떨림, 조용히 마음을 울리는 음악", false),
47-
new ComposerResponseDto(3L, "A. 스카를라티", "Alessandro Scarlatti", "Alessandro Scarlatti", "이탈리아", "MALE", (short) 1660, (short) 1725, "수많은 이야기 속에 피어나는 이탈리아의 정열", true),
48-
new ComposerResponseDto(4L, "D. 스카를라티", "Domenico Scarlatti", "Domenico Scarlatti", "이탈리아", "MALE", (short) 1685, (short) 1757, "하늘을 나는 건반 위 상상, 자유로운 영혼의 소나타", false),
49-
new ComposerResponseDto(5L, "비발디", "Antonio Vivaldi", "Antonio Vivaldi", "이탈리아", "MALE", (short) 1678, (short) 1741, "빨간 머리의 계절처럼 쏟아지는 생명과 빛", true),
50-
new ComposerResponseDto(6L, "타르티니", "Giuseppe Tartini", "Giuseppe Tartini", "이탈리아", "MALE", (short) 1692, (short) 1770, "악마도 울릴 만큼 깊은 꿈결, 신비로운 선율의 마법", false),
51-
new ComposerResponseDto(7L, "파헬벨", "Johann Pachelbel", "Johann Pachelbel", "독일", "MALE", (short) 1653, (short) 1706, "시간 너머의 따뜻한 안식, 평화로운 하루의 시작", true),
52-
new ComposerResponseDto(8L, "마테존", "Johann Mattheson", "Johann Mattheson", "독일", "MALE", (short) 1681, (short) 1764, "생각과 음악이 나란히 걷는 길, 새로움을 질문하는 순간", false)
45+
new ComposerResponseDto(1L, "코렐리", "Arcangelo Corelli", "Arcangelo Corelli", "이탈리아", "MALE", (short) 1653, (short) 1713, "바이올린의 따스한 숨결이 서로를 감싸는 밤", true, 3L, Instant.parse("2024-03-01T12:00:00Z")),
46+
new ComposerResponseDto(2L, "비탈리", "Tomaso Antonio Vitali", "Tomaso Antonio Vitali", "이탈리아", "MALE", (short) 1663, (short) 1745, "서정이 흐르는 현의 떨림, 조용히 마음을 울리는 음악", false, 0L, null),
47+
new ComposerResponseDto(3L, "A. 스카를라티", "Alessandro Scarlatti", "Alessandro Scarlatti", "이탈리아", "MALE", (short) 1660, (short) 1725, "수많은 이야기 속에 피어나는 이탈리아의 정열", true, 1L, Instant.parse("2024-02-15T09:00:00Z")),
48+
new ComposerResponseDto(4L, "D. 스카를라티", "Domenico Scarlatti", "Domenico Scarlatti", "이탈리아", "MALE", (short) 1685, (short) 1757, "하늘을 나는 건반 위 상상, 자유로운 영혼의 소나타", false, 0L, null),
49+
new ComposerResponseDto(5L, "비발디", "Antonio Vivaldi", "Antonio Vivaldi", "이탈리아", "MALE", (short) 1678, (short) 1741, "빨간 머리의 계절처럼 쏟아지는 생명과 빛", true, 5L, Instant.parse("2024-03-10T18:30:00Z")),
50+
new ComposerResponseDto(6L, "타르티니", "Giuseppe Tartini", "Giuseppe Tartini", "이탈리아", "MALE", (short) 1692, (short) 1770, "악마도 울릴 만큼 깊은 꿈결, 신비로운 선율의 마법", false, 2L, Instant.parse("2024-01-20T14:00:00Z")),
51+
new ComposerResponseDto(7L, "파헬벨", "Johann Pachelbel", "Johann Pachelbel", "독일", "MALE", (short) 1653, (short) 1706, "시간 너머의 따뜻한 안식, 평화로운 하루의 시작", true, 0L, null),
52+
new ComposerResponseDto(8L, "마테존", "Johann Mattheson", "Johann Mattheson", "독일", "MALE", (short) 1681, (short) 1764, "생각과 음악이 나란히 걷는 길, 새로움을 질문하는 순간", false, 4L, Instant.parse("2024-03-05T10:00:00Z"))
5353
);
5454
when(composerQueryService.getAllComposers(any(), any(), any())).thenReturn(response);
5555

@@ -81,7 +81,9 @@ public class ComposerQueryControllerTest extends ControllerTestSupport {
8181
fieldWithPath("[].birthYear").type(JsonFieldType.NUMBER).description("작곡가 출생년도").optional(),
8282
fieldWithPath("[].deathYear").type(JsonFieldType.NUMBER).description("작곡가 사망년도").optional(),
8383
fieldWithPath("[].bio").type(JsonFieldType.STRING).description("작곡가 소개").optional(),
84-
fieldWithPath("[].isLiked").type(JsonFieldType.BOOLEAN).description("현재 유저의 좋아요 여부 (비로그인 시 false)")
84+
fieldWithPath("[].isLiked").type(JsonFieldType.BOOLEAN).description("현재 유저의 좋아요 여부 (비로그인 시 false)"),
85+
fieldWithPath("[].storyPostCount").type(JsonFieldType.NUMBER).description("스토리 게시글 수"),
86+
fieldWithPath("[].lastStoryPostAt").type(JsonFieldType.STRING).description("가장 최근 스토리 게시글 작성 시각 (게시글 없으면 null)").optional()
8587
)
8688
.build()
8789
)
@@ -94,7 +96,8 @@ public class ComposerQueryControllerTest extends ControllerTestSupport {
9496
Long composerId = 1L;
9597
ComposerResponseDto composerDto = new ComposerResponseDto(
9698
1L, "코렐리", "Arcangelo Corelli", "Arcangelo Corelli", "이탈리아", "MALE",
97-
(short) 1653, (short) 1713, "“바이올린의 따스한 숨결이 서로를 감싸는 밤”", true
99+
(short) 1653, (short) 1713, "바이올린의 따스한 숨결이 서로를 감싸는 밤", true,
100+
3L, Instant.parse("2024-01-15T10:30:00Z")
98101
);
99102

100103
PostResponseDto post1 = new PostResponseDto(
@@ -176,6 +179,8 @@ public class ComposerQueryControllerTest extends ControllerTestSupport {
176179
fieldWithPath("composer.deathYear").type(JsonFieldType.NUMBER).description("작곡가 사망년도").optional(),
177180
fieldWithPath("composer.bio").type(JsonFieldType.STRING).description("작곡가 소개").optional(),
178181
fieldWithPath("composer.isLiked").type(JsonFieldType.BOOLEAN).description("현재 유저의 좋아요 여부 (비로그인 시 false)"),
182+
fieldWithPath("composer.storyPostCount").type(JsonFieldType.NUMBER).description("스토리 게시글 수"),
183+
fieldWithPath("composer.lastStoryPostAt").type(JsonFieldType.STRING).description("가장 최근 스토리 게시글 작성 시각 (게시글 없으면 null)").optional(),
179184
fieldWithPath("posts").type(JsonFieldType.OBJECT).description("포스트 목록 페이징 정보"),
180185
fieldWithPath("posts.content").type(JsonFieldType.ARRAY).description("포스트 목록"),
181186
fieldWithPath("posts.content[].id").type(JsonFieldType.NUMBER).description("포스트 ID"),

0 commit comments

Comments
 (0)