diff --git a/backend/src/main/java/io/f1/backend/domain/question/api/QuestionController.java b/backend/src/main/java/io/f1/backend/domain/question/api/QuestionController.java new file mode 100644 index 00000000..328ba7d9 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/api/QuestionController.java @@ -0,0 +1,44 @@ +package io.f1.backend.domain.question.api; + +import io.f1.backend.domain.question.app.QuestionService; +import io.f1.backend.domain.question.dto.QuestionUpdateRequest; + +import lombok.RequiredArgsConstructor; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/questions") +@RequiredArgsConstructor +public class QuestionController { + + private final QuestionService questionService; + + @PutMapping("/{questionId}") + public ResponseEntity updateQuestion( + @PathVariable Long questionId, @RequestBody QuestionUpdateRequest request) { + + if (request.content() != null) { + questionService.updateQuestionContent(questionId, request.content()); + } + + if (request.content() != null) { + questionService.updateQuestionAnswer(questionId, request.answer()); + } + + return ResponseEntity.noContent().build(); + } + + @DeleteMapping("/{questionId}") + public ResponseEntity deleteQuestion(@PathVariable Long questionId) { + questionService.deleteQuestion(questionId); + + return ResponseEntity.noContent().build(); + } +} 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 87a0e222..6fc7c954 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 @@ -15,6 +15,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.NoSuchElementException; + @Service @RequiredArgsConstructor public class QuestionService { @@ -33,4 +35,54 @@ public void saveQuestion(Quiz quiz, QuestionRequest request) { textQuestionRepository.save(textQuestion); question.addTextQuestion(textQuestion); } + + @Transactional + public void updateQuestionContent(Long questionId, String content) { + + validateContent(content); + + Question question = + questionRepository + .findById(questionId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 문제입니다.")); + + TextQuestion textQuestion = question.getTextQuestion(); + textQuestion.changeContent(content); + } + + @Transactional + public void updateQuestionAnswer(Long questionId, String answer) { + + validateAnswer(answer); + + Question question = + questionRepository + .findById(questionId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 문제입니다.")); + + question.changeAnswer(answer); + } + + @Transactional + public void deleteQuestion(Long questionId) { + + Question question = + questionRepository + .findById(questionId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 문제입니다.")); + + questionRepository.delete(question); + } + + private void validateAnswer(String answer) { + if (answer.trim().length() < 5 || answer.trim().length() > 30) { + throw new IllegalArgumentException("정답은 1자 이상 30자 이하로 입력해주세요."); + } + } + + private void validateContent(String content) { + if (content.trim().length() < 5 || content.trim().length() > 30) { + throw new IllegalArgumentException("문제는 5자 이상 30자 이하로 입력해주세요."); + } + } } 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 index 9374a9b9..4cdce5ff 100644 --- 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 @@ -1,5 +1,7 @@ package io.f1.backend.domain.question.dto; +import io.f1.backend.global.validation.TrimmedSize; + import jakarta.validation.constraints.NotBlank; import lombok.AccessLevel; @@ -10,9 +12,11 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class QuestionRequest { + @TrimmedSize(min = 5, max = 30) @NotBlank(message = "문제를 입력해주세요.") private String content; + @TrimmedSize(min = 1, max = 30) @NotBlank(message = "정답을 입력해주세요.") private String answer; } diff --git a/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionUpdateRequest.java b/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionUpdateRequest.java new file mode 100644 index 00000000..e77a3dea --- /dev/null +++ b/backend/src/main/java/io/f1/backend/domain/question/dto/QuestionUpdateRequest.java @@ -0,0 +1,3 @@ +package io.f1.backend.domain.question.dto; + +public record QuestionUpdateRequest(String content, 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 b197ac6e..fe6d0783 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 @@ -45,4 +45,8 @@ public Question(Quiz quiz, String answer) { public void addTextQuestion(TextQuestion textQuestion) { this.textQuestion = textQuestion; } + + public void changeAnswer(String answer) { + this.answer = answer; + } } 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 7adf05c1..b10be109 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 @@ -32,4 +32,8 @@ public TextQuestion(Question question, String content) { this.question = question; this.content = content; } + + public void changeContent(String content) { + this.content = 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 index 64b4846e..ee8d03a6 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 @@ -61,7 +61,17 @@ public ResponseEntity updateQuiz( @RequestPart QuizUpdateRequest request) throws IOException { - quizService.updateQuiz(quizId, thumbnailFile, request); + if (request.title() != null) { + quizService.updateQuizTitle(quizId, request.title()); + } + + if (request.description() != null) { + quizService.updateQuizDesc(quizId, request.description()); + } + + if (thumbnailFile != null && !thumbnailFile.isEmpty()) { + quizService.updateThumbnail(quizId, thumbnailFile); + } return ResponseEntity.noContent().build(); } 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 37c0585b..a74f0658 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 @@ -12,7 +12,6 @@ import io.f1.backend.domain.quiz.dto.QuizListPageResponse; import io.f1.backend.domain.quiz.dto.QuizListResponse; import io.f1.backend.domain.quiz.dto.QuizQuestionListResponse; -import io.f1.backend.domain.quiz.dto.QuizUpdateRequest; import io.f1.backend.domain.quiz.entity.Quiz; import io.f1.backend.domain.user.dao.UserRepository; import io.f1.backend.domain.user.entity.User; @@ -123,28 +122,52 @@ public void deleteQuiz(Long quizId) { } @Transactional - public void updateQuiz(Long quizId, MultipartFile thumbnailFile, QuizUpdateRequest request) - throws IOException { + public void updateQuizTitle(Long quizId, String title) { + Quiz quiz = + quizRepository + .findById(quizId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다.")); + + validateTitle(title); + quiz.changeTitle(title); + } + + @Transactional + public void updateQuizDesc(Long quizId, String description) { Quiz quiz = quizRepository .findById(quizId) .orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다.")); - if (request.title() != null) { - quiz.changeTitle(request.title()); - } + validateDesc(description); + quiz.changeDescription(description); + } - if (request.description() != null) { - quiz.changeDescription(request.description()); - } + @Transactional + public void updateThumbnail(Long quizId, MultipartFile thumbnailFile) throws IOException { - if (thumbnailFile != null && !thumbnailFile.isEmpty()) { - validateImageFile(thumbnailFile); - String newThumbnailPath = convertToThumbnailPath(thumbnailFile); + Quiz quiz = + quizRepository + .findById(quizId) + .orElseThrow(() -> new NoSuchElementException("존재하지 않는 퀴즈입니다.")); + + validateImageFile(thumbnailFile); + String newThumbnailPath = convertToThumbnailPath(thumbnailFile); + + deleteThumbnailFile(quiz.getThumbnailUrl()); + quiz.changeThumbnailUrl(newThumbnailPath); + } + + private void validateDesc(String desc) { + if (desc.trim().length() < 10 || desc.trim().length() > 50) { + throw new IllegalArgumentException("설명은 10자 이상 50자 이하로 입력해주세요."); + } + } - deleteThumbnailFile(quiz.getThumbnailUrl()); - quiz.changeThumbnailUrl(newThumbnailPath); + private void validateTitle(String title) { + if (title.trim().length() < 2 || title.trim().length() > 30) { + throw new IllegalArgumentException("제목은 2자 이상 30자 이하로 입력해주세요."); } } 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 index 418477fb..313a519a 100644 --- 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 @@ -2,6 +2,7 @@ import io.f1.backend.domain.question.dto.QuestionRequest; import io.f1.backend.domain.quiz.entity.QuizType; +import io.f1.backend.global.validation.TrimmedSize; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -17,12 +18,14 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class QuizCreateRequest { + @TrimmedSize(min = 2, max = 30) @NotBlank(message = "퀴즈 제목을 설정해주세요.") private String title; @NotNull(message = "퀴즈 종류를 선택해주세요.") private QuizType quizType; + @TrimmedSize(min = 10, max = 50) @NotBlank(message = "퀴즈 설명을 적어주세요.") private String description; diff --git a/backend/src/main/java/io/f1/backend/global/validation/TrimmedSize.java b/backend/src/main/java/io/f1/backend/global/validation/TrimmedSize.java new file mode 100644 index 00000000..9b0d9fa9 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/validation/TrimmedSize.java @@ -0,0 +1,23 @@ +package io.f1.backend.global.validation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = TrimmedSizeValidator.class) +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +public @interface TrimmedSize { + + String message() default "공백 제외 길이가 {min}자 이상 {min}자 이하여야 합니다."; + + int min() default 0; + + int max() default 50; + + Class[] groups() default {}; + + Class[] payload() default {}; +} diff --git a/backend/src/main/java/io/f1/backend/global/validation/TrimmedSizeValidator.java b/backend/src/main/java/io/f1/backend/global/validation/TrimmedSizeValidator.java new file mode 100644 index 00000000..f3c32e99 --- /dev/null +++ b/backend/src/main/java/io/f1/backend/global/validation/TrimmedSizeValidator.java @@ -0,0 +1,26 @@ +package io.f1.backend.global.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class TrimmedSizeValidator implements ConstraintValidator { + + private int min; + private int max; + + @Override + public void initialize(TrimmedSize constraintAnnotation) { + this.min = constraintAnnotation.min(); + this.max = constraintAnnotation.max(); + } + + @Override + public boolean isValid(String value, ConstraintValidatorContext context) { + if (value == null) return true; + + String trimmed = value.trim(); + int length = trimmed.length(); + + return length >= min && length <= max; + } +}