diff --git a/src/main/java/com/dnd/gongmuin/question_post/controller/QuestionPostController.java b/src/main/java/com/dnd/gongmuin/question_post/controller/QuestionPostController.java index dba661ed..459d15b4 100644 --- a/src/main/java/com/dnd/gongmuin/question_post/controller/QuestionPostController.java +++ b/src/main/java/com/dnd/gongmuin/question_post/controller/QuestionPostController.java @@ -16,6 +16,7 @@ import com.dnd.gongmuin.question_post.dto.request.RegisterQuestionPostRequest; import com.dnd.gongmuin.question_post.dto.response.QuestionPostDetailResponse; import com.dnd.gongmuin.question_post.dto.response.QuestionPostSimpleResponse; +import com.dnd.gongmuin.question_post.dto.response.RecQuestionPostResponse; import com.dnd.gongmuin.question_post.dto.response.RegisterQuestionPostResponse; import com.dnd.gongmuin.question_post.service.QuestionPostService; @@ -64,4 +65,14 @@ public ResponseEntity> searchQuestionPo condition, pageable); return ResponseEntity.ok(response); } + + @GetMapping("/api/question-posts/recommends") + public ResponseEntity> getRecommendQuestionPosts( + @AuthenticationPrincipal Member member, + Pageable pageable + ) { + PageResponse response + = questionPostService.getRecommendQuestionPosts(member, pageable); + return ResponseEntity.ok(response); + } } \ No newline at end of file diff --git a/src/main/java/com/dnd/gongmuin/question_post/dto/response/RecQuestionPostResponse.java b/src/main/java/com/dnd/gongmuin/question_post/dto/response/RecQuestionPostResponse.java new file mode 100644 index 00000000..ba03d8ea --- /dev/null +++ b/src/main/java/com/dnd/gongmuin/question_post/dto/response/RecQuestionPostResponse.java @@ -0,0 +1,30 @@ +package com.dnd.gongmuin.question_post.dto.response; + +import com.dnd.gongmuin.question_post.domain.QuestionPost; +import com.querydsl.core.annotations.QueryProjection; + +public record RecQuestionPostResponse( + Long questionPostId, + String title, + int reward, + boolean isChosen, + int savedCount, + int recommendCount +) { + + @QueryProjection + public RecQuestionPostResponse( + QuestionPost questionPost, + int savedCount, + int recommendCount + ) { + this( + questionPost.getId(), + questionPost.getTitle(), + questionPost.getReward(), + questionPost.getIsChosen(), + savedCount, + recommendCount + ); + } +} diff --git a/src/main/java/com/dnd/gongmuin/question_post/repository/QuestionPostQueryRepository.java b/src/main/java/com/dnd/gongmuin/question_post/repository/QuestionPostQueryRepository.java index bbd1b49e..43839b91 100644 --- a/src/main/java/com/dnd/gongmuin/question_post/repository/QuestionPostQueryRepository.java +++ b/src/main/java/com/dnd/gongmuin/question_post/repository/QuestionPostQueryRepository.java @@ -3,9 +3,18 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; +import com.dnd.gongmuin.member.domain.JobGroup; import com.dnd.gongmuin.question_post.dto.request.QuestionPostSearchCondition; import com.dnd.gongmuin.question_post.dto.response.QuestionPostSimpleResponse; +import com.dnd.gongmuin.question_post.dto.response.RecQuestionPostResponse; public interface QuestionPostQueryRepository { - Slice searchQuestionPosts(QuestionPostSearchCondition condition, Pageable pageable); + Slice searchQuestionPosts( + QuestionPostSearchCondition condition, Pageable pageable + ); + + Slice getRecommendQuestionPosts( + JobGroup targetJobGroup, + Pageable pageable + ); } diff --git a/src/main/java/com/dnd/gongmuin/question_post/repository/QuestionPostQueryRepositoryImpl.java b/src/main/java/com/dnd/gongmuin/question_post/repository/QuestionPostQueryRepositoryImpl.java index 314ae467..73ec2924 100644 --- a/src/main/java/com/dnd/gongmuin/question_post/repository/QuestionPostQueryRepositoryImpl.java +++ b/src/main/java/com/dnd/gongmuin/question_post/repository/QuestionPostQueryRepositoryImpl.java @@ -15,7 +15,9 @@ import com.dnd.gongmuin.question_post.domain.QQuestionPost; import com.dnd.gongmuin.question_post.dto.request.QuestionPostSearchCondition; import com.dnd.gongmuin.question_post.dto.response.QQuestionPostSimpleResponse; +import com.dnd.gongmuin.question_post.dto.response.QRecQuestionPostResponse; import com.dnd.gongmuin.question_post.dto.response.QuestionPostSimpleResponse; +import com.dnd.gongmuin.question_post.dto.response.RecQuestionPostResponse; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -54,6 +56,44 @@ public Slice searchQuestionPosts( jobGroupContains(condition.jobGroups()), isChosenEq(condition.isChosen()) ) + .orderBy(questionPost.createdAt.desc()) + .limit(pageable.getPageSize() + 1L) + .offset(pageable.getOffset()) + .fetch(); + boolean hasNext = hasNext(pageable.getPageSize(), content); + return new SliceImpl<>(content, pageable, hasNext); + } + + @Override + public Slice getRecommendQuestionPosts( + JobGroup targetJobGroup, + Pageable pageable + ) { + QQuestionPost questionPost = QQuestionPost.questionPost; + QInteractionCount saved = new QInteractionCount("saved"); + QInteractionCount recommend = new QInteractionCount("recommend"); + + List content = queryFactory + .select(new QRecQuestionPostResponse( + questionPost, + saved.count.coalesce(0), + recommend.count.coalesce(0) + )) + .from(questionPost) + .leftJoin(saved) + .on(questionPost.id.eq(saved.questionPostId) + .and(saved.type.eq(InteractionType.SAVED))) + .leftJoin(recommend) + .on(questionPost.id.eq(recommend.questionPostId) + .and(recommend.type.eq(InteractionType.RECOMMEND))) + .where( + questionPost.jobGroup.eq(targetJobGroup) + ) + .orderBy( + recommend.count.coalesce(0).desc(), + saved.count.coalesce(0).desc(), + questionPost.createdAt.desc() + ) .limit(pageable.getPageSize() + 1L) .offset(pageable.getOffset()) .fetch(); @@ -83,11 +123,11 @@ private BooleanExpression keywordContains(String keyword) { return keyword != null ? questionPost.title.contains(keyword) : null; } - private boolean hasNext(int pageSize, List questionPosts) { - if (questionPosts.size() <= pageSize) { + private boolean hasNext(int pageSize, List items) { + if (items.size() <= pageSize) { return false; } - questionPosts.remove(pageSize); + items.remove(pageSize); return true; } } diff --git a/src/main/java/com/dnd/gongmuin/question_post/service/QuestionPostService.java b/src/main/java/com/dnd/gongmuin/question_post/service/QuestionPostService.java index 110f52b9..e4e86199 100644 --- a/src/main/java/com/dnd/gongmuin/question_post/service/QuestionPostService.java +++ b/src/main/java/com/dnd/gongmuin/question_post/service/QuestionPostService.java @@ -20,6 +20,7 @@ import com.dnd.gongmuin.question_post.dto.request.RegisterQuestionPostRequest; import com.dnd.gongmuin.question_post.dto.response.QuestionPostDetailResponse; import com.dnd.gongmuin.question_post.dto.response.QuestionPostSimpleResponse; +import com.dnd.gongmuin.question_post.dto.response.RecQuestionPostResponse; import com.dnd.gongmuin.question_post.dto.response.RegisterQuestionPostResponse; import com.dnd.gongmuin.question_post.exception.QuestionPostErrorCode; import com.dnd.gongmuin.question_post.repository.QuestionPostRepository; @@ -64,8 +65,18 @@ public PageResponse searchQuestionPost( QuestionPostSearchCondition condition, Pageable pageable ) { - Slice responsePage = questionPostRepository - .searchQuestionPosts(condition, pageable); + Slice responsePage = + questionPostRepository.searchQuestionPosts(condition, pageable); + return PageMapper.toPageResponse(responsePage); + } + + @Transactional(readOnly = true) + public PageResponse getRecommendQuestionPosts( + Member member, + Pageable pageable + ) { + Slice responsePage + = questionPostRepository.getRecommendQuestionPosts(member.getJobGroup(), pageable); return PageMapper.toPageResponse(responsePage); } diff --git a/src/test/java/com/dnd/gongmuin/common/fixture/InteractionFixture.java b/src/test/java/com/dnd/gongmuin/common/fixture/InteractionFixture.java index ccce2b78..310fb524 100644 --- a/src/test/java/com/dnd/gongmuin/common/fixture/InteractionFixture.java +++ b/src/test/java/com/dnd/gongmuin/common/fixture/InteractionFixture.java @@ -25,8 +25,8 @@ public static Interaction interaction( ReflectionTestUtils.setField(interaction, "id", id); return interaction; } - - public static Interaction interaction( + + public static Interaction interaction( InteractionType type, Long memberId, Long questionPostId @@ -37,17 +37,4 @@ public static Interaction interaction( questionPostId ); } - - public static Interaction interaction2( - InteractionType type, - Long memberId, - Long questionPostId - ) { - Interaction interaction = Interaction.of( - type, - memberId, - questionPostId - ); - return interaction; - } } diff --git a/src/test/java/com/dnd/gongmuin/common/fixture/MemberFixture.java b/src/test/java/com/dnd/gongmuin/common/fixture/MemberFixture.java index e4f79c64..6aca9626 100644 --- a/src/test/java/com/dnd/gongmuin/common/fixture/MemberFixture.java +++ b/src/test/java/com/dnd/gongmuin/common/fixture/MemberFixture.java @@ -48,8 +48,12 @@ public static Member member3() { public static Member member4() { return Member.of( + "회원", "소셜회원", + JobGroup.ADMINISTRATION, + JobCategory.GAS, "KAKAO1234/member2@daum.net", + "member@korea.kr", 20000 ); } diff --git a/src/test/java/com/dnd/gongmuin/member/controller/MemberControllerTest.java b/src/test/java/com/dnd/gongmuin/member/controller/MemberControllerTest.java index ed32c50d..6ebc02f4 100644 --- a/src/test/java/com/dnd/gongmuin/member/controller/MemberControllerTest.java +++ b/src/test/java/com/dnd/gongmuin/member/controller/MemberControllerTest.java @@ -182,13 +182,13 @@ void getBookmarksByMember() throws Exception { QuestionPost questionPost3 = QuestionPostFixture.questionPost(loginMember, "세 번째 게시글입니다."); questionPostRepository.saveAll(List.of(questionPost1, questionPost2, questionPost3)); - Interaction interaction1 = InteractionFixture.interaction2(InteractionType.SAVED, loginMember.getId(), + Interaction interaction1 = InteractionFixture.interaction(InteractionType.SAVED, loginMember.getId(), questionPost1.getId()); - Interaction interaction3 = InteractionFixture.interaction2(InteractionType.RECOMMEND, loginMember.getId(), + Interaction interaction3 = InteractionFixture.interaction(InteractionType.RECOMMEND, loginMember.getId(), questionPost2.getId()); - Interaction interaction2 = InteractionFixture.interaction2(InteractionType.SAVED, loginMember.getId(), + Interaction interaction2 = InteractionFixture.interaction(InteractionType.SAVED, loginMember.getId(), questionPost3.getId()); - Interaction interaction4 = InteractionFixture.interaction2(InteractionType.RECOMMEND, loginMember.getId(), + Interaction interaction4 = InteractionFixture.interaction(InteractionType.RECOMMEND, loginMember.getId(), questionPost3.getId()); interactionRepository.saveAll(List.of(interaction1, interaction2, interaction3, interaction4)); diff --git a/src/test/java/com/dnd/gongmuin/member/repository/MemberRepositoryTest.java b/src/test/java/com/dnd/gongmuin/member/repository/MemberRepositoryTest.java index b954f2fa..8b2c19fd 100644 --- a/src/test/java/com/dnd/gongmuin/member/repository/MemberRepositoryTest.java +++ b/src/test/java/com/dnd/gongmuin/member/repository/MemberRepositoryTest.java @@ -365,13 +365,13 @@ void getBookmarksByMember() { QuestionPost questionPost3 = QuestionPostFixture.questionPost(member2, "세 번째 게시글입니다."); questionPostRepository.saveAll(List.of(questionPost1, questionPost2, questionPost3)); - Interaction interaction1 = InteractionFixture.interaction2(InteractionType.SAVED, member1.getId(), + Interaction interaction1 = InteractionFixture.interaction(InteractionType.SAVED, member1.getId(), questionPost1.getId()); - Interaction interaction2 = InteractionFixture.interaction2(InteractionType.SAVED, member1.getId(), + Interaction interaction2 = InteractionFixture.interaction(InteractionType.SAVED, member1.getId(), questionPost2.getId()); - Interaction interaction3 = InteractionFixture.interaction2(InteractionType.RECOMMEND, member1.getId(), + Interaction interaction3 = InteractionFixture.interaction(InteractionType.RECOMMEND, member1.getId(), questionPost2.getId()); - Interaction interaction4 = InteractionFixture.interaction2(InteractionType.RECOMMEND, member1.getId(), + Interaction interaction4 = InteractionFixture.interaction(InteractionType.RECOMMEND, member1.getId(), questionPost3.getId()); interactionRepository.saveAll(List.of(interaction1, interaction2, interaction3, interaction4)); diff --git a/src/test/java/com/dnd/gongmuin/question_post/controller/QuestionPostControllerTest.java b/src/test/java/com/dnd/gongmuin/question_post/controller/QuestionPostControllerTest.java index 2a8ae51a..f790af22 100644 --- a/src/test/java/com/dnd/gongmuin/question_post/controller/QuestionPostControllerTest.java +++ b/src/test/java/com/dnd/gongmuin/question_post/controller/QuestionPostControllerTest.java @@ -14,9 +14,16 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.result.MockMvcResultHandlers; +import com.dnd.gongmuin.common.fixture.InteractionCountFixture; +import com.dnd.gongmuin.common.fixture.InteractionFixture; import com.dnd.gongmuin.common.fixture.QuestionPostFixture; import com.dnd.gongmuin.common.support.ApiTestSupport; import com.dnd.gongmuin.member.exception.MemberErrorCode; +import com.dnd.gongmuin.post_interaction.domain.Interaction; +import com.dnd.gongmuin.post_interaction.domain.InteractionCount; +import com.dnd.gongmuin.post_interaction.domain.InteractionType; +import com.dnd.gongmuin.post_interaction.repository.InteractionCountRepository; +import com.dnd.gongmuin.post_interaction.repository.InteractionRepository; import com.dnd.gongmuin.question_post.domain.QuestionPost; import com.dnd.gongmuin.question_post.dto.request.RegisterQuestionPostRequest; import com.dnd.gongmuin.question_post.repository.QuestionPostRepository; @@ -27,10 +34,18 @@ class QuestionPostControllerTest extends ApiTestSupport { @Autowired private QuestionPostRepository questionPostRepository; + @Autowired + private InteractionRepository interactionRepository; + + @Autowired + private InteractionCountRepository interactionCountRepository; + @AfterEach void teardown() { memberRepository.deleteAll(); questionPostRepository.deleteAll(); + interactionRepository.deleteAll(); + interactionCountRepository.deleteAll(); } @DisplayName("[질문글을 등록할 수 있다.]") @@ -120,8 +135,8 @@ void searchQuestionPost() throws Exception { .andExpect(status().isOk()) .andDo(MockMvcResultHandlers.print()) .andExpect(jsonPath("$.size").value(2)) - .andExpect(jsonPath("$.content[0].questionPostId").value(questionPost1.getId())) - .andExpect(jsonPath("$.content[1].questionPostId").value(questionPost2.getId())); + .andExpect(jsonPath("$.content[0].questionPostId").value(questionPost2.getId())) //최신순 정렬 + .andExpect(jsonPath("$.content[1].questionPostId").value(questionPost1.getId())); } @DisplayName("[질문글을 여러 직군들로 필터링할 수 있다.]") @@ -174,4 +189,33 @@ void searchQuestionPostByIsChosen() throws Exception { .andExpect(jsonPath("$.size").value(1)) .andExpect(jsonPath("$.content[0].questionPostId").value(questionPost2.getId())); } -} + + @DisplayName("[직군에 맞는 추천 질문 게시물을 조회할 수 있다. 추천순>북마크순]") + @Test + void getRecommendQuestionPosts() throws Exception { + QuestionPost questionPost1 = questionPostRepository.save(QuestionPostFixture.questionPost(loginMember)); + QuestionPost questionPost2 = questionPostRepository.save(QuestionPostFixture.questionPost(loginMember)); + QuestionPost questionPost3 = questionPostRepository.save(QuestionPostFixture.questionPost(loginMember)); + questionPostRepository.saveAll(List.of(questionPost1, questionPost2, questionPost3)); + + interactPost(questionPost3.getId(), InteractionType.RECOMMEND); + interactPost(questionPost1.getId(), InteractionType.SAVED); + mockMvc.perform(get("/api/question-posts/recommends") + .header(AUTHORIZATION, accessToken)) + .andExpect(status().isOk()) + .andDo(MockMvcResultHandlers.print()) + .andExpect(jsonPath("$.size").value(3)) + .andExpect(jsonPath("$.content[0].questionPostId").value(questionPost3.getId())) + .andExpect(jsonPath("$.content[1].questionPostId").value(questionPost1.getId())) + .andExpect(jsonPath("$.content[2].questionPostId").value(questionPost2.getId())); + } + + private void interactPost(Long questionPostId, InteractionType type) { + Interaction interaction = + InteractionFixture.interaction(type, 2L, questionPostId); + interactionRepository.save(interaction); + InteractionCount interactionCount = + InteractionCountFixture.interactionCount(type, questionPostId); + interactionCountRepository.save(interactionCount); + } +} \ No newline at end of file diff --git a/src/test/java/com/dnd/gongmuin/question_post/repository/QuestionPostRepositoryTest.java b/src/test/java/com/dnd/gongmuin/question_post/repository/QuestionPostRepositoryTest.java index e2ba7e76..e1abd6df 100644 --- a/src/test/java/com/dnd/gongmuin/question_post/repository/QuestionPostRepositoryTest.java +++ b/src/test/java/com/dnd/gongmuin/question_post/repository/QuestionPostRepositoryTest.java @@ -27,6 +27,7 @@ import com.dnd.gongmuin.question_post.domain.QuestionPost; import com.dnd.gongmuin.question_post.dto.request.QuestionPostSearchCondition; import com.dnd.gongmuin.question_post.dto.response.QuestionPostSimpleResponse; +import com.dnd.gongmuin.question_post.dto.response.RecQuestionPostResponse; import lombok.extern.slf4j.Slf4j; @@ -54,7 +55,7 @@ void setup() { member = memberRepository.save(MemberFixture.member()); } - @DisplayName("검색어로 필터링할 수 있다.") + @DisplayName("검색어로 필터링할 수 있다. 최신순으로 조회한다.") @Test void question_post_search_filter() { //given @@ -77,8 +78,8 @@ void question_post_search_filter() { //then Assertions.assertAll( () -> assertThat(responses).hasSize(2), - () -> assertThat(responses.get(0).questionPostId()).isEqualTo(questionPost1.getId()), - () -> assertThat(responses.get(1).questionPostId()).isEqualTo(questionPost2.getId()) + () -> assertThat(responses.get(0).questionPostId()).isEqualTo(questionPost2.getId()), + () -> assertThat(responses.get(1).questionPostId()).isEqualTo(questionPost1.getId()) ); } @@ -106,8 +107,8 @@ void question_post_jobgroup_filter() { //then Assertions.assertAll( () -> assertThat(responses).hasSize(2), - () -> assertThat(responses.get(0).questionPostId()).isEqualTo(questionPost1.getId()), - () -> assertThat(responses.get(1).questionPostId()).isEqualTo(questionPost2.getId()) + () -> assertThat(responses.get(0).questionPostId()).isEqualTo(questionPost2.getId()), + () -> assertThat(responses.get(1).questionPostId()).isEqualTo(questionPost1.getId()) ); } @@ -169,6 +170,86 @@ void question_post_join_interactionCount() { ); } + @DisplayName("추천 게시물들을 직군으로 필터링할 수 있다.") + @Test + void getRecommendPost_jobGroup_filter() { + //given + Member viewer = memberRepository.save(MemberFixture.member4()); + + QuestionPost questionPost1 = questionPostRepository.save(QuestionPostFixture.questionPost(member)); + ReflectionTestUtils.setField(questionPost1, "jobGroup", viewer.getJobGroup()); + questionPostRepository.save(QuestionPostFixture.questionPost(member)); + + //when + List responses = questionPostRepository + .getRecommendQuestionPosts(viewer.getJobGroup(), pageRequest) + .getContent(); + + //then + Assertions.assertAll( + () -> assertThat(responses).hasSize(1), + + () -> assertThat(responses.get(0).questionPostId()) + .isEqualTo(questionPost1.getId()) + ); + } + + @DisplayName("추천수로 내림차순 정렬된 추천 게시물들을 조회할 수 있다.") + @Test + void getRecommendPost_recCnt_sort() { + //given + + QuestionPost questionPost1 = questionPostRepository.save(QuestionPostFixture.questionPost(member)); + QuestionPost questionPost2 = questionPostRepository.save(QuestionPostFixture.questionPost(member)); + + interactPost(questionPost2.getId(), InteractionType.RECOMMEND); + + //when + List responses = questionPostRepository + .getRecommendQuestionPosts(member.getJobGroup(), pageRequest) + .getContent(); + + //then + Assertions.assertAll( + () -> assertThat(responses).hasSize(2), + + () -> assertThat(responses.get(0).questionPostId()) + .isEqualTo(questionPost2.getId()), + () -> assertThat(responses.get(1).questionPostId()) + .isEqualTo(questionPost1.getId()) + ); + } + + @DisplayName("추천 수가 동일할 경우, 북마크 수 기준으로 내림차순 정렬한다.") + @Test + void getRecommendPost_savedCnt_sort() { + //given + QuestionPost questionPost1 = questionPostRepository.save(QuestionPostFixture.questionPost(member)); + QuestionPost questionPost2 = questionPostRepository.save(QuestionPostFixture.questionPost(member)); + QuestionPost questionPost3 = questionPostRepository.save(QuestionPostFixture.questionPost(member)); + + interactPost(questionPost2.getId(), InteractionType.RECOMMEND); + interactPost(questionPost3.getId(), InteractionType.RECOMMEND); + interactPost(questionPost2.getId(), InteractionType.SAVED); + + //when + List responses = questionPostRepository + .getRecommendQuestionPosts(member.getJobGroup(), pageRequest) + .getContent(); + + //then + Assertions.assertAll( + () -> assertThat(responses).hasSize(3), + + () -> assertThat(responses.get(0).questionPostId()) + .isEqualTo(questionPost2.getId()), + () -> assertThat(responses.get(1).questionPostId()) + .isEqualTo(questionPost3.getId()), + () -> assertThat(responses.get(2).questionPostId()) + .isEqualTo(questionPost1.getId()) + ); + } + private void interactPost(Long questionPostId, InteractionType type) { Interaction interaction = InteractionFixture.interaction(type, 2L, questionPostId); @@ -177,5 +258,4 @@ private void interactPost(Long questionPostId, InteractionType type) { InteractionCountFixture.interactionCount(type, questionPostId); interactionCountRepository.save(interactionCount); } - } \ No newline at end of file