feat(education): project and review wrong questions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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