diff --git a/build.gradle b/build.gradle index a0a581b..4d9db4d 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,8 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' + + implementation 'software.amazon.awssdk:s3:2.28.23' } tasks.named('test') { diff --git a/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java b/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java index 8910414..11f6b4e 100644 --- a/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java +++ b/src/main/java/dmu/dasom/api/domain/common/exception/ErrorCode.java @@ -34,7 +34,7 @@ public enum ErrorCode { SLOT_FULL(400, "C025", "해당 슬롯이 가득 찼습니다."), RESERVATION_NOT_FOUND(400, "C026", "예약을 찾을 수 없습니다."), SLOT_NOT_ACTIVE(400, "C027", "해당 슬롯이 비활성화 되었습니다."), - FILE_ENCODE_FAIL(400, "C028", "파일 인코딩에 실패하였습니다."), + FILE_UPLOAD_FAIL(400, "C028", "파일 업로드에 실패하였습니다."), RECRUITMENT_NOT_ACTIVE(400, "C029", "모집 기간이 아닙니다."), NOT_FOUND_PARTICIPANT(400, "C030", "참가자를 찾을 수 없습니다.") ; diff --git a/src/main/java/dmu/dasom/api/global/R2/config/R2Config.java b/src/main/java/dmu/dasom/api/global/R2/config/R2Config.java new file mode 100644 index 0000000..293bf8f --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/R2/config/R2Config.java @@ -0,0 +1,35 @@ +package dmu.dasom.api.global.R2.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +import java.net.URI; + +@Configuration +public class R2Config { + + @Value("${cloudflare.r2.endpoint}") + private String endPoint; + + @Value("${cloudflare.r2.access-key-id}") + private String accessKeyId; + + @Value("${cloudflare.r2.secret-access-key}") + private String secretAccessKey; + + @Bean + public S3Client s3Client() { + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKeyId, secretAccessKey); + + return S3Client.builder() + .region(Region.of("auto")) // R2 does not require a specific region + .endpointOverride(URI.create(endPoint)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build(); + } +} diff --git a/src/main/java/dmu/dasom/api/global/R2/service/R2Service.java b/src/main/java/dmu/dasom/api/global/R2/service/R2Service.java new file mode 100644 index 0000000..6747845 --- /dev/null +++ b/src/main/java/dmu/dasom/api/global/R2/service/R2Service.java @@ -0,0 +1,56 @@ +package dmu.dasom.api.global.R2.service; + +import dmu.dasom.api.domain.common.exception.CustomException; +import dmu.dasom.api.domain.common.exception.ErrorCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.io.IOException; +import java.util.UUID; + +@Service +public class R2Service { + + private final S3Client s3Client; + + @Value("${cloudflare.r2.bucket}") + private String bucket; + + @Value("${cloudflare.r2.public-url}") + private String publicUrl; + + public R2Service(S3Client s3Client) { + this.s3Client = s3Client; + } + + public String uploadFile(MultipartFile file) { + String key = UUID.randomUUID() + "_" + file.getOriginalFilename(); + try { + PutObjectRequest request = PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(file.getContentType()) + .build(); + + s3Client.putObject(request, + RequestBody.fromInputStream(file.getInputStream(), file.getSize())); + } catch (IOException e) { + throw new CustomException(ErrorCode.FILE_UPLOAD_FAIL); + } + return publicUrl + "/" + key; + } + + public void deleteFile(String fileUrl) { + String key = fileUrl.replace(publicUrl + "/", ""); + DeleteObjectRequest request = DeleteObjectRequest.builder() + .bucket(bucket) + .key(key) + .build(); + s3Client.deleteObject(request); + } +} diff --git a/src/main/java/dmu/dasom/api/global/admin/controller/AdminFileController.java b/src/main/java/dmu/dasom/api/global/admin/controller/AdminFileController.java index 8fa6ee7..9d3b6b2 100644 --- a/src/main/java/dmu/dasom/api/global/admin/controller/AdminFileController.java +++ b/src/main/java/dmu/dasom/api/global/admin/controller/AdminFileController.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -40,14 +41,14 @@ public class AdminFileController { )) }) @PostMapping(value = "/upload", consumes = {"multipart/form-data"}) - public ResponseEntity uploadFiles( + public ResponseEntity> uploadFiles( @RequestParam("files") List files, @RequestParam("fileType") FileType fileType, @RequestParam("targetId") @Min(1) Long targetId ) { - fileService.uploadFiles(files, fileType, targetId); - return ResponseEntity.ok() - .build(); + List fileResponseDtos = fileService.uploadFiles(files, fileType, targetId); + return ResponseEntity.status(HttpStatus.CREATED) + .body(fileResponseDtos); } @ApiResponses(value = { diff --git a/src/main/java/dmu/dasom/api/global/file/dto/FileResponseDto.java b/src/main/java/dmu/dasom/api/global/file/dto/FileResponseDto.java index eee46ff..1f48aa6 100644 --- a/src/main/java/dmu/dasom/api/global/file/dto/FileResponseDto.java +++ b/src/main/java/dmu/dasom/api/global/file/dto/FileResponseDto.java @@ -15,12 +15,11 @@ public class FileResponseDto { @NotNull private Long id; - @Schema(description = "파일 형식", example = "image/png") + @Schema(description = "파일 URL", example = "url") @NotNull - private String fileFormat; + private String encodedData; // r2에 저장된 파일의 URL - @Schema(description = "인코딩 된 파일", example = "base64encoded") + @Schema(description = "파일 형식", example = "image/png") @NotNull - private String encodedData; // Base64 인코딩 데이터 - + private String fileFormat; } diff --git a/src/main/java/dmu/dasom/api/global/file/entity/FileEntity.java b/src/main/java/dmu/dasom/api/global/file/entity/FileEntity.java index 2aea9a9..62b923e 100644 --- a/src/main/java/dmu/dasom/api/global/file/entity/FileEntity.java +++ b/src/main/java/dmu/dasom/api/global/file/entity/FileEntity.java @@ -21,8 +21,8 @@ public class FileEntity { private String originalName; @Lob - @Column(name = "ENCODED_DATA", nullable = false, columnDefinition = "CLOB") - private String encodedData; + @Column(name = "FILE_URL", nullable = false, columnDefinition = "CLOB") + private String fileUrl; @Column(name = "FILE_FORMAT", nullable = false) private String fileFormat; @@ -41,7 +41,7 @@ public FileResponseDto toResponseDto() { return FileResponseDto.builder() .id(id) .fileFormat(fileFormat) - .encodedData(encodedData) + .encodedData(fileUrl) .build(); } diff --git a/src/main/java/dmu/dasom/api/global/file/service/FileService.java b/src/main/java/dmu/dasom/api/global/file/service/FileService.java index 6837b38..67e8212 100644 --- a/src/main/java/dmu/dasom/api/global/file/service/FileService.java +++ b/src/main/java/dmu/dasom/api/global/file/service/FileService.java @@ -3,6 +3,7 @@ import dmu.dasom.api.domain.common.exception.CustomException; import dmu.dasom.api.domain.common.exception.ErrorCode; import dmu.dasom.api.domain.news.entity.NewsEntity; +import dmu.dasom.api.global.R2.service.R2Service; import dmu.dasom.api.global.file.dto.FileResponseDto; import dmu.dasom.api.global.file.entity.FileEntity; import dmu.dasom.api.global.file.enums.FileType; @@ -10,10 +11,9 @@ import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.ObjectUtils; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; -import java.util.Base64; import java.util.List; import java.util.Map; import java.util.stream.Collectors; @@ -23,21 +23,27 @@ public class FileService { private final FileRepository fileRepository; + private final R2Service r2Service; // 파일 업로드 - public void uploadFiles(List files, FileType fileType, Long targetId) { + @Transactional + public List uploadFiles(List files, FileType fileType, Long targetId) { List filesToEntity = files.stream() - .map(file -> FileEntity.builder() - .originalName(file.getOriginalFilename()) - .encodedData(encode(file)) - .fileFormat(file.getContentType()) - .fileSize(file.getSize()) - .fileType(fileType) - .targetId(targetId) - .build()) - .toList(); - - fileRepository.saveAllAndFlush(filesToEntity); + .map(file -> { + String fileUrl = r2Service.uploadFile(file); + return FileEntity.builder() + .originalName(file.getOriginalFilename()) + .fileUrl(fileUrl) + .fileFormat(file.getContentType()) + .fileSize(file.getSize()) + .fileType(fileType) + .targetId(targetId) + .build(); + }).toList(); + List savedFiles = fileRepository.saveAll(filesToEntity); + return savedFiles.stream() + .map(FileEntity::toResponseDto) + .collect(Collectors.toList()); } // 단일 파일 조회 @@ -56,13 +62,17 @@ public List getFilesByTypeAndTargetId(FileType fileType, Long t .toList(); } + @Transactional public void deleteFilesByTypeAndTargetId(FileType fileType, Long targetId) { List files = findByFileTypeAndTargetId(fileType, targetId); - if (ObjectUtils.isNotEmpty(files)) - fileRepository.deleteAll(files); + if (ObjectUtils.isNotEmpty(files)) { + files.forEach(file -> r2Service.deleteFile(file.getFileUrl())); + } + fileRepository.deleteAll(files); } + @Transactional public void deleteFilesById(NewsEntity news, List fileIds) { List files = fileRepository.findAllById(fileIds); @@ -92,15 +102,4 @@ public Map getFirstFileByTypeAndTargetIds(FileType fileTy private List findByFileTypeAndTargetId(FileType fileType, Long targetId) { return fileRepository.findByFileTypeAndTargetId(fileType, targetId); } - - private String encode(MultipartFile file) { - try { - byte[] bytes = file.getBytes(); - return Base64.getEncoder() - .encodeToString(bytes); - } catch (IOException e) { - throw new CustomException(ErrorCode.FILE_ENCODE_FAIL); - } - } - } \ No newline at end of file diff --git a/src/main/resources/application-credentials.yml b/src/main/resources/application-credentials.yml index be47469..e71af2f 100644 --- a/src/main/resources/application-credentials.yml +++ b/src/main/resources/application-credentials.yml @@ -35,4 +35,11 @@ google: file: path: ${GOOGLE_CREDENTIALS_PATH} spreadsheet: - id: ${GOOGLE_SPREADSHEET_ID} \ No newline at end of file + id: ${GOOGLE_SPREADSHEET_ID} +cloudflare: + r2: + endpoint: ${CLOUDFLARE_R2_ENDPOINT} + bucket: ${CLOUDFLARE_R2_BUCKET} + access-key-id: ${CLOUDFLARE_R2_ACCESS_KEY_ID} + secret-access-key: ${CLOUDFLARE_R2_SECRET_ACCESS_KEY} + public-url: ${CLOUDFLARE_R2_PUBLIC_URL} \ No newline at end of file