6 changed files with 358 additions and 14 deletions

No files matched your search

@@ -16,6 +16,7 @@ import com.example.questionbank.repository.UserRepository;
import com.example.questionbank.web.rest.dto.AnswerResult; 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.ExamAttemptSummary;
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.ExamResult;
import com.example.questionbank.web.rest.dto.SubmitAnswerRequest; import com.example.questionbank.web.rest.dto.SubmitAnswerRequest;
@@ -140,22 +141,18 @@ public class QuizAttemptService {
int totalScore = 0; int totalScore = 0;
for (SubmitAnswerRequest answerRequest : request.answers()) { for (SubmitAnswerRequest answerRequest : request.answers()) {
// 동일한 문제를 중복 제출했는지 확인
if (!seenQuestionIds.add(answerRequest.questionId())) { if (!seenQuestionIds.add(answerRequest.questionId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "중복 제출된 문항이 있습니다."); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "중복 제출된 문항이 있습니다.");
} }
// 제출한 문제 조회
Question question = questionRepository Question question = questionRepository
.findById(answerRequest.questionId()) .findById(answerRequest.questionId())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "문항을 찾을 수 없습니다.")); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "문항을 찾을 수 없습니다."));
// 현재 과목에 속한 활성 문제인지 확인
if (!Boolean.TRUE.equals(question.getActive()) || !question.getSubject().getId().equals(attempt.getSubject().getId())) { if (!Boolean.TRUE.equals(question.getActive()) || !question.getSubject().getId().equals(attempt.getSubject().getId())) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "응시할 수 없는 문항입니다."); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "응시할 수 없는 문항입니다.");
} }
// 보기 4개와 정답 1개가 올바르게 등록됐는지 확인
List<Choice> choices = new ArrayList<>(question.getChoices()); List<Choice> choices = new ArrayList<>(question.getChoices());
List<Choice> correctChoices = choices List<Choice> correctChoices = choices
@@ -169,7 +166,6 @@ public class QuizAttemptService {
throw new ResponseStatusException(HttpStatus.CONFLICT, "문항의 선택지 구성이 올바르지 않습니다."); throw new ResponseStatusException(HttpStatus.CONFLICT, "문항의 선택지 구성이 올바르지 않습니다.");
} }
// 사용자가 선택한 보기 찾기
OptionKey selectedOption = answerRequest.selectedOption(); OptionKey selectedOption = answerRequest.selectedOption();
Choice selectedChoice = Choice selectedChoice =
@@ -183,10 +179,8 @@ public class QuizAttemptService {
new ResponseStatusException(HttpStatus.BAD_REQUEST, "해당 문항에 존재하지 않는 선택지입니다.") new ResponseStatusException(HttpStatus.BAD_REQUEST, "해당 문항에 존재하지 않는 선택지입니다.")
); );
// 앞에서 만든 ExamScoringService로 채점
ExamScoringService.ScoringResult score = scoringService.score(correctChoices.getFirst().getOptionKey(), selectedOption); ExamScoringService.ScoringResult score = scoringService.score(correctChoices.getFirst().getOptionKey(), selectedOption);
// 문제·해설·선택 답안의 스냅샷 저장
ExamAnswer answer = new ExamAnswer(); ExamAnswer answer = new ExamAnswer();
answer.setAttempt(attempt); answer.setAttempt(attempt);
answer.setQuestion(question); answer.setQuestion(question);
@@ -199,10 +193,8 @@ public class QuizAttemptService {
answerRepository.save(answer); answerRepository.save(answer);
// 총점 계산
totalScore += score.awardedPoints(); totalScore += score.awardedPoints();
// 응시자에게 반환할 채점 결과 만들기
results.add( results.add(
new AnswerResult( new AnswerResult(
question.getId(), question.getId(),
@@ -216,14 +208,11 @@ public class QuizAttemptService {
); );
} }
// 모든 활성 문제를 제출했는지 확인
if (!seenQuestionIds.equals(requiredQuestionIds)) { if (!seenQuestionIds.equals(requiredQuestionIds)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "과목의 모든 활성 문항을 빠짐없이 제출해야 합니다."); throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "과목의 모든 활성 문항을 빠짐없이 제출해야 합니다.");
} }
// 시험 제출 완료 처리
Instant submittedAt = Instant.now(); Instant submittedAt = Instant.now();
attempt.setScore(totalScore); attempt.setScore(totalScore);
attempt.setSubmittedAt(submittedAt); attempt.setSubmittedAt(submittedAt);
attempt.setStatus(AttemptStatus.SUBMITTED); attempt.setStatus(AttemptStatus.SUBMITTED);
@@ -231,7 +220,57 @@ public class QuizAttemptService {
return new ExamResult(attempt.getId(), totalScore, submittedAt, results); return new ExamResult(attempt.getId(), totalScore, submittedAt, results);
} }
// 4. 응시 기록 소유자 확인 // 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) { private ExamAttempt getOwnedAttempt(Long attemptId, String login) {
ExamAttempt attempt = attemptRepository ExamAttempt attempt = attemptRepository
.findById(attemptId) .findById(attemptId)
@@ -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,