diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java index 22a542e..4601451 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelRepository.java @@ -12,10 +12,18 @@ public interface ChannelRepository extends JpaRepository { Optional findByChannelId(String channelId); boolean existsByChannelId(String channelId); - /** 내 채널(MY). role 이 null 인 레거시 행도 MY 로 취급한다. */ + /** 벤치마킹 등록 채널(MY). role 이 null 인 레거시 행도 MY 로 취급한다. */ @Query("select c from Channel c where c.role is null or c.role = 'MY'") List findMyChannels(); + /** 내가 운영하는 채널(OWN). 한 개만 두는 것을 전제로 하지만 목록으로 반환한다. */ + @Query("select c from Channel c where c.role = 'OWN' order by c.id asc") + List findOwnChannels(); + + /** 자동 수집·스냅샷 대상: 벤치마킹 채널 + 내 채널 (피드 시드는 FeedCollectionService 담당). */ + @Query("select c from Channel c where c.role is null or c.role in ('MY','OWN')") + List findTrackedChannels(); + /** 특정 역할의 채널. SOURCE/RIVAL 조회용. */ List findByRole(String role); diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelRole.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelRole.java index c97e46d..0f44fce 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelRole.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelRole.java @@ -4,7 +4,9 @@ package com.hlab.yanalyst.domain.channel; * 등록 채널의 역할. * *
    - *
  • {@link #MY} — 내 채널(성과 추적 대상). 기존 등록 채널은 전부 여기에 해당한다.
  • + *
  • {@link #MY} — 벤치마킹용 등록 채널. 영상이 수집함으로 들어가 소재 후보가 된다.
  • + *
  • {@link #OWN} — 내가 운영하는 채널. 이미 발행한 결과물이라 소재가 아니므로 + * 수집함·칸반·발굴·피드 어디에도 섞이지 않고 내 채널 분석 화면에서만 쓰인다.
  • *
  • {@link #SOURCE} — 소재 원본 채널(웹예능 공식채널). 롱폼만 피드에 수집한다.
  • *
  • {@link #RIVAL} — 경쟁 쇼츠 채널(같은 소재를 다루는 클립 채널). 쇼츠만 피드에 수집한다.
  • *
@@ -14,6 +16,7 @@ package com.hlab.yanalyst.domain.channel; public final class ChannelRole { public static final String MY = "MY"; + public static final String OWN = "OWN"; public static final String SOURCE = "SOURCE"; public static final String RIVAL = "RIVAL"; @@ -24,11 +27,19 @@ public final class ChannelRole { if (role == null || role.isBlank()) return MY; String r = role.trim().toUpperCase(); return switch (r) { - case SOURCE, RIVAL, MY -> r; + case SOURCE, RIVAL, OWN, MY -> r; default -> MY; }; } + /** + * 이 역할의 채널에서 수집한 영상에 붙일 출처(ChannelVideo.source). + * 수집함/발굴 쿼리는 CHANNEL·SEARCH 만 보므로, OWN 은 자동으로 격리된다. + */ + public static String videoSource(String role) { + return OWN.equals(normalize(role)) ? OWN : "CHANNEL"; + } + /** 피드(소재 원본/경쟁) 역할인지. */ public static boolean isFeed(String role) { String r = normalize(role); diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java index d662903..439a406 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/ChannelService.java @@ -292,7 +292,8 @@ public class ChannelService { } private void processVideos(Channel channel, List videoIds) { - upsertVideos(channel, videoIds, "CHANNEL", null, null); + // 내 채널(OWN)의 영상은 소재가 아니라 성과라 출처를 달리해 수집함/발굴에서 격리한다. + upsertVideos(channel, videoIds, ChannelRole.videoSource(channel.roleOrDefault()), null, null); } /** diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/MyChannelController.java b/src/main/java/com/hlab/yanalyst/domain/channel/MyChannelController.java new file mode 100644 index 0000000..3c08667 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/MyChannelController.java @@ -0,0 +1,66 @@ +package com.hlab.yanalyst.domain.channel; + +import com.hlab.yanalyst.domain.channel.dto.MyChannelSummaryDto; +import com.hlab.yanalyst.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** 내 채널(OWN) 분석 API. 소재 파이프라인과 완전히 분리된 성과 추적용. */ +@RestController +@RequestMapping("/api/my-channel") +@RequiredArgsConstructor +@Tag(name = "My Channel API", description = "내 채널 성과 분석(소재별 랭킹 포함)") +public class MyChannelController { + + private final MyChannelService myChannelService; + + @GetMapping + @Operation(summary = "내 채널 요약", description = "등록 안 돼 있으면 registered=false") + public ApiResponse summary() { + return ApiResponse.ok(myChannelService.summary()); + } + + @GetMapping("/topics") + @Operation(summary = "소재별 성과", description = "해시태그별 평균 조회수 랭킹(2편 이상). index=채널 평균 대비 배수") + public ApiResponse> topics() { + return ApiResponse.ok(myChannelService.topics()); + } + + @GetMapping("/videos") + @Operation(summary = "내 영상 목록", description = "업로드 최신순") + public ApiResponse> videos() { + return ApiResponse.ok(myChannelService.videos()); + } + + @GetMapping("/growth") + @Operation(summary = "성장 추이", description = "일별 구독자/조회수 스냅샷(오래된 순)") + public ApiResponse> growth() { + return ApiResponse.ok(myChannelService.growth()); + } + + @PostMapping + @Operation(summary = "내 채널 등록", description = "채널 URL 로 등록하고 즉시 영상을 수집한다. 이미 있으면 교체.") + public ApiResponse register(@RequestBody Map body) { + String url = body.get("url"); + if (url == null || url.isBlank()) throw new IllegalArgumentException("채널 URL 이 필요합니다."); + return ApiResponse.created(myChannelService.register(url)); + } + + @PostMapping("/sync") + @Operation(summary = "영상 재동기화", description = "내 채널 영상과 조회수를 다시 받아온다") + public ApiResponse sync() { + return ApiResponse.ok(myChannelService.sync()); + } + + @DeleteMapping + @Operation(summary = "등록 해제", description = "내 채널과 수집된 영상을 함께 제거") + public ApiResponse unregister() { + myChannelService.unregister(); + return ApiResponse.ok(null); + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/MyChannelService.java b/src/main/java/com/hlab/yanalyst/domain/channel/MyChannelService.java new file mode 100644 index 0000000..bd0c441 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/MyChannelService.java @@ -0,0 +1,125 @@ +package com.hlab.yanalyst.domain.channel; + +import com.hlab.yanalyst.domain.channel.dto.MyChannelSummaryDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +/** + * 내 채널(OWN) 분석. + * + *

내가 이미 발행한 영상은 소재가 아니라 성과다. 수집함·칸반·발굴·피드 어디에도 섞이지 않도록 + * 출처를 OWN 으로 분리해 두고(자세한 건 {@link ChannelRole}), 이 화면에서만 다룬다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MyChannelService { + + /** 랭킹에 올릴 소재의 최소 편수. 1편짜리 우연을 상위에 띄우지 않기 위함. */ + private static final int MIN_TOPIC_VIDEOS = 2; + + private final ChannelRepository channelRepository; + private final ChannelVideoRepository channelVideoRepository; + private final ChannelSnapshotRepository channelSnapshotRepository; + private final ChannelService channelService; + + /** 등록된 내 채널. 없으면 empty. */ + public Optional findOwnChannel() { + List own = channelRepository.findOwnChannels(); + return own.isEmpty() ? Optional.empty() : Optional.of(own.get(0)); + } + + public MyChannelSummaryDto summary() { + Channel c = findOwnChannel().orElse(null); + if (c == null) return MyChannelSummaryDto.empty(); + + List videos = channelVideoRepository.findByChannelId(c.getId()); + long avg = TopicPerformance.channelAverage(toEntries(videos)); + + LocalDateTime since = LocalDateTime.now().minusDays(30); + long last30 = videos.stream() + .filter(v -> v.getPublishedAt() != null && v.getPublishedAt().isAfter(since)) + .mapToLong(v -> v.getViewCount() == null ? 0 : v.getViewCount()) + .sum(); + + return new MyChannelSummaryDto(true, c.getId(), c.getChannelId(), c.getTitle(), c.getThumbnailUrl(), + c.getSubscriberCount(), c.getViewCount(), c.getVideoCount(), videos.size(), avg, last30); + } + + /** 해시태그(소재)별 성과 랭킹. */ + public List topics() { + Channel c = findOwnChannel().orElse(null); + if (c == null) return List.of(); + return TopicPerformance.aggregate(toEntries(channelVideoRepository.findByChannelId(c.getId())), + MIN_TOPIC_VIDEOS); + } + + /** 내 영상 목록(최신순). */ + public List videos() { + Channel c = findOwnChannel().orElse(null); + if (c == null) return List.of(); + List list = new ArrayList<>(channelVideoRepository.findByChannelId(c.getId())); + list.sort(Comparator.comparing(ChannelVideo::getPublishedAt, + Comparator.nullsLast(Comparator.reverseOrder()))); + return list; + } + + /** 성장 추이(일별 스냅샷, 오래된 순). */ + public List growth() { + Channel c = findOwnChannel().orElse(null); + if (c == null) return List.of(); + return channelSnapshotRepository.findByChannelIdOrderBySnapshotDateAsc(c.getId()); + } + + /** + * 내 채널을 등록한다. 이미 등록돼 있으면 교체한다(내 채널은 하나만 둔다). + * 등록 즉시 영상을 수집해 분석할 데이터를 채운다. + */ + @Transactional + public MyChannelSummaryDto register(String url) { + findOwnChannel().ifPresent(prev -> { + log.info("[MyChannel] 기존 내 채널 {} 을(를) 교체합니다", prev.getTitle()); + channelService.deleteChannel(prev.getId()); + }); + + Channel channel = channelService.saveChannelFromUrl(url); + channel.changeRole(ChannelRole.OWN); + channelRepository.save(channel); + channelService.collectChannelVideos(channel.getId()); + return summary(); + } + + /** 내 채널 영상을 다시 동기화한다. */ + @Transactional + public MyChannelSummaryDto sync() { + Channel c = findOwnChannel() + .orElseThrow(() -> new IllegalArgumentException("등록된 내 채널이 없습니다.")); + channelService.collectChannelVideos(c.getId()); + return summary(); + } + + /** 내 채널 등록을 해제하고 수집된 영상도 함께 지운다. */ + @Transactional + public void unregister() { + Channel c = findOwnChannel() + .orElseThrow(() -> new IllegalArgumentException("등록된 내 채널이 없습니다.")); + channelService.deleteChannel(c.getId()); + } + + private List toEntries(List videos) { + List out = new ArrayList<>(videos.size()); + for (ChannelVideo v : videos) { + out.add(new TopicPerformance.Entry(v.getHashtags(), v.getViewCount(), v.getTitle(), v.getVideoId())); + } + return out; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/SeedSuggestService.java b/src/main/java/com/hlab/yanalyst/domain/channel/SeedSuggestService.java index 1335458..fb94e95 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/SeedSuggestService.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/SeedSuggestService.java @@ -63,7 +63,7 @@ public class SeedSuggestService { * @return 태그 → 등장 횟수 (빈도 내림차순) */ public Map analyzeKeywords() { - List mine = channelRepository.findMyChannels(); + List mine = channelRepository.findOwnChannels(); List texts = new ArrayList<>(); for (Channel c : mine) { for (ChannelVideo v : channelVideoRepository.findByChannelId(c.getId())) { diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/TopicPerformance.java b/src/main/java/com/hlab/yanalyst/domain/channel/TopicPerformance.java new file mode 100644 index 0000000..2aebaa4 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/TopicPerformance.java @@ -0,0 +1,107 @@ +package com.hlab.yanalyst.domain.channel; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +/** + * 내 영상을 해시태그(소재)별로 묶어 성과를 집계하는 순수 로직. + * + *

"유퀴즈 클립은 평균 12만, 핑계고는 3만" 같은 답을 만들어, 피드에서 무엇을 자를지 + * 결정하는 근거로 쓴다. 채널 평균 대비 배수(index)로 표시해 절대 조회수가 아니라 + * 상대적으로 잘 먹히는 소재가 드러나게 한다. + */ +public final class TopicPerformance { + + private TopicPerformance() {} + + /** + * 소재 한 줄. + * + * @param topic 해시태그(프로그램명·인물명) + * @param videoCount 이 소재로 만든 영상 수 + * @param avgViews 평균 조회수 + * @param maxViews 최고 조회수 + * @param index 채널 평균 대비 배수. 1.0 이면 평균, 2.0 이면 평균의 두 배 + * @param topTitle 이 소재에서 가장 잘 된 영상 제목 + * @param topVideoId 그 영상의 YouTube ID + */ + public record Topic(String topic, int videoCount, long avgViews, long maxViews, + double index, String topTitle, String topVideoId) {} + + /** 집계 입력. 엔티티에 직접 의존하지 않도록 필요한 값만 받는다. */ + public record Entry(String hashtags, Long viewCount, String title, String videoId) {} + + /** + * 해시태그별 성과를 집계한다. + * + * @param entries 내 영상들 + * @param minCount 이 편수 미만인 소재는 버린다(1편짜리 우연을 랭킹에 올리지 않기 위함) + * @return 평균 조회수 내림차순 목록 + */ + public static List aggregate(List entries, int minCount) { + if (entries == null || entries.isEmpty()) return List.of(); + + // 채널 평균 = 소재 유무와 무관하게 전체 영상 기준 + long total = 0; + int counted = 0; + for (Entry e : entries) { + if (e.viewCount() == null) continue; + total += e.viewCount(); + counted++; + } + double channelAvg = counted == 0 ? 0 : (double) total / counted; + + // 소문자키 → 누적치 + Map sums = new LinkedHashMap<>(); // [합계, 편수, 최고조회수] + Map display = new LinkedHashMap<>(); // 원문 표기 + Map best = new LinkedHashMap<>(); // [제목, videoId] + + for (Entry e : entries) { + long views = e.viewCount() == null ? 0 : e.viewCount(); + for (String tag : HashtagExtractor.split(e.hashtags())) { + String key = tag.toLowerCase(Locale.ROOT); + long[] acc = sums.computeIfAbsent(key, k -> new long[3]); + acc[0] += views; + acc[1] += 1; + if (views >= acc[2]) { + acc[2] = views; + best.put(key, new String[]{e.title(), e.videoId()}); + } + display.putIfAbsent(key, tag); + } + } + + List out = new ArrayList<>(); + for (Map.Entry en : sums.entrySet()) { + long[] acc = en.getValue(); + int count = (int) acc[1]; + if (count < minCount) continue; + long avg = acc[0] / count; + double index = channelAvg == 0 ? 0 : avg / channelAvg; + String[] b = best.getOrDefault(en.getKey(), new String[]{null, null}); + out.add(new Topic(display.get(en.getKey()), count, avg, acc[2], + Math.round(index * 100) / 100.0, b[0], b[1])); + } + + out.sort(Comparator.comparingLong(Topic::avgViews).reversed() + .thenComparing(Topic::topic)); + return out; + } + + /** 전체 영상의 평균 조회수. 비교 기준선으로 화면에 함께 보여준다. */ + public static long channelAverage(List entries) { + if (entries == null || entries.isEmpty()) return 0; + long total = 0; + int counted = 0; + for (Entry e : entries) { + if (e.viewCount() == null) continue; + total += e.viewCount(); + counted++; + } + return counted == 0 ? 0 : total / counted; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/dto/MyChannelSummaryDto.java b/src/main/java/com/hlab/yanalyst/domain/channel/dto/MyChannelSummaryDto.java new file mode 100644 index 0000000..3ed2f79 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/dto/MyChannelSummaryDto.java @@ -0,0 +1,25 @@ +package com.hlab.yanalyst.domain.channel.dto; + +/** + * 내 채널 요약. 채널이 등록돼 있지 않으면 registered=false 만 채워 보낸다. + * + * @param avgViews 영상 평균 조회수(소재별 랭킹의 비교 기준선) + * @param last30Views 최근 30일 업로드분의 조회수 합 + */ +public record MyChannelSummaryDto( + boolean registered, + Long id, + String channelId, + String title, + String thumbnailUrl, + Long subscriberCount, + Long viewCount, + Long videoCount, + int collectedVideos, + long avgViews, + long last30Views +) { + public static MyChannelSummaryDto empty() { + return new MyChannelSummaryDto(false, null, null, null, null, null, null, null, 0, 0, 0); + } +} diff --git a/src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java b/src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java index 43d818d..3a754e6 100644 --- a/src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java +++ b/src/main/java/com/hlab/yanalyst/global/schedule/ScheduledCollectionService.java @@ -124,8 +124,8 @@ public class ScheduledCollectionService { /** 수동/스케줄 공용. 모든 등록 채널을 쿼터 한도 내에서 수집하고 결과 요약을 반환. */ public Map runChannelCollection() { - // 피드 시드(SOURCE/RIVAL)는 FeedCollectionService 가 3시간마다 따로 수집한다. - List channels = channelRepository.findMyChannels(); + // 벤치마킹 채널 + 내 채널. 피드 시드(SOURCE/RIVAL)는 FeedCollectionService 가 3시간마다 따로 수집한다. + List channels = channelRepository.findTrackedChannels(); int ok = 0, failed = 0, skippedByQuota = 0; for (Channel c : channels) { @@ -164,8 +164,8 @@ public class ScheduledCollectionService { /** 모든 채널의 통계를 갱신하며 일별 성장 스냅샷을 기록. */ public Map runChannelSnapshot() { - // 피드 시드(SOURCE/RIVAL)는 FeedCollectionService 가 3시간마다 따로 수집한다. - List channels = channelRepository.findMyChannels(); + // 벤치마킹 채널 + 내 채널. 피드 시드(SOURCE/RIVAL)는 FeedCollectionService 가 3시간마다 따로 수집한다. + List channels = channelRepository.findTrackedChannels(); int ok = 0, failed = 0, skippedByQuota = 0; for (Channel c : channels) { diff --git a/src/main/java/com/hlab/yanalyst/web/WebController.java b/src/main/java/com/hlab/yanalyst/web/WebController.java index 6a03f92..2b7a8fe 100644 --- a/src/main/java/com/hlab/yanalyst/web/WebController.java +++ b/src/main/java/com/hlab/yanalyst/web/WebController.java @@ -52,6 +52,12 @@ public class WebController { return "discover"; } + @GetMapping("/my-channel") + public String myChannel(Model model) { + model.addAttribute("currentPage", "my-channel"); + return "my_channel"; + } + @GetMapping("/feed") public String feed(Model model) { model.addAttribute("currentPage", "feed"); diff --git a/src/main/resources/templates/layout/sidebar.html b/src/main/resources/templates/layout/sidebar.html index c741036..bf38929 100644 --- a/src/main/resources/templates/layout/sidebar.html +++ b/src/main/resources/templates/layout/sidebar.html @@ -29,6 +29,9 @@ 대시보드 + + 내 채널 + 발굴 diff --git a/src/main/resources/templates/my_channel.html b/src/main/resources/templates/my_channel.html new file mode 100644 index 0000000..1bcaee9 --- /dev/null +++ b/src/main/resources/templates/my_channel.html @@ -0,0 +1,384 @@ + + + + + h-lab - 내 채널 + + + +

+ + + + + + + + +
+ + + + + +
+ + + diff --git a/src/test/java/com/hlab/yanalyst/domain/channel/ChannelRoleTest.java b/src/test/java/com/hlab/yanalyst/domain/channel/ChannelRoleTest.java index 143d897..e637a5f 100644 --- a/src/test/java/com/hlab/yanalyst/domain/channel/ChannelRoleTest.java +++ b/src/test/java/com/hlab/yanalyst/domain/channel/ChannelRoleTest.java @@ -33,4 +33,18 @@ class ChannelRoleTest { assertThat(ChannelRole.acceptsShorts(ChannelRole.RIVAL)).isTrue(); assertThat(ChannelRole.acceptsShorts(ChannelRole.SOURCE)).isFalse(); } + + @Test + void OWN은_별도_역할이며_isFeed가_아니다() { + assertThat(ChannelRole.normalize("own")).isEqualTo(ChannelRole.OWN); + assertThat(ChannelRole.isFeed(ChannelRole.OWN)).isFalse(); + } + + @Test + void videoSource_내채널만_OWN_나머지는_CHANNEL() { + // 수집함/발굴 쿼리는 CHANNEL·SEARCH 만 보므로 OWN 은 이걸로 격리된다 + assertThat(ChannelRole.videoSource(ChannelRole.OWN)).isEqualTo("OWN"); + assertThat(ChannelRole.videoSource(ChannelRole.MY)).isEqualTo("CHANNEL"); + assertThat(ChannelRole.videoSource(null)).isEqualTo("CHANNEL"); + } } diff --git a/src/test/java/com/hlab/yanalyst/domain/channel/TopicPerformanceTest.java b/src/test/java/com/hlab/yanalyst/domain/channel/TopicPerformanceTest.java new file mode 100644 index 0000000..d6b0d3b --- /dev/null +++ b/src/test/java/com/hlab/yanalyst/domain/channel/TopicPerformanceTest.java @@ -0,0 +1,93 @@ +package com.hlab.yanalyst.domain.channel; + +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class TopicPerformanceTest { + + private TopicPerformance.Entry e(String tags, Long views, String title) { + return new TopicPerformance.Entry(tags, views, title, "vid_" + title); + } + + @Test + void aggregate_소재별_평균조회수_내림차순() { + List entries = List.of( + e("유퀴즈,윤경호", 100_000L, "a"), + e("유퀴즈", 140_000L, "b"), + e("핑계고", 20_000L, "c"), + e("핑계고,윤경호", 40_000L, "d")); + + List out = TopicPerformance.aggregate(entries, 2); + + assertThat(out).extracting(TopicPerformance.Topic::topic) + .containsExactly("유퀴즈", "윤경호", "핑계고"); + assertThat(out.get(0).avgViews()).isEqualTo(120_000); // (100000+140000)/2 + assertThat(out.get(0).videoCount()).isEqualTo(2); + assertThat(out.get(0).maxViews()).isEqualTo(140_000); + } + + @Test + void aggregate_최소편수_미만은_제외() { + List entries = List.of( + e("유퀴즈", 100_000L, "a"), + e("유퀴즈", 100_000L, "b"), + e("어쩌다사장", 500_000L, "c")); // 1편뿐 → 조회수가 높아도 랭킹에서 뺀다 + + List out = TopicPerformance.aggregate(entries, 2); + + assertThat(out).extracting(TopicPerformance.Topic::topic).containsExactly("유퀴즈"); + } + + @Test + void index는_채널평균_대비_배수() { + // 채널 평균 = (200000 + 200000 + 40000 + 40000) / 4 = 120000 + List entries = List.of( + e("유퀴즈", 200_000L, "a"), + e("유퀴즈", 200_000L, "b"), + e("핑계고", 40_000L, "c"), + e("핑계고", 40_000L, "d")); + + List out = TopicPerformance.aggregate(entries, 2); + + assertThat(TopicPerformance.channelAverage(entries)).isEqualTo(120_000); + assertThat(out.get(0).index()).isEqualTo(1.67); // 200000/120000 + assertThat(out.get(1).index()).isEqualTo(0.33); // 40000/120000 + } + + @Test + void 최고조회수_영상의_제목을_들고온다() { + List entries = List.of( + e("유퀴즈", 10_000L, "낮은편"), + e("유퀴즈", 90_000L, "터진편")); + + List out = TopicPerformance.aggregate(entries, 2); + + assertThat(out.get(0).topTitle()).isEqualTo("터진편"); + assertThat(out.get(0).topVideoId()).isEqualTo("vid_터진편"); + } + + @Test + void 해시태그_없거나_조회수_null_이어도_깨지지_않는다() { + List entries = List.of( + e(null, 50_000L, "태그없음"), + e("", 30_000L, "빈태그"), + e("유퀴즈", null, "조회수없음"), + e("유퀴즈", 60_000L, "정상")); + + List out = TopicPerformance.aggregate(entries, 2); + + assertThat(out).hasSize(1); + assertThat(out.get(0).topic()).isEqualTo("유퀴즈"); + assertThat(out.get(0).avgViews()).isEqualTo(30_000); // (0 + 60000) / 2 + } + + @Test + void 빈_입력은_빈_결과() { + assertThat(TopicPerformance.aggregate(List.of(), 2)).isEmpty(); + assertThat(TopicPerformance.aggregate(null, 2)).isEmpty(); + assertThat(TopicPerformance.channelAverage(List.of())).isZero(); + } +}