feat(education): project and review wrong questions
This commit is contained in:
@@ -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.
|
||||
154
sql/mysql/education/005-education-wrong-question.sql
Normal file
154
sql/mysql/education/005-education-wrong-question.sql
Normal file
@@ -0,0 +1,154 @@
|
||||
-- =============================================
|
||||
-- Education 模块 — 错题本 DDL
|
||||
-- Ticket #9: 错题自动收集、复习练习创建
|
||||
-- Migration: 005
|
||||
-- Prerequisites: 004-education-submit-report.sql (report + detail tables)
|
||||
-- =============================================
|
||||
|
||||
-- =============================================
|
||||
-- Preconditions
|
||||
-- =============================================
|
||||
-- Operator is expected to verify:
|
||||
-- SELECT COUNT(*) FROM information_schema.tables
|
||||
-- WHERE table_schema = DATABASE()
|
||||
-- AND table_name IN ('education_wrong_question',
|
||||
-- 'education_wrong_question_idempotency');
|
||||
-- Result MUST be 0 before executing this migration.
|
||||
--
|
||||
-- Verify prerequisite tables exist:
|
||||
-- SELECT COUNT(*) FROM information_schema.tables
|
||||
-- WHERE table_schema = DATABASE()
|
||||
-- AND table_name IN ('education_practice_report',
|
||||
-- 'education_practice_report_detail');
|
||||
-- Result MUST be 2.
|
||||
|
||||
-- =============================================
|
||||
-- PracticeReportDetailDO: add options snapshot column
|
||||
-- =============================================
|
||||
-- PracticeReportDetailDO: add content_version + options snapshot columns
|
||||
-- =============================================
|
||||
-- Purpose: At submit time, snapshot question content version and options
|
||||
-- (without isCorrect) so wrong-question book and review sessions have
|
||||
-- stable display data. The options are already stripped of isCorrect
|
||||
-- by the submit flow.
|
||||
ALTER TABLE `education_practice_report_detail`
|
||||
ADD COLUMN `content_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '题目内容版本快照',
|
||||
ADD COLUMN `options` TEXT DEFAULT NULL COMMENT '选项快照 JSON(不含 isCorrect)';
|
||||
|
||||
-- =============================================
|
||||
-- PracticeSessionDO: add review fingerprint column
|
||||
-- =============================================
|
||||
-- Purpose: Persist the canonical fingerprint of wrong-question IDs used
|
||||
-- to create a review session. On idempotent replay, the fingerprint
|
||||
-- is compared: same tenant+clientSessionId+sameUser+sortedIDs match
|
||||
-- returns the existing session; different ID set returns
|
||||
-- SESSION_IDEMPOTENCY_MISMATCH.
|
||||
ALTER TABLE `education_practice_session`
|
||||
ADD COLUMN `review_fingerprint` VARCHAR(64) DEFAULT NULL COMMENT '复习会话题目指纹(SHA-256 of sorted unique wrongQuestionIds)';
|
||||
-- 错题表
|
||||
-- =============================================
|
||||
-- Purpose: Persistent wrong-question book per student.
|
||||
-- Each (tenant, user, question) is a unique entry.
|
||||
-- Repeated wrong answers on the SAME question increment wrong_count
|
||||
-- and update last_wrong_time. The idempotency guard table ensures
|
||||
-- each (tenant, user, question, report) can upsert at most once.
|
||||
--
|
||||
-- master_status values: 'PENDING' (default) | 'MASTERED'
|
||||
-- Marking mastered retains the full history and count; it does NOT
|
||||
-- delete or archive the record. Students can optionally un-master.
|
||||
--
|
||||
-- Snapshot fields (stem, type, difficulty, options, content_version):
|
||||
-- populated from the latest report detail that touched this question.
|
||||
-- These are for listing/detail display without joining report details.
|
||||
--
|
||||
-- latest_correct_answer, latest_explanation:
|
||||
-- also from the latest report detail; available for detail display
|
||||
-- post-submit (not exposed in review session creation pre-submit).
|
||||
--
|
||||
-- Indexes:
|
||||
-- uk_tenant_user_question — per-tenant, per-user, per-question uniqueness.
|
||||
-- INSERT ... ON DUPLICATE KEY UPDATE is the primary write path.
|
||||
-- idx_tenant_user_status — covers filtered list queries (page with status filter).
|
||||
-- idx_tenant_user_last_wrong — covers time-sorted listing.
|
||||
CREATE TABLE `education_wrong_question` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
|
||||
`user_id` BIGINT NOT NULL COMMENT '学生用户编号',
|
||||
`question_id` VARCHAR(64) NOT NULL COMMENT '原始题目 ID',
|
||||
-- snapshot fields for listing / detail (from latest report detail)
|
||||
`stem` TEXT NOT NULL COMMENT '题干快照(最新)',
|
||||
`type` VARCHAR(32) NOT NULL COMMENT '题型快照',
|
||||
`difficulty` VARCHAR(32) DEFAULT NULL COMMENT '难度快照',
|
||||
`options` JSON NOT NULL COMMENT '选项快照 JSON(不含 isCorrect)',
|
||||
`content_version` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '题目内容版本',
|
||||
`latest_correct_answer` TEXT DEFAULT NULL COMMENT '正确答案快照(最新,供详情展示)',
|
||||
`latest_explanation` TEXT DEFAULT NULL COMMENT '解析快照(最新,供详情展示)',
|
||||
-- timing & count
|
||||
`first_wrong_time` DATETIME NOT NULL COMMENT '首次错误时间',
|
||||
`last_wrong_time` DATETIME NOT NULL COMMENT '最近错误时间',
|
||||
`wrong_count` INT NOT NULL DEFAULT 1 COMMENT '累计错误次数',
|
||||
-- mastery
|
||||
`master_status` VARCHAR(20) NOT NULL DEFAULT 'PENDING'
|
||||
COMMENT '掌握状态:PENDING-待掌握, MASTERED-已掌握',
|
||||
`mastered_time` DATETIME DEFAULT NULL COMMENT '标记掌握时间',
|
||||
-- provenance
|
||||
`last_report_id` BIGINT DEFAULT NULL COMMENT '最近关联的报告 ID',
|
||||
`last_session_id` BIGINT DEFAULT NULL COMMENT '最近关联的会话 ID',
|
||||
-- audit
|
||||
`creator` VARCHAR(64) DEFAULT '' COMMENT '创建者',
|
||||
`create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` VARCHAR(64) DEFAULT '' COMMENT '更新者',
|
||||
`update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` BIT(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_tenant_user_question` (`tenant_id`, `user_id`, `question_id`),
|
||||
KEY `idx_tenant_user_status` (`tenant_id`, `user_id`, `master_status`),
|
||||
KEY `idx_tenant_user_last_wrong` (`tenant_id`, `user_id`, `last_wrong_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='教育-错题本';
|
||||
|
||||
-- =============================================
|
||||
-- 错题流水幂等表
|
||||
-- =============================================
|
||||
-- Purpose: Ensure each (tenant, user, question, report) upserts the
|
||||
-- wrong-question book exactly once. The submitSession transaction
|
||||
-- INSERT IGNOREs into this table BEFORE the wrong question upsert;
|
||||
-- a duplicate means this report already contributed to the count.
|
||||
-- This guards against:
|
||||
-- - Replayed submit (idempotent resubmit) double-counting
|
||||
-- - Concurrent submit races where both threads evaluate the
|
||||
-- same report details
|
||||
--
|
||||
-- Indexes:
|
||||
-- uk_tenant_user_question_report — per (tenant, user, question, report) uniqueness.
|
||||
-- INSERT IGNORE provides the idempotency guard BEFORE upserting.
|
||||
-- wrong_question_id is filled after upsert for audit purposes.
|
||||
-- idx_report — fast lookup by report for audit/debug.
|
||||
CREATE TABLE `education_wrong_question_idempotency` (
|
||||
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`tenant_id` BIGINT NOT NULL COMMENT '租户编号',
|
||||
`user_id` BIGINT NOT NULL COMMENT '学生用户编号',
|
||||
`wrong_question_id` BIGINT DEFAULT NULL COMMENT '错题记录 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;
|
||||
@@ -0,0 +1,112 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.wrong;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionDetailRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionPageItemRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionPageReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionReviewReqVO;
|
||||
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.enums.GlobalErrorCodeConstants.UNAUTHORIZED;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
|
||||
/**
|
||||
* 错题本 Controller — 学生端已认证接口。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Tag(name = "用户 APP - 错题本")
|
||||
@RestController
|
||||
@RequestMapping("/education")
|
||||
@Validated
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
|
||||
public class WrongQuestionController {
|
||||
|
||||
@Resource
|
||||
private WrongQuestionService wrongQuestionService;
|
||||
|
||||
// ========== 错题列表 ==========
|
||||
|
||||
@GetMapping("/wrong-question/page")
|
||||
@Operation(summary = "分页查询错题列表",
|
||||
description = "按当前用户分页查询错题,支持按掌握状态筛选,按最近错误时间降序。")
|
||||
public CommonResult<PageResult<WrongQuestionPageItemRespVO>> page(@Valid WrongQuestionPageReqVO reqVO) {
|
||||
return success(wrongQuestionService.getWrongQuestionPage(
|
||||
getUserId(), getTenantId(),
|
||||
reqVO.getPageNo(), reqVO.getPageSize(),
|
||||
reqVO.getMasterStatus()));
|
||||
}
|
||||
|
||||
// ========== 错题详情 ==========
|
||||
|
||||
@GetMapping("/wrong-question/get")
|
||||
@Operation(summary = "获取错题详情",
|
||||
description = "获取指定错题的完整信息,含正确答案和解析。仅限本人错题。")
|
||||
public CommonResult<WrongQuestionDetailRespVO> get(
|
||||
@Parameter(description = "错题记录 ID", required = true) @RequestParam Long id) {
|
||||
return success(wrongQuestionService.getWrongQuestionDetail(id, getUserId(), getTenantId()));
|
||||
}
|
||||
|
||||
// ========== 掌握操作 ==========
|
||||
|
||||
@PutMapping("/wrong-question/master")
|
||||
@Operation(summary = "标记错题已掌握(幂等)",
|
||||
description = "标记指定错题为已掌握。已掌握的错题再次调用无操作。不删除历史记录。")
|
||||
public CommonResult<Boolean> master(
|
||||
@Parameter(description = "错题记录 ID", required = true) @RequestParam Long id) {
|
||||
wrongQuestionService.markMastered(id, getUserId(), getTenantId());
|
||||
return success(true);
|
||||
}
|
||||
|
||||
@PutMapping("/wrong-question/unmaster")
|
||||
@Operation(summary = "取消掌握标记(幂等)",
|
||||
description = "取消指定错题的掌握标记。未掌握的错题再次调用无操作。")
|
||||
public CommonResult<Boolean> unmaster(
|
||||
@Parameter(description = "错题记录 ID", required = true) @RequestParam Long id) {
|
||||
wrongQuestionService.unmarkMastered(id, getUserId(), getTenantId());
|
||||
return success(true);
|
||||
}
|
||||
|
||||
// ========== 复习入口 ==========
|
||||
|
||||
@PostMapping("/wrong-question/review-session")
|
||||
@Operation(summary = "从错题创建复习练习(幂等)",
|
||||
description = "选择错题 ID 列表创建一次复习练习会话。服务端验证所有权。"
|
||||
+ "同一 clientSessionId 重复调用返回已有会话。"
|
||||
+ "使用错题快照创建题目,不暴露正确答案。")
|
||||
public CommonResult<PracticeSessionRespVO> createReviewSession(
|
||||
@Valid @RequestBody WrongQuestionReviewReqVO reqVO) {
|
||||
return success(wrongQuestionService.createReviewSession(reqVO, getUserId(), getTenantId()));
|
||||
}
|
||||
|
||||
// ========== security helpers ==========
|
||||
|
||||
private Long getUserId() {
|
||||
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
|
||||
if (loginUser == null) {
|
||||
throw exception(UNAUTHORIZED);
|
||||
}
|
||||
return loginUser.getId();
|
||||
}
|
||||
|
||||
private Long getTenantId() {
|
||||
LoginUser loginUser = SecurityFrameworkUtils.getLoginUser();
|
||||
if (loginUser == null) {
|
||||
throw exception(UNAUTHORIZED);
|
||||
}
|
||||
return loginUser.getTenantId();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package cn.iocoder.yudao.module.education.controller.app.wrong.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.NotEmpty;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 错题复习练习创建请求 VO。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Schema(description = "错题复习练习创建请求")
|
||||
@Data
|
||||
public class WrongQuestionReviewReqVO {
|
||||
|
||||
@Schema(description = "客户端生成的会话标识(UUID),用于幂等创建", requiredMode = Schema.RequiredMode.REQUIRED, example = "550e8400-e29b-41d4-a716-446655440000")
|
||||
@NotEmpty(message = "clientSessionId 不能为空")
|
||||
private String clientSessionId;
|
||||
|
||||
@Schema(description = "错题记录 ID 列表", requiredMode = Schema.RequiredMode.REQUIRED, example = "[1, 2, 3]")
|
||||
@NotEmpty(message = "错题 ID 列表不能为空")
|
||||
@Size(max = 100, message = "单次复习题目数不能超过 100")
|
||||
private List<Long> wrongQuestionIds;
|
||||
|
||||
}
|
||||
@@ -60,4 +60,10 @@ public class PracticeReportDetailDO extends TenantBaseDO {
|
||||
/** 解析快照 */
|
||||
private String explanation;
|
||||
|
||||
/** 题目内容版本快照 */
|
||||
private String contentVersion;
|
||||
|
||||
/** 选项快照 JSON(不含 isCorrect) */
|
||||
private String options;
|
||||
|
||||
}
|
||||
|
||||
@@ -56,4 +56,7 @@ public class PracticeSessionDO extends TenantBaseDO {
|
||||
/** 会话级最后接受的客户端命令序号(跨题目单调递增),用于拒绝乱序请求 */
|
||||
private Integer lastClientSequence;
|
||||
|
||||
/** 复习会话题目指纹(SHA-256 of sorted unique wrongQuestionIds),用于幂等重放校验 */
|
||||
private String reviewFingerprint;
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 错题本 DO — 学生错题持久记录。
|
||||
*
|
||||
* <p>同一 (tenant, user, question) 唯一一条。重复答错只更新计数和快照。
|
||||
* 标记掌握不删除记录;masterStatus 字段标识当前状态。</p>
|
||||
*
|
||||
* <p>快照字段(stem/type/difficulty/options/contentVersion)来自最近一次
|
||||
* 错误报告明细,用于列表和详情展示,无需回查 report_detail 表。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName("education_wrong_question")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class WrongQuestionDO extends TenantBaseDO {
|
||||
|
||||
/** 主键 */
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
/** 学生用户编号 */
|
||||
private Long userId;
|
||||
|
||||
/** 原始题目 ID */
|
||||
private String questionId;
|
||||
|
||||
/** 题干快照(最新) */
|
||||
private String stem;
|
||||
|
||||
/** 题型快照 */
|
||||
private String type;
|
||||
|
||||
/** 难度快照 */
|
||||
private String difficulty;
|
||||
|
||||
/** 选项快照 JSON(不含 isCorrect) */
|
||||
private String options;
|
||||
|
||||
/** 题目内容版本 */
|
||||
private String contentVersion;
|
||||
|
||||
/** 正确答案快照(最新,供详情展示) */
|
||||
private String latestCorrectAnswer;
|
||||
|
||||
/** 解析快照(最新,供详情展示) */
|
||||
private String latestExplanation;
|
||||
|
||||
/** 首次错误时间 */
|
||||
private LocalDateTime firstWrongTime;
|
||||
|
||||
/** 最近错误时间 */
|
||||
private LocalDateTime lastWrongTime;
|
||||
|
||||
/** 累计错误次数 */
|
||||
private Integer wrongCount;
|
||||
|
||||
/** 掌握状态:PENDING / MASTERED */
|
||||
private String masterStatus;
|
||||
|
||||
/** 标记掌握时间 */
|
||||
private LocalDateTime masteredTime;
|
||||
|
||||
/** 最近关联的报告 ID */
|
||||
private Long lastReportId;
|
||||
|
||||
/** 最近关联的会话 ID */
|
||||
private Long lastSessionId;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package cn.iocoder.yudao.module.education.dal.dataobject;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 错题流水幂等 DO。
|
||||
*
|
||||
* <p>同一 (wrong_question_id, report_id) 唯一,确保每个报告明细
|
||||
* 对错题本最多贡献一次。用于防止重放交卷和并发提交重复计数。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@TableName("education_wrong_question_idempotency")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public class WrongQuestionIdempotencyDO extends TenantBaseDO {
|
||||
|
||||
/** 主键 */
|
||||
@TableId
|
||||
private Long id;
|
||||
|
||||
/** 学生用户编号 */
|
||||
private Long userId;
|
||||
|
||||
/** 错题记录 ID */
|
||||
private Long wrongQuestionId;
|
||||
|
||||
/** 报告 ID */
|
||||
private Long reportId;
|
||||
|
||||
/** 题目 ID */
|
||||
private String questionId;
|
||||
|
||||
}
|
||||
@@ -63,4 +63,21 @@ public interface PracticeSessionMapper extends BaseMapperX<PracticeSessionDO> {
|
||||
.set(PracticeSessionDO::getLastClientSequence, lastClientSequence));
|
||||
}
|
||||
|
||||
/**
|
||||
* INSERT IGNORE — attempt session creation for idempotency.
|
||||
* Used by review session creation to resolve concurrent create races
|
||||
* without catching DuplicateKeyException inside a transaction.
|
||||
*
|
||||
* @return 1 if inserted, 0 if duplicate was silently ignored
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Insert("INSERT IGNORE INTO education_practice_session " +
|
||||
"(tenant_id, user_id, client_session_id, status, question_count, " +
|
||||
"collection_id, node_id, type, difficulty, version, review_fingerprint, " +
|
||||
"creator, create_time, updater, update_time, deleted) " +
|
||||
"VALUES (#{tenantId}, #{userId}, #{clientSessionId}, #{status}, #{questionCount}, " +
|
||||
"#{collectionId}, #{nodeId}, #{type}, #{difficulty}, #{version}, #{reviewFingerprint}, " +
|
||||
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
|
||||
@org.apache.ibatis.annotations.Options(useGeneratedKeys = true, keyProperty = "id")
|
||||
int insertIgnore(PracticeSessionDO record);
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.WrongQuestionIdempotencyDO;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
|
||||
/**
|
||||
* 错题流水幂等 Mapper。
|
||||
*
|
||||
* <p>INSERT IGNORE 提供 (wrong_question_id, report_id) 的 at-most-once 保障。
|
||||
* 返回 1 = 已插入(可以 upsert wrong question);返回 0 = 该 report 已贡献过。</p>
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Mapper
|
||||
public interface WrongQuestionIdempotencyMapper extends BaseMapperX<WrongQuestionIdempotencyDO> {
|
||||
|
||||
/**
|
||||
* INSERT IGNORE — attempt idempotency guard insertion.
|
||||
*
|
||||
* @return 1 if inserted (first time for this wrong_question+report),
|
||||
* 0 if duplicate was silently ignored
|
||||
*/
|
||||
@Insert("INSERT IGNORE INTO education_wrong_question_idempotency " +
|
||||
"(tenant_id, user_id, wrong_question_id, report_id, question_id, " +
|
||||
"creator, create_time, updater, update_time, deleted) " +
|
||||
"VALUES (#{tenantId}, #{userId}, #{wrongQuestionId}, #{reportId}, #{questionId}, " +
|
||||
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE)")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||
int insertIgnore(WrongQuestionIdempotencyDO record);
|
||||
|
||||
/**
|
||||
* Update wrong_question_id after upsert resolves the wrong question row.
|
||||
* Called within the same transaction as the upsert.
|
||||
*
|
||||
* @param tenantId 租户编号
|
||||
* @param userId 用户编号
|
||||
* @param questionId 题目 ID
|
||||
* @param reportId 报告 ID
|
||||
* @param wrongQuestionId 解析后的错题记录 ID
|
||||
* @return 受影响行数
|
||||
*/
|
||||
@org.apache.ibatis.annotations.Update("UPDATE education_wrong_question_idempotency " +
|
||||
"SET wrong_question_id = #{wrongQuestionId} " +
|
||||
"WHERE tenant_id = #{tenantId} AND user_id = #{userId} " +
|
||||
"AND question_id = #{questionId} AND report_id = #{reportId}")
|
||||
int updateWrongQuestionId(Long tenantId, Long userId, String questionId,
|
||||
Long reportId, Long wrongQuestionId);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package cn.iocoder.yudao.module.education.dal.mysql;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.WrongQuestionDO;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import org.apache.ibatis.annotations.Insert;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Options;
|
||||
|
||||
/**
|
||||
* 错题本 Mapper。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Mapper
|
||||
public interface WrongQuestionMapper extends BaseMapperX<WrongQuestionDO> {
|
||||
|
||||
/**
|
||||
* INSERT ... ON DUPLICATE KEY UPDATE — upsert a wrong question entry.
|
||||
* On conflict (tenant_id, user_id, question_id), increments wrong_count,
|
||||
* updates snapshot fields, and advances last_wrong_time.
|
||||
*
|
||||
* <p>This is the ONLY write path for wrong questions. Callers MUST first
|
||||
* guard with WrongQuestionIdempotencyMapper to ensure at-most-once per report.</p>
|
||||
*
|
||||
* @return 1 = inserted, 2 = updated (MySQL convention for ON DUPLICATE KEY UPDATE)
|
||||
*/
|
||||
@Insert("INSERT INTO education_wrong_question " +
|
||||
"(tenant_id, user_id, question_id, stem, type, difficulty, options, content_version, " +
|
||||
"latest_correct_answer, latest_explanation, " +
|
||||
"first_wrong_time, last_wrong_time, wrong_count, master_status, " +
|
||||
"last_report_id, last_session_id, " +
|
||||
"creator, create_time, updater, update_time, deleted) " +
|
||||
"VALUES (#{tenantId}, #{userId}, #{questionId}, #{stem}, #{type}, #{difficulty}, #{options}, #{contentVersion}, " +
|
||||
"#{latestCorrectAnswer}, #{latestExplanation}, " +
|
||||
"#{firstWrongTime}, #{lastWrongTime}, #{wrongCount}, #{masterStatus}, " +
|
||||
"#{lastReportId}, #{lastSessionId}, " +
|
||||
"#{creator}, #{createTime}, #{updater}, #{updateTime}, FALSE) " +
|
||||
"ON DUPLICATE KEY UPDATE " +
|
||||
"id = LAST_INSERT_ID(id), " +
|
||||
"stem = VALUES(stem), " +
|
||||
"type = VALUES(type), " +
|
||||
"difficulty = VALUES(difficulty), " +
|
||||
"options = VALUES(options), " +
|
||||
"content_version = VALUES(content_version), " +
|
||||
"latest_correct_answer = VALUES(latest_correct_answer), " +
|
||||
"latest_explanation = VALUES(latest_explanation), " +
|
||||
"last_wrong_time = VALUES(last_wrong_time), " +
|
||||
"wrong_count = wrong_count + 1, " +
|
||||
"last_report_id = VALUES(last_report_id), " +
|
||||
"last_session_id = VALUES(last_session_id), " +
|
||||
"update_time = VALUES(update_time)")
|
||||
@Options(useGeneratedKeys = true, keyProperty = "id")
|
||||
int upsert(WrongQuestionDO record);
|
||||
|
||||
/**
|
||||
* 按 ID、租户、用户查找单条错题(所有权校验)。
|
||||
*/
|
||||
default WrongQuestionDO selectByIdAndTenantAndUser(Long id, Long tenantId, Long userId) {
|
||||
return selectOne(new LambdaQueryWrapperX<WrongQuestionDO>()
|
||||
.eq(WrongQuestionDO::getId, id)
|
||||
.eq(WrongQuestionDO::getTenantId, tenantId)
|
||||
.eq(WrongQuestionDO::getUserId, userId));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按租户和用户分页查询错题,可选掌握状态筛选,按最近错误时间降序。
|
||||
*/
|
||||
default IPage<WrongQuestionDO> selectPageByTenantAndUser(IPage<WrongQuestionDO> page,
|
||||
Long tenantId, Long userId,
|
||||
String masterStatus) {
|
||||
LambdaQueryWrapperX<WrongQuestionDO> q = new LambdaQueryWrapperX<WrongQuestionDO>()
|
||||
.eq(WrongQuestionDO::getTenantId, tenantId)
|
||||
.eq(WrongQuestionDO::getUserId, userId);
|
||||
if (masterStatus != null && !masterStatus.isEmpty()) {
|
||||
q.eq(WrongQuestionDO::getMasterStatus, masterStatus);
|
||||
}
|
||||
q.orderByDesc(WrongQuestionDO::getLastWrongTime);
|
||||
return selectPage(page, q);
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新掌握状态(idempotent — 已处于目标状态则无操作)。
|
||||
*
|
||||
* @return 受影响行数(1 = 更新成功,0 = 记录不存在/租户用户不匹配)
|
||||
*/
|
||||
default int updateMasterStatus(Long id, Long tenantId, Long userId,
|
||||
String oldStatus, String newStatus,
|
||||
java.time.LocalDateTime masteredTime) {
|
||||
return update(null,
|
||||
new LambdaUpdateWrapper<WrongQuestionDO>()
|
||||
.eq(WrongQuestionDO::getId, id)
|
||||
.eq(WrongQuestionDO::getTenantId, tenantId)
|
||||
.eq(WrongQuestionDO::getUserId, userId)
|
||||
.eq(WrongQuestionDO::getMasterStatus, oldStatus)
|
||||
.set(WrongQuestionDO::getMasterStatus, newStatus)
|
||||
.set(WrongQuestionDO::getMasteredTime, masteredTime));
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset master_status to PENDING and clear mastered_time if currently MASTERED.
|
||||
* Called after upsert on a new wrong answer to reopen a previously-mastered question.
|
||||
* Idempotent: if already PENDING, no rows affected.
|
||||
*/
|
||||
default void resetMasterStatusIfMastered(Long tenantId, Long userId, String questionId) {
|
||||
update(null,
|
||||
new LambdaUpdateWrapper<WrongQuestionDO>()
|
||||
.eq(WrongQuestionDO::getTenantId, tenantId)
|
||||
.eq(WrongQuestionDO::getUserId, userId)
|
||||
.eq(WrongQuestionDO::getQuestionId, questionId)
|
||||
.eq(WrongQuestionDO::getMasterStatus, "MASTERED")
|
||||
.set(WrongQuestionDO::getMasterStatus, "PENDING")
|
||||
.set(WrongQuestionDO::getMasteredTime, (java.time.LocalDateTime) null));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按用户批量查询指定题目 ID 的错题(用于复习会话创建时验证所有权)。
|
||||
*/
|
||||
default java.util.List<WrongQuestionDO> selectByUserAndQuestionIds(Long tenantId, Long userId,
|
||||
java.util.Collection<String> questionIds) {
|
||||
return selectList(new LambdaQueryWrapperX<WrongQuestionDO>()
|
||||
.eq(WrongQuestionDO::getTenantId, tenantId)
|
||||
.eq(WrongQuestionDO::getUserId, userId)
|
||||
.in(WrongQuestionDO::getQuestionId, questionIds));
|
||||
}
|
||||
}
|
||||
@@ -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, "单次复习题目数不能超过 {}");
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionService;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.*;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.*;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.*;
|
||||
@@ -40,6 +41,7 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
private final PracticeReportMapper reportMapper;
|
||||
private final PracticeReportDetailMapper reportDetailMapper;
|
||||
private final QuestionCatalogProvider questionCatalogProvider;
|
||||
private final WrongQuestionService wrongQuestionService;
|
||||
|
||||
public PracticeSessionServiceImpl(PracticeSessionMapper sessionMapper,
|
||||
PracticeQuestionMapper questionMapper,
|
||||
@@ -47,7 +49,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
SubmitIdempotencyMapper submitIdempotencyMapper,
|
||||
PracticeReportMapper reportMapper,
|
||||
PracticeReportDetailMapper reportDetailMapper,
|
||||
QuestionCatalogProvider questionCatalogProvider) {
|
||||
QuestionCatalogProvider questionCatalogProvider,
|
||||
WrongQuestionService wrongQuestionService) {
|
||||
this.sessionMapper = sessionMapper;
|
||||
this.questionMapper = questionMapper;
|
||||
this.idempotencyMapper = idempotencyMapper;
|
||||
@@ -55,8 +58,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
this.reportMapper = reportMapper;
|
||||
this.reportDetailMapper = reportDetailMapper;
|
||||
this.questionCatalogProvider = questionCatalogProvider;
|
||||
this.wrongQuestionService = wrongQuestionService;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public PracticeSessionRespVO createPracticeSession(PracticeSessionCreateReqVO reqVO, Long userId, Long tenantId) {
|
||||
@@ -359,6 +362,8 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
detail.setCorrectAnswer(q.getCorrectAnswer());
|
||||
detail.setIsCorrect(q.getIsAnswered() != null && q.getIsAnswered() && isAnswerCorrect(q));
|
||||
detail.setExplanation(q.getExplanation());
|
||||
detail.setOptions(q.getOptions());
|
||||
detail.setContentVersion(q.getContentVersion() != null ? q.getContentVersion() : "");
|
||||
details.add(detail);
|
||||
}
|
||||
|
||||
@@ -404,6 +409,9 @@ public class PracticeSessionServiceImpl implements PracticeSessionService {
|
||||
}
|
||||
reportDetailMapper.insertBatch(details);
|
||||
|
||||
// 8.5 Upsert wrong questions for incorrect answers (within same transaction)
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, report.getId(), session.getId(), details);
|
||||
|
||||
// 9. CAS: ACTIVE → SUBMITTED
|
||||
int casResult = sessionMapper.update(null,
|
||||
new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper<PracticeSessionDO>()
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package cn.iocoder.yudao.module.education.service.wrong;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionDetailRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionPageItemRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionReviewReqVO;
|
||||
|
||||
/**
|
||||
* 错题本服务接口。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
public interface WrongQuestionService {
|
||||
|
||||
/**
|
||||
* 分页查询当前用户的错题列表。
|
||||
*
|
||||
* @param userId 当前学生用户
|
||||
* @param tenantId 当前租户
|
||||
* @param pageNo 页码
|
||||
* @param pageSize 每页条数
|
||||
* @param masterStatus 可选掌握状态筛选 (null = 全部)
|
||||
* @return 分页结果
|
||||
*/
|
||||
PageResult<WrongQuestionPageItemRespVO> getWrongQuestionPage(
|
||||
Long userId, Long tenantId, int pageNo, int pageSize, String masterStatus);
|
||||
|
||||
/**
|
||||
* 获取错题详情(含正确答案和解析,所有权校验)。
|
||||
*/
|
||||
WrongQuestionDetailRespVO getWrongQuestionDetail(Long id, Long userId, Long tenantId);
|
||||
|
||||
/**
|
||||
* 标记错题为已掌握(幂等)。
|
||||
* 已掌握的错题再次调用无操作。
|
||||
*/
|
||||
void markMastered(Long id, Long userId, Long tenantId);
|
||||
|
||||
/**
|
||||
* 取消掌握标记(幂等)。
|
||||
* 未掌握的错题再次调用无操作。
|
||||
*/
|
||||
void unmarkMastered(Long id, Long userId, Long tenantId);
|
||||
|
||||
/**
|
||||
* 从错题创建复习练习会话。
|
||||
*
|
||||
* <p>服务端验证所有错题 ID 属于当前用户且记录有效(非删除)。
|
||||
* 使用错题表中的快照数据创建 PracticeSession + PracticeQuestion,
|
||||
* 复用稳定会话创建/答题/交卷流程。不暴露正确答案和解析。</p>
|
||||
*
|
||||
* <p>clientSessionId 提供幂等创建。</p>
|
||||
*
|
||||
* @return 创建的练习会话响应
|
||||
*/
|
||||
PracticeSessionRespVO createReviewSession(WrongQuestionReviewReqVO reqVO, Long userId, Long tenantId);
|
||||
|
||||
/**
|
||||
* 交卷后批量 upsert 错题(内部方法,由 submitSession 调用)。
|
||||
*
|
||||
* <p>对每个 isCorrect=false 的 report detail 执行 upsert。
|
||||
* 通过 idempotency 表保证每 (wrong_question_id, report_id) 最多贡献一次。
|
||||
* 未作答的题目不计入错题。</p>
|
||||
*
|
||||
* @param tenantId 租户
|
||||
* @param userId 用户
|
||||
* @param reportId 报告 ID
|
||||
* @param sessionId 会话 ID
|
||||
* @param details 报告明细列表
|
||||
*/
|
||||
void upsertWrongQuestions(Long tenantId, Long userId, Long reportId, Long sessionId,
|
||||
java.util.List<cn.iocoder.yudao.module.education.dal.dataobject.PracticeReportDetailDO> details);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
package cn.iocoder.yudao.module.education.service.wrong;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.crypto.digest.DigestUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeQuestionRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionDetailRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionPageItemRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionReviewReqVO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeQuestionDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeReportDetailDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.PracticeSessionDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.WrongQuestionDO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.WrongQuestionIdempotencyDO;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.PracticeQuestionMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.PracticeSessionMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.WrongQuestionIdempotencyMapper;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.WrongQuestionMapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
|
||||
/**
|
||||
* 错题本服务实现。
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Service
|
||||
@Slf4j
|
||||
@ConditionalOnProperty(prefix = "yudao.education", name = "enabled", havingValue = "true")
|
||||
public class WrongQuestionServiceImpl implements WrongQuestionService {
|
||||
|
||||
private static final int MAX_REVIEW_QUESTION_COUNT = 100;
|
||||
|
||||
private final WrongQuestionMapper wrongQuestionMapper;
|
||||
private final WrongQuestionIdempotencyMapper idempotencyMapper;
|
||||
private final PracticeSessionMapper sessionMapper;
|
||||
private final PracticeQuestionMapper questionMapper;
|
||||
|
||||
public WrongQuestionServiceImpl(WrongQuestionMapper wrongQuestionMapper,
|
||||
WrongQuestionIdempotencyMapper idempotencyMapper,
|
||||
PracticeSessionMapper sessionMapper,
|
||||
PracticeQuestionMapper questionMapper) {
|
||||
this.wrongQuestionMapper = wrongQuestionMapper;
|
||||
this.idempotencyMapper = idempotencyMapper;
|
||||
this.sessionMapper = sessionMapper;
|
||||
this.questionMapper = questionMapper;
|
||||
}
|
||||
|
||||
// ========== Query ==========
|
||||
|
||||
@Override
|
||||
public PageResult<WrongQuestionPageItemRespVO> getWrongQuestionPage(
|
||||
Long userId, Long tenantId, int pageNo, int pageSize, String masterStatus) {
|
||||
IPage<WrongQuestionDO> page = wrongQuestionMapper.selectPageByTenantAndUser(
|
||||
new Page<>(pageNo, pageSize), tenantId, userId, masterStatus);
|
||||
|
||||
List<WrongQuestionPageItemRespVO> list = page.getRecords().stream()
|
||||
.map(this::toPageItem)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return new PageResult<>(list, page.getTotal());
|
||||
}
|
||||
|
||||
@Override
|
||||
public WrongQuestionDetailRespVO getWrongQuestionDetail(Long id, Long userId, Long tenantId) {
|
||||
WrongQuestionDO wq = wrongQuestionMapper.selectByIdAndTenantAndUser(id, tenantId, userId);
|
||||
if (wq == null) {
|
||||
throw exception(WRONG_QUESTION_NOT_FOUND);
|
||||
}
|
||||
return toDetail(wq);
|
||||
}
|
||||
|
||||
// ========== Mastery ==========
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void markMastered(Long id, Long userId, Long tenantId) {
|
||||
int updated = wrongQuestionMapper.updateMasterStatus(
|
||||
id, tenantId, userId, "PENDING", "MASTERED", LocalDateTime.now());
|
||||
if (updated == 0) {
|
||||
// Check if already mastered (idempotent) or not found
|
||||
WrongQuestionDO existing = wrongQuestionMapper.selectByIdAndTenantAndUser(id, tenantId, userId);
|
||||
if (existing == null) {
|
||||
throw exception(WRONG_QUESTION_NOT_FOUND);
|
||||
}
|
||||
if ("MASTERED".equals(existing.getMasterStatus())) {
|
||||
return; // idempotent
|
||||
}
|
||||
throw exception(WRONG_QUESTION_NOT_OWN);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void unmarkMastered(Long id, Long userId, Long tenantId) {
|
||||
int updated = wrongQuestionMapper.updateMasterStatus(
|
||||
id, tenantId, userId, "MASTERED", "PENDING", null);
|
||||
if (updated == 0) {
|
||||
WrongQuestionDO existing = wrongQuestionMapper.selectByIdAndTenantAndUser(id, tenantId, userId);
|
||||
if (existing == null) {
|
||||
throw exception(WRONG_QUESTION_NOT_FOUND);
|
||||
}
|
||||
if ("PENDING".equals(existing.getMasterStatus())) {
|
||||
return; // idempotent
|
||||
}
|
||||
throw exception(WRONG_QUESTION_NOT_OWN);
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Review Session ==========
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public PracticeSessionRespVO createReviewSession(WrongQuestionReviewReqVO reqVO, Long userId, Long tenantId) {
|
||||
List<Long> wrongQuestionIds = reqVO.getWrongQuestionIds();
|
||||
if (CollUtil.isEmpty(wrongQuestionIds)) {
|
||||
throw exception(WRONG_QUESTION_REVIEW_IDS_EMPTY);
|
||||
}
|
||||
if (wrongQuestionIds.size() > MAX_REVIEW_QUESTION_COUNT) {
|
||||
throw exception(WRONG_QUESTION_REVIEW_IDS_TOO_MANY, MAX_REVIEW_QUESTION_COUNT);
|
||||
}
|
||||
|
||||
// 1. Compute canonical fingerprint from sorted, unique wrong question IDs
|
||||
String fingerprint = computeReviewFingerprint(wrongQuestionIds);
|
||||
|
||||
// 2. Check existing session by tenant + clientSessionId
|
||||
PracticeSessionDO existing = sessionMapper.selectByTenantAndClientSessionId(
|
||||
tenantId, reqVO.getClientSessionId());
|
||||
if (existing != null) {
|
||||
// Cross-user: same clientSessionId, different user → not found
|
||||
if (!Objects.equals(existing.getUserId(), userId)) {
|
||||
throw exception(SESSION_NOT_FOUND);
|
||||
}
|
||||
// Different ID set → mismatch
|
||||
if (!Objects.equals(existing.getReviewFingerprint(), fingerprint)) {
|
||||
throw exception(SESSION_IDEMPOTENCY_MISMATCH);
|
||||
}
|
||||
// Same tenant + clientSessionId + user + fingerprint → replay
|
||||
return buildSessionResp(existing, questionMapper.selectBySessionIdOrderBySequence(existing.getId()));
|
||||
}
|
||||
|
||||
// 3. Load all wrong questions by ID, verify ownership
|
||||
List<WrongQuestionDO> wrongQuestions = wrongQuestionMapper.selectBatchIds(wrongQuestionIds);
|
||||
if (wrongQuestions.size() != wrongQuestionIds.size()) {
|
||||
throw exception(WRONG_QUESTION_REVIEW_NOT_ALL_OWNED);
|
||||
}
|
||||
for (WrongQuestionDO wq : wrongQuestions) {
|
||||
if (!Objects.equals(wq.getUserId(), userId) || !Objects.equals(wq.getTenantId(), tenantId)) {
|
||||
throw exception(WRONG_QUESTION_REVIEW_NOT_ALL_OWNED);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Create session via INSERT IGNORE (safe concurrent create race resolution)
|
||||
PracticeSessionDO session = new PracticeSessionDO();
|
||||
session.setTenantId(tenantId);
|
||||
session.setUserId(userId);
|
||||
session.setClientSessionId(reqVO.getClientSessionId());
|
||||
session.setStatus("ACTIVE");
|
||||
session.setQuestionCount(wrongQuestions.size());
|
||||
session.setVersion(1);
|
||||
session.setReviewFingerprint(fingerprint);
|
||||
|
||||
int inserted = sessionMapper.insertIgnore(session);
|
||||
if (inserted == 0) {
|
||||
// Race: another concurrent request with same clientSessionId won the insert.
|
||||
// Re-read to confirm ownership and fingerprint.
|
||||
PracticeSessionDO winner = sessionMapper.selectByTenantAndClientSessionId(
|
||||
tenantId, reqVO.getClientSessionId());
|
||||
if (winner == null || !Objects.equals(winner.getUserId(), userId)) {
|
||||
throw exception(SESSION_NOT_FOUND);
|
||||
}
|
||||
if (!Objects.equals(winner.getReviewFingerprint(), fingerprint)) {
|
||||
throw exception(SESSION_IDEMPOTENCY_MISMATCH);
|
||||
}
|
||||
return buildSessionResp(winner, questionMapper.selectBySessionIdOrderBySequence(winner.getId()));
|
||||
}
|
||||
|
||||
// 4. Create question snapshots from wrong question data (NO correct answer exposed pre-submit)
|
||||
List<PracticeQuestionDO> questionDOs = new ArrayList<>(wrongQuestions.size());
|
||||
int seq = 1;
|
||||
for (WrongQuestionDO wq : wrongQuestions) {
|
||||
PracticeQuestionDO pq = new PracticeQuestionDO();
|
||||
pq.setTenantId(tenantId);
|
||||
pq.setSessionId(session.getId());
|
||||
pq.setSequence(seq++);
|
||||
pq.setQuestionId(wq.getQuestionId());
|
||||
pq.setContentVersion(wq.getContentVersion() != null ? wq.getContentVersion() : "");
|
||||
pq.setStem(wq.getStem());
|
||||
pq.setType(wq.getType());
|
||||
pq.setDifficulty(wq.getDifficulty());
|
||||
pq.setOptions(wq.getOptions());
|
||||
pq.setCorrectAnswer(wq.getLatestCorrectAnswer());
|
||||
pq.setExplanation(wq.getLatestExplanation());
|
||||
pq.setIsAnswered(false);
|
||||
questionDOs.add(pq);
|
||||
}
|
||||
questionMapper.insertBatch(questionDOs);
|
||||
|
||||
return buildSessionResp(session, questionDOs);
|
||||
}
|
||||
|
||||
// ========== Upsert (internal, called from submitSession) ==========
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void upsertWrongQuestions(Long tenantId, Long userId, Long reportId, Long sessionId,
|
||||
List<PracticeReportDetailDO> details) {
|
||||
if (CollUtil.isEmpty(details)) {
|
||||
return;
|
||||
}
|
||||
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
|
||||
for (PracticeReportDetailDO detail : details) {
|
||||
// Only count answered + incorrect
|
||||
if (detail.getIsCorrect() == null || detail.getIsCorrect()) {
|
||||
continue;
|
||||
}
|
||||
// Unanswered → selectedAnswer is null → not a wrong answer
|
||||
if (detail.getSelectedAnswer() == null || detail.getSelectedAnswer().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 1. INSERT IGNORE idempotency guard — per (tenant, user, question, report)
|
||||
WrongQuestionIdempotencyDO idem = new WrongQuestionIdempotencyDO();
|
||||
idem.setTenantId(tenantId);
|
||||
idem.setUserId(userId);
|
||||
idem.setReportId(reportId);
|
||||
idem.setQuestionId(detail.getQuestionId());
|
||||
|
||||
int idemInserted = idempotencyMapper.insertIgnore(idem);
|
||||
if (idemInserted == 0) {
|
||||
// This (question, report) pair already contributed — skip
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Upsert wrong question
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId(detail.getQuestionId());
|
||||
wq.setStem(detail.getStem());
|
||||
wq.setType(detail.getType());
|
||||
wq.setDifficulty(detail.getDifficulty());
|
||||
wq.setOptions(detail.getOptions() != null ? detail.getOptions() : "[]");
|
||||
wq.setContentVersion(detail.getContentVersion() != null ? detail.getContentVersion() : "");
|
||||
wq.setLatestCorrectAnswer(detail.getCorrectAnswer());
|
||||
wq.setLatestExplanation(detail.getExplanation());
|
||||
wq.setFirstWrongTime(now);
|
||||
wq.setLastWrongTime(now);
|
||||
wq.setWrongCount(1);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wq.setMasteredTime(null); // explicit null: reopen PENDING if was MASTERED
|
||||
wq.setLastReportId(reportId);
|
||||
wq.setLastSessionId(sessionId);
|
||||
|
||||
wrongQuestionMapper.upsert(wq);
|
||||
|
||||
// 2.5 Reset master_status to PENDING and clear mastered_time if was MASTERED.
|
||||
// The upsert SQL only updates snapshots/count — master fields handled here
|
||||
// for H2 compatibility (VALUES() on nullable datetime unsupported in H2).
|
||||
wrongQuestionMapper.resetMasterStatusIfMastered(tenantId, userId,
|
||||
detail.getQuestionId());
|
||||
|
||||
// 3. Update idempotency row with resolved wrong_question_id for audit
|
||||
idempotencyMapper.updateWrongQuestionId(tenantId, userId,
|
||||
detail.getQuestionId(), reportId, wq.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// ========== Internal helpers ==========
|
||||
|
||||
|
||||
/**
|
||||
* Compute a canonical fingerprint from sorted, distinct wrong question IDs.
|
||||
* Uses SHA-256 of comma-joined sorted unique IDs for deterministic comparison.
|
||||
*/
|
||||
private String computeReviewFingerprint(List<Long> wrongQuestionIds) {
|
||||
List<Long> sorted = wrongQuestionIds.stream()
|
||||
.distinct()
|
||||
.sorted()
|
||||
.collect(Collectors.toList());
|
||||
String joined = sorted.stream()
|
||||
.map(String::valueOf)
|
||||
.collect(Collectors.joining(","));
|
||||
return DigestUtil.sha256Hex(joined);
|
||||
}
|
||||
private WrongQuestionPageItemRespVO toPageItem(WrongQuestionDO wq) {
|
||||
return WrongQuestionPageItemRespVO.builder()
|
||||
.id(wq.getId())
|
||||
.questionId(wq.getQuestionId())
|
||||
.stem(wq.getStem())
|
||||
.type(wq.getType())
|
||||
.difficulty(wq.getDifficulty())
|
||||
.wrongCount(wq.getWrongCount())
|
||||
.lastWrongTime(wq.getLastWrongTime())
|
||||
.masterStatus(wq.getMasterStatus())
|
||||
.build();
|
||||
}
|
||||
|
||||
private WrongQuestionDetailRespVO toDetail(WrongQuestionDO wq) {
|
||||
return WrongQuestionDetailRespVO.builder()
|
||||
.id(wq.getId())
|
||||
.questionId(wq.getQuestionId())
|
||||
.stem(wq.getStem())
|
||||
.type(wq.getType())
|
||||
.difficulty(wq.getDifficulty())
|
||||
.options(wq.getOptions())
|
||||
.contentVersion(wq.getContentVersion())
|
||||
.correctAnswer(wq.getLatestCorrectAnswer())
|
||||
.explanation(wq.getLatestExplanation())
|
||||
.firstWrongTime(wq.getFirstWrongTime())
|
||||
.lastWrongTime(wq.getLastWrongTime())
|
||||
.wrongCount(wq.getWrongCount())
|
||||
.masterStatus(wq.getMasterStatus())
|
||||
.masteredTime(wq.getMasteredTime())
|
||||
.build();
|
||||
}
|
||||
|
||||
private PracticeSessionRespVO buildSessionResp(PracticeSessionDO session, List<PracticeQuestionDO> questions) {
|
||||
List<PracticeQuestionRespVO> questionVOs = questions.stream()
|
||||
.map(pq -> {
|
||||
List<PracticeQuestionRespVO.OptionVO> options = parseOptions(pq.getOptions());
|
||||
return PracticeQuestionRespVO.builder()
|
||||
.sequence(pq.getSequence())
|
||||
.questionId(pq.getQuestionId())
|
||||
.stem(pq.getStem())
|
||||
.type(pq.getType())
|
||||
.difficulty(pq.getDifficulty())
|
||||
.options(options)
|
||||
.selectedAnswer(pq.getSelectedAnswer())
|
||||
.isAnswered(pq.getIsAnswered() != null && pq.getIsAnswered())
|
||||
.contentVersion(pq.getContentVersion())
|
||||
.build();
|
||||
})
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return PracticeSessionRespVO.builder()
|
||||
.sessionId(session.getId())
|
||||
.status(session.getStatus())
|
||||
.questionCount(session.getQuestionCount())
|
||||
.version(session.getVersion())
|
||||
.clientSessionId(session.getClientSessionId())
|
||||
.lastClientSequence(session.getLastClientSequence())
|
||||
.questions(questionVOs)
|
||||
.build();
|
||||
}
|
||||
|
||||
@SuppressWarnings({"unchecked", "rawtypes"})
|
||||
private List<PracticeQuestionRespVO.OptionVO> parseOptions(String optionsJson) {
|
||||
if (optionsJson == null || optionsJson.isEmpty()) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
try {
|
||||
List<Map<String, Object>> raw = (List) cn.iocoder.yudao.framework.common.util.json.JsonUtils.parseArray(optionsJson, Map.class);
|
||||
if (raw == null) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
return raw.stream()
|
||||
.map(m -> PracticeQuestionRespVO.OptionVO.builder()
|
||||
.label((String) m.get("label"))
|
||||
.content((String) m.get("content"))
|
||||
.order(m.get("order") != null ? ((Number) m.get("order")).doubleValue() : null)
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
} catch (Exception e) {
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
package cn.iocoder.yudao.module.education.service.practice;
|
||||
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitReqVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSubmitRespVO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.*;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.*;
|
||||
import cn.iocoder.yudao.module.education.service.question.QuestionCatalogProvider;
|
||||
import cn.iocoder.yudao.module.education.service.wrong.WrongQuestionServiceImpl;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* Integration test verifying that submitSession transactionally creates wrong
|
||||
* question projections. Uses real WrongQuestionServiceImpl (no mock) so the
|
||||
* full submit → wrong-question pipeline is exercised with real DB.
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Import({PracticeSessionServiceImpl.class, WrongQuestionServiceImpl.class})
|
||||
public class PracticeSubmitProjectionIntegrationTest extends BaseDbUnitTest {
|
||||
|
||||
@Resource
|
||||
private PracticeSessionService service;
|
||||
|
||||
@Resource
|
||||
private PracticeSessionMapper sessionMapper;
|
||||
|
||||
@Resource
|
||||
private PracticeQuestionMapper questionMapper;
|
||||
|
||||
@Resource
|
||||
private PracticeReportMapper reportMapper;
|
||||
|
||||
@Resource
|
||||
private PracticeReportDetailMapper reportDetailMapper;
|
||||
|
||||
@Resource
|
||||
private WrongQuestionMapper wrongQuestionMapper;
|
||||
|
||||
@Resource
|
||||
private WrongQuestionIdempotencyMapper idempotencyMapper;
|
||||
|
||||
@MockitoBean
|
||||
private QuestionCatalogProvider provider;
|
||||
|
||||
private final Long userId = 100L;
|
||||
private final Long tenantId = 1L;
|
||||
|
||||
// ========== helpers ==========
|
||||
|
||||
private record SessionFixture(Long sessionId, List<PracticeQuestionDO> questions, Integer version) {}
|
||||
|
||||
private SessionFixture createSessionWithQuestions(String clientSessionId, int questionCount, String correctAnswer) {
|
||||
PracticeSessionDO session = new PracticeSessionDO();
|
||||
session.setTenantId(tenantId);
|
||||
session.setUserId(userId);
|
||||
session.setClientSessionId(clientSessionId);
|
||||
session.setStatus("ACTIVE");
|
||||
session.setQuestionCount(questionCount);
|
||||
session.setCollectionId("col-001");
|
||||
session.setVersion(1);
|
||||
sessionMapper.insert(session);
|
||||
|
||||
List<PracticeQuestionDO> questions = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < questionCount; i++) {
|
||||
PracticeQuestionDO q = new PracticeQuestionDO();
|
||||
q.setTenantId(tenantId);
|
||||
q.setSessionId(session.getId());
|
||||
q.setSequence(i + 1);
|
||||
q.setQuestionId("q-" + String.format("%03d", i + 1));
|
||||
q.setContentVersion("v1");
|
||||
q.setStem("Question " + (i + 1));
|
||||
q.setType("choice");
|
||||
q.setDifficulty("easy");
|
||||
q.setOptions("[{\"label\":\"A\",\"content\":\"Option A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Option B\",\"order\":2.0}]");
|
||||
q.setCorrectAnswer(correctAnswer);
|
||||
q.setExplanation("Explanation " + (i + 1));
|
||||
q.setIsAnswered(false);
|
||||
questions.add(q);
|
||||
}
|
||||
questionMapper.insertBatch(questions);
|
||||
|
||||
return new SessionFixture(session.getId(), questions, session.getVersion());
|
||||
}
|
||||
|
||||
private void answerQuestion(Long questionId, String answer) {
|
||||
PracticeQuestionDO q = questionMapper.selectById(questionId);
|
||||
if (q != null) {
|
||||
q.setSelectedAnswer(answer);
|
||||
q.setIsAnswered(true);
|
||||
questionMapper.updateById(q);
|
||||
}
|
||||
}
|
||||
|
||||
private PracticeSubmitReqVO createSubmitReq(Long sessionId, String idempotencyKey, int expectedVersion) {
|
||||
PracticeSubmitReqVO req = new PracticeSubmitReqVO();
|
||||
req.setSessionId(sessionId);
|
||||
req.setIdempotencyKey(idempotencyKey);
|
||||
req.setExpectedSessionVersion(expectedVersion);
|
||||
return req;
|
||||
}
|
||||
|
||||
// ========== Submit creates wrong question projection ==========
|
||||
|
||||
@Test
|
||||
void shouldCreateWrongQuestionProjectionOnSubmit() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-proj-create", 3, "B");
|
||||
// Answer q1 wrong (A), q2 correct (B), q3 unanswered
|
||||
answerQuestion(f.questions.get(0).getId(), "A");
|
||||
answerQuestion(f.questions.get(1).getId(), "B");
|
||||
// q3: no answer
|
||||
|
||||
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-proj-create", 1);
|
||||
PracticeSubmitRespVO resp = service.submitSession(req, userId, tenantId);
|
||||
|
||||
assertNotNull(resp.getReportId());
|
||||
|
||||
// Only q1 is wrong → one wrong question entry
|
||||
List<WrongQuestionDO> wqs = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, wqs.size());
|
||||
WrongQuestionDO wq = wqs.get(0);
|
||||
assertEquals("q-001", wq.getQuestionId());
|
||||
assertEquals(1, wq.getWrongCount());
|
||||
assertNotNull(wq.getOptions(), "options must be populated from detail");
|
||||
assertNotEquals("[]", wq.getOptions(), "options must not be empty placeholder");
|
||||
assertEquals("v1", wq.getContentVersion(),
|
||||
"contentVersion must be populated from report detail");
|
||||
|
||||
// One idempotency entry
|
||||
assertEquals(1, idempotencyMapper.selectList().size());
|
||||
|
||||
// Report stats correct
|
||||
PracticeReportDO report = reportMapper.selectById(resp.getReportId());
|
||||
assertEquals(3, report.getQuestionCount());
|
||||
assertEquals(2, report.getAnsweredCount());
|
||||
assertEquals(1, report.getUnansweredCount());
|
||||
assertEquals(1, report.getCorrectCount());
|
||||
assertEquals(1, report.getIncorrectCount());
|
||||
}
|
||||
|
||||
// ========== Replay submit does not double-count wrong questions ==========
|
||||
|
||||
@Test
|
||||
void shouldNotDoubleCountWrongQuestionOnSubmitReplay() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-proj-replay", 2, "B");
|
||||
answerQuestion(f.questions.get(0).getId(), "A");
|
||||
answerQuestion(f.questions.get(1).getId(), "A"); // both wrong
|
||||
|
||||
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-proj-replay", 1);
|
||||
PracticeSubmitRespVO first = service.submitSession(req, userId, tenantId);
|
||||
assertNotNull(first.getReportId());
|
||||
|
||||
// Replay same submit — idempotent
|
||||
PracticeSubmitRespVO replay = service.submitSession(req, userId, tenantId);
|
||||
assertEquals(first.getReportId(), replay.getReportId());
|
||||
|
||||
// Wrong question count unchanged
|
||||
List<WrongQuestionDO> wqs = wrongQuestionMapper.selectList();
|
||||
assertEquals(2, wqs.size());
|
||||
for (WrongQuestionDO wq : wqs) {
|
||||
assertEquals(1, wq.getWrongCount(),
|
||||
"replay must not double-count wrong question: " + wq.getQuestionId());
|
||||
}
|
||||
|
||||
// Still exactly 2 idempotency entries (one per question)
|
||||
assertEquals(2, idempotencyMapper.selectList().size());
|
||||
}
|
||||
|
||||
// ========== Unanswered questions excluded from wrong projection ==========
|
||||
|
||||
@Test
|
||||
void shouldNotCreateWrongQuestionForUnanswered() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-proj-unans", 3, "B");
|
||||
answerQuestion(f.questions.get(0).getId(), "A"); // wrong
|
||||
// questions 2 and 3 unanswered
|
||||
|
||||
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-proj-unans", 1);
|
||||
PracticeSubmitRespVO resp = service.submitSession(req, userId, tenantId);
|
||||
assertNotNull(resp.getReportId());
|
||||
|
||||
// Only 1 wrong question
|
||||
List<WrongQuestionDO> wqs = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, wqs.size());
|
||||
assertEquals("q-001", wqs.get(0).getQuestionId());
|
||||
}
|
||||
|
||||
// ========== Correct answers excluded from wrong projection ==========
|
||||
|
||||
@Test
|
||||
void shouldNotCreateWrongQuestionForCorrectAnswers() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-proj-correct", 2, "B");
|
||||
answerQuestion(f.questions.get(0).getId(), "B"); // correct
|
||||
answerQuestion(f.questions.get(1).getId(), "B"); // correct
|
||||
|
||||
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-proj-correct", 1);
|
||||
PracticeSubmitRespVO resp = service.submitSession(req, userId, tenantId);
|
||||
assertNotNull(resp.getReportId());
|
||||
|
||||
// No wrong questions
|
||||
List<WrongQuestionDO> wqs = wrongQuestionMapper.selectList();
|
||||
assertEquals(0, wqs.size());
|
||||
}
|
||||
|
||||
// ========== CAS failure on session status: wrong projection guard ==========
|
||||
|
||||
@Test
|
||||
void shouldNotCreateWrongQuestionWhenSessionCasFails() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-proj-cas", 2, "B");
|
||||
answerQuestion(f.questions.get(0).getId(), "A");
|
||||
|
||||
// First submit wins
|
||||
PracticeSubmitReqVO req1 = createSubmitReq(f.sessionId, "idem-proj-cas-1", 1);
|
||||
PracticeSubmitRespVO winner = service.submitSession(req1, userId, tenantId);
|
||||
assertNotNull(winner.getReportId());
|
||||
|
||||
// Second submit with same session + different key → CAS failure (session not ACTIVE)
|
||||
PracticeSubmitReqVO req2 = createSubmitReq(f.sessionId, "idem-proj-cas-2", 1);
|
||||
PracticeSubmitRespVO loser = service.submitSession(req2, userId, tenantId);
|
||||
// The CAS failure path returns the winner's report (existing SUBMITTED session)
|
||||
assertEquals(winner.getReportId(), loser.getReportId(),
|
||||
"CAS loser must get winner's report, not new wrong questions");
|
||||
|
||||
// Wrong question count unchanged — CAS failure prevents new wrong projection
|
||||
List<WrongQuestionDO> wqs = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, wqs.size());
|
||||
assertEquals(1, wqs.get(0).getWrongCount());
|
||||
}
|
||||
|
||||
// ========== wrong_question_id populated in idempotency guard ==========
|
||||
|
||||
@Test
|
||||
void shouldPopulateWrongQuestionIdInIdempotencyGuard() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-proj-wqid", 1, "B");
|
||||
answerQuestion(f.questions.get(0).getId(), "A");
|
||||
|
||||
PracticeSubmitReqVO req = createSubmitReq(f.sessionId, "idem-proj-wqid", 1);
|
||||
PracticeSubmitRespVO resp = service.submitSession(req, userId, tenantId);
|
||||
assertNotNull(resp.getReportId());
|
||||
|
||||
// Verify idempotency row has wrong_question_id populated
|
||||
List<WrongQuestionIdempotencyDO> idems = idempotencyMapper.selectList();
|
||||
assertEquals(1, idems.size());
|
||||
WrongQuestionIdempotencyDO idem = idems.get(0);
|
||||
assertNotNull(idem.getWrongQuestionId(),
|
||||
"wrong_question_id must be populated after upsert");
|
||||
|
||||
// Verify it matches the actual wrong question
|
||||
WrongQuestionDO wq = wrongQuestionMapper.selectById(idem.getWrongQuestionId());
|
||||
assertNotNull(wq);
|
||||
assertEquals("q-001", wq.getQuestionId());
|
||||
}
|
||||
}
|
||||
@@ -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 ==========
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,794 @@
|
||||
package cn.iocoder.yudao.module.education.service.wrong;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.yudao.module.education.controller.app.practice.vo.PracticeSessionRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionDetailRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionPageItemRespVO;
|
||||
import cn.iocoder.yudao.module.education.controller.app.wrong.vo.WrongQuestionReviewReqVO;
|
||||
import cn.iocoder.yudao.module.education.dal.dataobject.*;
|
||||
import cn.iocoder.yudao.module.education.dal.mysql.*;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.context.annotation.Import;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
|
||||
import static cn.iocoder.yudao.module.education.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* WrongQuestionService test — real DB (H2) backing all wrong question assertions.
|
||||
*
|
||||
* @author 恭学教育
|
||||
*/
|
||||
@Import(WrongQuestionServiceImpl.class)
|
||||
public class WrongQuestionServiceImplTest extends BaseDbUnitTest {
|
||||
|
||||
@Resource
|
||||
private WrongQuestionService wrongQuestionService;
|
||||
|
||||
@Resource
|
||||
private WrongQuestionMapper wrongQuestionMapper;
|
||||
|
||||
@Resource
|
||||
private WrongQuestionIdempotencyMapper idempotencyMapper;
|
||||
|
||||
@Resource
|
||||
private PracticeSessionMapper sessionMapper;
|
||||
|
||||
@Resource
|
||||
private PracticeQuestionMapper questionMapper;
|
||||
|
||||
private final Long userId = 100L;
|
||||
private final Long tenantId = 1L;
|
||||
|
||||
// ========== helpers ==========
|
||||
|
||||
private record SessionFixture(Long sessionId, List<PracticeQuestionDO> questions, Integer version) {}
|
||||
|
||||
private SessionFixture createSessionWithQuestions(String clientSessionId, int questionCount, String correctAnswer) {
|
||||
PracticeSessionDO session = new PracticeSessionDO();
|
||||
session.setTenantId(tenantId);
|
||||
session.setUserId(userId);
|
||||
session.setClientSessionId(clientSessionId);
|
||||
session.setStatus("ACTIVE");
|
||||
session.setQuestionCount(questionCount);
|
||||
session.setCollectionId("col-001");
|
||||
session.setVersion(1);
|
||||
sessionMapper.insert(session);
|
||||
|
||||
List<PracticeQuestionDO> questions = new java.util.ArrayList<>();
|
||||
for (int i = 0; i < questionCount; i++) {
|
||||
PracticeQuestionDO q = new PracticeQuestionDO();
|
||||
q.setTenantId(tenantId);
|
||||
q.setSessionId(session.getId());
|
||||
q.setSequence(i + 1);
|
||||
q.setQuestionId("q-" + String.format("%03d", i + 1));
|
||||
q.setContentVersion("v1");
|
||||
q.setStem("Question " + (i + 1));
|
||||
q.setType("choice");
|
||||
q.setDifficulty("easy");
|
||||
q.setOptions("[{\"label\":\"A\",\"content\":\"Option A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Option B\",\"order\":2.0}]");
|
||||
q.setCorrectAnswer(correctAnswer);
|
||||
q.setExplanation("Explanation " + (i + 1));
|
||||
q.setIsAnswered(false);
|
||||
questions.add(q);
|
||||
}
|
||||
questionMapper.insertBatch(questions);
|
||||
|
||||
return new SessionFixture(session.getId(), questions, session.getVersion());
|
||||
}
|
||||
|
||||
private PracticeReportDetailDO createReportDetail(PracticeQuestionDO q, Long reportId, Long sessionId,
|
||||
String selectedAnswer, boolean isCorrect) {
|
||||
PracticeReportDetailDO detail = new PracticeReportDetailDO();
|
||||
detail.setTenantId(tenantId);
|
||||
detail.setUserId(userId);
|
||||
detail.setReportId(reportId);
|
||||
detail.setSessionId(sessionId);
|
||||
detail.setQuestionId(q.getQuestionId());
|
||||
detail.setSequence(q.getSequence());
|
||||
detail.setStem(q.getStem());
|
||||
detail.setType(q.getType());
|
||||
detail.setDifficulty(q.getDifficulty());
|
||||
detail.setSelectedAnswer(selectedAnswer);
|
||||
detail.setCorrectAnswer(q.getCorrectAnswer());
|
||||
detail.setIsCorrect(isCorrect);
|
||||
detail.setExplanation(q.getExplanation());
|
||||
detail.setOptions(q.getOptions());
|
||||
return detail;
|
||||
}
|
||||
|
||||
// ========== First wrong insert ==========
|
||||
|
||||
@Test
|
||||
void shouldCreateWrongQuestionOnFirstWrongAnswer() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-first-wrong", 3, "B");
|
||||
PracticeReportDetailDO detail1 = createReportDetail(f.questions.get(0), 1L, f.sessionId, "A", false);
|
||||
PracticeReportDetailDO detail2 = createReportDetail(f.questions.get(1), 1L, f.sessionId, "B", true);
|
||||
PracticeReportDetailDO detail3 = createReportDetail(f.questions.get(2), 1L, f.sessionId, null, false);
|
||||
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId,
|
||||
List.of(detail1, detail2, detail3));
|
||||
|
||||
// Only question 1 is wrong (answered "A", correct is "B")
|
||||
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, all.size());
|
||||
WrongQuestionDO wq = all.get(0);
|
||||
assertEquals("q-001", wq.getQuestionId());
|
||||
assertEquals("B", wq.getLatestCorrectAnswer());
|
||||
assertEquals(1, wq.getWrongCount());
|
||||
assertNotNull(wq.getFirstWrongTime());
|
||||
assertNotNull(wq.getLastWrongTime());
|
||||
assertEquals("PENDING", wq.getMasterStatus());
|
||||
}
|
||||
|
||||
// ========== Repeated wrong increments ==========
|
||||
|
||||
@Test
|
||||
void shouldIncrementWrongCountOnRepeatedWrongAnswer() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-repeat", 1, "B");
|
||||
PracticeReportDetailDO detail1 = createReportDetail(f.questions.get(0), 1L, f.sessionId, "A", false);
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail1));
|
||||
|
||||
// Same question, different report
|
||||
PracticeReportDetailDO detail2 = createReportDetail(f.questions.get(0), 2L, f.sessionId, "C", false);
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 2L, f.sessionId, List.of(detail2));
|
||||
|
||||
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, all.size());
|
||||
assertEquals(2, all.get(0).getWrongCount());
|
||||
// Last wrong time should be updated, stem snapshots from latest detail
|
||||
assertEquals("Question 1", all.get(0).getStem());
|
||||
assertEquals(2L, all.get(0).getLastReportId().longValue());
|
||||
}
|
||||
|
||||
// ========== Submit replay no double increment ==========
|
||||
|
||||
@Test
|
||||
void shouldNotDoubleIncrementOnReplay() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-replay-wq", 1, "B");
|
||||
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 1L, f.sessionId, "A", false);
|
||||
|
||||
// First upsert
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail));
|
||||
|
||||
// Same (question, report) second time — idempotency skips
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail));
|
||||
|
||||
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, all.size());
|
||||
assertEquals(1, all.get(0).getWrongCount());
|
||||
|
||||
// Idempotency table has exactly 1 entry for this (question, report)
|
||||
List<WrongQuestionIdempotencyDO> idems = idempotencyMapper.selectList();
|
||||
assertEquals(1, idems.size());
|
||||
}
|
||||
|
||||
// ========== Concurrent submit no double count ==========
|
||||
|
||||
@Test
|
||||
void shouldNotDoubleCountUnderConcurrentUpsert() throws Exception {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-conc-wq", 1, "B");
|
||||
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 1L, f.sessionId, "A", false);
|
||||
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch go = new CountDownLatch(1);
|
||||
AtomicInteger successCount = new AtomicInteger(0);
|
||||
|
||||
Runnable task = () -> {
|
||||
try {
|
||||
ready.countDown();
|
||||
go.await();
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail));
|
||||
successCount.incrementAndGet();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
};
|
||||
|
||||
Thread t1 = new Thread(task);
|
||||
Thread t2 = new Thread(task);
|
||||
t1.start();
|
||||
t2.start();
|
||||
ready.await();
|
||||
go.countDown();
|
||||
t1.join(10000);
|
||||
t2.join(10000);
|
||||
|
||||
// Both threads completed without exception (idempotency handles the race)
|
||||
assertEquals(2, successCount.get());
|
||||
|
||||
// Exactly one wrong question with count = 1
|
||||
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, all.size());
|
||||
assertEquals(1, all.get(0).getWrongCount());
|
||||
}
|
||||
|
||||
// ========== Unanswered excluded ==========
|
||||
|
||||
@Test
|
||||
void shouldExcludeUnansweredQuestions() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-unans", 1, "B");
|
||||
// isCorrect=false but selectedAnswer is null (unanswered) → should NOT be counted as wrong
|
||||
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 1L, f.sessionId, null, false);
|
||||
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail));
|
||||
|
||||
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
|
||||
assertEquals(0, all.size());
|
||||
}
|
||||
|
||||
// ========== Correct answer excluded ==========
|
||||
|
||||
@Test
|
||||
void shouldNotCreateWrongQuestionForCorrectAnswer() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-correct", 1, "B");
|
||||
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 1L, f.sessionId, "B", true);
|
||||
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, f.sessionId, List.of(detail));
|
||||
|
||||
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
|
||||
assertEquals(0, all.size());
|
||||
}
|
||||
|
||||
// ========== Mastery idempotent ==========
|
||||
|
||||
@Test
|
||||
void shouldMarkMasteredIdempotently() {
|
||||
// Manually insert a wrong question
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-master");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
|
||||
wq.setLastWrongTime(LocalDateTime.now().minusDays(1));
|
||||
wq.setWrongCount(3);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
// Mark mastered
|
||||
wrongQuestionService.markMastered(wq.getId(), userId, tenantId);
|
||||
|
||||
WrongQuestionDO reloaded = wrongQuestionMapper.selectById(wq.getId());
|
||||
assertEquals("MASTERED", reloaded.getMasterStatus());
|
||||
assertNotNull(reloaded.getMasteredTime());
|
||||
assertEquals(3, reloaded.getWrongCount()); // count preserved
|
||||
|
||||
// Mark mastered again — idempotent
|
||||
wrongQuestionService.markMastered(wq.getId(), userId, tenantId);
|
||||
WrongQuestionDO again = wrongQuestionMapper.selectById(wq.getId());
|
||||
assertEquals("MASTERED", again.getMasterStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUnmarkMasteredIdempotently() {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-unmaster");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
|
||||
wq.setLastWrongTime(LocalDateTime.now().minusDays(1));
|
||||
wq.setWrongCount(2);
|
||||
wq.setMasterStatus("MASTERED");
|
||||
wq.setMasteredTime(LocalDateTime.now().minusDays(1));
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
wrongQuestionService.unmarkMastered(wq.getId(), userId, tenantId);
|
||||
|
||||
WrongQuestionDO reloaded = wrongQuestionMapper.selectById(wq.getId());
|
||||
assertEquals("PENDING", reloaded.getMasterStatus());
|
||||
assertNull(reloaded.getMasteredTime());
|
||||
assertEquals(2, reloaded.getWrongCount()); // count preserved
|
||||
|
||||
// Unmark again — idempotent
|
||||
wrongQuestionService.unmarkMastered(wq.getId(), userId, tenantId);
|
||||
WrongQuestionDO again = wrongQuestionMapper.selectById(wq.getId());
|
||||
assertEquals("PENDING", again.getMasterStatus());
|
||||
}
|
||||
|
||||
// ========== Cross ownership ==========
|
||||
|
||||
@Test
|
||||
void shouldRejectAccessForDifferentUser() {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-cross");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(1));
|
||||
wq.setLastWrongTime(LocalDateTime.now());
|
||||
wq.setWrongCount(1);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
// Different user tries to get detail
|
||||
assertServiceException(
|
||||
() -> wrongQuestionService.getWrongQuestionDetail(wq.getId(), 999L, tenantId),
|
||||
WRONG_QUESTION_NOT_FOUND);
|
||||
|
||||
// Different user tries to mark mastered
|
||||
assertServiceException(
|
||||
() -> wrongQuestionService.markMastered(wq.getId(), 999L, tenantId),
|
||||
WRONG_QUESTION_NOT_FOUND);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectForDifferentTenant() {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-cross-tenant");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(1));
|
||||
wq.setLastWrongTime(LocalDateTime.now());
|
||||
wq.setWrongCount(1);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
assertServiceException(
|
||||
() -> wrongQuestionService.getWrongQuestionDetail(wq.getId(), userId, 999L),
|
||||
WRONG_QUESTION_NOT_FOUND);
|
||||
}
|
||||
|
||||
// ========== Page bounds ==========
|
||||
|
||||
@Test
|
||||
void shouldPaginateWrongQuestions() {
|
||||
// Insert 5 wrong questions
|
||||
for (int i = 1; i <= 5; i++) {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-page-" + String.format("%03d", i));
|
||||
wq.setStem("Question page " + i);
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(i));
|
||||
wq.setLastWrongTime(LocalDateTime.now().minusHours(i));
|
||||
wq.setWrongCount(i);
|
||||
wq.setMasterStatus(i % 2 == 0 ? "MASTERED" : "PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
}
|
||||
|
||||
// Page 1, size 3 — no filter
|
||||
PageResult<WrongQuestionPageItemRespVO> page1 = wrongQuestionService.getWrongQuestionPage(
|
||||
userId, tenantId, 1, 3, null);
|
||||
assertEquals(5, page1.getTotal());
|
||||
assertEquals(3, page1.getList().size());
|
||||
|
||||
// Page 2, size 3
|
||||
PageResult<WrongQuestionPageItemRespVO> page2 = wrongQuestionService.getWrongQuestionPage(
|
||||
userId, tenantId, 2, 3, null);
|
||||
assertEquals(2, page2.getList().size());
|
||||
|
||||
// Filter by PENDING
|
||||
PageResult<WrongQuestionPageItemRespVO> pending = wrongQuestionService.getWrongQuestionPage(
|
||||
userId, tenantId, 1, 10, "PENDING");
|
||||
assertEquals(3, pending.getTotal());
|
||||
|
||||
// Filter by MASTERED
|
||||
PageResult<WrongQuestionPageItemRespVO> mastered = wrongQuestionService.getWrongQuestionPage(
|
||||
userId, tenantId, 1, 10, "MASTERED");
|
||||
assertEquals(2, mastered.getTotal());
|
||||
|
||||
// Different user — empty
|
||||
PageResult<WrongQuestionPageItemRespVO> other = wrongQuestionService.getWrongQuestionPage(
|
||||
999L, tenantId, 1, 10, null);
|
||||
assertEquals(0, other.getTotal());
|
||||
}
|
||||
|
||||
// ========== Review session: ownership + safe snapshot ==========
|
||||
|
||||
@Test
|
||||
void shouldCreateReviewSessionFromOwnWrongQuestions() {
|
||||
// Insert 2 wrong questions
|
||||
for (int i = 1; i <= 2; i++) {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-review-" + i);
|
||||
wq.setStem("Review question " + i);
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"Option A\",\"order\":1.0},{\"label\":\"B\",\"content\":\"Option B\",\"order\":2.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setLatestCorrectAnswer("B");
|
||||
wq.setLatestExplanation("Explanation " + i);
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(i));
|
||||
wq.setLastWrongTime(LocalDateTime.now());
|
||||
wq.setWrongCount(i);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
}
|
||||
|
||||
WrongQuestionReviewReqVO req = new WrongQuestionReviewReqVO();
|
||||
req.setClientSessionId("uuid-review-1");
|
||||
req.setWrongQuestionIds(List.of(
|
||||
wrongQuestionMapper.selectList().get(0).getId(),
|
||||
wrongQuestionMapper.selectList().get(1).getId()));
|
||||
|
||||
PracticeSessionRespVO resp = wrongQuestionService.createReviewSession(req, userId, tenantId);
|
||||
|
||||
assertNotNull(resp.getSessionId());
|
||||
assertEquals("ACTIVE", resp.getStatus());
|
||||
assertEquals(2, resp.getQuestionCount());
|
||||
assertEquals(2, resp.getQuestions().size());
|
||||
|
||||
// Verify questions are safe — no correct answer exposed
|
||||
for (var q : resp.getQuestions()) {
|
||||
assertNotNull(q.getStem());
|
||||
assertNotNull(q.getOptions());
|
||||
assertTrue(q.getOptions().size() > 0);
|
||||
// correctAnswer and explanation are NOT on PracticeQuestionRespVO (structural guarantee)
|
||||
}
|
||||
|
||||
// Verify idempotent replay
|
||||
PracticeSessionRespVO replay = wrongQuestionService.createReviewSession(req, userId, tenantId);
|
||||
assertEquals(resp.getSessionId(), replay.getSessionId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRejectReviewSessionWithOtherUsersWrongQuestions() {
|
||||
// Insert wrong question for user 100
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-other-owner");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setFirstWrongTime(LocalDateTime.now());
|
||||
wq.setLastWrongTime(LocalDateTime.now());
|
||||
wq.setWrongCount(1);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
WrongQuestionReviewReqVO req = new WrongQuestionReviewReqVO();
|
||||
req.setClientSessionId("uuid-cross-review");
|
||||
req.setWrongQuestionIds(List.of(wq.getId()));
|
||||
|
||||
// Different user tries to create review
|
||||
assertServiceException(
|
||||
() -> wrongQuestionService.createReviewSession(req, 999L, tenantId),
|
||||
WRONG_QUESTION_REVIEW_NOT_ALL_OWNED);
|
||||
}
|
||||
|
||||
// ========== DuplicateKeyException on clientSessionId race ==========
|
||||
|
||||
@Test
|
||||
void shouldHandleClientSessionIdRaceInReviewSession() {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-race");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setFirstWrongTime(LocalDateTime.now());
|
||||
wq.setLastWrongTime(LocalDateTime.now());
|
||||
wq.setWrongCount(1);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
WrongQuestionReviewReqVO req = new WrongQuestionReviewReqVO();
|
||||
req.setClientSessionId("uuid-race-review");
|
||||
req.setWrongQuestionIds(List.of(wq.getId()));
|
||||
|
||||
// Create first session
|
||||
PracticeSessionRespVO first = wrongQuestionService.createReviewSession(req, userId, tenantId);
|
||||
assertNotNull(first.getSessionId());
|
||||
|
||||
// Same clientSessionId → should return existing (idempotent)
|
||||
PracticeSessionRespVO second = wrongQuestionService.createReviewSession(req, userId, tenantId);
|
||||
assertEquals(first.getSessionId(), second.getSessionId());
|
||||
}
|
||||
|
||||
// ========== Detail: includes correct answer and explanation ==========
|
||||
|
||||
@Test
|
||||
void shouldExposeCorrectAnswerInDetail() {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-detail");
|
||||
wq.setStem("Detail question");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("hard");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v2");
|
||||
wq.setLatestCorrectAnswer("B");
|
||||
wq.setLatestExplanation("Because B is correct");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(3));
|
||||
wq.setLastWrongTime(LocalDateTime.now());
|
||||
wq.setWrongCount(2);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
WrongQuestionDetailRespVO detail = wrongQuestionService.getWrongQuestionDetail(wq.getId(), userId, tenantId);
|
||||
|
||||
assertEquals("q-detail", detail.getQuestionId());
|
||||
assertEquals("Detail question", detail.getStem());
|
||||
assertEquals("hard", detail.getDifficulty());
|
||||
assertEquals("B", detail.getCorrectAnswer());
|
||||
assertEquals("Because B is correct", detail.getExplanation());
|
||||
assertEquals(2, detail.getWrongCount());
|
||||
assertNotNull(detail.getFirstWrongTime());
|
||||
assertNotNull(detail.getLastWrongTime());
|
||||
}
|
||||
|
||||
|
||||
// ========== New wrong after MASTERED reopens PENDING ==========
|
||||
|
||||
@Test
|
||||
void shouldReopenPendingAfterMasteredOnNewWrong() {
|
||||
// Insert a MASTERED wrong question
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-reopen");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setLatestCorrectAnswer("B");
|
||||
wq.setLatestExplanation("Old explanation");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
|
||||
wq.setLastWrongTime(LocalDateTime.now().minusDays(3));
|
||||
wq.setWrongCount(3);
|
||||
wq.setMasterStatus("MASTERED");
|
||||
wq.setMasteredTime(LocalDateTime.now().minusDays(2));
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
// Now submit a new wrong answer for the same question (new report)
|
||||
SessionFixture f = createSessionWithQuestions("uuid-reopen", 1, "B");
|
||||
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 999L, f.sessionId, "A", false);
|
||||
detail.setQuestionId("q-reopen"); // override to match existing wrong question
|
||||
detail.setContentVersion("v2");
|
||||
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 999L, f.sessionId, List.of(detail));
|
||||
|
||||
WrongQuestionDO reloaded = wrongQuestionMapper.selectById(wq.getId());
|
||||
assertEquals("PENDING", reloaded.getMasterStatus(),
|
||||
"new wrong after MASTERED must reopen PENDING");
|
||||
assertNull(reloaded.getMasteredTime(),
|
||||
"mastered_time must be cleared on new wrong");
|
||||
assertEquals(4, reloaded.getWrongCount(),
|
||||
"wrong_count must increment (3→4)");
|
||||
assertEquals("v2", reloaded.getContentVersion(),
|
||||
"contentVersion must be updated from new report detail");
|
||||
assertEquals(999L, reloaded.getLastReportId().longValue());
|
||||
}
|
||||
|
||||
// ========== Replay same report does NOT reopen/double-count ==========
|
||||
|
||||
@Test
|
||||
void shouldNotReopenOrDoubleCountOnReplayAfterMastered() {
|
||||
// Insert MASTERED question, then submit once → PENDING + count=4
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId);
|
||||
wq.setUserId(userId);
|
||||
wq.setQuestionId("q-replay-after-mastered");
|
||||
wq.setStem("Test stem");
|
||||
wq.setType("choice");
|
||||
wq.setDifficulty("easy");
|
||||
wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1");
|
||||
wq.setLatestCorrectAnswer("B");
|
||||
wq.setFirstWrongTime(LocalDateTime.now().minusDays(7));
|
||||
wq.setLastWrongTime(LocalDateTime.now().minusDays(3));
|
||||
wq.setWrongCount(3);
|
||||
wq.setMasterStatus("MASTERED");
|
||||
wq.setMasteredTime(LocalDateTime.now().minusDays(2));
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
SessionFixture f = createSessionWithQuestions("uuid-replay-mastered", 1, "B");
|
||||
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 1001L, f.sessionId, "A", false);
|
||||
detail.setQuestionId("q-replay-after-mastered");
|
||||
detail.setContentVersion("v2");
|
||||
|
||||
// First upsert: reopens PENDING, count 4
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1001L, f.sessionId, List.of(detail));
|
||||
|
||||
// Replay same report: idempotency guard blocks → count stays 4, status stays PENDING
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1001L, f.sessionId, List.of(detail));
|
||||
|
||||
WrongQuestionDO reloaded = wrongQuestionMapper.selectById(wq.getId());
|
||||
assertEquals("PENDING", reloaded.getMasterStatus());
|
||||
assertEquals(4, reloaded.getWrongCount(),
|
||||
"replay must not double-count");
|
||||
assertNull(reloaded.getMasteredTime());
|
||||
}
|
||||
|
||||
// ========== Review session fingerprint mismatch ==========
|
||||
|
||||
@Test
|
||||
void shouldRejectReviewSessionWithDifferentIdSet() {
|
||||
// Insert 2 wrong questions
|
||||
WrongQuestionDO wq1 = new WrongQuestionDO();
|
||||
wq1.setTenantId(tenantId); wq1.setUserId(userId);
|
||||
wq1.setQuestionId("q-fp-1"); wq1.setStem("Q1"); wq1.setType("choice");
|
||||
wq1.setDifficulty("easy"); wq1.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq1.setContentVersion("v1"); wq1.setFirstWrongTime(LocalDateTime.now());
|
||||
wq1.setLastWrongTime(LocalDateTime.now()); wq1.setWrongCount(1);
|
||||
wq1.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq1);
|
||||
|
||||
WrongQuestionDO wq2 = new WrongQuestionDO();
|
||||
wq2.setTenantId(tenantId); wq2.setUserId(userId);
|
||||
wq2.setQuestionId("q-fp-2"); wq2.setStem("Q2"); wq2.setType("choice");
|
||||
wq2.setDifficulty("easy"); wq2.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq2.setContentVersion("v1"); wq2.setFirstWrongTime(LocalDateTime.now());
|
||||
wq2.setLastWrongTime(LocalDateTime.now()); wq2.setWrongCount(1);
|
||||
wq2.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq2);
|
||||
|
||||
// Create session with IDs [wq1.id, wq2.id]
|
||||
WrongQuestionReviewReqVO req1 = new WrongQuestionReviewReqVO();
|
||||
req1.setClientSessionId("uuid-fp-mismatch");
|
||||
req1.setWrongQuestionIds(List.of(wq1.getId(), wq2.getId()));
|
||||
PracticeSessionRespVO first = wrongQuestionService.createReviewSession(req1, userId, tenantId);
|
||||
assertNotNull(first.getSessionId());
|
||||
|
||||
// Replay with different ID set [wq1.id only] → mismatch
|
||||
WrongQuestionReviewReqVO req2 = new WrongQuestionReviewReqVO();
|
||||
req2.setClientSessionId("uuid-fp-mismatch");
|
||||
req2.setWrongQuestionIds(List.of(wq1.getId()));
|
||||
assertServiceException(
|
||||
() -> wrongQuestionService.createReviewSession(req2, userId, tenantId),
|
||||
SESSION_IDEMPOTENCY_MISMATCH);
|
||||
|
||||
// Replay with same IDs but different order — fingerprint sorts, so same session returned
|
||||
WrongQuestionReviewReqVO req3 = new WrongQuestionReviewReqVO();
|
||||
req3.setClientSessionId("uuid-fp-mismatch");
|
||||
req3.setWrongQuestionIds(List.of(wq2.getId(), wq1.getId())); // reversed order
|
||||
PracticeSessionRespVO replay = wrongQuestionService.createReviewSession(req3, userId, tenantId);
|
||||
assertEquals(first.getSessionId(), replay.getSessionId(), "sorted fingerprint must match");
|
||||
}
|
||||
|
||||
// ========== Cross-user review session idempotency ==========
|
||||
|
||||
@Test
|
||||
void shouldRejectReviewSessionReplayWithDifferentUser() {
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId); wq.setUserId(userId);
|
||||
wq.setQuestionId("q-cross-user-session"); wq.setStem("Q"); wq.setType("choice");
|
||||
wq.setDifficulty("easy"); wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1"); wq.setFirstWrongTime(LocalDateTime.now());
|
||||
wq.setLastWrongTime(LocalDateTime.now()); wq.setWrongCount(1);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
WrongQuestionReviewReqVO req = new WrongQuestionReviewReqVO();
|
||||
req.setClientSessionId("uuid-cross-user-review");
|
||||
req.setWrongQuestionIds(List.of(wq.getId()));
|
||||
|
||||
// User 100 creates session
|
||||
PracticeSessionRespVO first = wrongQuestionService.createReviewSession(req, userId, tenantId);
|
||||
assertNotNull(first.getSessionId());
|
||||
|
||||
// Different user tries to replay same clientSessionId → not found
|
||||
assertServiceException(
|
||||
() -> wrongQuestionService.createReviewSession(req, 999L, tenantId),
|
||||
SESSION_NOT_FOUND);
|
||||
}
|
||||
|
||||
// ========== Concurrent review session create race ==========
|
||||
|
||||
@Test
|
||||
void shouldHandleConcurrentReviewSessionCreateRace() throws Exception {
|
||||
// Insert wrong question
|
||||
WrongQuestionDO wq = new WrongQuestionDO();
|
||||
wq.setTenantId(tenantId); wq.setUserId(userId);
|
||||
wq.setQuestionId("q-conc-review"); wq.setStem("Q"); wq.setType("choice");
|
||||
wq.setDifficulty("easy"); wq.setOptions("[{\"label\":\"A\",\"content\":\"A\",\"order\":1.0}]");
|
||||
wq.setContentVersion("v1"); wq.setFirstWrongTime(LocalDateTime.now());
|
||||
wq.setLastWrongTime(LocalDateTime.now()); wq.setWrongCount(1);
|
||||
wq.setMasterStatus("PENDING");
|
||||
wrongQuestionMapper.insert(wq);
|
||||
|
||||
WrongQuestionReviewReqVO req = new WrongQuestionReviewReqVO();
|
||||
req.setClientSessionId("uuid-conc-review-race");
|
||||
req.setWrongQuestionIds(List.of(wq.getId()));
|
||||
|
||||
CountDownLatch ready = new CountDownLatch(2);
|
||||
CountDownLatch go = new CountDownLatch(1);
|
||||
AtomicReference<PracticeSessionRespVO> r1 = new AtomicReference<>();
|
||||
AtomicReference<PracticeSessionRespVO> r2 = new AtomicReference<>();
|
||||
|
||||
Thread t1 = new Thread(() -> {
|
||||
try { ready.countDown(); go.await();
|
||||
r1.set(wrongQuestionService.createReviewSession(req, userId, tenantId)); }
|
||||
catch (Exception ignored) {}
|
||||
});
|
||||
Thread t2 = new Thread(() -> {
|
||||
try { ready.countDown(); go.await();
|
||||
r2.set(wrongQuestionService.createReviewSession(req, userId, tenantId)); }
|
||||
catch (Exception ignored) {}
|
||||
});
|
||||
|
||||
t1.start(); t2.start();
|
||||
ready.await(); go.countDown();
|
||||
t1.join(10000); t2.join(10000);
|
||||
|
||||
assertNotNull(r1.get(), "thread 1 must get a response");
|
||||
assertNotNull(r2.get(), "thread 2 must get a response");
|
||||
assertEquals(r1.get().getSessionId(), r2.get().getSessionId(),
|
||||
"concurrent same-fingerprint creates must converge on one session");
|
||||
|
||||
// Only one session created
|
||||
List<PracticeSessionDO> sessions = sessionMapper.selectList(
|
||||
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<PracticeSessionDO>()
|
||||
.eq(PracticeSessionDO::getClientSessionId, "uuid-conc-review-race"));
|
||||
assertEquals(1, sessions.size());
|
||||
}
|
||||
|
||||
// ========== contentVersion population ==========
|
||||
|
||||
@Test
|
||||
void shouldPopulateContentVersionFromReportDetail() {
|
||||
SessionFixture f = createSessionWithQuestions("uuid-contentver", 1, "B");
|
||||
PracticeReportDetailDO detail = createReportDetail(f.questions.get(0), 2001L, f.sessionId, "A", false);
|
||||
detail.setContentVersion("v3-custom");
|
||||
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 2001L, f.sessionId, List.of(detail));
|
||||
|
||||
List<WrongQuestionDO> all = wrongQuestionMapper.selectList();
|
||||
assertEquals(1, all.size());
|
||||
assertEquals("v3-custom", all.get(0).getContentVersion(),
|
||||
"wrong question contentVersion must come from report detail");
|
||||
|
||||
// Detail view exposes contentVersion
|
||||
WrongQuestionDetailRespVO detailVO = wrongQuestionService.getWrongQuestionDetail(
|
||||
all.get(0).getId(), userId, tenantId);
|
||||
assertEquals("v3-custom", detailVO.getContentVersion());
|
||||
}
|
||||
// ========== Empty detail list ==========
|
||||
|
||||
@Test
|
||||
void shouldHandleEmptyDetailList() {
|
||||
// Should not throw
|
||||
wrongQuestionService.upsertWrongQuestions(tenantId, userId, 1L, 1L, List.of());
|
||||
assertEquals(0, wrongQuestionMapper.selectList().size());
|
||||
}
|
||||
|
||||
// ========== Wrong question not found ==========
|
||||
|
||||
@Test
|
||||
void shouldThrowOnNotFound() {
|
||||
assertServiceException(
|
||||
() -> wrongQuestionService.getWrongQuestionDetail(99999L, userId, tenantId),
|
||||
WRONG_QUESTION_NOT_FOUND);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user