From a30c49f0156b0d812461b4bc63bb6102f1cf0872 Mon Sep 17 00:00:00 2001 From: JongKeun Kim Date: Wed, 7 Aug 2024 22:29:56 +0900 Subject: [PATCH] =?UTF-8?q?[#91]=20=EC=98=81=EC=83=81=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD(presigned?= =?UTF-8?q?=20url=20=EB=B0=9C=ED=96=89=20=ED=9B=84=20=EC=A0=80=EC=9E=A5=20?= =?UTF-8?q?=EB=B0=A9=EC=8B=9D)=20(#98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor: 기록 생성 api에서 video multipart 대신 videoUrl 받도록 수정 * refactor: s3Service video upload 로직 제거 및 presignedUrl 생성 로직 추가 * refactor: s3Service 로직 변경에 따른 RecordService 리팩토링 * feat: s3 presignedUrl 생성 api 추가 * fix: presingedUrl에서 videoUrl 추출하는 과정 중 발생한 버그 수정 * add: signIn, signUp response body에 memberId 추가 (#103) * fix: @modelAttribute -> @requestbody로 변경 --- .../record/api/RecordController.java | 3 +- .../api/request/RecordCreateRequest.java | 6 +- .../record/application/RecordService.java | 15 +++- .../climingoApi/upload/S3Service.java | 78 +++++++++---------- .../upload/ThumbnailExtractor.java | 8 +- .../climingoApi/upload/api/S3Controller.java | 36 +++++++++ .../request/PresignedUrlCreateRequest.java | 14 ++++ .../api/response/PresignedUrlResponse.java | 12 +++ .../record/application/RecordServiceTest.java | 7 +- 9 files changed, 125 insertions(+), 54 deletions(-) create mode 100644 src/main/java/com/climingo/climingoApi/upload/api/S3Controller.java create mode 100644 src/main/java/com/climingo/climingoApi/upload/api/request/PresignedUrlCreateRequest.java create mode 100644 src/main/java/com/climingo/climingoApi/upload/api/response/PresignedUrlResponse.java diff --git a/src/main/java/com/climingo/climingoApi/record/api/RecordController.java b/src/main/java/com/climingo/climingoApi/record/api/RecordController.java index 7d1020c..35e7d7e 100644 --- a/src/main/java/com/climingo/climingoApi/record/api/RecordController.java +++ b/src/main/java/com/climingo/climingoApi/record/api/RecordController.java @@ -20,6 +20,7 @@ import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -32,7 +33,7 @@ public class RecordController { @PostMapping("/records") public ResponseEntity> create( @LoginMember Member loginMember, - @ModelAttribute RecordCreateRequest request) throws IOException { + @RequestBody RecordCreateRequest request) throws IOException { Record record = recordService.createRecord(loginMember, request); return ResponseEntity.ok().body(Map.of("recordId", record.getId())); diff --git a/src/main/java/com/climingo/climingoApi/record/api/request/RecordCreateRequest.java b/src/main/java/com/climingo/climingoApi/record/api/request/RecordCreateRequest.java index 348ca86..416aa96 100644 --- a/src/main/java/com/climingo/climingoApi/record/api/request/RecordCreateRequest.java +++ b/src/main/java/com/climingo/climingoApi/record/api/request/RecordCreateRequest.java @@ -18,12 +18,12 @@ public class RecordCreateRequest { private Long levelId; @NotNull - private MultipartFile video; + private String videoUrl; - public RecordCreateRequest(Long gymId, Long levelId, MultipartFile video) { + public RecordCreateRequest(Long gymId, Long levelId, String videoUrl) { this.gymId = gymId; this.levelId = levelId; - this.video = video; + this.videoUrl = videoUrl; } } diff --git a/src/main/java/com/climingo/climingoApi/record/application/RecordService.java b/src/main/java/com/climingo/climingoApi/record/application/RecordService.java index 7d99e1e..86ba221 100644 --- a/src/main/java/com/climingo/climingoApi/record/application/RecordService.java +++ b/src/main/java/com/climingo/climingoApi/record/application/RecordService.java @@ -22,10 +22,12 @@ import java.util.NoSuchElementException; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @RequiredArgsConstructor @Service public class RecordService { @@ -44,8 +46,14 @@ public Record createRecord(Member loginMember, RecordCreateRequest request) thro Level level = levelRepository.findById(request.getLevelId()) .orElseThrow(() -> new EntityNotFoundException(request.getLevelId() + "is not found")); - String videoUrl = s3Service.uploadVideoFile(request.getVideo()); - String thumbnailImageUrl = s3Service.uploadImageFile(thumbnailExtractor.extractImage(videoUrl)); + String videoUrl = request.getVideoUrl(); + String thumbnailImageUrl = ""; + try { + thumbnailImageUrl = s3Service.uploadThumbnailImageFile(thumbnailExtractor.extractImage(videoUrl)); + } catch (Exception e) { + log.error("error: ", e); + // TODO 썸네일 이미지 저장 안되었을 때 처리 + } Record record = Record.builder() .member(loginMember) @@ -56,8 +64,7 @@ public Record createRecord(Member loginMember, RecordCreateRequest request) thro .thumbnailUrl(thumbnailImageUrl) .build(); - Record save = recordRepository.save(record); - return save; + return recordRepository.save(record); } @Transactional diff --git a/src/main/java/com/climingo/climingoApi/upload/S3Service.java b/src/main/java/com/climingo/climingoApi/upload/S3Service.java index d32cd45..4655440 100644 --- a/src/main/java/com/climingo/climingoApi/upload/S3Service.java +++ b/src/main/java/com/climingo/climingoApi/upload/S3Service.java @@ -5,19 +5,16 @@ import com.amazonaws.services.s3.model.CannedAccessControlList; import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; import com.amazonaws.services.s3.model.PutObjectRequest; +import com.climingo.climingoApi.upload.api.request.PresignedUrlCreateRequest; import java.io.File; -import java.io.FileOutputStream; -import java.io.IOException; import java.net.URL; import java.time.LocalDateTime; +import java.util.Date; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; -import org.springframework.web.multipart.MultipartFile; @Slf4j @Service @@ -29,52 +26,53 @@ public class S3Service { @Value("${cloud.aws.s3.bucket}") private String bucket; - @Transactional(propagation = Propagation.REQUIRED) - public String uploadVideoFile(MultipartFile videoFile) throws IOException { - StringBuilder fileName = new StringBuilder(); - fileName.append("비디오_") - .append(LocalDateTime.now()) - .append(".") - .append(StringUtils.getFilenameExtension(videoFile.getOriginalFilename())); + public String uploadThumbnailImageFile(File image) { + String fileName = new StringBuilder().append("썸네일_") + .append(LocalDateTime.now()) + .append(".") + .append(StringUtils.getFilenameExtension(image.getName())) + .toString(); + + s3Client.putObject(new PutObjectRequest(bucket, fileName, image).withCannedAcl( + CannedAccessControlList.PublicRead)); - File file = convertMultipartFileToFile(videoFile); - s3Client.putObject(new PutObjectRequest(bucket, fileName.toString(), file).withCannedAcl(CannedAccessControlList.PublicRead)); - file.delete(); + image.delete(); - URL videoUrl = generatePermanentPresignedUrl(fileName.toString()); + URL url = s3Client.getUrl(bucket, fileName); - return videoUrl.toString().substring(0, videoUrl.toString().indexOf("?")); + return url.toString(); } - private File convertMultipartFileToFile(MultipartFile file) throws IOException { - File convertedFile = new File(file.getOriginalFilename()); - FileOutputStream fos = new FileOutputStream(convertedFile); - fos.write(file.getBytes()); - fos.close(); - return convertedFile; + public URL generatePresignedUrl(PresignedUrlCreateRequest request) { + String fileName = parseFileName("비디오_", request.getFileName(), request.getExtension()); + return s3Client.generatePresignedUrl(getGeneratePreSignedUrlRequest(bucket, fileName)); } - public URL generatePermanentPresignedUrl(String objectKey) { - GeneratePresignedUrlRequest generatePresignedUrlRequest = - new GeneratePresignedUrlRequest(bucket, objectKey) - .withMethod(HttpMethod.GET); - - return s3Client.generatePresignedUrl(generatePresignedUrlRequest); + private String parseFileName(String prefix, String fileName, String extension) { + return prefix + + fileName + + "_" + + LocalDateTime.now() + + "." + + extension; } - public String uploadImageFile(File image) { - StringBuilder fileName = new StringBuilder(); - fileName.append("썸네일_") - .append(LocalDateTime.now()) - .append(".") - .append(StringUtils.getFilenameExtension(image.getName())); + private GeneratePresignedUrlRequest getGeneratePreSignedUrlRequest(String bucket, String fileName) { + GeneratePresignedUrlRequest generatePresignedUrlRequest = new GeneratePresignedUrlRequest( + bucket, fileName) + .withMethod(HttpMethod.PUT) + .withExpiration(getPreSignedUrlExpiration()); + generatePresignedUrlRequest.addRequestParameter("x-amz-acl", "public-read"); - s3Client.putObject(new PutObjectRequest(bucket, fileName.toString(), image).withCannedAcl( - CannedAccessControlList.PublicRead)); - image.delete(); + return generatePresignedUrlRequest; + } - URL url = generatePermanentPresignedUrl(fileName.toString()); + private Date getPreSignedUrlExpiration() { + Date expiration = new Date(); + long expTimeMillis = expiration.getTime(); + expTimeMillis += 1000 * 60 * 3; // 3분 + expiration.setTime(expTimeMillis); - return url.toString().substring(0, url.toString().indexOf("?")); + return expiration; } } diff --git a/src/main/java/com/climingo/climingoApi/upload/ThumbnailExtractor.java b/src/main/java/com/climingo/climingoApi/upload/ThumbnailExtractor.java index e4c594a..1ec370f 100644 --- a/src/main/java/com/climingo/climingoApi/upload/ThumbnailExtractor.java +++ b/src/main/java/com/climingo/climingoApi/upload/ThumbnailExtractor.java @@ -4,6 +4,7 @@ import java.io.File; import java.io.IOException; import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import lombok.extern.slf4j.Slf4j; import net.bramp.ffmpeg.FFmpeg; import net.bramp.ffmpeg.FFmpegExecutor; @@ -37,7 +38,9 @@ public void init() { } public File extractImage(String videoPath) throws IOException { - File thumbnail = File.createTempFile("temp" + LocalDateTime.now(), ".jpg"); + File thumbnail = new File("thumbnail_" + + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS")) + + ".jpg"); try { FFmpegBuilder builder = new FFmpegBuilder() @@ -46,11 +49,12 @@ public File extractImage(String videoPath) throws IOException { .addExtraArgs("-ss", "00:00:02") .addOutput(thumbnail.getAbsolutePath()) .setFrames(100) + .addExtraArgs("-update", "1") .done(); FFmpegExecutor executor = new FFmpegExecutor(ffmpeg, ffprobe); executor.createJob(builder).run(); } catch (Exception e) { - log.info(e.getMessage()); + log.error("Error extracting thumbnail from " + videoPath, e); } return thumbnail; } diff --git a/src/main/java/com/climingo/climingoApi/upload/api/S3Controller.java b/src/main/java/com/climingo/climingoApi/upload/api/S3Controller.java new file mode 100644 index 0000000..4c88532 --- /dev/null +++ b/src/main/java/com/climingo/climingoApi/upload/api/S3Controller.java @@ -0,0 +1,36 @@ +package com.climingo.climingoApi.upload.api; + +import com.climingo.climingoApi.upload.S3Service; +import com.climingo.climingoApi.upload.api.request.PresignedUrlCreateRequest; +import com.climingo.climingoApi.upload.api.response.PresignedUrlResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class S3Controller { + + private final S3Service s3Service; + + @PostMapping("/s3/presigned-url") + public ResponseEntity generatePresignedUrl( + @RequestBody PresignedUrlCreateRequest request) { + String presignedUrl = s3Service.generatePresignedUrl(request).toString(); + String videoUrl = parseUrlWithoutQuery(presignedUrl); + + return ResponseEntity.ok(new PresignedUrlResponse(presignedUrl, videoUrl)); + } + + private String parseUrlWithoutQuery(String url) { + int queryIndex = url.indexOf("?"); + + if (queryIndex != -1) { + return url.substring(0, queryIndex); + } + + return url; + } +} \ No newline at end of file diff --git a/src/main/java/com/climingo/climingoApi/upload/api/request/PresignedUrlCreateRequest.java b/src/main/java/com/climingo/climingoApi/upload/api/request/PresignedUrlCreateRequest.java new file mode 100644 index 0000000..d944008 --- /dev/null +++ b/src/main/java/com/climingo/climingoApi/upload/api/request/PresignedUrlCreateRequest.java @@ -0,0 +1,14 @@ +package com.climingo.climingoApi.upload.api.request; + +import jakarta.validation.constraints.NotNull; +import lombok.Getter; + +@Getter +public class PresignedUrlCreateRequest { + + @NotNull + private String fileName; + + @NotNull + private String extension; +} diff --git a/src/main/java/com/climingo/climingoApi/upload/api/response/PresignedUrlResponse.java b/src/main/java/com/climingo/climingoApi/upload/api/response/PresignedUrlResponse.java new file mode 100644 index 0000000..d2e3952 --- /dev/null +++ b/src/main/java/com/climingo/climingoApi/upload/api/response/PresignedUrlResponse.java @@ -0,0 +1,12 @@ +package com.climingo.climingoApi.upload.api.response; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class PresignedUrlResponse { + + private final String presignedUrl; + private final String videoUrl; +} diff --git a/src/test/java/com/climingo/climingoApi/record/application/RecordServiceTest.java b/src/test/java/com/climingo/climingoApi/record/application/RecordServiceTest.java index a0475c4..2f5b609 100644 --- a/src/test/java/com/climingo/climingoApi/record/application/RecordServiceTest.java +++ b/src/test/java/com/climingo/climingoApi/record/application/RecordServiceTest.java @@ -46,8 +46,7 @@ void setUp() throws IOException { levelRepository = mock(LevelRepository.class); recordRepository = mock(RecordRepository.class); - when(s3Service.uploadVideoFile(any())).thenReturn("http://mock-video-url"); - when(s3Service.uploadImageFile(any())).thenReturn("http://mock-thumbnail-url"); + when(s3Service.uploadThumbnailImageFile(any())).thenReturn("http://mock-thumbnail-url"); when(thumbnailExtractor.extractImage(any())).thenReturn(mock(File.class)); recordService = new RecordService( @@ -59,12 +58,12 @@ void setUp() throws IOException { void create_test() throws IOException { Long mockGymId = 1L; Long mockLevelId = 1L; - MultipartFile mockVideo = mock(MultipartFile.class); + String mockVideoUrl = "http://mock-video-url"; Member loginMember = Member.builder() .id(99999L) .build(); - RecordCreateRequest request = new RecordCreateRequest(mockGymId, mockLevelId, mockVideo); + RecordCreateRequest request = new RecordCreateRequest(mockGymId, mockLevelId, mockVideoUrl); Record expected = Record.builder() .member(loginMember)