From 507ae7c760e75fb73fdaa6555ff5dbbefa79d474 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Fri, 26 Jun 2026 10:41:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(discover):=20RecommendedChannel=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0+Repo,=20DiscoveryRanker=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9=EB=A1=9C=EC=A7=81(TDD)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- .../domain/channel/DiscoveryRanker.java | 45 +++++++++++++++ .../domain/channel/RecommendedChannel.java | 52 ++++++++++++++++++ .../channel/RecommendedChannelRepository.java | 12 ++++ .../domain/channel/DiscoveryRankerTest.java | 55 +++++++++++++++++++ 4 files changed, 164 insertions(+) create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/DiscoveryRanker.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java create mode 100644 src/test/java/com/hlab/yanalyst/domain/channel/DiscoveryRankerTest.java diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/DiscoveryRanker.java b/src/main/java/com/hlab/yanalyst/domain/channel/DiscoveryRanker.java new file mode 100644 index 0000000..1351129 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/DiscoveryRanker.java @@ -0,0 +1,45 @@ +package com.hlab.yanalyst.domain.channel; + +import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** 검색된 Shorts 목록 → 채널별 최고 배율 후보 집계·필터·정렬(순수 로직). */ +public final class DiscoveryRanker { + + private DiscoveryRanker() {} + + public record Candidate(String channelId, String channelTitle, String thumbnailUrl, Long subscriberCount, + String topVideoId, String topVideoTitle, Long topVideoViewCount, + double ratio, String region) {} + + public static List rank(List items, long maxSubscribers, + double minRatio, Set excludeChannelIds) { + Map best = new LinkedHashMap<>(); + for (YoutubeSearchResultDto it : items) { + String ch = it.getChannelId(); + Long subs = it.getSubscriberCount(); + Long views = it.getViewCount(); + if (ch == null || subs == null || subs <= 0 || views == null) continue; + if (excludeChannelIds.contains(ch)) continue; + if (subs > maxSubscribers) continue; + + double ratio = (double) views / subs; + if (ratio < minRatio) continue; + + Candidate prev = best.get(ch); + if (prev == null || ratio > prev.ratio()) { + best.put(ch, new Candidate(ch, it.getChannelTitle(), it.getThumbnailUrl(), subs, + it.getVideoId(), it.getTitle(), views, ratio, it.getChannelCountry())); + } + } + List out = new ArrayList<>(best.values()); + out.sort(Comparator.comparingDouble(Candidate::ratio).reversed()); + return out; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java b/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java new file mode 100644 index 0000000..3606e7f --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannel.java @@ -0,0 +1,52 @@ +package com.hlab.yanalyst.domain.channel; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.hibernate.annotations.CreationTimestamp; +import org.hibernate.annotations.UpdateTimestamp; + +import java.time.LocalDateTime; + +/** 자동 발굴된 추천 채널(떡상 Shorts 기반). status: NEW|REGISTERED|EXCLUDED. */ +@Entity +@Table(name = "recommended_channels") +@Getter +@Setter +@NoArgsConstructor +public class RecommendedChannel { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String channelId; + + private String channelTitle; + + @Column(length = 2083) + private String thumbnailUrl; // 대표 떡상 영상의 썸네일 + + private Long subscriberCount; + + @Column(nullable = false) + private String status = "NEW"; + + // 대표(최고 배율) 영상 + private String topVideoId; + @Column(length = 500) + private String topVideoTitle; + private Long topVideoViewCount; + private Double ratio; // topVideo viewCount / subscriberCount + + private String region; // 발견 지역(KR/JP/US) + + @CreationTimestamp + @Column(updatable = false) + private LocalDateTime discoveredAt; + + @UpdateTimestamp + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java b/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java new file mode 100644 index 0000000..575e4d8 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/RecommendedChannelRepository.java @@ -0,0 +1,12 @@ +package com.hlab.yanalyst.domain.channel; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; + +public interface RecommendedChannelRepository extends JpaRepository { + Optional findByChannelId(String channelId); + List findByStatusOrderByRatioDesc(String status); + boolean existsByChannelIdAndStatus(String channelId, String status); +} diff --git a/src/test/java/com/hlab/yanalyst/domain/channel/DiscoveryRankerTest.java b/src/test/java/com/hlab/yanalyst/domain/channel/DiscoveryRankerTest.java new file mode 100644 index 0000000..4080665 --- /dev/null +++ b/src/test/java/com/hlab/yanalyst/domain/channel/DiscoveryRankerTest.java @@ -0,0 +1,55 @@ +package com.hlab.yanalyst.domain.channel; + +import com.hlab.yanalyst.web.dto.YoutubeSearchResultDto; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.assertj.core.api.Assertions.assertThat; + +class DiscoveryRankerTest { + + private YoutubeSearchResultDto item(String ch, long subs, String vid, long views) { + YoutubeSearchResultDto d = new YoutubeSearchResultDto(); + d.setChannelId(ch); + d.setChannelTitle(ch + "_title"); + d.setThumbnailUrl("thumb_" + vid); + d.setSubscriberCount(subs); + d.setVideoId(vid); + d.setTitle("v_" + vid); + d.setViewCount(views); + d.setChannelCountry("KR"); + return d; + } + + @Test + void rank_채널별_최고배율_선정_필터_정렬() { + List items = List.of( + item("A", 1000, "a1", 50000), // 배율 50 (작은채널·고배율) + item("A", 1000, "a2", 10000), // 배율 10 (A의 비대표) + item("B", 5_000_000, "b1", 60_000_000), // 구독자 과다 → 제외(maxSubs) + item("C", 2000, "c1", 4000), // 배율 2 → minRatio 미달 제외 + item("D", 1000, "d1", 30000) // 배율 30 + ); + + List out = DiscoveryRanker.rank(items, 100_000, 5.0, Set.of()); + + assertThat(out).extracting(DiscoveryRanker.Candidate::channelId).containsExactly("A", "D"); + assertThat(out.get(0).topVideoId()).isEqualTo("a1"); + assertThat(out.get(0).ratio()).isEqualTo(50.0); + } + + @Test + void rank_제외채널_및_구독자0_제거() { + List items = List.of( + item("A", 1000, "a1", 50000), // 배율 50 이지만 제외목록 + item("E", 0, "e1", 9999), // 구독자 0 → 배율 계산 불가 제외 + item("F", 1000, "f1", 20000) // 배율 20 + ); + + List out = DiscoveryRanker.rank(items, 100_000, 5.0, Set.of("A")); + + assertThat(out).extracting(DiscoveryRanker.Candidate::channelId).containsExactly("F"); + } +}