Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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<RecommendContentsResponse> 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 = "조회 성공"),
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -73,36 +70,6 @@ public class RecommendService {
private final StringRedisTemplate redisTemplate;
private final ObjectMapper objectMapper;

@Transactional(readOnly = true)
public RecommendContentsResponse getRecommendContents(UUID userId) {
List<UUID> tagIds = getOrCacheTagIds(userId);

if (!tagIds.isEmpty()) {
List<String> tagNames = tagRepository.findAllById(tagIds).stream()
.map(Tag::getName).toList();
if (!tagNames.isEmpty()) {
List<Content> candidates = findByTagNamesInTitle(tagNames, userId, CANDIDATE_LIMIT);
if (candidates.size() >= RESULT_SIZE) {
return buildResponse(shuffleAndTake(candidates, userId), userId, true, null);
}
}
}

List<String> userTagNames = userTagRepository.findByUser_Id(userId).stream()
.map(ut -> ut.getTag().getName()).toList();

if (!userTagNames.isEmpty()) {
List<Content> candidates = findByTagNamesInTitle(userTagNames, userId, CANDIDATE_LIMIT);
if (!candidates.isEmpty()) {
return buildResponse(shuffleAndTake(candidates, userId), userId, true, null);
}
}

List<Content> latest = contentRepository.findLatestExcludingYoutubeAndScrapped(
userId, PageRequest.of(0, CANDIDATE_LIMIT));
return buildResponse(shuffleAndTake(latest, userId), userId, false, NOT_ENOUGH_MESSAGE);
}

private List<Content> findByTagNamesInTitle(List<String> tagNames, UUID userId, int limit) {
Set<UUID> seen = new LinkedHashSet<>();
List<Content> result = new ArrayList<>();
Expand Down Expand Up @@ -150,19 +117,6 @@ List<UUID> getOrCacheTagIds(UUID userId) {
return tagIds;
}

private RecommendContentsResponse buildResponse(
List<Content> contents, UUID userId, boolean isPersonalized, String message) {
List<ContentSummaryResponse> items = contents.stream()
.map(c -> {
boolean isLiked = likeRepository.existsByUser_IdAndContent_Id(userId, c.getId());
Optional<String> 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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -91,76 +90,6 @@ void setUp() {

// ─── 글 추천 테스트 ───────────────────────────────────────────────────────

@Test
@DisplayName("history 태그 기반 후보 10개 이상 → 개인화 응답, isPersonalized=true")
void getRecommendContents_historyTags_returnsPersonalized() throws JsonProcessingException {
List<UUID> 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<UUID> historyTagIds = List.of(UUID.randomUUID());
List<Content> 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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -296,34 +176,6 @@ void getOrCacheTagIds_serializeFails_stillReturnsTagIds() throws JsonProcessingE
assertThat(result).containsExactly(tagId);
}

@Test
@DisplayName("결과가 최대 8개")
void getRecommendContents_returnsAtMostEight() throws JsonProcessingException {
List<Content> 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<String, Object> channelFromJson(String json) {
Expand Down
Loading