diff --git a/build.gradle b/build.gradle index d503ff06..5c0e09e9 100644 --- a/build.gradle +++ b/build.gradle @@ -110,7 +110,8 @@ dependencies { //email-smtp implementation 'org.springframework.boot:spring-boot-starter-mail' - + // S3 SDK + implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' } tasks.named('test') { diff --git a/src/main/java/org/ezcode/codetest/application/problem/service/ProblemService.java b/src/main/java/org/ezcode/codetest/application/problem/service/ProblemService.java index a065300d..afabe5f6 100644 --- a/src/main/java/org/ezcode/codetest/application/problem/service/ProblemService.java +++ b/src/main/java/org/ezcode/codetest/application/problem/service/ProblemService.java @@ -1,19 +1,22 @@ package org.ezcode.codetest.application.problem.service; import org.ezcode.codetest.application.problem.dto.request.ProblemCreateRequest; -import org.ezcode.codetest.domain.problem.model.ProblemSearchCondition; import org.ezcode.codetest.application.problem.dto.request.ProblemUpdateRequest; import org.ezcode.codetest.application.problem.dto.response.ProblemDetailResponse; import org.ezcode.codetest.application.problem.dto.response.ProblemResponse; +import org.ezcode.codetest.domain.problem.model.ProblemSearchCondition; import org.ezcode.codetest.domain.problem.model.entity.Problem; import org.ezcode.codetest.domain.problem.service.ProblemDomainService; import org.ezcode.codetest.domain.user.model.entity.AuthUser; import org.ezcode.codetest.domain.user.model.entity.User; import org.ezcode.codetest.domain.user.service.UserDomainService; +import org.ezcode.codetest.infrastructure.s3.S3Directory; +import org.ezcode.codetest.infrastructure.s3.S3Uploader; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import lombok.RequiredArgsConstructor; @@ -23,16 +26,22 @@ public class ProblemService { private final ProblemDomainService problemDomainService; private final UserDomainService userDomainService; + private final S3Uploader s3Uploader; // 문제 생성 ( 관리자 ) @Transactional - public ProblemDetailResponse createProblem(ProblemCreateRequest requestDto, AuthUser authUser) { + public ProblemDetailResponse createProblem(ProblemCreateRequest requestDto, MultipartFile image, AuthUser authUser) { User user = userDomainService.getUserById(authUser.getId()); - Problem savedProblem = problemDomainService.createProblem( - ProblemCreateRequest.toEntity(requestDto, user) - ); + Problem problem = ProblemCreateRequest.toEntity(requestDto, user); + Problem savedProblem = problemDomainService.createProblem(problem); + + // 문제 이미지 있다면? + if (image != null && !image.isEmpty()) { + String imageUrl = s3Uploader.upload(image, S3Directory.PROBLEM.getDir()); + savedProblem.addImage(imageUrl); + } return ProblemDetailResponse.from(savedProblem); } diff --git a/src/main/java/org/ezcode/codetest/domain/problem/model/entity/Problem.java b/src/main/java/org/ezcode/codetest/domain/problem/model/entity/Problem.java index b87c04f6..2395fdc3 100644 --- a/src/main/java/org/ezcode/codetest/domain/problem/model/entity/Problem.java +++ b/src/main/java/org/ezcode/codetest/domain/problem/model/entity/Problem.java @@ -11,9 +11,11 @@ import jakarta.persistence.CascadeType; import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -67,9 +69,18 @@ public class Problem extends BaseEntity { @Column(nullable = false) private Boolean isDeleted; + @Column(nullable = false) + private Long totalSubmissions; + + @Column(nullable = false) + private Long correctSubmissions; + @OneToMany(mappedBy = "problem", cascade = CascadeType.ALL, orphanRemoval = true) private List testcases = new ArrayList<>(); + @ElementCollection(fetch = FetchType.LAZY) + private List imageUrl = new ArrayList<>(); + @Builder public Problem(User creator, Category category, String title, String description, int score, String difficulty, Long memoryLimit, Long timeLimit, Reference reference) { @@ -83,6 +94,8 @@ public Problem(User creator, Category category, String title, String description this.timeLimit = timeLimit; this.reference = reference; isDeleted = false; + this.totalSubmissions = 0L; + this.correctSubmissions = 0L; } // 여러개를 하나의 객체로 만드는 것 @@ -122,4 +135,18 @@ public void update(User creator, Category category, String title, String descrip public void softDelete() { this.isDeleted = true; } + + // 이미지 추가 + public void addImage(String image) { + if (image == null || image.trim().isEmpty()) { + throw new IllegalArgumentException("이미지 URL을 찾을수 없습니다"); + } + + if (imageUrl.contains(image)) { + return; // 중복된 URL 무시 + } + + imageUrl.add(image); + } + } diff --git a/src/main/java/org/ezcode/codetest/domain/problem/model/entity/ProblemImage.java b/src/main/java/org/ezcode/codetest/domain/problem/model/entity/ProblemImage.java deleted file mode 100644 index 44e39e50..00000000 --- a/src/main/java/org/ezcode/codetest/domain/problem/model/entity/ProblemImage.java +++ /dev/null @@ -1,26 +0,0 @@ -package org.ezcode.codetest.domain.problem.model.entity; - -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import lombok.Getter; - -@Entity -@Getter -public class ProblemImage { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - - @ManyToOne - @JoinColumn(name = "problem_id", nullable = false) - private Problem problem; - - @Column(columnDefinition = "text", nullable = false) - private String imageUrl; -} diff --git a/src/main/java/org/ezcode/codetest/domain/problem/repository/ProblemImageRepository.java b/src/main/java/org/ezcode/codetest/domain/problem/repository/ProblemImageRepository.java deleted file mode 100644 index 431884a5..00000000 --- a/src/main/java/org/ezcode/codetest/domain/problem/repository/ProblemImageRepository.java +++ /dev/null @@ -1,4 +0,0 @@ -package org.ezcode.codetest.domain.problem.repository; - -public interface ProblemImageRepository { -} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/problem/ProblemImageJpaRepository.java b/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/problem/ProblemImageJpaRepository.java deleted file mode 100644 index 098a398b..00000000 --- a/src/main/java/org/ezcode/codetest/infrastructure/persistence/repository/problem/ProblemImageJpaRepository.java +++ /dev/null @@ -1,7 +0,0 @@ -package org.ezcode.codetest.infrastructure.persistence.repository.problem; - -import org.ezcode.codetest.domain.problem.model.entity.ProblemImage; -import org.springframework.data.jpa.repository.JpaRepository; - -public interface ProblemImageJpaRepository extends JpaRepository { -} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/s3/S3Directory.java b/src/main/java/org/ezcode/codetest/infrastructure/s3/S3Directory.java new file mode 100644 index 00000000..bc37fd9f --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/s3/S3Directory.java @@ -0,0 +1,20 @@ +package org.ezcode.codetest.infrastructure.s3; + +/** + * S3 prefix를 enum으로 관리 + * */ +public enum S3Directory { + PROBLEM("problem"), + PROFILE("profile"); + + // 소문자로 패키지 관리 하기 위해 dir추가 + private final String dir; + + S3Directory(String dir) { + this.dir = dir; + } + + public String getDir() { + return dir; + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/s3/S3Uploader.java b/src/main/java/org/ezcode/codetest/infrastructure/s3/S3Uploader.java new file mode 100644 index 00000000..fe591a4d --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/s3/S3Uploader.java @@ -0,0 +1,61 @@ +package org.ezcode.codetest.infrastructure.s3; + +import java.io.IOException; +import java.util.UUID; + +import org.ezcode.codetest.infrastructure.s3.exception.S3Exception; +import org.ezcode.codetest.infrastructure.s3.exception.code.S3ExceptionCode; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +import com.amazonaws.services.s3.AmazonS3; +import com.amazonaws.services.s3.model.AmazonS3Exception; +import com.amazonaws.services.s3.model.ObjectMetadata; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Component +@RequiredArgsConstructor +@Slf4j +public class S3Uploader { + + private final AmazonS3 amazonS3; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + public String upload(MultipartFile multipartFile, String dirName) { + try { + // MIME 타입 검사 (png, jpeg, jpg, webp 만 가능) + String contentType = multipartFile.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new S3Exception(S3ExceptionCode.S3_INVALID_FILE_TYPE); + } + + // S3 파일명 지정 ( 디렉토리/UUID-원본파일명 ) + String fileName = dirName + "/" + UUID.randomUUID() + "-" + multipartFile.getOriginalFilename(); + + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(multipartFile.getSize()); + metadata.setContentType(contentType); + + amazonS3.putObject(bucket, fileName, multipartFile.getInputStream(), metadata); + String result = amazonS3.getUrl(bucket, fileName).toString(); // 업로드 파일 URL로 변환 ( 문자열 ) + log.info("S3 버킷 이미지 업로드 완료 {}", result); + + return result; + + } catch (IOException e) { + log.error("S3 업로드 중 IO 오류 발생",e); + throw new S3Exception(S3ExceptionCode.S3_UPLOAD_FAILED); + } catch (AmazonS3Exception e) { + log.error("S3 서비스 오류 발생: {}", e.getErrorMessage(), e); + throw new S3Exception(S3ExceptionCode.S3_UPLOAD_FAILED); + } catch (Exception e) { + log.error("예상치 못한 업로드 오류 발생", e); + throw new S3Exception(S3ExceptionCode.S3_UPLOAD_FAILED); + } + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/s3/config/S3Config.java b/src/main/java/org/ezcode/codetest/infrastructure/s3/config/S3Config.java new file mode 100644 index 00000000..ab0317e4 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/s3/config/S3Config.java @@ -0,0 +1,39 @@ +package org.ezcode.codetest.infrastructure.s3.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +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; + +/** +* S3 연동 +* */ +@Configuration +@RequiredArgsConstructor + +public class S3Config { + + @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 amazonS3() { + BasicAWSCredentials credentials = new BasicAWSCredentials(accessKey, secretKey); + return AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(credentials)) + .build(); + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/s3/exception/S3Exception.java b/src/main/java/org/ezcode/codetest/infrastructure/s3/exception/S3Exception.java new file mode 100644 index 00000000..feef1537 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/s3/exception/S3Exception.java @@ -0,0 +1,24 @@ +package org.ezcode.codetest.infrastructure.s3.exception; + +import org.ezcode.codetest.common.base.exception.BaseException; +import org.ezcode.codetest.common.base.exception.ResponseCode; +import org.ezcode.codetest.infrastructure.s3.exception.code.S3ExceptionCode; +import org.springframework.http.HttpStatus; + +import lombok.Getter; + +@Getter +public class S3Exception extends BaseException { + + private final ResponseCode responseCode; + + private final HttpStatus httpStatus; + + private final String message; + + public S3Exception(S3ExceptionCode responseCode) { + this.responseCode = responseCode; + this.httpStatus = responseCode.getStatus(); + this.message = responseCode.getMessage(); + } +} diff --git a/src/main/java/org/ezcode/codetest/infrastructure/s3/exception/code/S3ExceptionCode.java b/src/main/java/org/ezcode/codetest/infrastructure/s3/exception/code/S3ExceptionCode.java new file mode 100644 index 00000000..579b8c55 --- /dev/null +++ b/src/main/java/org/ezcode/codetest/infrastructure/s3/exception/code/S3ExceptionCode.java @@ -0,0 +1,21 @@ +package org.ezcode.codetest.infrastructure.s3.exception.code; + +import org.ezcode.codetest.common.base.exception.ResponseCode; +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum S3ExceptionCode implements ResponseCode { + + S3_UPLOAD_FAILED(false, HttpStatus.INTERNAL_SERVER_ERROR, "S3 이미지 업로드 중 오류가 발생했습니다."), + S3_INVALID_FILE_TYPE(false, HttpStatus.BAD_REQUEST, "이미지 파일만 업로드할 수 있습니다."); + + private final boolean success; + + private final HttpStatus status; + + private final String message; +} diff --git a/src/main/java/org/ezcode/codetest/presentation/problemmanagement/problem/ProblemAdminController.java b/src/main/java/org/ezcode/codetest/presentation/problemmanagement/problem/ProblemAdminController.java index 0b6dfaff..95ffb19c 100644 --- a/src/main/java/org/ezcode/codetest/presentation/problemmanagement/problem/ProblemAdminController.java +++ b/src/main/java/org/ezcode/codetest/presentation/problemmanagement/problem/ProblemAdminController.java @@ -6,6 +6,7 @@ import org.ezcode.codetest.application.problem.service.ProblemService; import org.ezcode.codetest.domain.user.model.entity.AuthUser; import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; @@ -14,7 +15,9 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; 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; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -30,17 +33,18 @@ public class ProblemAdminController { private final ProblemService problemService; - @PostMapping + @PostMapping(consumes = { MediaType.MULTIPART_FORM_DATA_VALUE }) @Operation(summary = "문제 등록", description = "문제를 등록합니다.") @ApiResponse(responseCode = "201", description = "문제 생성 성공") public ResponseEntity createProblem( - @Valid @RequestBody ProblemCreateRequest request, + @RequestPart @Valid ProblemCreateRequest request, + @RequestPart(value = "image", required = false) MultipartFile image, @AuthenticationPrincipal AuthUser user ) { return ResponseEntity .status(HttpStatus.CREATED) - .body(problemService.createProblem(request, user)); + .body(problemService.createProblem(request, image, user)); } @PutMapping("/{problemId}") diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index a449d2d1..1817fb06 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -112,6 +112,17 @@ spring.mail.properties.mail.smtp.auth=true spring.mail.properties.mail.smtp.timeout=5000 spring.mail.properties.mail.smtp.starttls.enable=true +# ======================== +# S3 +# ======================== +cloud.aws.credentials.access-key=${ACCESS_KEY} +cloud.aws.credentials.secret-key=${SECRET_KEY} + +cloud.aws.s3.bucket=ezcode-s3 +cloud.aws.region.static=ap-northeast-2 +cloud.aws.stack.auto=false + + #logging.level.org.springframework.security=DEBUG #logging.level.org.springframework.security.oauth2=DEBUG #logging.level.org.springframework.security.oauth2.client=TRACE diff --git a/src/test/java/org/ezcode/codetest/CodetestApplicationTests.java b/src/test/java/org/ezcode/codetest/CodetestApplicationTests.java deleted file mode 100644 index 5f01056d..00000000 --- a/src/test/java/org/ezcode/codetest/CodetestApplicationTests.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.ezcode.codetest; - -import org.junit.jupiter.api.Test; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -class CodetestApplicationTests { - - @Test - void contextLoads() { - } - -}