diff --git a/src/main/java/com/teamEWSN/gitdeun/common/aop/GlobalLoggingAspect.java b/src/main/java/com/teamEWSN/gitdeun/common/aop/GlobalLoggingAspect.java new file mode 100644 index 0000000..d6cebdb --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/aop/GlobalLoggingAspect.java @@ -0,0 +1,41 @@ +package com.teamEWSN.gitdeun.common.aop; + +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.JoinPoint; +import org.aspectj.lang.annotation.*; +import org.springframework.stereotype.Component; + +import java.util.Arrays; + +@Slf4j +@Aspect +@Component +public class GlobalLoggingAspect { + + @Pointcut("execution(* com.teamEWSN.gitdeun..*(..))") + private void globalPointcut() { + + } + + @Before("globalPointcut()") + public void logBeforeMethod(JoinPoint joinPoint) { + String methodName = joinPoint.getSignature().toShortString(); + Object[] args = joinPoint.getArgs(); + + log.debug("[실행 메서드]: {} [매개변수]: {}", methodName, Arrays.toString(args)); + } + + @AfterReturning(value = "globalPointcut()", returning = "result") + public void logAfterMethod(JoinPoint joinPoint, Object result) { + String methodName = joinPoint.getSignature().toShortString(); + + log.debug("[종료 메서드]: {} [반환값]: {}", methodName, result); + } + + @AfterThrowing(value = "globalPointcut()", throwing = "ex") + public void logAfterThrowing(JoinPoint joinPoint, Throwable ex) { + String methodName = joinPoint.getSignature().toShortString(); + + log.error("[예외 발생 메서드]: {} [예외]: {}", methodName, ex.getMessage()); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java index fbdde60..713dae7 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/exception/ErrorCode.java @@ -24,14 +24,20 @@ public enum ErrorCode { // S3 파일 관련 - FILE_COUNT_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-001", "업로드 가능한 파일 개수를 초과했습니다."), - FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-002", "파일 크기가 허용된 용량을 초과했습니다."), - INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "FILE-003", "지원하지 않는 파일 형식입니다."), - FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE-004", "요청한 파일을 찾을 수 없습니다."), - FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-006", "파일 업로드 중 오류가 발생했습니다."), - INVALID_FILE_LIST(HttpStatus.BAD_REQUEST, "FILE-006", "파일 목록이 비어있거나 유효하지 않습니다."), - INVALID_FILE_PATH(HttpStatus.BAD_REQUEST, "FILE-007", "파일 경로나 이름이 유효하지 않습니다."), - FILE_MOVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-008", "파일 이동 중 오류가 발생했습니다."); + // Client Errors (4xx) + FILE_NOT_FOUND(HttpStatus.NOT_FOUND, "FILE-001", "요청한 파일을 찾을 수 없습니다."), + INVALID_FILE_LIST(HttpStatus.BAD_REQUEST, "FILE-002", "파일 목록이 비어있거나 유효하지 않습니다."), + INVALID_FILE_PATH(HttpStatus.BAD_REQUEST, "FILE-003", "파일 경로나 이름이 유효하지 않습니다."), + INVALID_FILE_TYPE(HttpStatus.BAD_REQUEST, "FILE-004", "지원하지 않는 파일 형식입니다."), + FILE_COUNT_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-005", "업로드 가능한 파일 개수를 초과했습니다."), + FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE-006", "파일 크기가 허용된 용량을 초과했습니다."), + INVALID_S3_URL(HttpStatus.BAD_REQUEST, "FILE-007", "S3 URL 형식이 올바르지 않습니다."), + + // Server Errors (5xx) + FILE_UPLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-501", "파일 업로드 중 서버 오류가 발생했습니다."), + FILE_DOWNLOAD_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-502", "파일 다운로드 중 서버 오류가 발생했습니다."), + FILE_DELETE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-503", "파일 삭제 중 서버 오류가 발생했습니다."), + FILE_MOVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "FILE-504", "파일 이동 중 서버 오류가 발생했습니다."); diff --git a/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java b/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java new file mode 100644 index 0000000..de56ba0 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/s3/controller/S3BucketController.java @@ -0,0 +1,56 @@ +package com.teamEWSN.gitdeun.common.s3.controller; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import com.teamEWSN.gitdeun.common.s3.service.S3BucketService; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.Resource; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.List; + +@RestController +@RequestMapping("/api/s3/bucket") +@RequiredArgsConstructor +public class S3BucketController { + + private final S3BucketService s3BucketService; + private static final int MAX_FILE_COUNT = 10; + + @PostMapping("/upload") + public ResponseEntity> uploadFiles( + @RequestParam("files") List files, + @RequestParam("path") String path + ) { + // FILE-005: 업로드 가능한 파일 개수를 초과했습니다. + if (files.size() > MAX_FILE_COUNT) { + throw new GlobalException(ErrorCode.FILE_COUNT_EXCEEDED); + } + + List fileUrls = s3BucketService.upload(files, path); + return ResponseEntity.ok(fileUrls); + } + + @DeleteMapping("/delete") + public ResponseEntity deleteFiles(@RequestBody List urls) { + s3BucketService.remove(urls); + return ResponseEntity.noContent().build(); + } + + @GetMapping("/download") + public ResponseEntity downloadFile(@RequestParam("url") String url) { + Resource resource = s3BucketService.download(url); + String filename = URLEncoder.encode(resource.getFilename(), StandardCharsets.UTF_8); + + return ResponseEntity.ok() + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + filename + "\"") + .body(resource); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java b/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java new file mode 100644 index 0000000..6ed3ec5 --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/s3/service/S3BucketService.java @@ -0,0 +1,127 @@ +package com.teamEWSN.gitdeun.common.s3.service; + +import com.teamEWSN.gitdeun.common.exception.ErrorCode; +import com.teamEWSN.gitdeun.common.exception.GlobalException; +import io.awspring.cloud.s3.S3Resource; +import io.awspring.cloud.s3.S3Template; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.core.exception.SdkException; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class S3BucketService { + + private final S3Template s3Template; + + @Value("${cloud.aws.s3.bucket.name}") + private String bucketName; + + public List upload(List files, String path) { + // FILE-002: 파일 목록이 비어있거나 유효하지 않습니다. + if (files == null || files.stream().allMatch(MultipartFile::isEmpty)) { + throw new GlobalException(ErrorCode.INVALID_FILE_LIST); + } + + List uploadedUrls = new ArrayList<>(); + for (MultipartFile file : files) { + if (file.isEmpty()) continue; + + if (!isValidFileType(file.getOriginalFilename())) { + // FILE-004: 지원하지 않는 파일 형식입니다. + throw new GlobalException(ErrorCode.INVALID_FILE_TYPE); + } + + String fullPath = generateValidPath(path) + createUniqueFileName(file.getOriginalFilename()); + + try { + S3Resource s3Resource = s3Template.upload(bucketName, fullPath, file.getInputStream()); + uploadedUrls.add(s3Resource.getURL().toString()); + } catch (IOException | SdkException e) { + // FILE-501: 파일 업로드 중 서버 오류가 발생했습니다. + throw new GlobalException(ErrorCode.FILE_UPLOAD_FAILED); + } + } + return uploadedUrls; + } + + public void remove(List urls) { + // FILE-002: 파일 목록이 비어있거나 유효하지 않습니다. + if (urls == null || urls.isEmpty()) { + throw new GlobalException(ErrorCode.INVALID_FILE_LIST); + } + + for (String url : urls) { + String key = extractKeyFromUrl(url); + + try { + if (!s3Template.objectExists(bucketName, key)) { + // FILE-001: 요청한 파일을 찾을 수 없습니다. + throw new GlobalException(ErrorCode.FILE_NOT_FOUND); + } + s3Template.deleteObject(bucketName, key); + } catch (SdkException e) { + // FILE-503: 파일 삭제 중 서버 오류가 발생했습니다. + throw new GlobalException(ErrorCode.FILE_DELETE_FAILED); + } + } + } + + public S3Resource download(String url) { + String key = extractKeyFromUrl(url); + + try { + if (!s3Template.objectExists(bucketName, key)) { + // FILE-001: 요청한 파일을 찾을 수 없습니다. + throw new GlobalException(ErrorCode.FILE_NOT_FOUND); + } + return s3Template.download(bucketName, key); + } catch (SdkException e) { + // FILE-502: 파일 다운로드 중 서버 오류가 발생했습니다. + throw new GlobalException(ErrorCode.FILE_DOWNLOAD_FAILED); + } + } + + private String extractKeyFromUrl(String url) { + try { + String urlPrefix = "https://" + bucketName + ".s3."; + int startIndex = url.indexOf(urlPrefix); + int keyStartIndex = url.indexOf('/', startIndex + urlPrefix.length()); + return url.substring(keyStartIndex + 1); + } catch (Exception e) { + // FILE-007: S3 URL 형식이 올바르지 않습니다. + throw new GlobalException(ErrorCode.INVALID_S3_URL); + } + } + + private String generateValidPath(String path) { + if (path == null || path.trim().isEmpty()) { + return ""; + } + if (path.contains("..")) { + // FILE-003: 파일 경로나 이름이 유효하지 않습니다. + throw new GlobalException(ErrorCode.INVALID_FILE_PATH); + } + return path.replaceAll("^/+|/+$", "") + "/"; + } + + private String createUniqueFileName(String originalFileName) { + String extension = StringUtils.getFilenameExtension(originalFileName); + return UUID.randomUUID() + "." + extension; + } + + private boolean isValidFileType(String filename) { + if (filename == null) return false; + String extension = StringUtils.getFilenameExtension(filename.toLowerCase()); + List allowedExtensions = List.of("jpg", "jpeg", "png", "gif", "pdf", "docs"); // 허용 확장자 + return allowedExtensions.contains(extension); + } +} \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/common/util/BaseEntity.java b/src/main/java/com/teamEWSN/gitdeun/common/util/BaseEntity.java new file mode 100644 index 0000000..e2dcb5b --- /dev/null +++ b/src/main/java/com/teamEWSN/gitdeun/common/util/BaseEntity.java @@ -0,0 +1,28 @@ +package com.teamEWSN.gitdeun.common.util; + +import jakarta.persistence.Column; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.MappedSuperclass; +import lombok.Getter; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@EntityListeners(AuditingEntityListener.class) +@Getter +@Setter +@MappedSuperclass +public class BaseEntity { + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false, columnDefinition = "DATETIME(0)") + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "updated_at", nullable = false, columnDefinition = "DATETIME(0)") + private LocalDateTime updatedAt; + +} \ No newline at end of file diff --git a/src/main/resources/application-s3Bucket.yml b/src/main/resources/application-s3Bucket.yml index 74b910e..86a1525 100644 --- a/src/main/resources/application-s3Bucket.yml +++ b/src/main/resources/application-s3Bucket.yml @@ -1,12 +1,15 @@ -aws: - s3: - bucket: - stack.auto: false - name: gitdeun - region: ap-northeast-2 - credentials: - accessKey: ${S3_ACCESS_KEY} - secretAccessKey: ${S3_SECRET_KEY} +cloud: + aws: + credentials: + access-key: ${S3_ACCESS_KEY} + secret-key: ${S3_SECRET_KEY} + region: + static: ap-northeast-2 + stack: + auto: false # CloudFormation 스택 자동 생성을 비활성화 + s3: + bucket: + name: gitdeun spring: config: