Browse Source

Merge branch 'add_exam_mode' of FanYanPeng/EAI-Backend into refactor

LiuLuLin 5 months ago
parent
commit
b87a8014a7

+ 3 - 0
eai.sql

@@ -50,6 +50,8 @@ CREATE TABLE `assignment`  (
   `teacher_portal_id` bigint NULL DEFAULT NULL,
   `correct_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,
   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;
@@ -293,6 +295,7 @@ CREATE TABLE `student_assignment`  (
   `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 '纯文本内容',
   `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,
   UNIQUE INDEX `uk_assignment_student_latest`(`assignment_id`, `student_id`) USING BTREE,
   INDEX `idx_student_assignment`(`student_id`, `assignment_id`) USING BTREE

+ 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.data.redis.connection.lettuce.LettuceConnectionFactory;
 import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.listener.PatternTopic;
+import org.springframework.data.redis.listener.RedisMessageListenerContainer;
 
 /**
  * @author: Leonezhurui
@@ -20,5 +22,17 @@ public class RedisConfig {
         template.setConnectionFactory(connectionFactory);
         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 过期监听器已注册");
+    }
+}

+ 14 - 1
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.service.AIDialogueService;
 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 jakarta.servlet.http.HttpServletRequest;
 import jakarta.servlet.http.HttpServletResponse;
@@ -41,11 +43,14 @@ public class AssignmentController {
 
     private final AIDialogueService aiDialogueService;
 
+    private final ExamTimerService examTimerService;
+
 
     @Autowired
-    public AssignmentController(AssignmentService assignmentService, AIDialogueService aiDialogueService) {
+    public AssignmentController(AssignmentService assignmentService, AIDialogueService aiDialogueService, ExamTimerService examTimerService) {
         this.assignmentService = assignmentService;
         this.aiDialogueService = aiDialogueService;
+        this.examTimerService = examTimerService;
     }
 
     /**
@@ -150,6 +155,14 @@ public class AssignmentController {
         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的更新
      */

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

@@ -38,4 +38,8 @@ public class AssignmentDTO {
 
     private Integer engageNumber;
     private Integer correctNumber;
+
+    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")
     private Date endTime;
+
+    private Boolean examMode; // 是否为考试模式:false-普通作业,true-考试模式
+
+    private Integer examDuration; // 考试时长(分钟),仅考试模式有效
 }

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

@@ -33,6 +33,11 @@ public interface StudentAssignmentMapper extends BaseMapper<Engagement> {
 
     List<StudentAssignmentHistory> findHistoryByStudentIdAndAssignmentId(Long studentId, Long assignmentId);
 
+    StudentAssignmentHistory findHistoryByAssignmentIdAndStudentIdAndVersion(
+            @Param("assignmentId") Long assignmentId,
+            @Param("studentId") Long studentId,
+            @Param("version") Integer version);
+
     /**
      * 根据作业ID和学生ID列表查询作业提交情况
      * @param assignmentId 作业ID

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

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

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

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

+ 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);
+}

+ 57 - 4
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.service.AssignmentService;
 import com.njuzr.eaibackend.service.ClassService;
+import com.njuzr.eaibackend.service.ExamTimerService;
 import com.njuzr.eaibackend.service.TextAnalysisService;
 import com.njuzr.eaibackend.utils.*;
 import com.njuzr.eaibackend.vo.*;
@@ -52,6 +53,7 @@ public class AssignmentServiceImpl implements AssignmentService {
 
     private final ClassService classService;
     private final OssUtil ossUtil;
+    private final ExamTimerService examTimerService;
 
     private final FileUtil fileUtil = new FileUtil();
 
@@ -65,7 +67,8 @@ public class AssignmentServiceImpl implements AssignmentService {
                                  CourseServiceImpl courseServiceImpl,
                                  CourseStudentMapper courseStudentMapper,
                                  ClassService classService,
-                                 TextAnalysisService textAnalysisService) {
+                                 TextAnalysisService textAnalysisService,
+                                 ExamTimerService examTimerService) {
         this.assignmentMapper = assignmentMapper;
         this.courseMapper = courseMapper;
         this.studentAssignmentMapper = studentAssignmentMapper;
@@ -73,6 +76,7 @@ public class AssignmentServiceImpl implements AssignmentService {
         this.ossUtil = ossUtil;
         this.textAnalysisService = textAnalysisService;
         this.classService = classService;
+        this.examTimerService = examTimerService;
     }
 
     @Override
@@ -232,6 +236,10 @@ public class AssignmentServiceImpl implements AssignmentService {
         Assignment assignment = assignmentMapper.selectById(assignmentId);
         assignment.setEngageNumber(assignment.getEngageNumber() + 1);
 
+        if (Boolean.TRUE.equals(assignment.getExamMode()) && assignment.getExamDuration() != null) {
+            examTimerService.startExamTimer(studentId, assignmentId, assignment.getExamDuration());
+        }
+
 
 //        // 2通过FileUtil创建一个空的docx文件
 //        InputStream inputStream = fileUtil.createEmptyDocx();
@@ -367,19 +375,46 @@ public class AssignmentServiceImpl implements AssignmentService {
     @Override
     @Transactional
     public void engagementSubmit(Long studentId, Long assignmentId) {
+        log.info("=== engagementSubmit 开始 ===");
+        log.info("studentId: {}, assignmentId: {}", 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, "参与作业情况不存在");
+        }
+        log.info("找到参与记录 - engagementId: {}, currentStatus: {}, currentSubmitted: {}",
+                engagement.getId(), engagement.getStatus(), engagement.getSubmitted());
+
         int originalVersion = engagement.getVersion();
         engagement.setVersion(originalVersion + 1);
         engagement.setStatus(AssignmentCompletionStatus.SUBMITTED);
+        log.info("设置状态为 SUBMITTED");
+
+        Assignment assignment = assignmentMapper.selectById(assignmentId);
+        log.info("作业信息 - assignmentId: {}, examMode: {}",
+                assignment != null ? assignment.getAssignmentId() : "null",
+                assignment != null ? assignment.getExamMode() : "null");
+
+        if (Boolean.TRUE.equals(assignment.getExamMode())) {
+            log.info("考试模式,调用 submitExam 并设置 submitted=true");
+            examTimerService.submitExam(studentId, assignmentId);
+            engagement.setSubmitted(true);
+        } else {
+            log.info("非考试模式,不设置 submitted");
+        }
+
         try {
             int code = studentAssignmentMapper.updateById(engagement);
-            if (code == 0)
+            log.info("数据库更新结果 - 影响行数: {}", code);
+            if (code == 0) {
+                log.error("更新失败 - 影响行数为0");
                 throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "更新文件版本失败");
+            }
             recordHistorySnapshot(engagement);
+            log.info("=== engagementSubmit 完成 ===");
         } catch (Exception e) {
-            log.error(e.getMessage());
+            log.error("更新异常: {}", e.getMessage(), e);
             throw MyException.create(HttpStatus.INTERNAL_SERVER_ERROR, "更新文件版本失败");
         }
     }
@@ -399,6 +434,13 @@ public class AssignmentServiceImpl implements AssignmentService {
         int originalVersion = engagement.getVersion();
         engagement.setVersion(originalVersion + 1);
         engagement.setStatus(AssignmentCompletionStatus.LATE_SUBMITTED);
+
+        Assignment assignment = assignmentMapper.selectById(assignmentId);
+        if (Boolean.TRUE.equals(assignment.getExamMode())) {
+            examTimerService.submitExam(studentId, assignmentId);
+            engagement.setSubmitted(true);
+        }
+
         try {
             int code = studentAssignmentMapper.updateById(engagement);
             if (code == 0)
@@ -561,6 +603,15 @@ public class AssignmentServiceImpl implements AssignmentService {
     }
 
     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();
         history.setAssignmentId(engagement.getAssignmentId());
         history.setStudentId(engagement.getStudentId());
@@ -571,6 +622,8 @@ public class AssignmentServiceImpl implements AssignmentService {
         history.setHtmlContent(engagement.getHtmlContent());
         history.setSubmitTime(new Date());
         studentAssignmentMapper.insertHistory(history);
+        log.info("历史记录插入成功 - assignmentId: {}, studentId: {}, version: {}",
+                engagement.getAssignmentId(), engagement.getStudentId(), engagement.getVersion());
     }
 
     private AssignmentStatus calculateStatus(Date startTime, Date endTime) {

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

@@ -0,0 +1,245 @@
+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;
+            }
+            recordHistorySnapshot(engagement);
+            log.info("考试自动提交完成 - studentId: {}, assignmentId: {}", studentId, assignmentId);
+        } catch (Exception e) {
+            log.error("自动提交异常 - studentId: {}, assignmentId: {}, error: {}", studentId, assignmentId, e.getMessage());
+        }
+    }
+
+    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();
+        history.setAssignmentId(engagement.getAssignmentId());
+        history.setStudentId(engagement.getStudentId());
+        history.setVersion(engagement.getVersion());
+        history.setStatus(engagement.getStatus());
+        history.setQuillContent(engagement.getQuillContent());
+        history.setTextContent(engagement.getTextContent());
+        history.setHtmlContent(engagement.getHtmlContent());
+        history.setSubmitTime(new Date());
+        studentAssignmentMapper.insertHistory(history);
+        log.info("历史记录插入成功 - assignmentId: {}, studentId: {}, version: {}",
+                engagement.getAssignmentId(), engagement.getStudentId(), engagement.getVersion());
+    }
+}

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

@@ -45,4 +45,7 @@ public class AssignmentVO implements Serializable {
 
     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 textContent;
     private String htmlContent;
+
+    private Boolean submitted; // 是否已交卷:false-未交卷,true-已交卷
 //    private String fileUrl; // 学生作业的oss链接
 //    private String fileKey;
 }

+ 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;
+}

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

@@ -35,6 +35,12 @@
         ORDER BY version DESC
     </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 FROM student_assignment
         WHERE student_id = #{studentId} AND assignment_id = #{assignmentId}

+ 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);
+    }
+}