From e5205d9e9c24660cfdf5cda4f2c7b0278c5048a5 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Sun, 2 Aug 2026 18:28:31 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=B1=84=EB=84=90=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20=E2=80=94=20OWN=20=EC=97=AD=ED=95=A0=20+=20?= =?UTF-8?q?=EC=86=8C=EC=9E=AC=EB=B3=84=20=EC=84=B1=EA=B3=BC=20=EB=9E=AD?= =?UTF-8?q?=ED=82=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 내 채널을 등록하면 그 영상이 수집함·칸반에 소재로 섞여 들어갔다. 이미 발행한 영상은 소재가 아니라 성과이므로 아예 분리한다. - ChannelRole.OWN 추가. OWN 채널 영상은 source=OWN 으로 저장되고, 수집함·칸반· 발굴 쿼리는 CHANNEL·SEARCH 화이트리스트라 자동으로 격리된다(쿼리 수정 불필요). /channels 목록에도 안 뜨고 피드에도 안 섞인다. - /my-channel 신설: 성과 요약(구독·총조회·영상평균·최근30일), 성장 추이 차트, 내 영상 목록. - 핵심은 소재별 성과 랭킹(TopicPerformance). 해시태그별 평균 조회수를 채널 평균 대비 배수로 보여준다. 실제 데이터로 유퀴즈 2.43x, 워크맨 0.30x 처럼 갈려서 피드에서 무엇을 자를지 판단하는 근거가 된다. 1편짜리 우연은 2편 미만 컷으로 제외. - 자동 수집·스냅샷 대상에 OWN 포함(findTrackedChannels). 해시태그 역분석의 원천도 MY → OWN 으로 바꿨다. 테스트 8건 추가(TopicPerformance 6, ChannelRole 2) — 총 84건 통과. Co-Authored-By: Claude Opus 5 (1M context) --- .../domain/channel/ChannelRepository.java | 10 +- .../yanalyst/domain/channel/ChannelRole.java | 15 +- .../domain/channel/ChannelService.java | 3 +- .../domain/channel/MyChannelController.java | 66 +++ .../domain/channel/MyChannelService.java | 125 ++++++ .../domain/channel/SeedSuggestService.java | 2 +- .../domain/channel/TopicPerformance.java | 107 +++++ .../channel/dto/MyChannelSummaryDto.java | 25 ++ .../schedule/ScheduledCollectionService.java | 8 +- .../com/hlab/yanalyst/web/WebController.java | 6 + .../resources/templates/layout/sidebar.html | 3 + src/main/resources/templates/my_channel.html | 384 ++++++++++++++++++ .../domain/channel/ChannelRoleTest.java | 14 + .../domain/channel/TopicPerformanceTest.java | 93 +++++ 14 files changed, 852 insertions(+), 9 deletions(-) create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/MyChannelController.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/MyChannelService.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/TopicPerformance.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/dto/MyChannelSummaryDto.java create mode 100644 src/main/resources/templates/my_channel.html create mode 100644 src/test/java/com/hlab/yanalyst/domain/channel/TopicPerformanceTest.java 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(); + } +}