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