fix: 유튜브 최근 업로드 실제 동기화
This commit is contained in:
parent
6e4b23e849
commit
536d87aba2
@ -69,4 +69,9 @@ public class YoutubeConnection {
|
||||
this.tokenStatus = "ACTIVE";
|
||||
this.lastSyncedAt = LocalDateTime.now();
|
||||
}
|
||||
|
||||
public void markSynced() {
|
||||
this.lastSyncedAt = LocalDateTime.now();
|
||||
this.tokenStatus = "ACTIVE";
|
||||
}
|
||||
}
|
||||
|
||||
@ -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가 설정되지 않았습니다.");
|
||||
|
||||
@ -81,6 +81,12 @@ public class YoutubeOAuthController {
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
@PostMapping("/connections/{id}/sync")
|
||||
@ResponseBody
|
||||
public ApiResponse<YoutubeOAuthService.SyncDto> sync(@PathVariable Long id) {
|
||||
return ApiResponse.ok(service.sync(id));
|
||||
}
|
||||
|
||||
private ResponseEntity<Void> redirect(String location) {
|
||||
return ResponseEntity.status(302).location(URI.create(location)).build();
|
||||
}
|
||||
|
||||
@ -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<LocalDate, Integer> counts = new LinkedHashMap<>();
|
||||
for (int i = 6; i >= 0; i--) counts.put(today.minusDays(i), 0);
|
||||
List<RecentUploadDto> 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<String, String> 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<DailyCountDto> dailyCounts,
|
||||
List<RecentUploadDto> recentUploads) { }
|
||||
public record DailyCountDto(LocalDate date, int count) { }
|
||||
public record RecentUploadDto(String videoId, String title, Instant publishedAt) { }
|
||||
}
|
||||
|
||||
@ -96,15 +96,13 @@
|
||||
<section class="us-top" aria-label="채널과 폴더 현황">
|
||||
<div class="us-channel">
|
||||
<div class="us-channel-line"><div class="us-avatar" id="channelAvatar">Y</div><div><div class="us-channel-name" id="channelName">YouTube 채널을 연결하세요</div><div class="us-muted" id="channelMeta">Google OAuth 연결이 필요합니다.</div></div><select class="us-select" id="channelSelect" aria-label="YouTube 채널" onchange="selectChannel(this.value)"><option value="">연결된 채널 없음</option></select></div>
|
||||
<div class="us-rhythm" aria-label="최근 7일 업로드 8개">
|
||||
<div class="us-day" style="--h:22%"><span>월</span></div><div class="us-day" style="--h:60%"><span>화</span></div><div class="us-day" style="--h:36%"><span>수</span></div><div class="us-day" style="--h:82%"><span>목</span></div><div class="us-day" style="--h:50%"><span>금</span></div><div class="us-day" style="--h:18%"><span>토</span></div><div class="us-day" style="--h:70%"><span>오늘</span></div>
|
||||
</div>
|
||||
<div class="us-rhythm" id="uploadRhythm" aria-label="최근 7일 업로드"><div class="us-day" style="--h:8%"><span>불러오는 중</span></div></div>
|
||||
</div>
|
||||
<div class="us-folder"><div class="us-folder-title"><i data-lucide="folder-check" style="width:17px;color:var(--success)"></i> <span id="folderTitle">CapCut 내보내기 폴더</span></div><div class="us-folder-path" id="folderPath">폴더를 연결하면 영상 파일을 바로 비교합니다.</div><button class="us-button" type="button" onclick="chooseFolder()"><i data-lucide="folder-open"></i> 폴더 선택</button><input id="folderFallback" type="file" accept="video/*" webkitdirectory multiple hidden></div>
|
||||
</section>
|
||||
|
||||
<section class="us-summary" aria-label="업로드 요약">
|
||||
<div class="us-stat"><strong id="totalCount">4</strong><span>보관함 영상</span></div><div class="us-stat"><strong id="waitCount">2</strong><span>업로드 대기</span></div><div class="us-stat"><strong>8</strong><span>최근 7일 발행</span></div><div class="us-stat"><strong>오늘 18:30</strong><span>다음 예약</span></div>
|
||||
<div class="us-stat"><strong id="totalCount">4</strong><span>보관함 영상</span></div><div class="us-stat"><strong id="waitCount">2</strong><span>업로드 대기</span></div><div class="us-stat"><strong id="recentCount">—</strong><span>최근 7일 발행</span></div><div class="us-stat"><strong>오늘 18:30</strong><span>다음 예약</span></div>
|
||||
</section>
|
||||
|
||||
<div class="us-work">
|
||||
@ -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 `<div class="us-day" style="--h:${height}%" title="${d.date} · ${d.count}개"><span>${label}</span></div>`;}).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);});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user