Skip to content
Merged
5 changes: 4 additions & 1 deletion backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[L4-변경제안]
Mapper 호출 부분은 스태틱 임포트해서 더 간결하게 표현하면 어떨까요 🤔

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
Expand Up @@ -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
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
}
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,
@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 {
Copy link
Collaborator

@jiwon1217 jiwon1217 Jul 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[L4-변경제안]
이미지 경로를 변환하는 작업을 수행하기 때문에 "썸네일을 저장한다"는 이름보다 메서드의 동작을 잘 나타내는 이름을 정하는 것이 좋다고 생각합니다 !

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

파일을 서버에 올린다(?)는 걸 저장한다고 생각해서 saveThumbnail으로 정했는데, 생각해보니 좀 모호한 메서드명인 것 같습니다. 파일을 썸네일 경로에 저장하겠다는 의미로 convertToThumbnailPath로 수정하겠습니다 !

의견 감사합니다 👍

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) {}
25 changes: 25 additions & 0 deletions backend/src/main/java/io/f1/backend/domain/quiz/entity/Quiz.java
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
}
Copy link
Collaborator

@sehee123 sehee123 Jul 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[L4-변경제안]
현재 id를 제외한 모든 필드가 필수값으로 보이는데 이런 상황에선 생성자를 사용하게되면 필수값들을 강제할 수 있다는 점에서 빌더보단 생성자가 의도가 더 명확하지않나 생각됩니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

의견 감사합니다 ! 사실 처음에 생성자로 했다가 빌더로 변경했었는데, 그 이유가 아무래도 들어가는 필드값이 많다보니 생성자로 해두었을 때 가독성이 낮고, 어떤 필드에 뭐가 들어가는건지 좀 보기 힘들더라구요..! 그래서 빌더패턴으로 수정했었는데,,,

세희님 리뷰 듣고 저 두 특징에 대해 고민을 좀 해봤는데, 어차피 dto -> entity를 mapper로 분리해놨으니, 서비스에서 이 생성자 코드를 볼 일은 없다고 생각이 되고, 그렇게 되면 가독성 이슈를 조금 덜 신경써도 되겠다는 생각이 듭니다! 그래서 필드값을 강제할 수 있는 생성자로 수정하는 것도 괜찮겠다고 생각이드네요..! 이 부분 수정하겠습니다 👍


public void addQuestion(Question question) {
this.questions.add(question);
}
}
Loading