From 6151228248c423ea89e278bea63c1ecf2498716a Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Mon, 31 Aug 2026 16:33:36 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=84=B1?= =?UTF-8?q?=EA=B3=BC=20=EC=9D=BC=EB=B3=84=20=EC=A1=B0=ED=9A=8C=EC=88=98=20?= =?UTF-8?q?=EB=B6=84=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/upload/YoutubeOAuthService.java | 69 ++++++++++++++++--- .../resources/templates/upload-studio.html | 24 ++++--- 2 files changed, 73 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthService.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthService.java index 6e50b9a..dd1bd99 100644 --- a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthService.java +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthService.java @@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; +import org.springframework.web.client.HttpStatusCodeException; import org.springframework.web.util.UriComponentsBuilder; import java.net.URI; @@ -28,7 +29,7 @@ public class YoutubeOAuthService { private static final String AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"; private static final String TOKEN_URL = "https://oauth2.googleapis.com/token"; private static final String CHANNELS_URL = "https://www.googleapis.com/youtube/v3/channels"; - private static final String SCOPE = "https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube.readonly"; + private static final String SCOPE = "https://www.googleapis.com/auth/youtube.upload https://www.googleapis.com/auth/youtube.readonly https://www.googleapis.com/auth/yt-analytics.readonly"; @Qualifier("restTemplate") private final RestTemplate restTemplate; @@ -121,7 +122,9 @@ public class YoutubeOAuthService { LocalDate today = LocalDate.now(zone); Map counts = new LinkedHashMap<>(); for (int i = 6; i >= 0; i--) counts.put(today.minusDays(i), 0); + Map dailyViews = loadDailyViews(accessToken, today.minusDays(6), today); List recent = new ArrayList<>(); + List recentVideoIds = new ArrayList<>(); for (JsonNode item : items) { String publishedText = item.path("contentDetails").path("videoPublishedAt").asText( item.path("snippet").path("publishedAt").asText("")); @@ -130,14 +133,22 @@ public class YoutubeOAuthService { LocalDate date = publishedAt.atZone(zone).toLocalDate(); if (counts.containsKey(date)) { counts.put(date, counts.get(date) + 1); - recent.add(new RecentUploadDto(item.path("contentDetails").path("videoId").asText(""), - item.path("snippet").path("title").asText(""), publishedAt)); + String videoId = item.path("contentDetails").path("videoId").asText(""); + recentVideoIds.add(videoId); + recent.add(new RecentUploadDto(videoId, item.path("snippet").path("title").asText(""), publishedAt, 0)); } } + Map videoViews = loadVideoViews(accessToken, recentVideoIds); + recent = recent.stream().map(video -> new RecentUploadDto(video.videoId(), video.title(), + video.publishedAt(), videoViews.getOrDefault(video.videoId(), 0L))).toList(); connection.markSynced(); - int total = counts.values().stream().mapToInt(Integer::intValue).sum(); - return new SyncDto(total, counts.entrySet().stream() - .map(e -> new DailyCountDto(e.getKey(), e.getValue())).toList(), recent); + int totalUploads = counts.values().stream().mapToInt(Integer::intValue).sum(); + long totalViews = dailyViews.values().stream().mapToLong(Long::longValue).sum(); + long recentVideoViews = recent.stream().mapToLong(RecentUploadDto::viewCount).sum(); + return new SyncDto(totalUploads, totalViews, recentVideoViews, + totalUploads == 0 ? 0 : Math.round((double) totalViews / totalUploads), + counts.entrySet().stream().map(e -> new DailyCountDto(e.getKey(), e.getValue(), + dailyViews.getOrDefault(e.getKey(), 0L))).toList(), recent); } @Transactional @@ -188,6 +199,45 @@ public class YoutubeOAuthService { return readJson(body).path("items"); } + private Map loadDailyViews(String accessToken, LocalDate startDate, LocalDate endDate) { + URI uri = UriComponentsBuilder.fromUriString("https://youtubeanalytics.googleapis.com/v2/reports") + .queryParam("ids", "channel==MINE").queryParam("startDate", startDate) + .queryParam("endDate", endDate).queryParam("metrics", "views") + .queryParam("dimensions", "day").queryParam("sort", "day").build().encode().toUri(); + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + String body; + try { + body = restTemplate.exchange(uri, org.springframework.http.HttpMethod.GET, + new HttpEntity<>(headers), String.class).getBody(); + } catch (HttpStatusCodeException e) { + if (e.getStatusCode().value() == 401 || e.getStatusCode().value() == 403) { + throw new IllegalStateException("일별 조회수 분석 권한이 없습니다. 채널 연결을 해제한 뒤 다시 연결해 주세요.", e); + } + throw e; + } + Map views = new LinkedHashMap<>(); + for (JsonNode row : readJson(body).path("rows")) { + if (row.isArray() && row.size() >= 2) views.put(LocalDate.parse(row.get(0).asText()), row.get(1).asLong()); + } + return views; + } + + private Map loadVideoViews(String accessToken, List videoIds) { + if (videoIds.isEmpty()) return Map.of(); + URI uri = UriComponentsBuilder.fromUriString("https://www.googleapis.com/youtube/v3/videos") + .queryParam("part", "statistics").queryParam("id", String.join(",", videoIds)).build().encode().toUri(); + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + String body = restTemplate.exchange(uri, org.springframework.http.HttpMethod.GET, + new HttpEntity<>(headers), String.class).getBody(); + Map views = new LinkedHashMap<>(); + for (JsonNode video : readJson(body).path("items")) { + views.put(video.path("id").asText(), video.path("statistics").path("viewCount").asLong()); + } + return views; + } + private JsonNode loadChannel(String accessToken) { URI uri = UriComponentsBuilder.fromUriString(CHANNELS_URL) .queryParam("part", "snippet,contentDetails,statistics") @@ -227,8 +277,9 @@ public class YoutubeOAuthService { c.getThumbnailUrl(), c.getTokenStatus(), c.getLastSyncedAt()); } } - public record SyncDto(int recentSevenDayCount, List dailyCounts, + public record SyncDto(int recentSevenDayCount, long sevenDayViews, long recentVideoViews, + long viewsPerUpload, List dailyCounts, List recentUploads) { } - public record DailyCountDto(LocalDate date, int count) { } - public record RecentUploadDto(String videoId, String title, Instant publishedAt) { } + public record DailyCountDto(LocalDate date, int count, long views) { } + public record RecentUploadDto(String videoId, String title, Instant publishedAt, long viewCount) { } } diff --git a/src/main/resources/templates/upload-studio.html b/src/main/resources/templates/upload-studio.html index 2412294..f3f54bd 100644 --- a/src/main/resources/templates/upload-studio.html +++ b/src/main/resources/templates/upload-studio.html @@ -20,9 +20,11 @@ .us-channel-name { font-weight:750; font-size:1.05rem; } .us-muted { color:var(--text-2); font-size:.82rem; } .us-select { margin-left:auto; border:1px solid var(--border); background:var(--surface-2); color:var(--text); padding:.55rem 2rem .55rem .7rem; border-radius:var(--r-sm); } - .us-rhythm { display:grid; grid-template-columns:repeat(7,1fr); gap:5px; margin-top:1rem; height:58px; align-items:end; } - .us-day { height:var(--h); min-height:7px; background:var(--studio-soft); border-top:2px solid var(--studio); position:relative; } - .us-day span { position:absolute; top:calc(100% + 6px); left:50%; transform:translateX(-50%); font:600 .58rem/1 var(--font-mono); color:var(--text-3); } + .us-rhythm { display:grid; grid-template-columns:repeat(7,1fr); gap:7px; margin-top:1rem; height:92px; align-items:end; } + .us-day { height:var(--h); min-height:9px; background:linear-gradient(180deg,rgba(109,93,252,.28),var(--studio-soft)); border-top:2px solid var(--studio); position:relative; } + .us-day-label { position:absolute; top:calc(100% + 6px); left:50%; transform:translateX(-50%); font:600 .58rem/1 var(--font-mono); color:var(--text-3); } + .us-day-views { position:absolute; left:5px; top:5px; font:750 .62rem/1 var(--font-mono); color:var(--text); white-space:nowrap; } + .us-day-count { position:absolute; right:4px; top:-20px; color:var(--studio); font:700 .58rem/1 var(--font-mono); white-space:nowrap; } .us-folder { border-left:1px solid var(--border); padding:1.15rem 1.25rem; display:flex; flex-direction:column; justify-content:center; } .us-folder-title { display:flex; align-items:center; gap:.45rem; font-weight:700; } .us-folder-path { color:var(--text-2); font:500 .7rem/1.5 var(--font-mono); margin:.55rem 0 .8rem; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; } @@ -107,7 +109,7 @@
-
0영상 및 작업
0업로드 대기
—최근 7일 발행
—다음 예약
+
—7일 조회수 합계
—7일 업로드
—업로드 1개당 조회수
—최근 영상 누적 조회수
@@ -155,15 +157,11 @@ const q=document.getElementById('videoSearch').value.toLowerCase(); const list=videos.filter(v=>(filter==='all'||v.status===filter)&&v.name.toLowerCase().includes(q)); document.getElementById('videoList').innerHTML=list.length?list.map(v=>``).join(''):'
폴더를 선택하거나 YouTube 동기화를 실행해 주세요.
'; - document.getElementById('totalCount').textContent=videos.length; - document.getElementById('waitCount').textContent=videos.filter(v=>v.status==='wait').length; } function rebuildVideos(){ const jobVideoIds=new Set(uploadJobs.map(v=>v.youtubeVideoId).filter(Boolean)); videos=[...localVideos,...uploadJobs,...recentUploads.filter(v=>!jobVideoIds.has(v.youtubeVideoId))]; if(selectedId&&!videos.some(v=>v.id===selectedId))selectedId=null; - const schedules=uploadJobs.filter(v=>v.publishAt&&v.status==='scheduled').sort((a,b)=>a.publishAt.localeCompare(b.publishAt)); - document.getElementById('nextSchedule').textContent=schedules.length?new Date(schedules[0].publishAt).toLocaleString('ko-KR',{month:'numeric',day:'numeric',hour:'2-digit',minute:'2-digit'}):'—'; renderVideos(); } function selectVideo(id){ @@ -251,16 +249,20 @@ const response=await fetch('/api/youtube/connections/'+id+'/sync',{method:'POST'});const body=await response.json(); if(!response.ok||!body.success)throw new Error(body.message||'동기화 실패'); document.getElementById('recentCount').textContent=body.data.recentSevenDayCount; + document.getElementById('sevenDayViews').textContent=compactNumber(body.data.sevenDayViews); + document.getElementById('viewsPerUpload').textContent=compactNumber(body.data.viewsPerUpload); + document.getElementById('recentVideoViews').textContent=compactNumber(body.data.recentVideoViews); recentUploads=(body.data.recentUploads||[]).map(v=>({id:'youtube-'+v.videoId,name:v.title,title:v.title, - meta:'YouTube 게시 영상 · '+v.videoId,status:'done',label:'게시됨',youtubeVideoId:v.videoId, + meta:'조회수 '+Number(v.viewCount||0).toLocaleString('ko-KR')+' · '+v.videoId,status:'done',label:'게시됨',youtubeVideoId:v.videoId, time:new Date(v.publishedAt).toLocaleDateString('ko-KR')})); renderRhythm(body.data.dailyCounts);await loadConnections();rebuildVideos();if(!silent)showToast('최근 7일 업로드 '+body.data.recentSevenDayCount+'개를 동기화했습니다.'); }catch(e){showToast(e.message||'YouTube 동기화에 실패했습니다.');} } function renderRhythm(days){ - const max=Math.max(1,...days.map(d=>d.count));const labels=['일','월','화','수','목','금','토']; - document.getElementById('uploadRhythm').innerHTML=days.map((d,i)=>{const date=new Date(d.date+'T00:00:00');const label=i===days.length-1?'오늘':labels[date.getDay()];const height=Math.max(8,Math.round(d.count/max*88));return `
${label}
`;}).join(''); + const max=Math.max(1,...days.map(d=>d.views));const labels=['일','월','화','수','목','금','토']; + document.getElementById('uploadRhythm').innerHTML=days.map((d,i)=>{const date=new Date(d.date+'T00:00:00');const label=i===days.length-1?'오늘':labels[date.getDay()];const height=Math.max(10,Math.round(Math.sqrt(d.views/max)*88));return `
${compactNumber(d.views)}↑ ${d.count}${label}
`;}).join(''); } + function compactNumber(value){return new Intl.NumberFormat('ko-KR',{notation:'compact',maximumFractionDigits:1}).format(Number(value||0));} async function submitUpload(e){ e.preventDefault(); const connectionId=document.getElementById('channelSelect').value;