Procházet zdrojové kódy

Merge branch 'refactor' into feat/behavior_record

wuzilong před 3 měsíci
rodič
revize
57d5ec330c
67 změnil soubory, kde provedl 3395 přidání a 212 odebrání
  1. 1 0
      .gitignore
  2. 22 1
      application.yaml
  3. 76 0
      db/mysql_suggested_indexes.sql
  4. 33 3
      eai.sql
  5. 20 0
      src/main/java/com/njuzr/eaibackend/config/DeepSeekConfig.java
  6. 78 8
      src/main/java/com/njuzr/eaibackend/config/JWTAuthenticationFilter.java
  7. 14 0
      src/main/java/com/njuzr/eaibackend/config/RedisConfig.java
  8. 42 0
      src/main/java/com/njuzr/eaibackend/config/RedisKeyExpirationListener.java
  9. 1 0
      src/main/java/com/njuzr/eaibackend/config/SecurityConfig.java
  10. 18 1
      src/main/java/com/njuzr/eaibackend/config/ThreadPoolConfig.java
  11. 1 0
      src/main/java/com/njuzr/eaibackend/config/WebConfig.java
  12. 65 0
      src/main/java/com/njuzr/eaibackend/constant/EvaluationPromptConstant.java
  13. 50 3
      src/main/java/com/njuzr/eaibackend/controller/AIController.java
  14. 15 2
      src/main/java/com/njuzr/eaibackend/controller/AssignmentController.java
  15. 37 2
      src/main/java/com/njuzr/eaibackend/controller/BehaviorRecordController.java
  16. 33 0
      src/main/java/com/njuzr/eaibackend/controller/ClassController.java
  17. 13 0
      src/main/java/com/njuzr/eaibackend/controller/EvaluationController.java
  18. 5 0
      src/main/java/com/njuzr/eaibackend/dto/AIDTO.java
  19. 5 1
      src/main/java/com/njuzr/eaibackend/dto/AssignmentDTO.java
  20. 4 0
      src/main/java/com/njuzr/eaibackend/dto/AssignmentUpdateDTO.java
  21. 16 0
      src/main/java/com/njuzr/eaibackend/dto/SingleStudentImportDTO.java
  22. 35 0
      src/main/java/com/njuzr/eaibackend/dto/WindowSwitchDTO.java
  23. 28 0
      src/main/java/com/njuzr/eaibackend/dto/deepseek/DeepSeekMessage.java
  24. 23 0
      src/main/java/com/njuzr/eaibackend/dto/deepseek/DeepSeekRequest.java
  25. 48 0
      src/main/java/com/njuzr/eaibackend/dto/deepseek/DeepSeekResponse.java
  26. 57 0
      src/main/java/com/njuzr/eaibackend/dto/deepseek/DeepSeekStreamResponse.java
  27. 7 3
      src/main/java/com/njuzr/eaibackend/exception/GlobalExceptionHandler.java
  28. 17 0
      src/main/java/com/njuzr/eaibackend/mapper/StudentAssignmentMapper.java
  29. 35 0
      src/main/java/com/njuzr/eaibackend/mapper/WindowSwitchRecordMapper.java
  30. 12 4
      src/main/java/com/njuzr/eaibackend/po/AIDialogue.java
  31. 11 4
      src/main/java/com/njuzr/eaibackend/po/AIRewriteRecord.java
  32. 4 0
      src/main/java/com/njuzr/eaibackend/po/Assignment.java
  33. 6 3
      src/main/java/com/njuzr/eaibackend/po/BehaviorOverview.java
  34. 2 0
      src/main/java/com/njuzr/eaibackend/po/Engagement.java
  35. 10 3
      src/main/java/com/njuzr/eaibackend/po/SpeakingAIDialogue.java
  36. 41 0
      src/main/java/com/njuzr/eaibackend/po/StudentWindowSwitchRecord.java
  37. 11 5
      src/main/java/com/njuzr/eaibackend/po/TextAnalysis.java
  38. 218 4
      src/main/java/com/njuzr/eaibackend/service/AIDialogueService.java
  39. 244 55
      src/main/java/com/njuzr/eaibackend/service/AIEvaluationService.java
  40. 3 0
      src/main/java/com/njuzr/eaibackend/service/AssignmentService.java
  41. 4 0
      src/main/java/com/njuzr/eaibackend/service/ClassService.java
  42. 60 0
      src/main/java/com/njuzr/eaibackend/service/DeepSeekService.java
  43. 50 0
      src/main/java/com/njuzr/eaibackend/service/ExamTimerService.java
  44. 35 6
      src/main/java/com/njuzr/eaibackend/service/ExportBehaviorOverviewService.java
  45. 6 1
      src/main/java/com/njuzr/eaibackend/service/ExportCompositionService.java
  46. 36 0
      src/main/java/com/njuzr/eaibackend/service/WindowSwitchService.java
  47. 88 14
      src/main/java/com/njuzr/eaibackend/service/impl/AssignmentServiceImpl.java
  48. 56 7
      src/main/java/com/njuzr/eaibackend/service/impl/AuthenticationServiceImpl.java
  49. 149 77
      src/main/java/com/njuzr/eaibackend/service/impl/ClassServiceImpl.java
  50. 272 0
      src/main/java/com/njuzr/eaibackend/service/impl/DeepSeekServiceImpl.java
  51. 1 1
      src/main/java/com/njuzr/eaibackend/service/impl/DoubaoServiceImpl.java
  52. 222 0
      src/main/java/com/njuzr/eaibackend/service/impl/ExamTimerServiceImpl.java
  53. 2 2
      src/main/java/com/njuzr/eaibackend/service/impl/MediaAnalysisServiceImpl.java
  54. 72 0
      src/main/java/com/njuzr/eaibackend/service/impl/WindowSwitchServiceImpl.java
  55. 3 0
      src/main/java/com/njuzr/eaibackend/vo/AssignmentVO.java
  56. 2 0
      src/main/java/com/njuzr/eaibackend/vo/EngagementVO.java
  57. 21 0
      src/main/java/com/njuzr/eaibackend/vo/EvaluationCurrentVO.java
  58. 18 0
      src/main/java/com/njuzr/eaibackend/vo/ExamTimeVO.java
  59. 22 0
      src/main/java/com/njuzr/eaibackend/vo/WindowSwitchSummaryVO.java
  60. 16 1
      src/main/resources/application-dev.yaml
  61. 15 1
      src/main/resources/application-prod.yaml
  62. 51 0
      src/main/resources/mapper/StudentAssignmentMapper.xml
  63. 33 0
      src/main/resources/mapper/WindowSwitchRecordMapper.xml
  64. 184 0
      src/test/java/com/njuzr/eaibackend/service/DeepSeekServiceTest.java
  65. 131 0
      src/test/java/com/njuzr/eaibackend/service/ExamTimeRemainingTest.java
  66. 391 0
      src/test/java/com/njuzr/eaibackend/service/ExamTimerServiceTest.java
  67. 94 0
      src/test/java/com/njuzr/eaibackend/service/RedisKeyCheckTest.java

+ 1 - 0
.gitignore

@@ -31,3 +31,4 @@ build/
 
 
 ### VS Code ###
 ### VS Code ###
 .vscode/
 .vscode/
+/docs

+ 22 - 1
application.yaml

@@ -7,11 +7,22 @@ spring:
     username: root
     username: root
     password: eai123456
     password: eai123456
     url: jdbc:mysql://8.130.28.19:3306/eai?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true
     url: jdbc:mysql://8.130.28.19:3306/eai?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true
+    hikari:
+      maximum-pool-size: 30
+      minimum-idle: 10
+      connection-timeout: 10000
+      validation-timeout: 3000
+      idle-timeout: 600000
+      # Keep maxLifetime shorter than MySQL wait_timeout to avoid stale pooled connections.
+      max-lifetime: 240000
+      keepalive-time: 120000
+      leak-detection-threshold: 30000
 #    url: jdbc:mysql://127.0.0.1:3306/eai?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true
 #    url: jdbc:mysql://127.0.0.1:3306/eai?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true
   data:
   data:
     mongodb:
     mongodb:
       uri: mongodb://admin:eai123456@8.130.28.19:27017/eai?authSource=admin
       uri: mongodb://admin:eai123456@8.130.28.19:27017/eai?authSource=admin
       authentication-database: admin
       authentication-database: admin
+      auto-index-creation: true
     #redis配置更新
     #redis配置更新
     redis:
     redis:
       host: 8.130.28.19
       host: 8.130.28.19
@@ -45,6 +56,16 @@ jwt:
   secret: UenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRW
   secret: UenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRW
   issuer: eai
   issuer: eai
   expiration: 86400
   expiration: 86400
+  portal-sync:
+    enabled: true
+    throttle-seconds: 60
+
+# DeepSeek AI 配置
+deepseek:
+  api-key: sk-994409a3b69648de8171be819274fc98
+  base-url: https://api.deepseek.com/v1
+  model: deepseek-chat
+  timeout: 60000
 
 
 
 
 aliyun:
 aliyun:
@@ -102,5 +123,5 @@ springdoc:
     path: /doc/swagger/swagger-ui.html
     path: /doc/swagger/swagger-ui.html
     packagesToScan: com.njuzr.eaibackend.controller
     packagesToScan: com.njuzr.eaibackend.controller
 portal:
 portal:
-  base-url: http://localhost:8080
+  base-url: https://p-nju.seec.seecoder.cn
   batch-register-path: /api/user/register/batch/internal
   batch-register-path: /api/user/register/batch/internal

+ 76 - 0
db/mysql_suggested_indexes.sql

@@ -0,0 +1,76 @@
+-- =============================================================================
+-- EAI MySQL 建议索引(结合 Mapper / 服务层查询,与 eai 库结构一致)
+-- 执行前请:1) 在从库或备份后执行  2) 用 SHOW INDEX / EXPLAIN 确认与线上一致
+-- 若报 Duplicate key / 索引名已存在,则跳过该条
+-- MySQL 8.0+ InnoDB
+-- =============================================================================
+
+USE eai;
+
+-- ---------------------------------------------------------------------------
+-- 1) assignment
+-- 依据:AssignmentMapper selectByCourseId + ORDER BY create_time DESC
+-- 仅 idx_course_id 时常见 Using filesort;联合索引可兼顾过滤与排序
+-- ---------------------------------------------------------------------------
+-- 若线上已有单列 idx_course_id 且会保留本联合索引,可视 EXPLAIN 结果再决定是否删除单列
+CREATE INDEX idx_assignment_course_create_time
+    ON assignment (course_id, create_time);
+
+-- 门户/教师维度按 teacher_portal_id 拉作业(若业务有此类查询,且列非空时选择性更好)
+-- CREATE INDEX idx_assignment_teacher_portal_id ON assignment (teacher_portal_id);
+
+-- ---------------------------------------------------------------------------
+-- 2) course_student
+-- 依据:CourseStudentMapper:按 course_id 拉人、按 student_id 拉课、dropCourse 等值双条件
+-- 原 eai.sql 无二级索引
+-- ---------------------------------------------------------------------------
+CREATE INDEX idx_course_student_course_id ON course_student (course_id);
+CREATE INDEX idx_course_student_student_id ON course_student (student_id);
+-- 若需强制「同一学生同一课一条」且已清洗完重复数据,可再考虑:
+-- ALTER TABLE course_student ADD CONSTRAINT uk_cs UNIQUE (course_id, student_id);
+
+-- ---------------------------------------------------------------------------
+-- 3) users
+-- 依据:UserMapper selectByOfficialNumber / Phone / OfficialEmail、selectNameByPid、
+--       IN (official_number)、登录态 id 等
+-- 原 eai.sql 无二级索引
+-- ---------------------------------------------------------------------------
+CREATE INDEX idx_users_official_number ON users (official_number);
+CREATE INDEX idx_users_pid ON users (pid);
+CREATE INDEX idx_users_phone ON users (phone);
+CREATE INDEX idx_users_official_email ON users (official_email);
+
+-- ---------------------------------------------------------------------------
+-- 4) ai_speaking_assignment
+-- 依据:按 assignment_id 反查口语任务扩展信息
+-- ---------------------------------------------------------------------------
+CREATE INDEX idx_ai_speaking_assignment_fk ON ai_speaking_assignment (assignment_id);
+
+-- ---------------------------------------------------------------------------
+-- 5) student_window_switch_record
+-- 依据:WindowSwitchRecordMapper:WHERE assignment_id = ?(且 GROUP BY 学生);
+--       现有多为 (student_id, assignment_id) 时,无法很好支持仅 assignment 过滤
+-- 注意:表需在库中存在、列名与下划线一致(与 MyBatis-Plus 默认驼峰一致)
+-- ---------------------------------------------------------------------------
+CREATE INDEX idx_window_switch_assignment_student
+    ON student_window_switch_record (assignment_id, student_id);
+
+-- 若上表已有 idx_student_assignment(student_id, assignment_id) 可保留,两者用途不同、可并存
+
+-- ---------------------------------------------------------------------------
+-- 6) 可选清理:与唯一约束重复的二级索引(减少写入与空间)
+-- ---------------------------------------------------------------------------
+-- phone_verification 上若已有 UNIQUE(phone),则普通索引 verification_phone_index 可删:
+-- ALTER TABLE phone_verification DROP INDEX verification_phone_index;
+
+-- course 上 idx_time_range 与 idx_course_time_range 若列完全一致,保留一个即可:
+-- ALTER TABLE course DROP INDEX idx_course_time_range;
+-- 或 DROP INDEX idx_course_time_range 保留 idx_course_time_range 其一
+
+-- =============================================================================
+-- 已在 eai.sql 中覆盖较好、一般无需再补(仅作说明)
+-- - student_assignment:uk(assignment_id, student_id) + idx(student_id, assignment_id)
+-- - evaluation / overall_evaluation / sentence_evaluation:唯一键与 idx_student_assignment
+-- - student_assignment_history:uk 与 idx(assignment, student, submit_time)
+-- - translations、sign_*、class:已有合适索引
+-- =============================================================================

+ 33 - 3
eai.sql

@@ -50,6 +50,8 @@ CREATE TABLE `assignment`  (
   `teacher_portal_id` bigint NULL DEFAULT NULL,
   `teacher_portal_id` bigint NULL DEFAULT NULL,
   `correct_number` int NULL DEFAULT 0,
   `correct_number` int NULL DEFAULT 0,
   `engage_number` int NULL DEFAULT 0,
   `engage_number` int NULL DEFAULT 0,
+  `exam_mode` tinyint(1) NOT NULL DEFAULT 0 COMMENT '是否为考试模式:0-普通作业,1-考试模式',
+  `exam_duration` int NULL DEFAULT NULL COMMENT '考试时长(分钟),仅考试模式有效',
   PRIMARY KEY (`assignment_id`) USING BTREE,
   PRIMARY KEY (`assignment_id`) USING BTREE,
   INDEX `idx_time_range`(`start_time`, `end_time`) USING BTREE
   INDEX `idx_time_range`(`start_time`, `end_time`) USING BTREE
 ) ENGINE = InnoDB AUTO_INCREMENT = 79 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '课程作业表' ROW_FORMAT = Dynamic;
 ) ENGINE = InnoDB AUTO_INCREMENT = 79 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '课程作业表' ROW_FORMAT = Dynamic;
@@ -147,7 +149,8 @@ CREATE TABLE `evaluation`  (
   `overall` int NULL DEFAULT NULL COMMENT '整体评分',
   `overall` int NULL DEFAULT NULL COMMENT '整体评分',
   `sentence` int NULL DEFAULT NULL COMMENT '句子评分',
   `sentence` int NULL DEFAULT NULL COMMENT '句子评分',
   PRIMARY KEY (`id`) USING BTREE,
   PRIMARY KEY (`id`) USING BTREE,
-  INDEX `idx_student_assignment`(`student_id`, `assignment_id`) USING BTREE
+  INDEX `idx_student_assignment`(`student_id`, `assignment_id`) USING BTREE,
+  UNIQUE INDEX `uk_evaluation_assignment_student_version`(`assignment_id`, `student_id`, `version`) USING BTREE
 ) ENGINE = InnoDB AUTO_INCREMENT = 17 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作业评价表' ROW_FORMAT = Dynamic;
 ) ENGINE = InnoDB AUTO_INCREMENT = 17 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作业评价表' ROW_FORMAT = Dynamic;
 
 
 -- ----------------------------
 -- ----------------------------
@@ -175,7 +178,8 @@ CREATE TABLE `overall_evaluation`  (
   `version` int NOT NULL DEFAULT 1 COMMENT '批改版本(跟随作业版本)',
   `version` int NOT NULL DEFAULT 1 COMMENT '批改版本(跟随作业版本)',
   `evaluation` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '整体评估结果',
   `evaluation` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '整体评估结果',
   PRIMARY KEY (`id`) USING BTREE,
   PRIMARY KEY (`id`) USING BTREE,
-  INDEX `idx_student_assignment`(`student_id`, `assignment_id`) USING BTREE
+  INDEX `idx_student_assignment`(`student_id`, `assignment_id`) USING BTREE,
+  UNIQUE INDEX `uk_overall_eval_assignment_student_version`(`assignment_id`, `student_id`, `version`) USING BTREE
 ) ENGINE = InnoDB AUTO_INCREMENT = 20 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作业整体评估表' ROW_FORMAT = Dynamic;
 ) ENGINE = InnoDB AUTO_INCREMENT = 20 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '作业整体评估表' ROW_FORMAT = Dynamic;
 
 
 -- ----------------------------
 -- ----------------------------
@@ -216,9 +220,34 @@ CREATE TABLE `sentence_evaluation`  (
   `type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '句子类型',
   `type` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '句子类型',
   `category` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '句子类别',
   `category` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '句子类别',
   PRIMARY KEY (`id`) USING BTREE,
   PRIMARY KEY (`id`) USING BTREE,
-  INDEX `idx_student_assignment`(`student_id`, `assignment_id`) USING BTREE
+  INDEX `idx_student_assignment`(`student_id`, `assignment_id`) USING BTREE,
+  UNIQUE INDEX `uk_sentence_eval_assignment_student_version_no`(`assignment_id`, `student_id`, `version`, `no`) USING BTREE
 ) ENGINE = InnoDB AUTO_INCREMENT = 74 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '句子评估表' ROW_FORMAT = Dynamic;
 ) ENGINE = InnoDB AUTO_INCREMENT = 74 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '句子评估表' ROW_FORMAT = Dynamic;
 
 
+-- ----------------------------
+-- Migration notes (for existing databases)
+-- ----------------------------
+-- If your production DB already contains duplicate rows, the UNIQUE indexes above will fail to create.
+-- Recommended dedup strategy before adding UNIQUE indexes:
+--
+-- 1) evaluation: keep latest by time
+--   DELETE e1 FROM evaluation e1
+--   JOIN evaluation e2
+--     ON e1.assignment_id=e2.assignment_id AND e1.student_id=e2.student_id AND e1.version=e2.version
+--    AND e1.id < e2.id;
+--
+-- 2) overall_evaluation: keep latest row per (assignment_id, student_id, version)
+--   DELETE o1 FROM overall_evaluation o1
+--   JOIN overall_evaluation o2
+--     ON o1.assignment_id=o2.assignment_id AND o1.student_id=o2.student_id AND o1.version=o2.version
+--    AND o1.id < o2.id;
+--
+-- 3) sentence_evaluation: keep latest row per (assignment_id, student_id, version, no)
+--   DELETE s1 FROM sentence_evaluation s1
+--   JOIN sentence_evaluation s2
+--     ON s1.assignment_id=s2.assignment_id AND s1.student_id=s2.student_id AND s1.version=s2.version AND s1.no=s2.no
+--    AND s1.id < s2.id;
+
 -- ----------------------------
 -- ----------------------------
 -- Table structure for sign_records
 -- Table structure for sign_records
 -- ----------------------------
 -- ----------------------------
@@ -293,6 +322,7 @@ CREATE TABLE `student_assignment`  (
   `quill_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT 'Quill富文本内容',
   `quill_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT 'Quill富文本内容',
   `text_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '纯文本内容',
   `text_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '纯文本内容',
   `html_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT 'HTML格式内容',
   `html_content` text CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT 'HTML格式内容',
+  `submitted` tinyint(1) NULL DEFAULT 0 COMMENT '是否已交卷:0-未交卷,1-已交卷',
   PRIMARY KEY (`id`) USING BTREE,
   PRIMARY KEY (`id`) USING BTREE,
   UNIQUE INDEX `uk_assignment_student_latest`(`assignment_id`, `student_id`) USING BTREE,
   UNIQUE INDEX `uk_assignment_student_latest`(`assignment_id`, `student_id`) USING BTREE,
   INDEX `idx_student_assignment`(`student_id`, `assignment_id`) USING BTREE
   INDEX `idx_student_assignment`(`student_id`, `assignment_id`) USING BTREE

+ 20 - 0
src/main/java/com/njuzr/eaibackend/config/DeepSeekConfig.java

@@ -0,0 +1,20 @@
+package com.njuzr.eaibackend.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * DeepSeek 配置类
+ * 从 application.yaml 加载配置
+ */
+@Data
+@Configuration
+@ConfigurationProperties(prefix = "deepseek")
+public class DeepSeekConfig {
+
+    private String apiKey;
+    private String baseUrl = "https://api.deepseek.com/v1";
+    private String model = "deepseek-chat";
+    private Integer timeout = 60000;
+}

+ 78 - 8
src/main/java/com/njuzr/eaibackend/config/JWTAuthenticationFilter.java

@@ -12,6 +12,8 @@ import jakarta.servlet.ServletException;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import jakarta.servlet.http.HttpServletResponse;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.security.authentication.CredentialsExpiredException;
 import org.springframework.security.authentication.CredentialsExpiredException;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
 import org.springframework.security.core.context.SecurityContextHolder;
 import org.springframework.security.core.context.SecurityContextHolder;
@@ -21,6 +23,7 @@ import org.springframework.web.filter.OncePerRequestFilter;
 
 
 import java.io.IOException;
 import java.io.IOException;
 import java.util.Map;
 import java.util.Map;
+import java.util.concurrent.TimeUnit;
 
 
 /**
 /**
  * @author: Leonezhurui
  * @author: Leonezhurui
@@ -35,13 +38,24 @@ public class JWTAuthenticationFilter extends OncePerRequestFilter {
     private final JWTTokenUtil jwtTokenUtil;
     private final JWTTokenUtil jwtTokenUtil;
     private final MyUserDetailService userDetailService;
     private final MyUserDetailService userDetailService;
     private final UserService userService;
     private final UserService userService;
+    private final RedisTemplate<String, Object> redisTemplate;
+
+    @Value("${jwt.portal-sync.enabled:true}")
+    private boolean portalSyncEnabled;
+
+    @Value("${jwt.portal-sync.throttle-seconds:60}")
+    private long portalSyncThrottleSeconds;
 
 
     private final MyAuthenticationEntryPoint authenticationEntryPoint = new MyAuthenticationEntryPoint();
     private final MyAuthenticationEntryPoint authenticationEntryPoint = new MyAuthenticationEntryPoint();
 
 
-    public JWTAuthenticationFilter(JWTTokenUtil jwtTokenUtil, MyUserDetailService userDetailService, UserService userService) {
+    public JWTAuthenticationFilter(JWTTokenUtil jwtTokenUtil,
+                                   MyUserDetailService userDetailService,
+                                   UserService userService,
+                                   RedisTemplate<String, Object> redisTemplate) {
         this.jwtTokenUtil = jwtTokenUtil;
         this.jwtTokenUtil = jwtTokenUtil;
         this.userDetailService = userDetailService;
         this.userDetailService = userDetailService;
         this.userService = userService;
         this.userService = userService;
+        this.redisTemplate = redisTemplate;
     }
     }
 
 
     // 原登录jwtTokenUtil.parseToken(token)获得的是学号,门户是手机号
     // 原登录jwtTokenUtil.parseToken(token)获得的是学号,门户是手机号
@@ -60,7 +74,7 @@ public class JWTAuthenticationFilter extends OncePerRequestFilter {
             String phone = jwtTokenUtil.parseToken(token);
             String phone = jwtTokenUtil.parseToken(token);
             Map<String, Object> userInfoMap = jwtTokenUtil.parseClaim(token).getBody().get("user_info", Map.class);
             Map<String, Object> userInfoMap = jwtTokenUtil.parseClaim(token).getBody().get("user_info", Map.class);
             // 不为空则认为是门户请求,此时同步用户信息
             // 不为空则认为是门户请求,此时同步用户信息
-            if(userInfoMap != null){
+            if (userInfoMap != null && shouldSyncUserInfo(userInfoMap, phone)) {
                 syncUserInfo(userInfoMap, phone);
                 syncUserInfo(userInfoMap, phone);
             }
             }
 
 
@@ -81,12 +95,12 @@ public class JWTAuthenticationFilter extends OncePerRequestFilter {
     }
     }
 
 
     private void syncUserInfo(Map<String, Object> userInfoMap, String phone) {
     private void syncUserInfo(Map<String, Object> userInfoMap, String phone) {
-        log.info("syncUserInfo user info in token:{}", userInfoMap.toString());
-        Integer pid = Integer.valueOf(userInfoMap.get("id").toString());
-        String name = userInfoMap.get("name").toString();
-        String email = userInfoMap.get("email").toString();
-        String username = userInfoMap.get("username").toString();
-        Role role = Role.valueOf(userInfoMap.get("role").toString());
+        log.info("syncUserInfo user info in token:{}", userInfoMap);
+        Integer pid = parseRequiredInt(userInfoMap, "id");
+        String name = parseRequiredString(userInfoMap, "name");
+        String email = parseOptionalString(userInfoMap, "email");
+        String username = parseRequiredString(userInfoMap, "username");
+        Role role = Role.valueOf(parseRequiredString(userInfoMap, "role"));
         // 如果EAI数据库中没有该用户,则添加该用户
         // 如果EAI数据库中没有该用户,则添加该用户
         if (!userService.userExists(pid)) {
         if (!userService.userExists(pid)) {
             log.info("syncUserInfo user don't exist, add: {}", phone);
             log.info("syncUserInfo user don't exist, add: {}", phone);
@@ -109,4 +123,60 @@ public class JWTAuthenticationFilter extends OncePerRequestFilter {
             userService.updateUserByPid(pid, update);
             userService.updateUserByPid(pid, update);
         }
         }
     }
     }
+
+    private String parseRequiredString(Map<String, Object> map, String key) {
+        Object value = map.get(key);
+        if (value == null) {
+            throw new IllegalArgumentException("portal token missing field: " + key);
+        }
+        String text = value.toString().trim();
+        if (text.isEmpty()) {
+            throw new IllegalArgumentException("portal token empty field: " + key);
+        }
+        return text;
+    }
+
+    private String parseOptionalString(Map<String, Object> map, String key) {
+        Object value = map.get(key);
+        if (value == null) {
+            return null;
+        }
+        String text = value.toString().trim();
+        return text.isEmpty() ? null : text;
+    }
+
+    private Integer parseRequiredInt(Map<String, Object> map, String key) {
+        Object value = map.get(key);
+        if (value == null) {
+            throw new IllegalArgumentException("portal token missing field: " + key);
+        }
+        if (value instanceof Number) {
+            return ((Number) value).intValue();
+        }
+        return Integer.valueOf(value.toString());
+    }
+
+    private boolean shouldSyncUserInfo(Map<String, Object> userInfoMap, String phone) {
+        if (!portalSyncEnabled || portalSyncThrottleSeconds <= 0) {
+            return true;
+        }
+
+        Object pid = userInfoMap.get("id");
+        String syncTarget = pid == null ? phone : pid.toString();
+        String throttleKey = "auth:portal-sync:" + syncTarget;
+
+        try {
+            Boolean shouldSync = redisTemplate.opsForValue().setIfAbsent(
+                    throttleKey,
+                    System.currentTimeMillis(),
+                    portalSyncThrottleSeconds,
+                    TimeUnit.SECONDS
+            );
+            return Boolean.TRUE.equals(shouldSync);
+        } catch (Exception e) {
+            // Redis is an optimization here; if unavailable we keep current auth behavior.
+            log.warn("shouldSyncUserInfo redis throttle failed, fallback to immediate sync. key={}", throttleKey, e);
+            return true;
+        }
+    }
 }
 }

+ 14 - 0
src/main/java/com/njuzr/eaibackend/config/RedisConfig.java

@@ -4,6 +4,8 @@ import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
 import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.listener.PatternTopic;
+import org.springframework.data.redis.listener.RedisMessageListenerContainer;
 
 
 /**
 /**
  * @author: Leonezhurui
  * @author: Leonezhurui
@@ -20,5 +22,17 @@ public class RedisConfig {
         template.setConnectionFactory(connectionFactory);
         template.setConnectionFactory(connectionFactory);
         return template;
         return template;
     }
     }
+
+    @Bean
+    public RedisMessageListenerContainer redisMessageListenerContainer(LettuceConnectionFactory connectionFactory) {
+        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
+        container.setConnectionFactory(connectionFactory);
+        return container;
+    }
+
+    @Bean
+    public PatternTopic patternTopic() {
+        return new PatternTopic("__keyevent@*__:expired");
+    }
 }
 }
 
 

+ 42 - 0
src/main/java/com/njuzr/eaibackend/config/RedisKeyExpirationListener.java

@@ -0,0 +1,42 @@
+package com.njuzr.eaibackend.config;
+
+import com.njuzr.eaibackend.service.ExamTimerService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.connection.Message;
+import org.springframework.data.redis.connection.MessageListener;
+import org.springframework.data.redis.listener.PatternTopic;
+import org.springframework.data.redis.listener.RedisMessageListenerContainer;
+import org.springframework.stereotype.Component;
+
+import jakarta.annotation.PostConstruct;
+
+@Slf4j
+@Component
+public class RedisKeyExpirationListener {
+
+    @Autowired
+    private RedisMessageListenerContainer listenerContainer;
+
+    @Autowired
+    private PatternTopic patternTopic;
+
+    @Autowired
+    private ExamTimerService examTimerService;
+
+    @PostConstruct
+    public void init() {
+        listenerContainer.addMessageListener(new MessageListener() {
+            @Override
+            public void onMessage(Message message, byte[] pattern) {
+                String expiredKey = new String(message.getBody());
+                log.debug("Redis Key 过期事件: {}", expiredKey);
+
+                if (expiredKey.startsWith(ExamTimerService.EXAM_TIMER_KEY_PREFIX)) {
+                    examTimerService.handleExamTimerExpired(expiredKey);
+                }
+            }
+        }, patternTopic);
+        log.info("Redis Key 过期监听器已注册");
+    }
+}

+ 1 - 0
src/main/java/com/njuzr/eaibackend/config/SecurityConfig.java

@@ -61,6 +61,7 @@ public class SecurityConfig {
                                 "/doc/**",
                                 "/doc/**",
                                 "/api/export/**",
                                 "/api/export/**",
                                 "/api/test",
                                 "/api/test",
+                                "/api/ai/stream",  // SSE流式接口已通过JWT认证,跳过Security权限检查
                                 "api/auth/loginByPortal").permitAll() // 允许公开访问的路径
                                 "api/auth/loginByPortal").permitAll() // 允许公开访问的路径
                         .anyRequest().authenticated() // 其他所有请求需要认证
                         .anyRequest().authenticated() // 其他所有请求需要认证
                 )
                 )

+ 18 - 1
src/main/java/com/njuzr/eaibackend/config/ThreadPoolConfig.java

@@ -3,8 +3,11 @@ package com.njuzr.eaibackend.config;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 import org.springframework.context.annotation.Configuration;
+import org.springframework.core.task.TaskDecorator;
 import org.springframework.scheduling.annotation.EnableAsync;
 import org.springframework.scheduling.annotation.EnableAsync;
 import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
 import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
 
 
 import java.util.concurrent.ThreadPoolExecutor;
 import java.util.concurrent.ThreadPoolExecutor;
 
 
@@ -47,8 +50,22 @@ public class ThreadPoolConfig {
         executor.setMaxPoolSize(50);
         executor.setMaxPoolSize(50);
         executor.setQueueCapacity(100);
         executor.setQueueCapacity(100);
         executor.setThreadNamePrefix("ai-eval-");
         executor.setThreadNamePrefix("ai-eval-");
-        executor.initialize();
+        executor.setTaskDecorator(runnable -> {
+        var auth = SecurityContextHolder.getContext().getAuthentication();
+        var context = SecurityContextHolder.createEmptyContext();
+        context.setAuthentication(auth);
+
+        return () -> {
+            try {
+                SecurityContextHolder.setContext(context);
+                runnable.run();
+            } finally {
+                SecurityContextHolder.clearContext();
+            }
+        };
+        });
         executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
         executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
+        executor.initialize();
         return executor;
         return executor;
     }
     }
 
 

+ 1 - 0
src/main/java/com/njuzr/eaibackend/config/WebConfig.java

@@ -29,6 +29,7 @@ public class WebConfig implements WebMvcConfigurer {
                 .allowedOriginPatterns("*")
                 .allowedOriginPatterns("*")
                 .allowedMethods("GET", "POST", "PUT", "DELETE") // 允许的请求方法
                 .allowedMethods("GET", "POST", "PUT", "DELETE") // 允许的请求方法
                 .allowedHeaders("*") // 允许的请求头
                 .allowedHeaders("*") // 允许的请求头
+                .exposedHeaders("Content-Disposition") // 允许前端读取文件名响应头
                 .allowCredentials(true) // 允许发送Cookies
                 .allowCredentials(true) // 允许发送Cookies
                 .maxAge(3600); // 预检请求的缓存时间(秒)
                 .maxAge(3600); // 预检请求的缓存时间(秒)
     }
     }

+ 65 - 0
src/main/java/com/njuzr/eaibackend/constant/EvaluationPromptConstant.java

@@ -0,0 +1,65 @@
+package com.njuzr.eaibackend.constant;
+
+public class EvaluationPromptConstant {
+
+    public static final String OVERALL_EVALUATION_PROMPT = "你是一位专业的英语写作评分专家。请严格按照以下评分标准对作文进行评价。\n\n" +
+            "【评分维度与权重】(满分100分)\n" +
+            "- 内容与观点 (30分):主题相关性、观点明确性、论据充分性、逻辑连贯性\n" +
+            "- 词汇运用 (25分):词汇丰富度、高级词汇使用、词汇准确性、拼写正确性\n" +
+            "- 句型与语法 (25分):句型多样性、语法正确性、主谓一致、时态语态\n" +
+            "- 文章结构 (10分):开头结尾、段落衔接、过渡词使用\n" +
+            "- 语言风格 (10分):正式程度、表达流畅性、创意性\n\n" +
+            "【输出格式要求】\n" +
+            "请按以下Markdown格式输出评价结果:\n\n" +
+            "## 📊 总体评分\n" +
+            "**总分:XX分**\n\n" +
+            "| 维度 | 得分 | 评价 |\n" +
+            "|------|------|------|\n" +
+            "| 内容与观点 | XX/30 | 具体评语 |\n" +
+            "| 词汇运用 | XX/25 | 具体评语 |\n" +
+            "| 句型与语法 | XX/25 | 具体评语 |\n" +
+            "| 文章结构 | XX/10 | 具体评语 |\n" +
+            "| 语言风格 | XX/10 | 具体评语 |\n\n" +
+            "## ✨ 优点\n" +
+            "1. 优点1\n" +
+            "2. 优点2\n" +
+            "3. 优点3\n\n" +
+            "## 💡 改进建议\n" +
+            "1. 改进点1\n" +
+            "2. 改进点2\n" +
+            "3. 改进点3\n\n" +
+            "## 📝 总评\n" +
+            "一两句总体评价\n\n" +
+            "【注意事项】\n" +
+            "- 每个维度的评语要具体指出学生写得好的地方和具体问题\n" +
+            "- 优点和改进点各列出3条最具代表性的\n" +
+            "- 评分要有区分度,避免所有作文都是70-80分\n" +
+            "- 严格按照Markdown表格格式输出\n\n" +
+            "【作文信息】\n" +
+            "作业描述:%s\n\n" +
+            "学生作文:\n" +
+            "%s";
+
+    public static final String SENTENCE_EVALUATION_PROMPT = "你是一位专业的英语写作批改老师。请对以下句子进行批改。\n\n" +
+            "【批改维度】\n" +
+            "1. 词汇:拼写、词性、搭配、准确性\n" +
+            "2. 语法:时态、语态、主谓一致、句型结构\n" +
+            "3. 内容:与主题相关性、逻辑连贯性\n\n" +
+            "【输出格式】\n" +
+            "请严格按以下Markdown格式输出:\n\n" +
+            "## 句子批改\n\n" +
+            "**原句:** xxx\n\n" +
+            "**状态:** ✅ 正确 / ⚠️ 需改进\n\n" +
+            "**问题维度:** 词汇 / 语法 / 内容 / 无\n\n" +
+            "**修改建议:**\n" +
+            "修改后的句子(如果需要)\n\n" +
+            "**原因说明:**\n" +
+            "解释为什么这样改\n\n" +
+            "【作业信息】\n" +
+            "作业描述:%s\n\n" +
+            "学生作文全文:\n" +
+            "%s\n\n" +
+            "待批改的句子:\n" +
+            "%s";
+
+}

+ 50 - 3
src/main/java/com/njuzr/eaibackend/controller/AIController.java

@@ -1,7 +1,6 @@
 package com.njuzr.eaibackend.controller;
 package com.njuzr.eaibackend.controller;
 
 
 import com.njuzr.eaibackend.dto.AIDTO;
 import com.njuzr.eaibackend.dto.AIDTO;
-import com.njuzr.eaibackend.po.AIDialogue;
 import com.njuzr.eaibackend.po.AIEntry;
 import com.njuzr.eaibackend.po.AIEntry;
 import com.njuzr.eaibackend.service.AIDialogueService;
 import com.njuzr.eaibackend.service.AIDialogueService;
 import com.njuzr.eaibackend.service.AIRequestService;
 import com.njuzr.eaibackend.service.AIRequestService;
@@ -9,10 +8,11 @@ import com.njuzr.eaibackend.vo.AIDialogueVO;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.data.domain.Page;
 import org.springframework.data.domain.Page;
-import org.springframework.security.access.method.P;
+import org.springframework.http.MediaType;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.core.annotation.AuthenticationPrincipal;
 import org.springframework.security.core.annotation.AuthenticationPrincipal;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
 
 /**
 /**
  * @author: Leonezhurui
  * @author: Leonezhurui
@@ -72,7 +72,7 @@ public class AIController {
             @RequestParam Long assignmentId,
             @RequestParam Long assignmentId,
             @AuthenticationPrincipal(expression = "id") Long userId
             @AuthenticationPrincipal(expression = "id") Long userId
     ) {
     ) {
-        aiDialogueService.createAIDialogue(assignmentId, userId);
+        aiDialogueService.ensureAIDialogueExists(assignmentId, userId);
         return MyResponse.success("创建AI会话成功");
         return MyResponse.success("创建AI会话成功");
     }
     }
 
 
@@ -93,6 +93,53 @@ public class AIController {
         return MyResponse.success(entry);
         return MyResponse.success(entry);
     }
     }
 
 
+    /**
+     * 学生请求AI(流式响应)- 使用 DeepSeek
+     * 返回 SSE 流式响应,实时显示AI回复内容
+     * @param dialogueId 对话ID
+     * @param aidto 对话请求DTO
+     * @return SseEmitter 流式响应
+     */
+    @PutMapping(value = "/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
+    public SseEmitter requestAIStream(
+            @RequestParam String dialogueId,
+            @RequestBody(required = false) AIDTO aidto,
+            @AuthenticationPrincipal(expression = "id") Long principalUserId
+    ) {
+        log.info("========== 流式接口被调用 ==========");
+        log.info("流式对话请求 - dialogueId={}, aidto={}, principalUserId={}", dialogueId, aidto, principalUserId);
+        
+        if (aidto == null) {
+            log.error("【错误】请求体为空");
+            SseEmitter emitter = new SseEmitter();
+            emitter.completeWithError(new RuntimeException("请求体不能为空"));
+            return emitter;
+        }
+        if (aidto.getMessages() == null) {
+            log.error("【错误】消息列表为空");
+            SseEmitter emitter = new SseEmitter();
+            emitter.completeWithError(new RuntimeException("消息列表不能为空"));
+            return emitter;
+        }
+
+        if (aidto.getUserId() == null && principalUserId != null) {
+            aidto.setUserId(principalUserId);
+        }
+        
+        log.info("【参数校验通过】dialogueId={}, 消息数={}", dialogueId, aidto.getMessages().size());
+        
+        try {
+            SseEmitter result = aiDialogueService.requestAIStream(dialogueId, aidto);
+            log.info("【成功】调用aiDialogueService.requestAIStream完成");
+            return result;
+        } catch (Exception e) {
+            log.error("【异常】调用aiDialogueService.requestAIStream失败: {}", e.getMessage(), e);
+            SseEmitter emitter = new SseEmitter();
+            emitter.completeWithError(e);
+            return emitter;
+        }
+    }
+
     @PostMapping("/rewrite")
     @PostMapping("/rewrite")
     public MyResponse rewrite(
     public MyResponse rewrite(
             @RequestParam Long assignmentId,
             @RequestParam Long assignmentId,

+ 15 - 2
src/main/java/com/njuzr/eaibackend/controller/AssignmentController.java

@@ -13,6 +13,8 @@ import com.njuzr.eaibackend.po.Engagement;
 import com.njuzr.eaibackend.po.MyUserDetails;
 import com.njuzr.eaibackend.po.MyUserDetails;
 import com.njuzr.eaibackend.service.AIDialogueService;
 import com.njuzr.eaibackend.service.AIDialogueService;
 import com.njuzr.eaibackend.service.AssignmentService;
 import com.njuzr.eaibackend.service.AssignmentService;
+import com.njuzr.eaibackend.service.ExamTimerService;
+import com.njuzr.eaibackend.vo.ExamTimeVO;
 import com.njuzr.eaibackend.vo.StudentAssignmentStatusVO;
 import com.njuzr.eaibackend.vo.StudentAssignmentStatusVO;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
 import jakarta.servlet.http.HttpServletResponse;
@@ -41,11 +43,14 @@ public class AssignmentController {
 
 
     private final AIDialogueService aiDialogueService;
     private final AIDialogueService aiDialogueService;
 
 
+    private final ExamTimerService examTimerService;
+
 
 
     @Autowired
     @Autowired
-    public AssignmentController(AssignmentService assignmentService, AIDialogueService aiDialogueService) {
+    public AssignmentController(AssignmentService assignmentService, AIDialogueService aiDialogueService, ExamTimerService examTimerService) {
         this.assignmentService = assignmentService;
         this.assignmentService = assignmentService;
         this.aiDialogueService = aiDialogueService;
         this.aiDialogueService = aiDialogueService;
+        this.examTimerService = examTimerService;
     }
     }
 
 
     /**
     /**
@@ -135,7 +140,7 @@ public class AssignmentController {
             @RequestParam Long assignmentId
             @RequestParam Long assignmentId
     ) {
     ) {
         assignmentService.engageAssignment(studentId, assignmentId);
         assignmentService.engageAssignment(studentId, assignmentId);
-        aiDialogueService.createAIDialogue(assignmentId, studentId);
+        aiDialogueService.ensureAIDialogueExists(assignmentId, studentId);
         return MyResponse.success("参加作业成功");
         return MyResponse.success("参加作业成功");
     }
     }
 
 
@@ -150,6 +155,14 @@ public class AssignmentController {
         return MyResponse.success(assignmentService.getEngagement(studentId, assignmentId));
         return MyResponse.success(assignmentService.getEngagement(studentId, assignmentId));
     }
     }
 
 
+    @GetMapping("/engage/exam-time")
+    public MyResponse getExamTime(
+            @RequestParam Long studentId,
+            @RequestParam Long assignmentId
+    ) {
+        return MyResponse.success(examTimerService.getExamTimeInfo(studentId, assignmentId));
+    }
+
     /**
     /**
         已完成onlyOffice向Quill的更新
         已完成onlyOffice向Quill的更新
      */
      */

+ 37 - 2
src/main/java/com/njuzr/eaibackend/controller/BehaviorRecordController.java

@@ -1,7 +1,9 @@
 package com.njuzr.eaibackend.controller;
 package com.njuzr.eaibackend.controller;
 
 
+import com.njuzr.eaibackend.dto.WindowSwitchDTO;
 import com.njuzr.eaibackend.service.BehaviorRecordService;
 import com.njuzr.eaibackend.service.BehaviorRecordService;
 import com.njuzr.eaibackend.service.BehaviorOverviewService;
 import com.njuzr.eaibackend.service.BehaviorOverviewService;
+import com.njuzr.eaibackend.service.WindowSwitchService;
 import com.njuzr.eaibackend.vo.BehaviorOverviewVO;
 import com.njuzr.eaibackend.vo.BehaviorOverviewVO;
 import com.njuzr.eaibackend.vo.TextAnalysisVO;
 import com.njuzr.eaibackend.vo.TextAnalysisVO;
 import com.njuzr.eaibackend.vo.PauseStatisticsVO;
 import com.njuzr.eaibackend.vo.PauseStatisticsVO;
@@ -22,12 +24,15 @@ import org.springframework.web.multipart.MultipartFile;
 public class BehaviorRecordController {
 public class BehaviorRecordController {
     private final BehaviorRecordService behaviorRecordService;
     private final BehaviorRecordService behaviorRecordService;
     private final BehaviorOverviewService behaviorOverviewService;
     private final BehaviorOverviewService behaviorOverviewService;
+    private final WindowSwitchService windowSwitchService;
 
 
     @Autowired
     @Autowired
-    public BehaviorRecordController(BehaviorRecordService behaviorRecordService, 
-            BehaviorOverviewService behaviorOverviewService) {
+    public BehaviorRecordController(BehaviorRecordService behaviorRecordService,
+            BehaviorOverviewService behaviorOverviewService,
+            WindowSwitchService windowSwitchService) {
         this.behaviorRecordService = behaviorRecordService;
         this.behaviorRecordService = behaviorRecordService;
         this.behaviorOverviewService = behaviorOverviewService;
         this.behaviorOverviewService = behaviorOverviewService;
+        this.windowSwitchService = windowSwitchService;
     }
     }
 
 
     @GetMapping()
     @GetMapping()
@@ -112,4 +117,34 @@ public class BehaviorRecordController {
         String result = behaviorOverviewService.migrateBehaviorRecordsToOverviews(studentId, assignmentId);
         String result = behaviorOverviewService.migrateBehaviorRecordsToOverviews(studentId, assignmentId);
         return MyResponse.success(result);
         return MyResponse.success(result);
     }
     }
+
+    /**
+     * 上报窗口切换行为数据(每次暂存时触发)
+     * 前端在学生点击「暂存」时调用,上报距上次暂存以来的窗口切换次数与详情
+     */
+    @PostMapping("/windowSwitch")
+    public MyResponse reportWindowSwitch(@RequestBody WindowSwitchDTO dto) {
+        log.info("收到窗口切换上报: studentId={}, assignmentId={}, switchCount={}",
+                dto.getStudentId(), dto.getAssignmentId(), dto.getSwitchCount());
+        windowSwitchService.recordWindowSwitch(dto);
+        return MyResponse.success(null);
+    }
+
+    /**
+     * 查询某学生某作业的窗口切换汇总(总切换次数、暂存次数)
+     */
+    @GetMapping("/windowSwitch/summary")
+    public MyResponse getWindowSwitchSummary(
+            @RequestParam Long studentId,
+            @RequestParam Long assignmentId) {
+        return MyResponse.success(windowSwitchService.getSummaryByStudentAndAssignment(studentId, assignmentId));
+    }
+
+    /**
+     * 查询某作业所有学生的窗口切换汇总列表,按总切换次数降序
+     */
+    @GetMapping("/windowSwitch/summaryByAssignment")
+    public MyResponse getWindowSwitchSummaryByAssignment(@RequestParam Long assignmentId) {
+        return MyResponse.success(windowSwitchService.getSummaryByAssignment(assignmentId));
+    }
 }
 }

+ 33 - 0
src/main/java/com/njuzr/eaibackend/controller/ClassController.java

@@ -1,6 +1,7 @@
 package com.njuzr.eaibackend.controller;
 package com.njuzr.eaibackend.controller;
 
 
 import com.njuzr.eaibackend.dto.ClassDTO;
 import com.njuzr.eaibackend.dto.ClassDTO;
+import com.njuzr.eaibackend.dto.SingleStudentImportDTO;
 import com.njuzr.eaibackend.exception.MyException;
 import com.njuzr.eaibackend.exception.MyException;
 import com.njuzr.eaibackend.po.Class;
 import com.njuzr.eaibackend.po.Class;
 import com.njuzr.eaibackend.service.AssignmentService;
 import com.njuzr.eaibackend.service.AssignmentService;
@@ -12,6 +13,7 @@ import com.njuzr.eaibackend.vo.StudentInfoVO;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.util.StringUtils;
 import org.springframework.util.StringUtils;
+import jakarta.validation.Valid;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 import org.springframework.web.multipart.MultipartFile;
 
 
@@ -110,6 +112,37 @@ public class ClassController {
         }
         }
     }
     }
 
 
+    /**
+     * 单独导入一个学生到班级并完成注册
+     * @param classId 班级ID
+     * @param dto 学生信息(学号/姓名/手机号)
+     * @return 导入结果(与批量导入结构一致)
+     */
+    @PostMapping("/{classId}/students/import")
+    @PreAuthorize("hasRole('ADMIN') or hasRole('TEACHER')")
+    public MyResponse importSingleStudent(
+            @PathVariable Long classId,
+            @Valid @RequestBody SingleStudentImportDTO dto,
+            @RequestHeader(value = "Authorization", required = false) String authorization) {
+
+        try {
+            if (!StringUtils.hasText(authorization)) {
+                return MyResponse.error(401, "缺少Authorization令牌");
+            }
+            String token = authorization.startsWith("Bearer ") ? authorization.substring(7).trim() : authorization.trim();
+            if (!StringUtils.hasText(token)) {
+                return MyResponse.error(401, "无效的Authorization令牌");
+            }
+            BatchStudentImportResultVO result = classService.importSingleStudent(classId, dto, token);
+            return MyResponse.success(result);
+        } catch (MyException e) {
+            return MyResponse.error(e.getErrCode(), e.getMessage());
+        } catch (Exception e) {
+            log.error("单独导入学生失败", e);
+            return MyResponse.error(500, "服务器内部错误");
+        }
+    }
+
     /**
     /**
      * 通过学号添加学生到班级
      * 通过学号添加学生到班级
      * @param classId 班级ID
      * @param classId 班级ID

+ 13 - 0
src/main/java/com/njuzr/eaibackend/controller/EvaluationController.java

@@ -1,6 +1,7 @@
 package com.njuzr.eaibackend.controller;
 package com.njuzr.eaibackend.controller;
 
 
 import com.njuzr.eaibackend.service.AIEvaluationService;
 import com.njuzr.eaibackend.service.AIEvaluationService;
+import com.njuzr.eaibackend.vo.EvaluationCurrentVO;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.bind.annotation.*;
 
 
@@ -104,6 +105,18 @@ public class EvaluationController {
         return MyResponse.success(aiEvaluationService.getVersionList(assignmentId, studentId));
         return MyResponse.success(aiEvaluationService.getVersionList(assignmentId, studentId));
     }
     }
 
 
+    /**
+     * 获取当前最新提交版本号(student_assignment.version)与该版本的批改状态。
+     */
+    @GetMapping("/current")
+    public MyResponse getCurrent(
+            @RequestParam Long assignmentId,
+            @RequestParam Long studentId
+    ) {
+        EvaluationCurrentVO vo = aiEvaluationService.getCurrentEvaluationStatus(assignmentId, studentId);
+        return MyResponse.success(vo);
+    }
+
     /**
     /**
      * 用于支持单句重试
      * 用于支持单句重试
      * @param assignmentId
      * @param assignmentId

+ 5 - 0
src/main/java/com/njuzr/eaibackend/dto/AIDTO.java

@@ -15,4 +15,9 @@ import java.util.List;
 public class AIDTO {
 public class AIDTO {
     private String model; // 设置AI类别,目前支持ChatGLM3、Qwen
     private String model; // 设置AI类别,目前支持ChatGLM3、Qwen
     private List<AIEntry> messages;
     private List<AIEntry> messages;
+    /**
+     * 用于在无会话/会话 id 失配时自动补建/纠偏(可选,建议前端在流式对话时携带)
+     */
+    private Long assignmentId;
+    private Long userId;
 }
 }

+ 5 - 1
src/main/java/com/njuzr/eaibackend/dto/AssignmentDTO.java

@@ -40,4 +40,8 @@ public class AssignmentDTO {
     private Integer correctNumber;
     private Integer correctNumber;
 
 
     private List<Long> classIds;
     private List<Long> classIds;
-}
+
+    private Boolean examMode; // 是否为考试模式:false-普通作业,true-考试模式
+
+    private Integer examDuration; // 考试时长(分钟),仅考试模式有效
+}

+ 4 - 0
src/main/java/com/njuzr/eaibackend/dto/AssignmentUpdateDTO.java

@@ -30,4 +30,8 @@ public class AssignmentUpdateDTO {
 
 
     @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
     @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Shanghai")
     private Date endTime;
     private Date endTime;
+
+    private Boolean examMode; // 是否为考试模式:false-普通作业,true-考试模式
+
+    private Integer examDuration; // 考试时长(分钟),仅考试模式有效
 }
 }

+ 16 - 0
src/main/java/com/njuzr/eaibackend/dto/SingleStudentImportDTO.java

@@ -0,0 +1,16 @@
+package com.njuzr.eaibackend.dto;
+
+import jakarta.validation.constraints.NotBlank;
+import lombok.Data;
+
+@Data
+public class SingleStudentImportDTO {
+    @NotBlank(message = "学号不能为空")
+    private String officialNumber;
+
+    @NotBlank(message = "姓名不能为空")
+    private String stuName;
+
+    @NotBlank(message = "手机号不能为空")
+    private String phone;
+}

+ 35 - 0
src/main/java/com/njuzr/eaibackend/dto/WindowSwitchDTO.java

@@ -0,0 +1,35 @@
+package com.njuzr.eaibackend.dto;
+
+import lombok.Data;
+
+import javax.validation.Valid;
+import javax.validation.constraints.NotNull;
+import java.util.List;
+
+/**
+ * 窗口切换行为上报请求体
+ */
+@Data
+public class WindowSwitchDTO {
+
+    @NotNull(message = "studentId 不能为空")
+    private Long studentId;
+
+    @NotNull(message = "assignmentId 不能为空")
+    private Long assignmentId;
+
+    @NotNull(message = "switchCount 不能为空")
+    private Integer switchCount;
+
+    @NotNull(message = "switchEvents 不能为空")
+    private List<SwitchEvent> switchEvents;
+
+    @Data
+    public static class SwitchEvent {
+        /** 事件类型:hidden(离开页面)或 visible(返回页面) */
+        private String eventType;
+
+        /** 事件发生时间,ISO 8601 格式(UTC),如 2026-04-14T10:05:30.123Z */
+        private String timestamp;
+    }
+}

+ 28 - 0
src/main/java/com/njuzr/eaibackend/dto/deepseek/DeepSeekMessage.java

@@ -0,0 +1,28 @@
+package com.njuzr.eaibackend.dto.deepseek;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * DeepSeek 消息对象
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class DeepSeekMessage {
+    private String role;
+    private String content;
+
+    public static DeepSeekMessage user(String content) {
+        return new DeepSeekMessage("user", content);
+    }
+
+    public static DeepSeekMessage assistant(String content) {
+        return new DeepSeekMessage("assistant", content);
+    }
+
+    public static DeepSeekMessage system(String content) {
+        return new DeepSeekMessage("system", content);
+    }
+}

+ 23 - 0
src/main/java/com/njuzr/eaibackend/dto/deepseek/DeepSeekRequest.java

@@ -0,0 +1,23 @@
+package com.njuzr.eaibackend.dto.deepseek;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * DeepSeek 请求对象
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class DeepSeekRequest {
+    private String model;
+    private List<DeepSeekMessage> messages;
+    private Boolean stream = false;
+    private Double temperature = 0.7;
+    private Integer maxTokens = 2048;
+}

+ 48 - 0
src/main/java/com/njuzr/eaibackend/dto/deepseek/DeepSeekResponse.java

@@ -0,0 +1,48 @@
+package com.njuzr.eaibackend.dto.deepseek;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * DeepSeek 响应对象
+ */
+@Data
+public class DeepSeekResponse {
+    private String id;
+    private String object;
+    private Long created;
+    private String model;
+    private List<Choice> choices;
+    private Usage usage;
+
+    @Data
+    public static class Choice {
+        private Integer index;
+        private DeepSeekMessage message;
+        @JsonProperty("finish_reason")
+        private String finishReason;
+        private Object logprobs;
+    }
+
+    @Data
+    public static class Usage {
+        @JsonProperty("prompt_tokens")
+        private Integer promptTokens;
+        @JsonProperty("completion_tokens")
+        private Integer completionTokens;
+        @JsonProperty("total_tokens")
+        private Integer totalTokens;
+    }
+
+    /**
+     * 获取回复内容
+     */
+    public String getContent() {
+        if (choices == null || choices.isEmpty()) {
+            return null;
+        }
+        return choices.get(0).getMessage().getContent();
+    }
+}

+ 57 - 0
src/main/java/com/njuzr/eaibackend/dto/deepseek/DeepSeekStreamResponse.java

@@ -0,0 +1,57 @@
+package com.njuzr.eaibackend.dto.deepseek;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * DeepSeek 流式响应对象
+ */
+@Data
+public class DeepSeekStreamResponse {
+    private String id;
+    private String object;
+    private Long created;
+    private String model;
+    private List<StreamChoice> choices;
+
+    @Data
+    public static class StreamChoice {
+        private Integer index;
+        private Delta delta;
+        @JsonProperty("finish_reason")
+        private String finishReason;
+        private Object logprobs;
+    }
+
+    @Data
+    public static class Delta {
+        private String role;
+        private String content;
+    }
+
+    /**
+     * 获取流式内容片段
+     */
+    public String getContent() {
+        if (choices == null || choices.isEmpty()) {
+            return null;
+        }
+        Delta delta = choices.get(0).getDelta();
+        if (delta == null) {
+            return null;
+        }
+        return delta.getContent();
+    }
+
+    /**
+     * 是否完成
+     */
+    public boolean isDone() {
+        if (choices == null || choices.isEmpty()) {
+            return false;
+        }
+        return "stop".equals(choices.get(0).getFinishReason());
+    }
+}

+ 7 - 3
src/main/java/com/njuzr/eaibackend/exception/GlobalExceptionHandler.java

@@ -27,7 +27,8 @@ public class GlobalExceptionHandler {
     // 务必注意这里的异常类型:org.springframework.security.access.AccessDeniedException.class
     // 务必注意这里的异常类型:org.springframework.security.access.AccessDeniedException.class
     @ExceptionHandler(org.springframework.security.access.AccessDeniedException.class)
     @ExceptionHandler(org.springframework.security.access.AccessDeniedException.class)
     public MyResponse handleAccessDeniedException(org.springframework.security.access.AccessDeniedException e) {
     public MyResponse handleAccessDeniedException(org.springframework.security.access.AccessDeniedException e) {
-        log.error("全局异常处理器捕获,访问被拒绝,错误如下:"+e.getMessage());
+        log.error("【全局异常】权限被拒绝: {}", e.getMessage());
+        log.error("【全局异常】堆栈: ", e);
         return MyResponse.error(HttpStatus.FORBIDDEN.value(), "权限不够:" + e.getMessage());
         return MyResponse.error(HttpStatus.FORBIDDEN.value(), "权限不够:" + e.getMessage());
     }
     }
 
 
@@ -45,7 +46,8 @@ public class GlobalExceptionHandler {
 
 
     @ExceptionHandler(HttpMessageNotReadableException.class)
     @ExceptionHandler(HttpMessageNotReadableException.class)
     public MyResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
     public MyResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) {
-        log.error("全局异常处理器捕获,请求体转换出错,错误如下"+":"+e.getMessage());
+        log.error("【全局异常】请求体转换出错: {}", e.getMessage());
+        log.error("【全局异常】原因: ", e.getCause());
         return MyResponse.error(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase()+":"+"传递信息错误");
         return MyResponse.error(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase()+":"+"传递信息错误");
 
 
     }
     }
@@ -59,7 +61,9 @@ public class GlobalExceptionHandler {
 
 
     @ExceptionHandler(Exception.class)
     @ExceptionHandler(Exception.class)
     public MyResponse handleGlobalException(Exception e) {
     public MyResponse handleGlobalException(Exception e) {
-        log.error("全局异常处理器捕获,错误如下:"+e.getMessage());
+        log.error("【全局异常】捕获到未处理异常: {}", e.getMessage());
+        log.error("【全局异常】异常类型: {}", e.getClass().getName());
+        log.error("【全局异常】完整堆栈: ", e);
         return MyResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "服务器错误: " + e.getMessage());
         return MyResponse.error(HttpStatus.INTERNAL_SERVER_ERROR.value(), "服务器错误: " + e.getMessage());
     }
     }
 
 

+ 17 - 0
src/main/java/com/njuzr/eaibackend/mapper/StudentAssignmentMapper.java

@@ -5,6 +5,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.njuzr.eaibackend.po.Engagement;
 import com.njuzr.eaibackend.po.Engagement;
 import com.njuzr.eaibackend.po.StudentAssignment;
 import com.njuzr.eaibackend.po.StudentAssignment;
 import com.njuzr.eaibackend.po.StudentAssignmentHistory;
 import com.njuzr.eaibackend.po.StudentAssignmentHistory;
+import com.njuzr.eaibackend.vo.EngageNumberVo;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Mapper;
 import org.apache.ibatis.annotations.Param;
 import org.apache.ibatis.annotations.Param;
 
 
@@ -25,14 +26,30 @@ public interface StudentAssignmentMapper extends BaseMapper<Engagement> {
 
 
     List<Engagement> findEngagementByAssignmentId(Long assignmentId);
     List<Engagement> findEngagementByAssignmentId(Long assignmentId);
 
 
+    EngageNumberVo countEngageNumberByCurrentClassStudents(@Param("assignmentId") Long assignmentId);
+
     void delete(Long studentId, Long assignmentId);
     void delete(Long studentId, Long assignmentId);
 
 
     String getTextContent(Long studentId, Long assignmentId);
     String getTextContent(Long studentId, Long assignmentId);
 
 
     void insertHistory(StudentAssignmentHistory history);
     void insertHistory(StudentAssignmentHistory history);
 
 
+    /**
+     * 将 student_assignment 当前行快照写入 student_assignment_history(若该 version 尚不存在)。
+     * 用于保证每次 version 变化后都能落一条历史记录(幂等)。
+     *
+     * @return 实际插入行数(0/1)
+     */
+    int insertHistorySnapshot(@Param("assignmentId") Long assignmentId,
+                              @Param("studentId") Long studentId);
+
     List<StudentAssignmentHistory> findHistoryByStudentIdAndAssignmentId(Long studentId, Long assignmentId);
     List<StudentAssignmentHistory> findHistoryByStudentIdAndAssignmentId(Long studentId, Long assignmentId);
 
 
+    StudentAssignmentHistory findHistoryByAssignmentIdAndStudentIdAndVersion(
+            @Param("assignmentId") Long assignmentId,
+            @Param("studentId") Long studentId,
+            @Param("version") Integer version);
+
     /**
     /**
      * 根据作业ID和学生ID列表查询作业提交情况
      * 根据作业ID和学生ID列表查询作业提交情况
      * @param assignmentId 作业ID
      * @param assignmentId 作业ID

+ 35 - 0
src/main/java/com/njuzr/eaibackend/mapper/WindowSwitchRecordMapper.java

@@ -0,0 +1,35 @@
+package com.njuzr.eaibackend.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.njuzr.eaibackend.po.StudentWindowSwitchRecord;
+import com.njuzr.eaibackend.vo.WindowSwitchSummaryVO;
+import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+/**
+ * 窗口切换行为记录 Mapper
+ */
+@Mapper
+public interface WindowSwitchRecordMapper extends BaseMapper<StudentWindowSwitchRecord> {
+
+    /**
+     * 查询某学生某作业的总切换次数及分段上报次数
+     *
+     * @param studentId    学生ID
+     * @param assignmentId 作业ID
+     * @return 汇总结果
+     */
+    WindowSwitchSummaryVO selectSummaryByStudentAndAssignment(
+            @Param("studentId") Long studentId,
+            @Param("assignmentId") Long assignmentId);
+
+    /**
+     * 查询某作业所有学生的切换次数汇总,按总切换次数降序排列
+     *
+     * @param assignmentId 作业ID
+     * @return 各学生的汇总列表
+     */
+    List<WindowSwitchSummaryVO> selectSummaryByAssignment(@Param("assignmentId") Long assignmentId);
+}

+ 12 - 4
src/main/java/com/njuzr/eaibackend/po/AIDialogue.java

@@ -3,8 +3,9 @@ package com.njuzr.eaibackend.po;
 import lombok.AllArgsConstructor;
 import lombok.AllArgsConstructor;
 import lombok.Data;
 import lombok.Data;
 import org.springframework.data.annotation.Id;
 import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.index.CompoundIndex;
+import org.springframework.data.mongodb.core.index.CompoundIndexes;
 import org.springframework.data.mongodb.core.mapping.Document;
 import org.springframework.data.mongodb.core.mapping.Document;
-import org.springframework.data.redis.core.index.Indexed;
 
 
 import java.util.List;
 import java.util.List;
 
 
@@ -16,15 +17,22 @@ import java.util.List;
 
 
 @Data
 @Data
 @Document(collection = "aiDialogues")
 @Document(collection = "aiDialogues")
+@CompoundIndexes({
+        // findOne(assignmentId+userId)、按作业前缀查询、$push 按 _id
+        @CompoundIndex(
+                name = "idx_ai_dialogue_assignment_user",
+                def = "{'assignmentId': 1, 'userId': 1}",
+                // 历史数据可能重复,先不唯一;去重后可在库中改回 unique
+                background = true
+        )
+})
 public class AIDialogue {
 public class AIDialogue {
     @Id
     @Id
     private String id;
     private String id;
 
 
-    @Indexed
     private Long assignmentId; // 外键
     private Long assignmentId; // 外键
 
 
-    @Indexed
-    private Long userId; // 外键,需要建立(assignmentId, userId)外键索引
+    private Long userId; // 外键
 
 
     private List<DialogueEntry> dialogues;
     private List<DialogueEntry> dialogues;
 
 

+ 11 - 4
src/main/java/com/njuzr/eaibackend/po/AIRewriteRecord.java

@@ -3,8 +3,9 @@ package com.njuzr.eaibackend.po;
 import lombok.AllArgsConstructor;
 import lombok.AllArgsConstructor;
 import lombok.Data;
 import lombok.Data;
 import org.springframework.data.annotation.Id;
 import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.index.CompoundIndex;
+import org.springframework.data.mongodb.core.index.CompoundIndexes;
 import org.springframework.data.mongodb.core.mapping.Document;
 import org.springframework.data.mongodb.core.mapping.Document;
-import org.springframework.data.redis.core.index.Indexed;
 
 
 import java.util.List;
 import java.util.List;
 
 
@@ -16,15 +17,21 @@ import java.util.List;
 
 
 @Data
 @Data
 @Document(collection = "aiRewriteRecords")
 @Document(collection = "aiRewriteRecords")
+@CompoundIndexes({
+        @CompoundIndex(
+                name = "idx_ai_rewrite_assignment_user",
+                def = "{'assignmentId': 1, 'userId': 1}",
+                unique = true,
+                background = true
+        )
+})
 public class AIRewriteRecord {
 public class AIRewriteRecord {
     @Id
     @Id
     private String id;
     private String id;
 
 
-    @Indexed
     private Long assignmentId; // 外键
     private Long assignmentId; // 外键
 
 
-    @Indexed
-    private Long userId; // 外键,需要建立(assignmentId, userId)外键索引
+    private Long userId; // 外键
 
 
     private List<RecordEntry> records;
     private List<RecordEntry> records;
 
 

+ 4 - 0
src/main/java/com/njuzr/eaibackend/po/Assignment.java

@@ -47,4 +47,8 @@ public class Assignment {
     private Integer engageNumber;
     private Integer engageNumber;
 
 
     private Integer correctNumber;
     private Integer correctNumber;
+
+    private Boolean examMode; // 是否为考试模式:false-普通作业,true-考试模式
+
+    private Integer examDuration; // 考试时长(分钟),仅考试模式有效
 }
 }

+ 6 - 3
src/main/java/com/njuzr/eaibackend/po/BehaviorOverview.java

@@ -4,6 +4,7 @@ import lombok.Data;
 import org.springframework.data.annotation.Id;
 import org.springframework.data.annotation.Id;
 import org.springframework.data.mongodb.core.index.CompoundIndex;
 import org.springframework.data.mongodb.core.index.CompoundIndex;
 import org.springframework.data.mongodb.core.index.CompoundIndexes;
 import org.springframework.data.mongodb.core.index.CompoundIndexes;
+import org.springframework.data.mongodb.core.index.Indexed;
 import org.springframework.data.mongodb.core.mapping.Document;
 import org.springframework.data.mongodb.core.mapping.Document;
 import org.springframework.data.mongodb.core.mapping.Field;
 import org.springframework.data.mongodb.core.mapping.Field;
 
 
@@ -18,10 +19,11 @@ import java.util.List;
 @Data
 @Data
 @Document(collection = "behaviorOverviews")
 @Document(collection = "behaviorOverviews")
 @CompoundIndexes({
 @CompoundIndexes({
-        // 复合唯一索引:确保 student_id + assignment_id 的业务唯一性
+        // 唯一:(assignment, student) —— 与 findByAssignmentId、按作业+学生查询、聚合 $match 一致
+        // 若库中已有同名旧索引 student_assignment_unique( student_id, assignment_id ),可手动 drop 后由应用重建
         @CompoundIndex(
         @CompoundIndex(
-                name = "student_assignment_unique",
-                def = "{'student_id': 1, 'assignment_id': 1}",
+                name = "uk_behavior_overview_assignment_student",
+                def = "{'assignment_id': 1, 'student_id': 1}",
                 unique = true,
                 unique = true,
                 background = true
                 background = true
         )
         )
@@ -32,6 +34,7 @@ public class BehaviorOverview {
     private String id; // MongoDB自动生成的ObjectId
     private String id; // MongoDB自动生成的ObjectId
 
 
     @Field("student_id")
     @Field("student_id")
+    @Indexed(name = "idx_behavior_overview_student", background = true)
     private Long studentId; // 学生ID
     private Long studentId; // 学生ID
 
 
     @Field("assignment_id")
     @Field("assignment_id")

+ 2 - 0
src/main/java/com/njuzr/eaibackend/po/Engagement.java

@@ -29,4 +29,6 @@ public class Engagement {
     private String quillContent;
     private String quillContent;
     private String textContent;
     private String textContent;
     private String htmlContent;
     private String htmlContent;
+
+    private Boolean submitted; // 是否已交卷:false-未交卷,true-已交卷
 }
 }

+ 10 - 3
src/main/java/com/njuzr/eaibackend/po/SpeakingAIDialogue.java

@@ -5,8 +5,9 @@ import lombok.AllArgsConstructor;
 import lombok.Data;
 import lombok.Data;
 import lombok.NoArgsConstructor;
 import lombok.NoArgsConstructor;
 import org.springframework.data.annotation.Id;
 import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.index.CompoundIndex;
+import org.springframework.data.mongodb.core.index.CompoundIndexes;
 import org.springframework.data.mongodb.core.mapping.Document;
 import org.springframework.data.mongodb.core.mapping.Document;
-import org.springframework.data.redis.core.index.Indexed;
 
 
 import java.util.List;
 import java.util.List;
 
 
@@ -16,14 +17,20 @@ import java.util.List;
  */
  */
 @Data
 @Data
 @Document(collection = "speakingAiDialogues")
 @Document(collection = "speakingAiDialogues")
+@CompoundIndexes({
+        // findOne(assignmentId+userId)、仅 assignmentId 过滤(左前缀)
+        @CompoundIndex(
+                name = "idx_speaking_dialogue_assignment_user",
+                def = "{'assignmentId': 1, 'userId': 1}",
+                background = true
+        )
+})
 public class SpeakingAIDialogue {
 public class SpeakingAIDialogue {
     @Id
     @Id
     private String id;
     private String id;
 
 
-    @Indexed
     private Long assignmentId; // 外键
     private Long assignmentId; // 外键
 
 
-    @Indexed
     private Long userId;
     private Long userId;
 
 
     /**
     /**

+ 41 - 0
src/main/java/com/njuzr/eaibackend/po/StudentWindowSwitchRecord.java

@@ -0,0 +1,41 @@
+package com.njuzr.eaibackend.po;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 学生写作页面窗口切换行为记录(按暂存区间分段存储)
+ * 对应数据库表:student_window_switch_record
+ */
+@Data
+@TableName("student_window_switch_record")
+public class StudentWindowSwitchRecord {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 学生ID */
+    private Long studentId;
+
+    /** 作业ID */
+    private Long assignmentId;
+
+    /** 本次暂存区间内离开写作窗口的次数(hidden 事件数量) */
+    private Integer switchCount;
+
+    /**
+     * 本次暂存区间内的切换事件详情,JSON 数组字符串
+     * 结构示例:[{"eventType":"hidden","timestamp":"2026-04-14T10:05:30.123Z"},...]
+     */
+    private String switchEvents;
+
+    /** 上报时间(即学生点击暂存的时间) */
+    private Date reportTime;
+
+    /** 记录创建时间 */
+    private Date createdAt;
+}

+ 11 - 5
src/main/java/com/njuzr/eaibackend/po/TextAnalysis.java

@@ -3,11 +3,10 @@ package com.njuzr.eaibackend.po;
 import com.njuzr.eaibackend.vo.TextAnalysisVO;
 import com.njuzr.eaibackend.vo.TextAnalysisVO;
 import lombok.Data;
 import lombok.Data;
 import org.springframework.data.annotation.Id;
 import org.springframework.data.annotation.Id;
+import org.springframework.data.mongodb.core.index.CompoundIndex;
+import org.springframework.data.mongodb.core.index.CompoundIndexes;
 import org.springframework.data.mongodb.core.mapping.Document;
 import org.springframework.data.mongodb.core.mapping.Document;
-import org.springframework.data.mongodb.core.mapping.Field;
-import org.springframework.data.redis.core.index.Indexed;
 
 
-import java.util.Date;
 import java.util.List;
 import java.util.List;
 
 
 /**
 /**
@@ -17,15 +16,22 @@ import java.util.List;
  */
  */
 @Data
 @Data
 @Document(collection = "textAnalyses")
 @Document(collection = "textAnalyses")
+@CompoundIndexes({
+        // getTextAnalysis:studentId + assignmentId
+        @CompoundIndex(
+                name = "idx_text_analysis_student_assignment",
+                def = "{'studentId': 1, 'assignmentId': 1}",
+                unique = true,
+                background = true
+        )
+})
 public class TextAnalysis {
 public class TextAnalysis {
 
 
     @Id
     @Id
     private String id; // MongoDB自动生成的ObjectId
     private String id; // MongoDB自动生成的ObjectId
     // 1. 标识信息
     // 1. 标识信息
-    @Indexed
     private Long studentId; // 学生ID
     private Long studentId; // 学生ID
 
 
-    @Indexed
     private Long assignmentId; // 作业ID
     private Long assignmentId; // 作业ID
 
 
     // 2. 原始文本
     // 2. 原始文本

+ 218 - 4
src/main/java/com/njuzr/eaibackend/service/AIDialogueService.java

@@ -1,6 +1,7 @@
 package com.njuzr.eaibackend.service;
 package com.njuzr.eaibackend.service;
 
 
 import com.njuzr.eaibackend.dto.AIDTO;
 import com.njuzr.eaibackend.dto.AIDTO;
+import com.njuzr.eaibackend.dto.deepseek.DeepSeekMessage;
 import com.njuzr.eaibackend.exception.MyException;
 import com.njuzr.eaibackend.exception.MyException;
 import com.njuzr.eaibackend.mapper.AIDialogueMapper;
 import com.njuzr.eaibackend.mapper.AIDialogueMapper;
 import com.njuzr.eaibackend.mapper.AIRewriteRecordMapper;
 import com.njuzr.eaibackend.mapper.AIRewriteRecordMapper;
@@ -20,8 +21,10 @@ import org.springframework.data.mongodb.core.aggregation.*;
 import org.springframework.data.mongodb.core.query.Criteria;
 import org.springframework.data.mongodb.core.query.Criteria;
 import org.springframework.data.mongodb.core.query.Query;
 import org.springframework.data.mongodb.core.query.Query;
 import org.springframework.data.mongodb.core.query.Update;
 import org.springframework.data.mongodb.core.query.Update;
+import com.mongodb.client.result.UpdateResult;
 import org.springframework.http.HttpStatus;
 import org.springframework.http.HttpStatus;
 import org.springframework.stereotype.Service;
 import org.springframework.stereotype.Service;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
 
 
 import java.io.IOException;
 import java.io.IOException;
 import java.io.InputStream;
 import java.io.InputStream;
@@ -48,6 +51,8 @@ public class AIDialogueService {
 
 
     private final AIRequestService aiRequestService;
     private final AIRequestService aiRequestService;
 
 
+    private final DeepSeekService deepSeekService;
+
     private final StudentAssignmentMapper studentAssignmentMapper;
     private final StudentAssignmentMapper studentAssignmentMapper;
 
 
     private final AssignmentMapper assignmentMapper;
     private final AssignmentMapper assignmentMapper;
@@ -55,11 +60,12 @@ public class AIDialogueService {
     private final FileUtil fileUtil = new FileUtil();
     private final FileUtil fileUtil = new FileUtil();
 
 
     @Autowired
     @Autowired
-    public AIDialogueService(MongoTemplate mongoTemplate, AIDialogueMapper aiDialogueMapper, AIRewriteRecordMapper aiRewriteRecordMapper, AIRequestService aiRequestService, StudentAssignmentMapper studentAssignmentMapper, AssignmentMapper assignmentMapper) {
+    public AIDialogueService(MongoTemplate mongoTemplate, AIDialogueMapper aiDialogueMapper, AIRewriteRecordMapper aiRewriteRecordMapper, AIRequestService aiRequestService, DeepSeekService deepSeekService, StudentAssignmentMapper studentAssignmentMapper, AssignmentMapper assignmentMapper) {
         this.mongoTemplate = mongoTemplate;
         this.mongoTemplate = mongoTemplate;
         this.aiDialogueMapper = aiDialogueMapper;
         this.aiDialogueMapper = aiDialogueMapper;
         this.aiRewriteRecordMapper = aiRewriteRecordMapper;
         this.aiRewriteRecordMapper = aiRewriteRecordMapper;
         this.aiRequestService = aiRequestService;
         this.aiRequestService = aiRequestService;
+        this.deepSeekService = deepSeekService;
         this.studentAssignmentMapper = studentAssignmentMapper;
         this.studentAssignmentMapper = studentAssignmentMapper;
         this.assignmentMapper = assignmentMapper;
         this.assignmentMapper = assignmentMapper;
     }
     }
@@ -80,6 +86,14 @@ public class AIDialogueService {
         aiDialogueMapper.save(aiDialogue);
         aiDialogueMapper.save(aiDialogue);
     }
     }
 
 
+    /**
+     * 幂等补建:若 (assignmentId, userId) 在 Mongo 中已有会话则直接返回,否则创建空会话。
+     * 用于参加作业、流式前纠偏等场景,避免「已参与但无 AIDialogue」或重复调用 createAIDialogue 抛错导致整体失败。
+     */
+    public void ensureAIDialogueExists(Long assignmentId, Long userId) {
+        findOrCreateByAssignmentAndUser(assignmentId, userId, false);
+    }
+
     public void createAIRewriteRecords(Long assignmentId, Long userId) {
     public void createAIRewriteRecords(Long assignmentId, Long userId) {
         if (aiRewriteRecordMapper.findByAssignmentIdAndUserId(assignmentId, userId, PageRequest.of(0,1)).hasContent()) {
         if (aiRewriteRecordMapper.findByAssignmentIdAndUserId(assignmentId, userId, PageRequest.of(0,1)).hasContent()) {
             throw MyException.create(HttpStatus.BAD_REQUEST, "AI改写记录已存在");
             throw MyException.create(HttpStatus.BAD_REQUEST, "AI改写记录已存在");
@@ -110,7 +124,14 @@ public class AIDialogueService {
         );
         );
 
 
         if (targetDialogue == null) {
         if (targetDialogue == null) {
-            throw new MyException(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase()+":"+"找不到AI会话");
+            // 自愈合:如果历史上出现“能对话但无会话文档”的脏数据,补一条空会话,避免前端反复 400
+            log.warn("【AI会话】未找到对话文档,将自动补建: assignmentId={}, userId={}", assignmentId, userId);
+            AIDialogue created = new AIDialogue();
+            created.setAssignmentId(assignmentId);
+            created.setUserId(userId);
+            created.setDialogues(new ArrayList<>());
+            AIDialogue saved = mongoTemplate.save(created, "aiDialogues");
+            targetDialogue = saved;
         }
         }
 
 
         Pageable pageable = PageRequest.of(page, size);
         Pageable pageable = PageRequest.of(page, size);
@@ -201,6 +222,190 @@ public class AIDialogueService {
         return new AIEntry(role, retContent);
         return new AIEntry(role, retContent);
     }
     }
 
 
+    /**
+     * 系统提示词 - 简洁精炼版
+     */
+    private static final String SYSTEM_PROMPT = "你是英语写作辅导老师,用简洁的中文回复 🌟\n" +
+            "\n" +
+            "【回答风格】\n" +
+            "- 用温暖的中文和学生对话,像朋友一样耐心倾听 💬\n" +
+            "- 适当使用表情符号让交流更生动 😊✨\n" +
+            "- 先肯定学生的努力和想法,再给出建议 👍\n" +
+            "【核心要求】\n" +
+            "- 回复**简短精炼**,控制在100字以内\n" +
+            "- 重点突出,一针见血\n" +
+            "- 用表情符号增加亲切感 �\n" +
+            "\n" +
+             "【内容要求】\n" +
+            "- 详略得当:重点详细讲,次要内容简要提\n" +
+            "- 指出问题时,用'可以这样改...'代替'你错了'\n" +
+            "- 英语例句配中文翻译,让学生轻松理解\n" +
+            "- 结尾给出1条具体可行的操作建议 💡\n" +
+            "\n" +
+            "【思路拓展】当学生询问写作主题时:\n" +
+            "- 热情地提供3-5个不同角度的写作方向 🎯\n" +
+            "- 每个方向给一个生动的例子或切入点\n" +
+            "- 用'你可以试试...'、'不妨考虑...'等建议语气\n" +
+            "- 简要分析每个方向适合什么样的学生\n" ;
+
+
+    /**
+     * 使用 DeepSeek 流式对话
+     * @param dialogueId 对话ID
+     * @param aidto 对话请求DTO
+     * @return SseEmitter 流式响应
+     */
+    public SseEmitter requestAIStream(String dialogueId, AIDTO aidto) {
+        log.info("【Service层】requestAIStream开始 - dialogueId={}", dialogueId);
+        
+        try {
+            // 构造用户消息(用于存储)
+            AIEntry lastEntry = aidto.getMessages().get(aidto.getMessages().size() - 1);
+            log.info("【Service层】最后一条消息 - role={}, content长度={}", lastEntry.getRole(), 
+                    lastEntry.getContent() != null ? lastEntry.getContent().length() : 0);
+            
+            AIDialogue.DialogueEntry userEntry = new AIDialogue.DialogueEntry(
+                    lastEntry.getRole(), 
+                    lastEntry.getContent(), 
+                    System.currentTimeMillis() / 1000
+            );
+
+            // 构造 DeepSeek 消息列表(添加系统提示词)
+            List<DeepSeekMessage> messages = new ArrayList<>();
+            messages.add(DeepSeekMessage.system(SYSTEM_PROMPT));
+            
+            // 添加历史对话上下文
+            int userMsgCount = 0;
+            int assistantMsgCount = 0;
+            for (AIEntry entry : aidto.getMessages()) {
+                if ("user".equals(entry.getRole())) {
+                    messages.add(DeepSeekMessage.user(entry.getContent()));
+                    userMsgCount++;
+                } else if ("assistant".equals(entry.getRole())) {
+                    messages.add(DeepSeekMessage.assistant(entry.getContent()));
+                    assistantMsgCount++;
+                }
+            }
+            log.info("【Service层】消息统计 - user消息={}, assistant消息={}, deepSeek入参消息数(含system)={}", 
+                    userMsgCount, assistantMsgCount, messages.size());
+
+            // 在发起外部模型请求前,确保 Mongo 中存在可用于 $push 的根文档;必要时按 assignmentId+userId 自动补建/纠偏 dialogueId
+            String effectiveDialogueId = resolveOrCreateDialogueId(dialogueId, aidto);
+            log.info("【Service层】resolved dialogueId: requested={}, effective={}, assignmentId={}, userId={}",
+                    dialogueId, effectiveDialogueId, aidto.getAssignmentId(), aidto.getUserId());
+
+            SseEmitter emitter = new SseEmitter(0L);
+            StringBuilder fullResponse = new StringBuilder();
+            
+            log.info("【Service层】开始调用deepSeekService.chatCompletionStream");
+            
+            // 使用回调方式处理流式响应
+            deepSeekService.chatCompletionStream(messages, 
+                content -> {
+                    // 流式内容回调 - 实时发送给客户端
+                    fullResponse.append(content);
+                    try {
+                        emitter.send(content);
+                    } catch (Exception e) {
+                        log.error("【Service层】发送流式响应失败: {}", e.getMessage());
+                    }
+                },
+                () -> {
+                    // 完成回调 - 保存对话记录到数据库
+                    log.info("【Service层】DeepSeek 流式响应完成,保存对话记录");
+                    AIDialogue.DialogueEntry aiEntry = new AIDialogue.DialogueEntry(
+                            "assistant", 
+                            fullResponse.toString(), 
+                            System.currentTimeMillis() / 1000
+                    );
+                    addDialogueEntry(effectiveDialogueId, userEntry, aiEntry);
+                    emitter.complete();
+                    log.info("【Service层】emitter.complete()已调用");
+                },
+                error -> {
+                    // 错误回调
+                    log.error("【Service层】DeepSeek 流式请求失败: {}", error.getMessage(), error);
+                    // SSE 场景:completeWithError 可能触发容器异常派发,进入全局异常处理器并导致 406(No acceptable representation)
+                    // 改为发送可读错误事件后正常 complete,前端可稳定展示错误信息
+                    try {
+                        String msg = (error instanceof MyException)
+                                ? ((MyException) error).getMessage()
+                                : ("AI 请求失败:" + (error.getMessage() == null ? "unknown" : error.getMessage()));
+                        emitter.send(SseEmitter.event().name("error").data(msg));
+                    } catch (Exception sendErr) {
+                        log.warn("【Service层】发送 SSE error 事件失败: {}", sendErr.getMessage());
+                    } finally {
+                        emitter.complete();
+                    }
+                }
+            );
+            
+            log.info("【Service层】requestAIStream正常返回emitter");
+            return emitter;
+            
+        } catch (Exception e) {
+            log.error("【Service层】requestAIStream异常: {}", e.getMessage(), e);
+            SseEmitter errorEmitter = new SseEmitter();
+            errorEmitter.completeWithError(e);
+            return errorEmitter;
+        }
+    }
+
+    /**
+     * 解析/补建对话根文档 id:
+     * - 优先信任请求参数 dialogueId(若存在且能查到)
+     * - 否则按 assignmentId+userId 查找/创建
+     * - 若仅有 userId 缺失,尝试用 aidto.userId(若前端传入)
+     */
+    private String resolveOrCreateDialogueId(String requestedDialogueId, AIDTO aidto) {
+        if (requestedDialogueId != null && !requestedDialogueId.isBlank()) {
+            AIDialogue byId = mongoTemplate.findById(requestedDialogueId, AIDialogue.class);
+            if (byId != null) {
+                return byId.getId();
+            }
+            log.warn("【AI会话】request dialogueId 不存在,将尝试按 assignmentId+userId 纠偏/补建。requestedDialogueId={}", requestedDialogueId);
+        }
+
+        Long assignmentId = aidto == null ? null : aidto.getAssignmentId();
+        Long userId = aidto == null ? null : aidto.getUserId();
+        if (assignmentId == null || userId == null) {
+            throw MyException.create(HttpStatus.BAD_REQUEST,
+                    "AI会话不存在,且缺少补建参数(assignmentId/userId)。请刷新页面后重试,或在流式请求体携带 assignmentId,并由登录态或请求体提供 userId");
+        }
+
+        return findOrCreateByAssignmentAndUser(assignmentId, userId, true);
+    }
+
+    /**
+     * 按 (assignmentId, userId) 查 Mongo 会话;不存在则补建。优先走 Repository 分页,避免与 MongoTemplate 各查各的导致误判。
+     *
+     * @param logRepair true 时仅在“本次新建了文档”打 warn 日志(流式纠偏场景);false 为参与作业等静默幂等
+     */
+    private String findOrCreateByAssignmentAndUser(Long assignmentId, Long userId, boolean logRepair) {
+        Page<AIDialogue> page = aiDialogueMapper.findByAssignmentIdAndUserId(assignmentId, userId, PageRequest.of(0, 1));
+        if (page.hasContent()) {
+            return page.getContent().get(0).getId();
+        }
+        AIDialogue byPair = mongoTemplate.findOne(
+                Query.query(Criteria.where("assignmentId").is(assignmentId).and("userId").is(userId)),
+                AIDialogue.class
+        );
+        if (byPair != null) {
+            return byPair.getId();
+        }
+        AIDialogue created = new AIDialogue();
+        created.setAssignmentId(assignmentId);
+        created.setUserId(userId);
+        created.setDialogues(new ArrayList<>());
+        AIDialogue saved = mongoTemplate.save(created, "aiDialogues");
+        if (logRepair) {
+            log.warn("【AI会话】已自动补建对话文档: id={}, assignmentId={}, userId={}", saved.getId(), assignmentId, userId);
+        } else {
+            log.info("【AI会话】ensure: 已补建空会话, id={}, assignmentId={}, userId={}", saved.getId(), assignmentId, userId);
+        }
+        return saved.getId();
+    }
+
 
 
     public AIEntry rewrite(Long assignmentId, Long studentId) {
     public AIEntry rewrite(Long assignmentId, Long studentId) {
         // 1 通过assignmentId和studentId找到engagement,获取当前文件链接
         // 1 通过assignmentId和studentId找到engagement,获取当前文件链接
@@ -291,9 +496,18 @@ public class AIDialogueService {
         try {
         try {
             Query query =  new Query(Criteria.where("id").is(dialogueId));
             Query query =  new Query(Criteria.where("id").is(dialogueId));
             Update update = new Update().push("dialogues").each(userEntry, aiEntry);
             Update update = new Update().push("dialogues").each(userEntry, aiEntry);
-            mongoTemplate.updateFirst(query, update, AIDialogue.class);
+            UpdateResult result = mongoTemplate.updateFirst(query, update, AIDialogue.class);
+            if (result == null || result.getMatchedCount() == 0) {
+                log.error("【AI会话】写库未匹配到对话文档: dialogueId={}, matched={}, modified={}",
+                        dialogueId,
+                        result == null ? "null" : result.getMatchedCount(),
+                        result == null ? "null" : result.getModifiedCount());
+                throw new MyException(HttpStatus.BAD_REQUEST.value(), HttpStatus.BAD_REQUEST.getReasonPhrase() + ":" + "AI会话不存在,无法保存对话");
+            }
+        } catch (MyException e) {
+            throw e;
         } catch (Exception e) {
         } catch (Exception e) {
-            log.error("MongoDB数据库更新出错");
+            log.error("MongoDB数据库更新出错: {}", e.getMessage(), e);
             throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()+":"+"数据库更新失败");
             throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase()+":"+"数据库更新失败");
         }
         }
     }
     }

+ 244 - 55
src/main/java/com/njuzr/eaibackend/service/AIEvaluationService.java

@@ -1,11 +1,16 @@
 package com.njuzr.eaibackend.service;
 package com.njuzr.eaibackend.service;
 
 
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
 import com.baomidou.mybatisplus.core.conditions.update.UpdateWrapper;
+import com.njuzr.eaibackend.constant.EvaluationPromptConstant;
 import com.njuzr.eaibackend.constant.PromptConstant;
 import com.njuzr.eaibackend.constant.PromptConstant;
+import com.njuzr.eaibackend.dto.deepseek.DeepSeekMessage;
+import com.njuzr.eaibackend.dto.deepseek.DeepSeekResponse;
 import com.njuzr.eaibackend.exception.MyException;
 import com.njuzr.eaibackend.exception.MyException;
 import com.njuzr.eaibackend.mapper.*;
 import com.njuzr.eaibackend.mapper.*;
 import com.njuzr.eaibackend.po.*;
 import com.njuzr.eaibackend.po.*;
+import com.njuzr.eaibackend.vo.EvaluationCurrentVO;
 import lombok.extern.slf4j.Slf4j;
 import lombok.extern.slf4j.Slf4j;
 import org.apache.commons.lang3.time.StopWatch;
 import org.apache.commons.lang3.time.StopWatch;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -18,6 +23,7 @@ import org.springframework.transaction.annotation.Transactional;
 import org.springframework.transaction.PlatformTransactionManager;
 import org.springframework.transaction.PlatformTransactionManager;
 import org.springframework.transaction.support.TransactionTemplate;
 import org.springframework.transaction.support.TransactionTemplate;
 import org.springframework.transaction.TransactionDefinition;
 import org.springframework.transaction.TransactionDefinition;
+import org.springframework.dao.DuplicateKeyException;
 
 
 import java.text.BreakIterator;
 import java.text.BreakIterator;
 import java.util.*;
 import java.util.*;
@@ -36,6 +42,7 @@ public class AIEvaluationService {
     private static final int RETRY_COUNT = 3;
     private static final int RETRY_COUNT = 3;
 
 
     private final AIRequestService aiRequestService;
     private final AIRequestService aiRequestService;
+    private final DeepSeekService deepSeekService;
 
 
     private final StudentAssignmentMapper studentAssignmentMapper;
     private final StudentAssignmentMapper studentAssignmentMapper;
 
 
@@ -62,12 +69,14 @@ public class AIEvaluationService {
     private SentenceEvaluationMapperServiceImpl sentenceEvaluationMapperServiceImpl;
     private SentenceEvaluationMapperServiceImpl sentenceEvaluationMapperServiceImpl;
 
 
     @Autowired
     @Autowired
-    public AIEvaluationService(AIRequestService aiRequestService, StudentAssignmentMapper studentAssignmentMapper,
+    public AIEvaluationService(AIRequestService aiRequestService, DeepSeekService deepSeekService,
+            StudentAssignmentMapper studentAssignmentMapper,
             AssignmentMapper assignmentMapper, OverallEvaluationMapper overallEvaluationMapper,
             AssignmentMapper assignmentMapper, OverallEvaluationMapper overallEvaluationMapper,
             SentenceEvaluationMapper sentenceEvaluationMapper, EvaluationMapper evaluationMapper,
             SentenceEvaluationMapper sentenceEvaluationMapper, EvaluationMapper evaluationMapper,
             EvaluationMapperServiceImpl evaluationMapperServiceImpl,
             EvaluationMapperServiceImpl evaluationMapperServiceImpl,
             PlatformTransactionManager transactionManager) {
             PlatformTransactionManager transactionManager) {
         this.aiRequestService = aiRequestService;
         this.aiRequestService = aiRequestService;
+        this.deepSeekService = deepSeekService;
         this.studentAssignmentMapper = studentAssignmentMapper;
         this.studentAssignmentMapper = studentAssignmentMapper;
         this.assignmentMapper = assignmentMapper;
         this.assignmentMapper = assignmentMapper;
         this.overallEvaluationMapper = overallEvaluationMapper;
         this.overallEvaluationMapper = overallEvaluationMapper;
@@ -99,6 +108,10 @@ public class AIEvaluationService {
         if(engagement == null) throw MyException.create(HttpStatus.BAD_REQUEST, "作业参与不存在");
         if(engagement == null) throw MyException.create(HttpStatus.BAD_REQUEST, "作业参与不存在");
         Assignment assignment = assignmentMapper.selectById(assignmentId);
         Assignment assignment = assignmentMapper.selectById(assignmentId);
         if (assignment == null) throw MyException.create(HttpStatus.BAD_REQUEST, "作业不存在");
         if (assignment == null) throw MyException.create(HttpStatus.BAD_REQUEST, "作业不存在");
+        final int version = engagement.getVersion();
+        log.info("[EVAL][OVERALL] request start assignmentId={}, studentId={}, version={}, textLen={}",
+                assignmentId, studentId, version,
+                engagement.getTextContent() == null ? -1 : engagement.getTextContent().length());
         /**
         /**
          * AI 作业评估主流程。
          * AI 作业评估主流程。
          *
          *
@@ -125,43 +138,85 @@ public class AIEvaluationService {
          * - 防止系统异常导致任务永久卡死。
          * - 防止系统异常导致任务永久卡死。
          * - 保证 evaluation 表始终只存在一条有效执行记录。
          * - 保证 evaluation 表始终只存在一条有效执行记录。
          */
          */
+        // 1) 先检查当前版本是否已完成整体批改:已完成则直接返回历史结果(幂等)
+        Evaluation exist = evaluationMapperServiceImpl.getByVersion(assignmentId, studentId, version);
+        log.info("[EVAL][OVERALL] current evaluation record assignmentId={}, studentId={}, version={}, exist={}, overall={}, sentence={}, time={}",
+                assignmentId, studentId, version,
+                exist != null,
+                exist == null ? null : exist.getOverall(),
+                exist == null ? null : exist.getSentence(),
+                exist == null ? null : exist.getTime());
+        if (exist != null && exist.getOverall() != null && exist.getOverall() == 1) {
+            OverallEvaluation overall = getOverallEvaluationIfExists(assignmentId, studentId, version);
+            if (overall != null) {
+                log.info("[EVAL][OVERALL] hit cached result assignmentId={}, studentId={}, version={}, evalLen={}",
+                        assignmentId, studentId, version,
+                        overall.getEvaluation() == null ? -1 : overall.getEvaluation().length());
+                return new AIEntry("assistant", overall.getEvaluation());
+            }
+            // 理论上不应该出现:标记完成但无结果;允许重新触发
+            log.warn("evaluation overall flag=1 but overall_evaluation missing, will re-run. assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+        }
+
+        // 2) 抢占执行权:不存在则插入占位记录;并发时依赖唯一索引冲突
         try {
         try {
             initEvaluationRecord(assignmentId, studentId, engagement);
             initEvaluationRecord(assignmentId, studentId, engagement);
-        } catch (org.springframework.dao.DuplicateKeyException ex) {
-            Evaluation exist = evaluationMapperServiceImpl.getByVersion(assignmentId, studentId, engagement.getVersion());
-            if (exist == null) {
+            log.info("[EVAL][OVERALL] init evaluation lock success assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+        } catch (DuplicateKeyException ex) {
+            log.info("[EVAL][OVERALL] init evaluation lock duplicate assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+            Evaluation locked = evaluationMapperServiceImpl.getByVersion(assignmentId, studentId, version);
+            if (locked == null) {
                 throw MyException.create(HttpStatus.BAD_REQUEST, "作业正在进行AI分析,请稍后重试");
                 throw MyException.create(HttpStatus.BAD_REQUEST, "作业正在进行AI分析,请稍后重试");
             }
             }
-            if (exist.getOverall() == 0) {
 
 
+            // overall==0: 处理中;overall==1: 已完成(上面已处理直接返回)
+            if (locked.getOverall() != null && locked.getOverall() == 0) {
                 long timeoutMs = 10L * 60 * 1000;
                 long timeoutMs = 10L * 60 * 1000;
                 Date expireBefore = new Date(System.currentTimeMillis() - timeoutMs);
                 Date expireBefore = new Date(System.currentTimeMillis() - timeoutMs);
-
-                if (exist.getTime() != null && exist.getTime().before(expireBefore)){
-                    evaluationMapperServiceImpl.deleteByVersion(assignmentId, studentId, engagement.getVersion());
-                   throw MyException.create(HttpStatus.BAD_REQUEST, "之前的 AI 分析已超时,已为您清理,请重新发起");
-
-                } else {
-                    throw MyException.create(HttpStatus.BAD_REQUEST, "作业正在进行AI分析,请勿重复发起请求");
+                if (locked.getTime() != null && locked.getTime().before(expireBefore)) {
+                    evaluationMapperServiceImpl.deleteByVersion(assignmentId, studentId, version);
+                    throw MyException.create(HttpStatus.BAD_REQUEST, "之前的 AI 分析已超时,已为您清理,请重新发起");
                 }
                 }
+                throw MyException.create(HttpStatus.BAD_REQUEST, "作业正在进行AI分析,请勿重复发起请求");
+            }
+
+            OverallEvaluation overall = getOverallEvaluationIfExists(assignmentId, studentId, version);
+            if (overall != null) {
+                log.info("[EVAL][OVERALL] duplicate request but result exists assignmentId={}, studentId={}, version={}, evalLen={}",
+                        assignmentId, studentId, version,
+                        overall.getEvaluation() == null ? -1 : overall.getEvaluation().length());
+                return new AIEntry("assistant", overall.getEvaluation());
             }
             }
             throw MyException.create(HttpStatus.BAD_REQUEST, "作业已进行过AI分析");
             throw MyException.create(HttpStatus.BAD_REQUEST, "作业已进行过AI分析");
         }
         }
+
         try {
         try {
-            String prompt = String.format(PromptConstant.REWRITE_PROMPT_TEMPLATE, assignment.getDescription(), engagement.getTextContent());
+            String prompt = String.format(EvaluationPromptConstant.OVERALL_EVALUATION_PROMPT,
+                    assignment.getDescription(), engagement.getTextContent());
+            log.info("[EVAL][OVERALL] llm request start assignmentId={}, studentId={}, version={}, promptLen={}",
+                    assignmentId, studentId, version, prompt.length());
             AIRequestService.AIResponse response = requestAIWithRetry(prompt, 0);
             AIRequestService.AIResponse response = requestAIWithRetry(prompt, 0);
             String retContent = response.getChoices().get(0).getMessage().getContent();
             String retContent = response.getChoices().get(0).getMessage().getContent();
             String role = response.getChoices().get(0).getMessage().getRole();
             String role = response.getChoices().get(0).getMessage().getRole();
 
 
-            // 保存评估结果
+            // 保存Markdown格式的评估结果
             saveOverallEvaluationResult(assignmentId, studentId, engagement, retContent);
             saveOverallEvaluationResult(assignmentId, studentId, engagement, retContent);
+            log.info("[EVAL][OVERALL] save success assignmentId={}, studentId={}, version={}, role={}, evalLen={}, cost={}",
+                    assignmentId, studentId, version, role,
+                    retContent == null ? -1 : retContent.length(),
+                    sw.formatTime());
 
 
+            log.info("【评分完成】作文长度: {}, 评价长度: {}", 
+                    engagement.getTextContent().length(), retContent.length());
             log.info("evaluation cost: {}", sw.formatTime());
             log.info("evaluation cost: {}", sw.formatTime());
             return new AIEntry(role, retContent);
             return new AIEntry(role, retContent);
 
 
         } catch (Exception e) {
         } catch (Exception e) {
             log.error("evaluation requestAI error: {}", e.getMessage(), e);
             log.error("evaluation requestAI error: {}", e.getMessage(), e);
-            evaluationMapperServiceImpl.deleteByVersion(assignmentId, studentId, engagement.getVersion());
+            evaluationMapperServiceImpl.deleteByVersion(assignmentId, studentId, version);
             throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(),HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase() + ":" + "请求失败,请重新尝试"
             throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(),HttpStatus.INTERNAL_SERVER_ERROR.getReasonPhrase() + ":" + "请求失败,请重新尝试"
             );
             );
         }
         }
@@ -207,30 +262,59 @@ public class AIEvaluationService {
 
 
     @Transactional(rollbackFor = Exception.class)
     @Transactional(rollbackFor = Exception.class)
     public void saveOverallEvaluationResult(Long assignmentId, Long studentId, Engagement engagement, String retContent) {
     public void saveOverallEvaluationResult(Long assignmentId, Long studentId, Engagement engagement, String retContent) {
+        final int version = engagement.getVersion();
         OverallEvaluation overallEvaluation = new OverallEvaluation()
         OverallEvaluation overallEvaluation = new OverallEvaluation()
             .setStudentId(studentId)
             .setStudentId(studentId)
             .setAssignmentId(assignmentId)
             .setAssignmentId(assignmentId)
             .setContent(engagement.getTextContent())
             .setContent(engagement.getTextContent())
             .setEvaluation(retContent)
             .setEvaluation(retContent)
-            .setVersion(engagement.getVersion());
-        overallEvaluationMapper.insert(overallEvaluation);
+            .setVersion(version);
+        try {
+            overallEvaluationMapper.insert(overallEvaluation);
+            log.info("[EVAL][OVERALL] overall_evaluation insert ok assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+        } catch (DuplicateKeyException e) {
+            // 幂等:同版本重复写入时忽略(依赖唯一索引)
+            log.info("overall_evaluation already exists, ignore duplicate insert. assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+        }
 
 
         evaluationMapperServiceImpl.update(new UpdateWrapper<Evaluation>()
         evaluationMapperServiceImpl.update(new UpdateWrapper<Evaluation>()
                 .set("overall", 1)
                 .set("overall", 1)
                 .eq("student_id", studentId)
                 .eq("student_id", studentId)
                 .eq("assignment_id", assignmentId)
                 .eq("assignment_id", assignmentId)
-                .eq("version", engagement.getVersion()));
+                .eq("version", version));
+        log.info("[EVAL][OVERALL] evaluation flag update overall=1 assignmentId={}, studentId={}, version={}",
+                assignmentId, studentId, version);
     }
     }
 
 
     private AIRequestService.AIResponse requestAIWithRetry(String prompt, int retryTime) {
     private AIRequestService.AIResponse requestAIWithRetry(String prompt, int retryTime) {
-        List<AIEntry> entry = new ArrayList<>();
-        entry.add(new AIEntry("user", prompt));
-        AIRequestService.AIResponse response = aiRequestService.requestChatGLM4(entry);
-        if(response == null && retryTime < 3) {
-            log.info("requestAI failed, retryTime: {}", retryTime + 1);
-            return requestAIWithRetry(prompt, retryTime + 1);
+        List<DeepSeekMessage> messages = new ArrayList<>();
+        messages.add(DeepSeekMessage.user(prompt));
+        try {
+            DeepSeekResponse response = deepSeekService.chatCompletion(messages);
+            if (response == null && retryTime < 3) {
+                log.info("DeepSeek request failed, retryTime: {}", retryTime + 1);
+                return requestAIWithRetry(prompt, retryTime + 1);
+            }
+            if (response != null && response.getChoices() != null && !response.getChoices().isEmpty()) {
+                AIRequestService.Choice choice = new AIRequestService.Choice();
+                AIRequestService.ResponseMessage msg = new AIRequestService.ResponseMessage();
+                msg.setRole(response.getChoices().get(0).getMessage().getRole());
+                msg.setContent(response.getChoices().get(0).getMessage().getContent());
+                choice.setMessage(msg);
+                AIRequestService.AIResponse aiResponse = new AIRequestService.AIResponse();
+                aiResponse.setChoices(List.of(choice));
+                return aiResponse;
+            }
+            return null;
+        } catch (Exception e) {
+            log.error("DeepSeek request error: {}, retryTime: {}/{}", e.getMessage(), retryTime + 1, 3);
+            if (retryTime < 3) {
+                return requestAIWithRetry(prompt, retryTime + 1);
+            }
+            return null;
         }
         }
-        return response;
     }
     }
 
 
     /**
     /**
@@ -247,41 +331,56 @@ public class AIEvaluationService {
         if(engagement == null) throw MyException.create(HttpStatus.BAD_REQUEST, "作业参与不存在");
         if(engagement == null) throw MyException.create(HttpStatus.BAD_REQUEST, "作业参与不存在");
         Assignment assignment = assignmentMapper.selectById(assignmentId);
         Assignment assignment = assignmentMapper.selectById(assignmentId);
         if (assignment == null) throw MyException.create(HttpStatus.BAD_REQUEST, "作业不存在");
         if (assignment == null) throw MyException.create(HttpStatus.BAD_REQUEST, "作业不存在");
+        final int version = engagement.getVersion();
+        log.info("[EVAL][SENTENCE] request start assignmentId={}, studentId={}, version={}, textLen={}",
+                assignmentId, studentId, version,
+                engagement.getTextContent() == null ? -1 : engagement.getTextContent().length());
+
+        // 逐句批改可独立触发:确保 evaluation 占位记录存在(不依赖整体先跑)
+        Evaluation evaluationJudge = ensureEvaluationRecordExists(assignmentId, studentId, engagement);
+        log.info("[EVAL][SENTENCE] current evaluation record assignmentId={}, studentId={}, version={}, sentence={}, overall={}, time={}",
+                assignmentId, studentId, version,
+                evaluationJudge == null ? null : evaluationJudge.getSentence(),
+                evaluationJudge == null ? null : evaluationJudge.getOverall(),
+                evaluationJudge == null ? null : evaluationJudge.getTime());
+
+        // 检查是否已完成逐句批改(sentence=1表示已完成)
+        if (evaluationJudge != null && evaluationJudge.getSentence() != null && evaluationJudge.getSentence() == 1) {
+            log.info("逐句批改已完成,直接返回现有结果 - assignmentId: {}, studentId: {}, version: {}",
+                    assignmentId, studentId, version);
+            LambdaQueryWrapper<SentenceEvaluation> queryWrapper = new LambdaQueryWrapper<>();
+            queryWrapper.eq(SentenceEvaluation::getAssignmentId, assignmentId)
+                    .eq(SentenceEvaluation::getStudentId, studentId)
+                    .eq(SentenceEvaluation::getVersion, version);
+            List<SentenceEvaluation> list = sentenceEvaluationMapperServiceImpl.list(queryWrapper);
+            log.info("[EVAL][SENTENCE] hit cached result assignmentId={}, studentId={}, version={}, count={}",
+                    assignmentId, studentId, version, list == null ? -1 : list.size());
+            return list;
+        }
 
 
-        // 没有智能评价记录时,进行重试,等待整体智能批改将记录写入数据库
-        // 使用编程式事务在新事务中查询,避免REPEATABLE READ隔离级别导致看不到其他事务提交的数据
-        int retryTime = 0;
-        Evaluation evaluationJudge = null;
-        while (retryTime < 3) {
-            final int currentVersion = engagement.getVersion();
-            evaluationJudge = requiresNewTransactionTemplate.execute(status ->
-                evaluationMapperServiceImpl.getByVersion(assignmentId, studentId, currentVersion)
-            );
-            log.info("rewritePerSentence find evaluation record, assignmentId: {}, studentId: {}, version: {}", assignmentId, studentId, engagement.getVersion());
-            if (evaluationJudge == null){
-                try {
-                    log.info("rewritePerSentence find evaluation record failed, assignmentId: {}, studentId: {}, retryTime: {}", assignmentId, studentId, retryTime + 1);
-                    Thread.sleep(1500);
-                    retryTime++;
-                } catch (InterruptedException e) {
-                    Thread.currentThread().interrupt();
-                    throw MyException.create(HttpStatus.BAD_REQUEST, "处理被中断: " + e.getMessage());
-                }
-            } else {
-                break;
-            }
-        }   
-        if(retryTime >= 3) {
-            log.info("rewritePerSentence failed, assignmentId: {}, studentId: {}, reason: over max retry times(3)", assignmentId, studentId);
-            throw MyException.create(HttpStatus.BAD_REQUEST, "逐句批改失败,超过重试次数,请重新发起请求");
+        // sentence=2 表示处理中,直接提示前端轮询读取即可
+        if (evaluationJudge != null && evaluationJudge.getSentence() != null && evaluationJudge.getSentence() == 2) {
+            log.info("[EVAL][SENTENCE] already processing assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+            throw MyException.create(HttpStatus.BAD_REQUEST, "逐句批改正在进行中,请稍后刷新");
         }
         }
 
 
+        // 设置状态为处理中(2),避免重复处理
+        evaluationMapperServiceImpl.update(new UpdateWrapper<Evaluation>()
+                .set("sentence", 2)
+                .eq("student_id", studentId)
+                .eq("assignment_id", assignmentId)
+                .eq("version", version)
+        );
+        log.info("[EVAL][SENTENCE] mark processing sentence=2 assignmentId={}, studentId={}, version={}",
+                assignmentId, studentId, version);
+
         try {
         try {
             String content = engagement.getTextContent();
             String content = engagement.getTextContent();
             String[] sentences = getSentenceList(content);
             String[] sentences = getSentenceList(content);
 
 
             log.info("rewritePerSentence start, assignmentId: {}, studentId: {}, sentenceCount: {}, version: {}",
             log.info("rewritePerSentence start, assignmentId: {}, studentId: {}, sentenceCount: {}, version: {}",
-                    assignmentId, studentId, sentences.length, engagement.getVersion());
+                    assignmentId, studentId, sentences.length, version);
 
 
             List<SentenceEvaluation> sentenceEvaluationsList = new ArrayList<>();
             List<SentenceEvaluation> sentenceEvaluationsList = new ArrayList<>();
             @SuppressWarnings("unchecked")
             @SuppressWarnings("unchecked")
@@ -296,7 +395,7 @@ public class AIEvaluationService {
                                 assignmentId, studentId,
                                 assignmentId, studentId,
                                 content, assignment.getDescription(),
                                 content, assignment.getDescription(),
                                 sentence, sentenceNo,
                                 sentence, sentenceNo,
-                                engagement.getVersion()
+                                version
                         ),
                         ),
                         aiPerSentenceExecutor
                         aiPerSentenceExecutor
                 );
                 );
@@ -325,6 +424,8 @@ public class AIEvaluationService {
             // 3) 批量保存逐句结果
             // 3) 批量保存逐句结果
             if (!sentenceEvaluationsList.isEmpty()) {
             if (!sentenceEvaluationsList.isEmpty()) {
                 sentenceEvaluationMapperServiceImpl.saveBatch(sentenceEvaluationsList);
                 sentenceEvaluationMapperServiceImpl.saveBatch(sentenceEvaluationsList);
+                log.info("[EVAL][SENTENCE] sentence_evaluation batch insert ok assignmentId={}, studentId={}, version={}, count={}",
+                        assignmentId, studentId, version, sentenceEvaluationsList.size());
             }
             }
 
 
             // 4) 完成态:2 -> 1(只允许从处理中改成完成,避免误覆盖)
             // 4) 完成态:2 -> 1(只允许从处理中改成完成,避免误覆盖)
@@ -332,9 +433,11 @@ public class AIEvaluationService {
                     .set("sentence", 1)
                     .set("sentence", 1)
                     .eq("student_id", studentId)
                     .eq("student_id", studentId)
                     .eq("assignment_id", assignmentId)
                     .eq("assignment_id", assignmentId)
-                    .eq("version", engagement.getVersion())
+                    .eq("version", version)
                     .eq("sentence", 2)
                     .eq("sentence", 2)
             );
             );
+            log.info("[EVAL][SENTENCE] mark done sentence=1 assignmentId={}, studentId={}, version={}, cost={}",
+                    assignmentId, studentId, version, sw.formatTime());
 
 
             log.info("rewritePerSentence success, assignmentId: {}, studentId: {}, cost: {}",
             log.info("rewritePerSentence success, assignmentId: {}, studentId: {}, cost: {}",
                     assignmentId, studentId, sw.formatTime());
                     assignmentId, studentId, sw.formatTime());
@@ -347,7 +450,7 @@ public class AIEvaluationService {
                 .set("sentence", 0)
                 .set("sentence", 0)
                 .eq("student_id", studentId)
                 .eq("student_id", studentId)
                 .eq("assignment_id", assignmentId)
                 .eq("assignment_id", assignmentId)
-                .eq("version", engagement.getVersion())
+                .eq("version", version)
                 .eq("sentence", 2)
                 .eq("sentence", 2)
         );
         );
         throw ex;
         throw ex;
@@ -518,14 +621,19 @@ public class AIEvaluationService {
      * @return
      * @return
      */
      */
     public OverallEvaluation getRewrite(Long assignmentId, Long studentId, int version){
     public OverallEvaluation getRewrite(Long assignmentId, Long studentId, int version){
+        log.info("[EVAL][SELECT][OVERALL] select assignmentId={}, studentId={}, version={}", assignmentId, studentId, version);
         QueryWrapper<OverallEvaluation> queryWrapper =  new QueryWrapper<>();
         QueryWrapper<OverallEvaluation> queryWrapper =  new QueryWrapper<>();
         queryWrapper.eq("assignment_id", assignmentId);
         queryWrapper.eq("assignment_id", assignmentId);
         queryWrapper.eq("student_id", studentId);
         queryWrapper.eq("student_id", studentId);
         queryWrapper.eq("version", version);
         queryWrapper.eq("version", version);
         OverallEvaluation overallEvaluation = overallEvaluationMapper.selectOne(queryWrapper);
         OverallEvaluation overallEvaluation = overallEvaluationMapper.selectOne(queryWrapper);
         if(overallEvaluation == null){
         if(overallEvaluation == null){
+            log.info("[EVAL][SELECT][OVERALL] not found assignmentId={}, studentId={}, version={}", assignmentId, studentId, version);
             throw MyException.create(HttpStatus.BAD_REQUEST, "AI批改不存在");
             throw MyException.create(HttpStatus.BAD_REQUEST, "AI批改不存在");
         }
         }
+        log.info("[EVAL][SELECT][OVERALL] found assignmentId={}, studentId={}, version={}, evalLen={}",
+                assignmentId, studentId, version,
+                overallEvaluation.getEvaluation() == null ? -1 : overallEvaluation.getEvaluation().length());
         return overallEvaluation;
         return overallEvaluation;
     }
     }
 
 
@@ -537,15 +645,19 @@ public class AIEvaluationService {
      * @return
      * @return
      */
      */
     public List<SentenceEvaluation> getRewritePerSentence(Long assignmentId, Long studentId, int version){
     public List<SentenceEvaluation> getRewritePerSentence(Long assignmentId, Long studentId, int version){
+        log.info("[EVAL][SELECT][SENTENCE] select assignmentId={}, studentId={}, version={}", assignmentId, studentId, version);
         List<SentenceEvaluation> sentenceEvaluations = sentenceEvaluationMapperServiceImpl.list(new QueryWrapper<SentenceEvaluation>()
         List<SentenceEvaluation> sentenceEvaluations = sentenceEvaluationMapperServiceImpl.list(new QueryWrapper<SentenceEvaluation>()
                .eq("assignment_id", assignmentId)
                .eq("assignment_id", assignmentId)
                .eq("student_id", studentId)
                .eq("student_id", studentId)
                .eq("version", version));
                .eq("version", version));
 
 
         if(sentenceEvaluations.isEmpty()){
         if(sentenceEvaluations.isEmpty()){
+            log.info("[EVAL][SELECT][SENTENCE] not found assignmentId={}, studentId={}, version={}", assignmentId, studentId, version);
             throw MyException.create(HttpStatus.BAD_REQUEST, "AI批改不存在");
             throw MyException.create(HttpStatus.BAD_REQUEST, "AI批改不存在");
         }
         }
 
 
+        log.info("[EVAL][SELECT][SENTENCE] found assignmentId={}, studentId={}, version={}, count={}",
+                assignmentId, studentId, version, sentenceEvaluations.size());
         return sentenceEvaluations.stream().sorted(Comparator.comparing(SentenceEvaluation::getNo)).toList();
         return sentenceEvaluations.stream().sorted(Comparator.comparing(SentenceEvaluation::getNo)).toList();
     }
     }
 
 
@@ -590,6 +702,83 @@ public class AIEvaluationService {
         return result;
         return result;
     }
     }
 
 
+    private OverallEvaluation getOverallEvaluationIfExists(Long assignmentId, Long studentId, int version) {
+        try {
+            QueryWrapper<OverallEvaluation> queryWrapper =  new QueryWrapper<>();
+            queryWrapper.eq("assignment_id", assignmentId);
+            queryWrapper.eq("student_id", studentId);
+            queryWrapper.eq("version", version);
+            return overallEvaluationMapper.selectOne(queryWrapper);
+        } catch (Exception e) {
+            log.warn("getOverallEvaluationIfExists failed: {}", e.getMessage());
+            return null;
+        }
+    }
+
+    private Evaluation ensureEvaluationRecordExists(Long assignmentId, Long studentId, Engagement engagement) {
+        final int version = engagement.getVersion();
+        Evaluation existing = requiresNewTransactionTemplate.execute(status ->
+                evaluationMapperServiceImpl.getByVersion(assignmentId, studentId, version)
+        );
+        if (existing != null) {
+            log.info("[EVAL][ENSURE] evaluation record exists assignmentId={}, studentId={}, version={}, overall={}, sentence={}",
+                    assignmentId, studentId, version, existing.getOverall(), existing.getSentence());
+            return existing;
+        }
+        try {
+            initEvaluationRecord(assignmentId, studentId, engagement);
+            log.info("[EVAL][ENSURE] init evaluation record ok assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+        } catch (DuplicateKeyException e) {
+            // 并发插入:忽略并重新查
+            log.info("[EVAL][ENSURE] init evaluation record duplicate assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+        }
+        Evaluation created = requiresNewTransactionTemplate.execute(status ->
+                evaluationMapperServiceImpl.getByVersion(assignmentId, studentId, version)
+        );
+        if (created == null) {
+            log.warn("[EVAL][ENSURE] init evaluation record failed assignmentId={}, studentId={}, version={}",
+                    assignmentId, studentId, version);
+            throw MyException.create(HttpStatus.BAD_REQUEST, "初始化逐句批改记录失败,请重试");
+        }
+        log.info("[EVAL][ENSURE] evaluation record ready assignmentId={}, studentId={}, version={}, overall={}, sentence={}",
+                assignmentId, studentId, version, created.getOverall(), created.getSentence());
+        return created;
+    }
+
+    /**
+     * 获取当前最新提交版本号(student_assignment.version)以及该版本的整体/逐句批改状态。
+     */
+    public EvaluationCurrentVO getCurrentEvaluationStatus(Long assignmentId, Long studentId) {
+        Engagement engagement = studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(studentId, assignmentId);
+        if (engagement == null) throw MyException.create(HttpStatus.BAD_REQUEST, "作业参与不存在");
+        int version = engagement.getVersion();
+
+        Evaluation evaluation = evaluationMapperServiceImpl.getByVersion(assignmentId, studentId, version);
+        Integer overall = evaluation == null ? 0 : Optional.ofNullable(evaluation.getOverall()).orElse(0);
+        Integer sentence = evaluation == null ? 0 : Optional.ofNullable(evaluation.getSentence()).orElse(0);
+
+        OverallEvaluation overallRow = getOverallEvaluationIfExists(assignmentId, studentId, version);
+        int sentenceCount = Math.toIntExact(sentenceEvaluationMapperServiceImpl.count(new LambdaQueryWrapper<SentenceEvaluation>()
+                .eq(SentenceEvaluation::getAssignmentId, assignmentId)
+                .eq(SentenceEvaluation::getStudentId, studentId)
+                .eq(SentenceEvaluation::getVersion, version)));
+        log.info("[EVAL][CURRENT] assignmentId={}, studentId={}, version={}, evalRow={}, overallStatus={}, sentenceStatus={}, overallRow={}, sentenceCount={}",
+                assignmentId, studentId, version,
+                evaluation != null,
+                overall, sentence,
+                overallRow != null,
+                sentenceCount);
+
+        return new EvaluationCurrentVO()
+                .setAssignmentId(assignmentId)
+                .setStudentId(studentId)
+                .setVersion(version)
+                .setOverallStatus(overall)
+                .setSentenceStatus(sentence);
+    }
+
     /**
     /**
      * 双智评:同时触发整体智评和逐句智评
      * 双智评:同时触发整体智评和逐句智评
      * 1. 创建智评记录
      * 1. 创建智评记录

+ 3 - 0
src/main/java/com/njuzr/eaibackend/service/AssignmentService.java

@@ -42,6 +42,9 @@ public interface AssignmentService {
 
 
     List<EngagementHistoryVO> getEngagementHistory(Long studentId, Long assignmentId);
     List<EngagementHistoryVO> getEngagementHistory(Long studentId, Long assignmentId);
 
 
+    List<EngagementHistoryVO> getEngagementHistoryAllowEmpty(Long studentId, Long assignmentId);
+
+
     void engagementSubmit(Long studentId, Long assignmentId);
     void engagementSubmit(Long studentId, Long assignmentId);
 
 
     /**
     /**

+ 4 - 0
src/main/java/com/njuzr/eaibackend/service/ClassService.java

@@ -1,6 +1,7 @@
 package com.njuzr.eaibackend.service;
 package com.njuzr.eaibackend.service;
 
 
 import com.njuzr.eaibackend.dto.ClassDTO;
 import com.njuzr.eaibackend.dto.ClassDTO;
+import com.njuzr.eaibackend.dto.SingleStudentImportDTO;
 import com.njuzr.eaibackend.po.Class;
 import com.njuzr.eaibackend.po.Class;
 import com.njuzr.eaibackend.vo.BatchStudentImportResultVO;
 import com.njuzr.eaibackend.vo.BatchStudentImportResultVO;
 import com.njuzr.eaibackend.vo.StudentInfoVO;
 import com.njuzr.eaibackend.vo.StudentInfoVO;
@@ -24,6 +25,9 @@ public interface ClassService {
     String deleteClassWithCheck(Long classId);
     String deleteClassWithCheck(Long classId);
 
 
     BatchStudentImportResultVO batchAddStudents(Long classId, MultipartFile file, String accessToken);
     BatchStudentImportResultVO batchAddStudents(Long classId, MultipartFile file, String accessToken);
+
+    BatchStudentImportResultVO importSingleStudent(Long classId, SingleStudentImportDTO dto, String accessToken);
+
     /**
     /**
      * 通过学号添加学生到班级
      * 通过学号添加学生到班级
      * @param classId 班级ID
      * @param classId 班级ID

+ 60 - 0
src/main/java/com/njuzr/eaibackend/service/DeepSeekService.java

@@ -0,0 +1,60 @@
+package com.njuzr.eaibackend.service;
+
+import com.njuzr.eaibackend.dto.deepseek.DeepSeekMessage;
+import com.njuzr.eaibackend.dto.deepseek.DeepSeekResponse;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * DeepSeek 服务接口
+ */
+public interface DeepSeekService {
+
+    /**
+     * 非流式对话
+     * @param message 用户消息
+     * @return 完整响应
+     */
+    DeepSeekResponse chatCompletion(String message);
+
+    /**
+     * 非流式对话(带历史记录)
+     * @param messages 消息列表
+     * @return 完整响应
+     */
+    DeepSeekResponse chatCompletion(List<DeepSeekMessage> messages);
+
+    /**
+     * 流式对话
+     * @param message 用户消息
+     * @return SseEmitter 流式响应
+     */
+    SseEmitter chatCompletionStream(String message);
+
+    /**
+     * 流式对话(带历史记录)
+     * @param messages 消息列表
+     * @return SseEmitter 流式响应
+     */
+    SseEmitter chatCompletionStream(List<DeepSeekMessage> messages);
+
+    /**
+     * 流式对话(带回调)
+     * @param message 用户消息
+     * @param onContent 内容回调
+     * @param onComplete 完成回调
+     * @param onError 错误回调
+     */
+    void chatCompletionStream(String message, Consumer<String> onContent, Runnable onComplete, Consumer<Throwable> onError);
+
+    /**
+     * 流式对话(带历史记录和回调)
+     * @param messages 消息列表
+     * @param onContent 内容回调
+     * @param onComplete 完成回调
+     * @param onError 错误回调
+     */
+    void chatCompletionStream(List<DeepSeekMessage> messages, Consumer<String> onContent, Runnable onComplete, Consumer<Throwable> onError);
+}

+ 50 - 0
src/main/java/com/njuzr/eaibackend/service/ExamTimerService.java

@@ -0,0 +1,50 @@
+package com.njuzr.eaibackend.service;
+
+import com.njuzr.eaibackend.vo.ExamTimeVO;
+
+/**
+ * 考试计时服务
+ * 使用 Redis Key 过期回调实现考试自动提交功能
+ */
+public interface ExamTimerService {
+
+    String EXAM_TIMER_KEY_PREFIX = "exam:timer:";
+
+    /**
+     * 开始考试计时
+     * 当学生首次参与考试作业时调用
+     * @param studentId 学生ID
+     * @param assignmentId 作业ID
+     * @param examDuration 考试时长(分钟)
+     */
+    void startExamTimer(Long studentId, Long assignmentId, Integer examDuration);
+
+    /**
+     * 学生交卷,删除计时器并标记已交卷
+     * @param studentId 学生ID
+     * @param assignmentId 作业ID
+     */
+    void submitExam(Long studentId, Long assignmentId);
+
+    /**
+     * 检查学生是否已交卷
+     * @param studentId 学生ID
+     * @param assignmentId 作业ID
+     * @return true-已交卷,false-未交卷
+     */
+    boolean isExamSubmitted(Long studentId, Long assignmentId);
+
+    /**
+     * 处理考试计时器过期(由Redis回调触发)
+     * @param key 过期的Redis key
+     */
+    void handleExamTimerExpired(String key);
+
+    /**
+     * 获取考试剩余时间信息
+     * @param studentId 学生ID
+     * @param assignmentId 作业ID
+     * @return 考试时间信息
+     */
+    ExamTimeVO getExamTimeInfo(Long studentId, Long assignmentId);
+}

+ 35 - 6
src/main/java/com/njuzr/eaibackend/service/ExportBehaviorOverviewService.java

@@ -4,12 +4,14 @@ import com.njuzr.eaibackend.exception.MyException;
 import com.njuzr.eaibackend.mapper.BehaviorOverviewMapper;
 import com.njuzr.eaibackend.mapper.BehaviorOverviewMapper;
 import com.njuzr.eaibackend.mapper.AssignmentMapper;
 import com.njuzr.eaibackend.mapper.AssignmentMapper;
 import com.njuzr.eaibackend.mapper.ClassMapper;
 import com.njuzr.eaibackend.mapper.ClassMapper;
+import com.njuzr.eaibackend.mapper.WindowSwitchRecordMapper;
 import com.njuzr.eaibackend.po.BehaviorOverview;
 import com.njuzr.eaibackend.po.BehaviorOverview;
 import com.njuzr.eaibackend.po.Assignment;
 import com.njuzr.eaibackend.po.Assignment;
 import com.njuzr.eaibackend.po.Class;
 import com.njuzr.eaibackend.po.Class;
 import com.njuzr.eaibackend.service.ClassService;
 import com.njuzr.eaibackend.service.ClassService;
 import com.njuzr.eaibackend.vo.BehaviorOverviewVO;
 import com.njuzr.eaibackend.vo.BehaviorOverviewVO;
 import com.njuzr.eaibackend.vo.StudentInfoVO;
 import com.njuzr.eaibackend.vo.StudentInfoVO;
+import com.njuzr.eaibackend.vo.WindowSwitchSummaryVO;
 import com.njuzr.eaibackend.utils.ZipExport;
 import com.njuzr.eaibackend.utils.ZipExport;
 import java.util.stream.Collectors;
 import java.util.stream.Collectors;
 import java.util.Set;
 import java.util.Set;
@@ -41,15 +43,17 @@ public class ExportBehaviorOverviewService {
     private final ClassService classService;
     private final ClassService classService;
     private final AssignmentMapper assignmentMapper;
     private final AssignmentMapper assignmentMapper;
     private final ClassMapper classMapper;
     private final ClassMapper classMapper;
+    private final WindowSwitchRecordMapper windowSwitchRecordMapper;
 
 
     @Autowired
     @Autowired
-    public ExportBehaviorOverviewService(BehaviorOverviewService behaviorOverviewService, BehaviorOverviewMapper behaviorOverviewMapper, UserService userService, ClassService classService, AssignmentMapper assignmentMapper, ClassMapper classMapper) {
+    public ExportBehaviorOverviewService(BehaviorOverviewService behaviorOverviewService, BehaviorOverviewMapper behaviorOverviewMapper, UserService userService, ClassService classService, AssignmentMapper assignmentMapper, ClassMapper classMapper, WindowSwitchRecordMapper windowSwitchRecordMapper) {
         this.behaviorOverviewService = behaviorOverviewService;
         this.behaviorOverviewService = behaviorOverviewService;
         this.behaviorOverviewMapper = behaviorOverviewMapper;
         this.behaviorOverviewMapper = behaviorOverviewMapper;
         this.userService = userService;
         this.userService = userService;
         this.classService = classService;
         this.classService = classService;
         this.assignmentMapper = assignmentMapper;
         this.assignmentMapper = assignmentMapper;
         this.classMapper = classMapper;
         this.classMapper = classMapper;
+        this.windowSwitchRecordMapper = windowSwitchRecordMapper;
     }
     }
 
 
     /**
     /**
@@ -147,7 +151,7 @@ public class ExportBehaviorOverviewService {
             int rowIdx = 0;
             int rowIdx = 0;
             Row header = summary.createRow(rowIdx++);
             Row header = summary.createRow(rowIdx++);
             String[] headers = new String[]{
             String[] headers = new String[]{
-                    "name", "studentId", "assignmentId", "insertCount", "deleteCount", "copyCount", "copyCharacterCount", "longPauseCount"
+                    "name", "studentId", "assignmentId", "insertCount", "deleteCount", "copyCount", "copyCharacterCount", "longPauseCount", "windowSwitchCount"
             };
             };
             for (int i = 0; i < headers.length; i++) {
             for (int i = 0; i < headers.length; i++) {
                 header.createCell(i).setCellValue(headers[i]);
                 header.createCell(i).setCellValue(headers[i]);
@@ -167,6 +171,8 @@ public class ExportBehaviorOverviewService {
                     Long studentId = student.getStudentId();
                     Long studentId = student.getStudentId();
                     if (studentId == null) continue;
                     if (studentId == null) continue;
                     
                     
+                    long windowSwitchCount = getWindowSwitchCount(studentId, assignmentId);
+
                     // 查询该学生在指定作业的行为概览
                     // 查询该学生在指定作业的行为概览
                     BehaviorOverviewVO vo = behaviorOverviewService.getBehaviorOverview(studentId, assignmentId);
                     BehaviorOverviewVO vo = behaviorOverviewService.getBehaviorOverview(studentId, assignmentId);
                     if (vo != null) {
                     if (vo != null) {
@@ -180,7 +186,8 @@ public class ExportBehaviorOverviewService {
                         row.createCell(col++).setCellValue(vo.getDeleteCount() == null ? 0 : vo.getDeleteCount());
                         row.createCell(col++).setCellValue(vo.getDeleteCount() == null ? 0 : vo.getDeleteCount());
                         row.createCell(col++).setCellValue(vo.getCopyCount() == null ? 0 : vo.getCopyCount());
                         row.createCell(col++).setCellValue(vo.getCopyCount() == null ? 0 : vo.getCopyCount());
                         row.createCell(col++).setCellValue(vo.getCopyCharacterCount() == null ? 0 : vo.getCopyCharacterCount());
                         row.createCell(col++).setCellValue(vo.getCopyCharacterCount() == null ? 0 : vo.getCopyCharacterCount());
-                        row.createCell(col).setCellValue(vo.getLongPauseCount() == null ? 0 : vo.getLongPauseCount());
+                        row.createCell(col++).setCellValue(vo.getLongPauseCount() == null ? 0 : vo.getLongPauseCount());
+                        row.createCell(col).setCellValue(windowSwitchCount);
 
 
                         // 为每个学生创建一个事件明细sheet
                         // 为每个学生创建一个事件明细sheet
                         String sheetName = safeSheetName(name != null ? name : "Unknown");
                         String sheetName = safeSheetName(name != null ? name : "Unknown");
@@ -198,7 +205,8 @@ public class ExportBehaviorOverviewService {
                         row.createCell(col++).setCellValue(0);
                         row.createCell(col++).setCellValue(0);
                         row.createCell(col++).setCellValue(0);
                         row.createCell(col++).setCellValue(0);
                         row.createCell(col++).setCellValue(0);
                         row.createCell(col++).setCellValue(0);
-                        row.createCell(col).setCellValue(0);
+                        row.createCell(col++).setCellValue(0);
+                        row.createCell(col).setCellValue(windowSwitchCount);
                     }
                     }
                 }
                 }
             }
             }
@@ -326,13 +334,15 @@ public class ExportBehaviorOverviewService {
         int rowIdx = 0;
         int rowIdx = 0;
         Row header = sheet.createRow(rowIdx++);
         Row header = sheet.createRow(rowIdx++);
         String[] headers = new String[]{
         String[] headers = new String[]{
-                "studentId", "assignmentId", "insertCount", "deleteCount", "copyCount", "copyCharacterCount", "longPauseCount"
+                "studentId", "assignmentId", "insertCount", "deleteCount", "copyCount", "copyCharacterCount", "longPauseCount", "windowSwitchCount"
         };
         };
         for (int i = 0; i < headers.length; i++) {
         for (int i = 0; i < headers.length; i++) {
             Cell cell = header.createCell(i);
             Cell cell = header.createCell(i);
             cell.setCellValue(headers[i]);
             cell.setCellValue(headers[i]);
         }
         }
 
 
+        long windowSwitchCount = getWindowSwitchCount(overview.getStudentId(), overview.getAssignmentId());
+
         Row row = sheet.createRow(rowIdx);
         Row row = sheet.createRow(rowIdx);
         int col = 0;
         int col = 0;
         row.createCell(col++).setCellValue(overview.getStudentId() == null ? 0 : overview.getStudentId());
         row.createCell(col++).setCellValue(overview.getStudentId() == null ? 0 : overview.getStudentId());
@@ -341,7 +351,8 @@ public class ExportBehaviorOverviewService {
         row.createCell(col++).setCellValue(overview.getDeleteCount() == null ? 0 : overview.getDeleteCount());
         row.createCell(col++).setCellValue(overview.getDeleteCount() == null ? 0 : overview.getDeleteCount());
         row.createCell(col++).setCellValue(overview.getCopyCount() == null ? 0 : overview.getCopyCount());
         row.createCell(col++).setCellValue(overview.getCopyCount() == null ? 0 : overview.getCopyCount());
         row.createCell(col++).setCellValue(overview.getCopyCharacterCount() == null ? 0 : overview.getCopyCharacterCount());
         row.createCell(col++).setCellValue(overview.getCopyCharacterCount() == null ? 0 : overview.getCopyCharacterCount());
-        row.createCell(col).setCellValue(overview.getLongPauseCount() == null ? 0 : overview.getLongPauseCount());
+        row.createCell(col++).setCellValue(overview.getLongPauseCount() == null ? 0 : overview.getLongPauseCount());
+        row.createCell(col).setCellValue(windowSwitchCount);
 
 
         for (int i = 0; i < headers.length; i++) {
         for (int i = 0; i < headers.length; i++) {
             sheet.autoSizeColumn(i);
             sheet.autoSizeColumn(i);
@@ -408,5 +419,23 @@ public class ExportBehaviorOverviewService {
         return v == null ? 0 : v;
         return v == null ? 0 : v;
     }
     }
 
 
+    /**
+     * 查询某学生某作业的窗口切换总次数,若无记录则返回 0
+     */
+    private long getWindowSwitchCount(Long studentId, Long assignmentId) {
+        if (studentId == null || assignmentId == null) {
+            return 0L;
+        }
+        try {
+            WindowSwitchSummaryVO summary = windowSwitchRecordMapper
+                    .selectSummaryByStudentAndAssignment(studentId, assignmentId);
+            return summary != null && summary.getTotalSwitchCount() != null
+                    ? summary.getTotalSwitchCount()
+                    : 0L;
+        } catch (Exception e) {
+            return 0L;
+        }
+    }
+
     // 已改为批量映射方式,不再需要单查姓名方法
     // 已改为批量映射方式,不再需要单查姓名方法
 }
 }

+ 6 - 1
src/main/java/com/njuzr/eaibackend/service/ExportCompositionService.java

@@ -280,7 +280,12 @@ public class ExportCompositionService {
                 Long studentId = user.getId();
                 Long studentId = user.getId();
                 String studentDir = folderDir + user.getOfficialNumber() + "_" + user.getName() + "/";
                 String studentDir = folderDir + user.getOfficialNumber() + "_" + user.getName() + "/";
                 
                 
-                List<EngagementHistoryVO> historyList = assignmentService.getEngagementHistory(studentId, assignmentId);
+                List<EngagementHistoryVO> historyList = assignmentService.getEngagementHistoryAllowEmpty(studentId, assignmentId);
+
+                if (historyList.isEmpty()) {
+                    zipItems.add(new ZipExport.ZipItem(studentDir, new byte[0]));
+                    continue; // 直接下一个学生
+                }
                 for (EngagementHistoryVO history : historyList) {
                 for (EngagementHistoryVO history : historyList) {
                     Map<String, Object> model = new HashMap<>();
                     Map<String, Object> model = new HashMap<>();
                     model.put("title", assignment.getAssignmentName());
                     model.put("title", assignment.getAssignmentName());

+ 36 - 0
src/main/java/com/njuzr/eaibackend/service/WindowSwitchService.java

@@ -0,0 +1,36 @@
+package com.njuzr.eaibackend.service;
+
+import com.njuzr.eaibackend.dto.WindowSwitchDTO;
+import com.njuzr.eaibackend.vo.WindowSwitchSummaryVO;
+
+import java.util.List;
+
+/**
+ * 窗口切换行为统计 Service
+ */
+public interface WindowSwitchService {
+
+    /**
+     * 上报并保存一次暂存区间内的窗口切换数据
+     *
+     * @param dto 前端上报的窗口切换数据
+     */
+    void recordWindowSwitch(WindowSwitchDTO dto);
+
+    /**
+     * 查询某学生某作业的窗口切换汇总(总切换次数、暂存次数)
+     *
+     * @param studentId    学生ID
+     * @param assignmentId 作业ID
+     * @return 汇总统计VO
+     */
+    WindowSwitchSummaryVO getSummaryByStudentAndAssignment(Long studentId, Long assignmentId);
+
+    /**
+     * 查询某作业所有学生的窗口切换汇总列表,按总切换次数降序
+     *
+     * @param assignmentId 作业ID
+     * @return 各学生汇总列表
+     */
+    List<WindowSwitchSummaryVO> getSummaryByAssignment(Long assignmentId);
+}

+ 88 - 14
src/main/java/com/njuzr/eaibackend/service/impl/AssignmentServiceImpl.java

@@ -17,6 +17,7 @@ import com.njuzr.eaibackend.po.*;
 import com.njuzr.eaibackend.po.Class;
 import com.njuzr.eaibackend.po.Class;
 import com.njuzr.eaibackend.service.AssignmentService;
 import com.njuzr.eaibackend.service.AssignmentService;
 import com.njuzr.eaibackend.service.ClassService;
 import com.njuzr.eaibackend.service.ClassService;
+import com.njuzr.eaibackend.service.ExamTimerService;
 import com.njuzr.eaibackend.service.TextAnalysisService;
 import com.njuzr.eaibackend.service.TextAnalysisService;
 import com.njuzr.eaibackend.utils.*;
 import com.njuzr.eaibackend.utils.*;
 import com.njuzr.eaibackend.vo.*;
 import com.njuzr.eaibackend.vo.*;
@@ -52,6 +53,7 @@ public class AssignmentServiceImpl implements AssignmentService {
 
 
     private final ClassService classService;
     private final ClassService classService;
     private final OssUtil ossUtil;
     private final OssUtil ossUtil;
+    private final ExamTimerService examTimerService;
 
 
     private final AssignmentClassMapper assignmentClassMapper;
     private final AssignmentClassMapper assignmentClassMapper;
 
 
@@ -71,7 +73,8 @@ public class AssignmentServiceImpl implements AssignmentService {
                                  ClassService classService,
                                  ClassService classService,
                                  TextAnalysisService textAnalysisService,
                                  TextAnalysisService textAnalysisService,
                                  AssignmentClassMapper assignmentClassMapper,
                                  AssignmentClassMapper assignmentClassMapper,
-                                 ClassMapper classMapper) {
+                                 ClassMapper classMapper,
+                                 ExamTimerService examTimerService) {
         this.assignmentMapper = assignmentMapper;
         this.assignmentMapper = assignmentMapper;
         this.courseMapper = courseMapper;
         this.courseMapper = courseMapper;
         this.studentAssignmentMapper = studentAssignmentMapper;
         this.studentAssignmentMapper = studentAssignmentMapper;
@@ -81,6 +84,7 @@ public class AssignmentServiceImpl implements AssignmentService {
         this.classService = classService;
         this.classService = classService;
         this.assignmentClassMapper = assignmentClassMapper;
         this.assignmentClassMapper = assignmentClassMapper;
         this.classMapper = classMapper;
         this.classMapper = classMapper;
+        this.examTimerService = examTimerService;
     }
     }
 
 
     @Override
     @Override
@@ -240,12 +244,16 @@ public class AssignmentServiceImpl implements AssignmentService {
         Engagement engagement = new Engagement();
         Engagement engagement = new Engagement();
         engagement.setStudentId(studentId);
         engagement.setStudentId(studentId);
         engagement.setAssignmentId(assignmentId);
         engagement.setAssignmentId(assignmentId);
-        engagement.setVersion(1); // 设置这是version
+        engagement.setVersion(0); // 初始版本从0开始,首次提交后变为1
         engagement.setStatus(AssignmentCompletionStatus.NOT_SUBMITTED); // 已经进入到页面编辑,但是还没有提交
         engagement.setStatus(AssignmentCompletionStatus.NOT_SUBMITTED); // 已经进入到页面编辑,但是还没有提交
 
 
         Assignment assignment = assignmentMapper.selectById(assignmentId);
         Assignment assignment = assignmentMapper.selectById(assignmentId);
         assignment.setEngageNumber(assignment.getEngageNumber() + 1);
         assignment.setEngageNumber(assignment.getEngageNumber() + 1);
 
 
+        if (Boolean.TRUE.equals(assignment.getExamMode()) && assignment.getExamDuration() != null) {
+            examTimerService.startExamTimer(studentId, assignmentId, assignment.getExamDuration());
+        }
+
 
 
 //        // 2通过FileUtil创建一个空的docx文件
 //        // 2通过FileUtil创建一个空的docx文件
 //        InputStream inputStream = fileUtil.createEmptyDocx();
 //        InputStream inputStream = fileUtil.createEmptyDocx();
@@ -347,6 +355,31 @@ public class AssignmentServiceImpl implements AssignmentService {
                 .toList();
                 .toList();
     }
     }
 
 
+    @Override
+    public List<EngagementHistoryVO> getEngagementHistoryAllowEmpty(Long studentId, Long assignmentId) {
+        Engagement engagement = studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(studentId, assignmentId);
+        if (engagement == null) {
+            return Collections.emptyList();
+        }
+
+        List<StudentAssignmentHistory> historyRecords =
+                studentAssignmentMapper.findHistoryByStudentIdAndAssignmentId(studentId, assignmentId);
+        return historyRecords.stream()
+                .map(item -> {
+                    EngagementHistoryVO vo = new EngagementHistoryVO();
+                    vo.setVersion(item.getVersion());
+                    vo.setQuillContent(item.getQuillContent());
+                    vo.setTextContent(item.getTextContent());
+                    vo.setHtmlContent(item.getHtmlContent());
+                    vo.setSubmitTime(item.getSubmitTime());
+                    if (item.getStatus() != null) {
+                        vo.setStatus(new AssignmentCompletionStatusVO(item.getStatus().getCode(), item.getStatus().getStatus()));
+                    }
+                    return vo;
+                })
+                .toList();
+    }
+
     /**
     /**
      * 作业提交,更新文件版本和作业状态
      * 作业提交,更新文件版本和作业状态
      *
      *
@@ -356,19 +389,31 @@ public class AssignmentServiceImpl implements AssignmentService {
     @Override
     @Override
     @Transactional
     @Transactional
     public void engagementSubmit(Long studentId, Long assignmentId) {
     public void engagementSubmit(Long studentId, Long assignmentId) {
+        log.info("studentId: {}, assignmentId: {}", studentId, assignmentId);
+
         Engagement engagement = studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(studentId, assignmentId);
         Engagement engagement = studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(studentId, assignmentId);
-        if (engagement == null)
+        if (engagement == null) {
+            log.error("参与作业情况不存在 - studentId: {}, assignmentId: {}", studentId, assignmentId);
             throw MyException.create(HttpStatus.BAD_REQUEST, "参与作业情况不存在");
             throw MyException.create(HttpStatus.BAD_REQUEST, "参与作业情况不存在");
+        }
         int originalVersion = engagement.getVersion();
         int originalVersion = engagement.getVersion();
         engagement.setVersion(originalVersion + 1);
         engagement.setVersion(originalVersion + 1);
         engagement.setStatus(AssignmentCompletionStatus.SUBMITTED);
         engagement.setStatus(AssignmentCompletionStatus.SUBMITTED);
+
+        Assignment assignment = assignmentMapper.selectById(assignmentId);
+        if (Boolean.TRUE.equals(assignment.getExamMode())) {
+            examTimerService.submitExam(studentId, assignmentId);
+            engagement.setSubmitted(true);
+        }
         try {
         try {
             int code = studentAssignmentMapper.updateById(engagement);
             int code = studentAssignmentMapper.updateById(engagement);
-            if (code == 0)
+            if (code == 0) {
                 throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "更新文件版本失败");
                 throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "更新文件版本失败");
-            recordHistorySnapshot(engagement);
+            }
+            // 基于主表当前行落一条快照(幂等),避免漏写/并发导致 history 缺版本
+            studentAssignmentMapper.insertHistorySnapshot(assignmentId, studentId);
         } catch (Exception e) {
         } catch (Exception e) {
-            log.error(e.getMessage());
+            log.error("更新异常: {}", e.getMessage(), e);
             throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "更新文件版本失败");
             throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "更新文件版本失败");
         }
         }
     }
     }
@@ -388,11 +433,18 @@ public class AssignmentServiceImpl implements AssignmentService {
         int originalVersion = engagement.getVersion();
         int originalVersion = engagement.getVersion();
         engagement.setVersion(originalVersion + 1);
         engagement.setVersion(originalVersion + 1);
         engagement.setStatus(AssignmentCompletionStatus.LATE_SUBMITTED);
         engagement.setStatus(AssignmentCompletionStatus.LATE_SUBMITTED);
+
+        Assignment assignment = assignmentMapper.selectById(assignmentId);
+        if (Boolean.TRUE.equals(assignment.getExamMode())) {
+            examTimerService.submitExam(studentId, assignmentId);
+            engagement.setSubmitted(true);
+        }
+
         try {
         try {
             int code = studentAssignmentMapper.updateById(engagement);
             int code = studentAssignmentMapper.updateById(engagement);
             if (code == 0)
             if (code == 0)
                 throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "更新文件版本失败");
                 throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "更新文件版本失败");
-            recordHistorySnapshot(engagement);
+            studentAssignmentMapper.insertHistorySnapshot(assignmentId, studentId);
         } catch (Exception e) {
         } catch (Exception e) {
             log.error(e.getMessage());
             log.error(e.getMessage());
             throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "更新文件版本失败");
             throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "更新文件版本失败");
@@ -411,6 +463,8 @@ public class AssignmentServiceImpl implements AssignmentService {
         Engagement engagement = studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(studentId, assignmentId);
         Engagement engagement = studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(studentId, assignmentId);
         if (engagement == null)
         if (engagement == null)
             throw MyException.create(HttpStatus.BAD_REQUEST, "参与作业情况不存在");
             throw MyException.create(HttpStatus.BAD_REQUEST, "参与作业情况不存在");
+        log.info("[ENGAGE][STAGE] start assignmentId={}, studentId={}, version(before)={}, status={}",
+                assignmentId, studentId, engagement.getVersion(), engagement.getStatus());
         String quillContent = jsonObject.getString("quillContent");
         String quillContent = jsonObject.getString("quillContent");
         String textContent = jsonObject.getString("textContent");
         String textContent = jsonObject.getString("textContent");
         String htmlContent = jsonObject.getString("htmlContent");
         String htmlContent = jsonObject.getString("htmlContent");
@@ -421,6 +475,9 @@ public class AssignmentServiceImpl implements AssignmentService {
             int code = studentAssignmentMapper.updateById(engagement);
             int code = studentAssignmentMapper.updateById(engagement);
             if (code == 0)
             if (code == 0)
                 throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "更新文件版本失败");
                 throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "更新文件版本失败");
+            log.info("[ENGAGE][STAGE] success assignmentId={}, studentId={}, version(after)={}, textLen={}",
+                    assignmentId, studentId, engagement.getVersion(),
+                    textContent == null ? -1 : textContent.length());
             // 异步启动文本分析,不影响主流程返回速度
             // 异步启动文本分析,不影响主流程返回速度
             try {
             try {
                 textAnalysisService.analyzeAndSaveTextContentAsync(studentId, assignmentId);
                 textAnalysisService.analyzeAndSaveTextContentAsync(studentId, assignmentId);
@@ -476,8 +533,18 @@ public class AssignmentServiceImpl implements AssignmentService {
         List<Engagement> allRecords = studentAssignmentMapper.selectList(
         List<Engagement> allRecords = studentAssignmentMapper.selectList(
                 new QueryWrapper<Engagement>()
                 new QueryWrapper<Engagement>()
                         .eq("assignment_id", assignmentId)
                         .eq("assignment_id", assignmentId)
-                // 可以在此添加其他查询条件
+                        .exists("""
+                                SELECT 1
+                                FROM assignment a
+                                JOIN course_student cs ON cs.course_id = a.course_id
+                                WHERE a.assignment_id = student_assignment.assignment_id
+                                  AND cs.student_id = student_assignment.student_id
+                                """)
         );
         );
+        if (allRecords.isEmpty()) {
+            throw MyException.create(HttpStatus.BAD_REQUEST, "当前作业没有可批改的在课学生");
+        }
+
         // 对记录进行排序:status为4或6的记录排到最后,其他记录保持原始顺序
         // 对记录进行排序:status为4或6的记录排到最后,其他记录保持原始顺序
         List<Engagement> sortedList = allRecords.stream()
         List<Engagement> sortedList = allRecords.stream()
                 .sorted(Comparator.comparing(
                 .sorted(Comparator.comparing(
@@ -492,7 +559,7 @@ public class AssignmentServiceImpl implements AssignmentService {
                 break;
                 break;
             }
             }
         }
         }
-        if (currentIndex == allRecords.size() - 1 || currentIndex == -1) {
+        if (currentIndex == sortedList.size() - 1 || currentIndex == -1) {
             return sortedList.get(0).getStudentId();
             return sortedList.get(0).getStudentId();
         }
         }
 
 
@@ -505,11 +572,7 @@ public class AssignmentServiceImpl implements AssignmentService {
         if(assignment == null){
         if(assignment == null){
             throw MyException.create(HttpStatus.BAD_REQUEST, "作业不存在");
             throw MyException.create(HttpStatus.BAD_REQUEST, "作业不存在");
         }
         }
-        if(assignment.getEngageNumber() != null && assignment.getCorrectNumber() != null){
-            return new EngageNumberVo(assignment.getEngageNumber(), assignment.getCorrectNumber());
-        } else {
-            throw MyException.create(HttpStatus.BAD_REQUEST, "未建立作业统计");
-        }
+        return studentAssignmentMapper.countEngageNumberByCurrentClassStudents(assignmentId);
     }
     }
 
 
     private Assignment convertToPO(AssignmentDTO assignmentDTO) {
     private Assignment convertToPO(AssignmentDTO assignmentDTO) {
@@ -550,6 +613,15 @@ public class AssignmentServiceImpl implements AssignmentService {
     }
     }
 
 
     private void recordHistorySnapshot(Engagement engagement) {
     private void recordHistorySnapshot(Engagement engagement) {
+        // 检查是否已存在相同版本的历史记录
+        StudentAssignmentHistory existingHistory = studentAssignmentMapper.findHistoryByAssignmentIdAndStudentIdAndVersion(
+                engagement.getAssignmentId(), engagement.getStudentId(), engagement.getVersion());
+        if (existingHistory != null) {
+            log.info("历史记录已存在,跳过插入 - assignmentId: {}, studentId: {}, version: {}",
+                    engagement.getAssignmentId(), engagement.getStudentId(), engagement.getVersion());
+            return;
+        }
+
         StudentAssignmentHistory history = new StudentAssignmentHistory();
         StudentAssignmentHistory history = new StudentAssignmentHistory();
         history.setAssignmentId(engagement.getAssignmentId());
         history.setAssignmentId(engagement.getAssignmentId());
         history.setStudentId(engagement.getStudentId());
         history.setStudentId(engagement.getStudentId());
@@ -560,6 +632,8 @@ public class AssignmentServiceImpl implements AssignmentService {
         history.setHtmlContent(engagement.getHtmlContent());
         history.setHtmlContent(engagement.getHtmlContent());
         history.setSubmitTime(new Date());
         history.setSubmitTime(new Date());
         studentAssignmentMapper.insertHistory(history);
         studentAssignmentMapper.insertHistory(history);
+        log.info("历史记录插入成功 - assignmentId: {}, studentId: {}, version: {}",
+                engagement.getAssignmentId(), engagement.getStudentId(), engagement.getVersion());
     }
     }
 
 
     private AssignmentStatus calculateStatus(Date startTime, Date endTime) {
     private AssignmentStatus calculateStatus(Date startTime, Date endTime) {

+ 56 - 7
src/main/java/com/njuzr/eaibackend/service/impl/AuthenticationServiceImpl.java

@@ -106,14 +106,17 @@ public class AuthenticationServiceImpl implements AuthenticationService {
     public MyResponse loginByPortal(String token) {
     public MyResponse loginByPortal(String token) {
         // 从门户返回的jwt中解析用户信息
         // 从门户返回的jwt中解析用户信息
         Map<String, Object> userInfoMap = jwtTokenUtil.parseClaim(token).getBody().get("user_info", Map.class);
         Map<String, Object> userInfoMap = jwtTokenUtil.parseClaim(token).getBody().get("user_info", Map.class);
-        log.info("解析出的用户信息:{}", userInfoMap.toString());
+        if (userInfoMap == null) {
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), "门户token缺少user_info");
+        }
+        log.info("解析出的用户信息:{}", userInfoMap);
         //pid:门户用户id
         //pid:门户用户id
-        Integer pid = Integer.valueOf(userInfoMap.get("id").toString());
-        String username = userInfoMap.get("username").toString();
-        String name = userInfoMap.get("name").toString();
-        String email = userInfoMap.get("email").toString();
-        String phone = userInfoMap.get("phone").toString();
-        Role role = Role.valueOf(userInfoMap.get("role").toString());
+        Integer pid = parseRequiredInt(userInfoMap, "id");
+        String username = parseRequiredString(userInfoMap, "username");
+        String name = parseRequiredString(userInfoMap, "name");
+        String email = parseOptionalString(userInfoMap, "email");
+        String phone = parseRequiredString(userInfoMap, "phone");
+        Role role = parseRequiredRole(userInfoMap, "role");
         // 如果EAI数据库中没有该用户,则添加该用户
         // 如果EAI数据库中没有该用户,则添加该用户
         if (!userService.userExists(pid)) {
         if (!userService.userExists(pid)) {
             log.info("用户不存在,添加用户{}", phone);
             log.info("用户不存在,添加用户{}", phone);
@@ -130,6 +133,7 @@ public class AuthenticationServiceImpl implements AuthenticationService {
             // 否则更新用户信息
             // 否则更新用户信息
             UserUpdateDTO update = new UserUpdateDTO();
             UserUpdateDTO update = new UserUpdateDTO();
             update.setContentEmail(email);
             update.setContentEmail(email);
+            update.setPhone(phone);
             update.setOfficialNumber(username);
             update.setOfficialNumber(username);
             update.setRole(role);
             update.setRole(role);
             System.out.println(role);
             System.out.println(role);
@@ -140,5 +144,50 @@ public class AuthenticationServiceImpl implements AuthenticationService {
         return MyResponse.success(userLoginVO);
         return MyResponse.success(userLoginVO);
     }
     }
 
 
+    private String parseRequiredString(Map<String, Object> map, String key) {
+        Object value = map.get(key);
+        if (value == null) {
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), "门户token缺少字段: " + key);
+        }
+        String text = value.toString().trim();
+        if (text.isEmpty()) {
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), "门户token字段为空: " + key);
+        }
+        return text;
+    }
+
+    private String parseOptionalString(Map<String, Object> map, String key) {
+        Object value = map.get(key);
+        if (value == null) {
+            return null;
+        }
+        String text = value.toString().trim();
+        return text.isEmpty() ? null : text;
+    }
+
+    private Integer parseRequiredInt(Map<String, Object> map, String key) {
+        Object value = map.get(key);
+        if (value == null) {
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), "门户token缺少字段: " + key);
+        }
+        try {
+            if (value instanceof Number) {
+                return ((Number) value).intValue();
+            }
+            return Integer.valueOf(value.toString());
+        } catch (NumberFormatException e) {
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), "门户token字段格式错误: " + key);
+        }
+    }
+
+    private Role parseRequiredRole(Map<String, Object> map, String key) {
+        String role = parseRequiredString(map, key);
+        try {
+            return Role.valueOf(role);
+        } catch (IllegalArgumentException e) {
+            throw new MyException(HttpStatus.BAD_REQUEST.value(), "门户token角色非法: " + role);
+        }
+    }
+
 
 
 }
 }

+ 149 - 77
src/main/java/com/njuzr/eaibackend/service/impl/ClassServiceImpl.java

@@ -2,6 +2,7 @@ package com.njuzr.eaibackend.service.impl;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.njuzr.eaibackend.dto.ClassDTO;
 import com.njuzr.eaibackend.dto.ClassDTO;
+import com.njuzr.eaibackend.dto.SingleStudentImportDTO;
 import com.njuzr.eaibackend.enums.ClassStudentState;
 import com.njuzr.eaibackend.enums.ClassStudentState;
 import com.njuzr.eaibackend.enums.Role;
 import com.njuzr.eaibackend.enums.Role;
 import com.njuzr.eaibackend.exception.MyException;
 import com.njuzr.eaibackend.exception.MyException;
@@ -53,7 +54,7 @@ public class ClassServiceImpl implements ClassService {
     private final CourseStudentMapper courseStudentMapper;
     private final CourseStudentMapper courseStudentMapper;
     private final WebClientUtil webClientUtil;
     private final WebClientUtil webClientUtil;
 
 
-    @Value("${portal.base-url:http://localhost:8080}")
+    @Value("${portal.base-url:https://p-nju.seec.seecoder.cn}")
     private String portalBaseUrl;
     private String portalBaseUrl;
 
 
     @Value("${portal.batch-register-path:/api/user/register/batch/internal}")
     @Value("${portal.batch-register-path:/api/user/register/batch/internal}")
@@ -187,12 +188,6 @@ public class ClassServiceImpl implements ClassService {
     @Override
     @Override
     @Transactional
     @Transactional
     public BatchStudentImportResultVO batchAddStudents(Long classId, MultipartFile file, String accessToken) {
     public BatchStudentImportResultVO batchAddStudents(Long classId, MultipartFile file, String accessToken) {
-        // 检查班级是否存在
-        Class cls = classMapper.selectById(classId);
-        if (cls == null) {
-            throw new MyException(404, "班级不存在");
-        }
-
         // 解析文件
         // 解析文件
         List<StudentImportRow> rows;
         List<StudentImportRow> rows;
         String fileName = file.getOriginalFilename();
         String fileName = file.getOriginalFilename();
@@ -210,6 +205,29 @@ public class ClassServiceImpl implements ClassService {
             throw new MyException(500, "解析学生文件失败: " + e.getMessage());
             throw new MyException(500, "解析学生文件失败: " + e.getMessage());
         }
         }
 
 
+        return importStudents(classId, rows, accessToken);
+    }
+
+    @Override
+    @Transactional
+    public BatchStudentImportResultVO importSingleStudent(Long classId, SingleStudentImportDTO dto, String accessToken) {
+        List<StudentImportRow> rows = new ArrayList<>();
+        StudentImportRow row = new StudentImportRow();
+        row.setRowIndex(1);
+        row.setOfficialNumber(dto.getOfficialNumber() == null ? "" : dto.getOfficialNumber().trim());
+        row.setStuName(dto.getStuName() == null ? "" : dto.getStuName().trim());
+        row.setPhone(dto.getPhone() == null ? "" : dto.getPhone().trim());
+        rows.add(row);
+        return importStudents(classId, rows, accessToken);
+    }
+
+    private BatchStudentImportResultVO importStudents(Long classId, List<StudentImportRow> rows, String accessToken) {
+        // 检查班级是否存在
+        Class cls = classMapper.selectById(classId);
+        if (cls == null) {
+            throw new MyException(404, "班级不存在");
+        }
+
         BatchStudentImportResultVO result = new BatchStudentImportResultVO();
         BatchStudentImportResultVO result = new BatchStudentImportResultVO();
         result.setTotal(rows.size());
         result.setTotal(rows.size());
         result.setSuccessCount(0);
         result.setSuccessCount(0);
@@ -220,12 +238,14 @@ public class ClassServiceImpl implements ClassService {
             return result;
             return result;
         }
         }
 
 
-        Map<Integer, Map<String, Object>> portalResultByRow = registerUsersInPortal(rows, accessToken);
         Set<String> fileOfficialNumbers = new HashSet<>();
         Set<String> fileOfficialNumbers = new HashSet<>();
         Set<String> filePhones = new HashSet<>();
         Set<String> filePhones = new HashSet<>();
 
 
+        List<StudentImportRow> rowsToRegister = new ArrayList<>();
+        Map<Integer, BatchStudentImportDetailVO> pendingRegisterDetails = new HashMap<>();
         List<User> usersToInsert = new ArrayList<>();
         List<User> usersToInsert = new ArrayList<>();
         List<ClassStudent> studentsToInsert = new ArrayList<>();
         List<ClassStudent> studentsToInsert = new ArrayList<>();
+        Set<Long> enrollmentStudentIds = new HashSet<>();
 
 
         for (StudentImportRow row : rows) {
         for (StudentImportRow row : rows) {
             BatchStudentImportDetailVO detail = new BatchStudentImportDetailVO();
             BatchStudentImportDetailVO detail = new BatchStudentImportDetailVO();
@@ -233,31 +253,43 @@ public class ClassServiceImpl implements ClassService {
             detail.setOfficialNumber(row.getOfficialNumber());
             detail.setOfficialNumber(row.getOfficialNumber());
             detail.setStuName(row.getStuName());
             detail.setStuName(row.getStuName());
             detail.setPhone(row.getPhone());
             detail.setPhone(row.getPhone());
+            result.getDetails().add(detail);
 
 
             String validationError = validateRow(row, fileOfficialNumbers, filePhones);
             String validationError = validateRow(row, fileOfficialNumbers, filePhones);
             if (validationError != null) {
             if (validationError != null) {
                 detail.setStatus("FAILED");
                 detail.setStatus("FAILED");
                 detail.setMessage(validationError);
                 detail.setMessage(validationError);
-                result.getDetails().add(detail);
                 result.setFailedCount(result.getFailedCount() + 1);
                 result.setFailedCount(result.getFailedCount() + 1);
                 continue;
                 continue;
             }
             }
 
 
-            Map<String, Object> portalRow = portalResultByRow.get(row.getRowIndex());
-            if (portalRow == null || !"SUCCESS".equals(String.valueOf(portalRow.get("status")))) {
+            User existsByNumber = userMapper.selectByOfficialNumber(row.getOfficialNumber());
+
+            QueryWrapper<ClassStudent> classStudentWrapper = new QueryWrapper<>();
+            classStudentWrapper.eq("class_id", classId)
+                    .eq("official_number", row.getOfficialNumber());
+            if (classStudentMapper.selectCount(classStudentWrapper) > 0) {
                 detail.setStatus("SKIPPED");
                 detail.setStatus("SKIPPED");
-                detail.setMessage(portalRow == null ? "Portal未返回该行处理结果" : String.valueOf(portalRow.get("message")));
-                result.getDetails().add(detail);
+                detail.setMessage("学生已在班级中,已跳过");
                 result.setSkippedCount(result.getSkippedCount() + 1);
                 result.setSkippedCount(result.getSkippedCount() + 1);
                 continue;
                 continue;
             }
             }
 
 
-            User existsByNumber = userMapper.selectByOfficialNumber(row.getOfficialNumber());
             if (existsByNumber != null) {
             if (existsByNumber != null) {
-                detail.setStatus("SKIPPED");
-                detail.setMessage("EAI中学号已存在,已跳过");
-                result.getDetails().add(detail);
-                result.setSkippedCount(result.getSkippedCount() + 1);
+                ClassStudent student = new ClassStudent();
+                student.setClassId(classId);
+                student.setOfficialNumber(row.getOfficialNumber());
+                student.setStuName(row.getStuName());
+                student.setState(ClassStudentState.NOT_JOINED);
+                studentsToInsert.add(student);
+
+                enrollmentStudentIds.add(existsByNumber.getId());
+
+                detail.setStatus("SUCCESS");
+                detail.setMessage("学生已存在,已关联班级和课程");
+                result.setSuccessCount(result.getSuccessCount() + 1);
+                fileOfficialNumbers.add(row.getOfficialNumber());
+                filePhones.add(row.getPhone());
                 continue;
                 continue;
             }
             }
 
 
@@ -265,73 +297,97 @@ public class ClassServiceImpl implements ClassService {
             if (existsByPhone != null) {
             if (existsByPhone != null) {
                 detail.setStatus("SKIPPED");
                 detail.setStatus("SKIPPED");
                 detail.setMessage("EAI中手机号已存在,已跳过");
                 detail.setMessage("EAI中手机号已存在,已跳过");
-                result.getDetails().add(detail);
                 result.setSkippedCount(result.getSkippedCount() + 1);
                 result.setSkippedCount(result.getSkippedCount() + 1);
                 continue;
                 continue;
             }
             }
 
 
-            QueryWrapper<ClassStudent> classStudentWrapper = new QueryWrapper<>();
-            classStudentWrapper.eq("class_id", classId)
-                    .eq("official_number", row.getOfficialNumber());
-            if (classStudentMapper.selectCount(classStudentWrapper) > 0) {
-                detail.setStatus("SKIPPED");
-                detail.setMessage("学生已在班级中,已跳过");
-                result.getDetails().add(detail);
-                result.setSkippedCount(result.getSkippedCount() + 1);
-                continue;
-            }
-
-            User user = new User();
-            user.setName(row.getStuName());
-            user.setOfficialNumber(row.getOfficialNumber());
-            user.setPhone(row.getPhone());
-            user.setRole(Role.STUDENT);
-            user.setCreateTime(new Date());
-            Object portalUserId = portalRow.get("userId");
-            if (portalUserId instanceof Number) {
-                user.setPid(String.valueOf(((Number) portalUserId).longValue()));
-            }
-            user.setPassword(passwordEncoder.encode(generateDefaultPassword(row.getOfficialNumber())));
-            usersToInsert.add(user);
-
-            ClassStudent student = new ClassStudent();
-            student.setClassId(classId);
-            student.setOfficialNumber(row.getOfficialNumber());
-            student.setStuName(row.getStuName());
-            student.setState(ClassStudentState.NOT_JOINED);
-            studentsToInsert.add(student);
-
-            detail.setStatus("SUCCESS");
-            detail.setMessage("导入成功");
-            result.getDetails().add(detail);
-            result.setSuccessCount(result.getSuccessCount() + 1);
+            rowsToRegister.add(row);
+            pendingRegisterDetails.put(row.getRowIndex(), detail);
             fileOfficialNumbers.add(row.getOfficialNumber());
             fileOfficialNumbers.add(row.getOfficialNumber());
             filePhones.add(row.getPhone());
             filePhones.add(row.getPhone());
         }
         }
 
 
+        if (!rowsToRegister.isEmpty()) {
+            Map<Integer, Map<String, Object>> portalResultByRow = registerUsersInPortal(rowsToRegister, accessToken);
+            for (StudentImportRow row : rowsToRegister) {
+                BatchStudentImportDetailVO detail = pendingRegisterDetails.get(row.getRowIndex());
+                Map<String, Object> portalRow = portalResultByRow.get(row.getRowIndex());
+                if (portalRow == null) {
+                    detail.setStatus("FAILED");
+                    detail.setMessage("Portal未返回该行处理结果");
+                    result.setFailedCount(result.getFailedCount() + 1);
+                    continue;
+                }
+
+                String portalStatus = String.valueOf(portalRow.get("status"));
+                boolean importable = "SUCCESS".equals(portalStatus) || "EXISTING".equals(portalStatus);
+                if (!importable) {
+                    detail.setStatus("FAILED");
+                    detail.setMessage(String.valueOf(portalRow.get("message")));
+                    result.setFailedCount(result.getFailedCount() + 1);
+                    continue;
+                }
+
+                User user = new User();
+                user.setName(row.getStuName());
+                user.setOfficialNumber(row.getOfficialNumber());
+                user.setPhone(row.getPhone());
+                user.setRole(Role.STUDENT);
+                user.setCreateTime(new Date());
+                Object portalUserId = portalRow.get("userId");
+                if (portalUserId instanceof Number) {
+                    user.setPid(String.valueOf(((Number) portalUserId).longValue()));
+                } else {
+                    detail.setStatus("FAILED");
+                    detail.setMessage("Portal返回userId缺失,无法在EAI补建账号");
+                    result.setFailedCount(result.getFailedCount() + 1);
+                    continue;
+                }
+                user.setPassword(passwordEncoder.encode(generateDefaultPassword(row.getOfficialNumber())));
+                usersToInsert.add(user);
+
+                ClassStudent student = new ClassStudent();
+                student.setClassId(classId);
+                student.setOfficialNumber(row.getOfficialNumber());
+                student.setStuName(row.getStuName());
+                student.setState(ClassStudentState.NOT_JOINED);
+                studentsToInsert.add(student);
+
+                detail.setStatus("SUCCESS");
+                if ("EXISTING".equals(portalStatus)) {
+                    detail.setMessage("Portal已存在账号,已在EAI补建并导入成功");
+                } else {
+                    detail.setMessage("导入成功");
+                }
+                result.setSuccessCount(result.getSuccessCount() + 1);
+            }
+        }
+
         if (!usersToInsert.isEmpty()) {
         if (!usersToInsert.isEmpty()) {
             userMapper.batchInsert(usersToInsert);
             userMapper.batchInsert(usersToInsert);
-            classStudentMapper.batchInsert(studentsToInsert);
-            classMapper.increaseStuNumber(classId, studentsToInsert.size());
-
-            List<String> importedOfficialNumbers = studentsToInsert.stream()
-                    .map(ClassStudent::getOfficialNumber)
+            List<String> importedOfficialNumbers = usersToInsert.stream()
+                    .map(User::getOfficialNumber)
                     .collect(Collectors.toList());
                     .collect(Collectors.toList());
             List<User> importedUsers = userMapper.selectUserIdByOfficialNumbers(importedOfficialNumbers);
             List<User> importedUsers = userMapper.selectUserIdByOfficialNumbers(importedOfficialNumbers);
-            List<Enrollment> enrollments = new ArrayList<>();
             for (User importedUser : importedUsers) {
             for (User importedUser : importedUsers) {
+                enrollmentStudentIds.add(importedUser.getId());
+            }
+        }
+
+        if (!studentsToInsert.isEmpty()) {
+            classStudentMapper.batchInsert(studentsToInsert);
+            classMapper.increaseStuNumber(classId, studentsToInsert.size());
+        }
+
+        for (Long studentId : enrollmentStudentIds) {
+            QueryWrapper<Enrollment> enrollmentWrapper = new QueryWrapper<>();
+            enrollmentWrapper.eq("course_id", cls.getCourseId())
+                    .eq("student_id", studentId);
+            if (!courseStudentMapper.exists(enrollmentWrapper)) {
                 Enrollment enrollment = new Enrollment();
                 Enrollment enrollment = new Enrollment();
                 enrollment.setCourseId(cls.getCourseId());
                 enrollment.setCourseId(cls.getCourseId());
-                enrollment.setStudentId(importedUser.getId());
-                enrollments.add(enrollment);
-            }
-            for (Enrollment enrollment : enrollments) {
-                QueryWrapper<Enrollment> enrollmentWrapper = new QueryWrapper<>();
-                enrollmentWrapper.eq("course_id", enrollment.getCourseId())
-                        .eq("student_id", enrollment.getStudentId());
-                if (!courseStudentMapper.exists(enrollmentWrapper)) {
-                    courseStudentMapper.insert(enrollment);
-                }
+                enrollment.setStudentId(studentId);
+                courseStudentMapper.insert(enrollment);
             }
             }
         }
         }
 
 
@@ -359,7 +415,7 @@ public class ClassServiceImpl implements ClassService {
 
 
         Map<String, Object> response;
         Map<String, Object> response;
         try {
         try {
-            response = webClientUtil.post(endpoint, request, Map.class);
+            response = webClientUtil.postWithToken(endpoint, request, Map.class, accessToken);
         } catch (Exception e) {
         } catch (Exception e) {
             throw new MyException(502, "调用Portal批量注册失败:" + e.getMessage());
             throw new MyException(502, "调用Portal批量注册失败:" + e.getMessage());
         }
         }
@@ -561,10 +617,11 @@ public class ClassServiceImpl implements ClassService {
             Row headerRow = sheet.getRow(0);
             Row headerRow = sheet.getRow(0);
             int nameCol = findColumnIndex(headerRow, "学生姓名", "姓名");
             int nameCol = findColumnIndex(headerRow, "学生姓名", "姓名");
             int numberCol = findColumnIndex(headerRow, "学生学号", "学号");
             int numberCol = findColumnIndex(headerRow, "学生学号", "学号");
-            int phoneCol = findColumnIndex(headerRow, "手机号", "手机号码");
+            int phoneCol = findColumnIndex(headerRow, "手机号", "手机号码", "学生电话", "学生手机");
 
 
-            if (nameCol == -1 || numberCol == -1 || phoneCol == -1) {
-                throw new MyException(400, "文件缺少必要列:学生姓名 / 学生学号 / 手机号");
+            List<String> missingColumns = getMissingRequiredColumns(nameCol, numberCol, phoneCol);
+            if (!missingColumns.isEmpty()) {
+                throw new MyException(400, "文件缺少必要列:" + String.join(" / ", missingColumns));
             }
             }
 
 
             for (int i = 1; i <= sheet.getLastRowNum(); i++) {
             for (int i = 1; i <= sheet.getLastRowNum(); i++) {
@@ -595,10 +652,11 @@ public class ClassServiceImpl implements ClassService {
 
 
             int nameCol = findColumnIndex(header, "学生姓名", "姓名");
             int nameCol = findColumnIndex(header, "学生姓名", "姓名");
             int numberCol = findColumnIndex(header, "学生学号", "学号");
             int numberCol = findColumnIndex(header, "学生学号", "学号");
-            int phoneCol = findColumnIndex(header, "手机号", "手机号码");
+            int phoneCol = findColumnIndex(header, "手机号", "手机号码", "学生电话", "学生手机");
 
 
-            if (nameCol == -1 || numberCol == -1 || phoneCol == -1) {
-                throw new MyException(400, "文件缺少必要列:学生姓名 / 学生学号 / 手机号");
+            List<String> missingColumns = getMissingRequiredColumns(nameCol, numberCol, phoneCol);
+            if (!missingColumns.isEmpty()) {
+                throw new MyException(400, "文件缺少必要列:" + String.join(" / ", missingColumns));
             }
             }
 
 
             String[] nextRecord;
             String[] nextRecord;
@@ -627,6 +685,20 @@ public class ClassServiceImpl implements ClassService {
         return rows;
         return rows;
     }
     }
 
 
+    private List<String> getMissingRequiredColumns(int nameCol, int numberCol, int phoneCol) {
+        List<String> missingColumns = new ArrayList<>();
+        if (nameCol == -1) {
+            missingColumns.add("学生姓名");
+        }
+        if (numberCol == -1) {
+            missingColumns.add("学生学号");
+        }
+        if (phoneCol == -1) {
+            missingColumns.add("手机号");
+        }
+        return missingColumns;
+    }
+
     private int findColumnIndex(Row headerRow, String... columnNames) {
     private int findColumnIndex(Row headerRow, String... columnNames) {
         if (headerRow == null) {
         if (headerRow == null) {
             return -1;
             return -1;

+ 272 - 0
src/main/java/com/njuzr/eaibackend/service/impl/DeepSeekServiceImpl.java

@@ -0,0 +1,272 @@
+package com.njuzr.eaibackend.service.impl;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.njuzr.eaibackend.config.DeepSeekConfig;
+import com.njuzr.eaibackend.dto.deepseek.DeepSeekMessage;
+import com.njuzr.eaibackend.dto.deepseek.DeepSeekRequest;
+import com.njuzr.eaibackend.dto.deepseek.DeepSeekResponse;
+import com.njuzr.eaibackend.dto.deepseek.DeepSeekStreamResponse;
+import com.njuzr.eaibackend.exception.MyException;
+import com.njuzr.eaibackend.service.DeepSeekService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.client.SimpleClientHttpRequestFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.web.client.RestTemplate;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.io.BufferedReader;
+import java.io.InputStreamReader;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.function.Consumer;
+
+/**
+ * DeepSeek 服务实现类
+ */
+@Slf4j
+@Service
+public class DeepSeekServiceImpl implements DeepSeekService {
+
+    @Autowired
+    private DeepSeekConfig deepSeekConfig;
+
+    private final ObjectMapper objectMapper = new ObjectMapper()
+            .configure(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
+
+    @Override
+    public DeepSeekResponse chatCompletion(String message) {
+        List<DeepSeekMessage> messages = Collections.singletonList(DeepSeekMessage.user(message));
+        return chatCompletion(messages);
+    }
+
+    @Override
+    public DeepSeekResponse chatCompletion(List<DeepSeekMessage> messages) {
+        try {
+            DeepSeekRequest request = DeepSeekRequest.builder()
+                    .model(deepSeekConfig.getModel())
+                    .messages(messages)
+                    .stream(false)
+                    .build();
+
+            RestTemplate restTemplate = createRestTemplate();
+            org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            String apiKey = deepSeekConfig.getApiKey();
+            if (apiKey != null) apiKey = apiKey.trim();
+            headers.setBearerAuth(apiKey);
+
+            org.springframework.http.HttpEntity<DeepSeekRequest> entity = 
+                    new org.springframework.http.HttpEntity<>(request, headers);
+
+            String url = deepSeekConfig.getBaseUrl() + "/chat/completions";
+            log.info("DeepSeek 非流式请求: {}", url);
+
+            DeepSeekResponse response = restTemplate.postForObject(
+                    url, entity, DeepSeekResponse.class);
+
+            log.info("DeepSeek 响应成功");
+            return response;
+        } catch (Exception e) {
+            log.error("DeepSeek 请求失败: {}", e.getMessage(), e);
+            throw new RuntimeException("DeepSeek 请求失败: " + e.getMessage(), e);
+        }
+    }
+
+    @Override
+    public SseEmitter chatCompletionStream(String message) {
+        List<DeepSeekMessage> messages = Collections.singletonList(DeepSeekMessage.user(message));
+        return chatCompletionStream(messages);
+    }
+
+    @Override
+    public SseEmitter chatCompletionStream(List<DeepSeekMessage> messages) {
+        SseEmitter emitter = new SseEmitter(0L);
+
+        new Thread(() -> {
+            HttpURLConnection connection = null;
+            try {
+                DeepSeekRequest request = DeepSeekRequest.builder()
+                        .model(deepSeekConfig.getModel())
+                        .messages(messages)
+                        .stream(true)
+                        .build();
+
+                String url = deepSeekConfig.getBaseUrl() + "/chat/completions";
+                log.info("DeepSeek 流式请求: {}", url);
+
+                connection = (HttpURLConnection) new URL(url).openConnection();
+                connection.setRequestMethod("POST");
+                connection.setRequestProperty(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
+                connection.setRequestProperty(HttpHeaders.AUTHORIZATION, "Bearer " + deepSeekConfig.getApiKey());
+                connection.setDoOutput(true);
+                connection.setDoInput(true);
+                connection.setConnectTimeout(deepSeekConfig.getTimeout());
+                connection.setReadTimeout(deepSeekConfig.getTimeout());
+
+                String requestBody = objectMapper.writeValueAsString(request);
+                connection.getOutputStream().write(requestBody.getBytes(StandardCharsets.UTF_8));
+
+                int responseCode = connection.getResponseCode();
+                if (responseCode != 200) {
+                    throw new RuntimeException("HTTP error code: " + responseCode);
+                }
+
+                try (BufferedReader reader = new BufferedReader(
+                        new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
+                    String line;
+                    while ((line = reader.readLine()) != null) {
+                        if (line.startsWith("data: ")) {
+                            String data = line.substring(6);
+                            if ("[DONE]".equals(data)) {
+                                emitter.complete();
+                                break;
+                            }
+                            try {
+                                DeepSeekStreamResponse streamResponse = 
+                                        objectMapper.readValue(data, DeepSeekStreamResponse.class);
+                                String content = streamResponse.getContent();
+                                if (content != null) {
+                                    emitter.send(content);
+                                }
+                                if (streamResponse.isDone()) {
+                                    emitter.complete();
+                                    break;
+                                }
+                            } catch (Exception e) {
+                                log.warn("解析流式响应失败: {}", data);
+                            }
+                        }
+                    }
+                }
+            } catch (Exception e) {
+                log.error("DeepSeek 流式请求失败: {}", e.getMessage(), e);
+                emitter.completeWithError(e);
+            } finally {
+                if (connection != null) {
+                    connection.disconnect();
+                }
+            }
+        }).start();
+
+        return emitter;
+    }
+
+    @Override
+    public void chatCompletionStream(String message, Consumer<String> onContent, Runnable onComplete, Consumer<Throwable> onError) {
+        List<DeepSeekMessage> messages = Collections.singletonList(DeepSeekMessage.user(message));
+        chatCompletionStream(messages, onContent, onComplete, onError);
+    }
+
+    @Override
+    public void chatCompletionStream(List<DeepSeekMessage> messages, Consumer<String> onContent, Runnable onComplete, Consumer<Throwable> onError) {
+        log.info("【DeepSeekService】chatCompletionStream被调用,消息数={}", messages.size());
+        
+        new Thread(() -> {
+            HttpURLConnection connection = null;
+            try {
+                String apiKey = deepSeekConfig.getApiKey();
+                if (apiKey != null) apiKey = apiKey.trim();
+                if (apiKey == null || apiKey.isBlank()) {
+                    throw MyException.create(HttpStatus.UNAUTHORIZED, "DeepSeek API key 未配置(deepseek.api-key / DEEPSEEK_API_KEY)");
+                }
+
+                DeepSeekRequest request = DeepSeekRequest.builder()
+                        .model(deepSeekConfig.getModel())
+                        .messages(messages)
+                        .stream(true)
+                        .build();
+
+                String url = deepSeekConfig.getBaseUrl() + "/chat/completions";
+                log.info("【DeepSeekService】开始HTTP请求: {}", url);
+                log.info("【DeepSeekService】使用模型: {}", deepSeekConfig.getModel());
+
+                connection = (HttpURLConnection) new URL(url).openConnection();
+                connection.setRequestMethod("POST");
+                connection.setRequestProperty(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE);
+                connection.setRequestProperty(HttpHeaders.AUTHORIZATION, "Bearer " + apiKey);
+                connection.setDoOutput(true);
+                connection.setDoInput(true);
+                connection.setConnectTimeout(deepSeekConfig.getTimeout());
+                connection.setReadTimeout(deepSeekConfig.getTimeout());
+
+                String requestBody = objectMapper.writeValueAsString(request);
+                log.info("【DeepSeekService】请求体大小: {} bytes", requestBody.getBytes(StandardCharsets.UTF_8).length);
+                
+                connection.getOutputStream().write(requestBody.getBytes(StandardCharsets.UTF_8));
+                connection.getOutputStream().flush();
+                connection.getOutputStream().close();
+
+                int responseCode = connection.getResponseCode();
+                log.info("【DeepSeekService】收到响应码: {}", responseCode);
+                
+                if (responseCode != 200) {
+                    log.error("【DeepSeekService】HTTP错误: {}", responseCode);
+                    throw new RuntimeException("HTTP error code: " + responseCode);
+                }
+
+                log.info("【DeepSeekService】开始读取流式响应...");
+                int chunkCount = 0;
+                
+                try (BufferedReader reader = new BufferedReader(
+                        new InputStreamReader(connection.getInputStream(), StandardCharsets.UTF_8))) {
+                    String line;
+                    while ((line = reader.readLine()) != null) {
+                        if (line.startsWith("data: ")) {
+                            String data = line.substring(6);
+                            if ("[DONE]".equals(data)) {
+                                log.info("【DeepSeekService】收到[DONE],流结束");
+                                onComplete.run();
+                                break;
+                            }
+                            try {
+                                DeepSeekStreamResponse streamResponse = 
+                                        objectMapper.readValue(data, DeepSeekStreamResponse.class);
+                                String content = streamResponse.getContent();
+                                if (content != null && !content.isEmpty()) {
+                                    onContent.accept(content);
+                                    chunkCount++;
+                                    if (chunkCount % 10 == 0) {
+                                        log.info("【DeepSeekService】已发送{}个内容块", chunkCount);
+                                    }
+                                }
+                                if (streamResponse.isDone()) {
+                                    log.info("【DeepSeekService】响应标记完成");
+                                    onComplete.run();
+                                    break;
+                                }
+                            } catch (Exception e) {
+                                log.warn("【DeepSeekService】解析流式响应失败: {}", data);
+                            }
+                        }
+                    }
+                }
+                log.info("【DeepSeekService】流式响应处理完成,共{}个内容块", chunkCount);
+                
+            } catch (Exception e) {
+                log.error("【DeepSeekService】流式请求失败: {}", e.getMessage(), e);
+                onError.accept(e);
+            } finally {
+                if (connection != null) {
+                    connection.disconnect();
+                    log.info("【DeepSeekService】连接已关闭");
+                }
+            }
+        }).start();
+        
+        log.info("【DeepSeekService】新线程已启动处理流式请求");
+    }
+
+    private RestTemplate createRestTemplate() {
+        SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
+        factory.setConnectTimeout(deepSeekConfig.getTimeout());
+        factory.setReadTimeout(deepSeekConfig.getTimeout());
+        return new RestTemplate(factory);
+    }
+}

+ 1 - 1
src/main/java/com/njuzr/eaibackend/service/impl/DoubaoServiceImpl.java

@@ -167,7 +167,7 @@ public class DoubaoServiceImpl implements DoubaoService {
 
 
 
 
     private static final String TSS_URL = "http://8.130.28.19:2002/tts";
     private static final String TSS_URL = "http://8.130.28.19:2002/tts";
-    @Async("doubaoTaskExecutor")
+    @Async("doubaoTaskExecutor") //无效注解
     private CompletableFuture<String> getMessageAudio(String message, int code, Long userId, Long assignmentId) {
     private CompletableFuture<String> getMessageAudio(String message, int code, Long userId, Long assignmentId) {
         return CompletableFuture.supplyAsync(() -> {
         return CompletableFuture.supplyAsync(() -> {
             OkHttpClient httpClient = new OkHttpClient.Builder()
             OkHttpClient httpClient = new OkHttpClient.Builder()

+ 222 - 0
src/main/java/com/njuzr/eaibackend/service/impl/ExamTimerServiceImpl.java

@@ -0,0 +1,222 @@
+package com.njuzr.eaibackend.service.impl;
+
+import com.njuzr.eaibackend.enums.AssignmentCompletionStatus;
+import com.njuzr.eaibackend.mapper.AssignmentMapper;
+import com.njuzr.eaibackend.mapper.StudentAssignmentMapper;
+import com.njuzr.eaibackend.po.Assignment;
+import com.njuzr.eaibackend.po.Engagement;
+import com.njuzr.eaibackend.po.StudentAssignmentHistory;
+import com.njuzr.eaibackend.service.ExamTimerService;
+import com.njuzr.eaibackend.vo.ExamTimeVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Service;
+
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.util.Date;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Service
+public class ExamTimerServiceImpl implements ExamTimerService {
+
+    private static final String EXAM_TIMER_KEY_PREFIX = "exam:timer:";
+    private static final String SUBMITTED_KEY_PREFIX = "exam:submitted:";
+    private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
+
+    @Autowired
+    private RedisTemplate<String, Object> redisTemplate;
+
+    @Autowired
+    private AssignmentMapper assignmentMapper;
+
+    @Autowired
+    private StudentAssignmentMapper studentAssignmentMapper;
+
+    @Override
+    public void startExamTimer(Long studentId, Long assignmentId, Integer examDuration) {
+        String timerKey = EXAM_TIMER_KEY_PREFIX + assignmentId + ":" + studentId;
+        String submittedKey = SUBMITTED_KEY_PREFIX + assignmentId + ":" + studentId;
+
+        if (Boolean.TRUE.equals(redisTemplate.hasKey(timerKey))) {
+            log.info("考试计时器已存在,跳过创建 - studentId: {}, assignmentId: {}", studentId, assignmentId);
+            return;
+        }
+
+        Engagement engagement = studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(studentId, assignmentId);
+        if (engagement != null && Boolean.TRUE.equals(engagement.getSubmitted())) {
+            log.info("学生已交卷,无法再次参与 - studentId: {}, assignmentId: {}", studentId, assignmentId);
+            return;
+        }
+
+        LocalDateTime startTime = LocalDateTime.now();
+        LocalDateTime endTime = startTime.plusMinutes(examDuration);
+
+        long ttlMinutes = examDuration + 5L;
+        redisTemplate.opsForValue().set(timerKey, startTime.format(TIME_FORMATTER), ttlMinutes, TimeUnit.MINUTES);
+
+        log.info("考试计时器已创建 - studentId: {}, assignmentId: {}, startTime: {}, endTime: {}, duration: {}min",
+                studentId, assignmentId, startTime, endTime, examDuration);
+    }
+
+    @Override
+    public void submitExam(Long studentId, Long assignmentId) {
+        String timerKey = EXAM_TIMER_KEY_PREFIX + assignmentId + ":" + studentId;
+        String submittedKey = SUBMITTED_KEY_PREFIX + assignmentId + ":" + studentId;
+
+        log.info("=== submitExam 开始 ===");
+        log.info("studentId: {}, assignmentId: {}", studentId, assignmentId);
+        log.info("删除 timerKey: {}", timerKey);
+        Boolean timerDeleted = redisTemplate.delete(timerKey);
+        log.info("timerKey 删除结果: {}", timerDeleted);
+
+        log.info("设置 submittedKey: {}", submittedKey);
+        redisTemplate.opsForValue().set(submittedKey, "1", 7, TimeUnit.DAYS);
+        log.info("考试交卷完成 - studentId: {}, assignmentId: {}", studentId, assignmentId);
+        log.info("=== submitExam 完成 ===");
+    }
+
+    @Override
+    public boolean isExamSubmitted(Long studentId, Long assignmentId) {
+        String submittedKey = SUBMITTED_KEY_PREFIX + assignmentId + ":" + studentId;
+        return Boolean.TRUE.equals(redisTemplate.hasKey(submittedKey));
+    }
+
+    @Override
+    public void handleExamTimerExpired(String key) {
+        if (!key.startsWith(EXAM_TIMER_KEY_PREFIX)) {
+            return;
+        }
+
+        String[] parts = key.substring(EXAM_TIMER_KEY_PREFIX.length()).split(":");
+        if (parts.length != 2) {
+            log.warn("考试计时器Key格式错误: {}", key);
+            return;
+        }
+
+        Long assignmentId = Long.parseLong(parts[0]);
+        Long studentId = Long.parseLong(parts[1]);
+
+        log.info("考试计时器过期,开始自动提交 - studentId: {}, assignmentId: {}", studentId, assignmentId);
+
+        autoSubmitExam(studentId, assignmentId);
+    }
+
+    @Override
+    public ExamTimeVO getExamTimeInfo(Long studentId, Long assignmentId) {
+        ExamTimeVO vo = new ExamTimeVO();
+        Assignment assignment = assignmentMapper.selectById(assignmentId);
+        if (assignment == null || !Boolean.TRUE.equals(assignment.getExamMode()) || assignment.getExamDuration() == null) {
+            vo.setSubmitted(true);
+            vo.setRemainingSeconds(0L);
+            return vo;
+        }
+
+        Engagement engagement = studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(studentId, assignmentId);
+
+        String timerKey = EXAM_TIMER_KEY_PREFIX + assignmentId + ":" + studentId;
+        String submittedKey = SUBMITTED_KEY_PREFIX + assignmentId + ":" + studentId;
+
+        if (Boolean.TRUE.equals(redisTemplate.hasKey(submittedKey))) {
+            vo.setSubmitted(true);
+            vo.setRemainingSeconds(0L);
+            vo.setExamDuration(assignment.getExamDuration());
+            return vo;
+        }
+
+        if (engagement != null && Boolean.TRUE.equals(engagement.getSubmitted())) {
+            vo.setSubmitted(true);
+            vo.setRemainingSeconds(0L);
+            vo.setExamDuration(assignment.getExamDuration());
+            return vo;
+        }
+
+        if (Boolean.TRUE.equals(redisTemplate.hasKey(timerKey))) {
+            Object startTimeObj = redisTemplate.opsForValue().get(timerKey);
+            LocalDateTime startTime = LocalDateTime.parse(startTimeObj.toString(), TIME_FORMATTER);
+            LocalDateTime endTime = startTime.plusMinutes(assignment.getExamDuration());
+
+            long remainingSeconds = java.time.Duration.between(LocalDateTime.now(), endTime).getSeconds();
+            if (remainingSeconds < 0) {
+                remainingSeconds = 0;
+            }
+
+            vo.setSubmitted(false);
+            vo.setExamStartTime(startTime);
+            vo.setExamEndTime(endTime);
+            vo.setExamDuration(assignment.getExamDuration());
+            vo.setRemainingSeconds(remainingSeconds);
+        } else if (engagement != null) {
+            // 学生已参与但Redis中没有timerKey,创建新的计时器
+            LocalDateTime startTime = LocalDateTime.now();
+            LocalDateTime endTime = startTime.plusMinutes(assignment.getExamDuration());
+
+            // 保存到Redis
+            long ttlMinutes = assignment.getExamDuration() + 5L;
+            redisTemplate.opsForValue().set(timerKey, startTime.format(TIME_FORMATTER), ttlMinutes, TimeUnit.MINUTES);
+            log.info("学生已参与但Redis中无timerKey,创建新的计时器 - studentId: {}, assignmentId: {}, startTime: {}",
+                    studentId, assignmentId, startTime);
+
+            vo.setSubmitted(false);
+            vo.setExamStartTime(startTime);
+            vo.setExamEndTime(endTime);
+            vo.setExamDuration(assignment.getExamDuration());
+            vo.setRemainingSeconds((long) assignment.getExamDuration() * 60);
+        } else {
+            // 学生未参与,返回完整时长
+            LocalDateTime now = LocalDateTime.now();
+            vo.setSubmitted(false);
+            vo.setExamStartTime(now);
+            vo.setExamEndTime(now.plusMinutes(assignment.getExamDuration()));
+            vo.setExamDuration(assignment.getExamDuration());
+            vo.setRemainingSeconds((long) assignment.getExamDuration() * 60);
+        }
+
+        return vo;
+    }
+
+    private void autoSubmitExam(Long studentId, Long assignmentId) {
+        Engagement engagement = studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(studentId, assignmentId);
+        if (engagement == null) {
+            log.warn("自动提交失败:未找到参与记录 - studentId: {}, assignmentId: {}", studentId, assignmentId);
+            return;
+        }
+
+        Assignment assignment = assignmentMapper.selectById(assignmentId);
+        if (assignment == null) {
+            log.warn("自动提交失败:未找到作业 - assignmentId: {}", assignmentId);
+            return;
+        }
+
+        if (!Boolean.TRUE.equals(assignment.getExamMode())) {
+            log.info("非考试模式,跳过自动提交 - assignmentId: {}", assignmentId);
+            return;
+        }
+
+        if (Boolean.TRUE.equals(engagement.getSubmitted())) {
+            log.info("学生已交卷,跳过自动提交 - studentId: {}, assignmentId: {}", studentId, assignmentId);
+            return;
+        }
+
+        int originalVersion = engagement.getVersion();
+        engagement.setVersion(originalVersion + 1);
+        engagement.setStatus(AssignmentCompletionStatus.SUBMITTED);
+        engagement.setSubmitted(true);
+
+        try {
+            int code = studentAssignmentMapper.updateById(engagement);
+            if (code == 0) {
+                log.error("自动提交失败:更新失败 - studentId: {}, assignmentId: {}", studentId, assignmentId);
+                return;
+            }
+            // 基于主表当前行落一条快照(幂等),避免漏写/并发导致 history 缺版本
+            studentAssignmentMapper.insertHistorySnapshot(assignmentId, studentId);
+            log.info("考试自动提交完成 - studentId: {}, assignmentId: {}", studentId, assignmentId);
+        } catch (Exception e) {
+            log.error("自动提交异常 - studentId: {}, assignmentId: {}, error: {}", studentId, assignmentId, e.getMessage());
+        }
+    }
+}

+ 2 - 2
src/main/java/com/njuzr/eaibackend/service/impl/MediaAnalysisServiceImpl.java

@@ -147,7 +147,7 @@ public class MediaAnalysisServiceImpl implements MediaAnalysisService {
                     }
                     }
                 });
                 });
 
 
-                return future.get();
+                return future.get(); //伪异步
             } catch (IOException | SignatureException e) {
             } catch (IOException | SignatureException e) {
                 log.error("{} 音频评分处理IO异常", AUDIO_SCORE_HEADER, e);
                 log.error("{} 音频评分处理IO异常", AUDIO_SCORE_HEADER, e);
                 throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "ISEService调用失败:" + e.getMessage());
                 throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "ISEService调用失败:" + e.getMessage());
@@ -239,7 +239,7 @@ public class MediaAnalysisServiceImpl implements MediaAnalysisService {
         }
         }
     }
     }
 
 
-    @Async("doubaoTaskExecutor")
+    @Async("doubaoTaskExecutor") //这个注解没必要,因为方法内部已经使用了CompletableFuture.supplyAsync,并且指定了线程池
     public CompletableFuture<String> getSpeakingSuggestionAsync(Long userId, Long assignmentId) {
     public CompletableFuture<String> getSpeakingSuggestionAsync(Long userId, Long assignmentId) {
         return CompletableFuture.supplyAsync(() -> getSpeakingSuggestion(userId, assignmentId), doubaoTaskExecutor);
         return CompletableFuture.supplyAsync(() -> getSpeakingSuggestion(userId, assignmentId), doubaoTaskExecutor);
     }
     }

+ 72 - 0
src/main/java/com/njuzr/eaibackend/service/impl/WindowSwitchServiceImpl.java

@@ -0,0 +1,72 @@
+package com.njuzr.eaibackend.service.impl;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.njuzr.eaibackend.dto.WindowSwitchDTO;
+import com.njuzr.eaibackend.exception.MyException;
+import com.njuzr.eaibackend.mapper.WindowSwitchRecordMapper;
+import com.njuzr.eaibackend.po.StudentWindowSwitchRecord;
+import com.njuzr.eaibackend.service.WindowSwitchService;
+import com.njuzr.eaibackend.vo.WindowSwitchSummaryVO;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Service;
+
+import java.util.Date;
+import java.util.List;
+
+/**
+ * 窗口切换行为统计 ServiceImpl
+ */
+@Slf4j
+@Service
+public class WindowSwitchServiceImpl implements WindowSwitchService {
+
+    private final WindowSwitchRecordMapper windowSwitchRecordMapper;
+    private final ObjectMapper objectMapper;
+
+    @Autowired
+    public WindowSwitchServiceImpl(WindowSwitchRecordMapper windowSwitchRecordMapper) {
+        this.windowSwitchRecordMapper = windowSwitchRecordMapper;
+        this.objectMapper = new ObjectMapper();
+    }
+
+    @Override
+    public void recordWindowSwitch(WindowSwitchDTO dto) {
+        String eventsJson;
+        try {
+            eventsJson = objectMapper.writeValueAsString(dto.getSwitchEvents());
+        } catch (JsonProcessingException e) {
+            log.error("序列化 switchEvents 失败: studentId={}, assignmentId={}", dto.getStudentId(), dto.getAssignmentId(), e);
+            throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "switchEvents 序列化失败");
+        }
+
+        StudentWindowSwitchRecord record = new StudentWindowSwitchRecord();
+        record.setStudentId(dto.getStudentId());
+        record.setAssignmentId(dto.getAssignmentId());
+        record.setSwitchCount(dto.getSwitchCount());
+        record.setSwitchEvents(eventsJson);
+        record.setReportTime(new Date());
+        record.setCreatedAt(new Date());
+
+        int rows = windowSwitchRecordMapper.insert(record);
+        if (rows != 1) {
+            log.error("窗口切换记录写入失败: studentId={}, assignmentId={}", dto.getStudentId(), dto.getAssignmentId());
+            throw new MyException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "记录写入数据库失败");
+        }
+
+        log.info("窗口切换记录已保存: studentId={}, assignmentId={}, switchCount={}",
+                dto.getStudentId(), dto.getAssignmentId(), dto.getSwitchCount());
+    }
+
+    @Override
+    public WindowSwitchSummaryVO getSummaryByStudentAndAssignment(Long studentId, Long assignmentId) {
+        return windowSwitchRecordMapper.selectSummaryByStudentAndAssignment(studentId, assignmentId);
+    }
+
+    @Override
+    public List<WindowSwitchSummaryVO> getSummaryByAssignment(Long assignmentId) {
+        return windowSwitchRecordMapper.selectSummaryByAssignment(assignmentId);
+    }
+}

+ 3 - 0
src/main/java/com/njuzr/eaibackend/vo/AssignmentVO.java

@@ -45,4 +45,7 @@ public class AssignmentVO implements Serializable {
 
 
     private Integer correctNumber;
     private Integer correctNumber;
 
 
+    private Boolean examMode; // 是否为考试模式:false-普通作业,true-考试模式
+
+    private Integer examDuration; // 考试时长(分钟),仅考试模式有效
 }
 }

+ 2 - 0
src/main/java/com/njuzr/eaibackend/vo/EngagementVO.java

@@ -22,6 +22,8 @@ public class EngagementVO {
     private String quillContent;
     private String quillContent;
     private String textContent;
     private String textContent;
     private String htmlContent;
     private String htmlContent;
+
+    private Boolean submitted; // 是否已交卷:false-未交卷,true-已交卷
 //    private String fileUrl; // 学生作业的oss链接
 //    private String fileUrl; // 学生作业的oss链接
 //    private String fileKey;
 //    private String fileKey;
 }
 }

+ 21 - 0
src/main/java/com/njuzr/eaibackend/vo/EvaluationCurrentVO.java

@@ -0,0 +1,21 @@
+package com.njuzr.eaibackend.vo;
+
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+/**
+ * 当前版本评价状态:
+ * - version: student_assignment.version(提交/补交时自增后的最新版本)
+ * - overallStatus: evaluation.overall(0未完成 / 1完成)
+ * - sentenceStatus: evaluation.sentence(0未开始 / 2处理中 / 1完成)
+ */
+@Data
+@Accessors(chain = true)
+public class EvaluationCurrentVO {
+    private Long assignmentId;
+    private Long studentId;
+    private Integer version;
+    private Integer overallStatus;
+    private Integer sentenceStatus;
+}
+

+ 18 - 0
src/main/java/com/njuzr/eaibackend/vo/ExamTimeVO.java

@@ -0,0 +1,18 @@
+package com.njuzr.eaibackend.vo;
+
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.time.LocalDateTime;
+
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+public class ExamTimeVO {
+    private Long remainingSeconds;
+    private LocalDateTime examStartTime;
+    private LocalDateTime examEndTime;
+    private Integer examDuration;
+    private Boolean submitted;
+}

+ 22 - 0
src/main/java/com/njuzr/eaibackend/vo/WindowSwitchSummaryVO.java

@@ -0,0 +1,22 @@
+package com.njuzr.eaibackend.vo;
+
+import lombok.Data;
+
+/**
+ * 窗口切换汇总统计 VO
+ */
+@Data
+public class WindowSwitchSummaryVO {
+
+    /** 学生ID */
+    private Long studentId;
+
+    /** 作业ID */
+    private Long assignmentId;
+
+    /** 该作业全程总窗口切换次数(各暂存区间 switchCount 之和) */
+    private Long totalSwitchCount;
+
+    /** 上报次数(即暂存次数) */
+    private Long stageTimes;
+}

+ 16 - 1
src/main/resources/application-dev.yaml

@@ -7,10 +7,22 @@ spring:
     username: root
     username: root
     password: eai123456
     password: eai123456
     url: jdbc:mysql://139.196.252.184:3306/eai?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true
     url: jdbc:mysql://139.196.252.184:3306/eai?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true
+    hikari:
+      maximum-pool-size: 30
+      minimum-idle: 10
+      connection-timeout: 10000
+      validation-timeout: 3000
+      idle-timeout: 600000
+      # Keep maxLifetime shorter than MySQL wait_timeout to avoid stale pooled connections.
+      max-lifetime: 240000
+      keepalive-time: 120000
+      leak-detection-threshold: 30000
   data:
   data:
     mongodb:
     mongodb:
       uri: mongodb://admin:eai123456@139.196.252.184:27017/eai?authSource=admin
       uri: mongodb://admin:eai123456@139.196.252.184:27017/eai?authSource=admin
       authentication-database: admin
       authentication-database: admin
+      # 根据 @Document/@CompoundIndex 等元数据在启动时建索引(生产可改为运维脚本建索引后关闭)
+      auto-index-creation: true
     redis:
     redis:
       host: 139.196.252.184
       host: 139.196.252.184
       port: 6378
       port: 6378
@@ -32,6 +44,9 @@ jwt:
   secret: UenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRW
   secret: UenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRW
   issuer: eai
   issuer: eai
   expiration: 86400
   expiration: 86400
+  portal-sync:
+    enabled: true
+    throttle-seconds: 60
 
 
 
 
 aliyun:
 aliyun:
@@ -78,5 +93,5 @@ springdoc:
     packagesToScan: com.njuzr.eaibackend.controller
     packagesToScan: com.njuzr.eaibackend.controller
 
 
 portal:
 portal:
-  base-url: http://localhost:8080
+  base-url: https://p-nju.seec.seecoder.cn
   batch-register-path: /api/user/register/batch/internal
   batch-register-path: /api/user/register/batch/internal

+ 15 - 1
src/main/resources/application-prod.yaml

@@ -7,10 +7,21 @@ spring:
     username: root
     username: root
     password: eai123456
     password: eai123456
     url: jdbc:mysql://139.196.252.184:3306/eai?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true
     url: jdbc:mysql://139.196.252.184:3306/eai?useSSL=false&serverTimezone=UTC&useUnicode=true&characterEncoding=utf8&allowPublicKeyRetrieval=true
+    hikari:
+      maximum-pool-size: 30
+      minimum-idle: 10
+      connection-timeout: 10000
+      validation-timeout: 3000
+      idle-timeout: 600000
+      # Keep maxLifetime shorter than MySQL wait_timeout to avoid stale pooled connections.
+      max-lifetime: 240000
+      keepalive-time: 120000
+      leak-detection-threshold: 30000
   data:
   data:
     mongodb:
     mongodb:
       uri: mongodb://admin:eai123456@139.196.252.184:27017/eai?authSource=admin
       uri: mongodb://admin:eai123456@139.196.252.184:27017/eai?authSource=admin
       authentication-database: admin
       authentication-database: admin
+      auto-index-creation: true
     redis:
     redis:
       host: 139.196.252.184
       host: 139.196.252.184
       port: 6378
       port: 6378
@@ -34,6 +45,9 @@ jwt:
   secret: UenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRW
   secret: UenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRWUenM4KGb6DskRW
   issuer: eai
   issuer: eai
   expiration: 86400
   expiration: 86400
+  portal-sync:
+    enabled: true
+    throttle-seconds: 60
 
 
 
 
 aliyun:
 aliyun:
@@ -77,5 +91,5 @@ springdoc:
     packagesToScan: com.njuzr.eaibackend.controller
     packagesToScan: com.njuzr.eaibackend.controller
 
 
 portal:
 portal:
-  base-url: http://localhost:8080
+  base-url: https://p-nju.seec.seecoder.cn
   batch-register-path: /api/user/register/batch/internal
   batch-register-path: /api/user/register/batch/internal

+ 51 - 0
src/main/resources/mapper/StudentAssignmentMapper.xml

@@ -14,6 +14,23 @@
         WHERE assignment_id = #{assignmentId}
         WHERE assignment_id = #{assignmentId}
     </select>
     </select>
 
 
+    <select id="countEngageNumberByCurrentClassStudents" resultType="com.njuzr.eaibackend.vo.EngageNumberVo">
+        SELECT
+            COUNT(*) AS engageNumber,
+            COALESCE(SUM(CASE WHEN sa.status IN (4, 6, 8) THEN 1 ELSE 0 END), 0) AS correctNumber
+        FROM student_assignment sa
+        WHERE sa.assignment_id = #{assignmentId}
+          AND EXISTS (
+              SELECT 1
+              FROM assignment a
+              JOIN `class` c ON c.course_id = a.course_id
+              JOIN class_student cs ON cs.class_id = c.class_id
+              JOIN users u ON u.official_number = cs.official_number
+              WHERE a.assignment_id = sa.assignment_id
+                AND u.id = sa.student_id
+          )
+    </select>
+
     <!--  通过studentId和assignmentID查找text_content  -->
     <!--  通过studentId和assignmentID查找text_content  -->
     <select id="getTextContent" resultType="java.lang.String">
     <select id="getTextContent" resultType="java.lang.String">
         SELECT text_content
         SELECT text_content
@@ -28,6 +45,34 @@
         (#{assignmentId}, #{studentId}, #{version}, #{status}, #{quillContent}, #{textContent}, #{htmlContent}, #{submitTime})
         (#{assignmentId}, #{studentId}, #{version}, #{status}, #{quillContent}, #{textContent}, #{htmlContent}, #{submitTime})
     </insert>
     </insert>
 
 
+    <!--
+      将 student_assignment 当前行写入 history(若该 version 尚不存在)。
+      这样即使业务层忘记组装 StudentAssignmentHistory,也能保证 history 与主表 version 对齐。
+    -->
+    <insert id="insertHistorySnapshot">
+        INSERT INTO student_assignment_history
+        (assignment_id, student_id, version, status, quill_content, text_content, html_content, submit_time)
+        SELECT
+            sa.assignment_id,
+            sa.student_id,
+            sa.version,
+            sa.status,
+            sa.quill_content,
+            sa.text_content,
+            sa.html_content,
+            NOW()
+        FROM student_assignment sa
+        WHERE sa.assignment_id = #{assignmentId}
+          AND sa.student_id = #{studentId}
+          AND NOT EXISTS (
+              SELECT 1
+              FROM student_assignment_history h
+              WHERE h.assignment_id = sa.assignment_id
+                AND h.student_id = sa.student_id
+                AND h.version = sa.version
+          )
+    </insert>
+
     <select id="findHistoryByStudentIdAndAssignmentId" resultType="com.njuzr.eaibackend.po.StudentAssignmentHistory">
     <select id="findHistoryByStudentIdAndAssignmentId" resultType="com.njuzr.eaibackend.po.StudentAssignmentHistory">
         SELECT *
         SELECT *
         FROM student_assignment_history
         FROM student_assignment_history
@@ -35,6 +80,12 @@
         ORDER BY version DESC
         ORDER BY version DESC
     </select>
     </select>
 
 
+    <select id="findHistoryByAssignmentIdAndStudentIdAndVersion" resultType="com.njuzr.eaibackend.po.StudentAssignmentHistory">
+        SELECT *
+        FROM student_assignment_history
+        WHERE assignment_id = #{assignmentId} AND student_id = #{studentId} AND version = #{version}
+    </select>
+
     <delete id="delete">
     <delete id="delete">
         DELETE FROM student_assignment
         DELETE FROM student_assignment
         WHERE student_id = #{studentId} AND assignment_id = #{assignmentId}
         WHERE student_id = #{studentId} AND assignment_id = #{assignmentId}

+ 33 - 0
src/main/resources/mapper/WindowSwitchRecordMapper.xml

@@ -0,0 +1,33 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.njuzr.eaibackend.mapper.WindowSwitchRecordMapper">
+
+    <!-- 查询某学生某作业的总切换次数及分段上报次数 -->
+    <select id="selectSummaryByStudentAndAssignment"
+            resultType="com.njuzr.eaibackend.vo.WindowSwitchSummaryVO">
+        SELECT
+            student_id        AS studentId,
+            assignment_id     AS assignmentId,
+            SUM(switch_count) AS totalSwitchCount,
+            COUNT(*)          AS stageTimes
+        FROM student_window_switch_record
+        WHERE student_id    = #{studentId}
+          AND assignment_id = #{assignmentId}
+        GROUP BY student_id, assignment_id
+    </select>
+
+    <!-- 查询某作业所有学生的切换次数汇总,按总切换次数降序 -->
+    <select id="selectSummaryByAssignment"
+            resultType="com.njuzr.eaibackend.vo.WindowSwitchSummaryVO">
+        SELECT
+            student_id        AS studentId,
+            assignment_id     AS assignmentId,
+            SUM(switch_count) AS totalSwitchCount,
+            COUNT(*)          AS stageTimes
+        FROM student_window_switch_record
+        WHERE assignment_id = #{assignmentId}
+        GROUP BY student_id, assignment_id
+        ORDER BY totalSwitchCount DESC
+    </select>
+
+</mapper>

+ 184 - 0
src/test/java/com/njuzr/eaibackend/service/DeepSeekServiceTest.java

@@ -0,0 +1,184 @@
+package com.njuzr.eaibackend.service;
+
+import com.njuzr.eaibackend.dto.deepseek.DeepSeekMessage;
+import com.njuzr.eaibackend.dto.deepseek.DeepSeekResponse;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.ActiveProfiles;
+import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
+
+import java.util.Arrays;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@Slf4j
+@ActiveProfiles("dev")
+@SpringBootTest
+public class DeepSeekServiceTest {
+
+    @Autowired
+    private DeepSeekService deepSeekService;
+
+    @Test
+    @DisplayName("测试1: 非流式单轮对话")
+    void testChatCompletion_SingleMessage() {
+        String message = "你好,请用一句话介绍自己";
+        log.info("测试非流式对话 - 输入: {}", message);
+
+        DeepSeekResponse response = deepSeekService.chatCompletion(message);
+
+        assertNotNull(response, "响应不应为null");
+        String content = response.getContent();
+        assertNotNull(content, "回复内容不应为null");
+        assertFalse(content.isEmpty(), "回复内容不应为空");
+
+        log.info("DeepSeek 回复: {}", content);
+        log.info("Token 使用情况 - Prompt: {}, Completion: {}, Total: {}",
+                response.getUsage() != null ? response.getUsage().getPromptTokens() : "N/A",
+                response.getUsage() != null ? response.getUsage().getCompletionTokens() : "N/A",
+                response.getUsage() != null ? response.getUsage().getTotalTokens() : "N/A");
+    }
+
+    @Test
+    @DisplayName("测试2: 非流式多轮对话(带历史记录)")
+    void testChatCompletion_WithHistory() {
+        var messages = Arrays.asList(
+                DeepSeekMessage.system("你是一个有用的AI助手,回答要简洁明了。"),
+                DeepSeekMessage.user("你好"),
+                DeepSeekMessage.assistant("你好!有什么我可以帮助你的吗?"),
+                DeepSeekMessage.user("今天天气怎么样?")
+        );
+        log.info("测试多轮对话");
+
+        DeepSeekResponse response = deepSeekService.chatCompletion(messages);
+
+        assertNotNull(response, "响应不应为null");
+        String content = response.getContent();
+        assertNotNull(content, "回复内容不应为null");
+        assertFalse(content.isEmpty(), "回复内容不应为空");
+
+        log.info("DeepSeek 回复: {}", content);
+    }
+
+    @Test
+    @DisplayName("测试3: 流式单轮对话")
+    void testChatCompletionStream_SingleMessage() throws Exception {
+        String message = "请用3句话介绍深度学习";
+        log.info("测试流式对话 - 输入: {}", message);
+
+        StringBuilder fullContent = new StringBuilder();
+        java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
+
+        // 使用带回调的方法
+        deepSeekService.chatCompletionStream(message,
+            content -> {
+                // 每收到一个片段就输出
+                fullContent.append(content);
+                System.out.print(content); // 实时输出到控制台
+            },
+            () -> {
+                // 完成时输出完整内容
+                System.out.println(); // 换行
+                log.info("流式响应完成,完整内容: {}", fullContent.toString());
+                latch.countDown();
+            },
+            error -> {
+                log.error("流式响应错误: {}", error.getMessage());
+                latch.countDown();
+            }
+        );
+
+        log.info("开始接收流式数据...");
+
+        // 等待流式响应完成(最多15秒)
+        boolean completed = latch.await(15, TimeUnit.SECONDS);
+        if (!completed) {
+            System.out.println(); // 换行
+            log.info("流式响应超时,已接收内容: {}", fullContent.toString());
+        }
+
+        log.info("流式测试完成");
+    }
+
+    @Test
+    @DisplayName("测试4: 流式多轮对话(带历史记录)")
+    void testChatCompletionStream_WithHistory() throws Exception {
+        var messages = Arrays.asList(
+                DeepSeekMessage.system("你是一个专业的程序员。"),
+                DeepSeekMessage.user("什么是Java?"),
+                DeepSeekMessage.assistant("Java是一种广泛使用的编程语言。"),
+                DeepSeekMessage.user("它有什么特点?")
+        );
+        log.info("测试流式多轮对话");
+
+        StringBuilder fullContent = new StringBuilder();
+        java.util.concurrent.CountDownLatch latch = new java.util.concurrent.CountDownLatch(1);
+
+        // 使用带回调的方法
+        deepSeekService.chatCompletionStream(messages,
+            content -> {
+                // 每收到一个片段就输出
+                fullContent.append(content);
+                System.out.print(content); // 实时输出到控制台
+            },
+            () -> {
+                // 完成时输出完整内容
+                System.out.println(); // 换行
+                log.info("流式多轮对话完成,完整内容: {}", fullContent.toString());
+                latch.countDown();
+            },
+            error -> {
+                log.error("流式响应错误: {}", error.getMessage());
+                latch.countDown();
+            }
+        );
+
+        log.info("开始接收流式多轮对话数据...");
+
+        // 等待流式响应完成
+        boolean completed = latch.await(20, TimeUnit.SECONDS);
+        if (!completed) {
+            System.out.println(); // 换行
+            log.info("流式响应超时,已接收内容: {}", fullContent.toString());
+        }
+
+        log.info("流式多轮对话测试完成");
+    }
+
+    @Test
+    @DisplayName("测试5: 长文本回复测试")
+    void testChatCompletion_LongResponse() {
+        String message = "请详细介绍一下Spring Boot框架的核心特性,至少列举5个";
+        log.info("测试长文本回复");
+
+        DeepSeekResponse response = deepSeekService.chatCompletion(message);
+
+        assertNotNull(response, "响应不应为null");
+        String content = response.getContent();
+        assertNotNull(content, "回复内容不应为null");
+        assertTrue(content.length() > 100, "长文本回复应该较长");
+
+        log.info("DeepSeek 回复长度: {} 字符", content.length());
+        log.info("DeepSeek 回复: {}", content.substring(0, Math.min(200, content.length())) + "...");
+    }
+
+    @Test
+    @DisplayName("测试6: 中文对话测试")
+    void testChatCompletion_Chinese() {
+        String message = "请写一首关于春天的五言绝句";
+        log.info("测试中文对话 - 输入: {}", message);
+
+        DeepSeekResponse response = deepSeekService.chatCompletion(message);
+
+        assertNotNull(response, "响应不应为null");
+        String content = response.getContent();
+        assertNotNull(content, "回复内容不应为null");
+        assertFalse(content.isEmpty(), "回复内容不应为空");
+
+        log.info("DeepSeek 中文回复:\n{}", content);
+    }
+}

+ 131 - 0
src/test/java/com/njuzr/eaibackend/service/ExamTimeRemainingTest.java

@@ -0,0 +1,131 @@
+package com.njuzr.eaibackend.service;
+
+import com.njuzr.eaibackend.vo.ExamTimeVO;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.test.context.ActiveProfiles;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@ActiveProfiles("dev")
+@SpringBootTest
+public class ExamTimeRemainingTest {
+
+    @Autowired
+    private ExamTimerService examTimerService;
+
+    @Autowired
+    private RedisTemplate<String, Object> redisTemplate;
+
+    @Test
+    @DisplayName("测试剩余时间是否随时间动态减少")
+    void testRemainingTimeDecreasesOverTime() throws InterruptedException {
+        Long studentId = 999L;
+        Long assignmentId = 999L;
+        Integer examDuration = 5; // 5分钟
+
+        String timerKey = "exam:timer:" + assignmentId + ":" + studentId;
+        String submittedKey = "exam:submitted:" + assignmentId + ":" + studentId;
+
+        // 清理之前的key
+        redisTemplate.delete(timerKey);
+        redisTemplate.delete(submittedKey);
+
+        System.out.println("=== 测试剩余时间动态减少 ===");
+
+        // 1. 开始考试计时
+        examTimerService.startExamTimer(studentId, assignmentId, examDuration);
+        System.out.println("开始考试计时");
+
+        // 2. 立即获取剩余时间
+        ExamTimeVO result1 = examTimerService.getExamTimeInfo(studentId, assignmentId);
+        Long remainingSeconds1 = result1.getRemainingSeconds();
+        System.out.println("第一次查询 - remainingSeconds: " + remainingSeconds1);
+        System.out.println("第一次查询 - examStartTime: " + result1.getExamStartTime());
+        System.out.println("第一次查询 - examEndTime: " + result1.getExamEndTime());
+
+        assertNotNull(remainingSeconds1);
+        assertTrue(remainingSeconds1 > 0, "剩余时间应该大于0");
+        assertTrue(remainingSeconds1 <= 300, "剩余时间应该小于等于300秒(5分钟)");
+
+        // 3. 等待3秒
+        System.out.println("等待3秒...");
+        Thread.sleep(3000);
+
+        // 4. 再次获取剩余时间
+        ExamTimeVO result2 = examTimerService.getExamTimeInfo(studentId, assignmentId);
+        Long remainingSeconds2 = result2.getRemainingSeconds();
+        System.out.println("第二次查询 - remainingSeconds: " + remainingSeconds2);
+        System.out.println("第二次查询 - examStartTime: " + result2.getExamStartTime());
+        System.out.println("第二次查询 - examEndTime: " + result2.getExamEndTime());
+
+        // 5. 验证剩余时间减少了
+        assertNotNull(remainingSeconds2);
+        System.out.println("时间差: " + (remainingSeconds1 - remainingSeconds2) + " 秒");
+        assertTrue(remainingSeconds2 < remainingSeconds1, "剩余时间应该减少");
+
+        // 6. 再等待2秒
+        System.out.println("等待2秒...");
+        Thread.sleep(2000);
+
+        // 7. 第三次获取剩余时间
+        ExamTimeVO result3 = examTimerService.getExamTimeInfo(studentId, assignmentId);
+        Long remainingSeconds3 = result3.getRemainingSeconds();
+        System.out.println("第三次查询 - remainingSeconds: " + remainingSeconds3);
+
+        System.out.println("总时间差: " + (remainingSeconds1 - remainingSeconds3) + " 秒");
+        assertTrue(remainingSeconds3 < remainingSeconds2, "剩余时间应该继续减少");
+
+        // 8. 验证开始时间保持一致
+        assertEquals(result1.getExamStartTime(), result2.getExamStartTime(), "开始时间应该保持一致");
+        assertEquals(result2.getExamStartTime(), result3.getExamStartTime(), "开始时间应该保持一致");
+
+        System.out.println("=== 测试通过 ===");
+
+        // 清理
+        redisTemplate.delete(timerKey);
+        redisTemplate.delete(submittedKey);
+    }
+
+    @Test
+    @DisplayName("测试多次刷新剩余时间持续减少")
+    void testRemainingTimeContinuouslyDecreases() throws InterruptedException {
+        Long studentId = 998L;
+        Long assignmentId = 998L;
+        Integer examDuration = 2; // 2分钟
+
+        String timerKey = "exam:timer:" + assignmentId + ":" + studentId;
+        String submittedKey = "exam:submitted:" + assignmentId + ":" + studentId;
+
+        redisTemplate.delete(timerKey);
+        redisTemplate.delete(submittedKey);
+
+        System.out.println("=== 测试多次刷新剩余时间持续减少 ===");
+
+        examTimerService.startExamTimer(studentId, assignmentId, examDuration);
+
+        long previousRemaining = Long.MAX_VALUE;
+        for (int i = 0; i < 5; i++) {
+            ExamTimeVO result = examTimerService.getExamTimeInfo(studentId, assignmentId);
+            long currentRemaining = result.getRemainingSeconds();
+
+            System.out.println("第" + (i + 1) + "次查询 - remainingSeconds: " + currentRemaining);
+
+            if (i > 0) {
+                assertTrue(currentRemaining <= previousRemaining,
+                    "第" + i + "次查询的剩余时间应该小于等于第" + (i - 1) + "次");
+            }
+
+            previousRemaining = currentRemaining;
+            Thread.sleep(1000); // 每秒查询一次
+        }
+
+        System.out.println("=== 测试通过 ===");
+
+        redisTemplate.delete(timerKey);
+        redisTemplate.delete(submittedKey);
+    }
+}

+ 391 - 0
src/test/java/com/njuzr/eaibackend/service/ExamTimerServiceTest.java

@@ -0,0 +1,391 @@
+package com.njuzr.eaibackend.service;
+
+import com.njuzr.eaibackend.enums.AssignmentCompletionStatus;
+import com.njuzr.eaibackend.mapper.AssignmentMapper;
+import com.njuzr.eaibackend.mapper.StudentAssignmentMapper;
+import com.njuzr.eaibackend.po.Assignment;
+import com.njuzr.eaibackend.po.Engagement;
+import com.njuzr.eaibackend.vo.ExamTimeVO;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@Slf4j
+@ActiveProfiles("dev")
+@SpringBootTest
+public class ExamTimerServiceTest {
+
+    private static final String TEST_TIMER_KEY_PREFIX = "exam:timer:";
+    private static final String TEST_SUBMITTED_KEY_PREFIX = "exam:submitted:";
+
+    @Autowired
+    private ExamTimerService examTimerService;
+
+    @Autowired
+    private RedisTemplate<String, Object> redisTemplate;
+
+    @Autowired
+    private AssignmentMapper assignmentMapper;
+
+    @Autowired
+    private StudentAssignmentMapper studentAssignmentMapper;
+
+    private Long testAssignmentId;
+    private Long testStudentId;
+
+    @BeforeEach
+    void setUp() {
+        testAssignmentId = 114L;
+        testStudentId = 1L;
+
+        String timerKey1 = TEST_TIMER_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        String submittedKey1 = TEST_SUBMITTED_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        String timerKey2 = TEST_TIMER_KEY_PREFIX + testAssignmentId + ":2";
+        String submittedKey2 = TEST_SUBMITTED_KEY_PREFIX + testAssignmentId + ":2";
+        String timerKey3 = TEST_TIMER_KEY_PREFIX + "115:" + testStudentId;
+        String submittedKey3 = TEST_SUBMITTED_KEY_PREFIX + "115:" + testStudentId;
+
+        redisTemplate.delete(timerKey1);
+        redisTemplate.delete(submittedKey1);
+        redisTemplate.delete(timerKey2);
+        redisTemplate.delete(submittedKey2);
+        redisTemplate.delete(timerKey3);
+        redisTemplate.delete(submittedKey3);
+
+        Engagement eng1 = studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(testStudentId, testAssignmentId);
+        if (eng1 != null) {
+            eng1.setSubmitted(false);
+            studentAssignmentMapper.updateById(eng1);
+        }
+    }
+
+    @Test
+    @DisplayName("测试1: 开始考试计时 - 应在Redis中创建key")
+    void testStartExamTimer_ShouldCreateRedisKey() {
+        String key = TEST_TIMER_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        redisTemplate.delete(key);
+
+        examTimerService.startExamTimer(testStudentId, testAssignmentId, 60);
+
+        assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(key)),
+            "考试计时器key应该存在");
+        log.info("测试1通过: 开始考试计时成功创建Redis key");
+    }
+
+    @Test
+    @DisplayName("测试2: 开始考试计时 - key应设置正确的TTL")
+    void testStartExamTimer_ShouldSetCorrectTTL() throws InterruptedException {
+        String key = TEST_TIMER_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        redisTemplate.delete(key);
+
+        int duration = 5;
+        examTimerService.startExamTimer(testStudentId, testAssignmentId, duration);
+
+        Long ttl = redisTemplate.getExpire(key, TimeUnit.MINUTES);
+        assertNotNull(ttl, "TTL应该存在");
+        assertTrue(ttl > 0 && ttl <= duration + 5,
+            "TTL应该在(duration, duration+5]范围内");
+        log.info("测试2通过: TTL设置正确,实际TTL={}分钟", ttl);
+    }
+
+    @Test
+    @DisplayName("测试3: 开始考试计时 - 重复调用应跳过不报错")
+    void testStartExamTimer_DuplicateCall_ShouldSkip() {
+        String key = TEST_TIMER_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        redisTemplate.delete(key);
+
+        examTimerService.startExamTimer(testStudentId, testAssignmentId, 60);
+        examTimerService.startExamTimer(testStudentId, testAssignmentId, 60);
+
+        assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(key)),
+            "计时器key应该仍然存在");
+        log.info("测试3通过: 重复调用未报错");
+    }
+
+    @Test
+    @DisplayName("测试4: 交卷删除计时器 - 应删除Redis timer key并创建submitted key")
+    void testSubmitExam_ShouldDeleteTimerKeyAndCreateSubmittedKey() {
+        String timerKey = TEST_TIMER_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        String submittedKey = TEST_SUBMITTED_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        redisTemplate.delete(timerKey);
+        redisTemplate.delete(submittedKey);
+
+        examTimerService.startExamTimer(testStudentId, testAssignmentId, 60);
+        assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(timerKey)),
+            "前置条件: 计时器key应存在");
+
+        examTimerService.submitExam(testStudentId, testAssignmentId);
+
+        assertFalse(Boolean.TRUE.equals(redisTemplate.hasKey(timerKey)),
+            "交卷后计时器key应该被删除");
+        assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(submittedKey)),
+            "交卷后submitted key应该存在");
+        log.info("测试4通过: 交卷成功删除timer key并创建submitted key");
+    }
+
+    @Test
+    @DisplayName("测试5: 检查是否已交卷 - 未交卷返回false")
+    void testIsExamSubmitted_NotSubmitted_ShouldReturnFalse() {
+        String timerKey = TEST_TIMER_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        redisTemplate.delete(timerKey);
+
+        examTimerService.startExamTimer(testStudentId, testAssignmentId, 60);
+
+        assertFalse(examTimerService.isExamSubmitted(testStudentId, testAssignmentId),
+            "未交卷时应该返回false");
+        log.info("测试5通过: 未交卷返回false");
+    }
+
+    @Test
+    @DisplayName("测试6: 检查是否已交卷 - 已交卷返回true")
+    void testIsExamSubmitted_Submitted_ShouldReturnTrue() {
+        String timerKey = TEST_TIMER_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        String submittedKey = TEST_SUBMITTED_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        redisTemplate.delete(timerKey);
+        redisTemplate.delete(submittedKey);
+
+        examTimerService.startExamTimer(testStudentId, testAssignmentId, 60);
+        examTimerService.submitExam(testStudentId, testAssignmentId);
+
+        assertTrue(examTimerService.isExamSubmitted(testStudentId, testAssignmentId),
+            "已交卷后应该返回true");
+        log.info("测试6通过: 已交卷返回true");
+    }
+
+    @Test
+    @DisplayName("测试7: 检查是否已交卷 - submitted key存在时返回true")
+    void testIsExamSubmitted_SubmittedKeyExist_ShouldReturnTrue() {
+        String submittedKey = TEST_SUBMITTED_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        redisTemplate.delete(submittedKey);
+        redisTemplate.opsForValue().set(submittedKey, "1", 1, TimeUnit.MINUTES);
+
+        assertTrue(examTimerService.isExamSubmitted(testStudentId, testAssignmentId),
+            "submitted key存在时应该返回true");
+        log.info("测试7通过: submitted key存在返回true");
+    }
+
+    @Test
+    @DisplayName("测试8: 交卷后无法再次开启计时器")
+    void testStartExamTimer_AfterSubmit_ShouldSkip() {
+        String timerKey = TEST_TIMER_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        String submittedKey = TEST_SUBMITTED_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        redisTemplate.delete(timerKey);
+        redisTemplate.delete(submittedKey);
+
+        examTimerService.startExamTimer(testStudentId, testAssignmentId, 60);
+
+        Engagement engagement = studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(testStudentId, testAssignmentId);
+        if (engagement == null) {
+            engagement = new Engagement();
+            engagement.setAssignmentId(testAssignmentId);
+            engagement.setStudentId(testStudentId);
+            engagement.setVersion(1);
+            engagement.setStatus(AssignmentCompletionStatus.NOT_SUBMITTED);
+            engagement.setSubmitted(true);
+            studentAssignmentMapper.insert(engagement);
+        } else {
+            engagement.setSubmitted(true);
+            studentAssignmentMapper.updateById(engagement);
+        }
+
+        examTimerService.startExamTimer(testStudentId, testAssignmentId, 60);
+
+        assertFalse(Boolean.TRUE.equals(redisTemplate.hasKey(timerKey)),
+            "交卷后再次启动计时应该被跳过");
+        log.info("测试8通过: 交卷后无法再次开启计时器");
+
+        engagement.setSubmitted(false);
+        studentAssignmentMapper.updateById(engagement);
+    }
+
+    @Test
+    @DisplayName("测试9: 不同学生应有独立的计时器")
+    void testStartExamTimer_DifferentStudents_ShouldHaveIndependentTimers() {
+        Long studentId1 = 1L;
+        Long studentId2 = 2L;
+        String timerKey1 = TEST_TIMER_KEY_PREFIX + testAssignmentId + ":" + studentId1;
+        String timerKey2 = TEST_TIMER_KEY_PREFIX + testAssignmentId + ":" + studentId2;
+        String submittedKey1 = TEST_SUBMITTED_KEY_PREFIX + testAssignmentId + ":" + studentId1;
+        String submittedKey2 = TEST_SUBMITTED_KEY_PREFIX + testAssignmentId + ":" + studentId2;
+        redisTemplate.delete(timerKey1);
+        redisTemplate.delete(timerKey2);
+        redisTemplate.delete(submittedKey1);
+        redisTemplate.delete(submittedKey2);
+
+        examTimerService.startExamTimer(studentId1, testAssignmentId, 60);
+        examTimerService.startExamTimer(studentId2, testAssignmentId, 60);
+
+        assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(timerKey1)),
+            "学生1的计时器应存在");
+        assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(timerKey2)),
+            "学生2的计时器应存在");
+
+        examTimerService.submitExam(studentId1, testAssignmentId);
+
+        assertFalse(Boolean.TRUE.equals(redisTemplate.hasKey(timerKey1)),
+            "学生1交卷后其计时器应删除");
+        assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(timerKey2)),
+            "学生1交卷不应影响学生2的计时器");
+        log.info("测试9通过: 不同学生计时器相互独立");
+
+        redisTemplate.delete(timerKey2);
+        redisTemplate.delete(submittedKey2);
+    }
+
+    @Test
+    @DisplayName("测试10: 不同作业应有独立的计时器")
+    void testStartExamTimer_DifferentAssignments_ShouldHaveIndependentTimers() {
+        Long assignmentId1 = 114L;
+        Long assignmentId2 = 115L;
+        String timerKey1 = TEST_TIMER_KEY_PREFIX + assignmentId1 + ":" + testStudentId;
+        String timerKey2 = TEST_TIMER_KEY_PREFIX + assignmentId2 + ":" + testStudentId;
+        String submittedKey1 = TEST_SUBMITTED_KEY_PREFIX + assignmentId1 + ":" + testStudentId;
+        String submittedKey2 = TEST_SUBMITTED_KEY_PREFIX + assignmentId2 + ":" + testStudentId;
+        redisTemplate.delete(timerKey1);
+        redisTemplate.delete(timerKey2);
+        redisTemplate.delete(submittedKey1);
+        redisTemplate.delete(submittedKey2);
+
+        examTimerService.startExamTimer(testStudentId, assignmentId1, 60);
+        examTimerService.startExamTimer(testStudentId, assignmentId2, 60);
+
+        assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(timerKey1)),
+            "作业1的计时器应存在");
+        assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(timerKey2)),
+            "作业2的计时器应存在");
+
+        examTimerService.submitExam(testStudentId, assignmentId1);
+
+        assertFalse(Boolean.TRUE.equals(redisTemplate.hasKey(timerKey1)),
+            "作业1交卷后其计时器应删除");
+        assertTrue(Boolean.TRUE.equals(redisTemplate.hasKey(timerKey2)),
+            "作业1交卷不应影响作业2的计时器");
+        log.info("测试10通过: 不同作业计时器相互独立");
+    }
+
+    @Test
+    @DisplayName("测试11: 获取考试时间信息 - 未参加考试")
+    void testGetExamTimeInfo_NotParticipated_ShouldReturnFullDuration() {
+        Assignment assignment = assignmentMapper.selectById(testAssignmentId);
+        if (assignment == null) {
+            log.warn("跳过测试11: 作业{}不存在", testAssignmentId);
+            return;
+        }
+        if (!Boolean.TRUE.equals(assignment.getExamMode())) {
+            assignment.setExamMode(true);
+            assignment.setExamDuration(60);
+            assignmentMapper.updateById(assignment);
+        }
+
+        String timerKey = TEST_TIMER_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        String submittedKey = TEST_SUBMITTED_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        redisTemplate.delete(timerKey);
+        redisTemplate.delete(submittedKey);
+
+        Engagement engagement = studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(testStudentId, testAssignmentId);
+        if (engagement != null) {
+            engagement.setSubmitted(false);
+            studentAssignmentMapper.updateById(engagement);
+        }
+
+        ExamTimeVO result = examTimerService.getExamTimeInfo(testStudentId, testAssignmentId);
+
+        assertNotNull(result, "结果不应为null");
+        assertEquals(60 * 60, result.getRemainingSeconds(), "未参加时应返回完整时长");
+        assertFalse(result.getSubmitted(), "submitted应为false");
+        assertEquals(60, result.getExamDuration(), "examDuration应为60分钟");
+        log.info("测试11通过: 未参加考试返回完整时长,remainingSeconds={}", result.getRemainingSeconds());
+    }
+
+    @Test
+    @DisplayName("测试12: 获取考试时间信息 - 正在考试中")
+    void testGetExamTimeInfo_InProgress_ShouldReturnRemainingTime() throws InterruptedException {
+        Assignment assignment = assignmentMapper.selectById(testAssignmentId);
+        if (assignment == null) {
+            log.warn("跳过测试12: 作业{}不存在", testAssignmentId);
+            return;
+        }
+        if (!Boolean.TRUE.equals(assignment.getExamMode())) {
+            assignment.setExamMode(true);
+            assignment.setExamDuration(5);
+            assignmentMapper.updateById(assignment);
+        }
+
+        String timerKey = TEST_TIMER_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        String submittedKey = TEST_SUBMITTED_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        redisTemplate.delete(timerKey);
+        redisTemplate.delete(submittedKey);
+
+        examTimerService.startExamTimer(testStudentId, testAssignmentId, 5);
+
+        Thread.sleep(1000);
+
+        ExamTimeVO result = examTimerService.getExamTimeInfo(testStudentId, testAssignmentId);
+
+        assertNotNull(result, "结果不应为null");
+        assertTrue(result.getRemainingSeconds() > 0 && result.getRemainingSeconds() <= 5 * 60,
+            "正在考试时应返回剩余时间,remainingSeconds=" + result.getRemainingSeconds());
+        assertFalse(result.getSubmitted(), "submitted应为false");
+        assertNotNull(result.getExamStartTime(), "examStartTime不应为null");
+        assertNotNull(result.getExamEndTime(), "examEndTime不应为null");
+        log.info("测试12通过: 正在考试中返回剩余时间,remainingSeconds={}", result.getRemainingSeconds());
+    }
+
+    @Test
+    @DisplayName("测试13: 获取考试时间信息 - 已交卷")
+    void testGetExamTimeInfo_Submitted_ShouldReturnZero() {
+        Assignment assignment = assignmentMapper.selectById(testAssignmentId);
+        if (assignment == null) {
+            log.warn("跳过测试13: 作业{}不存在", testAssignmentId);
+            return;
+        }
+        if (!Boolean.TRUE.equals(assignment.getExamMode())) {
+            assignment.setExamMode(true);
+            assignment.setExamDuration(60);
+            assignmentMapper.updateById(assignment);
+        }
+
+        String timerKey = TEST_TIMER_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        String submittedKey = TEST_SUBMITTED_KEY_PREFIX + testAssignmentId + ":" + testStudentId;
+        redisTemplate.delete(timerKey);
+        redisTemplate.delete(submittedKey);
+        redisTemplate.opsForValue().set(submittedKey, "1", 1, TimeUnit.MINUTES);
+
+        Engagement engagement = studentAssignmentMapper.findEngagementByStudentIdAndAssignmentId(testStudentId, testAssignmentId);
+        if (engagement == null) {
+            engagement = new Engagement();
+            engagement.setAssignmentId(testAssignmentId);
+            engagement.setStudentId(testStudentId);
+            engagement.setVersion(1);
+            engagement.setStatus(AssignmentCompletionStatus.SUBMITTED);
+            engagement.setSubmitted(true);
+            studentAssignmentMapper.insert(engagement);
+        } else {
+            engagement.setSubmitted(true);
+            engagement.setStatus(AssignmentCompletionStatus.SUBMITTED);
+            studentAssignmentMapper.updateById(engagement);
+        }
+
+        ExamTimeVO result = examTimerService.getExamTimeInfo(testStudentId, testAssignmentId);
+
+        assertNotNull(result, "结果不应为null");
+        assertEquals(0L, result.getRemainingSeconds(), "已交卷时remainingSeconds应为0");
+        assertTrue(result.getSubmitted(), "submitted应为true");
+        log.info("测试13通过: 已交卷返回remainingSeconds=0");
+
+        if (engagement != null) {
+            engagement.setSubmitted(false);
+            engagement.setStatus(AssignmentCompletionStatus.NOT_SUBMITTED);
+            studentAssignmentMapper.updateById(engagement);
+        }
+    }
+}

+ 94 - 0
src/test/java/com/njuzr/eaibackend/service/RedisKeyCheckTest.java

@@ -0,0 +1,94 @@
+package com.njuzr.eaibackend.service;
+
+import lombok.extern.slf4j.Slf4j;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+@Slf4j
+@ActiveProfiles("dev")
+@SpringBootTest
+public class RedisKeyCheckTest {
+
+    @Autowired
+    private RedisTemplate<String, Object> redisTemplate;
+
+    @Test
+    @DisplayName("检查 studentId=86, assignmentId=117 的 Redis key 状态")
+    void checkRedisKeys() {
+        String timerKey = "exam:timer:117:86";
+        String submittedKey = "exam:submitted:117:86";
+
+        log.info("=== 检查 Redis Keys ===");
+        log.info("timerKey: {}", timerKey);
+        log.info("submittedKey: {}", submittedKey);
+
+        Boolean timerKeyExists = redisTemplate.hasKey(timerKey);
+        Boolean submittedKeyExists = redisTemplate.hasKey(submittedKey);
+
+        log.info("timerKey 存在: {}", timerKeyExists);
+        log.info("submittedKey 存在: {}", submittedKeyExists);
+
+        if (Boolean.TRUE.equals(timerKeyExists)) {
+            Object value = redisTemplate.opsForValue().get(timerKey);
+            Long ttl = redisTemplate.getExpire(timerKey, TimeUnit.SECONDS);
+            log.info("timerKey 值: {}", value);
+            log.info("timerKey TTL(秒): {}", ttl);
+        }
+
+        if (Boolean.TRUE.equals(submittedKeyExists)) {
+            Object value = redisTemplate.opsForValue().get(submittedKey);
+            Long ttl = redisTemplate.getExpire(submittedKey, TimeUnit.SECONDS);
+            log.info("submittedKey 值: {}", value);
+            log.info("submittedKey TTL(秒): {}", ttl);
+        }
+
+        log.info("=== 模糊匹配检查所有 exam 相关 key ===");
+        Set<String> allExamKeys = redisTemplate.keys("exam:*");
+        if (allExamKeys != null && !allExamKeys.isEmpty()) {
+            for (String key : allExamKeys) {
+                log.info("Found key: {}", key);
+            }
+        } else {
+            log.info("没有找到任何 exam:* 相关 key");
+        }
+
+        log.info("=== 模糊匹配检查 117:86 相关 key ===");
+        Set<String> relatedKeys = redisTemplate.keys("*117*86*");
+        if (relatedKeys != null && !relatedKeys.isEmpty()) {
+            for (String key : relatedKeys) {
+                log.info("Found key: {}", key);
+            }
+        } else {
+            log.info("没有找到任何 *117*86* 相关 key");
+        }
+
+        assertTrue(timerKeyExists != null || submittedKeyExists != null, "检查完成");
+    }
+
+    @Test
+    @DisplayName("手动设置并检查 key")
+    void manualSetAndCheck() {
+        String testKey = "test:manual:check";
+        redisTemplate.opsForValue().set(testKey, "testValue", 60, TimeUnit.SECONDS);
+
+        Boolean exists = redisTemplate.hasKey(testKey);
+        Object value = redisTemplate.opsForValue().get(testKey);
+
+        log.info("手动测试 key: {}", testKey);
+        log.info("是否存在: {}", exists);
+        log.info("值: {}", value);
+
+        redisTemplate.delete(testKey);
+
+        assertEquals("testValue", value);
+    }
+}