feat: 내 채널 분석 페이지 분리 — OWN 역할 + 소재별 성과 랭킹
내 채널을 등록하면 그 영상이 수집함·칸반에 소재로 섞여 들어갔다. 이미 발행한 영상은 소재가 아니라 성과이므로 아예 분리한다. - 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) <noreply@anthropic.com>
This commit is contained in:
parent
32540d1ceb
commit
e5205d9e9c
@ -12,10 +12,18 @@ public interface ChannelRepository extends JpaRepository<Channel, Long> {
|
||||
Optional<Channel> 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<Channel> findMyChannels();
|
||||
|
||||
/** 내가 운영하는 채널(OWN). 한 개만 두는 것을 전제로 하지만 목록으로 반환한다. */
|
||||
@Query("select c from Channel c where c.role = 'OWN' order by c.id asc")
|
||||
List<Channel> findOwnChannels();
|
||||
|
||||
/** 자동 수집·스냅샷 대상: 벤치마킹 채널 + 내 채널 (피드 시드는 FeedCollectionService 담당). */
|
||||
@Query("select c from Channel c where c.role is null or c.role in ('MY','OWN')")
|
||||
List<Channel> findTrackedChannels();
|
||||
|
||||
/** 특정 역할의 채널. SOURCE/RIVAL 조회용. */
|
||||
List<Channel> findByRole(String role);
|
||||
|
||||
|
||||
@ -4,7 +4,9 @@ package com.hlab.yanalyst.domain.channel;
|
||||
* 등록 채널의 역할.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@link #MY} — 내 채널(성과 추적 대상). 기존 등록 채널은 전부 여기에 해당한다.</li>
|
||||
* <li>{@link #MY} — 벤치마킹용 등록 채널. 영상이 수집함으로 들어가 소재 후보가 된다.</li>
|
||||
* <li>{@link #OWN} — 내가 운영하는 채널. 이미 발행한 결과물이라 소재가 아니므로
|
||||
* 수집함·칸반·발굴·피드 어디에도 섞이지 않고 내 채널 분석 화면에서만 쓰인다.</li>
|
||||
* <li>{@link #SOURCE} — 소재 원본 채널(웹예능 공식채널). 롱폼만 피드에 수집한다.</li>
|
||||
* <li>{@link #RIVAL} — 경쟁 쇼츠 채널(같은 소재를 다루는 클립 채널). 쇼츠만 피드에 수집한다.</li>
|
||||
* </ul>
|
||||
@ -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);
|
||||
|
||||
@ -292,7 +292,8 @@ public class ChannelService {
|
||||
}
|
||||
|
||||
private void processVideos(Channel channel, List<String> videoIds) {
|
||||
upsertVideos(channel, videoIds, "CHANNEL", null, null);
|
||||
// 내 채널(OWN)의 영상은 소재가 아니라 성과라 출처를 달리해 수집함/발굴에서 격리한다.
|
||||
upsertVideos(channel, videoIds, ChannelRole.videoSource(channel.roleOrDefault()), null, null);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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<MyChannelSummaryDto> summary() {
|
||||
return ApiResponse.ok(myChannelService.summary());
|
||||
}
|
||||
|
||||
@GetMapping("/topics")
|
||||
@Operation(summary = "소재별 성과", description = "해시태그별 평균 조회수 랭킹(2편 이상). index=채널 평균 대비 배수")
|
||||
public ApiResponse<List<TopicPerformance.Topic>> topics() {
|
||||
return ApiResponse.ok(myChannelService.topics());
|
||||
}
|
||||
|
||||
@GetMapping("/videos")
|
||||
@Operation(summary = "내 영상 목록", description = "업로드 최신순")
|
||||
public ApiResponse<List<ChannelVideo>> videos() {
|
||||
return ApiResponse.ok(myChannelService.videos());
|
||||
}
|
||||
|
||||
@GetMapping("/growth")
|
||||
@Operation(summary = "성장 추이", description = "일별 구독자/조회수 스냅샷(오래된 순)")
|
||||
public ApiResponse<List<ChannelSnapshot>> growth() {
|
||||
return ApiResponse.ok(myChannelService.growth());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
@Operation(summary = "내 채널 등록", description = "채널 URL 로 등록하고 즉시 영상을 수집한다. 이미 있으면 교체.")
|
||||
public ApiResponse<MyChannelSummaryDto> register(@RequestBody Map<String, String> 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<MyChannelSummaryDto> sync() {
|
||||
return ApiResponse.ok(myChannelService.sync());
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
@Operation(summary = "등록 해제", description = "내 채널과 수집된 영상을 함께 제거")
|
||||
public ApiResponse<Void> unregister() {
|
||||
myChannelService.unregister();
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
}
|
||||
@ -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) 분석.
|
||||
*
|
||||
* <p>내가 이미 발행한 영상은 소재가 아니라 성과다. 수집함·칸반·발굴·피드 어디에도 섞이지 않도록
|
||||
* 출처를 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<Channel> findOwnChannel() {
|
||||
List<Channel> 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<ChannelVideo> 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<TopicPerformance.Topic> 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<ChannelVideo> videos() {
|
||||
Channel c = findOwnChannel().orElse(null);
|
||||
if (c == null) return List.of();
|
||||
List<ChannelVideo> list = new ArrayList<>(channelVideoRepository.findByChannelId(c.getId()));
|
||||
list.sort(Comparator.comparing(ChannelVideo::getPublishedAt,
|
||||
Comparator.nullsLast(Comparator.reverseOrder())));
|
||||
return list;
|
||||
}
|
||||
|
||||
/** 성장 추이(일별 스냅샷, 오래된 순). */
|
||||
public List<ChannelSnapshot> 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<TopicPerformance.Entry> toEntries(List<ChannelVideo> videos) {
|
||||
List<TopicPerformance.Entry> out = new ArrayList<>(videos.size());
|
||||
for (ChannelVideo v : videos) {
|
||||
out.add(new TopicPerformance.Entry(v.getHashtags(), v.getViewCount(), v.getTitle(), v.getVideoId()));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@ -63,7 +63,7 @@ public class SeedSuggestService {
|
||||
* @return 태그 → 등장 횟수 (빈도 내림차순)
|
||||
*/
|
||||
public Map<String, Integer> analyzeKeywords() {
|
||||
List<Channel> mine = channelRepository.findMyChannels();
|
||||
List<Channel> mine = channelRepository.findOwnChannels();
|
||||
List<String> texts = new ArrayList<>();
|
||||
for (Channel c : mine) {
|
||||
for (ChannelVideo v : channelVideoRepository.findByChannelId(c.getId())) {
|
||||
|
||||
@ -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;
|
||||
|
||||
/**
|
||||
* 내 영상을 해시태그(소재)별로 묶어 성과를 집계하는 순수 로직.
|
||||
*
|
||||
* <p>"유퀴즈 클립은 평균 12만, 핑계고는 3만" 같은 답을 만들어, 피드에서 무엇을 자를지
|
||||
* 결정하는 근거로 쓴다. 채널 평균 대비 배수(index)로 표시해 절대 조회수가 아니라
|
||||
* <b>상대적으로 잘 먹히는 소재</b>가 드러나게 한다.
|
||||
*/
|
||||
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<Topic> aggregate(List<Entry> 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<String, long[]> sums = new LinkedHashMap<>(); // [합계, 편수, 최고조회수]
|
||||
Map<String, String> display = new LinkedHashMap<>(); // 원문 표기
|
||||
Map<String, String[]> 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<Topic> out = new ArrayList<>();
|
||||
for (Map.Entry<String, long[]> 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<Entry> 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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -124,8 +124,8 @@ public class ScheduledCollectionService {
|
||||
|
||||
/** 수동/스케줄 공용. 모든 등록 채널을 쿼터 한도 내에서 수집하고 결과 요약을 반환. */
|
||||
public Map<String, Object> runChannelCollection() {
|
||||
// 피드 시드(SOURCE/RIVAL)는 FeedCollectionService 가 3시간마다 따로 수집한다.
|
||||
List<Channel> channels = channelRepository.findMyChannels();
|
||||
// 벤치마킹 채널 + 내 채널. 피드 시드(SOURCE/RIVAL)는 FeedCollectionService 가 3시간마다 따로 수집한다.
|
||||
List<Channel> channels = channelRepository.findTrackedChannels();
|
||||
int ok = 0, failed = 0, skippedByQuota = 0;
|
||||
|
||||
for (Channel c : channels) {
|
||||
@ -164,8 +164,8 @@ public class ScheduledCollectionService {
|
||||
|
||||
/** 모든 채널의 통계를 갱신하며 일별 성장 스냅샷을 기록. */
|
||||
public Map<String, Object> runChannelSnapshot() {
|
||||
// 피드 시드(SOURCE/RIVAL)는 FeedCollectionService 가 3시간마다 따로 수집한다.
|
||||
List<Channel> channels = channelRepository.findMyChannels();
|
||||
// 벤치마킹 채널 + 내 채널. 피드 시드(SOURCE/RIVAL)는 FeedCollectionService 가 3시간마다 따로 수집한다.
|
||||
List<Channel> channels = channelRepository.findTrackedChannels();
|
||||
int ok = 0, failed = 0, skippedByQuota = 0;
|
||||
|
||||
for (Channel c : channels) {
|
||||
|
||||
@ -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");
|
||||
|
||||
@ -29,6 +29,9 @@
|
||||
<a th:href="@{/}" class="nav-item" th:classappend="${currentPage == 'dashboard'} ? 'active'">
|
||||
<i data-lucide="layout-dashboard" class="nav-icon"></i><span class="nav-text">대시보드</span>
|
||||
</a>
|
||||
<a th:href="@{/my-channel}" class="nav-item" th:classappend="${currentPage == 'my-channel'} ? 'active'">
|
||||
<i data-lucide="trending-up" class="nav-icon"></i><span class="nav-text">내 채널</span>
|
||||
</a>
|
||||
<a th:href="@{/discover}" class="nav-item" th:classappend="${currentPage == 'discover'} ? 'active'">
|
||||
<i data-lucide="radar" class="nav-icon"></i><span class="nav-text">발굴</span>
|
||||
</a>
|
||||
|
||||
384
src/main/resources/templates/my_channel.html
Normal file
384
src/main/resources/templates/my_channel.html
Normal file
@ -0,0 +1,384 @@
|
||||
<!DOCTYPE html>
|
||||
<html xmlns:th="http://www.thymeleaf.org" xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
|
||||
layout:decorate="~{layout/base}">
|
||||
|
||||
<head>
|
||||
<title>h-lab - 내 채널</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1>내 채널</h1>
|
||||
<p class="sub">내가 발행한 영상의 성과와 어떤 소재가 잘 먹히는지 봅니다. 소재 파이프라인과는 분리돼 있습니다.</p>
|
||||
</div>
|
||||
<div class="actions" id="headerActions"></div>
|
||||
</div>
|
||||
|
||||
<!-- 미등록 상태 -->
|
||||
<div id="setup" class="card hidden" style="max-width:640px;">
|
||||
<div class="flex items-center gap-3 mb-3">
|
||||
<div class="setup-ic"><i data-lucide="trending-up"></i></div>
|
||||
<div>
|
||||
<div class="font-semibold">내 채널을 등록하세요</div>
|
||||
<div class="text-sm text-muted" style="margin-top:2px;">
|
||||
여기 등록한 채널의 영상은 수집함·칸반·피드에 섞이지 않습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="seed-add">
|
||||
<label class="sr-only" for="ownUrl">채널 URL</label>
|
||||
<input id="ownUrl" type="text" placeholder="https://www.youtube.com/@mychannel">
|
||||
<button class="btn btn-primary" id="registerBtn" onclick="registerOwn()">등록</button>
|
||||
</div>
|
||||
<p class="text-xs text-muted mt-2" id="setupHelp">
|
||||
등록하면 영상을 바로 수집합니다 (쿼터 약 12 units).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 등록 상태 -->
|
||||
<div id="main" class="hidden">
|
||||
<!-- 요약 -->
|
||||
<div class="card mb-4" id="summaryCard"></div>
|
||||
|
||||
<div class="mc-grid">
|
||||
<!-- 소재별 성과 -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-3" style="flex-wrap:wrap; gap:0.5rem;">
|
||||
<div>
|
||||
<h3 class="text-lg">소재별 성과</h3>
|
||||
<p class="text-xs text-muted" style="margin-top:2px;">
|
||||
해시태그 기준 · 2편 이상만 · 배수는 채널 평균 대비
|
||||
</p>
|
||||
</div>
|
||||
<span id="avgBadge" class="badge badge-muted"></span>
|
||||
</div>
|
||||
<div id="topics"></div>
|
||||
</div>
|
||||
|
||||
<!-- 성장 추이 -->
|
||||
<div class="card">
|
||||
<h3 class="text-lg mb-3">성장 추이</h3>
|
||||
<div id="growthWrap" style="position:relative; height:260px;">
|
||||
<canvas id="growthChart"></canvas>
|
||||
</div>
|
||||
<div id="growthEmpty" class="text-sm text-muted text-center hidden" style="padding:2.5rem 0;">
|
||||
아직 스냅샷이 없습니다. 매일 새벽 3시에 기록되며 이틀치가 쌓이면 그래프가 그려집니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 내 영상 -->
|
||||
<div class="card mt-4">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h3 class="text-lg">내 영상</h3>
|
||||
<span id="videoCount" class="badge badge-muted"></span>
|
||||
</div>
|
||||
<div id="videos" class="mc-videos"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toast" class="toast" role="status" aria-live="polite"></div>
|
||||
|
||||
<style>
|
||||
.mc-grid { display:grid; grid-template-columns:1.15fr 1fr; gap:1rem; align-items:start; }
|
||||
@media (max-width: 960px) { .mc-grid { grid-template-columns:1fr; } }
|
||||
|
||||
.setup-ic {
|
||||
width:38px; height:38px; border-radius:10px; flex-shrink:0;
|
||||
background:var(--accent-soft); color:var(--accent);
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
.seed-add { display:flex; gap:0.5rem; flex-wrap:wrap; }
|
||||
.seed-add input { flex:1; min-width:190px; }
|
||||
|
||||
/* 요약 */
|
||||
.mc-head { display:flex; align-items:center; gap:1rem; flex-wrap:wrap; }
|
||||
.mc-head img { width:56px; height:56px; border-radius:50%; object-fit:cover; background:var(--inset); flex-shrink:0; }
|
||||
.mc-stats { display:flex; gap:1.75rem; flex-wrap:wrap; margin-left:auto; }
|
||||
.mc-stat .k { font-size:0.72rem; color:var(--text-3); font-weight:600; }
|
||||
.mc-stat .v {
|
||||
font-size:1.15rem; font-weight:700; font-family:var(--font-mono);
|
||||
font-variant-numeric:tabular-nums; margin-top:2px;
|
||||
}
|
||||
@media (max-width: 700px) { .mc-stats { margin-left:0; gap:1.1rem; } }
|
||||
|
||||
/* 소재 랭킹 */
|
||||
.topic-row {
|
||||
display:flex; align-items:center; gap:0.7rem;
|
||||
padding:0.6rem 0; border-bottom:1px solid var(--border);
|
||||
}
|
||||
.topic-row:last-child { border-bottom:none; }
|
||||
.topic-name { min-width:0; flex:0 0 30%; }
|
||||
.topic-name b { display:block; font-size:0.85rem; font-weight:600; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.topic-name span { font-size:0.7rem; color:var(--text-3); }
|
||||
.topic-bar { flex:1; min-width:60px; }
|
||||
.topic-bar .track { height:7px; background:var(--inset); border-radius:var(--radius-full); overflow:hidden; }
|
||||
.topic-bar .fill { height:100%; border-radius:var(--radius-full); transition:width .5s cubic-bezier(.22,1,.36,1); }
|
||||
.topic-num {
|
||||
flex:0 0 auto; text-align:right; font-family:var(--font-mono);
|
||||
font-variant-numeric:tabular-nums; font-size:0.8rem; min-width:66px;
|
||||
}
|
||||
.topic-idx { flex:0 0 auto; min-width:52px; text-align:right; }
|
||||
|
||||
/* 영상 목록 */
|
||||
.mc-videos { display:grid; grid-template-columns:repeat(auto-fill, minmax(210px, 1fr)); gap:0.85rem; }
|
||||
.mcv { display:block; }
|
||||
.mcv .th { position:relative; aspect-ratio:16/9; border-radius:var(--r-sm); overflow:hidden; background:var(--inset); }
|
||||
.mcv .th img { width:100%; height:100%; object-fit:cover; display:block; }
|
||||
.mcv:focus-visible .th { outline:2px solid var(--accent); outline-offset:2px; }
|
||||
.mcv .t {
|
||||
font-size:0.8rem; font-weight:600; line-height:1.4; margin-top:0.4rem; color:var(--text);
|
||||
display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
|
||||
}
|
||||
.mcv:hover .t { text-decoration:underline; }
|
||||
.mcv .m { font-size:0.72rem; color:var(--text-3); margin-top:2px; font-variant-numeric:tabular-nums; }
|
||||
|
||||
.toast {
|
||||
position:fixed; left:50%; bottom:24px; transform:translate(-50%, 12px);
|
||||
background:var(--text); color:var(--bg);
|
||||
padding:0.65rem 1.1rem; border-radius:var(--r-sm); font-size:0.85rem; font-weight:600;
|
||||
box-shadow:var(--shadow-lg); z-index:2000;
|
||||
opacity:0; pointer-events:none; transition:opacity .2s ease, transform .2s ease;
|
||||
max-width:88vw; text-align:center;
|
||||
}
|
||||
.toast.show { opacity:1; transform:translate(-50%, 0); }
|
||||
.toast.err { background:var(--danger); color:#fff; }
|
||||
.sr-only {
|
||||
position:absolute; width:1px; height:1px; padding:0; margin:-1px;
|
||||
overflow:hidden; clip:rect(0,0,0,0); white-space:nowrap; border:0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.topic-bar .fill, .toast { transition:none; }
|
||||
}
|
||||
</style>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
|
||||
<script th:inline="javascript">
|
||||
/*<![CDATA[*/
|
||||
const API = '/api/my-channel';
|
||||
let growthChart = null;
|
||||
|
||||
function esc(s){ return (s==null?'':String(s)).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
function fmtNum(n){
|
||||
if(n==null) return '-';
|
||||
const v = Number(n);
|
||||
if(v >= 100000000) return (v/100000000).toFixed(1).replace(/\.0$/,'') + '억';
|
||||
if(v >= 10000) return (v/10000).toFixed(1).replace(/\.0$/,'') + '만';
|
||||
return v.toLocaleString();
|
||||
}
|
||||
function fmtDate(iso){
|
||||
if(!iso) return '-';
|
||||
const d = new Date(iso);
|
||||
return isNaN(d) ? '-' : `${d.getFullYear()}.${String(d.getMonth()+1).padStart(2,'0')}.${String(d.getDate()).padStart(2,'0')}`;
|
||||
}
|
||||
|
||||
let toastTimer = null;
|
||||
function toast(msg, isError){
|
||||
const el = document.getElementById('toast');
|
||||
el.textContent = msg;
|
||||
el.classList.toggle('err', !!isError);
|
||||
el.classList.add('show');
|
||||
clearTimeout(toastTimer);
|
||||
toastTimer = setTimeout(()=> el.classList.remove('show'), 3500);
|
||||
}
|
||||
|
||||
async function api(url, opts){
|
||||
const res = await fetch(url, opts);
|
||||
const json = await res.json().catch(()=>({}));
|
||||
if(!res.ok || (json && json.success===false)) throw new Error((json && json.message) || ('HTTP '+res.status));
|
||||
return json.data;
|
||||
}
|
||||
|
||||
// ---------- 요약 ----------
|
||||
function renderSummary(s){
|
||||
document.getElementById('summaryCard').innerHTML = `
|
||||
<div class="mc-head">
|
||||
<img src="${esc(s.thumbnailUrl)}" alt="" loading="lazy">
|
||||
<div style="min-width:0;">
|
||||
<div class="text-lg font-bold">${esc(s.title)}</div>
|
||||
<a href="https://www.youtube.com/channel/${esc(s.channelId)}" target="_blank" rel="noopener"
|
||||
class="text-xs text-muted hover:underline">YouTube 에서 보기</a>
|
||||
</div>
|
||||
<div class="mc-stats">
|
||||
<div class="mc-stat"><div class="k">구독자</div><div class="v">${fmtNum(s.subscriberCount)}</div></div>
|
||||
<div class="mc-stat"><div class="k">총 조회수</div><div class="v">${fmtNum(s.viewCount)}</div></div>
|
||||
<div class="mc-stat"><div class="k">영상 평균</div><div class="v">${fmtNum(s.avgViews)}</div></div>
|
||||
<div class="mc-stat"><div class="k">최근 30일</div><div class="v">${fmtNum(s.last30Views)}</div></div>
|
||||
</div>
|
||||
</div>`;
|
||||
document.getElementById('headerActions').innerHTML = `
|
||||
<button class="btn btn-secondary" id="syncBtn" onclick="syncOwn()">
|
||||
<i data-lucide="refresh-cw" style="width:15px;"></i> 동기화
|
||||
</button>
|
||||
<button class="btn btn-secondary" onclick="unregisterOwn()" title="등록 해제">
|
||||
<i data-lucide="unlink" style="width:15px;"></i>
|
||||
</button>`;
|
||||
if(window.lucide) lucide.createIcons();
|
||||
}
|
||||
|
||||
// ---------- 소재별 성과 ----------
|
||||
function renderTopics(list, avg){
|
||||
document.getElementById('avgBadge').textContent = '채널 평균 ' + fmtNum(avg);
|
||||
const box = document.getElementById('topics');
|
||||
if(!list || list.length === 0){
|
||||
box.innerHTML = `<div class="text-sm text-muted text-center" style="padding:2.5rem 0; line-height:1.6;">
|
||||
아직 집계할 소재가 없습니다.<br>영상 설명의 해시태그(#유퀴즈 #핑계고)로 묶기 때문에,
|
||||
같은 소재가 2편 이상 쌓이면 나타납니다.</div>`;
|
||||
return;
|
||||
}
|
||||
const max = Math.max(...list.map(t => t.avgViews), 1);
|
||||
box.innerHTML = list.map(t => {
|
||||
// 평균 이상은 강조색, 이하는 중립색 — 색만으로 전달하지 않도록 배수 텍스트를 함께 둔다
|
||||
const strong = t.index >= 1;
|
||||
const color = strong ? 'var(--accent)' : 'var(--border-strong)';
|
||||
const idxCls = t.index >= 1.5 ? 'badge-success' : (strong ? 'badge-primary' : 'badge-muted');
|
||||
const w = Math.max(2, Math.round(t.avgViews / max * 100));
|
||||
return `<div class="topic-row">
|
||||
<div class="topic-name">
|
||||
<b title="${esc(t.topic)}">#${esc(t.topic)}</b>
|
||||
<span>${t.videoCount}편 · 최고 ${fmtNum(t.maxViews)}</span>
|
||||
</div>
|
||||
<div class="topic-bar"><div class="track"><div class="fill" style="width:${w}%; background:${color};"></div></div></div>
|
||||
<div class="topic-num">${fmtNum(t.avgViews)}</div>
|
||||
<div class="topic-idx"><span class="badge ${idxCls}">${t.index.toFixed(1)}x</span></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ---------- 성장 추이 ----------
|
||||
function renderGrowth(rows){
|
||||
const wrap = document.getElementById('growthWrap');
|
||||
const empty = document.getElementById('growthEmpty');
|
||||
if(!rows || rows.length < 2){
|
||||
wrap.classList.add('hidden'); empty.classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
wrap.classList.remove('hidden'); empty.classList.add('hidden');
|
||||
|
||||
const css = getComputedStyle(document.documentElement);
|
||||
const accent = css.getPropertyValue('--accent').trim() || '#ff4d23';
|
||||
const text3 = css.getPropertyValue('--text-3').trim() || '#999';
|
||||
const border = css.getPropertyValue('--border').trim() || '#ddd';
|
||||
|
||||
if(growthChart) growthChart.destroy();
|
||||
growthChart = new Chart(document.getElementById('growthChart').getContext('2d'), {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: rows.map(r => fmtDate(r.snapshotDate)),
|
||||
datasets: [{
|
||||
label: '구독자',
|
||||
data: rows.map(r => r.subscriberCount),
|
||||
borderColor: accent,
|
||||
backgroundColor: accent + '22',
|
||||
fill: true, tension: 0.3, pointRadius: 2, borderWidth: 2
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true, maintainAspectRatio: false,
|
||||
animation: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? false : undefined,
|
||||
plugins: { legend: { display: false } },
|
||||
scales: {
|
||||
x: { grid: { color: border }, ticks: { color: text3, maxTicksLimit: 6 } },
|
||||
y: { grid: { color: border }, ticks: { color: text3 } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- 영상 ----------
|
||||
function renderVideos(list){
|
||||
document.getElementById('videoCount').textContent = (list?.length || 0) + '건';
|
||||
const box = document.getElementById('videos');
|
||||
if(!list || list.length === 0){
|
||||
box.innerHTML = '<div class="text-sm text-muted p-4">수집된 영상이 없습니다. 동기화를 눌러보세요.</div>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = list.map(v => `
|
||||
<a class="mcv" href="https://www.youtube.com/watch?v=${encodeURIComponent(v.videoId)}"
|
||||
target="_blank" rel="noopener">
|
||||
<div class="th"><img src="${esc(v.thumbnailUrl)}" alt="" loading="lazy" width="320" height="180"></div>
|
||||
<div class="t">${esc(v.title)}</div>
|
||||
<div class="m">${fmtNum(v.viewCount)}회 · ${fmtDate(v.publishedAt)}</div>
|
||||
</a>`).join('');
|
||||
}
|
||||
|
||||
// ---------- 액션 ----------
|
||||
async function registerOwn(){
|
||||
const url = document.getElementById('ownUrl').value.trim();
|
||||
const help = document.getElementById('setupHelp');
|
||||
if(!url){
|
||||
help.textContent = '채널 URL 을 입력하세요.';
|
||||
help.style.color = 'var(--danger)';
|
||||
document.getElementById('ownUrl').focus();
|
||||
return;
|
||||
}
|
||||
const btn = document.getElementById('registerBtn');
|
||||
btn.disabled = true;
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = '등록 중...';
|
||||
try {
|
||||
await api(API, { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({url}) });
|
||||
toast('등록하고 영상을 수집했습니다');
|
||||
await load();
|
||||
} catch(e){
|
||||
help.textContent = '등록 실패: ' + e.message;
|
||||
help.style.color = 'var(--danger)';
|
||||
} finally { btn.disabled = false; btn.textContent = orig; }
|
||||
}
|
||||
|
||||
async function syncOwn(){
|
||||
const btn = document.getElementById('syncBtn');
|
||||
btn.disabled = true;
|
||||
try {
|
||||
await api(API + '/sync', { method:'POST' });
|
||||
toast('동기화했습니다');
|
||||
await load();
|
||||
} catch(e){ toast('동기화 실패: ' + e.message, true); }
|
||||
finally { const b = document.getElementById('syncBtn'); if(b) b.disabled = false; }
|
||||
}
|
||||
|
||||
async function unregisterOwn(){
|
||||
if(!confirm('내 채널 등록을 해제할까요?\n수집된 영상과 소재별 성과 기록도 함께 삭제됩니다.')) return;
|
||||
try {
|
||||
await api(API, { method:'DELETE' });
|
||||
toast('등록을 해제했습니다');
|
||||
await load();
|
||||
} catch(e){ toast('해제 실패: ' + e.message, true); }
|
||||
}
|
||||
|
||||
// ---------- init ----------
|
||||
async function load(){
|
||||
let s;
|
||||
try { s = await api(API); }
|
||||
catch(e){ toast('불러오기 실패: ' + e.message, true); return; }
|
||||
|
||||
if(!s || !s.registered){
|
||||
document.getElementById('setup').classList.remove('hidden');
|
||||
document.getElementById('main').classList.add('hidden');
|
||||
document.getElementById('headerActions').innerHTML = '';
|
||||
if(window.lucide) lucide.createIcons();
|
||||
return;
|
||||
}
|
||||
document.getElementById('setup').classList.add('hidden');
|
||||
document.getElementById('main').classList.remove('hidden');
|
||||
renderSummary(s);
|
||||
|
||||
const [topics, videos, growth] = await Promise.all([
|
||||
api(API + '/topics').catch(()=>[]),
|
||||
api(API + '/videos').catch(()=>[]),
|
||||
api(API + '/growth').catch(()=>[])
|
||||
]);
|
||||
renderTopics(topics, s.avgViews);
|
||||
renderVideos(videos);
|
||||
renderGrowth(growth);
|
||||
}
|
||||
|
||||
load();
|
||||
/*]]>*/
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<TopicPerformance.Entry> entries = List.of(
|
||||
e("유퀴즈,윤경호", 100_000L, "a"),
|
||||
e("유퀴즈", 140_000L, "b"),
|
||||
e("핑계고", 20_000L, "c"),
|
||||
e("핑계고,윤경호", 40_000L, "d"));
|
||||
|
||||
List<TopicPerformance.Topic> 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<TopicPerformance.Entry> entries = List.of(
|
||||
e("유퀴즈", 100_000L, "a"),
|
||||
e("유퀴즈", 100_000L, "b"),
|
||||
e("어쩌다사장", 500_000L, "c")); // 1편뿐 → 조회수가 높아도 랭킹에서 뺀다
|
||||
|
||||
List<TopicPerformance.Topic> out = TopicPerformance.aggregate(entries, 2);
|
||||
|
||||
assertThat(out).extracting(TopicPerformance.Topic::topic).containsExactly("유퀴즈");
|
||||
}
|
||||
|
||||
@Test
|
||||
void index는_채널평균_대비_배수() {
|
||||
// 채널 평균 = (200000 + 200000 + 40000 + 40000) / 4 = 120000
|
||||
List<TopicPerformance.Entry> entries = List.of(
|
||||
e("유퀴즈", 200_000L, "a"),
|
||||
e("유퀴즈", 200_000L, "b"),
|
||||
e("핑계고", 40_000L, "c"),
|
||||
e("핑계고", 40_000L, "d"));
|
||||
|
||||
List<TopicPerformance.Topic> 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<TopicPerformance.Entry> entries = List.of(
|
||||
e("유퀴즈", 10_000L, "낮은편"),
|
||||
e("유퀴즈", 90_000L, "터진편"));
|
||||
|
||||
List<TopicPerformance.Topic> out = TopicPerformance.aggregate(entries, 2);
|
||||
|
||||
assertThat(out.get(0).topTitle()).isEqualTo("터진편");
|
||||
assertThat(out.get(0).topVideoId()).isEqualTo("vid_터진편");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 해시태그_없거나_조회수_null_이어도_깨지지_않는다() {
|
||||
List<TopicPerformance.Entry> entries = List.of(
|
||||
e(null, 50_000L, "태그없음"),
|
||||
e("", 30_000L, "빈태그"),
|
||||
e("유퀴즈", null, "조회수없음"),
|
||||
e("유퀴즈", 60_000L, "정상"));
|
||||
|
||||
List<TopicPerformance.Topic> 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();
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user