Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
import io.f1.backend.domain.game.store.RoomRepository;
import io.f1.backend.domain.quiz.app.QuizService;
import io.f1.backend.domain.quiz.entity.Quiz;
import io.f1.backend.global.exception.CustomException;
import io.f1.backend.global.exception.errorcode.GameErrorCode;
import io.f1.backend.global.exception.errorcode.RoomErrorCode;

import lombok.RequiredArgsConstructor;

Expand All @@ -31,10 +34,10 @@ public GameStartData gameStart(Long roomId, Long quizId) {
Room room =
roomRepository
.findRoom(roomId)
.orElseThrow(() -> new IllegalArgumentException("404 존재하지 않는 방입니다."));
.orElseThrow(() -> new CustomException(RoomErrorCode.ROOM_NOT_FOUND));

if (!validateReadyStatus(room)) {
throw new IllegalArgumentException("E403004 : 레디 상태가 아닙니다.");
throw new CustomException(RoomErrorCode.PLAYER_NOT_READY);
}

// 방의 gameSetting에 설정된 퀴즈랑 요청 퀴즈랑 같은지 체크 후 GameSetting에서 라운드 가져오기
Expand All @@ -58,7 +61,7 @@ private Integer checkGameSetting(Room room, Long quizId) {
GameSetting gameSetting = room.getGameSetting();

if (!gameSetting.checkQuizId(quizId)) {
throw new IllegalArgumentException("E409002 : 게임 설정이 다릅니다. (게임을 시작할 수 없습니다.)");
throw new CustomException(GameErrorCode.GAME_SETTING_CONFLICT);
}

return gameSetting.getRound();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@
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 io.f1.backend.global.exception.CustomException;
import io.f1.backend.global.exception.errorcode.QuestionErrorCode;

import lombok.RequiredArgsConstructor;

import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.NoSuchElementException;

@Service
@RequiredArgsConstructor
public class QuestionService {
Expand Down Expand Up @@ -44,7 +44,8 @@ public void updateQuestionContent(Long questionId, String content) {
Question question =
questionRepository
.findById(questionId)
.orElseThrow(() -> new NoSuchElementException("존재하지 않는 문제입니다."));
.orElseThrow(
() -> new CustomException(QuestionErrorCode.QUESTION_NOT_FOUND));

TextQuestion textQuestion = question.getTextQuestion();
textQuestion.changeContent(content);
Expand All @@ -58,7 +59,8 @@ public void updateQuestionAnswer(Long questionId, String answer) {
Question question =
questionRepository
.findById(questionId)
.orElseThrow(() -> new NoSuchElementException("존재하지 않는 문제입니다."));
.orElseThrow(
() -> new CustomException(QuestionErrorCode.QUESTION_NOT_FOUND));

question.changeAnswer(answer);
}
Expand All @@ -69,20 +71,21 @@ public void deleteQuestion(Long questionId) {
Question question =
questionRepository
.findById(questionId)
.orElseThrow(() -> new NoSuchElementException("존재하지 않는 문제입니다."));
.orElseThrow(
() -> new CustomException(QuestionErrorCode.QUESTION_NOT_FOUND));

questionRepository.delete(question);
}

private void validateAnswer(String answer) {
if (answer.trim().length() < 5 || answer.trim().length() > 30) {
throw new IllegalArgumentException("정답은 1자 이상 30자 이하로 입력해주세요.");
throw new CustomException(QuestionErrorCode.INVALID_ANSWER_LENGTH);
}
}

private void validateContent(String content) {
if (content.trim().length() < 5 || content.trim().length() > 30) {
throw new IllegalArgumentException("문제는 5자 이상 30자 이하로 입력해주세요.");
throw new CustomException(QuestionErrorCode.INVALID_CONTENT_LENGTH);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import io.f1.backend.domain.quiz.dto.QuizListPageResponse;
import io.f1.backend.domain.quiz.dto.QuizQuestionListResponse;
import io.f1.backend.domain.quiz.dto.QuizUpdateRequest;
import io.f1.backend.global.exception.CustomException;
import io.f1.backend.global.exception.errorcode.CommonErrorCode;

import jakarta.validation.Valid;

Expand All @@ -28,8 +30,6 @@
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;

import java.io.IOException;

@RestController
@RequestMapping("/quizzes")
@RequiredArgsConstructor
Expand All @@ -40,8 +40,7 @@ public class QuizController {
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<QuizCreateResponse> saveQuiz(
@RequestPart(required = false) MultipartFile thumbnailFile,
@Valid @RequestPart QuizCreateRequest request)
throws IOException {
@Valid @RequestPart QuizCreateRequest request) {
QuizCreateResponse response = quizService.saveQuiz(thumbnailFile, request);

return ResponseEntity.status(HttpStatus.CREATED).body(response);
Expand All @@ -58,8 +57,7 @@ public ResponseEntity<Void> deleteQuiz(@PathVariable Long quizId) {
public ResponseEntity<Void> updateQuiz(
@PathVariable Long quizId,
@RequestPart(required = false) MultipartFile thumbnailFile,
@RequestPart QuizUpdateRequest request)
throws IOException {
@RequestPart QuizUpdateRequest request) {

if (request.title() != null) {
quizService.updateQuizTitle(quizId, request.title());
Expand All @@ -83,6 +81,13 @@ public ResponseEntity<QuizListPageResponse> getQuizzes(
@RequestParam(required = false) String title,
@RequestParam(required = false) String creator) {

if (page <= 0) {
throw new CustomException(CommonErrorCode.INVALID_PAGINATION);
}
if (size <= 0 || size > 100) {
throw new CustomException(CommonErrorCode.INVALID_PAGINATION);
}

Pageable pageable =
PageRequest.of(page - 1, size, Sort.by(Sort.Direction.DESC, "createdAt"));
QuizListPageResponse quizzes = quizService.getQuizzes(title, creator, pageable);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
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 io.f1.backend.global.exception.CustomException;
import io.f1.backend.global.exception.errorcode.AuthErrorCode;
import io.f1.backend.global.exception.errorcode.QuizErrorCode;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand Down Expand Up @@ -48,15 +51,15 @@ public class QuizService {
private String defaultThumbnailPath;

private final String DEFAULT = "default";
private static final long MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB

// TODO : 시큐리티 구현 이후 삭제해도 되는 의존성 주입
private final UserRepository userRepository;
private final QuestionService questionService;
private final QuizRepository quizRepository;

@Transactional
public QuizCreateResponse saveQuiz(MultipartFile thumbnailFile, QuizCreateRequest request)
throws IOException {
public QuizCreateResponse saveQuiz(MultipartFile thumbnailFile, QuizCreateRequest request) {
String thumbnailPath = defaultThumbnailPath;

if (thumbnailFile != null && !thumbnailFile.isEmpty()) {
Expand All @@ -81,29 +84,38 @@ public QuizCreateResponse saveQuiz(MultipartFile thumbnailFile, QuizCreateReques
private void validateImageFile(MultipartFile thumbnailFile) {

if (!thumbnailFile.getContentType().startsWith("image")) {
// TODO : 이후 커스텀 예외로 변경
throw new IllegalArgumentException("이미지 파일을 업로드해주세요.");
throw new CustomException(QuizErrorCode.UNSUPPORTED_MEDIA_TYPE);
}

List<String> allowedExt = List.of("jpg", "jpeg", "png", "webp");
if (!allowedExt.contains(getExtension(thumbnailFile.getOriginalFilename()))) {
throw new IllegalArgumentException("지원하지 않는 확장자입니다.");
String ext = getExtension(thumbnailFile.getOriginalFilename());
if (!allowedExt.contains(ext)) {
throw new CustomException(QuizErrorCode.UNSUPPORTED_IMAGE_FORMAT);
}

if (thumbnailFile.getSize() > MAX_FILE_SIZE) {
throw new CustomException(QuizErrorCode.FILE_SIZE_TOO_LARGE);
}
}

private String convertToThumbnailPath(MultipartFile thumbnailFile) throws IOException {
private String convertToThumbnailPath(MultipartFile thumbnailFile) {
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());
try {
Path savePath = Paths.get(uploadPath, savedFilename).toAbsolutePath();
thumbnailFile.transferTo(savePath.toFile());
} catch (IOException e) {
log.error("썸네일 업로드 중 IOException 발생", e);
throw new CustomException(QuizErrorCode.THUMBNAIL_SAVE_FAILED);
}

return "/images/thumbnail/" + savedFilename;
}

private String getExtension(String filename) {
return filename.substring(filename.lastIndexOf(".") + 1);
return filename.substring(filename.lastIndexOf(".") + 1).toLowerCase();
}

@Transactional
Expand All @@ -112,11 +124,11 @@ public void deleteQuiz(Long quizId) {
Quiz quiz =
quizRepository
.findById(quizId)
.orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다."));
.orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND));

// TODO : util 메서드에서 사용자 ID 꺼내쓰는 식으로 수정하기
if (1L != quiz.getCreator().getId()) {
throw new RuntimeException("권한이 없습니다.");
throw new CustomException(AuthErrorCode.FORBIDDEN);
}

deleteThumbnailFile(quiz.getThumbnailUrl());
Expand All @@ -128,7 +140,7 @@ public void updateQuizTitle(Long quizId, String title) {
Quiz quiz =
quizRepository
.findById(quizId)
.orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다."));
.orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND));

validateTitle(title);
quiz.changeTitle(title);
Expand All @@ -140,19 +152,19 @@ public void updateQuizDesc(Long quizId, String description) {
Quiz quiz =
quizRepository
.findById(quizId)
.orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다."));
.orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND));

validateDesc(description);
quiz.changeDescription(description);
}

@Transactional
public void updateThumbnail(Long quizId, MultipartFile thumbnailFile) throws IOException {
public void updateThumbnail(Long quizId, MultipartFile thumbnailFile) {

Quiz quiz =
quizRepository
.findById(quizId)
.orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다."));
.orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND));

validateImageFile(thumbnailFile);
String newThumbnailPath = convertToThumbnailPath(thumbnailFile);
Expand All @@ -163,13 +175,13 @@ public void updateThumbnail(Long quizId, MultipartFile thumbnailFile) throws IOE

private void validateDesc(String desc) {
if (desc.trim().length() < 10 || desc.trim().length() > 50) {
throw new IllegalArgumentException("설명은 10자 이상 50자 이하로 입력해주세요.");
throw new CustomException(QuizErrorCode.INVALID_DESC_LENGTH);
}
}

private void validateTitle(String title) {
if (title.trim().length() < 2 || title.trim().length() > 30) {
throw new IllegalArgumentException("제목은 2자 이상 30자 이하로 입력해주세요.");
throw new CustomException(QuizErrorCode.INVALID_TITLE_LENGTH);
}
}

Expand All @@ -192,7 +204,7 @@ private void deleteThumbnailFile(String oldFilename) {
}
} catch (IOException e) {
log.error("기존 썸네일 삭제 중 오류 : {}", filePath);
throw new RuntimeException(e);
throw new CustomException(QuizErrorCode.THUMBNAIL_DELETE_FAILED);
}
}

Expand All @@ -202,9 +214,9 @@ public QuizListPageResponse getQuizzes(String title, String creator, Pageable pa
Page<Quiz> quizzes;

// 검색어가 있을 때
if (StringUtils.isBlank(title)) {
if (!StringUtils.isBlank(title)) {
quizzes = quizRepository.findQuizzesByTitleContaining(title, pageable);
} else if (StringUtils.isBlank(creator)) {
} else if (!StringUtils.isBlank(creator)) {
quizzes = quizRepository.findQuizzesByCreator_NicknameContaining(creator, pageable);
} else { // 검색어가 없을 때 혹은 빈 문자열일 때
quizzes = quizRepository.findAll(pageable);
Expand All @@ -220,7 +232,7 @@ public Quiz getQuizWithQuestionsById(Long quizId) {
Quiz quiz =
quizRepository
.findQuizWithQuestionsById(quizId)
.orElseThrow(() -> new RuntimeException("E404002: 존재하지 않는 퀴즈입니다."));
.orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND));
return quiz;
}

Expand All @@ -234,7 +246,7 @@ public QuizQuestionListResponse getQuizWithQuestions(Long quizId) {
Quiz quiz =
quizRepository
.findById(quizId)
.orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다."));
.orElseThrow(() -> new CustomException(QuizErrorCode.QUIZ_NOT_FOUND));

return quizToQuizQuestionListResponse(quiz);
}
Expand Down
18 changes: 17 additions & 1 deletion backend/src/main/java/io/f1/backend/domain/quiz/entity/Quiz.java
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ public class Quiz extends BaseEntity {
private String thumbnailUrl;

@ManyToOne
@JoinColumn(name = "creator_id")
@JoinColumn(name = "creator_id", nullable = true)
private User creator;

public Quiz(
Expand Down Expand Up @@ -81,4 +81,20 @@ public void changeDescription(String description) {
public void changeThumbnailUrl(String thumbnailUrl) {
this.thumbnailUrl = thumbnailUrl;
}

public Long getUserIdIfExists() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

[L5-참고의견]
메서드명에 IfExists는 제외하고 Optional을 return 해주는 것도 하나의 방법일 것 같습니다.

Copy link
Collaborator

Choose a reason for hiding this comment

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

추가로, UserId 대신 CreatorId가 의미 전달에 효과적일 수 있을 것 같습니다.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

옹 ! 메서드명 둘 다 Creator로 변경했습니다 ! 또한, IfExists보다, 확실하게 값이 있을지 없을지 모른다는 느낌으로 get보다는 find가 맞을 것 같아 findCreatorId로 메서드명 변경했습니다 ! 좋은 의견 감사합니다 !! :)

if (this.creator == null) {
return null;
}

return this.creator.getId();
}

public String getUserNicknameIfExists() {
Copy link
Collaborator

Choose a reason for hiding this comment

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

[L5-참고의견]
위와 마찬가지로 메서드명의 IfExists는 제외해도 충분한 의미 전달이 가능할 것 같습니다.

if (this.creator == null) {
return "탈퇴한 사용자";
}

return this.creator.getNickname();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,15 @@ public static QuizCreateResponse quizToQuizCreateResponse(Quiz quiz) {
quiz.getQuizType(),
quiz.getDescription(),
quiz.getThumbnailUrl(),
quiz.getCreator().getId());
quiz.getUserIdIfExists());
}

public static QuizListResponse quizToQuizListResponse(Quiz quiz) {
return new QuizListResponse(
quiz.getId(),
quiz.getTitle(),
quiz.getDescription(),
quiz.getCreator().getNickname(),
quiz.getUserNicknameIfExists(),
quiz.getQuestions().size(),
quiz.getThumbnailUrl());
}
Expand Down Expand Up @@ -79,7 +79,7 @@ public static QuizQuestionListResponse quizToQuizQuestionListResponse(Quiz quiz)
return new QuizQuestionListResponse(
quiz.getTitle(),
quiz.getQuizType(),
quiz.getCreator().getId(),
quiz.getUserIdIfExists(),
quiz.getDescription(),
quiz.getThumbnailUrl(),
quiz.getQuestions().size(),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package io.f1.backend.global.exception.errorcode;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

import org.springframework.http.HttpStatus;

@Getter
@RequiredArgsConstructor
public enum GameErrorCode implements ErrorCode {
GAME_SETTING_CONFLICT("E409002", HttpStatus.CONFLICT, "게임 설정이 맞지 않습니다.");

private final String code;

private final HttpStatus httpStatus;

private final String message;
}
Loading