Skip to content

Commit

Permalink
시험에 대한 이미지 업로드 기능 구현한다. (#43)
Browse files Browse the repository at this point in the history
* feat: ExamImage Entity 작성

* feat: Exam Image 테이블 생성 sql 작성

* feat: servlet max file size

* refactor: exam image의 id를 uuid로 변경

* feat: 시험 지문에 사용될 이미지 업로드 기능 구현

* feat: upload image 컨트롤러 응답 dto 구현

* feat: 시험 지문 이미지 업로드 기능 테스트 코드 작성

* feat: 시험 지문 이미지 업로드 기능 명세서 작성
  • Loading branch information
alstn113 authored Jan 31, 2025
1 parent b144c6e commit 8fd5443
Show file tree
Hide file tree
Showing 12 changed files with 209 additions and 21 deletions.
4 changes: 4 additions & 0 deletions server/src/docs/asciidoc/exam.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down
16 changes: 16 additions & 0 deletions server/src/main/java/com/fluffy/exam/api/ExamController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -125,6 +129,18 @@ public ResponseEntity<Void> publish(
return ResponseEntity.ok().build();
}


@PostMapping("/api/v1/exams/{examId}/images")
public ResponseEntity<UploadExamImageResponse> 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<Void> updateQuestions(
@PathVariable Long examId,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.fluffy.exam.api.response;

public record UploadExamImageResponse(String path) {
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
46 changes: 46 additions & 0 deletions server/src/main/java/com/fluffy/exam/domain/ExamImage.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.fluffy.exam.domain;

import org.springframework.data.jpa.repository.JpaRepository;

public interface ExamImageRepository extends JpaRepository<ExamImage, Long> {
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

public interface StorageClient {

String upload(MultipartFile file);
String upload(MultipartFile file, String fileName);

void delete(String fileName);
}
20 changes: 1 addition & 19 deletions server/src/main/java/com/fluffy/storage/infra/AwsS3Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);

Expand All @@ -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);
}
}
9 changes: 8 additions & 1 deletion server/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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);

39 changes: 39 additions & 0 deletions server/src/test/java/com/fluffy/exam/api/ExamDocumentTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()))
Expand Down

0 comments on commit 8fd5443

Please sign in to comment.