diff --git a/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java index 12cbd7ed..55357c74 100644 --- a/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java +++ b/backend/src/main/java/io/f1/backend/domain/game/app/GameService.java @@ -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; @@ -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에서 라운드 가져오기 @@ -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(); 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 index 6fc7c954..277353b1 100644 --- 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 @@ -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 { @@ -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); @@ -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); } @@ -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); } } } 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 index ee8d03a6..4d7a0d60 100644 --- 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 @@ -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; @@ -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 @@ -40,8 +40,7 @@ public class QuizController { @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity 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); @@ -58,8 +57,7 @@ public ResponseEntity deleteQuiz(@PathVariable Long quizId) { public ResponseEntity 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()); @@ -83,6 +81,13 @@ public ResponseEntity 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); 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 index ce705fb6..3edafc68 100644 --- 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 @@ -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; @@ -48,6 +51,7 @@ 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; @@ -55,8 +59,7 @@ public class QuizService { 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()) { @@ -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 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 @@ -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()); @@ -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); @@ -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); @@ -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); } } @@ -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); } } @@ -202,9 +214,9 @@ public QuizListPageResponse getQuizzes(String title, String creator, Pageable pa Page 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); @@ -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; } @@ -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); } 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 fde07c7e..1cba9273 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 @@ -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( @@ -81,4 +81,20 @@ public void changeDescription(String description) { public void changeThumbnailUrl(String thumbnailUrl) { this.thumbnailUrl = thumbnailUrl; } + + public Long findCreatorId() { + if (this.creator == null) { + return null; + } + + return this.creator.getId(); + } + + public String findCreatorNickname() { + if (this.creator == null) { + return "탈퇴한 사용자"; + } + + return this.creator.getNickname(); + } } 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 index 0c0c0513..b9dffc0e 100644 --- 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 @@ -39,7 +39,7 @@ public static QuizCreateResponse quizToQuizCreateResponse(Quiz quiz) { quiz.getQuizType(), quiz.getDescription(), quiz.getThumbnailUrl(), - quiz.getCreator().getId()); + quiz.findCreatorId()); } public static QuizListResponse quizToQuizListResponse(Quiz quiz) { @@ -47,7 +47,7 @@ public static QuizListResponse quizToQuizListResponse(Quiz quiz) { quiz.getId(), quiz.getTitle(), quiz.getDescription(), - quiz.getCreator().getNickname(), + quiz.findCreatorNickname(), quiz.getQuestions().size(), quiz.getThumbnailUrl()); } @@ -79,7 +79,7 @@ public static QuizQuestionListResponse quizToQuizQuestionListResponse(Quiz quiz) return new QuizQuestionListResponse( quiz.getTitle(), quiz.getQuizType(), - quiz.getCreator().getId(), + quiz.findCreatorId(), quiz.getDescription(), quiz.getThumbnailUrl(), quiz.getQuestions().size(), diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/GameErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/GameErrorCode.java new file mode 100644 index 00000000..73a12122 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/GameErrorCode.java @@ -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; +} diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java index 9b7c0c1e..7a179950 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuestionErrorCode.java @@ -8,6 +8,8 @@ @Getter @RequiredArgsConstructor public enum QuestionErrorCode implements ErrorCode { + INVALID_CONTENT_LENGTH("E400011", HttpStatus.BAD_REQUEST, "문제는 5자 이상 30자 이하로 입력해주세요."), + INVALID_ANSWER_LENGTH("E400012", HttpStatus.BAD_REQUEST, "정답은 1자 이상 30자 이하로 입력해주세요."), QUESTION_NOT_FOUND("E404003", HttpStatus.NOT_FOUND, "존재하지 않는 문제입니다."); private final String code; diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java index e03983b1..fbedd44c 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/QuizErrorCode.java @@ -9,9 +9,15 @@ @RequiredArgsConstructor public enum QuizErrorCode implements ErrorCode { FILE_SIZE_TOO_LARGE("E400005", HttpStatus.BAD_REQUEST, "파일 크기가 너무 큽니다."), + INVALID_TITLE_LENGTH("E400009", HttpStatus.BAD_REQUEST, "제목은 2자 이상 30자 이하로 입력해주세요."), + INVALID_DESC_LENGTH("E400010", HttpStatus.BAD_REQUEST, "설명은 10자 이상 50자 이하로 입력해주세요."), + UNSUPPORTED_IMAGE_FORMAT( + "E400013", HttpStatus.BAD_REQUEST, "지원하지 않는 이미지 형식입니다. (jpg, jpeg, png, webp 만 가능)"), UNSUPPORTED_MEDIA_TYPE("E415001", HttpStatus.UNSUPPORTED_MEDIA_TYPE, "지원하지 않는 파일 형식입니다."), INVALID_FILTER("E400007", HttpStatus.BAD_REQUEST, "title 또는 creator 중 하나만 입력 가능합니다."), - QUIZ_NOT_FOUND("E404002", HttpStatus.NOT_FOUND, "존재하지 않는 퀴즈입니다."); + QUIZ_NOT_FOUND("E404002", HttpStatus.NOT_FOUND, "존재하지 않는 퀴즈입니다."), + THUMBNAIL_SAVE_FAILED("E500002", HttpStatus.INTERNAL_SERVER_ERROR, "썸네일 저장에 실패했습니다."), + THUMBNAIL_DELETE_FAILED("E500003", HttpStatus.INTERNAL_SERVER_ERROR, "썸네일 삭제에 실패했습니다."); private final String code; diff --git a/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java b/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java index 191147ac..cc8e627b 100644 --- a/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java +++ b/backend/src/main/java/io/f1/backend/global/exception/errorcode/RoomErrorCode.java @@ -10,6 +10,7 @@ public enum RoomErrorCode implements ErrorCode { ROOM_USER_LIMIT_REACHED("E403002", HttpStatus.FORBIDDEN, "정원이 모두 찼습니다."), ROOM_GAME_IN_PROGRESS("E403003", HttpStatus.FORBIDDEN, "게임이 진행 중 입니다."), + PLAYER_NOT_READY("E403004", HttpStatus.FORBIDDEN, "게임 시작을 위한 준비 상태가 아닙니다."), ROOM_NOT_FOUND("E404005", HttpStatus.NOT_FOUND, "존재하지 않는 방입니다."), WRONG_PASSWORD("E401006", HttpStatus.UNAUTHORIZED, "비밀번호가 일치하지않습니다."); diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 4fe7ef60..a99b38c8 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,4 +1,8 @@ spring: + servlet: + multipart: + max-file-size: 5MB + config: import: optional:file:.env[.properties]