diff --git a/sql/mysql/education/005-education-wrong-question-rollback.sql b/sql/mysql/education/005-education-wrong-question-rollback.sql new file mode 100644 index 00000000..862b5060 --- /dev/null +++ b/sql/mysql/education/005-education-wrong-question-rollback.sql @@ -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. diff --git a/sql/mysql/education/005-education-wrong-question.sql b/sql/mysql/education/005-education-wrong-question.sql new file mode 100644 index 00000000..48fd2d8a --- /dev/null +++ b/sql/mysql/education/005-education-wrong-question.sql @@ -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 '错题记录 ID(upsert 后填充)', + `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; diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/wrong/WrongQuestionController.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/wrong/WrongQuestionController.java new file mode 100644 index 00000000..d1bdbd2d --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/wrong/WrongQuestionController.java @@ -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> 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 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 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 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 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(); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/wrong/vo/WrongQuestionDetailRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/wrong/vo/WrongQuestionDetailRespVO.java new file mode 100644 index 00000000..1f282d32 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/wrong/vo/WrongQuestionDetailRespVO.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/wrong/vo/WrongQuestionPageItemRespVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/wrong/vo/WrongQuestionPageItemRespVO.java new file mode 100644 index 00000000..0ac24035 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/wrong/vo/WrongQuestionPageItemRespVO.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/wrong/vo/WrongQuestionPageReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/wrong/vo/WrongQuestionPageReqVO.java new file mode 100644 index 00000000..2170162c --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/wrong/vo/WrongQuestionPageReqVO.java @@ -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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/wrong/vo/WrongQuestionReviewReqVO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/wrong/vo/WrongQuestionReviewReqVO.java new file mode 100644 index 00000000..9008e272 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/controller/app/wrong/vo/WrongQuestionReviewReqVO.java @@ -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 wrongQuestionIds; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeReportDetailDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeReportDetailDO.java index ff8dab98..5106fe69 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeReportDetailDO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeReportDetailDO.java @@ -60,4 +60,10 @@ public class PracticeReportDetailDO extends TenantBaseDO { /** 解析快照 */ private String explanation; + /** 题目内容版本快照 */ + private String contentVersion; + + /** 选项快照 JSON(不含 isCorrect) */ + private String options; + } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeSessionDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeSessionDO.java index 7199c4b1..b365d56d 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeSessionDO.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/PracticeSessionDO.java @@ -56,4 +56,7 @@ public class PracticeSessionDO extends TenantBaseDO { /** 会话级最后接受的客户端命令序号(跨题目单调递增),用于拒绝乱序请求 */ private Integer lastClientSequence; + /** 复习会话题目指纹(SHA-256 of sorted unique wrongQuestionIds),用于幂等重放校验 */ + private String reviewFingerprint; + } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/WrongQuestionDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/WrongQuestionDO.java new file mode 100644 index 00000000..2a07ed42 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/WrongQuestionDO.java @@ -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 — 学生错题持久记录。 + * + *

同一 (tenant, user, question) 唯一一条。重复答错只更新计数和快照。 + * 标记掌握不删除记录;masterStatus 字段标识当前状态。

+ * + *

快照字段(stem/type/difficulty/options/contentVersion)来自最近一次 + * 错误报告明细,用于列表和详情展示,无需回查 report_detail 表。

+ * + * @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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/WrongQuestionIdempotencyDO.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/WrongQuestionIdempotencyDO.java new file mode 100644 index 00000000..7e7944de --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/dataobject/WrongQuestionIdempotencyDO.java @@ -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。 + * + *

同一 (wrong_question_id, report_id) 唯一,确保每个报告明细 + * 对错题本最多贡献一次。用于防止重放交卷和并发提交重复计数。

+ * + * @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; + +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapper.java index 5057d87d..4df38129 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapper.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/PracticeSessionMapper.java @@ -63,4 +63,21 @@ public interface PracticeSessionMapper extends BaseMapperX { .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); + } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/WrongQuestionIdempotencyMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/WrongQuestionIdempotencyMapper.java new file mode 100644 index 00000000..abb01075 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/WrongQuestionIdempotencyMapper.java @@ -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。 + * + *

INSERT IGNORE 提供 (wrong_question_id, report_id) 的 at-most-once 保障。 + * 返回 1 = 已插入(可以 upsert wrong question);返回 0 = 该 report 已贡献过。

+ * + * @author 恭学教育 + */ +@Mapper +public interface WrongQuestionIdempotencyMapper extends BaseMapperX { + + /** + * 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); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/WrongQuestionMapper.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/WrongQuestionMapper.java new file mode 100644 index 00000000..56c9c41e --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/dal/mysql/WrongQuestionMapper.java @@ -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 { + + /** + * 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. + * + *

This is the ONLY write path for wrong questions. Callers MUST first + * guard with WrongQuestionIdempotencyMapper to ensure at-most-once per report.

+ * + * @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() + .eq(WrongQuestionDO::getId, id) + .eq(WrongQuestionDO::getTenantId, tenantId) + .eq(WrongQuestionDO::getUserId, userId)); + } + + /** + * 按租户和用户分页查询错题,可选掌握状态筛选,按最近错误时间降序。 + */ + default IPage selectPageByTenantAndUser(IPage page, + Long tenantId, Long userId, + String masterStatus) { + LambdaQueryWrapperX q = new LambdaQueryWrapperX() + .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() + .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() + .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 selectByUserAndQuestionIds(Long tenantId, Long userId, + java.util.Collection questionIds) { + return selectList(new LambdaQueryWrapperX() + .eq(WrongQuestionDO::getTenantId, tenantId) + .eq(WrongQuestionDO::getUserId, userId) + .in(WrongQuestionDO::getQuestionId, questionIds)); + } +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java index de6517ce..2b42006f 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/enums/ErrorCodeConstants.java @@ -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, "单次复习题目数不能超过 {}"); } diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java index 92f08abc..faf6abc2 100644 --- a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImpl.java @@ -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() diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/wrong/WrongQuestionService.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/wrong/WrongQuestionService.java new file mode 100644 index 00000000..df919d19 --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/wrong/WrongQuestionService.java @@ -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 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); + + /** + * 从错题创建复习练习会话。 + * + *

服务端验证所有错题 ID 属于当前用户且记录有效(非删除)。 + * 使用错题表中的快照数据创建 PracticeSession + PracticeQuestion, + * 复用稳定会话创建/答题/交卷流程。不暴露正确答案和解析。

+ * + *

clientSessionId 提供幂等创建。

+ * + * @return 创建的练习会话响应 + */ + PracticeSessionRespVO createReviewSession(WrongQuestionReviewReqVO reqVO, Long userId, Long tenantId); + + /** + * 交卷后批量 upsert 错题(内部方法,由 submitSession 调用)。 + * + *

对每个 isCorrect=false 的 report detail 执行 upsert。 + * 通过 idempotency 表保证每 (wrong_question_id, report_id) 最多贡献一次。 + * 未作答的题目不计入错题。

+ * + * @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 details); +} diff --git a/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/wrong/WrongQuestionServiceImpl.java b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/wrong/WrongQuestionServiceImpl.java new file mode 100644 index 00000000..9575f0cf --- /dev/null +++ b/yudao-module-education/src/main/java/cn/iocoder/yudao/module/education/service/wrong/WrongQuestionServiceImpl.java @@ -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 getWrongQuestionPage( + Long userId, Long tenantId, int pageNo, int pageSize, String masterStatus) { + IPage page = wrongQuestionMapper.selectPageByTenantAndUser( + new Page<>(pageNo, pageSize), tenantId, userId, masterStatus); + + List 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 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 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 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 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 wrongQuestionIds) { + List 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 questions) { + List questionVOs = questions.stream() + .map(pq -> { + List 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 parseOptions(String optionsJson) { + if (optionsJson == null || optionsJson.isEmpty()) { + return Collections.emptyList(); + } + try { + List> 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(); + } + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeAnswerServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeAnswerServiceImplTest.java index 9dc5bc1f..968e5e19 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeAnswerServiceImplTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeAnswerServiceImplTest.java @@ -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; diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImplTest.java index d960f26a..a7794ca2 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImplTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSessionServiceImplTest.java @@ -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 diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSubmitProjectionIntegrationTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSubmitProjectionIntegrationTest.java new file mode 100644 index 00000000..0368bd00 --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSubmitProjectionIntegrationTest.java @@ -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 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 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 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 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 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 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 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 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()); + } +} diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSubmitServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSubmitServiceImplTest.java index 627a9692..42078d0f 100644 --- a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSubmitServiceImplTest.java +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/practice/PracticeSubmitServiceImplTest.java @@ -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 ========== /** diff --git a/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/wrong/WrongQuestionServiceImplTest.java b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/wrong/WrongQuestionServiceImplTest.java new file mode 100644 index 00000000..4dabd68f --- /dev/null +++ b/yudao-module-education/src/test/java/cn/iocoder/yudao/module/education/service/wrong/WrongQuestionServiceImplTest.java @@ -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 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 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 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 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 all = wrongQuestionMapper.selectList(); + assertEquals(1, all.size()); + assertEquals(1, all.get(0).getWrongCount()); + + // Idempotency table has exactly 1 entry for this (question, report) + List 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 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 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 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 page1 = wrongQuestionService.getWrongQuestionPage( + userId, tenantId, 1, 3, null); + assertEquals(5, page1.getTotal()); + assertEquals(3, page1.getList().size()); + + // Page 2, size 3 + PageResult page2 = wrongQuestionService.getWrongQuestionPage( + userId, tenantId, 2, 3, null); + assertEquals(2, page2.getList().size()); + + // Filter by PENDING + PageResult pending = wrongQuestionService.getWrongQuestionPage( + userId, tenantId, 1, 10, "PENDING"); + assertEquals(3, pending.getTotal()); + + // Filter by MASTERED + PageResult mastered = wrongQuestionService.getWrongQuestionPage( + userId, tenantId, 1, 10, "MASTERED"); + assertEquals(2, mastered.getTotal()); + + // Different user — empty + PageResult 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 r1 = new AtomicReference<>(); + AtomicReference 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 sessions = sessionMapper.selectList( + new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() + .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 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); + } +} diff --git a/yudao-module-education/src/test/resources/sql/clean.sql b/yudao-module-education/src/test/resources/sql/clean.sql index ae391df6..14fa0883 100644 --- a/yudao-module-education/src/test/resources/sql/clean.sql +++ b/yudao-module-education/src/test/resources/sql/clean.sql @@ -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; diff --git a/yudao-module-education/src/test/resources/sql/create_tables.sql b/yudao-module-education/src/test/resources/sql/create_tables.sql index 00ec6ad1..baa0f9ab 100644 --- a/yudao-module-education/src/test/resources/sql/create_tables.sql +++ b/yudao-module-education/src/test/resources/sql/create_tables.sql @@ -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");