Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.example.inflace.domain.channel.dto.response.GetInfluencerInsightResponse;
import com.example.inflace.domain.video.domain.Video;
import com.example.inflace.domain.video.domain.VideoStats;
import com.example.inflace.domain.video.repository.projection.VideoFormatStatsProjection;
import com.example.inflace.global.util.AnalyticsCalculator;
import java.time.LocalDateTime;
import java.util.ArrayList;
Expand All @@ -25,7 +26,6 @@ public class InfluencerInsightCalculator {
private static final int CONTENT_WINDOW_SIZE = 50;
private static final int GROWTH_WINDOW_SIZE = 25;
private static final int FREQUENCY_INTERVAL_WINDOW_SIZE = 5;
private static final int RECENT_WINDOW_DAYS = 30;
private static final int MIN_ADVERTISEMENT_VIDEO_COUNT = 3;

public InfluencerInsightCalculator(InfluencerInsightScoreCalculator scoreCalculator) {
Expand All @@ -37,7 +37,8 @@ public GetInfluencerInsightResponse calculate(
ChannelStats channelStats,
List<String> categories,
List<Video> videos,
Map<Long, VideoStats> videoStatsMap
Map<Long, VideoStats> videoStatsMap,
List<VideoFormatStatsProjection> recentFormatProjections
) {
List<VideoMetric> metrics = buildVideoMetrics(videos, videoStatsMap);
List<Video> latestVideos = getLatestVideos(videos);
Expand All @@ -47,7 +48,7 @@ public GetInfluencerInsightResponse calculate(
GetInfluencerInsightResponse.Content content = buildContentMetrics(latestMetrics);
GetInfluencerInsightResponse.Activity activity = buildActivityMetrics(latestVideos);
GetInfluencerInsightResponse.Advertisement advertisement = buildAdvertisementMetrics(latestMetrics, channelStats);
GetInfluencerInsightResponse.FormatAnalysis formatAnalysis = buildFormatAnalysis(metrics);
GetInfluencerInsightResponse.FormatAnalysis formatAnalysis = buildFormatAnalysis(recentFormatProjections);

return new GetInfluencerInsightResponse(
channel.getId(),
Expand Down Expand Up @@ -203,29 +204,27 @@ private GetInfluencerInsightResponse.Activity buildActivityMetrics(List<Video> v
);
}

private GetInfluencerInsightResponse.FormatAnalysis buildFormatAnalysis(List<VideoMetric> metrics) {
LocalDateTime recent30d = LocalDateTime.now().minusDays(RECENT_WINDOW_DAYS);

List<VideoMetric> recentLongForm = metrics.stream()
.filter(metric -> metric.publishedAt() != null && !metric.publishedAt().isBefore(recent30d))
.filter(metric -> !metric.isShort())
private GetInfluencerInsightResponse.FormatAnalysis buildFormatAnalysis(
List<VideoFormatStatsProjection> projections
) {
List<VideoFormatStatsProjection> recentLongForm = projections.stream()
.filter(projection -> !projection.isShort())
.toList();

List<VideoMetric> recentShortForm = metrics.stream()
.filter(metric -> metric.publishedAt() != null && !metric.publishedAt().isBefore(recent30d))
.filter(VideoMetric::isShort)
List<VideoFormatStatsProjection> recentShortForm = projections.stream()
.filter(VideoFormatStatsProjection::isShort)
.toList();

return new GetInfluencerInsightResponse.FormatAnalysis(
new GetInfluencerInsightResponse.FormatMetric(
recentLongForm.size(),
calculateRound(averageViewCount(recentLongForm)),
calculateRound(averageEngagementRate(recentLongForm))
calculateRound(averageFormatViewCount(recentLongForm)),
calculateRound(averageFormatEngagementRate(recentLongForm))
),
new GetInfluencerInsightResponse.FormatMetric(
recentShortForm.size(),
calculateRound(averageViewCount(recentShortForm)),
calculateRound(averageEngagementRate(recentShortForm))
calculateRound(averageFormatViewCount(recentShortForm)),
calculateRound(averageFormatEngagementRate(recentShortForm))
)
);
}
Expand Down Expand Up @@ -292,7 +291,7 @@ private double calculateFixedWindowPercentOf(
.filter(predicate)
.count();

return matched * 100.0 / CONTENT_WINDOW_SIZE;
return matched * 100.0 / metrics.size();
}

private double averageViewCount(List<VideoMetric> metrics) {
Expand All @@ -305,12 +304,26 @@ private double averageViewCount(List<VideoMetric> metrics) {
.orElse(0.0);
}

private double averageEngagementRate(List<VideoMetric> metrics) {
if (metrics.isEmpty()) {
private double averageFormatViewCount(List<VideoFormatStatsProjection> projections) {
if (projections.isEmpty()) {
return 0.0;
}
return metrics.stream()
.mapToDouble(VideoMetric::engagementRate)
return projections.stream()
.mapToLong(VideoFormatStatsProjection::viewCount)
.average()
.orElse(0.0);
}

private double averageFormatEngagementRate(List<VideoFormatStatsProjection> projections) {
if (projections.isEmpty()) {
return 0.0;
}
return projections.stream()
.mapToDouble(projection -> AnalyticsCalculator.engagementRate(
projection.likeCount(),
projection.commentCount(),
projection.viewCount()
))
.average()
.orElse(0.0);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,18 @@
import com.example.inflace.domain.channel.repository.ChannelStatsRepository;
import com.example.inflace.domain.video.domain.Video;
import com.example.inflace.domain.video.repository.VideoRepository;
import com.example.inflace.domain.video.repository.projection.VideoFormatStatsProjection;
import com.example.inflace.domain.video.service.VideoService;
import com.example.inflace.global.annotation.ReadOnlyTransactional;
import com.example.inflace.global.exception.ApiException;
import com.example.inflace.global.exception.ErrorDefine;
import java.time.LocalDateTime;
import java.time.ZoneOffset;
import java.util.List;
import java.util.Objects;

import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;

Expand All @@ -26,7 +30,9 @@
public class InfluencerInsightQueryService {

private static final int MIN_VIDEO_COUNT_FOR_INSIGHT = 10;
private static final int LATEST_VIDEO_COUNT_FOR_INSIGHT = 50;
private static final int MAX_RECENT_VIDEO_DESCRIPTION_COUNT = 10;
private static final int RECENT_WINDOW_DAYS = 30;

private final ChannelRepository channelRepository;
private final ChannelStatsRepository channelStatsRepository;
Expand All @@ -40,10 +46,18 @@ public InfluencerInsightQueryResult getInsightQueryResult(Long channelId) {
Channel channel = channelRepository.findById(channelId)
.orElseThrow(() -> new ApiException(ErrorDefine.CHANNEL_NOT_FOUND));

List<Video> videos = videoRepository.findByChannelIdOrderByPublishedAtDesc(channelId);
if (videos.size() < MIN_VIDEO_COUNT_FOR_INSIGHT) {
if (videoRepository.countByChannelId(channelId) < MIN_VIDEO_COUNT_FOR_INSIGHT) {
throw new ApiException(ErrorDefine.CHANNEL_INSIGHT_REQUIRES_MIN_VIDEO_COUNT);
}
List<Video> latestVideos = videoRepository.findByChannelIdOrderByPublishedAtDesc(
channelId,
PageRequest.of(0, LATEST_VIDEO_COUNT_FOR_INSIGHT)
);
List<VideoFormatStatsProjection> recentFormatProjections = videoRepository
.findFormatStatsRowsByChannelIdAndPublishedAtGreaterThanEqual(
channelId,
LocalDateTime.now(ZoneOffset.UTC).minusDays(RECENT_WINDOW_DAYS)
);
List<String> categories = channelCategoryRepository.findAllByChannel_Id(channelId).stream()
.map(ChannelCategory::getCategory)
.filter(Objects::nonNull)
Expand All @@ -56,13 +70,14 @@ public InfluencerInsightQueryResult getInsightQueryResult(Long channelId) {
channel,
channelStats,
categories,
videos,
videoService.getVideoStatsMap(videos)
latestVideos,
videoService.getVideoStatsMap(latestVideos),
recentFormatProjections
);

return new InfluencerInsightQueryResult(
channel.getDescription(),
getRecentVideoDescriptions(videos),
getRecentVideoDescriptions(latestVideos),
insight
);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.example.inflace.domain.video.repository;

import com.example.inflace.domain.video.domain.Video;
import com.example.inflace.domain.video.repository.projection.VideoFormatStatsProjection;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;
Expand Down Expand Up @@ -50,12 +51,10 @@ order by coalesce(vs.risingScore, 0) desc, coalesce(va.ctr, 0) desc, v.id desc
""")
List<Video> findAllTopVideos(@Param("channelId") Long channelId, Limit limit);

Long countByChannelIdAndPublishedAtGreaterThanEqual(Long channelId, LocalDateTime publishedAt);
long countByChannelId(Long channelId);

List<Video> findByChannelId(Long channelId);

List<Video> findByChannelIdOrderByPublishedAtDesc(Long channelId);

@Query("""
select v from Video v
join fetch v.channel
Expand All @@ -64,4 +63,24 @@ order by coalesce(vs.risingScore, 0) desc, coalesce(va.ctr, 0) desc, v.id desc
order by coalesce(vs.viewCount, 0) desc
""")
List<Video> findTopAdVideos(Limit limit);

List<Video> findByChannelIdOrderByPublishedAtDesc(Long channelId, Pageable pageable);

@Query("""
select new com.example.inflace.domain.video.repository.projection.VideoFormatStatsProjection(
v.isShort,
vs.viewCount,
vs.likeCount,
vs.commentCount
)
from Video v
join VideoStats vs on vs.video = v
where v.channel.id = :channelId
and v.publishedAt >= :publishedAt
""")
List<VideoFormatStatsProjection> findFormatStatsRowsByChannelIdAndPublishedAtGreaterThanEqual(
@Param("channelId") Long channelId,
@Param("publishedAt") LocalDateTime publishedAt
);

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.example.inflace.domain.video.repository.projection;

public record VideoFormatStatsProjection(
boolean isShort,
long viewCount,
long likeCount,
long commentCount
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package com.example.inflace.domain.channel.service.insight;

import static org.assertj.core.api.Assertions.assertThat;

import com.example.inflace.domain.channel.domain.Channel;
import com.example.inflace.domain.channel.dto.response.GetInfluencerInsightResponse;
import com.example.inflace.domain.video.domain.Video;
import com.example.inflace.domain.video.domain.VideoStats;
import com.example.inflace.domain.video.repository.projection.VideoFormatStatsProjection;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;

class InfluencerInsightCalculatorTest {

@Test
void viralRatesUseActualMetricCountWhenLatestVideosAreLessThanFifty() {
InfluencerInsightCalculator calculator = new InfluencerInsightCalculator(new InfluencerInsightScoreCalculator());
Channel channel = Channel.builder()
.name("test-channel")
.build();
List<Long> viewCounts = List.of(1000L, 500L, 400L, 10L, 10L, 10L, 10L, 10L, 10L, 10L);
List<Video> videos = new ArrayList<>();
Map<Long, VideoStats> statsMap = new HashMap<>();

for (int i = 0; i < viewCounts.size(); i++) {
Video video = video((long) i + 1, LocalDateTime.now().minusDays(i));
videos.add(video);
statsMap.put(video.getId(), VideoStats.builder()
.video(video)
.viewCount(viewCounts.get(i))
.likeCount(0L)
.commentCount(0L)
.build());
}

GetInfluencerInsightResponse response = calculator.calculate(
channel,
null,
List.of(),
videos,
statsMap,
List.of()
);

assertThat(response.content().viral2xRate())
.as("expected viral2xRate to use actual 10-video denominator, not fixed 50 denominator")
.isEqualTo(30.0);
assertThat(response.content().viral5xRate())
.as("expected viral5xRate to use actual 10-video denominator, not fixed 50 denominator")
.isEqualTo(10.0);
}

@Test
void formatAnalysisUsesRecentThirtyDayProjectionRows() {
InfluencerInsightCalculator calculator = new InfluencerInsightCalculator(new InfluencerInsightScoreCalculator());
Video video = video();
VideoStats stats = VideoStats.builder()
.video(video)
.viewCount(100L)
.likeCount(10L)
.commentCount(5L)
.build();

GetInfluencerInsightResponse response = calculator.calculate(
Channel.builder().name("test-channel").build(),
null,
List.of(),
List.of(video),
Map.of(video.getId(), stats),
List.of(
new VideoFormatStatsProjection(false, 100L, 10L, 5L),
new VideoFormatStatsProjection(true, 50L, 5L, 5L)
)
);

assertThat(response.formatAnalysis().longForm().count())
.as("expected long-form count from recent 30-day projection rows")
.isOne();
assertThat(response.formatAnalysis().shortForm().count())
.as("expected short-form count from recent 30-day projection rows")
.isOne();
}

private Video video() {
Video video = Video.builder()
.title("test-video")
.isShort(false)
.build();
ReflectionTestUtils.setField(video, "id", 1L);
return video;
}

private Video video(Long id, LocalDateTime publishedAt) {
Video video = Video.builder()
.title("test-video-" + id)
.publishedAt(publishedAt)
.isShort(false)
.build();
ReflectionTestUtils.setField(video, "id", id);
return video;
}
}
Loading
Loading