Procházet zdrojové kódy

feat(quiz): year字段改为String类型、去重增加题型维度、学习本按科目过滤、答案比较trim、新增VO和工具类

hechuanqi před 1 týdnem
rodič
revize
619679b5c2
48 změnil soubory, kde provedl 1318 přidání a 359 odebrání
  1. 8 0
      zhijiayun-gateway/src/main/resources/application-local.yml
  2. 6 0
      zhijiayun-quiz/pom.xml
  3. 3 2
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/admin/AdminQuizChapterController.java
  4. 2 2
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/admin/AdminRealQuestionController.java
  5. 37 12
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizChapterController.java
  6. 2 2
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizQuestionController.java
  7. 3 3
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizRealExamController.java
  8. 15 5
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizStudyBookController.java
  9. 27 2
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizSubjectController.java
  10. 1 1
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/RealExamStartRequest.java
  11. 13 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/RedoWrongRequest.java
  12. 1 1
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/StudyBookSubmitRequest.java
  13. 0 2
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizChapter.java
  14. 2 1
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizQuestion.java
  15. 0 2
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizSubject.java
  16. 1 1
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizUserExamRecord.java
  17. 1 3
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizUserRecord.java
  18. 3 2
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/QuizChapterService.java
  19. 2 2
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/QuizQuestionService.java
  20. 3 3
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/RealExamService.java
  21. 4 3
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/StudyBookService.java
  22. 1 1
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/UserQuestionService.java
  23. 25 16
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/DailyPracticeServiceImpl.java
  24. 37 3
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/HomeServiceImpl.java
  25. 97 75
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizChapterImportServiceImpl.java
  26. 114 8
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizChapterServiceImpl.java
  27. 163 63
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizQuestionServiceImpl.java
  28. 77 16
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizTrackServiceImpl.java
  29. 20 2
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/RankServiceImpl.java
  30. 47 44
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/RealExamServiceImpl.java
  31. 294 47
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/StudyBookServiceImpl.java
  32. 57 21
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/UserQuestionServiceImpl.java
  33. 76 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/util/QuizDedupUtils.java
  34. 20 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ChapterListVO.java
  35. 2 2
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ChapterProgressItemVO.java
  36. 2 2
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ChapterQuestionsVO.java
  37. 1 1
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ExamDetailVO.java
  38. 1 1
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ExamRecordVO.java
  39. 1 1
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ExamYearVO.java
  40. 1 1
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/HomeIndexVO.java
  41. 1 1
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RealExamSubjectVO.java
  42. 36 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RealExamYearGroupVO.java
  43. 1 1
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/StartExamVO.java
  44. 4 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/StudyBookQuestionVO.java
  45. 3 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/StudyOverviewVO.java
  46. 1 1
      zhijiayun-user/src/main/java/com/xuekairui/user/config/DevCorsConfig.java
  47. 28 3
      zhijiayun-user/src/main/java/com/xuekairui/user/config/SecurityConfig.java
  48. 74 0
      zhijiayun-user/src/main/java/com/xuekairui/user/security/MockAuthFilter.java

+ 8 - 0
zhijiayun-gateway/src/main/resources/application-local.yml

@@ -16,3 +16,11 @@ spring:
       port: 6379
       password:
       database: 1
+
+# 本地联调:移动端做题接口(无 token 或生产 token)绕过 token 校验,注入固定测试用户
+security:
+  mock:
+    enabled: true
+    user-id: 2087801976835612673
+    role: USER
+    login-source: MINIAPP

+ 6 - 0
zhijiayun-quiz/pom.xml

@@ -21,6 +21,12 @@
             <artifactId>zhijiayun-common</artifactId>
         </dependency>
 
+        <!-- 用户模块(埋点报表需要关联用户信息) -->
+        <dependency>
+            <groupId>com.xuekairui</groupId>
+            <artifactId>zhijiayun-user</artifactId>
+        </dependency>
+
         <!-- Spring Boot -->
         <dependency>
             <groupId>org.springframework.boot</groupId>

+ 3 - 2
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/admin/AdminQuizChapterController.java

@@ -3,6 +3,7 @@ package com.xuekairui.quiz.controller.admin;
 import com.xuekairui.common.Result;
 import com.xuekairui.quiz.entity.QuizChapter;
 import com.xuekairui.quiz.service.QuizChapterService;
+import com.xuekairui.quiz.vo.ChapterListVO;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.*;
 
@@ -43,13 +44,13 @@ public class AdminQuizChapterController {
 
     /** 级联选择:按科目获取章列表 */
     @GetMapping("/listBySubject")
-    public Result<List<QuizChapter>> listBySubject(@RequestParam Long subjectId) {
+    public Result<List<ChapterListVO>> listBySubject(@RequestParam Long subjectId) {
         return Result.success(quizChapterService.listBySubject(subjectId));
     }
 
     /** 级联选择:按父节点获取子节点列表 */
     @GetMapping("/listByParent")
-    public Result<List<QuizChapter>> listByParent(@RequestParam Long parentId) {
+    public Result<List<ChapterListVO>> listByParent(@RequestParam Long parentId) {
         return Result.success(quizChapterService.listByParent(parentId));
     }
 }

+ 2 - 2
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/admin/AdminRealQuestionController.java

@@ -30,7 +30,7 @@ public class AdminRealQuestionController {
     /** 真题列表 */
     @GetMapping("/realList")
     public Result<IPage<Map<String, Object>>> realList(
-            @RequestParam(required = false) Integer year,
+            @RequestParam(required = false) String year,
             @RequestParam(required = false) Long subjectId,
             @RequestParam(required = false) Integer questionType,
             @RequestParam(required = false) String keyword,
@@ -53,7 +53,7 @@ public class AdminRealQuestionController {
 
     /** 真题年份下拉 */
     @GetMapping("/realYears")
-    public Result<List<Integer>> realYears() {
+    public Result<List<String>> realYears() {
         return Result.success(quizQuestionService.getRealYears());
     }
 

+ 37 - 12
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizChapterController.java

@@ -3,17 +3,18 @@ package com.xuekairui.quiz.controller.user;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.xuekairui.common.Result;
 import com.xuekairui.quiz.entity.QuizChapter;
+import com.xuekairui.quiz.entity.QuizQuestion;
 import com.xuekairui.quiz.entity.QuizUserRecord;
 import com.xuekairui.quiz.mapper.QuizChapterMapper;
+import com.xuekairui.quiz.mapper.QuizQuestionMapper;
 import com.xuekairui.quiz.mapper.QuizUserRecordMapper;
 import com.xuekairui.quiz.vo.UserChapterNodeVO;
 import jakarta.servlet.http.HttpServletRequest;
 import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.*;
 
-import java.util.ArrayList;
-import java.util.List;
-import java.util.Objects;
+import java.util.*;
+import java.util.stream.Collectors;
 
 @RestController
 @RequestMapping("/api/quiz/chapter")
@@ -22,6 +23,7 @@ public class UserQuizChapterController {
 
     private final QuizChapterMapper chapterMapper;
     private final QuizUserRecordMapper userRecordMapper;
+    private final QuizQuestionMapper questionMapper;
 
     @GetMapping("/list")
     public Result<List<UserChapterNodeVO>> list(@RequestParam Long subjectId, HttpServletRequest request) {
@@ -36,31 +38,36 @@ public class UserQuizChapterController {
                 ch.setParentId(0L);
             }
         }
-        List<UserChapterNodeVO> tree = buildUserTree(chapters, 0L, userId);
+
+        // 实时查询各考点(exam_point_id)直接关联的题目数
+        Map<Long, Integer> directCountMap = getDirectQuestionCounts(subjectId);
+
+        List<UserChapterNodeVO> tree = buildUserTree(chapters, 0L, userId, directCountMap);
         return Result.success(tree);
     }
 
-    private List<UserChapterNodeVO> buildUserTree(List<QuizChapter> chapters, Long parentId, Long userId) {
+    private List<UserChapterNodeVO> buildUserTree(List<QuizChapter> chapters, Long parentId, Long userId, Map<Long, Integer> directCountMap) {
         List<UserChapterNodeVO> result = new ArrayList<>();
         for (QuizChapter ch : chapters) {
             if (Objects.equals(ch.getParentId(), parentId)) {
                 long doneCount = userRecordMapper.selectCount(
                         new LambdaQueryWrapper<QuizUserRecord>()
                                 .eq(QuizUserRecord::getUserId, userId)
-                                .eq(QuizUserRecord::getChapterId, ch.getId())
+                                .eq(QuizUserRecord::getExamPointId, ch.getId())
                                 .eq(QuizUserRecord::getStatus, 1));
 
                 List<UserChapterNodeVO> children = null;
                 // 只展开到"节"(level=3),不再往下展示"考点"(level=4)
                 if (ch.getLevel() != null && ch.getLevel() < 3) {
-                    List<UserChapterNodeVO> subChildren = buildUserTree(chapters, ch.getId(), userId);
+                    List<UserChapterNodeVO> subChildren = buildUserTree(chapters, ch.getId(), userId, directCountMap);
                     if (!subChildren.isEmpty()) {
                         children = subChildren;
                     }
                 }
 
-                // 汇总本节点及所有子孙节点的题目数(题目挂在考点level=4上,节level=3自身为0)
-                int totalQuestionCount = sumQuestionCount(chapters, ch.getId()) + ch.getQuestionCount();
+                // 汇总本节点及所有子孙节点的题目数(实时查询,不依赖缓存字段)
+                int totalQuestionCount = sumQuestionCount(chapters, ch.getId(), directCountMap)
+                        + directCountMap.getOrDefault(ch.getId(), 0);
 
                 result.add(UserChapterNodeVO.builder()
                         .id(ch.getId())
@@ -75,14 +82,32 @@ public class UserQuizChapterController {
         return result;
     }
 
-    /** 递归汇总所有子孙节点的 questionCount */
-    private int sumQuestionCount(List<QuizChapter> chapters, Long parentId) {
+    /** 递归汇总所有子孙节点的题目数(实时查询) */
+    private int sumQuestionCount(List<QuizChapter> chapters, Long parentId, Map<Long, Integer> directCountMap) {
         int sum = 0;
         for (QuizChapter ch : chapters) {
             if (Objects.equals(ch.getParentId(), parentId)) {
-                sum += ch.getQuestionCount() + sumQuestionCount(chapters, ch.getId());
+                sum += directCountMap.getOrDefault(ch.getId(), 0)
+                        + sumQuestionCount(chapters, ch.getId(), directCountMap);
             }
         }
         return sum;
     }
+
+    /** 批量查询指定科目下各考点直接关联的练习题数(不含真题) */
+    private Map<Long, Integer> getDirectQuestionCounts(Long subjectId) {
+        List<QuizQuestion> questions = questionMapper.selectList(
+                new LambdaQueryWrapper<QuizQuestion>()
+                        .eq(QuizQuestion::getSubjectId, subjectId)
+                        .eq(QuizQuestion::getIsReal, 0)
+                        .select(QuizQuestion::getExamPointId)
+        );
+        Map<Long, Integer> map = new HashMap<>();
+        for (QuizQuestion q : questions) {
+            if (q.getExamPointId() != null) {
+                map.merge(q.getExamPointId(), 1, Integer::sum);
+            }
+        }
+        return map;
+    }
 }

+ 2 - 2
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizQuestionController.java

@@ -23,13 +23,13 @@ public class UserQuizQuestionController {
     /** 获取考点下的题目(练习用) */
     @GetMapping("/listByChapter")
     public Result<PageVO<QuestionVO>> listByChapter(
-            @RequestParam Long chapterId,
+            @RequestParam Long examPointId,
             @RequestParam(required = false, defaultValue = "order") String order,
             @RequestParam(defaultValue = "1") int pageNo,
             @RequestParam(defaultValue = "20") int pageSize,
             HttpServletRequest request) {
         Long userId = (Long) request.getAttribute("userId");
-        return Result.success(userQuestionService.listByChapter(chapterId, order, userId, pageNo, pageSize));
+        return Result.success(userQuestionService.listByChapter(examPointId, order, userId, pageNo, pageSize));
     }
 
     /** 提交单题答案 */

+ 3 - 3
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizRealExamController.java

@@ -9,7 +9,7 @@ import com.xuekairui.quiz.vo.ExamDetailVO;
 import com.xuekairui.quiz.vo.ExamRecordVO;
 import com.xuekairui.quiz.vo.ExamYearVO;
 import com.xuekairui.quiz.vo.PageVO;
-import com.xuekairui.quiz.vo.RealExamSubjectVO;
+import com.xuekairui.quiz.vo.RealExamYearGroupVO;
 import com.xuekairui.quiz.vo.StartExamVO;
 import com.xuekairui.quiz.vo.SubmitExamVO;
 import jakarta.servlet.http.HttpServletRequest;
@@ -25,9 +25,9 @@ public class UserQuizRealExamController {
 
     private final RealExamService realExamService;
 
-    /** 真题年份+科目列表(按科目分组) */
+    /** 真题年份+科目列表(按年份分组) */
     @GetMapping("/list")
-    public Result<List<RealExamSubjectVO>> list(
+    public Result<List<RealExamYearGroupVO>> list(
             @RequestParam(required = false) Long subjectId,
             HttpServletRequest request) {
         Long userId = (Long) request.getAttribute("userId");

+ 15 - 5
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizStudyBookController.java

@@ -1,6 +1,7 @@
 package com.xuekairui.quiz.controller.user;
 
 import com.xuekairui.common.Result;
+import com.xuekairui.quiz.dto.RedoWrongRequest;
 import com.xuekairui.quiz.dto.StudyBookSubmitRequest;
 import com.xuekairui.quiz.service.StudyBookService;
 import com.xuekairui.quiz.vo.ChapterProgressVO;
@@ -26,9 +27,11 @@ public class UserQuizStudyBookController {
 
     /** 学习本统计概览(做对/做错/收藏数) */
     @GetMapping("/overview")
-    public Result<StudyOverviewVO> overview(HttpServletRequest request) {
+    public Result<StudyOverviewVO> overview(
+            @RequestParam(required = false) Long subjectId,
+            HttpServletRequest request) {
         Long userId = (Long) request.getAttribute("userId");
-        return Result.success(studyBookService.getOverview(userId));
+        return Result.success(studyBookService.getOverview(userId, subjectId));
     }
 
     /** 学习本题目列表(按 correct/wrong/collected 筛选) */
@@ -57,16 +60,23 @@ public class UserQuizStudyBookController {
 
     /** 获取考点题目 */
     @GetMapping("/questions")
-    public Result<ChapterQuestionsVO> questions(@RequestParam Long chapterId, HttpServletRequest request) {
+    public Result<ChapterQuestionsVO> questions(@RequestParam Long examPointId, HttpServletRequest request) {
         Long userId = (Long) request.getAttribute("userId");
-        return Result.success(studyBookService.getChapterQuestions(userId, chapterId));
+        return Result.success(studyBookService.getChapterQuestions(userId, examPointId));
     }
 
     /** 提交章节练习 */
     @PostMapping("/submit")
     public Result<PracticeSubmitVO> submit(@RequestBody StudyBookSubmitRequest param, HttpServletRequest request) {
         Long userId = (Long) request.getAttribute("userId");
-        return Result.success(studyBookService.submitChapterPractice(userId, param.getSubjectId(), param.getChapterId(), param.getAnswers()));
+        return Result.success(studyBookService.submitChapterPractice(userId, param.getSubjectId(), param.getExamPointId(), param.getAnswers()));
+    }
+
+    /** 错题重做(批量提交) */
+    @PostMapping("/redoWrong")
+    public Result<PracticeSubmitVO> redoWrong(@RequestBody RedoWrongRequest param, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(studyBookService.redoWrong(userId, param.getAnswers()));
     }
 
     /** 获取章节学习进度 */

+ 27 - 2
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizSubjectController.java

@@ -2,8 +2,10 @@ package com.xuekairui.quiz.controller.user;
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.xuekairui.common.Result;
+import com.xuekairui.quiz.entity.QuizQuestion;
 import com.xuekairui.quiz.entity.QuizSubject;
 import com.xuekairui.quiz.entity.QuizUserRecord;
+import com.xuekairui.quiz.mapper.QuizQuestionMapper;
 import com.xuekairui.quiz.mapper.QuizSubjectMapper;
 import com.xuekairui.quiz.mapper.QuizUserRecordMapper;
 import com.xuekairui.quiz.vo.SubjectProgressVO;
@@ -12,7 +14,9 @@ import lombok.RequiredArgsConstructor;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.ArrayList;
+import java.util.LinkedHashMap;
 import java.util.List;
+import java.util.Map;
 
 @RestController
 @RequestMapping("/api/quiz/subject")
@@ -21,12 +25,17 @@ public class UserQuizSubjectController {
 
     private final QuizSubjectMapper subjectMapper;
     private final QuizUserRecordMapper userRecordMapper;
+    private final QuizQuestionMapper questionMapper;
 
     @GetMapping("/list")
     public Result<List<SubjectProgressVO>> list(HttpServletRequest request) {
         Long userId = (Long) request.getAttribute("userId");
         List<QuizSubject> subjects = subjectMapper.selectList(
                 new LambdaQueryWrapper<QuizSubject>().orderByAsc(QuizSubject::getSort));
+
+        // 实时查询各科目题目数
+        Map<Long, Integer> subjectCountMap = getSubjectQuestionCounts();
+
         List<SubjectProgressVO> result = new ArrayList<>();
         for (QuizSubject s : subjects) {
             long doneCount = userRecordMapper.selectCount(
@@ -34,16 +43,32 @@ public class UserQuizSubjectController {
                             .eq(QuizUserRecord::getUserId, userId)
                             .eq(QuizUserRecord::getSubjectId, s.getId())
                             .eq(QuizUserRecord::getStatus, 1));
+            int questionCount = subjectCountMap.getOrDefault(s.getId(), 0);
             result.add(SubjectProgressVO.builder()
                     .id(s.getId())
                     .name(s.getName())
                     .sort(s.getSort())
                     .icon("")
-                    .questionCount(s.getQuestionCount())
+                    .questionCount(questionCount)
                     .doneCount(doneCount)
-                    .progress(s.getQuestionCount() > 0 ? (int) (doneCount * 100 / s.getQuestionCount()) : 0)
+                    .progress(questionCount > 0 ? (int) (doneCount * 100 / questionCount) : 0)
                     .build());
         }
         return Result.success(result);
     }
+
+    private Map<Long, Integer> getSubjectQuestionCounts() {
+        List<QuizQuestion> questions = questionMapper.selectList(
+                new LambdaQueryWrapper<QuizQuestion>()
+                        .eq(QuizQuestion::getIsReal, 0)
+                        .select(QuizQuestion::getSubjectId)
+        );
+        Map<Long, Integer> map = new LinkedHashMap<>();
+        for (QuizQuestion q : questions) {
+            if (q.getSubjectId() != null) {
+                map.merge(q.getSubjectId(), 1, Integer::sum);
+            }
+        }
+        return map;
+    }
 }

+ 1 - 1
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/RealExamStartRequest.java

@@ -7,6 +7,6 @@ import lombok.Data;
  */
 @Data
 public class RealExamStartRequest {
-    private Integer year;
+    private String year;
     private Long subjectId;
 }

+ 13 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/RedoWrongRequest.java

@@ -0,0 +1,13 @@
+package com.xuekairui.quiz.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 错题重做提交入参
+ */
+@Data
+public class RedoWrongRequest {
+    private List<AnswerItem> answers;
+}

+ 1 - 1
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/StudyBookSubmitRequest.java

@@ -10,6 +10,6 @@ import java.util.List;
 @Data
 public class StudyBookSubmitRequest {
     private Long subjectId;
-    private Long chapterId;
+    private Long examPointId;
     private List<AnswerItem> answers;
 }

+ 0 - 2
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizChapter.java

@@ -19,8 +19,6 @@ public class QuizChapter {
     private Integer level;
     @Builder.Default
     private Integer sort = 0;
-    @Builder.Default
-    private Integer questionCount = 0;
     @TableField(fill = FieldFill.INSERT)
     private LocalDateTime createTime;
     @TableField(fill = FieldFill.INSERT_UPDATE)

+ 2 - 1
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizQuestion.java

@@ -18,6 +18,7 @@ public class QuizQuestion {
     private Long subjectId;
     private Long sectionId;
     private Long chapterId;
+    private Long examPointId;
     private Integer questionType;
     private String matchingGroup;
     private String sharedOptions;
@@ -28,7 +29,7 @@ public class QuizQuestion {
     private String analysis;
     @Builder.Default
     private Integer isReal = 0;
-    private Integer year;
+    private String year;
     private String source;
     @TableField(fill = FieldFill.INSERT)
     private LocalDateTime createTime;

+ 0 - 2
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizSubject.java

@@ -15,8 +15,6 @@ public class QuizSubject {
     private String name;
     @Builder.Default
     private Integer sort = 0;
-    @Builder.Default
-    private Integer questionCount = 0;
     @TableField(fill = FieldFill.INSERT)
     private LocalDateTime createTime;
     @TableField(fill = FieldFill.INSERT_UPDATE)

+ 1 - 1
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizUserExamRecord.java

@@ -17,7 +17,7 @@ public class QuizUserExamRecord {
     @TableId(type = IdType.ASSIGN_ID)
     private Long id;
     private Long userId;
-    private Integer year;
+    private String year;
     private Long subjectId;
     private LocalDateTime startTime;
     private LocalDateTime submitTime;

+ 1 - 3
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizUserRecord.java

@@ -3,7 +3,6 @@ package com.xuekairui.quiz.entity;
 import com.baomidou.mybatisplus.annotation.*;
 import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
 import lombok.*;
-import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.util.List;
 import java.util.Map;
@@ -18,7 +17,7 @@ public class QuizUserRecord {
     private Long id;
     private Long userId;
     private Long subjectId;
-    private Long chapterId;
+    private Long examPointId;
     private Integer answerType;
     @Builder.Default
     private Integer totalCount = 0;
@@ -26,7 +25,6 @@ public class QuizUserRecord {
     private Integer correctCount = 0;
     @Builder.Default
     private Integer wrongCount = 0;
-    private BigDecimal correctRate;
     @TableField(typeHandler = JacksonTypeHandler.class)
     private List<Map<String, Object>> answers;
     @Builder.Default

+ 3 - 2
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/QuizChapterService.java

@@ -1,6 +1,7 @@
 package com.xuekairui.quiz.service;
 
 import com.xuekairui.quiz.entity.QuizChapter;
+import com.xuekairui.quiz.vo.ChapterListVO;
 import java.util.List;
 import java.util.Map;
 
@@ -18,8 +19,8 @@ public interface QuizChapterService {
     void delete(Long id);
 
     /** 按科目ID获取章列表(级联选择用) */
-    List<QuizChapter> listBySubject(Long subjectId);
+    List<ChapterListVO> listBySubject(Long subjectId);
 
     /** 按父节点ID获取子节点列表(级联选择用) */
-    List<QuizChapter> listByParent(Long parentId);
+    List<ChapterListVO> listByParent(Long parentId);
 }

+ 2 - 2
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/QuizQuestionService.java

@@ -34,7 +34,7 @@ public interface QuizQuestionService {
     IPage<QuizQuestionImportBatch> importBatchList(int pageNo, int pageSize);
 
     /** 真题分页列表 */
-    IPage<Map<String, Object>> listRealQuestions(Integer year, Long subjectId, Integer questionType, String keyword, int pageNo, int pageSize);
+    IPage<Map<String, Object>> listRealQuestions(String year, Long subjectId, Integer questionType, String keyword, int pageNo, int pageSize);
 
     /** 新增真题 */
     QuizQuestion addReal(QuizQuestion question);
@@ -49,7 +49,7 @@ public interface QuizQuestionService {
     IPage<QuizQuestionImportBatch> realImportBatchList(int pageNo, int pageSize);
 
     /** 获取真题年份列表 */
-    List<Integer> getRealYears();
+    List<String> getRealYears();
 
     /** 下载导入失败明细 */
     byte[] downloadFailDetail(Long batchId);

+ 3 - 3
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/RealExamService.java

@@ -5,16 +5,16 @@ import com.xuekairui.quiz.vo.ExamDetailVO;
 import com.xuekairui.quiz.vo.ExamRecordVO;
 import com.xuekairui.quiz.vo.ExamYearVO;
 import com.xuekairui.quiz.vo.PageVO;
-import com.xuekairui.quiz.vo.RealExamSubjectVO;
+import com.xuekairui.quiz.vo.RealExamYearGroupVO;
 import com.xuekairui.quiz.vo.StartExamVO;
 import com.xuekairui.quiz.vo.SubmitExamVO;
 
 import java.util.List;
 
 public interface RealExamService {
-    List<RealExamSubjectVO> list(Long userId, Long subjectId);
+    List<RealExamYearGroupVO> list(Long userId, Long subjectId);
     List<ExamYearVO> getExamYears(Long subjectId);
-    StartExamVO startExam(Long userId, Integer year, Long subjectId);
+    StartExamVO startExam(Long userId, String year, Long subjectId);
     void saveProgress(Long userId, Long recordId, List<AnswerItem> answers);
     SubmitExamVO submitExam(Long userId, Long recordId, List<AnswerItem> answers, Integer usedTime);
     PageVO<ExamRecordVO> getExamHistory(Long userId, Long subjectId, int pageNo, int pageSize);

+ 4 - 3
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/StudyBookService.java

@@ -15,9 +15,10 @@ import java.util.List;
 public interface StudyBookService {
     List<SubjectSimpleVO> getSubjectList();
     ChapterTreeVO getChapterTree(Long subjectId);
-    ChapterQuestionsVO getChapterQuestions(Long userId, Long chapterId);
-    PracticeSubmitVO submitChapterPractice(Long userId, Long subjectId, Long chapterId, List<AnswerItem> answers);
+    ChapterQuestionsVO getChapterQuestions(Long userId, Long examPointId);
+    PracticeSubmitVO submitChapterPractice(Long userId, Long subjectId, Long examPointId, List<AnswerItem> answers);
     ChapterProgressVO getChapterProgress(Long userId, Long subjectId);
-    StudyOverviewVO getOverview(Long userId);
+    StudyOverviewVO getOverview(Long userId, Long subjectId);
     PageVO<StudyBookQuestionVO> getQuestionList(Long userId, String type, Long subjectId, int pageNo, int pageSize);
+    PracticeSubmitVO redoWrong(Long userId, List<AnswerItem> answers);
 }

+ 1 - 1
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/UserQuestionService.java

@@ -8,7 +8,7 @@ import com.xuekairui.quiz.vo.ToggleCollectVO;
 
 public interface UserQuestionService {
     /** 获取考点下的题目列表(练习模式,不返回答案) */
-    PageVO<QuestionVO> listByChapter(Long chapterId, String order, Long userId, int pageNo, int pageSize);
+    PageVO<QuestionVO> listByChapter(Long examPointId, String order, Long userId, int pageNo, int pageSize);
 
     /** 提交单题答案 */
     SubmitAnswerVO submitAnswer(Long userId, Long questionId, String userAnswer, Integer source, Integer timeSpent);

+ 25 - 16
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/DailyPracticeServiceImpl.java

@@ -82,6 +82,7 @@ public class DailyPracticeServiceImpl implements DailyPracticeService {
         int wrongCount = 0;
         List<PracticeResultVO> results = new ArrayList<>();
         List<Map<String, Object>> answerRecords = new ArrayList<>();
+        Long subjectId = null;
 
         for (AnswerItem ans : answers) {
             Long questionId = ans.getQuestionId();
@@ -91,14 +92,20 @@ public class DailyPracticeServiceImpl implements DailyPracticeService {
             QuizQuestion question = questionMapper.selectById(questionId);
             if (question == null) continue;
 
-            boolean isCorrect = question.getAnswer().equalsIgnoreCase(userAnswer);
+            // 从第一道题获取科目ID(subject_id 列为 NOT NULL)
+            if (subjectId == null) {
+                subjectId = question.getSubjectId();
+            }
+
+            String correctAnswer = question.getAnswer();
+            boolean isCorrect = correctAnswer != null && correctAnswer.equalsIgnoreCase(userAnswer);
             if (isCorrect) correctCount++;
             else wrongCount++;
 
             results.add(PracticeResultVO.builder()
                     .questionId(questionId)
                     .isCorrect(isCorrect ? 1 : 0)
-                    .correctAnswer(question.getAnswer())
+                    .correctAnswer(correctAnswer)
                     .analysis(question.getAnalysis())
                     .build());
 
@@ -113,20 +120,22 @@ public class DailyPracticeServiceImpl implements DailyPracticeService {
         int total = correctCount + wrongCount;
         BigDecimal correctRate = total > 0 ? BigDecimal.valueOf(correctCount * 100.0 / total).setScale(0, RoundingMode.HALF_UP) : BigDecimal.ZERO;
 
-        // 保存做题记录
-        QuizUserRecord record = QuizUserRecord.builder()
-                .userId(userId)
-                .answerType(2)
-                .totalCount(total)
-                .correctCount(correctCount)
-                .wrongCount(wrongCount)
-                .correctRate(correctRate)
-                .answers(answerRecords)
-                .status(1)
-                .startTime(LocalDateTime.now())
-                .endTime(LocalDateTime.now())
-                .build();
-        userRecordMapper.insert(record);
+        // 只有有答题记录时才插入(subject_id 为 NOT NULL)
+        if (total > 0) {
+            QuizUserRecord record = QuizUserRecord.builder()
+                    .userId(userId)
+                    .subjectId(subjectId)
+                    .answerType(2)
+                    .totalCount(total)
+                    .correctCount(correctCount)
+                    .wrongCount(wrongCount)
+                    .answers(answerRecords)
+                    .status(1)
+                    .startTime(LocalDateTime.now())
+                    .endTime(LocalDateTime.now())
+                    .build();
+            userRecordMapper.insert(record);
+        }
 
         return PracticeSubmitVO.builder()
                 .correctCount(correctCount)

+ 37 - 3
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/HomeServiceImpl.java

@@ -17,8 +17,11 @@ import java.time.LocalDateTime;
 import java.time.temporal.ChronoUnit;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
+import java.util.stream.Collectors;
 
 @Service
 @RequiredArgsConstructor
@@ -57,19 +60,50 @@ public class HomeServiceImpl implements HomeService {
                 new LambdaQueryWrapper<QuizSubject>().orderByAsc(QuizSubject::getSort));
         List<HomeIndexVO.HomeSubjectVO> subjectList = new ArrayList<>();
         for (QuizSubject s : subjects) {
-            long doneCount = userRecordMapper.selectCount(
+            // 实时统计练习题数量(不依赖缓存字段question_count)
+            long totalCount = questionMapper.selectCount(
+                    new LambdaQueryWrapper<QuizQuestion>()
+                            .eq(QuizQuestion::getSubjectId, s.getId())
+                            .eq(QuizQuestion::getIsReal, 0)
+            );
+            // 已做数量按题目ID去重(同一题重复做只算一次),排除收藏记录(answerType=99)
+            List<QuizUserRecord> records = userRecordMapper.selectList(
                     new LambdaQueryWrapper<QuizUserRecord>()
                             .eq(QuizUserRecord::getUserId, userId)
                             .eq(QuizUserRecord::getSubjectId, s.getId())
                             .eq(QuizUserRecord::getStatus, 1)
+                            .ne(QuizUserRecord::getAnswerType, 99)
             );
+            Set<Long> doneQuestionIds = new LinkedHashSet<>();
+            for (QuizUserRecord r : records) {
+                if (r.getAnswers() != null) {
+                    for (Map<String, Object> ans : r.getAnswers()) {
+                        if (ans.get("questionId") != null) {
+                            doneQuestionIds.add(Long.valueOf(ans.get("questionId").toString()));
+                        }
+                    }
+                }
+            }
+            // 过滤掉已删除的题目、真题、以及不属于当前科目的题目
+            if (!doneQuestionIds.isEmpty()) {
+                List<QuizQuestion> existing = questionMapper.selectList(
+                        new LambdaQueryWrapper<QuizQuestion>()
+                                .in(QuizQuestion::getId, doneQuestionIds)
+                                .eq(QuizQuestion::getSubjectId, s.getId())
+                                .eq(QuizQuestion::getIsReal, 0)
+                                .select(QuizQuestion::getId)
+                );
+                Set<Long> validIds = existing.stream().map(QuizQuestion::getId).collect(Collectors.toSet());
+                doneQuestionIds.retainAll(validIds);
+            }
+            long doneCount = doneQuestionIds.size();
             subjectList.add(HomeIndexVO.HomeSubjectVO.builder()
                     .id(s.getId())
                     .name(s.getName())
                     .icon("")
-                    .totalCount(s.getQuestionCount())
+                    .totalCount((int) totalCount)
                     .doneCount(doneCount)
-                    .progress(s.getQuestionCount() > 0 ? (int) (doneCount * 100 / s.getQuestionCount()) : 0)
+                    .progress(totalCount > 0 ? (int) (doneCount * 100 / totalCount) : 0)
                     .build());
         }
 

+ 97 - 75
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizChapterImportServiceImpl.java

@@ -92,112 +92,134 @@ public class QuizChapterImportServiceImpl extends ServiceImpl<QuizChapterImportB
         for (ChapterImportDTO row : rows) {
             rowCounter++;
             try {
+                // 合并单元格兼容:上级字段为空时继承当前状态
+                String subjectName = StringUtils.hasText(row.getSubjectName()) ? row.getSubjectName().trim() : null;
+                String chapterName = StringUtils.hasText(row.getChapterName()) ? row.getChapterName().trim() : null;
+                String sectionName = StringUtils.hasText(row.getSectionName()) ? row.getSectionName().trim() : null;
+                String examPointName = StringUtils.hasText(row.getExamPointName()) ? row.getExamPointName().trim() : null;
+
                 // 判断行类型(从最深层级往上判断)
-                if (StringUtils.hasText(row.getExamPointName())) {
-                    // 考点行:需要科目+章+节+考点都有值
-                    if (!StringUtils.hasText(row.getSubjectName()) || !StringUtils.hasText(row.getChapterName()) || !StringUtils.hasText(row.getSectionName())) {
-                        failDetails.add(buildFail(rowCounter + 1, row.getExamPointName(), "考点行缺少科目/章/节信息"));
+                if (examPointName != null) {
+                    // 考点行:需要科目+章+节上下文(可从当前状态继承)
+                    if (subjectName != null) {
+                        if (!subjectName.equals(currentSubjectName)) {
+                            currentSubjectId = subjectCache.get(subjectName);
+                            if (currentSubjectId == null) {
+                                currentSubjectId = createSubject(subjectName, parseSort(row.getSubjectSort(), rowCounter));
+                                subjectCache.put(subjectName, currentSubjectId);
+                            }
+                            currentSubjectName = subjectName;
+                            currentChapterId = null;
+                            currentChapterName = null;
+                            currentSectionId = null;
+                            currentSectionName = null;
+                        }
+                    }
+                    if (currentSubjectId == null) {
+                        failDetails.add(buildFail(rowCounter + 1, examPointName, "考点行缺少科目信息(上方无科目可继承)"));
                         continue;
                     }
 
-                    // 更新科目状态
-                    if (!row.getSubjectName().equals(currentSubjectName)) {
-                        currentSubjectId = subjectCache.get(row.getSubjectName());
-                        if (currentSubjectId == null) {
-                            int sort = parseSort(row.getSubjectSort(), rowCounter);
-                            currentSubjectId = createSubject(row.getSubjectName(), sort);
-                            subjectCache.put(row.getSubjectName(), currentSubjectId);
+                    if (chapterName != null) {
+                        if (!chapterName.equals(currentChapterName)) {
+                            currentChapterId = getOrCreateChapter(currentSubjectId, chapterName, parseSort(row.getChapterSort(), rowCounter));
+                            currentChapterName = chapterName;
+                            currentSectionId = null;
+                            currentSectionName = null;
                         }
-                        currentSubjectName = row.getSubjectName();
-                        currentChapterId = null;
-                        currentChapterName = null;
-                        currentSectionId = null;
-                        currentSectionName = null;
                     }
-
-                    // 更新章状态
-                    if (!row.getChapterName().equals(currentChapterName)) {
-                        currentChapterId = getOrCreateChapter(currentSubjectId, row.getChapterName(), parseSort(row.getChapterSort(), rowCounter));
-                        currentChapterName = row.getChapterName();
-                        currentSectionId = null;
-                        currentSectionName = null;
+                    if (currentChapterId == null) {
+                        failDetails.add(buildFail(rowCounter + 1, examPointName, "考点行缺少章信息(上方无章可继承)"));
+                        continue;
                     }
 
-                    // 更新节状态
-                    if (!row.getSectionName().equals(currentSectionName)) {
-                        currentSectionId = getOrCreateSection(currentSubjectId, currentChapterId, row.getSectionName(), parseSort(row.getSectionSort(), rowCounter));
-                        currentSectionName = row.getSectionName();
+                    if (sectionName != null) {
+                        if (!sectionName.equals(currentSectionName)) {
+                            currentSectionId = getOrCreateSection(currentSubjectId, currentChapterId, sectionName, parseSort(row.getSectionSort(), rowCounter));
+                            currentSectionName = sectionName;
+                        }
+                    }
+                    if (currentSectionId == null) {
+                        failDetails.add(buildFail(rowCounter + 1, examPointName, "考点行缺少节信息(上方无节可继承)"));
+                        continue;
                     }
 
-                    // 创建考点
-                    getOrCreateExamPoint(currentSubjectId, currentSectionId, row.getExamPointName(), parseSort(row.getExamPointSort(), rowCounter));
+                    getOrCreateExamPoint(currentSubjectId, currentSectionId, examPointName, parseSort(row.getExamPointSort(), rowCounter));
                     successCount++;
 
-                } else if (StringUtils.hasText(row.getSectionName())) {
+                } else if (sectionName != null) {
                     // 节行
-                    if (!StringUtils.hasText(row.getSubjectName()) || !StringUtils.hasText(row.getChapterName())) {
-                        failDetails.add(buildFail(rowCounter + 1, row.getSectionName(), "节行缺少科目/章信息"));
+                    if (subjectName != null) {
+                        if (!subjectName.equals(currentSubjectName)) {
+                            currentSubjectId = subjectCache.get(subjectName);
+                            if (currentSubjectId == null) {
+                                currentSubjectId = createSubject(subjectName, parseSort(row.getSubjectSort(), rowCounter));
+                                subjectCache.put(subjectName, currentSubjectId);
+                            }
+                            currentSubjectName = subjectName;
+                            currentChapterId = null;
+                            currentChapterName = null;
+                            currentSectionId = null;
+                            currentSectionName = null;
+                        }
+                    }
+                    if (currentSubjectId == null) {
+                        failDetails.add(buildFail(rowCounter + 1, sectionName, "节行缺少科目信息(上方无科目可继承)"));
                         continue;
                     }
 
-                    if (!row.getSubjectName().equals(currentSubjectName)) {
-                        currentSubjectId = subjectCache.get(row.getSubjectName());
-                        if (currentSubjectId == null) {
-                            currentSubjectId = createSubject(row.getSubjectName(), parseSort(row.getSubjectSort(), rowCounter));
-                            subjectCache.put(row.getSubjectName(), currentSubjectId);
+                    if (chapterName != null) {
+                        if (!chapterName.equals(currentChapterName)) {
+                            currentChapterId = getOrCreateChapter(currentSubjectId, chapterName, parseSort(row.getChapterSort(), rowCounter));
+                            currentChapterName = chapterName;
+                            currentSectionId = null;
+                            currentSectionName = null;
                         }
-                        currentSubjectName = row.getSubjectName();
-                        currentChapterId = null;
-                        currentChapterName = null;
-                        currentSectionId = null;
-                        currentSectionName = null;
                     }
-
-                    if (!row.getChapterName().equals(currentChapterName)) {
-                        currentChapterId = getOrCreateChapter(currentSubjectId, row.getChapterName(), parseSort(row.getChapterSort(), rowCounter));
-                        currentChapterName = row.getChapterName();
-                        currentSectionId = null;
-                        currentSectionName = null;
+                    if (currentChapterId == null) {
+                        failDetails.add(buildFail(rowCounter + 1, sectionName, "节行缺少章信息(上方无章可继承)"));
+                        continue;
                     }
 
-                    currentSectionId = getOrCreateSection(currentSubjectId, currentChapterId, row.getSectionName(), parseSort(row.getSectionSort(), rowCounter));
-                    currentSectionName = row.getSectionName();
+                    currentSectionId = getOrCreateSection(currentSubjectId, currentChapterId, sectionName, parseSort(row.getSectionSort(), rowCounter));
+                    currentSectionName = sectionName;
                     successCount++;
 
-                } else if (StringUtils.hasText(row.getChapterName())) {
+                } else if (chapterName != null) {
                     // 章行
-                    if (!StringUtils.hasText(row.getSubjectName())) {
-                        failDetails.add(buildFail(rowCounter + 1, row.getChapterName(), "章行缺少科目信息"));
-                        continue;
-                    }
-
-                    if (!row.getSubjectName().equals(currentSubjectName)) {
-                        currentSubjectId = subjectCache.get(row.getSubjectName());
-                        if (currentSubjectId == null) {
-                            currentSubjectId = createSubject(row.getSubjectName(), parseSort(row.getSubjectSort(), rowCounter));
-                            subjectCache.put(row.getSubjectName(), currentSubjectId);
+                    if (subjectName != null) {
+                        if (!subjectName.equals(currentSubjectName)) {
+                            currentSubjectId = subjectCache.get(subjectName);
+                            if (currentSubjectId == null) {
+                                currentSubjectId = createSubject(subjectName, parseSort(row.getSubjectSort(), rowCounter));
+                                subjectCache.put(subjectName, currentSubjectId);
+                            }
+                            currentSubjectName = subjectName;
+                            currentChapterId = null;
+                            currentChapterName = null;
+                            currentSectionId = null;
+                            currentSectionName = null;
                         }
-                        currentSubjectName = row.getSubjectName();
-                        currentChapterId = null;
-                        currentChapterName = null;
-                        currentSectionId = null;
-                        currentSectionName = null;
+                    }
+                    if (currentSubjectId == null) {
+                        failDetails.add(buildFail(rowCounter + 1, chapterName, "章行缺少科目信息(上方无科目可继承)"));
+                        continue;
                     }
 
-                    currentChapterId = getOrCreateChapter(currentSubjectId, row.getChapterName(), parseSort(row.getChapterSort(), rowCounter));
-                    currentChapterName = row.getChapterName();
+                    currentChapterId = getOrCreateChapter(currentSubjectId, chapterName, parseSort(row.getChapterSort(), rowCounter));
+                    currentChapterName = chapterName;
                     currentSectionId = null;
                     currentSectionName = null;
                     successCount++;
 
-                } else if (StringUtils.hasText(row.getSubjectName())) {
+                } else if (subjectName != null) {
                     // 科目行
-                    currentSubjectId = subjectCache.get(row.getSubjectName());
+                    currentSubjectId = subjectCache.get(subjectName);
                     if (currentSubjectId == null) {
-                        currentSubjectId = createSubject(row.getSubjectName(), parseSort(row.getSubjectSort(), rowCounter));
-                        subjectCache.put(row.getSubjectName(), currentSubjectId);
+                        currentSubjectId = createSubject(subjectName, parseSort(row.getSubjectSort(), rowCounter));
+                        subjectCache.put(subjectName, currentSubjectId);
                     }
-                    currentSubjectName = row.getSubjectName();
+                    currentSubjectName = subjectName;
                     currentChapterId = null;
                     currentChapterName = null;
                     currentSectionId = null;
@@ -239,7 +261,7 @@ public class QuizChapterImportServiceImpl extends ServiceImpl<QuizChapterImportB
         QuizChapter existing = chapterMapper.selectOne(
                 new LambdaQueryWrapper<QuizChapter>()
                         .eq(QuizChapter::getSubjectId, subjectId)
-                        .eq(QuizChapter::getParentId, 0L)
+                        .eq(QuizChapter::getParentId, subjectId)
                         .eq(QuizChapter::getName, name)
                         .eq(QuizChapter::getLevel, 2)
         );
@@ -247,7 +269,7 @@ public class QuizChapterImportServiceImpl extends ServiceImpl<QuizChapterImportB
 
         QuizChapter node = QuizChapter.builder()
                 .subjectId(subjectId)
-                .parentId(0L)
+                .parentId(subjectId)
                 .name(name)
                 .level(2)
                 .sort(sort)

+ 114 - 8
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizChapterServiceImpl.java

@@ -11,6 +11,7 @@ import com.xuekairui.quiz.mapper.QuizChapterMapper;
 import com.xuekairui.quiz.mapper.QuizQuestionMapper;
 import com.xuekairui.quiz.mapper.QuizSubjectMapper;
 import com.xuekairui.quiz.service.QuizChapterService;
+import com.xuekairui.quiz.vo.ChapterListVO;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 import org.springframework.util.StringUtils;
@@ -63,6 +64,9 @@ public class QuizChapterServiceImpl extends ServiceImpl<QuizChapterMapper, QuizC
             allChapters = allChapters.stream().filter(ch -> finalChapterIds.contains(ch.getId())).collect(Collectors.toList());
         }
 
+        // 实时统计题目数(不依赖缓存字段question_count)
+        Map<Long, Integer> directCountMap = getDirectQuestionCounts();
+
         // 构建树:科目作为根节点
         List<Map<String, Object>> tree = new ArrayList<>();
         for (QuizSubject s : subjects) {
@@ -74,12 +78,16 @@ public class QuizChapterServiceImpl extends ServiceImpl<QuizChapterMapper, QuizC
             subjectNode.put("level", 1);
             subjectNode.put("levelName", "科目");
             subjectNode.put("sort", s.getSort());
-            subjectNode.put("questionCount", s.getQuestionCount());
             subjectNode.put("updateTime", s.getUpdateTime());
-            subjectNode.put("children", buildTree(allChapters, s.getId()));
+            subjectNode.put("children", buildSubjectTree(allChapters, s.getId()));
             tree.add(subjectNode);
         }
 
+        // 递归计算每个节点的题目数(自身直接关联 + 所有子孙节点)
+        for (Map<String, Object> node : tree) {
+            sumTreeQuestionCount(node, directCountMap);
+        }
+
         // 统计
         long totalExamPoints = allChapters.stream().filter(c -> c.getLevel() == 4).count();
 
@@ -101,6 +109,30 @@ public class QuizChapterServiceImpl extends ServiceImpl<QuizChapterMapper, QuizC
         }
     }
 
+    /**
+     * 构建科目下的章节树(第一层章节点 parentId=subjectId)
+     */
+    private List<Map<String, Object>> buildSubjectTree(List<QuizChapter> chapters, Long subjectId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (QuizChapter ch : chapters) {
+            // 第一层:parentId=subjectId
+            if (ch.getSubjectId().equals(subjectId) && ch.getParentId().equals(subjectId)) {
+                Map<String, Object> node = new LinkedHashMap<>();
+                node.put("id", ch.getId());
+                node.put("subjectId", ch.getSubjectId());
+                node.put("parentId", ch.getParentId());
+                node.put("name", ch.getName());
+                node.put("level", ch.getLevel());
+                node.put("levelName", ch.getLevel() >= 1 && ch.getLevel() <= 4 ? LEVEL_NAMES[ch.getLevel()] : "");
+                node.put("sort", ch.getSort());
+                node.put("updateTime", ch.getUpdateTime());
+                node.put("children", buildTree(chapters, ch.getId()));
+                result.add(node);
+            }
+        }
+        return result;
+    }
+
     private List<Map<String, Object>> buildTree(List<QuizChapter> chapters, Long parentId) {
         List<Map<String, Object>> result = new ArrayList<>();
         for (QuizChapter ch : chapters) {
@@ -113,7 +145,6 @@ public class QuizChapterServiceImpl extends ServiceImpl<QuizChapterMapper, QuizC
                 node.put("level", ch.getLevel());
                 node.put("levelName", ch.getLevel() >= 1 && ch.getLevel() <= 4 ? LEVEL_NAMES[ch.getLevel()] : "");
                 node.put("sort", ch.getSort());
-                node.put("questionCount", ch.getQuestionCount());
                 node.put("updateTime", ch.getUpdateTime());
                 node.put("children", buildTree(chapters, ch.getId()));
                 result.add(node);
@@ -122,6 +153,41 @@ public class QuizChapterServiceImpl extends ServiceImpl<QuizChapterMapper, QuizC
         return result;
     }
 
+    /** 批量查询所有章节节点直接关联的练习题数(不含真题) */
+    private Map<Long, Integer> getDirectQuestionCounts() {
+        List<QuizQuestion> questions = questionMapper.selectList(
+                new LambdaQueryWrapper<QuizQuestion>()
+                        .eq(QuizQuestion::getIsReal, 0)
+                        .select(QuizQuestion::getExamPointId)
+        );
+        Map<Long, Integer> map = new HashMap<>();
+        for (QuizQuestion q : questions) {
+            if (q.getExamPointId() != null) {
+                map.merge(q.getExamPointId(), 1, Integer::sum);
+            }
+        }
+        return map;
+    }
+
+    /** 递归计算节点题目数 = 自身直接关联数 + 所有子孙节点之和 */
+    @SuppressWarnings("unchecked")
+    private int sumTreeQuestionCount(Map<String, Object> node, Map<Long, Integer> directCountMap) {
+        List<Map<String, Object>> children = (List<Map<String, Object>>) node.get("children");
+        int total = 0;
+        if (children != null && !children.isEmpty()) {
+            for (Map<String, Object> child : children) {
+                total += sumTreeQuestionCount(child, directCountMap);
+            }
+        }
+        Long nodeId = (Long) node.get("id");
+        Integer direct = directCountMap.get(nodeId);
+        if (direct != null) {
+            total += direct;
+        }
+        node.put("questionCount", total);
+        return total;
+    }
+
     @Override
     public QuizChapter addChild(Long parentId, String name, Integer sort) {
         // 先尝试在章节表中查找父节点
@@ -184,7 +250,7 @@ public class QuizChapterServiceImpl extends ServiceImpl<QuizChapterMapper, QuizC
         }
         // 检查关联题目
         long questionCount = questionMapper.selectCount(
-                new LambdaQueryWrapper<QuizQuestion>().eq(QuizQuestion::getChapterId, id)
+                new LambdaQueryWrapper<QuizQuestion>().eq(QuizQuestion::getExamPointId, id)
         );
         if (questionCount > 0) {
             throw new BusinessException(ErrorCode.QUIZ_CHAPTER_HAS_QUESTIONS);
@@ -193,17 +259,57 @@ public class QuizChapterServiceImpl extends ServiceImpl<QuizChapterMapper, QuizC
     }
 
     @Override
-    public List<QuizChapter> listBySubject(Long subjectId) {
-        return list(new LambdaQueryWrapper<QuizChapter>()
+    public List<ChapterListVO> listBySubject(Long subjectId) {
+        List<QuizChapter> chapters = list(new LambdaQueryWrapper<QuizChapter>()
                 .eq(QuizChapter::getSubjectId, subjectId)
                 .eq(QuizChapter::getLevel, 2)
                 .orderByAsc(QuizChapter::getSort));
+        return toVOList(chapters);
     }
 
     @Override
-    public List<QuizChapter> listByParent(Long parentId) {
-        return list(new LambdaQueryWrapper<QuizChapter>()
+    public List<ChapterListVO> listByParent(Long parentId) {
+        List<QuizChapter> chapters = list(new LambdaQueryWrapper<QuizChapter>()
                 .eq(QuizChapter::getParentId, parentId)
                 .orderByAsc(QuizChapter::getSort));
+        return toVOList(chapters);
+    }
+
+    private List<ChapterListVO> toVOList(List<QuizChapter> chapters) {
+        if (chapters == null || chapters.isEmpty()) return new ArrayList<>();
+
+        Map<Long, Integer> directCountMap = getDirectQuestionCounts();
+        List<QuizChapter> allChapters = list(new LambdaQueryWrapper<QuizChapter>());
+        Map<Long, List<QuizChapter>> childrenMap = new LinkedHashMap<>();
+        for (QuizChapter ch : allChapters) {
+            childrenMap.computeIfAbsent(ch.getParentId(), k -> new ArrayList<>()).add(ch);
+        }
+
+        List<ChapterListVO> result = new ArrayList<>();
+        for (QuizChapter ch : chapters) {
+            result.add(ChapterListVO.builder()
+                    .id(ch.getId())
+                    .subjectId(ch.getSubjectId())
+                    .parentId(ch.getParentId())
+                    .name(ch.getName())
+                    .level(ch.getLevel())
+                    .sort(ch.getSort())
+                    .questionCount(getSubtreeCount(ch.getId(), directCountMap, childrenMap))
+                    .createTime(ch.getCreateTime())
+                    .updateTime(ch.getUpdateTime())
+                    .build());
+        }
+        return result;
+    }
+
+    private int getSubtreeCount(Long chapterId, Map<Long, Integer> directCountMap, Map<Long, List<QuizChapter>> childrenMap) {
+        int count = directCountMap.getOrDefault(chapterId, 0);
+        List<QuizChapter> children = childrenMap.get(chapterId);
+        if (children != null) {
+            for (QuizChapter child : children) {
+                count += getSubtreeCount(child.getId(), directCountMap, childrenMap);
+            }
+        }
+        return count;
     }
 }

+ 163 - 63
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizQuestionServiceImpl.java

@@ -58,8 +58,8 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
     public QuizQuestion add(QuizQuestion question) {
         question.setIsReal(0);
         question.setYear(null);
+        fillSectionId(question);
         save(question);
-        updateQuestionCount(question.getSubjectId(), question.getChapterId(), 1);
         return question;
     }
 
@@ -70,6 +70,8 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
         if (existing == null) {
             throw new BusinessException(ErrorCode.QUIZ_QUESTION_NOT_FOUND);
         }
+        // 保留原有的 isReal 字段,防止前端未传导致题目类型改变
+        question.setIsReal(existing.getIsReal());
         updateById(question);
         return question;
     }
@@ -82,7 +84,6 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
             throw new BusinessException(ErrorCode.QUIZ_QUESTION_NOT_FOUND);
         }
         removeById(id);
-        updateQuestionCount(q.getSubjectId(), q.getChapterId(), -1);
     }
 
     @Override
@@ -91,9 +92,6 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
         List<QuizQuestion> questions = listByIds(ids);
         if (questions.isEmpty()) return;
         removeByIds(ids);
-        for (QuizQuestion q : questions) {
-            updateQuestionCount(q.getSubjectId(), q.getChapterId(), -1);
-        }
     }
 
     @Override
@@ -113,7 +111,7 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
     }
 
     @Override
-    public IPage<Map<String, Object>> listRealQuestions(Integer year, Long subjectId, Integer questionType, String keyword, int pageNo, int pageSize) {
+    public IPage<Map<String, Object>> listRealQuestions(String year, Long subjectId, Integer questionType, String keyword, int pageNo, int pageSize) {
         LambdaQueryWrapper<QuizQuestion> wrapper = new LambdaQueryWrapper<QuizQuestion>()
                 .eq(QuizQuestion::getIsReal, 1)
                 .eq(year != null, QuizQuestion::getYear, year)
@@ -130,8 +128,8 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
     @Transactional
     public QuizQuestion addReal(QuizQuestion question) {
         question.setIsReal(1);
+        fillSectionId(question);
         save(question);
-        updateQuestionCount(question.getSubjectId(), question.getChapterId(), 1);
         return question;
     }
 
@@ -142,6 +140,8 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
         if (existing == null) {
             throw new BusinessException(ErrorCode.QUIZ_QUESTION_NOT_FOUND);
         }
+        // 强制保留 isReal=1,防止前端未传该字段导致真题变成练习题
+        question.setIsReal(1);
         updateById(question);
         return question;
     }
@@ -163,7 +163,7 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
     }
 
     @Override
-    public List<Integer> getRealYears() {
+    public List<String> getRealYears() {
         LambdaQueryWrapper<QuizQuestion> wrapper = new LambdaQueryWrapper<QuizQuestion>()
                 .eq(QuizQuestion::getIsReal, 1)
                 .isNotNull(QuizQuestion::getYear)
@@ -268,14 +268,22 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
             chapterById.put(ch.getId(), ch);
         }
 
-        // 按名称索引:normalizedName → 匹配的chapter列表(可能重名)
-        Map<String, List<QuizChapter>> chaptersByName = new HashMap<>();
+        // 按名称+层级索引:避免跨层级误匹配(考点=level4, 节=level3, 章=level2)
+        Map<String, List<QuizChapter>> examPointByName = new HashMap<>();
+        Map<String, List<QuizChapter>> sectionByName = new HashMap<>();
+        Map<String, List<QuizChapter>> chapterByName = new HashMap<>();
         for (QuizChapter ch : allChapters) {
             String key = normalize(ch.getName());
-            chaptersByName.computeIfAbsent(key, k -> new ArrayList<>()).add(ch);
+            if (ch.getLevel() != null) {
+                switch (ch.getLevel()) {
+                    case 4 -> examPointByName.computeIfAbsent(key, k -> new ArrayList<>()).add(ch);
+                    case 3 -> sectionByName.computeIfAbsent(key, k -> new ArrayList<>()).add(ch);
+                    case 2 -> chapterByName.computeIfAbsent(key, k -> new ArrayList<>()).add(ch);
+                }
+            }
         }
 
-        // 完整路径缓存:科目名|章名|节名|考点名 → chapterId(level 4)
+        // 完整路径缓存:科目名|章名|节名|考点名 → examPointId(level 4)
         Map<String, Long> chapterPathCache = new HashMap<>();
         for (QuizChapter ch : allChapters) {
             if (ch.getLevel() == 4) {
@@ -299,22 +307,13 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
             rowNum++;
             try {
                 // 真题必须有年份
-                Integer year = null;
+                String year = null;
                 if (isReal) {
                     if (!StringUtils.hasText(row.getYear())) {
                         failDetails.add(buildFail(rowNum, row.getStem(), "年份为空"));
                         continue;
                     }
-                    try {
-                        year = (int) Double.parseDouble(row.getYear().trim());
-                    } catch (NumberFormatException e) {
-                        failDetails.add(buildFail(rowNum, row.getStem(), "年份格式错误:" + row.getYear()));
-                        continue;
-                    }
-                    if (year < 2015 || year > 2030) {
-                        failDetails.add(buildFail(rowNum, row.getStem(), "年份超出范围(2015-2030):" + year));
-                        continue;
-                    }
+                    year = row.getYear().trim();
                 }
 
                 // 校验必填字段
@@ -350,7 +349,7 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
                 }
 
                 // 解析考点路径(灵活匹配:优先完整路径,降级按名称匹配)
-                Long chapterId = null;
+                Long examPointId = null;
                 Long actualSubjectId = subjectId;
                 if (StringUtils.hasText(row.getExamPointName())) {
                     // 先尝试完整路径匹配
@@ -358,26 +357,26 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
                             + (StringUtils.hasText(row.getChapterName()) ? normalize(row.getChapterName().trim()) : "") + "|"
                             + (StringUtils.hasText(row.getSectionName()) ? normalize(row.getSectionName().trim()) : "") + "|"
                             + normalize(row.getExamPointName().trim());
-                    chapterId = chapterPathCache.get(pathKey);
+                    examPointId = chapterPathCache.get(pathKey);
 
-                    // 降级:按考点名称全局查找
-                    if (chapterId == null) {
-                        chapterId = findChapterByName(chaptersByName, normalize(row.getExamPointName().trim()), chapterById, subjectCache);
+                    // 降级:按考点名称查找(仅匹配level=4考点)
+                    if (examPointId == null) {
+                        examPointId = findChapterByName(examPointByName, normalize(row.getExamPointName().trim()), chapterById, subjectCache);
                     }
-                    // 再降级:按节名称查找
-                    if (chapterId == null && StringUtils.hasText(row.getSectionName())) {
-                        chapterId = findChapterByName(chaptersByName, normalize(row.getSectionName().trim()), chapterById, subjectCache);
+                    // 再降级:按节名称查找(仅匹配level=3节)
+                    if (examPointId == null && StringUtils.hasText(row.getSectionName())) {
+                        examPointId = findChapterByName(sectionByName, normalize(row.getSectionName().trim()), chapterById, subjectCache);
                     }
-                    // 再降级:按章名称查找
-                    if (chapterId == null && StringUtils.hasText(row.getChapterName())) {
-                        chapterId = findChapterByName(chaptersByName, normalize(row.getChapterName().trim()), chapterById, subjectCache);
+                    // 再降级:按章名称查找(仅匹配level=2章)
+                    if (examPointId == null && StringUtils.hasText(row.getChapterName())) {
+                        examPointId = findChapterByName(chapterByName, normalize(row.getChapterName().trim()), chapterById, subjectCache);
                     }
-                    if (chapterId == null) {
+                    if (examPointId == null) {
                         failDetails.add(buildFail(rowNum, row.getStem(), "考点不存在:" + row.getExamPointName()));
                         continue;
                     }
                     // 使用匹配到的章节的实际科目
-                    QuizChapter matchedChapter = chapterById.get(chapterId);
+                    QuizChapter matchedChapter = chapterById.get(examPointId);
                     if (matchedChapter != null) {
                         actualSubjectId = matchedChapter.getSubjectId();
                     }
@@ -390,12 +389,10 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
                     if (StringUtils.hasText(row.getSharedOptions())) {
                         currentMatchingGroup = matchingGroup;
                         currentSharedOptions = row.getSharedOptions().trim();
+                        sharedOptions = currentSharedOptions;
                     } else if (matchingGroup.equals(currentMatchingGroup)) {
                         sharedOptions = currentSharedOptions;
                     }
-                    if (sharedOptions == null && StringUtils.hasText(row.getSharedOptions())) {
-                        sharedOptions = row.getSharedOptions().trim();
-                    }
                 } else {
                     currentMatchingGroup = null;
                     currentSharedOptions = null;
@@ -404,19 +401,28 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
 
                 // 构建选项列表
                 List<Map<String, Object>> options = buildOptions(row);
-                // 配伍题允许无独立选项(使用共用选项),非配伍题至少需要2个选项
+                // 配伍题/综合分析题:独立选项为空时,从共用选项文本解析出结构化选项
                 boolean isMatching = matchingGroup != null && !"0".equals(matchingGroup);
+                if (options.isEmpty() && isMatching && sharedOptions != null) {
+                    options = parseSharedOptionsText(sharedOptions);
+                }
+                // 非配伍题至少需要2个选项
                 if (options.isEmpty() && !isMatching) {
                     failDetails.add(buildFail(rowNum, row.getStem(), "选项缺失(至少需要2个选项)"));
                     continue;
                 }
 
-                // 查找节ID(考点的父节点)
+                // 查找节ID(考点的父节点)和章ID(节的父节点)
                 Long sectionId = null;
-                if (chapterId != null) {
-                    QuizChapter examPoint = chapterById.get(chapterId);
+                Long chapterParentId = null;
+                if (examPointId != null) {
+                    QuizChapter examPoint = chapterById.get(examPointId);
                     if (examPoint != null && examPoint.getParentId() != null && examPoint.getParentId() != 0L) {
                         sectionId = examPoint.getParentId();
+                        QuizChapter section = chapterById.get(sectionId);
+                        if (section != null && section.getParentId() != null && section.getParentId() != 0L) {
+                            chapterParentId = section.getParentId();
+                        }
                     }
                 }
 
@@ -424,7 +430,8 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
                 QuizQuestion question = QuizQuestion.builder()
                         .subjectId(actualSubjectId)
                         .sectionId(sectionId)
-                        .chapterId(chapterId)
+                        .chapterId(chapterParentId)
+                        .examPointId(examPointId)
                         .questionType(questionType)
                         .matchingGroup(matchingGroup)
                         .sharedOptions(sharedOptions)
@@ -437,7 +444,6 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
                         .source("import")
                         .build();
                 save(question);
-                updateQuestionCount(actualSubjectId, chapterId, 1);
                 successCount++;
 
             } catch (Exception e) {
@@ -491,6 +497,41 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
         }
     }
 
+    /**
+     * 解析共用选项文本为结构化选项列表
+     * 支持格式:A.内容B.内容 / A、内容B、内容 / A.内容<br/>B.内容
+     */
+    private List<Map<String, Object>> parseSharedOptionsText(String text) {
+        List<Map<String, Object>> options = new ArrayList<>();
+        if (!StringUtils.hasText(text)) return options;
+
+        // 统一分隔符:将 <br/>、<br>、换行符 替换为统一标记
+        String normalized = text.replaceAll("<br\\s*/?>", "\n").replaceAll("[\r\n]+", "\n").trim();
+
+        // 按 A. / A、/ A) 等模式分割
+        String[] parts = normalized.split("(?=[A-E][.、)\\s])");
+        for (String part : parts) {
+            part = part.trim();
+            if (part.isEmpty()) continue;
+
+            // 提取选项字母和内容
+            String key = "";
+            String content = part;
+            if (part.length() >= 2 && part.charAt(0) >= 'A' && part.charAt(0) <= 'E') {
+                key = String.valueOf(part.charAt(0));
+                content = part.substring(1).replaceFirst("^[.、)\\s]+", "").trim();
+            }
+
+            if (StringUtils.hasText(key) && StringUtils.hasText(content)) {
+                Map<String, Object> opt = new LinkedHashMap<>();
+                opt.put("key", key);
+                opt.put("content", content);
+                options.add(opt);
+            }
+        }
+        return options;
+    }
+
     /**
      * 按名称全局查找章节节点(返回最深匹配节点的ID)
      */
@@ -549,25 +590,68 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
 
     // ==================== 辅助方法 ====================
 
-    private void updateQuestionCount(Long subjectId, Long chapterId, int delta) {
-        if (subjectId != null) {
-            QuizSubject subject = subjectMapper.selectById(subjectId);
-            if (subject != null) {
-                subject.setQuestionCount(Math.max(0, subject.getQuestionCount() + delta));
-                subjectMapper.updateById(subject);
-            }
-        }
-        if (chapterId != null) {
-            QuizChapter chapter = chapterMapper.selectById(chapterId);
-            if (chapter != null) {
-                chapter.setQuestionCount(Math.max(0, chapter.getQuestionCount() + delta));
-                chapterMapper.updateById(chapter);
+    /**
+     * 根据 examPointId(考点,level=4)自动填充 sectionId(节,level=3)和 chapterId(章,level=2)
+     * 考点的父节点是节,节的父节点是章
+     */
+    private void fillSectionId(QuizQuestion question) {
+        if (question.getExamPointId() != null) {
+            QuizChapter examPoint = chapterMapper.selectById(question.getExamPointId());
+            if (examPoint != null && examPoint.getLevel() != null && examPoint.getLevel() == 4) {
+                if (question.getSectionId() == null) {
+                    question.setSectionId(examPoint.getParentId());
+                }
+                if (question.getChapterId() == null && examPoint.getParentId() != null) {
+                    QuizChapter section = chapterMapper.selectById(examPoint.getParentId());
+                    if (section != null) {
+                        question.setChapterId(section.getParentId());
+                    }
+                }
             }
         }
     }
 
     private IPage<Map<String, Object>> convertPage(IPage<QuizQuestion> page) {
-        List<Map<String, Object>> list = page.getRecords().stream().map(q -> {
+        List<QuizQuestion> records = page.getRecords();
+        if (records.isEmpty()) {
+            Page<Map<String, Object>> empty = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
+            empty.setRecords(new ArrayList<>());
+            return empty;
+        }
+
+        // 批量预加载科目(1次查询代替N次)
+        Set<Long> subjectIds = records.stream()
+                .map(QuizQuestion::getSubjectId)
+                .filter(Objects::nonNull)
+                .collect(Collectors.toSet());
+        Map<Long, QuizSubject> subjectMap = new HashMap<>();
+        if (!subjectIds.isEmpty()) {
+            subjectMapper.selectBatchIds(subjectIds).forEach(s -> subjectMap.put(s.getId(), s));
+        }
+
+        // 批量预加载章节节点(考点、节、章),最多3层
+        Set<Long> chapterIds = new HashSet<>();
+        for (QuizQuestion q : records) {
+            if (q.getExamPointId() != null) chapterIds.add(q.getExamPointId());
+            if (q.getSectionId() != null) chapterIds.add(q.getSectionId());
+            if (q.getChapterId() != null) chapterIds.add(q.getChapterId());
+        }
+        Map<Long, QuizChapter> chapterMap = new HashMap<>();
+        if (!chapterIds.isEmpty()) {
+            chapterMapper.selectBatchIds(chapterIds).forEach(ch -> chapterMap.put(ch.getId(), ch));
+        }
+        // 补加载节的父节点(章)
+        Set<Long> parentIds = new HashSet<>();
+        for (QuizChapter ch : chapterMap.values()) {
+            if (ch.getParentId() != null && ch.getParentId() != 0L && !chapterMap.containsKey(ch.getParentId())) {
+                parentIds.add(ch.getParentId());
+            }
+        }
+        if (!parentIds.isEmpty()) {
+            chapterMapper.selectBatchIds(parentIds).forEach(ch -> chapterMap.put(ch.getId(), ch));
+        }
+
+        List<Map<String, Object>> list = records.stream().map(q -> {
             Map<String, Object> map = new LinkedHashMap<>();
             map.put("id", q.getId());
             map.put("stem", q.getStem() != null && q.getStem().length() > 80 ? q.getStem().substring(0, 80) + "..." : q.getStem());
@@ -575,14 +659,30 @@ public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, Qui
             map.put("questionTypeName", getTypeName(q.getQuestionType()));
             map.put("matchingGroup", q.getMatchingGroup());
             map.put("year", q.getYear());
-            QuizSubject subject = subjectMapper.selectById(q.getSubjectId());
+
+            QuizSubject subject = subjectMap.get(q.getSubjectId());
             map.put("subjectName", subject != null ? subject.getName() : "");
-            QuizChapter section = q.getSectionId() != null ? chapterMapper.selectById(q.getSectionId()) : null;
+
+            QuizChapter examPoint = chapterMap.get(q.getExamPointId());
+            map.put("examPointName", examPoint != null ? examPoint.getName() : "");
+
+            QuizChapter section = chapterMap.get(q.getSectionId());
             map.put("sectionName", section != null ? section.getName() : "");
-            QuizChapter chapter = chapterMapper.selectById(q.getChapterId());
-            map.put("chapterName", chapter != null ? chapter.getName() : "");
+
+            QuizChapter chapter = chapterMap.get(q.getChapterId());
+            String chapterName = chapter != null ? chapter.getName() : "";
+            // 历史数据 chapterId(章) 可能为空,用节的父节点兜底
+            if (chapterName.isEmpty() && section != null && section.getParentId() != null && section.getParentId() != 0L) {
+                QuizChapter ch = chapterMap.get(section.getParentId());
+                if (ch != null) {
+                    chapterName = ch.getName();
+                }
+            }
+            map.put("chapterName", chapterName);
             map.put("answer", q.getAnswer());
+            map.put("options", q.getOptions());
             map.put("hasAnalysis", StringUtils.hasText(q.getAnalysis()));
+            map.put("analysis", q.getAnalysis());
             map.put("updateTime", q.getUpdateTime());
             return map;
         }).collect(Collectors.toList());

+ 77 - 16
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizTrackServiceImpl.java

@@ -15,6 +15,8 @@ import com.xuekairui.quiz.mapper.QuizSubjectMapper;
 import com.xuekairui.quiz.mapper.QuizTrackLogMapper;
 import com.xuekairui.quiz.mapper.QuizUserRecordMapper;
 import com.xuekairui.quiz.service.QuizTrackService;
+import com.xuekairui.user.entity.User;
+import com.xuekairui.user.mapper.UserMapper;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.scheduling.annotation.Async;
@@ -37,6 +39,7 @@ public class QuizTrackServiceImpl extends ServiceImpl<QuizTrackLogMapper, QuizTr
     private final QuizQuestionMapper questionMapper;
     private final QuizSubjectMapper subjectMapper;
     private final QuizChapterMapper chapterMapper;
+    private final UserMapper userMapper;
 
     @Override
     @Async
@@ -76,10 +79,10 @@ public class QuizTrackServiceImpl extends ServiceImpl<QuizTrackLogMapper, QuizTr
         long totalUser = records.stream().map(QuizUserRecord::getUserId).distinct().count();
         // 累计做题场次
         long totalAnswerSession = records.size();
-        // 平均正确率
+        // 平均正确率(从 correctCount/totalCount 计算)
         double correctRate = records.stream()
-                .filter(r -> r.getCorrectRate() != null)
-                .mapToDouble(r -> r.getCorrectRate().doubleValue())
+                .filter(r -> r.getTotalCount() != null && r.getTotalCount() > 0 && r.getCorrectCount() != null)
+                .mapToDouble(r -> r.getCorrectCount() * 100.0 / r.getTotalCount())
                 .average()
                 .orElse(0.0);
 
@@ -114,22 +117,39 @@ public class QuizTrackServiceImpl extends ServiceImpl<QuizTrackLogMapper, QuizTr
                 .collect(Collectors.groupingBy(QuizUserRecord::getUserId));
 
         List<Map<String, Object>> userList = new ArrayList<>();
+
+        // 批量查询用户信息
+        Set<Long> allUserIds = grouped.keySet();
+        Map<Long, User> userMap = new HashMap<>();
+        if (!allUserIds.isEmpty()) {
+            List<User> users = userMapper.selectBatchIds(allUserIds);
+            for (User u : users) {
+                userMap.put(u.getId(), u);
+            }
+        }
+
         for (Map.Entry<Long, List<QuizUserRecord>> entry : grouped.entrySet()) {
             Long uid = entry.getKey();
             List<QuizUserRecord> records = entry.getValue();
-            // 如果有关键词过滤(userId匹配)
-            if (StringUtils.hasText(userKeyword) && !String.valueOf(uid).contains(userKeyword)) {
+            User user = userMap.get(uid);
+            String nickname = user != null && user.getNickname() != null ? user.getNickname() : "用户" + uid;
+            String phone = user != null && user.getPhone() != null ? user.getPhone() : "";
+            // 如果有关键词过滤(userId/昵称/手机号匹配)
+            if (StringUtils.hasText(userKeyword)
+                    && !String.valueOf(uid).contains(userKeyword)
+                    && !nickname.contains(userKeyword)
+                    && !phone.contains(userKeyword)) {
                 continue;
             }
-            Map<String, Object> user = new LinkedHashMap<>();
-            user.put("userId", uid);
-            user.put("nickname", "用户" + uid); // TODO: 关联用户表获取昵称
-            user.put("phone", ""); // TODO: 关联用户表获取手机号
-            user.put("registerTime", ""); // TODO: 关联用户表
-            user.put("firstUseTime", records.stream().map(QuizUserRecord::getCreateTime).min(LocalDateTime::compareTo).orElse(null));
-            user.put("totalAnswerCount", records.stream().mapToInt(r -> r.getTotalCount() != null ? r.getTotalCount() : 0).sum());
-            user.put("lastAnswerTime", records.stream().map(QuizUserRecord::getCreateTime).max(LocalDateTime::compareTo).orElse(null));
-            userList.add(user);
+            Map<String, Object> userItem = new LinkedHashMap<>();
+            userItem.put("userId", uid);
+            userItem.put("nickname", nickname);
+            userItem.put("phone", phone);
+            userItem.put("registerTime", user != null ? user.getCreateTime() : "");
+            userItem.put("firstUseTime", records.stream().map(QuizUserRecord::getCreateTime).min(LocalDateTime::compareTo).orElse(null));
+            userItem.put("totalAnswerCount", records.stream().mapToInt(r -> r.getTotalCount() != null ? r.getTotalCount() : 0).sum());
+            userItem.put("lastAnswerTime", records.stream().map(QuizUserRecord::getCreateTime).max(LocalDateTime::compareTo).orElse(null));
+            userList.add(userItem);
         }
 
         // 按最近做题时间倒序
@@ -172,16 +192,46 @@ public class QuizTrackServiceImpl extends ServiceImpl<QuizTrackLogMapper, QuizTr
         wrapper.last("LIMIT " + (pageNo - 1) * pageSize + "," + pageSize);
         List<QuizUserRecord> records = userRecordMapper.selectList(wrapper);
 
+        // 批量查询用户信息
+        Set<Long> userIds = records.stream().map(QuizUserRecord::getUserId).collect(Collectors.toSet());
+        Map<Long, User> userMap = new HashMap<>();
+        if (!userIds.isEmpty()) {
+            List<User> users = userMapper.selectBatchIds(userIds);
+            for (User u : users) {
+                userMap.put(u.getId(), u);
+            }
+        }
+
         List<Map<String, Object>> list = new ArrayList<>();
         for (QuizUserRecord r : records) {
             Map<String, Object> map = new LinkedHashMap<>();
+            // 用户信息
+            User user = userMap.get(r.getUserId());
             map.put("userId", r.getUserId());
+            map.put("nickname", user != null ? (user.getNickname() != null ? user.getNickname() : "") : "");
+            map.put("phone", user != null ? (user.getPhone() != null ? user.getPhone() : "") : "");
+            // 答题类型
             map.put("answerType", r.getAnswerType());
+            map.put("answerTypeName", getAnswerTypeName(r.getAnswerType()));
+            // 科目
             map.put("subjectId", r.getSubjectId());
+            QuizSubject subject = r.getSubjectId() != null ? subjectMapper.selectById(r.getSubjectId()) : null;
+            map.put("subjectName", subject != null ? subject.getName() : "");
+            // 考点
+            map.put("examPointId", r.getExamPointId());
+            QuizChapter examPoint = r.getExamPointId() != null ? chapterMapper.selectById(r.getExamPointId()) : null;
+            map.put("examPointName", examPoint != null ? examPoint.getName() : "");
+            // 做题统计
             map.put("totalCount", r.getTotalCount());
             map.put("correctCount", r.getCorrectCount());
-            map.put("correctRate", r.getCorrectRate());
+            map.put("wrongCount", r.getWrongCount());
+            int recordTotal = r.getTotalCount() != null ? r.getTotalCount() : 0;
+            int correct = r.getCorrectCount() != null ? r.getCorrectCount() : 0;
+            map.put("correctRate", recordTotal > 0 ? Math.round(correct * 10000.0 / recordTotal) / 100.0 : 0);
+            // 得分(每题1分,正确得分 = correctCount)
+            map.put("score", correct);
             map.put("status", r.getStatus());
+            map.put("statusName", r.getStatus() != null && r.getStatus() == 1 ? "已完成" : "未完成");
             map.put("createTime", r.getCreateTime());
             list.add(map);
         }
@@ -243,7 +293,7 @@ public class QuizTrackServiceImpl extends ServiceImpl<QuizTrackLogMapper, QuizTr
             if (question == null) continue;
 
             QuizSubject subject = subjectMapper.selectById(question.getSubjectId());
-            QuizChapter chapter = chapterMapper.selectById(question.getChapterId());
+            QuizChapter chapter = chapterMapper.selectById(question.getExamPointId());
 
             Map<String, Object> item = new LinkedHashMap<>();
             item.put("questionId", qid);
@@ -393,6 +443,17 @@ public class QuizTrackServiceImpl extends ServiceImpl<QuizTrackLogMapper, QuizTr
         };
     }
 
+    private String getAnswerTypeName(Integer answerType) {
+        if (answerType == null) return "";
+        return switch (answerType) {
+            case 1 -> "章节练习";
+            case 2 -> "每日一练";
+            case 3 -> "真题模考";
+            case 99 -> "收藏";
+            default -> "未知(" + answerType + ")";
+        };
+    }
+
     private String getPageName(String page) {
         if (page == null) return "";
         if (page.contains("chapter")) return "章节练习";

+ 20 - 2
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/RankServiceImpl.java

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.xuekairui.quiz.entity.QuizUserRecord;
 import com.xuekairui.quiz.mapper.QuizUserRecordMapper;
 import com.xuekairui.quiz.service.RankService;
+import com.xuekairui.quiz.util.QuizDedupUtils;
 import com.xuekairui.quiz.vo.MyRankVO;
 import com.xuekairui.quiz.vo.RankListVO;
 import lombok.RequiredArgsConstructor;
@@ -138,8 +139,25 @@ public class RankServiceImpl implements RankService {
             Long uid = entry.getKey();
             List<QuizUserRecord> userRecords = entry.getValue();
 
-            int totalCount = userRecords.stream().mapToInt(r -> r.getTotalCount() != null ? r.getTotalCount() : 0).sum();
-            int correctCount = userRecords.stream().mapToInt(r -> r.getCorrectCount() != null ? r.getCorrectCount() : 0).sum();
+            // 按时间倒序排列,保证去重时取到最新一次答题状态
+            List<QuizUserRecord> sortedRecords = userRecords.stream()
+                    .sorted((a, b) -> {
+                        LocalDateTime ta = a.getCreateTime();
+                        LocalDateTime tb = b.getCreateTime();
+                        if (ta == null && tb == null) return 0;
+                        if (ta == null) return 1;
+                        if (tb == null) return -1;
+                        return tb.compareTo(ta);
+                    })
+                    .collect(Collectors.toList());
+
+            // 按题目去重:同一道题只算一次,以最新状态为准
+            Map<Long, Integer> latestAnswerMap = QuizDedupUtils.extractLatestAnswerMap(sortedRecords);
+            int totalCount = latestAnswerMap.size();
+            int correctCount = 0;
+            for (Integer isCorrect : latestAnswerMap.values()) {
+                if (isCorrect != null && isCorrect == 1) correctCount++;
+            }
             BigDecimal correctRate = totalCount > 0
                     ? BigDecimal.valueOf(correctCount * 100.0 / totalCount).setScale(0, RoundingMode.HALF_UP)
                     : BigDecimal.ZERO;

+ 47 - 44
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/RealExamServiceImpl.java

@@ -16,7 +16,7 @@ import com.xuekairui.quiz.vo.ExamRecordVO;
 import com.xuekairui.quiz.vo.ExamYearVO;
 import com.xuekairui.quiz.vo.PageVO;
 import com.xuekairui.quiz.vo.RealExamQuestionVO;
-import com.xuekairui.quiz.vo.RealExamSubjectVO;
+import com.xuekairui.quiz.vo.RealExamYearGroupVO;
 import com.xuekairui.quiz.vo.StartExamVO;
 import com.xuekairui.quiz.vo.SubmitExamVO;
 import lombok.RequiredArgsConstructor;
@@ -42,7 +42,7 @@ public class RealExamServiceImpl implements RealExamService {
     private final QuizUserExamRecordMapper examRecordMapper;
 
     @Override
-    public List<RealExamSubjectVO> list(Long userId, Long subjectId) {
+    public List<RealExamYearGroupVO> list(Long userId, Long subjectId) {
         // 获取所有真题,按科目+年份分组
         List<QuizQuestion> questions = questionMapper.selectList(
                 new LambdaQueryWrapper<QuizQuestion>()
@@ -53,11 +53,11 @@ public class RealExamServiceImpl implements RealExamService {
                         .select(QuizQuestion::getYear, QuizQuestion::getSubjectId)
         );
 
-        // 按科目分组,再按年份分组
-        Map<Long, Map<Integer, Long>> subjectYearCount = questions.stream()
+        // 按年份分组,再按科目分组
+        Map<String, Map<Long, Long>> yearSubjectCount = questions.stream()
                 .collect(Collectors.groupingBy(
-                        QuizQuestion::getSubjectId,
-                        Collectors.groupingBy(QuizQuestion::getYear, Collectors.counting())
+                        QuizQuestion::getYear,
+                        Collectors.groupingBy(QuizQuestion::getSubjectId, Collectors.counting())
                 ));
 
         // 获取用户已完成的考试记录(用于hasDone和bestScore)
@@ -76,43 +76,45 @@ public class RealExamServiceImpl implements RealExamService {
             }
         }
 
-        // 构建结果
-        List<RealExamSubjectVO> result = new ArrayList<>();
-        for (Map.Entry<Long, Map<Integer, Long>> entry : subjectYearCount.entrySet()) {
-            Long sid = entry.getKey();
-            QuizSubject subject = subjectMapper.selectById(sid);
-            if (subject == null) continue;
-
-            List<RealExamSubjectVO.RealExamYearVO> years = new ArrayList<>();
-            entry.getValue().entrySet().stream()
-                    .sorted((a, b) -> b.getKey().compareTo(a.getKey()))
-                    .forEach(ye -> {
-                        String key = sid + "_" + ye.getKey();
-                        BigDecimal best = bestScoreMap.get(key);
-                        years.add(RealExamSubjectVO.RealExamYearVO.builder()
-                                .year(ye.getKey())
-                                .questionCount(ye.getValue().intValue())
-                                .hasDone(best != null)
-                                .bestScore(best)
-                                .build());
-                    });
-
-            result.add(RealExamSubjectVO.builder()
-                    .subjectId(sid)
-                    .subjectName(subject.getName())
-                    .years(years)
+        // 构建结果(按年份分组)
+        List<RealExamYearGroupVO> result = new ArrayList<>();
+        for (Map.Entry<String, Map<Long, Long>> entry : yearSubjectCount.entrySet()) {
+            String year = entry.getKey();
+            List<RealExamYearGroupVO.SubjectItem> subjects = new ArrayList<>();
+
+            entry.getValue().forEach((sid, count) -> {
+                QuizSubject subject = subjectMapper.selectById(sid);
+                if (subject == null) return;
+
+                String key = sid + "_" + year;
+                BigDecimal best = bestScoreMap.get(key);
+                subjects.add(RealExamYearGroupVO.SubjectItem.builder()
+                        .subjectId(sid)
+                        .subjectName(subject.getName())
+                        .questionCount(count.intValue())
+                        .hasDone(best != null)
+                        .bestScore(best)
+                        .build());
+            });
+
+            // 按科目sort排序
+            subjects.sort((a, b) -> {
+                QuizSubject sa = subjectMapper.selectById(a.getSubjectId());
+                QuizSubject sb = subjectMapper.selectById(b.getSubjectId());
+                return Integer.compare(
+                        sa != null ? sa.getSort() : 999,
+                        sb != null ? sb.getSort() : 999
+                );
+            });
+
+            result.add(RealExamYearGroupVO.builder()
+                    .year(year)
+                    .subjects(subjects)
                     .build());
         }
 
-        // 按科目sort排序
-        result.sort((a, b) -> {
-            QuizSubject sa = subjectMapper.selectById(a.getSubjectId());
-            QuizSubject sb = subjectMapper.selectById(b.getSubjectId());
-            return Integer.compare(
-                    sa != null ? sa.getSort() : 999,
-                    sb != null ? sb.getSort() : 999
-            );
-        });
+        // 按年份倒序
+        result.sort((a, b) -> b.getYear().compareTo(a.getYear()));
 
         return result;
     }
@@ -128,7 +130,7 @@ public class RealExamServiceImpl implements RealExamService {
         );
 
         // 按年份分组统计题目数量
-        Map<Integer, Long> yearCountMap = questions.stream()
+        Map<String, Long> yearCountMap = questions.stream()
                 .collect(Collectors.groupingBy(QuizQuestion::getYear, Collectors.counting()));
 
         List<ExamYearVO> result = new ArrayList<>();
@@ -143,7 +145,7 @@ public class RealExamServiceImpl implements RealExamService {
 
     @Override
     @Transactional
-    public StartExamVO startExam(Long userId, Integer year, Long subjectId) {
+    public StartExamVO startExam(Long userId, String year, Long subjectId) {
         // 检查是否已有未交卷的记录
         long inProgress = examRecordMapper.selectCount(
                 new LambdaQueryWrapper<QuizUserExamRecord>()
@@ -288,7 +290,8 @@ public class RealExamServiceImpl implements RealExamService {
             QuizQuestion question = questionMapper.selectById(questionId);
             if (question == null) continue;
 
-            boolean isCorrect = question.getAnswer().equalsIgnoreCase(userAnswer);
+            String correctAnswer = question.getAnswer();
+            boolean isCorrect = correctAnswer != null && correctAnswer.equalsIgnoreCase(userAnswer);
             if (isCorrect) correctCount++;
             else wrongCount++;
 
@@ -296,7 +299,7 @@ public class RealExamServiceImpl implements RealExamService {
             answerRecord.put("questionId", questionId);
             answerRecord.put("userAnswer", userAnswer);
             answerRecord.put("isCorrect", isCorrect ? 1 : 0);
-            answerRecord.put("correctAnswer", question.getAnswer());
+            answerRecord.put("correctAnswer", correctAnswer);
             answerRecord.put("analysis", question.getAnalysis());
             answerRecord.put("timeSpent", timeSpent);
             answerRecords.add(answerRecord);

+ 294 - 47
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/StudyBookServiceImpl.java

@@ -25,6 +25,7 @@ import com.xuekairui.quiz.vo.StudyBookQuestionVO;
 import com.xuekairui.quiz.vo.StudyOverviewVO;
 import com.xuekairui.quiz.vo.StudyQuestionVO;
 import com.xuekairui.quiz.vo.SubjectSimpleVO;
+import com.xuekairui.quiz.util.QuizDedupUtils;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -57,12 +58,15 @@ public class StudyBookServiceImpl implements StudyBookService {
                         .orderByAsc(QuizSubject::getSort)
         );
 
+        // 实时查询各科目题目数
+        Map<Long, Integer> subjectCountMap = getSubjectQuestionCounts();
+
         List<SubjectSimpleVO> result = new ArrayList<>();
         for (QuizSubject s : subjects) {
             result.add(SubjectSimpleVO.builder()
                     .id(s.getId())
                     .name(s.getName())
-                    .questionCount(s.getQuestionCount())
+                    .questionCount(subjectCountMap.getOrDefault(s.getId(), 0))
                     .build());
         }
         return result;
@@ -81,7 +85,10 @@ public class StudyBookServiceImpl implements StudyBookService {
                         .orderByAsc(QuizChapter::getSort)
         );
 
-        List<ChapterNodeVO> tree = buildChapterTree(chapters, 0L);
+        // 实时查询各考点题目数
+        Map<Long, Integer> directCountMap = getDirectQuestionCounts();
+
+        List<ChapterNodeVO> tree = buildChapterTree(chapters, 0L, directCountMap);
 
         return ChapterTreeVO.builder()
                 .subjectId(subject.getId())
@@ -90,16 +97,21 @@ public class StudyBookServiceImpl implements StudyBookService {
                 .build();
     }
 
-    private List<ChapterNodeVO> buildChapterTree(List<QuizChapter> allChapters, Long parentId) {
+    private List<ChapterNodeVO> buildChapterTree(List<QuizChapter> allChapters, Long parentId, Map<Long, Integer> directCountMap) {
         List<ChapterNodeVO> tree = new ArrayList<>();
         for (QuizChapter ch : allChapters) {
             if (Objects.equals(ch.getParentId(), parentId)) {
-                List<ChapterNodeVO> children = buildChapterTree(allChapters, ch.getId());
+                List<ChapterNodeVO> children = buildChapterTree(allChapters, ch.getId(), directCountMap);
+                // 递归汇总:自身直接关联的题目数 + 所有子节点的题目数
+                int count = directCountMap.getOrDefault(ch.getId(), 0);
+                for (ChapterNodeVO child : children) {
+                    count += child.getQuestionCount();
+                }
                 tree.add(ChapterNodeVO.builder()
                         .id(ch.getId())
                         .name(ch.getName())
                         .level(ch.getLevel())
-                        .questionCount(ch.getQuestionCount())
+                        .questionCount(count)
                         .children(children.isEmpty() ? null : children)
                         .build());
             }
@@ -108,8 +120,8 @@ public class StudyBookServiceImpl implements StudyBookService {
     }
 
     @Override
-    public ChapterQuestionsVO getChapterQuestions(Long userId, Long chapterId) {
-        QuizChapter chapter = chapterMapper.selectById(chapterId);
+    public ChapterQuestionsVO getChapterQuestions(Long userId, Long examPointId) {
+        QuizChapter chapter = chapterMapper.selectById(examPointId);
         if (chapter == null) {
             throw new BusinessException(ErrorCode.QUIZ_CHAPTER_NOT_FOUND);
         }
@@ -117,7 +129,7 @@ public class StudyBookServiceImpl implements StudyBookService {
         // 获取该考点下的练习题(非真题)
         List<QuizQuestion> questions = questionMapper.selectList(
                 new LambdaQueryWrapper<QuizQuestion>()
-                        .eq(QuizQuestion::getChapterId, chapterId)
+                        .eq(QuizQuestion::getExamPointId, examPointId)
                         .eq(QuizQuestion::getIsReal, 0)
                         .orderByAsc(QuizQuestion::getId)
         );
@@ -126,7 +138,7 @@ public class StudyBookServiceImpl implements StudyBookService {
         long doneCount = userRecordMapper.selectCount(
                 new LambdaQueryWrapper<QuizUserRecord>()
                         .eq(QuizUserRecord::getUserId, userId)
-                        .eq(QuizUserRecord::getChapterId, chapterId)
+                        .eq(QuizUserRecord::getExamPointId, examPointId)
                         .eq(QuizUserRecord::getAnswerType, 1)
                         .eq(QuizUserRecord::getStatus, 1)
         );
@@ -143,8 +155,8 @@ public class StudyBookServiceImpl implements StudyBookService {
         }
 
         return ChapterQuestionsVO.builder()
-                .chapterId(chapterId)
-                .chapterName(chapter.getName())
+                .examPointId(examPointId)
+                .examPointName(chapter.getName())
                 .questionCount(questionList.size())
                 .hasDone(doneCount > 0)
                 .questions(questionList)
@@ -153,11 +165,12 @@ public class StudyBookServiceImpl implements StudyBookService {
 
     @Override
     @Transactional
-    public PracticeSubmitVO submitChapterPractice(Long userId, Long subjectId, Long chapterId, List<AnswerItem> answers) {
+    public PracticeSubmitVO submitChapterPractice(Long userId, Long subjectId, Long examPointId, List<AnswerItem> answers) {
         int correctCount = 0;
         int wrongCount = 0;
         List<PracticeResultVO> results = new ArrayList<>();
         List<Map<String, Object>> answerRecords = new ArrayList<>();
+        Long resolvedSubjectId = subjectId;
 
         for (AnswerItem ans : answers) {
             Long questionId = ans.getQuestionId();
@@ -167,14 +180,22 @@ public class StudyBookServiceImpl implements StudyBookService {
             QuizQuestion question = questionMapper.selectById(questionId);
             if (question == null) continue;
 
-            boolean isCorrect = question.getAnswer().equalsIgnoreCase(userAnswer);
+            // subjectId 为空时从题目获取(subject_id 列为 NOT NULL)
+            if (resolvedSubjectId == null) {
+                resolvedSubjectId = question.getSubjectId();
+            }
+
+            String correctAnswer = question.getAnswer();
+            // 去除首尾空格后比较,避免前端传入的答案含多余空格导致误判
+            boolean isCorrect = correctAnswer != null && userAnswer != null
+                    && correctAnswer.trim().equalsIgnoreCase(userAnswer.trim());
             if (isCorrect) correctCount++;
             else wrongCount++;
 
             results.add(PracticeResultVO.builder()
                     .questionId(questionId)
                     .isCorrect(isCorrect ? 1 : 0)
-                    .correctAnswer(question.getAnswer())
+                    .correctAnswer(correctAnswer)
                     .analysis(question.getAnalysis())
                     .build());
 
@@ -191,21 +212,96 @@ public class StudyBookServiceImpl implements StudyBookService {
                 ? BigDecimal.valueOf(correctCount * 100.0 / total).setScale(0, RoundingMode.HALF_UP)
                 : BigDecimal.ZERO;
 
-        QuizUserRecord record = QuizUserRecord.builder()
-                .userId(userId)
-                .subjectId(subjectId)
-                .chapterId(chapterId)
-                .answerType(1)
-                .totalCount(total)
+        if (total > 0) {
+            QuizUserRecord record = QuizUserRecord.builder()
+                    .userId(userId)
+                    .subjectId(resolvedSubjectId)
+                    .examPointId(examPointId)
+                    .answerType(1)
+                    .totalCount(total)
+                    .correctCount(correctCount)
+                    .wrongCount(wrongCount)
+                    .answers(answerRecords)
+                    .status(1)
+                    .startTime(LocalDateTime.now())
+                    .endTime(LocalDateTime.now())
+                    .build();
+            userRecordMapper.insert(record);
+        }
+
+        return PracticeSubmitVO.builder()
                 .correctCount(correctCount)
                 .wrongCount(wrongCount)
                 .correctRate(correctRate)
-                .answers(answerRecords)
-                .status(1)
-                .startTime(LocalDateTime.now())
-                .endTime(LocalDateTime.now())
+                .results(results)
                 .build();
-        userRecordMapper.insert(record);
+    }
+
+    @Override
+    @Transactional
+    public PracticeSubmitVO redoWrong(Long userId, List<AnswerItem> answers) {
+        int correctCount = 0;
+        int wrongCount = 0;
+        List<PracticeResultVO> results = new ArrayList<>();
+        List<Map<String, Object>> answerRecords = new ArrayList<>();
+        Long subjectId = null;
+
+        for (AnswerItem ans : answers) {
+            Long questionId = ans.getQuestionId();
+            String userAnswer = ans.getUserAnswer();
+            Integer timeSpent = ans.getTimeSpent() != null ? ans.getTimeSpent() : 0;
+
+            QuizQuestion question = questionMapper.selectById(questionId);
+            if (question == null) continue;
+
+            // 从第一道题获取科目ID(subject_id 列为 NOT NULL)
+            if (subjectId == null) {
+                subjectId = question.getSubjectId();
+            }
+
+            String correctAnswer = question.getAnswer();
+            // 去除首尾空格后比较,避免前端传入的答案含多余空格导致误判
+            boolean isCorrect = correctAnswer != null && userAnswer != null
+                    && correctAnswer.trim().equalsIgnoreCase(userAnswer.trim());
+            if (isCorrect) correctCount++;
+            else wrongCount++;
+
+            results.add(PracticeResultVO.builder()
+                    .questionId(questionId)
+                    .isCorrect(isCorrect ? 1 : 0)
+                    .correctAnswer(correctAnswer)
+                    .analysis(question.getAnalysis())
+                    .build());
+
+            Map<String, Object> record = new LinkedHashMap<>();
+            record.put("questionId", questionId);
+            record.put("userAnswer", userAnswer);
+            record.put("isCorrect", isCorrect ? 1 : 0);
+            record.put("timeSpent", timeSpent);
+            answerRecords.add(record);
+        }
+
+        int total = correctCount + wrongCount;
+        BigDecimal correctRate = total > 0
+                ? BigDecimal.valueOf(correctCount * 100.0 / total).setScale(0, RoundingMode.HALF_UP)
+                : BigDecimal.ZERO;
+
+        // 只有有答题记录时才插入(subject_id 为 NOT NULL,无题目时无法插入)
+        if (total > 0) {
+            QuizUserRecord record = QuizUserRecord.builder()
+                    .userId(userId)
+                    .subjectId(subjectId)
+                    .answerType(3)
+                    .totalCount(total)
+                    .correctCount(correctCount)
+                    .wrongCount(wrongCount)
+                    .answers(answerRecords)
+                    .status(1)
+                    .startTime(LocalDateTime.now())
+                    .endTime(LocalDateTime.now())
+                    .build();
+            userRecordMapper.insert(record);
+        }
 
         return PracticeSubmitVO.builder()
                 .correctCount(correctCount)
@@ -233,6 +329,9 @@ public class StudyBookServiceImpl implements StudyBookService {
                 })
                 .collect(Collectors.toList());
 
+        // 实时查询各考点题目数
+        Map<Long, Integer> directCountMap = getDirectQuestionCounts();
+
         int completedCount = 0;
         List<ChapterProgressItemVO> progressList = new ArrayList<>();
 
@@ -240,7 +339,7 @@ public class StudyBookServiceImpl implements StudyBookService {
             long doneCount = userRecordMapper.selectCount(
                     new LambdaQueryWrapper<QuizUserRecord>()
                             .eq(QuizUserRecord::getUserId, userId)
-                            .eq(QuizUserRecord::getChapterId, ch.getId())
+                            .eq(QuizUserRecord::getExamPointId, ch.getId())
                             .eq(QuizUserRecord::getAnswerType, 1)
                             .eq(QuizUserRecord::getStatus, 1)
             );
@@ -249,9 +348,9 @@ public class StudyBookServiceImpl implements StudyBookService {
             if (completed) completedCount++;
 
             progressList.add(ChapterProgressItemVO.builder()
-                    .chapterId(ch.getId())
-                    .chapterName(ch.getName())
-                    .questionCount(ch.getQuestionCount())
+                    .examPointId(ch.getId())
+                    .examPointName(ch.getName())
+                    .questionCount(directCountMap.getOrDefault(ch.getId(), 0))
                     .completed(completed)
                     .practiceCount(doneCount)
                     .build());
@@ -272,9 +371,22 @@ public class StudyBookServiceImpl implements StudyBookService {
     }
 
     @Override
-    public StudyOverviewVO getOverview(Long userId) {
+    public StudyOverviewVO getOverview(Long userId, Long subjectId) {
         // 按题去重,以最新一次答题状态统计做对/做错题数
-        Map<Long, Integer> latestIsCorrect = getLatestIsCorrect(userId, null);
+        Map<Long, Integer> latestIsCorrect = getLatestIsCorrect(userId, subjectId);
+
+        // 过滤掉已删除的题目(只统计数据库中仍存在的题目)
+        Set<Long> existingQuestionIds = new LinkedHashSet<>(latestIsCorrect.keySet());
+        if (!existingQuestionIds.isEmpty()) {
+            List<QuizQuestion> existing = questionMapper.selectList(
+                    new LambdaQueryWrapper<QuizQuestion>()
+                            .in(QuizQuestion::getId, existingQuestionIds)
+                            .select(QuizQuestion::getId)
+            );
+            Set<Long> validIds = existing.stream().map(QuizQuestion::getId).collect(Collectors.toSet());
+            latestIsCorrect.keySet().retainAll(validIds);
+        }
+
         int totalCorrect = 0;
         int totalWrong = 0;
         for (Integer isCorrect : latestIsCorrect.values()) {
@@ -287,41 +399,50 @@ public class StudyBookServiceImpl implements StudyBookService {
                 new LambdaQueryWrapper<QuizUserRecord>()
                         .eq(QuizUserRecord::getUserId, userId)
                         .eq(QuizUserRecord::getAnswerType, 99)
+                        .eq(subjectId != null && subjectId != 0L, QuizUserRecord::getSubjectId, subjectId)
         );
 
+        int totalDone = totalCorrect + totalWrong;
+        BigDecimal correctRate = totalDone > 0
+                ? BigDecimal.valueOf(totalCorrect * 100.0 / totalDone).setScale(0, RoundingMode.HALF_UP)
+                : BigDecimal.ZERO;
+
         return StudyOverviewVO.builder()
                 .correctCount(totalCorrect)
                 .wrongCount(totalWrong)
                 .collectedCount((int) collectedCount)
-                .totalDone(totalCorrect + totalWrong)
+                .totalDone(totalDone)
+                .correctRate(correctRate)
                 .build();
     }
 
     /**
      * 获取用户每题最新一次答题状态(做对=1 / 做错=0)。
      * 记录按 createTime 倒序查询,首次出现的题目即为最新状态,后续重复题目跳过。
+     * 按题目实际的 subjectId 过滤(而非 record.subjectId),避免错题重做时多科目混记导致统计错误。
      */
     private Map<Long, Integer> getLatestIsCorrect(Long userId, Long subjectId) {
         List<QuizUserRecord> records = userRecordMapper.selectList(
                 new LambdaQueryWrapper<QuizUserRecord>()
                         .eq(QuizUserRecord::getUserId, userId)
-                        .eq(QuizUserRecord::getAnswerType, 1)
+                        .ne(QuizUserRecord::getAnswerType, 99)
                         .eq(QuizUserRecord::getStatus, 1)
-                        .eq(subjectId != null && subjectId != 0L, QuizUserRecord::getSubjectId, subjectId)
                         .orderByDesc(QuizUserRecord::getCreateTime)
         );
-
-        Map<Long, Integer> latest = new LinkedHashMap<>();
-        for (QuizUserRecord r : records) {
-            if (r.getAnswers() == null) continue;
-            for (Map<String, Object> ans : r.getAnswers()) {
-                if (ans.get("questionId") == null) continue;
-                Long qid = Long.valueOf(ans.get("questionId").toString());
-                if (latest.containsKey(qid)) continue;
-                Integer isCorrect = ans.get("isCorrect") != null ? Integer.valueOf(ans.get("isCorrect").toString()) : null;
-                latest.put(qid, isCorrect);
-            }
+        Map<Long, Integer> latest = QuizDedupUtils.extractLatestAnswerMap(records);
+
+        // 按题目实际的 subjectId 过滤
+        if (subjectId != null && subjectId != 0L && !latest.isEmpty()) {
+            List<QuizQuestion> questions = questionMapper.selectList(
+                    new LambdaQueryWrapper<QuizQuestion>()
+                            .in(QuizQuestion::getId, latest.keySet())
+                            .eq(QuizQuestion::getSubjectId, subjectId)
+                            .select(QuizQuestion::getId)
+            );
+            Set<Long> validIds = questions.stream().map(QuizQuestion::getId).collect(Collectors.toSet());
+            latest.keySet().retainAll(validIds);
         }
+
         return latest;
     }
 
@@ -359,6 +480,18 @@ public class StudyBookServiceImpl implements StudyBookService {
             // 做对/做错的题目:按题去重,以最新一次答题状态为准
             Map<Long, Integer> latestIsCorrect = getLatestIsCorrect(userId, subjectId);
 
+            // 过滤掉已删除的题目(只统计数据库中仍存在的题目)
+            Set<Long> existingQuestionIds = new LinkedHashSet<>(latestIsCorrect.keySet());
+            if (!existingQuestionIds.isEmpty()) {
+                List<QuizQuestion> existing = questionMapper.selectList(
+                        new LambdaQueryWrapper<QuizQuestion>()
+                                .in(QuizQuestion::getId, existingQuestionIds)
+                                .select(QuizQuestion::getId)
+                );
+                Set<Long> validIds = existing.stream().map(QuizQuestion::getId).collect(Collectors.toSet());
+                latestIsCorrect.keySet().retainAll(validIds);
+            }
+
             Set<Long> questionIds = new LinkedHashSet<>();
             for (Map.Entry<Long, Integer> e : latestIsCorrect.entrySet()) {
                 Integer isCorrect = e.getValue();
@@ -376,10 +509,37 @@ public class StudyBookServiceImpl implements StudyBookService {
                     .limit(pageSize)
                     .collect(Collectors.toList());
 
+            // 批量查询用户收藏的题目ID(1次查询代替N次)
+            Set<Long> collectedQuestionIds = new LinkedHashSet<>();
+            Map<Long, LocalDateTime> collectedTimeMap = new LinkedHashMap<>();
+            List<QuizUserRecord> collectRecords = userRecordMapper.selectList(
+                    new LambdaQueryWrapper<QuizUserRecord>()
+                            .eq(QuizUserRecord::getUserId, userId)
+                            .eq(QuizUserRecord::getAnswerType, 99)
+                            .orderByDesc(QuizUserRecord::getCreateTime)
+            );
+            for (QuizUserRecord r : collectRecords) {
+                if (r.getAnswers() != null) {
+                    for (Map<String, Object> ans : r.getAnswers()) {
+                        if (ans.get("questionId") != null) {
+                            Long qid = Long.valueOf(ans.get("questionId").toString());
+                            collectedQuestionIds.add(qid);
+                            if (!collectedTimeMap.containsKey(qid)) {
+                                collectedTimeMap.put(qid, r.getCreateTime());
+                            }
+                        }
+                    }
+                }
+            }
+
             for (Long qId : pagedIds) {
                 QuizQuestion q = questionMapper.selectById(qId);
                 if (q != null) {
-                    questionList.add(buildQuestionItem(q));
+                    StudyBookQuestionVO item = buildQuestionItem(q);
+                    if (collectedQuestionIds.contains(qId)) {
+                        item.setCollectedAt(collectedTimeMap.get(qId));
+                    }
+                    questionList.add(item);
                 }
             }
         }
@@ -391,16 +551,67 @@ public class StudyBookServiceImpl implements StudyBookService {
     }
 
     private StudyBookQuestionVO buildQuestionItem(QuizQuestion q) {
+        String subjectName = null;
+        if (q.getSubjectId() != null) {
+            QuizSubject subject = subjectMapper.selectById(q.getSubjectId());
+            if (subject != null) {
+                subjectName = subject.getName();
+            }
+        }
+        
+        // 配伍题/综合分析题:options 为空时,从 sharedOptions 解析选项
+        List<Map<String, Object>> options = q.getOptions();
+        if ((options == null || options.isEmpty()) && q.getSharedOptions() != null && !q.getSharedOptions().isEmpty()) {
+            options = parseSharedOptions(q.getSharedOptions());
+        }
+        
         return StudyBookQuestionVO.builder()
                 .id(q.getId())
+                .subjectId(q.getSubjectId())
+                .subjectName(subjectName)
                 .questionType(q.getQuestionType())
                 .questionTypeName(getTypeName(q.getQuestionType()))
+                .matchingGroup(q.getMatchingGroup())
+                .sharedOptions(q.getSharedOptions())
                 .stem(q.getStem())
-                .options(q.getOptions())
+                .options(options)
                 .answer(q.getAnswer())
                 .analysis(q.getAnalysis())
                 .build();
     }
+    
+    /**
+     * 解析共用选项文本为结构化选项列表
+     * 格式:A.内容B.内容C.内容...
+     */
+    private List<Map<String, Object>> parseSharedOptions(String sharedOptions) {
+        List<Map<String, Object>> options = new ArrayList<>();
+        if (sharedOptions == null || sharedOptions.isEmpty()) {
+            return options;
+        }
+        
+        // 按字母+点分割:A.xxx B.xxx C.xxx ...
+        String[] parts = sharedOptions.split("(?=[A-E]\\.?)");
+        for (String part : parts) {
+            part = part.trim();
+            if (part.isEmpty()) continue;
+            
+            // 提取选项字母和内容
+            String key = "";
+            String content = part;
+            if (part.length() >= 1) {
+                key = String.valueOf(part.charAt(0));
+                content = part.substring(1).replaceFirst("^\\.?", "").trim();
+            }
+            
+            Map<String, Object> option = new LinkedHashMap<>();
+            option.put("key", key);
+            option.put("content", content);
+            options.add(option);
+        }
+        
+        return options;
+    }
 
     private String getTypeName(Integer type) {
         if (type == null) return "";
@@ -412,4 +623,40 @@ public class StudyBookServiceImpl implements StudyBookService {
             default -> "";
         };
     }
+
+    /**
+     * 实时查询各考点(exam_point_id)直接关联的练习题数(不含真题)
+     */
+    private Map<Long, Integer> getDirectQuestionCounts() {
+        List<QuizQuestion> questions = questionMapper.selectList(
+                new LambdaQueryWrapper<QuizQuestion>()
+                        .eq(QuizQuestion::getIsReal, 0)
+                        .select(QuizQuestion::getExamPointId)
+        );
+        Map<Long, Integer> map = new LinkedHashMap<>();
+        for (QuizQuestion q : questions) {
+            if (q.getExamPointId() != null) {
+                map.merge(q.getExamPointId(), 1, Integer::sum);
+            }
+        }
+        return map;
+    }
+
+    /**
+     * 实时查询各科目练习题数(不含真题)
+     */
+    private Map<Long, Integer> getSubjectQuestionCounts() {
+        List<QuizQuestion> questions = questionMapper.selectList(
+                new LambdaQueryWrapper<QuizQuestion>()
+                        .eq(QuizQuestion::getIsReal, 0)
+                        .select(QuizQuestion::getSubjectId)
+        );
+        Map<Long, Integer> map = new LinkedHashMap<>();
+        for (QuizQuestion q : questions) {
+            if (q.getSubjectId() != null) {
+                map.merge(q.getSubjectId(), 1, Integer::sum);
+            }
+        }
+        return map;
+    }
 }

+ 57 - 21
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/UserQuestionServiceImpl.java

@@ -21,12 +21,13 @@ import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
-import java.math.BigDecimal;
 import java.time.LocalDateTime;
 import java.util.ArrayList;
 import java.util.LinkedHashMap;
+import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
+import java.util.Set;
 
 @Service
 @RequiredArgsConstructor
@@ -37,15 +38,15 @@ public class UserQuestionServiceImpl implements UserQuestionService {
     private final QuizChapterMapper chapterMapper;
 
     @Override
-    public PageVO<QuestionVO> listByChapter(Long chapterId, String order, Long userId, int pageNo, int pageSize) {
+    public PageVO<QuestionVO> listByChapter(Long examPointId, String order, Long userId, int pageNo, int pageSize) {
         // 章节列表只展示到"节",做题时需向下展开收集所有"考点"(level=4)再查询题目
-        List<Long> examPointIds = collectExamPointIds(chapterId);
+        List<Long> examPointIds = collectExamPointIds(examPointId);
         if (examPointIds.isEmpty()) {
             return PageVO.<QuestionVO>builder().total(0L).list(new ArrayList<>()).build();
         }
 
         LambdaQueryWrapper<QuizQuestion> wrapper = new LambdaQueryWrapper<QuizQuestion>()
-                .in(QuizQuestion::getChapterId, examPointIds)
+                .in(QuizQuestion::getExamPointId, examPointIds)
                 .eq(QuizQuestion::getIsReal, 0);
 
         if ("random".equals(order)) {
@@ -67,19 +68,19 @@ public class UserQuestionServiceImpl implements UserQuestionService {
      * - 传入考点(level=4)时返回自身
      * - 传入章(level=2)或节(level=3)时向下递归收集所有考点
      */
-    private List<Long> collectExamPointIds(Long chapterId) {
+    private List<Long> collectExamPointIds(Long examPointId) {
         List<Long> result = new ArrayList<>();
-        if (chapterId == null) return result;
+        if (examPointId == null) return result;
 
-        QuizChapter chapter = chapterMapper.selectById(chapterId);
+        QuizChapter chapter = chapterMapper.selectById(examPointId);
         if (chapter == null) return result;
 
         if (chapter.getLevel() != null && chapter.getLevel() == 4) {
-            result.add(chapterId);
+            result.add(examPointId);
             return result;
         }
 
-        collectDescendantExamPointIds(chapter.getSubjectId(), chapterId, result);
+        collectDescendantExamPointIds(chapter.getSubjectId(), examPointId, result);
         return result;
     }
 
@@ -105,6 +106,8 @@ public class UserQuestionServiceImpl implements UserQuestionService {
      * - 综合分析选择题(4):id + stem + matchGroup + sharedOptions(共用题干)+ 各自的 options
      */
     private List<QuestionVO> buildChapterQuestionList(List<QuizQuestion> questions, Long userId) {
+        // 批量查询用户收藏的题目ID(1次查询代替N次)
+        Set<Long> collectedIds = getCollectedQuestionIds(userId);
         List<QuestionVO> result = new ArrayList<>();
         for (QuizQuestion q : questions) {
             Integer type = q.getQuestionType();
@@ -117,7 +120,7 @@ public class UserQuestionServiceImpl implements UserQuestionService {
                     .questionType(type)
                     .questionTypeName(getTypeName(type))
                     .stem(q.getStem())
-                    .isCollected(isCollected(userId, q.getId()))
+                    .isCollected(collectedIds.contains(q.getId()))
                     .userRecord(getUserRecord(userId, q.getId()));
 
             if (grouped) {
@@ -151,18 +154,18 @@ public class UserQuestionServiceImpl implements UserQuestionService {
             throw new BusinessException(ErrorCode.QUIZ_QUESTION_NOT_FOUND);
         }
 
-        boolean isCorrect = question.getAnswer().equalsIgnoreCase(userAnswer);
+        String correctAnswer = question.getAnswer();
+        boolean isCorrect = correctAnswer != null && correctAnswer.equalsIgnoreCase(userAnswer);
 
         // 保存做题记录
         QuizUserRecord record = QuizUserRecord.builder()
                 .userId(userId)
                 .subjectId(question.getSubjectId())
-                .chapterId(question.getChapterId())
+                .examPointId(question.getExamPointId())
                 .answerType(source != null ? source : 1)
                 .totalCount(1)
                 .correctCount(isCorrect ? 1 : 0)
                 .wrongCount(isCorrect ? 0 : 1)
-                .correctRate(isCorrect ? BigDecimal.valueOf(100) : BigDecimal.ZERO)
                 .status(1)
                 .startTime(LocalDateTime.now())
                 .endTime(LocalDateTime.now())
@@ -181,7 +184,7 @@ public class UserQuestionServiceImpl implements UserQuestionService {
 
         return SubmitAnswerVO.builder()
                 .isCorrect(isCorrect ? 1 : 0)
-                .correctAnswer(question.getAnswer())
+                .correctAnswer(correctAnswer)
                 .analysis(question.getAnalysis())
                 .isCollected(isCollected(userId, questionId))
                 .build();
@@ -190,14 +193,29 @@ public class UserQuestionServiceImpl implements UserQuestionService {
     @Override
     @Transactional
     public ToggleCollectVO toggleCollect(Long userId, Long questionId) {
-        // 使用特殊answerType=99表示收藏
-        QuizUserRecord existing = userRecordMapper.selectOne(
+        // 查询用户所有收藏记录,在内存中匹配(避免JSON_CONTAINS对大数字的类型匹配问题)
+        List<QuizUserRecord> collectRecords = userRecordMapper.selectList(
                 new LambdaQueryWrapper<QuizUserRecord>()
                         .eq(QuizUserRecord::getUserId, userId)
                         .eq(QuizUserRecord::getAnswerType, 99)
-                        .apply("JSON_CONTAINS(answers, JSON_OBJECT('questionId', " + questionId + "))")
         );
 
+        QuizUserRecord existing = null;
+        for (QuizUserRecord r : collectRecords) {
+            if (r.getAnswers() != null) {
+                for (Map<String, Object> ans : r.getAnswers()) {
+                    if (ans.get("questionId") != null) {
+                        Long qid = Long.valueOf(ans.get("questionId").toString());
+                        if (qid.equals(questionId)) {
+                            existing = r;
+                            break;
+                        }
+                    }
+                }
+                if (existing != null) break;
+            }
+        }
+
         boolean isCollected;
         if (existing != null) {
             // 取消收藏
@@ -212,7 +230,7 @@ public class UserQuestionServiceImpl implements UserQuestionService {
             QuizUserRecord collect = QuizUserRecord.builder()
                     .userId(userId)
                     .subjectId(question.getSubjectId())
-                    .chapterId(question.getChapterId())
+                    .examPointId(question.getExamPointId())
                     .answerType(99)
                     .totalCount(1)
                     .status(1)
@@ -254,12 +272,30 @@ public class UserQuestionServiceImpl implements UserQuestionService {
 
     private boolean isCollected(Long userId, Long questionId) {
         if (userId == null || questionId == null) return false;
-        return userRecordMapper.selectCount(
+        return getCollectedQuestionIds(userId).contains(questionId);
+    }
+
+    /**
+     * 批量查询用户收藏的题目ID(避免JSON_CONTAINS对大数字的类型匹配问题)
+     */
+    private Set<Long> getCollectedQuestionIds(Long userId) {
+        if (userId == null) return new LinkedHashSet<>();
+        List<QuizUserRecord> collectRecords = userRecordMapper.selectList(
                 new LambdaQueryWrapper<QuizUserRecord>()
                         .eq(QuizUserRecord::getUserId, userId)
                         .eq(QuizUserRecord::getAnswerType, 99)
-                        .apply("JSON_CONTAINS(answers, JSON_OBJECT('questionId', " + questionId + "))")
-        ) > 0;
+        );
+        Set<Long> ids = new LinkedHashSet<>();
+        for (QuizUserRecord r : collectRecords) {
+            if (r.getAnswers() != null) {
+                for (Map<String, Object> ans : r.getAnswers()) {
+                    if (ans.get("questionId") != null) {
+                        ids.add(Long.valueOf(ans.get("questionId").toString()));
+                    }
+                }
+            }
+        }
+        return ids;
     }
 
     private UserRecordVO getUserRecord(Long userId, Long questionId) {

+ 76 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/util/QuizDedupUtils.java

@@ -0,0 +1,76 @@
+package com.xuekairui.quiz.util;
+
+import com.xuekairui.quiz.entity.QuizUserRecord;
+
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 题目去重统一工具类。
+ * <p>
+ * 覆盖两类去重:
+ * 1. 内容去重(导入时判断同一道题是否已存在):{@link #buildQuestionFingerprint}
+ * 2. 记录去重(统计答题正确数量时同一道题只算一次):{@link #extractLatestAnswerMap}
+ */
+public final class QuizDedupUtils {
+
+    private QuizDedupUtils() {
+    }
+
+    /**
+     * 题干归一化:去除 HTML 标签、统一空白字符。
+     * 保证"同一道题"在不同来源(Excel、富文本编辑)下判定一致。
+     */
+    public static String normalizeStem(String stem) {
+        if (stem == null) {
+            return "";
+        }
+        return stem.replaceAll("<[^>]+>", "")
+                .replace('\u00A0', ' ')
+                .replaceAll("[\\t\\n\\r ]+", " ")
+                .trim();
+    }
+
+    /**
+     * 构建题目内容指纹,用于导入去重。
+     * 练习题(isReal=0)与真题(isReal=1)分开去重;真题再按年份区分,避免同题跨年份被误判为重复。
+     */
+    public static String buildQuestionFingerprint(Long subjectId, Integer questionType,
+                                                  Integer isReal, String year, String stem) {
+        return subjectId + "|" + questionType + "|" + isReal
+                + "|" + (year != null ? year : "")
+                + "|" + normalizeStem(stem);
+    }
+
+    /**
+     * 从做题记录中按题目ID去重,提取每题最新一次答题状态(isCorrect:1=对 / 0=错 / null=未知)。
+     * <p>
+     * 要求 records 已按时间倒序排列,首次出现的题目即为最新状态,后续重复题目跳过。
+     */
+    public static Map<Long, Integer> extractLatestAnswerMap(List<QuizUserRecord> records) {
+        Map<Long, Integer> latest = new LinkedHashMap<>();
+        if (records == null) {
+            return latest;
+        }
+        for (QuizUserRecord r : records) {
+            if (r.getAnswers() == null) {
+                continue;
+            }
+            for (Map<String, Object> ans : r.getAnswers()) {
+                Object qidObj = ans.get("questionId");
+                if (qidObj == null) {
+                    continue;
+                }
+                Long qid = Long.valueOf(qidObj.toString());
+                if (latest.containsKey(qid)) {
+                    continue;
+                }
+                Object correctObj = ans.get("isCorrect");
+                Integer isCorrect = correctObj != null ? Integer.valueOf(correctObj.toString()) : null;
+                latest.put(qid, isCorrect);
+            }
+        }
+        return latest;
+    }
+}

+ 20 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ChapterListVO.java

@@ -0,0 +1,20 @@
+package com.xuekairui.quiz.vo;
+
+import lombok.*;
+import java.time.LocalDateTime;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+public class ChapterListVO {
+    private Long id;
+    private Long subjectId;
+    private Long parentId;
+    private String name;
+    private Integer level;
+    private Integer sort;
+    private Integer questionCount;
+    private LocalDateTime createTime;
+    private LocalDateTime updateTime;
+}

+ 2 - 2
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ChapterProgressItemVO.java

@@ -15,8 +15,8 @@ import lombok.NoArgsConstructor;
 @AllArgsConstructor
 @JsonInclude(JsonInclude.Include.NON_NULL)
 public class ChapterProgressItemVO {
-    private Long chapterId;
-    private String chapterName;
+    private Long examPointId;
+    private String examPointName;
     private Integer questionCount;
     private Boolean completed;
     private Long practiceCount;

+ 2 - 2
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ChapterQuestionsVO.java

@@ -17,8 +17,8 @@ import java.util.List;
 @AllArgsConstructor
 @JsonInclude(JsonInclude.Include.NON_NULL)
 public class ChapterQuestionsVO {
-    private Long chapterId;
-    private String chapterName;
+    private Long examPointId;
+    private String examPointName;
     private Integer questionCount;
     private Boolean hasDone;
     private List<StudyQuestionVO> questions;

+ 1 - 1
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ExamDetailVO.java

@@ -21,7 +21,7 @@ import java.util.Map;
 @JsonInclude(JsonInclude.Include.NON_NULL)
 public class ExamDetailVO {
     private Long recordId;
-    private Integer year;
+    private String year;
     private BigDecimal totalScore;
     private Integer correctCount;
     private Integer wrongCount;

+ 1 - 1
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ExamRecordVO.java

@@ -19,7 +19,7 @@ import java.time.LocalDateTime;
 @JsonInclude(JsonInclude.Include.NON_NULL)
 public class ExamRecordVO {
     private Long recordId;
-    private Integer year;
+    private String year;
     private BigDecimal totalScore;
     private Integer correctCount;
     private Integer wrongCount;

+ 1 - 1
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ExamYearVO.java

@@ -15,6 +15,6 @@ import lombok.NoArgsConstructor;
 @AllArgsConstructor
 @JsonInclude(JsonInclude.Include.NON_NULL)
 public class ExamYearVO {
-    private Integer year;
+    private String year;
     private Integer questionCount;
 }

+ 1 - 1
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/HomeIndexVO.java

@@ -64,7 +64,7 @@ public class HomeIndexVO {
     @AllArgsConstructor
     @JsonInclude(JsonInclude.Include.NON_NULL)
     public static class LatestExamPaperVO {
-        private Integer year;
+        private String year;
         private Long subjectId;
         private String subjectName;
         private Long questionCount;

+ 1 - 1
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RealExamSubjectVO.java

@@ -28,7 +28,7 @@ public class RealExamSubjectVO {
     @AllArgsConstructor
     @JsonInclude(JsonInclude.Include.NON_NULL)
     public static class RealExamYearVO {
-        private Integer year;
+        private String year;
         private Integer questionCount;
         private Boolean hasDone;
         private BigDecimal bestScore;

+ 36 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RealExamYearGroupVO.java

@@ -0,0 +1,36 @@
+package com.xuekairui.quiz.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+/**
+ * 真题年份+科目分组列表(按年份分组)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class RealExamYearGroupVO {
+    private String year;
+    private List<SubjectItem> subjects;
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class SubjectItem {
+        private Long subjectId;
+        private String subjectName;
+        private Integer questionCount;
+        private Boolean hasDone;
+        private BigDecimal bestScore;
+    }
+}

+ 1 - 1
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/StartExamVO.java

@@ -19,7 +19,7 @@ import java.util.List;
 @JsonInclude(JsonInclude.Include.NON_NULL)
 public class StartExamVO {
     private Long recordId;
-    private Integer year;
+    private String year;
     private Integer questionCount;
     private LocalDateTime startTime;
     private Integer status;

+ 4 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/StudyBookQuestionVO.java

@@ -20,8 +20,12 @@ import java.util.Map;
 @JsonInclude(JsonInclude.Include.NON_NULL)
 public class StudyBookQuestionVO {
     private Long id;
+    private Long subjectId;
+    private String subjectName;
     private Integer questionType;
     private String questionTypeName;
+    private String matchingGroup;
+    private String sharedOptions;
     private String stem;
     private List<Map<String, Object>> options;
     private String answer;

+ 3 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/StudyOverviewVO.java

@@ -6,6 +6,8 @@ import lombok.Builder;
 import lombok.Data;
 import lombok.NoArgsConstructor;
 
+import java.math.BigDecimal;
+
 /**
  * 学习本统计概览
  */
@@ -19,4 +21,5 @@ public class StudyOverviewVO {
     private Integer wrongCount;
     private Integer collectedCount;
     private Integer totalDone;
+    private BigDecimal correctRate;
 }

+ 1 - 1
zhijiayun-user/src/main/java/com/xuekairui/user/config/DevCorsConfig.java

@@ -16,7 +16,7 @@ import java.util.List;
  * 生产环境 CORS 由 Nginx 统一处理,此配置不会介入。
  */
 @Configuration
-@Profile("dev")
+@Profile({"dev", "local"})
 public class DevCorsConfig {
 
     @Bean

+ 28 - 3
zhijiayun-user/src/main/java/com/xuekairui/user/config/SecurityConfig.java

@@ -4,7 +4,9 @@ import com.fasterxml.jackson.databind.ObjectMapper;
 import com.xuekairui.common.ErrorCode;
 import com.xuekairui.common.Result;
 import com.xuekairui.user.security.JwtAuthenticationFilter;
+import com.xuekairui.user.security.MockAuthFilter;
 import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.core.env.Environment;
@@ -32,6 +34,19 @@ public class SecurityConfig {
     private final JwtAuthenticationFilter jwtAuthenticationFilter;
     private final Environment environment;
 
+    /** 是否启用本地 Mock 用户鉴权(仅本地联调用,生产关闭) */
+    @Value("${security.mock.enabled:false}")
+    private boolean mockEnabled;
+
+    @Value("${security.mock.user-id:0}")
+    private Long mockUserId;
+
+    @Value("${security.mock.role:USER}")
+    private String mockRole;
+
+    @Value("${security.mock.login-source:MINIAPP}")
+    private String mockLoginSource;
+
     /** 公开接口白名单 */
     private static final String[] PUBLIC_URLS = {
             "/api/auth/sms/send",
@@ -69,9 +84,9 @@ public class SecurityConfig {
 
     @Bean
     public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
-        // dev 环境启用后端 CORS 兜底(DevCorsConfig 提供配置源),生产环境由 Nginx 统一处理
-        boolean isDev = environment.acceptsProfiles(org.springframework.core.env.Profiles.of("dev"));
-        if (isDev) {
+        // dev/local 环境启用后端 CORS 兜底(DevCorsConfig 提供配置源),生产环境由 Nginx 统一处理
+        boolean isDevOrLocal = environment.acceptsProfiles(org.springframework.core.env.Profiles.of("dev", "local"));
+        if (isDevOrLocal) {
             http.cors(cors -> {});  // 使用默认,自动发现 CorsConfigurationSource Bean
         } else {
             http.cors(AbstractHttpConfigurer::disable);
@@ -118,6 +133,16 @@ public class SecurityConfig {
                 .addFilterBefore(jwtAuthenticationFilter,
                         UsernamePasswordAuthenticationFilter.class);
 
+        // 本地联调:对做题接口注入固定 mock 用户,绕过 token 校验(生产不启用)
+        // 双重保险:即使配置里误开了 security.mock.enabled=true,prod 环境也强制关闭
+        boolean isProd = environment.acceptsProfiles(org.springframework.core.env.Profiles.of("prod"));
+        if (mockEnabled && !isProd) {
+            http.addFilterAfter(
+                    new MockAuthFilter(mockUserId, mockRole, mockLoginSource),
+                    JwtAuthenticationFilter.class
+            );
+        }
+
         return http.build();
     }
 

+ 74 - 0
zhijiayun-user/src/main/java/com/xuekairui/user/security/MockAuthFilter.java

@@ -0,0 +1,74 @@
+package com.xuekairui.user.security;
+
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.GrantedAuthority;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * 本地联调用 Mock 用户过滤器(仅 dev/local 环境注册)
+ * <p>
+ * 移动端前端拿着生产 token 调用本地接口时,由于生产 JWT 密钥与本地不一致,token 无法解析,
+ * 会导致鉴权失败返回 401。此过滤器对做题相关接口(/api/quiz/**)在"无 token 或 token 无法解析"时
+ * 注入固定测试用户,绕过 token 校验。
+ * <p>
+ * PC 端使用本地有效 token,JWT 过滤器已成功认证,此过滤器检测到已有认证信息后直接放行,
+ * 不覆盖真实身份。
+ * <p>
+ * 生产环境不注册(security.mock.enabled=false,默认关闭)。
+ */
+public class MockAuthFilter extends OncePerRequestFilter {
+
+    private final Long mockUserId;
+    private final String mockRole;
+    private final String mockLoginSource;
+
+    public MockAuthFilter(Long mockUserId, String mockRole, String mockLoginSource) {
+        this.mockUserId = mockUserId;
+        this.mockRole = mockRole;
+        this.mockLoginSource = mockLoginSource;
+    }
+
+    @Override
+    protected boolean shouldNotFilter(HttpServletRequest request) {
+        // 仅对做题相关接口生效
+        return !request.getRequestURI().startsWith("/api/quiz/");
+    }
+
+    @Override
+    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+            throws ServletException, IOException {
+        // PC 端等使用本地有效 token 的请求,前面的 JWT 过滤器已成功认证,直接放行,不覆盖真实身份
+        if (SecurityContextHolder.getContext().getAuthentication() != null) {
+            filterChain.doFilter(request, response);
+            return;
+        }
+
+        // 移动端生产 token(本地无法解析)或未携带 token 时,注入固定测试用户
+        List<GrantedAuthority> authorities = Collections.singletonList(
+                new SimpleGrantedAuthority("ROLE_" + mockRole)
+        );
+
+        UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
+                mockUserId, null, authorities
+        );
+        authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
+        SecurityContextHolder.getContext().setAuthentication(authentication);
+
+        request.setAttribute("userId", mockUserId);
+        request.setAttribute("role", mockRole);
+        request.setAttribute("loginSource", mockLoginSource);
+
+        filterChain.doFilter(request, response);
+    }
+}