Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEAT] 시험 문제 자동 채점기능 구현 #99

Merged
merged 13 commits into from
Nov 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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;
}

Copy link
Contributor

Choose a reason for hiding this comment

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

특히 이 부분 코드가 읽기 좋고 잘 하셨네요!

// 정답과 일치하는지 확인
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
Loading