6 changed files with 522 additions and 2 deletions

No files matched your search

@@ -1,17 +1,33 @@
package com.example.questionbank.service.quiz; 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.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.ExamAttemptStartResult; import com.example.questionbank.web.rest.dto.ExamAttemptStartResult;
import com.example.questionbank.web.rest.dto.ExamAttemptSummary;
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.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;
@@ -21,22 +37,29 @@ 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. 시험 응시 시작
@Transactional @Transactional
public ExamAttemptStartResult start(Long subjectId, String login) { public ExamAttemptStartResult start(Long subjectId, String login) {
Subject subject = subjectRepository Subject subject = subjectRepository
@@ -65,6 +88,198 @@ public class QuizAttemptService {
attempt.setScore(0); attempt.setScore(0);
ExamAttempt saved = attemptRepository.save(attempt); ExamAttempt saved = attemptRepository.save(attempt);
return new ExamAttemptStartResult(saved.getId(), saved.getStartedAt()); 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, "응시할 수 없는 문항입니다.");
}
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.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. 제출 완료된 시험 결과 조회
@Transactional(readOnly = true)
public ExamResult getResult(Long attemptId, String login) {
ExamAttempt attempt = getOwnedAttempt(attemptId, login);
if (attempt.getStatus() != AttemptStatus.SUBMITTED) {
throw new ResponseStatusException(HttpStatus.CONFLICT, "아직 제출되지 않은 응시입니다.");
}
List<AnswerResult> results = attempt
.getAnswers()
.stream()
.sorted(Comparator.comparing(a -> a.getQuestion().getId()))
.map(answer ->
new AnswerResult(
answer.getQuestion().getId(),
answer.getQuestionTextSnapshot(),
answer.getSelectedOption(),
answer.getSelectedContentSnapshot(),
Boolean.TRUE.equals(answer.getCorrect()),
answer.getAwardedPoints() == null ? 0 : answer.getAwardedPoints(),
answer.getExplanationSnapshot()
)
)
.toList();
return new ExamResult(attempt.getId(), attempt.getScore() == null ? 0 : attempt.getScore(), attempt.getSubmittedAt(), results);
}
// 5. 로그인한 사용자의 응시 이력 조회
@Transactional(readOnly = true)
public List<ExamAttemptSummary> getMyAttempts(String login) {
return attemptRepository
.findAllByUserLogin(login)
.stream()
.sorted(Comparator.comparing(ExamAttempt::getStartedAt, Comparator.nullsLast(Comparator.reverseOrder())))
.map(attempt ->
new ExamAttemptSummary(
attempt.getId(),
attempt.getSubject().getId(),
attempt.getSubject().getName(),
attempt.getStartedAt(),
attempt.getSubmittedAt(),
attempt.getStatus(),
attempt.getScore()
)
)
.toList();
}
// 6. 응시 기록 소유자 확인
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;
}
} }
@@ -3,6 +3,13 @@ package com.example.questionbank.web.rest;
import com.example.questionbank.repository.ExamAttemptRepository; import com.example.questionbank.repository.ExamAttemptRepository;
import com.example.questionbank.service.ExamAttemptService; import com.example.questionbank.service.ExamAttemptService;
import com.example.questionbank.service.dto.ExamAttemptDTO; import com.example.questionbank.service.dto.ExamAttemptDTO;
import com.example.questionbank.service.quiz.QuizAttemptService;
import com.example.questionbank.web.rest.dto.ExamAttemptStartResult;
import com.example.questionbank.web.rest.dto.ExamAttemptSummary;
import com.example.questionbank.web.rest.dto.ExamQuestionView;
import com.example.questionbank.web.rest.dto.ExamResult;
import com.example.questionbank.web.rest.dto.StartExamRequest;
import com.example.questionbank.web.rest.dto.SubmitExamRequest;
import com.example.questionbank.web.rest.errors.BadRequestAlertException; import com.example.questionbank.web.rest.errors.BadRequestAlertException;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.NotNull;
@@ -17,7 +24,9 @@ import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpHeaders; import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder; import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import tech.jhipster.web.util.HeaderUtil; import tech.jhipster.web.util.HeaderUtil;
@@ -31,6 +40,8 @@ import tech.jhipster.web.util.ResponseUtil;
@RequestMapping("/api/exam-attempts") @RequestMapping("/api/exam-attempts")
public class ExamAttemptResource { public class ExamAttemptResource {
private final QuizAttemptService quizAttemptService;
private static final Logger LOG = LoggerFactory.getLogger(ExamAttemptResource.class); private static final Logger LOG = LoggerFactory.getLogger(ExamAttemptResource.class);
private static final String ENTITY_NAME = "examAttempt"; private static final String ENTITY_NAME = "examAttempt";
@@ -42,9 +53,14 @@ public class ExamAttemptResource {
private final ExamAttemptRepository examAttemptRepository; private final ExamAttemptRepository examAttemptRepository;
public ExamAttemptResource(ExamAttemptService examAttemptService, ExamAttemptRepository examAttemptRepository) { public ExamAttemptResource(
ExamAttemptService examAttemptService,
ExamAttemptRepository examAttemptRepository,
QuizAttemptService quizAttemptService
) {
this.examAttemptService = examAttemptService; this.examAttemptService = examAttemptService;
this.examAttemptRepository = examAttemptRepository; this.examAttemptRepository = examAttemptRepository;
this.quizAttemptService = quizAttemptService;
} }
/** /**
@@ -185,4 +201,43 @@ public class ExamAttemptResource {
.headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, id.toString())) .headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, id.toString()))
.build(); .build();
} }
// 1. 시험 응시 시작
@PostMapping("/start")
public ResponseEntity<ExamAttemptStartResult> startExam(@Valid @RequestBody StartExamRequest request, Authentication authentication) {
ExamAttemptStartResult result = quizAttemptService.start(request.subjectId(), authentication.getName());
return ResponseEntity.status(HttpStatus.CREATED).body(result);
}
// 2. 시험 문제 조회
@GetMapping("/{attemptId}/questions")
public ResponseEntity<List<ExamQuestionView>> getExamQuestions(
@PathVariable("attemptId") Long attemptId,
Authentication authentication
) {
return ResponseEntity.ok(quizAttemptService.getQuestions(attemptId, authentication.getName()));
}
// 3. 답안 제출 및 자동 채점
@PostMapping("/{attemptId}/submit")
public ResponseEntity<ExamResult> submitExam(
@PathVariable("attemptId") Long attemptId,
@Valid @RequestBody SubmitExamRequest request,
Authentication authentication
) {
return ResponseEntity.ok(quizAttemptService.submit(attemptId, authentication.getName(), request));
}
// 4. 제출된 시험 결과 조회
@GetMapping("/{attemptId}/result")
public ResponseEntity<ExamResult> getExamResult(@PathVariable("attemptId") Long attemptId, Authentication authentication) {
return ResponseEntity.ok(quizAttemptService.getResult(attemptId, authentication.getName()));
}
// 5. 내 응시 이력 조회
@GetMapping("/my")
public ResponseEntity<List<ExamAttemptSummary>> getMyExamAttempts(Authentication authentication) {
return ResponseEntity.ok(quizAttemptService.getMyAttempts(authentication.getName()));
}
} }
+100
View File
@@ -0,0 +1,100 @@
import { defineComponent, onMounted, ref } from 'vue';
import {
type ExamQuestionView,
type ExamResult,
type OptionKey,
type SubjectItem,
fetchActiveSubjects,
loadQuestions,
startExam,
submitExam,
} from './quiz.service';
export default defineComponent({
name: 'QuizView',
setup() {
const subjects = ref<SubjectItem[]>([]);
const selectedSubjectId = ref<number | null>(null);
const attemptId = ref<number | null>(null);
const questions = ref<ExamQuestionView[]>([]);
const selectedAnswers = ref<Record<number, OptionKey | null>>({});
const result = ref<ExamResult | null>(null);
const errorMessage = ref<string | null>(null);
const loading = ref(false);
onMounted(async () => {
try {
subjects.value = await fetchActiveSubjects();
} catch {
errorMessage.value = '과목 목록을 불러오지 못했습니다.';
}
});
const handleStartExam = async () => {
if (!selectedSubjectId.value) return;
errorMessage.value = null;
loading.value = true;
try {
const started = await startExam(selectedSubjectId.value);
attemptId.value = started.attemptId;
const loadedQuestions = await loadQuestions(started.attemptId);
questions.value = loadedQuestions;
selectedAnswers.value = {};
loadedQuestions.forEach(q => {
selectedAnswers.value[q.questionId] = null;
});
} catch (err: any) {
errorMessage.value = err?.response?.data?.detail || '응시를 시작할 수 없습니다.';
attemptId.value = null;
} finally {
loading.value = false;
}
};
const handleSubmitExam = async () => {
if (!attemptId.value) return;
errorMessage.value = null;
loading.value = true;
try {
const payload = questions.value.map(q => ({
questionId: q.questionId,
selectedOption: selectedAnswers.value[q.questionId] ?? null,
}));
result.value = await submitExam(attemptId.value, payload);
} catch (err: any) {
errorMessage.value = err?.response?.data?.detail || '답안 제출 중 오류가 발생했습니다.';
} finally {
loading.value = false;
}
};
const resetQuiz = () => {
attemptId.value = null;
questions.value = [];
selectedAnswers.value = {};
result.value = null;
errorMessage.value = null;
};
return {
subjects,
selectedSubjectId,
attemptId,
questions,
selectedAnswers,
result,
errorMessage,
loading,
handleStartExam,
handleSubmitExam,
resetQuiz,
};
},
});
+64
View File
@@ -0,0 +1,64 @@
import axios from 'axios';
export type OptionKey = 'A' | 'B' | 'C' | 'D';
export interface SubjectItem {
id: number;
name: string;
description?: string;
active: boolean;
}
export interface ChoiceView {
optionKey: OptionKey;
content: string;
displayOrder: number;
}
export interface ExamQuestionView {
questionId: number;
prompt: string;
choices: ChoiceView[];
}
export interface SubmitAnswer {
questionId: number;
selectedOption: OptionKey | null;
}
export interface AnswerResult {
questionId: number;
questionText: string;
selectedOption: OptionKey | null;
selectedContent: string | null;
correct: boolean;
awardedPoints: number;
explanation?: string;
}
export interface ExamResult {
attemptId: number;
totalScore: number;
submittedAt: string;
answers: AnswerResult[];
}
export async function fetchActiveSubjects(): Promise<SubjectItem[]> {
const response = await axios.get<SubjectItem[]>('api/subjects?page=0&size=100');
return response.data.filter(s => s.active);
}
export async function startExam(subjectId: number): Promise<{ attemptId: number; startedAt: string }> {
const response = await axios.post('api/exam-attempts/start', { subjectId });
return response.data;
}
export async function loadQuestions(attemptId: number): Promise<ExamQuestionView[]> {
const response = await axios.get<ExamQuestionView[]>(`api/exam-attempts/${attemptId}/questions`);
return response.data;
}
export async function submitExam(attemptId: number, answers: SubmitAnswer[]): Promise<ExamResult> {
const response = await axios.post<ExamResult>(`api/exam-attempts/${attemptId}/submit`, { answers });
return response.data;
}
+78
View File
@@ -0,0 +1,78 @@
<template>
<div class="container mt-4">
<h2>온라인 문제 풀이</h2>
<div v-if="errorMessage" class="alert alert-danger mt-3">
{{ errorMessage }}
</div>
<!-- 1단계: 과목 선택 -->
<div v-if="!attemptId" class="card p-4 mt-3">
<h4>응시할 과목을 선택하세요</h4>
<div class="form-group mt-2">
<select v-model="selectedSubjectId" class="form-control">
<option :value="null" disabled>-- 과목 선택 --</option>
<option v-for="subject in subjects" :key="subject.id" :value="subject.id">
{{ subject.name }}
</option>
</select>
</div>
<button class="btn btn-primary mt-3" :disabled="!selectedSubjectId || loading" @click="handleStartExam">응시 시작</button>
</div>
<!-- 2단계: 문제 풀이 -->
<div v-if="attemptId && !result" class="mt-3">
<div v-for="(q, index) in questions" :key="q.questionId" class="card p-3 mb-3">
<h5>문제 {{ index + 1 }}. {{ q.prompt }}</h5>
<div v-for="choice in q.choices" :key="choice.optionKey" class="form-check mt-2">
<input
:id="'q-' + q.questionId + '-' + choice.optionKey"
v-model="selectedAnswers[q.questionId]"
class="form-check-input"
type="radio"
:name="'question-' + q.questionId"
:value="choice.optionKey"
/>
<label class="form-check-label" :for="'q-' + q.questionId + '-' + choice.optionKey">
<strong>{{ choice.optionKey }}.</strong>
{{ choice.content }}
</label>
</div>
</div>
<button class="btn btn-success" :disabled="loading" @click="handleSubmitExam">답안 제출 및 채점</button>
</div>
<!-- 3단계: 채점 결과 -->
<div v-if="result" class="card p-4 mt-3">
<h3>채점 결과: 총점 {{ result.totalScore }} / {{ result.answers.length }}점</h3>
<p class="text-muted">제출 시각: {{ result.submittedAt }}</p>
<div v-for="(ans, idx) in result.answers" :key="ans.questionId" class="border-top pt-3 mt-3">
<h5>
문제 {{ idx + 1 }}. {{ ans.questionText }}
<span :class="ans.correct ? 'badge bg-success' : 'badge bg-danger'">
{{ ans.correct ? '정답 (+1점)' : '오답 (0점)' }}
</span>
</h5>
<p class="mb-1">
내 선택:
{{ ans.selectedOption ? `${ans.selectedOption} (${ans.selectedContent})` : '무응답' }}
</p>
<p v-if="ans.explanation" class="text-info mb-0"><strong>해설:</strong> {{ ans.explanation }}</p>
</div>
<button class="btn btn-secondary mt-4" @click="resetQuiz">다른 과목 응시하기</button>
</div>
</div>
</template>
<script lang="ts" src="./quiz.component.ts"></script>
+8
View File
@@ -7,6 +7,8 @@ import admin from '@/router/admin';
import entities from '@/router/entities'; import entities from '@/router/entities';
import pages from '@/router/pages'; import pages from '@/router/pages';
const QuizView = () => import('@/quiz/quiz.vue');
export const createRouter = () => export const createRouter = () =>
createVueRouter({ createVueRouter({
history: createWebHistory(), history: createWebHistory(),
@@ -28,6 +30,12 @@ export const createRouter = () =>
component: Error, component: Error,
meta: { error404: true }, meta: { error404: true },
}, },
{
path: '/quiz',
name: 'QuizView',
component: QuizView,
meta: { authorities: ['ROLE_USER'] },
},
...account, ...account,
...admin, ...admin,
entities, entities,