Przeglądaj źródła

feat(quiz): 新增执业药师刷题模块 - 科目/章节/考点管理、练习题/真题CRUD及批量导入、小程序端章节列表/做题/真题模考/学习本/每日一练/排行榜、后台管理端、sectionId关联节字段、JacksonConfig Long序列化、ErrorCode 5001-5019

hechuanqi 1 tydzień temu
rodzic
commit
daaeb1f425
100 zmienionych plików z 6234 dodań i 1 usunięć
  1. 7 0
      pom.xml
  2. 22 1
      zhijiayun-common/src/main/java/com/xuekairui/common/ErrorCode.java
  3. 6 0
      zhijiayun-gateway/pom.xml
  4. 18 0
      zhijiayun-gateway/src/main/resources/application-local.yml
  5. 160 0
      zhijiayun-gateway/src/main/resources/db/migration-v22.sql
  6. 48 0
      zhijiayun-quiz/pom.xml
  7. 55 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/admin/AdminQuizChapterController.java
  8. 54 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/admin/AdminQuizChapterImportController.java
  9. 110 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/admin/AdminQuizQuestionController.java
  10. 42 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/admin/AdminQuizSubjectController.java
  11. 111 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/admin/AdminQuizTrackController.java
  12. 97 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/admin/AdminRealQuestionController.java
  13. 88 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizChapterController.java
  14. 30 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizDailyPracticeController.java
  15. 22 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizHomeController.java
  16. 57 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizQuestionController.java
  17. 39 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizRankController.java
  18. 83 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizRealExamController.java
  19. 78 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizStudyBookController.java
  20. 49 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizSubjectController.java
  21. 35 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizTrackController.java
  22. 13 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/AnswerItem.java
  23. 35 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/ChapterImportDTO.java
  24. 13 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/DailyPracticeSubmitRequest.java
  25. 59 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/QuestionImportDTO.java
  26. 14 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/RealExamSaveProgressRequest.java
  27. 12 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/RealExamStartRequest.java
  28. 15 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/RealExamSubmitRequest.java
  29. 63 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/RealQuestionImportDTO.java
  30. 15 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/StudyBookSubmitRequest.java
  31. 14 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/SubmitAnswerRequest.java
  32. 11 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/ToggleCollectRequest.java
  33. 24 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/TrackReportRequest.java
  34. 30 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizChapter.java
  35. 38 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizChapterImportBatch.java
  36. 39 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizQuestion.java
  37. 38 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizQuestionImportBatch.java
  38. 26 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizSubject.java
  39. 26 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizTrackLog.java
  40. 37 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizUserExamRecord.java
  41. 40 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizUserRecord.java
  42. 9 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizChapterImportBatchMapper.java
  43. 9 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizChapterMapper.java
  44. 9 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizQuestionImportBatchMapper.java
  45. 9 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizQuestionMapper.java
  46. 9 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizSubjectMapper.java
  47. 9 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizTrackLogMapper.java
  48. 9 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizUserExamRecordMapper.java
  49. 9 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizUserRecordMapper.java
  50. 12 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/DailyPracticeService.java
  51. 7 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/HomeService.java
  52. 13 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/QuizChapterImportService.java
  53. 25 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/QuizChapterService.java
  54. 56 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/QuizQuestionService.java
  55. 11 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/QuizSubjectService.java
  56. 41 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/QuizTrackService.java
  57. 9 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/RankService.java
  58. 22 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/RealExamService.java
  59. 23 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/StudyBookService.java
  60. 21 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/UserQuestionService.java
  61. 149 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/DailyPracticeServiceImpl.java
  62. 114 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/HomeServiceImpl.java
  63. 333 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizChapterImportServiceImpl.java
  64. 209 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizChapterServiceImpl.java
  65. 637 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizQuestionServiceImpl.java
  66. 68 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizSubjectServiceImpl.java
  67. 503 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizTrackServiceImpl.java
  68. 183 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/RankServiceImpl.java
  69. 427 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/RealExamServiceImpl.java
  70. 415 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/StudyBookServiceImpl.java
  71. 290 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/UserQuestionServiceImpl.java
  72. 25 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ChapterNodeVO.java
  73. 23 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ChapterProgressItemVO.java
  74. 26 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ChapterProgressVO.java
  75. 25 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ChapterQuestionsVO.java
  76. 23 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ChapterTreeVO.java
  77. 38 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/DailyPracticeQuestionsVO.java
  78. 33 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ExamDetailVO.java
  79. 30 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ExamRecordVO.java
  80. 20 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ExamYearVO.java
  81. 72 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/HomeIndexVO.java
  82. 29 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/MyRankVO.java
  83. 22 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/PageVO.java
  84. 22 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/PracticeResultVO.java
  85. 25 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/PracticeSubmitVO.java
  86. 31 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/QuestionDetailVO.java
  87. 30 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/QuestionVO.java
  88. 39 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RankListVO.java
  89. 28 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RealExamQuestionVO.java
  90. 36 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RealExamSubjectVO.java
  91. 27 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/StartExamVO.java
  92. 30 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/StudyBookQuestionVO.java
  93. 22 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/StudyOverviewVO.java
  94. 26 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/StudyQuestionVO.java
  95. 25 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/SubjectProgressVO.java
  96. 21 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/SubjectSimpleVO.java
  97. 22 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/SubmitAnswerVO.java
  98. 26 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/SubmitExamVO.java
  99. 19 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ToggleCollectVO.java
  100. 26 0
      zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/UserChapterNodeVO.java

+ 7 - 0
pom.xml

@@ -25,6 +25,7 @@
         <module>zhijiayun-payment</module>
         <module>zhijiayun-invite</module>
         <module>zhijiayun-activity</module>
+        <module>zhijiayun-quiz</module>
         <module>zhijiayun-gateway</module>
         <module>zhijiayun-test</module>
     </modules>
@@ -43,6 +44,7 @@
         <zhijiayun-payment.version>1.0.0</zhijiayun-payment.version>
         <zhijiayun-invite.version>1.0.0</zhijiayun-invite.version>
         <zhijiayun-activity.version>1.0.0</zhijiayun-activity.version>
+        <zhijiayun-quiz.version>1.0.0</zhijiayun-quiz.version>
         <zhijiayun-gateway.version>1.0.0</zhijiayun-gateway.version>
 
         <!-- 第三方依赖版本 -->
@@ -86,6 +88,11 @@
                 <artifactId>zhijiayun-activity</artifactId>
                 <version>${zhijiayun-activity.version}</version>
             </dependency>
+            <dependency>
+                <groupId>com.xuekairui</groupId>
+                <artifactId>zhijiayun-quiz</artifactId>
+                <version>${zhijiayun-quiz.version}</version>
+            </dependency>
             <dependency>
                 <groupId>com.xuekairui</groupId>
                 <artifactId>zhijiayun-gateway</artifactId>

+ 22 - 1
zhijiayun-common/src/main/java/com/xuekairui/common/ErrorCode.java

@@ -114,7 +114,28 @@ public enum ErrorCode {
     // ========== 关注模块 4401-4499 ==========
     WATCHLIST_LIMIT_EXCEEDED(4401, "关注品种数已达上限"),
     WATCHLIST_ALREADY_EXISTS(4402, "该药品已在关注列表中"),
-    WATCHLIST_NOT_FOUND(4403, "关注记录不存在");
+    WATCHLIST_NOT_FOUND(4403, "关注记录不存在"),
+
+    // ========== 做题模块 5001-5099 ==========
+    QUIZ_SUBJECT_NOT_FOUND(5001, "科目不存在"),
+    QUIZ_SUBJECT_HAS_CHILDREN(5002, "该科目下存在章节,无法删除"),
+    QUIZ_CHAPTER_NOT_FOUND(5003, "章节/考点不存在"),
+    QUIZ_CHAPTER_HAS_CHILDREN(5004, "该节点下存在子节点,请先删除子节点"),
+    QUIZ_CHAPTER_HAS_QUESTIONS(5005, "该考点下存在关联题目,请先处理题目"),
+    QUIZ_QUESTION_NOT_FOUND(5006, "题目不存在"),
+    QUIZ_IMPORT_FILE_EMPTY(5007, "导入文件为空"),
+    QUIZ_IMPORT_FILE_FORMAT_ERROR(5008, "导入文件格式错误,请上传Excel/CSV文件"),
+    QUIZ_IMPORT_SUBJECT_NOT_FOUND(5009, "导入失败:科目不存在"),
+    QUIZ_IMPORT_CHAPTER_NOT_FOUND(5010, "导入失败:考点不存在"),
+    QUIZ_IMPORT_TYPE_INVALID(5011, "导入失败:题型非法"),
+    QUIZ_IMPORT_OPTION_MISSING(5012, "导入失败:选项缺失"),
+    QUIZ_IMPORT_ANSWER_MISSING(5013, "导入失败:答案缺失"),
+    QUIZ_IMPORT_ANALYSIS_MISSING(5014, "导入失败:解析缺失"),
+    QUIZ_IMPORT_YEAR_INVALID(5015, "导入失败:年份非法"),
+    QUIZ_IMPORT_EXAM_POINT_DUPLICATE(5016, "导入失败:考点名称重复"),
+    QUIZ_RECORD_NOT_FOUND(5017, "做题记录不存在"),
+    QUIZ_RECORD_ALREADY_SUBMITTED(5018, "该记录已交卷,请勿重复提交"),
+    QUIZ_TRACK_EVENT_EMPTY(5019, "埋点事件名称不能为空");
 
     private final int code;
     private final String message;

+ 6 - 0
zhijiayun-gateway/pom.xml

@@ -41,6 +41,12 @@
             <artifactId>zhijiayun-activity</artifactId>
         </dependency>
 
+        <!-- 执业药师刷题模块 -->
+        <dependency>
+            <groupId>com.xuekairui</groupId>
+            <artifactId>zhijiayun-quiz</artifactId>
+        </dependency>
+
         <!-- ========== 公共基础 ========== -->
         <dependency>
             <groupId>com.xuekairui</groupId>

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

@@ -0,0 +1,18 @@
+# ========================================
+# 本地开发环境覆盖配置(本地 MySQL 3306 空密码)
+# 启动方式:-Dspring.profiles.active=dev,local
+# ========================================
+
+spring:
+  datasource:
+    driver-class-name: com.mysql.cj.jdbc.Driver
+    url: jdbc:mysql://localhost:3306/zhijiayun_pharmacy?useUnicode=true&characterEncoding=utf-8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
+    username: root
+    password:
+
+  data:
+    redis:
+      host: 127.0.0.1
+      port: 6379
+      password:
+      database: 1

+ 160 - 0
zhijiayun-gateway/src/main/resources/db/migration-v22.sql

@@ -0,0 +1,160 @@
+-- ========================================
+-- migration-v22.sql
+-- 执业药师刷题模块建表脚本
+-- ========================================
+
+-- 1. 科目表
+CREATE TABLE IF NOT EXISTS `t_quiz_subject` (
+    `id`              BIGINT       NOT NULL COMMENT '主键ID(雪花算法)',
+    `name`            VARCHAR(100) NOT NULL COMMENT '科目名称',
+    `sort`            INT          DEFAULT 0 COMMENT '排序',
+    `question_count`  INT          DEFAULT 0 COMMENT '题目总数(冗余)',
+    `create_time`     DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`     DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    `deleted`         TINYINT      DEFAULT 0 COMMENT '删除标记',
+    PRIMARY KEY (`id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='科目表';
+
+-- 2. 章节考点表(树形结构)
+CREATE TABLE IF NOT EXISTS `t_quiz_chapter` (
+    `id`              BIGINT       NOT NULL COMMENT '主键ID(雪花算法)',
+    `subject_id`      BIGINT       NOT NULL COMMENT '科目ID',
+    `parent_id`       BIGINT       DEFAULT 0 COMMENT '父节点ID(0为顶级)',
+    `name`            VARCHAR(200) NOT NULL COMMENT '名称',
+    `level`           TINYINT      NOT NULL COMMENT '层级:1-科目,2-章,3-节,4-考点',
+    `sort`            INT          DEFAULT 0 COMMENT '排序',
+    `question_count`  INT          DEFAULT 0 COMMENT '关联题目数(冗余)',
+    `create_time`     DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`     DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    `deleted`         TINYINT      DEFAULT 0 COMMENT '删除标记',
+    PRIMARY KEY (`id`),
+    KEY `idx_subject_id` (`subject_id`),
+    KEY `idx_parent_id` (`parent_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='章节考点表(树形结构)';
+
+-- 3. 题目表(练习题+真题共用)
+CREATE TABLE IF NOT EXISTS `t_quiz_question` (
+    `id`              BIGINT       NOT NULL COMMENT '主键ID(雪花算法)',
+    `subject_id`      BIGINT       NOT NULL COMMENT '科目ID',
+    `chapter_id`      BIGINT       NOT NULL COMMENT '关联考点ID(最末级考点)',
+    `question_type`   TINYINT      NOT NULL COMMENT '题型:1-最佳选择题,2-多项选择题,3-配伍选择题,4-综合分析选择题',
+    `matching_group`  VARCHAR(50)  DEFAULT NULL COMMENT '配伍组号(配伍题共用,同组题目共享选项)',
+    `shared_options`  TEXT         DEFAULT NULL COMMENT '共用选项(配伍题共用选项内容)',
+    `stem`            TEXT         NOT NULL COMMENT '题干(支持富文本)',
+    `options`         JSON         DEFAULT NULL COMMENT '选项(JSON格式)',
+    `answer`          VARCHAR(50)  NOT NULL COMMENT '正确答案(单选:A,多选:ABC)',
+    `analysis`        TEXT         DEFAULT NULL COMMENT '答案解析',
+    `is_real`         TINYINT      DEFAULT 0 COMMENT '是否真题:0-练习题,1-真题',
+    `year`            INT          DEFAULT NULL COMMENT '真题年份(is_real=1时有效)',
+    `source`          VARCHAR(100) DEFAULT NULL COMMENT '来源',
+    `create_time`     DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`     DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    `deleted`         TINYINT      DEFAULT 0 COMMENT '删除标记',
+    PRIMARY KEY (`id`),
+    KEY `idx_subject_id` (`subject_id`),
+    KEY `idx_chapter_id` (`chapter_id`),
+    KEY `idx_year` (`year`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题目表';
+
+-- 4. 题目导入批次表
+CREATE TABLE IF NOT EXISTS `t_quiz_question_import_batch` (
+    `id`             BIGINT       NOT NULL COMMENT '主键ID(雪花算法)',
+    `batch_no`       VARCHAR(50)  NOT NULL COMMENT '批次号',
+    `file_name`      VARCHAR(255) NOT NULL COMMENT '上传文件名',
+    `file_size`      BIGINT       DEFAULT NULL COMMENT '文件大小(字节)',
+    `total_count`    INT          DEFAULT 0 COMMENT '导入总行数',
+    `success_count`  INT          DEFAULT 0 COMMENT '校验通过数',
+    `fail_count`     INT          DEFAULT 0 COMMENT '校验失败数',
+    `pass_rate`      DECIMAL(5,2) DEFAULT NULL COMMENT '通过率(%)',
+    `status`         TINYINT      DEFAULT 0 COMMENT '状态:0-校验中,1-校验完成,2-已入库',
+    `fail_details`   JSON         DEFAULT NULL COMMENT '失败明细(JSON数组)',
+    `admin_id`       BIGINT       NOT NULL COMMENT '操作管理员ID(关联t_admin.id)',
+    `create_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_batch_no` (`batch_no`),
+    KEY `idx_admin_id` (`admin_id`),
+    KEY `idx_create_time` (`create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题目导入批次表';
+
+-- 5. 考点导入批次表
+CREATE TABLE IF NOT EXISTS `t_quiz_chapter_import_batch` (
+    `id`             BIGINT       NOT NULL COMMENT '主键ID(雪花算法)',
+    `batch_no`       VARCHAR(50)  NOT NULL COMMENT '批次号',
+    `file_name`      VARCHAR(255) NOT NULL COMMENT '上传文件名',
+    `file_size`      BIGINT       DEFAULT NULL COMMENT '文件大小(字节)',
+    `total_count`    INT          DEFAULT 0 COMMENT '导入总行数',
+    `success_count`  INT          DEFAULT 0 COMMENT '校验通过数',
+    `fail_count`     INT          DEFAULT 0 COMMENT '校验失败数',
+    `pass_rate`      DECIMAL(5,2) DEFAULT NULL COMMENT '通过率(%)',
+    `status`         TINYINT      DEFAULT 0 COMMENT '状态:0-校验中,1-校验完成,2-已入库',
+    `fail_details`   JSON         DEFAULT NULL COMMENT '失败明细(JSON数组)',
+    `admin_id`       BIGINT       NOT NULL COMMENT '操作管理员ID(关联t_admin.id)',
+    `create_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    UNIQUE KEY `uk_batch_no` (`batch_no`),
+    KEY `idx_admin_id` (`admin_id`),
+    KEY `idx_create_time` (`create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考点导入批次表';
+
+-- 6. 用户做题记录表
+CREATE TABLE IF NOT EXISTS `t_quiz_user_record` (
+    `id`             BIGINT       NOT NULL COMMENT '主键ID(雪花算法)',
+    `user_id`        BIGINT       NOT NULL COMMENT '用户ID(关联t_user.id)',
+    `subject_id`     BIGINT       NOT NULL COMMENT '科目ID',
+    `chapter_id`     BIGINT       DEFAULT NULL COMMENT '考点ID(章节练习时有值)',
+    `answer_type`    TINYINT      NOT NULL COMMENT '答题类型:1-章节练习,2-每日一练,3-真题模考',
+    `total_count`    INT          DEFAULT 0 COMMENT '总题数',
+    `correct_count`  INT          DEFAULT 0 COMMENT '正确数',
+    `wrong_count`    INT          DEFAULT 0 COMMENT '错误数',
+    `correct_rate`   DECIMAL(5,2) DEFAULT NULL COMMENT '正确率(%)',
+    `answers`        JSON         DEFAULT NULL COMMENT '答题详情(JSON)',
+    `status`         TINYINT      DEFAULT 0 COMMENT '状态:0-进行中,1-已完成',
+    `start_time`     DATETIME     NOT NULL COMMENT '开始时间',
+    `end_time`       DATETIME     DEFAULT NULL COMMENT '结束时间',
+    `create_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_user_id` (`user_id`),
+    KEY `idx_subject_id` (`subject_id`),
+    KEY `idx_answer_type` (`answer_type`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户做题记录表';
+
+-- 7. 用户真题模考记录表
+CREATE TABLE IF NOT EXISTS `t_quiz_user_exam_record` (
+    `id`             BIGINT       NOT NULL COMMENT '主键ID(雪花算法)',
+    `user_id`        BIGINT       NOT NULL COMMENT '用户ID(关联t_user.id)',
+    `year`           INT          NOT NULL COMMENT '真题年份(2015-2025)',
+    `subject_id`     BIGINT       NOT NULL COMMENT '科目ID',
+    `start_time`     DATETIME     NOT NULL COMMENT '开始时间',
+    `submit_time`    DATETIME     DEFAULT NULL COMMENT '提交时间',
+    `used_time`      INT          DEFAULT NULL COMMENT '用时(秒)',
+    `total_score`    DECIMAL(5,1) DEFAULT NULL COMMENT '得分',
+    `correct_count`  INT          DEFAULT NULL COMMENT '正确题数',
+    `wrong_count`    INT          DEFAULT NULL COMMENT '错误题数',
+    `is_passed`      TINYINT      DEFAULT NULL COMMENT '是否及格:0-否,1-是',
+    `answers`        JSON         DEFAULT NULL COMMENT '答题详情(JSON)',
+    `status`         TINYINT      DEFAULT 0 COMMENT '状态:0-进行中,1-已交卷',
+    `create_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
+    `update_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_user_id` (`user_id`),
+    KEY `idx_year_subject` (`year`, `subject_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户真题模考记录表';
+
+-- 8. 埋点数据表
+CREATE TABLE IF NOT EXISTS `t_quiz_track_log` (
+    `id`             BIGINT       NOT NULL COMMENT '主键ID(雪花算法)',
+    `user_id`        BIGINT       DEFAULT NULL COMMENT '用户ID(未登录为null,关联t_user.id)',
+    `event`          VARCHAR(50)  NOT NULL COMMENT '事件名称',
+    `properties`     JSON         DEFAULT NULL COMMENT '事件属性(JSON)',
+    `page`           VARCHAR(100) DEFAULT NULL COMMENT '页面路径',
+    `device_info`    VARCHAR(500) DEFAULT NULL COMMENT '设备信息',
+    `ip`             VARCHAR(50)  DEFAULT NULL COMMENT 'IP地址',
+    `create_time`    DATETIME     DEFAULT CURRENT_TIMESTAMP COMMENT '上报时间',
+    PRIMARY KEY (`id`),
+    KEY `idx_user_id` (`user_id`),
+    KEY `idx_event` (`event`),
+    KEY `idx_create_time` (`create_time`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='埋点数据表';

+ 48 - 0
zhijiayun-quiz/pom.xml

@@ -0,0 +1,48 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+
+    <parent>
+        <groupId>com.xuekairui</groupId>
+        <artifactId>zhijiayun-pharmacy</artifactId>
+        <version>1.0.0</version>
+    </parent>
+
+    <artifactId>zhijiayun-quiz</artifactId>
+    <name>zhijiayun-quiz</name>
+    <description>智价云(药店版)- 执业药师刷题模块(题库管理、做题、真题模考、埋点统计)</description>
+
+    <dependencies>
+        <!-- 公共模块 -->
+        <dependency>
+            <groupId>com.xuekairui</groupId>
+            <artifactId>zhijiayun-common</artifactId>
+        </dependency>
+
+        <!-- Spring Boot -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-validation</artifactId>
+        </dependency>
+
+        <!-- MyBatis-Plus -->
+        <dependency>
+            <groupId>com.baomidou</groupId>
+            <artifactId>mybatis-plus-spring-boot3-starter</artifactId>
+        </dependency>
+
+        <!-- EasyExcel -->
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>easyexcel</artifactId>
+            <version>4.0.3</version>
+        </dependency>
+    </dependencies>
+
+</project>

+ 55 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/admin/AdminQuizChapterController.java

@@ -0,0 +1,55 @@
+package com.xuekairui.quiz.controller.admin;
+
+import com.xuekairui.common.Result;
+import com.xuekairui.quiz.entity.QuizChapter;
+import com.xuekairui.quiz.service.QuizChapterService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/quiz/chapter")
+@RequiredArgsConstructor
+public class AdminQuizChapterController {
+
+    private final QuizChapterService quizChapterService;
+
+    /** 章节树形列表 */
+    @GetMapping("/tree")
+    public Result<Map<String, Object>> tree(@RequestParam(required = false) String keyword) {
+        return Result.success(quizChapterService.getTree(keyword));
+    }
+
+    /** 新增子级(章/节/考点) */
+    @PostMapping("/add")
+    public Result<QuizChapter> add(@RequestBody QuizChapter param) {
+        return Result.success(quizChapterService.addChild(param.getParentId(), param.getName(), param.getSort()));
+    }
+
+    /** 编辑节点 */
+    @PostMapping("/update")
+    public Result<QuizChapter> update(@RequestBody QuizChapter param) {
+        return Result.success(quizChapterService.update(param.getId(), param.getName(), param.getSort()));
+    }
+
+    /** 删除节点 */
+    @PostMapping("/delete")
+    public Result<Void> delete(@RequestBody QuizChapter param) {
+        quizChapterService.delete(param.getId());
+        return Result.success();
+    }
+
+    /** 级联选择:按科目获取章列表 */
+    @GetMapping("/listBySubject")
+    public Result<List<QuizChapter>> listBySubject(@RequestParam Long subjectId) {
+        return Result.success(quizChapterService.listBySubject(subjectId));
+    }
+
+    /** 级联选择:按父节点获取子节点列表 */
+    @GetMapping("/listByParent")
+    public Result<List<QuizChapter>> listByParent(@RequestParam Long parentId) {
+        return Result.success(quizChapterService.listByParent(parentId));
+    }
+}

+ 54 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/admin/AdminQuizChapterImportController.java

@@ -0,0 +1,54 @@
+package com.xuekairui.quiz.controller.admin;
+
+import com.alibaba.excel.EasyExcel;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.xuekairui.common.Result;
+import com.xuekairui.quiz.dto.ChapterImportDTO;
+import com.xuekairui.quiz.entity.QuizChapterImportBatch;
+import com.xuekairui.quiz.service.QuizChapterImportService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+
+@RestController
+@RequestMapping("/api/admin/quiz/chapter")
+@RequiredArgsConstructor
+public class AdminQuizChapterImportController {
+
+    private final QuizChapterImportService quizChapterImportService;
+
+    /** 下载考点导入模板 */
+    @GetMapping("/template")
+    public void downloadTemplate(HttpServletResponse response) throws IOException {
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        String fileName = URLEncoder.encode("考点导入模板.xlsx", StandardCharsets.UTF_8).replace("+", "%20");
+        response.setHeader("Content-Disposition", "attachment;filename=" + fileName);
+        EasyExcel.write(response.getOutputStream(), ChapterImportDTO.class)
+                .sheet("考点导入模板")
+                .doWrite(Collections.emptyList());
+    }
+
+    /** Excel批量导入考点 */
+    @PostMapping("/batchImport")
+    public Result<QuizChapterImportBatch> batchImport(
+            @RequestParam("file") MultipartFile file,
+            HttpServletRequest request) {
+        Long adminId = (Long) request.getAttribute("userId");
+        return Result.success(quizChapterImportService.batchImport(file, adminId != null ? adminId : 0L));
+    }
+
+    /** 考点导入批次记录列表 */
+    @GetMapping("/importBatchList")
+    public Result<IPage<QuizChapterImportBatch>> importBatchList(
+            @RequestParam(defaultValue = "1") int pageNo,
+            @RequestParam(defaultValue = "10") int pageSize) {
+        return Result.success(quizChapterImportService.importBatchList(pageNo, pageSize));
+    }
+}

+ 110 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/admin/AdminQuizQuestionController.java

@@ -0,0 +1,110 @@
+package com.xuekairui.quiz.controller.admin;
+
+import com.alibaba.excel.EasyExcel;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.xuekairui.common.Result;
+import com.xuekairui.quiz.dto.QuestionImportDTO;
+import com.xuekairui.quiz.entity.QuizQuestion;
+import com.xuekairui.quiz.entity.QuizQuestionImportBatch;
+import com.xuekairui.quiz.service.QuizQuestionService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/quiz/question")
+@RequiredArgsConstructor
+public class AdminQuizQuestionController {
+
+    private final QuizQuestionService quizQuestionService;
+
+    /** 练习题列表 */
+    @GetMapping("/list")
+    public Result<IPage<Map<String, Object>>> list(
+            @RequestParam(required = false) Long subjectId,
+            @RequestParam(required = false) String keyword,
+            @RequestParam(defaultValue = "1") int pageNo,
+            @RequestParam(defaultValue = "10") int pageSize) {
+        return Result.success(quizQuestionService.listQuestions(subjectId, keyword, pageNo, pageSize));
+    }
+
+    /** 题目详情 */
+    @GetMapping("/detail")
+    public Result<QuizQuestion> detail(@RequestParam Long id) {
+        return Result.success(quizQuestionService.detail(id));
+    }
+
+    /** 新增练习题 */
+    @PostMapping("/add")
+    public Result<QuizQuestion> add(@RequestBody QuizQuestion question) {
+        return Result.success(quizQuestionService.add(question));
+    }
+
+    /** 编辑题目 */
+    @PostMapping("/update")
+    public Result<QuizQuestion> update(@RequestBody QuizQuestion question) {
+        return Result.success(quizQuestionService.update(question));
+    }
+
+    /** 删除题目 */
+    @PostMapping("/delete")
+    public Result<Void> delete(@RequestBody Map<String, Long> param) {
+        quizQuestionService.delete(param.get("id"));
+        return Result.success();
+    }
+
+    /** 批量删除 */
+    @PostMapping("/batchDelete")
+    public Result<Void> batchDelete(@RequestBody Map<String, List<Long>> param) {
+        quizQuestionService.batchDelete(param.get("ids"));
+        return Result.success();
+    }
+
+    /** 下载练习题导入模板 */
+    @GetMapping("/template")
+    public void downloadTemplate(HttpServletResponse response) throws IOException {
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        String fileName = URLEncoder.encode("题目导入模板.xlsx", StandardCharsets.UTF_8).replace("+", "%20");
+        response.setHeader("Content-Disposition", "attachment;filename=" + fileName);
+        // 练习题模板不含"年份"列,用includeColumnFieldNames排除
+        EasyExcel.write(response.getOutputStream(), QuestionImportDTO.class)
+                .excludeColumnFieldNames(Collections.singletonList("year"))
+                .sheet("题目导入模板")
+                .doWrite(Collections.emptyList());
+    }
+
+    /** 批量导入练习题 */
+    @PostMapping("/batchImport")
+    public Result<QuizQuestionImportBatch> batchImport(
+            @RequestParam("file") MultipartFile file,
+            HttpServletRequest request) {
+        Long adminId = (Long) request.getAttribute("userId");
+        return Result.success(quizQuestionService.batchImport(file, adminId != null ? adminId : 0L));
+    }
+
+    /** 导入批次记录列表 */
+    @GetMapping("/importBatchList")
+    public Result<IPage<QuizQuestionImportBatch>> importBatchList(
+            @RequestParam(defaultValue = "1") int pageNo,
+            @RequestParam(defaultValue = "10") int pageSize) {
+        return Result.success(quizQuestionService.importBatchList(pageNo, pageSize));
+    }
+
+    /** 下载导入失败明细 */
+    @GetMapping("/downloadFailDetails")
+    public void downloadFailDetails(@RequestParam Long batchId, HttpServletResponse response) throws IOException {
+        byte[] data = quizQuestionService.downloadFailDetail(batchId);
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setHeader("Content-Disposition", "attachment;filename=fail-detail.xlsx");
+        response.getOutputStream().write(data);
+    }
+}

+ 42 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/admin/AdminQuizSubjectController.java

@@ -0,0 +1,42 @@
+package com.xuekairui.quiz.controller.admin;
+
+import com.xuekairui.common.Result;
+import com.xuekairui.quiz.entity.QuizSubject;
+import com.xuekairui.quiz.service.QuizSubjectService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/admin/quiz/subject")
+@RequiredArgsConstructor
+public class AdminQuizSubjectController {
+
+    private final QuizSubjectService quizSubjectService;
+
+    /** 科目列表 */
+    @GetMapping("/list")
+    public Result<List<QuizSubject>> list() {
+        return Result.success(quizSubjectService.listAll());
+    }
+
+    /** 新增科目 */
+    @PostMapping("/add")
+    public Result<QuizSubject> add(@RequestBody QuizSubject param) {
+        return Result.success(quizSubjectService.add(param.getName(), param.getSort()));
+    }
+
+    /** 编辑科目 */
+    @PostMapping("/update")
+    public Result<QuizSubject> update(@RequestBody QuizSubject param) {
+        return Result.success(quizSubjectService.update(param.getId(), param.getName(), param.getSort()));
+    }
+
+    /** 删除科目 */
+    @PostMapping("/delete")
+    public Result<Void> delete(@RequestBody QuizSubject param) {
+        quizSubjectService.delete(param.getId());
+        return Result.success();
+    }
+}

+ 111 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/admin/AdminQuizTrackController.java

@@ -0,0 +1,111 @@
+package com.xuekairui.quiz.controller.admin;
+
+import com.xuekairui.common.Result;
+import com.xuekairui.quiz.service.QuizTrackService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/quiz/track")
+@RequiredArgsConstructor
+public class AdminQuizTrackController {
+
+    private final QuizTrackService quizTrackService;
+
+    /** KPI统计卡片 */
+    @GetMapping("/overview")
+    public Result<Map<String, Object>> overview(
+            @RequestParam(required = false) Long subjectId,
+            @RequestParam(required = false) Integer answerType,
+            @RequestParam(required = false) Integer completeStatus,
+            @RequestParam(required = false) String startTime,
+            @RequestParam(required = false) String endTime,
+            @RequestParam(required = false, defaultValue = "30") Integer days) {
+        return Result.success(quizTrackService.getOverview(subjectId, answerType, completeStatus, startTime, endTime, days));
+    }
+
+    /** 用户明细列表 */
+    @GetMapping("/userList")
+    public Result<Map<String, Object>> userList(
+            @RequestParam(required = false) String userKeyword,
+            @RequestParam(required = false) Long subjectId,
+            @RequestParam(required = false) Integer answerType,
+            @RequestParam(required = false) Integer completeStatus,
+            @RequestParam(required = false) String startTime,
+            @RequestParam(required = false) String endTime,
+            @RequestParam(required = false, defaultValue = "30") Integer days,
+            @RequestParam(defaultValue = "1") int pageNo,
+            @RequestParam(defaultValue = "10") int pageSize) {
+        return Result.success(quizTrackService.getUserList(userKeyword, subjectId, answerType, completeStatus, startTime, endTime, days, pageNo, pageSize));
+    }
+
+    /** 做题记录列表 */
+    @GetMapping("/answerList")
+    public Result<Map<String, Object>> answerList(
+            @RequestParam(required = false) String userKeyword,
+            @RequestParam(required = false) Long subjectId,
+            @RequestParam(required = false) Integer answerType,
+            @RequestParam(required = false) Integer completeStatus,
+            @RequestParam(required = false) String startTime,
+            @RequestParam(required = false) String endTime,
+            @RequestParam(required = false, defaultValue = "30") Integer days,
+            @RequestParam(defaultValue = "1") int pageNo,
+            @RequestParam(defaultValue = "10") int pageSize) {
+        return Result.success(quizTrackService.getAnswerList(userKeyword, subjectId, answerType, completeStatus, startTime, endTime, days, pageNo, pageSize));
+    }
+
+    /** 高频错题排行榜 */
+    @GetMapping("/wrongQuestionRank")
+    public Result<Map<String, Object>> hotWrongList(
+            @RequestParam(required = false) Long subjectId,
+            @RequestParam(required = false) Integer answerType,
+            @RequestParam(required = false) String startTime,
+            @RequestParam(required = false) String endTime,
+            @RequestParam(required = false, defaultValue = "30") Integer days,
+            @RequestParam(defaultValue = "1") int pageNo,
+            @RequestParam(defaultValue = "10") int pageSize) {
+        return Result.success(quizTrackService.getHotWrongList(subjectId, answerType, startTime, endTime, days, pageNo, pageSize));
+    }
+
+    /** 答题时段分布 */
+    @GetMapping("/answerTimeDistribution")
+    public Result<Map<String, Object>> answerTimeDistribution(
+            @RequestParam(required = false) Long subjectId,
+            @RequestParam(required = false) Integer answerType,
+            @RequestParam(required = false) String startTime,
+            @RequestParam(required = false) String endTime,
+            @RequestParam(required = false, defaultValue = "30") Integer days) {
+        return Result.success(quizTrackService.getAnswerTimeDistribution(subjectId, answerType, startTime, endTime, days));
+    }
+
+    /** 埋点事件明细列表 */
+    @GetMapping("/list")
+    public Result<Map<String, Object>> eventList(
+            @RequestParam(required = false) String event,
+            @RequestParam(required = false) Long userId,
+            @RequestParam(required = false) String startTime,
+            @RequestParam(required = false) String endTime,
+            @RequestParam(defaultValue = "1") int pageNo,
+            @RequestParam(defaultValue = "10") int pageSize) {
+        return Result.success(quizTrackService.getEventList(event, userId, startTime, endTime, pageNo, pageSize));
+    }
+
+    /** 导出报表 */
+    @GetMapping("/export")
+    public void export(
+            @RequestParam String type,
+            @RequestParam(required = false) Long subjectId,
+            @RequestParam(required = false) Integer answerType,
+            @RequestParam(required = false) Integer completeStatus,
+            @RequestParam(required = false) String startTime,
+            @RequestParam(required = false) String endTime,
+            @RequestParam(required = false, defaultValue = "30") Integer days,
+            jakarta.servlet.http.HttpServletResponse response) throws java.io.IOException {
+        byte[] data = quizTrackService.exportReport(type, subjectId, answerType, completeStatus, startTime, endTime, days);
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setHeader("Content-Disposition", "attachment;filename=" + type + "-report.xlsx");
+        response.getOutputStream().write(data);
+    }
+}

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

@@ -0,0 +1,97 @@
+package com.xuekairui.quiz.controller.admin;
+
+import com.alibaba.excel.EasyExcel;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.xuekairui.common.Result;
+import com.xuekairui.quiz.dto.RealQuestionImportDTO;
+import com.xuekairui.quiz.entity.QuizQuestion;
+import com.xuekairui.quiz.entity.QuizQuestionImportBatch;
+import com.xuekairui.quiz.service.QuizQuestionService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.IOException;
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/admin/quiz/question")
+@RequiredArgsConstructor
+public class AdminRealQuestionController {
+
+    private final QuizQuestionService quizQuestionService;
+
+    /** 真题列表 */
+    @GetMapping("/realList")
+    public Result<IPage<Map<String, Object>>> realList(
+            @RequestParam(required = false) Integer year,
+            @RequestParam(required = false) Long subjectId,
+            @RequestParam(required = false) Integer questionType,
+            @RequestParam(required = false) String keyword,
+            @RequestParam(defaultValue = "1") int pageNo,
+            @RequestParam(defaultValue = "10") int pageSize) {
+        return Result.success(quizQuestionService.listRealQuestions(year, subjectId, questionType, keyword, pageNo, pageSize));
+    }
+
+    /** 新增真题 */
+    @PostMapping("/addReal")
+    public Result<QuizQuestion> addReal(@RequestBody QuizQuestion question) {
+        return Result.success(quizQuestionService.addReal(question));
+    }
+
+    /** 编辑真题 */
+    @PostMapping("/updateReal")
+    public Result<QuizQuestion> updateReal(@RequestBody QuizQuestion question) {
+        return Result.success(quizQuestionService.updateReal(question));
+    }
+
+    /** 真题年份下拉 */
+    @GetMapping("/realYears")
+    public Result<List<Integer>> realYears() {
+        return Result.success(quizQuestionService.getRealYears());
+    }
+
+    /** 下载真题导入模板 */
+    @GetMapping("/realTemplate")
+    public void downloadRealTemplate(HttpServletResponse response) throws IOException {
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        String fileName = URLEncoder.encode("真题导入模板.xlsx", StandardCharsets.UTF_8).replace("+", "%20");
+        response.setHeader("Content-Disposition", "attachment;filename=" + fileName);
+        // 真题模板包含所有列(含"年份"),表头与解析用的 RealQuestionImportDTO 对齐
+        EasyExcel.write(response.getOutputStream(), RealQuestionImportDTO.class)
+                .sheet("真题导入模板")
+                .doWrite(Collections.emptyList());
+    }
+
+    /** 批量导入真题 */
+    @PostMapping("/batchImportReal")
+    public Result<QuizQuestionImportBatch> batchImportReal(
+            @RequestParam("file") MultipartFile file,
+            HttpServletRequest request) {
+        Long adminId = (Long) request.getAttribute("userId");
+        return Result.success(quizQuestionService.batchImportReal(file, adminId != null ? adminId : 0L));
+    }
+
+    /** 真题导入批次记录列表 */
+    @GetMapping("/realImportBatchList")
+    public Result<IPage<QuizQuestionImportBatch>> realImportBatchList(
+            @RequestParam(defaultValue = "1") int pageNo,
+            @RequestParam(defaultValue = "10") int pageSize) {
+        return Result.success(quizQuestionService.realImportBatchList(pageNo, pageSize));
+    }
+
+    /** 下载真题导入失败明细 */
+    @GetMapping("/realImportFailDetail")
+    public void realImportFailDetail(@RequestParam Long batchId, HttpServletResponse response) throws IOException {
+        byte[] data = quizQuestionService.downloadFailDetail(batchId);
+        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+        response.setHeader("Content-Disposition", "attachment;filename=real-fail-detail.xlsx");
+        response.getOutputStream().write(data);
+    }
+}

+ 88 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizChapterController.java

@@ -0,0 +1,88 @@
+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.QuizUserRecord;
+import com.xuekairui.quiz.mapper.QuizChapterMapper;
+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;
+
+@RestController
+@RequestMapping("/api/quiz/chapter")
+@RequiredArgsConstructor
+public class UserQuizChapterController {
+
+    private final QuizChapterMapper chapterMapper;
+    private final QuizUserRecordMapper userRecordMapper;
+
+    @GetMapping("/list")
+    public Result<List<UserChapterNodeVO>> list(@RequestParam Long subjectId, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        List<QuizChapter> chapters = chapterMapper.selectList(
+                new LambdaQueryWrapper<QuizChapter>()
+                        .eq(QuizChapter::getSubjectId, subjectId)
+                        .orderByAsc(QuizChapter::getSort));
+        // 后台在"科目"下新增的章 parentId=科目ID;移动端省略科目层,统一归一化为 0 作为根节点
+        for (QuizChapter ch : chapters) {
+            if (subjectId.equals(ch.getParentId())) {
+                ch.setParentId(0L);
+            }
+        }
+        List<UserChapterNodeVO> tree = buildUserTree(chapters, 0L, userId);
+        return Result.success(tree);
+    }
+
+    private List<UserChapterNodeVO> buildUserTree(List<QuizChapter> chapters, Long parentId, Long userId) {
+        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::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);
+                    if (!subChildren.isEmpty()) {
+                        children = subChildren;
+                    }
+                }
+
+                // 汇总本节点及所有子孙节点的题目数(题目挂在考点level=4上,节level=3自身为0)
+                int totalQuestionCount = sumQuestionCount(chapters, ch.getId()) + ch.getQuestionCount();
+
+                result.add(UserChapterNodeVO.builder()
+                        .id(ch.getId())
+                        .name(ch.getName())
+                        .level(ch.getLevel())
+                        .questionCount(totalQuestionCount)
+                        .doneCount(doneCount)
+                        .children(children)
+                        .build());
+            }
+        }
+        return result;
+    }
+
+    /** 递归汇总所有子孙节点的 questionCount */
+    private int sumQuestionCount(List<QuizChapter> chapters, Long parentId) {
+        int sum = 0;
+        for (QuizChapter ch : chapters) {
+            if (Objects.equals(ch.getParentId(), parentId)) {
+                sum += ch.getQuestionCount() + sumQuestionCount(chapters, ch.getId());
+            }
+        }
+        return sum;
+    }
+}

+ 30 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizDailyPracticeController.java

@@ -0,0 +1,30 @@
+package com.xuekairui.quiz.controller.user;
+
+import com.xuekairui.common.Result;
+import com.xuekairui.quiz.dto.DailyPracticeSubmitRequest;
+import com.xuekairui.quiz.service.DailyPracticeService;
+import com.xuekairui.quiz.vo.DailyPracticeQuestionsVO;
+import com.xuekairui.quiz.vo.PracticeSubmitVO;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/quiz/dailyPractice")
+@RequiredArgsConstructor
+public class UserQuizDailyPracticeController {
+
+    private final DailyPracticeService dailyPracticeService;
+
+    @GetMapping("/questions")
+    public Result<DailyPracticeQuestionsVO> questions(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(dailyPracticeService.getQuestions(userId));
+    }
+
+    @PostMapping("/submit")
+    public Result<PracticeSubmitVO> submit(@RequestBody DailyPracticeSubmitRequest param, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(dailyPracticeService.submit(userId, param.getAnswers()));
+    }
+}

+ 22 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizHomeController.java

@@ -0,0 +1,22 @@
+package com.xuekairui.quiz.controller.user;
+
+import com.xuekairui.common.Result;
+import com.xuekairui.quiz.service.HomeService;
+import com.xuekairui.quiz.vo.HomeIndexVO;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/quiz/home")
+@RequiredArgsConstructor
+public class UserQuizHomeController {
+
+    private final HomeService homeService;
+
+    @GetMapping("/index")
+    public Result<HomeIndexVO> index(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(homeService.getIndex(userId));
+    }
+}

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

@@ -0,0 +1,57 @@
+package com.xuekairui.quiz.controller.user;
+
+import com.xuekairui.common.Result;
+import com.xuekairui.quiz.dto.SubmitAnswerRequest;
+import com.xuekairui.quiz.dto.ToggleCollectRequest;
+import com.xuekairui.quiz.service.UserQuestionService;
+import com.xuekairui.quiz.vo.PageVO;
+import com.xuekairui.quiz.vo.QuestionDetailVO;
+import com.xuekairui.quiz.vo.QuestionVO;
+import com.xuekairui.quiz.vo.SubmitAnswerVO;
+import com.xuekairui.quiz.vo.ToggleCollectVO;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/quiz/question")
+@RequiredArgsConstructor
+public class UserQuizQuestionController {
+
+    private final UserQuestionService userQuestionService;
+
+    /** 获取考点下的题目(练习用) */
+    @GetMapping("/listByChapter")
+    public Result<PageVO<QuestionVO>> listByChapter(
+            @RequestParam Long chapterId,
+            @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));
+    }
+
+    /** 提交单题答案 */
+    @PostMapping("/submitAnswer")
+    public Result<SubmitAnswerVO> submitAnswer(@RequestBody SubmitAnswerRequest param, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        Integer source = param.getSource() != null ? param.getSource() : 1;
+        Integer timeSpent = param.getTimeSpent() != null ? param.getTimeSpent() : 0;
+        return Result.success(userQuestionService.submitAnswer(userId, param.getQuestionId(), param.getUserAnswer(), source, timeSpent));
+    }
+
+    /** 收藏/取消收藏 */
+    @PostMapping("/toggleCollect")
+    public Result<ToggleCollectVO> toggleCollect(@RequestBody ToggleCollectRequest param, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(userQuestionService.toggleCollect(userId, param.getQuestionId()));
+    }
+
+    /** 题目详情 */
+    @GetMapping("/detail")
+    public Result<QuestionDetailVO> detail(@RequestParam Long id, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(userQuestionService.detail(id, userId));
+    }
+}

+ 39 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizRankController.java

@@ -0,0 +1,39 @@
+package com.xuekairui.quiz.controller.user;
+
+import com.xuekairui.common.Result;
+import com.xuekairui.quiz.service.RankService;
+import com.xuekairui.quiz.vo.MyRankVO;
+import com.xuekairui.quiz.vo.RankListVO;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/quiz/rank")
+@RequiredArgsConstructor
+public class UserQuizRankController {
+
+    private final RankService rankService;
+
+    /** 获取排行榜 */
+    @GetMapping("/weekly")
+    public Result<RankListVO> rankList(
+            @RequestParam(defaultValue = "all") String period,
+            @RequestParam(defaultValue = "totalCount") String rankType,
+            @RequestParam(defaultValue = "1") int pageNo,
+            @RequestParam(defaultValue = "20") int pageSize,
+            HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(rankService.getRankList(userId, period, rankType, pageNo, pageSize));
+    }
+
+    /** 获取我的排名 */
+    @GetMapping("/my")
+    public Result<MyRankVO> myRank(
+            @RequestParam(defaultValue = "all") String period,
+            @RequestParam(defaultValue = "totalCount") String rankType,
+            HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(rankService.getMyRank(userId, period, rankType));
+    }
+}

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

@@ -0,0 +1,83 @@
+package com.xuekairui.quiz.controller.user;
+
+import com.xuekairui.common.Result;
+import com.xuekairui.quiz.dto.RealExamSaveProgressRequest;
+import com.xuekairui.quiz.dto.RealExamStartRequest;
+import com.xuekairui.quiz.dto.RealExamSubmitRequest;
+import com.xuekairui.quiz.service.RealExamService;
+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.StartExamVO;
+import com.xuekairui.quiz.vo.SubmitExamVO;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/quiz/realExam")
+@RequiredArgsConstructor
+public class UserQuizRealExamController {
+
+    private final RealExamService realExamService;
+
+    /** 真题年份+科目列表(按科目分组) */
+    @GetMapping("/list")
+    public Result<List<RealExamSubjectVO>> list(
+            @RequestParam(required = false) Long subjectId,
+            HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(realExamService.list(userId, subjectId));
+    }
+
+    /** 获取可用真题年份列表 */
+    @GetMapping("/years")
+    public Result<List<ExamYearVO>> years(@RequestParam(required = false) Long subjectId) {
+        return Result.success(realExamService.getExamYears(subjectId));
+    }
+
+    /** 开始真题考试 */
+    @PostMapping("/start")
+    public Result<StartExamVO> start(@RequestBody RealExamStartRequest param, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(realExamService.startExam(userId, param.getYear(), param.getSubjectId()));
+    }
+
+    /** 保存答题进度(自动保存) */
+    @PostMapping("/saveProgress")
+    public Result<Void> saveProgress(@RequestBody RealExamSaveProgressRequest param, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        realExamService.saveProgress(userId, param.getRecordId(), param.getAnswers());
+        return Result.success();
+    }
+
+    /** 交卷 */
+    @PostMapping("/submit")
+    public Result<SubmitExamVO> submit(@RequestBody RealExamSubmitRequest param, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        Integer usedTime = param.getUsedTime() != null ? param.getUsedTime() : 0;
+        return Result.success(realExamService.submitExam(userId, param.getRecordId(), param.getAnswers(), usedTime));
+    }
+
+    /** 考试记录 */
+    @GetMapping("/records")
+    public Result<PageVO<ExamRecordVO>> records(
+            @RequestParam(required = false) Long subjectId,
+            @RequestParam(defaultValue = "1") int pageNo,
+            @RequestParam(defaultValue = "10") int pageSize,
+            HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(realExamService.getExamHistory(userId, subjectId, pageNo, pageSize));
+    }
+
+    /** 考试详情 */
+    @GetMapping("/recordDetail")
+    public Result<ExamDetailVO> recordDetail(@RequestParam Long recordId, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(realExamService.getExamDetail(userId, recordId));
+    }
+}

+ 78 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizStudyBookController.java

@@ -0,0 +1,78 @@
+package com.xuekairui.quiz.controller.user;
+
+import com.xuekairui.common.Result;
+import com.xuekairui.quiz.dto.StudyBookSubmitRequest;
+import com.xuekairui.quiz.service.StudyBookService;
+import com.xuekairui.quiz.vo.ChapterProgressVO;
+import com.xuekairui.quiz.vo.ChapterQuestionsVO;
+import com.xuekairui.quiz.vo.ChapterTreeVO;
+import com.xuekairui.quiz.vo.PageVO;
+import com.xuekairui.quiz.vo.PracticeSubmitVO;
+import com.xuekairui.quiz.vo.StudyBookQuestionVO;
+import com.xuekairui.quiz.vo.StudyOverviewVO;
+import com.xuekairui.quiz.vo.SubjectSimpleVO;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/quiz/studyBook")
+@RequiredArgsConstructor
+public class UserQuizStudyBookController {
+
+    private final StudyBookService studyBookService;
+
+    /** 学习本统计概览(做对/做错/收藏数) */
+    @GetMapping("/overview")
+    public Result<StudyOverviewVO> overview(HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(studyBookService.getOverview(userId));
+    }
+
+    /** 学习本题目列表(按 correct/wrong/collected 筛选) */
+    @GetMapping("/questionList")
+    public Result<PageVO<StudyBookQuestionVO>> questionList(
+            @RequestParam(defaultValue = "correct") String type,
+            @RequestParam(required = false) Long subjectId,
+            @RequestParam(defaultValue = "1") int pageNo,
+            @RequestParam(defaultValue = "10") int pageSize,
+            HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(studyBookService.getQuestionList(userId, type, subjectId, pageNo, pageSize));
+    }
+
+    /** 获取科目列表 */
+    @GetMapping("/subjects")
+    public Result<List<SubjectSimpleVO>> subjects() {
+        return Result.success(studyBookService.getSubjectList());
+    }
+
+    /** 获取章节树 */
+    @GetMapping("/chapters")
+    public Result<ChapterTreeVO> chapters(@RequestParam Long subjectId) {
+        return Result.success(studyBookService.getChapterTree(subjectId));
+    }
+
+    /** 获取考点题目 */
+    @GetMapping("/questions")
+    public Result<ChapterQuestionsVO> questions(@RequestParam Long chapterId, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(studyBookService.getChapterQuestions(userId, chapterId));
+    }
+
+    /** 提交章节练习 */
+    @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()));
+    }
+
+    /** 获取章节学习进度 */
+    @GetMapping("/progress")
+    public Result<ChapterProgressVO> progress(@RequestParam Long subjectId, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        return Result.success(studyBookService.getChapterProgress(userId, subjectId));
+    }
+}

+ 49 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizSubjectController.java

@@ -0,0 +1,49 @@
+package com.xuekairui.quiz.controller.user;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.xuekairui.common.Result;
+import com.xuekairui.quiz.entity.QuizSubject;
+import com.xuekairui.quiz.entity.QuizUserRecord;
+import com.xuekairui.quiz.mapper.QuizSubjectMapper;
+import com.xuekairui.quiz.mapper.QuizUserRecordMapper;
+import com.xuekairui.quiz.vo.SubjectProgressVO;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/quiz/subject")
+@RequiredArgsConstructor
+public class UserQuizSubjectController {
+
+    private final QuizSubjectMapper subjectMapper;
+    private final QuizUserRecordMapper userRecordMapper;
+
+    @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));
+        List<SubjectProgressVO> result = new ArrayList<>();
+        for (QuizSubject s : subjects) {
+            long doneCount = userRecordMapper.selectCount(
+                    new LambdaQueryWrapper<QuizUserRecord>()
+                            .eq(QuizUserRecord::getUserId, userId)
+                            .eq(QuizUserRecord::getSubjectId, s.getId())
+                            .eq(QuizUserRecord::getStatus, 1));
+            result.add(SubjectProgressVO.builder()
+                    .id(s.getId())
+                    .name(s.getName())
+                    .sort(s.getSort())
+                    .icon("")
+                    .questionCount(s.getQuestionCount())
+                    .doneCount(doneCount)
+                    .progress(s.getQuestionCount() > 0 ? (int) (doneCount * 100 / s.getQuestionCount()) : 0)
+                    .build());
+        }
+        return Result.success(result);
+    }
+}

+ 35 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/controller/user/UserQuizTrackController.java

@@ -0,0 +1,35 @@
+package com.xuekairui.quiz.controller.user;
+
+import com.xuekairui.common.Result;
+import com.xuekairui.quiz.dto.TrackReportRequest;
+import com.xuekairui.quiz.service.QuizTrackService;
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+@RestController
+@RequestMapping("/api/quiz/track")
+@RequiredArgsConstructor
+public class UserQuizTrackController {
+
+    private final QuizTrackService quizTrackService;
+
+    /** 上报埋点事件(支持单条和批量) */
+    @PostMapping("/report")
+    public Result<Void> report(@RequestBody TrackReportRequest param, HttpServletRequest request) {
+        Long userId = (Long) request.getAttribute("userId");
+        String deviceInfo = request.getHeader("User-Agent");
+        String ip = request.getRemoteAddr();
+
+        // 支持批量上报:{ "events": [...] }
+        if (param.getEvents() != null && !param.getEvents().isEmpty()) {
+            for (TrackReportRequest.EventItem evt : param.getEvents()) {
+                quizTrackService.trackEvent(userId, evt.getEvent(), evt.getProperties(), evt.getPage(), deviceInfo, ip);
+            }
+        } else {
+            // 单条上报
+            quizTrackService.trackEvent(userId, param.getEvent(), param.getProperties(), param.getPage(), deviceInfo, ip);
+        }
+        return Result.success();
+    }
+}

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

@@ -0,0 +1,13 @@
+package com.xuekairui.quiz.dto;
+
+import lombok.Data;
+
+/**
+ * 单题作答入参(提交/自动保存共用)
+ */
+@Data
+public class AnswerItem {
+    private Long questionId;
+    private String userAnswer;
+    private Integer timeSpent;
+}

+ 35 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/ChapterImportDTO.java

@@ -0,0 +1,35 @@
+package com.xuekairui.quiz.dto;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import lombok.Data;
+
+/**
+ * 考点导入Excel行DTO
+ * 树形展开格式:每行只填当前层级的名称和序号
+ */
+@Data
+public class ChapterImportDTO {
+    @ExcelProperty("科目名称")
+    private String subjectName;
+
+    @ExcelProperty("科目序号")
+    private String subjectSort;
+
+    @ExcelProperty("章名称")
+    private String chapterName;
+
+    @ExcelProperty("章序号")
+    private String chapterSort;
+
+    @ExcelProperty("节名称")
+    private String sectionName;
+
+    @ExcelProperty("节序号")
+    private String sectionSort;
+
+    @ExcelProperty("考点名称")
+    private String examPointName;
+
+    @ExcelProperty("考点序号")
+    private String examPointSort;
+}

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

@@ -0,0 +1,13 @@
+package com.xuekairui.quiz.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 每日一练提交入参
+ */
+@Data
+public class DailyPracticeSubmitRequest {
+    private List<AnswerItem> answers;
+}

+ 59 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/QuestionImportDTO.java

@@ -0,0 +1,59 @@
+package com.xuekairui.quiz.dto;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import lombok.Data;
+
+/**
+ * 题目/真题导入Excel行DTO
+ * 练习题15列,真题16列(多一个"年份"列在最前面)
+ */
+@Data
+public class QuestionImportDTO {
+    @ExcelProperty("年份")
+    private String year;
+
+    @ExcelProperty("试题类型")
+    private String questionType;
+
+    @ExcelProperty("配伍组号")
+    private String matchingGroup;
+
+    @ExcelProperty("共用选项")
+    private String sharedOptions;
+
+    @ExcelProperty("关联科目")
+    private String subjectName;
+
+    @ExcelProperty("关联章")
+    private String chapterName;
+
+    @ExcelProperty("关联节")
+    private String sectionName;
+
+    @ExcelProperty("关联考点")
+    private String examPointName;
+
+    @ExcelProperty("题干")
+    private String stem;
+
+    @ExcelProperty("选项A")
+    private String optionA;
+
+    @ExcelProperty("选项B")
+    private String optionB;
+
+    @ExcelProperty("选项C")
+    private String optionC;
+
+    @ExcelProperty("选项D")
+    private String optionD;
+
+    @ExcelProperty("选项E")
+    private String optionE;
+
+    @ExcelProperty("正确答案")
+    private String answer;
+
+    @ExcelProperty("解析")
+    private String analysis;
+}

+ 14 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/RealExamSaveProgressRequest.java

@@ -0,0 +1,14 @@
+package com.xuekairui.quiz.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 真题考试自动保存进度入参
+ */
+@Data
+public class RealExamSaveProgressRequest {
+    private Long recordId;
+    private List<AnswerItem> answers;
+}

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

@@ -0,0 +1,12 @@
+package com.xuekairui.quiz.dto;
+
+import lombok.Data;
+
+/**
+ * 开始真题考试入参
+ */
+@Data
+public class RealExamStartRequest {
+    private Integer year;
+    private Long subjectId;
+}

+ 15 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/RealExamSubmitRequest.java

@@ -0,0 +1,15 @@
+package com.xuekairui.quiz.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 真题考试交卷入参
+ */
+@Data
+public class RealExamSubmitRequest {
+    private Long recordId;
+    private List<AnswerItem> answers;
+    private Integer usedTime;
+}

+ 63 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/RealQuestionImportDTO.java

@@ -0,0 +1,63 @@
+package com.xuekairui.quiz.dto;
+
+import com.alibaba.excel.annotation.ExcelProperty;
+import lombok.Data;
+
+/**
+ * 真题导入Excel行DTO(17列,产品真题模板结构)
+ * 分组:parent_id 为组号(配伍题 Pxx、综合分析题 Qxx,0 表示无分组)
+ * 共用内容:extra_title(配伍题=共用选项,综合分析题=共用题干/案例)
+ */
+@Data
+public class RealQuestionImportDTO {
+    @ExcelProperty("question_id")
+    private String questionId;
+
+    @ExcelProperty("question_type")
+    private String questionType;
+
+    @ExcelProperty("parent_id")
+    private String matchingGroup;
+
+    @ExcelProperty("extra_title")
+    private String sharedOptions;
+
+    @ExcelProperty("question")
+    private String stem;
+
+    @ExcelProperty("option_a")
+    private String optionA;
+
+    @ExcelProperty("option_b")
+    private String optionB;
+
+    @ExcelProperty("option_c")
+    private String optionC;
+
+    @ExcelProperty("option_d")
+    private String optionD;
+
+    @ExcelProperty("option_e")
+    private String optionE;
+
+    @ExcelProperty("correct_answer")
+    private String answer;
+
+    @ExcelProperty("answer_explanation")
+    private String analysis;
+
+    @ExcelProperty("subject")
+    private String subjectName;
+
+    @ExcelProperty("year")
+    private String year;
+
+    @ExcelProperty("章名称")
+    private String chapterName;
+
+    @ExcelProperty("节名称")
+    private String sectionName;
+
+    @ExcelProperty("考点名称")
+    private String examPointName;
+}

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

@@ -0,0 +1,15 @@
+package com.xuekairui.quiz.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 章节练习提交入参
+ */
+@Data
+public class StudyBookSubmitRequest {
+    private Long subjectId;
+    private Long chapterId;
+    private List<AnswerItem> answers;
+}

+ 14 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/SubmitAnswerRequest.java

@@ -0,0 +1,14 @@
+package com.xuekairui.quiz.dto;
+
+import lombok.Data;
+
+/**
+ * 练习模式提交单题答案入参
+ */
+@Data
+public class SubmitAnswerRequest {
+    private Long questionId;
+    private String userAnswer;
+    private Integer source;
+    private Integer timeSpent;
+}

+ 11 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/ToggleCollectRequest.java

@@ -0,0 +1,11 @@
+package com.xuekairui.quiz.dto;
+
+import lombok.Data;
+
+/**
+ * 收藏/取消收藏入参
+ */
+@Data
+public class ToggleCollectRequest {
+    private Long questionId;
+}

+ 24 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/dto/TrackReportRequest.java

@@ -0,0 +1,24 @@
+package com.xuekairui.quiz.dto;
+
+import lombok.Data;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 埋点上报入参(支持单条与批量)
+ */
+@Data
+public class TrackReportRequest {
+    private String event;
+    private Map<String, Object> properties;
+    private String page;
+    private List<EventItem> events;
+
+    @Data
+    public static class EventItem {
+        private String event;
+        private Map<String, Object> properties;
+        private String page;
+    }
+}

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

@@ -0,0 +1,30 @@
+package com.xuekairui.quiz.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.*;
+import java.time.LocalDateTime;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_quiz_chapter")
+public class QuizChapter {
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+    private Long subjectId;
+    @Builder.Default
+    private Long parentId = 0L;
+    private String name;
+    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)
+    private LocalDateTime updateTime;
+    @TableLogic
+    private Integer deleted;
+}

+ 38 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizChapterImportBatch.java

@@ -0,0 +1,38 @@
+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;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName(value = "t_quiz_chapter_import_batch", autoResultMap = true)
+public class QuizChapterImportBatch {
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+    private String batchNo;
+    private String fileName;
+    private Long fileSize;
+    @Builder.Default
+    private Integer totalCount = 0;
+    @Builder.Default
+    private Integer successCount = 0;
+    @Builder.Default
+    private Integer failCount = 0;
+    private BigDecimal passRate;
+    @Builder.Default
+    private Integer status = 0;
+    @TableField(typeHandler = JacksonTypeHandler.class)
+    private List<Map<String, Object>> failDetails;
+    private Long adminId;
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 39 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizQuestion.java

@@ -0,0 +1,39 @@
+package com.xuekairui.quiz.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
+import lombok.*;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName(value = "t_quiz_question", autoResultMap = true)
+public class QuizQuestion {
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+    private Long subjectId;
+    private Long sectionId;
+    private Long chapterId;
+    private Integer questionType;
+    private String matchingGroup;
+    private String sharedOptions;
+    private String stem;
+    @TableField(typeHandler = JacksonTypeHandler.class)
+    private List<Map<String, Object>> options;
+    private String answer;
+    private String analysis;
+    @Builder.Default
+    private Integer isReal = 0;
+    private Integer year;
+    private String source;
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+    @TableLogic
+    private Integer deleted;
+}

+ 38 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizQuestionImportBatch.java

@@ -0,0 +1,38 @@
+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;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName(value = "t_quiz_question_import_batch", autoResultMap = true)
+public class QuizQuestionImportBatch {
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+    private String batchNo;
+    private String fileName;
+    private Long fileSize;
+    @Builder.Default
+    private Integer totalCount = 0;
+    @Builder.Default
+    private Integer successCount = 0;
+    @Builder.Default
+    private Integer failCount = 0;
+    private BigDecimal passRate;
+    @Builder.Default
+    private Integer status = 0;
+    @TableField(typeHandler = JacksonTypeHandler.class)
+    private List<Map<String, Object>> failDetails;
+    private Long adminId;
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

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

@@ -0,0 +1,26 @@
+package com.xuekairui.quiz.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import lombok.*;
+import java.time.LocalDateTime;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName("t_quiz_subject")
+public class QuizSubject {
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+    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)
+    private LocalDateTime updateTime;
+    @TableLogic
+    private Integer deleted;
+}

+ 26 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizTrackLog.java

@@ -0,0 +1,26 @@
+package com.xuekairui.quiz.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.baomidou.mybatisplus.extension.handlers.JacksonTypeHandler;
+import lombok.*;
+import java.time.LocalDateTime;
+import java.util.Map;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName(value = "t_quiz_track_log", autoResultMap = true)
+public class QuizTrackLog {
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+    private Long userId;
+    private String event;
+    @TableField(typeHandler = JacksonTypeHandler.class)
+    private Map<String, Object> properties;
+    private String page;
+    private String deviceInfo;
+    private String ip;
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+}

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

@@ -0,0 +1,37 @@
+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;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName(value = "t_quiz_user_exam_record", autoResultMap = true)
+public class QuizUserExamRecord {
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+    private Long userId;
+    private Integer year;
+    private Long subjectId;
+    private LocalDateTime startTime;
+    private LocalDateTime submitTime;
+    private Integer usedTime;
+    private BigDecimal totalScore;
+    private Integer correctCount;
+    private Integer wrongCount;
+    private Integer isPassed;
+    @TableField(typeHandler = JacksonTypeHandler.class)
+    private List<Map<String, Object>> answers;
+    @Builder.Default
+    private Integer status = 0;
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 40 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/entity/QuizUserRecord.java

@@ -0,0 +1,40 @@
+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;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@Builder
+@TableName(value = "t_quiz_user_record", autoResultMap = true)
+public class QuizUserRecord {
+    @TableId(type = IdType.ASSIGN_ID)
+    private Long id;
+    private Long userId;
+    private Long subjectId;
+    private Long chapterId;
+    private Integer answerType;
+    @Builder.Default
+    private Integer totalCount = 0;
+    @Builder.Default
+    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
+    private Integer status = 0;
+    private LocalDateTime startTime;
+    private LocalDateTime endTime;
+    @TableField(fill = FieldFill.INSERT)
+    private LocalDateTime createTime;
+    @TableField(fill = FieldFill.INSERT_UPDATE)
+    private LocalDateTime updateTime;
+}

+ 9 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizChapterImportBatchMapper.java

@@ -0,0 +1,9 @@
+package com.xuekairui.quiz.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.quiz.entity.QuizChapterImportBatch;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface QuizChapterImportBatchMapper extends BaseMapper<QuizChapterImportBatch> {
+}

+ 9 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizChapterMapper.java

@@ -0,0 +1,9 @@
+package com.xuekairui.quiz.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.quiz.entity.QuizChapter;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface QuizChapterMapper extends BaseMapper<QuizChapter> {
+}

+ 9 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizQuestionImportBatchMapper.java

@@ -0,0 +1,9 @@
+package com.xuekairui.quiz.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.quiz.entity.QuizQuestionImportBatch;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface QuizQuestionImportBatchMapper extends BaseMapper<QuizQuestionImportBatch> {
+}

+ 9 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizQuestionMapper.java

@@ -0,0 +1,9 @@
+package com.xuekairui.quiz.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.quiz.entity.QuizQuestion;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface QuizQuestionMapper extends BaseMapper<QuizQuestion> {
+}

+ 9 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizSubjectMapper.java

@@ -0,0 +1,9 @@
+package com.xuekairui.quiz.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.quiz.entity.QuizSubject;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface QuizSubjectMapper extends BaseMapper<QuizSubject> {
+}

+ 9 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizTrackLogMapper.java

@@ -0,0 +1,9 @@
+package com.xuekairui.quiz.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.quiz.entity.QuizTrackLog;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface QuizTrackLogMapper extends BaseMapper<QuizTrackLog> {
+}

+ 9 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizUserExamRecordMapper.java

@@ -0,0 +1,9 @@
+package com.xuekairui.quiz.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.quiz.entity.QuizUserExamRecord;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface QuizUserExamRecordMapper extends BaseMapper<QuizUserExamRecord> {
+}

+ 9 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/mapper/QuizUserRecordMapper.java

@@ -0,0 +1,9 @@
+package com.xuekairui.quiz.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.xuekairui.quiz.entity.QuizUserRecord;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface QuizUserRecordMapper extends BaseMapper<QuizUserRecord> {
+}

+ 12 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/DailyPracticeService.java

@@ -0,0 +1,12 @@
+package com.xuekairui.quiz.service;
+
+import com.xuekairui.quiz.dto.AnswerItem;
+import com.xuekairui.quiz.vo.DailyPracticeQuestionsVO;
+import com.xuekairui.quiz.vo.PracticeSubmitVO;
+
+import java.util.List;
+
+public interface DailyPracticeService {
+    DailyPracticeQuestionsVO getQuestions(Long userId);
+    PracticeSubmitVO submit(Long userId, List<AnswerItem> answers);
+}

+ 7 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/HomeService.java

@@ -0,0 +1,7 @@
+package com.xuekairui.quiz.service;
+
+import com.xuekairui.quiz.vo.HomeIndexVO;
+
+public interface HomeService {
+    HomeIndexVO getIndex(Long userId);
+}

+ 13 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/QuizChapterImportService.java

@@ -0,0 +1,13 @@
+package com.xuekairui.quiz.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.xuekairui.quiz.entity.QuizChapterImportBatch;
+import org.springframework.web.multipart.MultipartFile;
+
+public interface QuizChapterImportService {
+    /** CSV批量导入考点 */
+    QuizChapterImportBatch batchImport(MultipartFile file, Long adminId);
+
+    /** 导入批次记录列表 */
+    IPage<QuizChapterImportBatch> importBatchList(int pageNo, int pageSize);
+}

+ 25 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/QuizChapterService.java

@@ -0,0 +1,25 @@
+package com.xuekairui.quiz.service;
+
+import com.xuekairui.quiz.entity.QuizChapter;
+import java.util.List;
+import java.util.Map;
+
+public interface QuizChapterService {
+    /** 获取完整树形结构(支持keyword搜索) */
+    Map<String, Object> getTree(String keyword);
+
+    /** 新增子级(章/节/考点) */
+    QuizChapter addChild(Long parentId, String name, Integer sort);
+
+    /** 编辑节点 */
+    QuizChapter update(Long id, String name, Integer sort);
+
+    /** 删除节点 */
+    void delete(Long id);
+
+    /** 按科目ID获取章列表(级联选择用) */
+    List<QuizChapter> listBySubject(Long subjectId);
+
+    /** 按父节点ID获取子节点列表(级联选择用) */
+    List<QuizChapter> listByParent(Long parentId);
+}

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

@@ -0,0 +1,56 @@
+package com.xuekairui.quiz.service;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.xuekairui.quiz.entity.QuizQuestion;
+import com.xuekairui.quiz.entity.QuizQuestionImportBatch;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+import java.util.Map;
+
+public interface QuizQuestionService {
+    /** 练习题分页列表 */
+    IPage<Map<String, Object>> listQuestions(Long subjectId, String keyword, int pageNo, int pageSize);
+
+    /** 题目详情 */
+    QuizQuestion detail(Long id);
+
+    /** 新增练习题 */
+    QuizQuestion add(QuizQuestion question);
+
+    /** 编辑题目 */
+    QuizQuestion update(QuizQuestion question);
+
+    /** 删除题目 */
+    void delete(Long id);
+
+    /** 批量删除 */
+    void batchDelete(List<Long> ids);
+
+    /** 批量导入练习题 */
+    QuizQuestionImportBatch batchImport(MultipartFile file, Long adminId);
+
+    /** 导入批次记录列表 */
+    IPage<QuizQuestionImportBatch> importBatchList(int pageNo, int pageSize);
+
+    /** 真题分页列表 */
+    IPage<Map<String, Object>> listRealQuestions(Integer year, Long subjectId, Integer questionType, String keyword, int pageNo, int pageSize);
+
+    /** 新增真题 */
+    QuizQuestion addReal(QuizQuestion question);
+
+    /** 编辑真题 */
+    QuizQuestion updateReal(QuizQuestion question);
+
+    /** 批量导入真题 */
+    QuizQuestionImportBatch batchImportReal(MultipartFile file, Long adminId);
+
+    /** 真题导入批次记录列表 */
+    IPage<QuizQuestionImportBatch> realImportBatchList(int pageNo, int pageSize);
+
+    /** 获取真题年份列表 */
+    List<Integer> getRealYears();
+
+    /** 下载导入失败明细 */
+    byte[] downloadFailDetail(Long batchId);
+}

+ 11 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/QuizSubjectService.java

@@ -0,0 +1,11 @@
+package com.xuekairui.quiz.service;
+
+import com.xuekairui.quiz.entity.QuizSubject;
+import java.util.List;
+
+public interface QuizSubjectService {
+    List<QuizSubject> listAll();
+    QuizSubject add(String name, Integer sort);
+    QuizSubject update(Long id, String name, Integer sort);
+    void delete(Long id);
+}

+ 41 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/QuizTrackService.java

@@ -0,0 +1,41 @@
+package com.xuekairui.quiz.service;
+
+import com.xuekairui.quiz.entity.QuizTrackLog;
+import java.util.List;
+import java.util.Map;
+
+public interface QuizTrackService {
+    /** 上报埋点事件 */
+    void trackEvent(Long userId, String event, Map<String, Object> properties, String page, String deviceInfo, String ip);
+
+    /** KPI统计卡片 */
+    Map<String, Object> getOverview(Long subjectId, Integer answerType, Integer completeStatus,
+                                     String startTime, String endTime, Integer days);
+
+    /** 用户明细列表 */
+    Map<String, Object> getUserList(String userKeyword, Long subjectId, Integer answerType,
+                                     Integer completeStatus, String startTime, String endTime,
+                                     Integer days, int pageNo, int pageSize);
+
+    /** 做题记录列表 */
+    Map<String, Object> getAnswerList(String userKeyword, Long subjectId, Integer answerType,
+                                       Integer completeStatus, String startTime, String endTime,
+                                       Integer days, int pageNo, int pageSize);
+
+    /** 高频错题排行榜 */
+    Map<String, Object> getHotWrongList(Long subjectId, Integer answerType,
+                                         String startTime, String endTime,
+                                         Integer days, int pageNo, int pageSize);
+
+    /** 答题时段分布 */
+    Map<String, Object> getAnswerTimeDistribution(Long subjectId, Integer answerType,
+                                                   String startTime, String endTime, Integer days);
+
+    /** 埋点事件明细列表 */
+    Map<String, Object> getEventList(String event, Long userId, String startTime, String endTime,
+                                      int pageNo, int pageSize);
+
+    /** 导出报表 */
+    byte[] exportReport(String type, Long subjectId, Integer answerType, Integer completeStatus,
+                         String startTime, String endTime, Integer days);
+}

+ 9 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/RankService.java

@@ -0,0 +1,9 @@
+package com.xuekairui.quiz.service;
+
+import com.xuekairui.quiz.vo.MyRankVO;
+import com.xuekairui.quiz.vo.RankListVO;
+
+public interface RankService {
+    RankListVO getRankList(Long userId, String period, String rankType, int pageNo, int pageSize);
+    MyRankVO getMyRank(Long userId, String period, String rankType);
+}

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

@@ -0,0 +1,22 @@
+package com.xuekairui.quiz.service;
+
+import com.xuekairui.quiz.dto.AnswerItem;
+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.StartExamVO;
+import com.xuekairui.quiz.vo.SubmitExamVO;
+
+import java.util.List;
+
+public interface RealExamService {
+    List<RealExamSubjectVO> list(Long userId, Long subjectId);
+    List<ExamYearVO> getExamYears(Long subjectId);
+    StartExamVO startExam(Long userId, Integer 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);
+    ExamDetailVO getExamDetail(Long userId, Long recordId);
+}

+ 23 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/StudyBookService.java

@@ -0,0 +1,23 @@
+package com.xuekairui.quiz.service;
+
+import com.xuekairui.quiz.dto.AnswerItem;
+import com.xuekairui.quiz.vo.ChapterProgressVO;
+import com.xuekairui.quiz.vo.ChapterQuestionsVO;
+import com.xuekairui.quiz.vo.ChapterTreeVO;
+import com.xuekairui.quiz.vo.PageVO;
+import com.xuekairui.quiz.vo.PracticeSubmitVO;
+import com.xuekairui.quiz.vo.StudyBookQuestionVO;
+import com.xuekairui.quiz.vo.StudyOverviewVO;
+import com.xuekairui.quiz.vo.SubjectSimpleVO;
+
+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);
+    ChapterProgressVO getChapterProgress(Long userId, Long subjectId);
+    StudyOverviewVO getOverview(Long userId);
+    PageVO<StudyBookQuestionVO> getQuestionList(Long userId, String type, Long subjectId, int pageNo, int pageSize);
+}

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

@@ -0,0 +1,21 @@
+package com.xuekairui.quiz.service;
+
+import com.xuekairui.quiz.vo.PageVO;
+import com.xuekairui.quiz.vo.QuestionDetailVO;
+import com.xuekairui.quiz.vo.QuestionVO;
+import com.xuekairui.quiz.vo.SubmitAnswerVO;
+import com.xuekairui.quiz.vo.ToggleCollectVO;
+
+public interface UserQuestionService {
+    /** 获取考点下的题目列表(练习模式,不返回答案) */
+    PageVO<QuestionVO> listByChapter(Long chapterId, String order, Long userId, int pageNo, int pageSize);
+
+    /** 提交单题答案 */
+    SubmitAnswerVO submitAnswer(Long userId, Long questionId, String userAnswer, Integer source, Integer timeSpent);
+
+    /** 收藏/取消收藏 */
+    ToggleCollectVO toggleCollect(Long userId, Long questionId);
+
+    /** 题目详情(含答案和解析) */
+    QuestionDetailVO detail(Long questionId, Long userId);
+}

+ 149 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/DailyPracticeServiceImpl.java

@@ -0,0 +1,149 @@
+package com.xuekairui.quiz.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.xuekairui.quiz.dto.AnswerItem;
+import com.xuekairui.quiz.entity.QuizQuestion;
+import com.xuekairui.quiz.entity.QuizUserRecord;
+import com.xuekairui.quiz.mapper.QuizQuestionMapper;
+import com.xuekairui.quiz.mapper.QuizUserRecordMapper;
+import com.xuekairui.quiz.service.DailyPracticeService;
+import com.xuekairui.quiz.vo.DailyPracticeQuestionsVO;
+import com.xuekairui.quiz.vo.PracticeResultVO;
+import com.xuekairui.quiz.vo.PracticeSubmitVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Random;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class DailyPracticeServiceImpl implements DailyPracticeService {
+
+    private final QuizQuestionMapper questionMapper;
+    private final QuizUserRecordMapper userRecordMapper;
+
+    @Override
+    public DailyPracticeQuestionsVO getQuestions(Long userId) {
+        LocalDate today = LocalDate.now();
+        LocalDateTime todayStart = today.atStartOfDay();
+
+        // 检查今天是否已完成
+        long doneCount = userRecordMapper.selectCount(
+                new LambdaQueryWrapper<QuizUserRecord>()
+                        .eq(QuizUserRecord::getUserId, userId)
+                        .eq(QuizUserRecord::getAnswerType, 2)
+                        .ge(QuizUserRecord::getCreateTime, todayStart)
+                        .eq(QuizUserRecord::getStatus, 1)
+        );
+        boolean hasDone = doneCount > 0;
+
+        // 按日期种子随机抽取10道题(同一天同一用户题目固定)
+        long seed = today.toEpochDay() + userId;
+        Random random = new Random(seed);
+        List<QuizQuestion> allQuestions = questionMapper.selectList(
+                new LambdaQueryWrapper<QuizQuestion>().eq(QuizQuestion::getIsReal, 0));
+        Collections.shuffle(allQuestions, random);
+        List<QuizQuestion> selected = allQuestions.stream().limit(10).collect(Collectors.toList());
+
+        List<DailyPracticeQuestionsVO.DailyQuestionVO> questionList = new ArrayList<>();
+        for (QuizQuestion q : selected) {
+            questionList.add(DailyPracticeQuestionsVO.DailyQuestionVO.builder()
+                    .id(q.getId())
+                    .questionType(q.getQuestionType())
+                    .questionTypeName(getTypeName(q.getQuestionType()))
+                    .stem(q.getStem())
+                    .options(q.getOptions())
+                    .build());
+        }
+
+        return DailyPracticeQuestionsVO.builder()
+                .date(today.toString())
+                .questionCount(selected.size())
+                .hasDone(hasDone)
+                .questions(questionList)
+                .build();
+    }
+
+    @Override
+    @Transactional
+    public PracticeSubmitVO submit(Long userId, List<AnswerItem> answers) {
+        int correctCount = 0;
+        int wrongCount = 0;
+        List<PracticeResultVO> results = new ArrayList<>();
+        List<Map<String, Object>> answerRecords = new ArrayList<>();
+
+        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;
+
+            boolean isCorrect = question.getAnswer().equalsIgnoreCase(userAnswer);
+            if (isCorrect) correctCount++;
+            else wrongCount++;
+
+            results.add(PracticeResultVO.builder()
+                    .questionId(questionId)
+                    .isCorrect(isCorrect ? 1 : 0)
+                    .correctAnswer(question.getAnswer())
+                    .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;
+
+        // 保存做题记录
+        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);
+
+        return PracticeSubmitVO.builder()
+                .correctCount(correctCount)
+                .wrongCount(wrongCount)
+                .correctRate(correctRate)
+                .results(results)
+                .build();
+    }
+
+    private String getTypeName(Integer type) {
+        if (type == null) return "";
+        return switch (type) {
+            case 1 -> "最佳选择题";
+            case 2 -> "多项选择题";
+            case 3 -> "配伍选择题";
+            case 4 -> "综合分析选择题";
+            default -> "";
+        };
+    }
+}

+ 114 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/HomeServiceImpl.java

@@ -0,0 +1,114 @@
+package com.xuekairui.quiz.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+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.service.HomeService;
+import com.xuekairui.quiz.vo.HomeIndexVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class HomeServiceImpl implements HomeService {
+
+    private final QuizSubjectMapper subjectMapper;
+    private final QuizQuestionMapper questionMapper;
+    private final QuizUserRecordMapper userRecordMapper;
+
+    @Override
+    public HomeIndexVO getIndex(Long userId) {
+        // 考试信息
+        LocalDate examDate = LocalDate.of(2026, 10, 25);
+        HomeIndexVO.ExamInfoVO examInfo = HomeIndexVO.ExamInfoVO.builder()
+                .name("2026全国执业药师统考")
+                .examDate(examDate.toString())
+                .daysLeft(Math.max(0, ChronoUnit.DAYS.between(LocalDate.now(), examDate)))
+                .build();
+
+        // 每日一练状态
+        LocalDateTime todayStart = LocalDate.now().atStartOfDay();
+        long dailyDone = userRecordMapper.selectCount(
+                new LambdaQueryWrapper<QuizUserRecord>()
+                        .eq(QuizUserRecord::getUserId, userId)
+                        .eq(QuizUserRecord::getAnswerType, 2)
+                        .ge(QuizUserRecord::getCreateTime, todayStart)
+        );
+        HomeIndexVO.DailyPracticeVO dailyPractice = HomeIndexVO.DailyPracticeVO.builder()
+                .hasDone(dailyDone > 0)
+                .doneCount(dailyDone > 0 ? 10L : 0L)
+                .totalCount(10)
+                .build();
+
+        // 科目列表(含进度)
+        List<QuizSubject> subjects = subjectMapper.selectList(
+                new LambdaQueryWrapper<QuizSubject>().orderByAsc(QuizSubject::getSort));
+        List<HomeIndexVO.HomeSubjectVO> subjectList = new ArrayList<>();
+        for (QuizSubject s : subjects) {
+            long doneCount = userRecordMapper.selectCount(
+                    new LambdaQueryWrapper<QuizUserRecord>()
+                            .eq(QuizUserRecord::getUserId, userId)
+                            .eq(QuizUserRecord::getSubjectId, s.getId())
+                            .eq(QuizUserRecord::getStatus, 1)
+            );
+            subjectList.add(HomeIndexVO.HomeSubjectVO.builder()
+                    .id(s.getId())
+                    .name(s.getName())
+                    .icon("")
+                    .totalCount(s.getQuestionCount())
+                    .doneCount(doneCount)
+                    .progress(s.getQuestionCount() > 0 ? (int) (doneCount * 100 / s.getQuestionCount()) : 0)
+                    .build());
+        }
+
+        // 最新真题
+        List<QuizQuestion> realQuestions = questionMapper.selectList(
+                new LambdaQueryWrapper<QuizQuestion>()
+                        .eq(QuizQuestion::getIsReal, 1)
+                        .isNotNull(QuizQuestion::getYear)
+                        .select(QuizQuestion::getYear, QuizQuestion::getSubjectId)
+                        .groupBy(QuizQuestion::getYear, QuizQuestion::getSubjectId)
+                        .orderByDesc(QuizQuestion::getYear)
+                        .last("LIMIT 10")
+        );
+        // 按year+subject聚合
+        Map<String, HomeIndexVO.LatestExamPaperVO> paperMap = new LinkedHashMap<>();
+        for (QuizQuestion q : realQuestions) {
+            String key = q.getYear() + "-" + q.getSubjectId();
+            if (!paperMap.containsKey(key)) {
+                QuizSubject sub = subjectMapper.selectById(q.getSubjectId());
+                long count = questionMapper.selectCount(
+                        new LambdaQueryWrapper<QuizQuestion>()
+                                .eq(QuizQuestion::getIsReal, 1)
+                                .eq(QuizQuestion::getYear, q.getYear())
+                                .eq(QuizQuestion::getSubjectId, q.getSubjectId())
+                );
+                paperMap.put(key, HomeIndexVO.LatestExamPaperVO.builder()
+                        .year(q.getYear())
+                        .subjectId(q.getSubjectId())
+                        .subjectName(sub != null ? sub.getName() : "")
+                        .questionCount(count)
+                        .build());
+            }
+        }
+
+        return HomeIndexVO.builder()
+                .examInfo(examInfo)
+                .dailyPractice(dailyPractice)
+                .subjects(subjectList)
+                .latestExamPapers(new ArrayList<>(paperMap.values()))
+                .build();
+    }
+}

+ 333 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizChapterImportServiceImpl.java

@@ -0,0 +1,333 @@
+package com.xuekairui.quiz.service.impl;
+
+import com.alibaba.excel.EasyExcel;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.quiz.dto.ChapterImportDTO;
+import com.xuekairui.quiz.entity.QuizChapter;
+import com.xuekairui.quiz.entity.QuizChapterImportBatch;
+import com.xuekairui.quiz.entity.QuizSubject;
+import com.xuekairui.quiz.mapper.QuizChapterImportBatchMapper;
+import com.xuekairui.quiz.mapper.QuizChapterMapper;
+import com.xuekairui.quiz.mapper.QuizSubjectMapper;
+import com.xuekairui.quiz.service.QuizChapterImportService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class QuizChapterImportServiceImpl extends ServiceImpl<QuizChapterImportBatchMapper, QuizChapterImportBatch> implements QuizChapterImportService {
+
+    private final QuizSubjectMapper subjectMapper;
+    private final QuizChapterMapper chapterMapper;
+
+    @Override
+    @Transactional
+    public QuizChapterImportBatch batchImport(MultipartFile file, Long adminId) {
+        if (file == null || file.isEmpty()) {
+            throw new BusinessException(ErrorCode.QUIZ_IMPORT_FILE_EMPTY);
+        }
+        String fileName = file.getOriginalFilename();
+        if (fileName == null || (!fileName.endsWith(".xlsx") && !fileName.endsWith(".xls"))) {
+            throw new BusinessException(ErrorCode.QUIZ_IMPORT_FILE_FORMAT_ERROR, "请上传Excel文件(.xlsx)");
+        }
+
+        String batchNo = "CH" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
+        QuizChapterImportBatch batch = QuizChapterImportBatch.builder()
+                .batchNo(batchNo)
+                .fileName(fileName)
+                .fileSize(file.getSize())
+                .adminId(adminId)
+                .status(0)
+                .build();
+        save(batch);
+
+        // 读取Excel所有行
+        List<ChapterImportDTO> rows;
+        try {
+            rows = EasyExcel.read(file.getInputStream())
+                    .head(ChapterImportDTO.class)
+                    .sheet()
+                    .doReadSync();
+        } catch (Exception e) {
+            log.error("Excel解析失败", e);
+            throw new BusinessException(ErrorCode.QUIZ_IMPORT_FILE_FORMAT_ERROR, "Excel文件解析失败:" + e.getMessage());
+        }
+
+        List<Map<String, Object>> failDetails = new ArrayList<>();
+        int totalCount = rows.size();
+        int successCount = 0;
+        int rowCounter = 0;
+
+        // 状态追踪:当前科目/章/节
+        Long currentSubjectId = null;
+        String currentSubjectName = null;
+        Long currentChapterId = null;
+        String currentChapterName = null;
+        Long currentSectionId = null;
+        String currentSectionName = null;
+
+        // 预加载科目缓存
+        Map<String, Long> subjectCache = new HashMap<>();
+        List<QuizSubject> subjects = subjectMapper.selectList(null);
+        for (QuizSubject s : subjects) {
+            subjectCache.put(s.getName(), s.getId());
+        }
+
+        for (ChapterImportDTO row : rows) {
+            rowCounter++;
+            try {
+                // 判断行类型(从最深层级往上判断)
+                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(), "考点行缺少科目/章/节信息"));
+                        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);
+                        }
+                        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 (!row.getSectionName().equals(currentSectionName)) {
+                        currentSectionId = getOrCreateSection(currentSubjectId, currentChapterId, row.getSectionName(), parseSort(row.getSectionSort(), rowCounter));
+                        currentSectionName = row.getSectionName();
+                    }
+
+                    // 创建考点
+                    getOrCreateExamPoint(currentSubjectId, currentSectionId, row.getExamPointName(), parseSort(row.getExamPointSort(), rowCounter));
+                    successCount++;
+
+                } else if (StringUtils.hasText(row.getSectionName())) {
+                    // 节行
+                    if (!StringUtils.hasText(row.getSubjectName()) || !StringUtils.hasText(row.getChapterName())) {
+                        failDetails.add(buildFail(rowCounter + 1, row.getSectionName(), "节行缺少科目/章信息"));
+                        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);
+                        }
+                        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;
+                    }
+
+                    currentSectionId = getOrCreateSection(currentSubjectId, currentChapterId, row.getSectionName(), parseSort(row.getSectionSort(), rowCounter));
+                    currentSectionName = row.getSectionName();
+                    successCount++;
+
+                } else if (StringUtils.hasText(row.getChapterName())) {
+                    // 章行
+                    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);
+                        }
+                        currentSubjectName = row.getSubjectName();
+                        currentChapterId = null;
+                        currentChapterName = null;
+                        currentSectionId = null;
+                        currentSectionName = null;
+                    }
+
+                    currentChapterId = getOrCreateChapter(currentSubjectId, row.getChapterName(), parseSort(row.getChapterSort(), rowCounter));
+                    currentChapterName = row.getChapterName();
+                    currentSectionId = null;
+                    currentSectionName = null;
+                    successCount++;
+
+                } else if (StringUtils.hasText(row.getSubjectName())) {
+                    // 科目行
+                    currentSubjectId = subjectCache.get(row.getSubjectName());
+                    if (currentSubjectId == null) {
+                        currentSubjectId = createSubject(row.getSubjectName(), parseSort(row.getSubjectSort(), rowCounter));
+                        subjectCache.put(row.getSubjectName(), currentSubjectId);
+                    }
+                    currentSubjectName = row.getSubjectName();
+                    currentChapterId = null;
+                    currentChapterName = null;
+                    currentSectionId = null;
+                    currentSectionName = null;
+                    successCount++;
+
+                } else {
+                    failDetails.add(buildFail(rowCounter + 1, "", "空行,无任何数据"));
+                }
+            } catch (Exception e) {
+                failDetails.add(buildFail(rowCounter + 1, getRowContent(row), e.getMessage()));
+            }
+        }
+
+        int failCount = totalCount - successCount;
+        BigDecimal passRate = totalCount > 0 ? BigDecimal.valueOf(successCount * 100.0 / totalCount).setScale(2, RoundingMode.HALF_UP) : BigDecimal.ZERO;
+
+        batch.setTotalCount(totalCount);
+        batch.setSuccessCount(successCount);
+        batch.setFailCount(failCount);
+        batch.setPassRate(passRate);
+        batch.setFailDetails(failDetails);
+        batch.setStatus(failCount == 0 ? 2 : 1);
+        updateById(batch);
+
+        return batch;
+    }
+
+    private Long createSubject(String name, int sort) {
+        QuizSubject subject = QuizSubject.builder()
+                .name(name)
+                .sort(sort)
+                .build();
+        subjectMapper.insert(subject);
+        return subject.getId();
+    }
+
+    private Long getOrCreateChapter(Long subjectId, String name, int sort) {
+        QuizChapter existing = chapterMapper.selectOne(
+                new LambdaQueryWrapper<QuizChapter>()
+                        .eq(QuizChapter::getSubjectId, subjectId)
+                        .eq(QuizChapter::getParentId, 0L)
+                        .eq(QuizChapter::getName, name)
+                        .eq(QuizChapter::getLevel, 2)
+        );
+        if (existing != null) return existing.getId();
+
+        QuizChapter node = QuizChapter.builder()
+                .subjectId(subjectId)
+                .parentId(0L)
+                .name(name)
+                .level(2)
+                .sort(sort)
+                .build();
+        chapterMapper.insert(node);
+        return node.getId();
+    }
+
+    private Long getOrCreateSection(Long subjectId, Long chapterId, String name, int sort) {
+        QuizChapter existing = chapterMapper.selectOne(
+                new LambdaQueryWrapper<QuizChapter>()
+                        .eq(QuizChapter::getSubjectId, subjectId)
+                        .eq(QuizChapter::getParentId, chapterId)
+                        .eq(QuizChapter::getName, name)
+                        .eq(QuizChapter::getLevel, 3)
+        );
+        if (existing != null) return existing.getId();
+
+        QuizChapter node = QuizChapter.builder()
+                .subjectId(subjectId)
+                .parentId(chapterId)
+                .name(name)
+                .level(3)
+                .sort(sort)
+                .build();
+        chapterMapper.insert(node);
+        return node.getId();
+    }
+
+    private Long getOrCreateExamPoint(Long subjectId, Long sectionId, String name, int sort) {
+        QuizChapter existing = chapterMapper.selectOne(
+                new LambdaQueryWrapper<QuizChapter>()
+                        .eq(QuizChapter::getSubjectId, subjectId)
+                        .eq(QuizChapter::getParentId, sectionId)
+                        .eq(QuizChapter::getName, name)
+                        .eq(QuizChapter::getLevel, 4)
+        );
+        if (existing != null) return existing.getId();
+
+        QuizChapter node = QuizChapter.builder()
+                .subjectId(subjectId)
+                .parentId(sectionId)
+                .name(name)
+                .level(4)
+                .sort(sort)
+                .build();
+        chapterMapper.insert(node);
+        return node.getId();
+    }
+
+    private int parseSort(String sortStr, int defaultVal) {
+        if (!StringUtils.hasText(sortStr)) return defaultVal;
+        try {
+            // 处理Excel可能带来的小数格式(如 "1.0")
+            double d = Double.parseDouble(sortStr.trim());
+            return (int) d;
+        } catch (NumberFormatException e) {
+            return defaultVal;
+        }
+    }
+
+    private String getRowContent(ChapterImportDTO row) {
+        if (StringUtils.hasText(row.getExamPointName())) return row.getExamPointName();
+        if (StringUtils.hasText(row.getSectionName())) return row.getSectionName();
+        if (StringUtils.hasText(row.getChapterName())) return row.getChapterName();
+        if (StringUtils.hasText(row.getSubjectName())) return row.getSubjectName();
+        return "";
+    }
+
+    private Map<String, Object> buildFail(int row, String content, String reason) {
+        Map<String, Object> fail = new LinkedHashMap<>();
+        fail.put("row", row);
+        fail.put("content", content);
+        fail.put("reason", reason);
+        return fail;
+    }
+
+    @Override
+    public IPage<QuizChapterImportBatch> importBatchList(int pageNo, int pageSize) {
+        return page(new Page<>(pageNo, pageSize),
+                new LambdaQueryWrapper<QuizChapterImportBatch>().orderByDesc(QuizChapterImportBatch::getCreateTime));
+    }
+}

+ 209 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizChapterServiceImpl.java

@@ -0,0 +1,209 @@
+package com.xuekairui.quiz.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.quiz.entity.QuizChapter;
+import com.xuekairui.quiz.entity.QuizQuestion;
+import com.xuekairui.quiz.entity.QuizSubject;
+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 lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class QuizChapterServiceImpl extends ServiceImpl<QuizChapterMapper, QuizChapter> implements QuizChapterService {
+
+    private final QuizQuestionMapper questionMapper;
+    private final QuizSubjectMapper subjectMapper;
+
+    private static final String[] LEVEL_NAMES = {"", "科目", "章", "节", "考点"};
+
+    @Override
+    public Map<String, Object> getTree(String keyword) {
+        // 获取所有科目(level 1)
+        List<QuizSubject> subjects = subjectMapper.selectList(
+                new LambdaQueryWrapper<QuizSubject>().orderByAsc(QuizSubject::getSort));
+        // 获取所有章节节点(level 2-4)
+        List<QuizChapter> allChapters = list(
+                new LambdaQueryWrapper<QuizChapter>().orderByAsc(QuizChapter::getSort));
+
+        // 如果有关键词搜索,过滤匹配的节点及其父节点路径
+        if (StringUtils.hasText(keyword)) {
+            Set<Long> matchedChapterIds = new HashSet<>();
+            Set<Long> matchedSubjectIds = new HashSet<>();
+            for (QuizChapter ch : allChapters) {
+                if (ch.getName().contains(keyword)) {
+                    matchedChapterIds.add(ch.getId());
+                    addParentIds(allChapters, ch.getParentId(), matchedChapterIds, matchedSubjectIds);
+                }
+            }
+            for (QuizSubject s : subjects) {
+                if (s.getName().contains(keyword)) {
+                    matchedSubjectIds.add(s.getId());
+                }
+            }
+            // 如果匹配了子节点,其所属科目也要保留
+            for (QuizChapter ch : allChapters) {
+                if (matchedChapterIds.contains(ch.getId())) {
+                    matchedSubjectIds.add(ch.getSubjectId());
+                }
+            }
+            final Set<Long> finalSubjectIds = matchedSubjectIds;
+            final Set<Long> finalChapterIds = matchedChapterIds;
+            subjects = subjects.stream().filter(s -> finalSubjectIds.contains(s.getId())).collect(Collectors.toList());
+            allChapters = allChapters.stream().filter(ch -> finalChapterIds.contains(ch.getId())).collect(Collectors.toList());
+        }
+
+        // 构建树:科目作为根节点
+        List<Map<String, Object>> tree = new ArrayList<>();
+        for (QuizSubject s : subjects) {
+            Map<String, Object> subjectNode = new LinkedHashMap<>();
+            subjectNode.put("id", s.getId());
+            subjectNode.put("subjectId", s.getId());
+            subjectNode.put("parentId", 0L);
+            subjectNode.put("name", s.getName());
+            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()));
+            tree.add(subjectNode);
+        }
+
+        // 统计
+        long totalExamPoints = allChapters.stream().filter(c -> c.getLevel() == 4).count();
+
+        Map<String, Object> result = new HashMap<>();
+        result.put("tree", tree);
+        result.put("summary", "共 " + subjects.size() + " 个科目 · 已维护考点 " + totalExamPoints + " 个");
+        return result;
+    }
+
+    private void addParentIds(List<QuizChapter> allChapters, Long parentId, Set<Long> matchedChapterIds, Set<Long> matchedSubjectIds) {
+        if (parentId == null || parentId == 0L) return;
+        matchedChapterIds.add(parentId);
+        for (QuizChapter ch : allChapters) {
+            if (ch.getId().equals(parentId)) {
+                matchedSubjectIds.add(ch.getSubjectId());
+                addParentIds(allChapters, ch.getParentId(), matchedChapterIds, matchedSubjectIds);
+                break;
+            }
+        }
+    }
+
+    private List<Map<String, Object>> buildTree(List<QuizChapter> chapters, Long parentId) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        for (QuizChapter ch : chapters) {
+            if (Objects.equals(ch.getParentId(), parentId)) {
+                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("questionCount", ch.getQuestionCount());
+                node.put("updateTime", ch.getUpdateTime());
+                node.put("children", buildTree(chapters, ch.getId()));
+                result.add(node);
+            }
+        }
+        return result;
+    }
+
+    @Override
+    public QuizChapter addChild(Long parentId, String name, Integer sort) {
+        // 先尝试在章节表中查找父节点
+        QuizChapter parentChapter = getById(parentId);
+        Long subjectId;
+        int childLevel;
+
+        if (parentChapter != null) {
+            // 父节点是章/节(level 2-3),子节点 level = parent + 1
+            subjectId = parentChapter.getSubjectId();
+            childLevel = parentChapter.getLevel() + 1;
+        } else {
+            // 父节点可能是科目(level 1),在科目表中查找
+            QuizSubject parentSubject = subjectMapper.selectById(parentId);
+            if (parentSubject == null) {
+                throw new BusinessException(ErrorCode.QUIZ_CHAPTER_NOT_FOUND);
+            }
+            subjectId = parentSubject.getId();
+            childLevel = 2; // 科目下新增章
+        }
+
+        if (childLevel > 4) {
+            throw new BusinessException(ErrorCode.QUIZ_CHAPTER_NOT_FOUND, "考点节点下不能再添加子节点");
+        }
+        QuizChapter child = QuizChapter.builder()
+                .subjectId(subjectId)
+                .parentId(parentId)
+                .name(name)
+                .level(childLevel)
+                .sort(sort != null ? sort : 0)
+                .build();
+        save(child);
+        return child;
+    }
+
+    @Override
+    public QuizChapter update(Long id, String name, Integer sort) {
+        QuizChapter chapter = getById(id);
+        if (chapter == null) {
+            throw new BusinessException(ErrorCode.QUIZ_CHAPTER_NOT_FOUND);
+        }
+        chapter.setName(name);
+        if (sort != null) {
+            chapter.setSort(sort);
+        }
+        updateById(chapter);
+        return chapter;
+    }
+
+    @Override
+    public void delete(Long id) {
+        QuizChapter chapter = getById(id);
+        if (chapter == null) {
+            throw new BusinessException(ErrorCode.QUIZ_CHAPTER_NOT_FOUND);
+        }
+        // 检查子节点
+        long childCount = count(new LambdaQueryWrapper<QuizChapter>().eq(QuizChapter::getParentId, id));
+        if (childCount > 0) {
+            throw new BusinessException(ErrorCode.QUIZ_CHAPTER_HAS_CHILDREN);
+        }
+        // 检查关联题目
+        long questionCount = questionMapper.selectCount(
+                new LambdaQueryWrapper<QuizQuestion>().eq(QuizQuestion::getChapterId, id)
+        );
+        if (questionCount > 0) {
+            throw new BusinessException(ErrorCode.QUIZ_CHAPTER_HAS_QUESTIONS);
+        }
+        removeById(id);
+    }
+
+    @Override
+    public List<QuizChapter> listBySubject(Long subjectId) {
+        return list(new LambdaQueryWrapper<QuizChapter>()
+                .eq(QuizChapter::getSubjectId, subjectId)
+                .eq(QuizChapter::getLevel, 2)
+                .orderByAsc(QuizChapter::getSort));
+    }
+
+    @Override
+    public List<QuizChapter> listByParent(Long parentId) {
+        return list(new LambdaQueryWrapper<QuizChapter>()
+                .eq(QuizChapter::getParentId, parentId)
+                .orderByAsc(QuizChapter::getSort));
+    }
+}

+ 637 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizQuestionServiceImpl.java

@@ -0,0 +1,637 @@
+package com.xuekairui.quiz.service.impl;
+
+import com.alibaba.excel.EasyExcel;
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.quiz.dto.QuestionImportDTO;
+import com.xuekairui.quiz.dto.RealQuestionImportDTO;
+import com.xuekairui.quiz.entity.*;
+import com.xuekairui.quiz.mapper.*;
+import com.xuekairui.quiz.service.QuizQuestionService;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class QuizQuestionServiceImpl extends ServiceImpl<QuizQuestionMapper, QuizQuestion> implements QuizQuestionService {
+
+    private final QuizSubjectMapper subjectMapper;
+    private final QuizChapterMapper chapterMapper;
+    private final QuizQuestionImportBatchMapper importBatchMapper;
+
+    @Override
+    public IPage<Map<String, Object>> listQuestions(Long subjectId, String keyword, int pageNo, int pageSize) {
+        LambdaQueryWrapper<QuizQuestion> wrapper = new LambdaQueryWrapper<QuizQuestion>()
+                .eq(QuizQuestion::getIsReal, 0)
+                .eq(subjectId != null, QuizQuestion::getSubjectId, subjectId)
+                .like(StringUtils.hasText(keyword), QuizQuestion::getStem, keyword)
+                .orderByDesc(QuizQuestion::getUpdateTime);
+        IPage<QuizQuestion> page = page(new Page<>(pageNo, pageSize), wrapper);
+        return convertPage(page);
+    }
+
+    @Override
+    public QuizQuestion detail(Long id) {
+        QuizQuestion q = getById(id);
+        if (q == null) {
+            throw new BusinessException(ErrorCode.QUIZ_QUESTION_NOT_FOUND);
+        }
+        return q;
+    }
+
+    @Override
+    @Transactional
+    public QuizQuestion add(QuizQuestion question) {
+        question.setIsReal(0);
+        question.setYear(null);
+        save(question);
+        updateQuestionCount(question.getSubjectId(), question.getChapterId(), 1);
+        return question;
+    }
+
+    @Override
+    @Transactional
+    public QuizQuestion update(QuizQuestion question) {
+        QuizQuestion existing = getById(question.getId());
+        if (existing == null) {
+            throw new BusinessException(ErrorCode.QUIZ_QUESTION_NOT_FOUND);
+        }
+        updateById(question);
+        return question;
+    }
+
+    @Override
+    @Transactional
+    public void delete(Long id) {
+        QuizQuestion q = getById(id);
+        if (q == null) {
+            throw new BusinessException(ErrorCode.QUIZ_QUESTION_NOT_FOUND);
+        }
+        removeById(id);
+        updateQuestionCount(q.getSubjectId(), q.getChapterId(), -1);
+    }
+
+    @Override
+    @Transactional
+    public void batchDelete(List<Long> ids) {
+        List<QuizQuestion> questions = listByIds(ids);
+        if (questions.isEmpty()) return;
+        removeByIds(ids);
+        for (QuizQuestion q : questions) {
+            updateQuestionCount(q.getSubjectId(), q.getChapterId(), -1);
+        }
+    }
+
+    @Override
+    @Transactional
+    public QuizQuestionImportBatch batchImport(MultipartFile file, Long adminId) {
+        return doImport(file, adminId, false);
+    }
+
+    @Override
+    public IPage<QuizQuestionImportBatch> importBatchList(int pageNo, int pageSize) {
+        return importBatchMapper.selectPage(
+                new Page<>(pageNo, pageSize),
+                new LambdaQueryWrapper<QuizQuestionImportBatch>()
+                        .likeRight(QuizQuestionImportBatch::getBatchNo, "Q")
+                        .orderByDesc(QuizQuestionImportBatch::getCreateTime)
+        );
+    }
+
+    @Override
+    public IPage<Map<String, Object>> listRealQuestions(Integer 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)
+                .eq(subjectId != null, QuizQuestion::getSubjectId, subjectId)
+                .eq(questionType != null, QuizQuestion::getQuestionType, questionType)
+                .like(StringUtils.hasText(keyword), QuizQuestion::getStem, keyword)
+                .orderByDesc(QuizQuestion::getYear)
+                .orderByDesc(QuizQuestion::getUpdateTime);
+        IPage<QuizQuestion> page = page(new Page<>(pageNo, pageSize), wrapper);
+        return convertPage(page);
+    }
+
+    @Override
+    @Transactional
+    public QuizQuestion addReal(QuizQuestion question) {
+        question.setIsReal(1);
+        save(question);
+        updateQuestionCount(question.getSubjectId(), question.getChapterId(), 1);
+        return question;
+    }
+
+    @Override
+    @Transactional
+    public QuizQuestion updateReal(QuizQuestion question) {
+        QuizQuestion existing = getById(question.getId());
+        if (existing == null) {
+            throw new BusinessException(ErrorCode.QUIZ_QUESTION_NOT_FOUND);
+        }
+        updateById(question);
+        return question;
+    }
+
+    @Override
+    @Transactional
+    public QuizQuestionImportBatch batchImportReal(MultipartFile file, Long adminId) {
+        return doImport(file, adminId, true);
+    }
+
+    @Override
+    public IPage<QuizQuestionImportBatch> realImportBatchList(int pageNo, int pageSize) {
+        return importBatchMapper.selectPage(
+                new Page<>(pageNo, pageSize),
+                new LambdaQueryWrapper<QuizQuestionImportBatch>()
+                        .likeRight(QuizQuestionImportBatch::getBatchNo, "REAL")
+                        .orderByDesc(QuizQuestionImportBatch::getCreateTime)
+        );
+    }
+
+    @Override
+    public List<Integer> getRealYears() {
+        LambdaQueryWrapper<QuizQuestion> wrapper = new LambdaQueryWrapper<QuizQuestion>()
+                .eq(QuizQuestion::getIsReal, 1)
+                .isNotNull(QuizQuestion::getYear)
+                .select(QuizQuestion::getYear)
+                .groupBy(QuizQuestion::getYear)
+                .orderByDesc(QuizQuestion::getYear);
+        return list(wrapper).stream()
+                .map(QuizQuestion::getYear)
+                .distinct()
+                .sorted(Comparator.reverseOrder())
+                .collect(Collectors.toList());
+    }
+
+    @Override
+    public byte[] downloadFailDetail(Long batchId) {
+        QuizQuestionImportBatch batch = importBatchMapper.selectById(batchId);
+        if (batch == null || batch.getFailDetails() == null || batch.getFailDetails().isEmpty()) {
+            throw new BusinessException(ErrorCode.QUIZ_QUESTION_NOT_FOUND, "批次记录不存在或无失败明细");
+        }
+        try (java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream()) {
+            EasyExcel.write(bos)
+                    .sheet("失败明细")
+                    .head(buildFailHead())
+                    .doWrite(buildFailData(batch.getFailDetails()));
+            return bos.toByteArray();
+        } catch (Exception e) {
+            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "生成失败明细Excel异常");
+        }
+    }
+
+    // ==================== 核心导入逻辑 ====================
+
+    private QuizQuestionImportBatch doImport(MultipartFile file, Long adminId, boolean isReal) {
+        if (file == null || file.isEmpty()) {
+            throw new BusinessException(ErrorCode.QUIZ_IMPORT_FILE_EMPTY);
+        }
+        String fileName = file.getOriginalFilename();
+        if (fileName == null || (!fileName.endsWith(".xlsx") && !fileName.endsWith(".xls"))) {
+            throw new BusinessException(ErrorCode.QUIZ_IMPORT_FILE_FORMAT_ERROR, "请上传Excel文件(.xlsx)");
+        }
+
+        String prefix = isReal ? "REAL" : "Q";
+        String batchNo = prefix + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss"));
+        QuizQuestionImportBatch batch = QuizQuestionImportBatch.builder()
+                .batchNo(batchNo)
+                .fileName(fileName)
+                .fileSize(file.getSize())
+                .adminId(adminId)
+                .status(0)
+                .build();
+        importBatchMapper.insert(batch);
+
+        // 读取Excel(真题和练习题表头不同,使用不同DTO)
+        List<QuestionImportDTO> rows;
+        try {
+            if (isReal) {
+                List<RealQuestionImportDTO> realRows = EasyExcel.read(file.getInputStream())
+                        .head(RealQuestionImportDTO.class)
+                        .sheet()
+                        .doReadSync();
+                rows = realRows.stream().map(r -> {
+                    QuestionImportDTO dto = new QuestionImportDTO();
+                    dto.setYear(r.getYear());
+                    dto.setQuestionType(r.getQuestionType());
+                    dto.setMatchingGroup(normalizeGroup(r.getMatchingGroup()));
+                    dto.setSharedOptions(r.getSharedOptions());
+                    dto.setSubjectName(r.getSubjectName());
+                    dto.setChapterName(r.getChapterName());
+                    dto.setSectionName(r.getSectionName());
+                    dto.setExamPointName(r.getExamPointName());
+                    dto.setStem(r.getStem());
+                    dto.setOptionA(r.getOptionA());
+                    dto.setOptionB(r.getOptionB());
+                    dto.setOptionC(r.getOptionC());
+                    dto.setOptionD(r.getOptionD());
+                    dto.setOptionE(r.getOptionE());
+                    dto.setAnswer(r.getAnswer());
+                    dto.setAnalysis(r.getAnalysis());
+                    return dto;
+                }).collect(Collectors.toList());
+            } else {
+                rows = EasyExcel.read(file.getInputStream())
+                        .head(QuestionImportDTO.class)
+                        .sheet()
+                        .doReadSync();
+            }
+        } catch (Exception e) {
+            log.error("Excel解析失败", e);
+            throw new BusinessException(ErrorCode.QUIZ_IMPORT_FILE_FORMAT_ERROR, "Excel文件解析失败:" + e.getMessage());
+        }
+
+        // 构建缓存(名称做归一化,兼容全角/半角括号差异)
+        Map<String, Long> subjectCache = new HashMap<>();
+        for (QuizSubject s : subjectMapper.selectList(null)) {
+            subjectCache.put(normalize(s.getName()), s.getId());
+        }
+
+        // 章节全量缓存
+        List<QuizChapter> allChapters = chapterMapper.selectList(null);
+        Map<Long, QuizChapter> chapterById = new HashMap<>();
+        for (QuizChapter ch : allChapters) {
+            chapterById.put(ch.getId(), ch);
+        }
+
+        // 按名称索引:normalizedName → 匹配的chapter列表(可能重名)
+        Map<String, List<QuizChapter>> chaptersByName = new HashMap<>();
+        for (QuizChapter ch : allChapters) {
+            String key = normalize(ch.getName());
+            chaptersByName.computeIfAbsent(key, k -> new ArrayList<>()).add(ch);
+        }
+
+        // 完整路径缓存:科目名|章名|节名|考点名 → chapterId(level 4)
+        Map<String, Long> chapterPathCache = new HashMap<>();
+        for (QuizChapter ch : allChapters) {
+            if (ch.getLevel() == 4) {
+                String path = buildChapterPath(ch, chapterById, subjectCache);
+                if (path != null) {
+                    chapterPathCache.put(path, ch.getId());
+                }
+            }
+        }
+
+        List<Map<String, Object>> failDetails = new ArrayList<>();
+        int totalCount = rows.size();
+        int successCount = 0;
+        int rowNum = 1; // Excel行号(从2开始,1是表头)
+
+        // 配伍题共用选项追踪
+        String currentMatchingGroup = null;
+        String currentSharedOptions = null;
+
+        for (QuestionImportDTO row : rows) {
+            rowNum++;
+            try {
+                // 真题必须有年份
+                Integer 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;
+                    }
+                }
+
+                // 校验必填字段
+                if (!StringUtils.hasText(row.getStem())) {
+                    failDetails.add(buildFail(rowNum, "", "题干为空"));
+                    continue;
+                }
+                if (!StringUtils.hasText(row.getQuestionType())) {
+                    failDetails.add(buildFail(rowNum, row.getStem(), "试题类型为空"));
+                    continue;
+                }
+                if (!StringUtils.hasText(row.getAnswer())) {
+                    failDetails.add(buildFail(rowNum, row.getStem(), "正确答案为空"));
+                    continue;
+                }
+
+                // 解析题型
+                Integer questionType = parseQuestionType(row.getQuestionType().trim());
+                if (questionType == null) {
+                    failDetails.add(buildFail(rowNum, row.getStem(), "题型非法:" + row.getQuestionType()));
+                    continue;
+                }
+
+                // 解析科目
+                if (!StringUtils.hasText(row.getSubjectName())) {
+                    failDetails.add(buildFail(rowNum, row.getStem(), "关联科目为空"));
+                    continue;
+                }
+                Long subjectId = subjectCache.get(normalize(row.getSubjectName().trim()));
+                if (subjectId == null) {
+                    failDetails.add(buildFail(rowNum, row.getStem(), "科目不存在:" + row.getSubjectName()));
+                    continue;
+                }
+
+                // 解析考点路径(灵活匹配:优先完整路径,降级按名称匹配)
+                Long chapterId = null;
+                Long actualSubjectId = subjectId;
+                if (StringUtils.hasText(row.getExamPointName())) {
+                    // 先尝试完整路径匹配
+                    String pathKey = normalize(row.getSubjectName().trim()) + "|"
+                            + (StringUtils.hasText(row.getChapterName()) ? normalize(row.getChapterName().trim()) : "") + "|"
+                            + (StringUtils.hasText(row.getSectionName()) ? normalize(row.getSectionName().trim()) : "") + "|"
+                            + normalize(row.getExamPointName().trim());
+                    chapterId = chapterPathCache.get(pathKey);
+
+                    // 降级:按考点名称全局查找
+                    if (chapterId == null) {
+                        chapterId = findChapterByName(chaptersByName, normalize(row.getExamPointName().trim()), chapterById, subjectCache);
+                    }
+                    // 再降级:按节名称查找
+                    if (chapterId == null && StringUtils.hasText(row.getSectionName())) {
+                        chapterId = findChapterByName(chaptersByName, normalize(row.getSectionName().trim()), chapterById, subjectCache);
+                    }
+                    // 再降级:按章名称查找
+                    if (chapterId == null && StringUtils.hasText(row.getChapterName())) {
+                        chapterId = findChapterByName(chaptersByName, normalize(row.getChapterName().trim()), chapterById, subjectCache);
+                    }
+                    if (chapterId == null) {
+                        failDetails.add(buildFail(rowNum, row.getStem(), "考点不存在:" + row.getExamPointName()));
+                        continue;
+                    }
+                    // 使用匹配到的章节的实际科目
+                    QuizChapter matchedChapter = chapterById.get(chapterId);
+                    if (matchedChapter != null) {
+                        actualSubjectId = matchedChapter.getSubjectId();
+                    }
+                }
+
+                // 处理配伍题/综合分析题共用选项(共用题干)
+                String matchingGroup = StringUtils.hasText(row.getMatchingGroup()) ? row.getMatchingGroup().trim() : null;
+                String sharedOptions = null;
+                if (matchingGroup != null && !"0".equals(matchingGroup)) {
+                    if (StringUtils.hasText(row.getSharedOptions())) {
+                        currentMatchingGroup = matchingGroup;
+                        currentSharedOptions = row.getSharedOptions().trim();
+                    } else if (matchingGroup.equals(currentMatchingGroup)) {
+                        sharedOptions = currentSharedOptions;
+                    }
+                    if (sharedOptions == null && StringUtils.hasText(row.getSharedOptions())) {
+                        sharedOptions = row.getSharedOptions().trim();
+                    }
+                } else {
+                    currentMatchingGroup = null;
+                    currentSharedOptions = null;
+                    matchingGroup = null;
+                }
+
+                // 构建选项列表
+                List<Map<String, Object>> options = buildOptions(row);
+                // 配伍题允许无独立选项(使用共用选项),非配伍题至少需要2个选项
+                boolean isMatching = matchingGroup != null && !"0".equals(matchingGroup);
+                if (options.isEmpty() && !isMatching) {
+                    failDetails.add(buildFail(rowNum, row.getStem(), "选项缺失(至少需要2个选项)"));
+                    continue;
+                }
+
+                // 查找节ID(考点的父节点)
+                Long sectionId = null;
+                if (chapterId != null) {
+                    QuizChapter examPoint = chapterById.get(chapterId);
+                    if (examPoint != null && examPoint.getParentId() != null && examPoint.getParentId() != 0L) {
+                        sectionId = examPoint.getParentId();
+                    }
+                }
+
+                // 创建题目
+                QuizQuestion question = QuizQuestion.builder()
+                        .subjectId(actualSubjectId)
+                        .sectionId(sectionId)
+                        .chapterId(chapterId)
+                        .questionType(questionType)
+                        .matchingGroup(matchingGroup)
+                        .sharedOptions(sharedOptions)
+                        .stem(row.getStem().trim())
+                        .options(options)
+                        .answer(row.getAnswer().trim())
+                        .analysis(StringUtils.hasText(row.getAnalysis()) ? row.getAnalysis().trim() : null)
+                        .isReal(isReal ? 1 : 0)
+                        .year(year)
+                        .source("import")
+                        .build();
+                save(question);
+                updateQuestionCount(actualSubjectId, chapterId, 1);
+                successCount++;
+
+            } catch (Exception e) {
+                log.warn("导入第{}行异常", rowNum, e);
+                failDetails.add(buildFail(rowNum, row.getStem(), e.getMessage()));
+            }
+        }
+
+        int failCount = totalCount - successCount;
+        java.math.BigDecimal passRate = totalCount > 0
+                ? java.math.BigDecimal.valueOf(successCount * 100.0 / totalCount).setScale(2, java.math.RoundingMode.HALF_UP)
+                : java.math.BigDecimal.ZERO;
+
+        batch.setTotalCount(totalCount);
+        batch.setSuccessCount(successCount);
+        batch.setFailCount(failCount);
+        batch.setPassRate(passRate);
+        batch.setFailDetails(failDetails);
+        batch.setStatus(failCount == 0 ? 2 : 1);
+        importBatchMapper.updateById(batch);
+
+        return batch;
+    }
+
+    private Integer parseQuestionType(String typeStr) {
+        return switch (typeStr) {
+            case "最佳选择题" -> 1;
+            case "多项选择题" -> 2;
+            case "配伍选择题" -> 3;
+            case "综合分析选择题" -> 4;
+            default -> null;
+        };
+    }
+
+    private List<Map<String, Object>> buildOptions(QuestionImportDTO row) {
+        List<Map<String, Object>> options = new ArrayList<>();
+        addOption(options, "A", row.getOptionA());
+        addOption(options, "B", row.getOptionB());
+        addOption(options, "C", row.getOptionC());
+        addOption(options, "D", row.getOptionD());
+        addOption(options, "E", row.getOptionE());
+        return options;
+    }
+
+    private void addOption(List<Map<String, Object>> options, String key, String content) {
+        if (StringUtils.hasText(content)) {
+            Map<String, Object> opt = new LinkedHashMap<>();
+            opt.put("key", key);
+            opt.put("content", content.trim());
+            options.add(opt);
+        }
+    }
+
+    /**
+     * 按名称全局查找章节节点(返回最深匹配节点的ID)
+     */
+    private Long findChapterByName(Map<String, List<QuizChapter>> chaptersByName, String normalizedName,
+                                   Map<Long, QuizChapter> chapterById, Map<String, Long> subjectCache) {
+        List<QuizChapter> matches = chaptersByName.get(normalizedName);
+        if (matches == null || matches.isEmpty()) return null;
+        // 如果只有一个匹配,直接返回
+        if (matches.size() == 1) return matches.get(0).getId();
+        // 多个匹配时,优先返回level最高的(最深的节点)
+        matches.sort((a, b) -> Integer.compare(b.getLevel(), a.getLevel()));
+        return matches.get(0).getId();
+    }
+
+    /**
+     * 构建考点路径:科目名|章名|节名|考点名(归一化后)
+     */
+    private String buildChapterPath(QuizChapter examPoint, Map<Long, QuizChapter> chapterById, Map<String, Long> subjectCache) {
+        QuizChapter section = chapterById.get(examPoint.getParentId());
+        if (section == null) return null;
+        QuizChapter chapter = chapterById.get(section.getParentId());
+        if (chapter == null) return null;
+
+        String subjectName = null;
+        for (Map.Entry<String, Long> entry : subjectCache.entrySet()) {
+            if (entry.getValue().equals(examPoint.getSubjectId())) {
+                subjectName = entry.getKey();
+                break;
+            }
+        }
+        if (subjectName == null) return null;
+
+        return subjectName + "|" + normalize(chapter.getName()) + "|" + normalize(section.getName()) + "|" + normalize(examPoint.getName());
+    }
+
+    /**
+     * 名称归一化:全角括号→半角括号,去除多余空格
+     */
+    private String normalize(String name) {
+        if (name == null) return "";
+        return name.trim()
+                .replace('(', '(')
+                .replace(')', ')')
+                .replace(':', ':')
+                .replace(',', ',')
+                .replace(';', ';');
+    }
+
+    /** 归一化分组号:parent_id 中的 0 / 0.0 / 空 都视为无分组 */
+    private String normalizeGroup(String group) {
+        if (!StringUtils.hasText(group)) return null;
+        String g = group.trim();
+        if ("0".equals(g) || "0.0".equals(g)) return null;
+        return g;
+    }
+
+    // ==================== 辅助方法 ====================
+
+    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);
+            }
+        }
+    }
+
+    private IPage<Map<String, Object>> convertPage(IPage<QuizQuestion> page) {
+        List<Map<String, Object>> list = page.getRecords().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());
+            map.put("questionType", q.getQuestionType());
+            map.put("questionTypeName", getTypeName(q.getQuestionType()));
+            map.put("matchingGroup", q.getMatchingGroup());
+            map.put("year", q.getYear());
+            QuizSubject subject = subjectMapper.selectById(q.getSubjectId());
+            map.put("subjectName", subject != null ? subject.getName() : "");
+            QuizChapter section = q.getSectionId() != null ? chapterMapper.selectById(q.getSectionId()) : null;
+            map.put("sectionName", section != null ? section.getName() : "");
+            QuizChapter chapter = chapterMapper.selectById(q.getChapterId());
+            map.put("chapterName", chapter != null ? chapter.getName() : "");
+            map.put("answer", q.getAnswer());
+            map.put("hasAnalysis", StringUtils.hasText(q.getAnalysis()));
+            map.put("updateTime", q.getUpdateTime());
+            return map;
+        }).collect(Collectors.toList());
+
+        Page<Map<String, Object>> result = new Page<>(page.getCurrent(), page.getSize(), page.getTotal());
+        result.setRecords(list);
+        return result;
+    }
+
+    private String getTypeName(Integer type) {
+        if (type == null) return "";
+        return switch (type) {
+            case 1 -> "最佳选择题";
+            case 2 -> "多项选择题";
+            case 3 -> "配伍选择题";
+            case 4 -> "综合分析选择题";
+            default -> "";
+        };
+    }
+
+    private List<List<String>> buildFailHead() {
+        List<List<String>> head = new ArrayList<>();
+        head.add(Collections.singletonList("行号"));
+        head.add(Collections.singletonList("题干"));
+        head.add(Collections.singletonList("科目"));
+        head.add(Collections.singletonList("考点"));
+        head.add(Collections.singletonList("失败原因"));
+        return head;
+    }
+
+    private List<List<Object>> buildFailData(List<Map<String, Object>> failDetails) {
+        List<List<Object>> data = new ArrayList<>();
+        for (Map<String, Object> detail : failDetails) {
+            List<Object> row = new ArrayList<>();
+            row.add(detail.getOrDefault("row", ""));
+            row.add(detail.getOrDefault("stem", ""));
+            row.add(detail.getOrDefault("subjectName", ""));
+            row.add(detail.getOrDefault("examPointName", ""));
+            row.add(detail.getOrDefault("reason", ""));
+            data.add(row);
+        }
+        return data;
+    }
+
+    private Map<String, Object> buildFail(int row, String stem, String reason) {
+        Map<String, Object> fail = new LinkedHashMap<>();
+        fail.put("row", row);
+        fail.put("stem", stem != null ? stem : "");
+        fail.put("reason", reason);
+        return fail;
+    }
+}

+ 68 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizSubjectServiceImpl.java

@@ -0,0 +1,68 @@
+package com.xuekairui.quiz.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.quiz.entity.QuizChapter;
+import com.xuekairui.quiz.entity.QuizSubject;
+import com.xuekairui.quiz.mapper.QuizChapterMapper;
+import com.xuekairui.quiz.mapper.QuizSubjectMapper;
+import com.xuekairui.quiz.service.QuizSubjectService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class QuizSubjectServiceImpl extends ServiceImpl<QuizSubjectMapper, QuizSubject> implements QuizSubjectService {
+
+    private final QuizChapterMapper chapterMapper;
+
+    @Override
+    public List<QuizSubject> listAll() {
+        return list(new LambdaQueryWrapper<QuizSubject>().orderByAsc(QuizSubject::getSort));
+    }
+
+    @Override
+    public QuizSubject add(String name, Integer sort) {
+        QuizSubject subject = QuizSubject.builder()
+                .name(name)
+                .sort(sort != null ? sort : 0)
+                .build();
+        save(subject);
+        return subject;
+    }
+
+    @Override
+    public QuizSubject update(Long id, String name, Integer sort) {
+        QuizSubject subject = getById(id);
+        if (subject == null) {
+            throw new BusinessException(ErrorCode.QUIZ_SUBJECT_NOT_FOUND);
+        }
+        subject.setName(name);
+        if (sort != null) {
+            subject.setSort(sort);
+        }
+        updateById(subject);
+        return subject;
+    }
+
+    @Override
+    public void delete(Long id) {
+        QuizSubject subject = getById(id);
+        if (subject == null) {
+            throw new BusinessException(ErrorCode.QUIZ_SUBJECT_NOT_FOUND);
+        }
+        // 检查是否有子章节
+        long childCount = chapterMapper.selectCount(
+                new LambdaQueryWrapper<QuizChapter>()
+                        .eq(QuizChapter::getSubjectId, id)
+        );
+        if (childCount > 0) {
+            throw new BusinessException(ErrorCode.QUIZ_SUBJECT_HAS_CHILDREN);
+        }
+        removeById(id);
+    }
+}

+ 503 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/QuizTrackServiceImpl.java

@@ -0,0 +1,503 @@
+package com.xuekairui.quiz.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.quiz.entity.QuizChapter;
+import com.xuekairui.quiz.entity.QuizQuestion;
+import com.xuekairui.quiz.entity.QuizSubject;
+import com.xuekairui.quiz.entity.QuizTrackLog;
+import com.xuekairui.quiz.entity.QuizUserRecord;
+import com.xuekairui.quiz.mapper.QuizChapterMapper;
+import com.xuekairui.quiz.mapper.QuizQuestionMapper;
+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 lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.*;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class QuizTrackServiceImpl extends ServiceImpl<QuizTrackLogMapper, QuizTrackLog> implements QuizTrackService {
+
+    private final QuizUserRecordMapper userRecordMapper;
+    private final QuizQuestionMapper questionMapper;
+    private final QuizSubjectMapper subjectMapper;
+    private final QuizChapterMapper chapterMapper;
+
+    @Override
+    @Async
+    public void trackEvent(Long userId, String event, Map<String, Object> properties, String page, String deviceInfo, String ip) {
+        if (!StringUtils.hasText(event)) {
+            throw new BusinessException(ErrorCode.QUIZ_TRACK_EVENT_EMPTY);
+        }
+        QuizTrackLog trackLog = QuizTrackLog.builder()
+                .userId(userId)
+                .event(event)
+                .properties(properties)
+                .page(page)
+                .deviceInfo(deviceInfo)
+                .ip(ip)
+                .build();
+        save(trackLog);
+    }
+
+    @Override
+    public Map<String, Object> getOverview(Long subjectId, Integer answerType, Integer completeStatus,
+                                            String startTime, String endTime, Integer days) {
+        LocalDateTime[] timeRange = parseTimeRange(startTime, endTime, days);
+
+        LambdaQueryWrapper<QuizUserRecord> wrapper = new LambdaQueryWrapper<QuizUserRecord>()
+                .eq(subjectId != null, QuizUserRecord::getSubjectId, subjectId)
+                .eq(answerType != null, QuizUserRecord::getAnswerType, answerType)
+                .ge(QuizUserRecord::getCreateTime, timeRange[0])
+                .le(QuizUserRecord::getCreateTime, timeRange[1]);
+
+        if (completeStatus != null) {
+            wrapper.eq(QuizUserRecord::getStatus, completeStatus);
+        }
+
+        List<QuizUserRecord> records = userRecordMapper.selectList(wrapper);
+
+        // 累计使用用户(去重userId)
+        long totalUser = records.stream().map(QuizUserRecord::getUserId).distinct().count();
+        // 累计做题场次
+        long totalAnswerSession = records.size();
+        // 平均正确率
+        double correctRate = records.stream()
+                .filter(r -> r.getCorrectRate() != null)
+                .mapToDouble(r -> r.getCorrectRate().doubleValue())
+                .average()
+                .orElse(0.0);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("totalUser", totalUser);
+        result.put("totalAnswerSession", totalAnswerSession);
+        result.put("correctRate", Math.round(correctRate * 100.0) / 100.0);
+        // 高频错题数:调用getHotWrongList获取total
+        Map<String, Object> hotWrong = getHotWrongList(subjectId, answerType, startTime, endTime, days, 1, 1);
+        result.put("hotWrongCount", hotWrong.get("total"));
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> getUserList(String userKeyword, Long subjectId, Integer answerType,
+                                            Integer completeStatus, String startTime, String endTime,
+                                            Integer days, int pageNo, int pageSize) {
+        LocalDateTime[] timeRange = parseTimeRange(startTime, endTime, days);
+
+        // 从做题记录中聚合用户维度数据
+        LambdaQueryWrapper<QuizUserRecord> wrapper = new LambdaQueryWrapper<QuizUserRecord>()
+                .eq(subjectId != null, QuizUserRecord::getSubjectId, subjectId)
+                .eq(answerType != null, QuizUserRecord::getAnswerType, answerType)
+                .eq(completeStatus != null, QuizUserRecord::getStatus, completeStatus)
+                .ge(QuizUserRecord::getCreateTime, timeRange[0])
+                .le(QuizUserRecord::getCreateTime, timeRange[1]);
+
+        List<QuizUserRecord> allRecords = userRecordMapper.selectList(wrapper);
+
+        // 按userId聚合
+        Map<Long, List<QuizUserRecord>> grouped = allRecords.stream()
+                .collect(Collectors.groupingBy(QuizUserRecord::getUserId));
+
+        List<Map<String, Object>> userList = new ArrayList<>();
+        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)) {
+                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);
+        }
+
+        // 按最近做题时间倒序
+        userList.sort((a, b) -> {
+            LocalDateTime ta = (LocalDateTime) a.get("lastAnswerTime");
+            LocalDateTime tb = (LocalDateTime) b.get("lastAnswerTime");
+            if (ta == null && tb == null) return 0;
+            if (ta == null) return 1;
+            if (tb == null) return -1;
+            return tb.compareTo(ta);
+        });
+
+        int total = userList.size();
+        int from = Math.min((pageNo - 1) * pageSize, total);
+        int to = Math.min(from + pageSize, total);
+        List<Map<String, Object>> pageList = userList.subList(from, to);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("total", total);
+        result.put("list", pageList);
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> getAnswerList(String userKeyword, Long subjectId, Integer answerType,
+                                              Integer completeStatus, String startTime, String endTime,
+                                              Integer days, int pageNo, int pageSize) {
+        LocalDateTime[] timeRange = parseTimeRange(startTime, endTime, days);
+
+        LambdaQueryWrapper<QuizUserRecord> wrapper = new LambdaQueryWrapper<QuizUserRecord>()
+                .eq(subjectId != null, QuizUserRecord::getSubjectId, subjectId)
+                .eq(answerType != null, QuizUserRecord::getAnswerType, answerType)
+                .eq(completeStatus != null, QuizUserRecord::getStatus, completeStatus)
+                .ge(QuizUserRecord::getCreateTime, timeRange[0])
+                .le(QuizUserRecord::getCreateTime, timeRange[1])
+                .orderByDesc(QuizUserRecord::getCreateTime);
+
+        // 简单分页
+        long total = userRecordMapper.selectCount(wrapper);
+        wrapper.last("LIMIT " + (pageNo - 1) * pageSize + "," + pageSize);
+        List<QuizUserRecord> records = userRecordMapper.selectList(wrapper);
+
+        List<Map<String, Object>> list = new ArrayList<>();
+        for (QuizUserRecord r : records) {
+            Map<String, Object> map = new LinkedHashMap<>();
+            map.put("userId", r.getUserId());
+            map.put("answerType", r.getAnswerType());
+            map.put("subjectId", r.getSubjectId());
+            map.put("totalCount", r.getTotalCount());
+            map.put("correctCount", r.getCorrectCount());
+            map.put("correctRate", r.getCorrectRate());
+            map.put("status", r.getStatus());
+            map.put("createTime", r.getCreateTime());
+            list.add(map);
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("total", total);
+        result.put("list", list);
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> getHotWrongList(Long subjectId, Integer answerType,
+                                                String startTime, String endTime,
+                                                Integer days, int pageNo, int pageSize) {
+        LocalDateTime[] timeRange = parseTimeRange(startTime, endTime, days);
+
+        LambdaQueryWrapper<QuizUserRecord> wrapper = new LambdaQueryWrapper<QuizUserRecord>()
+                .eq(subjectId != null, QuizUserRecord::getSubjectId, subjectId)
+                .eq(answerType != null, QuizUserRecord::getAnswerType, answerType)
+                .ge(QuizUserRecord::getCreateTime, timeRange[0])
+                .le(QuizUserRecord::getCreateTime, timeRange[1]);
+
+        List<QuizUserRecord> records = userRecordMapper.selectList(wrapper);
+
+        // 从answers JSON中聚合每道题的错误次数
+        // answers格式: [{"questionId":1,"userAnswer":"A","isCorrect":0},...]
+        Map<Long, int[]> questionStats = new HashMap<>(); // questionId -> [wrongCount, totalAnswerCount]
+        Map<Long, Set<Long>> questionUsers = new HashMap<>();
+
+        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());
+                int isCorrect = ans.get("isCorrect") != null ? Integer.parseInt(ans.get("isCorrect").toString()) : 0;
+
+                questionStats.computeIfAbsent(qid, k -> new int[]{0, 0});
+                questionStats.get(qid)[1]++;
+                if (isCorrect == 0) {
+                    questionStats.get(qid)[0]++;
+                }
+                questionUsers.computeIfAbsent(qid, k -> new HashSet<>()).add(r.getUserId());
+            }
+        }
+
+        // 过滤错误率>=30%的题目,按错误次数排序
+        List<Map<String, Object>> wrongList = new ArrayList<>();
+        for (Map.Entry<Long, int[]> entry : questionStats.entrySet()) {
+            Long qid = entry.getKey();
+            int[] stats = entry.getValue();
+            int wrongCount = stats[0];
+            int totalCount = stats[1];
+            if (totalCount == 0) continue;
+            double wrongRate = wrongCount * 100.0 / totalCount;
+            if (wrongRate < 30.0) continue;
+
+            QuizQuestion question = questionMapper.selectById(qid);
+            if (question == null) continue;
+
+            QuizSubject subject = subjectMapper.selectById(question.getSubjectId());
+            QuizChapter chapter = chapterMapper.selectById(question.getChapterId());
+
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("questionId", qid);
+            item.put("stem", question.getStem() != null && question.getStem().length() > 50 ? question.getStem().substring(0, 50) + "..." : question.getStem());
+            item.put("subjectName", subject != null ? subject.getName() : "");
+            item.put("chapterName", chapter != null ? chapter.getName() : "");
+            item.put("wrongTimes", wrongCount);
+            item.put("wrongRate", Math.round(wrongRate * 100.0) / 100.0);
+            item.put("answerUserCount", questionUsers.getOrDefault(qid, Collections.emptySet()).size());
+            wrongList.add(item);
+        }
+
+        wrongList.sort((a, b) -> Integer.compare((int) b.get("wrongTimes"), (int) a.get("wrongTimes")));
+
+        // 添加排名
+        for (int i = 0; i < wrongList.size(); i++) {
+            wrongList.get(i).put("rank", i + 1);
+        }
+
+        int total = wrongList.size();
+        int from = Math.min((pageNo - 1) * pageSize, total);
+        int to = Math.min(from + pageSize, total);
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("total", total);
+        result.put("list", wrongList.subList(from, to));
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> getAnswerTimeDistribution(Long subjectId, Integer answerType,
+                                                          String startTime, String endTime, Integer days) {
+        LocalDateTime[] timeRange = parseTimeRange(startTime, endTime, days);
+
+        LambdaQueryWrapper<QuizUserRecord> wrapper = new LambdaQueryWrapper<QuizUserRecord>()
+                .eq(subjectId != null, QuizUserRecord::getSubjectId, subjectId)
+                .eq(answerType != null, QuizUserRecord::getAnswerType, answerType)
+                .ge(QuizUserRecord::getCreateTime, timeRange[0])
+                .le(QuizUserRecord::getCreateTime, timeRange[1]);
+
+        List<QuizUserRecord> records = userRecordMapper.selectList(wrapper);
+
+        int[] counts = new int[24];
+        for (QuizUserRecord r : records) {
+            if (r.getCreateTime() != null) {
+                counts[r.getCreateTime().getHour()]++;
+            }
+        }
+
+        List<Integer> hours = new ArrayList<>();
+        List<Integer> countList = new ArrayList<>();
+        for (int i = 0; i < 24; i++) {
+            hours.add(i);
+            countList.add(counts[i]);
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("hours", hours);
+        result.put("counts", countList);
+        return result;
+    }
+
+    @Override
+    public Map<String, Object> getEventList(String event, Long userId, String startTime, String endTime,
+                                             int pageNo, int pageSize) {
+        LambdaQueryWrapper<QuizTrackLog> wrapper = new LambdaQueryWrapper<QuizTrackLog>()
+                .eq(StringUtils.hasText(event), QuizTrackLog::getEvent, event)
+                .eq(userId != null, QuizTrackLog::getUserId, userId);
+
+        if (StringUtils.hasText(startTime)) {
+            wrapper.ge(QuizTrackLog::getCreateTime, LocalDate.parse(startTime, DateTimeFormatter.ofPattern("yyyy-MM-dd")).atStartOfDay());
+        }
+        if (StringUtils.hasText(endTime)) {
+            wrapper.le(QuizTrackLog::getCreateTime, LocalDate.parse(endTime, DateTimeFormatter.ofPattern("yyyy-MM-dd")).atTime(LocalTime.MAX));
+        }
+        wrapper.orderByDesc(QuizTrackLog::getCreateTime);
+
+        long total = count(wrapper);
+        wrapper.last("LIMIT " + (pageNo - 1) * pageSize + "," + pageSize);
+        List<QuizTrackLog> logs = list(wrapper);
+
+        List<Map<String, Object>> list = new ArrayList<>();
+        for (QuizTrackLog log : logs) {
+            Map<String, Object> item = new LinkedHashMap<>();
+            item.put("id", log.getId());
+            item.put("userId", log.getUserId());
+            item.put("event", log.getEvent());
+            item.put("eventName", getEventName(log.getEvent()));
+            item.put("page", log.getPage());
+            item.put("pageName", getPageName(log.getPage()));
+            item.put("properties", log.getProperties());
+            item.put("ip", log.getIp());
+            item.put("createTime", log.getCreateTime());
+            list.add(item);
+        }
+
+        Map<String, Object> result = new LinkedHashMap<>();
+        result.put("total", total);
+        result.put("list", list);
+        return result;
+    }
+
+    @Override
+    public byte[] exportReport(String type, Long subjectId, Integer answerType, Integer completeStatus,
+                                String startTime, String endTime, Integer days) {
+        try (java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream()) {
+            if ("user".equals(type)) {
+                Map<String, Object> data = getUserList(null, subjectId, answerType, completeStatus, startTime, endTime, days, 1, 100000);
+                @SuppressWarnings("unchecked")
+                List<Map<String, Object>> list = (List<Map<String, Object>>) data.get("list");
+                com.alibaba.excel.EasyExcel.write(bos)
+                        .sheet("用户明细")
+                        .head(buildUserHead())
+                        .doWrite(buildUserData(list));
+            } else if ("answer".equals(type)) {
+                Map<String, Object> data = getAnswerList(null, subjectId, answerType, completeStatus, startTime, endTime, days, 1, 100000);
+                @SuppressWarnings("unchecked")
+                List<Map<String, Object>> list = (List<Map<String, Object>>) data.get("list");
+                com.alibaba.excel.EasyExcel.write(bos)
+                        .sheet("做题记录")
+                        .head(buildAnswerHead())
+                        .doWrite(buildAnswerData(list));
+            } else if ("wrongQuestion".equals(type)) {
+                Map<String, Object> data = getHotWrongList(subjectId, answerType, startTime, endTime, days, 1, 100000);
+                @SuppressWarnings("unchecked")
+                List<Map<String, Object>> list = (List<Map<String, Object>>) data.get("list");
+                com.alibaba.excel.EasyExcel.write(bos)
+                        .sheet("高频错题")
+                        .head(buildWrongHead())
+                        .doWrite(buildWrongData(list));
+            }
+            return bos.toByteArray();
+        } catch (Exception e) {
+            throw new BusinessException(ErrorCode.INTERNAL_ERROR, "导出报表异常");
+        }
+    }
+
+    private String getEventName(String event) {
+        if (event == null) return "";
+        return switch (event) {
+            case "page_view" -> "页面浏览";
+            case "question_answer" -> "答题";
+            case "question_collect" -> "收藏";
+            case "exam_start" -> "开始模考";
+            case "exam_submit" -> "交卷";
+            default -> event;
+        };
+    }
+
+    private String getPageName(String page) {
+        if (page == null) return "";
+        if (page.contains("chapter")) return "章节练习";
+        if (page.contains("daily")) return "每日一练";
+        if (page.contains("exam")) return "真题模考";
+        if (page.contains("home")) return "首页";
+        return page;
+    }
+
+    private List<List<String>> buildUserHead() {
+        List<List<String>> head = new ArrayList<>();
+        head.add(Collections.singletonList("用户ID"));
+        head.add(Collections.singletonList("昵称"));
+        head.add(Collections.singletonList("手机号"));
+        head.add(Collections.singletonList("累计做题"));
+        head.add(Collections.singletonList("首次使用"));
+        head.add(Collections.singletonList("最近做题"));
+        return head;
+    }
+
+    private List<List<Object>> buildUserData(List<Map<String, Object>> list) {
+        List<List<Object>> data = new ArrayList<>();
+        for (Map<String, Object> item : list) {
+            List<Object> row = new ArrayList<>();
+            row.add(item.get("userId"));
+            row.add(item.get("nickname"));
+            row.add(item.get("phone"));
+            row.add(item.get("totalAnswerCount"));
+            row.add(item.get("firstUseTime"));
+            row.add(item.get("lastAnswerTime"));
+            data.add(row);
+        }
+        return data;
+    }
+
+    private List<List<String>> buildAnswerHead() {
+        List<List<String>> head = new ArrayList<>();
+        head.add(Collections.singletonList("用户ID"));
+        head.add(Collections.singletonList("答题类型"));
+        head.add(Collections.singletonList("科目"));
+        head.add(Collections.singletonList("总题数"));
+        head.add(Collections.singletonList("正确数"));
+        head.add(Collections.singletonList("正确率"));
+        head.add(Collections.singletonList("状态"));
+        head.add(Collections.singletonList("答题时间"));
+        return head;
+    }
+
+    private List<List<Object>> buildAnswerData(List<Map<String, Object>> list) {
+        List<List<Object>> data = new ArrayList<>();
+        for (Map<String, Object> item : list) {
+            List<Object> row = new ArrayList<>();
+            row.add(item.get("userId"));
+            row.add(item.get("answerType"));
+            row.add(item.get("subjectId"));
+            row.add(item.get("totalCount"));
+            row.add(item.get("correctCount"));
+            row.add(item.get("correctRate"));
+            row.add(item.get("status"));
+            row.add(item.get("createTime"));
+            data.add(row);
+        }
+        return data;
+    }
+
+    private List<List<String>> buildWrongHead() {
+        List<List<String>> head = new ArrayList<>();
+        head.add(Collections.singletonList("排名"));
+        head.add(Collections.singletonList("题干"));
+        head.add(Collections.singletonList("科目"));
+        head.add(Collections.singletonList("考点"));
+        head.add(Collections.singletonList("错误次数"));
+        head.add(Collections.singletonList("错误率"));
+        head.add(Collections.singletonList("作答人数"));
+        return head;
+    }
+
+    private List<List<Object>> buildWrongData(List<Map<String, Object>> list) {
+        List<List<Object>> data = new ArrayList<>();
+        for (Map<String, Object> item : list) {
+            List<Object> row = new ArrayList<>();
+            row.add(item.get("rank"));
+            row.add(item.get("stem"));
+            row.add(item.get("subjectName"));
+            row.add(item.get("chapterName"));
+            row.add(item.get("wrongTimes"));
+            row.add(item.get("wrongRate"));
+            row.add(item.get("answerUserCount"));
+            data.add(row);
+        }
+        return data;
+    }
+
+    private LocalDateTime[] parseTimeRange(String startTime, String endTime, Integer days) {
+        DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+        if (StringUtils.hasText(startTime) && StringUtils.hasText(endTime)) {
+            return new LocalDateTime[]{
+                    LocalDate.parse(startTime, fmt).atStartOfDay(),
+                    LocalDate.parse(endTime, fmt).atTime(LocalTime.MAX)
+            };
+        }
+        int d = (days != null && days > 0) ? days : 30;
+        return new LocalDateTime[]{
+                LocalDate.now().minusDays(d).atStartOfDay(),
+                LocalDateTime.now()
+        };
+    }
+}

+ 183 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/RankServiceImpl.java

@@ -0,0 +1,183 @@
+package com.xuekairui.quiz.service.impl;
+
+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.vo.MyRankVO;
+import com.xuekairui.quiz.vo.RankListVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.DayOfWeek;
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.LocalTime;
+import java.time.temporal.TemporalAdjusters;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class RankServiceImpl implements RankService {
+
+    private final QuizUserRecordMapper userRecordMapper;
+
+    @Override
+    public RankListVO getRankList(Long userId, String period, String rankType, int pageNo, int pageSize) {
+        LocalDateTime[] timeRange = getTimeRange(period);
+
+        List<QuizUserRecord> records = userRecordMapper.selectList(
+                new LambdaQueryWrapper<QuizUserRecord>()
+                        .eq(QuizUserRecord::getStatus, 1)
+                        .ge(QuizUserRecord::getCreateTime, timeRange[0])
+                        .le(QuizUserRecord::getCreateTime, timeRange[1])
+        );
+
+        // 按用户聚合统计
+        Map<Long, UserStats> statsMap = aggregateStats(records);
+
+        // 排序
+        List<UserStats> sorted = new ArrayList<>(statsMap.values());
+        if ("correctRate".equals(rankType)) {
+            sorted.sort((a, b) -> b.correctRate.compareTo(a.correctRate));
+        } else {
+            // 默认按做题总数排序
+            sorted.sort((a, b) -> b.totalCount - a.totalCount);
+        }
+
+        // 分页
+        int total = sorted.size();
+        int fromIndex = Math.min((pageNo - 1) * pageSize, total);
+        int toIndex = Math.min(fromIndex + pageSize, total);
+        List<UserStats> pageList = sorted.subList(fromIndex, toIndex);
+
+        List<RankListVO.RankItemVO> list = new ArrayList<>();
+        int rank = fromIndex + 1;
+        for (UserStats stats : pageList) {
+            list.add(RankListVO.RankItemVO.builder()
+                    .rank(rank++)
+                    .userId(stats.userId)
+                    .totalCount(stats.totalCount)
+                    .correctCount(stats.correctCount)
+                    .correctRate(stats.correctRate)
+                    .sessionCount(stats.sessionCount)
+                    .build());
+        }
+
+        return RankListVO.builder()
+                .total(total)
+                .list(list)
+                .period(period)
+                .rankType(rankType)
+                .build();
+    }
+
+    @Override
+    public MyRankVO getMyRank(Long userId, String period, String rankType) {
+        LocalDateTime[] timeRange = getTimeRange(period);
+
+        List<QuizUserRecord> records = userRecordMapper.selectList(
+                new LambdaQueryWrapper<QuizUserRecord>()
+                        .eq(QuizUserRecord::getStatus, 1)
+                        .ge(QuizUserRecord::getCreateTime, timeRange[0])
+                        .le(QuizUserRecord::getCreateTime, timeRange[1])
+        );
+
+        Map<Long, UserStats> statsMap = aggregateStats(records);
+
+        // 排序并找到当前用户的排名
+        List<UserStats> sorted = new ArrayList<>(statsMap.values());
+        if ("correctRate".equals(rankType)) {
+            sorted.sort((a, b) -> b.correctRate.compareTo(a.correctRate));
+        } else {
+            sorted.sort((a, b) -> b.totalCount - a.totalCount);
+        }
+
+        UserStats myStats = statsMap.get(userId);
+        int myRank = 0;
+        for (int i = 0; i < sorted.size(); i++) {
+            if (sorted.get(i).userId.equals(userId)) {
+                myRank = i + 1;
+                break;
+            }
+        }
+
+        MyRankVO.MyRankVOBuilder builder = MyRankVO.builder()
+                .userId(userId)
+                .rank(myRank)
+                .totalUsers(sorted.size())
+                .period(period)
+                .rankType(rankType);
+
+        if (myStats != null) {
+            builder.totalCount(myStats.totalCount)
+                    .correctCount(myStats.correctCount)
+                    .correctRate(myStats.correctRate)
+                    .sessionCount(myStats.sessionCount);
+        } else {
+            builder.totalCount(0)
+                    .correctCount(0)
+                    .correctRate(BigDecimal.ZERO)
+                    .sessionCount(0);
+        }
+        return builder.build();
+    }
+
+    private Map<Long, UserStats> aggregateStats(List<QuizUserRecord> records) {
+        Map<Long, List<QuizUserRecord>> grouped = records.stream()
+                .collect(Collectors.groupingBy(QuizUserRecord::getUserId));
+
+        Map<Long, UserStats> statsMap = new LinkedHashMap<>();
+        for (Map.Entry<Long, List<QuizUserRecord>> entry : grouped.entrySet()) {
+            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();
+            BigDecimal correctRate = totalCount > 0
+                    ? BigDecimal.valueOf(correctCount * 100.0 / totalCount).setScale(0, RoundingMode.HALF_UP)
+                    : BigDecimal.ZERO;
+
+            UserStats stats = new UserStats();
+            stats.userId = uid;
+            stats.totalCount = totalCount;
+            stats.correctCount = correctCount;
+            stats.correctRate = correctRate;
+            stats.sessionCount = userRecords.size();
+            statsMap.put(uid, stats);
+        }
+        return statsMap;
+    }
+
+    private LocalDateTime[] getTimeRange(String period) {
+        LocalDate today = LocalDate.now();
+        return switch (period != null ? period : "all") {
+            case "week" -> new LocalDateTime[]{
+                    today.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)).atStartOfDay(),
+                    today.with(TemporalAdjusters.nextOrSame(DayOfWeek.SUNDAY)).atTime(LocalTime.MAX)
+            };
+            case "month" -> new LocalDateTime[]{
+                    today.with(TemporalAdjusters.firstDayOfMonth()).atStartOfDay(),
+                    today.with(TemporalAdjusters.lastDayOfMonth()).atTime(LocalTime.MAX)
+            };
+            default -> new LocalDateTime[]{
+                    LocalDate.of(2000, 1, 1).atStartOfDay(),
+                    LocalDateTime.now()
+            };
+        };
+    }
+
+    private static class UserStats {
+        Long userId;
+        int totalCount;
+        int correctCount;
+        BigDecimal correctRate;
+        int sessionCount;
+    }
+}

+ 427 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/RealExamServiceImpl.java

@@ -0,0 +1,427 @@
+package com.xuekairui.quiz.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.quiz.dto.AnswerItem;
+import com.xuekairui.quiz.entity.QuizQuestion;
+import com.xuekairui.quiz.entity.QuizSubject;
+import com.xuekairui.quiz.entity.QuizUserExamRecord;
+import com.xuekairui.quiz.mapper.QuizQuestionMapper;
+import com.xuekairui.quiz.mapper.QuizSubjectMapper;
+import com.xuekairui.quiz.mapper.QuizUserExamRecordMapper;
+import com.xuekairui.quiz.service.RealExamService;
+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.RealExamQuestionVO;
+import com.xuekairui.quiz.vo.RealExamSubjectVO;
+import com.xuekairui.quiz.vo.StartExamVO;
+import com.xuekairui.quiz.vo.SubmitExamVO;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class RealExamServiceImpl implements RealExamService {
+
+    private final QuizQuestionMapper questionMapper;
+    private final QuizSubjectMapper subjectMapper;
+    private final QuizUserExamRecordMapper examRecordMapper;
+
+    @Override
+    public List<RealExamSubjectVO> list(Long userId, Long subjectId) {
+        // 获取所有真题,按科目+年份分组
+        List<QuizQuestion> questions = questionMapper.selectList(
+                new LambdaQueryWrapper<QuizQuestion>()
+                        .eq(QuizQuestion::getIsReal, 1)
+                        .eq(subjectId != null, QuizQuestion::getSubjectId, subjectId)
+                        .isNotNull(QuizQuestion::getYear)
+                        .isNotNull(QuizQuestion::getSubjectId)
+                        .select(QuizQuestion::getYear, QuizQuestion::getSubjectId)
+        );
+
+        // 按科目分组,再按年份分组
+        Map<Long, Map<Integer, Long>> subjectYearCount = questions.stream()
+                .collect(Collectors.groupingBy(
+                        QuizQuestion::getSubjectId,
+                        Collectors.groupingBy(QuizQuestion::getYear, Collectors.counting())
+                ));
+
+        // 获取用户已完成的考试记录(用于hasDone和bestScore)
+        List<QuizUserExamRecord> userRecords = examRecordMapper.selectList(
+                new LambdaQueryWrapper<QuizUserExamRecord>()
+                        .eq(QuizUserExamRecord::getUserId, userId)
+                        .eq(QuizUserExamRecord::getStatus, 1)
+        );
+        // key: "subjectId_year" → bestScore
+        Map<String, BigDecimal> bestScoreMap = new HashMap<>();
+        for (QuizUserExamRecord r : userRecords) {
+            String key = r.getSubjectId() + "_" + r.getYear();
+            BigDecimal existing = bestScoreMap.get(key);
+            if (existing == null || (r.getTotalScore() != null && r.getTotalScore().compareTo(existing) > 0)) {
+                bestScoreMap.put(key, r.getTotalScore());
+            }
+        }
+
+        // 构建结果
+        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)
+                    .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
+            );
+        });
+
+        return result;
+    }
+
+    @Override
+    public List<ExamYearVO> getExamYears(Long subjectId) {
+        List<QuizQuestion> questions = questionMapper.selectList(
+                new LambdaQueryWrapper<QuizQuestion>()
+                        .eq(QuizQuestion::getIsReal, 1)
+                        .eq(subjectId != null, QuizQuestion::getSubjectId, subjectId)
+                        .isNotNull(QuizQuestion::getYear)
+                        .select(QuizQuestion::getYear, QuizQuestion::getSubjectId)
+        );
+
+        // 按年份分组统计题目数量
+        Map<Integer, Long> yearCountMap = questions.stream()
+                .collect(Collectors.groupingBy(QuizQuestion::getYear, Collectors.counting()));
+
+        List<ExamYearVO> result = new ArrayList<>();
+        yearCountMap.entrySet().stream()
+                .sorted((a, b) -> b.getKey().compareTo(a.getKey()))
+                .forEach(entry -> result.add(ExamYearVO.builder()
+                        .year(entry.getKey())
+                        .questionCount(entry.getValue().intValue())
+                        .build()));
+        return result;
+    }
+
+    @Override
+    @Transactional
+    public StartExamVO startExam(Long userId, Integer year, Long subjectId) {
+        // 检查是否已有未交卷的记录
+        long inProgress = examRecordMapper.selectCount(
+                new LambdaQueryWrapper<QuizUserExamRecord>()
+                        .eq(QuizUserExamRecord::getUserId, userId)
+                        .eq(QuizUserExamRecord::getYear, year)
+                        .eq(subjectId != null, QuizUserExamRecord::getSubjectId, subjectId)
+                        .eq(QuizUserExamRecord::getStatus, 0)
+        );
+        if (inProgress > 0) {
+            // 返回已有的进行中记录
+            QuizUserExamRecord existing = examRecordMapper.selectOne(
+                    new LambdaQueryWrapper<QuizUserExamRecord>()
+                            .eq(QuizUserExamRecord::getUserId, userId)
+                            .eq(QuizUserExamRecord::getYear, year)
+                            .eq(subjectId != null, QuizUserExamRecord::getSubjectId, subjectId)
+                            .eq(QuizUserExamRecord::getStatus, 0)
+                            .last("LIMIT 1")
+            );
+            return buildExamPaper(existing, subjectId);
+        }
+
+        // 获取该年份真题
+        List<QuizQuestion> questions = questionMapper.selectList(
+                new LambdaQueryWrapper<QuizQuestion>()
+                        .eq(QuizQuestion::getIsReal, 1)
+                        .eq(QuizQuestion::getYear, year)
+                        .eq(subjectId != null, QuizQuestion::getSubjectId, subjectId)
+                        .orderByAsc(QuizQuestion::getId)
+        );
+
+        if (questions.isEmpty()) {
+            throw new BusinessException(ErrorCode.QUIZ_QUESTION_NOT_FOUND, "该年份暂无真题");
+        }
+
+        // 创建考试记录
+        QuizUserExamRecord record = QuizUserExamRecord.builder()
+                .userId(userId)
+                .year(year)
+                .subjectId(subjectId)
+                .startTime(LocalDateTime.now())
+                .status(0)
+                .build();
+        examRecordMapper.insert(record);
+
+        return buildExamPaper(record, subjectId);
+    }
+
+    private StartExamVO buildExamPaper(QuizUserExamRecord record, Long subjectId) {
+        List<QuizQuestion> questions = questionMapper.selectList(
+                new LambdaQueryWrapper<QuizQuestion>()
+                        .eq(QuizQuestion::getIsReal, 1)
+                        .eq(QuizQuestion::getYear, record.getYear())
+                        .eq(subjectId != null, QuizQuestion::getSubjectId, subjectId)
+                        .orderByAsc(QuizQuestion::getId)
+        );
+
+        List<RealExamQuestionVO> questionList = buildQuestionList(questions);
+
+        return StartExamVO.builder()
+                .recordId(record.getId())
+                .year(record.getYear())
+                .questionCount(questions.size())
+                .startTime(record.getStartTime())
+                .status(record.getStatus())
+                .questions(questionList)
+                .build();
+    }
+
+    /**
+     * 将题目列表平铺返回(每题独立一条):
+     * - 最佳选择题(1)、多项选择题(2):id + stem + options
+     * - 配伍选择题(3):id + stem + matchGroup + sharedOptions(无独立 options)
+     * - 综合分析选择题(4):id + stem + matchGroup + sharedOptions(共用题干)+ 各自的 options
+     */
+    private List<RealExamQuestionVO> buildQuestionList(List<QuizQuestion> questions) {
+        List<RealExamQuestionVO> result = new ArrayList<>();
+        for (QuizQuestion q : questions) {
+            Integer type = q.getQuestionType();
+            String group = q.getMatchingGroup();
+            boolean grouped = (type != null && (type == 3 || type == 4))
+                    && group != null && !group.isEmpty();
+
+            RealExamQuestionVO.RealExamQuestionVOBuilder builder = RealExamQuestionVO.builder()
+                    .id(q.getId())
+                    .questionType(type)
+                    .questionTypeName(getTypeName(type))
+                    .stem(q.getStem());
+
+            if (grouped) {
+                builder.matchGroup(group).sharedOptions(q.getSharedOptions());
+            }
+            // 配伍题(有分组号)无独立选项,其余情况返回 options
+            if (!(type != null && type == 3 && grouped)) {
+                builder.options(q.getOptions());
+            }
+            result.add(builder.build());
+        }
+        return result;
+    }
+
+    @Override
+    @Transactional
+    public void saveProgress(Long userId, Long recordId, List<AnswerItem> answers) {
+        QuizUserExamRecord record = examRecordMapper.selectById(recordId);
+        if (record == null) {
+            throw new BusinessException(ErrorCode.QUIZ_RECORD_NOT_FOUND);
+        }
+        if (!record.getUserId().equals(userId)) {
+            throw new BusinessException(ErrorCode.FORBIDDEN);
+        }
+        if (record.getStatus() == 1) {
+            return; // 已交卷,忽略保存请求
+        }
+        // 将当前答案暂存到 answers 字段
+        record.setAnswers(toAnswerMaps(answers));
+        examRecordMapper.updateById(record);
+    }
+
+    @Override
+    @Transactional
+    public SubmitExamVO submitExam(Long userId, Long recordId, List<AnswerItem> answers, Integer usedTime) {
+        QuizUserExamRecord record = examRecordMapper.selectById(recordId);
+        if (record == null) {
+            throw new BusinessException(ErrorCode.QUIZ_RECORD_NOT_FOUND);
+        }
+        if (!record.getUserId().equals(userId)) {
+            throw new BusinessException(ErrorCode.FORBIDDEN);
+        }
+        if (record.getStatus() == 1) {
+            throw new BusinessException(ErrorCode.QUIZ_RECORD_ALREADY_SUBMITTED);
+        }
+
+        int correctCount = 0;
+        int wrongCount = 0;
+        List<Map<String, Object>> answerRecords = new ArrayList<>();
+
+        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;
+
+            boolean isCorrect = question.getAnswer().equalsIgnoreCase(userAnswer);
+            if (isCorrect) correctCount++;
+            else wrongCount++;
+
+            Map<String, Object> answerRecord = new LinkedHashMap<>();
+            answerRecord.put("questionId", questionId);
+            answerRecord.put("userAnswer", userAnswer);
+            answerRecord.put("isCorrect", isCorrect ? 1 : 0);
+            answerRecord.put("correctAnswer", question.getAnswer());
+            answerRecord.put("analysis", question.getAnalysis());
+            answerRecord.put("timeSpent", timeSpent);
+            answerRecords.add(answerRecord);
+        }
+
+        int total = correctCount + wrongCount;
+        // 每题1分,总分按正确数计算;及格线为60%
+        BigDecimal totalScore = BigDecimal.valueOf(correctCount);
+        BigDecimal passScore = BigDecimal.valueOf(total * 0.6).setScale(0, RoundingMode.HALF_UP);
+        int isPassed = totalScore.compareTo(passScore) >= 0 ? 1 : 0;
+
+        record.setSubmitTime(LocalDateTime.now());
+        record.setUsedTime(usedTime);
+        record.setTotalScore(totalScore);
+        record.setCorrectCount(correctCount);
+        record.setWrongCount(wrongCount);
+        record.setIsPassed(isPassed);
+        record.setAnswers(answerRecords);
+        record.setStatus(1);
+        examRecordMapper.updateById(record);
+
+        return SubmitExamVO.builder()
+                .recordId(record.getId())
+                .totalScore(totalScore)
+                .correctCount(correctCount)
+                .wrongCount(wrongCount)
+                .isPassed(isPassed)
+                .usedTime(usedTime)
+                .build();
+    }
+
+    @Override
+    public PageVO<ExamRecordVO> getExamHistory(Long userId, Long subjectId, int pageNo, int pageSize) {
+        LambdaQueryWrapper<QuizUserExamRecord> wrapper = new LambdaQueryWrapper<QuizUserExamRecord>()
+                .eq(QuizUserExamRecord::getUserId, userId)
+                .eq(subjectId != null, QuizUserExamRecord::getSubjectId, subjectId)
+                .eq(QuizUserExamRecord::getStatus, 1)
+                .orderByDesc(QuizUserExamRecord::getCreateTime);
+
+        long total = examRecordMapper.selectCount(wrapper);
+        wrapper.last("LIMIT " + (pageNo - 1) * pageSize + "," + pageSize);
+        List<QuizUserExamRecord> records = examRecordMapper.selectList(wrapper);
+
+        List<ExamRecordVO> list = new ArrayList<>();
+        for (QuizUserExamRecord r : records) {
+            list.add(ExamRecordVO.builder()
+                    .recordId(r.getId())
+                    .year(r.getYear())
+                    .totalScore(r.getTotalScore())
+                    .correctCount(r.getCorrectCount())
+                    .wrongCount(r.getWrongCount())
+                    .isPassed(r.getIsPassed())
+                    .usedTime(r.getUsedTime())
+                    .startTime(r.getStartTime())
+                    .submitTime(r.getSubmitTime())
+                    .build());
+        }
+
+        return PageVO.<ExamRecordVO>builder()
+                .total(total)
+                .list(list)
+                .build();
+    }
+
+    @Override
+    public ExamDetailVO getExamDetail(Long userId, Long recordId) {
+        QuizUserExamRecord record = examRecordMapper.selectById(recordId);
+        if (record == null) {
+            throw new BusinessException(ErrorCode.QUIZ_RECORD_NOT_FOUND);
+        }
+        if (!record.getUserId().equals(userId)) {
+            throw new BusinessException(ErrorCode.FORBIDDEN);
+        }
+
+        // 补充每道题的题干与选项,便于前端错题回顾展示
+        List<Map<String, Object>> answers = record.getAnswers();
+        if (answers != null && !answers.isEmpty()) {
+            for (Map<String, Object> ans : answers) {
+                Object qid = ans.get("questionId");
+                if (qid == null) continue;
+                QuizQuestion q = questionMapper.selectById(Long.valueOf(qid.toString()));
+                if (q == null) continue;
+                ans.put("stem", q.getStem());
+                ans.put("options", q.getOptions());
+                ans.put("questionType", q.getQuestionType());
+                ans.put("matchingGroup", q.getMatchingGroup());
+                ans.put("sharedOptions", q.getSharedOptions());
+            }
+        }
+
+        return ExamDetailVO.builder()
+                .recordId(record.getId())
+                .year(record.getYear())
+                .totalScore(record.getTotalScore())
+                .correctCount(record.getCorrectCount())
+                .wrongCount(record.getWrongCount())
+                .isPassed(record.getIsPassed())
+                .usedTime(record.getUsedTime())
+                .startTime(record.getStartTime())
+                .submitTime(record.getSubmitTime())
+                .answers(answers)
+                .build();
+    }
+
+    private List<Map<String, Object>> toAnswerMaps(List<AnswerItem> answers) {
+        List<Map<String, Object>> result = new ArrayList<>();
+        if (answers == null) return result;
+        for (AnswerItem ans : answers) {
+            Map<String, Object> map = new LinkedHashMap<>();
+            map.put("questionId", ans.getQuestionId());
+            map.put("userAnswer", ans.getUserAnswer());
+            map.put("timeSpent", ans.getTimeSpent());
+            result.add(map);
+        }
+        return result;
+    }
+
+    private String getTypeName(Integer type) {
+        if (type == null) return "";
+        return switch (type) {
+            case 1 -> "最佳选择题";
+            case 2 -> "多项选择题";
+            case 3 -> "配伍选择题";
+            case 4 -> "综合分析选择题";
+            default -> "";
+        };
+    }
+}

+ 415 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/StudyBookServiceImpl.java

@@ -0,0 +1,415 @@
+package com.xuekairui.quiz.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+import com.xuekairui.quiz.dto.AnswerItem;
+import com.xuekairui.quiz.entity.QuizChapter;
+import com.xuekairui.quiz.entity.QuizQuestion;
+import com.xuekairui.quiz.entity.QuizSubject;
+import com.xuekairui.quiz.entity.QuizUserRecord;
+import com.xuekairui.quiz.mapper.QuizChapterMapper;
+import com.xuekairui.quiz.mapper.QuizQuestionMapper;
+import com.xuekairui.quiz.mapper.QuizSubjectMapper;
+import com.xuekairui.quiz.mapper.QuizUserRecordMapper;
+import com.xuekairui.quiz.service.StudyBookService;
+import com.xuekairui.quiz.vo.ChapterNodeVO;
+import com.xuekairui.quiz.vo.ChapterProgressItemVO;
+import com.xuekairui.quiz.vo.ChapterProgressVO;
+import com.xuekairui.quiz.vo.ChapterQuestionsVO;
+import com.xuekairui.quiz.vo.ChapterTreeVO;
+import com.xuekairui.quiz.vo.PageVO;
+import com.xuekairui.quiz.vo.PracticeResultVO;
+import com.xuekairui.quiz.vo.PracticeSubmitVO;
+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 lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+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.Objects;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class StudyBookServiceImpl implements StudyBookService {
+
+    private final QuizSubjectMapper subjectMapper;
+    private final QuizChapterMapper chapterMapper;
+    private final QuizQuestionMapper questionMapper;
+    private final QuizUserRecordMapper userRecordMapper;
+
+    @Override
+    public List<SubjectSimpleVO> getSubjectList() {
+        List<QuizSubject> subjects = subjectMapper.selectList(
+                new LambdaQueryWrapper<QuizSubject>()
+                        .orderByAsc(QuizSubject::getSort)
+        );
+
+        List<SubjectSimpleVO> result = new ArrayList<>();
+        for (QuizSubject s : subjects) {
+            result.add(SubjectSimpleVO.builder()
+                    .id(s.getId())
+                    .name(s.getName())
+                    .questionCount(s.getQuestionCount())
+                    .build());
+        }
+        return result;
+    }
+
+    @Override
+    public ChapterTreeVO getChapterTree(Long subjectId) {
+        QuizSubject subject = subjectMapper.selectById(subjectId);
+        if (subject == null) {
+            throw new BusinessException(ErrorCode.QUIZ_SUBJECT_NOT_FOUND);
+        }
+
+        List<QuizChapter> chapters = chapterMapper.selectList(
+                new LambdaQueryWrapper<QuizChapter>()
+                        .eq(QuizChapter::getSubjectId, subjectId)
+                        .orderByAsc(QuizChapter::getSort)
+        );
+
+        List<ChapterNodeVO> tree = buildChapterTree(chapters, 0L);
+
+        return ChapterTreeVO.builder()
+                .subjectId(subject.getId())
+                .subjectName(subject.getName())
+                .chapters(tree)
+                .build();
+    }
+
+    private List<ChapterNodeVO> buildChapterTree(List<QuizChapter> allChapters, Long parentId) {
+        List<ChapterNodeVO> tree = new ArrayList<>();
+        for (QuizChapter ch : allChapters) {
+            if (Objects.equals(ch.getParentId(), parentId)) {
+                List<ChapterNodeVO> children = buildChapterTree(allChapters, ch.getId());
+                tree.add(ChapterNodeVO.builder()
+                        .id(ch.getId())
+                        .name(ch.getName())
+                        .level(ch.getLevel())
+                        .questionCount(ch.getQuestionCount())
+                        .children(children.isEmpty() ? null : children)
+                        .build());
+            }
+        }
+        return tree;
+    }
+
+    @Override
+    public ChapterQuestionsVO getChapterQuestions(Long userId, Long chapterId) {
+        QuizChapter chapter = chapterMapper.selectById(chapterId);
+        if (chapter == null) {
+            throw new BusinessException(ErrorCode.QUIZ_CHAPTER_NOT_FOUND);
+        }
+
+        // 获取该考点下的练习题(非真题)
+        List<QuizQuestion> questions = questionMapper.selectList(
+                new LambdaQueryWrapper<QuizQuestion>()
+                        .eq(QuizQuestion::getChapterId, chapterId)
+                        .eq(QuizQuestion::getIsReal, 0)
+                        .orderByAsc(QuizQuestion::getId)
+        );
+
+        // 查询用户在该考点的做题记录
+        long doneCount = userRecordMapper.selectCount(
+                new LambdaQueryWrapper<QuizUserRecord>()
+                        .eq(QuizUserRecord::getUserId, userId)
+                        .eq(QuizUserRecord::getChapterId, chapterId)
+                        .eq(QuizUserRecord::getAnswerType, 1)
+                        .eq(QuizUserRecord::getStatus, 1)
+        );
+
+        List<StudyQuestionVO> questionList = new ArrayList<>();
+        for (QuizQuestion q : questions) {
+            questionList.add(StudyQuestionVO.builder()
+                    .id(q.getId())
+                    .questionType(q.getQuestionType())
+                    .questionTypeName(getTypeName(q.getQuestionType()))
+                    .stem(q.getStem())
+                    .options(q.getOptions())
+                    .build());
+        }
+
+        return ChapterQuestionsVO.builder()
+                .chapterId(chapterId)
+                .chapterName(chapter.getName())
+                .questionCount(questionList.size())
+                .hasDone(doneCount > 0)
+                .questions(questionList)
+                .build();
+    }
+
+    @Override
+    @Transactional
+    public PracticeSubmitVO submitChapterPractice(Long userId, Long subjectId, Long chapterId, List<AnswerItem> answers) {
+        int correctCount = 0;
+        int wrongCount = 0;
+        List<PracticeResultVO> results = new ArrayList<>();
+        List<Map<String, Object>> answerRecords = new ArrayList<>();
+
+        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;
+
+            boolean isCorrect = question.getAnswer().equalsIgnoreCase(userAnswer);
+            if (isCorrect) correctCount++;
+            else wrongCount++;
+
+            results.add(PracticeResultVO.builder()
+                    .questionId(questionId)
+                    .isCorrect(isCorrect ? 1 : 0)
+                    .correctAnswer(question.getAnswer())
+                    .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;
+
+        QuizUserRecord record = QuizUserRecord.builder()
+                .userId(userId)
+                .subjectId(subjectId)
+                .chapterId(chapterId)
+                .answerType(1)
+                .totalCount(total)
+                .correctCount(correctCount)
+                .wrongCount(wrongCount)
+                .correctRate(correctRate)
+                .answers(answerRecords)
+                .status(1)
+                .startTime(LocalDateTime.now())
+                .endTime(LocalDateTime.now())
+                .build();
+        userRecordMapper.insert(record);
+
+        return PracticeSubmitVO.builder()
+                .correctCount(correctCount)
+                .wrongCount(wrongCount)
+                .correctRate(correctRate)
+                .results(results)
+                .build();
+    }
+
+    @Override
+    public ChapterProgressVO getChapterProgress(Long userId, Long subjectId) {
+        List<QuizChapter> chapters = chapterMapper.selectList(
+                new LambdaQueryWrapper<QuizChapter>()
+                        .eq(QuizChapter::getSubjectId, subjectId)
+                        .orderByAsc(QuizChapter::getSort)
+        );
+
+        // 获取叶子节点(考点)
+        List<QuizChapter> leafChapters = chapters.stream()
+                .filter(ch -> {
+                    long childCount = chapters.stream()
+                            .filter(c -> Objects.equals(c.getParentId(), ch.getId()))
+                            .count();
+                    return childCount == 0;
+                })
+                .collect(Collectors.toList());
+
+        int completedCount = 0;
+        List<ChapterProgressItemVO> progressList = new ArrayList<>();
+
+        for (QuizChapter ch : leafChapters) {
+            long doneCount = userRecordMapper.selectCount(
+                    new LambdaQueryWrapper<QuizUserRecord>()
+                            .eq(QuizUserRecord::getUserId, userId)
+                            .eq(QuizUserRecord::getChapterId, ch.getId())
+                            .eq(QuizUserRecord::getAnswerType, 1)
+                            .eq(QuizUserRecord::getStatus, 1)
+            );
+
+            boolean completed = doneCount > 0;
+            if (completed) completedCount++;
+
+            progressList.add(ChapterProgressItemVO.builder()
+                    .chapterId(ch.getId())
+                    .chapterName(ch.getName())
+                    .questionCount(ch.getQuestionCount())
+                    .completed(completed)
+                    .practiceCount(doneCount)
+                    .build());
+        }
+
+        int totalChapters = leafChapters.size();
+        BigDecimal progressRate = totalChapters > 0
+                ? BigDecimal.valueOf(completedCount * 100.0 / totalChapters).setScale(0, RoundingMode.HALF_UP)
+                : BigDecimal.ZERO;
+
+        return ChapterProgressVO.builder()
+                .subjectId(subjectId)
+                .totalChapters(totalChapters)
+                .completedChapters(completedCount)
+                .progressRate(progressRate)
+                .chapters(progressList)
+                .build();
+    }
+
+    @Override
+    public StudyOverviewVO getOverview(Long userId) {
+        // 按题去重,以最新一次答题状态统计做对/做错题数
+        Map<Long, Integer> latestIsCorrect = getLatestIsCorrect(userId, null);
+        int totalCorrect = 0;
+        int totalWrong = 0;
+        for (Integer isCorrect : latestIsCorrect.values()) {
+            if (isCorrect != null && isCorrect == 1) totalCorrect++;
+            else if (isCorrect != null && isCorrect == 0) totalWrong++;
+        }
+
+        // 收藏数量(answerType=99)
+        long collectedCount = userRecordMapper.selectCount(
+                new LambdaQueryWrapper<QuizUserRecord>()
+                        .eq(QuizUserRecord::getUserId, userId)
+                        .eq(QuizUserRecord::getAnswerType, 99)
+        );
+
+        return StudyOverviewVO.builder()
+                .correctCount(totalCorrect)
+                .wrongCount(totalWrong)
+                .collectedCount((int) collectedCount)
+                .totalDone(totalCorrect + totalWrong)
+                .build();
+    }
+
+    /**
+     * 获取用户每题最新一次答题状态(做对=1 / 做错=0)。
+     * 记录按 createTime 倒序查询,首次出现的题目即为最新状态,后续重复题目跳过。
+     */
+    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)
+                        .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);
+            }
+        }
+        return latest;
+    }
+
+    @Override
+    public PageVO<StudyBookQuestionVO> getQuestionList(Long userId, String type, Long subjectId, int pageNo, int pageSize) {
+        List<StudyBookQuestionVO> questionList = new ArrayList<>();
+        long total = 0;
+
+        if ("collected".equals(type)) {
+            // 收藏的题目
+            LambdaQueryWrapper<QuizUserRecord> wrapper = new LambdaQueryWrapper<QuizUserRecord>()
+                    .eq(QuizUserRecord::getUserId, userId)
+                    .eq(QuizUserRecord::getAnswerType, 99)
+                    .eq(subjectId != null && subjectId != 0L, QuizUserRecord::getSubjectId, subjectId)
+                    .orderByDesc(QuizUserRecord::getCreateTime);
+            total = userRecordMapper.selectCount(wrapper);
+            wrapper.last("LIMIT " + (pageNo - 1) * pageSize + "," + pageSize);
+            List<QuizUserRecord> records = userRecordMapper.selectList(wrapper);
+
+            for (QuizUserRecord r : records) {
+                if (r.getAnswers() != null && !r.getAnswers().isEmpty()) {
+                    Long questionId = r.getAnswers().get(0).get("questionId") != null
+                            ? Long.valueOf(r.getAnswers().get(0).get("questionId").toString()) : null;
+                    if (questionId != null) {
+                        QuizQuestion q = questionMapper.selectById(questionId);
+                        if (q != null) {
+                            StudyBookQuestionVO item = buildQuestionItem(q);
+                            item.setCollectedAt(r.getCreateTime());
+                            questionList.add(item);
+                        }
+                    }
+                }
+            }
+        } else {
+            // 做对/做错的题目:按题去重,以最新一次答题状态为准
+            Map<Long, Integer> latestIsCorrect = getLatestIsCorrect(userId, subjectId);
+
+            Set<Long> questionIds = new LinkedHashSet<>();
+            for (Map.Entry<Long, Integer> e : latestIsCorrect.entrySet()) {
+                Integer isCorrect = e.getValue();
+                boolean match = ("correct".equals(type) && isCorrect != null && isCorrect == 1)
+                        || ("wrong".equals(type) && isCorrect != null && isCorrect == 0);
+                if (match) {
+                    questionIds.add(e.getKey());
+                }
+            }
+
+            total = questionIds.size();
+            // 分页
+            List<Long> pagedIds = questionIds.stream()
+                    .skip((long) (pageNo - 1) * pageSize)
+                    .limit(pageSize)
+                    .collect(Collectors.toList());
+
+            for (Long qId : pagedIds) {
+                QuizQuestion q = questionMapper.selectById(qId);
+                if (q != null) {
+                    questionList.add(buildQuestionItem(q));
+                }
+            }
+        }
+
+        return PageVO.<StudyBookQuestionVO>builder()
+                .total(total)
+                .list(questionList)
+                .build();
+    }
+
+    private StudyBookQuestionVO buildQuestionItem(QuizQuestion q) {
+        return StudyBookQuestionVO.builder()
+                .id(q.getId())
+                .questionType(q.getQuestionType())
+                .questionTypeName(getTypeName(q.getQuestionType()))
+                .stem(q.getStem())
+                .options(q.getOptions())
+                .answer(q.getAnswer())
+                .analysis(q.getAnalysis())
+                .build();
+    }
+
+    private String getTypeName(Integer type) {
+        if (type == null) return "";
+        return switch (type) {
+            case 1 -> "最佳选择题";
+            case 2 -> "多项选择题";
+            case 3 -> "配伍选择题";
+            case 4 -> "综合分析选择题";
+            default -> "";
+        };
+    }
+}

+ 290 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/service/impl/UserQuestionServiceImpl.java

@@ -0,0 +1,290 @@
+package com.xuekairui.quiz.service.impl;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.xuekairui.common.BusinessException;
+import com.xuekairui.common.ErrorCode;
+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.service.UserQuestionService;
+import com.xuekairui.quiz.vo.PageVO;
+import com.xuekairui.quiz.vo.QuestionDetailVO;
+import com.xuekairui.quiz.vo.QuestionVO;
+import com.xuekairui.quiz.vo.SubmitAnswerVO;
+import com.xuekairui.quiz.vo.ToggleCollectVO;
+import com.xuekairui.quiz.vo.UserRecordVO;
+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.List;
+import java.util.Map;
+
+@Service
+@RequiredArgsConstructor
+public class UserQuestionServiceImpl implements UserQuestionService {
+
+    private final QuizQuestionMapper questionMapper;
+    private final QuizUserRecordMapper userRecordMapper;
+    private final QuizChapterMapper chapterMapper;
+
+    @Override
+    public PageVO<QuestionVO> listByChapter(Long chapterId, String order, Long userId, int pageNo, int pageSize) {
+        // 章节列表只展示到"节",做题时需向下展开收集所有"考点"(level=4)再查询题目
+        List<Long> examPointIds = collectExamPointIds(chapterId);
+        if (examPointIds.isEmpty()) {
+            return PageVO.<QuestionVO>builder().total(0L).list(new ArrayList<>()).build();
+        }
+
+        LambdaQueryWrapper<QuizQuestion> wrapper = new LambdaQueryWrapper<QuizQuestion>()
+                .in(QuizQuestion::getChapterId, examPointIds)
+                .eq(QuizQuestion::getIsReal, 0);
+
+        if ("random".equals(order)) {
+            wrapper.last("ORDER BY RAND()");
+        } else {
+            wrapper.orderByAsc(QuizQuestion::getId);
+        }
+
+        Page<QuizQuestion> page = questionMapper.selectPage(new Page<>(pageNo, pageSize), wrapper);
+
+        return PageVO.<QuestionVO>builder()
+                .total(page.getTotal())
+                .list(buildChapterQuestionList(page.getRecords(), userId))
+                .build();
+    }
+
+    /**
+     * 收集节点(章/节/考点)下所有考点ID:
+     * - 传入考点(level=4)时返回自身
+     * - 传入章(level=2)或节(level=3)时向下递归收集所有考点
+     */
+    private List<Long> collectExamPointIds(Long chapterId) {
+        List<Long> result = new ArrayList<>();
+        if (chapterId == null) return result;
+
+        QuizChapter chapter = chapterMapper.selectById(chapterId);
+        if (chapter == null) return result;
+
+        if (chapter.getLevel() != null && chapter.getLevel() == 4) {
+            result.add(chapterId);
+            return result;
+        }
+
+        collectDescendantExamPointIds(chapter.getSubjectId(), chapterId, result);
+        return result;
+    }
+
+    private void collectDescendantExamPointIds(Long subjectId, Long parentId, List<Long> result) {
+        List<QuizChapter> children = chapterMapper.selectList(
+                new LambdaQueryWrapper<QuizChapter>()
+                        .eq(QuizChapter::getSubjectId, subjectId)
+                        .eq(QuizChapter::getParentId, parentId)
+        );
+        for (QuizChapter child : children) {
+            if (child.getLevel() != null && child.getLevel() == 4) {
+                result.add(child.getId());
+            } else {
+                collectDescendantExamPointIds(subjectId, child.getId(), result);
+            }
+        }
+    }
+
+    /**
+     * 将章节题目列表平铺返回(每题独立一条):
+     * - 最佳选择题(1)、多项选择题(2):id + stem + options
+     * - 配伍选择题(3):id + stem + matchGroup + sharedOptions(无独立 options)
+     * - 综合分析选择题(4):id + stem + matchGroup + sharedOptions(共用题干)+ 各自的 options
+     */
+    private List<QuestionVO> buildChapterQuestionList(List<QuizQuestion> questions, Long userId) {
+        List<QuestionVO> result = new ArrayList<>();
+        for (QuizQuestion q : questions) {
+            Integer type = q.getQuestionType();
+            String group = q.getMatchingGroup();
+            boolean grouped = (type != null && (type == 3 || type == 4))
+                    && group != null && !group.isEmpty();
+
+            QuestionVO.QuestionVOBuilder builder = QuestionVO.builder()
+                    .id(q.getId())
+                    .questionType(type)
+                    .questionTypeName(getTypeName(type))
+                    .stem(q.getStem())
+                    .isCollected(isCollected(userId, q.getId()))
+                    .userRecord(getUserRecord(userId, q.getId()));
+
+            if (grouped) {
+                builder.matchGroup(group).sharedOptions(q.getSharedOptions());
+            }
+            // 配伍题(有分组号)无独立选项,其余情况返回 options
+            if (!(type != null && type == 3 && grouped)) {
+                builder.options(q.getOptions());
+            }
+            result.add(builder.build());
+        }
+        return result;
+    }
+
+    private String getTypeName(Integer type) {
+        if (type == null) return "";
+        return switch (type) {
+            case 1 -> "最佳选择题";
+            case 2 -> "多项选择题";
+            case 3 -> "配伍选择题";
+            case 4 -> "综合分析选择题";
+            default -> "";
+        };
+    }
+
+    @Override
+    @Transactional
+    public SubmitAnswerVO submitAnswer(Long userId, Long questionId, String userAnswer, Integer source, Integer timeSpent) {
+        QuizQuestion question = questionMapper.selectById(questionId);
+        if (question == null) {
+            throw new BusinessException(ErrorCode.QUIZ_QUESTION_NOT_FOUND);
+        }
+
+        boolean isCorrect = question.getAnswer().equalsIgnoreCase(userAnswer);
+
+        // 保存做题记录
+        QuizUserRecord record = QuizUserRecord.builder()
+                .userId(userId)
+                .subjectId(question.getSubjectId())
+                .chapterId(question.getChapterId())
+                .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())
+                .build();
+
+        // 构建answers JSON
+        List<Map<String, Object>> answers = new ArrayList<>();
+        Map<String, Object> ans = new LinkedHashMap<>();
+        ans.put("questionId", questionId);
+        ans.put("userAnswer", userAnswer);
+        ans.put("isCorrect", isCorrect ? 1 : 0);
+        ans.put("timeSpent", timeSpent);
+        answers.add(ans);
+        record.setAnswers(answers);
+        userRecordMapper.insert(record);
+
+        return SubmitAnswerVO.builder()
+                .isCorrect(isCorrect ? 1 : 0)
+                .correctAnswer(question.getAnswer())
+                .analysis(question.getAnalysis())
+                .isCollected(isCollected(userId, questionId))
+                .build();
+    }
+
+    @Override
+    @Transactional
+    public ToggleCollectVO toggleCollect(Long userId, Long questionId) {
+        // 使用特殊answerType=99表示收藏
+        QuizUserRecord existing = userRecordMapper.selectOne(
+                new LambdaQueryWrapper<QuizUserRecord>()
+                        .eq(QuizUserRecord::getUserId, userId)
+                        .eq(QuizUserRecord::getAnswerType, 99)
+                        .apply("JSON_CONTAINS(answers, JSON_OBJECT('questionId', " + questionId + "))")
+        );
+
+        boolean isCollected;
+        if (existing != null) {
+            // 取消收藏
+            userRecordMapper.deleteById(existing.getId());
+            isCollected = false;
+        } else {
+            // 添加收藏
+            QuizQuestion question = questionMapper.selectById(questionId);
+            if (question == null) {
+                throw new BusinessException(ErrorCode.QUIZ_QUESTION_NOT_FOUND);
+            }
+            QuizUserRecord collect = QuizUserRecord.builder()
+                    .userId(userId)
+                    .subjectId(question.getSubjectId())
+                    .chapterId(question.getChapterId())
+                    .answerType(99)
+                    .totalCount(1)
+                    .status(1)
+                    .startTime(LocalDateTime.now())
+                    .endTime(LocalDateTime.now())
+                    .build();
+            List<Map<String, Object>> answers = new ArrayList<>();
+            Map<String, Object> ans = new LinkedHashMap<>();
+            ans.put("questionId", questionId);
+            answers.add(ans);
+            collect.setAnswers(answers);
+            userRecordMapper.insert(collect);
+            isCollected = true;
+        }
+
+        return ToggleCollectVO.builder().isCollected(isCollected).build();
+    }
+
+    @Override
+    public QuestionDetailVO detail(Long questionId, Long userId) {
+        QuizQuestion question = questionMapper.selectById(questionId);
+        if (question == null) {
+            throw new BusinessException(ErrorCode.QUIZ_QUESTION_NOT_FOUND);
+        }
+
+        return QuestionDetailVO.builder()
+                .id(question.getId())
+                .questionType(question.getQuestionType())
+                .stem(question.getStem())
+                .options(question.getOptions())
+                .answer(question.getAnswer())
+                .analysis(question.getAnalysis())
+                .matchingGroup(question.getMatchingGroup())
+                .sharedOptions(question.getSharedOptions())
+                .isCollected(isCollected(userId, questionId))
+                .userRecord(getUserRecord(userId, questionId))
+                .build();
+    }
+
+    private boolean isCollected(Long userId, Long questionId) {
+        if (userId == null || questionId == null) return false;
+        return userRecordMapper.selectCount(
+                new LambdaQueryWrapper<QuizUserRecord>()
+                        .eq(QuizUserRecord::getUserId, userId)
+                        .eq(QuizUserRecord::getAnswerType, 99)
+                        .apply("JSON_CONTAINS(answers, JSON_OBJECT('questionId', " + questionId + "))")
+        ) > 0;
+    }
+
+    private UserRecordVO getUserRecord(Long userId, Long questionId) {
+        if (userId == null) return null;
+        List<QuizUserRecord> records = userRecordMapper.selectList(
+                new LambdaQueryWrapper<QuizUserRecord>()
+                        .eq(QuizUserRecord::getUserId, userId)
+                        .ne(QuizUserRecord::getAnswerType, 99)
+                        .orderByDesc(QuizUserRecord::getCreateTime)
+                        .last("LIMIT 100")
+        );
+        for (QuizUserRecord r : records) {
+            if (r.getAnswers() != null) {
+                for (Map<String, Object> ans : r.getAnswers()) {
+                    Object qid = ans.get("questionId");
+                    if (qid != null && Long.valueOf(qid.toString()).equals(questionId)) {
+                        return UserRecordVO.builder()
+                                .userAnswer(ans.get("userAnswer") != null ? ans.get("userAnswer").toString() : null)
+                                .isCorrect(ans.get("isCorrect") != null ? Integer.valueOf(ans.get("isCorrect").toString()) : null)
+                                .timeSpent(ans.get("timeSpent") != null ? Integer.valueOf(ans.get("timeSpent").toString()) : null)
+                                .build();
+                    }
+                }
+            }
+        }
+        return null;
+    }
+}

+ 25 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ChapterNodeVO.java

@@ -0,0 +1,25 @@
+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.util.List;
+
+/**
+ * 章节树节点(学习本章节树)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ChapterNodeVO {
+    private Long id;
+    private String name;
+    private Integer level;
+    private Integer questionCount;
+    private List<ChapterNodeVO> children;
+}

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

@@ -0,0 +1,23 @@
+package com.xuekairui.quiz.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 章节学习进度项
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ChapterProgressItemVO {
+    private Long chapterId;
+    private String chapterName;
+    private Integer questionCount;
+    private Boolean completed;
+    private Long practiceCount;
+}

+ 26 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ChapterProgressVO.java

@@ -0,0 +1,26 @@
+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 ChapterProgressVO {
+    private Long subjectId;
+    private Integer totalChapters;
+    private Integer completedChapters;
+    private BigDecimal progressRate;
+    private List<ChapterProgressItemVO> chapters;
+}

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

@@ -0,0 +1,25 @@
+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.util.List;
+
+/**
+ * 考点题目列表
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ChapterQuestionsVO {
+    private Long chapterId;
+    private String chapterName;
+    private Integer questionCount;
+    private Boolean hasDone;
+    private List<StudyQuestionVO> questions;
+}

+ 23 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ChapterTreeVO.java

@@ -0,0 +1,23 @@
+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.util.List;
+
+/**
+ * 学习本-科目章节树
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ChapterTreeVO {
+    private Long subjectId;
+    private String subjectName;
+    private List<ChapterNodeVO> chapters;
+}

+ 38 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/DailyPracticeQuestionsVO.java

@@ -0,0 +1,38 @@
+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.util.List;
+import java.util.Map;
+
+/**
+ * 每日一练题目
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class DailyPracticeQuestionsVO {
+    private String date;
+    private Integer questionCount;
+    private Boolean hasDone;
+    private List<DailyQuestionVO> questions;
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class DailyQuestionVO {
+        private Long id;
+        private Integer questionType;
+        private String questionTypeName;
+        private String stem;
+        private List<Map<String, Object>> options;
+    }
+}

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

@@ -0,0 +1,33 @@
+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.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 考试详情(含逐题作答记录)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ExamDetailVO {
+    private Long recordId;
+    private Integer year;
+    private BigDecimal totalScore;
+    private Integer correctCount;
+    private Integer wrongCount;
+    private Integer isPassed;
+    private Integer usedTime;
+    private LocalDateTime startTime;
+    private LocalDateTime submitTime;
+    private List<Map<String, Object>> answers;
+}

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

@@ -0,0 +1,30 @@
+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.time.LocalDateTime;
+
+/**
+ * 考试记录项
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ExamRecordVO {
+    private Long recordId;
+    private Integer year;
+    private BigDecimal totalScore;
+    private Integer correctCount;
+    private Integer wrongCount;
+    private Integer isPassed;
+    private Integer usedTime;
+    private LocalDateTime startTime;
+    private LocalDateTime submitTime;
+}

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

@@ -0,0 +1,20 @@
+package com.xuekairui.quiz.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 真题年份信息
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ExamYearVO {
+    private Integer year;
+    private Integer questionCount;
+}

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

@@ -0,0 +1,72 @@
+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.util.List;
+
+/**
+ * 首页聚合信息
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class HomeIndexVO {
+    private ExamInfoVO examInfo;
+    private DailyPracticeVO dailyPractice;
+    private List<HomeSubjectVO> subjects;
+    private List<LatestExamPaperVO> latestExamPapers;
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class ExamInfoVO {
+        private String name;
+        private String examDate;
+        private Long daysLeft;
+    }
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class DailyPracticeVO {
+        private Boolean hasDone;
+        private Long doneCount;
+        private Integer totalCount;
+    }
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class HomeSubjectVO {
+        private Long id;
+        private String name;
+        private String icon;
+        private Integer totalCount;
+        private Long doneCount;
+        private Integer progress;
+    }
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class LatestExamPaperVO {
+        private Integer year;
+        private Long subjectId;
+        private String subjectName;
+        private Long questionCount;
+    }
+}

+ 29 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/MyRankVO.java

@@ -0,0 +1,29 @@
+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;
+
+/**
+ * 我的排名
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class MyRankVO {
+    private Long userId;
+    private Integer rank;
+    private Integer totalUsers;
+    private Integer totalCount;
+    private Integer correctCount;
+    private BigDecimal correctRate;
+    private Integer sessionCount;
+    private String period;
+    private String rankType;
+}

+ 22 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/PageVO.java

@@ -0,0 +1,22 @@
+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.util.List;
+
+/**
+ * 分页返回通用结构
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class PageVO<T> {
+    private Long total;
+    private List<T> list;
+}

+ 22 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/PracticeResultVO.java

@@ -0,0 +1,22 @@
+package com.xuekairui.quiz.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 单题作答结果
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class PracticeResultVO {
+    private Long questionId;
+    private Integer isCorrect;
+    private String correctAnswer;
+    private String analysis;
+}

+ 25 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/PracticeSubmitVO.java

@@ -0,0 +1,25 @@
+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 PracticeSubmitVO {
+    private Integer correctCount;
+    private Integer wrongCount;
+    private BigDecimal correctRate;
+    private List<PracticeResultVO> results;
+}

+ 31 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/QuestionDetailVO.java

@@ -0,0 +1,31 @@
+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.util.List;
+import java.util.Map;
+
+/**
+ * 题目详情(含答案与解析)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class QuestionDetailVO {
+    private Long id;
+    private Integer questionType;
+    private String stem;
+    private List<Map<String, Object>> options;
+    private String answer;
+    private String analysis;
+    private String matchingGroup;
+    private String sharedOptions;
+    private Boolean isCollected;
+    private UserRecordVO userRecord;
+}

+ 30 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/QuestionVO.java

@@ -0,0 +1,30 @@
+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.util.List;
+import java.util.Map;
+
+/**
+ * 练习题目列表项(按章节平铺)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class QuestionVO {
+    private Long id;
+    private Integer questionType;
+    private String questionTypeName;
+    private String matchGroup;
+    private String sharedOptions;
+    private String stem;
+    private List<Map<String, Object>> options;
+    private Boolean isCollected;
+    private UserRecordVO userRecord;
+}

+ 39 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RankListVO.java

@@ -0,0 +1,39 @@
+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 RankListVO {
+    private Integer total;
+    private List<RankItemVO> list;
+    private String period;
+    private String rankType;
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class RankItemVO {
+        private Integer rank;
+        private Long userId;
+        private Integer totalCount;
+        private Integer correctCount;
+        private BigDecimal correctRate;
+        private Integer sessionCount;
+    }
+}

+ 28 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RealExamQuestionVO.java

@@ -0,0 +1,28 @@
+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.util.List;
+import java.util.Map;
+
+/**
+ * 真题题目列表项(平铺)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class RealExamQuestionVO {
+    private Long id;
+    private Integer questionType;
+    private String questionTypeName;
+    private String matchGroup;
+    private String sharedOptions;
+    private String stem;
+    private List<Map<String, Object>> options;
+}

+ 36 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/RealExamSubjectVO.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 RealExamSubjectVO {
+    private Long subjectId;
+    private String subjectName;
+    private List<RealExamYearVO> years;
+
+    @Data
+    @Builder
+    @NoArgsConstructor
+    @AllArgsConstructor
+    @JsonInclude(JsonInclude.Include.NON_NULL)
+    public static class RealExamYearVO {
+        private Integer year;
+        private Integer questionCount;
+        private Boolean hasDone;
+        private BigDecimal bestScore;
+    }
+}

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

@@ -0,0 +1,27 @@
+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.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 开始真题考试结果
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class StartExamVO {
+    private Long recordId;
+    private Integer year;
+    private Integer questionCount;
+    private LocalDateTime startTime;
+    private Integer status;
+    private List<RealExamQuestionVO> questions;
+}

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

@@ -0,0 +1,30 @@
+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.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 学习本题目列表项(含答案与解析)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class StudyBookQuestionVO {
+    private Long id;
+    private Integer questionType;
+    private String questionTypeName;
+    private String stem;
+    private List<Map<String, Object>> options;
+    private String answer;
+    private String analysis;
+    private LocalDateTime collectedAt;
+}

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

@@ -0,0 +1,22 @@
+package com.xuekairui.quiz.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 学习本统计概览
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class StudyOverviewVO {
+    private Integer correctCount;
+    private Integer wrongCount;
+    private Integer collectedCount;
+    private Integer totalDone;
+}

+ 26 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/StudyQuestionVO.java

@@ -0,0 +1,26 @@
+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.util.List;
+import java.util.Map;
+
+/**
+ * 考点题目列表项(练习/每日一练,不含答案)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class StudyQuestionVO {
+    private Long id;
+    private Integer questionType;
+    private String questionTypeName;
+    private String stem;
+    private List<Map<String, Object>> options;
+}

+ 25 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/SubjectProgressVO.java

@@ -0,0 +1,25 @@
+package com.xuekairui.quiz.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 用户端科目列表项(含进度)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class SubjectProgressVO {
+    private Long id;
+    private String name;
+    private Integer sort;
+    private String icon;
+    private Integer questionCount;
+    private Long doneCount;
+    private Integer progress;
+}

+ 21 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/SubjectSimpleVO.java

@@ -0,0 +1,21 @@
+package com.xuekairui.quiz.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 科目简要信息
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class SubjectSimpleVO {
+    private Long id;
+    private String name;
+    private Integer questionCount;
+}

+ 22 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/SubmitAnswerVO.java

@@ -0,0 +1,22 @@
+package com.xuekairui.quiz.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 提交单题答案结果
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class SubmitAnswerVO {
+    private Integer isCorrect;
+    private String correctAnswer;
+    private String analysis;
+    private Boolean isCollected;
+}

+ 26 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/SubmitExamVO.java

@@ -0,0 +1,26 @@
+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;
+
+/**
+ * 交卷结果
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class SubmitExamVO {
+    private Long recordId;
+    private BigDecimal totalScore;
+    private Integer correctCount;
+    private Integer wrongCount;
+    private Integer isPassed;
+    private Integer usedTime;
+}

+ 19 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/ToggleCollectVO.java

@@ -0,0 +1,19 @@
+package com.xuekairui.quiz.vo;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 收藏/取消收藏结果
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class ToggleCollectVO {
+    private Boolean isCollected;
+}

+ 26 - 0
zhijiayun-quiz/src/main/java/com/xuekairui/quiz/vo/UserChapterNodeVO.java

@@ -0,0 +1,26 @@
+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.util.List;
+
+/**
+ * 用户端章节树节点(含做题进度)
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@JsonInclude(JsonInclude.Include.NON_NULL)
+public class UserChapterNodeVO {
+    private Long id;
+    private String name;
+    private Integer level;
+    private Integer questionCount;
+    private Long doneCount;
+    private List<UserChapterNodeVO> children;
+}

Niektóre pliki nie zostały wyświetlone z powodu dużej ilości zmienionych plików