前言

一、需求分析

在线考试系统中,考生可能会因为网络故障、浏览器崩溃、设备断电等等意外情况导致中断考试,为了预防这种情况,就实现这个断点续考功能,允许考生在意外中断考试之后,重新进入系统可以进行考试,并且恢复之前的答题情况。

二、技术实现

2.1 实现思路

我们前后端进行了商量,前端通过浏览器本地缓存考生答题情况,后端使用Redis记录考生答题情况。可以有人会问,前端既然已经通过浏览器本地缓存了考生的答题情况,后端还有必要在实现吗?因为在各种意外中断考试的情况中,如果考生重新进行考试的浏览器或者设备和最开始的不一致,那他的答题情况就会不见了。

2.2 数据结构

考生答案(Hash)

key格式:exam:answer:{examId}:{userId}
value内容:题目ID—>答案json

为什么使用Hash记录答案?

  • 使用HSET更新答案的操作是支持原子操作的
  • 使用k-v的存储获取迅速,在内存压缩存储比String更省空间

考试信息(Hash)

key格式:exam:session:{examId}:{userId}
value内容:
{
    startTime:开始时间
    endTime:结束时间
    currentQuestion:目前答题id
    status:状态
}

核心实现

下面是我之前在实现该功能的一个demo的service的代码。

@Service
@Slf4j
public class ExamServiceImpl implements ExamService {

    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    
    // 考试答案保存key
    private static final String EXAM_ANSWERS_KEY = "exam:answers:%s:%s";
    // 考试会话信息key
    private static final String EXAM_SESSION_KEY = "exam:session:%s:%s";
    // 考试状态
    private static final String EXAM_STATUS_KEY = "exam:status:%s:%s";
    
    @Override
    public ExamSessionDTO startExam(String examId, String userId) {
        // 生成考试会话Key
        String sessionKey = String.format(EXAM_SESSION_KEY, examId, userId);
        String answersKey = String.format(EXAM_ANSWERS_KEY, examId, userId);
        String statusKey = String.format(EXAM_STATUS_KEY, examId, userId);
        
        // 检查是否存在中断的考试
        if (redisTemplate.hasKey(sessionKey)) {
            return restoreExamSession(examId, userId);
        }
        
        // 创建新的考试会话
        ExamSessionDTO session = new ExamSessionDTO();
        session.setExamId(examId);
        session.setUserId(userId);
        session.setStartTime(System.currentTimeMillis());
        session.setCurrentQuestion(1);
        session.setStatus(ExamStatus.IN_PROGRESS);
        
        // 保存到Redis
        HashOperations<String, String, Object> hashOps = redisTemplate.opsForHash();
        hashOps.put(sessionKey, "examId", session.getExamId());
        hashOps.put(sessionKey, "userId", session.getUserId());
        hashOps.put(sessionKey, "startTime", session.getStartTime().toString());
        hashOps.put(sessionKey, "currentQuestion", session.getCurrentQuestion().toString());
        hashOps.put(sessionKey, "status", session.getStatus().name());
        
        // 设置过期时间
        long examDuration = getExamDuration(examId) + 600; // 增加10分钟缓冲
        redisTemplate.expire(sessionKey, examDuration, TimeUnit.SECONDS);
        redisTemplate.expire(answersKey, examDuration, TimeUnit.SECONDS);
        redisTemplate.expire(statusKey, examDuration, TimeUnit.SECONDS);
        
        return session;
    }
    
    @Override
    public ExamSessionDTO restoreExamSession(String examId, String userId) {
        String sessionKey = String.format(EXAM_SESSION_KEY, examId, userId);
        
        if (!redisTemplate.hasKey(sessionKey)) {
            throw new BusinessException("没有找到可恢复的考试记录");
        }
        
        HashOperations<String, String, Object> hashOps = redisTemplate.opsForHash();
        ExamSessionDTO session = new ExamSessionDTO();
        session.setExamId((String) hashOps.get(sessionKey, "examId"));
        session.setUserId((String) hashOps.get(sessionKey, "userId"));
        session.setStartTime(Long.parseLong((String) hashOps.get(sessionKey, "startTime")));
        session.setCurrentQuestion(Integer.parseInt((String) hashOps.get(sessionKey, "currentQuestion")));
        session.setStatus(ExamStatus.valueOf((String) hashOps.get(sessionKey, "status")));
        
        // 更新状态为进行中
        hashOps.put(sessionKey, "status", ExamStatus.IN_PROGRESS.name());
        redisTemplate.opsForValue().set(
            String.format(EXAM_STATUS_KEY, examId, userId), 
            ExamStatus.IN_PROGRESS.name()
        );
        
        return session;
    }
    
    @Override
    public void saveAnswer(String examId, String userId, Integer questionId, 
                          QuestionAnswerDTO answer) {
        String answersKey = String.format(EXAM_ANSWERS_KEY, examId, userId);
        
        try {
            String answerJson = objectMapper.writeValueAsString(answer);
            redisTemplate.opsForHash().put(answersKey, "question_" + questionId, answerJson);
            updateLastActivityTime(examId, userId);
        } catch (JsonProcessingException e) {
            log.error("答案保存失败", e);
            throw new BusinessException("保存答案失败");
        }
    }
    
    @Override
    public Map<Integer, QuestionAnswerDTO> getAnswers(String examId, String userId) {
        String answersKey = String.format(EXAM_ANSWERS_KEY, examId, userId);
        Map<Object, Object> answerMap = redisTemplate.opsForHash().entries(answersKey);
        
        Map<Integer, QuestionAnswerDTO> result = new HashMap<>();
        for (Map.Entry<Object, Object> entry : answerMap.entrySet()) {
            try {
                String key = (String) entry.getKey();
                Integer questionId = Integer.parseInt(key.replace("question_", ""));
                String value = (String) entry.getValue();
                
                QuestionAnswerDTO answer = objectMapper.readValue(value, QuestionAnswerDTO.class);
                result.put(questionId, answer);
            } catch (Exception e) {
                log.warn("解析答案失败: {}", entry.getKey(), e);
            }
        }
        
        return result;
    }
    
    @Override
    public void updateCurrentQuestion(String examId, String userId, Integer questionNo) {
        String sessionKey = String.format(EXAM_SESSION_KEY, examId, userId);
        redisTemplate.opsForHash().put(sessionKey, "currentQuestion", questionNo.toString());
        updateLastActivityTime(examId, userId);
    }
    
    private void updateLastActivityTime(String examId, String userId) {
        String sessionKey = String.format(EXAM_SESSION_KEY, examId, userId);
        redisTemplate.opsForHash().put(sessionKey, "lastActivityTime", 
            String.valueOf(System.currentTimeMillis()));
    }
    
    @Override
    public void submitExam(String examId, String userId) {
        // 从Redis获取所有答案
        Map<Integer, QuestionAnswerDTO> answers = getAnswers(examId, userId);
        
        // 持久化到数据库
        examRepository.saveAnswers(examId, userId, answers);
        
        // 清理Redis数据
        String sessionKey = String.format(EXAM_SESSION_KEY, examId, userId);
        String answersKey = String.format(EXAM_ANSWERS_KEY, examId, userId);
        String statusKey = String.format(EXAM_STATUS_KEY, examId, userId);
        
        redisTemplate.delete(sessionKey);
        redisTemplate.delete(answersKey);
        redisTemplate.delete(statusKey);
    }
    
    @Override
    public void handleExamInterruption(String examId, String userId) {
        String sessionKey = String.format(EXAM_SESSION_KEY, examId, userId);
        String statusKey = String.format(EXAM_STATUS_KEY, examId, userId);
        
        if (redisTemplate.hasKey(sessionKey)) {
            redisTemplate.opsForHash().put(sessionKey, "status", 
                ExamStatus.INTERRUPTED.name());
            redisTemplate.opsForValue().set(statusKey, ExamStatus.INTERRUPTED.name());
            
            log.info("考试中断已记录: examId={}, userId={}", examId, userId);
        }
    }
}

定时任务将Redis的记录持久化MySQL

@Slf4j
@Component
public class ExamDataBackupTask {
    @Autowired
    private ExamService examService;
    
    @Scheduled(cron = "0 */5 * * * ?")
    public void backupExamData() {
        Set<String> sessionKeys = redisTemplate.keys("exam:session:*");
        
        for (String key : sessionKeys) {
            String[] parts = key.split(":");
            String examId = parts[2];
            String userId = parts[3];
            
            try {
                Map<Integer, QuestionAnswerDTO> answers = examService.getAnswers(examId, userId);
                examRepository.backupAnswers(examId, userId, answers);
            } catch (Exception e) {
                log.error("保存考试数据失败: examId={}, userId={}", examId, userId, e);
            }
        }
    }
}

总结

这次只是对之前项目的功能实现的印象加深,系统中实现的全部代码还是没能在文章中完整的展示出来,只能给出相关的重要代码片段,不足之处,还请见谅。

本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:[email protected]