From 8fd54437022243cf15801db0a882c1551d0f61f4 Mon Sep 17 00:00:00 2001 From: Minsu Kim Date: Fri, 31 Jan 2025 21:15:31 +0900 Subject: [PATCH] =?UTF-8?q?=EC=8B=9C=ED=97=98=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C?= =?UTF-8?q?=EB=93=9C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20(#43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: ExamImage Entity 작성 * feat: Exam Image 테이블 생성 sql 작성 * feat: servlet max file size * refactor: exam image의 id를 uuid로 변경 * feat: 시험 지문에 사용될 이미지 업로드 기능 구현 * feat: upload image 컨트롤러 응답 dto 구현 * feat: 시험 지문 이미지 업로드 기능 테스트 코드 작성 * feat: 시험 지문 이미지 업로드 기능 명세서 작성 --- server/src/docs/asciidoc/exam.adoc | 4 ++ .../com/fluffy/exam/api/ExamController.java | 16 +++++ .../api/response/UploadExamImageResponse.java | 4 ++ .../exam/application/ExamImageService.java | 62 +++++++++++++++++++ .../com/fluffy/exam/domain/ExamImage.java | 46 ++++++++++++++ .../exam/domain/ExamImageRepository.java | 6 ++ .../storage/application/StorageClient.java | 2 +- .../com/fluffy/storage/infra/AwsS3Client.java | 20 +----- server/src/main/resources/application.yml | 9 ++- .../db/migration/V7__add_exam_image_table.sql | 18 ++++++ .../com/fluffy/exam/api/ExamDocumentTest.java | 39 ++++++++++++ .../support/AbstractControllerTest.java | 4 ++ 12 files changed, 209 insertions(+), 21 deletions(-) create mode 100644 server/src/main/java/com/fluffy/exam/api/response/UploadExamImageResponse.java create mode 100644 server/src/main/java/com/fluffy/exam/application/ExamImageService.java create mode 100644 server/src/main/java/com/fluffy/exam/domain/ExamImage.java create mode 100644 server/src/main/java/com/fluffy/exam/domain/ExamImageRepository.java create mode 100644 server/src/main/resources/db/migration/V7__add_exam_image_table.sql diff --git a/server/src/docs/asciidoc/exam.adoc b/server/src/docs/asciidoc/exam.adoc index 4b0d888..c70fb9e 100644 --- a/server/src/docs/asciidoc/exam.adoc +++ b/server/src/docs/asciidoc/exam.adoc @@ -32,6 +32,10 @@ operation::exam-document-test/create-exam[] operation::exam-document-test/publish[] +=== 시험 지문 이미지 업로드 + +operation::exam-document-test/upload-image[] + === 시험 문제 수정 operation::exam-document-test/update-questions[] diff --git a/server/src/main/java/com/fluffy/exam/api/ExamController.java b/server/src/main/java/com/fluffy/exam/api/ExamController.java index 79d5f29..b7960d0 100644 --- a/server/src/main/java/com/fluffy/exam/api/ExamController.java +++ b/server/src/main/java/com/fluffy/exam/api/ExamController.java @@ -5,6 +5,8 @@ import com.fluffy.exam.api.request.UpdateExamDescriptionWebRequest; import com.fluffy.exam.api.request.UpdateExamQuestionsWebRequest; import com.fluffy.exam.api.request.UpdateExamTitleWebRequest; +import com.fluffy.exam.api.response.UploadExamImageResponse; +import com.fluffy.exam.application.ExamImageService; import com.fluffy.exam.application.ExamQueryService; import com.fluffy.exam.application.ExamService; import com.fluffy.exam.application.response.CreateExamResponse; @@ -32,12 +34,14 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; @RestController @RequiredArgsConstructor public class ExamController { private final ExamService examService; + private final ExamImageService examImageService; private final ExamQueryService examQueryService; @GetMapping("/api/v1/exams") @@ -125,6 +129,18 @@ public ResponseEntity publish( return ResponseEntity.ok().build(); } + + @PostMapping("/api/v1/exams/{examId}/images") + public ResponseEntity uploadImage( + @PathVariable Long examId, + @RequestParam MultipartFile image, + @Auth Accessor accessor + ) { + String path = examImageService.uploadImage(examId, image, accessor); + + return ResponseEntity.ok(new UploadExamImageResponse(path)); + } + @PutMapping("/api/v1/exams/{examId}/questions") public ResponseEntity updateQuestions( @PathVariable Long examId, diff --git a/server/src/main/java/com/fluffy/exam/api/response/UploadExamImageResponse.java b/server/src/main/java/com/fluffy/exam/api/response/UploadExamImageResponse.java new file mode 100644 index 0000000..60f1635 --- /dev/null +++ b/server/src/main/java/com/fluffy/exam/api/response/UploadExamImageResponse.java @@ -0,0 +1,4 @@ +package com.fluffy.exam.api.response; + +public record UploadExamImageResponse(String path) { +} diff --git a/server/src/main/java/com/fluffy/exam/application/ExamImageService.java b/server/src/main/java/com/fluffy/exam/application/ExamImageService.java new file mode 100644 index 0000000..b399671 --- /dev/null +++ b/server/src/main/java/com/fluffy/exam/application/ExamImageService.java @@ -0,0 +1,62 @@ +package com.fluffy.exam.application; + +import com.fluffy.auth.domain.Member; +import com.fluffy.auth.domain.MemberRepository; +import com.fluffy.exam.domain.Exam; +import com.fluffy.exam.domain.ExamImage; +import com.fluffy.exam.domain.ExamImageRepository; +import com.fluffy.exam.domain.ExamRepository; +import com.fluffy.global.exception.ForbiddenException; +import com.fluffy.global.web.Accessor; +import com.fluffy.storage.application.StorageClient; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + +@Service +@RequiredArgsConstructor +public class ExamImageService { + + private final StorageClient storageClient; + private final ExamImageRepository examImageRepository; + private final ExamRepository examRepository; + private final MemberRepository memberRepository; + + @Transactional + public String uploadImage(Long examId, MultipartFile image, Accessor accessor) { + validateExamAuthor(examId, accessor); + + UUID imageId = UUID.randomUUID(); + + Long fileSize = image.getSize(); + String filePath = generateUploadPath(imageId, accessor.id(), image.getOriginalFilename()); + + ExamImage examImage = new ExamImage(imageId, accessor.id(), examId, filePath, fileSize); + examImageRepository.save(examImage); + + try { + return storageClient.upload(image, filePath); + } catch (Exception e) { + examImageRepository.delete(examImage); + throw e; + } + } + + private void validateExamAuthor(Long examId, Accessor accessor) { + Exam exam = examRepository.findByIdOrThrow(examId); + Member member = memberRepository.findByIdOrThrow(accessor.id()); + + if (exam.isNotWrittenBy(member.getId())) { + throw new ForbiddenException("시험 작성자만 이미지를 업로드할 수 있습니다."); + } + } + + private String generateUploadPath(UUID imageId, Long memberId, String originalFilename) { + int lastDotIndex = originalFilename.lastIndexOf("."); + String extension = originalFilename.substring(lastDotIndex + 1); + + return "images/%d/exams/%s.%s".formatted(memberId, imageId, extension); + } +} diff --git a/server/src/main/java/com/fluffy/exam/domain/ExamImage.java b/server/src/main/java/com/fluffy/exam/domain/ExamImage.java new file mode 100644 index 0000000..8c42fd2 --- /dev/null +++ b/server/src/main/java/com/fluffy/exam/domain/ExamImage.java @@ -0,0 +1,46 @@ +package com.fluffy.exam.domain; + +import com.fluffy.global.persistence.AuditableEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class ExamImage extends AuditableEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @Column(nullable = false) + private Long memberId; + + @Column(nullable = false) + private Long examId; + + @Column(nullable = false) + private String path; + + @Column(nullable = false) + private Long fileSize; + + public ExamImage(Long memberId, Long examId, String path, Long fileSize) { + this(null, memberId, examId, path, fileSize); + } + + public ExamImage(UUID id, Long memberId, Long examId, String path, Long fileSize) { + this.id = id; + this.memberId = memberId; + this.examId = examId; + this.path = path; + this.fileSize = fileSize; + } +} diff --git a/server/src/main/java/com/fluffy/exam/domain/ExamImageRepository.java b/server/src/main/java/com/fluffy/exam/domain/ExamImageRepository.java new file mode 100644 index 0000000..5ff02ee --- /dev/null +++ b/server/src/main/java/com/fluffy/exam/domain/ExamImageRepository.java @@ -0,0 +1,6 @@ +package com.fluffy.exam.domain; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ExamImageRepository extends JpaRepository { +} diff --git a/server/src/main/java/com/fluffy/storage/application/StorageClient.java b/server/src/main/java/com/fluffy/storage/application/StorageClient.java index cdf9909..1873675 100644 --- a/server/src/main/java/com/fluffy/storage/application/StorageClient.java +++ b/server/src/main/java/com/fluffy/storage/application/StorageClient.java @@ -4,7 +4,7 @@ public interface StorageClient { - String upload(MultipartFile file); + String upload(MultipartFile file, String fileName); void delete(String fileName); } diff --git a/server/src/main/java/com/fluffy/storage/infra/AwsS3Client.java b/server/src/main/java/com/fluffy/storage/infra/AwsS3Client.java index b36841e..9db6c96 100644 --- a/server/src/main/java/com/fluffy/storage/infra/AwsS3Client.java +++ b/server/src/main/java/com/fluffy/storage/infra/AwsS3Client.java @@ -8,7 +8,6 @@ import io.awspring.cloud.s3.S3Template; import java.io.IOException; import java.io.InputStream; -import java.text.SimpleDateFormat; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -24,14 +23,12 @@ public class AwsS3Client implements StorageClient { private String bucketName; @Override - public String upload(MultipartFile file) { + public String upload(MultipartFile file, String fileName) { if (file.isEmpty()) { throw new BadRequestException("파일이 비어있습니다."); } - String fileName = generateFileName(file.getOriginalFilename()); - try (InputStream is = file.getInputStream()) { S3Resource upload = s3Template.upload(bucketName, fileName, is); @@ -49,19 +46,4 @@ public void delete(String fileName) { throw new NotFoundException("파일을 찾을 수 없습니다.", e); } } - - private String generateFileName(String originalFileName) { - if (originalFileName == null) { - throw new NotFoundException("파일 이름을 찾을 수 없습니다."); - } - - int extensionIndex = originalFileName.lastIndexOf("."); - - String extension = originalFileName.substring(extensionIndex); - String fileName = originalFileName.substring(0, extensionIndex); - - String now = new SimpleDateFormat("yyyy_MM_dd_HH_mm_ss_SSS").format(System.currentTimeMillis()); - - return "%s-%s%s".formatted(fileName, now, extension); - } } diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 4a7b616..ea62f7e 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -36,7 +36,10 @@ spring: static: ${AWS_S3_REGION} s3: bucket: ${AWS_S3_BUCKET} - + servlet: + multipart: + max-file-size: 5MB + max-request-size: 5MB api-host: http://localhost:8080 client-host: http://localhost:5173 @@ -112,6 +115,10 @@ spring: static: ${AWS_S3_REGION} s3: bucket: ${AWS_S3_BUCKET} + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB api-host: https://api.fluffy.run client-host: https://www.fluffy.run diff --git a/server/src/main/resources/db/migration/V7__add_exam_image_table.sql b/server/src/main/resources/db/migration/V7__add_exam_image_table.sql new file mode 100644 index 0000000..6500309 --- /dev/null +++ b/server/src/main/resources/db/migration/V7__add_exam_image_table.sql @@ -0,0 +1,18 @@ +CREATE TABLE exam_image +( + id UUID DEFAULT gen_random_uuid(), + member_id BIGINT NOT NULL, + exam_id BIGINT NOT NULL, + path VARCHAR(255) NOT NULL, + file_size BIGINT NOT NULL, + created_at TIMESTAMP(6) NOT NULL, + updated_at TIMESTAMP(6) NOT NULL, + PRIMARY KEY (id) +); + +ALTER TABLE exam_image + ADD CONSTRAINT fk_member FOREIGN KEY (member_id) REFERENCES member (id); + +ALTER TABLE exam_image + ADD CONSTRAINT fk_exam FOREIGN KEY (exam_id) REFERENCES exam (id); + diff --git a/server/src/test/java/com/fluffy/exam/api/ExamDocumentTest.java b/server/src/test/java/com/fluffy/exam/api/ExamDocumentTest.java index 7972155..64444e3 100644 --- a/server/src/test/java/com/fluffy/exam/api/ExamDocumentTest.java +++ b/server/src/test/java/com/fluffy/exam/api/ExamDocumentTest.java @@ -3,12 +3,15 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.when; +import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.multipart; import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; import static org.springframework.restdocs.request.RequestDocumentation.parameterWithName; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; import static org.springframework.restdocs.request.RequestDocumentation.pathParameters; import static org.springframework.restdocs.request.RequestDocumentation.queryParameters; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; @@ -20,6 +23,7 @@ import com.fluffy.exam.api.request.UpdateExamDescriptionWebRequest; import com.fluffy.exam.api.request.UpdateExamQuestionsWebRequest; import com.fluffy.exam.api.request.UpdateExamTitleWebRequest; +import com.fluffy.exam.api.response.UploadExamImageResponse; import com.fluffy.exam.application.request.question.LongAnswerQuestionAppRequest; import com.fluffy.exam.application.request.question.MultipleChoiceAppRequest; import com.fluffy.exam.application.request.question.QuestionOptionRequest; @@ -47,6 +51,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.http.MediaType; +import org.springframework.mock.web.MockMultipartFile; import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; class ExamDocumentTest extends AbstractDocumentTest { @@ -464,6 +469,40 @@ void publish() throws Exception { )); } + @Test + @DisplayName("시험에 대한 이미지를 업로드할 수 있다.") + void uploadImage() throws Exception { + MockMultipartFile imageFile = new MockMultipartFile( + "image", + "test.jpg", + MediaType.IMAGE_JPEG_VALUE, + "dummy content".getBytes() + ); + + String filePath = "https://s3.ap-northeast-2.amazonaws.com/images/4/exams/uuid.png"; + when(examImageService.uploadImage(any(), any(), any())) + .thenReturn(filePath); + + mockMvc.perform(multipart("/api/v1/exams/{examId}/images", 1L) + .file(imageFile) + .param("examId", "1") + .contentType(MediaType.MULTIPART_FORM_DATA) + .cookie(new Cookie("accessToken", "{ACCESS_TOKEN}")) + ) + .andExpectAll( + status().isOk(), + content().json(objectMapper.writeValueAsString(new UploadExamImageResponse(filePath))) + ) + .andDo(restDocs.document( + pathParameters( + parameterWithName("examId").description("시험 ID") + ), + requestParts( + partWithName("image").description("업로드할 이미지 파일") + ) + )); + } + @Test @DisplayName("시험 문제를 수정할 수 있다.") void updateQuestions() throws Exception { diff --git a/server/src/test/java/com/fluffy/support/AbstractControllerTest.java b/server/src/test/java/com/fluffy/support/AbstractControllerTest.java index 1c6bd76..e734b49 100644 --- a/server/src/test/java/com/fluffy/support/AbstractControllerTest.java +++ b/server/src/test/java/com/fluffy/support/AbstractControllerTest.java @@ -8,6 +8,7 @@ import com.fluffy.auth.application.AuthService; import com.fluffy.exam.api.ExamController; import com.fluffy.exam.api.ExamLikeController; +import com.fluffy.exam.application.ExamImageService; import com.fluffy.exam.application.ExamLikeService; import com.fluffy.exam.application.ExamQueryService; import com.fluffy.exam.application.ExamService; @@ -68,6 +69,9 @@ public abstract class AbstractControllerTest { @MockBean protected SubmissionQueryService submissionQueryService; + @MockBean + protected ExamImageService examImageService; + @BeforeEach public void setUp() { when(authArgumentResolver.resolveArgument(any(), any(), any(), any()))