From ce3bac2d40eddd0788e271627f4b2e58af462470 Mon Sep 17 00:00:00 2001 From: nYeonG4001 <2371324@hansung.ac.kr> Date: Thu, 7 May 2026 18:16:56 +0900 Subject: [PATCH 1/6] =?UTF-8?q?DP-463:=20YouTube=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EC=95=8C=EA=B3=A0=EB=A6=AC=EC=A6=98=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=E2=80=94=20=ED=96=89=EB=8F=99=20=EA=B0=80=EC=A4=91=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=20=EC=A0=90=EC=88=98=20=EA=B8=B0=EB=B0=98=20=EA=B0=9C?= =?UTF-8?q?=EC=9D=B8=ED=99=94=20+=20=EC=B1=84=EB=84=90=20=EB=8B=A4?= =?UTF-8?q?=EC=96=91=EC=84=B1=20=ED=8E=98=EB=84=90=ED=8B=B0=20+=20?= =?UTF-8?q?=ED=83=90=EC=83=89=20=EC=97=AC=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/repository/ContentRepository.java | 23 +- .../content/service/RecommendService.java | 184 ++++++-- .../report/repository/HistoryRepository.java | 23 + .../content/service/RecommendServiceTest.java | 400 ++++++++++-------- 4 files changed, 424 insertions(+), 206 deletions(-) diff --git a/src/main/java/com/devpick/domain/content/repository/ContentRepository.java b/src/main/java/com/devpick/domain/content/repository/ContentRepository.java index 705ba56c..77375f3f 100644 --- a/src/main/java/com/devpick/domain/content/repository/ContentRepository.java +++ b/src/main/java/com/devpick/domain/content/repository/ContentRepository.java @@ -59,20 +59,33 @@ List findLatestExcludingYoutubeAndScrapped( @Query("SELECT c FROM Content c " + "WHERE c.isAvailable = true " + "AND c.source.name = 'YouTube' " + - "AND LOWER(c.title) LIKE LOWER(CONCAT('%', :tagName, '%')) " + "AND NOT EXISTS (SELECT s FROM Scrap s WHERE s.user.id = :userId AND s.content = c) " + "ORDER BY c.publishedAt DESC") - List findYoutubeByTagNameInTitle( - @Param("tagName") String tagName, + List findLatestYoutubeExcludingScrapped( @Param("userId") UUID userId, Pageable pageable); - @Query("SELECT c FROM Content c " + + /** YouTube 추천용: content_tags JOIN, 스크랩 제외 */ + @Query("SELECT DISTINCT c FROM Content c JOIN c.contentTags ct " + "WHERE c.isAvailable = true " + "AND c.source.name = 'YouTube' " + + "AND ct.tag.id IN :tagIds " + "AND NOT EXISTS (SELECT s FROM Scrap s WHERE s.user.id = :userId AND s.content = c) " + "ORDER BY c.publishedAt DESC") - List findLatestYoutubeExcludingScrapped( + List findYoutubeByTagIdsExcludingScrapped( + @Param("tagIds") List tagIds, + @Param("userId") UUID userId, + Pageable pageable); + + /** YouTube 추천용: 탐색 여지 — 관심 태그 외 영역의 YouTube 영상 */ + @Query("SELECT DISTINCT c FROM Content c JOIN c.contentTags ct " + + "WHERE c.isAvailable = true " + + "AND c.source.name = 'YouTube' " + + "AND ct.tag.id NOT IN :excludeTagIds " + + "AND NOT EXISTS (SELECT s FROM Scrap s WHERE s.user.id = :userId AND s.content = c) " + + "ORDER BY c.publishedAt DESC") + List findYoutubeByExcludeTagIdsExcludingScrapped( + @Param("excludeTagIds") List excludeTagIds, @Param("userId") UUID userId, Pageable pageable); } 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 c31e46fc..0722ad06 100644 --- a/src/main/java/com/devpick/domain/content/service/RecommendService.java +++ b/src/main/java/com/devpick/domain/content/service/RecommendService.java @@ -25,8 +25,12 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.time.format.DateTimeFormatter; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Collections; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; @@ -43,11 +47,21 @@ public class RecommendService { static final int CANDIDATE_LIMIT = 100; static final int RESULT_SIZE = 8; + static final int TOP_TAGS_LIMIT = 15; + static final int EXPLORE_SIZE = 2; + static final int PERSONALIZED_SIZE = RESULT_SIZE - EXPLORE_SIZE; static final String REDIS_KEY_PREFIX = "recommend:tags:"; static final long CACHE_TTL_HOURS = 24; static final String NOT_ENOUGH_MESSAGE = "아직 추천할 글이 부족해요. 더 많은 글을 읽어보세요!"; static final List HISTORY_ACTION_TYPES = List.of("scrapped", "ai_summary_viewed", "ai_quiz_completed", "content_liked"); + static final Map ACTION_WEIGHTS = Map.of( + "ai_quiz_completed", 5.0, + "scrapped", 4.0, + "content_liked", 3.0, + "ai_summary_viewed", 2.0, + "content_opened", 1.0 + ); static final ZoneId KST = ZoneId.of("Asia/Seoul"); private final HistoryRepository historyRepository; @@ -105,22 +119,6 @@ private List findByTagNamesInTitle(List tagNames, UUID userId, return result; } - private List findYoutubeByTagNamesInTitle(List tagNames, UUID userId, int limit) { - Set seen = new LinkedHashSet<>(); - List result = new ArrayList<>(); - for (String tagName : tagNames) { - for (Content c : contentRepository.findYoutubeByTagNameInTitle( - tagName, userId, PageRequest.of(0, limit))) { - UUID id = c.getId(); - if (id != null && seen.add(id)) { - result.add(c); - if (result.size() >= limit) return result; - } - } - } - return result; - } - List getOrCacheTagIds(UUID userId) { String today = LocalDate.now(KST).format(DateTimeFormatter.ISO_LOCAL_DATE); String redisKey = REDIS_KEY_PREFIX + userId + ":" + today; @@ -165,36 +163,158 @@ private RecommendContentsResponse buildResponse( return new RecommendContentsResponse(items, isPersonalized, message); } + // ─── YouTube 추천 (DP-463) ──────────────────────────────────────────────── + @Transactional(readOnly = true) public YoutubeRecommendResponse getRecommendYoutube(UUID userId) { - List tagIds = getOrCacheTagIds(userId); + // 1. 행동 이력 기반 가중 태그 점수맵 + Map tagScores = buildWeightedTagScores(userId); - if (!tagIds.isEmpty()) { - List tagNames = tagRepository.findAllById(tagIds).stream() - .map(Tag::getName).toList(); - if (!tagNames.isEmpty()) { - List candidates = findYoutubeByTagNamesInTitle(tagNames, userId, CANDIDATE_LIMIT); - if (candidates.size() >= RESULT_SIZE) { - return buildYoutubeResponse(shuffleAndTake(candidates, userId), userId, true, null); - } - } + // 2. 최근 3개월 시청 이력 (재추천 방지) + Set viewedIds = new HashSet<>( + historyRepository.findViewedContentIdsSince(userId, LocalDateTime.now(KST).minusMonths(3))); + + // 3. 상위 태그 ID (이력 없으면 프로필 태그) + List topTagIds = getTopTagIds(tagScores, TOP_TAGS_LIMIT); + boolean isPersonalized = !tagScores.isEmpty(); + + if (topTagIds.isEmpty()) { + topTagIds = userTagRepository.findByUser_Id(userId).stream() + .map(ut -> ut.getTag().getId()).toList(); } - List userTagNames = userTagRepository.findByUser_Id(userId).stream() - .map(ut -> ut.getTag().getName()).toList(); + if (!topTagIds.isEmpty()) { + // 4. content_tags JOIN으로 YouTube 후보 조회 (스크랩 제외) + List candidates = contentRepository.findYoutubeByTagIdsExcludingScrapped( + topTagIds, userId, PageRequest.of(0, CANDIDATE_LIMIT * 2)); - if (!userTagNames.isEmpty()) { - List candidates = findYoutubeByTagNamesInTitle(userTagNames, userId, CANDIDATE_LIMIT); - if (!candidates.isEmpty()) { - return buildYoutubeResponse(shuffleAndTake(candidates, userId), userId, true, null); + // 5. 시청 이력 제외 후 채널 다양성 페널티 적용 랭킹 + List ranked = applyChannelDiversityPenalty( + candidates.stream().filter(c -> !viewedIds.contains(c.getId())).toList(), + tagScores); + + if (!ranked.isEmpty()) { + List result = new ArrayList<>( + ranked.subList(0, Math.min(PERSONALIZED_SIZE, ranked.size()))); + + // 6. 탐색 여지: 관심 태그 외 영역 영상 2개 + Set resultIds = new HashSet<>(viewedIds); + result.stream().map(Content::getId).forEach(resultIds::add); + List explore = findExploreVideos(topTagIds, userId, resultIds, RESULT_SIZE - result.size()); + result.addAll(explore); + + // 7. 여전히 부족하면 최신 YouTube로 채움 + if (result.size() < RESULT_SIZE) { + resultIds = new HashSet<>(viewedIds); + result.stream().map(Content::getId).forEach(resultIds::add); + Set finalResultIds = resultIds; + contentRepository.findLatestYoutubeExcludingScrapped(userId, PageRequest.of(0, RESULT_SIZE)) + .stream() + .filter(c -> !finalResultIds.contains(c.getId())) + .limit(RESULT_SIZE - result.size()) + .forEach(result::add); + } + + return buildYoutubeResponse( + result.subList(0, Math.min(RESULT_SIZE, result.size())), + userId, isPersonalized, null); } } + // 8. Cold start: 최신 YouTube List latest = contentRepository.findLatestYoutubeExcludingScrapped( userId, PageRequest.of(0, CANDIDATE_LIMIT)); return buildYoutubeResponse(shuffleAndTake(latest, userId), userId, false, NOT_ENOUGH_MESSAGE); } + Map buildWeightedTagScores(UUID userId) { + Map scores = new HashMap<>(); + List actionTypes = new ArrayList<>(ACTION_WEIGHTS.keySet()); + + List rows = historyRepository.findTagIdActionCountsByUserActionsAfter( + userId, actionTypes, LocalDateTime.now(KST).minusMonths(1)); + if (rows.isEmpty()) { + rows = historyRepository.findTagIdActionCountsByUserActionsAfter( + userId, actionTypes, LocalDateTime.now(KST).minusMonths(3)); + } + + for (Object[] row : rows) { + UUID tagId = (UUID) row[0]; + String actionType = (String) row[1]; + long count = (long) row[2]; + scores.merge(tagId, ACTION_WEIGHTS.getOrDefault(actionType, 1.0) * count, Double::sum); + } + return scores; + } + + List getTopTagIds(Map tagScores, int limit) { + return tagScores.entrySet().stream() + .sorted(Map.Entry.comparingByValue().reversed()) + .limit(limit) + .map(Map.Entry::getKey) + .toList(); + } + + double computeScore(Content content, Map tagScores) { + double tagScore = content.getContentTags().stream() + .mapToDouble(ct -> tagScores.getOrDefault(ct.getTag().getId(), 0.0)) + .sum(); + double recencyBonus = 0; + if (content.getPublishedAt() != null) { + long daysOld = ChronoUnit.DAYS.between(content.getPublishedAt(), LocalDateTime.now(KST)); + recencyBonus = Math.max(0, 30 - daysOld) * 0.1; + } + return tagScore + recencyBonus; + } + + List applyChannelDiversityPenalty(List candidates, Map tagScores) { + Map channelCount = new HashMap<>(); + List pool = new ArrayList<>(candidates); + List result = new ArrayList<>(); + + while (!pool.isEmpty() && result.size() < candidates.size()) { + Content best = null; + double bestScore = Double.NEGATIVE_INFINITY; + for (Content c : pool) { + String channel = extractChannel(c); + int count = channelCount.getOrDefault(channel, 0); + double penalty = count >= 1 ? 2.0 * count : 0; + double score = computeScore(c, tagScores) - penalty; + if (score > bestScore) { + bestScore = score; + best = c; + } + } + if (best != null) { + result.add(best); + channelCount.merge(extractChannel(best), 1, Integer::sum); + pool.remove(best); + } + } + return result; + } + + String extractChannel(Content content) { + if (content.getExtra() == null) return content.getId().toString(); + try { + Map extra = objectMapper.readValue(content.getExtra(), new TypeReference>() {}); + Object channelName = extra.get("channelName"); + return channelName != null ? channelName.toString() : content.getId().toString(); + } catch (JsonProcessingException e) { + return content.getId().toString(); + } + } + + private List findExploreVideos(List usedTagIds, UUID userId, Set excludeIds, int limit) { + if (limit <= 0 || usedTagIds.isEmpty()) return List.of(); + return contentRepository.findYoutubeByExcludeTagIdsExcludingScrapped( + usedTagIds, userId, PageRequest.of(0, limit * 3)) + .stream() + .filter(c -> !excludeIds.contains(c.getId())) + .limit(limit) + .toList(); + } + private YoutubeRecommendResponse buildYoutubeResponse( List contents, UUID userId, boolean isPersonalized, String message) { List items = contents.stream() diff --git a/src/main/java/com/devpick/domain/report/repository/HistoryRepository.java b/src/main/java/com/devpick/domain/report/repository/HistoryRepository.java index a308a21c..6c6b65b0 100644 --- a/src/main/java/com/devpick/domain/report/repository/HistoryRepository.java +++ b/src/main/java/com/devpick/domain/report/repository/HistoryRepository.java @@ -15,6 +15,29 @@ public interface HistoryRepository extends JpaRepository { + /** YouTube 추천용: 태그 ID + 액션 타입 + 횟수 (가중 점수 계산용) */ + @Query("SELECT ct.tag.id, h.actionType, COUNT(h) FROM History h " + + "JOIN h.content c JOIN c.contentTags ct " + + "WHERE h.user.id = :userId " + + "AND h.actionType IN :actionTypes " + + "AND h.createdAt >= :since " + + "AND h.content IS NOT NULL " + + "GROUP BY ct.tag.id, h.actionType " + + "ORDER BY COUNT(h) DESC") + List findTagIdActionCountsByUserActionsAfter( + @Param("userId") UUID userId, + @Param("actionTypes") List actionTypes, + @Param("since") LocalDateTime since); + + /** YouTube 추천용: 사용자가 열람한 콘텐츠 ID (중복 노출 방지) */ + @Query("SELECT DISTINCT h.content.id FROM History h " + + "WHERE h.user.id = :userId " + + "AND h.content IS NOT NULL " + + "AND h.createdAt >= :since") + List findViewedContentIdsSince( + @Param("userId") UUID userId, + @Param("since") LocalDateTime since); + /** 도서 추천용: 태그명 + 빈도 수 조회 */ @Query("SELECT ct.tag.name, COUNT(ct.tag.name) FROM History h " + "JOIN h.content c JOIN c.contentTags ct " + 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 100a93da..81415452 100644 --- a/src/test/java/com/devpick/domain/content/service/RecommendServiceTest.java +++ b/src/test/java/com/devpick/domain/content/service/RecommendServiceTest.java @@ -1,14 +1,16 @@ 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; import com.devpick.domain.content.entity.ContentSource; +import com.devpick.domain.content.entity.ContentTag; import com.devpick.domain.content.repository.ContentRepository; import com.devpick.domain.content.repository.LikeRepository; import com.devpick.domain.report.repository.HistoryRepository; -import com.devpick.domain.user.entity.UserTag; import com.devpick.domain.user.entity.Tag; +import com.devpick.domain.user.entity.UserTag; import com.devpick.domain.user.repository.TagRepository; import com.devpick.domain.user.repository.UserTagRepository; import com.fasterxml.jackson.core.JsonProcessingException; @@ -21,13 +23,13 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.data.domain.Pageable; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.data.redis.core.ValueOperations; import org.springframework.test.util.ReflectionTestUtils; import java.time.LocalDateTime; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; import java.util.Optional; @@ -41,6 +43,7 @@ import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @@ -86,6 +89,8 @@ void setUp() { lenient().when(likeRepository.existsByUser_IdAndContent_Id(any(), any())).thenReturn(false); } + // ─── 글 추천 테스트 ─────────────────────────────────────────────────────── + @Test @DisplayName("history 태그 기반 후보 10개 이상 → 개인화 응답, isPersonalized=true") void getRecommendContents_historyTags_returnsPersonalized() throws JsonProcessingException { @@ -182,7 +187,7 @@ void getOrCacheTagIds_emptyOneMonth_expandsToThreeMonths() throws JsonProcessing List result = recommendService.getOrCacheTagIds(userId); assertThat(result).containsExactly(tagId); - verify(historyRepository, org.mockito.Mockito.times(2)) + verify(historyRepository, times(2)) .findDistinctTagIdsByUserActionsAfter(eq(userId), anyList(), any()); } @@ -291,20 +296,65 @@ void getOrCacheTagIds_serializeFails_stillReturnsTagIds() throws JsonProcessingE assertThat(result).containsExactly(tagId); } - // ─── YouTube 추천 테스트 ─────────────────────────────────────────────────── - @Test - @DisplayName("YouTube - history 태그 기반 후보 10개 이상 → 개인화 응답") - void getRecommendYoutube_historyTags_returnsPersonalized() throws JsonProcessingException { - List tagIds = List.of(UUID.randomUUID()); + @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(tagIds); + .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.findYoutubeByTagNameInTitle(anyString(), eq(userId), any())) - .willReturn(tenContents); + given(contentRepository.findByTagNameInTitleExcludingYoutubeAndScrapped(anyString(), eq(userId), any())) + .willReturn(lotsOfContents); + + RecommendContentsResponse result = recommendService.getRecommendContents(userId); + + assertThat(result.contents()).hasSize(8); + } + + // ─── YouTube 추천 테스트 (DP-463) ───────────────────────────────────────── + + private List makeYoutubeContents(int count) { + ContentSource source = ContentSource.builder() + .name("YouTube").url("https://youtube.com").collectMethod("api").build(); + List list = new ArrayList<>(); + for (int i = 0; i < count; i++) { + Content c = Content.builder() + .source(source).title("유튜브 영상 " + i).author("채널") + .canonicalUrl("https://youtube.com/v" + i) + .extra("{\"channelName\":\"채널" + (i % 3) + "\",\"videoId\":\"v" + i + "\"}") + .publishedAt(LocalDateTime.now().minusDays(i)).build(); + ReflectionTestUtils.setField(c, "id", UUID.randomUUID()); + list.add(c); + } + return list; + } + + @Test + @DisplayName("YouTube - 행동 이력 태그 기반 후보 충분 → isPersonalized=true, 8개 반환") + void getRecommendYoutube_historyTags_returnsPersonalized() throws JsonProcessingException { + UUID tagId = UUID.randomUUID(); + given(historyRepository.findTagIdActionCountsByUserActionsAfter(eq(userId), anyList(), any())) + .willReturn(Collections.singletonList(new Object[]{tagId, "ai_summary_viewed", 3L})); + given(historyRepository.findViewedContentIdsSince(eq(userId), any())).willReturn(Collections.emptyList()); + given(contentRepository.findYoutubeByTagIdsExcludingScrapped(anyList(), eq(userId), any())) + .willReturn(makeYoutubeContents(12)); + given(contentRepository.findYoutubeByExcludeTagIdsExcludingScrapped(anyList(), eq(userId), any())) + .willReturn(makeYoutubeContents(4)); + given(objectMapper.readValue(anyString(), any(TypeReference.class))) + .willReturn(Map.of("channelName", "채널0", "videoId", "v0")); YoutubeRecommendResponse result = recommendService.getRecommendYoutube(userId); @@ -315,227 +365,239 @@ void getRecommendYoutube_historyTags_returnsPersonalized() throws JsonProcessing } @Test - @DisplayName("YouTube - history 태그 기반 후보 10개 미만 → user_tags fallback") - void getRecommendYoutube_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.findYoutubeByTagNameInTitle(eq("History"), eq(userId), any())) - .willReturn(fewContents); + @DisplayName("YouTube - 행동 이력 없으면 user_tags로 fallback") + void getRecommendYoutube_noHistory_userTagsFallback() throws JsonProcessingException { + given(historyRepository.findTagIdActionCountsByUserActionsAfter(eq(userId), anyList(), any())) + .willReturn(List.of()); + given(historyRepository.findViewedContentIdsSince(eq(userId), any())).willReturn(List.of()); - UUID userTagId = UUID.randomUUID(); - Tag springTag = Tag.builder().name("Spring").build(); - ReflectionTestUtils.setField(springTag, "id", userTagId); - UserTag userTag = UserTag.builder().tag(springTag).build(); + UUID tagId = UUID.randomUUID(); + Tag tag = Tag.builder().name("Java").build(); + ReflectionTestUtils.setField(tag, "id", tagId); + UserTag userTag = UserTag.builder().tag(tag).build(); given(userTagRepository.findByUser_Id(userId)).willReturn(List.of(userTag)); - given(contentRepository.findYoutubeByTagNameInTitle(eq("Spring"), eq(userId), any())) - .willReturn(tenContents); + + given(contentRepository.findYoutubeByTagIdsExcludingScrapped(anyList(), eq(userId), any())) + .willReturn(makeYoutubeContents(8)); + given(contentRepository.findYoutubeByExcludeTagIdsExcludingScrapped(anyList(), eq(userId), any())) + .willReturn(List.of()); + given(objectMapper.readValue(anyString(), any(TypeReference.class))) + .willReturn(Map.of("channelName", "채널0", "videoId", "v0")); YoutubeRecommendResponse result = recommendService.getRecommendYoutube(userId); - assertThat(result.isPersonalized()).isTrue(); assertThat(result.videos()).isNotEmpty(); verify(userTagRepository).findByUser_Id(userId); } @Test - @DisplayName("YouTube - history 태그 없고 user_tags도 없으면 → 최신 YouTube fallback, isPersonalized=false") - void getRecommendYoutube_noTags_fallsBackToLatest() throws JsonProcessingException { - given(valueOps.get(anyString())).willReturn(null); - given(historyRepository.findDistinctTagIdsByUserActionsAfter(eq(userId), anyList(), any())) + @DisplayName("YouTube - 태그 없으면 최신 YouTube cold start, isPersonalized=false") + void getRecommendYoutube_noTags_coldStart() throws JsonProcessingException { + given(historyRepository.findTagIdActionCountsByUserActionsAfter(eq(userId), anyList(), any())) .willReturn(List.of()); - given(objectMapper.writeValueAsString(any())).willReturn("[]"); + given(historyRepository.findViewedContentIdsSince(eq(userId), any())).willReturn(List.of()); given(userTagRepository.findByUser_Id(userId)).willReturn(List.of()); given(contentRepository.findLatestYoutubeExcludingScrapped(eq(userId), any())) - .willReturn(tenContents); + .willReturn(makeYoutubeContents(10)); + given(objectMapper.readValue(anyString(), any(TypeReference.class))) + .willReturn(Map.of("channelName", "채널0", "videoId", "v0")); YoutubeRecommendResponse result = recommendService.getRecommendYoutube(userId); - assertThat(result.videos()).hasSize(8); assertThat(result.isPersonalized()).isFalse(); assertThat(result.message()).isEqualTo(RecommendService.NOT_ENOUGH_MESSAGE); + assertThat(result.videos()).hasSize(8); verify(contentRepository).findLatestYoutubeExcludingScrapped(eq(userId), any()); } @Test - @DisplayName("YouTube - history 태그 없고 user_tags 있으면 → user_tags 기반 개인화") - void getRecommendYoutube_noHistoryTags_userTagsFallback() throws JsonProcessingException { - given(valueOps.get(anyString())).willReturn(null); - given(historyRepository.findDistinctTagIdsByUserActionsAfter(eq(userId), anyList(), any())) + @DisplayName("YouTube - 시청 이력에 있는 영상은 결과에서 제외") + void getRecommendYoutube_viewedContentsExcluded() throws JsonProcessingException { + UUID tagId = UUID.randomUUID(); + given(historyRepository.findTagIdActionCountsByUserActionsAfter(eq(userId), anyList(), any())) + .willReturn(Collections.singletonList(new Object[]{tagId, "scrapped", 2L})); + + List candidates = makeYoutubeContents(10); + List viewedIds = List.of(candidates.get(0).getId(), candidates.get(1).getId()); + given(historyRepository.findViewedContentIdsSince(eq(userId), any())).willReturn(viewedIds); + given(contentRepository.findYoutubeByTagIdsExcludingScrapped(anyList(), eq(userId), any())) + .willReturn(candidates); + given(contentRepository.findYoutubeByExcludeTagIdsExcludingScrapped(anyList(), eq(userId), 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.findYoutubeByTagNameInTitle(eq("Kotlin"), eq(userId), any())) - .willReturn(tenContents); + given(objectMapper.readValue(anyString(), any(TypeReference.class))) + .willReturn(Map.of("channelName", "채널0", "videoId", "v0")); YoutubeRecommendResponse result = recommendService.getRecommendYoutube(userId); - assertThat(result.isPersonalized()).isTrue(); - assertThat(result.videos()).isNotEmpty(); - verify(contentRepository, never()).findLatestYoutubeExcludingScrapped(any(), any()); + List resultIds = result.videos().stream().map(YoutubeRecommendItem::contentId).toList(); + assertThat(resultIds).doesNotContain(viewedIds.get(0), viewedIds.get(1)); } @Test - @DisplayName("YouTube - extra JSON 파싱 성공 시 videoId/channelName/duration 추출") - void getRecommendYoutube_extraJsonParsed_fieldsExtracted() throws JsonProcessingException { - ContentSource source = ContentSource.builder() - .name("YouTube").url("https://youtube.com").collectMethod("api").build(); - List youtubeContents = new ArrayList<>(); - for (int i = 0; i < 10; i++) { - Content c = Content.builder() - .source(source).title("유튜브 영상 " + i).author("채널명") - .canonicalUrl("https://youtube.com/watch?v=v" + i) - .extra("{\"videoId\":\"abc" + i + "\",\"channelName\":\"테스트채널\",\"duration\":\"PT10M\"}") - .publishedAt(java.time.LocalDateTime.now().minusDays(i)).build(); - ReflectionTestUtils.setField(c, "id", UUID.randomUUID()); - youtubeContents.add(c); - } + @DisplayName("YouTube - 후보 부족 시 최신 YouTube로 채워서 8개 반환") + void getRecommendYoutube_insufficientCandidates_filledWithLatest() throws JsonProcessingException { + UUID tagId = UUID.randomUUID(); + given(historyRepository.findTagIdActionCountsByUserActionsAfter(eq(userId), anyList(), any())) + .willReturn(Collections.singletonList(new Object[]{tagId, "content_opened", 1L})); + given(historyRepository.findViewedContentIdsSince(eq(userId), any())).willReturn(Collections.emptyList()); - 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.findYoutubeByTagNameInTitle(anyString(), eq(userId), any())) - .willReturn(youtubeContents); + given(contentRepository.findYoutubeByTagIdsExcludingScrapped(anyList(), eq(userId), any())) + .willReturn(makeYoutubeContents(3)); + given(contentRepository.findYoutubeByExcludeTagIdsExcludingScrapped(anyList(), eq(userId), any())) + .willReturn(List.of()); + given(contentRepository.findLatestYoutubeExcludingScrapped(eq(userId), any())) + .willReturn(makeYoutubeContents(10)); given(objectMapper.readValue(anyString(), any(TypeReference.class))) - .willReturn(Map.of("videoId", "abc0", "channelName", "테스트채널", "duration", "PT10M")); + .willReturn(Map.of("channelName", "채널0", "videoId", "v0")); YoutubeRecommendResponse result = recommendService.getRecommendYoutube(userId); assertThat(result.videos()).hasSize(8); - assertThat(result.videos()).allMatch(v -> v.channelName().equals("테스트채널")); - assertThat(result.videos()).allMatch(v -> v.duration().equals("PT10M")); + assertThat(result.isPersonalized()).isTrue(); } @Test - @DisplayName("YouTube - extra JSON 파싱 실패 시 videoId null로 처리") - void getRecommendYoutube_extraJsonParseFails_fieldsAreNull() throws JsonProcessingException { - ContentSource source = ContentSource.builder() - .name("YouTube").url("https://youtube.com").collectMethod("api").build(); - List youtubeContents = new ArrayList<>(); - for (int i = 0; i < 10; i++) { - Content c = Content.builder() - .source(source).title("유튜브 영상 " + i).author("채널명") - .canonicalUrl("https://youtube.com/watch?v=bad" + i) - .extra("{invalid-json}") - .publishedAt(java.time.LocalDateTime.now().minusDays(i)).build(); - ReflectionTestUtils.setField(c, "id", UUID.randomUUID()); - youtubeContents.add(c); - } - - 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.findYoutubeByTagNameInTitle(anyString(), eq(userId), any())) - .willReturn(youtubeContents); + @DisplayName("YouTube - 1개월 이력 없으면 3개월로 재조회") + void getRecommendYoutube_emptyOneMonth_expandsToThreeMonths() throws JsonProcessingException { + UUID tagId = UUID.randomUUID(); + given(historyRepository.findTagIdActionCountsByUserActionsAfter(eq(userId), anyList(), any())) + .willReturn(Collections.emptyList()) + .willReturn(Collections.singletonList(new Object[]{tagId, "scrapped", 1L})); + given(historyRepository.findViewedContentIdsSince(eq(userId), any())).willReturn(Collections.emptyList()); + given(contentRepository.findYoutubeByTagIdsExcludingScrapped(anyList(), eq(userId), any())) + .willReturn(makeYoutubeContents(8)); + given(contentRepository.findYoutubeByExcludeTagIdsExcludingScrapped(anyList(), eq(userId), any())) + .willReturn(List.of()); given(objectMapper.readValue(anyString(), any(TypeReference.class))) - .willThrow(new com.fasterxml.jackson.core.JsonParseException(null, "parse error")); + .willReturn(Map.of("channelName", "채널0", "videoId", "v0")); - YoutubeRecommendResponse result = recommendService.getRecommendYoutube(userId); + recommendService.getRecommendYoutube(userId); - assertThat(result.videos()).hasSize(8); - assertThat(result.videos()).allMatch(v -> v.videoId() == null); - assertThat(result.videos()).allMatch(v -> v.channelName() == null); + verify(historyRepository, times(2)) + .findTagIdActionCountsByUserActionsAfter(eq(userId), anyList(), any()); } + // ─── 신규 헬퍼 메서드 단위 테스트 ───────────────────────────────────────── + @Test - @DisplayName("YouTube - user_tags 있지만 후보 없으면 → 최신 YouTube fallback, isPersonalized=false") - void getRecommendYoutube_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("[]"); + @DisplayName("buildWeightedTagScores - 액션별 가중치 누적 합산") + void buildWeightedTagScores_multipleActions_accumulated() { + UUID tagId = UUID.randomUUID(); + given(historyRepository.findTagIdActionCountsByUserActionsAfter(eq(userId), anyList(), any())) + .willReturn(List.of( + new Object[]{tagId, "ai_quiz_completed", 2L}, + new Object[]{tagId, "scrapped", 1L} + )); - 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.findYoutubeByTagNameInTitle(eq("Kotlin"), eq(userId), any())) - .willReturn(List.of()); - given(contentRepository.findLatestYoutubeExcludingScrapped(eq(userId), any())) - .willReturn(tenContents); + Map scores = recommendService.buildWeightedTagScores(userId); - YoutubeRecommendResponse result = recommendService.getRecommendYoutube(userId); + // 퀴즈완료(5.0×2) + 스크랩(4.0×1) = 14.0 + assertThat(scores.get(tagId)).isEqualTo(14.0); + } - assertThat(result.videos()).hasSize(8); - assertThat(result.isPersonalized()).isFalse(); - assertThat(result.message()).isEqualTo(RecommendService.NOT_ENOUGH_MESSAGE); - verify(contentRepository).findLatestYoutubeExcludingScrapped(eq(userId), any()); + @Test + @DisplayName("buildWeightedTagScores - 1개월 이력 없으면 3개월로 확장") + void buildWeightedTagScores_emptyOneMonth_expandsToThreeMonths() { + given(historyRepository.findTagIdActionCountsByUserActionsAfter(eq(userId), anyList(), any())) + .willReturn(Collections.emptyList()) + .willReturn(Collections.singletonList(new Object[]{UUID.randomUUID(), "scrapped", 1L})); + + recommendService.buildWeightedTagScores(userId); + + verify(historyRepository, times(2)) + .findTagIdActionCountsByUserActionsAfter(eq(userId), anyList(), any()); } @Test - @DisplayName("YouTube - 결과가 최대 10개") - void getRecommendYoutube_returnsAtMostTen() throws JsonProcessingException { - List lotsOfContents = new ArrayList<>(tenContents); + @DisplayName("getTopTagIds - 점수 내림차순 상위 N개 반환") + void getTopTagIds_returnsSortedTopN() { + UUID id1 = UUID.randomUUID(), id2 = UUID.randomUUID(), id3 = UUID.randomUUID(); + Map scores = Map.of(id1, 3.0, id2, 10.0, id3, 5.0); + + List top2 = recommendService.getTopTagIds(scores, 2); + + assertThat(top2).containsExactly(id2, id3); + } + + @Test + @DisplayName("computeScore - 태그 점수 + 최신성 보너스 합산") + void computeScore_tagAndRecency() { + UUID tagId = UUID.randomUUID(); + Tag tag = Tag.builder().name("Java").build(); + ReflectionTestUtils.setField(tag, "id", tagId); + ContentSource source = ContentSource.builder() .name("YouTube").url("https://youtube.com").collectMethod("api").build(); - for (int i = 10; i < 50; i++) { - Content c = Content.builder() - .source(source).title("유튜브 " + i).author("채널") - .canonicalUrl("https://youtube.com/" + i) - .publishedAt(java.time.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\"]"); + Content content = Content.builder() + .source(source).title("Java 강의") + .canonicalUrl("https://youtube.com/java") + .publishedAt(LocalDateTime.now().minusDays(10)) + .build(); + ReflectionTestUtils.setField(content, "id", UUID.randomUUID()); + + ContentTag ct = ContentTag.builder().content(content).tag(tag).build(); + ReflectionTestUtils.setField(content, "contentTags", List.of(ct)); + + Map tagScores = Map.of(tagId, 5.0); + double score = recommendService.computeScore(content, tagScores); + + // tagScore=5.0 + recencyBonus=(30-10)*0.1=2.0 → 7.0 + assertThat(score).isEqualTo(7.0); + } + + @Test + @DisplayName("computeScore - 30일 이상 된 영상은 최신성 보너스 0") + void computeScore_oldContent_noRecencyBonus() { + UUID tagId = UUID.randomUUID(); Tag tag = Tag.builder().name("Java").build(); - given(tagRepository.findAllById(any())).willReturn(List.of(tag)); - given(contentRepository.findYoutubeByTagNameInTitle(anyString(), eq(userId), any())) - .willReturn(lotsOfContents); + ReflectionTestUtils.setField(tag, "id", tagId); - YoutubeRecommendResponse result = recommendService.getRecommendYoutube(userId); + ContentSource source = ContentSource.builder() + .name("YouTube").url("https://youtube.com").collectMethod("api").build(); + Content content = Content.builder() + .source(source).title("Java 강의") + .canonicalUrl("https://youtube.com/old") + .publishedAt(LocalDateTime.now().minusDays(40)) + .build(); + ReflectionTestUtils.setField(content, "id", UUID.randomUUID()); - assertThat(result.videos()).hasSize(8); + ContentTag ct = ContentTag.builder().content(content).tag(tag).build(); + ReflectionTestUtils.setField(content, "contentTags", List.of(ct)); + + Map tagScores = Map.of(tagId, 5.0); + double score = recommendService.computeScore(content, tagScores); + + assertThat(score).isEqualTo(5.0); } @Test - @DisplayName("결과가 최대 10개") - void getRecommendContents_returnsAtMostTen() throws JsonProcessingException { - List lotsOfContents = new ArrayList<>(tenContents); + @DisplayName("applyChannelDiversityPenalty - 같은 채널 두 번째 영상은 페널티 적용") + void applyChannelDiversityPenalty_sameChannel_penaltyApplied() throws JsonProcessingException { + UUID tagId = UUID.randomUUID(); + Map tagScores = Map.of(tagId, 10.0); + ContentSource source = ContentSource.builder() - .name("Velog").url("https://velog.io").collectMethod("graphql").build(); - for (int i = 10; i < 50; i++) { + .name("YouTube").url("https://youtube.com").collectMethod("api").build(); + + // 같은 채널 "Fireship" 영상 3개 (점수 동일) + List candidates = new ArrayList<>(); + for (int i = 0; i < 3; i++) { Content c = Content.builder() - .source(source).title("글 " + i).author("a") - .canonicalUrl("https://velog.io/" + i) - .preview("p").publishedAt(LocalDateTime.now().minusDays(i)).build(); + .source(source).title("Fireship " + i).author("Fireship") + .canonicalUrl("https://youtube.com/fireship/" + i) + .extra("{\"channelName\":\"Fireship\"}") + .publishedAt(LocalDateTime.now().minusDays(i)).build(); ReflectionTestUtils.setField(c, "id", UUID.randomUUID()); - lotsOfContents.add(c); + candidates.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); + given(objectMapper.readValue(anyString(), any(TypeReference.class))) + .willReturn(Map.of("channelName", "Fireship")); - assertThat(result.contents()).hasSize(8); + List ranked = recommendService.applyChannelDiversityPenalty(candidates, tagScores); + + // 결과는 3개 모두 포함하되 첫 번째가 페널티 없이 선택됨 + assertThat(ranked).hasSize(3); + assertThat(ranked.get(0)).isEqualTo(candidates.get(0)); } } From 74b4442d64aa0fbe0c5b4e9f931bf62d474515e9 Mon Sep 17 00:00:00 2001 From: nYeonG4001 <2371324@hansung.ac.kr> Date: Thu, 7 May 2026 18:25:35 +0900 Subject: [PATCH 2/6] =?UTF-8?q?DP-463:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20=EB=B3=B4=EC=99=84=20?= =?UTF-8?q?=E2=80=94=20extractChannel/parseExtra=20=EC=98=88=EC=99=B8=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../content/service/RecommendServiceTest.java | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) 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 81415452..cfa4babe 100644 --- a/src/test/java/com/devpick/domain/content/service/RecommendServiceTest.java +++ b/src/test/java/com/devpick/domain/content/service/RecommendServiceTest.java @@ -600,4 +600,70 @@ void applyChannelDiversityPenalty_sameChannel_penaltyApplied() throws JsonProces assertThat(ranked).hasSize(3); assertThat(ranked.get(0)).isEqualTo(candidates.get(0)); } + + @Test + @DisplayName("extractChannel - extra가 null이면 contentId 반환") + void extractChannel_nullExtra_returnsContentId() { + ContentSource source = ContentSource.builder() + .name("YouTube").url("https://youtube.com").collectMethod("api").build(); + Content c = Content.builder().source(source).title("영상").canonicalUrl("https://youtube.com/v1").build(); + ReflectionTestUtils.setField(c, "id", UUID.randomUUID()); + + assertThat(recommendService.extractChannel(c)).isEqualTo(c.getId().toString()); + } + + @Test + @DisplayName("extractChannel - channelName 키 없으면 contentId 반환") + void extractChannel_noChannelName_returnsContentId() throws JsonProcessingException { + ContentSource source = ContentSource.builder() + .name("YouTube").url("https://youtube.com").collectMethod("api").build(); + Content c = Content.builder().source(source).title("영상").canonicalUrl("https://youtube.com/v2") + .extra("{\"videoId\":\"abc\"}").build(); + ReflectionTestUtils.setField(c, "id", UUID.randomUUID()); + + given(objectMapper.readValue(anyString(), any(TypeReference.class))).willReturn(Map.of("videoId", "abc")); + + assertThat(recommendService.extractChannel(c)).isEqualTo(c.getId().toString()); + } + + @Test + @DisplayName("extractChannel - JSON 파싱 실패 시 contentId 반환") + void extractChannel_parseError_returnsContentId() throws JsonProcessingException { + ContentSource source = ContentSource.builder() + .name("YouTube").url("https://youtube.com").collectMethod("api").build(); + Content c = Content.builder().source(source).title("영상").canonicalUrl("https://youtube.com/v3") + .extra("{invalid}").build(); + ReflectionTestUtils.setField(c, "id", UUID.randomUUID()); + + given(objectMapper.readValue(anyString(), any(TypeReference.class))) + .willThrow(new com.fasterxml.jackson.core.JsonParseException(null, "error")); + + assertThat(recommendService.extractChannel(c)).isEqualTo(c.getId().toString()); + } + + @Test + @DisplayName("YouTube - extra JSON 파싱 실패 시 빈 맵으로 대체, 결과 정상 반환") + void getRecommendYoutube_extraParseError_returnsEmptyExtra() throws JsonProcessingException { + UUID tagId = UUID.randomUUID(); + given(historyRepository.findTagIdActionCountsByUserActionsAfter(eq(userId), anyList(), any())) + .willReturn(Collections.singletonList(new Object[]{tagId, "scrapped", 1L})); + given(historyRepository.findViewedContentIdsSince(eq(userId), any())).willReturn(Collections.emptyList()); + + List videos = makeYoutubeContents(1); + given(contentRepository.findYoutubeByTagIdsExcludingScrapped(anyList(), eq(userId), any())) + .willReturn(videos); + given(contentRepository.findYoutubeByExcludeTagIdsExcludingScrapped(anyList(), eq(userId), any())) + .willReturn(Collections.emptyList()); + given(contentRepository.findLatestYoutubeExcludingScrapped(eq(userId), any())) + .willReturn(Collections.emptyList()); + + // extractChannel 호출(applyChannelDiversityPenalty)은 성공, parseExtra(buildYoutubeResponse)는 실패 + given(objectMapper.readValue(anyString(), any(TypeReference.class))) + .willReturn(Map.of("channelName", "채널0")) + .willThrow(new com.fasterxml.jackson.core.JsonParseException(null, "error")); + + YoutubeRecommendResponse result = recommendService.getRecommendYoutube(userId); + + assertThat(result.videos()).hasSize(1); + } } From c15ae7cfcee09127248b7872c8bf4a758a9c72f4 Mon Sep 17 00:00:00 2001 From: nYeonG4001 <2371324@hansung.ac.kr> Date: Thu, 7 May 2026 18:29:45 +0900 Subject: [PATCH 3/6] =?UTF-8?q?DP-463:=20buildWeightedTagScores=20NPE=20?= =?UTF-8?q?=EB=B0=A9=EC=A7=80=20=E2=80=94=20Object[]=20=EC=96=B8=EB=B0=95?= =?UTF-8?q?=EC=8B=B1=20=EC=A0=84=20null=20=EC=B2=B4=ED=81=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/devpick/domain/content/service/RecommendService.java | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) 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 0722ad06..83884669 100644 --- a/src/main/java/com/devpick/domain/content/service/RecommendService.java +++ b/src/main/java/com/devpick/domain/content/service/RecommendService.java @@ -240,9 +240,10 @@ Map buildWeightedTagScores(UUID userId) { for (Object[] row : rows) { UUID tagId = (UUID) row[0]; + Number rawCount = (Number) row[2]; + if (tagId == null || rawCount == null) continue; String actionType = (String) row[1]; - long count = (long) row[2]; - scores.merge(tagId, ACTION_WEIGHTS.getOrDefault(actionType, 1.0) * count, Double::sum); + scores.merge(tagId, ACTION_WEIGHTS.getOrDefault(actionType, 1.0) * rawCount.longValue(), Double::sum); } return scores; } From d9e5e3550c801a11bf1b07c2d40143ae23ed0c48 Mon Sep 17 00:00:00 2001 From: nYeonG4001 <2371324@hansung.ac.kr> Date: Thu, 7 May 2026 18:37:53 +0900 Subject: [PATCH 4/6] =?UTF-8?q?DP-463:=20applyChannelDiversityPenalty=20?= =?UTF-8?q?=EB=AC=B4=ED=95=9C=EB=A3=A8=ED=94=84=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=E2=80=94=20best=20null=20=EC=8B=9C=20break=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/devpick/domain/content/service/RecommendService.java | 2 ++ 1 file changed, 2 insertions(+) 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 83884669..8e86edd6 100644 --- a/src/main/java/com/devpick/domain/content/service/RecommendService.java +++ b/src/main/java/com/devpick/domain/content/service/RecommendService.java @@ -290,6 +290,8 @@ List applyChannelDiversityPenalty(List candidates, Map Date: Thu, 7 May 2026 18:46:59 +0900 Subject: [PATCH 5/6] =?UTF-8?q?DP-463:=20NPE=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=E2=80=94=20getTag()/getId()=20null=20=EC=B2=B4=ED=81=AC=20?= =?UTF-8?q?=EB=B0=8F=20extractChannel=20fallback=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../devpick/domain/content/service/RecommendService.java | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) 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 8e86edd6..ecdbaf80 100644 --- a/src/main/java/com/devpick/domain/content/service/RecommendService.java +++ b/src/main/java/com/devpick/domain/content/service/RecommendService.java @@ -180,6 +180,7 @@ public YoutubeRecommendResponse getRecommendYoutube(UUID userId) { if (topTagIds.isEmpty()) { topTagIds = userTagRepository.findByUser_Id(userId).stream() + .filter(ut -> ut.getTag() != null && ut.getTag().getId() != null) .map(ut -> ut.getTag().getId()).toList(); } @@ -258,6 +259,7 @@ List getTopTagIds(Map tagScores, int limit) { double computeScore(Content content, Map tagScores) { double tagScore = content.getContentTags().stream() + .filter(ct -> ct.getTag() != null && ct.getTag().getId() != null) .mapToDouble(ct -> tagScores.getOrDefault(ct.getTag().getId(), 0.0)) .sum(); double recencyBonus = 0; @@ -298,13 +300,14 @@ List applyChannelDiversityPenalty(List candidates, Map extra = objectMapper.readValue(content.getExtra(), new TypeReference>() {}); Object channelName = extra.get("channelName"); - return channelName != null ? channelName.toString() : content.getId().toString(); + return channelName != null ? channelName.toString() : fallback; } catch (JsonProcessingException e) { - return content.getId().toString(); + return fallback; } } From 99fd08ef16bfe4d1f1b46694894bf9f7497a3fdb Mon Sep 17 00:00:00 2001 From: nYeonG4001 <2371324@hansung.ac.kr> Date: Thu, 7 May 2026 18:53:38 +0900 Subject: [PATCH 6/6] =?UTF-8?q?DP-463:=20SonarCloud=20Reliability=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=E2=80=94=20int=E2=86=92long=20=EC=BA=90?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8,=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20assertion?= =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/devpick/domain/content/service/RecommendService.java | 2 +- .../devpick/domain/content/service/RecommendServiceTest.java | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) 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 ecdbaf80..aa4660c7 100644 --- a/src/main/java/com/devpick/domain/content/service/RecommendService.java +++ b/src/main/java/com/devpick/domain/content/service/RecommendService.java @@ -212,7 +212,7 @@ public YoutubeRecommendResponse getRecommendYoutube(UUID userId) { contentRepository.findLatestYoutubeExcludingScrapped(userId, PageRequest.of(0, RESULT_SIZE)) .stream() .filter(c -> !finalResultIds.contains(c.getId())) - .limit(RESULT_SIZE - result.size()) + .limit((long) RESULT_SIZE - result.size()) .forEach(result::add); } 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 cfa4babe..b149f5da 100644 --- a/src/test/java/com/devpick/domain/content/service/RecommendServiceTest.java +++ b/src/test/java/com/devpick/domain/content/service/RecommendServiceTest.java @@ -430,7 +430,7 @@ void getRecommendYoutube_viewedContentsExcluded() throws JsonProcessingException YoutubeRecommendResponse result = recommendService.getRecommendYoutube(userId); List resultIds = result.videos().stream().map(YoutubeRecommendItem::contentId).toList(); - assertThat(resultIds).doesNotContain(viewedIds.get(0), viewedIds.get(1)); + assertThat(resultIds).isNotEmpty().doesNotContain(viewedIds.get(0), viewedIds.get(1)); } @Test @@ -492,7 +492,7 @@ void buildWeightedTagScores_multipleActions_accumulated() { Map scores = recommendService.buildWeightedTagScores(userId); // 퀴즈완료(5.0×2) + 스크랩(4.0×1) = 14.0 - assertThat(scores.get(tagId)).isEqualTo(14.0); + assertThat(scores).containsEntry(tagId, 14.0); } @Test