diff --git a/backend/.gitignore b/backend/.gitignore index e6bbcaae..7d8f1866 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -40,4 +40,7 @@ out/ .env ### .idea ### -.idea \ No newline at end of file +.idea + +### images/thumbnail ### +images/thumbnail/** \ No newline at end of file diff --git a/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java b/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java new file mode 100644 index 00000000..87a0e222 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/app/QuestionService.java @@ -0,0 +1,36 @@ +package io.f1.backend.domain.question.app; + +import static io.f1.backend.domain.question.mapper.QuestionMapper.questionRequestToQuestion; +import static io.f1.backend.domain.question.mapper.TextQuestionMapper.questionRequestToTextQuestion; + +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.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 = questionRequestToQuestion(quiz, request); + quiz.addQuestion(question); + questionRepository.save(question); + + TextQuestion textQuestion = questionRequestToTextQuestion(question, request.getContent()); + textQuestionRepository.save(textQuestion); + question.addTextQuestion(textQuestion); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/question/dao/QuestionRepository.java b/backend/src/main/java/io/f1/backend/domain/question/dao/QuestionRepository.java new file mode 100644 index 00000000..7ff72081 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/dao/QuestionRepository.java @@ -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 {} diff --git a/backend/src/main/java/io/f1/backend/domain/question/dao/TextQuestionRepository.java b/backend/src/main/java/io/f1/backend/domain/question/dao/TextQuestionRepository.java new file mode 100644 index 00000000..39812de0 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/dao/TextQuestionRepository.java @@ -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 {} diff --git a/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionRequest.java b/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionRequest.java new file mode 100644 index 00000000..9374a9b9 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionRequest.java @@ -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; +} diff --git a/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java b/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java index cc3ad718..1b769383 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java +++ b/backend/src/main/java/io/f1/backend/domain/question/entity/Question.java @@ -14,7 +14,11 @@ import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class Question extends BaseEntity { @Id @@ -30,4 +34,13 @@ public class Question extends BaseEntity { @OneToOne(mappedBy = "question", cascade = CascadeType.REMOVE) private TextQuestion textQuestion; + + public Question(Quiz quiz, String answer) { + this.quiz = quiz; + this.answer = answer; + } + + public void addTextQuestion(TextQuestion textQuestion) { + this.textQuestion = textQuestion; + } } diff --git a/backend/src/main/java/io/f1/backend/domain/question/entity/TextQuestion.java b/backend/src/main/java/io/f1/backend/domain/question/entity/TextQuestion.java index e395e5d6..61eed1c7 100644 --- a/backend/src/main/java/io/f1/backend/domain/question/entity/TextQuestion.java +++ b/backend/src/main/java/io/f1/backend/domain/question/entity/TextQuestion.java @@ -8,7 +8,11 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.OneToOne; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + @Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) public class TextQuestion { @Id @@ -21,4 +25,9 @@ public class TextQuestion { @Column(nullable = false) private String content; + + public TextQuestion(Question question, String content) { + this.question = question; + this.content = content; + } } diff --git a/backend/src/main/java/io/f1/backend/domain/question/mapper/QuestionMapper.java b/backend/src/main/java/io/f1/backend/domain/question/mapper/QuestionMapper.java new file mode 100644 index 00000000..4228fe8f --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/mapper/QuestionMapper.java @@ -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()); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/question/mapper/TextQuestionMapper.java b/backend/src/main/java/io/f1/backend/domain/question/mapper/TextQuestionMapper.java new file mode 100644 index 00000000..97e42cab --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/mapper/TextQuestionMapper.java @@ -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); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/api/QuizController.java b/backend/src/main/java/io/f1/backend/domain/quiz/api/QuizController.java new file mode 100644 index 00000000..b25897c2 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/quiz/api/QuizController.java @@ -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 saveQuiz( + @RequestPart(required = false) MultipartFile thumbnailFile, + @Valid @RequestPart QuizCreateRequest request) + throws IOException { + QuizCreateResponse response = quizService.saveQuiz(thumbnailFile, request); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java b/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java new file mode 100644 index 00000000..ffe46c50 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/quiz/app/QuizService.java @@ -0,0 +1,94 @@ +package io.f1.backend.domain.quiz.app; + +import static io.f1.backend.domain.quiz.mapper.QuizMapper.quizCreateRequestToQuiz; +import static io.f1.backend.domain.quiz.mapper.QuizMapper.quizToQuizCreateResponse; + +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.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 thumbnailFile, QuizCreateRequest request) + throws IOException { + String thumbnailPath = defaultThumbnailPath; + + if (thumbnailFile != null && !thumbnailFile.isEmpty()) { + validateImageFile(thumbnailFile); + thumbnailPath = convertToThumbnailPath(thumbnailFile); + } + + // TODO : 시큐리티 구현 이후 삭제 (data.sql로 초기 저장해둔 유저 get), 나중엔 현재 로그인한 유저의 아이디를 받아오도록 수정 + User user = userRepository.findById(1L).orElseThrow(RuntimeException::new); + + Quiz quiz = quizCreateRequestToQuiz(request, thumbnailPath, user); + + Quiz savedQuiz = quizRepository.save(quiz); + + for (QuestionRequest qRequest : request.getQuestions()) { + questionService.saveQuestion(savedQuiz, qRequest); + } + + return quizToQuizCreateResponse(savedQuiz); + } + + private void validateImageFile(MultipartFile thumbnailFile) { + + if (!thumbnailFile.getContentType().startsWith("image")) { + // TODO : 이후 커스텀 예외로 변경 + throw new IllegalArgumentException("이미지 파일을 업로드해주세요."); + } + + List allowedExt = List.of("jpg", "jpeg", "png", "webp"); + if (!allowedExt.contains(getExtension(thumbnailFile.getOriginalFilename()))) { + throw new IllegalArgumentException("지원하지 않는 확장자입니다."); + } + } + + private String convertToThumbnailPath(MultipartFile thumbnailFile) throws IOException { + String originalFilename = thumbnailFile.getOriginalFilename(); + String ext = getExtension(originalFilename); + String savedFilename = UUID.randomUUID().toString() + "." + ext; + + Path savePath = Paths.get(uploadPath, savedFilename).toAbsolutePath(); + thumbnailFile.transferTo(savePath.toFile()); + + return "/images/thumbnail/" + savedFilename; + } + + private String getExtension(String filename) { + return filename.substring(filename.lastIndexOf(".") + 1); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dao/QuizRepository.java b/backend/src/main/java/io/f1/backend/domain/quiz/dao/QuizRepository.java new file mode 100644 index 00000000..a88c98e7 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dao/QuizRepository.java @@ -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 {} diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateRequest.java b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateRequest.java new file mode 100644 index 00000000..418477fb --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateRequest.java @@ -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 questions; +} diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateResponse.java b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateResponse.java new file mode 100644 index 00000000..eddf6dfc --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/quiz/dto/QuizCreateResponse.java @@ -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) {} diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/entity/Quiz.java b/backend/src/main/java/io/f1/backend/domain/quiz/entity/Quiz.java index befca13c..5168a1e1 100644 --- a/backend/src/main/java/io/f1/backend/domain/quiz/entity/Quiz.java +++ b/backend/src/main/java/io/f1/backend/domain/quiz/entity/Quiz.java @@ -17,6 +17,7 @@ import jakarta.persistence.OneToMany; import lombok.Getter; +import lombok.NoArgsConstructor; import lombok.Setter; import java.util.ArrayList; @@ -25,6 +26,7 @@ @Getter @Setter // quizService의 퀴즈 조회 메서드 구현 시까지 임시 사용 @Entity +@NoArgsConstructor public class Quiz extends BaseEntity { @Id @@ -50,4 +52,21 @@ public class Quiz extends BaseEntity { @ManyToOne @JoinColumn(name = "creator_id") private User creator; + + 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; + } + + public void addQuestion(Question question) { + this.questions.add(question); + } } diff --git a/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java b/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java new file mode 100644 index 00000000..762c6ead --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/quiz/mapper/QuizMapper.java @@ -0,0 +1,33 @@ +package io.f1.backend.domain.quiz.mapper; + +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.user.entity.User; + +public class QuizMapper { + + // TODO : 이후 파라미터에서 user 삭제하기 + public static Quiz quizCreateRequestToQuiz( + QuizCreateRequest quizCreateRequest, String imgUrl, User user) { + + return new Quiz( + quizCreateRequest.getTitle(), + quizCreateRequest.getDescription(), + quizCreateRequest.getQuizType(), + imgUrl, + user // TODO : 이후 creator에 들어갈 User은 현재 로그인 중인 유저를 가져오도록 변경 + ); + } + + public static QuizCreateResponse quizToQuizCreateResponse(Quiz quiz) { + // TODO : creatorId 넣어주는 부분에서 Getter를 안 쓰고, 현재 로그인한 유저의 id를 담는 식으로 바꿔도 될 듯 + return new QuizCreateResponse( + quiz.getId(), + quiz.getTitle(), + quiz.getQuizType(), + quiz.getDescription(), + quiz.getThumbnailUrl(), + quiz.getCreator().getId()); + } +} diff --git a/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java b/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java new file mode 100644 index 00000000..71267cd3 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/user/dao/UserRepository.java @@ -0,0 +1,8 @@ +package io.f1.backend.domain.user.dao; + +import io.f1.backend.domain.user.entity.User; + +import org.springframework.data.jpa.repository.JpaRepository; + +// TODO : 퀴즈 생성을 위한 user 생성을 위해 임의로 만듦. +public interface UserRepository extends JpaRepository {} diff --git a/backend/src/main/java/io/f1/backend/global/config/WebConfig.java b/backend/src/main/java/io/f1/backend/global/config/WebConfig.java index 224c315d..8d093eec 100644 --- a/backend/src/main/java/io/f1/backend/global/config/WebConfig.java +++ b/backend/src/main/java/io/f1/backend/global/config/WebConfig.java @@ -1,3 +1,17 @@ package io.f1.backend.global.config; -public class WebConfig {} +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + // 클라이언트가 /image/thumbnail/~~ 로 요청 + // 실제 서버 경로 images/thumbnail/ 에서 리소스 찾아서 응답 + registry.addResourceHandler("/images/thumbnail/**") + .addResourceLocations("file:images/thumbnail/"); + } +} diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 82beff8c..7a6d0fbd 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -2,6 +2,10 @@ spring: config: import: optional:file:.env[.properties] + sql: + init: + mode: always # 현재는 data.sql 에서 더미 유저 자동 추가를 위해 넣어뒀음. + datasource: driver-class-name: com.mysql.cj.jdbc.Driver url : ${DB_URL} @@ -14,10 +18,15 @@ spring: port: ${REDIS_PORT} jpa: + defer-datasource-initialization: true # 현재는 data.sql 에서 더미 유저 자동 추가를 위해 넣어뒀음. hibernate: ddl-auto: create properties: hibernate: show_sql: true - format_sql: true \ No newline at end of file + format_sql: true + +file: + thumbnail-path : images/thumbnail/ # 이후 배포 환경에서는 바꾸면 될 듯 + default-thumbnail-url: /images/thumbnail/default.png diff --git a/backend/src/main/resources/data.sql b/backend/src/main/resources/data.sql new file mode 100644 index 00000000..015cab81 --- /dev/null +++ b/backend/src/main/resources/data.sql @@ -0,0 +1,2 @@ +INSERT INTO user (id, nickname, provider, provider_id, last_login) +VALUES (1, 'test-user', 'kakao', 'kakao-1234', NOW()); diff --git a/backend/src/test/resources/application.yml b/backend/src/test/resources/application.yml index 384b0bcb..713e2b9c 100644 --- a/backend/src/test/resources/application.yml +++ b/backend/src/test/resources/application.yml @@ -2,4 +2,12 @@ spring: datasource: url: jdbc:h2:mem:testdb;MODE=MYSQL username: sa - password: \ No newline at end of file + password: + + sql: + init: + mode: never + +file: + thumbnail-path : images/thumbnail/ # 이후 배포 환경에서는 바꾸면 될 듯 + default-thumbnail-url: /images/thumbnail/default.png