diff --git a/src/main/java/com/example/questionbank/service/quiz/QuizAttemptService.java b/src/main/java/com/example/questionbank/service/quiz/QuizAttemptService.java index eb8b158..8504e49 100644 --- a/src/main/java/com/example/questionbank/service/quiz/QuizAttemptService.java +++ b/src/main/java/com/example/questionbank/service/quiz/QuizAttemptService.java @@ -1,21 +1,32 @@ package com.example.questionbank.service.quiz; import com.example.questionbank.domain.Choice; +import com.example.questionbank.domain.ExamAnswer; import com.example.questionbank.domain.ExamAttempt; import com.example.questionbank.domain.Question; import com.example.questionbank.domain.Subject; import com.example.questionbank.domain.User; 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.QuestionRepository; import com.example.questionbank.repository.SubjectRepository; 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.ExamAttemptStartResult; 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.util.ArrayList; import java.util.Comparator; +import java.util.HashSet; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,20 +36,26 @@ import org.springframework.web.server.ResponseStatusException; public class QuizAttemptService { private final ExamAttemptRepository attemptRepository; + private final ExamAnswerRepository answerRepository; private final SubjectRepository subjectRepository; private final QuestionRepository questionRepository; private final UserRepository userRepository; + private final ExamScoringService scoringService; public QuizAttemptService( ExamAttemptRepository attemptRepository, + ExamAnswerRepository answerRepository, SubjectRepository subjectRepository, QuestionRepository questionRepository, - UserRepository userRepository + UserRepository userRepository, + ExamScoringService scoringService ) { this.attemptRepository = attemptRepository; + this.answerRepository = answerRepository; this.subjectRepository = subjectRepository; this.questionRepository = questionRepository; this.userRepository = userRepository; + this.scoringService = scoringService; } // 1. 시험 응시 시작 @@ -74,7 +91,7 @@ public class QuizAttemptService { return new ExamAttemptStartResult(saved.getId(), saved.getStartedAt()); } - // 2. 시험 문제 조회 (정답은 숨김) + // 2. 시험 문제 조회 (정답 숨김) @Transactional(readOnly = true) public List getQuestions(Long attemptId, String login) { ExamAttempt attempt = getOwnedAttempt(attemptId, login); @@ -102,7 +119,119 @@ public class QuizAttemptService { .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 seenQuestionIds = new HashSet<>(); + + Set requiredQuestionIds = questionRepository + .findAllBySubjectIdAndActiveTrue(attempt.getSubject().getId()) + .stream() + .map(Question::getId) + .collect(Collectors.toSet()); + + List 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 choices = new ArrayList<>(question.getChoices()); + + List correctChoices = choices + .stream() + .filter(choice -> Boolean.TRUE.equals(choice.getCorrect())) + .toList(); + + Set 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) { ExamAttempt attempt = attemptRepository .findById(attemptId)