巅峰免安装联机版
4.86G · 2025-09-19
在线考试系统中,考生可能会因为网络故障、浏览器崩溃、设备断电等等意外情况导致中断考试,为了预防这种情况,就实现这个断点续考功能,允许考生在意外中断考试之后,重新进入系统可以进行考试,并且恢复之前的答题情况。
我们前后端进行了商量,前端通过浏览器本地缓存考生答题情况,后端使用Redis记录考生答题情况。可以有人会问,前端既然已经通过浏览器本地缓存了考生的答题情况,后端还有必要在实现吗?因为在各种意外中断考试的情况中,如果考生重新进行考试的浏览器或者设备和最开始的不一致,那他的答题情况就会不见了。
考生答案(Hash)
key格式:exam:answer:{examId}:{userId}
value内容:题目ID—>答案json
为什么使用Hash记录答案?
考试信息(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);
}
}
}
}
这次只是对之前项目的功能实现的印象加深,系统中实现的全部代码还是没能在文章中完整的展示出来,只能给出相关的重要代码片段,不足之处,还请见谅。