내 채널을 등록하면 그 영상이 수집함·칸반에 소재로 섞여 들어갔다. 이미 발행한 영상은 소재가 아니라 성과이므로 아예 분리한다. - 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>
126 lines
5.0 KiB
Java
126 lines
5.0 KiB
Java
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;
|
|
}
|
|
}
|