diff --git a/app/src/main/resources/db/migration/V21__add_wrong_answer_set.sql b/app/src/main/resources/db/migration/V21__add_wrong_answer_set.sql new file mode 100644 index 00000000..fb8b5d93 --- /dev/null +++ b/app/src/main/resources/db/migration/V21__add_wrong_answer_set.sql @@ -0,0 +1,34 @@ +-- V21: 오답 모아풀기 — 세트 출처 구분, 재출제 혈통, 풀이 완료 시각 +-- +-- problem_set.origin 자료 기반(DOCUMENT) vs 오답 모음(WRONG_ANSWER) 구분. +-- 목록에서 두 종류를 구별하고, 원본 자료를 전제로 하는 후속 동작을 오답 문제집에서 감추는 근거다. +-- problem_set.source_folder_id 어느 폴더에서 모았는지. +-- problem.origin_* 재출제된 문항이 가리키는 최초 조상(세트 id + 문항 번호). 복제할 때 조상 값을 물려받으므로 +-- 세대가 반복돼도 항상 최초 문항을 가리킨다. 같은 문항이 두 번 담기지 않게 하는 중복 제거 키다. +-- quiz_history.completed_at 풀이를 마친 시각. created_at 은 기록 행이 처음 만들어진 시각이라 재풀이해도 갱신되지 않아 +-- "가장 최근에 틀린 순"을 표현할 수 없다. 이 정렬이 상한에서 어떤 문항이 남는지를 결정한다. +-- 기존 완료 행은 created_at 으로 백필해 표시 변화를 만들지 않는다. + +ALTER TABLE problem_set + ADD COLUMN origin VARCHAR(20) NOT NULL DEFAULT 'DOCUMENT', + ADD COLUMN source_folder_id BIGINT NULL; + +ALTER TABLE problem + ADD COLUMN origin_problem_set_id BIGINT NULL, + ADD COLUMN origin_number INT NULL; + +ALTER TABLE quiz_history + ADD COLUMN completed_at DATETIME(6) NULL; + +UPDATE quiz_history +SET completed_at = created_at +WHERE status = 'COMPLETED'; + +-- PII 분류(새 컬럼 커버리지 게이트) — 출처 구분·혈통 참조·완료 시각은 개인정보 아님. +INSERT INTO pii_classification (table_name, column_name, strategy, note) +VALUES ('problem_set', 'origin', 'SAFE', '세트 출처(DOCUMENT/WRONG_ANSWER)'), + ('problem_set', 'source_folder_id', 'SAFE', 'FK→quiz_folder (수집 범위)'), + ('problem', 'origin_problem_set_id', 'SAFE', '재출제 혈통(최초 조상 세트)'), + ('problem', 'origin_number', 'SAFE', '재출제 혈통(최초 조상 문항번호)'), + ('quiz_history', 'completed_at', 'SAFE', '풀이 완료 시각') +ON DUPLICATE KEY UPDATE strategy = VALUES(strategy), note = VALUES(note); diff --git a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/WrongAnswerSetService.java b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/WrongAnswerSetService.java new file mode 100644 index 00000000..9a94c982 --- /dev/null +++ b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/WrongAnswerSetService.java @@ -0,0 +1,14 @@ +package com.icc.qasker.quizhistory; + +import com.icc.qasker.quizhistory.dto.ferequest.CreateWrongAnswerSetRequest; +import com.icc.qasker.quizhistory.dto.feresponse.WrongAnswerSetResponse; + +/** 한 폴더에서 내가 틀린 문항을 모아 유형마다 새 문제집을 만든다. */ +public interface WrongAnswerSetService { + + /** + * @param idempotencyKey 한 번의 사용자 조작을 가리키는 값. 같은 값으로 다시 들어오면 새로 만들지 않고 먼저 만들어진 것을 돌려준다. + */ + WrongAnswerSetResponse createFromFolder( + String userId, CreateWrongAnswerSetRequest request, String idempotencyKey); +} diff --git a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/ferequest/CreateWrongAnswerSetRequest.java b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/ferequest/CreateWrongAnswerSetRequest.java new file mode 100644 index 00000000..a46caa11 --- /dev/null +++ b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/ferequest/CreateWrongAnswerSetRequest.java @@ -0,0 +1,7 @@ +package com.icc.qasker.quizhistory.dto.ferequest; + +import jakarta.validation.constraints.NotBlank; + +/** 오답 모아풀기 요청. 수집 범위는 폴더 하나이며, 요청자는 토큰에서 얻으므로 바디에 담지 않는다. */ +public record CreateWrongAnswerSetRequest( + @NotBlank(message = "folderId가 존재하지 않습니다.") String folderId) {} diff --git a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/HistorySummaryResponse.java b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/HistorySummaryResponse.java index 918084cf..aa6e7d9c 100644 --- a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/HistorySummaryResponse.java +++ b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/HistorySummaryResponse.java @@ -1,5 +1,6 @@ package com.icc.qasker.quizhistory.dto.feresponse; +import com.icc.qasker.quizset.ProblemSetOrigin; import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; import java.time.Instant; @@ -14,4 +15,5 @@ public record HistorySummaryResponse( Integer score, Instant takenAt, String folderId, - String folderName) {} + String folderName, + ProblemSetOrigin origin) {} diff --git a/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/WrongAnswerSetResponse.java b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/WrongAnswerSetResponse.java new file mode 100644 index 00000000..6205dd7b --- /dev/null +++ b/modules/quiz-history/api/src/main/java/com/icc/qasker/quizhistory/dto/feresponse/WrongAnswerSetResponse.java @@ -0,0 +1,45 @@ +package com.icc.qasker.quizhistory.dto.feresponse; + +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import java.util.List; + +/** + * 오답 모아풀기 결과. 모을 오답이 없거나 상한에 걸리거나 일부 유형이 실패해도 실패 응답이 아니다 — 사용자에게 알려야 할 상태이지 요청의 실패가 아니고, 화면 문구는 이 + * 사실들로 프론트가 만든다. + * + * @param createdSets 만들어진 문제집. 유형마다 하나이며, 하나도 없으면 빈 목록이다. + * @param excludedEssayCount 수집에서 빠진 서술형 문항 수. 서술형은 정오답이 규칙으로 갈리지 않아 "틀린 수"가 아니라 "제외된 수"다. + * @param deletedSourceCount 원본 문제집이 이미 지워져 건너뛴 기록 수. + * @param failedTypes 만들다 실패한 유형. 성공한 문제집은 그대로 남는다. + * @param emptyReason 만들어진 문제집이 하나도 없을 때의 사유. 하나라도 있으면 null. + */ +public record WrongAnswerSetResponse( + List createdSets, + int excludedEssayCount, + int deletedSourceCount, + List failedTypes, + EmptyReason emptyReason) { + + /** + * @param truncated 상한에 걸려 일부만 담겼는지. 걸리지 않은 유형은 false다. + */ + public record CreatedSet( + String problemSetId, + String historyId, + QuizType quizType, + String title, + int questionCount, + boolean truncated) {} + + /** 모을 것이 없던 이유. 판정 우선순위는 선언 순서와 같다. */ + public enum EmptyReason { + /** 폴더에 끝까지 푼 기록이 하나도 없다. */ + NO_HISTORY, + /** 수집할 수 있는 기록이 서술형뿐이었다. */ + ESSAY_ONLY, + /** 기록은 있었으나 원본 문제집이 모두 지워졌다. */ + SOURCE_DELETED, + /** 수집 대상은 있었고 틀린 문항이 하나도 없었다. */ + ALL_CORRECT + } +} diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/controller/WrongAnswerSetController.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/controller/WrongAnswerSetController.java new file mode 100644 index 00000000..6c460ec0 --- /dev/null +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/controller/WrongAnswerSetController.java @@ -0,0 +1,49 @@ +package com.icc.qasker.quizhistory.controller; + +import com.icc.qasker.global.annotation.RateLimit; +import com.icc.qasker.global.annotation.UserId; +import com.icc.qasker.global.ratelimit.RateLimitTier; +import com.icc.qasker.quizhistory.WrongAnswerSetService; +import com.icc.qasker.quizhistory.dto.ferequest.CreateWrongAnswerSetRequest; +import com.icc.qasker.quizhistory.dto.feresponse.WrongAnswerSetResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 오답 모아풀기 API. + * + *

모을 오답이 없거나, 상한에 걸려 일부만 담기거나, 일부 유형이 실패해도 200이다 — 사용자에게 알려야 할 상태이지 요청의 실패가 아니고, 오류로 내리면 프론트의 공통 + * 오류 처리가 문구를 가로채 상황에 맞는 안내를 할 수 없다. + */ +@Tag(name = "ProblemSet", description = "문제세트 관련 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/problem-set") +public class WrongAnswerSetController { + + private final WrongAnswerSetService wrongAnswerSetService; + + @Operation(summary = "현재 폴더에서 틀린 문항을 모아 유형별로 새 문제집을 만든다") + @RateLimit(RateLimitTier.WRITE) + @PostMapping("/wrong-answers") + public ResponseEntity createWrongAnswerSets( + @UserId String userId, + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey, + @Valid @RequestBody CreateWrongAnswerSetRequest request) { + // 키가 없으면 멱등 보장 없이 매번 새로 만든다. 연타·재시도를 흡수하려면 한 번의 조작에 같은 키를 보내야 한다. + String key = + (idempotencyKey == null || idempotencyKey.isBlank()) + ? UUID.randomUUID().toString() + : idempotencyKey; + return ResponseEntity.ok(wrongAnswerSetService.createFromFolder(userId, request, key)); + } +} diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/entity/QuizHistory.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/entity/QuizHistory.java index f8279002..47ce05ce 100644 --- a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/entity/QuizHistory.java +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/entity/QuizHistory.java @@ -10,6 +10,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Table; import jakarta.persistence.UniqueConstraint; +import java.time.Instant; import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -55,6 +56,9 @@ public class QuizHistory extends CreatedAt { @Column private Integer score; + /** 풀이를 마친 시각. createdAt 은 기록 행이 처음 만들어진 시각이라 재풀이해도 갱신되지 않아 "가장 최근에 푼 순"을 표현하지 못한다. 미완료면 null. */ + @Column private Instant completedAt; + private String totalTime; @Enumerated(EnumType.STRING) @@ -76,6 +80,7 @@ public void completeQuiz(List answers, Integer score, String tot this.score = score; this.totalTime = totalTime; this.status = QuizHistoryStatus.COMPLETED; + this.completedAt = Instant.now(); } public enum QuizHistoryStatus { diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/grading/AnswerJudge.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/grading/AnswerJudge.java new file mode 100644 index 00000000..a07e1fcf --- /dev/null +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/grading/AnswerJudge.java @@ -0,0 +1,46 @@ +package com.icc.qasker.quizhistory.grading; + +import com.icc.qasker.quizhistory.entity.AnswerSnapshotView; +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import com.icc.qasker.quizset.dto.readonly.ProblemDetail; +import com.icc.qasker.quizset.dto.readonly.SelectionDetail; +import com.icc.qasker.quizset.grading.RealBlankGrader; +import java.util.List; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 저장된 답안 스냅샷으로 문항별 정오답을 되짚는 판정기(SSOT). 정오답은 저장되지 않고(기록에는 답안과 총점만 남는다) 조회 시마다 다시 판정하므로, 기록 상세와 오답 + * 수집이 같은 함수를 거쳐 동일한 답을 얻어야 한다. + * + *

ESSAY는 규칙으로 정오답이 갈리지 않아(AI 채점 점수만 있다) 여기서 다루지 않는다. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class AnswerJudge { + + /** 정답 선택지의 1-based 위치. 정답 선택지가 없으면 어떤 답과도 같지 않도록 -1. */ + public static int correctIndex(List selections) { + if (selections == null) { + return -1; + } + for (int i = 0; i < selections.size(); i++) { + if (selections.get(i).correct()) { + return i + 1; + } + } + return -1; + } + + /** + * REAL_BLANK는 텍스트 정규화 + 인정 집합 멤버십으로, 그 외 유형은 정답 인덱스와 사용자 답을 비교해 판정한다. 미응답(userAnswer=0)은 어떤 정답 + * 인덱스와도 같지 않아 오답이 된다. + */ + public static boolean isCorrect( + QuizType quizType, ProblemDetail problem, AnswerSnapshotView answers) { + if (quizType == QuizType.REAL_BLANK) { + return RealBlankGrader.grade(problem.selections(), answers.textAnswer(problem.number())) + .isCorrect(); + } + return answers.userAnswer(problem.number()) == correctIndex(problem.selections()); + } +} diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/mapper/QuizHistoryMapper.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/mapper/QuizHistoryMapper.java index 5711c4ab..465395ce 100644 --- a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/mapper/QuizHistoryMapper.java +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/mapper/QuizHistoryMapper.java @@ -10,6 +10,7 @@ import com.icc.qasker.quizhistory.entity.AnswerSnapshotView; import com.icc.qasker.quizhistory.entity.EssayGradeLog; import com.icc.qasker.quizhistory.entity.QuizHistory; +import com.icc.qasker.quizhistory.grading.AnswerJudge; import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; import com.icc.qasker.quizset.dto.feresponse.Selection; import com.icc.qasker.quizset.dto.readonly.ProblemDetail; @@ -28,7 +29,12 @@ public final class QuizHistoryMapper { private final HashUtil hashUtil; - /** QuizHistory + ProblemSetSummary → HistorySummaryResponse 변환. folderName은 미분류면 null. */ + /** + * QuizHistory + ProblemSetSummary → HistorySummaryResponse 변환. folderName은 미분류면 null. + * + *

{@code takenAt}은 완료 시각이다. 오답 모아풀기가 미완료 기록을 폴더에 만들기 때문에, 기록 생성 시각을 쓰면 아직 풀지도 않은 문제집에 완료일이 + * 찍힌다. 완료하지 않은 기록은 완료 시각이 없으므로 null이다. + */ public HistorySummaryResponse toSummary( QuizHistory history, ProblemSetSummary problemSet, String folderName) { boolean completed = @@ -46,9 +52,10 @@ public HistorySummaryResponse toSummary( problemSet.totalQuizCount(), completed, history.getScore(), - history.getCreatedAt(), + history.getCompletedAt(), folderId, - folderName); + folderName, + problemSet.origin()); } /** @@ -61,9 +68,8 @@ public ProblemWithAnswer toProblemWithAnswer( return toRealBlankProblemWithAnswer(problem, answers); } List rawSelections = problem.selections(); - int correctIndex = findCorrectIndex(rawSelections); int userAnswer = answers.userAnswer(problem.number()); - boolean correct = userAnswer == correctIndex; + boolean correct = AnswerJudge.isCorrect(quizType, problem, answers); List selections = IntStream.range(0, rawSelections.size()) .mapToObj( @@ -131,13 +137,4 @@ private GradeResult toGradeResult(EssayGradeLog gradeLog) { gradeLog.getOverallFeedback(), elementScores); } - - private int findCorrectIndex(List selections) { - for (int i = 0; i < selections.size(); i++) { - if (selections.get(i).correct()) { - return i + 1; - } - } - return -1; - } } diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/repository/QuizHistoryRepository.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/repository/QuizHistoryRepository.java index 7f0249fd..8810b92f 100644 --- a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/repository/QuizHistoryRepository.java +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/repository/QuizHistoryRepository.java @@ -27,6 +27,10 @@ Page findAllByUserIdAndFolderIdIsNullOrderByCreatedAtDesc( Optional findByUserIdAndProblemSetId(String userId, Long problemSetId); + /** 폴더 하나에 든 내 기록. 오답 모아풀기의 수집 범위가 이 한 줄로 닫힌다(폴더 밖·타인 기록이 섞일 자리가 없다). */ + List findAllByUserIdAndFolderIdAndStatusOrderByCreatedAtDesc( + String userId, Long folderId, QuizHistory.QuizHistoryStatus status); + /** 사용자의 폴더별 기록 수(미분류 제외). */ @Query( "SELECT h.folderId AS folderId, COUNT(h) AS count FROM QuizHistory h" diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/wronganswer/QuizTypeLabel.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/wronganswer/QuizTypeLabel.java new file mode 100644 index 00000000..31c3d473 --- /dev/null +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/wronganswer/QuizTypeLabel.java @@ -0,0 +1,23 @@ +package com.icc.qasker.quizhistory.service.wronganswer; + +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 문제집 제목에 쓰는 유형 이름. 사용자가 문제를 만들 때 옵션 화면에서 고른 바로 그 단어를 쓴다 — 목록의 제목과 화면의 유형 표기가 다른 말을 하지 않게 하려는 것이다. + * 두 빈칸 유형이 같은 이름을 쓰면 유형별로 나뉘어 만들어진 문제집을 서로 구별할 수 없다. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +final class QuizTypeLabel { + + static String of(QuizType quizType) { + return switch (quizType) { + case MULTIPLE -> "객관식"; + case OX -> "OX 퀴즈"; + case BLANK -> "빈칸 넣기"; + case REAL_BLANK -> "빈칸 직접입력"; + case ESSAY -> "서술형"; + }; + } +} diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/wronganswer/WrongAnswerCollector.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/wronganswer/WrongAnswerCollector.java new file mode 100644 index 00000000..c67e5cf9 --- /dev/null +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/wronganswer/WrongAnswerCollector.java @@ -0,0 +1,102 @@ +package com.icc.qasker.quizhistory.service.wronganswer; + +import com.icc.qasker.quizhistory.entity.AnswerSnapshotView; +import com.icc.qasker.quizhistory.grading.AnswerJudge; +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import com.icc.qasker.quizset.dto.readonly.ProblemDetail; +import com.icc.qasker.quizset.dto.readonly.ProblemLineage; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * 풀이 기록에서 틀린 문항을 골라 유형별로 나눈다. 부수효과가 없어 규칙 자체를 단위 테스트로 고정할 수 있다. + * + *

순서가 규칙의 일부다 — 최근에 푼 순으로 정렬한 뒤 중복을 걷어내고 마지막에 상한으로 자른다. 정렬보다 중복 제거가 앞서면 같은 문항의 오래된 쪽이 남아 + * 상한에서 최신 오답이 밀려난다. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class WrongAnswerCollector { + + /** 문제집 하나에 담기는 문항 수 상한. 유형마다 따로 적용되므로 한 번의 실행이 만드는 총 문항 수는 이보다 많을 수 있다. */ + public static final int MAX_QUESTIONS_PER_SET = 100; + + /** + * 수집 대상이 된 기록 하나. + * + * @param solvedAt 풀이를 마친 시각. 최근에 틀린 순을 정하는 기준이다. + */ + public record AnsweredSet( + Long problemSetId, + QuizType quizType, + Instant solvedAt, + List problems, + AnswerSnapshotView answers) {} + + /** + * 한 유형에서 만들어질 문제집. + * + * @param sources 복제할 원본 문항의 좌표. 이 순서가 새 문제집의 문항 순서가 된다. + * @param truncated 상한에 걸려 일부만 담겼는지. + */ + public record TypeGroup(QuizType quizType, List sources, boolean truncated) {} + + /** 유형별 문제집 구성을 만든다. 틀린 문항이 없는 유형은 결과에 들어가지 않는다. */ + public static List collect(List answeredSets) { + List candidates = new ArrayList<>(); + for (AnsweredSet set : answeredSets) { + for (ProblemDetail problem : set.problems()) { + if (AnswerJudge.isCorrect(set.quizType(), problem, set.answers())) { + continue; + } + candidates.add( + new Candidate( + set.quizType(), + new ProblemLineage(set.problemSetId(), problem.number()), + problem.lineage(set.problemSetId()), + set.solvedAt(), + problem.number())); + } + } + + candidates.sort( + Comparator.comparing(Candidate::solvedAt, Comparator.reverseOrder()) + .thenComparingInt(Candidate::number)); + + Map> byType = new LinkedHashMap<>(); + Set seen = new HashSet<>(); + for (Candidate candidate : candidates) { + // 같은 문항이 여러 문제집에 걸쳐 있어도(세대가 반복돼도) 한 번만 담는다. 정렬이 끝난 뒤라 먼저 만난 쪽이 더 최근이다. + if (!seen.add(candidate.lineage())) { + continue; + } + byType.computeIfAbsent(candidate.quizType(), type -> new ArrayList<>()).add(candidate); + } + + return byType.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .map(entry -> toGroup(entry.getKey(), entry.getValue())) + .toList(); + } + + private static TypeGroup toGroup(QuizType quizType, List candidates) { + boolean truncated = candidates.size() > MAX_QUESTIONS_PER_SET; + List sources = + candidates.stream().limit(MAX_QUESTIONS_PER_SET).map(Candidate::source).toList(); + return new TypeGroup(quizType, sources, truncated); + } + + private record Candidate( + QuizType quizType, + ProblemLineage source, + ProblemLineage lineage, + Instant solvedAt, + int number) {} +} diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/wronganswer/WrongAnswerSetFactory.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/wronganswer/WrongAnswerSetFactory.java new file mode 100644 index 00000000..a5eab40c --- /dev/null +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/wronganswer/WrongAnswerSetFactory.java @@ -0,0 +1,99 @@ +package com.icc.qasker.quizhistory.service.wronganswer; + +import com.icc.qasker.global.component.HashUtil; +import com.icc.qasker.quizhistory.dto.feresponse.WrongAnswerSetResponse.CreatedSet; +import com.icc.qasker.quizhistory.entity.QuizHistory; +import com.icc.qasker.quizhistory.repository.QuizHistoryRepository; +import com.icc.qasker.quizset.WrongAnswerSetCreationService; +import com.icc.qasker.quizset.dto.WrongAnswerSetCreation; +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import com.icc.qasker.quizset.dto.readonly.ProblemLineage; +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** + * 유형 하나 몫의 문제집을 만든다. 유형마다 별도의 트랜잭션이라, 한 유형이 실패해도 이미 만들어진 다른 유형은 그대로 남고 실패한 유형은 반쯤 만들어진 채 남지 + * 않는다. + * + *

문제집과 함께 풀이 기록(미완료)을 출처 폴더에 만든다. 이 레포에서 폴더는 문제집이 아니라 풀이 기록에 붙고 사용자 목록도 기록을 훑으므로, 기록이 없으면 만들어진 + * 문제집이 목록에도 폴더에도 나타나지 않는다. + */ +@Component +@RequiredArgsConstructor +public class WrongAnswerSetFactory { + + // 사용자에게 보이는 제목이라 서비스 사용자의 시간대로 찍는다(서버 시간대에 좌우되지 않게 고정). + private static final ZoneId TITLE_ZONE = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter TITLE_TIME = DateTimeFormatter.ofPattern("MM/dd HH:mm"); + private static final String TITLE_PREFIX = "오답 모음"; + + private final WrongAnswerSetCreationService creationService; + private final QuizHistoryRepository quizHistoryRepository; + private final HashUtil hashUtil; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public CreatedSet create( + String userId, + Long folderId, + String sessionId, + QuizType quizType, + List sources, + boolean truncated, + Instant now) { + String title = title(quizType, now); + Long problemSetId = + creationService.create( + new WrongAnswerSetCreation(userId, sessionId, title, quizType, folderId, sources)); + QuizHistory history = saveHistory(userId, folderId, problemSetId, title); + return new CreatedSet( + hashUtil.encode(problemSetId), + hashUtil.encode(history.getId()), + quizType, + title, + sources.size(), + truncated); + } + + /** 이미 만들어져 있던 세트를 그대로 돌려준다(같은 요청이 두 번 들어온 경우). 기록 행도 이미 있으므로 찾아 쓴다. */ + @Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true) + public CreatedSet existing( + String userId, Long problemSetId, QuizType quizType, String title, int questionCount) { + String historyId = + quizHistoryRepository + .findByUserIdAndProblemSetId(userId, problemSetId) + .map(h -> hashUtil.encode(h.getId())) + .orElse(null); + return new CreatedSet( + hashUtil.encode(problemSetId), + historyId, + quizType, + title, + questionCount, + // 이미 만들어진 세트는 몇 문항이 잘렸는지 남겨 두지 않는다. 안내는 처음 만든 응답에서 이미 전달됐다. + false); + } + + private QuizHistory saveHistory(String userId, Long folderId, Long problemSetId, String title) { + return quizHistoryRepository.save( + QuizHistory.builder() + .userId(userId) + .problemSetId(problemSetId) + .folderId(folderId) + .title(title) + .build()); + } + + private String title(QuizType quizType, Instant now) { + return TITLE_PREFIX + + " · " + + QuizTypeLabel.of(quizType) + + " · " + + TITLE_TIME.format(now.atZone(TITLE_ZONE)); + } +} diff --git a/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/wronganswer/WrongAnswerSetServiceImpl.java b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/wronganswer/WrongAnswerSetServiceImpl.java new file mode 100644 index 00000000..1d8d4e0a --- /dev/null +++ b/modules/quiz-history/impl/src/main/java/com/icc/qasker/quizhistory/service/wronganswer/WrongAnswerSetServiceImpl.java @@ -0,0 +1,189 @@ +package com.icc.qasker.quizhistory.service.wronganswer; + +import com.icc.qasker.global.component.HashUtil; +import com.icc.qasker.global.error.CustomException; +import com.icc.qasker.global.error.ExceptionMessage; +import com.icc.qasker.quizhistory.WrongAnswerSetService; +import com.icc.qasker.quizhistory.dto.ferequest.CreateWrongAnswerSetRequest; +import com.icc.qasker.quizhistory.dto.feresponse.WrongAnswerSetResponse; +import com.icc.qasker.quizhistory.dto.feresponse.WrongAnswerSetResponse.CreatedSet; +import com.icc.qasker.quizhistory.dto.feresponse.WrongAnswerSetResponse.EmptyReason; +import com.icc.qasker.quizhistory.entity.AnswerSnapshotView; +import com.icc.qasker.quizhistory.entity.QuizFolder; +import com.icc.qasker.quizhistory.entity.QuizHistory; +import com.icc.qasker.quizhistory.entity.QuizHistory.QuizHistoryStatus; +import com.icc.qasker.quizhistory.repository.QuizFolderRepository; +import com.icc.qasker.quizhistory.repository.QuizHistoryRepository; +import com.icc.qasker.quizhistory.service.wronganswer.WrongAnswerCollector.AnsweredSet; +import com.icc.qasker.quizhistory.service.wronganswer.WrongAnswerCollector.TypeGroup; +import com.icc.qasker.quizset.ProblemSetReadService; +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import com.icc.qasker.quizset.dto.readonly.ProblemSetSummary; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; + +/** + * 폴더 하나에서 내가 틀린 문항을 모아 유형마다 새 문제집을 만든다. + * + *

메서드-레벨 트랜잭션을 두지 않는 것이 핵심이다 — 유형마다 {@link WrongAnswerSetFactory}가 자기 트랜잭션을 열어 하나가 실패해도 나머지가 + * 살아남고, 같은 요청이 두 번 들어와 세션 식별자가 충돌했을 때 그 예외를 여기서 잡아 이미 만들어진 것을 돌려줄 수 있다(트랜잭션 안에서 잡으면 롤백 표시로 오염돼 이어지는 + * 재조회가 실패한다). + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class WrongAnswerSetServiceImpl implements WrongAnswerSetService { + + private final QuizFolderRepository quizFolderRepository; + private final QuizHistoryRepository quizHistoryRepository; + private final ProblemSetReadService problemSetReadService; + private final WrongAnswerSetFactory factory; + private final HashUtil hashUtil; + + @Override + public WrongAnswerSetResponse createFromFolder( + String userId, CreateWrongAnswerSetRequest request, String idempotencyKey) { + QuizFolder folder = + quizFolderRepository + .findByIdAndUserId(hashUtil.decode(request.folderId()), userId) + .orElseThrow(() -> new CustomException(ExceptionMessage.FOLDER_NOT_FOUND)); + + Scope scope = scope(userId, folder.getId()); + List groups = WrongAnswerCollector.collect(scope.answeredSets()); + + List createdSets = new ArrayList<>(); + List failedTypes = new ArrayList<>(); + Instant now = Instant.now(); + for (TypeGroup group : groups) { + createOne(userId, folder.getId(), idempotencyKey, group, now) + .ifPresentOrElse(createdSets::add, () -> failedTypes.add(group.quizType())); + } + + return new WrongAnswerSetResponse( + createdSets, + scope.excludedEssayCount(), + scope.deletedSourceCount(), + failedTypes, + emptyReason(createdSets, scope)); + } + + /** 폴더 ∩ 내 기록으로 범위를 닫고, 그 안에서 수집 대상과 제외 사유를 함께 센다. */ + private Scope scope(String userId, Long folderId) { + List histories = + quizHistoryRepository + .findAllByUserIdAndFolderIdAndStatusOrderByCreatedAtDesc( + userId, folderId, QuizHistoryStatus.COMPLETED) + .stream() + .filter(history -> history.getAnswers() != null && !history.getAnswers().isEmpty()) + .toList(); + + Map setById = + problemSetReadService + .findProblemSetsByIds( + histories.stream().map(QuizHistory::getProblemSetId).distinct().toList()) + .stream() + .collect(Collectors.toMap(ProblemSetSummary::id, Function.identity())); + + List answeredSets = new ArrayList<>(); + int excludedEssayCount = 0; + int deletedSourceCount = 0; + for (QuizHistory history : histories) { + ProblemSetSummary summary = setById.get(history.getProblemSetId()); + if (summary == null) { + // 원본 문제집이 이미 지워졌다. 그 기록만 건너뛰고 나머지로 구성한다. + deletedSourceCount++; + continue; + } + if (summary.quizType() == QuizType.ESSAY) { + // 서술형은 정오답이 규칙으로 갈리지 않는다. 틀린 수를 세면 없던 채점 기준을 만드는 셈이라 "제외된 문항 수"만 센다. + excludedEssayCount += history.getAnswers().size(); + continue; + } + answeredSets.add( + new AnsweredSet( + history.getProblemSetId(), + summary.quizType(), + solvedAt(history), + problemSetReadService.findProblemsByProblemSetId(history.getProblemSetId()), + AnswerSnapshotView.from(history.getAnswers()))); + } + return new Scope(answeredSets, histories.size(), excludedEssayCount, deletedSourceCount); + } + + /** 완료 시각이 없는 기록(컬럼 도입 전 데이터)은 기록 생성 시각으로 대신한다. */ + private static Instant solvedAt(QuizHistory history) { + return history.getCompletedAt() == null ? history.getCreatedAt() : history.getCompletedAt(); + } + + /** + * 유형 하나를 만든다. 세션 식별자가 요청 단위로 결정론이라, 같은 요청이 두 번 들어오면 두 번째는 유니크 제약에 걸린다 — 그때는 새로 만들지 않고 먼저 만들어진 것을 + * 돌려줘 연타나 재시도가 목록을 어지럽히지 않게 한다. + */ + private Optional createOne( + String userId, Long folderId, String idempotencyKey, TypeGroup group, Instant now) { + String sessionId = sessionId(idempotencyKey, group.quizType()); + try { + return Optional.of( + factory.create( + userId, + folderId, + sessionId, + group.quizType(), + group.sources(), + group.truncated(), + now)); + } catch (DataIntegrityViolationException e) { + log.info("[오답 모아풀기 멱등] 같은 요청 재수신 — 기존 문제집 반환 sessionId={}", sessionId); + return problemSetReadService + .findProblemSetBySessionId(sessionId) + .map( + summary -> + factory.existing( + userId, + summary.id(), + summary.quizType(), + summary.title(), + summary.totalQuizCount())); + } catch (RuntimeException e) { + log.error("[오답 모아풀기 실패] 유형 하나를 만들지 못했다 quizType={}", group.quizType(), e); + return Optional.empty(); + } + } + + private static String sessionId(String idempotencyKey, QuizType quizType) { + return "wa-" + idempotencyKey + "-" + quizType.name(); + } + + private static EmptyReason emptyReason(List createdSets, Scope scope) { + if (!createdSets.isEmpty()) { + return null; + } + if (scope.answeredHistoryCount() == 0) { + return EmptyReason.NO_HISTORY; + } + if (scope.answeredSets().isEmpty()) { + return scope.excludedEssayCount() > 0 ? EmptyReason.ESSAY_ONLY : EmptyReason.SOURCE_DELETED; + } + return EmptyReason.ALL_CORRECT; + } + + /** + * 수집 범위를 훑은 결과. + * + * @param answeredHistoryCount 답안이 남아 있는 기록 수. 0이면 이 폴더에서 푼 것이 없다는 뜻이다. + */ + private record Scope( + List answeredSets, + int answeredHistoryCount, + int excludedEssayCount, + int deletedSourceCount) {} +} diff --git a/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/wronganswer/WrongAnswerCollectorTest.java b/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/wronganswer/WrongAnswerCollectorTest.java new file mode 100644 index 00000000..db572f16 --- /dev/null +++ b/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/wronganswer/WrongAnswerCollectorTest.java @@ -0,0 +1,287 @@ +package com.icc.qasker.quizhistory.service.wronganswer; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.icc.qasker.quizhistory.entity.AnswerSnapshot; +import com.icc.qasker.quizhistory.entity.AnswerSnapshotView; +import com.icc.qasker.quizhistory.service.wronganswer.WrongAnswerCollector.AnsweredSet; +import com.icc.qasker.quizhistory.service.wronganswer.WrongAnswerCollector.TypeGroup; +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import com.icc.qasker.quizset.dto.readonly.ProblemDetail; +import com.icc.qasker.quizset.dto.readonly.ProblemLineage; +import com.icc.qasker.quizset.dto.readonly.SelectionDetail; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.IntStream; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** 오답 수집 규칙을 고정한다. 문제집당 상한·"가장 최근에 틀린 순" 우선순위·중복 제거는 이 테스트가 유일한 검증처다(기능 E2E는 100문항 시드를 만들지 않는다). */ +class WrongAnswerCollectorTest { + + private static final Instant OLD = Instant.parse("2026-01-01T00:00:00Z"); + private static final Instant RECENT = Instant.parse("2026-06-01T00:00:00Z"); + + /** 2번 선택지가 정답인 4지선다. */ + private static ProblemDetail problem(int number) { + return new ProblemDetail( + number, + "문항 " + number, + List.of( + new SelectionDetail("1번", false), + new SelectionDetail("2번", true), + new SelectionDetail("3번", false), + new SelectionDetail("4번", false)), + "해설"); + } + + /** 재출제된 문항 — 최초 조상을 가리킨다. */ + private static ProblemDetail clonedProblem(int number, long ancestorSetId, int ancestorNumber) { + ProblemDetail origin = problem(number); + return new ProblemDetail( + number, + origin.title(), + origin.selections(), + origin.explanationContent(), + ancestorSetId, + ancestorNumber); + } + + private static AnswerSnapshotView answers(List snapshots) { + return AnswerSnapshotView.from(snapshots); + } + + /** 지정한 번호만 틀리게 답한 스냅샷(정답은 2번). */ + private static AnswerSnapshotView wrongOn(List wrongNumbers, List allNumbers) { + List snapshots = new ArrayList<>(); + for (int number : allNumbers) { + int given = wrongNumbers.contains(number) ? 3 : 2; + snapshots.add(new AnswerSnapshot(number, given, false, null)); + } + return answers(snapshots); + } + + @Nested + @DisplayName("무엇을 틀린 것으로 보는가") + class Judging { + + @Test + @DisplayName("맞힌 문항은 담기지 않는다") + void skipsCorrect() { + AnsweredSet set = + new AnsweredSet( + 1L, + QuizType.MULTIPLE, + RECENT, + List.of(problem(1), problem(2)), + wrongOn(List.of(2), List.of(1, 2))); + + List groups = WrongAnswerCollector.collect(List.of(set)); + + assertThat(groups).hasSize(1); + assertThat(groups.getFirst().sources()).containsExactly(new ProblemLineage(1L, 2)); + } + + @Test + @DisplayName("답을 내지 않은 문항은 오답으로 담긴다") + void unansweredIsWrong() { + AnsweredSet set = + new AnsweredSet( + 1L, + QuizType.MULTIPLE, + RECENT, + List.of(problem(1)), + answers(List.of(new AnswerSnapshot(1, 0, false, null)))); + + List groups = WrongAnswerCollector.collect(List.of(set)); + + assertThat(groups.getFirst().sources()).containsExactly(new ProblemLineage(1L, 1)); + } + + @Test + @DisplayName("빈칸 직접입력은 표기가 흔들려도 인정 표현에 들면 오답이 아니다") + void realBlankUsesGrader() { + ProblemDetail blank = + new ProblemDetail( + 1, + "빈칸", + List.of(new SelectionDetail("정답", true, List.of(List.of("정답", "answer")))), + "해설"); + AnsweredSet correct = + new AnsweredSet( + 1L, + QuizType.REAL_BLANK, + RECENT, + List.of(blank), + answers(List.of(new AnswerSnapshot(1, 0, false, " 정답. ")))); + AnsweredSet wrong = + new AnsweredSet( + 2L, + QuizType.REAL_BLANK, + RECENT, + List.of(blank), + answers(List.of(new AnswerSnapshot(1, 0, false, "엉뚱한 말")))); + + assertThat(WrongAnswerCollector.collect(List.of(correct))).isEmpty(); + assertThat(WrongAnswerCollector.collect(List.of(wrong))).hasSize(1); + } + } + + @Nested + @DisplayName("유형별로 나눈다 (SC-004a)") + class Splitting { + + @Test + @DisplayName("유형이 섞이면 유형마다 하나씩 만들고, 한 문제집에는 한 유형만 담긴다") + void splitsByType() { + AnsweredSet multiple = + new AnsweredSet( + 1L, QuizType.MULTIPLE, RECENT, List.of(problem(1)), wrongOn(List.of(1), List.of(1))); + AnsweredSet ox = + new AnsweredSet( + 2L, QuizType.OX, RECENT, List.of(problem(1)), wrongOn(List.of(1), List.of(1))); + + List groups = WrongAnswerCollector.collect(List.of(multiple, ox)); + + assertThat(groups).hasSize(2); + assertThat(groups) + .extracting(TypeGroup::quizType) + .containsExactly(QuizType.MULTIPLE, QuizType.OX); + assertThat(groups).allSatisfy(group -> assertThat(group.sources()).hasSize(1)); + } + + @Test + @DisplayName("틀린 문항이 없는 유형은 만들지 않는다") + void skipsTypeWithoutWrongAnswers() { + AnsweredSet allCorrect = + new AnsweredSet( + 1L, QuizType.OX, RECENT, List.of(problem(1)), wrongOn(List.of(), List.of(1))); + + assertThat(WrongAnswerCollector.collect(List.of(allCorrect))).isEmpty(); + } + } + + @Nested + @DisplayName("중복을 걷어낸다 (FR-005)") + class Deduplication { + + @Test + @DisplayName("여러 문제집에 걸쳐 같은 조상을 가진 문항은 한 번만 담긴다") + void dedupesByLineage() { + AnsweredSet origin = + new AnsweredSet( + 1L, QuizType.MULTIPLE, OLD, List.of(problem(7)), wrongOn(List.of(7), List.of(7))); + // 1번 세트 7번 문항을 재출제한 오답 문제집. 다시 풀어 또 틀렸다. + AnsweredSet regenerated = + new AnsweredSet( + 2L, + QuizType.MULTIPLE, + RECENT, + List.of(clonedProblem(1, 1L, 7)), + wrongOn(List.of(1), List.of(1))); + + List groups = WrongAnswerCollector.collect(List.of(origin, regenerated)); + + assertThat(groups.getFirst().sources()).hasSize(1); + } + + @Test + @DisplayName("중복 중 남는 것은 더 최근에 푼 쪽이다 — 정렬이 중복 제거보다 앞선다") + void keepsMostRecentDuplicate() { + AnsweredSet older = + new AnsweredSet( + 1L, QuizType.MULTIPLE, OLD, List.of(problem(7)), wrongOn(List.of(7), List.of(7))); + AnsweredSet newer = + new AnsweredSet( + 2L, + QuizType.MULTIPLE, + RECENT, + List.of(clonedProblem(1, 1L, 7)), + wrongOn(List.of(1), List.of(1))); + + List groups = WrongAnswerCollector.collect(List.of(older, newer)); + + // 오래된 쪽이 남으면 상한에서 최신 오답이 밀려난다. 최근에 푼 2번 세트의 문항이 남아야 한다. + assertThat(groups.getFirst().sources()).containsExactly(new ProblemLineage(2L, 1)); + } + } + + @Nested + @DisplayName("상한을 지킨다 (FR-006 · SC-004)") + class Capping { + + private AnsweredSet setOf(long problemSetId, Instant solvedAt, int count) { + List numbers = IntStream.rangeClosed(1, count).boxed().toList(); + List problems = + numbers.stream().map(WrongAnswerCollectorTest::problem).toList(); + return new AnsweredSet( + problemSetId, QuizType.MULTIPLE, solvedAt, problems, wrongOn(numbers, numbers)); + } + + @Test + @DisplayName("101문항이 모이면 100개만 담고 잘렸음을 알린다") + void capsAtHundred() { + List groups = WrongAnswerCollector.collect(List.of(setOf(1L, RECENT, 101))); + + TypeGroup group = groups.getFirst(); + assertThat(group.sources()).hasSize(WrongAnswerCollector.MAX_QUESTIONS_PER_SET); + assertThat(group.truncated()).isTrue(); + } + + @Test + @DisplayName("상한에 걸리지 않으면 잘렸다고 하지 않는다") + void notTruncatedUnderCap() { + List groups = WrongAnswerCollector.collect(List.of(setOf(1L, RECENT, 100))); + + assertThat(groups.getFirst().sources()).hasSize(100); + assertThat(groups.getFirst().truncated()).isFalse(); + } + + @Test + @DisplayName("잘릴 때 남는 것은 가장 최근에 틀린 문항이다") + void keepsMostRecentWhenTruncated() { + // 오래 전에 푼 100문항 + 최근에 푼 1문항 → 최근 것이 반드시 살아남아야 한다. + List groups = + WrongAnswerCollector.collect(List.of(setOf(1L, OLD, 100), setOf(2L, RECENT, 1))); + + TypeGroup group = groups.getFirst(); + assertThat(group.sources()).hasSize(100); + assertThat(group.sources().getFirst()).isEqualTo(new ProblemLineage(2L, 1)); + assertThat(group.truncated()).isTrue(); + } + + @Test + @DisplayName("푼 시각이 모두 같으면 문항 번호가 앞선 것부터 남는다") + void breaksTieByQuestionNumber() { + // 완료 시각 컬럼이 생기기 전 기록은 백필로 전부 같은 값이 될 수 있다. 그 구간에서 무엇이 잘려나갈지를 + // 정하는 것은 문항 번호뿐이므로, 상한과 맞물리는 이 순서를 고정해 둔다. + List groups = WrongAnswerCollector.collect(List.of(setOf(1L, OLD, 101))); + + List sources = groups.getFirst().sources(); + assertThat(sources.getFirst()).isEqualTo(new ProblemLineage(1L, 1)); + assertThat(sources.getLast()).isEqualTo(new ProblemLineage(1L, 100)); + // 잘려나간 것은 번호가 가장 뒤인 101번이다. + assertThat(sources).doesNotContain(new ProblemLineage(1L, 101)); + } + + @Test + @DisplayName("상한은 문제집 하나마다 적용된다 — 한 유형이 잘려도 다른 유형은 영향받지 않는다") + void capIsPerType() { + List numbers = IntStream.rangeClosed(1, 101).boxed().toList(); + AnsweredSet multiple = setOf(1L, RECENT, 101); + AnsweredSet ox = + new AnsweredSet( + 2L, QuizType.OX, RECENT, List.of(problem(1)), wrongOn(List.of(1), List.of(1))); + + List groups = WrongAnswerCollector.collect(List.of(multiple, ox)); + + assertThat(numbers).hasSize(101); + assertThat(groups).hasSize(2); + assertThat(groups.getFirst().truncated()).isTrue(); + assertThat(groups.getLast().sources()).hasSize(1); + assertThat(groups.getLast().truncated()).isFalse(); + } + } +} diff --git a/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/wronganswer/WrongAnswerSetServiceImplTest.java b/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/wronganswer/WrongAnswerSetServiceImplTest.java new file mode 100644 index 00000000..7daa277b --- /dev/null +++ b/modules/quiz-history/impl/src/test/java/com/icc/qasker/quizhistory/service/wronganswer/WrongAnswerSetServiceImplTest.java @@ -0,0 +1,284 @@ +package com.icc.qasker.quizhistory.service.wronganswer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.icc.qasker.global.component.HashUtil; +import com.icc.qasker.global.error.CustomException; +import com.icc.qasker.quizhistory.dto.ferequest.CreateWrongAnswerSetRequest; +import com.icc.qasker.quizhistory.dto.feresponse.WrongAnswerSetResponse; +import com.icc.qasker.quizhistory.dto.feresponse.WrongAnswerSetResponse.CreatedSet; +import com.icc.qasker.quizhistory.dto.feresponse.WrongAnswerSetResponse.EmptyReason; +import com.icc.qasker.quizhistory.entity.AnswerSnapshot; +import com.icc.qasker.quizhistory.entity.QuizFolder; +import com.icc.qasker.quizhistory.entity.QuizHistory; +import com.icc.qasker.quizhistory.entity.QuizHistory.QuizHistoryStatus; +import com.icc.qasker.quizhistory.repository.QuizFolderRepository; +import com.icc.qasker.quizhistory.repository.QuizHistoryRepository; +import com.icc.qasker.quizset.ProblemSetReadService; +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import com.icc.qasker.quizset.dto.readonly.ProblemDetail; +import com.icc.qasker.quizset.dto.readonly.ProblemSetSummary; +import com.icc.qasker.quizset.dto.readonly.SelectionDetail; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +/** 수집 범위 격리(SC-003)·서술형 제외·빈 결과 사유·부분 실패 격리·멱등 동작을 고정한다. */ +@ExtendWith(MockitoExtension.class) +class WrongAnswerSetServiceImplTest { + + private static final String USER = "me"; + private static final String FOLDER_HASH = "FOLDER"; + private static final long FOLDER_ID = 42L; + private static final String KEY = "idem-key"; + + @Mock private QuizFolderRepository quizFolderRepository; + @Mock private QuizHistoryRepository quizHistoryRepository; + @Mock private ProblemSetReadService problemSetReadService; + @Mock private WrongAnswerSetFactory factory; + @Mock private HashUtil hashUtil; + + private WrongAnswerSetServiceImpl service; + + @BeforeEach + void setUp() { + service = + new WrongAnswerSetServiceImpl( + quizFolderRepository, quizHistoryRepository, problemSetReadService, factory, hashUtil); + lenient().when(hashUtil.decode(FOLDER_HASH)).thenReturn(FOLDER_ID); + } + + private void folderIsMine() { + when(quizFolderRepository.findByIdAndUserId(FOLDER_ID, USER)) + .thenReturn( + Optional.of(QuizFolder.builder().id(FOLDER_ID).userId(USER).name("폴더").build())); + } + + private void historiesInFolder(QuizHistory... histories) { + when(quizHistoryRepository.findAllByUserIdAndFolderIdAndStatusOrderByCreatedAtDesc( + USER, FOLDER_ID, QuizHistoryStatus.COMPLETED)) + .thenReturn(List.of(histories)); + } + + private QuizHistory history(long problemSetId, int questionCount) { + QuizHistory history = + QuizHistory.builder().userId(USER).problemSetId(problemSetId).folderId(FOLDER_ID).build(); + // 전부 3번을 골라 오답(정답은 2번) + history.completeQuiz( + java.util.stream.IntStream.rangeClosed(1, questionCount) + .mapToObj(number -> new AnswerSnapshot(number, 3, false, null)) + .toList(), + 0, + "00:10"); + return history; + } + + private ProblemSetSummary summary(long id, QuizType type, int count) { + return new ProblemSetSummary(id, type, count, "원본", Instant.parse("2026-01-01T00:00:00Z")); + } + + private void problemsOf(long problemSetId, int count) { + when(problemSetReadService.findProblemsByProblemSetId(problemSetId)) + .thenReturn( + java.util.stream.IntStream.rangeClosed(1, count) + .mapToObj( + number -> + new ProblemDetail( + number, + "문항", + List.of( + new SelectionDetail("1번", false), new SelectionDetail("2번", true)), + "해설")) + .toList()); + } + + private void factoryCreatesSuccessfully() { + when(factory.create(anyString(), anyLong(), anyString(), any(), any(), anyBoolean(), any())) + .thenAnswer( + invocation -> + new CreatedSet( + "PSID", + "HID", + invocation.getArgument(3), + "제목", + ((List) invocation.getArgument(4)).size(), + invocation.getArgument(5))); + } + + private WrongAnswerSetResponse run() { + return service.createFromFolder(USER, new CreateWrongAnswerSetRequest(FOLDER_HASH), KEY); + } + + @Test + @DisplayName("내 폴더가 아니면 만들지 않는다") + void rejectsForeignFolder() { + when(quizFolderRepository.findByIdAndUserId(FOLDER_ID, USER)).thenReturn(Optional.empty()); + + assertThatThrownBy(this::run).isInstanceOf(CustomException.class); + } + + @Test + @DisplayName("수집 범위는 내 기록 ∩ 이 폴더 ∩ 끝까지 푼 것으로만 닫힌다 (SC-003)") + void scopeIsClosedByUserAndFolder() { + folderIsMine(); + historiesInFolder(); + + run(); + + // 다른 조건으로 기록을 끌어오는 경로가 없어야 폴더 밖·타인 기록이 샐 자리가 없다. + verify(quizHistoryRepository) + .findAllByUserIdAndFolderIdAndStatusOrderByCreatedAtDesc( + USER, FOLDER_ID, QuizHistoryStatus.COMPLETED); + } + + @Test + @DisplayName("서술형은 수집하지 않고 제외된 문항 수만 알린다 (FR-016·FR-016a)") + void excludesEssay() { + folderIsMine(); + historiesInFolder(history(1L, 3), history(2L, 2)); + when(problemSetReadService.findProblemSetsByIds(any())) + .thenReturn(List.of(summary(1L, QuizType.ESSAY, 3), summary(2L, QuizType.MULTIPLE, 2))); + problemsOf(2L, 2); + factoryCreatesSuccessfully(); + + WrongAnswerSetResponse response = run(); + + assertThat(response.excludedEssayCount()).isEqualTo(3); + assertThat(response.createdSets()) + .singleElement() + .satisfies(set -> assertThat(set.quizType()).isEqualTo(QuizType.MULTIPLE)); + } + + @Test + @DisplayName("원본이 지워진 기록은 건너뛰고 나머지로 구성한다") + void skipsDeletedSource() { + folderIsMine(); + historiesInFolder(history(1L, 2), history(2L, 2)); + when(problemSetReadService.findProblemSetsByIds(any())) + .thenReturn(List.of(summary(2L, QuizType.MULTIPLE, 2))); + problemsOf(2L, 2); + factoryCreatesSuccessfully(); + + WrongAnswerSetResponse response = run(); + + assertThat(response.deletedSourceCount()).isEqualTo(1); + assertThat(response.createdSets()).hasSize(1); + assertThat(response.emptyReason()).isNull(); + } + + @Test + @DisplayName("푼 기록이 없으면 사유가 NO_HISTORY 다") + void emptyWithoutHistory() { + folderIsMine(); + historiesInFolder(); + + assertThat(run().emptyReason()).isEqualTo(EmptyReason.NO_HISTORY); + } + + @Test + @DisplayName("수집 가능한 것이 서술형뿐이면 사유가 ESSAY_ONLY 다") + void emptyWithEssayOnly() { + folderIsMine(); + historiesInFolder(history(1L, 3)); + when(problemSetReadService.findProblemSetsByIds(any())) + .thenReturn(List.of(summary(1L, QuizType.ESSAY, 3))); + + WrongAnswerSetResponse response = run(); + + assertThat(response.emptyReason()).isEqualTo(EmptyReason.ESSAY_ONLY); + assertThat(response.excludedEssayCount()).isEqualTo(3); + } + + @Test + @DisplayName("원본이 전부 지워졌으면 사유가 SOURCE_DELETED 다") + void emptyWithDeletedSources() { + folderIsMine(); + historiesInFolder(history(1L, 2)); + when(problemSetReadService.findProblemSetsByIds(any())).thenReturn(List.of()); + + assertThat(run().emptyReason()).isEqualTo(EmptyReason.SOURCE_DELETED); + } + + @Test + @DisplayName("전부 맞혔으면 사유가 ALL_CORRECT 다") + void emptyWhenAllCorrect() { + folderIsMine(); + QuizHistory allCorrect = + QuizHistory.builder().userId(USER).problemSetId(1L).folderId(FOLDER_ID).build(); + allCorrect.completeQuiz(List.of(new AnswerSnapshot(1, 2, false, null)), 1, "00:10"); + historiesInFolder(allCorrect); + when(problemSetReadService.findProblemSetsByIds(any())) + .thenReturn(List.of(summary(1L, QuizType.MULTIPLE, 1))); + problemsOf(1L, 1); + + assertThat(run().emptyReason()).isEqualTo(EmptyReason.ALL_CORRECT); + } + + @Test + @DisplayName("한 유형이 실패해도 성공한 유형은 남고 실패한 유형만 알린다 (FR-018)") + void isolatesFailure() { + folderIsMine(); + historiesInFolder(history(1L, 1), history(2L, 1)); + when(problemSetReadService.findProblemSetsByIds(any())) + .thenReturn(List.of(summary(1L, QuizType.MULTIPLE, 1), summary(2L, QuizType.OX, 1))); + problemsOf(1L, 1); + problemsOf(2L, 1); + when(factory.create(anyString(), anyLong(), anyString(), any(), any(), anyBoolean(), any())) + .thenAnswer( + invocation -> { + if (invocation.getArgument(3) == QuizType.OX) { + throw new IllegalStateException("저장 실패"); + } + return new CreatedSet("PSID", "HID", QuizType.MULTIPLE, "제목", 1, false); + }); + + WrongAnswerSetResponse response = run(); + + assertThat(response.createdSets()) + .singleElement() + .satisfies(set -> assertThat(set.quizType()).isEqualTo(QuizType.MULTIPLE)); + assertThat(response.failedTypes()).containsExactly(QuizType.OX); + } + + @Test + @DisplayName("같은 요청이 두 번 들어오면 새로 만들지 않고 먼저 만들어진 것을 돌려준다") + void isIdempotent() { + folderIsMine(); + historiesInFolder(history(1L, 1)); + when(problemSetReadService.findProblemSetsByIds(any())) + .thenReturn(List.of(summary(1L, QuizType.MULTIPLE, 1))); + problemsOf(1L, 1); + when(factory.create(anyString(), anyLong(), anyString(), any(), any(), anyBoolean(), any())) + .thenThrow(new DataIntegrityViolationException("uk_problem_set_session")); + when(problemSetReadService.findProblemSetBySessionId("wa-" + KEY + "-MULTIPLE")) + .thenReturn(Optional.of(summary(9L, QuizType.MULTIPLE, 1))); + when(factory.existing(eq(USER), eq(9L), eq(QuizType.MULTIPLE), anyString(), eq(1))) + .thenReturn(new CreatedSet("PSID", "HID", QuizType.MULTIPLE, "원본", 1, false)); + + WrongAnswerSetResponse response = run(); + + assertThat(response.createdSets()) + .singleElement() + .satisfies( + set -> { + assertThat(set.problemSetId()).isEqualTo("PSID"); + }); + assertThat(response.failedTypes()).isEmpty(); + } +} diff --git a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationBatchConsumer.java b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationBatchConsumer.java index af74beeb..5d891435 100644 --- a/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationBatchConsumer.java +++ b/modules/quiz-make/impl/src/main/java/com/icc/qasker/quizmake/service/generation/GenerationBatchConsumer.java @@ -9,6 +9,7 @@ import com.icc.qasker.quizmake.dto.ferequest.GenerationRequest; import com.icc.qasker.quizmake.mapper.AIProblemSetMapper; import com.icc.qasker.quizmake.mapper.ExplanationMarkdownBuilder; +import com.icc.qasker.quizset.ProblemSetOrigin; import com.icc.qasker.quizset.QualityLogService; import com.icc.qasker.quizset.QuizCommandService; import com.icc.qasker.quizset.QuizQueryService; @@ -160,6 +161,7 @@ private void sendCreated(List savedNumbers) { GENERATING, request.quizType(), request.quizCount(), + ProblemSetOrigin.DOCUMENT, quizForFeList)); } diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/ProblemSetOrigin.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/ProblemSetOrigin.java new file mode 100644 index 00000000..d64c18f4 --- /dev/null +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/ProblemSetOrigin.java @@ -0,0 +1,7 @@ +package com.icc.qasker.quizset; + +/** 문제 세트가 어디서 왔는지. DOCUMENT = 업로드한 자료로 생성, WRONG_ANSWER = 틀린 문항을 모아 재출제. */ +public enum ProblemSetOrigin { + DOCUMENT, + WRONG_ANSWER +} diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/ProblemSetReadService.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/ProblemSetReadService.java index 7ff78059..f5e33e64 100644 --- a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/ProblemSetReadService.java +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/ProblemSetReadService.java @@ -10,6 +10,9 @@ public interface ProblemSetReadService { Optional findProblemSetById(Long id); + /** 세션 식별자로 세트를 찾는다. 같은 요청이 두 번 들어왔을 때 이미 만들어진 세트를 그대로 돌려주기 위해 쓴다. */ + Optional findProblemSetBySessionId(String sessionId); + List findProblemSetsByIds(List ids); List findProblemsByProblemSetId(Long problemSetId); diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/WrongAnswerSetCreationService.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/WrongAnswerSetCreationService.java new file mode 100644 index 00000000..349a00fc --- /dev/null +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/WrongAnswerSetCreationService.java @@ -0,0 +1,14 @@ +package com.icc.qasker.quizset; + +import com.icc.qasker.quizset.dto.WrongAnswerSetCreation; + +/** 이미 있는 문항을 복제해 오답 문제집을 만든다. 새 문제를 생성하지 않으므로 AI를 거치지 않는다. */ +public interface WrongAnswerSetCreationService { + + /** + * 세트와 문항을 만들고 새 세트 id를 돌려준다. 호출자의 트랜잭션에 참여하므로, 세트·문항·호출자가 함께 남기는 것이 한 번에 커밋되거나 한 번에 사라진다. + * + * @throws org.springframework.dao.DataIntegrityViolationException 같은 sessionId의 세트가 이미 있을 때 + */ + Long create(WrongAnswerSetCreation request); +} diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/WrongAnswerSetCreation.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/WrongAnswerSetCreation.java new file mode 100644 index 00000000..3cd5a047 --- /dev/null +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/WrongAnswerSetCreation.java @@ -0,0 +1,18 @@ +package com.icc.qasker.quizset.dto; + +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import com.icc.qasker.quizset.dto.readonly.ProblemLineage; +import java.util.List; + +/** + * 틀린 문항을 그대로 복제해 새 세트를 만들 때 필요한 것. {@code sources}는 복제할 원본 문항의 좌표이며, 담기는 순서가 새 세트의 문항 번호 순서가 된다. + * + *

{@code sessionId}는 호출자가 결정론적으로 만든다 — 컬럼이 UNIQUE라 같은 요청이 두 번 들어와도 두 번째는 제약 위반으로 걸러진다. + */ +public record WrongAnswerSetCreation( + String userId, + String sessionId, + String title, + QuizType quizType, + Long sourceFolderId, + List sources) {} diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/feresponse/ProblemSetResponse.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/feresponse/ProblemSetResponse.java index ad1fce78..7f6cbcf4 100644 --- a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/feresponse/ProblemSetResponse.java +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/feresponse/ProblemSetResponse.java @@ -1,6 +1,7 @@ package com.icc.qasker.quizset.dto.feresponse; import com.icc.qasker.quizset.GenerationStatus; +import com.icc.qasker.quizset.ProblemSetOrigin; import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; import java.util.List; @@ -11,6 +12,7 @@ public record ProblemSetResponse( GenerationStatus generationStatus, QuizType quizType, Integer totalCount, + ProblemSetOrigin origin, List quiz) { public record QuizForFe( diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/ProblemDetail.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/ProblemDetail.java index 15087256..0a63ff18 100644 --- a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/ProblemDetail.java +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/ProblemDetail.java @@ -2,6 +2,27 @@ import java.util.List; -/** Problem Entity의 read-only DTO. 모듈 경계를 넘어 Problem 데이터를 전달할 때 사용. */ +/** + * Problem Entity의 read-only DTO. 모듈 경계를 넘어 Problem 데이터를 전달할 때 사용. {@code originProblemSetId}/{@code + * originNumber}는 재출제된 문항이 가리키는 최초 조상이며, 자료로 생성된 원본 문항은 둘 다 null이다. + */ public record ProblemDetail( - int number, String title, List selections, String explanationContent) {} + int number, + String title, + List selections, + String explanationContent, + Long originProblemSetId, + Integer originNumber) { + + public ProblemDetail( + int number, String title, List selections, String explanationContent) { + this(number, title, selections, explanationContent, null, null); + } + + /** 중복 제거용 문항 신원 — 혈통이 있으면 최초 조상을, 없으면 자기 자신을 가리킨다. */ + public ProblemLineage lineage(Long owningProblemSetId) { + return new ProblemLineage( + originProblemSetId == null ? owningProblemSetId : originProblemSetId, + originNumber == null ? number : originNumber); + } +} diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/ProblemLineage.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/ProblemLineage.java new file mode 100644 index 00000000..06d6d249 --- /dev/null +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/ProblemLineage.java @@ -0,0 +1,4 @@ +package com.icc.qasker.quizset.dto.readonly; + +/** 문항 좌표(세트 id + 문항 번호). 최초 조상을 가리키면 세대가 반복돼도 같은 값이라 중복 제거 키가 된다. */ +public record ProblemLineage(Long problemSetId, int number) {} diff --git a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/ProblemSetSummary.java b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/ProblemSetSummary.java index 740534b4..7c9421a1 100644 --- a/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/ProblemSetSummary.java +++ b/modules/quiz-set/api/src/main/java/com/icc/qasker/quizset/dto/readonly/ProblemSetSummary.java @@ -1,8 +1,20 @@ package com.icc.qasker.quizset.dto.readonly; +import com.icc.qasker.quizset.ProblemSetOrigin; import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; import java.time.Instant; /** ProblemSet Entity의 read-only DTO. 모듈 경계를 넘어 ProblemSet 데이터를 전달할 때 사용. */ public record ProblemSetSummary( - Long id, QuizType quizType, int totalQuizCount, String title, Instant createdAt) {} + Long id, + QuizType quizType, + int totalQuizCount, + String title, + Instant createdAt, + ProblemSetOrigin origin) { + + public ProblemSetSummary( + Long id, QuizType quizType, int totalQuizCount, String title, Instant createdAt) { + this(id, quizType, totalQuizCount, title, createdAt, ProblemSetOrigin.DOCUMENT); + } +} diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/Problem.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/Problem.java index a74f30cf..cfbb918d 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/Problem.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/Problem.java @@ -64,6 +64,12 @@ public class Problem extends CreatedAt { @Column(columnDefinition = "TEXT") private String appliedInstruction; + // 재출제 혈통 — 이 문항이 복제된 최초 조상(세트 id + 문항 번호). 복제 시 조상 값을 물려받으므로 세대가 반복돼도 최초 문항을 가리킨다. + // 자료로 생성된 원본 문항은 둘 다 null. + @Column private Long originProblemSetId; + + @Column private Integer originNumber; + // Phase 1: 문제 생성 시 선택지와 참조 페이지를 바인딩 public void bindQuizData(List selections, List referencedPages) { this.selections = selections == null ? List.of() : List.copyOf(selections); diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/ProblemSet.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/ProblemSet.java index a77262f5..cbb2eb49 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/ProblemSet.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/entity/ProblemSet.java @@ -2,6 +2,7 @@ import com.icc.qasker.global.entity.CreatedAt; import com.icc.qasker.quizset.GenerationStatus; +import com.icc.qasker.quizset.ProblemSetOrigin; import com.icc.qasker.quizset.converter.IntegerListConverter; import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; import jakarta.persistence.CascadeType; @@ -51,6 +52,15 @@ public class ProblemSet extends CreatedAt { @Enumerated(EnumType.STRING) private QuizType quizType; + // 세트 출처. 오답 모아풀기로 만들어진 세트는 원본 자료가 없어, 자료를 전제로 하는 후속 동작의 노출 여부를 이 값으로 가른다. + @Enumerated(EnumType.STRING) + @Builder.Default + @Column(nullable = false, length = 20) + private ProblemSetOrigin origin = ProblemSetOrigin.DOCUMENT; + + // 오답 모아풀기로 만든 세트가 어느 폴더에서 모였는지. 자료 기반 세트는 null. + @Column private Long sourceFolderId; + @PositiveOrZero @Column(nullable = false) private Integer totalQuizCount; diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/mapper/ProblemSetResponseMapper.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/mapper/ProblemSetResponseMapper.java index 89da26a5..fc4d4b2b 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/mapper/ProblemSetResponseMapper.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/mapper/ProblemSetResponseMapper.java @@ -62,6 +62,7 @@ public ProblemSetResponse toResponse(ProblemSet problemSet, List proble problemSet.getGenerationStatus(), problemSet.getQuizType(), problemSet.getTotalQuizCount(), + problemSet.getOrigin(), quizzes); } } diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/repository/ProblemRepository.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/repository/ProblemRepository.java index 048ce216..59b8eedb 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/repository/ProblemRepository.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/repository/ProblemRepository.java @@ -29,4 +29,9 @@ List findRemainingProblems( @EntityGraph(attributePaths = {"explanationContent"}) @Query("SELECT p FROM Problem p where p.id.problemSetId=:setId ORDER BY p.id.number") List findExplanationsBySetId(@Param("setId") Long setId); + + /** 여러 세트에 흩어진 문항을 한 번에 읽는다. 복제가 해설까지 그대로 옮기므로 lazy 그룹을 함께 페치해 문항마다 개별 SELECT가 나가지 않게 한다. */ + @EntityGraph(attributePaths = {"explanationContent"}) + @Query("SELECT p FROM Problem p WHERE p.id IN :ids") + List findAllWithExplanationByIdIn(@Param("ids") Collection ids); } diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetReadServiceImpl.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetReadServiceImpl.java index 16c3fc8e..473bb01d 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetReadServiceImpl.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetReadServiceImpl.java @@ -27,6 +27,13 @@ public Optional findProblemSetById(Long id) { return problemSetRepository.findById(id).map(this::toSummary); } + @Override + public Optional findProblemSetBySessionId(String sessionId) { + return problemSetRepository + .findFirstBySessionIdOrderByCreatedAtDesc(sessionId) + .map(this::toSummary); + } + @Override public List findProblemSetsByIds(List ids) { return problemSetRepository.findAllById(ids).stream().map(this::toSummary).toList(); @@ -43,7 +50,12 @@ public List findProblemsByProblemSetId(Long problemSetId) { private ProblemSetSummary toSummary(ProblemSet ps) { return new ProblemSetSummary( - ps.getId(), ps.getQuizType(), ps.getTotalQuizCount(), ps.getTitle(), ps.getCreatedAt()); + ps.getId(), + ps.getQuizType(), + ps.getTotalQuizCount(), + ps.getTitle(), + ps.getCreatedAt(), + ps.getOrigin()); } private ProblemDetail toDetail(Problem p) { @@ -52,6 +64,11 @@ private ProblemDetail toDetail(Problem p) { .map(s -> new SelectionDetail(s.content(), s.correct(), s.acceptedAnswers())) .toList(); return new ProblemDetail( - p.getId().getNumber(), p.getTitle(), selections, p.getExplanationContent()); + p.getId().getNumber(), + p.getTitle(), + selections, + p.getExplanationContent(), + p.getOriginProblemSetId(), + p.getOriginNumber()); } } diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetServiceImpl.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetServiceImpl.java index ebf31e4c..d0ed2565 100644 --- a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetServiceImpl.java +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/query/ProblemSetServiceImpl.java @@ -3,6 +3,7 @@ import com.icc.qasker.global.component.HashUtil; import com.icc.qasker.global.error.CustomException; import com.icc.qasker.global.error.ExceptionMessage; +import com.icc.qasker.quizset.ProblemSetOrigin; import com.icc.qasker.quizset.ProblemSetService; import com.icc.qasker.quizset.dto.ferequest.ChangeTitleRequest; import com.icc.qasker.quizset.dto.feresponse.ChangeTitleResponse; @@ -37,7 +38,10 @@ public ProblemSetResponse getProblemSet(String problemSetId) { public RegenerationConditionResponse getRegenerationCondition(String problemSetId) { Assert.hasText(problemSetId, "problemSetId must not be blank"); ProblemSet ps = getProblemSetEntityByEncoded(problemSetId); - // 자료 능동 만료검사는 이번 스코프 미도입 → documentAvailable 항상 true(후속에 실 판정으로 대체). + // 자료 능동 만료검사는 이번 스코프 미도입 → 자료 기반 세트는 documentAvailable 항상 true(후속에 실 판정으로 대체). + // 오답 모아풀기로 만든 세트는 근거 자료 자체가 없으므로 false. 404로 막지 않는 것은 의도다 — 프론트가 이 응답을 받아 + // 폴백 경로로 degrade 하고, 진입점을 실제로 감추는 것은 응답의 origin 을 보는 프론트 쪽 판단이다. + boolean documentAvailable = ps.getOrigin() != ProblemSetOrigin.WRONG_ANSWER; return new RegenerationConditionResponse( ps.getQuizType(), ps.getTotalQuizCount(), @@ -46,7 +50,7 @@ public RegenerationConditionResponse getRegenerationCondition(String problemSetI ps.getCustomInstruction(), ps.getFileUrl(), ps.getTitle(), - true); + documentAvailable); } // legacy 세트는 컬럼 NULL이 IntegerListConverter를 거쳐 빈 리스트로 읽힌다. 계약대로 null로 정규화해 프론트 폴백 판정을 명확히 한다. diff --git a/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/wronganswer/WrongAnswerSetCreationServiceImpl.java b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/wronganswer/WrongAnswerSetCreationServiceImpl.java new file mode 100644 index 00000000..28420b7e --- /dev/null +++ b/modules/quiz-set/impl/src/main/java/com/icc/qasker/quizset/service/wronganswer/WrongAnswerSetCreationServiceImpl.java @@ -0,0 +1,108 @@ +package com.icc.qasker.quizset.service.wronganswer; + +import com.icc.qasker.quizset.GenerationStatus; +import com.icc.qasker.quizset.ProblemSetOrigin; +import com.icc.qasker.quizset.WrongAnswerSetCreationService; +import com.icc.qasker.quizset.dto.WrongAnswerSetCreation; +import com.icc.qasker.quizset.dto.readonly.ProblemLineage; +import com.icc.qasker.quizset.entity.Problem; +import com.icc.qasker.quizset.entity.ProblemId; +import com.icc.qasker.quizset.entity.ProblemSet; +import com.icc.qasker.quizset.repository.ProblemRepository; +import com.icc.qasker.quizset.repository.ProblemSetRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 원본 문항을 그대로 복제해 오답 문제집을 만든다. + * + *

호출자의 트랜잭션에 참여한다(새 트랜잭션을 열지 않는다) — 세트·문항과 호출자가 함께 남기는 풀이 기록이 한 덩어리로 커밋돼야 반쯤 만들어진 문제집이 목록에 남지 않기 + * 때문이다. + */ +@Service +@RequiredArgsConstructor +@Transactional +public class WrongAnswerSetCreationServiceImpl implements WrongAnswerSetCreationService { + + private final ProblemSetRepository problemSetRepository; + private final ProblemRepository problemRepository; + + @Override + public Long create(WrongAnswerSetCreation request) { + ProblemSet problemSet = + problemSetRepository.save( + ProblemSet.builder() + .userId(request.userId()) + .sessionId(request.sessionId()) + .title(request.title()) + .quizType(request.quizType()) + .totalQuizCount(request.sources().size()) + // 자료 없이 만들어진 세트다. file_url 은 NOT NULL DEFAULT '' 이라 빈 문자열이 정상값이다. + .fileUrl("") + .origin(ProblemSetOrigin.WRONG_ANSWER) + .sourceFolderId(request.sourceFolderId()) + // 문항이 이미 확정돼 있으므로 생성 중 상태를 거치지 않는다 — 응답 직후 바로 풀 수 있어야 한다. + .generationStatus(GenerationStatus.COMPLETED) + .build()); + + problemRepository.saveAll(cloneProblems(request.sources(), problemSet)); + return problemSet.getId(); + } + + /** 요청된 순서대로 1..N 번을 다시 매겨 복제한다. 조회는 한 번에 하고, 순서는 요청 순서를 따른다. */ + private List cloneProblems(List sources, ProblemSet target) { + List ids = + sources.stream() + .map(s -> ProblemId.builder().problemSetId(s.problemSetId()).number(s.number()).build()) + .toList(); + Map originals = + problemRepository.findAllWithExplanationByIdIn(ids).stream() + .collect( + Collectors.toMap( + p -> new ProblemLineage(p.getId().getProblemSetId(), p.getId().getNumber()), + Function.identity())); + + List clones = new ArrayList<>(sources.size()); + int number = 0; + for (ProblemLineage source : sources) { + Problem original = originals.get(source); + if (original == null) { + continue; + } + clones.add(cloneOne(original, target, ++number)); + } + return clones; + } + + private Problem cloneOne(Problem original, ProblemSet target, int number) { + ProblemLineage ancestor = + new ProblemLineage( + original.getOriginProblemSetId() == null + ? original.getId().getProblemSetId() + : original.getOriginProblemSetId(), + original.getOriginNumber() == null + ? original.getId().getNumber() + : original.getOriginNumber()); + + Problem clone = + Problem.builder() + .id(ProblemId.builder().number(number).build()) + .problemSet(target) + .title(original.getTitle()) + // 원본 자료를 가리키는 페이지 번호는 물려주지 않는다. 오답 문제집엔 자료가 없어 해설의 참조 자료 안내가 + // 사실과 다른 말을 하게 되고, FR-004 가 요구하는 동일성(지문·선택지·정답·해설)에도 들어 있지 않다. + .originProblemSetId(ancestor.problemSetId()) + .originNumber(ancestor.number()) + .build(); + clone.bindQuizData(original.getSelections(), List.of()); + clone.updateExplanation(original.getExplanationContent()); + clone.updateAppliedInstruction(original.getAppliedInstruction()); + return clone; + } +} diff --git a/modules/quiz-set/impl/src/test/java/com/icc/qasker/quizset/service/wronganswer/WrongAnswerSetCreationServiceImplTest.java b/modules/quiz-set/impl/src/test/java/com/icc/qasker/quizset/service/wronganswer/WrongAnswerSetCreationServiceImplTest.java new file mode 100644 index 00000000..65b9dc0e --- /dev/null +++ b/modules/quiz-set/impl/src/test/java/com/icc/qasker/quizset/service/wronganswer/WrongAnswerSetCreationServiceImplTest.java @@ -0,0 +1,178 @@ +package com.icc.qasker.quizset.service.wronganswer; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.icc.qasker.quizset.GenerationStatus; +import com.icc.qasker.quizset.ProblemSetOrigin; +import com.icc.qasker.quizset.dto.WrongAnswerSetCreation; +import com.icc.qasker.quizset.dto.ferequest.enums.QuizType; +import com.icc.qasker.quizset.dto.readonly.ProblemLineage; +import com.icc.qasker.quizset.entity.Problem; +import com.icc.qasker.quizset.entity.ProblemId; +import com.icc.qasker.quizset.entity.ProblemSet; +import com.icc.qasker.quizset.entity.Selection; +import com.icc.qasker.quizset.repository.ProblemRepository; +import com.icc.qasker.quizset.repository.ProblemSetRepository; +import com.icc.qasker.quizset.support.JpaIntegrationTestBase; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; + +/** 재출제 동일성(FR-004)과 혈통 승계를 실제 저장 결과로 고정한다. */ +class WrongAnswerSetCreationServiceImplTest extends JpaIntegrationTestBase { + + @Autowired private ProblemSetRepository problemSetRepository; + @Autowired private ProblemRepository problemRepository; + + private WrongAnswerSetCreationServiceImpl service() { + return new WrongAnswerSetCreationServiceImpl(problemSetRepository, problemRepository); + } + + private ProblemSet persistOriginalSet(String sessionId) { + ProblemSet set = + ProblemSet.builder() + .sessionId(sessionId) + .title("원본") + .userId("me") + .quizType(QuizType.MULTIPLE) + .totalQuizCount(1) + .fileUrl("https://cdn/original.pdf") + .generationStatus(GenerationStatus.COMPLETED) + .build(); + em.persist(set); + return set; + } + + private Problem persistProblem(ProblemSet set, int number) { + Problem problem = + Problem.builder() + .id(ProblemId.builder().problemSetId(set.getId()).number(number).build()) + .problemSet(set) + .title("지문 " + number) + .build(); + problem.bindQuizData( + List.of( + new Selection("보기1", "선지해설1", false, null), + new Selection("정답", "선지해설2", true, List.of(List.of("정답", "answer")))), + List.of(3, 4)); + problem.updateExplanation("해설 " + number); + problem.updateAppliedInstruction("지시 " + number); + em.persist(problem); + return problem; + } + + private WrongAnswerSetCreation creation(String sessionId, List sources) { + return new WrongAnswerSetCreation("me", sessionId, "오답 모음", QuizType.MULTIPLE, 5L, sources); + } + + @Test + @DisplayName("문항의 지문·선택지·정답·해설을 그대로 복제하고 번호만 1부터 다시 매긴다") + void clones_problem_content() { + ProblemSet origin = persistOriginalSet("origin"); + persistProblem(origin, 4); + persistProblem(origin, 9); + flushAndClear(); + + Long createdId = + service() + .create( + creation( + "wa-key-MULTIPLE", + List.of( + new ProblemLineage(origin.getId(), 9), + new ProblemLineage(origin.getId(), 4)))); + flushAndClear(); + + List clones = problemRepository.findExplanationsBySetId(createdId); + assertThat(clones).extracting(p -> p.getId().getNumber()).containsExactly(1, 2); + // 요청한 순서(9번 먼저)가 새 번호 순서가 된다. + assertThat(clones.getFirst().getTitle()).isEqualTo("지문 9"); + assertThat(clones.getFirst().getExplanationContent()).isEqualTo("해설 9"); + assertThat(clones.getFirst().getAppliedInstruction()).isEqualTo("지시 9"); + assertThat(clones.getFirst().getSelections()) + .containsExactlyElementsOf( + List.of( + new Selection("보기1", "선지해설1", false, null), + new Selection("정답", "선지해설2", true, List.of(List.of("정답", "answer"))))); + } + + @Test + @DisplayName("원본 자료를 가리키는 페이지 번호는 물려주지 않는다") + void drops_referenced_pages() { + ProblemSet origin = persistOriginalSet("origin"); + persistProblem(origin, 1); + flushAndClear(); + + Long createdId = + service() + .create(creation("wa-key-MULTIPLE", List.of(new ProblemLineage(origin.getId(), 1)))); + flushAndClear(); + + assertThat(problemRepository.findExplanationsBySetId(createdId).getFirst().getReferencedPages()) + .isEmpty(); + } + + @Test + @DisplayName("만들어진 세트는 자료 없이도 바로 풀 수 있는 상태이고 출처가 오답 모음으로 남는다") + void created_set_is_ready_to_solve() { + ProblemSet origin = persistOriginalSet("origin"); + persistProblem(origin, 1); + flushAndClear(); + + Long createdId = + service() + .create(creation("wa-key-MULTIPLE", List.of(new ProblemLineage(origin.getId(), 1)))); + flushAndClear(); + + ProblemSet created = problemSetRepository.findById(createdId).orElseThrow(); + assertThat(created.getGenerationStatus()).isEqualTo(GenerationStatus.COMPLETED); + assertThat(created.getOrigin()).isEqualTo(ProblemSetOrigin.WRONG_ANSWER); + assertThat(created.getSourceFolderId()).isEqualTo(5L); + assertThat(created.getFileUrl()).isEmpty(); + assertThat(created.getTotalQuizCount()).isEqualTo(1); + } + + @Test + @DisplayName("세대가 거듭돼도 혈통은 최초 조상을 가리킨다") + void lineage_points_to_first_ancestor() { + ProblemSet origin = persistOriginalSet("origin"); + persistProblem(origin, 7); + flushAndClear(); + + Long firstGeneration = + service().create(creation("wa-1-MULTIPLE", List.of(new ProblemLineage(origin.getId(), 7)))); + flushAndClear(); + + Long secondGeneration = + service() + .create(creation("wa-2-MULTIPLE", List.of(new ProblemLineage(firstGeneration, 1)))); + flushAndClear(); + + Problem second = problemRepository.findExplanationsBySetId(secondGeneration).getFirst(); + assertThat(second.getOriginProblemSetId()).isEqualTo(origin.getId()); + assertThat(second.getOriginNumber()).isEqualTo(7); + } + + @Test + @DisplayName("같은 세션 식별자로 두 번 만들면 두 번째는 제약에 걸린다 — 호출자가 이걸 보고 기존 것을 돌려준다") + void duplicate_session_is_rejected() { + ProblemSet origin = persistOriginalSet("origin"); + persistProblem(origin, 1); + flushAndClear(); + + service().create(creation("wa-same-MULTIPLE", List.of(new ProblemLineage(origin.getId(), 1)))); + flushAndClear(); + + assertThatThrownBy( + () -> { + service() + .create( + creation("wa-same-MULTIPLE", List.of(new ProblemLineage(origin.getId(), 1)))); + em.flush(); + }) + .isInstanceOf(DataIntegrityViolationException.class); + } +} diff --git a/scripts/e2e/seed-wrong-answer-set.sql b/scripts/e2e/seed-wrong-answer-set.sql new file mode 100644 index 00000000..12e011e9 --- /dev/null +++ b/scripts/e2e/seed-wrong-answer-set.sql @@ -0,0 +1,80 @@ +-- 오답 모아풀기 기능 E2E 시드. +-- +-- 한 폴더에 여러 유형의 문제집과 "일부만 틀린" 풀이 기록을 만들어, 유형별 분할·서술형 제외·범위 격리를 +-- 한 번의 실행으로 관측할 수 있게 한다. 마지막 두 세트는 함정이다 — 폴더 밖 기록과 타인 기록이며, +-- 수집 범위가 새면 객관식 문항 수가 3이 아니라 7로 나온다. +-- +-- 실행 결과 기대값: 객관식 3문제 / OX 2문제 / 빈칸 직접입력 2문제 세 개가 만들어지고, 서술형 3문항은 제외된다. +-- +-- 사용자 로컬 DB와 격리된 일회용 DB에서 돌린다(api/CLAUDE.local.md "기능 E2E를 사용자 DB와 격리해 돌리는 법"). +-- docker exec -i q-asker-db mysql --default-character-set=utf8mb4 \ +-- -uuser -p <일회용DB> < scripts/e2e/seed-wrong-answer-set.sql +-- +-- 지우고 다시 넣으므로 몇 번을 실행해도 같은 상태가 된다. id 를 90만 대로 고정했으므로 어느 DB 에 넣어도 +-- 인코딩된 식별자가 같다 — 프론트 스펙이 그 값을 상수로 들고 있어도 깨지지 않는다. + +SET NAMES utf8mb4; + +-- 시드 세트뿐 아니라 지난 실행에서 만들어진 오답 문제집까지 소유자 기준으로 걷어낸다(만들어지는 id 를 미리 알 수 없다). +DELETE FROM problem WHERE problem_set_id IN + (SELECT id FROM problem_set WHERE user_id IN ('e2e-008-user', 'e2e-008-other')); +DELETE FROM quiz_history WHERE user_id IN ('e2e-008-user', 'e2e-008-other'); +DELETE FROM problem_set WHERE user_id IN ('e2e-008-user', 'e2e-008-other'); +DELETE FROM quiz_folder WHERE user_id IN ('e2e-008-user', 'e2e-008-other'); +DELETE FROM user WHERE user_id IN ('e2e-008-user', 'e2e-008-other'); + +INSERT INTO user (user_id, role, provider, nickname, created_at) +VALUES ('e2e-008-user', 'ROLE_USER', 'GOOGLE', 'E2E 오답유저', NOW(6)), + ('e2e-008-other', 'ROLE_USER', 'GOOGLE', 'E2E 타인', NOW(6)); + +INSERT INTO quiz_folder (id, user_id, name, created_at) +VALUES (900001, 'e2e-008-user', 'E2E-WRONG-ANSWER', NOW(6)), + (900002, 'e2e-008-user', 'E2E-OTHER-FOLDER', NOW(6)); + + +INSERT INTO problem_set (id, title, user_id, generation_status, quiz_type, total_quiz_count, session_id, file_url, origin, created_at) VALUES + (900001, 'E2E 객관식 원본', 'e2e-008-user', 'COMPLETED', 'MULTIPLE', 5, 'e2e-008-900001', 'https://example.invalid/e2e.pdf', 'DOCUMENT', NOW(6)), + (900002, 'E2E OX 원본', 'e2e-008-user', 'COMPLETED', 'OX', 5, 'e2e-008-900002', 'https://example.invalid/e2e.pdf', 'DOCUMENT', NOW(6)), + (900003, 'E2E 빈칸 직접입력 원본', 'e2e-008-user', 'COMPLETED', 'REAL_BLANK', 3, 'e2e-008-900003', 'https://example.invalid/e2e.pdf', 'DOCUMENT', NOW(6)), + (900004, 'E2E 서술형 원본', 'e2e-008-user', 'COMPLETED', 'ESSAY', 3, 'e2e-008-900004', 'https://example.invalid/e2e.pdf', 'DOCUMENT', NOW(6)), + (900005, 'E2E 폴더 밖 객관식(함정)', 'e2e-008-user', 'COMPLETED', 'MULTIPLE', 5, 'e2e-008-900005', 'https://example.invalid/e2e.pdf', 'DOCUMENT', NOW(6)), + (900006, 'E2E 타인 소유 객관식(함정)', 'e2e-008-other', 'COMPLETED', 'MULTIPLE', 5, 'e2e-008-900006', 'https://example.invalid/e2e.pdf', 'DOCUMENT', NOW(6)); + + +INSERT INTO problem (problem_set_id, number, title, selections, explanation_content, referenced_pages, created_at) VALUES + (900001, 1, '900001-1번 문항 지문', '[{"content": "1번 오답 보기", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "1번 정답 보기", "explanation": "정답 사유", "correct": true, "acceptedAnswers": null}, {"content": "1번 오답 보기2", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "1번 오답 보기3", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}]', '1번 해설', '[1, 2]', NOW(6)), + (900001, 2, '900001-2번 문항 지문', '[{"content": "2번 오답 보기", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "2번 정답 보기", "explanation": "정답 사유", "correct": true, "acceptedAnswers": null}, {"content": "2번 오답 보기2", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "2번 오답 보기3", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}]', '2번 해설', '[1, 2]', NOW(6)), + (900001, 3, '900001-3번 문항 지문', '[{"content": "3번 오답 보기", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "3번 정답 보기", "explanation": "정답 사유", "correct": true, "acceptedAnswers": null}, {"content": "3번 오답 보기2", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "3번 오답 보기3", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}]', '3번 해설', '[1, 2]', NOW(6)), + (900001, 4, '900001-4번 문항 지문', '[{"content": "4번 오답 보기", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "4번 정답 보기", "explanation": "정답 사유", "correct": true, "acceptedAnswers": null}, {"content": "4번 오답 보기2", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "4번 오답 보기3", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}]', '4번 해설', '[1, 2]', NOW(6)), + (900001, 5, '900001-5번 문항 지문', '[{"content": "5번 오답 보기", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "5번 정답 보기", "explanation": "정답 사유", "correct": true, "acceptedAnswers": null}, {"content": "5번 오답 보기2", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "5번 오답 보기3", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}]', '5번 해설', '[1, 2]', NOW(6)), + (900002, 1, '900002-1번 문항 지문', '[{"content": "O", "explanation": "틀린 설명", "correct": false, "acceptedAnswers": null}, {"content": "X", "explanation": "맞는 설명", "correct": true, "acceptedAnswers": null}]', '1번 해설', '[1, 2]', NOW(6)), + (900002, 2, '900002-2번 문항 지문', '[{"content": "O", "explanation": "틀린 설명", "correct": false, "acceptedAnswers": null}, {"content": "X", "explanation": "맞는 설명", "correct": true, "acceptedAnswers": null}]', '2번 해설', '[1, 2]', NOW(6)), + (900002, 3, '900002-3번 문항 지문', '[{"content": "O", "explanation": "틀린 설명", "correct": false, "acceptedAnswers": null}, {"content": "X", "explanation": "맞는 설명", "correct": true, "acceptedAnswers": null}]', '3번 해설', '[1, 2]', NOW(6)), + (900002, 4, '900002-4번 문항 지문', '[{"content": "O", "explanation": "틀린 설명", "correct": false, "acceptedAnswers": null}, {"content": "X", "explanation": "맞는 설명", "correct": true, "acceptedAnswers": null}]', '4번 해설', '[1, 2]', NOW(6)), + (900002, 5, '900002-5번 문항 지문', '[{"content": "O", "explanation": "틀린 설명", "correct": false, "acceptedAnswers": null}, {"content": "X", "explanation": "맞는 설명", "correct": true, "acceptedAnswers": null}]', '5번 해설', '[1, 2]', NOW(6)), + (900003, 1, '900003-1번 문항 지문', '[{"content": "정답1", "explanation": "빈칸 해설", "correct": true, "acceptedAnswers": [["정답1", "answer1"]]}]', '1번 해설', '[1, 2]', NOW(6)), + (900003, 2, '900003-2번 문항 지문', '[{"content": "정답2", "explanation": "빈칸 해설", "correct": true, "acceptedAnswers": [["정답2", "answer2"]]}]', '2번 해설', '[1, 2]', NOW(6)), + (900003, 3, '900003-3번 문항 지문', '[{"content": "정답3", "explanation": "빈칸 해설", "correct": true, "acceptedAnswers": [["정답3", "answer3"]]}]', '3번 해설', '[1, 2]', NOW(6)), + (900004, 1, '900004-1번 문항 지문', '[{"content": "1번 모범답안", "explanation": "채점 기준", "correct": true, "acceptedAnswers": null}]', '1번 해설', '[1, 2]', NOW(6)), + (900004, 2, '900004-2번 문항 지문', '[{"content": "2번 모범답안", "explanation": "채점 기준", "correct": true, "acceptedAnswers": null}]', '2번 해설', '[1, 2]', NOW(6)), + (900004, 3, '900004-3번 문항 지문', '[{"content": "3번 모범답안", "explanation": "채점 기준", "correct": true, "acceptedAnswers": null}]', '3번 해설', '[1, 2]', NOW(6)), + (900005, 1, '900005-1번 문항 지문', '[{"content": "1번 오답 보기", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "1번 정답 보기", "explanation": "정답 사유", "correct": true, "acceptedAnswers": null}, {"content": "1번 오답 보기2", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "1번 오답 보기3", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}]', '1번 해설', '[1, 2]', NOW(6)), + (900005, 2, '900005-2번 문항 지문', '[{"content": "2번 오답 보기", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "2번 정답 보기", "explanation": "정답 사유", "correct": true, "acceptedAnswers": null}, {"content": "2번 오답 보기2", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "2번 오답 보기3", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}]', '2번 해설', '[1, 2]', NOW(6)), + (900005, 3, '900005-3번 문항 지문', '[{"content": "3번 오답 보기", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "3번 정답 보기", "explanation": "정답 사유", "correct": true, "acceptedAnswers": null}, {"content": "3번 오답 보기2", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "3번 오답 보기3", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}]', '3번 해설', '[1, 2]', NOW(6)), + (900005, 4, '900005-4번 문항 지문', '[{"content": "4번 오답 보기", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "4번 정답 보기", "explanation": "정답 사유", "correct": true, "acceptedAnswers": null}, {"content": "4번 오답 보기2", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "4번 오답 보기3", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}]', '4번 해설', '[1, 2]', NOW(6)), + (900005, 5, '900005-5번 문항 지문', '[{"content": "5번 오답 보기", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "5번 정답 보기", "explanation": "정답 사유", "correct": true, "acceptedAnswers": null}, {"content": "5번 오답 보기2", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "5번 오답 보기3", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}]', '5번 해설', '[1, 2]', NOW(6)), + (900006, 1, '900006-1번 문항 지문', '[{"content": "1번 오답 보기", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "1번 정답 보기", "explanation": "정답 사유", "correct": true, "acceptedAnswers": null}, {"content": "1번 오답 보기2", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "1번 오답 보기3", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}]', '1번 해설', '[1, 2]', NOW(6)), + (900006, 2, '900006-2번 문항 지문', '[{"content": "2번 오답 보기", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "2번 정답 보기", "explanation": "정답 사유", "correct": true, "acceptedAnswers": null}, {"content": "2번 오답 보기2", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "2번 오답 보기3", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}]', '2번 해설', '[1, 2]', NOW(6)), + (900006, 3, '900006-3번 문항 지문', '[{"content": "3번 오답 보기", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "3번 정답 보기", "explanation": "정답 사유", "correct": true, "acceptedAnswers": null}, {"content": "3번 오답 보기2", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "3번 오답 보기3", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}]', '3번 해설', '[1, 2]', NOW(6)), + (900006, 4, '900006-4번 문항 지문', '[{"content": "4번 오답 보기", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "4번 정답 보기", "explanation": "정답 사유", "correct": true, "acceptedAnswers": null}, {"content": "4번 오답 보기2", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "4번 오답 보기3", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}]', '4번 해설', '[1, 2]', NOW(6)), + (900006, 5, '900006-5번 문항 지문', '[{"content": "5번 오답 보기", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "5번 정답 보기", "explanation": "정답 사유", "correct": true, "acceptedAnswers": null}, {"content": "5번 오답 보기2", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}, {"content": "5번 오답 보기3", "explanation": "오답 사유", "correct": false, "acceptedAnswers": null}]', '5번 해설', '[1, 2]', NOW(6)); + + +-- 풀이 기록. completed_at 을 서로 다르게 둬 "가장 최근에 틀린 순" 정렬이 결정적으로 재현되게 한다. +INSERT INTO quiz_history (id, user_id, problem_set_id, folder_id, title, answers, score, completed_at, total_time, status, created_at) VALUES + (900001, 'e2e-008-user', 900001, 900001, 'E2E 객관식 원본 풀이', '[{"number": 1, "userAnswer": 1, "inReview": false, "textAnswer": null}, {"number": 2, "userAnswer": 2, "inReview": false, "textAnswer": null}, {"number": 3, "userAnswer": 1, "inReview": false, "textAnswer": null}, {"number": 4, "userAnswer": 2, "inReview": false, "textAnswer": null}, {"number": 5, "userAnswer": 1, "inReview": false, "textAnswer": null}]', 2, DATE_SUB(NOW(6), INTERVAL 6 MINUTE), '00:05:00', 'COMPLETED', NOW(6)), + (900002, 'e2e-008-user', 900002, 900001, 'E2E OX 원본 풀이', '[{"number": 1, "userAnswer": 2, "inReview": false, "textAnswer": null}, {"number": 2, "userAnswer": 1, "inReview": false, "textAnswer": null}, {"number": 3, "userAnswer": 2, "inReview": false, "textAnswer": null}, {"number": 4, "userAnswer": 1, "inReview": false, "textAnswer": null}, {"number": 5, "userAnswer": 2, "inReview": false, "textAnswer": null}]', 3, DATE_SUB(NOW(6), INTERVAL 5 MINUTE), '00:05:00', 'COMPLETED', NOW(6)), + (900003, 'e2e-008-user', 900003, 900001, 'E2E 빈칸 직접입력 원본 풀이', '[{"number": 1, "userAnswer": 0, "inReview": false, "textAnswer": "틀린답"}, {"number": 2, "userAnswer": 0, "inReview": false, "textAnswer": "틀린답"}, {"number": 3, "userAnswer": 0, "inReview": false, "textAnswer": "정답3"}]', 1, DATE_SUB(NOW(6), INTERVAL 4 MINUTE), '00:05:00', 'COMPLETED', NOW(6)), + (900004, 'e2e-008-user', 900004, 900001, 'E2E 서술형 원본 풀이', '[{"number": 1, "userAnswer": 0, "inReview": false, "textAnswer": "1번 서술형 답안"}, {"number": 2, "userAnswer": 0, "inReview": false, "textAnswer": "2번 서술형 답안"}, {"number": 3, "userAnswer": 0, "inReview": false, "textAnswer": "3번 서술형 답안"}]', 3, DATE_SUB(NOW(6), INTERVAL 3 MINUTE), '00:05:00', 'COMPLETED', NOW(6)), + (900005, 'e2e-008-user', 900005, 900002, 'E2E 폴더 밖 객관식(함정) 풀이', '[{"number": 1, "userAnswer": 1, "inReview": false, "textAnswer": null}, {"number": 2, "userAnswer": 1, "inReview": false, "textAnswer": null}, {"number": 3, "userAnswer": 1, "inReview": false, "textAnswer": null}, {"number": 4, "userAnswer": 1, "inReview": false, "textAnswer": null}, {"number": 5, "userAnswer": 2, "inReview": false, "textAnswer": null}]', 1, DATE_SUB(NOW(6), INTERVAL 2 MINUTE), '00:05:00', 'COMPLETED', NOW(6)), + (900006, 'e2e-008-other', 900006, 900001, 'E2E 타인 소유 객관식(함정) 풀이', '[{"number": 1, "userAnswer": 1, "inReview": false, "textAnswer": null}, {"number": 2, "userAnswer": 1, "inReview": false, "textAnswer": null}, {"number": 3, "userAnswer": 1, "inReview": false, "textAnswer": null}, {"number": 4, "userAnswer": 1, "inReview": false, "textAnswer": null}, {"number": 5, "userAnswer": 2, "inReview": false, "textAnswer": null}]', 1, DATE_SUB(NOW(6), INTERVAL 1 MINUTE), '00:05:00', 'COMPLETED', NOW(6));