feat(discover): RecommendedChannel 엔티티+Repo, DiscoveryRanker 랭킹로직(TDD)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hehihoho3@gmail.com 2026-06-26 10:41:52 +09:00
parent ee4d556851
commit 507ae7c760
4 changed files with 164 additions and 0 deletions

View File

@ -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<Candidate> rank(List<YoutubeSearchResultDto> items, long maxSubscribers,
double minRatio, Set<String> excludeChannelIds) {
Map<String, Candidate> 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<Candidate> out = new ArrayList<>(best.values());
out.sort(Comparator.comparingDouble(Candidate::ratio).reversed());
return out;
}
}

View File

@ -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;
}

View File

@ -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<RecommendedChannel, Long> {
Optional<RecommendedChannel> findByChannelId(String channelId);
List<RecommendedChannel> findByStatusOrderByRatioDesc(String status);
boolean existsByChannelIdAndStatus(String channelId, String status);
}

View File

@ -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<YoutubeSearchResultDto> 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<DiscoveryRanker.Candidate> 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<YoutubeSearchResultDto> items = List.of(
item("A", 1000, "a1", 50000), // 배율 50 이지만 제외목록
item("E", 0, "e1", 9999), // 구독자 0 배율 계산 불가 제외
item("F", 1000, "f1", 20000) // 배율 20
);
List<DiscoveryRanker.Candidate> out = DiscoveryRanker.rank(items, 100_000, 5.0, Set.of("A"));
assertThat(out).extracting(DiscoveryRanker.Candidate::channelId).containsExactly("F");
}
}