From 536d87aba2e1c799023d3358a9d4495ec02f89ee Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Mon, 31 Aug 2026 14:15:27 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=9C=A0=ED=8A=9C=EB=B8=8C=20=EC=B5=9C?= =?UTF-8?q?=EA=B7=BC=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EB=8F=99=EA=B8=B0=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/upload/YoutubeConnection.java | 5 ++ .../upload/YoutubeCredentialCipher.java | 16 +++++ .../domain/upload/YoutubeOAuthController.java | 6 ++ .../domain/upload/YoutubeOAuthService.java | 67 +++++++++++++++++++ .../resources/templates/upload-studio.html | 23 +++++-- 5 files changed, 111 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeConnection.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeConnection.java index 76105ee..a6d15af 100644 --- a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeConnection.java +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeConnection.java @@ -69,4 +69,9 @@ public class YoutubeConnection { this.tokenStatus = "ACTIVE"; this.lastSyncedAt = LocalDateTime.now(); } + + public void markSynced() { + this.lastSyncedAt = LocalDateTime.now(); + this.tokenStatus = "ACTIVE"; + } } diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeCredentialCipher.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeCredentialCipher.java index 01b0c57..864288b 100644 --- a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeCredentialCipher.java +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeCredentialCipher.java @@ -43,6 +43,22 @@ public class YoutubeCredentialCipher { } } + public String decrypt(String encodedPayload) { + try { + byte[] payload = Base64.getDecoder().decode(encodedPayload); + if (payload.length <= IV_LENGTH) throw new IllegalArgumentException("잘못된 암호문입니다."); + byte[] iv = new byte[IV_LENGTH]; + byte[] encrypted = new byte[payload.length - IV_LENGTH]; + System.arraycopy(payload, 0, iv, 0, IV_LENGTH); + System.arraycopy(payload, IV_LENGTH, encrypted, 0, encrypted.length); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, new SecretKeySpec(key(), "AES"), new GCMParameterSpec(TAG_LENGTH, iv)); + return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); + } catch (GeneralSecurityException | IllegalArgumentException e) { + throw new IllegalStateException("YouTube OAuth 토큰을 복호화하지 못했습니다.", e); + } + } + private byte[] key() { if (encodedKey == null || encodedKey.isBlank()) { throw new IllegalStateException("YOUTUBE_OAUTH_ENCRYPTION_KEY가 설정되지 않았습니다."); 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 2ef6c24..e000752 100644 --- a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java @@ -81,6 +81,12 @@ public class YoutubeOAuthController { return ApiResponse.ok(null); } + @PostMapping("/connections/{id}/sync") + @ResponseBody + public ApiResponse sync(@PathVariable Long id) { + return ApiResponse.ok(service.sync(id)); + } + private ResponseEntity redirect(String location) { return ResponseEntity.status(302).location(URI.create(location)).build(); } 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 f44ef9a..481b645 100644 --- a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthService.java +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthService.java @@ -15,6 +15,12 @@ import org.springframework.web.client.RestTemplate; import org.springframework.web.util.UriComponentsBuilder; import java.net.URI; +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.List; @Service @@ -101,6 +107,39 @@ public class YoutubeOAuthService { return repository.count() > 0; } + @Transactional + public SyncDto sync(Long id) { + YoutubeConnection connection = repository.findById(id) + .orElseThrow(() -> new IllegalArgumentException("연결된 YouTube 채널을 찾지 못했습니다.")); + String accessToken = refreshAccessToken(cipher.decrypt(connection.getEncryptedRefreshToken())); + JsonNode channel = loadChannel(accessToken); + String uploadsPlaylistId = channel.path("contentDetails").path("relatedPlaylists").path("uploads").asText(""); + if (uploadsPlaylistId.isBlank()) throw new IllegalStateException("채널의 업로드 재생목록을 찾지 못했습니다."); + + JsonNode items = loadRecentUploads(accessToken, uploadsPlaylistId); + ZoneId zone = ZoneId.of("Asia/Seoul"); + LocalDate today = LocalDate.now(zone); + Map counts = new LinkedHashMap<>(); + for (int i = 6; i >= 0; i--) counts.put(today.minusDays(i), 0); + List recent = new ArrayList<>(); + for (JsonNode item : items) { + String publishedText = item.path("contentDetails").path("videoPublishedAt").asText( + item.path("snippet").path("publishedAt").asText("")); + if (publishedText.isBlank()) continue; + Instant publishedAt = Instant.parse(publishedText); + 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)); + } + } + 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); + } + @Transactional public void disconnect(Long id) { repository.deleteById(id); @@ -119,6 +158,30 @@ public class YoutubeOAuthService { return readJson(body); } + private String refreshAccessToken(String refreshToken) { + MultiValueMap form = new LinkedMultiValueMap<>(); + form.add("client_id", clientId); + form.add("client_secret", clientSecret); + form.add("refresh_token", refreshToken); + form.add("grant_type", "refresh_token"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + JsonNode token = readJson(restTemplate.postForObject(TOKEN_URL, new HttpEntity<>(form, headers), String.class)); + return requiredText(token, "access_token", "YouTube access token을 갱신하지 못했습니다."); + } + + private JsonNode loadRecentUploads(String accessToken, String playlistId) { + URI uri = UriComponentsBuilder.fromUriString("https://www.googleapis.com/youtube/v3/playlistItems") + .queryParam("part", "snippet,contentDetails") + .queryParam("playlistId", playlistId) + .queryParam("maxResults", 50).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(); + return readJson(body).path("items"); + } + private JsonNode loadChannel(String accessToken) { URI uri = UriComponentsBuilder.fromUriString(CHANNELS_URL) .queryParam("part", "snippet,contentDetails,statistics") @@ -158,4 +221,8 @@ public class YoutubeOAuthService { c.getThumbnailUrl(), c.getTokenStatus(), c.getLastSyncedAt()); } } + public record SyncDto(int recentSevenDayCount, List dailyCounts, + List recentUploads) { } + public record DailyCountDto(LocalDate date, int count) { } + public record RecentUploadDto(String videoId, String title, Instant publishedAt) { } } diff --git a/src/main/resources/templates/upload-studio.html b/src/main/resources/templates/upload-studio.html index 31416b7..9cd367a 100644 --- a/src/main/resources/templates/upload-studio.html +++ b/src/main/resources/templates/upload-studio.html @@ -96,15 +96,13 @@
Y
YouTube 채널을 연결하세요
Google OAuth 연결이 필요합니다.
-
-
월
화
수
목
금
토
오늘
-
+
불러오는 중
CapCut 내보내기 폴더
폴더를 연결하면 영상 파일을 바로 비교합니다.
-
4보관함 영상
2업로드 대기
8최근 7일 발행
오늘 18:30다음 예약
+
4보관함 영상
2업로드 대기
—최근 7일 발행
오늘 18:30다음 예약
@@ -203,14 +201,27 @@ document.getElementById('channelMeta').textContent=c?(c.handle||c.channelId)+' · 토큰 '+(c.tokenStatus==='ACTIVE'?'정상':'확인 필요'):'Google OAuth 연결이 필요합니다.'; document.getElementById('channelAvatar').textContent=c?c.title.slice(0,1).toUpperCase():'Y'; } - function syncStudio(){if(!connections.length){connectYoutube();return;}loadConnections().then(()=>showToast('YouTube 채널 정보를 동기화했습니다.'));} + async function syncStudio(silent=false){ + if(!connections.length){if(!silent)connectYoutube();return;} + const id=document.getElementById('channelSelect').value;if(!id)return; + try{ + 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; + renderRhythm(body.data.dailyCounts);await loadConnections();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(''); + } function submitUpload(e){e.preventDefault();if(!selectedId){showToast('먼저 영상을 선택해 주세요.');return;}if(uploadMode==='schedule'&&!document.getElementById('scheduleAt').value){showToast('공개 예정 시간을 선택해 주세요.');return;}const v=videos.find(x=>x.id===selectedId);v.status=uploadMode==='schedule'?'done':'done';v.label=uploadMode==='schedule'?'예약됨':'업로드 중';renderVideos();showToast(uploadMode==='schedule'?'예약 업로드를 등록했습니다.':'업로드를 시작했습니다.');} function showToast(message){const t=document.getElementById('studioToast');t.textContent=message;t.style.display='block';clearTimeout(t.timer);t.timer=setTimeout(()=>t.style.display='none',2400);} const oauthResult=new URLSearchParams(location.search).get('youtube'); if(oauthResult){const messages={connected:'YouTube 채널을 연결했습니다.',denied:'YouTube 권한 요청이 취소되었습니다.','invalid-state':'안전하지 않은 OAuth 응답을 차단했습니다.','missing-code':'Google 인증 코드가 없습니다.',failed:'YouTube 채널 연결에 실패했습니다.'};setTimeout(()=>showToast(messages[oauthResult]||'YouTube 연결을 확인해 주세요.'),100);history.replaceState(null,'',location.pathname);} renderVideos(); loadOAuthStatus(); - loadConnections(); + loadConnections().then(()=>{if(connections.length)syncStudio(true);});