diff --git a/core/src/main/java/com/pocket/core/image/S3TestController.java b/core/src/main/java/com/pocket/core/image/S3TestController.java new file mode 100644 index 0000000..e469421 --- /dev/null +++ b/core/src/main/java/com/pocket/core/image/S3TestController.java @@ -0,0 +1,25 @@ +package com.pocket.core.image; + +import com.pocket.core.exception.common.ApiResponse; +import com.pocket.core.image.service.AwsS3Service; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/v1/test") +public class S3TestController { + + private final AwsS3Service awsS3Service; + + @PostMapping(value = "/uploadFile", consumes = "multipart/form-data") + public ApiResponse uploadFile(@RequestPart(value = "file", required = false) MultipartFile file) { + return ApiResponse.onSuccess(awsS3Service.uploadFile(file)); + } +} diff --git a/core/src/main/java/com/pocket/core/image/config/AwsS3Config.java b/core/src/main/java/com/pocket/core/image/config/AwsS3Config.java new file mode 100644 index 0000000..40c4528 --- /dev/null +++ b/core/src/main/java/com/pocket/core/image/config/AwsS3Config.java @@ -0,0 +1,35 @@ +package com.pocket.core.image.config; + +import com.amazonaws.auth.AWSCredentials; +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class AwsS3Config { + + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3 generateS3client() { + AWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + + } +} diff --git a/core/src/main/java/com/pocket/core/image/dto/PresignedUrlResponse.java b/core/src/main/java/com/pocket/core/image/dto/PresignedUrlResponse.java new file mode 100644 index 0000000..8979739 --- /dev/null +++ b/core/src/main/java/com/pocket/core/image/dto/PresignedUrlResponse.java @@ -0,0 +1,17 @@ +package com.pocket.core.image.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@Schema(description = "프리사인드 URL 응답 DTO") +public class PresignedUrlResponse { + + @Schema(description = "생성된 프리사인드 URL", example = "https://example.com/presigned-url") + private final String url; + + @Schema(description = "파일 경로", example = "images/example.txt") + private final String filePath; +} \ No newline at end of file diff --git a/core/src/main/java/com/pocket/core/image/exception/FileDeleteException.java b/core/src/main/java/com/pocket/core/image/exception/FileDeleteException.java new file mode 100644 index 0000000..b33a8de --- /dev/null +++ b/core/src/main/java/com/pocket/core/image/exception/FileDeleteException.java @@ -0,0 +1,13 @@ +package com.pocket.core.image.exception; + +import com.pocket.core.exception.common.BaseErrorCode; + +public class FileDeleteException extends ImageException { + public FileDeleteException(BaseErrorCode errorCode) { + super(errorCode); + } + + public FileDeleteException(BaseErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} diff --git a/core/src/main/java/com/pocket/core/image/exception/FileExtensionException.java b/core/src/main/java/com/pocket/core/image/exception/FileExtensionException.java new file mode 100644 index 0000000..c4f52bd --- /dev/null +++ b/core/src/main/java/com/pocket/core/image/exception/FileExtensionException.java @@ -0,0 +1,13 @@ +package com.pocket.core.image.exception; + +import com.pocket.core.exception.common.BaseErrorCode; + +public class FileExtensionException extends ImageException { + public FileExtensionException(BaseErrorCode errorCode) { + super(errorCode); + } + + public FileExtensionException(BaseErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} diff --git a/core/src/main/java/com/pocket/core/image/exception/FileUploadException.java b/core/src/main/java/com/pocket/core/image/exception/FileUploadException.java new file mode 100644 index 0000000..7b87f8c --- /dev/null +++ b/core/src/main/java/com/pocket/core/image/exception/FileUploadException.java @@ -0,0 +1,13 @@ +package com.pocket.core.image.exception; + +import com.pocket.core.exception.common.BaseErrorCode; + +public class FileUploadException extends ImageException { + public FileUploadException(BaseErrorCode errorCode) { + super(errorCode); + } + + public FileUploadException(BaseErrorCode errorCode, Throwable cause) { + super(errorCode, cause); + } +} diff --git a/core/src/main/java/com/pocket/core/image/exception/ImageErrorCode.java b/core/src/main/java/com/pocket/core/image/exception/ImageErrorCode.java new file mode 100644 index 0000000..c028fb9 --- /dev/null +++ b/core/src/main/java/com/pocket/core/image/exception/ImageErrorCode.java @@ -0,0 +1,27 @@ +package com.pocket.core.image.exception; + +import com.pocket.core.exception.common.ApiResponse; +import com.pocket.core.exception.common.BaseErrorCode; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum ImageErrorCode implements BaseErrorCode { + + FILE_UPLOAD_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "3000", "파일 업로드에 실패했습니다."), + FILE_DELETE_FAIL(HttpStatus.INTERNAL_SERVER_ERROR, "3000", "파일 삭제에 실패했습니다."), + WRONG_FILE_FORMAT(HttpStatus.INTERNAL_SERVER_ERROR, "3000", "파일 타입이 올바르지 않습니다."), + ; + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + + @Override + public ApiResponse getErrorResponse() { + return null; + } +} diff --git a/core/src/main/java/com/pocket/core/image/exception/ImageException.java b/core/src/main/java/com/pocket/core/image/exception/ImageException.java new file mode 100644 index 0000000..a73ad22 --- /dev/null +++ b/core/src/main/java/com/pocket/core/image/exception/ImageException.java @@ -0,0 +1,22 @@ +package com.pocket.core.image.exception; + +import com.pocket.core.exception.common.BaseErrorCode; +import lombok.Getter; + +@Getter +public class ImageException extends RuntimeException { + + private final BaseErrorCode errorCode; + + private final Throwable cause; + + public ImageException(BaseErrorCode errorCode) { + this.errorCode = errorCode; + this.cause = null; + } + + public ImageException(BaseErrorCode errorCode, Throwable cause) { + this.errorCode = errorCode; + this.cause = cause; + } +} \ No newline at end of file diff --git a/core/src/main/java/com/pocket/core/image/service/AwsS3Service.java b/core/src/main/java/com/pocket/core/image/service/AwsS3Service.java new file mode 100644 index 0000000..2ef8a58 --- /dev/null +++ b/core/src/main/java/com/pocket/core/image/service/AwsS3Service.java @@ -0,0 +1,81 @@ +package com.pocket.core.image.service; + +import com.amazonaws.AmazonServiceException; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.ObjectMetadata; +import com.amazonaws.services.s3.model.PutObjectRequest; +import com.pocket.core.image.exception.FileDeleteException; +import com.pocket.core.image.exception.FileExtensionException; +import com.pocket.core.image.exception.FileUploadException; +import com.pocket.core.image.exception.ImageErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Objects; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class AwsS3Service { + + private final AmazonS3 amazonS3; + @Value("${cloud.aws.s3.bucket}") + private String bucketName; + + /** + * file upload + */ + public String uploadFile(MultipartFile multipartFile) { + if (Objects.isNull(multipartFile)) return null; + if (multipartFile.isEmpty()) return null; + + String fileName = createFileName(multipartFile.getOriginalFilename()); + + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentType(multipartFile.getContentType()); + + try (InputStream inputStream = multipartFile.getInputStream()) { + amazonS3.putObject(new PutObjectRequest(bucketName, fileName, inputStream, objectMetadata)); + } catch (IOException e) { + throw new FileUploadException(ImageErrorCode.FILE_UPLOAD_FAIL); + } + + return amazonS3.getUrl(bucketName, fileName).toString(); + } + + + /** + * 파일 삭제 메서드 + */ + public void deleteFile(String fileUrl) { + if (fileUrl == null) return; + try { + amazonS3.deleteObject(bucketName, fileUrl); + } catch (AmazonServiceException e) { + throw new FileDeleteException(ImageErrorCode.FILE_DELETE_FAIL); + } + } + + /** + * 파일 업로드 시에 파일명을 난수화하는 메서드 + */ + private String createFileName(String fileName) { + return UUID.randomUUID().toString().concat(getFileExtension(fileName)); + } + + /** + * 파일 확장자 가져오는 메서드 + */ + private String getFileExtension(String fileName) { + try { + return fileName.substring(fileName.lastIndexOf(".")); + } catch (StringIndexOutOfBoundsException e) { + throw new FileExtensionException(ImageErrorCode.WRONG_FILE_FORMAT); + } + } + +} diff --git a/core/src/main/java/com/pocket/core/image/service/FileService.java b/core/src/main/java/com/pocket/core/image/service/FileService.java new file mode 100644 index 0000000..194d784 --- /dev/null +++ b/core/src/main/java/com/pocket/core/image/service/FileService.java @@ -0,0 +1,72 @@ +package com.pocket.core.image.service; + +import com.amazonaws.HttpMethod; +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; +import com.pocket.core.image.dto.PresignedUrlResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.net.URL; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class FileService { + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + @Value("${cloud.aws.s3.expTime}") + private Long expTime; + + + private final AmazonS3 amazonS3; + + public PresignedUrlResponse getUploadPresignedUrl(String prefix, String originalFileName) { + String filePath = createPath(prefix, originalFileName); + GeneratePresignedUrlRequest generatePresignedUrlRequest = getGeneratePresignedUrlRequest(bucket, filePath, HttpMethod.PUT); + URL url = amazonS3.generatePresignedUrl(generatePresignedUrlRequest); + + return new PresignedUrlResponse(url.toString(), filePath); + } + + public String getDownloadPresignedUrl(String filePath) { + if (filePath != null && filePath.startsWith("images/")) { + GeneratePresignedUrlRequest generatePresignedUrlRequest = getGeneratePresignedUrlRequest(bucket, filePath, HttpMethod.GET); + URL url = amazonS3.generatePresignedUrl(generatePresignedUrlRequest); + return url.toString(); + } + + return filePath; + } + + private GeneratePresignedUrlRequest getGeneratePresignedUrlRequest(String bucket, String fileName, HttpMethod method) { + + return new GeneratePresignedUrlRequest(bucket, fileName) + .withMethod(method) + .withExpiration(getPresignedUrlExpiration()); + } + + private Date getPresignedUrlExpiration() { + Date expiration = new Date(); + long expTimeMillis = expiration.getTime(); + expTimeMillis += expTime; + expiration.setTime(expTimeMillis); + + return expiration; + } + + private String createFileId() { + return UUID.randomUUID().toString(); + } + + private String createPath(String prefix, String fileName) { + String fileId = createFileId(); + String timestamp = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date()); + return String.format("%s/%s-%s-%s", prefix, timestamp, fileId, fileName); + } +}