diff --git a/src/main/java/com/devpick/domain/content/controller/RecommendController.java b/src/main/java/com/devpick/domain/content/controller/RecommendController.java index da489aad..dea3f85f 100644 --- a/src/main/java/com/devpick/domain/content/controller/RecommendController.java +++ b/src/main/java/com/devpick/domain/content/controller/RecommendController.java @@ -1,7 +1,6 @@ package com.devpick.domain.content.controller; import com.devpick.domain.content.dto.BookRecommendResponse; -import com.devpick.domain.content.dto.RecommendContentsResponse; import com.devpick.domain.content.dto.YoutubeRecommendResponse; import com.devpick.domain.content.service.BookRecommendService; import com.devpick.domain.content.service.RecommendService; @@ -26,17 +25,6 @@ public class RecommendController { private final RecommendService recommendService; private final BookRecommendService bookRecommendService; - @Operation(summary = "홈 글 추천", description = "행동 이력 기반 동적 개인화 콘텐츠 10개 반환 (YouTube 제외, 스크랩 제외)") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 필요") - }) - @GetMapping("/contents") - public ApiResponse getRecommendContents( - @AuthenticationPrincipal UUID userId) { - return ApiResponse.ok(recommendService.getRecommendContents(userId)); - } - @Operation(summary = "YouTube 영상 추천", description = "행동 이력 기반 개인화 YouTube 영상 10개 반환 (스크랩 제외)") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), diff --git a/src/main/java/com/devpick/domain/content/dto/RecommendContentsResponse.java b/src/main/java/com/devpick/domain/content/dto/RecommendContentsResponse.java deleted file mode 100644 index 908a12e6..00000000 --- a/src/main/java/com/devpick/domain/content/dto/RecommendContentsResponse.java +++ /dev/null @@ -1,10 +0,0 @@ -package com.devpick.domain.content.dto; - -import java.util.List; - -public record RecommendContentsResponse( - List contents, - boolean isPersonalized, - String message -) { -} diff --git a/src/main/java/com/devpick/domain/content/service/RecommendService.java b/src/main/java/com/devpick/domain/content/service/RecommendService.java index c6b4ea07..4764e514 100644 --- a/src/main/java/com/devpick/domain/content/service/RecommendService.java +++ b/src/main/java/com/devpick/domain/content/service/RecommendService.java @@ -1,7 +1,5 @@ package com.devpick.domain.content.service; -import com.devpick.domain.content.dto.ContentSummaryResponse; -import com.devpick.domain.content.dto.RecommendContentsResponse; import com.devpick.domain.content.dto.YoutubeRecommendItem; import com.devpick.domain.content.dto.YoutubeRecommendResponse; import com.devpick.domain.content.entity.Content; @@ -34,7 +32,6 @@ import java.util.LinkedHashSet; import java.util.List; import java.util.Map; -import java.util.Optional; import java.util.Random; import java.util.Set; import java.util.UUID; @@ -73,36 +70,6 @@ public class RecommendService { private final StringRedisTemplate redisTemplate; private final ObjectMapper objectMapper; - @Transactional(readOnly = true) - public RecommendContentsResponse getRecommendContents(UUID userId) { - List tagIds = getOrCacheTagIds(userId); - - if (!tagIds.isEmpty()) { - List tagNames = tagRepository.findAllById(tagIds).stream() - .map(Tag::getName).toList(); - if (!tagNames.isEmpty()) { - List candidates = findByTagNamesInTitle(tagNames, userId, CANDIDATE_LIMIT); - if (candidates.size() >= RESULT_SIZE) { - return buildResponse(shuffleAndTake(candidates, userId), userId, true, null); - } - } - } - - List userTagNames = userTagRepository.findByUser_Id(userId).stream() - .map(ut -> ut.getTag().getName()).toList(); - - if (!userTagNames.isEmpty()) { - List candidates = findByTagNamesInTitle(userTagNames, userId, CANDIDATE_LIMIT); - if (!candidates.isEmpty()) { - return buildResponse(shuffleAndTake(candidates, userId), userId, true, null); - } - } - - List latest = contentRepository.findLatestExcludingYoutubeAndScrapped( - userId, PageRequest.of(0, CANDIDATE_LIMIT)); - return buildResponse(shuffleAndTake(latest, userId), userId, false, NOT_ENOUGH_MESSAGE); - } - private List findByTagNamesInTitle(List tagNames, UUID userId, int limit) { Set seen = new LinkedHashSet<>(); List result = new ArrayList<>(); @@ -150,19 +117,6 @@ List getOrCacheTagIds(UUID userId) { return tagIds; } - private RecommendContentsResponse buildResponse( - List contents, UUID userId, boolean isPersonalized, String message) { - List items = contents.stream() - .map(c -> { - boolean isLiked = likeRepository.existsByUser_IdAndContent_Id(userId, c.getId()); - Optional core = aiSummaryService.findCachedCoreSummary(c.getId(), "junior"); - String preview = core.filter(s -> !s.isBlank()).orElse(c.getPreview()); - return ContentSummaryResponse.of(c, false, isLiked, preview); - }) - .toList(); - return new RecommendContentsResponse(items, isPersonalized, message); - } - // ─── YouTube 추천 (DP-463) ──────────────────────────────────────────────── @Transactional(readOnly = true) diff --git a/src/test/java/com/devpick/domain/content/controller/RecommendControllerTest.java b/src/test/java/com/devpick/domain/content/controller/RecommendControllerTest.java index 12af2b25..3e2ae8cd 100644 --- a/src/test/java/com/devpick/domain/content/controller/RecommendControllerTest.java +++ b/src/test/java/com/devpick/domain/content/controller/RecommendControllerTest.java @@ -2,8 +2,6 @@ import com.devpick.domain.content.dto.BookItem; import com.devpick.domain.content.dto.BookRecommendResponse; -import com.devpick.domain.content.dto.ContentSummaryResponse; -import com.devpick.domain.content.dto.RecommendContentsResponse; import com.devpick.domain.content.dto.YoutubeRecommendItem; import com.devpick.domain.content.dto.YoutubeRecommendResponse; import com.devpick.domain.content.service.BookRecommendService; @@ -65,51 +63,6 @@ void tearDown() { SecurityContextHolder.clearContext(); } - @Test - @DisplayName("GET /recommend/contents - 개인화 성공 시 200, isPersonalized=true, message=null") - void getRecommendContents_personalized_returns200() throws Exception { - ContentSummaryResponse summary = new ContentSummaryResponse( - UUID.randomUUID(), "Spring Boot 추천글", null, "작성자", "Velog", - "미리보기", null, null, null, "https://velog.io/@test/spring", - List.of("Spring"), Instant.now(), false, false, - null, null, null, null); - RecommendContentsResponse response = new RecommendContentsResponse( - List.of(summary), true, null); - given(recommendService.getRecommendContents(eq(userId))).willReturn(response); - - mockMvc.perform(get("/recommend/contents")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.isPersonalized").value(true)) - .andExpect(jsonPath("$.data.message").doesNotExist()) - .andExpect(jsonPath("$.data.contents[0].title").value("Spring Boot 추천글")); - } - - @Test - @DisplayName("GET /recommend/contents - fallback 시 isPersonalized=false, message 포함") - void getRecommendContents_fallback_returnsMessage() throws Exception { - RecommendContentsResponse response = new RecommendContentsResponse( - List.of(), false, "아직 추천할 글이 부족해요. 더 많은 글을 읽어보세요!"); - given(recommendService.getRecommendContents(eq(userId))).willReturn(response); - - mockMvc.perform(get("/recommend/contents")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.success").value(true)) - .andExpect(jsonPath("$.data.isPersonalized").value(false)) - .andExpect(jsonPath("$.data.message").value("아직 추천할 글이 부족해요. 더 많은 글을 읽어보세요!")); - } - - @Test - @DisplayName("GET /recommend/contents - 빈 결과도 200 반환") - void getRecommendContents_emptyContents_returns200() throws Exception { - RecommendContentsResponse response = new RecommendContentsResponse(List.of(), true, null); - given(recommendService.getRecommendContents(eq(userId))).willReturn(response); - - mockMvc.perform(get("/recommend/contents")) - .andExpect(status().isOk()) - .andExpect(jsonPath("$.data.contents").isEmpty()); - } - @Test @DisplayName("GET /recommend/youtube - 개인화 성공 시 200, isPersonalized=true, videoId 포함") void getRecommendYoutube_personalized_returns200() throws Exception { diff --git a/src/test/java/com/devpick/domain/content/service/RecommendServiceTest.java b/src/test/java/com/devpick/domain/content/service/RecommendServiceTest.java index 01cd793d..71f7cbe1 100644 --- a/src/test/java/com/devpick/domain/content/service/RecommendServiceTest.java +++ b/src/test/java/com/devpick/domain/content/service/RecommendServiceTest.java @@ -1,6 +1,5 @@ package com.devpick.domain.content.service; -import com.devpick.domain.content.dto.RecommendContentsResponse; import com.devpick.domain.content.dto.YoutubeRecommendItem; import com.devpick.domain.content.dto.YoutubeRecommendResponse; import com.devpick.domain.content.entity.Content; @@ -91,76 +90,6 @@ void setUp() { // ─── 글 추천 테스트 ─────────────────────────────────────────────────────── - @Test - @DisplayName("history 태그 기반 후보 10개 이상 → 개인화 응답, isPersonalized=true") - void getRecommendContents_historyTags_returnsPersonalized() throws JsonProcessingException { - List tagIds = List.of(UUID.randomUUID()); - given(valueOps.get(anyString())).willReturn(null); - given(historyRepository.findDistinctTagIdsByUserActionsAfter(eq(userId), anyList(), any())) - .willReturn(tagIds); - given(objectMapper.writeValueAsString(any())).willReturn("[\"uuid\"]"); - Tag tag = Tag.builder().name("Java").build(); - given(tagRepository.findAllById(any())).willReturn(List.of(tag)); - given(contentRepository.findByTagNameInTitleExcludingYoutubeAndScrapped(anyString(), eq(userId), any())) - .willReturn(tenContents); - - RecommendContentsResponse result = recommendService.getRecommendContents(userId); - - assertThat(result.isPersonalized()).isTrue(); - assertThat(result.message()).isNull(); - assertThat(result.contents()).hasSize(8); - verify(userTagRepository, never()).findByUser_Id(any()); - } - - @Test - @DisplayName("history 태그 기반 후보 10개 미만 → user_tags fallback, isPersonalized=true") - void getRecommendContents_historyTagsInsufficient_fallsBackToUserTags() throws JsonProcessingException { - List historyTagIds = List.of(UUID.randomUUID()); - List fewContents = tenContents.subList(0, 5); - - given(valueOps.get(anyString())).willReturn(null); - given(historyRepository.findDistinctTagIdsByUserActionsAfter(eq(userId), anyList(), any())) - .willReturn(historyTagIds); - given(objectMapper.writeValueAsString(any())).willReturn("[\"uuid\"]"); - - Tag histTag = Tag.builder().name("History").build(); - given(tagRepository.findAllById(any())).willReturn(List.of(histTag)); - given(contentRepository.findByTagNameInTitleExcludingYoutubeAndScrapped(eq("History"), eq(userId), any())) - .willReturn(fewContents); - - UUID userTagId = UUID.randomUUID(); - Tag springTag = Tag.builder().name("Spring").build(); - ReflectionTestUtils.setField(springTag, "id", userTagId); - UserTag userTag = UserTag.builder().tag(springTag).build(); - given(userTagRepository.findByUser_Id(userId)).willReturn(List.of(userTag)); - given(contentRepository.findByTagNameInTitleExcludingYoutubeAndScrapped(eq("Spring"), eq(userId), any())) - .willReturn(tenContents); - - RecommendContentsResponse result = recommendService.getRecommendContents(userId); - - assertThat(result.isPersonalized()).isTrue(); - assertThat(result.contents()).isNotEmpty(); - verify(userTagRepository).findByUser_Id(userId); - } - - @Test - @DisplayName("history 태그 없고 user_tags도 없으면 → 최신순 fallback, isPersonalized=false, message 포함") - void getRecommendContents_noTags_fallsBackToLatest() throws JsonProcessingException { - given(valueOps.get(anyString())).willReturn(null); - given(historyRepository.findDistinctTagIdsByUserActionsAfter(eq(userId), anyList(), any())) - .willReturn(List.of()); - given(objectMapper.writeValueAsString(any())).willReturn("[]"); - given(userTagRepository.findByUser_Id(userId)).willReturn(List.of()); - given(contentRepository.findLatestExcludingYoutubeAndScrapped(eq(userId), any())) - .willReturn(tenContents); - - RecommendContentsResponse result = recommendService.getRecommendContents(userId); - - assertThat(result.isPersonalized()).isFalse(); - assertThat(result.message()).isEqualTo(RecommendService.NOT_ENOUGH_MESSAGE); - verify(contentRepository).findLatestExcludingYoutubeAndScrapped(eq(userId), any()); - } - @Test @DisplayName("Redis 캐시 히트 시 history 쿼리 미실행") void getOrCacheTagIds_cacheHit_skipsHistoryQuery() throws JsonProcessingException { @@ -215,55 +144,6 @@ void shuffleAndTake_emptyList_returnsEmpty() { assertThat(result).isEmpty(); } - @Test - @DisplayName("history 태그 없고 user_tags 있으면 → user_tags 기반 개인화, isPersonalized=true") - void getRecommendContents_noHistoryTags_userTagsFallback() throws JsonProcessingException { - given(valueOps.get(anyString())).willReturn(null); - given(historyRepository.findDistinctTagIdsByUserActionsAfter(eq(userId), anyList(), any())) - .willReturn(List.of()); - given(objectMapper.writeValueAsString(any())).willReturn("[]"); - - UUID userTagId = UUID.randomUUID(); - Tag tag = Tag.builder().name("Kotlin").build(); - ReflectionTestUtils.setField(tag, "id", userTagId); - UserTag userTag = UserTag.builder().tag(tag).build(); - given(userTagRepository.findByUser_Id(userId)).willReturn(List.of(userTag)); - given(contentRepository.findByTagNameInTitleExcludingYoutubeAndScrapped(eq("Kotlin"), eq(userId), any())) - .willReturn(tenContents); - - RecommendContentsResponse result = recommendService.getRecommendContents(userId); - - assertThat(result.isPersonalized()).isTrue(); - assertThat(result.message()).isNull(); - assertThat(result.contents()).isNotEmpty(); - verify(contentRepository, never()).findLatestExcludingYoutubeAndScrapped(any(), any()); - } - - @Test - @DisplayName("history 태그 없고 user_tags 있지만 후보 없으면 → latest fallback, isPersonalized=false") - void getRecommendContents_userTagsButNoCandidates_fallsBackToLatest() throws JsonProcessingException { - given(valueOps.get(anyString())).willReturn(null); - given(historyRepository.findDistinctTagIdsByUserActionsAfter(eq(userId), anyList(), any())) - .willReturn(List.of()); - given(objectMapper.writeValueAsString(any())).willReturn("[]"); - - UUID userTagId = UUID.randomUUID(); - Tag tag = Tag.builder().name("Kotlin").build(); - ReflectionTestUtils.setField(tag, "id", userTagId); - UserTag userTag = UserTag.builder().tag(tag).build(); - given(userTagRepository.findByUser_Id(userId)).willReturn(List.of(userTag)); - given(contentRepository.findByTagNameInTitleExcludingYoutubeAndScrapped(eq("Kotlin"), eq(userId), any())) - .willReturn(List.of()); - given(contentRepository.findLatestExcludingYoutubeAndScrapped(eq(userId), any())) - .willReturn(tenContents); - - RecommendContentsResponse result = recommendService.getRecommendContents(userId); - - assertThat(result.isPersonalized()).isFalse(); - assertThat(result.message()).isEqualTo(RecommendService.NOT_ENOUGH_MESSAGE); - verify(contentRepository).findLatestExcludingYoutubeAndScrapped(eq(userId), any()); - } - @Test @DisplayName("Redis 역직렬화 실패 시 history 쿼리 실행") void getOrCacheTagIds_deserializeFails_fallsBackToHistoryQuery() throws JsonProcessingException { @@ -296,34 +176,6 @@ void getOrCacheTagIds_serializeFails_stillReturnsTagIds() throws JsonProcessingE assertThat(result).containsExactly(tagId); } - @Test - @DisplayName("결과가 최대 8개") - void getRecommendContents_returnsAtMostEight() throws JsonProcessingException { - List lotsOfContents = new ArrayList<>(tenContents); - ContentSource source = ContentSource.builder() - .name("Velog").url("https://velog.io").collectMethod("graphql").build(); - for (int i = 10; i < 50; i++) { - Content c = Content.builder() - .source(source).title("글 " + i).author("a") - .canonicalUrl("https://velog.io/" + i) - .preview("p").publishedAt(LocalDateTime.now().minusDays(i)).build(); - ReflectionTestUtils.setField(c, "id", UUID.randomUUID()); - lotsOfContents.add(c); - } - given(valueOps.get(anyString())).willReturn(null); - given(historyRepository.findDistinctTagIdsByUserActionsAfter(eq(userId), anyList(), any())) - .willReturn(List.of(UUID.randomUUID())); - given(objectMapper.writeValueAsString(any())).willReturn("[\"uuid\"]"); - Tag tag = Tag.builder().name("Java").build(); - given(tagRepository.findAllById(any())).willReturn(List.of(tag)); - given(contentRepository.findByTagNameInTitleExcludingYoutubeAndScrapped(anyString(), eq(userId), any())) - .willReturn(lotsOfContents); - - RecommendContentsResponse result = recommendService.getRecommendContents(userId); - - assertThat(result.contents()).hasSize(8); - } - // ─── YouTube 추천 테스트 (DP-463) ───────────────────────────────────────── private Map channelFromJson(String json) {