题库、练习与错题本代码阅读地图
你下一步适合阅读 "学生查看题目 → 提交答案 → 自动判分 → 记录错题 → 发放积分"。登录模块回答"你是谁",这条链路回答"确定身份后,系统怎样处理你的学习行为"。
1. 先看业务接力图
下面展示提交一道练习题的主要流程;身份恢复仍由你已经读过的认证模块完成。
PointService:积分记账员 WrongBookService:错题管理员 ObjectiveJudge:阅卷员 数据库 CurrentUser:身份取件员 QuestionController:调度员 浏览器 PointService:积分记账员 WrongBookService:错题管理员 ObjectiveJudge:阅卷员 数据库 CurrentUser:身份取件员 QuestionController:调度员 浏览器 #mermaid-svg-qHHOwvOeAHIwIayK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-qHHOwvOeAHIwIayK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-qHHOwvOeAHIwIayK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-qHHOwvOeAHIwIayK .error-icon{fill:#552222;}#mermaid-svg-qHHOwvOeAHIwIayK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-qHHOwvOeAHIwIayK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-qHHOwvOeAHIwIayK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-qHHOwvOeAHIwIayK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-qHHOwvOeAHIwIayK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-qHHOwvOeAHIwIayK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-qHHOwvOeAHIwIayK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-qHHOwvOeAHIwIayK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-qHHOwvOeAHIwIayK .marker.cross{stroke:#333333;}#mermaid-svg-qHHOwvOeAHIwIayK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-qHHOwvOeAHIwIayK p{margin:0;}#mermaid-svg-qHHOwvOeAHIwIayK .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-qHHOwvOeAHIwIayK text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-qHHOwvOeAHIwIayK .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-qHHOwvOeAHIwIayK .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-qHHOwvOeAHIwIayK .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-qHHOwvOeAHIwIayK .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-qHHOwvOeAHIwIayK #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-qHHOwvOeAHIwIayK .sequenceNumber{fill:white;}#mermaid-svg-qHHOwvOeAHIwIayK #sequencenumber{fill:#333;}#mermaid-svg-qHHOwvOeAHIwIayK #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-qHHOwvOeAHIwIayK .messageText{fill:#333;stroke:none;}#mermaid-svg-qHHOwvOeAHIwIayK .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-qHHOwvOeAHIwIayK .labelText,#mermaid-svg-qHHOwvOeAHIwIayK .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-qHHOwvOeAHIwIayK .loopText,#mermaid-svg-qHHOwvOeAHIwIayK .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-qHHOwvOeAHIwIayK .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-qHHOwvOeAHIwIayK .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-qHHOwvOeAHIwIayK .noteText,#mermaid-svg-qHHOwvOeAHIwIayK .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-qHHOwvOeAHIwIayK .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-qHHOwvOeAHIwIayK .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-qHHOwvOeAHIwIayK .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-qHHOwvOeAHIwIayK .actorPopupMenu{position:absolute;}#mermaid-svg-qHHOwvOeAHIwIayK .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-qHHOwvOeAHIwIayK .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-qHHOwvOeAHIwIayK .actor-man circle,#mermaid-svg-qHHOwvOeAHIwIayK line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-qHHOwvOeAHIwIayK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 检查是否允许练习这道题 opt 普通练习答对,且不处于当前错题本中 POST /api/student/questions/{id}/practice student(),确认学生身份 返回 LoginUser 同步历史错题、查询当前错题状态 读取题目、标准答案和分值 score(),计算得分 返回满分或零分 保存本次 practice_record recordPracticeResult(),更新错题状态 返回错题状态及是否可移出 检查重复来源、每日额度并记录奖励 返回实际奖励及积分信息 返回正确性、答案、解析、积分和错题状态
2. 阅读哪些文件,谁负责什么
| 文件 | 角色标签 | 主要职责 |
|---|---|---|
| QuestionController.java | 调度员 | 接收题库和练习请求,检查权限,组织判分、保存、错题和积分处理。 |
| ObjectiveJudge.java | 阅卷员 | 比较标准答案与学生答案,计算单题得分,不读写数据库。 |
| WrongBookService.java | 错题管理员 | 维护待复习、待移出、已掌握三种状态,补齐历史错题。 |
| PointService.java | 积分记账员 | 查询余额、限制每日奖励、避免同一来源重复发分。 |
| GradeRules.java | 年级规则表 | 判断下一年级,以及当前题目是否属于可积分年级范围。 |
不要从 QuestionController 第一行一直读到底。 先读 studentDetail() 和 submitPractice(),遇到方法调用再跳到对应类;教师分页、随机组卷和批量录题可以留到下一轮。
3. 按四条链路阅读
| 链路 | 接口与入口方法 | 应抓住的主干 |
|---|---|---|
| 查看练习列表 | GET /api/student/questions → studentList() |
确认学生身份 → 同步历史错题 → 确定当前或下一年级 → 查询公开、审核通过、用途为 PRACTICE 的题目 → 附加积分和错题提示。九年级没有下一年级,默认查询下一年级时返回空列表。 |
| 打开一道题 | GET /api/student/questions/{id} → studentDetail() |
读取题目 → 判断是否为当前错题 → ensureStudentPracticeAccess() 校验资格 → 补充展示字段。当前错题允许继续复习;普通练习检查审核、公开性、用途和年级。 |
| 提交答案 | POST /api/student/questions/{id}/practice → submitPractice() |
重新检查资格 → 判分 → 无论对错都保存练习记录 → 更新错题 → 根据分支决定是否发积分 → 返回结果。能打开题目不代表提交时可以省略权限校验。 |
| 查看与移出错题 | wrongQuestions()、wrongBookHistory()、removeWrongQuestion() |
当前列表显示 IN_BOOK 和 READY_TO_REMOVE;历史显示 MASTERED;移出接口只允许 READY_TO_REMOVE 转为 MASTERED,不直接删除记录。 |
题目列表通过 questionSelect(false) 不返回标准答案和解析;但 studentDetail() 的 SQL 已经返回 analysis,没有返回标准答案 。提交后返回 correctAnswer 和 analysis。阅读时要以实际查询字段为准,不能笼统说"所有解析都在答题后才返回"。
4. 提交答案中的三个关键分支
| 场景 | 错题处理 | 积分处理 |
|---|---|---|
| 普通题答对,且没有当前错题状态 | 不凭空新增错题记录 | 符合年级规则时请求奖励 2 分,实际奖励还受查重和每日额度限制。 |
| 普通题答错 | 新建或更新为 IN_BOOK | 不奖励积分,返回加入错题本的提示。 |
| 当前错题复习 | 答对进入 READY_TO_REMOVE;答错回到或保持 IN_BOOK | 答对也不奖励积分,复习目标是巩固并确认掌握。 |
"答对"和"拿到积分"是两个结果。correct 表示判分结果,awardedPoints 表示实际奖励;达到每日上限或该题已处理过奖励时,答对也可能获得 0 分。
5. 核心规则为什么这样写
| 问题 | 结合真实代码理解 |
|---|---|
| 为什么判分单独放在工具类? | Controller 负责业务流程,ObjectiveJudge 专注答案比较。单选忽略大小写;多选集合必须一致且拒绝重复项;判断题按文本匹配;填空命中任一可接受答案。不实现语义判断或部分给分。 |
| 为什么同时保存练习记录和错题状态? | practice_record 记录"每次做了什么";wrong_question_state 记录"这道题目前是否需要复习"。历史行为与当前状态用途不同。 |
| 为什么答对错题后不立即移出? | recordPracticeResult() 先设为 READY_TO_REMOVE,markMastered() 再通过条件更新确认掌握。直接调用移出接口而未处于该状态,会被拒绝。再次答错仍会进入 IN_BOOK。 |
| 为什么查询错题前同步历史? | syncMissingWrongQuestions() 合并练习、已批改考试和已提交竞赛的错误记录,补建缺失状态。使用 UNION ALL 保留次数,用 INSERT IGNORE 避免覆盖已有复习成果。这些查询入口可能执行补建写入,并非完全只读。 |
| 为什么反复练习不会反复得分? | 练习奖励调用的来源是 PRACTICE + 题目ID,再加当前学生 ID 查重,不是用每次新建的练习记录 ID。练习可以多次记录,奖励来源只处理一次。 |
| 为什么提交练习有事务? | submitPractice() 用 @Transactional 包住练习记录、错题更新和积分处理,避免一部分成功、一部分失败;PointService 自身没有事务注解,依赖这层调用提供事务范围。 |
| 为什么发积分先锁用户? | SELECT ... FOR UPDATE 锁定学生用户行,使同一学生的发分流程在事务内协调执行,保护查重和每日额度计算。每日 120 分是 PRACTICE、EXAM、CONTEST 正向奖励共享的上限。 |
积分余额来自全部正负流水求和;每日额度只统计当天正向学习奖励。即使实际奖励为 0,PointService 也会写来源流水,重复请求不会在次日自动补发这次奖励。
6. 一次实际阅读怎么走
- 在
studentDetail()中找到CurrentUser.student(),连接已经学过的认证流程。 - 跳到
ensureStudentPracticeAccess(),看清普通练习与错题复习的权限差异。 - 回到
submitPractice(),追踪question → score → correct → wrongBookUpdate → awardedPoints。 - 打开
ObjectiveJudge.score()和isCorrect(),用"A,C"和"C,A"理解多选比较。 - 打开
WrongBookService.recordPracticeResult()、recordWrong()、markMastered(),理解三种状态。 - 最后读
PointService.awardLearningPoints(),区分"题目分数""奖励积分""积分余额"。
本次没有核对建表脚本和前端,所以数据库唯一约束的具体定义、页面按钮如何调用这些接口,未在本次阅读文件中确认。掌握这条链路后,再读 PaperController → ExamController → ExamDeadline,就能进一步理解整场考试。
7. 答辩可能怎么问,怎么答
| 老师可能问 | 可以怎么答 |
|---|---|
| 为什么已经登录,还调用 student()? | 登录只确认身份,student() 进一步确认角色,教师和管理员不能调用学生专属练习入口。 |
| 系统怎么自动判题? | 用 ObjectiveJudge 根据题型比较标准答案与学生答案,正确给满分、错误给零分,不是人工智能语义批改。 |
| 为什么错题和练习记录分表? | 练习表保留每次作答历史,错题状态表表示当前复习进度,分别解决追溯和管理问题。 |
| 学生一直重复答对能刷积分吗? | 奖励按学生、PRACTICE 类型和题目 ID 查重,并受每日学习奖励上限限制;练习记录仍可以继续产生。 |
| 错题复习答对有积分吗? | 当前实现没有。答对会进入待移出状态,学生确认后标记为已掌握。 |
| 为什么既需要事务又需要行锁? | 事务保证多表修改一起提交或回滚;行锁协调并发请求。它们解决不同问题,积分用户锁需要在事务期间保持有效。 |