Author SHA1 Message Date
jieun f042861fcd Fix quiz API URLs and verify exam flow 2026-10-20 21:30:52 +09:00
jieun 392ac642b0 Implement Vue quiz screen and fix route authority 2026-10-20 21:20:28 +09:00
jieun aaa051c7f7 Complete quiz attempt service with result and history queries 2026-10-20 20:56:30 +09:00
jieun b539a9a99a Complete quiz attempt service with result and history queries 2026-10-20 20:52:53 +09:00
jieun 1db02c4e15 Implement quiz answer submission and scoring 2026-10-20 20:50:54 +09:00
jieun c03a7e6362 Implement secure quiz question retrieval 2026-10-20 20:49:28 +09:00
jieun 7b5d091989 Implement quiz attempt start service 2026-10-20 20:46:43 +09:00
jieun ab2129a965 Add unit tests for exam scoring service 2026-10-20 20:41:04 +09:00
jieun d8ef029bef Implement exam scoring service 2026-10-20 20:36:47 +09:00
jieun 995e69e302 Add quiz repository query methods 2026-10-20 20:33:50 +09:00
jieun 698bb0e99f Add exam attempt history summary DTO 2026-10-20 20:29:13 +09:00
jieun 3c0455bff7 Add exam grading result DTOs 2026-10-20 20:26:18 +09:00
jieun ed109df111 Add exam answer submission DTOs 2026-10-20 20:22:38 +09:00
jieun e8de49732b Add exam question and choice view DTOs 2026-10-20 20:20:06 +09:00
jieun 123261aad3 Add exam start request and response DTOs 2026-10-20 20:17:02 +09:00
jieun 1ed458a9c1 Add confirmation dialog to Subject save 2026-10-20 20:08:37 +09:00
jieun 3da5840e8b Improve Subject Vue list and pagination 2026-10-20 20:02:36 +09:00
jieun ed30cf85dc Fix relationship dropdown pagination 2026-10-20 19:44:08 +09:00
24 changed files with 731 additions and 6 deletions

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,285 @@
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.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.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, "응시할 수 없는 문항입니다.");
}
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.service.ExamAttemptService;
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 jakarta.validation.Valid;
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.Pageable;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
import tech.jhipster.web.util.HeaderUtil;
@@ -31,6 +40,8 @@ import tech.jhipster.web.util.ResponseUtil;
@RequestMapping("/api/exam-attempts")
public class ExamAttemptResource {
private final QuizAttemptService quizAttemptService;
private static final Logger LOG = LoggerFactory.getLogger(ExamAttemptResource.class);
private static final String ENTITY_NAME = "examAttempt";
@@ -42,9 +53,14 @@ public class ExamAttemptResource {
private final ExamAttemptRepository examAttemptRepository;
public ExamAttemptResource(ExamAttemptService examAttemptService, ExamAttemptRepository examAttemptRepository) {
public ExamAttemptResource(
ExamAttemptService examAttemptService,
ExamAttemptRepository examAttemptRepository,
QuizAttemptService quizAttemptService
) {
this.examAttemptService = examAttemptService;
this.examAttemptRepository = examAttemptRepository;
this.quizAttemptService = quizAttemptService;
}
/**
@@ -185,4 +201,43 @@ public class ExamAttemptResource {
.headers(HeaderUtil.createEntityDeletionAlert(applicationName, true, ENTITY_NAME, id.toString()))
.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()));
}
}
@@ -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) {}
@@ -48,7 +48,7 @@ export default defineComponent({
const initRelationships = () => {
questionService()
.retrieve()
.retrieve({ page: 0, size: 100 })
.then(res => {
questions.value = res.data;
});
@@ -46,7 +46,7 @@ export default defineComponent({
const initRelationships = () => {
subjectService()
.retrieve()
.retrieve({ page: 0, size: 100 })
.then(res => {
subjects.value = res.data;
});
@@ -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>
+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 pages from '@/router/pages';
const QuizView = () => import('@/quiz/quiz.vue');
export const createRouter = () =>
createVueRouter({
history: createWebHistory(),
@@ -28,6 +30,12 @@ export const createRouter = () =>
component: Error,
meta: { error404: true },
},
{
path: '/quiz',
name: 'QuizView',
component: QuizView,
meta: { authorities: ['ROLE_USER'] },
},
...account,
...admin,
entities,
@@ -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());
}
}