diff --git a/src/main/java/com/example/epari/exam/controller/ExamController.java b/src/main/java/com/example/epari/exam/controller/ExamController.java index df735155..8d651f51 100644 --- a/src/main/java/com/example/epari/exam/controller/ExamController.java +++ b/src/main/java/com/example/epari/exam/controller/ExamController.java @@ -3,6 +3,9 @@ import java.util.List; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -15,7 +18,6 @@ import com.example.epari.exam.dto.request.ExamRequestDto; import com.example.epari.exam.dto.response.ExamResponseDto; import com.example.epari.exam.service.ExamService; -import com.example.epari.exam.service.InstructorExamService; import com.example.epari.global.annotation.CurrentUserEmail; import lombok.RequiredArgsConstructor; @@ -30,52 +32,70 @@ public class ExamController { private final ExamService examService; - private final InstructorExamService instructorExamService; - - // 시험 생성 - @PostMapping - public ResponseEntity createExam( + // 시험 목록 조회 + @GetMapping + @PreAuthorize("hasAnyRole('INSTRUCTOR', 'STUDENT')") + public ResponseEntity> getExams( @PathVariable Long courseId, - @RequestBody ExamRequestDto examRequestDto, - @CurrentUserEmail String instructorEmail) { - Long examId = instructorExamService.createExam(courseId, examRequestDto, instructorEmail); - return ResponseEntity.ok(examId); - } + @CurrentUserEmail String email, + Authentication authentication) { + String role = authentication.getAuthorities().stream() + .findFirst() + .map(GrantedAuthority::getAuthority) + .orElseThrow(() -> new IllegalStateException("권한 정보를 찾을 수 없습니다.")); - // 특정 강의에 해당하는 시험 정보 조회 - @GetMapping - public ResponseEntity> getExams(@PathVariable Long courseId) { - List exams = examService.getExamByCourse(courseId); + List exams = examService.getExams(courseId, email, role); return ResponseEntity.ok(exams); } - // 특정 강의에 속한 시험 상세 조회 + // 시험 조회 @GetMapping("/{examId}") + @PreAuthorize("hasAnyRole('INSTRUCTOR', 'STUDENT')") public ResponseEntity getExam( @PathVariable Long courseId, - @PathVariable("examId") Long id) { - ExamResponseDto exam = examService.getExam(courseId, id); + @PathVariable Long examId, + @CurrentUserEmail String email, + Authentication authentication) { + String role = authentication.getAuthorities().stream() + .findFirst() + .map(GrantedAuthority::getAuthority) + .orElseThrow(() -> new IllegalStateException("권한 정보를 찾을 수 없습니다.")); + + ExamResponseDto exam = examService.getExam(courseId, examId, email, role); return ResponseEntity.ok(exam); } - // 특정 강의에 속한 시험 수정 + // 시험 생성 + @PostMapping + @PreAuthorize("hasRole('INSTRUCTOR') and @courseSecurityChecker.checkInstructorAccess(#courseId, #instructorEmail)") + public ResponseEntity createExam( + @PathVariable Long courseId, + @RequestBody ExamRequestDto examRequestDto, + @CurrentUserEmail String instructorEmail) { + Long examId = examService.createExam(courseId, examRequestDto, instructorEmail); + return ResponseEntity.ok(examId); + } + + // 시험 수정 @PutMapping("/{examId}") + @PreAuthorize("hasRole('INSTRUCTOR') and @courseSecurityChecker.checkInstructorAccess(#courseId, #instructorEmail)") public ResponseEntity updateExam( @PathVariable Long courseId, - @PathVariable("examId") Long id, + @PathVariable Long examId, @RequestBody ExamRequestDto examRequestDto, @CurrentUserEmail String instructorEmail) { - ExamResponseDto updateExam = instructorExamService.updateExam(courseId, id, examRequestDto, instructorEmail); + ExamResponseDto updateExam = examService.updateExam(courseId, examId, examRequestDto, instructorEmail); return ResponseEntity.ok(updateExam); } - // 특정 강의에 속한 시험 삭제 + // 시험 삭제 @DeleteMapping("/{examId}") + @PreAuthorize("hasRole('INSTRUCTOR') and @courseSecurityChecker.checkInstructorAccess(#courseId, #instructorEmail)") public ResponseEntity deleteExam( @PathVariable Long courseId, - @PathVariable("examId") Long id, + @PathVariable Long examId, @CurrentUserEmail String instructorEmail) { - instructorExamService.deleteExam(courseId, id, instructorEmail); + examService.deleteExam(courseId, examId, instructorEmail); return ResponseEntity.noContent().build(); } diff --git a/src/main/java/com/example/epari/exam/controller/ExamQuestionController.java b/src/main/java/com/example/epari/exam/controller/ExamQuestionController.java new file mode 100644 index 00000000..d9e496a6 --- /dev/null +++ b/src/main/java/com/example/epari/exam/controller/ExamQuestionController.java @@ -0,0 +1,86 @@ +package com.example.epari.exam.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.example.epari.exam.dto.request.CreateQuestionRequestDto; +import com.example.epari.exam.dto.request.UpdateQuestionRequestDto; +import com.example.epari.exam.dto.response.ExamQuestionResponseDto; +import com.example.epari.exam.service.ExamQuestionService; +import com.example.epari.global.annotation.CurrentUserEmail; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +/** + * 시험 문제 관련 HTTP 요청을 처리하는 Controller 클래스 + */ +@RestController +@RequestMapping("/api/courses/{courseId}/exams/{examId}/questions") +@RequiredArgsConstructor +public class ExamQuestionController { + + private final ExamQuestionService examQuestionService; + + // 시험 문제 생성 + @PostMapping + @PreAuthorize("hasRole('INSTRUCTOR') and @courseSecurityChecker.checkInstructorAccess(#courseId, #instructorEmail)") + public ResponseEntity createQuestion( + @PathVariable Long courseId, + @PathVariable Long examId, + @RequestBody @Valid CreateQuestionRequestDto request, + @CurrentUserEmail String instructorEmail) { + Long questionId = examQuestionService.addQuestion(courseId, examId, request, instructorEmail); + return ResponseEntity.ok(questionId); + } + + // 시험 문제 재정렬 + @PutMapping("/{questionId}/order") + @PreAuthorize("hasRole('INSTRUCTOR') and @courseSecurityChecker.checkInstructorAccess(#courseId, #instructorEmail)") + public ResponseEntity reorderQuestion( + @PathVariable Long courseId, + @PathVariable Long examId, + @PathVariable Long questionId, + @RequestParam int newNumber, + @CurrentUserEmail String instructorEmail) { + examQuestionService.reorderQuestions(courseId, examId, questionId, newNumber, instructorEmail); + return ResponseEntity.ok().build(); + } + + // 시험 문제 수정 + @PutMapping("/{questionId}") + @PreAuthorize("hasRole('INSTRUCTOR') and @courseSecurityChecker.checkInstructorAccess(#courseId, #instructorEmail)") + public ResponseEntity updateQuestion( + @PathVariable Long courseId, + @PathVariable Long examId, + @PathVariable Long questionId, + @RequestBody @Valid UpdateQuestionRequestDto requestDto, + @CurrentUserEmail String instructorEmail) { + + ExamQuestionResponseDto updatedQuestion = examQuestionService.updateQuestion( + courseId, examId, questionId, requestDto, instructorEmail); + return ResponseEntity.ok(updatedQuestion); + } + + // 시험 문제 삭제 + @DeleteMapping("/{questionId}") + @PreAuthorize("hasRole('INSTRUCTOR') and @courseSecurityChecker.checkInstructorAccess(#courseId, #instructorEmail)") + public ResponseEntity deleteQuestion( + @PathVariable Long courseId, + @PathVariable Long examId, + @PathVariable Long questionId, + @CurrentUserEmail String instructorEmail) { + + examQuestionService.deleteQuestion(courseId, examId, questionId, instructorEmail); + return ResponseEntity.noContent().build(); + } + +} diff --git a/src/main/java/com/example/epari/exam/controller/InstructorExamController.java b/src/main/java/com/example/epari/exam/controller/InstructorExamController.java deleted file mode 100644 index be85c92f..00000000 --- a/src/main/java/com/example/epari/exam/controller/InstructorExamController.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.example.epari.exam.controller; - -import java.util.List; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.example.epari.exam.dto.response.ExamResponseDto; -import com.example.epari.exam.service.InstructorExamService; -import com.example.epari.global.annotation.CurrentUserEmail; - -import lombok.RequiredArgsConstructor; - -/** - * 강사의 시험 관련 HTTP 요청을 처리하는 Controller 클래스 - */ -@RestController -@RequestMapping("/api/instructor/exams") -@RequiredArgsConstructor -public class InstructorExamController { - - private final InstructorExamService instructorExamService; - - // 강사가 담당하는 강의의 모든 시험 조회 - @GetMapping - public ResponseEntity> getExamsByInstructor(@CurrentUserEmail String email) { - List exams = instructorExamService.getExams(email); - return ResponseEntity.ok(exams); - } - -} diff --git a/src/main/java/com/example/epari/exam/controller/StudentExamController.java b/src/main/java/com/example/epari/exam/controller/StudentExamController.java deleted file mode 100644 index f81ae1ef..00000000 --- a/src/main/java/com/example/epari/exam/controller/StudentExamController.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.example.epari.exam.controller; - -import java.util.List; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; - -import com.example.epari.exam.dto.response.ExamResponseDto; -import com.example.epari.exam.service.StudentExamService; -import com.example.epari.global.annotation.CurrentUserEmail; - -import lombok.RequiredArgsConstructor; - -/** - * 학생 시험 관련 HTTP 요청을 처리하는 Controller 클래스 - */ -@RestController -@RequestMapping("/api/student/exams") -@RequiredArgsConstructor -public class StudentExamController { - - private final StudentExamService studentExamService; - - // 학생이 수강중인 강의의 모든 시험 조회 - @GetMapping - public ResponseEntity> getExamsByStudent(@CurrentUserEmail String email) { - List exams = studentExamService.getExams(email); - return ResponseEntity.ok(exams); - } - -} diff --git a/src/main/java/com/example/epari/exam/domain/Exam.java b/src/main/java/com/example/epari/exam/domain/Exam.java index 61fee71a..43b16f19 100644 --- a/src/main/java/com/example/epari/exam/domain/Exam.java +++ b/src/main/java/com/example/epari/exam/domain/Exam.java @@ -4,8 +4,8 @@ import java.util.ArrayList; import java.util.List; -import com.example.epari.global.common.base.BaseTimeEntity; import com.example.epari.course.domain.Course; +import com.example.epari.global.common.base.BaseTimeEntity; import jakarta.persistence.CascadeType; import jakarta.persistence.Column; @@ -59,8 +59,8 @@ public class Exam extends BaseTimeEntity { private List questions = new ArrayList<>(); @Builder - private Exam(String title, LocalDateTime examDateTime, Integer duration, - Integer totalScore, String description, Course course) { + private Exam(String title, LocalDateTime examDateTime, + Integer duration, Integer totalScore, String description, Course course) { this.title = title; this.examDateTime = examDateTime; this.duration = duration; @@ -83,4 +83,42 @@ public void addQuestion(ExamQuestion question) { question.setExam(this); } + public void reorderQuestions(Long questionId, int newNumber) { + // 1. 이동할 문제 찾기 + ExamQuestion targetQuestion = questions.stream() + .filter(q -> q.getId().equals(questionId)) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("문제를 찾을 수 없습니다. ID: " + questionId)); + + // 2. 현재 문제 번호 + int currentNumber = targetQuestion.getExamNumber(); + + // 3. 유효성 검사 + if (newNumber < 1 || newNumber > questions.size()) { + throw new IllegalArgumentException("유효하지 않은 문제 번호입니다: " + newNumber); + } + + // 4. 문제 번호 재정렬 + if (currentNumber < newNumber) { + // 현재 위치에서 뒤로 이동하는 경우 + questions.stream() + .filter(q -> q.getExamNumber() > currentNumber && q.getExamNumber() <= newNumber) + .forEach(q -> q.updateExamNumber(q.getExamNumber() - 1)); + } else if (currentNumber > newNumber) { + // 현재 위치에서 앞으로 이동하는 경우 + questions.stream() + .filter(q -> q.getExamNumber() >= newNumber && q.getExamNumber() < currentNumber) + .forEach(q -> q.updateExamNumber(q.getExamNumber() + 1)); + } + + // 5. 대상 문제의 번호 업데이트 + targetQuestion.updateExamNumber(newNumber); + } + + public void reorderQuestionsAfterDelete(int deletedNumber) { + questions.stream() + .filter(q -> q.getExamNumber() > deletedNumber) + .forEach(q -> q.updateExamNumber(q.getExamNumber() - 1)); + } + } diff --git a/src/main/java/com/example/epari/exam/domain/ExamQuestion.java b/src/main/java/com/example/epari/exam/domain/ExamQuestion.java index 26586886..60df6478 100644 --- a/src/main/java/com/example/epari/exam/domain/ExamQuestion.java +++ b/src/main/java/com/example/epari/exam/domain/ExamQuestion.java @@ -5,6 +5,7 @@ import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorColumn; +import jakarta.persistence.DiscriminatorType; import jakarta.persistence.Embedded; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -30,7 +31,7 @@ @Entity @Table(name = "exam_questions") @Inheritance(strategy = InheritanceType.SINGLE_TABLE) -@DiscriminatorColumn(name = "question_type") +@DiscriminatorColumn(name = "question_type", discriminatorType = DiscriminatorType.STRING) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public abstract class ExamQuestion extends BaseTimeEntity { @@ -52,6 +53,9 @@ public abstract class ExamQuestion extends BaseTimeEntity { @Column(nullable = false) private ExamQuestionType type; + @Column(name = "correct_answer", nullable = false) // 단일 정답 필드 + private String correctAnswer; + @Embedded private QuestionImage image; @@ -60,14 +64,18 @@ public abstract class ExamQuestion extends BaseTimeEntity { private Exam exam; protected ExamQuestion(String questionText, int examNumber, int score, - ExamQuestionType type, Exam exam) { + ExamQuestionType type, Exam exam, String correctAnswer) { // 생성자 수정 this.questionText = questionText; this.examNumber = examNumber; this.score = score; this.type = type; this.exam = exam; + this.correctAnswer = correctAnswer; } + // 정답 검증을 위한 추상 메소드 + public abstract boolean validateAnswer(String studentAnswer); + public void setImage(QuestionImage image) { this.image = image; } @@ -76,4 +84,14 @@ void setExam(Exam exam) { this.exam = exam; } + public void updateExamNumber(int newNumber) { + this.examNumber = newNumber; + } + + public void updateQuestion(String questionText, int score, String correctAnswer) { + this.questionText = questionText; + this.score = score; + this.correctAnswer = correctAnswer; + } + } diff --git a/src/main/java/com/example/epari/exam/domain/MultipleChoiceQuestion.java b/src/main/java/com/example/epari/exam/domain/MultipleChoiceQuestion.java index 63424f1a..7c0d5f1d 100644 --- a/src/main/java/com/example/epari/exam/domain/MultipleChoiceQuestion.java +++ b/src/main/java/com/example/epari/exam/domain/MultipleChoiceQuestion.java @@ -6,7 +6,6 @@ import com.example.epari.global.common.enums.ExamQuestionType; import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; import jakarta.persistence.OneToMany; @@ -19,28 +18,31 @@ /** * ExamQuestion을 상속받아 객관식 문제를 구현 */ + @Entity @DiscriminatorValue("MULTIPLE_CHOICE") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class MultipleChoiceQuestion extends ExamQuestion { - @Column(name = "correct_choice_number", nullable = false) - private int correctChoiceNumber; - @OneToMany(mappedBy = "question", cascade = CascadeType.ALL, orphanRemoval = true) @OrderBy("number asc") private List choices = new ArrayList<>(); @Builder private MultipleChoiceQuestion(String questionText, int examNumber, int score, - Exam exam, int correctChoiceNumber) { - super(questionText, examNumber, score, ExamQuestionType.MULTIPLE_CHOICE, exam); - this.correctChoiceNumber = correctChoiceNumber; + Exam exam, String correctAnswer) { // correctChoiceNumber 대신 correctAnswer 사용 + super(questionText, examNumber, score, ExamQuestionType.MULTIPLE_CHOICE, exam, correctAnswer); + } + + @Override + public boolean validateAnswer(String studentAnswer) { + return getCorrectAnswer().equals(studentAnswer); } public void addChoice(Choice choice) { this.choices.add(choice); choice.setQuestion(this); } + } diff --git a/src/main/java/com/example/epari/exam/domain/SubjectiveQuestion.java b/src/main/java/com/example/epari/exam/domain/SubjectiveQuestion.java index 90d34f32..e37f2298 100644 --- a/src/main/java/com/example/epari/exam/domain/SubjectiveQuestion.java +++ b/src/main/java/com/example/epari/exam/domain/SubjectiveQuestion.java @@ -2,7 +2,6 @@ import com.example.epari.global.common.enums.ExamQuestionType; -import jakarta.persistence.Column; import jakarta.persistence.DiscriminatorValue; import jakarta.persistence.Entity; import lombok.AccessLevel; @@ -10,27 +9,22 @@ import lombok.Getter; import lombok.NoArgsConstructor; -/** - * ExamQuestion을 상속받아 주관식 문제를 구현 - */ @Entity @DiscriminatorValue("SUBJECTIVE") @NoArgsConstructor(access = AccessLevel.PROTECTED) @Getter public class SubjectiveQuestion extends ExamQuestion { - @Column(name = "correct_answer", nullable = false) - private String correctAnswer; - @Builder private SubjectiveQuestion(String questionText, int examNumber, int score, Exam exam, String correctAnswer) { - super(questionText, examNumber, score, ExamQuestionType.SUBJECTIVE, exam); - this.correctAnswer = correctAnswer; + super(questionText, examNumber, score, ExamQuestionType.SUBJECTIVE, exam, correctAnswer); } - public void updateCorrectAnswer(String correctAnswer) { - this.correctAnswer = correctAnswer; + @Override + public boolean validateAnswer(String studentAnswer) { + // 주관식은 좀 더 유연한 검증이 필요할 수 있음 + return getCorrectAnswer().trim().equalsIgnoreCase(studentAnswer.trim()); } } diff --git a/src/main/java/com/example/epari/exam/dto/request/ChoiceRequestDto.java b/src/main/java/com/example/epari/exam/dto/request/ChoiceRequestDto.java new file mode 100644 index 00000000..e870c335 --- /dev/null +++ b/src/main/java/com/example/epari/exam/dto/request/ChoiceRequestDto.java @@ -0,0 +1,19 @@ +package com.example.epari.exam.dto.request; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class ChoiceRequestDto { + + @NotNull(message = "보기 번호는 필수입니다") + @Min(value = 1, message = "보기 번호는 1 이상이어야 합니다") + private int number; + + @NotNull(message = "보기 내용은 필수입니다") + private String choiceText; + +} diff --git a/src/main/java/com/example/epari/exam/dto/request/CreateQuestionRequestDto.java b/src/main/java/com/example/epari/exam/dto/request/CreateQuestionRequestDto.java new file mode 100644 index 00000000..6cb92e24 --- /dev/null +++ b/src/main/java/com/example/epari/exam/dto/request/CreateQuestionRequestDto.java @@ -0,0 +1,99 @@ +package com.example.epari.exam.dto.request; + +import java.util.List; + +import com.example.epari.exam.domain.Choice; +import com.example.epari.exam.domain.Exam; +import com.example.epari.exam.domain.ExamQuestion; +import com.example.epari.exam.domain.MultipleChoiceQuestion; +import com.example.epari.exam.domain.SubjectiveQuestion; +import com.example.epari.global.common.enums.ExamQuestionType; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 시험 문제 생성 요청을 위한 DTO 클래스 + */ +@Getter +@NoArgsConstructor +public class CreateQuestionRequestDto { + + @NotBlank(message = "문제 내용은 필수입니다") + private String questionText; + + @Min(value = 1, message = "배점은 1점 이상이어야 합니다") + private int score; + + @NotNull(message = "문제 유형은 필수입니다") + private ExamQuestionType type; + + // 객관식일 경우 + private List choices; + + // 모든 유형의 정답을 하나의 필드로 통합 + @NotBlank(message = "정답은 필수입니다") + private String correctAnswer; + + public ExamQuestion toEntity(Exam exam) { + // exam 유효성 검사 + if (exam == null) { + throw new IllegalArgumentException("시험 정보는 필수입니다"); + } + + // 문제 번호는 현재 시험의 문제 개수 + 1 + int examNumber = exam.getQuestions().size() + 1; + + if (ExamQuestionType.MULTIPLE_CHOICE.equals(type)) { + // 객관식 문제 유효성 검사 + if (choices == null || choices.isEmpty()) { + throw new IllegalArgumentException("객관식 문제는 보기가 필요합니다"); + } + + // 객관식 정답 번호 유효성 검사 + try { + int answerNumber = Integer.parseInt(correctAnswer); + if (answerNumber < 1 || answerNumber > choices.size()) { + throw new IllegalArgumentException("올바르지 않은 정답 번호입니다"); + } + } catch (NumberFormatException e) { + throw new IllegalArgumentException("객관식 문제의 정답은 숫자여야 합니다"); + } + + MultipleChoiceQuestion question = MultipleChoiceQuestion.builder() + .questionText(questionText) + .examNumber(examNumber) + .score(score) + .exam(exam) + .correctAnswer(correctAnswer) // 문자열로 저장된 선택지 번호 + .build(); + + // 선택지 추가 + choices.forEach(choiceDto -> { + Choice choice = Choice.builder() + .number(choiceDto.getNumber()) + .choiceText(choiceDto.getChoiceText()) + .build(); + question.addChoice(choice); + }); + + return question; + + } else if (ExamQuestionType.SUBJECTIVE.equals(type)) { + // 주관식 문제의 경우 바로 생성 + return SubjectiveQuestion.builder() + .questionText(questionText) + .examNumber(examNumber) + .score(score) + .exam(exam) + .correctAnswer(correctAnswer) + .build(); + } + + throw new IllegalArgumentException("지원하지 않는 문제 유형입니다: " + type); + } + +} diff --git a/src/main/java/com/example/epari/exam/dto/request/UpdateQuestionRequestDto.java b/src/main/java/com/example/epari/exam/dto/request/UpdateQuestionRequestDto.java new file mode 100644 index 00000000..e54bc10c --- /dev/null +++ b/src/main/java/com/example/epari/exam/dto/request/UpdateQuestionRequestDto.java @@ -0,0 +1,52 @@ +package com.example.epari.exam.dto.request; + +import java.util.List; + +import com.example.epari.global.common.enums.ExamQuestionType; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class UpdateQuestionRequestDto { + + @NotBlank(message = "문제 내용은 필수입니다") + private String questionText; + + @Min(value = 1, message = "배점은 1점 이상이어야 합니다") + private int score; + + @NotNull(message = "문제 유형은 필수입니다") + private ExamQuestionType type; + + @NotBlank(message = "정답은 필수입니다") + private String correctAnswer; + + // 객관식일 경우 + private List choices; + + // 유효성 검사 메소드 + public void validate() { + if (ExamQuestionType.MULTIPLE_CHOICE.equals(type)) { + // 객관식 문제 유효성 검사 + if (choices == null || choices.isEmpty()) { + throw new IllegalArgumentException("객관식 문제는 보기가 필요합니다"); + } + + // 객관식 정답 번호 유효성 검사 + try { + int answerNumber = Integer.parseInt(correctAnswer); + if (answerNumber < 1 || answerNumber > choices.size()) { + throw new IllegalArgumentException("올바르지 않은 정답 번호입니다"); + } + } catch (NumberFormatException e) { + throw new IllegalArgumentException("객관식 문제의 정답은 숫자여야 합니다"); + } + } + } + +} diff --git a/src/main/java/com/example/epari/exam/dto/response/ChoiceResponseDto.java b/src/main/java/com/example/epari/exam/dto/response/ChoiceResponseDto.java new file mode 100644 index 00000000..26f666e3 --- /dev/null +++ b/src/main/java/com/example/epari/exam/dto/response/ChoiceResponseDto.java @@ -0,0 +1,23 @@ +package com.example.epari.exam.dto.response; + +import com.example.epari.exam.domain.Choice; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +class ChoiceResponseDto { + + private Integer number; + + private String choiceText; + + public static ChoiceResponseDto from(Choice choice) { + return ChoiceResponseDto.builder() + .number(choice.getNumber()) + .choiceText(choice.getChoiceText()) + .build(); + } + +} diff --git a/src/main/java/com/example/epari/exam/dto/response/ExamQuestionResponseDto.java b/src/main/java/com/example/epari/exam/dto/response/ExamQuestionResponseDto.java new file mode 100644 index 00000000..9a8256c8 --- /dev/null +++ b/src/main/java/com/example/epari/exam/dto/response/ExamQuestionResponseDto.java @@ -0,0 +1,61 @@ +package com.example.epari.exam.dto.response; + +import java.util.List; +import java.util.stream.Collectors; + +import com.example.epari.exam.domain.ExamQuestion; +import com.example.epari.exam.domain.MultipleChoiceQuestion; +import com.example.epari.exam.domain.SubjectiveQuestion; +import com.example.epari.global.common.enums.ExamQuestionType; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ExamQuestionResponseDto { + + private Long id; + + private String questionText; + + private int examNumber; + + private int score; + + private ExamQuestionType type; + + private String correctAnswer; + + private List choices; // 객관식인 경우에만 사용 + + public static ExamQuestionResponseDto from(ExamQuestion question) { + if (question instanceof MultipleChoiceQuestion) { + MultipleChoiceQuestion mcq = (MultipleChoiceQuestion)question; + return ExamQuestionResponseDto.builder() + .id(mcq.getId()) + .questionText(mcq.getQuestionText()) + .examNumber(mcq.getExamNumber()) + .score(mcq.getScore()) + .type(ExamQuestionType.MULTIPLE_CHOICE) + .correctAnswer(mcq.getCorrectAnswer()) + .choices(mcq.getChoices().stream() + .map(ChoiceResponseDto::from) + .collect(Collectors.toList())) + .build(); + } else if (question instanceof SubjectiveQuestion) { + SubjectiveQuestion sq = (SubjectiveQuestion)question; + return ExamQuestionResponseDto.builder() + .id(sq.getId()) + .questionText(sq.getQuestionText()) + .examNumber(sq.getExamNumber()) + .score(sq.getScore()) + .type(ExamQuestionType.SUBJECTIVE) + .correctAnswer(sq.getCorrectAnswer()) + .build(); + } + + throw new IllegalArgumentException("지원하지 않는 문제 유형입니다"); + } + +} diff --git a/src/main/java/com/example/epari/exam/repository/ExamQuestionRepository.java b/src/main/java/com/example/epari/exam/repository/ExamQuestionRepository.java new file mode 100644 index 00000000..d1f65827 --- /dev/null +++ b/src/main/java/com/example/epari/exam/repository/ExamQuestionRepository.java @@ -0,0 +1,20 @@ +package com.example.epari.exam.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.epari.exam.domain.ExamQuestion; + +public interface ExamQuestionRepository extends JpaRepository { + + // 시험별 문제 목록 조회 + List findByExamId(Long examId); + + // 시험별 문제 번호순 정렬 조회 + List findByExamIdOrderByExamNumberAsc(Long examId); + + // 시험에 속한 문제인지 확인 + boolean existsByIdAndExamId(Long questionId, Long examId); + +} diff --git a/src/main/java/com/example/epari/exam/repository/ExamRepository.java b/src/main/java/com/example/epari/exam/repository/ExamRepository.java index 3f8ba457..cfaea38d 100644 --- a/src/main/java/com/example/epari/exam/repository/ExamRepository.java +++ b/src/main/java/com/example/epari/exam/repository/ExamRepository.java @@ -13,18 +13,12 @@ */ public interface ExamRepository extends JpaRepository { - /** - * 1. 강의 - */ // 특정 강의에 속한 모든 시험 조회 List findByCourseId(Long courseId); // 특정 강의에 속한 시험 상세 조회 Optional findByCourseIdAndId(Long courseId, Long id); - /** - * 2. 강사 - */ // 강사가 담당하는 강의의 모든 시험 조회 @Query("SELECT e FROM Exam e " + "JOIN FETCH e.course c " @@ -32,9 +26,6 @@ public interface ExamRepository extends JpaRepository { + "WHERE i.email = :instructorEmail") List findByInstructorEmail(String instructorEmail); - /** - * 3. 학생 - */ // 학생이 수강중인 강의의 모든 시험 조회 @Query("SELECT e FROM Exam e " + "JOIN FETCH e.course c " diff --git a/src/main/java/com/example/epari/exam/service/ExamFinder.java b/src/main/java/com/example/epari/exam/service/ExamFinder.java new file mode 100644 index 00000000..b596430e --- /dev/null +++ b/src/main/java/com/example/epari/exam/service/ExamFinder.java @@ -0,0 +1,21 @@ +package com.example.epari.exam.service; + +import org.springframework.stereotype.Component; + +import com.example.epari.exam.domain.Exam; +import com.example.epari.exam.repository.ExamRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ExamFinder { + + private final ExamRepository examRepository; + + public Exam findExam(Long courseId, Long examId) { + return examRepository.findByCourseIdAndId(courseId, examId) + .orElseThrow(() -> new IllegalArgumentException("시험을 찾을 수 없습니다.")); + } + +} diff --git a/src/main/java/com/example/epari/exam/service/ExamQuestionService.java b/src/main/java/com/example/epari/exam/service/ExamQuestionService.java new file mode 100644 index 00000000..45ca9454 --- /dev/null +++ b/src/main/java/com/example/epari/exam/service/ExamQuestionService.java @@ -0,0 +1,165 @@ +package com.example.epari.exam.service; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.epari.exam.domain.Choice; +import com.example.epari.exam.domain.Exam; +import com.example.epari.exam.domain.ExamQuestion; +import com.example.epari.exam.domain.MultipleChoiceQuestion; +import com.example.epari.exam.domain.SubjectiveQuestion; +import com.example.epari.exam.dto.request.CreateQuestionRequestDto; +import com.example.epari.exam.dto.request.UpdateQuestionRequestDto; +import com.example.epari.exam.dto.response.ExamQuestionResponseDto; +import com.example.epari.exam.repository.ExamQuestionRepository; +import com.example.epari.exam.repository.ExamRepository; +import com.example.epari.global.common.enums.ExamQuestionType; +import com.example.epari.global.validator.CourseAccessValidator; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional +public class ExamQuestionService { + + private final ExamRepository examRepository; + + private final CourseAccessValidator courseAccessValidator; + + private final ExamFinder examFinder; + + private final ExamQuestionRepository examQuestionRepository; + + // 문제 생성 + public Long addQuestion(Long courseId, Long examId, CreateQuestionRequestDto dto, String instructorEmail) { + + courseAccessValidator.validateInstructorAccess(courseId, instructorEmail); + + Exam exam = examFinder.findExam(courseId, examId); + ExamQuestion question = dto.toEntity(exam); + exam.addQuestion(question); + + return question.getId(); + } + + // 문제 순서 변경 + public void reorderQuestions(Long courseId, Long examId, Long questionId, int newNumber, + String instructorEmail) { + courseAccessValidator.validateInstructorAccess(courseId, instructorEmail); + + Exam exam = examFinder.findExam(courseId, examId); + exam.reorderQuestions(questionId, newNumber); + } + + // 문제 수정 + public ExamQuestionResponseDto updateQuestion(Long courseId, Long examId, Long questionId, + UpdateQuestionRequestDto requestDto, String instructorEmail) { + + // 접근 권한 검증 + courseAccessValidator.validateInstructorAccess(courseId, instructorEmail); + + // 시험 존재 여부 확인 + Exam exam = examRepository.findById(examId) + .orElseThrow(() -> new IllegalArgumentException("시험을 찾을 수 없습니다.")); + + // 문제 존재 여부 확인 + ExamQuestion question = examQuestionRepository.findById(questionId) + .orElseThrow(() -> new IllegalArgumentException("문제를 찾을 수 없습니다.")); + + // 문제가 해당 시험에 속하는지 확인 + if (!question.getExam().getId().equals(examId)) { + throw new IllegalArgumentException("해당 시험에 속한 문제가 아닙니다."); + } + + // 문제 유형이 변경되었는지 확인 + if (!question.getType().equals(requestDto.getType())) { + throw new IllegalArgumentException("문제 유형은 변경할 수 없습니다."); + } + + // 문제 유형에 따른 수정 처리 + if (requestDto.getType() == ExamQuestionType.MULTIPLE_CHOICE) { + return updateMultipleChoiceQuestion((MultipleChoiceQuestion)question, requestDto); + } else { + return updateSubjectiveQuestion((SubjectiveQuestion)question, requestDto); + } + } + + // 객관식 문제 수정 + private ExamQuestionResponseDto updateMultipleChoiceQuestion(MultipleChoiceQuestion question, + UpdateQuestionRequestDto requestDto) { + // 기존 선택지 모두 삭제 + question.getChoices().clear(); + + // 새로운 선택지 추가 + requestDto.getChoices().forEach(choiceDto -> { + Choice choice = Choice.builder() + .number(choiceDto.getNumber()) + .choiceText(choiceDto.getChoiceText()) + .build(); + question.addChoice(choice); + }); + + // 문제 정보 업데이트 + updateQuestionCommonFields(question, requestDto); + + return ExamQuestionResponseDto.from(question); + } + + // 주관식 문제 수정 + private ExamQuestionResponseDto updateSubjectiveQuestion(SubjectiveQuestion question, + UpdateQuestionRequestDto requestDto) { + // 문제 정보 업데이트 + updateQuestionCommonFields(question, requestDto); + + return ExamQuestionResponseDto.from(question); + } + + // 문제 공통 사항 수정 + private void updateQuestionCommonFields(ExamQuestion question, UpdateQuestionRequestDto requestDto) { + question.updateQuestion( + requestDto.getQuestionText(), + requestDto.getScore(), + requestDto.getCorrectAnswer() + ); + } + + // 문제 삭제 + public void deleteQuestion(Long courseId, Long examId, Long questionId, String instructorEmail) { + // 접근 권한 검증 + courseAccessValidator.validateInstructorAccess(courseId, instructorEmail); + + // 시험 존재 여부 확인 + Exam exam = examFinder.findExam(courseId, examId); + + // 문제 존재 여부 확인 + ExamQuestion question = examQuestionRepository.findById(questionId) + .orElseThrow(() -> new IllegalArgumentException("문제를 찾을 수 없습니다.")); + + // 문제가 해당 시험에 속하는지 확인 + if (!question.getExam().getId().equals(examId)) { + throw new IllegalArgumentException("해당 시험에 속한 문제가 아닙니다."); + } + + // 삭제할 문제의 번호 저장 + int deletedQuestionNumber = question.getExamNumber(); + + // exam의 questions 리스트에서 제거 + exam.getQuestions().remove(question); + + // 문제 삭제 + examQuestionRepository.delete(question); + + // 남은 문제들의 번호 재정렬 + exam.getQuestions().stream() + .filter(q -> q.getExamNumber() > deletedQuestionNumber) + .forEach(q -> q.updateExamNumber(q.getExamNumber() - 1)); + } + + private void validateDeletable(ExamQuestion question) { + // TODO: 답안 제출 여부 등 검증 로직 추가 예정 + // 현재는 임시로 true 반환 + return; + } + +} diff --git a/src/main/java/com/example/epari/exam/service/ExamService.java b/src/main/java/com/example/epari/exam/service/ExamService.java index 36d4f8f3..86b634e9 100644 --- a/src/main/java/com/example/epari/exam/service/ExamService.java +++ b/src/main/java/com/example/epari/exam/service/ExamService.java @@ -6,10 +6,13 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.example.epari.course.domain.Course; import com.example.epari.course.repository.CourseRepository; import com.example.epari.exam.domain.Exam; +import com.example.epari.exam.dto.request.ExamRequestDto; import com.example.epari.exam.dto.response.ExamResponseDto; import com.example.epari.exam.repository.ExamRepository; +import com.example.epari.global.validator.CourseAccessValidator; import lombok.RequiredArgsConstructor; @@ -18,39 +21,88 @@ */ @Service @RequiredArgsConstructor -@Transactional +@Transactional(readOnly = true) public class ExamService { private final ExamRepository examRepository; private final CourseRepository courseRepository; - // 특정 강의의 시험 조회 - @Transactional(readOnly = true) - public List getExamByCourse(Long courseId) { - // 강의 존재여부 확인 - if (!courseRepository.existsById(courseId)) { - throw new IllegalArgumentException("강의를 찾을 수 없습니다. ID: " + courseId); - } + private final CourseAccessValidator courseAccessValidator; + + private final ExamFinder examFinder; + + // 시험 생성 + @Transactional + public Long createExam(Long courseId, ExamRequestDto requestDto, String instructorEmail) { + courseAccessValidator.validateInstructorAccess(courseId, instructorEmail); + + Course course = courseRepository.findById(courseId) + .orElseThrow(() -> new IllegalArgumentException("강의를 찾을 수 없습니다.")); - // 시험 목록 조회 및 DTO 반환 - return examRepository.findByCourseId(courseId).stream() - .map(ExamResponseDto::fromExam) - .collect(Collectors.toList()); + Exam exam = Exam.builder() + .title(requestDto.getTitle()) + .examDateTime(requestDto.getExamDateTime()) + .duration(requestDto.getDuration()) + .totalScore(requestDto.getTotalScore()) + .description(requestDto.getDescription()) + .course(course) + .build(); + return examRepository.save(exam).getId(); + } + + // 시험 목록 조회 + public List getExams(Long courseId, String email, String role) { + if (role.contains("ROLE_INSTRUCTOR")) { + courseAccessValidator.validateInstructorAccess(courseId, email); + return examRepository.findByInstructorEmail(email).stream() + .map(ExamResponseDto::fromExam) + .collect(Collectors.toList()); + } else { + courseAccessValidator.validateStudentAccess(courseId, email); + return examRepository.findByStudentEmail(email).stream() + .map(ExamResponseDto::fromExam) + .collect(Collectors.toList()); + } } - // 특정 강의에 속한 시험 상세 조회 - @Transactional(readOnly = true) - public ExamResponseDto getExam(Long courseId, Long id) { - if (!courseRepository.existsById(courseId)) { - throw new IllegalArgumentException("강의를 찾을 수 없습니다. ID: " + courseId); + // 시험 상세 조회 + public ExamResponseDto getExam(Long courseId, Long examId, String email, String role) { + if (role.contains("ROLE_INSTRUCTOR")) { + courseAccessValidator.validateInstructorAccess(courseId, email); + } else { + courseAccessValidator.validateStudentAccess(courseId, email); } - Exam exam = examRepository.findByCourseIdAndId(courseId, id) - .orElseThrow(() -> new IllegalArgumentException("시험을 찾을 수 없습니다." + id)); + Exam exam = examFinder.findExam(courseId, examId); + return ExamResponseDto.fromExam(exam); + } + + // 시험 수정 + @Transactional + public ExamResponseDto updateExam(Long courseId, Long examId, ExamRequestDto requestDto, String instructorEmail) { + courseAccessValidator.validateInstructorAccess(courseId, instructorEmail); + + Exam exam = examFinder.findExam(courseId, examId); + exam.updateExam( + requestDto.getTitle(), + requestDto.getExamDateTime(), + requestDto.getDuration(), + requestDto.getTotalScore(), + requestDto.getDescription() + ); return ExamResponseDto.fromExam(exam); } + // 시험 삭제 + @Transactional + public void deleteExam(Long courseId, Long examId, String instructorEmail) { + courseAccessValidator.validateInstructorAccess(courseId, instructorEmail); + + Exam exam = examFinder.findExam(courseId, examId); + examRepository.delete(exam); + } + } diff --git a/src/main/java/com/example/epari/exam/service/InstructorExamService.java b/src/main/java/com/example/epari/exam/service/InstructorExamService.java deleted file mode 100644 index 967da40b..00000000 --- a/src/main/java/com/example/epari/exam/service/InstructorExamService.java +++ /dev/null @@ -1,100 +0,0 @@ -package com.example.epari.exam.service; - -import java.util.List; -import java.util.stream.Collectors; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.epari.course.domain.Course; -import com.example.epari.course.repository.CourseRepository; -import com.example.epari.exam.domain.Exam; -import com.example.epari.exam.dto.request.ExamRequestDto; -import com.example.epari.exam.dto.response.ExamResponseDto; -import com.example.epari.exam.repository.ExamRepository; -import com.example.epari.global.validator.CourseAccessValidator; - -import lombok.RequiredArgsConstructor; - -/** - * 강사의 시험 조회 관련 비즈니스 로직을 처리하는 Service 클래스 - */ -@Service -@RequiredArgsConstructor -@Transactional -public class InstructorExamService { - - private final ExamRepository examRepository; - - private final CourseRepository courseRepository; - - private final CourseAccessValidator courseAccessValidator; - - @Transactional(readOnly = true) - public List getExams(String email) { - List exams = examRepository.findByInstructorEmail(email); - return exams.stream() - .map(ExamResponseDto::fromExam) - .collect(Collectors.toList()); - } - - // 특정 강의에 시험 생성 - public Long createExam(Long courseId, ExamRequestDto requestDto, String instructorEmail) { - - courseAccessValidator.validateInstructorAccess(courseId, instructorEmail); - - // 강의 존재여부 확인 - Course course = courseRepository.findById(courseId) - .orElseThrow(() -> new IllegalArgumentException("강의를 찾을 수 없습니다.")); - - Exam exam = Exam.builder() - .title(requestDto.getTitle()) - .examDateTime(requestDto.getExamDateTime()) - .duration(requestDto.getDuration()) - .totalScore(requestDto.getTotalScore()) - .description(requestDto.getDescription()) - .course(course) - .build(); - - return examRepository.save(exam).getId(); - } - - // 특정 강의에 속한 시험 수정 - public ExamResponseDto updateExam(Long courseId, Long id, ExamRequestDto requestDto, String instructorEmail) { - - courseAccessValidator.validateInstructorAccess(courseId, instructorEmail); - - if (!courseRepository.existsById(courseId)) { - throw new IllegalArgumentException("강의를 찾을 수 없습니다. ID: " + courseId); - } - - Exam exam = examRepository.findByCourseIdAndId(courseId, id) - .orElseThrow(() -> new IllegalArgumentException("시험을 찾을 수 없습니다. ID:" + id)); - - exam.updateExam( - requestDto.getTitle(), - requestDto.getExamDateTime(), - requestDto.getDuration(), - requestDto.getTotalScore(), - requestDto.getDescription() - ); - - return ExamResponseDto.fromExam(exam); - } - - // 특정 강의에 속한 시험 삭제 - public void deleteExam(Long courseId, Long id, String instructorEmail) { - - courseAccessValidator.validateInstructorAccess(courseId, instructorEmail); - - if (!courseRepository.existsById(courseId)) { - throw new IllegalArgumentException("강의를 찾을 수 없습니다. ID: " + courseId); - } - - Exam exam = examRepository.findByCourseIdAndId(courseId, id) - .orElseThrow(() -> new IllegalArgumentException("시험을 찾을 수 없습니다. ID:" + id)); - - examRepository.delete(exam); - } - -} diff --git a/src/main/java/com/example/epari/exam/service/StudentExamService.java b/src/main/java/com/example/epari/exam/service/StudentExamService.java deleted file mode 100644 index e0dfc317..00000000 --- a/src/main/java/com/example/epari/exam/service/StudentExamService.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.example.epari.exam.service; - -import java.util.List; -import java.util.stream.Collectors; - -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.example.epari.exam.domain.Exam; -import com.example.epari.exam.dto.response.ExamResponseDto; -import com.example.epari.exam.repository.ExamRepository; - -import lombok.RequiredArgsConstructor; - -/** - * 학생의 시험 조회 관련 비즈니스 로직을 처리하는 Service 클래스 - */ -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class StudentExamService { - - private final ExamRepository examRepository; - - public List getExams(String email) { - List exams = examRepository.findByStudentEmail(email); - return exams.stream() - .map(ExamResponseDto::fromExam) - .collect(Collectors.toList()); - } - -} diff --git a/src/test/java/com/example/epari/EpariBackendApplicationTests.java b/src/test/java/com/example/epari/EpariBackendApplicationTests.java index 8f6f48dd..4da91058 100644 --- a/src/test/java/com/example/epari/EpariBackendApplicationTests.java +++ b/src/test/java/com/example/epari/EpariBackendApplicationTests.java @@ -2,12 +2,27 @@ import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.ActiveProfiles; +import com.example.epari.global.common.service.S3FileService; + +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + @SpringBootTest @ActiveProfiles("test") class EpariBackendApplicationTests { + @MockBean + S3Client s3Client; + + @MockBean + S3Presigner s3Presigner; + + @MockBean + S3FileService s3FileService; + @Test void contextLoads() { }