From 17b8a0c74db94eabf79ba5834214f942016cf7b7 Mon Sep 17 00:00:00 2001 From: "hehihoho3@gmail.com" Date: Mon, 31 Aug 2026 15:55:43 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=9C=A0=ED=8A=9C=EB=B8=8C=20=EC=8B=A4?= =?UTF-8?q?=EC=A0=9C=20=EC=97=85=EB=A1=9C=EB=93=9C=EC=99=80=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=20=EA=B3=B5=EA=B0=9C=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/upload/YoutubeOAuthController.java | 21 ++++ .../domain/upload/YoutubeOAuthService.java | 6 + .../upload/YoutubeResumableUploadClient.java | 68 +++++++++++ .../domain/upload/YoutubeUploadJob.java | 59 ++++++++++ .../upload/YoutubeUploadJobRepository.java | 8 ++ .../domain/upload/YoutubeUploadService.java | 67 +++++++++++ .../global/error/GlobalExceptionHandler.java | 7 ++ src/main/resources/application.yml | 4 +- .../resources/templates/upload-studio.html | 106 ++++++++++++++---- .../upload/YoutubeUploadServiceTest.java | 51 +++++++++ 10 files changed, 375 insertions(+), 22 deletions(-) create mode 100644 src/main/java/com/hlab/yanalyst/domain/upload/YoutubeResumableUploadClient.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/upload/YoutubeUploadJob.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/upload/YoutubeUploadJobRepository.java create mode 100644 src/main/java/com/hlab/yanalyst/domain/upload/YoutubeUploadService.java create mode 100644 src/test/java/com/hlab/yanalyst/domain/upload/YoutubeUploadServiceTest.java 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 e000752..c715a8a 100644 --- a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthController.java @@ -13,8 +13,11 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.multipart.MultipartFile; import java.net.URI; +import java.time.Instant; import java.util.List; import java.util.UUID; @@ -25,6 +28,7 @@ public class YoutubeOAuthController { private static final String STATE_SESSION_KEY = "youtube.oauth.state"; private final YoutubeOAuthService service; private final YoutubeOAuthLocalSettings localSettings; + private final YoutubeUploadService uploadService; @GetMapping("/oauth/start") public ResponseEntity start(HttpSession session) { @@ -87,6 +91,23 @@ public class YoutubeOAuthController { return ApiResponse.ok(service.sync(id)); } + @GetMapping("/connections/{id}/uploads") + @ResponseBody + public ApiResponse> uploads(@PathVariable Long id) { + return ApiResponse.ok(uploadService.jobs(id)); + } + + @PostMapping(value = "/connections/{id}/uploads", consumes = "multipart/form-data") + @ResponseBody + public ApiResponse upload(@PathVariable Long id, + @RequestPart("file") MultipartFile file, + @RequestParam String title, + @RequestParam(defaultValue = "") String description, + @RequestParam(defaultValue = "public") String privacyStatus, + @RequestParam(required = false) Instant publishAt) { + return ApiResponse.created(uploadService.upload(id, file, title, description, privacyStatus, publishAt)); + } + 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 481b645..6e50b9a 100644 --- a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthService.java +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeOAuthService.java @@ -145,6 +145,12 @@ public class YoutubeOAuthService { repository.deleteById(id); } + public String accessToken(Long connectionId) { + YoutubeConnection connection = repository.findById(connectionId) + .orElseThrow(() -> new IllegalArgumentException("연결된 YouTube 채널을 찾지 못했습니다.")); + return refreshAccessToken(cipher.decrypt(connection.getEncryptedRefreshToken())); + } + private JsonNode exchangeCode(String code) { MultiValueMap form = new LinkedMultiValueMap<>(); form.add("code", code); diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeResumableUploadClient.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeResumableUploadClient.java new file mode 100644 index 0000000..a6bf2b6 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeResumableUploadClient.java @@ -0,0 +1,68 @@ +package com.hlab.yanalyst.domain.upload; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.*; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.file.Path; +import java.time.Duration; +import java.time.Instant; + +@Component +public class YoutubeResumableUploadClient { + private static final String UPLOAD_URL = "https://www.googleapis.com/upload/youtube/v3/videos"; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final HttpClient httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(30)).build(); + + public YoutubeResumableUploadClient(@Qualifier("restTemplate") RestTemplate restTemplate, ObjectMapper objectMapper) { + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + } + + public String upload(String accessToken, Path file, long size, String contentType, String title, + String description, String privacyStatus, Instant publishAt) throws Exception { + ObjectNode metadata = objectMapper.createObjectNode(); + metadata.putObject("snippet").put("title", title).put("description", description).put("categoryId", "22"); + ObjectNode status = metadata.putObject("status"); + status.put("privacyStatus", publishAt == null ? privacyStatus : "private"); + status.put("selfDeclaredMadeForKids", false); + if (publishAt != null) status.put("publishAt", publishAt.toString()); + + URI initiateUri = UriComponentsBuilder.fromUriString(UPLOAD_URL) + .queryParam("uploadType", "resumable").queryParam("part", "snippet,status").build().toUri(); + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.set("X-Upload-Content-Type", contentType); + headers.set("X-Upload-Content-Length", Long.toString(size)); + ResponseEntity initiated = restTemplate.exchange(initiateUri, HttpMethod.POST, + new HttpEntity<>(objectMapper.writeValueAsString(metadata), headers), String.class); + URI sessionUri = initiated.getHeaders().getLocation(); + if (sessionUri == null) throw new IllegalStateException("YouTube 업로드 세션 주소를 받지 못했습니다."); + + HttpRequest request = HttpRequest.newBuilder(sessionUri).timeout(Duration.ofHours(2)) + .header("Authorization", "Bearer " + accessToken) + .header("Content-Type", contentType) + .header("Content-Length", Long.toString(size)) + .PUT(HttpRequest.BodyPublishers.ofFile(file)).build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (response.statusCode() < 200 || response.statusCode() >= 300) { + throw new IllegalStateException("YouTube 업로드 실패 (HTTP " + response.statusCode() + "): " + + response.body().substring(0, Math.min(500, response.body().length()))); + } + JsonNode result = objectMapper.readTree(response.body()); + String videoId = result.path("id").asText(""); + if (videoId.isBlank()) throw new IllegalStateException("YouTube 영상 ID를 받지 못했습니다."); + return videoId; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeUploadJob.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeUploadJob.java new file mode 100644 index 0000000..667c6ac --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeUploadJob.java @@ -0,0 +1,59 @@ +package com.hlab.yanalyst.domain.upload; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.Instant; +import java.time.LocalDateTime; + +@Entity +@Table(name = "youtube_upload_jobs") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@EntityListeners(AuditingEntityListener.class) +public class YoutubeUploadJob { + public enum Status { UPLOADING, SCHEDULED, PUBLISHED, FAILED } + + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + @Column(nullable = false) private Long connectionId; + @Column(nullable = false) private String fileName; + @Column(nullable = false) private String title; + @Column(columnDefinition = "TEXT") private String description; + @Column(nullable = false, length = 20) private String privacyStatus; + private Instant publishAt; + @Enumerated(EnumType.STRING) @Column(nullable = false, length = 20) private Status status; + private String youtubeVideoId; + @Column(columnDefinition = "TEXT") private String errorMessage; + private long fileSize; + @CreatedDate @Column(updatable = false) private LocalDateTime createdAt; + @LastModifiedDate private LocalDateTime updatedAt; + + public YoutubeUploadJob(Long connectionId, String fileName, String title, String description, + String privacyStatus, Instant publishAt, long fileSize) { + this.connectionId = connectionId; + this.fileName = fileName; + this.title = title; + this.description = description; + this.privacyStatus = privacyStatus; + this.publishAt = publishAt; + this.fileSize = fileSize; + this.status = Status.UPLOADING; + } + + public void complete(String videoId) { + this.youtubeVideoId = videoId; + this.status = publishAt == null ? Status.PUBLISHED : Status.SCHEDULED; + this.errorMessage = null; + } + + public void fail(String message) { + this.status = Status.FAILED; + this.errorMessage = message == null ? "YouTube 업로드에 실패했습니다." : message; + } +} diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeUploadJobRepository.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeUploadJobRepository.java new file mode 100644 index 0000000..30652a0 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeUploadJobRepository.java @@ -0,0 +1,8 @@ +package com.hlab.yanalyst.domain.upload; + +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; + +public interface YoutubeUploadJobRepository extends JpaRepository { + List findAllByConnectionIdOrderByCreatedAtDesc(Long connectionId); +} diff --git a/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeUploadService.java b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeUploadService.java new file mode 100644 index 0000000..6c53848 --- /dev/null +++ b/src/main/java/com/hlab/yanalyst/domain/upload/YoutubeUploadService.java @@ -0,0 +1,67 @@ +package com.hlab.yanalyst.domain.upload; + +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; + +@Service +public class YoutubeUploadService { + private final YoutubeOAuthService oauthService; + private final YoutubeUploadJobRepository jobRepository; + private final YoutubeResumableUploadClient uploadClient; + + public YoutubeUploadService(YoutubeOAuthService oauthService, YoutubeUploadJobRepository jobRepository, + YoutubeResumableUploadClient uploadClient) { + this.oauthService = oauthService; + this.jobRepository = jobRepository; + this.uploadClient = uploadClient; + } + + public JobDto upload(Long connectionId, MultipartFile file, String title, String description, + String privacyStatus, Instant publishAt) { + if (file == null || file.isEmpty()) throw new IllegalArgumentException("업로드할 영상 파일을 선택해 주세요."); + if (title == null || title.isBlank()) throw new IllegalArgumentException("영상 제목을 입력해 주세요."); + if (title.length() > 100) throw new IllegalArgumentException("영상 제목은 100자 이하여야 합니다."); + if (!List.of("public", "unlisted", "private").contains(privacyStatus)) throw new IllegalArgumentException("공개 상태가 올바르지 않습니다."); + if (publishAt != null && !publishAt.isAfter(Instant.now())) throw new IllegalArgumentException("예약 시간은 현재보다 이후여야 합니다."); + + YoutubeUploadJob job = jobRepository.saveAndFlush(new YoutubeUploadJob(connectionId, + file.getOriginalFilename() == null ? "video" : file.getOriginalFilename(), title, + description == null ? "" : description, publishAt == null ? privacyStatus : "private", publishAt, file.getSize())); + Path temporaryFile = null; + try { + String suffix = file.getOriginalFilename() != null && file.getOriginalFilename().contains(".") + ? file.getOriginalFilename().substring(file.getOriginalFilename().lastIndexOf('.')) : ".video"; + temporaryFile = Files.createTempFile("hlab-youtube-", suffix); + file.transferTo(temporaryFile); + String contentType = file.getContentType() == null ? "application/octet-stream" : file.getContentType(); + String videoId = uploadClient.upload(oauthService.accessToken(connectionId), temporaryFile, + file.getSize(), contentType, title.trim(), description == null ? "" : description, privacyStatus, publishAt); + job.complete(videoId); + return JobDto.from(jobRepository.save(job)); + } catch (Exception e) { + job.fail(e.getMessage()); + jobRepository.save(job); + throw new IllegalStateException(e.getMessage(), e); + } finally { + if (temporaryFile != null) try { Files.deleteIfExists(temporaryFile); } catch (Exception ignored) { } + } + } + + public List jobs(Long connectionId) { + return jobRepository.findAllByConnectionIdOrderByCreatedAtDesc(connectionId).stream().map(JobDto::from).toList(); + } + + public record JobDto(Long id, String fileName, String title, String privacyStatus, Instant publishAt, + String status, String youtubeVideoId, String errorMessage, long fileSize, + java.time.LocalDateTime createdAt) { + static JobDto from(YoutubeUploadJob job) { + return new JobDto(job.getId(), job.getFileName(), job.getTitle(), job.getPrivacyStatus(), job.getPublishAt(), + job.getStatus().name(), job.getYoutubeVideoId(), job.getErrorMessage(), job.getFileSize(), job.getCreatedAt()); + } + } +} diff --git a/src/main/java/com/hlab/yanalyst/global/error/GlobalExceptionHandler.java b/src/main/java/com/hlab/yanalyst/global/error/GlobalExceptionHandler.java index 1a176a3..9e5c1f9 100644 --- a/src/main/java/com/hlab/yanalyst/global/error/GlobalExceptionHandler.java +++ b/src/main/java/com/hlab/yanalyst/global/error/GlobalExceptionHandler.java @@ -27,6 +27,13 @@ public class GlobalExceptionHandler { .body(ApiResponse.error(e.getMessage())); } + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleIllegalState(IllegalStateException e) { + log.error("IllegalStateException: {}", e.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_GATEWAY) + .body(ApiResponse.error(e.getMessage())); + } + @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity> handleValidationExceptions(MethodArgumentNotValidException e) { String errorMessage = e.getBindingResult().getAllErrors().get(0).getDefaultMessage(); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fdf81cb..ad7d926 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -7,8 +7,8 @@ spring: import: optional:classpath:application-local.yml servlet: multipart: - max-file-size: ${UPLOAD_MAX_FILE_SIZE:200MB} # 업로드 영상(Whisper 전사용) - max-request-size: ${UPLOAD_MAX_REQUEST_SIZE:200MB} + max-file-size: ${UPLOAD_MAX_FILE_SIZE:2GB} # YouTube 업로드 및 Whisper 전사용 + max-request-size: ${UPLOAD_MAX_REQUEST_SIZE:2GB} datasource: url: ${DB_URL:} driverClassName: org.postgresql.Driver diff --git a/src/main/resources/templates/upload-studio.html b/src/main/resources/templates/upload-studio.html index 9cd367a..2412294 100644 --- a/src/main/resources/templates/upload-studio.html +++ b/src/main/resources/templates/upload-studio.html @@ -28,6 +28,8 @@ .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; } .us-button { min-height:42px; border:1px solid var(--border-strong); background:var(--surface); color:var(--text); padding:.6rem .85rem; border-radius:var(--r-sm); font-weight:700; cursor:pointer; display:inline-flex; align-items:center; justify-content:center; gap:.45rem; } .us-button svg { width:16px; height:16px; } + .us-button:disabled { cursor:not-allowed; opacity:.45; } + .us-danger { color:var(--danger); border-color:rgba(var(--danger-rgb),.35); } .us-summary { display:grid; grid-template-columns:repeat(4,1fr); border:1px solid var(--border); background:var(--surface); margin-bottom:1rem; } .us-stat { padding:.85rem 1rem; border-right:1px solid var(--border); } .us-stat:last-child { border:0; } @@ -58,6 +60,9 @@ .us-badge.wait { color:#6655e8; background:var(--studio-soft); } .us-badge.done { color:var(--success); background:rgba(var(--success-rgb),.1); } .us-badge.review { color:var(--warning); background:rgba(196,122,18,.11); } + .us-badge.scheduled { color:#6655e8; background:var(--studio-soft); } + .us-badge.uploading { color:var(--warning); background:rgba(196,122,18,.11); } + .us-badge.failed { color:var(--danger); background:rgba(var(--danger-rgb),.1); } .us-time { color:var(--text-2); font-size:.75rem; } .us-panel { position:sticky; top:1rem; border:1px solid var(--border); background:var(--surface); } .us-panel-head { padding:1rem; border-bottom:1px solid var(--border); } @@ -90,7 +95,7 @@
YouTube operations desk

업로드 스튜디오

로컬 내보내기 영상과 채널 발행 기록을 맞춰 보고, 업로드와 예약을 한 흐름에서 관리합니다.

-
+
@@ -102,22 +107,22 @@
-
4보관함 영상
2업로드 대기
—최근 7일 발행
오늘 18:30다음 예약
+
0영상 및 작업
0업로드 대기
—최근 7일 발행
—다음 예약
-
+