feat(education): project and review wrong questions

This commit is contained in:
2026-07-27 23:43:16 +08:00
parent 4cb18b844a
commit 12676dfbec
25 changed files with 2386 additions and 2 deletions

View File

@@ -10,6 +10,7 @@ import cn.iocoder.yudao.module.education.dal.mysql.AnswerIdempotencyMapper;
import cn.iocoder.yudao.module.education.dal.mysql.PracticeQuestionMapper;
import cn.iocoder.yudao.module.education.dal.mysql.PracticeSessionMapper;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
@@ -48,6 +49,9 @@ public class PracticeAnswerServiceImplTest extends BaseDbUnitTest {
@MockitoBean
private QuestionCatalogProvider provider;
@MockitoBean
private WrongQuestionService wrongQuestionService;
private final Long userId = 100L;
private final Long tenantId = 1L;

View File

@@ -9,6 +9,7 @@ import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO;
import cn.iocoder.yudao.module.education.dal.mysql.PracticeQuestionMapper;
import cn.iocoder.yudao.module.education.dal.mysql.PracticeSessionMapper;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService;
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionDTO;
import cn.iocoder.yudao.module.education.service.question.dto.CatalogQuestionPageResult;
import jakarta.annotation.Resource;
@@ -47,6 +48,9 @@ public class PracticeSessionServiceImplTest extends BaseDbUnitTest {
@MockitoBean
private QuestionCatalogProvider provider;
@MockitoBean
private WrongQuestionService wrongQuestionService;
// ========== Create: basic ==========
@Test

View File

@@ -0,0 +1,259 @@
package cn.iocoder.yudao.module.education.service.practice;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitReqVO;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitRespVO;
import cn.iocoder.yudao.module.education.dal.dataobject.*;
import cn.iocoder.yudao.module.education.dal.mysql.*;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionServiceImpl;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Integration test verifying that submitSession transactionally creates wrong
* question projections. Uses real WrongQuestionServiceImpl (no mock) so the
* full submit → wrong-question pipeline is exercised with real DB.
*
* @author 恭学教育
*/
@Import({PracticeSessionServiceImpl.class, WrongQuestionServiceImpl.class})
public class PracticeSubmitProjectionIntegrationTest extends BaseDbUnitTest {
@Resource
private PracticeSessionService service;
@Resource
private PracticeSessionMapper sessionMapper;
@Resource
private PracticeQuestionMapper questionMapper;
@Resource
private PracticeReportMapper reportMapper;
@Resource
private PracticeReportDetailMapper reportDetailMapper;
@Resource
private WrongQuestionMapper wrongQuestionMapper;
@Resource
private WrongQuestionIdempotencyMapper idempotencyMapper;
@MockitoBean
private QuestionCatalogProvider provider;
private final Long userId = 100L;
private final Long tenantId = 1L;
// ========== helpers ==========
private record SessionFixture(Long sessionId, List<PracticeQuestionDO> questions, Integer version) {}
private SessionFixture createSessionWithQuestions(String clientSessionId, int questionCount, String correctAnswer) {
PracticeSessionDO session = new PracticeSessionDO();
session.setTenantId(tenantId);
session.setUserId(userId);
session.setClientSessionId(clientSessionId);
session.setStatus("ACTIVE");
session.setQuestionCount(questionCount);
session.setCollectionId("col-001");
session.setVersion(1);
sessionMapper.insert(session);
List<PracticeQuestionDO> questions = new java.util.ArrayList<>();
for (int i = 0; i < questionCount; i++) {
PracticeQuestionDO q = new PracticeQuestionDO();
q.setTenantId(tenantId);
q.setSessionId(session.getId());
q.setSequence(i + 1);
q.setQuestionId("q-" + String.format("%03d", i + 1));
q.setContentVersion("v1");
q.setStem("Question " + (i + 1));
q.setType("choice");
q.setDifficulty("easy");
q.setOptions("[{\"label\":\"A\",\"content\":\"Option A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Option B\",\"order\":2.0}]");
q.setCorrectAnswer(correctAnswer);
q.setExplanation("Explanation " + (i + 1));
q.setIsAnswered(false);
questions.add(q);
}
questionMapper.insertBatch(questions);
return new SessionFixture(session.getId(), questions, session.getVersion());
}
private void answerQuestion(Long questionId, String answer) {
PracticeQuestionDO q = questionMapper.selectById(questionId);
if (q != null) {
q.setSelectedAnswer(answer);
q.setIsAnswered(true);
questionMapper.updateById(q);
}
}
private PracticeSubmitReqVO createSubmitReq(Long sessionId, String idempotencyKey, int expectedVersion) {
PracticeSubmitReqVO req = new PracticeSubmitReqVO();
req.setSessionId(sessionId);
req.setIdempotencyKey(idempotencyKey);
req.setExpectedSessionVersion(expectedVersion);
return req;
}
// ========== Submit creates wrong question projection ==========
@Test
void shouldCreateWrongQuestionProjectionOnSubmit() {
SessionFixture f = createSessionWithQuestions("uuid-proj-create", 3, "B");
// Answer q1 wrong (A), q2 correct (B), q3 unanswered
answerQuestion(f.questions.get(0).getId(), "A");
answerQuestion(f.questions.get(1).getId(), "B");
// q3: no answer
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-proj-create", 1);
PracticeSubmitRespVO resp = service.submitSession(req, userId, tenantId);
assertNotNull(resp.getReportId());
// Only q1 is wrong → one wrong question entry
List<WrongQuestionDO> wqs = wrongQuestionMapper.selectList();
assertEquals(1, wqs.size());
WrongQuestionDO wq = wqs.get(0);
assertEquals("q-001", wq.getQuestionId());
assertEquals(1, wq.getWrongCount());
assertNotNull(wq.getOptions(), "options must be populated from detail");
assertNotEquals("[]", wq.getOptions(), "options must not be empty placeholder");
assertEquals("v1", wq.getContentVersion(),
"contentVersion must be populated from report detail");
// One idempotency entry
assertEquals(1, idempotencyMapper.selectList().size());
// Report stats correct
PracticeReportDO report = reportMapper.selectById(resp.getReportId());
assertEquals(3, report.getQuestionCount());
assertEquals(2, report.getAnsweredCount());
assertEquals(1, report.getUnansweredCount());
assertEquals(1, report.getCorrectCount());
assertEquals(1, report.getIncorrectCount());
}
// ========== Replay submit does not double-count wrong questions ==========
@Test
void shouldNotDoubleCountWrongQuestionOnSubmitReplay() {
SessionFixture f = createSessionWithQuestions("uuid-proj-replay", 2, "B");
answerQuestion(f.questions.get(0).getId(), "A");
answerQuestion(f.questions.get(1).getId(), "A"); // both wrong
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-proj-replay", 1);
PracticeSubmitRespVO first = service.submitSession(req, userId, tenantId);
assertNotNull(first.getReportId());
// Replay same submit — idempotent
PracticeSubmitRespVO replay = service.submitSession(req, userId, tenantId);
assertEquals(first.getReportId(), replay.getReportId());
// Wrong question count unchanged
List<WrongQuestionDO> wqs = wrongQuestionMapper.selectList();
assertEquals(2, wqs.size());
for (WrongQuestionDO wq : wqs) {
assertEquals(1, wq.getWrongCount(),
"replay must not double-count wrong question: " + wq.getQuestionId());
}
// Still exactly 2 idempotency entries (one per question)
assertEquals(2, idempotencyMapper.selectList().size());
}
// ========== Unanswered questions excluded from wrong projection ==========
@Test
void shouldNotCreateWrongQuestionForUnanswered() {
SessionFixture f = createSessionWithQuestions("uuid-proj-unans", 3, "B");
answerQuestion(f.questions.get(0).getId(), "A"); // wrong
// questions 2 and 3 unanswered
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-proj-unans", 1);
PracticeSubmitRespVO resp = service.submitSession(req, userId, tenantId);
assertNotNull(resp.getReportId());
// Only 1 wrong question
List<WrongQuestionDO> wqs = wrongQuestionMapper.selectList();
assertEquals(1, wqs.size());
assertEquals("q-001", wqs.get(0).getQuestionId());
}
// ========== Correct answers excluded from wrong projection ==========
@Test
void shouldNotCreateWrongQuestionForCorrectAnswers() {
SessionFixture f = createSessionWithQuestions("uuid-proj-correct", 2, "B");
answerQuestion(f.questions.get(0).getId(), "B"); // correct
answerQuestion(f.questions.get(1).getId(), "B"); // correct
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-proj-correct", 1);
PracticeSubmitRespVO resp = service.submitSession(req, userId, tenantId);
assertNotNull(resp.getReportId());
// No wrong questions
List<WrongQuestionDO> wqs = wrongQuestionMapper.selectList();
assertEquals(0, wqs.size());
}
// ========== CAS failure on session status: wrong projection guard ==========
@Test
void shouldNotCreateWrongQuestionWhenSessionCasFails() {
SessionFixture f = createSessionWithQuestions("uuid-proj-cas", 2, "B");
answerQuestion(f.questions.get(0).getId(), "A");
// First submit wins
PracticeSubmitReqVO req1 = createSubmitReq(f.sessionId, "idem-proj-cas-1", 1);
PracticeSubmitRespVO winner = service.submitSession(req1, userId, tenantId);
assertNotNull(winner.getReportId());
// Second submit with same session + different key → CAS failure (session not ACTIVE)
PracticeSubmitReqVO req2 = createSubmitReq(f.sessionId, "idem-proj-cas-2", 1);
PracticeSubmitRespVO loser = service.submitSession(req2, userId, tenantId);
// The CAS failure path returns the winner's report (existing SUBMITTED session)
assertEquals(winner.getReportId(), loser.getReportId(),
"CAS loser must get winner's report, not new wrong questions");
// Wrong question count unchanged — CAS failure prevents new wrong projection
List<WrongQuestionDO> wqs = wrongQuestionMapper.selectList();
assertEquals(1, wqs.size());
assertEquals(1, wqs.get(0).getWrongCount());
}
// ========== wrong_question_id populated in idempotency guard ==========
@Test
void shouldPopulateWrongQuestionIdInIdempotencyGuard() {
SessionFixture f = createSessionWithQuestions("uuid-proj-wqid", 1, "B");
answerQuestion(f.questions.get(0).getId(), "A");
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-proj-wqid", 1);
PracticeSubmitRespVO resp = service.submitSession(req, userId, tenantId);
assertNotNull(resp.getReportId());
// Verify idempotency row has wrong_question_id populated
List<WrongQuestionIdempotencyDO> idems = idempotencyMapper.selectList();
assertEquals(1, idems.size());
WrongQuestionIdempotencyDO idem = idems.get(0);
assertNotNull(idem.getWrongQuestionId(),
"wrong_question_id must be populated after upsert");
// Verify it matches the actual wrong question
WrongQuestionDO wq = wrongQuestionMapper.selectById(idem.getWrongQuestionId());
assertNotNull(wq);
assertEquals("q-001", wq.getQuestionId());
}
}

View File

@@ -6,6 +6,7 @@ import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.*;
import cn.iocoder.yudao.module.education.dal.dataobject.*;
import cn.iocoder.yudao.module.education.dal.mysql.*;
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService;
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
@@ -53,6 +54,9 @@ public class PracticeSubmitServiceImplTest extends BaseDbUnitTest {
private final Long userId = 100L;
private final Long tenantId = 1L;
@MockitoBean
private WrongQuestionService wrongQuestionService;
// ========== helpers ==========
/**

View File

@@ -0,0 +1,794 @@
package cn.iocoder.yudao.module.education.service.wrong;
import cn.iocoder.yudao.framework.common.exception.ServiceException;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionDetailRespVO;
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionPageItemRespVO;
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionReviewReqVO;
import cn.iocoder.yudao.module.education.dal.dataobject.*;
import cn.iocoder.yudao.module.education.dal.mysql.*;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import java.time.LocalDateTime;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
import static org.junit.jupiter.api.Assertions.*;
/**
* WrongQuestionService test — real DB (H2) backing all wrong question assertions.
*
* @author 恭学教育
*/
@Import(WrongQuestionServiceImpl.class)
public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
@Resource
private WrongQuestionService wrongQuestionService;
@Resource
private WrongQuestionMapper wrongQuestionMapper;
@Resource
private WrongQuestionIdempotencyMapper idempotencyMapper;
@Resource
private PracticeSessionMapper sessionMapper;
@Resource
private PracticeQuestionMapper questionMapper;
private final Long userId = 100L;
private final Long tenantId = 1L;
// ========== helpers ==========
private record SessionFixture(Long sessionId, List<PracticeQuestionDO> questions, Integer version) {}
private SessionFixture createSessionWithQuestions(String clientSessionId, int questionCount, String correctAnswer) {
PracticeSessionDO session = new PracticeSessionDO();
session.setTenantId(tenantId);
session.setUserId(userId);
session.setClientSessionId(clientSessionId);
session.setStatus("ACTIVE");
session.setQuestionCount(questionCount);
session.setCollectionId("col-001");
session.setVersion(1);
sessionMapper.insert(session);
List<PracticeQuestionDO> questions = new java.util.ArrayList<>();
for (int i = 0; i < questionCount; i++) {
PracticeQuestionDO q = new PracticeQuestionDO();
q.setTenantId(tenantId);
q.setSessionId(session.getId());
q.setSequence(i + 1);
q.setQuestionId("q-" + String.format("%03d", i + 1));
q.setContentVersion("v1");
q.setStem("Question " + (i + 1));
q.setType("choice");
q.setDifficulty("easy");
q.setOptions("[{\"label\":\"A\",\"content\":\"Option A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Option B\",\"order\":2.0}]");
q.setCorrectAnswer(correctAnswer);
q.setExplanation("Explanation " + (i + 1));
q.setIsAnswered(false);
questions.add(q);
}
questionMapper.insertBatch(questions);
return new SessionFixture(session.getId(), questions, session.getVersion());
}
private PracticeReportDetailDO createReportDetail(PracticeQuestionDO q, Long reportId, Long sessionId,
String selectedAnswer, boolean isCorrect) {
PracticeReportDetailDO detail = new PracticeReportDetailDO();
detail.setTenantId(tenantId);
detail.setUserId(userId);
detail.setReportId(reportId);
detail.setSessionId(sessionId);
detail.setQuestionId(q.getQuestionId());
detail.setSequence(q.getSequence());
detail.setStem(q.getStem());
detail.setType(q.getType());
detail.setDifficulty(q.getDifficulty());
detail.setSelectedAnswer(selectedAnswer);
detail.setCorrectAnswer(q.getCorrectAnswer());
detail.setIsCorrect(isCorrect);
detail.setExplanation(q.getExplanation());
detail.setOptions(q.getOptions());
return detail;
}
// ========== First wrong insert ==========
@Test
void shouldCreateWrongQuestionOnFirstWrongAnswer() {
SessionFixture f = createSessionWithQuestions("uuid-first-wrong", 3, "B");
PracticeReportDetailDO detail1 = createReportDetail(f.questions.get(0), 1L, f.sessionId, "A", false);
PracticeReportDetailDO detail2 = createReportDetail(f.questions.get(1), 1L, f.sessionId, "B", true);
PracticeReportDetailDO detail3 = createReportDetail(f.questions.get(2), 1L, f.sessionId, null, false);
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId,
List.of(detail1, detail2, detail3));
// Only question 1 is wrong (answered "A", correct is "B")
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
assertEquals(1, all.size());
WrongQuestionDO wq = all.get(0);
assertEquals("q-001", wq.getQuestionId());
assertEquals("B", wq.getLatestCorrectAnswer());
assertEquals(1, wq.getWrongCount());
assertNotNull(wq.getFirstWrongTime());
assertNotNull(wq.getLastWrongTime());
assertEquals("PENDING", wq.getMasterStatus());
}
// ========== Repeated wrong increments ==========
@Test
void shouldIncrementWrongCountOnRepeatedWrongAnswer() {
SessionFixture f = createSessionWithQuestions("uuid-repeat", 1, "B");
PracticeReportDetailDO detail1 = createReportDetail(f.questions.get(0), 1L, f.sessionId, "A", false);
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail1));
// Same question, different report
PracticeReportDetailDO detail2 = createReportDetail(f.questions.get(0), 2L, f.sessionId, "C", false);
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 2L, f.sessionId, List.of(detail2));
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
assertEquals(1, all.size());
assertEquals(2, all.get(0).getWrongCount());
// Last wrong time should be updated, stem snapshots from latest detail
assertEquals("Question 1", all.get(0).getStem());
assertEquals(2L, all.get(0).getLastReportId().longValue());
}
// ========== Submit replay no double increment ==========
@Test
void shouldNotDoubleIncrementOnReplay() {
SessionFixture f = createSessionWithQuestions("uuid-replay-wq", 1, "B");
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 1L, f.sessionId, "A", false);
// First upsert
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail));
// Same (question, report) second time — idempotency skips
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail));
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
assertEquals(1, all.size());
assertEquals(1, all.get(0).getWrongCount());
// Idempotency table has exactly 1 entry for this (question, report)
List<WrongQuestionIdempotencyDO> idems = idempotencyMapper.selectList();
assertEquals(1, idems.size());
}
// ========== Concurrent submit no double count ==========
@Test
void shouldNotDoubleCountUnderConcurrentUpsert() throws Exception {
SessionFixture f = createSessionWithQuestions("uuid-conc-wq", 1, "B");
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 1L, f.sessionId, "A", false);
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
AtomicInteger successCount = new AtomicInteger(0);
Runnable task = () -> {
try {
ready.countDown();
go.await();
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail));
successCount.incrementAndGet();
} catch (Exception ignored) {
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
ready.await();
go.countDown();
t1.join(10000);
t2.join(10000);
// Both threads completed without exception (idempotency handles the race)
assertEquals(2, successCount.get());
// Exactly one wrong question with count = 1
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
assertEquals(1, all.size());
assertEquals(1, all.get(0).getWrongCount());
}
// ========== Unanswered excluded ==========
@Test
void shouldExcludeUnansweredQuestions() {
SessionFixture f = createSessionWithQuestions("uuid-unans", 1, "B");
// isCorrect=false but selectedAnswer is null (unanswered) → should NOT be counted as wrong
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 1L, f.sessionId, null, false);
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail));
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
assertEquals(0, all.size());
}
// ========== Correct answer excluded ==========
@Test
void shouldNotCreateWrongQuestionForCorrectAnswer() {
SessionFixture f = createSessionWithQuestions("uuid-correct", 1, "B");
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 1L, f.sessionId, "B", true);
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail));
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
assertEquals(0, all.size());
}
// ========== Mastery idempotent ==========
@Test
void shouldMarkMasteredIdempotently() {
// Manually insert a wrong question
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId);
wq.setUserId(userId);
wq.setQuestionId("q-master");
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
wq.setLastWrongTime(LocalDateTime.now().minusDays(1));
wq.setWrongCount(3);
wq.setMasterStatus("PENDING");
wrongQuestionMapper.insert(wq);
// Mark mastered
wrongQuestionService.markMastered(wq.getId(), userId, tenantId);
WrongQuestionDO reloaded = wrongQuestionMapper.selectById(wq.getId());
assertEquals("MASTERED", reloaded.getMasterStatus());
assertNotNull(reloaded.getMasteredTime());
assertEquals(3, reloaded.getWrongCount()); // count preserved
// Mark mastered again — idempotent
wrongQuestionService.markMastered(wq.getId(), userId, tenantId);
WrongQuestionDO again = wrongQuestionMapper.selectById(wq.getId());
assertEquals("MASTERED", again.getMasterStatus());
}
@Test
void shouldUnmarkMasteredIdempotently() {
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId);
wq.setUserId(userId);
wq.setQuestionId("q-unmaster");
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
wq.setLastWrongTime(LocalDateTime.now().minusDays(1));
wq.setWrongCount(2);
wq.setMasterStatus("MASTERED");
wq.setMasteredTime(LocalDateTime.now().minusDays(1));
wrongQuestionMapper.insert(wq);
wrongQuestionService.unmarkMastered(wq.getId(), userId, tenantId);
WrongQuestionDO reloaded = wrongQuestionMapper.selectById(wq.getId());
assertEquals("PENDING", reloaded.getMasterStatus());
assertNull(reloaded.getMasteredTime());
assertEquals(2, reloaded.getWrongCount()); // count preserved
// Unmark again — idempotent
wrongQuestionService.unmarkMastered(wq.getId(), userId, tenantId);
WrongQuestionDO again = wrongQuestionMapper.selectById(wq.getId());
assertEquals("PENDING", again.getMasterStatus());
}
// ========== Cross ownership ==========
@Test
void shouldRejectAccessForDifferentUser() {
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId);
wq.setUserId(userId);
wq.setQuestionId("q-cross");
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(1));
wq.setLastWrongTime(LocalDateTime.now());
wq.setWrongCount(1);
wq.setMasterStatus("PENDING");
wrongQuestionMapper.insert(wq);
// Different user tries to get detail
assertServiceException(
() -> wrongQuestionService.getWrongQuestionDetail(wq.getId(), 999L, tenantId),
WRONG_QUESTION_NOT_FOUND);
// Different user tries to mark mastered
assertServiceException(
() -> wrongQuestionService.markMastered(wq.getId(), 999L, tenantId),
WRONG_QUESTION_NOT_FOUND);
}
@Test
void shouldRejectForDifferentTenant() {
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId);
wq.setUserId(userId);
wq.setQuestionId("q-cross-tenant");
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(1));
wq.setLastWrongTime(LocalDateTime.now());
wq.setWrongCount(1);
wq.setMasterStatus("PENDING");
wrongQuestionMapper.insert(wq);
assertServiceException(
() -> wrongQuestionService.getWrongQuestionDetail(wq.getId(), userId, 999L),
WRONG_QUESTION_NOT_FOUND);
}
// ========== Page bounds ==========
@Test
void shouldPaginateWrongQuestions() {
// Insert 5 wrong questions
for (int i = 1; i <= 5; i++) {
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId);
wq.setUserId(userId);
wq.setQuestionId("q-page-" + String.format("%03d", i));
wq.setStem("Question page " + i);
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(i));
wq.setLastWrongTime(LocalDateTime.now().minusHours(i));
wq.setWrongCount(i);
wq.setMasterStatus(i % 2 == 0 ? "MASTERED" : "PENDING");
wrongQuestionMapper.insert(wq);
}
// Page 1, size 3 — no filter
PageResult<WrongQuestionPageItemRespVO> page1 = wrongQuestionService.getWrongQuestionPage(
userId, tenantId, 1, 3, null);
assertEquals(5, page1.getTotal());
assertEquals(3, page1.getList().size());
// Page 2, size 3
PageResult<WrongQuestionPageItemRespVO> page2 = wrongQuestionService.getWrongQuestionPage(
userId, tenantId, 2, 3, null);
assertEquals(2, page2.getList().size());
// Filter by PENDING
PageResult<WrongQuestionPageItemRespVO> pending = wrongQuestionService.getWrongQuestionPage(
userId, tenantId, 1, 10, "PENDING");
assertEquals(3, pending.getTotal());
// Filter by MASTERED
PageResult<WrongQuestionPageItemRespVO> mastered = wrongQuestionService.getWrongQuestionPage(
userId, tenantId, 1, 10, "MASTERED");
assertEquals(2, mastered.getTotal());
// Different user — empty
PageResult<WrongQuestionPageItemRespVO> other = wrongQuestionService.getWrongQuestionPage(
999L, tenantId, 1, 10, null);
assertEquals(0, other.getTotal());
}
// ========== Review session: ownership + safe snapshot ==========
@Test
void shouldCreateReviewSessionFromOwnWrongQuestions() {
// Insert 2 wrong questions
for (int i = 1; i <= 2; i++) {
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId);
wq.setUserId(userId);
wq.setQuestionId("q-review-" + i);
wq.setStem("Review question " + i);
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"Option A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Option B\",\"order\":2.0}]");
wq.setContentVersion("v1");
wq.setLatestCorrectAnswer("B");
wq.setLatestExplanation("Explanation " + i);
wq.setFirstWrongTime(LocalDateTime.now().minusDays(i));
wq.setLastWrongTime(LocalDateTime.now());
wq.setWrongCount(i);
wq.setMasterStatus("PENDING");
wrongQuestionMapper.insert(wq);
}
WrongQuestionReviewReqVO req = new WrongQuestionReviewReqVO();
req.setClientSessionId("uuid-review-1");
req.setWrongQuestionIds(List.of(
wrongQuestionMapper.selectList().get(0).getId(),
wrongQuestionMapper.selectList().get(1).getId()));
PracticeSessionRespVO resp = wrongQuestionService.createReviewSession(req, userId, tenantId);
assertNotNull(resp.getSessionId());
assertEquals("ACTIVE", resp.getStatus());
assertEquals(2, resp.getQuestionCount());
assertEquals(2, resp.getQuestions().size());
// Verify questions are safe — no correct answer exposed
for (var q : resp.getQuestions()) {
assertNotNull(q.getStem());
assertNotNull(q.getOptions());
assertTrue(q.getOptions().size() > 0);
// correctAnswer and explanation are NOT on PracticeQuestionRespVO (structural guarantee)
}
// Verify idempotent replay
PracticeSessionRespVO replay = wrongQuestionService.createReviewSession(req, userId, tenantId);
assertEquals(resp.getSessionId(), replay.getSessionId());
}
@Test
void shouldRejectReviewSessionWithOtherUsersWrongQuestions() {
// Insert wrong question for user 100
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId);
wq.setUserId(userId);
wq.setQuestionId("q-other-owner");
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now());
wq.setLastWrongTime(LocalDateTime.now());
wq.setWrongCount(1);
wq.setMasterStatus("PENDING");
wrongQuestionMapper.insert(wq);
WrongQuestionReviewReqVO req = new WrongQuestionReviewReqVO();
req.setClientSessionId("uuid-cross-review");
req.setWrongQuestionIds(List.of(wq.getId()));
// Different user tries to create review
assertServiceException(
() -> wrongQuestionService.createReviewSession(req, 999L, tenantId),
WRONG_QUESTION_REVIEW_NOT_ALL_OWNED);
}
// ========== DuplicateKeyException on clientSessionId race ==========
@Test
void shouldHandleClientSessionIdRaceInReviewSession() {
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId);
wq.setUserId(userId);
wq.setQuestionId("q-race");
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setContentVersion("v1");
wq.setFirstWrongTime(LocalDateTime.now());
wq.setLastWrongTime(LocalDateTime.now());
wq.setWrongCount(1);
wq.setMasterStatus("PENDING");
wrongQuestionMapper.insert(wq);
WrongQuestionReviewReqVO req = new WrongQuestionReviewReqVO();
req.setClientSessionId("uuid-race-review");
req.setWrongQuestionIds(List.of(wq.getId()));
// Create first session
PracticeSessionRespVO first = wrongQuestionService.createReviewSession(req, userId, tenantId);
assertNotNull(first.getSessionId());
// Same clientSessionId → should return existing (idempotent)
PracticeSessionRespVO second = wrongQuestionService.createReviewSession(req, userId, tenantId);
assertEquals(first.getSessionId(), second.getSessionId());
}
// ========== Detail: includes correct answer and explanation ==========
@Test
void shouldExposeCorrectAnswerInDetail() {
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId);
wq.setUserId(userId);
wq.setQuestionId("q-detail");
wq.setStem("Detail question");
wq.setType("choice");
wq.setDifficulty("hard");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setContentVersion("v2");
wq.setLatestCorrectAnswer("B");
wq.setLatestExplanation("Because B is correct");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(3));
wq.setLastWrongTime(LocalDateTime.now());
wq.setWrongCount(2);
wq.setMasterStatus("PENDING");
wrongQuestionMapper.insert(wq);
WrongQuestionDetailRespVO detail = wrongQuestionService.getWrongQuestionDetail(wq.getId(), userId, tenantId);
assertEquals("q-detail", detail.getQuestionId());
assertEquals("Detail question", detail.getStem());
assertEquals("hard", detail.getDifficulty());
assertEquals("B", detail.getCorrectAnswer());
assertEquals("Because B is correct", detail.getExplanation());
assertEquals(2, detail.getWrongCount());
assertNotNull(detail.getFirstWrongTime());
assertNotNull(detail.getLastWrongTime());
}
// ========== New wrong after MASTERED reopens PENDING ==========
@Test
void shouldReopenPendingAfterMasteredOnNewWrong() {
// Insert a MASTERED wrong question
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId);
wq.setUserId(userId);
wq.setQuestionId("q-reopen");
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setContentVersion("v1");
wq.setLatestCorrectAnswer("B");
wq.setLatestExplanation("Old explanation");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
wq.setLastWrongTime(LocalDateTime.now().minusDays(3));
wq.setWrongCount(3);
wq.setMasterStatus("MASTERED");
wq.setMasteredTime(LocalDateTime.now().minusDays(2));
wrongQuestionMapper.insert(wq);
// Now submit a new wrong answer for the same question (new report)
SessionFixture f = createSessionWithQuestions("uuid-reopen", 1, "B");
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 999L, f.sessionId, "A", false);
detail.setQuestionId("q-reopen"); // override to match existing wrong question
detail.setContentVersion("v2");
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 999L, f.sessionId, List.of(detail));
WrongQuestionDO reloaded = wrongQuestionMapper.selectById(wq.getId());
assertEquals("PENDING", reloaded.getMasterStatus(),
"new wrong after MASTERED must reopen PENDING");
assertNull(reloaded.getMasteredTime(),
"mastered_time must be cleared on new wrong");
assertEquals(4, reloaded.getWrongCount(),
"wrong_count must increment (3→4)");
assertEquals("v2", reloaded.getContentVersion(),
"contentVersion must be updated from new report detail");
assertEquals(999L, reloaded.getLastReportId().longValue());
}
// ========== Replay same report does NOT reopen/double-count ==========
@Test
void shouldNotReopenOrDoubleCountOnReplayAfterMastered() {
// Insert MASTERED question, then submit once → PENDING + count=4
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId);
wq.setUserId(userId);
wq.setQuestionId("q-replay-after-mastered");
wq.setStem("Test stem");
wq.setType("choice");
wq.setDifficulty("easy");
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setContentVersion("v1");
wq.setLatestCorrectAnswer("B");
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
wq.setLastWrongTime(LocalDateTime.now().minusDays(3));
wq.setWrongCount(3);
wq.setMasterStatus("MASTERED");
wq.setMasteredTime(LocalDateTime.now().minusDays(2));
wrongQuestionMapper.insert(wq);
SessionFixture f = createSessionWithQuestions("uuid-replay-mastered", 1, "B");
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 1001L, f.sessionId, "A", false);
detail.setQuestionId("q-replay-after-mastered");
detail.setContentVersion("v2");
// First upsert: reopens PENDING, count 4
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1001L, f.sessionId, List.of(detail));
// Replay same report: idempotency guard blocks → count stays 4, status stays PENDING
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1001L, f.sessionId, List.of(detail));
WrongQuestionDO reloaded = wrongQuestionMapper.selectById(wq.getId());
assertEquals("PENDING", reloaded.getMasterStatus());
assertEquals(4, reloaded.getWrongCount(),
"replay must not double-count");
assertNull(reloaded.getMasteredTime());
}
// ========== Review session fingerprint mismatch ==========
@Test
void shouldRejectReviewSessionWithDifferentIdSet() {
// Insert 2 wrong questions
WrongQuestionDO wq1 = new WrongQuestionDO();
wq1.setTenantId(tenantId); wq1.setUserId(userId);
wq1.setQuestionId("q-fp-1"); wq1.setStem("Q1"); wq1.setType("choice");
wq1.setDifficulty("easy"); wq1.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq1.setContentVersion("v1"); wq1.setFirstWrongTime(LocalDateTime.now());
wq1.setLastWrongTime(LocalDateTime.now()); wq1.setWrongCount(1);
wq1.setMasterStatus("PENDING");
wrongQuestionMapper.insert(wq1);
WrongQuestionDO wq2 = new WrongQuestionDO();
wq2.setTenantId(tenantId); wq2.setUserId(userId);
wq2.setQuestionId("q-fp-2"); wq2.setStem("Q2"); wq2.setType("choice");
wq2.setDifficulty("easy"); wq2.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq2.setContentVersion("v1"); wq2.setFirstWrongTime(LocalDateTime.now());
wq2.setLastWrongTime(LocalDateTime.now()); wq2.setWrongCount(1);
wq2.setMasterStatus("PENDING");
wrongQuestionMapper.insert(wq2);
// Create session with IDs [wq1.id, wq2.id]
WrongQuestionReviewReqVO req1 = new WrongQuestionReviewReqVO();
req1.setClientSessionId("uuid-fp-mismatch");
req1.setWrongQuestionIds(List.of(wq1.getId(), wq2.getId()));
PracticeSessionRespVO first = wrongQuestionService.createReviewSession(req1, userId, tenantId);
assertNotNull(first.getSessionId());
// Replay with different ID set [wq1.id only] → mismatch
WrongQuestionReviewReqVO req2 = new WrongQuestionReviewReqVO();
req2.setClientSessionId("uuid-fp-mismatch");
req2.setWrongQuestionIds(List.of(wq1.getId()));
assertServiceException(
() -> wrongQuestionService.createReviewSession(req2, userId, tenantId),
SESSION_IDEMPOTENCY_MISMATCH);
// Replay with same IDs but different order — fingerprint sorts, so same session returned
WrongQuestionReviewReqVO req3 = new WrongQuestionReviewReqVO();
req3.setClientSessionId("uuid-fp-mismatch");
req3.setWrongQuestionIds(List.of(wq2.getId(), wq1.getId())); // reversed order
PracticeSessionRespVO replay = wrongQuestionService.createReviewSession(req3, userId, tenantId);
assertEquals(first.getSessionId(), replay.getSessionId(), "sorted fingerprint must match");
}
// ========== Cross-user review session idempotency ==========
@Test
void shouldRejectReviewSessionReplayWithDifferentUser() {
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId); wq.setUserId(userId);
wq.setQuestionId("q-cross-user-session"); wq.setStem("Q"); wq.setType("choice");
wq.setDifficulty("easy"); wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setContentVersion("v1"); wq.setFirstWrongTime(LocalDateTime.now());
wq.setLastWrongTime(LocalDateTime.now()); wq.setWrongCount(1);
wq.setMasterStatus("PENDING");
wrongQuestionMapper.insert(wq);
WrongQuestionReviewReqVO req = new WrongQuestionReviewReqVO();
req.setClientSessionId("uuid-cross-user-review");
req.setWrongQuestionIds(List.of(wq.getId()));
// User 100 creates session
PracticeSessionRespVO first = wrongQuestionService.createReviewSession(req, userId, tenantId);
assertNotNull(first.getSessionId());
// Different user tries to replay same clientSessionId → not found
assertServiceException(
() -> wrongQuestionService.createReviewSession(req, 999L, tenantId),
SESSION_NOT_FOUND);
}
// ========== Concurrent review session create race ==========
@Test
void shouldHandleConcurrentReviewSessionCreateRace() throws Exception {
// Insert wrong question
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId); wq.setUserId(userId);
wq.setQuestionId("q-conc-review"); wq.setStem("Q"); wq.setType("choice");
wq.setDifficulty("easy"); wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
wq.setContentVersion("v1"); wq.setFirstWrongTime(LocalDateTime.now());
wq.setLastWrongTime(LocalDateTime.now()); wq.setWrongCount(1);
wq.setMasterStatus("PENDING");
wrongQuestionMapper.insert(wq);
WrongQuestionReviewReqVO req = new WrongQuestionReviewReqVO();
req.setClientSessionId("uuid-conc-review-race");
req.setWrongQuestionIds(List.of(wq.getId()));
CountDownLatch ready = new CountDownLatch(2);
CountDownLatch go = new CountDownLatch(1);
AtomicReference<PracticeSessionRespVO> r1 = new AtomicReference<>();
AtomicReference<PracticeSessionRespVO> r2 = new AtomicReference<>();
Thread t1 = new Thread(() -> {
try { ready.countDown(); go.await();
r1.set(wrongQuestionService.createReviewSession(req, userId, tenantId)); }
catch (Exception ignored) {}
});
Thread t2 = new Thread(() -> {
try { ready.countDown(); go.await();
r2.set(wrongQuestionService.createReviewSession(req, userId, tenantId)); }
catch (Exception ignored) {}
});
t1.start(); t2.start();
ready.await(); go.countDown();
t1.join(10000); t2.join(10000);
assertNotNull(r1.get(), "thread 1 must get a response");
assertNotNull(r2.get(), "thread 2 must get a response");
assertEquals(r1.get().getSessionId(), r2.get().getSessionId(),
"concurrent same-fingerprint creates must converge on one session");
// Only one session created
List<PracticeSessionDO> sessions = sessionMapper.selectList(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PracticeSessionDO>()
.eq(PracticeSessionDO::getClientSessionId, "uuid-conc-review-race"));
assertEquals(1, sessions.size());
}
// ========== contentVersion population ==========
@Test
void shouldPopulateContentVersionFromReportDetail() {
SessionFixture f = createSessionWithQuestions("uuid-contentver", 1, "B");
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 2001L, f.sessionId, "A", false);
detail.setContentVersion("v3-custom");
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 2001L, f.sessionId, List.of(detail));
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
assertEquals(1, all.size());
assertEquals("v3-custom", all.get(0).getContentVersion(),
"wrong question contentVersion must come from report detail");
// Detail view exposes contentVersion
WrongQuestionDetailRespVO detailVO = wrongQuestionService.getWrongQuestionDetail(
all.get(0).getId(), userId, tenantId);
assertEquals("v3-custom", detailVO.getContentVersion());
}
// ========== Empty detail list ==========
@Test
void shouldHandleEmptyDetailList() {
// Should not throw
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, 1L, List.of());
assertEquals(0, wrongQuestionMapper.selectList().size());
}
// ========== Wrong question not found ==========
@Test
void shouldThrowOnNotFound() {
assertServiceException(
() -> wrongQuestionService.getWrongQuestionDetail(99999L, userId, tenantId),
WRONG_QUESTION_NOT_FOUND);
}
}

View File

@@ -1,3 +1,6 @@
DELETE FROM education_wrong_question_idempotency;
DELETE FROM education_wrong_question;
DELETE FROM education_practice_report_detail;
DELETE FROM education_practice_report;
DELETE FROM education_submit_idempotency;

View File

@@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS "education_practice_session" (
"difficulty" VARCHAR(32) DEFAULT NULL,
"version" INT NOT NULL DEFAULT 1,
"last_client_sequence" INT DEFAULT NULL,
"review_fingerprint" VARCHAR(64) DEFAULT NULL,
"creator" VARCHAR(64) DEFAULT '',
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" VARCHAR(64) DEFAULT '',
@@ -133,6 +134,7 @@ CREATE TABLE IF NOT EXISTS "education_practice_report_detail" (
"correct_answer" CLOB DEFAULT NULL,
"is_correct" BIT NOT NULL DEFAULT FALSE,
"explanation" CLOB DEFAULT NULL,
"content_version" VARCHAR(64) NOT NULL DEFAULT '',
"creator" VARCHAR(64) DEFAULT '',
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" VARCHAR(64) DEFAULT '',
@@ -143,3 +145,58 @@ CREATE TABLE IF NOT EXISTS "education_practice_report_detail" (
);
CREATE INDEX IF NOT EXISTS "idx_detail_session" ON "education_practice_report_detail" ("session_id");
-- Ticket #9: add content_version + options snapshot to report detail
ALTER TABLE "education_practice_report_detail"
ADD COLUMN IF NOT EXISTS "content_version" VARCHAR(64) NOT NULL DEFAULT '';
ALTER TABLE "education_practice_report_detail"
ADD COLUMN IF NOT EXISTS "options" CLOB DEFAULT NULL;
CREATE TABLE IF NOT EXISTS "education_wrong_question" (
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"tenant_id" BIGINT NOT NULL,
"user_id" BIGINT NOT NULL,
"question_id" VARCHAR(64) NOT NULL,
"stem" CLOB NOT NULL,
"type" VARCHAR(32) NOT NULL,
"difficulty" VARCHAR(32) DEFAULT NULL,
"options" CLOB NOT NULL,
"content_version" VARCHAR(64) NOT NULL DEFAULT '',
"latest_correct_answer" CLOB DEFAULT NULL,
"latest_explanation" CLOB DEFAULT NULL,
"first_wrong_time" TIMESTAMP NOT NULL,
"last_wrong_time" TIMESTAMP NOT NULL,
"wrong_count" INT NOT NULL DEFAULT 1,
"master_status" VARCHAR(20) NOT NULL DEFAULT 'PENDING',
"mastered_time" TIMESTAMP DEFAULT NULL,
"last_report_id" BIGINT DEFAULT NULL,
"last_session_id" BIGINT DEFAULT NULL,
"creator" VARCHAR(64) DEFAULT '',
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" VARCHAR(64) DEFAULT '',
"update_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" BIT NOT NULL DEFAULT FALSE,
PRIMARY KEY ("id"),
CONSTRAINT "uk_tenant_user_question" UNIQUE ("tenant_id", "user_id", "question_id")
);
CREATE INDEX IF NOT EXISTS "idx_wq_tenant_user_status" ON "education_wrong_question" ("tenant_id", "user_id", "master_status");
CREATE INDEX IF NOT EXISTS "idx_wq_tenant_user_last_wrong" ON "education_wrong_question" ("tenant_id", "user_id", "last_wrong_time");
CREATE TABLE IF NOT EXISTS "education_wrong_question_idempotency" (
"id" BIGINT NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"tenant_id" BIGINT NOT NULL,
"user_id" BIGINT NOT NULL,
"wrong_question_id" BIGINT DEFAULT NULL,
"report_id" BIGINT NOT NULL,
"question_id" VARCHAR(64) NOT NULL,
"creator" VARCHAR(64) DEFAULT '',
"create_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updater" VARCHAR(64) DEFAULT '',
"update_time" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
"deleted" BIT NOT NULL DEFAULT FALSE,
PRIMARY KEY ("id"),
CONSTRAINT "uk_tenant_user_question_report" UNIQUE ("tenant_id", "user_id", "question_id", "report_id")
);
CREATE INDEX IF NOT EXISTS "idx_wq_idem_report" ON "education_wrong_question_idempotency" ("report_id");