Compare commits
13
Commits
main
...
1db02c4e15
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1db02c4e15 | ||
|
|
c03a7e6362 | ||
|
|
7b5d091989 | ||
|
|
ab2129a965 | ||
|
|
d8ef029bef | ||
|
|
995e69e302 | ||
|
|
698bb0e99f | ||
|
|
3c0455bff7 | ||
|
|
ed109df111 | ||
|
|
e8de49732b | ||
|
|
123261aad3 | ||
|
|
1ed458a9c1 | ||
|
|
3da5840e8b |
No files matched your search
@@ -2,6 +2,7 @@ package com.example.questionbank.repository;
|
||||
|
||||
import com.example.questionbank.domain.ExamAttempt;
|
||||
import java.util.List;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -14,6 +15,8 @@ import org.springframework.stereotype.Repository;
|
||||
*/
|
||||
@Repository
|
||||
public interface ExamAttemptRepository extends JpaRepository<ExamAttempt, Long> {
|
||||
List<ExamAttempt> findAllByUserLogin(String login);
|
||||
|
||||
@Query("select examAttempt from ExamAttempt examAttempt where examAttempt.user.login = ?#{authentication.name}")
|
||||
List<ExamAttempt> findByUserIsCurrentUser();
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.example.questionbank.repository;
|
||||
|
||||
import com.example.questionbank.domain.Question;
|
||||
import java.util.List;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
@@ -14,6 +15,8 @@ import org.springframework.stereotype.Repository;
|
||||
*/
|
||||
@Repository
|
||||
public interface QuestionRepository extends JpaRepository<Question, Long> {
|
||||
List<Question> findAllBySubjectIdAndActiveTrue(Long subjectId);
|
||||
|
||||
default Optional<Question> findOneWithEagerRelationships(Long id) {
|
||||
return this.findOneWithToOneRelationships(id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.example.questionbank.service.quiz;
|
||||
|
||||
import com.example.questionbank.domain.enumeration.OptionKey;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
@Service
|
||||
public class ExamScoringService {
|
||||
|
||||
public ScoringResult score(OptionKey correctOption, OptionKey selectedOption) {
|
||||
boolean correct = selectedOption != null && selectedOption == correctOption;
|
||||
return new ScoringResult(correct, correct ? 1 : 0);
|
||||
}
|
||||
|
||||
public record ScoringResult(boolean correct, int awardedPoints) {}
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
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;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
@Service
|
||||
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,
|
||||
ExamScoringService scoringService
|
||||
) {
|
||||
this.attemptRepository = attemptRepository;
|
||||
this.answerRepository = answerRepository;
|
||||
this.subjectRepository = subjectRepository;
|
||||
this.questionRepository = questionRepository;
|
||||
this.userRepository = userRepository;
|
||||
this.scoringService = scoringService;
|
||||
}
|
||||
|
||||
// 1. 시험 응시 시작
|
||||
@Transactional
|
||||
public ExamAttemptStartResult start(Long subjectId, String login) {
|
||||
Subject subject = subjectRepository
|
||||
.findById(subjectId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "과목을 찾을 수 없습니다."));
|
||||
|
||||
if (!Boolean.TRUE.equals(subject.getActive())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "비활성 과목은 응시할 수 없습니다.");
|
||||
}
|
||||
|
||||
List<Question> activeQuestions = questionRepository.findAllBySubjectIdAndActiveTrue(subjectId);
|
||||
|
||||
if (activeQuestions.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "출제된 활성 문항이 없는 과목입니다.");
|
||||
}
|
||||
|
||||
User user = userRepository
|
||||
.findOneByLogin(login)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "사용자를 찾을 수 없습니다."));
|
||||
|
||||
ExamAttempt attempt = new ExamAttempt();
|
||||
attempt.setSubject(subject);
|
||||
attempt.setUser(user);
|
||||
attempt.setStartedAt(Instant.now());
|
||||
attempt.setStatus(AttemptStatus.IN_PROGRESS);
|
||||
attempt.setScore(0);
|
||||
|
||||
ExamAttempt saved = attemptRepository.save(attempt);
|
||||
|
||||
return new ExamAttemptStartResult(saved.getId(), saved.getStartedAt());
|
||||
}
|
||||
|
||||
// 2. 시험 문제 조회 (정답 숨김)
|
||||
@Transactional(readOnly = true)
|
||||
public List<ExamQuestionView> getQuestions(Long attemptId, String login) {
|
||||
ExamAttempt attempt = getOwnedAttempt(attemptId, login);
|
||||
|
||||
if (attempt.getStatus() != AttemptStatus.IN_PROGRESS) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "진행 중인 응시만 문제를 조회할 수 있습니다.");
|
||||
}
|
||||
|
||||
return questionRepository
|
||||
.findAllBySubjectIdAndActiveTrue(attempt.getSubject().getId())
|
||||
.stream()
|
||||
.sorted(Comparator.comparing(Question::getId))
|
||||
.map(question ->
|
||||
new ExamQuestionView(
|
||||
question.getId(),
|
||||
question.getPrompt(),
|
||||
question
|
||||
.getChoices()
|
||||
.stream()
|
||||
.sorted(Comparator.comparing(Choice::getDisplayOrder))
|
||||
.map(choice -> new ChoiceView(choice.getOptionKey(), choice.getContent(), choice.getDisplayOrder()))
|
||||
.toList()
|
||||
)
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
|
||||
// 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) {
|
||||
ExamAttempt attempt = attemptRepository
|
||||
.findById(attemptId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "응시를 찾을 수 없습니다."));
|
||||
|
||||
if (!attempt.getUser().getLogin().equals(login)) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "응시를 찾을 수 없습니다.");
|
||||
}
|
||||
|
||||
return attempt;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.example.questionbank.web.rest.dto;
|
||||
|
||||
import com.example.questionbank.domain.enumeration.OptionKey;
|
||||
|
||||
public record AnswerResult(
|
||||
Long questionId,
|
||||
String questionText,
|
||||
OptionKey selectedOption,
|
||||
String selectedContent,
|
||||
boolean correct,
|
||||
int awardedPoints,
|
||||
String explanation
|
||||
) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.questionbank.web.rest.dto;
|
||||
|
||||
import com.example.questionbank.domain.enumeration.OptionKey;
|
||||
|
||||
public record ChoiceView(OptionKey optionKey, String content, Integer displayOrder) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.questionbank.web.rest.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
public record ExamAttemptStartResult(Long attemptId, Instant startedAt) {}
|
||||
@@ -0,0 +1,14 @@
|
||||
package com.example.questionbank.web.rest.dto;
|
||||
|
||||
import com.example.questionbank.domain.enumeration.AttemptStatus;
|
||||
import java.time.Instant;
|
||||
|
||||
public record ExamAttemptSummary(
|
||||
Long attemptId,
|
||||
Long subjectId,
|
||||
String subjectName,
|
||||
Instant startedAt,
|
||||
Instant submittedAt,
|
||||
AttemptStatus status,
|
||||
Integer score
|
||||
) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.questionbank.web.rest.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record ExamQuestionView(Long questionId, String prompt, List<ChoiceView> choices) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.example.questionbank.web.rest.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record ExamResult(Long attemptId, int totalScore, Instant submittedAt, List<AnswerResult> answers) {}
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.questionbank.web.rest.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record StartExamRequest(@NotNull Long subjectId) {}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.example.questionbank.web.rest.dto;
|
||||
|
||||
import com.example.questionbank.domain.enumeration.OptionKey;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
public record SubmitAnswerRequest(@NotNull Long questionId, OptionKey selectedOption) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.example.questionbank.web.rest.dto;
|
||||
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import java.util.List;
|
||||
|
||||
public record SubmitExamRequest(@NotEmpty List<@Valid SubmitAnswerRequest> answers) {}
|
||||
@@ -70,7 +70,14 @@ export default defineComponent({
|
||||
created(): void {},
|
||||
methods: {
|
||||
save(): void {
|
||||
const message = this.subject.id ? '이 과목의 수정 내용을 저장하시겠습니까?' : '새 과목을 등록하시겠습니까?';
|
||||
|
||||
if (!window.confirm(message)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSaving = true;
|
||||
|
||||
if (this.subject.id) {
|
||||
this.subjectService()
|
||||
.update(this.subject)
|
||||
|
||||
@@ -13,7 +13,7 @@ export default defineComponent({
|
||||
const subjectService = inject('subjectService', () => new SubjectService());
|
||||
const alertService = inject('alertService', () => useAlertService(), true);
|
||||
|
||||
const itemsPerPage = ref(20);
|
||||
const itemsPerPage = ref(10);
|
||||
const queryCount: Ref<number> = ref(null);
|
||||
const page: Ref<number> = ref(1);
|
||||
const propOrder = ref('id');
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
<td>
|
||||
<router-link :to="{ name: 'SubjectView', params: { subjectId: subject.id } }">{{ subject.id }}</router-link>
|
||||
</td>
|
||||
<td>{{ subject.name }}</td>
|
||||
<td>📘{{ subject.name }}</td>
|
||||
<td>{{ subject.description }}</td>
|
||||
<td>{{ subject.active }}</td>
|
||||
<td class="text-end">
|
||||
@@ -106,7 +106,11 @@
|
||||
<jhi-item-count :page="page" :total="queryCount" :items-per-page="itemsPerPage"></jhi-item-count>
|
||||
</div>
|
||||
<div class="d-flex justify-content-center">
|
||||
<b-pagination size="md" :total-rows="totalItems" v-model="page" :per-page="itemsPerPage"></b-pagination>
|
||||
<p>전체 과목 수: {{ totalItems }}개</p>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-center">
|
||||
<b-pagination :total-rows="totalItems" v-model="page" :per-page="itemsPerPage"></b-pagination>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.example.questionbank.service.quiz;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.example.questionbank.domain.enumeration.OptionKey;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class ExamScoringServiceTest {
|
||||
|
||||
private final ExamScoringService service = new ExamScoringService();
|
||||
|
||||
@Test
|
||||
void correctAnswerGetsOnePoint() {
|
||||
var result = service.score(OptionKey.A, OptionKey.A);
|
||||
|
||||
assertTrue(result.correct());
|
||||
assertEquals(1, result.awardedPoints());
|
||||
}
|
||||
|
||||
@Test
|
||||
void wrongAnswerGetsZeroPoints() {
|
||||
var result = service.score(OptionKey.A, OptionKey.D);
|
||||
|
||||
assertFalse(result.correct());
|
||||
assertEquals(0, result.awardedPoints());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unansweredQuestionGetsZeroPoints() {
|
||||
var result = service.score(OptionKey.A, null);
|
||||
|
||||
assertFalse(result.correct());
|
||||
assertEquals(0, result.awardedPoints());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user