-
Notifications
You must be signed in to change notification settings - Fork 3
[feat] 퀴즈 생성 기능 추가 #17
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 3 commits
2b9349e
679ee07
3800aa8
0c1e44c
e2d396c
14b7a63
a9a3a55
8a1973f
b304ece
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,4 +40,7 @@ out/ | |
| .env | ||
|
|
||
| ### .idea ### | ||
| .idea | ||
| .idea | ||
|
|
||
| ### images/thumbnail ### | ||
| images/thumbnail/** | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| package io.f1.backend.domain.question.app; | ||
|
|
||
| import io.f1.backend.domain.question.dao.QuestionRepository; | ||
| import io.f1.backend.domain.question.dao.TextQuestionRepository; | ||
| import io.f1.backend.domain.question.dto.QuestionRequest; | ||
| import io.f1.backend.domain.question.entity.Question; | ||
| import io.f1.backend.domain.question.entity.TextQuestion; | ||
| import io.f1.backend.domain.question.mapper.QuestionMapper; | ||
| import io.f1.backend.domain.question.mapper.TextQuestionMapper; | ||
| import io.f1.backend.domain.quiz.entity.Quiz; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class QuestionService { | ||
|
|
||
| private final QuestionRepository questionRepository; | ||
| private final TextQuestionRepository textQuestionRepository; | ||
|
|
||
| @Transactional | ||
| public void saveQuestion(Quiz quiz, QuestionRequest request) { | ||
|
|
||
| Question question = QuestionMapper.questionRequestToQuestion(quiz, request); | ||
| quiz.addQuestion(question); | ||
| questionRepository.save(question); | ||
|
|
||
| TextQuestion textQuestion = | ||
| TextQuestionMapper.questionRequestToTextQuestion(question, request.getContent()); | ||
| textQuestionRepository.save(textQuestion); | ||
| question.addTextQuestion(textQuestion); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package io.f1.backend.domain.question.dao; | ||
|
|
||
| import io.f1.backend.domain.question.entity.Question; | ||
|
|
||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface QuestionRepository extends JpaRepository<Question, Long> {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package io.f1.backend.domain.question.dao; | ||
|
|
||
| import io.f1.backend.domain.question.entity.TextQuestion; | ||
|
|
||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface TextQuestionRepository extends JpaRepository<TextQuestion, Long> {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package io.f1.backend.domain.question.dto; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
|
|
||
| import lombok.AccessLevel; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class QuestionRequest { | ||
|
|
||
| @NotBlank(message = "문제를 입력해주세요.") | ||
| private String content; | ||
|
|
||
| @NotBlank(message = "정답을 입력해주세요.") | ||
| private String answer; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| package io.f1.backend.domain.question.mapper; | ||
|
|
||
| import io.f1.backend.domain.question.dto.QuestionRequest; | ||
| import io.f1.backend.domain.question.entity.Question; | ||
| import io.f1.backend.domain.quiz.entity.Quiz; | ||
|
|
||
| public class QuestionMapper { | ||
|
|
||
| public static Question questionRequestToQuestion(Quiz quiz, QuestionRequest questionRequest) { | ||
| return new Question(quiz, questionRequest.getAnswer()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package io.f1.backend.domain.question.mapper; | ||
|
|
||
| import io.f1.backend.domain.question.entity.Question; | ||
| import io.f1.backend.domain.question.entity.TextQuestion; | ||
|
|
||
| public class TextQuestionMapper { | ||
|
|
||
| public static TextQuestion questionRequestToTextQuestion(Question question, String content) { | ||
| return new TextQuestion(question, content); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| package io.f1.backend.domain.quiz.api; | ||
|
|
||
| import io.f1.backend.domain.quiz.app.QuizService; | ||
| import io.f1.backend.domain.quiz.dto.QuizCreateRequest; | ||
| import io.f1.backend.domain.quiz.dto.QuizCreateResponse; | ||
|
|
||
| import jakarta.validation.Valid; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| import org.springframework.http.HttpStatus; | ||
| import org.springframework.http.MediaType; | ||
| import org.springframework.http.ResponseEntity; | ||
| import org.springframework.web.bind.annotation.PostMapping; | ||
| 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 java.io.IOException; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/quizzes") | ||
| @RequiredArgsConstructor | ||
| public class QuizController { | ||
|
|
||
| private final QuizService quizService; | ||
|
|
||
| @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) | ||
| public ResponseEntity<QuizCreateResponse> saveQuiz( | ||
| @RequestPart(required = false) MultipartFile file, | ||
dlsrks1021 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| @Valid @RequestPart QuizCreateRequest request) | ||
| throws IOException { | ||
| QuizCreateResponse response = quizService.saveQuiz(file, request); | ||
|
|
||
| return ResponseEntity.status(HttpStatus.CREATED).body(response); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| package io.f1.backend.domain.quiz.app; | ||
|
|
||
| import io.f1.backend.domain.question.app.QuestionService; | ||
| import io.f1.backend.domain.question.dto.QuestionRequest; | ||
| import io.f1.backend.domain.quiz.dao.QuizRepository; | ||
| import io.f1.backend.domain.quiz.dto.QuizCreateRequest; | ||
| import io.f1.backend.domain.quiz.dto.QuizCreateResponse; | ||
| import io.f1.backend.domain.quiz.entity.Quiz; | ||
| import io.f1.backend.domain.quiz.mapper.QuizMapper; | ||
| import io.f1.backend.domain.user.dao.UserRepository; | ||
| import io.f1.backend.domain.user.entity.User; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
|
|
||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
| import org.springframework.web.multipart.MultipartFile; | ||
|
|
||
| import java.io.IOException; | ||
| import java.nio.file.Path; | ||
| import java.nio.file.Paths; | ||
| import java.util.List; | ||
| import java.util.UUID; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| public class QuizService { | ||
|
|
||
| @Value("${file.thumbnail-path}") | ||
| private String uploadPath; | ||
|
|
||
| @Value("${file.default-thumbnail-url}") | ||
| private String defaultThumbnailPath; | ||
|
|
||
| // TODO : 시큐리티 구현 이후 삭제해도 되는 의존성 주입 | ||
| private final UserRepository userRepository; | ||
| private final QuestionService questionService; | ||
| private final QuizRepository quizRepository; | ||
|
|
||
| @Transactional | ||
| public QuizCreateResponse saveQuiz(MultipartFile file, QuizCreateRequest request) | ||
| throws IOException { | ||
| String imgUrl = defaultThumbnailPath; | ||
|
|
||
| if (file != null && !file.isEmpty()) { | ||
| validateImageFile(file); | ||
| imgUrl = saveThumbnail(file); | ||
| } | ||
|
|
||
| // TODO : 시큐리티 구현 이후 삭제 (data.sql로 초기 저장해둔 유저 get), 나중엔 현재 로그인한 유저의 아이디를 받아오도록 수정 | ||
| User user = userRepository.findById(1L).orElseThrow(RuntimeException::new); | ||
|
|
||
| Quiz quiz = QuizMapper.quizCreateRequestToQuiz(request, imgUrl, user); | ||
|
|
||
| Quiz savedQuiz = quizRepository.save(quiz); | ||
|
|
||
| for (QuestionRequest qRequest : request.getQuestions()) { | ||
| questionService.saveQuestion(savedQuiz, qRequest); | ||
| } | ||
|
|
||
| return QuizMapper.quizToQuizCreateResponse(savedQuiz); | ||
| } | ||
|
|
||
| private void validateImageFile(MultipartFile file) { | ||
|
|
||
| if (!file.getContentType().startsWith("image")) { | ||
| // TODO : 이후 커스텀 예외로 변경 | ||
| throw new IllegalArgumentException("이미지 파일을 업로드해주세요."); | ||
| } | ||
|
|
||
| List<String> allowedExt = List.of("jpg", "jpeg", "png", "webp"); | ||
| if (!allowedExt.contains(getExtension(file.getOriginalFilename()))) { | ||
| throw new IllegalArgumentException("지원하지 않는 확장자입니다."); | ||
| } | ||
| } | ||
|
|
||
| private String saveThumbnail(MultipartFile file) throws IOException { | ||
|
||
| String originalFilename = file.getOriginalFilename(); | ||
| String ext = getExtension(originalFilename); | ||
| String savedFilename = UUID.randomUUID().toString() + "." + ext; | ||
|
|
||
| Path savePath = Paths.get(uploadPath, savedFilename).toAbsolutePath(); | ||
| file.transferTo(savePath.toFile()); | ||
|
|
||
| return "/images/thumbnail/" + savedFilename; | ||
| } | ||
|
|
||
| private String getExtension(String filename) { | ||
| return filename.substring(filename.lastIndexOf(".") + 1); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package io.f1.backend.domain.quiz.dao; | ||
|
|
||
| import io.f1.backend.domain.quiz.entity.Quiz; | ||
|
|
||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
|
|
||
| public interface QuizRepository extends JpaRepository<Quiz, Long> {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,31 @@ | ||
| package io.f1.backend.domain.quiz.dto; | ||
|
|
||
| import io.f1.backend.domain.question.dto.QuestionRequest; | ||
| import io.f1.backend.domain.quiz.entity.QuizType; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.NotNull; | ||
| import jakarta.validation.constraints.Size; | ||
|
|
||
| import lombok.AccessLevel; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class QuizCreateRequest { | ||
|
|
||
| @NotBlank(message = "퀴즈 제목을 설정해주세요.") | ||
| private String title; | ||
|
|
||
| @NotNull(message = "퀴즈 종류를 선택해주세요.") | ||
| private QuizType quizType; | ||
|
|
||
| @NotBlank(message = "퀴즈 설명을 적어주세요.") | ||
| private String description; | ||
|
|
||
| @Size(min = 10, max = 80, message = "문제는 최소 10개, 최대 80개로 정해주세요.") | ||
| private List<QuestionRequest> questions; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package io.f1.backend.domain.quiz.dto; | ||
|
|
||
| import io.f1.backend.domain.quiz.entity.QuizType; | ||
|
|
||
| public record QuizCreateResponse( | ||
| Long id, | ||
| String title, | ||
| QuizType quizType, | ||
| String description, | ||
| String thumbnailUrl, | ||
| Long creatorId) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,10 +16,17 @@ | |
| import jakarta.persistence.ManyToOne; | ||
| import jakarta.persistence.OneToMany; | ||
|
|
||
| import lombok.AccessLevel; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
| import lombok.NoArgsConstructor; | ||
|
|
||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
|
|
||
| @Entity | ||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| public class Quiz extends BaseEntity { | ||
|
|
||
| @Id | ||
|
|
@@ -45,4 +52,22 @@ public class Quiz extends BaseEntity { | |
| @ManyToOne | ||
| @JoinColumn(name = "creator_id") | ||
| private User creator; | ||
|
|
||
| @Builder | ||
| public Quiz( | ||
| String title, | ||
| String description, | ||
| QuizType quizType, | ||
| String thumbnailUrl, | ||
| User creator) { | ||
| this.title = title; | ||
| this.description = description; | ||
| this.quizType = quizType; | ||
| this.thumbnailUrl = thumbnailUrl; | ||
| this.creator = creator; | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [L4-변경제안]
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 의견 감사합니다 ! 사실 처음에 생성자로 했다가 빌더로 변경했었는데, 그 이유가 아무래도 들어가는 필드값이 많다보니 생성자로 해두었을 때 가독성이 낮고, 어떤 필드에 뭐가 들어가는건지 좀 보기 힘들더라구요..! 그래서 빌더패턴으로 수정했었는데,,, 세희님 리뷰 듣고 저 두 특징에 대해 고민을 좀 해봤는데, 어차피 |
||
|
|
||
| public void addQuestion(Question question) { | ||
| this.questions.add(question); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[L4-변경제안]
Mapper 호출 부분은 스태틱 임포트해서 더 간결하게 표현하면 어떨까요 🤔