feat(discover): RecommendedChannel 엔티티+Repo, DiscoveryRanker 랭킹로직(TDD)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ee4d556851
commit
507ae7c760
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user