diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/controller/FileController.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/controller/FileController.java index 13e036ef..d5a5948b 100644 --- a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/controller/FileController.java +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/controller/FileController.java @@ -1,10 +1,14 @@ package com.jootalkpia.file_server.controller; import com.jootalkpia.file_server.dto.ChangeProfileResponseDto; +import com.jootalkpia.file_server.dto.MultipartChunk; +import com.jootalkpia.file_server.dto.UploadChunkRequestDto; import com.jootalkpia.file_server.dto.UploadFileRequestDto; import com.jootalkpia.file_server.dto.UploadFileResponseDto; +import com.jootalkpia.file_server.dto.UploadFilesResponseDto; import com.jootalkpia.file_server.service.FileService; import com.jootalkpia.file_server.utils.ValidationUtils; +import java.util.Map; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.io.InputStreamResource; @@ -17,13 +21,14 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; import software.amazon.awssdk.core.ResponseInputStream; import software.amazon.awssdk.services.s3.model.GetObjectResponse; @RestController -@RequestMapping("/api/v1") +@RequestMapping("/api/v1/files") @RequiredArgsConstructor @Slf4j public class FileController { @@ -36,8 +41,47 @@ public ResponseEntity testEndpoint() { return ResponseEntity.ok("Test successful"); } - @PostMapping("/files") - public ResponseEntity uploadFiles(@ModelAttribute UploadFileRequestDto uploadFileRequest) { +// @PostMapping("/init-upload") +// public ResponseEntity initFileUpload(@RequestBody UploadChunkRequestDto request) { +// log.info("Received init-upload request: {}", request); +// +// ValidationUtils.validateWorkSpaceId(request.getWorkspaceId()); +// ValidationUtils.validateChannelId(request.getChannelId()); +// +// return ResponseEntity.ok(); +// } + + @PostMapping("/chunk") + public ResponseEntity uploadFileChunk( + @RequestParam("workspaceId") Long workspaceId, + @RequestParam("channelId") Long channelId, + @RequestParam("tempFileIdentifier") String tempFileIdentifier, + @RequestParam("totalChunks") Long totalChunks, + @RequestParam("chunkSize") Long chunkSize, + @RequestParam("chunkInfo.chunkIndex") Long chunkIndex, + @RequestPart("chunkInfo.chunk") MultipartFile chunk) { + + log.info("청크 업로드 요청: chunkIndex={}, totalChunks={}", chunkIndex, totalChunks); + + ValidationUtils.validateWorkSpaceId(workspaceId); + ValidationUtils.validateChannelId(channelId); + ValidationUtils.validateFile(chunk); + ValidationUtils.validateFileId(tempFileIdentifier); + ValidationUtils.validateTotalChunksAndChunkIndex(totalChunks, chunkIndex); + + // DTO로 변환 + MultipartChunk multipartChunk = new MultipartChunk(chunkIndex, chunk); + UploadChunkRequestDto request = new UploadChunkRequestDto( + workspaceId, channelId, tempFileIdentifier, totalChunks, chunkSize, multipartChunk + ); + + Object response = fileService.uploadFileChunk(request); + return ResponseEntity.ok(response); + } + + + @PostMapping + public ResponseEntity uploadFiles(@ModelAttribute UploadFileRequestDto uploadFileRequest) { log.info("got uploadFileRequest: {}", uploadFileRequest); ValidationUtils.validateLengthOfFilesAndThumbnails(uploadFileRequest.getFiles().length, uploadFileRequest.getThumbnails().length); ValidationUtils.validateWorkSpaceId(uploadFileRequest.getWorkspaceId()); @@ -46,11 +90,11 @@ public ResponseEntity uploadFiles(@ModelAttribute UploadF ValidationUtils.validateFiles(uploadFileRequest.getThumbnails()); log.info("got uploadFileRequest: {}", uploadFileRequest.getFiles().length); - UploadFileResponseDto response = fileService.uploadFiles(userId, uploadFileRequest); + UploadFilesResponseDto response = fileService.uploadFiles(userId, uploadFileRequest); return ResponseEntity.ok(response); } - @GetMapping("/files/{fileId}") + @GetMapping("/{fileId}") public ResponseEntity downloadFile(@PathVariable Long fileId) { log.info("got downloadFile id: {}", fileId); ValidationUtils.validateFileId(fileId); @@ -86,4 +130,4 @@ public ResponseEntity changeProfile( ChangeProfileResponseDto response = fileService.changeProfile(userId, newImage); return ResponseEntity.ok(response); } -} +} \ No newline at end of file diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/MultipartChunk.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/MultipartChunk.java new file mode 100644 index 00000000..db20bb68 --- /dev/null +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/MultipartChunk.java @@ -0,0 +1,15 @@ +package com.jootalkpia.file_server.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.web.multipart.MultipartFile; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class MultipartChunk { + private Long chunkIndex; + private MultipartFile chunk; +} \ No newline at end of file diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/UploadChunkRequestDto.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/UploadChunkRequestDto.java new file mode 100644 index 00000000..541bf699 --- /dev/null +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/UploadChunkRequestDto.java @@ -0,0 +1,18 @@ +package com.jootalkpia.file_server.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UploadChunkRequestDto { + private Long workspaceId; + private Long channelId; + private String tempFileIdentifier; + private Long totalChunks; + private Long chunkSize; + private MultipartChunk chunkInfo; +} \ No newline at end of file diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/UploadFileResponseDto.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/UploadFileResponseDto.java index dd59e860..6595be44 100644 --- a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/UploadFileResponseDto.java +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/UploadFileResponseDto.java @@ -1,14 +1,13 @@ package com.jootalkpia.file_server.dto; -import java.util.List; import lombok.AllArgsConstructor; import lombok.Getter; import lombok.Setter; -@Setter @Getter +@Setter @AllArgsConstructor public class UploadFileResponseDto { - private List fileTypes; // IMAGE, VIDEO, THUMBNAIL - private List fileIds; -} + private Long fileId; + private String fileType; +} \ No newline at end of file diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/UploadFilesResponseDto.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/UploadFilesResponseDto.java new file mode 100644 index 00000000..a8299102 --- /dev/null +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/UploadFilesResponseDto.java @@ -0,0 +1,14 @@ +package com.jootalkpia.file_server.dto; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Setter +@Getter +@AllArgsConstructor +public class UploadFilesResponseDto { + private List fileTypes; // IMAGE, VIDEO, THUMBNAIL + private List fileIds; +} \ No newline at end of file diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/exception/common/ErrorCode.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/exception/common/ErrorCode.java index e0a7df4a..3ce31146 100644 --- a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/exception/common/ErrorCode.java +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/exception/common/ErrorCode.java @@ -30,8 +30,11 @@ public enum ErrorCode { IMAGE_UPLOAD_FAILED("F50003", "파일 업로드에 실패했습니다."), IMAGE_DOWNLOAD_FAILED("F50004", "파일 다운로드 중 오류가 발생했습니다."), FILE_PROCESSING_FAILED("F50005", "파일 처리 중 오류가 발생했습니다."), + CHUNK_PROCESSING_FAILED("F50008", "청크 처리 중 오류가 발생했습니다."), + CHUNK_MERGING_FAILED("F50007", "청크 병합 중 오류가 발생했습니다."), + MIMETYPE_DETECTION_FAILED("F50009", "mimetype 감지에 실패했습니다."), UNEXPECTED_ERROR("F50006", "예상치 못한 오류가 발생했습니다."); private final String code; private final String msg; -} +} \ No newline at end of file diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/service/FileService.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/service/FileService.java index 81eca594..2f949440 100644 --- a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/service/FileService.java +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/service/FileService.java @@ -1,8 +1,11 @@ package com.jootalkpia.file_server.service; import com.jootalkpia.file_server.dto.ChangeProfileResponseDto; +import com.jootalkpia.file_server.dto.MultipartChunk; +import com.jootalkpia.file_server.dto.UploadChunkRequestDto; import com.jootalkpia.file_server.dto.UploadFileRequestDto; import com.jootalkpia.file_server.dto.UploadFileResponseDto; +import com.jootalkpia.file_server.dto.UploadFilesResponseDto; import com.jootalkpia.file_server.entity.Files; import com.jootalkpia.file_server.entity.User; import com.jootalkpia.file_server.exception.common.CustomException; @@ -10,8 +13,19 @@ import com.jootalkpia.file_server.repository.FileRepository; import com.jootalkpia.file_server.repository.UserRepository; import jakarta.transaction.Transactional; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.InputStream; import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -27,9 +41,113 @@ public class FileService { private final FileRepository fileRepository; private final S3Service s3Service; private final UserRepository userRepository; + private final FileTypeDetector fileTypeDetector; + + // 청크 저장을 위한 Map (tempFileIdentifier 기준으로 리스트 저장) + private static final ConcurrentHashMap> TEMP_FILE_STORAGE = new ConcurrentHashMap<>(); + + @Transactional + public Object uploadFileChunk(UploadChunkRequestDto request) { + MultipartFile chunkFile = request.getChunkInfo().getChunk(); + String tempFileIdentifier = request.getTempFileIdentifier(); + int totalChunks = request.getTotalChunks().intValue(); + int chunkIndex = request.getChunkInfo().getChunkIndex().intValue(); + + log.info("Processing chunk {} of {}", chunkIndex, totalChunks); + + try { + // 청크 저장 리스트 불러오고 없으면 생성 + List chunkList = TEMP_FILE_STORAGE.computeIfAbsent(tempFileIdentifier, k -> new ArrayList<>(totalChunks)); + + // 리스트 크기를 totalChunks 크기로 확장 + while (chunkList.size() < totalChunks) { + chunkList.add(null); + } + + int adjustedIndex = chunkIndex - 1; + + File tempChunkFile = File.createTempFile("chunk_" + chunkIndex, ".part"); + appendChunkToFile(tempChunkFile, chunkFile); + chunkList.set(adjustedIndex, tempChunkFile); + + // 모든 청크가 수신 완료되었는지 확인 (전체 크기가 맞으면 병합) + if (chunkList.size() == totalChunks && chunkList.stream().allMatch(java.util.Objects::nonNull)) { + log.info("모든 청크가 도착함 - 병합 시작 (임시 파일 ID: {})", tempFileIdentifier); + return finalizeFileUpload(tempFileIdentifier, chunkList); + } + } catch (IOException e) { + throw new CustomException(ErrorCode.CHUNK_PROCESSING_FAILED.getCode(), ErrorCode.CHUNK_PROCESSING_FAILED.getMsg()); + } + + // 병합이 완료되지 않은 경우 기본 응답 반환 + Map response = new HashMap<>(); + response.put("code", 200); + response.put("status", "partial"); + return response; + } + + private UploadFileResponseDto finalizeFileUpload(String tempFileIdentifier, List chunkList) { + try { + File mergedFile = mergeChunks(chunkList, tempFileIdentifier); + + Files filesEntity = new Files(); + fileRepository.save(filesEntity); + Long fileId = filesEntity.getFileId(); + + // S3 업로드 + String fileType = fileTypeDetector.detectFileTypeFromFile(mergedFile); + String s3Url = s3Service.uploadFileMultipart(mergedFile, fileType.toLowerCase() + "s/", fileId); + + filesEntity.setUrl(s3Url); + filesEntity.setFileType(fileType); + filesEntity.setFileSize(mergedFile.length()); + fileRepository.save(filesEntity); + + // 임시 데이터 정리 + TEMP_FILE_STORAGE.remove(tempFileIdentifier); + chunkList.forEach(File::delete); + + return new UploadFileResponseDto(fileId, fileType); + } catch (IOException e) { + throw new CustomException(ErrorCode.FILE_PROCESSING_FAILED.getCode(), ErrorCode.FILE_PROCESSING_FAILED.getMsg()); + } + } + + // 청크를 임시 파일에 추가 + private void appendChunkToFile(File tempFile, MultipartFile chunkFile) throws IOException { + try (FileOutputStream fos = new FileOutputStream(tempFile); + InputStream inputStream = chunkFile.getInputStream()) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + fos.write(buffer, 0, bytesRead); + } + } + } + + // 청크 파일 병합 + private File mergeChunks(List chunkList, String tempFileIdentifier) throws IOException { + File mergedFile = File.createTempFile("merged_" + tempFileIdentifier, ".tmp"); + + try (FileOutputStream fos = new FileOutputStream(mergedFile)) { + for (File chunk : chunkList) { + try (InputStream inputStream = new FileInputStream(chunk)) { + byte[] buffer = new byte[8192]; + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + fos.write(buffer, 0, bytesRead); + } + } + } + } catch (IOException e) { + throw new CustomException(ErrorCode.CHUNK_MERGING_FAILED.getCode(), ErrorCode.CHUNK_MERGING_FAILED.getMsg()); + } + + return mergedFile; + } @Transactional - public UploadFileResponseDto uploadFiles(Long userId, UploadFileRequestDto uploadFileRequestDto) { + public UploadFilesResponseDto uploadFiles(Long userId, UploadFileRequestDto uploadFileRequestDto) { MultipartFile[] files = uploadFileRequestDto.getFiles(); MultipartFile[] thumbnails = uploadFileRequestDto.getThumbnails(); @@ -47,7 +165,7 @@ public UploadFileResponseDto uploadFiles(Long userId, UploadFileRequestDto uploa MultipartFile file = files[i]; // 파일 타입 결정 - String fileType = FileTypeDetector.detectFileTypeFromMultipartFile(file); + String fileType = fileTypeDetector.detectFileTypeFromMultipartFile(file); log.info(fileType); String s3Url = uploadEachFile(fileType, fileId, file); @@ -68,7 +186,7 @@ public UploadFileResponseDto uploadFiles(Long userId, UploadFileRequestDto uploa log.info("now saving filesentity"); fileRepository.save(filesEntity); } - return new UploadFileResponseDto(fileTypes, fileIds); + return new UploadFilesResponseDto(fileTypes, fileIds); } private String uploadEachFile(String fileType, Long fileId, MultipartFile file) { @@ -106,7 +224,7 @@ public ChangeProfileResponseDto changeProfile(Long userId, MultipartFile newImag .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND.getCode(), ErrorCode.USER_NOT_FOUND.getMsg())); try { - String fileType = FileTypeDetector.detectFileTypeFromMultipartFile(newImage); + String fileType = fileTypeDetector.detectFileTypeFromMultipartFile(newImage); String s3Url = uploadEachFile(fileType, userId, newImage); user.updateProfileImage(s3Url); @@ -117,4 +235,4 @@ public ChangeProfileResponseDto changeProfile(Long userId, MultipartFile newImag throw new CustomException(ErrorCode.IMAGE_UPLOAD_FAILED.getCode(), ErrorCode.IMAGE_UPLOAD_FAILED.getMsg()); } } -} +} \ No newline at end of file diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/service/FileTypeDetector.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/service/FileTypeDetector.java index 9622c487..9fbef313 100644 --- a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/service/FileTypeDetector.java +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/service/FileTypeDetector.java @@ -2,29 +2,61 @@ import com.jootalkpia.file_server.exception.common.CustomException; import com.jootalkpia.file_server.exception.common.ErrorCode; +import java.io.File; import java.io.IOException; import java.io.InputStream; +import lombok.extern.slf4j.Slf4j; import org.apache.tika.Tika; +import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +@Service +@Slf4j public class FileTypeDetector { - private static final Tika tika = new Tika(); + private final Tika tika = new Tika(); - public static String detectFileTypeFromMultipartFile(MultipartFile file) { + public String detectFileTypeFromMultipartFile(MultipartFile file) { try (InputStream inputStream = file.getInputStream()) { - // 파일을 한 번에 메모리에 로드하지 않고 스트리밍 방식으로 MIME 타입 감지 - String mimeType = tika.detect(inputStream); + return detectFileType(inputStream); + } catch (IOException e) { + throw new CustomException(ErrorCode.UNSUPPORTED_FILE_TYPE.getCode(), ErrorCode.UNSUPPORTED_FILE_TYPE.getMsg()); + } + } - if (mimeType.startsWith("image/")) { - return "IMAGE"; - } else if (mimeType.startsWith("video/")) { - return "VIDEO"; + public String detectFileTypeFromFile(File file) { + try { + return detectFileType(file); + } catch (IOException e) { + throw new CustomException(ErrorCode.UNSUPPORTED_FILE_TYPE.getCode(), ErrorCode.UNSUPPORTED_FILE_TYPE.getMsg()); + } + } + + private String detectFileType(Object file) throws IOException { + String mimeType = detectMimeType(file); + + if (mimeType.startsWith("image/")) { + return "IMAGE"; + } else if (mimeType.startsWith("video/")) { + return "VIDEO"; + } else { + return "UNKNOWN"; + } + } + + public String detectMimeType(Object file) { + try { + if (file instanceof InputStream) { + return tika.detect((InputStream) file); + } else if (file instanceof File) { + return tika.detect((File) file); } else { - return "UNKNOWN"; + throw new IllegalArgumentException("Unsupported file type for detection"); } } catch (IOException e) { - throw new CustomException(ErrorCode.UNSUPPORTED_FILE_TYPE.getCode(), ErrorCode.UNSUPPORTED_FILE_TYPE.getMsg()); + log.warn("MIME 타입 감지 실패, 기본값 사용: binary/octet-stream", e); + throw new CustomException(ErrorCode.MIMETYPE_DETECTION_FAILED.getCode(), ErrorCode.MIMETYPE_DETECTION_FAILED.getMsg()); } } -} + +} \ No newline at end of file diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/service/S3Service.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/service/S3Service.java index c02623e2..51cd8063 100644 --- a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/service/S3Service.java +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/service/S3Service.java @@ -2,9 +2,7 @@ import com.jootalkpia.file_server.exception.common.CustomException; import com.jootalkpia.file_server.exception.common.ErrorCode; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; +import java.util.Arrays; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -12,11 +10,15 @@ import org.springframework.web.multipart.MultipartFile; import software.amazon.awssdk.core.ResponseInputStream; import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.GetObjectRequest; -import software.amazon.awssdk.services.s3.model.GetObjectResponse; -import software.amazon.awssdk.services.s3.model.NoSuchKeyException; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.*; + +import java.io.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; @Service @Slf4j @@ -24,17 +26,105 @@ public class S3Service { private final S3Client s3Client; + private final FileTypeDetector fileTypeDetector; @Value("${spring.cloud.aws.s3.bucket}") private String bucketName; + @Value("${spring.cloud.aws.region.static}") private String region; + // 멀티파트 업로드 방식으로 S3에 파일 업로드 + public String uploadFileMultipart(File file, String folder, Long fileId) { + String key = folder + fileId; + log.info("S3 멀티파트 업로드 시작: {}", key); + + // 멀티파트 업로드 시작 + CreateMultipartUploadRequest createRequest = CreateMultipartUploadRequest.builder() + .bucket(bucketName) + .key(key) + .contentType(fileTypeDetector.detectMimeType(file)) + .build(); + + CreateMultipartUploadResponse createResponse = s3Client.createMultipartUpload(createRequest); + String uploadId = createResponse.uploadId(); + List completedParts = new ArrayList<>(); + + try (InputStream inputStream = new FileInputStream(file)) { + byte[] buffer = new byte[5 * 1024 * 1024]; // 5MB 청크 + int bytesRead; + int partNumber = 1; + + while ((bytesRead = inputStream.read(buffer)) != -1) { + byte[] chunkData = Arrays.copyOf(buffer, bytesRead); + + UploadPartRequest uploadPartRequest = UploadPartRequest.builder() + .bucket(bucketName) + .key(key) + .uploadId(uploadId) + .partNumber(partNumber) + .build(); + + UploadPartResponse uploadPartResponse = s3Client.uploadPart( + uploadPartRequest, + RequestBody.fromBytes(chunkData) + ); + + completedParts.add(CompletedPart.builder() + .partNumber(partNumber) + .eTag(uploadPartResponse.eTag()) + .build()); + + log.info("청크 업로드 완료 - 파트 번호: {}", partNumber); + partNumber++; + } + + + // 업로드 완료 + CompleteMultipartUploadRequest completeRequest = CompleteMultipartUploadRequest.builder() + .bucket(bucketName) + .key(key) + .uploadId(uploadId) + .multipartUpload(CompletedMultipartUpload.builder() + .parts(completedParts) + .build()) + .build(); + + s3Client.completeMultipartUpload(completeRequest); + log.info("멀티파트 업로드 완료: {}", key); + + return "https://" + bucketName + ".s3." + region + ".amazonaws.com/" + key; + + } catch (IOException e) { + log.error("멀티파트 업로드 실패: {}", e.getMessage()); + abortMultipartUpload(bucketName, key, uploadId); + throw new CustomException(ErrorCode.IMAGE_UPLOAD_FAILED.getCode(), ErrorCode.IMAGE_UPLOAD_FAILED.getMsg()); + } + } + + // 멀티파트 업로드 실패 시 업로드 취소 + private void abortMultipartUpload(String bucket, String key, String uploadId) { + try { + AbortMultipartUploadRequest abortRequest = AbortMultipartUploadRequest.builder() + .bucket(bucket) + .key(key) + .uploadId(uploadId) + .build(); + + s3Client.abortMultipartUpload(abortRequest); + log.warn("멀티파트 업로드 취소 완료: {}", key); + + } catch (Exception ex) { + log.error("멀티파트 업로드 취소 실패: {}", ex.getMessage()); + throw new CustomException(ErrorCode.IMAGE_UPLOAD_FAILED.getCode(), ErrorCode.IMAGE_UPLOAD_FAILED.getMsg()); + } + } + + public String uploadFile(MultipartFile file, String folder, Long fileId) { Path tempFile = null; log.info("Ready to upload file to S3 bucket: {}", bucketName); try { - // S3에 저장될 파일 키 생성 String key = folder + fileId; @@ -51,8 +141,8 @@ public String uploadFile(MultipartFile file, String folder, Long fileId) { .build(), tempFile); - log.info("파일 업로드 완료 - S3 Key: {}", "https://" + bucketName +".s3."+region+".amazonaws.com/" + key); - return "https://" + bucketName +".s3."+region+".amazonaws.com/" + key; + log.info("파일 업로드 완료 - S3 Key: {}", "https://" + bucketName + ".s3." + region + ".amazonaws.com/" + key); + return "https://" + bucketName + ".s3." + region + ".amazonaws.com/" + key; } catch (IOException e) { log.error("파일 업로드 중 IOException 발생: {}", e.getMessage(), e); @@ -66,8 +156,7 @@ public String uploadFile(MultipartFile file, String folder, Long fileId) { log.error("알 수 없는 오류 발생: {}", e.getMessage(), e); throw new CustomException(ErrorCode.UNKNOWN.getCode(), "알 수 없는 오류 발생"); - } - finally { + } finally { // 임시 파일 삭제 try { if (tempFile != null && Files.exists(tempFile)) { @@ -103,4 +192,4 @@ public ResponseInputStream downloadFile(String folder, Long f throw new CustomException(ErrorCode.IMAGE_DOWNLOAD_FAILED.getCode(), ErrorCode.IMAGE_DOWNLOAD_FAILED.getMsg()); } } -} +} \ No newline at end of file diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/utils/ValidationUtils.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/utils/ValidationUtils.java index 55a5ada3..e38aa707 100644 --- a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/utils/ValidationUtils.java +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/utils/ValidationUtils.java @@ -47,6 +47,28 @@ public static void validateFile(MultipartFile file) { } } + public static void validateFileId(String fileId) { + if (fileId == null || fileId.isEmpty() ) { + log.info("Validation file id is null"); + throw new CustomException(ErrorCode.INVALID_PARAMETER.getCode(), ErrorCode.INVALID_PARAMETER.getMsg()); + } + } + + public static void validateTotalChunksAndChunkIndex(Long totalChunks, Long chunkIndex) { + if (totalChunks == null || totalChunks <= 0) { + log.info("Validation chunk index is null"); + throw new CustomException(ErrorCode.INVALID_PARAMETER.getCode(), ErrorCode.INVALID_PARAMETER.getMsg()); + } + if (chunkIndex == null || chunkIndex <= 0) { + log.info("Validation chunk index is null"); + throw new CustomException(ErrorCode.INVALID_PARAMETER.getCode(), ErrorCode.INVALID_PARAMETER.getMsg()); + } + if (chunkIndex > totalChunks) { + log.error("chunkIndex({})가 1~{} 범위를 벗어남", chunkIndex, totalChunks); + throw new CustomException(ErrorCode.INVALID_PARAMETER.getCode(), ErrorCode.INVALID_PARAMETER.getMsg()); + } + } + public static void validateLengthOfFilesAndThumbnails(int fileLength, int thumbnailLength) { if (fileLength < thumbnailLength) { log.info("Validation length of files and thumbnails"); diff --git a/src/backend/file_server/src/main/resources/application.yml b/src/backend/file_server/src/main/resources/application.yml index 5a990e87..b3dc8097 100644 --- a/src/backend/file_server/src/main/resources/application.yml +++ b/src/backend/file_server/src/main/resources/application.yml @@ -50,6 +50,4 @@ spring: region: static: ${AWS_REGION} stack: - auto: false - - + auto: false \ No newline at end of file