forked from wangziqi/ruoyi-vue-pro
feat(education): project and review wrong questions
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user