Skip to content

Commit

Permalink
[FEAT] 시험 문제 자동 채점기능 구현 (#99)
Browse files Browse the repository at this point in the history
- 답안 자동 채점 로직 (gradeExamResult) 및 총점 검증 로직 (verifyTotalScore) 구현
- ExamResult와 ExamScore 클래스 개선 (상태 관리 강화, 불필요 필드 제거 등)
- ExamResultRepository에 채점 상태 및 시간 초과 시험 조회 쿼리 추가
- AutoGradingScheduler로 시험 자동 제출 및 채점 기능 구현
- 채점 관련 예외 처리 및 오류 코드 추가
  • Loading branch information
hyerin315 authored Nov 20, 2024
1 parent ed5ee46 commit 9302a4c
Show file tree
Hide file tree
Showing 16 changed files with 531 additions and 44 deletions.
112 changes: 112 additions & 0 deletions src/main/java/com/example/epari/exam/controller/GradingController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package com.example.epari.exam.controller;

import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import com.example.epari.exam.exception.GradingException;
import com.example.epari.exam.exception.GradingNotPossibleException;
import com.example.epari.exam.exception.InvalidScoreException;
import com.example.epari.exam.service.GradingService;
import com.example.epari.exam.service.GradingService.ScoreStatistics;
import com.example.epari.global.annotation.CurrentUserEmail;
import com.example.epari.global.exception.ErrorCode;
import com.example.epari.global.exception.ErrorResponse;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;


/**
* 시험 채점 및 성적 통계 관련 요청을 처리하는 Controller
*/
@Slf4j
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/courses/{courseId}/exams/{examId}/grades")
public class GradingController {

private final GradingService gradingService;

/**
* 시험 결과 채점 요청
* @param courseId 강의 ID
* @param examId 시험 ID
* @param resultId 채점할 시험 결과 ID
* @param instructorEmail 강사 이메일
* @return 채점 완료 응답
*/
@PostMapping("/results/{resultId}")
@PreAuthorize("hasRole('INSTRUCTOR') and @courseSecurityChecker.checkInstructorAccess(#courseId, #instructorEmail)")
public ResponseEntity<Void> gradeExam(
@PathVariable Long courseId,
@PathVariable Long examId,
@PathVariable Long resultId,
@CurrentUserEmail String instructorEmail) {

log.info("Grading request - courseId: {}, examId: {}, resultId: {}", courseId, examId, resultId);
gradingService.gradeExamResult(resultId);
return ResponseEntity.ok().build();
}

/**
* 평균 점수 조회
* @param courseId 강의 ID
* @param examId 시험 ID
* @param instructorEmail 강사 이메일
* @return 평균 점수
*/
@GetMapping("/average")
@PreAuthorize("hasRole('INSTRUCTOR') and @courseSecurityChecker.checkInstructorAccess(#courseId, #instructorEmail)")
public ResponseEntity<Double> getAverageScore(
@PathVariable Long courseId,
@PathVariable Long examId,
@CurrentUserEmail String instructorEmail) {

log.info("Retrieving average score - courseId: {}, examId: {}", courseId, examId);
double averageScore = gradingService.calculateAverageScore(examId);
return ResponseEntity.ok(averageScore);
}

/**
* 최고/최저 점수 통계 조회
* @param courseId 강의 ID
* @param examId 시험 ID
* @param instructorEmail 강사 이메일
* @return 점수 통계 정보
*/
@GetMapping("/statistics")
@PreAuthorize("hasRole('INSTRUCTOR') and @courseSecurityChecker.checkInstructorAccess(#courseId, #instructorEmail)")
public ResponseEntity<ScoreStatistics> getScoreStatistics(
@PathVariable Long courseId,
@PathVariable Long examId,
@CurrentUserEmail String instructorEmail) {

log.info("Retrieving score statistics - courseId: {}, examId: {}", courseId, examId);
ScoreStatistics statistics = gradingService.calculateScoreStatistics(examId);
return ResponseEntity.ok(statistics);
}

@ExceptionHandler(GradingException.class)
public ResponseEntity<ErrorResponse> handleGradingException(GradingException e) {
log.error("채점 처리 중 오류 발생", e);
return ErrorResponse.toResponseEntity(ErrorCode.GRADING_FAILED);
}

@ExceptionHandler(GradingNotPossibleException.class)
public ResponseEntity<ErrorResponse> handleGradingNotPossibleException(GradingNotPossibleException e) {
log.error("채점 불가능한 상태", e);
return ErrorResponse.toResponseEntity(ErrorCode.EXAM_NOT_SUBMITTED);
}

@ExceptionHandler(InvalidScoreException.class)
public ResponseEntity<ErrorResponse> handleInvalidScoreException(InvalidScoreException e) {
log.error("유효하지 않은 점수", e);
return ErrorResponse.toResponseEntity(ErrorCode.INVALID_SCORE_VALUE);
}
}
23 changes: 19 additions & 4 deletions src/main/java/com/example/epari/exam/domain/ExamQuestion.java
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public abstract class ExamQuestion extends BaseTimeEntity {
@Column(nullable = false)
private ExamQuestionType type;

@Column(name = "correct_answer", nullable = false) // 단일 정답 필드
@Column(name = "correct_answer", nullable = false)
private String correctAnswer;

@Embedded
Expand All @@ -73,9 +73,6 @@ protected ExamQuestion(String questionText, int examNumber, int score,
this.correctAnswer = correctAnswer;
}

// 정답 검증을 위한 추상 메소드
public abstract boolean validateAnswer(String studentAnswer);

public void setImage(QuestionImage image) {
this.image = image;
}
Expand All @@ -84,6 +81,24 @@ void setExam(Exam exam) {
this.exam = exam;
}

/**
* 학생의 답안을 채점하여 맞았는지 여부를 반환
* @param studentAnswer 학생이 제출한 답안
* @return 정답 여부
*/
public final boolean validateAnswer(String studentAnswer) {
if (studentAnswer == null || studentAnswer.trim().isEmpty()) {
return false;
}
return doValidateAnswer(studentAnswer.trim());
}

/**
* 실제 답안 검증 로직을 구현하는 추상 메서드
* 각 문제 타입별로 구체적인 검증 방식을 구현
*/
protected abstract boolean doValidateAnswer(String studentAnswer);

public void updateExamNumber(int newNumber) {
this.examNumber = newNumber;
}
Expand Down
77 changes: 63 additions & 14 deletions src/main/java/com/example/epari/exam/domain/ExamResult.java
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,9 @@ public class ExamResult extends BaseTimeEntity {
@Column(nullable = false)
private LocalDateTime submitTime;

@Column(nullable = false)
private Integer totalScore = 0;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private ExamStatus status;
Expand All @@ -74,12 +77,19 @@ public void updateStatus(ExamStatus status) {
this.status = status;
}

/**
* 답안 추가
*/
public void addScore(ExamScore score) {
validateScoreAddable();
scores.add(score);
score.setExamResult(this);
}

/**
* 답안 제출 처리
* @param force 강제 제출 여부 (시험 시간 초과 등의 경우)
*/
public void submit(boolean force) {
if (!force) {
validateSubmittable();
Expand All @@ -89,13 +99,66 @@ public void submit(boolean force) {
scores.forEach(ExamScore::markAsSubmitted);
}

/**
* 임시저장 답안 여부 확인
*/
public boolean hasTemporaryAnswer(Long questionId) {
return scores.stream()
.anyMatch(score ->
score.getQuestion().getId().equals(questionId) &&
score.isTemporary()
);
}

/**
* 채점 결과 반영
*/
public void updateScore() {
if (status != ExamStatus.SUBMITTED) {
throw new BusinessBaseException(ErrorCode.EXAM_NOT_SUBMITTED);
}

this.totalScore = calculateTotalScore();
this.status = ExamStatus.GRADED;
}

public int getEarnedScore() {
return scores.stream()
.filter(score -> !score.isTemporary())
.mapToInt(ExamScore::getEarnedScore)
.sum();
}

/**
* 제출된 문제 수 조회
*/
public int getSubmittedQuestionCount() {
return (int)scores.stream()
.filter(score -> !score.isTemporary())
.count();
}

/**
* 정답률 계산
*/
public double getCorrectAnswerRate() {
if (scores.isEmpty()) {
return 0.0;
}

long correctCount = scores.stream()
.filter(score -> score.getEarnedScore() > 0)
.count();

return (double)correctCount / scores.size() * 100;
}

private int calculateTotalScore() {
return scores.stream()
.mapToInt(ExamScore::getEarnedScore)
.sum();
}

private void validateExam(Exam exam) {
if (exam == null) {
throw new BusinessBaseException(ErrorCode.EXAM_NOT_FOUND);
Expand Down Expand Up @@ -134,18 +197,4 @@ private void validateAllQuestionsAnswered() {
}
}

public int getSubmittedQuestionCount() {
return (int)scores.stream()
.filter(score -> !score.isTemporary())
.count();
}

public boolean hasTemporaryAnswer(Long questionId) {
return scores.stream()
.anyMatch(score ->
score.getQuestion().getId().equals(questionId) &&
score.isTemporary()
);
}

}
24 changes: 16 additions & 8 deletions src/main/java/com/example/epari/exam/domain/ExamScore.java
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
/**
* 개별 문제에 대한 학생의 답안과 채점 결과를 관리하는 엔티티
*/

@Entity
@Table(name = "exam_scores")
@Getter
Expand Down Expand Up @@ -47,8 +46,6 @@ public class ExamScore extends BaseTimeEntity {
@Column(nullable = false)
private boolean temporary;

private String feedback;

@Builder
public ExamScore(ExamResult examResult, ExamQuestion question, String studentAnswer, boolean temporary) {
this.examResult = examResult;
Expand All @@ -62,21 +59,32 @@ void setExamResult(ExamResult examResult) {
this.examResult = examResult;
}

public void updateScore(int earnedScore, String feedback) {
this.earnedScore = earnedScore;
this.feedback = feedback;
}

/**
* 답안 업데이트
*/
public void updateAnswer(String answer) {
this.studentAnswer = answer;
}

/**
* 임시저장 상태 해제 (최종 제출)
*/
public void markAsSubmitted() {
this.temporary = false;
}

/**
* 채점 결과 업데이트
*/
public void updateScore(int score) {
this.earnedScore = score;
}

/**
* 정답 여부 확인
*/
public boolean isCorrect() {
return earnedScore > 0;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,22 @@ private MultipleChoiceQuestion(String questionText, int examNumber, int score,
}

@Override
public boolean validateAnswer(String studentAnswer) {
return getCorrectAnswer().equals(studentAnswer);
protected boolean doValidateAnswer(String studentAnswer) {
try {
// 선택지 번호가 숫자인지 확인
int selectedNumber = Integer.parseInt(studentAnswer);

// 유효한 선택지 범위인지 확인
if (selectedNumber < 1 || selectedNumber > choices.size()) {
return false;
}

// 정답과 일치하는지 확인
return studentAnswer.equals(getCorrectAnswer());

} catch (NumberFormatException e) {
return false;
}
}

public void addChoice(Choice choice) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,12 @@ private SubjectiveQuestion(String questionText, int examNumber, int score,
}

@Override
public boolean validateAnswer(String studentAnswer) {
// 주관식은 좀 더 유연한 검증이 필요할 수 있음
return getCorrectAnswer().trim().equalsIgnoreCase(studentAnswer.trim());
protected boolean doValidateAnswer(String studentAnswer) {
// 대소문자 무시, 앞뒤 공백 제거 후 비교
String normalizedCorrectAnswer = getCorrectAnswer().trim().toLowerCase();
String normalizedStudentAnswer = studentAnswer.trim().toLowerCase();

return normalizedCorrectAnswer.equals(normalizedStudentAnswer);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.example.epari.exam.exception;

public class GradingException extends RuntimeException {

private static final long serialVersionUID = 1L;

public GradingException(String message) {
super(message);
}

public GradingException(String message, Throwable cause) {
super(message, cause);
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.example.epari.exam.exception;

public class GradingNotPossibleException extends GradingException {

public GradingNotPossibleException(String message) {
super(message);
}
}
Loading

0 comments on commit 9302a4c

Please sign in to comment.