diff --git a/src/backend/file_server/Dockerfile b/src/backend/file_server/Dockerfile new file mode 100644 index 00000000..9de44cdd --- /dev/null +++ b/src/backend/file_server/Dockerfile @@ -0,0 +1,21 @@ +# Azul Zulu JDK 17 기반 이미지 사용 +FROM azul/zulu-openjdk:17 + +# 작업 디렉토리 설정 +WORKDIR /app + +# 환경 변수 파일 복사 +COPY .env .env + +# 빌드된 JAR 파일 복사 +ARG JAR_FILE=build/libs/file_server-0.0.1-SNAPSHOT.jar +COPY ${JAR_FILE} app.jar + +# 환경 변수 설정 +ENV $(cat .env) + +# 포트 노출 +EXPOSE 8083 + +# 애플리케이션 실행 +ENTRYPOINT ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/src/backend/file_server/local.env b/src/backend/file_server/local.env deleted file mode 100644 index e69de29b..00000000 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 1f5852d2..13e036ef 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,18 +1,26 @@ package com.jootalkpia.file_server.controller; +import com.jootalkpia.file_server.dto.ChangeProfileResponseDto; import com.jootalkpia.file_server.dto.UploadFileRequestDto; import com.jootalkpia.file_server.dto.UploadFileResponseDto; import com.jootalkpia.file_server.service.FileService; +import com.jootalkpia.file_server.utils.ValidationUtils; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.InputStreamResource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; 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.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") @@ -28,24 +36,54 @@ public ResponseEntity testEndpoint() { return ResponseEntity.ok("Test successful"); } - @PostMapping("/workspace/{workspaceId}/channel/{channelId}") - public ResponseEntity uploadFiles( - @PathVariable Long workspaceId, - @PathVariable Long channelId, - @RequestParam("files") MultipartFile[] files, - @RequestParam("thumbnails") MultipartFile[] thumbnails) { - log.info("ids: {}", workspaceId); - log.info("Files: {}", files != null ? files.length : "null"); - log.info("Thumbnails: {}", thumbnails != null ? thumbnails.length : "null"); - - UploadFileRequestDto uploadFileRequest = new UploadFileRequestDto(); - uploadFileRequest.setWorkspaceId(workspaceId); - uploadFileRequest.setChannelId(channelId); - uploadFileRequest.setFiles(files); - uploadFileRequest.setThumbnails(thumbnails); - - UploadFileResponseDto response = fileService.uploadFiles(1L, uploadFileRequest); + @PostMapping("/files") + public ResponseEntity uploadFiles(@ModelAttribute UploadFileRequestDto uploadFileRequest) { + log.info("got uploadFileRequest: {}", uploadFileRequest); + ValidationUtils.validateLengthOfFilesAndThumbnails(uploadFileRequest.getFiles().length, uploadFileRequest.getThumbnails().length); + ValidationUtils.validateWorkSpaceId(uploadFileRequest.getWorkspaceId()); + ValidationUtils.validateChannelId(uploadFileRequest.getChannelId()); + ValidationUtils.validateFiles(uploadFileRequest.getFiles()); + ValidationUtils.validateFiles(uploadFileRequest.getThumbnails()); + + log.info("got uploadFileRequest: {}", uploadFileRequest.getFiles().length); + UploadFileResponseDto response = fileService.uploadFiles(userId, uploadFileRequest); return ResponseEntity.ok(response); } + @GetMapping("/files/{fileId}") + public ResponseEntity downloadFile(@PathVariable Long fileId) { + log.info("got downloadFile id: {}", fileId); + ValidationUtils.validateFileId(fileId); + + ResponseInputStream s3InputStream = fileService.downloadFile(fileId); + + // response 생성 + long contentLength = s3InputStream.response().contentLength(); + + // Content-Type 가져오기 기본값: application/octet-stream + String contentType = s3InputStream.response().contentType() != null + ? s3InputStream.response().contentType() + : MediaType.APPLICATION_OCTET_STREAM_VALUE; + + // 헤더 설정 + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType(contentType)); + headers.setContentLength(contentLength); + headers.setContentDispositionFormData("attachment", "file-" + fileId); + + return ResponseEntity.ok() + .headers(headers) + .body(new InputStreamResource(s3InputStream)); + } + + @PostMapping("/{userId}/profile-image") + public ResponseEntity changeProfile( + @PathVariable Long userId, + @RequestParam("newImage") MultipartFile newImage) { + log.info("got new profile Image: {}", newImage); + ValidationUtils.validateFile(newImage); + + ChangeProfileResponseDto response = fileService.changeProfile(userId, newImage); + return ResponseEntity.ok(response); + } } diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/ChangeProfileResponseDto.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/ChangeProfileResponseDto.java new file mode 100644 index 00000000..784b3656 --- /dev/null +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/dto/ChangeProfileResponseDto.java @@ -0,0 +1,14 @@ +package com.jootalkpia.file_server.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@AllArgsConstructor +public class ChangeProfileResponseDto { + private Long userId; + private String nickname; + private String profileImage; +} diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/entity/BaseTimeEntity.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/entity/BaseTimeEntity.java new file mode 100644 index 00000000..5224d39b --- /dev/null +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/entity/BaseTimeEntity.java @@ -0,0 +1,21 @@ +package com.jootalkpia.file_server.entity; + +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import java.time.LocalDateTime; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseTimeEntity { + + @CreatedDate + private LocalDateTime createdAt; + + @LastModifiedDate + protected LocalDateTime updatedAt; +} \ No newline at end of file diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/entity/User.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/entity/User.java new file mode 100644 index 00000000..fbefac3d --- /dev/null +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/entity/User.java @@ -0,0 +1,39 @@ +package com.jootalkpia.file_server.entity; + +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "users") +@Builder +@NoArgsConstructor +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class User extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long userId; + + private Long socialId; + + private String email; + + private String platform; + + private String nickname; + + private String profileImage; + + public void updateProfileImage(final String newProfileImage) { + this.profileImage = newProfileImage; + } +} \ No newline at end of file diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/exception/common/CustomException.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/exception/common/CustomException.java new file mode 100644 index 00000000..5aa41be8 --- /dev/null +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/exception/common/CustomException.java @@ -0,0 +1,17 @@ +package com.jootalkpia.file_server.exception.common; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class CustomException extends RuntimeException { + + private final String code; + + public CustomException(String code, String message) { + super(message); + this.code = code; + } + +} 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 new file mode 100644 index 00000000..e0a7df4a --- /dev/null +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/exception/common/ErrorCode.java @@ -0,0 +1,37 @@ +package com.jootalkpia.file_server.exception.common; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorCode { + + // 400 Bad Request + UNKNOWN("F00001", "알 수 없는 에러가 발생했습니다."), + BAD_REQUEST("F40001", "잘못된 요청입니다."), + VALIDATION_FAILED("F40002", "유효성 검증에 실패했습니다."), + MISSING_PARAMETER("F40003", "필수 파라미터가 누락되었습니다."), + INVALID_PARAMETER("F40004", "잘못된 파라미터가 포함되었습니다."), + MISSING_FILES("F40005", "파일이 포함되어야 합니다."), + + // 404 Not Found + WORKSPACE_NOT_FOUND("F40401", "등록되지 않은 워크스페이스입니다."), + CHANNEL_NOT_FOUND("F40402", "등록되지 않은 채널입니다."), + USER_NOT_FOUND("F40403", "등록되지 않은 유저입니다."), + FILE_NOT_FOUND("F40404", "등록되지 않은 파일입니다."), + + // 415 Unsupported Type + UNSUPPORTED_FILE_TYPE("F41501", "지원되지 않는 파일 타입입니다."), + + // 500 Internal Server Error + INTERNAL_SERVER_ERROR("F50001", "서버 내부 오류가 발생했습니다."), + DATABASE_ERROR("F50002", "데이터베이스 처리 중 오류가 발생했습니다."), + IMAGE_UPLOAD_FAILED("F50003", "파일 업로드에 실패했습니다."), + IMAGE_DOWNLOAD_FAILED("F50004", "파일 다운로드 중 오류가 발생했습니다."), + FILE_PROCESSING_FAILED("F50005", "파일 처리 중 오류가 발생했습니다."), + UNEXPECTED_ERROR("F50006", "예상치 못한 오류가 발생했습니다."); + + private final String code; + private final String msg; +} diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/exception/common/ErrorResponse.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/exception/common/ErrorResponse.java new file mode 100644 index 00000000..fd26bbec --- /dev/null +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/exception/common/ErrorResponse.java @@ -0,0 +1,11 @@ +package com.jootalkpia.file_server.exception.common; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@Data +@AllArgsConstructor +public class ErrorResponse { + private String code; + private String message; +} diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/exception/common/GlobalExceptionHandler.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/exception/common/GlobalExceptionHandler.java new file mode 100644 index 00000000..00cb7173 --- /dev/null +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/exception/common/GlobalExceptionHandler.java @@ -0,0 +1,22 @@ +package com.jootalkpia.file_server.exception.common; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(CustomException.class) + public ResponseEntity handleCustomException(CustomException ex) { + ErrorResponse errorResponse = new ErrorResponse(ex.getCode(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleGenericException(Exception ex) { + ErrorResponse errorResponse = new ErrorResponse("UNKNOWN_ERROR", "An unexpected error occurred."); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } +} diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/repository/FileRepository.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/repository/FileRepository.java index d1826565..6d5e903a 100644 --- a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/repository/FileRepository.java +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/repository/FileRepository.java @@ -6,4 +6,5 @@ @Repository public interface FileRepository extends JpaRepository { + } diff --git a/src/backend/file_server/src/main/java/com/jootalkpia/file_server/repository/UserRepository.java b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/repository/UserRepository.java new file mode 100644 index 00000000..30b03a1e --- /dev/null +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/repository/UserRepository.java @@ -0,0 +1,7 @@ +package com.jootalkpia.file_server.repository; + +import com.jootalkpia.file_server.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { +} 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 fd62b8cb..75331647 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,17 +1,23 @@ package com.jootalkpia.file_server.service; +import com.jootalkpia.file_server.dto.ChangeProfileResponseDto; import com.jootalkpia.file_server.dto.UploadFileRequestDto; import com.jootalkpia.file_server.dto.UploadFileResponseDto; import com.jootalkpia.file_server.entity.Files; +import com.jootalkpia.file_server.entity.User; +import com.jootalkpia.file_server.exception.common.CustomException; +import com.jootalkpia.file_server.exception.common.ErrorCode; import com.jootalkpia.file_server.repository.FileRepository; +import com.jootalkpia.file_server.repository.UserRepository; import jakarta.transaction.Transactional; +import java.util.ArrayList; +import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; - -import java.util.ArrayList; -import java.util.List; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; @Service @Slf4j @@ -20,6 +26,7 @@ public class FileService { private final FileRepository fileRepository; private final S3Service s3Service; + private final UserRepository userRepository; @Transactional public UploadFileResponseDto uploadFiles(Long userId, UploadFileRequestDto uploadFileRequestDto) { @@ -29,57 +36,82 @@ public UploadFileResponseDto uploadFiles(Long userId, UploadFileRequestDto uploa List fileTypes = new ArrayList<>(); List fileIds = new ArrayList<>(); - try { - if (files != null && files.length > 0) { - for (int i = 0; i < files.length; i++) { - Long fileId = null; - Files filesEntity = new Files(); - fileRepository.save(filesEntity); - fileId = filesEntity.getFileId(); + for (int i = 0; i < files.length; i++) { + Long fileId = null; + Files filesEntity = new Files(); + fileRepository.save(filesEntity); + fileId = filesEntity.getFileId(); - MultipartFile file = files[i]; + MultipartFile file = files[i]; - // 파일 타입 결정 - String fileType = FileTypeDetector.detectFileTypeFromMultipartFile(file); + // 파일 타입 결정 + String fileType = FileTypeDetector.detectFileTypeFromMultipartFile(file); + log.info(fileType); - // S3에 업로드 - String s3Url = s3Service.uploadFile(file, fileType, fileId); + String s3Url = uploadEachFile(fileType, fileId, file); - // DB에 파일 저장 - filesEntity.setUrl(s3Url); - filesEntity.setFileType(fileType); - filesEntity.setMimeType(file.getContentType()); - filesEntity.setFileSize(file.getSize()); - fileRepository.save(filesEntity); + filesEntity.setUrl(s3Url); + filesEntity.setMimeType(file.getContentType()); + filesEntity.setFileType(fileType); + filesEntity.setFileSize(file.getSize()); - fileIds.add(filesEntity.getFileId()); - fileTypes.add(filesEntity.getFileType()); + fileIds.add(filesEntity.getFileId()); + fileTypes.add(filesEntity.getFileType()); - // 영상 파일일 경우 썸네일 처리 - if ("VIDEO".equalsIgnoreCase(fileType) && thumbnails != null && i < thumbnails.length && thumbnails[i] != null) { - fileId = null; - filesEntity = new Files(); - fileRepository.save(filesEntity); - fileId = filesEntity.getFileId(); + if ("VIDEO".equalsIgnoreCase(fileType) && thumbnails != null && i < thumbnails.length && thumbnails[i] != null) { + MultipartFile thumbnail = thumbnails[i]; + String thumbnailUrl = uploadEachFile(fileType, fileId, thumbnail); + filesEntity.setUrlThumbnail(thumbnailUrl); + } + fileRepository.save(filesEntity); + } + return new UploadFileResponseDto(fileTypes, fileIds); + } - MultipartFile thumbnail = thumbnails[i]; - String thumbnailUrl = s3Service.uploadFile(thumbnail, "THUMBNAIL", fileId); + private String uploadEachFile(String fileType, Long fileId, MultipartFile file) { + String folder = defineFolderToUpload(fileType) + "/"; + return s3Service.uploadFile(file, folder, fileId); + } - filesEntity.setUrlThumbnail(thumbnailUrl); + public ResponseInputStream downloadFile(Long fileId) { + // 파일 조회 + Files fileEntity = fileRepository.findById(fileId) + .orElseThrow(() -> new CustomException(ErrorCode.FILE_NOT_FOUND.getCode(), ErrorCode.FILE_NOT_FOUND.getMsg())); - fileRepository.save(filesEntity); + // 폴더 결정 + String folder = defineFolderToUpload(fileEntity.getFileType()); -// fileIds.add(filesEntity.getFileId()); -// fileTypes.add("THUMBNAIL"); - } - } - } + // S3에서 파일 다운로드 + return s3Service.downloadFile(folder, fileId); + } + + public String defineFolderToUpload(String fileType) { + if ("VIDEO".equalsIgnoreCase(fileType)) { + return "videos"; + } else if ("IMAGE".equalsIgnoreCase(fileType)) { + return "images"; + } else if ("THUMBNAIL".equalsIgnoreCase(fileType)) { + return "thumbnails"; + } else { + throw new CustomException(ErrorCode.FILE_PROCESSING_FAILED.getCode(), ErrorCode.FILE_PROCESSING_FAILED.getMsg()); + } + } + + @Transactional + public ChangeProfileResponseDto changeProfile(Long userId, MultipartFile newImage) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND.getCode(), ErrorCode.USER_NOT_FOUND.getMsg())); + + try { + String fileType = FileTypeDetector.detectFileTypeFromMultipartFile(newImage); + String s3Url = uploadEachFile(fileType, userId, newImage); - return new UploadFileResponseDto(fileTypes, fileIds); + user.updateProfileImage(s3Url); + userRepository.save(user); + return new ChangeProfileResponseDto(userId, user.getNickname(), s3Url); } catch (Exception e) { - log.error("파일 업로드 중 오류 발생: {}", e.getMessage(), e); - throw new RuntimeException("파일 업로드 중 오류가 발생했습니다."); + throw new CustomException(ErrorCode.IMAGE_UPLOAD_FAILED.getCode(), ErrorCode.IMAGE_UPLOAD_FAILED.getMsg()); } } } 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 65bd22a2..9622c487 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 @@ -1,19 +1,20 @@ package com.jootalkpia.file_server.service; +import com.jootalkpia.file_server.exception.common.CustomException; +import com.jootalkpia.file_server.exception.common.ErrorCode; import java.io.IOException; +import java.io.InputStream; import org.apache.tika.Tika; -import org.apache.tika.mime.MimeTypeException; -import org.apache.tika.mime.MimeTypes; import org.springframework.web.multipart.MultipartFile; public class FileTypeDetector { private static final Tika tika = new Tika(); - public static String detectFileType(byte[] fileBytes) { - try { - // 파일의 MIME 타입 추출 - String mimeType = tika.detect(fileBytes); + public static String detectFileTypeFromMultipartFile(MultipartFile file) { + try (InputStream inputStream = file.getInputStream()) { + // 파일을 한 번에 메모리에 로드하지 않고 스트리밍 방식으로 MIME 타입 감지 + String mimeType = tika.detect(inputStream); if (mimeType.startsWith("image/")) { return "IMAGE"; @@ -22,18 +23,8 @@ public static String detectFileType(byte[] fileBytes) { } else { return "UNKNOWN"; } - } catch (Exception e) { - throw new RuntimeException("파일 타입 분석 중 오류 발생: " + e.getMessage(), e); - } - } - - public static String detectFileTypeFromMultipartFile(MultipartFile file) { - try { - // MultipartFile로부터 바이트 배열 추출 - return detectFileType(file.getBytes()); } catch (IOException e) { - throw new RuntimeException("파일 분석 중 오류 발생: " + e.getMessage(), e); + throw new CustomException(ErrorCode.UNSUPPORTED_FILE_TYPE.getCode(), ErrorCode.UNSUPPORTED_FILE_TYPE.getMsg()); } } } - 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 3860a5d1..ce5132f8 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 @@ -1,17 +1,23 @@ package com.jootalkpia.file_server.service; +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 lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.ResponseInputStream; +import software.amazon.awssdk.core.exception.SdkClientException; 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 java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Path; - @Service @Slf4j @RequiredArgsConstructor @@ -21,28 +27,19 @@ public class S3Service { @Value("${spring.cloud.aws.s3.bucket}") private String bucketName; + @Value("${spring.cloud.aws.region.static}") + private String region; - public String uploadFile(MultipartFile file, String fileType, Long fileId) { + public String uploadFile(MultipartFile file, String folder, Long fileId) { Path tempFile = null; try { - // 폴더 경로 설정 - String folder; - if ("IMAGE".equalsIgnoreCase(fileType)) { - folder = "images/"; - } else if ("VIDEO".equalsIgnoreCase(fileType)) { - folder = "videos/"; - } else if ("THUMBNAIL".equalsIgnoreCase(fileType)) { - folder = "thumbnails/"; - } else { - throw new IllegalArgumentException("Unsupported file type: " + fileType); - } // S3에 저장될 파일 키 생성 String key = folder + fileId; // 임시 파일 생성 tempFile = Files.createTempFile("temp-", ".tmp"); - file.transferTo(tempFile.toFile()); // MultipartFile -> 임시 파일 저장 + file.transferTo(tempFile.toFile()); // S3에 업로드 s3Client.putObject( @@ -53,13 +50,23 @@ public String uploadFile(MultipartFile file, String fileType, Long fileId) { .build(), tempFile); - log.info("파일 업로드 완료 - S3 Key: {}", key); - return "https://" + bucketName + ".s3.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("파일 업로드 중 오류 발생: {}", e.getMessage(), e); - throw new RuntimeException("파일 업로드 중 오류가 발생했습니다."); - } finally { + log.error("파일 업로드 중 IOException 발생: {}", e.getMessage(), e); + throw new CustomException(ErrorCode.IMAGE_UPLOAD_FAILED.getCode(), ErrorCode.IMAGE_UPLOAD_FAILED.getMsg()); + + } catch (SdkClientException e) { + log.error("S3 클라이언트 예외 발생: {}", e.getMessage(), e); + throw new CustomException(ErrorCode.IMAGE_UPLOAD_FAILED.getCode(), "S3 클라이언트 오류 발생"); + + } catch (Exception e) { + log.error("알 수 없는 오류 발생: {}", e.getMessage(), e); + throw new CustomException(ErrorCode.UNKNOWN.getCode(), "알 수 없는 오류 발생"); + + } + finally { // 임시 파일 삭제 try { if (tempFile != null && Files.exists(tempFile)) { @@ -71,4 +78,28 @@ public String uploadFile(MultipartFile file, String fileType, Long fileId) { } } } + + public ResponseInputStream downloadFile(String folder, Long fileId) { + String key = folder + "/" + fileId; + + try { + return s3Client.getObject( + GetObjectRequest.builder() + .bucket(bucketName) + .key(key) + .build()); + + } catch (NoSuchKeyException e) { + log.error("S3에서 파일을 찾을 수 없음: key={}", key, e); + throw new CustomException(ErrorCode.FILE_NOT_FOUND.getCode(), ErrorCode.FILE_NOT_FOUND.getMsg()); + + } catch (SdkClientException e) { + log.error("S3 클라이언트 오류 발생: key={}, 오류={}", key, e.getMessage(), e); + throw new CustomException(ErrorCode.IMAGE_DOWNLOAD_FAILED.getCode(), ErrorCode.IMAGE_DOWNLOAD_FAILED.getMsg()); + + } catch (Exception e) { + log.error("S3 파일 다운로드 중 오류 발생 - key: {}, 오류: {}", key, e.getMessage(), e); + throw new CustomException(ErrorCode.IMAGE_DOWNLOAD_FAILED.getCode(), ErrorCode.IMAGE_DOWNLOAD_FAILED.getMsg()); + } + } } 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 new file mode 100644 index 00000000..1e3e808a --- /dev/null +++ b/src/backend/file_server/src/main/java/com/jootalkpia/file_server/utils/ValidationUtils.java @@ -0,0 +1,48 @@ +package com.jootalkpia.file_server.utils; + +import com.jootalkpia.file_server.exception.common.CustomException; +import com.jootalkpia.file_server.exception.common.ErrorCode; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +@AllArgsConstructor +public class ValidationUtils { + + public static void validateWorkSpaceId(Long workSpaceId) { + if (workSpaceId == null || workSpaceId <= 0) { + throw new CustomException(ErrorCode.INVALID_PARAMETER.getCode(), ErrorCode.INVALID_PARAMETER.getMsg()); + } + } + + public static void validateChannelId(Long channelId) { + if (channelId == null || channelId <= 0) { + throw new CustomException(ErrorCode.INVALID_PARAMETER.getCode(), ErrorCode.INVALID_PARAMETER.getMsg()); + } + } + + public static void validateFileId(Long fileId) { + if (fileId == null || fileId <= 0) { + throw new CustomException(ErrorCode.INVALID_PARAMETER.getCode(), ErrorCode.INVALID_PARAMETER.getMsg()); + } + } + + public static void validateFiles(MultipartFile[] files) { + if (files == null || files.length == 0) { + throw new CustomException(ErrorCode.MISSING_FILES.getCode(), ErrorCode.MISSING_FILES.getMsg()); + } + } + + public static void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new CustomException(ErrorCode.MISSING_FILES.getCode(), ErrorCode.MISSING_FILES.getMsg()); + } + } + + public static void validateLengthOfFilesAndThumbnails(int fileLength, int thumbnailLength) { + if (fileLength < thumbnailLength) { + throw new CustomException(ErrorCode.INVALID_PARAMETER.getCode(), ErrorCode.INVALID_PARAMETER.getMsg()); + } + } +} \ No newline at end of file diff --git a/src/backend/file_server/src/main/resources/application.yml b/src/backend/file_server/src/main/resources/application.yml index e0c8d3fe..5ff2ff7b 100644 --- a/src/backend/file_server/src/main/resources/application.yml +++ b/src/backend/file_server/src/main/resources/application.yml @@ -1,5 +1,5 @@ server: - port: 8080 + port: ${FILE_PORT} tomcat: max-swallow-size: -1 spring: