Implement quiz answer submission and scoring

This commit is contained in:
jieun committed 2026-10-20 20:50:54 +09:00
1 parent c03a7e6362
commit 1db02c4e15
1 file changed
+132 -3
@@ -1,21 +1,32 @@
package com.example.questionbank.service.quiz; package com.example.questionbank.service.quiz;
import com.example.questionbank.domain.Choice; import com.example.questionbank.domain.Choice;
import com.example.questionbank.domain.ExamAnswer;
import com.example.questionbank.domain.ExamAttempt; import com.example.questionbank.domain.ExamAttempt;
import com.example.questionbank.domain.Question; import com.example.questionbank.domain.Question;
import com.example.questionbank.domain.Subject; import com.example.questionbank.domain.Subject;
import com.example.questionbank.domain.User; import com.example.questionbank.domain.User;
import com.example.questionbank.domain.enumeration.AttemptStatus; import com.example.questionbank.domain.enumeration.AttemptStatus;
import com.example.questionbank.domain.enumeration.OptionKey;
import com.example.questionbank.repository.ExamAnswerRepository;
import com.example.questionbank.repository.ExamAttemptRepository; import com.example.questionbank.repository.ExamAttemptRepository;
import com.example.questionbank.repository.QuestionRepository; import com.example.questionbank.repository.QuestionRepository;
import com.example.questionbank.repository.SubjectRepository; import com.example.questionbank.repository.SubjectRepository;
import com.example.questionbank.repository.UserRepository; import com.example.questionbank.repository.UserRepository;
import com.example.questionbank.web.rest.dto.AnswerResult;
import com.example.questionbank.web.rest.dto.ChoiceView; import com.example.questionbank.web.rest.dto.ChoiceView;
import com.example.questionbank.web.rest.dto.ExamAttemptStartResult; import com.example.questionbank.web.rest.dto.ExamAttemptStartResult;
import com.example.questionbank.web.rest.dto.ExamQuestionView; import com.example.questionbank.web.rest.dto.ExamQuestionView;
import com.example.questionbank.web.rest.dto.ExamResult;
import com.example.questionbank.web.rest.dto.SubmitAnswerRequest;
import com.example.questionbank.web.rest.dto.SubmitExamRequest;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator; import java.util.Comparator;
import java.util.HashSet;
import java.util.List; import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
@@ -25,20 +36,26 @@ import org.springframework.web.server.ResponseStatusException;
public class QuizAttemptService { public class QuizAttemptService {
private final ExamAttemptRepository attemptRepository; private final ExamAttemptRepository attemptRepository;
private final ExamAnswerRepository answerRepository;
private final SubjectRepository subjectRepository; private final SubjectRepository subjectRepository;
private final QuestionRepository questionRepository; private final QuestionRepository questionRepository;
private final UserRepository userRepository; private final UserRepository userRepository;
private final ExamScoringService scoringService;
public QuizAttemptService( public QuizAttemptService(
ExamAttemptRepository attemptRepository, ExamAttemptRepository attemptRepository,
ExamAnswerRepository answerRepository,
SubjectRepository subjectRepository, SubjectRepository subjectRepository,
QuestionRepository questionRepository, QuestionRepository questionRepository,
UserRepository userRepository UserRepository userRepository,
ExamScoringService scoringService
) { ) {
this.attemptRepository = attemptRepository; this.attemptRepository = attemptRepository;
this.answerRepository = answerRepository;
this.subjectRepository = subjectRepository; this.subjectRepository = subjectRepository;
this.questionRepository = questionRepository; this.questionRepository = questionRepository;
this.userRepository = userRepository; this.userRepository = userRepository;
this.scoringService = scoringService;
} }
// 1. 시험 응시 시작 // 1. 시험 응시 시작
@@ -74,7 +91,7 @@ public class QuizAttemptService {
return new ExamAttemptStartResult(saved.getId(), saved.getStartedAt()); return new ExamAttemptStartResult(saved.getId(), saved.getStartedAt());
} }
// 2. 시험 문제 조회 (정답은 숨김) // 2. 시험 문제 조회 (정답 숨김)
@Transactional(readOnly = true) @Transactional(readOnly = true)
public List<ExamQuestionView> getQuestions(Long attemptId, String login) { public List<ExamQuestionView> getQuestions(Long attemptId, String login) {
ExamAttempt attempt = getOwnedAttempt(attemptId, login); ExamAttempt attempt = getOwnedAttempt(attemptId, login);
@@ -102,7 +119,119 @@ public class QuizAttemptService {
.toList(); .toList();
} }
// 3. 응시 기록이 로그인한 사용자 본인의 것인지 확인 // 3. 답안 제출 및 자동 채점
@Transactional
public ExamResult submit(Long attemptId, String login, SubmitExamRequest request) {
ExamAttempt attempt = getOwnedAttempt(attemptId, login);
if (attempt.getStatus() != AttemptStatus.IN_PROGRESS) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "이미 제출된 응시입니다.");
}
Set<Long> seenQuestionIds = new HashSet<>();
Set<Long> requiredQuestionIds = questionRepository
.findAllBySubjectIdAndActiveTrue(attempt.getSubject().getId())
.stream()
.map(Question::getId)
.collect(Collectors.toSet());
List<AnswerResult> results = new ArrayList<>();
int totalScore = 0;
for (SubmitAnswerRequest answerRequest : request.answers()) {
// 동일한 문제를 중복 제출했는지 확인
if (!seenQuestionIds.add(answerRequest.questionId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "중복 제출된 문항이 있습니다.");
}
// 제출한 문제 조회
Question question = questionRepository
.findById(answerRequest.questionId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "문항을 찾을 수 없습니다."));
// 현재 과목에 속한 활성 문제인지 확인
if (!Boolean.TRUE.equals(question.getActive()) || !question.getSubject().getId().equals(attempt.getSubject().getId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "응시할 수 없는 문항입니다.");
}
// 보기 4개와 정답 1개가 올바르게 등록됐는지 확인
List<Choice> choices = new ArrayList<>(question.getChoices());
List<Choice> correctChoices = choices
.stream()
.filter(choice -> Boolean.TRUE.equals(choice.getCorrect()))
.toList();
Set<OptionKey> optionKeys = choices.stream().map(Choice::getOptionKey).collect(Collectors.toSet());
if (choices.size() != 4 || optionKeys.size() != 4 || correctChoices.size() != 1) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "문항의 선택지 구성이 올바르지 않습니다.");
}
// 사용자가 선택한 보기 찾기
OptionKey selectedOption = answerRequest.selectedOption();
Choice selectedChoice =
selectedOption == null
? null
: choices
.stream()
.filter(choice -> choice.getOptionKey() == selectedOption)
.findFirst()
.orElseThrow(() ->
new ResponseStatusException(HttpStatus.BAD_REQUEST, "해당 문항에 존재하지 않는 선택지입니다.")
);
// 앞에서 만든 ExamScoringService로 채점
ExamScoringService.ScoringResult score = scoringService.score(correctChoices.getFirst().getOptionKey(), selectedOption);
// 문제·해설·선택 답안의 스냅샷 저장
ExamAnswer answer = new ExamAnswer();
answer.setAttempt(attempt);
answer.setQuestion(question);
answer.setQuestionTextSnapshot(question.getPrompt());
answer.setExplanationSnapshot(question.getExplanation());
answer.setSelectedOption(selectedOption);
answer.setSelectedContentSnapshot(selectedChoice == null ? null : selectedChoice.getContent());
answer.setCorrect(score.correct());
answer.setAwardedPoints(score.awardedPoints());
answerRepository.save(answer);
// 총점 계산
totalScore += score.awardedPoints();
// 응시자에게 반환할 채점 결과 만들기
results.add(
new AnswerResult(
question.getId(),
question.getPrompt(),
selectedOption,
selectedChoice == null ? null : selectedChoice.getContent(),
score.correct(),
score.awardedPoints(),
question.getExplanation()
)
);
}
// 모든 활성 문제를 제출했는지 확인
if (!seenQuestionIds.equals(requiredQuestionIds)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "과목의 모든 활성 문항을 빠짐없이 제출해야 합니다.");
}
// 시험 제출 완료 처리
Instant submittedAt = Instant.now();
attempt.setScore(totalScore);
attempt.setSubmittedAt(submittedAt);
attempt.setStatus(AttemptStatus.SUBMITTED);
return new ExamResult(attempt.getId(), totalScore, submittedAt, results);
}
// 4. 응시 기록 소유자 확인
private ExamAttempt getOwnedAttempt(Long attemptId, String login) { private ExamAttempt getOwnedAttempt(Long attemptId, String login) {
ExamAttempt attempt = attemptRepository ExamAttempt attempt = attemptRepository
.findById(attemptId) .findById(attemptId)