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

@@ -0,0 +1,25 @@
-- =============================================
-- Education 模块 — 错题本 DDL Rollback
-- Migration: 005
-- =============================================
-- IMPORTANT: This is a documentation-only rollback.
-- No DROP/ALTER/DELETE statements are executed. The wrong_question
-- table is provenance-safe: it only accumulates data and mastering
-- is a status flag. Dropping these tables would lose student error
-- history with no recovery path.
--
-- What this migration created:
-- - education_wrong_question (new table)
-- - education_wrong_question_idempotency (new table)
-- - education_practice_report_detail.options (new column)
-- - education_practice_session.review_fingerprint (new column)
--
-- Manual rollback requires:
-- 1. Verified database backup before rollback
-- 2. Operator approval (DBA sign-off)
-- 3. Provenance of all wrong-question records preserved (exported)
-- 4. Soft-delete via deleted = b'1' before any hard drop
--
-- These tables are NOT deleted by this script. Wrong history is
-- retained; if deletion is required by external policy, consult
-- the DBA for a verified rollback procedure.

View File

@@ -0,0 +1,154 @@
-- =============================================
-- Education 模块 — 错题本 DDL
-- Ticket #9: 错题自动收集、复习练习创建
-- Migration: 005
-- Prerequisites: 004-education-submit-report.sql (report + detail tables)
-- =============================================
-- =============================================
-- Preconditions
-- =============================================
-- Operator is expected to verify:
-- SELECT COUNT(*) FROM information_schema.tables
-- WHERE table_schema = DATABASE()
-- AND table_name IN ('education_wrong_question',
-- 'education_wrong_question_idempotency');
-- Result MUST be 0 before executing this migration.
--
-- Verify prerequisite tables exist:
-- SELECT COUNT(*) FROM information_schema.tables
-- WHERE table_schema = DATABASE()
-- AND table_name IN ('education_practice_report',
-- 'education_practice_report_detail');
-- Result MUST be 2.
-- =============================================
-- PracticeReportDetailDO: add options snapshot column
-- =============================================
-- PracticeReportDetailDO: add content_version + options snapshot columns
-- =============================================
-- Purpose: At submit time, snapshot question content version and options
-- (without isCorrect) so wrong-question book and review sessions have
-- stable display data. The options are already stripped of isCorrect
-- by the submit flow.
ALTER TABLE `education_practice_report_detail`
ADD COLUMN `content_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '题目内容版本快照',
ADD COLUMN `options` TEXT DEFAULT NULL COMMENT '选项快照 JSON不含 isCorrect';
-- =============================================
-- PracticeSessionDO: add review fingerprint column
-- =============================================
-- Purpose: Persist the canonical fingerprint of wrong-question IDs used
-- to create a review session. On idempotent replay, the fingerprint
-- is compared: same tenant+clientSessionId+sameUser+sortedIDs match
-- returns the existing session; different ID set returns
-- SESSION_IDEMPOTENCY_MISMATCH.
ALTER TABLE `education_practice_session`
ADD COLUMN `review_fingerprint` VARCHAR(64) DEFAULT NULL COMMENT '复习会话题目指纹SHA-256 of sorted unique wrongQuestionIds';
-- 错题表
-- =============================================
-- Purpose: Persistent wrong-question book per student.
-- Each (tenant, user, question) is a unique entry.
-- Repeated wrong answers on the SAME question increment wrong_count
-- and update last_wrong_time. The idempotency guard table ensures
-- each (tenant, user, question, report) can upsert at most once.
--
-- master_status values: 'PENDING' (default) | 'MASTERED'
-- Marking mastered retains the full history and count; it does NOT
-- delete or archive the record. Students can optionally un-master.
--
-- Snapshot fields (stem, type, difficulty, options, content_version):
-- populated from the latest report detail that touched this question.
-- These are for listing/detail display without joining report details.
--
-- latest_correct_answer, latest_explanation:
-- also from the latest report detail; available for detail display
-- post-submit (not exposed in review session creation pre-submit).
--
-- Indexes:
-- uk_tenant_user_question — per-tenant, per-user, per-question uniqueness.
-- INSERT ... ON DUPLICATE KEY UPDATE is the primary write path.
-- idx_tenant_user_status — covers filtered list queries (page with status filter).
-- idx_tenant_user_last_wrong — covers time-sorted listing.
CREATE TABLE `education_wrong_question` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
`user_id` BIGINT NOT NULL COMMENT '学生用户编号',
`question_id` VARCHAR(64) NOT NULL COMMENT '原始题目 ID',
-- snapshot fields for listing / detail (from latest report detail)
`stem` TEXT NOT NULL COMMENT '题干快照(最新)',
`type` VARCHAR(32) NOT NULL COMMENT '题型快照',
`difficulty` VARCHAR(32) DEFAULT NULL COMMENT '难度快照',
`options` JSON NOT NULL COMMENT '选项快照 JSON不含 isCorrect',
`content_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '题目内容版本',
`latest_correct_answer` TEXT DEFAULT NULL COMMENT '正确答案快照(最新,供详情展示)',
`latest_explanation` TEXT DEFAULT NULL COMMENT '解析快照(最新,供详情展示)',
-- timing & count
`first_wrong_time` DATETIME NOT NULL COMMENT '首次错误时间',
`last_wrong_time` DATETIME NOT NULL COMMENT '最近错误时间',
`wrong_count` INT NOT NULL DEFAULT 1 COMMENT '累计错误次数',
-- mastery
`master_status` VARCHAR(20) NOT NULL DEFAULT 'PENDING'
COMMENT '掌握状态PENDING-待掌握, MASTERED-已掌握',
`mastered_time` DATETIME DEFAULT NULL COMMENT '标记掌握时间',
-- provenance
`last_report_id` BIGINT DEFAULT NULL COMMENT '最近关联的报告 ID',
`last_session_id` BIGINT DEFAULT NULL COMMENT '最近关联的会话 ID',
-- audit
`creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_user_question` (`tenant_id`, `user_id`, `question_id`),
KEY `idx_tenant_user_status` (`tenant_id`, `user_id`, `master_status`),
KEY `idx_tenant_user_last_wrong` (`tenant_id`, `user_id`, `last_wrong_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-错题本';
-- =============================================
-- 错题流水幂等表
-- =============================================
-- Purpose: Ensure each (tenant, user, question, report) upserts the
-- wrong-question book exactly once. The submitSession transaction
-- INSERT IGNOREs into this table BEFORE the wrong question upsert;
-- a duplicate means this report already contributed to the count.
-- This guards against:
-- - Replayed submit (idempotent resubmit) double-counting
-- - Concurrent submit races where both threads evaluate the
-- same report details
--
-- Indexes:
-- uk_tenant_user_question_report — per (tenant, user, question, report) uniqueness.
-- INSERT IGNORE provides the idempotency guard BEFORE upserting.
-- wrong_question_id is filled after upsert for audit purposes.
-- idx_report — fast lookup by report for audit/debug.
CREATE TABLE `education_wrong_question_idempotency` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
`user_id` BIGINT NOT NULL COMMENT '学生用户编号',
`wrong_question_id` BIGINT DEFAULT NULL COMMENT '错题记录 IDupsert 后填充)',
`report_id` BIGINT NOT NULL COMMENT '报告 ID',
`question_id` VARCHAR(64) NOT NULL COMMENT '题目 ID',
`creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_tenant_user_question_report` (`tenant_id`, `user_id`, `question_id`, `report_id`),
KEY `idx_report` (`report_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-错题流水幂等';
-- =============================================
-- Post-migration verification queries
-- =============================================
-- Verify new tables exist:
-- SHOW CREATE TABLE education_wrong_question;
-- SHOW CREATE TABLE education_wrong_question_idempotency;
-- Verify unique keys are enforced:
-- SHOW INDEX FROM education_wrong_question WHERE Key_name = 'uk_tenant_user_question';
-- SHOW INDEX FROM education_wrong_question_idempotency WHERE Key_name = 'uk_tenant_user_question_report';
-- Verify no orphan data (should be 0 after fresh migration):
-- SELECT COUNT(*) FROM education_wrong_question;
-- SELECT COUNT(*) FROM education_wrong_question_idempotency;

View File

@@ -0,0 +1,112 @@
package cn.iocoder.yudao.module.education.controller.app.wrong;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.security.core.LoginUser;
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
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.WrongQuestionPageReqVO;
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionReviewReqVO;
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
/**
* 错题本 Controller — 学生端已认证接口。
*
* @author 恭学教育
*/
@Tag(name = "用户 APP - 错题本")
@RestController
@RequestMapping("/education")
@Validated
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class WrongQuestionController {
@Resource
private WrongQuestionService wrongQuestionService;
// ========== 错题列表 ==========
@GetMapping("/wrong-question/page")
@Operation(summary = "分页查询错题列表",
description = "按当前用户分页查询错题,支持按掌握状态筛选,按最近错误时间降序。")
public CommonResult<PageResult<WrongQuestionPageItemRespVO>> page(@Valid WrongQuestionPageReqVO reqVO) {
return success(wrongQuestionService.getWrongQuestionPage(
getUserId(), getTenantId(),
reqVO.getPageNo(), reqVO.getPageSize(),
reqVO.getMasterStatus()));
}
// ========== 错题详情 ==========
@GetMapping("/wrong-question/get")
@Operation(summary = "获取错题详情",
description = "获取指定错题的完整信息,含正确答案和解析。仅限本人错题。")
public CommonResult<WrongQuestionDetailRespVO> get(
@Parameter(description = "错题记录 ID", required = true) @RequestParam Long id) {
return success(wrongQuestionService.getWrongQuestionDetail(id, getUserId(), getTenantId()));
}
// ========== 掌握操作 ==========
@PutMapping("/wrong-question/master")
@Operation(summary = "标记错题已掌握(幂等)",
description = "标记指定错题为已掌握。已掌握的错题再次调用无操作。不删除历史记录。")
public CommonResult<Boolean> master(
@Parameter(description = "错题记录 ID", required = true) @RequestParam Long id) {
wrongQuestionService.markMastered(id, getUserId(), getTenantId());
return success(true);
}
@PutMapping("/wrong-question/unmaster")
@Operation(summary = "取消掌握标记(幂等)",
description = "取消指定错题的掌握标记。未掌握的错题再次调用无操作。")
public CommonResult<Boolean> unmaster(
@Parameter(description = "错题记录 ID", required = true) @RequestParam Long id) {
wrongQuestionService.unmarkMastered(id, getUserId(), getTenantId());
return success(true);
}
// ========== 复习入口 ==========
@PostMapping("/wrong-question/review-session")
@Operation(summary = "从错题创建复习练习(幂等)",
description = "选择错题 ID 列表创建一次复习练习会话。服务端验证所有权。"
+ "同一 clientSessionId 重复调用返回已有会话。"
+ "使用错题快照创建题目,不暴露正确答案。")
public CommonResult<PracticeSessionRespVO> createReviewSession(
@Valid @RequestBody WrongQuestionReviewReqVO reqVO) {
return success(wrongQuestionService.createReviewSession(reqVO, getUserId(), getTenantId()));
}
// ========== security helpers ==========
private Long getUserId() {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null) {
throw exception(UNAUTHORIZED);
}
return loginUser.getId();
}
private Long getTenantId() {
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
if (loginUser == null) {
throw exception(UNAUTHORIZED);
}
return loginUser.getTenantId();
}
}

View File

@@ -0,0 +1,65 @@
package cn.iocoder.yudao.module.education.controller.app.wrong.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 错题详情响应 VO — 含正确答案和解析(仅已提交后可用)。
*
* @author 恭学教育
*/
@Schema(description = "错题详情")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WrongQuestionDetailRespVO {
@Schema(description = "错题记录 ID", example = "1")
private Long id;
@Schema(description = "原始题目 ID", example = "q-001")
private String questionId;
@Schema(description = "题干快照", example = "1+1=?")
private String stem;
@Schema(description = "题型", example = "choice")
private String type;
@Schema(description = "难度", example = "easy")
private String difficulty;
@Schema(description = "选项快照 JSON", example = "[{\"label\":\"A\",\"content\":\"1\",\"order\":1.0}]")
private String options;
@Schema(description = "题目内容版本", example = "v1")
private String contentVersion;
@Schema(description = "正确答案", example = "B")
private String correctAnswer;
@Schema(description = "解析", example = "因为...所以选B")
private String explanation;
@Schema(description = "首次错误时间", example = "2026-07-20T10:00:00")
private LocalDateTime firstWrongTime;
@Schema(description = "最近错误时间", example = "2026-07-27T10:00:00")
private LocalDateTime lastWrongTime;
@Schema(description = "累计错误次数", example = "3")
private Integer wrongCount;
@Schema(description = "掌握状态", example = "PENDING")
private String masterStatus;
@Schema(description = "标记掌握时间", example = "2026-07-28T10:00:00")
private LocalDateTime masteredTime;
}

View File

@@ -0,0 +1,47 @@
package cn.iocoder.yudao.module.education.controller.app.wrong.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 错题列表项响应 VO。
*
* @author 恭学教育
*/
@Schema(description = "错题列表项")
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class WrongQuestionPageItemRespVO {
@Schema(description = "错题记录 ID", example = "1")
private Long id;
@Schema(description = "原始题目 ID", example = "q-001")
private String questionId;
@Schema(description = "题干快照", example = "1+1=?")
private String stem;
@Schema(description = "题型", example = "choice")
private String type;
@Schema(description = "难度", example = "easy")
private String difficulty;
@Schema(description = "累计错误次数", example = "3")
private Integer wrongCount;
@Schema(description = "最近错误时间", example = "2026-07-27T10:00:00")
private LocalDateTime lastWrongTime;
@Schema(description = "掌握状态", example = "PENDING")
private String masterStatus;
}

View File

@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.education.controller.app.wrong.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 错题分页查询请求 VO。
*
* @author 恭学教育
*/
@Schema(description = "错题分页查询请求")
@Data
public class WrongQuestionPageReqVO {
@Schema(description = "页码", example = "1")
@Min(value = 1, message = "页码最小为 1")
@NotNull(message = "页码不能为空")
private Integer pageNo = 1;
@Schema(description = "每页条数", example = "10")
@Min(value = 1, message = "每页条数最小为 1")
@Max(value = 100, message = "每页条数最大为 100")
@NotNull(message = "每页条数不能为空")
private Integer pageSize = 10;
@Schema(description = "掌握状态筛选(留空=全部)", example = "PENDING")
private String masterStatus;
}

View File

@@ -0,0 +1,28 @@
package cn.iocoder.yudao.module.education.controller.app.wrong.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* 错题复习练习创建请求 VO。
*
* @author 恭学教育
*/
@Schema(description = "错题复习练习创建请求")
@Data
public class WrongQuestionReviewReqVO {
@Schema(description = "客户端生成的会话标识UUID用于幂等创建", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440000")
@NotEmpty(message = "clientSessionId 不能为空")
private String clientSessionId;
@Schema(description = "错题记录 ID 列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2, 3]")
@NotEmpty(message = "错题 ID 列表不能为空")
@Size(max = 100, message = "单次复习题目数不能超过 100")
private List<Long> wrongQuestionIds;
}

View File

@@ -60,4 +60,10 @@ public class PracticeReportDetailDO extends TenantBaseDO {
/** 解析快照 */
private String explanation;
/** 题目内容版本快照 */
private String contentVersion;
/** 选项快照 JSON不含 isCorrect */
private String options;
}

View File

@@ -56,4 +56,7 @@ public class PracticeSessionDO extends TenantBaseDO {
/** 会话级最后接受的客户端命令序号(跨题目单调递增),用于拒绝乱序请求 */
private Integer lastClientSequence;
/** 复习会话题目指纹SHA-256 of sorted unique wrongQuestionIds用于幂等重放校验 */
private String reviewFingerprint;
}

View File

@@ -0,0 +1,80 @@
package cn.iocoder.yudao.module.education.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.time.LocalDateTime;
/**
* 错题本 DO — 学生错题持久记录。
*
* <p>同一 (tenant, user, question) 唯一一条。重复答错只更新计数和快照。
* 标记掌握不删除记录masterStatus 字段标识当前状态。</p>
*
* <p>快照字段stem/type/difficulty/options/contentVersion来自最近一次
* 错误报告明细,用于列表和详情展示,无需回查 report_detail 表。</p>
*
* @author 恭学教育
*/
@TableName("education_wrong_question")
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public class WrongQuestionDO extends TenantBaseDO {
/** 主键 */
@TableId
private Long id;
/** 学生用户编号 */
private Long userId;
/** 原始题目 ID */
private String questionId;
/** 题干快照(最新) */
private String stem;
/** 题型快照 */
private String type;
/** 难度快照 */
private String difficulty;
/** 选项快照 JSON不含 isCorrect */
private String options;
/** 题目内容版本 */
private String contentVersion;
/** 正确答案快照(最新,供详情展示) */
private String latestCorrectAnswer;
/** 解析快照(最新,供详情展示) */
private String latestExplanation;
/** 首次错误时间 */
private LocalDateTime firstWrongTime;
/** 最近错误时间 */
private LocalDateTime lastWrongTime;
/** 累计错误次数 */
private Integer wrongCount;
/** 掌握状态PENDING / MASTERED */
private String masterStatus;
/** 标记掌握时间 */
private LocalDateTime masteredTime;
/** 最近关联的报告 ID */
private Long lastReportId;
/** 最近关联的会话 ID */
private Long lastSessionId;
}

View File

@@ -0,0 +1,39 @@
package cn.iocoder.yudao.module.education.dal.dataobject;
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**
* 错题流水幂等 DO。
*
* <p>同一 (wrong_question_id, report_id) 唯一,确保每个报告明细
* 对错题本最多贡献一次。用于防止重放交卷和并发提交重复计数。</p>
*
* @author 恭学教育
*/
@TableName("education_wrong_question_idempotency")
@Data
@EqualsAndHashCode(callSuper = true)
@NoArgsConstructor
@AllArgsConstructor
public class WrongQuestionIdempotencyDO extends TenantBaseDO {
/** 主键 */
@TableId
private Long id;
/** 学生用户编号 */
private Long userId;
/** 错题记录 ID */
private Long wrongQuestionId;
/** 报告 ID */
private Long reportId;
/** 题目 ID */
private String questionId;
}

View File

@@ -63,4 +63,21 @@ public interface PracticeSessionMapper extends BaseMapperX<PracticeSessionDO> {
.set(PracticeSessionDO::getLastClientSequence, lastClientSequence));
}
/**
* INSERT IGNORE — attempt session creation for idempotency.
* Used by review session creation to resolve concurrent create races
* without catching DuplicateKeyException inside a transaction.
*
* @return 1 if inserted, 0 if duplicate was silently ignored
*/
@org.apache.ibatis.annotations.Insert("INSERT IGNORE INTO education_practice_session " +
"(tenant_id, user_id, client_session_id, status, question_count, " +
"collection_id, node_id, type, difficulty, version, review_fingerprint, " +
"creator, create_time, updater, update_time, deleted) " +
"VALUES (#{tenantId}, #{userId}, #{clientSessionId}, #{status}, #{questionCount}, " +
"#{collectionId}, #{nodeId}, #{type}, #{difficulty}, #{version}, #{reviewFingerprint}, " +
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
@org.apache.ibatis.annotations.Options(useGeneratedKeys = true, keyProperty = "id")
int insertIgnore(PracticeSessionDO record);
}

View File

@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.education.dal.mysql;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.WrongQuestionIdempotencyDO;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
/**
* 错题流水幂等 Mapper。
*
* <p>INSERT IGNORE 提供 (wrong_question_id, report_id) 的 at-most-once 保障。
* 返回 1 = 已插入(可以 upsert wrong question返回 0 = 该 report 已贡献过。</p>
*
* @author 恭学教育
*/
@Mapper
public interface WrongQuestionIdempotencyMapper extends BaseMapperX<WrongQuestionIdempotencyDO> {
/**
* INSERT IGNORE — attempt idempotency guard insertion.
*
* @return 1 if inserted (first time for this wrong_question+report),
* 0 if duplicate was silently ignored
*/
@Insert("INSERT IGNORE INTO education_wrong_question_idempotency " +
"(tenant_id, user_id, wrong_question_id, report_id, question_id, " +
"creator, create_time, updater, update_time, deleted) " +
"VALUES (#{tenantId}, #{userId}, #{wrongQuestionId}, #{reportId}, #{questionId}, " +
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
@Options(useGeneratedKeys = true, keyProperty = "id")
int insertIgnore(WrongQuestionIdempotencyDO record);
/**
* Update wrong_question_id after upsert resolves the wrong question row.
* Called within the same transaction as the upsert.
*
* @param tenantId 租户编号
* @param userId 用户编号
* @param questionId 题目 ID
* @param reportId 报告 ID
* @param wrongQuestionId 解析后的错题记录 ID
* @return 受影响行数
*/
@org.apache.ibatis.annotations.Update("UPDATE education_wrong_question_idempotency " +
"SET wrong_question_id = #{wrongQuestionId} " +
"WHERE tenant_id = #{tenantId} AND user_id = #{userId} " +
"AND question_id = #{questionId} AND report_id = #{reportId}")
int updateWrongQuestionId(Long tenantId, Long userId, String questionId,
Long reportId, Long wrongQuestionId);
}

View File

@@ -0,0 +1,128 @@
package cn.iocoder.yudao.module.education.dal.mysql;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.education.dal.dataobject.WrongQuestionDO;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Options;
/**
* 错题本 Mapper。
*
* @author 恭学教育
*/
@Mapper
public interface WrongQuestionMapper extends BaseMapperX<WrongQuestionDO> {
/**
* INSERT ... ON DUPLICATE KEY UPDATE — upsert a wrong question entry.
* On conflict (tenant_id, user_id, question_id), increments wrong_count,
* updates snapshot fields, and advances last_wrong_time.
*
* <p>This is the ONLY write path for wrong questions. Callers MUST first
* guard with WrongQuestionIdempotencyMapper to ensure at-most-once per report.</p>
*
* @return 1 = inserted, 2 = updated (MySQL convention for ON DUPLICATE KEY UPDATE)
*/
@Insert("INSERT INTO education_wrong_question " +
"(tenant_id, user_id, question_id, stem, type, difficulty, options, content_version, " +
"latest_correct_answer, latest_explanation, " +
"first_wrong_time, last_wrong_time, wrong_count, master_status, " +
"last_report_id, last_session_id, " +
"creator, create_time, updater, update_time, deleted) " +
"VALUES (#{tenantId}, #{userId}, #{questionId}, #{stem}, #{type}, #{difficulty}, #{options}, #{contentVersion}, " +
"#{latestCorrectAnswer}, #{latestExplanation}, " +
"#{firstWrongTime}, #{lastWrongTime}, #{wrongCount}, #{masterStatus}, " +
"#{lastReportId}, #{lastSessionId}, " +
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE) " +
"ON DUPLICATE KEY UPDATE " +
"id = LAST_INSERT_ID(id), " +
"stem = VALUES(stem), " +
"type = VALUES(type), " +
"difficulty = VALUES(difficulty), " +
"options = VALUES(options), " +
"content_version = VALUES(content_version), " +
"latest_correct_answer = VALUES(latest_correct_answer), " +
"latest_explanation = VALUES(latest_explanation), " +
"last_wrong_time = VALUES(last_wrong_time), " +
"wrong_count = wrong_count + 1, " +
"last_report_id = VALUES(last_report_id), " +
"last_session_id = VALUES(last_session_id), " +
"update_time = VALUES(update_time)")
@Options(useGeneratedKeys = true, keyProperty = "id")
int upsert(WrongQuestionDO record);
/**
* 按 ID、租户、用户查找单条错题所有权校验
*/
default WrongQuestionDO selectByIdAndTenantAndUser(Long id, Long tenantId, Long userId) {
return selectOne(new LambdaQueryWrapperX<WrongQuestionDO>()
.eq(WrongQuestionDO::getId, id)
.eq(WrongQuestionDO::getTenantId, tenantId)
.eq(WrongQuestionDO::getUserId, userId));
}
/**
* 按租户和用户分页查询错题,可选掌握状态筛选,按最近错误时间降序。
*/
default IPage<WrongQuestionDO> selectPageByTenantAndUser(IPage<WrongQuestionDO> page,
Long tenantId, Long userId,
String masterStatus) {
LambdaQueryWrapperX<WrongQuestionDO> q = new LambdaQueryWrapperX<WrongQuestionDO>()
.eq(WrongQuestionDO::getTenantId, tenantId)
.eq(WrongQuestionDO::getUserId, userId);
if (masterStatus != null && !masterStatus.isEmpty()) {
q.eq(WrongQuestionDO::getMasterStatus, masterStatus);
}
q.orderByDesc(WrongQuestionDO::getLastWrongTime);
return selectPage(page, q);
}
/**
* 更新掌握状态idempotent — 已处于目标状态则无操作)。
*
* @return 受影响行数1 = 更新成功0 = 记录不存在/租户用户不匹配)
*/
default int updateMasterStatus(Long id, Long tenantId, Long userId,
String oldStatus, String newStatus,
java.time.LocalDateTime masteredTime) {
return update(null,
new LambdaUpdateWrapper<WrongQuestionDO>()
.eq(WrongQuestionDO::getId, id)
.eq(WrongQuestionDO::getTenantId, tenantId)
.eq(WrongQuestionDO::getUserId, userId)
.eq(WrongQuestionDO::getMasterStatus, oldStatus)
.set(WrongQuestionDO::getMasterStatus, newStatus)
.set(WrongQuestionDO::getMasteredTime, masteredTime));
}
/**
* Reset master_status to PENDING and clear mastered_time if currently MASTERED.
* Called after upsert on a new wrong answer to reopen a previously-mastered question.
* Idempotent: if already PENDING, no rows affected.
*/
default void resetMasterStatusIfMastered(Long tenantId, Long userId, String questionId) {
update(null,
new LambdaUpdateWrapper<WrongQuestionDO>()
.eq(WrongQuestionDO::getTenantId, tenantId)
.eq(WrongQuestionDO::getUserId, userId)
.eq(WrongQuestionDO::getQuestionId, questionId)
.eq(WrongQuestionDO::getMasterStatus, "MASTERED")
.set(WrongQuestionDO::getMasterStatus, "PENDING")
.set(WrongQuestionDO::getMasteredTime, (java.time.LocalDateTime) null));
}
/**
* 按用户批量查询指定题目 ID 的错题(用于复习会话创建时验证所有权)。
*/
default java.util.List<WrongQuestionDO> selectByUserAndQuestionIds(Long tenantId, Long userId,
java.util.Collection<String> questionIds) {
return selectList(new LambdaQueryWrapperX<WrongQuestionDO>()
.eq(WrongQuestionDO::getTenantId, tenantId)
.eq(WrongQuestionDO::getUserId, userId)
.in(WrongQuestionDO::getQuestionId, questionIds));
}
}

View File

@@ -74,4 +74,12 @@ public interface ErrorCodeConstants {
ErrorCode REPORT_NOT_FOUND = new ErrorCode(1_005_003_034, "报告不存在");
ErrorCode REPORT_NOT_OWN = new ErrorCode(1_005_003_035, "无权访问该报告");
ErrorCode REPORT_SESSION_NOT_SUBMITTED = new ErrorCode(1_005_003_036, "会话尚未提交,报告不可用");
// ========== 错题本 1-005-003-040 ~ 1-005-003-059 ==========
ErrorCode WRONG_QUESTION_NOT_FOUND = new ErrorCode(1_005_003_040, "错题不存在");
ErrorCode WRONG_QUESTION_NOT_OWN = new ErrorCode(1_005_003_041, "无权访问该错题");
ErrorCode WRONG_QUESTION_MASTERY_NOOP = new ErrorCode(1_005_003_042, "错题已处于目标掌握状态");
ErrorCode WRONG_QUESTION_REVIEW_IDS_EMPTY = new ErrorCode(1_005_003_043, "复习题目 ID 列表不能为空");
ErrorCode WRONG_QUESTION_REVIEW_NOT_ALL_OWNED = new ErrorCode(1_005_003_044, "部分错题 ID 不属于当前用户或不存在");
ErrorCode WRONG_QUESTION_REVIEW_IDS_TOO_MANY = new ErrorCode(1_005_003_045, "单次复习题目数不能超过 {}");
}

View File

@@ -4,6 +4,7 @@ import cn.hutool.core.collection.CollUtil;
import cn.hutool.crypto.digest.DigestUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService;
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.*;
@@ -40,6 +41,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
private final PracticeReportMapper reportMapper;
private final PracticeReportDetailMapper reportDetailMapper;
private final QuestionCatalogProvider questionCatalogProvider;
private final WrongQuestionService wrongQuestionService;
public PracticeSessionServiceImpl(PracticeSessionMapper sessionMapper,
PracticeQuestionMapper questionMapper,
@@ -47,7 +49,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
SubmitIdempotencyMapper submitIdempotencyMapper,
PracticeReportMapper reportMapper,
PracticeReportDetailMapper reportDetailMapper,
QuestionCatalogProvider questionCatalogProvider) {
QuestionCatalogProvider questionCatalogProvider,
WrongQuestionService wrongQuestionService) {
this.sessionMapper = sessionMapper;
this.questionMapper = questionMapper;
this.idempotencyMapper = idempotencyMapper;
@@ -55,8 +58,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
this.reportMapper = reportMapper;
this.reportDetailMapper = reportDetailMapper;
this.questionCatalogProvider = questionCatalogProvider;
this.wrongQuestionService = wrongQuestionService;
}
@Override
@Transactional(rollbackFor = Exception.class)
public PracticeSessionRespVO createPracticeSession(PracticeSessionCreateReqVO reqVO, Long userId, Long tenantId) {
@@ -359,6 +362,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
detail.setCorrectAnswer(q.getCorrectAnswer());
detail.setIsCorrect(q.getIsAnswered() != null && q.getIsAnswered() && isAnswerCorrect(q));
detail.setExplanation(q.getExplanation());
detail.setOptions(q.getOptions());
detail.setContentVersion(q.getContentVersion() != null ? q.getContentVersion() : "");
details.add(detail);
}
@@ -404,6 +409,9 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
}
reportDetailMapper.insertBatch(details);
// 8.5 Upsert wrong questions for incorrect answers (within same transaction)
wrongQuestionService.upsertWrongQuestions(tenantId, userId, report.getId(), session.getId(), details);
// 9. CAS: ACTIVE → SUBMITTED
int casResult = sessionMapper.update(null,
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<PracticeSessionDO>()

View File

@@ -0,0 +1,74 @@
package cn.iocoder.yudao.module.education.service.wrong;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
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;
/**
* 错题本服务接口。
*
* @author 恭学教育
*/
public interface WrongQuestionService {
/**
* 分页查询当前用户的错题列表。
*
* @param userId 当前学生用户
* @param tenantId 当前租户
* @param pageNo 页码
* @param pageSize 每页条数
* @param masterStatus 可选掌握状态筛选 (null = 全部)
* @return 分页结果
*/
PageResult<WrongQuestionPageItemRespVO> getWrongQuestionPage(
Long userId, Long tenantId, int pageNo, int pageSize, String masterStatus);
/**
* 获取错题详情(含正确答案和解析,所有权校验)。
*/
WrongQuestionDetailRespVO getWrongQuestionDetail(Long id, Long userId, Long tenantId);
/**
* 标记错题为已掌握(幂等)。
* 已掌握的错题再次调用无操作。
*/
void markMastered(Long id, Long userId, Long tenantId);
/**
* 取消掌握标记(幂等)。
* 未掌握的错题再次调用无操作。
*/
void unmarkMastered(Long id, Long userId, Long tenantId);
/**
* 从错题创建复习练习会话。
*
* <p>服务端验证所有错题 ID 属于当前用户且记录有效(非删除)。
* 使用错题表中的快照数据创建 PracticeSession + PracticeQuestion
* 复用稳定会话创建/答题/交卷流程。不暴露正确答案和解析。</p>
*
* <p>clientSessionId 提供幂等创建。</p>
*
* @return 创建的练习会话响应
*/
PracticeSessionRespVO createReviewSession(WrongQuestionReviewReqVO reqVO, Long userId, Long tenantId);
/**
* 交卷后批量 upsert 错题(内部方法,由 submitSession 调用)。
*
* <p>对每个 isCorrect=false 的 report detail 执行 upsert。
* 通过 idempotency 表保证每 (wrong_question_id, report_id) 最多贡献一次。
* 未作答的题目不计入错题。</p>
*
* @param tenantId 租户
* @param userId 用户
* @param reportId 报告 ID
* @param sessionId 会话 ID
* @param details 报告明细列表
*/
void upsertWrongQuestions(Long tenantId, Long userId, Long reportId, Long sessionId,
java.util.List<cn.iocoder.yudao.module.education.dal.dataobject.PracticeReportDetailDO> details);
}

View File

@@ -0,0 +1,382 @@
package cn.iocoder.yudao.module.education.service.wrong;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.crypto.digest.DigestUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeQuestionRespVO;
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.PracticeQuestionDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeReportDetailDO;
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO;
import cn.iocoder.yudao.module.education.dal.dataobject.WrongQuestionDO;
import cn.iocoder.yudao.module.education.dal.dataobject.WrongQuestionIdempotencyDO;
import cn.iocoder.yudao.module.education.dal.mysql.PracticeQuestionMapper;
import cn.iocoder.yudao.module.education.dal.mysql.PracticeSessionMapper;
import cn.iocoder.yudao.module.education.dal.mysql.WrongQuestionIdempotencyMapper;
import cn.iocoder.yudao.module.education.dal.mysql.WrongQuestionMapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
/**
* 错题本服务实现。
*
* @author 恭学教育
*/
@Service
@Slf4j
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
public class WrongQuestionServiceImpl implements WrongQuestionService {
private static final int MAX_REVIEW_QUESTION_COUNT = 100;
private final WrongQuestionMapper wrongQuestionMapper;
private final WrongQuestionIdempotencyMapper idempotencyMapper;
private final PracticeSessionMapper sessionMapper;
private final PracticeQuestionMapper questionMapper;
public WrongQuestionServiceImpl(WrongQuestionMapper wrongQuestionMapper,
WrongQuestionIdempotencyMapper idempotencyMapper,
PracticeSessionMapper sessionMapper,
PracticeQuestionMapper questionMapper) {
this.wrongQuestionMapper = wrongQuestionMapper;
this.idempotencyMapper = idempotencyMapper;
this.sessionMapper = sessionMapper;
this.questionMapper = questionMapper;
}
// ========== Query ==========
@Override
public PageResult<WrongQuestionPageItemRespVO> getWrongQuestionPage(
Long userId, Long tenantId, int pageNo, int pageSize, String masterStatus) {
IPage<WrongQuestionDO> page = wrongQuestionMapper.selectPageByTenantAndUser(
new Page<>(pageNo, pageSize), tenantId, userId, masterStatus);
List<WrongQuestionPageItemRespVO> list = page.getRecords().stream()
.map(this::toPageItem)
.collect(Collectors.toList());
return new PageResult<>(list, page.getTotal());
}
@Override
public WrongQuestionDetailRespVO getWrongQuestionDetail(Long id, Long userId, Long tenantId) {
WrongQuestionDO wq = wrongQuestionMapper.selectByIdAndTenantAndUser(id, tenantId, userId);
if (wq == null) {
throw exception(WRONG_QUESTION_NOT_FOUND);
}
return toDetail(wq);
}
// ========== Mastery ==========
@Override
@Transactional(rollbackFor = Exception.class)
public void markMastered(Long id, Long userId, Long tenantId) {
int updated = wrongQuestionMapper.updateMasterStatus(
id, tenantId, userId, "PENDING", "MASTERED", LocalDateTime.now());
if (updated == 0) {
// Check if already mastered (idempotent) or not found
WrongQuestionDO existing = wrongQuestionMapper.selectByIdAndTenantAndUser(id, tenantId, userId);
if (existing == null) {
throw exception(WRONG_QUESTION_NOT_FOUND);
}
if ("MASTERED".equals(existing.getMasterStatus())) {
return; // idempotent
}
throw exception(WRONG_QUESTION_NOT_OWN);
}
}
@Override
@Transactional(rollbackFor = Exception.class)
public void unmarkMastered(Long id, Long userId, Long tenantId) {
int updated = wrongQuestionMapper.updateMasterStatus(
id, tenantId, userId, "MASTERED", "PENDING", null);
if (updated == 0) {
WrongQuestionDO existing = wrongQuestionMapper.selectByIdAndTenantAndUser(id, tenantId, userId);
if (existing == null) {
throw exception(WRONG_QUESTION_NOT_FOUND);
}
if ("PENDING".equals(existing.getMasterStatus())) {
return; // idempotent
}
throw exception(WRONG_QUESTION_NOT_OWN);
}
}
// ========== Review Session ==========
@Override
@Transactional(rollbackFor = Exception.class)
public PracticeSessionRespVO createReviewSession(WrongQuestionReviewReqVO reqVO, Long userId, Long tenantId) {
List<Long> wrongQuestionIds = reqVO.getWrongQuestionIds();
if (CollUtil.isEmpty(wrongQuestionIds)) {
throw exception(WRONG_QUESTION_REVIEW_IDS_EMPTY);
}
if (wrongQuestionIds.size() > MAX_REVIEW_QUESTION_COUNT) {
throw exception(WRONG_QUESTION_REVIEW_IDS_TOO_MANY, MAX_REVIEW_QUESTION_COUNT);
}
// 1. Compute canonical fingerprint from sorted, unique wrong question IDs
String fingerprint = computeReviewFingerprint(wrongQuestionIds);
// 2. Check existing session by tenant + clientSessionId
PracticeSessionDO existing = sessionMapper.selectByTenantAndClientSessionId(
tenantId, reqVO.getClientSessionId());
if (existing != null) {
// Cross-user: same clientSessionId, different user → not found
if (!Objects.equals(existing.getUserId(), userId)) {
throw exception(SESSION_NOT_FOUND);
}
// Different ID set → mismatch
if (!Objects.equals(existing.getReviewFingerprint(), fingerprint)) {
throw exception(SESSION_IDEMPOTENCY_MISMATCH);
}
// Same tenant + clientSessionId + user + fingerprint → replay
return buildSessionResp(existing, questionMapper.selectBySessionIdOrderBySequence(existing.getId()));
}
// 3. Load all wrong questions by ID, verify ownership
List<WrongQuestionDO> wrongQuestions = wrongQuestionMapper.selectBatchIds(wrongQuestionIds);
if (wrongQuestions.size() != wrongQuestionIds.size()) {
throw exception(WRONG_QUESTION_REVIEW_NOT_ALL_OWNED);
}
for (WrongQuestionDO wq : wrongQuestions) {
if (!Objects.equals(wq.getUserId(), userId) || !Objects.equals(wq.getTenantId(), tenantId)) {
throw exception(WRONG_QUESTION_REVIEW_NOT_ALL_OWNED);
}
}
// 4. Create session via INSERT IGNORE (safe concurrent create race resolution)
PracticeSessionDO session = new PracticeSessionDO();
session.setTenantId(tenantId);
session.setUserId(userId);
session.setClientSessionId(reqVO.getClientSessionId());
session.setStatus("ACTIVE");
session.setQuestionCount(wrongQuestions.size());
session.setVersion(1);
session.setReviewFingerprint(fingerprint);
int inserted = sessionMapper.insertIgnore(session);
if (inserted == 0) {
// Race: another concurrent request with same clientSessionId won the insert.
// Re-read to confirm ownership and fingerprint.
PracticeSessionDO winner = sessionMapper.selectByTenantAndClientSessionId(
tenantId, reqVO.getClientSessionId());
if (winner == null || !Objects.equals(winner.getUserId(), userId)) {
throw exception(SESSION_NOT_FOUND);
}
if (!Objects.equals(winner.getReviewFingerprint(), fingerprint)) {
throw exception(SESSION_IDEMPOTENCY_MISMATCH);
}
return buildSessionResp(winner, questionMapper.selectBySessionIdOrderBySequence(winner.getId()));
}
// 4. Create question snapshots from wrong question data (NO correct answer exposed pre-submit)
List<PracticeQuestionDO> questionDOs = new ArrayList<>(wrongQuestions.size());
int seq = 1;
for (WrongQuestionDO wq : wrongQuestions) {
PracticeQuestionDO pq = new PracticeQuestionDO();
pq.setTenantId(tenantId);
pq.setSessionId(session.getId());
pq.setSequence(seq++);
pq.setQuestionId(wq.getQuestionId());
pq.setContentVersion(wq.getContentVersion() != null ? wq.getContentVersion() : "");
pq.setStem(wq.getStem());
pq.setType(wq.getType());
pq.setDifficulty(wq.getDifficulty());
pq.setOptions(wq.getOptions());
pq.setCorrectAnswer(wq.getLatestCorrectAnswer());
pq.setExplanation(wq.getLatestExplanation());
pq.setIsAnswered(false);
questionDOs.add(pq);
}
questionMapper.insertBatch(questionDOs);
return buildSessionResp(session, questionDOs);
}
// ========== Upsert (internal, called from submitSession) ==========
@Override
@Transactional(rollbackFor = Exception.class)
public void upsertWrongQuestions(Long tenantId, Long userId, Long reportId, Long sessionId,
List<PracticeReportDetailDO> details) {
if (CollUtil.isEmpty(details)) {
return;
}
LocalDateTime now = LocalDateTime.now();
for (PracticeReportDetailDO detail : details) {
// Only count answered + incorrect
if (detail.getIsCorrect() == null || detail.getIsCorrect()) {
continue;
}
// Unanswered → selectedAnswer is null → not a wrong answer
if (detail.getSelectedAnswer() == null || detail.getSelectedAnswer().isEmpty()) {
continue;
}
// 1. INSERT IGNORE idempotency guard — per (tenant, user, question, report)
WrongQuestionIdempotencyDO idem = new WrongQuestionIdempotencyDO();
idem.setTenantId(tenantId);
idem.setUserId(userId);
idem.setReportId(reportId);
idem.setQuestionId(detail.getQuestionId());
int idemInserted = idempotencyMapper.insertIgnore(idem);
if (idemInserted == 0) {
// This (question, report) pair already contributed — skip
continue;
}
// 2. Upsert wrong question
WrongQuestionDO wq = new WrongQuestionDO();
wq.setTenantId(tenantId);
wq.setUserId(userId);
wq.setQuestionId(detail.getQuestionId());
wq.setStem(detail.getStem());
wq.setType(detail.getType());
wq.setDifficulty(detail.getDifficulty());
wq.setOptions(detail.getOptions() != null ? detail.getOptions() : "[]");
wq.setContentVersion(detail.getContentVersion() != null ? detail.getContentVersion() : "");
wq.setLatestCorrectAnswer(detail.getCorrectAnswer());
wq.setLatestExplanation(detail.getExplanation());
wq.setFirstWrongTime(now);
wq.setLastWrongTime(now);
wq.setWrongCount(1);
wq.setMasterStatus("PENDING");
wq.setMasteredTime(null); // explicit null: reopen PENDING if was MASTERED
wq.setLastReportId(reportId);
wq.setLastSessionId(sessionId);
wrongQuestionMapper.upsert(wq);
// 2.5 Reset master_status to PENDING and clear mastered_time if was MASTERED.
// The upsert SQL only updates snapshots/count — master fields handled here
// for H2 compatibility (VALUES() on nullable datetime unsupported in H2).
wrongQuestionMapper.resetMasterStatusIfMastered(tenantId, userId,
detail.getQuestionId());
// 3. Update idempotency row with resolved wrong_question_id for audit
idempotencyMapper.updateWrongQuestionId(tenantId, userId,
detail.getQuestionId(), reportId, wq.getId());
}
}
// ========== Internal helpers ==========
/**
* Compute a canonical fingerprint from sorted, distinct wrong question IDs.
* Uses SHA-256 of comma-joined sorted unique IDs for deterministic comparison.
*/
private String computeReviewFingerprint(List<Long> wrongQuestionIds) {
List<Long> sorted = wrongQuestionIds.stream()
.distinct()
.sorted()
.collect(Collectors.toList());
String joined = sorted.stream()
.map(String::valueOf)
.collect(Collectors.joining(","));
return DigestUtil.sha256Hex(joined);
}
private WrongQuestionPageItemRespVO toPageItem(WrongQuestionDO wq) {
return WrongQuestionPageItemRespVO.builder()
.id(wq.getId())
.questionId(wq.getQuestionId())
.stem(wq.getStem())
.type(wq.getType())
.difficulty(wq.getDifficulty())
.wrongCount(wq.getWrongCount())
.lastWrongTime(wq.getLastWrongTime())
.masterStatus(wq.getMasterStatus())
.build();
}
private WrongQuestionDetailRespVO toDetail(WrongQuestionDO wq) {
return WrongQuestionDetailRespVO.builder()
.id(wq.getId())
.questionId(wq.getQuestionId())
.stem(wq.getStem())
.type(wq.getType())
.difficulty(wq.getDifficulty())
.options(wq.getOptions())
.contentVersion(wq.getContentVersion())
.correctAnswer(wq.getLatestCorrectAnswer())
.explanation(wq.getLatestExplanation())
.firstWrongTime(wq.getFirstWrongTime())
.lastWrongTime(wq.getLastWrongTime())
.wrongCount(wq.getWrongCount())
.masterStatus(wq.getMasterStatus())
.masteredTime(wq.getMasteredTime())
.build();
}
private PracticeSessionRespVO buildSessionResp(PracticeSessionDO session, List<PracticeQuestionDO> questions) {
List<PracticeQuestionRespVO> questionVOs = questions.stream()
.map(pq -> {
List<PracticeQuestionRespVO.OptionVO> options = parseOptions(pq.getOptions());
return PracticeQuestionRespVO.builder()
.sequence(pq.getSequence())
.questionId(pq.getQuestionId())
.stem(pq.getStem())
.type(pq.getType())
.difficulty(pq.getDifficulty())
.options(options)
.selectedAnswer(pq.getSelectedAnswer())
.isAnswered(pq.getIsAnswered() != null && pq.getIsAnswered())
.contentVersion(pq.getContentVersion())
.build();
})
.collect(Collectors.toList());
return PracticeSessionRespVO.builder()
.sessionId(session.getId())
.status(session.getStatus())
.questionCount(session.getQuestionCount())
.version(session.getVersion())
.clientSessionId(session.getClientSessionId())
.lastClientSequence(session.getLastClientSequence())
.questions(questionVOs)
.build();
}
@SuppressWarnings({"unchecked", "rawtypes"})
private List<PracticeQuestionRespVO.OptionVO> parseOptions(String optionsJson) {
if (optionsJson == null || optionsJson.isEmpty()) {
return Collections.emptyList();
}
try {
List<Map<String, Object>> raw = (List) cn.iocoder.yudao.framework.common.util.json.JsonUtils.parseArray(optionsJson, Map.class);
if (raw == null) {
return Collections.emptyList();
}
return raw.stream()
.map(m -> PracticeQuestionRespVO.OptionVO.builder()
.label((String) m.get("label"))
.content((String) m.get("content"))
.order(m.get("order") != null ? ((Number) m.get("order")).doubleValue() : null)
.build())
.collect(Collectors.toList());
} catch (Exception e) {
return Collections.emptyList();
}
}
}

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");