From 118ca72281b3a1cba7f3098a9cd5ea9741d7f867 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Tue, 8 Sep 2026 08:17:40 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=82=B4=20=EC=B1=84=EB=84=90=20?= =?UTF-8?q?=EA=B8=B0=EA=B0=84=EB=B3=84=20=EB=B6=84=EC=84=9D=20=EB=A6=AC?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/channel/MyChannelController.java | 22 + .../YoutubeChannelAnalyticsService.java | 279 +++++++ .../dto/YoutubeChannelAnalyticsDto.java | 39 + .../domain/upload/YoutubeOAuthController.java | 26 +- src/main/resources/templates/my_channel.html | 734 +++++++++--------- .../YoutubeChannelAnalyticsServiceTest.java | 77 ++ 6 files changed, 796 insertions(+), 381 deletions(-) create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/YoutubeChannelAnalyticsService.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/channel/dto/YoutubeChannelAnalyticsDto.java create mode 100644 src/test/java/com/hlab/yanalyst/domain/channel/YoutubeChannelAnalyticsServiceTest.java diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/MyChannelController.java b/src/main/java/com/hlab/yanalyst/domain/channel/MyChannelController.java index 3c08667..645176c 100644 --- a/src/main/java/com/hlab/yanalyst/domain/channel/MyChannelController.java +++ b/src/main/java/com/hlab/yanalyst/domain/channel/MyChannelController.java @@ -1,6 +1,7 @@ package com.hlab.yanalyst.domain.channel; import com.hlab.yanalyst.domain.channel.dto.MyChannelSummaryDto; +import com.hlab.yanalyst.domain.channel.dto.YoutubeChannelAnalyticsDto; import com.hlab.yanalyst.global.common.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -9,6 +10,7 @@ import org.springframework.web.bind.annotation.*; import java.util.List; import java.util.Map; +import java.time.LocalDate; /** 내 채널(OWN) 분석 API. 소재 파이프라인과 완전히 분리된 성과 추적용. */ @RestController @@ -18,6 +20,7 @@ import java.util.Map; public class MyChannelController { private final MyChannelService myChannelService; + private final YoutubeChannelAnalyticsService youtubeChannelAnalyticsService; @GetMapping @Operation(summary = "내 채널 요약", description = "등록 안 돼 있으면 registered=false") @@ -43,6 +46,25 @@ public class MyChannelController { return ApiResponse.ok(myChannelService.growth()); } + @GetMapping("/analytics") + @Operation(summary = "OAuth 내 채널 기간 분석", description = "연결한 채널의 일별 추이, 영상별 성과, 업로드 리듬을 반환한다.") + public ApiResponse analytics( + @RequestParam Long connectionId, + @RequestParam LocalDate startDate, + @RequestParam LocalDate endDate) { + return ApiResponse.ok(youtubeChannelAnalyticsService.analyze(connectionId, startDate, endDate)); + } + + @GetMapping("/analytics/videos/{videoId}/timeline") + @Operation(summary = "내 영상 일별 추이", description = "선택 영상의 기간 내 일별 조회수와 구독자 증감 추이를 반환한다.") + public ApiResponse> videoTimeline( + @RequestParam Long connectionId, + @PathVariable String videoId, + @RequestParam LocalDate startDate, + @RequestParam LocalDate endDate) { + return ApiResponse.ok(youtubeChannelAnalyticsService.videoTimeline(connectionId, videoId, startDate, endDate)); + } + @PostMapping @Operation(summary = "내 채널 등록", description = "채널 URL 로 등록하고 즉시 영상을 수집한다. 이미 있으면 교체.") public ApiResponse register(@RequestBody Map body) { diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/YoutubeChannelAnalyticsService.java b/src/main/java/com/hlab/yanalyst/domain/channel/YoutubeChannelAnalyticsService.java new file mode 100644 index 0000000..0715458 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/YoutubeChannelAnalyticsService.java @@ -0,0 +1,279 @@ +package com.hlab.yanalyst.domain.channel; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hlab.yanalyst.domain.channel.dto.YoutubeChannelAnalyticsDto; +import com.hlab.yanalyst.domain.upload.YoutubeConnection; +import com.hlab.yanalyst.domain.upload.YoutubeConnectionRepository; +import com.hlab.yanalyst.domain.upload.YoutubeOAuthService; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** YouTube Analytics API와 Data API를 조합해 내 채널 분석용 보고서를 만든다. */ +@Service +public class YoutubeChannelAnalyticsService { + private static final String ANALYTICS_URL = "https://youtubeanalytics.googleapis.com/v2/reports"; + private static final String CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels"; + private static final String PLAYLIST_ITEMS_URL = "https://www.googleapis.com/youtube/v3/playlistItems"; + private static final String VIDEOS_URL = "https://www.googleapis.com/youtube/v3/videos"; + private static final String METRICS = "views,estimatedMinutesWatched,averageViewDuration,subscribersGained,subscribersLost,likes,comments"; + private static final ZoneId KOREA = ZoneId.of("Asia/Seoul"); + private static final int MAX_RANGE_DAYS = 730; + private static final int MAX_UPLOADS = 500; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final YoutubeOAuthService oauthService; + private final YoutubeConnectionRepository connectionRepository; + + public YoutubeChannelAnalyticsService(@Qualifier("restTemplate") RestTemplate restTemplate, + ObjectMapper objectMapper, + YoutubeOAuthService oauthService, + YoutubeConnectionRepository connectionRepository) { + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + this.oauthService = oauthService; + this.connectionRepository = connectionRepository; + } + + public YoutubeChannelAnalyticsDto analyze(Long connectionId, LocalDate startDate, LocalDate endDate) { + validatePeriod(startDate, endDate); + YoutubeConnection connection = connectionRepository.findById(connectionId) + .orElseThrow(() -> new IllegalArgumentException("연결된 YouTube 채널을 찾을 수 없습니다.")); + String accessToken = oauthService.accessToken(connectionId); + + List daily = queryDaily(accessToken, startDate, endDate); + List videoMetrics = queryVideoMetrics(accessToken, startDate, endDate); + Map metadata = loadVideoMetadata(accessToken, + videoMetrics.stream().map(RawVideoMetric::videoId).toList()); + List videos = videoMetrics.stream() + .map(metric -> toVideoDto(metric, metadata.get(metric.videoId()))) + .toList(); + + List uploads = loadUploads(accessToken, connection.getChannelId()).stream() + .filter(upload -> !upload.publishedAt().atZone(KOREA).toLocalDate().isBefore(startDate)) + .filter(upload -> !upload.publishedAt().atZone(KOREA).toLocalDate().isAfter(endDate)) + .toList(); + + long views = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::views).sum(); + long watchMinutes = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::watchMinutes).sum(); + long subscribersGained = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::subscribersGained).sum(); + long subscribersLost = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::subscribersLost).sum(); + long likes = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::likes).sum(); + long comments = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::comments).sum(); + double averageViewDuration = weightedAverageDuration(daily); + + return new YoutubeChannelAnalyticsDto( + new YoutubeChannelAnalyticsDto.ChannelDto(connection.getId(), connection.getChannelId(), + connection.getChannelTitle(), connection.getChannelHandle(), connection.getThumbnailUrl(), + connection.getLastSyncedAt() == null ? null : connection.getLastSyncedAt().atZone(KOREA).toInstant()), + new YoutubeChannelAnalyticsDto.PeriodDto(startDate, endDate, + Duration.between(startDate.atStartOfDay(KOREA), endDate.plusDays(1).atStartOfDay(KOREA)).toDays()), + new YoutubeChannelAnalyticsDto.SummaryDto(views, watchMinutes, averageViewDuration, + subscribersGained, subscribersLost, subscribersGained - subscribersLost, likes, comments, + uploads.size(), uploads.isEmpty() ? 0 : Math.round((double) views / uploads.size())), + daily, + videos, + uploadRhythm(uploads) + ); + } + + public List videoTimeline(Long connectionId, String videoId, + LocalDate startDate, LocalDate endDate) { + if (videoId == null || videoId.isBlank()) throw new IllegalArgumentException("영상 ID가 필요합니다."); + validatePeriod(startDate, endDate); + String accessToken = oauthService.accessToken(connectionId); + return queryDaily(accessToken, startDate, endDate, "video==" + videoId); + } + + private List queryDaily(String accessToken, LocalDate startDate, + LocalDate endDate) { + return queryDaily(accessToken, startDate, endDate, null); + } + + private List queryDaily(String accessToken, LocalDate startDate, + LocalDate endDate, String filters) { + JsonNode report = queryReport(accessToken, startDate, endDate, "day", METRICS, filters, "day", null); + List result = new ArrayList<>(); + for (JsonNode row : report.path("rows")) { + if (!row.isArray() || row.size() < 8) continue; + result.add(new YoutubeChannelAnalyticsDto.DailyDto( + LocalDate.parse(row.path(0).asText()), row.path(1).asLong(), row.path(2).asLong(), + row.path(3).asDouble(), row.path(4).asLong(), row.path(5).asLong(), + row.path(6).asLong(), row.path(7).asLong())); + } + return result; + } + + private List queryVideoMetrics(String accessToken, LocalDate startDate, LocalDate endDate) { + JsonNode report = queryReport(accessToken, startDate, endDate, "video", METRICS, null, "-views", 50); + List result = new ArrayList<>(); + for (JsonNode row : report.path("rows")) { + if (!row.isArray() || row.size() < 8) continue; + result.add(new RawVideoMetric(row.path(0).asText(), row.path(1).asLong(), row.path(2).asLong(), + row.path(3).asDouble(), row.path(4).asLong(), row.path(5).asLong(), row.path(6).asLong(), + row.path(7).asLong())); + } + return result; + } + + private JsonNode queryReport(String accessToken, LocalDate startDate, LocalDate endDate, + String dimensions, String metrics, String filters, String sort, Integer maxResults) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(ANALYTICS_URL) + .queryParam("ids", "channel==MINE") + .queryParam("startDate", startDate) + .queryParam("endDate", endDate) + .queryParam("dimensions", dimensions) + .queryParam("metrics", metrics) + .queryParam("sort", sort); + if (filters != null) builder.queryParam("filters", filters); + if (maxResults != null) builder.queryParam("maxResults", maxResults); + return getJson(builder.build().encode().toUri(), accessToken); + } + + private Map loadVideoMetadata(String accessToken, List ids) { + Map result = new HashMap<>(); + for (int index = 0; index < ids.size(); index += 50) { + List batch = ids.subList(index, Math.min(index + 50, ids.size())); + if (batch.isEmpty()) continue; + URI uri = UriComponentsBuilder.fromUriString(VIDEOS_URL) + .queryParam("part", "snippet,contentDetails") + .queryParam("id", String.join(",", batch)).build().encode().toUri(); + for (JsonNode item : getJson(uri, accessToken).path("items")) { + JsonNode snippet = item.path("snippet"); + String publishedAt = snippet.path("publishedAt").asText(""); + result.put(item.path("id").asText(), new VideoMetadata( + snippet.path("title").asText("제목을 불러올 수 없는 영상"), + thumbnailUrl(snippet), publishedAt.isBlank() ? null : Instant.parse(publishedAt))); + } + } + return result; + } + + private List loadUploads(String accessToken, String channelId) { + URI channelUri = UriComponentsBuilder.fromUriString(CHANNELS_URL) + .queryParam("part", "contentDetails").queryParam("id", channelId).build().encode().toUri(); + String playlistId = getJson(channelUri, accessToken).path("items").path(0) + .path("contentDetails").path("relatedPlaylists").path("uploads").asText(""); + if (playlistId.isBlank()) return List.of(); + + List uploads = new ArrayList<>(); + String pageToken = null; + while (uploads.size() < MAX_UPLOADS) { + UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(PLAYLIST_ITEMS_URL) + .queryParam("part", "contentDetails").queryParam("playlistId", playlistId).queryParam("maxResults", 50); + if (pageToken != null) builder.queryParam("pageToken", pageToken); + JsonNode page = getJson(builder.build().encode().toUri(), accessToken); + for (JsonNode item : page.path("items")) { + String publishedAt = item.path("contentDetails").path("videoPublishedAt").asText(""); + if (!publishedAt.isBlank()) uploads.add(new Upload(Instant.parse(publishedAt))); + if (uploads.size() >= MAX_UPLOADS) break; + } + pageToken = page.path("nextPageToken").asText(""); + if (pageToken.isBlank()) break; + } + return uploads; + } + + private YoutubeChannelAnalyticsDto.UploadRhythmDto uploadRhythm(List uploads) { + long[] weekdays = new long[7]; + long[] hours = new long[24]; + List published = uploads.stream().map(Upload::publishedAt).sorted().toList(); + for (Instant instant : published) { + var dateTime = instant.atZone(KOREA); + weekdays[dateTime.getDayOfWeek().getValue() - 1]++; + hours[dateTime.getHour()]++; + } + List weekdayCounts = new ArrayList<>(); + List hourCounts = new ArrayList<>(); + for (int i = 0; i < weekdays.length; i++) weekdayCounts.add(new YoutubeChannelAnalyticsDto.BucketDto(i + 1, weekdays[i])); + for (int i = 0; i < hours.length; i++) hourCounts.add(new YoutubeChannelAnalyticsDto.BucketDto(i, hours[i])); + return new YoutubeChannelAnalyticsDto.UploadRhythmDto(uploads.size(), averageGap(published), + mostActiveIndex(weekdays) + 1, mostActiveIndex(hours), weekdayCounts, hourCounts); + } + + private double averageGap(List published) { + if (published.size() < 2) return 0; + long minutes = 0; + for (int i = 1; i < published.size(); i++) minutes += Duration.between(published.get(i - 1), published.get(i)).toMinutes(); + return Math.round((minutes / 1440.0 / (published.size() - 1)) * 10) / 10.0; + } + + private int mostActiveIndex(long[] counts) { + int best = 0; + for (int i = 1; i < counts.length; i++) if (counts[i] > counts[best]) best = i; + return best; + } + + private YoutubeChannelAnalyticsDto.VideoDto toVideoDto(RawVideoMetric metric, VideoMetadata metadata) { + return new YoutubeChannelAnalyticsDto.VideoDto(metric.videoId(), + metadata == null ? "제목을 불러올 수 없는 영상" : metadata.title(), + metadata == null ? "" : metadata.thumbnailUrl(), metadata == null ? null : metadata.publishedAt(), + metric.views(), metric.watchMinutes(), metric.averageViewDurationSeconds(), metric.subscribersGained(), + metric.subscribersLost(), metric.subscribersGained() - metric.subscribersLost(), metric.likes(), metric.comments()); + } + + private double weightedAverageDuration(List daily) { + long totalViews = daily.stream().mapToLong(YoutubeChannelAnalyticsDto.DailyDto::views).sum(); + if (totalViews == 0) return 0; + double totalDuration = daily.stream().mapToDouble(day -> day.averageViewDurationSeconds() * day.views()).sum(); + return Math.round(totalDuration / totalViews * 10) / 10.0; + } + + private JsonNode getJson(URI uri, String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + String body; + try { + body = restTemplate.exchange(uri, HttpMethod.GET, new HttpEntity<>(headers), String.class).getBody(); + } catch (HttpStatusCodeException e) { + if (e.getStatusCode().value() == 401 || e.getStatusCode().value() == 403) { + throw new IllegalStateException("YouTube 분석 권한이 만료되었거나 부족합니다. 채널을 다시 연결해 주세요.", e); + } + throw new IllegalStateException("YouTube 분석 데이터를 불러오지 못했습니다.", e); + } + try { + return objectMapper.readTree(body); + } catch (Exception e) { + throw new IllegalStateException("YouTube 응답을 해석하지 못했습니다.", e); + } + } + + private String thumbnailUrl(JsonNode snippet) { + JsonNode thumbnails = snippet.path("thumbnails"); + String high = thumbnails.path("high").path("url").asText(""); + return high.isBlank() ? thumbnails.path("medium").path("url").asText("") : high; + } + + private void validatePeriod(LocalDate startDate, LocalDate endDate) { + if (startDate == null || endDate == null) throw new IllegalArgumentException("시작일과 종료일을 모두 선택하세요."); + if (startDate.isAfter(endDate)) throw new IllegalArgumentException("시작일은 종료일보다 앞서야 합니다."); + if (endDate.isAfter(LocalDate.now(KOREA))) throw new IllegalArgumentException("종료일은 오늘 이후로 선택할 수 없습니다."); + if (startDate.plusDays(MAX_RANGE_DAYS - 1L).isBefore(endDate)) { + throw new IllegalArgumentException("분석 기간은 최대 " + MAX_RANGE_DAYS + "일입니다."); + } + } + + private record RawVideoMetric(String videoId, long views, long watchMinutes, double averageViewDurationSeconds, + long subscribersGained, long subscribersLost, long likes, long comments) { } + + private record VideoMetadata(String title, String thumbnailUrl, Instant publishedAt) { } + + private record Upload(Instant publishedAt) { } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/channel/dto/YoutubeChannelAnalyticsDto.java b/src/main/java/com/hlab/yanalyst/domain/channel/dto/YoutubeChannelAnalyticsDto.java new file mode 100644 index 0000000..ba4d79d --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/channel/dto/YoutubeChannelAnalyticsDto.java @@ -0,0 +1,39 @@ +package com.hlab.yanalyst.domain.channel.dto; + +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +/** OAuth로 연결된 내 YouTube 채널의 기간별 분석 응답. */ +public record YoutubeChannelAnalyticsDto( + ChannelDto channel, + PeriodDto period, + SummaryDto summary, + List daily, + List videos, + UploadRhythmDto uploadRhythm +) { + public record ChannelDto(Long connectionId, String channelId, String title, String handle, + String thumbnailUrl, Instant lastSyncedAt) { } + + public record PeriodDto(LocalDate startDate, LocalDate endDate, long days) { } + + public record SummaryDto(long views, long watchMinutes, double averageViewDurationSeconds, + long subscribersGained, long subscribersLost, long netSubscribers, + long likes, long comments, long uploads, double viewsPerUpload) { } + + public record DailyDto(LocalDate date, long views, long watchMinutes, + double averageViewDurationSeconds, long subscribersGained, + long subscribersLost, long likes, long comments) { } + + public record VideoDto(String videoId, String title, String thumbnailUrl, Instant publishedAt, + long views, long watchMinutes, double averageViewDurationSeconds, + long subscribersGained, long subscribersLost, long netSubscribers, + long likes, long comments) { } + + public record UploadRhythmDto(long uploads, double averageDaysBetweenUploads, + int mostActiveWeekday, int mostActiveHour, + List weekdayCounts, List hourCounts) { } + + public record BucketDto(int value, long count) { } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java index c715a8a..f29d055 100644 --- a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java @@ -26,14 +26,16 @@ import java.util.UUID; @RequiredArgsConstructor public class YoutubeOAuthController { private static final String STATE_SESSION_KEY = "youtube.oauth.state"; + private static final String RETURN_TO_SESSION_KEY = "youtube.oauth.return-to"; private final YoutubeOAuthService service; private final YoutubeOAuthLocalSettings localSettings; private final YoutubeUploadService uploadService; @GetMapping("/oauth/start") - public ResponseEntity start(HttpSession session) { + public ResponseEntity start(@RequestParam(required = false) String returnTo, HttpSession session) { String state = UUID.randomUUID().toString(); session.setAttribute(STATE_SESSION_KEY, state); + session.setAttribute(RETURN_TO_SESSION_KEY, safeReturnTo(returnTo)); return ResponseEntity.status(302).location(service.authorizationUri(state)).build(); } @@ -43,15 +45,18 @@ public class YoutubeOAuthController { @RequestParam(required = false) String error, HttpSession session) { String expected = (String) session.getAttribute(STATE_SESSION_KEY); + String returnTo = (String) session.getAttribute(RETURN_TO_SESSION_KEY); session.removeAttribute(STATE_SESSION_KEY); - if (error != null) return redirect("/upload-studio?youtube=denied"); - if (expected == null || !expected.equals(state)) return redirect("/upload-studio?youtube=invalid-state"); - if (code == null || code.isBlank()) return redirect("/upload-studio?youtube=missing-code"); + session.removeAttribute(RETURN_TO_SESSION_KEY); + String target = returnTo == null ? "/upload-studio" : returnTo; + if (error != null) return redirect(target + "?youtube=denied"); + if (expected == null || !expected.equals(state)) return redirect(target + "?youtube=invalid-state"); + if (code == null || code.isBlank()) return redirect(target + "?youtube=missing-code"); try { service.connect(code); - return redirect("/upload-studio?youtube=connected"); + return redirect(target + "?youtube=connected"); } catch (RuntimeException e) { - return redirect("/upload-studio?youtube=failed"); + return redirect(target + "?youtube=failed"); } } @@ -112,6 +117,15 @@ public class YoutubeOAuthController { return ResponseEntity.status(302).location(URI.create(location)).build(); } + private String safeReturnTo(String returnTo) { + if (returnTo == null || returnTo.isBlank()) return "/upload-studio"; + if (!returnTo.startsWith("/") || returnTo.startsWith("//") || returnTo.contains("?") || returnTo.contains("#") + || returnTo.contains("\r") || returnTo.contains("\n")) { + throw new IllegalArgumentException("허용되지 않은 OAuth 이동 경로입니다."); + } + return returnTo; + } + public record SettingsRequest(String clientId, String clientSecret, String redirectUri) { } public record SettingsStatus(boolean configured, String redirectUri) { } } diff --git a/src/main/resources/templates/my_channel.html b/src/main/resources/templates/my_channel.html index 1bcaee9..daa22ae 100644 --- a/src/main/resources/templates/my_channel.html +++ b/src/main/resources/templates/my_channel.html @@ -1,384 +1,368 @@ - + layout:decorate="~{layout/base}"> - h-lab - 내 채널 + h-lab - 내 채널 분석 - -
-