技术栈说明
- 后端框架:Spring Boot 2.7.18 + Spring Security
- 前端框架:基于Geeker Admin模板(基于Vue3)
- 核心功能:考试系统支持动态出题、随机组卷、题目乱序、选项乱序、防作弊,高性能。
系统模块划分
用户管理模块
- 角色权限设计(Spring Security配置)
管理员、教师、学生三种角色,通过@PreAuthorize注解以及API权限控制接口权限 - JWT令牌认证流程
实现自动续期
考试管理模块
分为 以下模块:
- 科目管理
- 题库管理
- 试卷管理
- 计划管理
- 我的
- 模拟练习
- 练习记录
- 人工阅卷
- 错题本

整个形成闭环。
题目类型设计:支持单选题、多选题、判断题、简答题的实体类建模
题目难度分级:定义五级难度
组卷策略模块
支持固定组卷 和 随机组卷。
组卷规则 关联 科目,题型,难度,数量。

防作弊:随机出题 + 题目乱序 + 选项乱序:

查看 自己的考试计划或成绩:


模拟练习:

做一道题后前端防抖提交,后端高性能机制保存答案。



限时提交控制
使用定时任务确保考生因断网等不可抗因素时仍可以交卷。

阅卷分析模块
单选,多选,判断题 可自动判分。简答题需要人工阅卷。后续可接入AI阅卷.
关键代码示例
java
@Slf4j
@Component
@DisallowConcurrentExecution
public class AutoSubmitExamJob extends QuartzJobBean {
@Resource
private ExamRecordService examRecordService;
@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
log.info("开始执行自动提交考试任务...");
// 查找超时未交卷的记录,逻辑:状态=0 且 截止时间 < 当前时间
List<ExamRecordEntity> expiredRecords = examRecordService.listExpiredRecords();
if (CollectionUtils.isEmpty(expiredRecords)) {
log.info("没有超时未交卷的考试记录");
return;
}
log.info("找到超时未交卷的考试记录条数:{}", expiredRecords.size());
for (ExamRecordEntity record : expiredRecords) {
try {
// 强制交卷
examRecordService.forceSubmit(record);
log.info("记录[{}]强制收卷成功", record.getId());
} catch (Exception e) {
log.error("记录[{}]强制收卷失败", record.getId(), e);
}
}
}
}
高性能并发架构答题能力:
java
/**
* @Async("taskExecutor") 异步线程池 (轻量优化,适合毕设)
* 缺点:只是把压力从 Tomcat 线程转移到了 DB 连接池。如果瞬间并发太高,DB 连接池满了,
* 线程池队列满了,依然会丢数据或报错(虽然配了 CallerRunsPolicy,但这会阻塞 Controller)。
* 3000 人考试对于现在的 MySQL 来说,只要不是同一秒点提交,其实也能抗。
*
* 但是,我们要展现高并发架构能力:Redis 缓冲 + 定时批量落库
* @param req
*/
// @Async("taskExecutor")
@Override
public void saveUserAnswer(SaveAnswerReq req) {
String key = BizRedisKeys.EXAM_ANSWER_KEY + req.getRecordId();
stringRedisTemplate.opsForHash().put(key, req.getDetailId().toString(), req.getUserAnswer());
// 将 recordId 加入一个 Set,方便 Job 扫描
stringRedisTemplate.opsForSet().add(BizRedisKeys.EXAM_ACTIVE_RECORDS, req.getRecordId().toString());
}
java
@Slf4j
@Component
public class AnswerSyncTask {
@Autowired
private StringRedisTemplate redisTemplate;
@Autowired
private ExamRecordDetailService examRecordDetailService;
/**
* 每 5 秒执行一次同步
* fixedDelay: 上一次执行结束后 5000ms 再执行 (防重叠)
*/
@Scheduled(fixedDelay = 5000)
public void syncAnswersToDb() {
// 从 Set 中取出 50 个待同步的 recordId (支持集群并发): pop 是原子操作,多台机器不会拿到重复的 ID
List<String> recordIds = redisTemplate.opsForSet().pop(BizRedisKeys.EXAM_ACTIVE_RECORDS, 50);
if (CollectionUtils.isEmpty(recordIds)) {
return;
}
log.info("开始同步 {} 个考卷的答案到数据库...", recordIds.size());
List<ExamRecordDetailEntity> batchUpdates = new ArrayList<>();
// 记录每个 recordId 对应处理了哪些 detailId (用于精准删除)
Map<String, List<Object>> processedFieldsMap = new HashMap<>();
for (String rid : recordIds) {
String key = BizRedisKeys.EXAM_ANSWER_KEY + rid;
// 获取该考卷所有暂存的答案
Map<Object, Object> answers = redisTemplate.opsForHash().entries(key);
if (answers.isEmpty()) {
continue;
}
List<Object> fields = new ArrayList<>();
for (Map.Entry<Object, Object> entry : answers.entrySet()) {
try {
ExamRecordDetailEntity detail = new ExamRecordDetailEntity();
// detailId
detail.setId(Integer.valueOf(entry.getKey().toString()));
detail.setUserAnswer((String) entry.getValue());
batchUpdates.add(detail);
// 记录已处理的 Field
fields.add(entry.getKey());
} catch (Exception e) {
log.error("解析答案数据异常: rid={}, entry={}", rid, entry, e);
}
}
processedFieldsMap.put(key, fields);
}
// 批量写入数据库
if (!batchUpdates.isEmpty()) {
try {
examRecordDetailService.updateBatchById(batchUpdates);
log.info("成功同步 {} 条答案明细", batchUpdates.size());
// 精准删除 Redis 中的 Field
for (Map.Entry<String, List<Object>> entry : processedFieldsMap.entrySet()) {
String key = entry.getKey();
List<Object> fields = entry.getValue();
if (!fields.isEmpty()) {
redisTemplate.opsForHash().delete(key, fields.toArray());
}
// 检查该 Key 是否还有剩余 Field
// 如果删完了,不用管,Key 会自动消失(或变成空Hash)。
// 如果期间有新写入,Key 依然存在。
// 关键点:如果 Key 还有数据,必须把 recordId 放回 Set,否则下次轮询不到它了!
Long size = redisTemplate.opsForHash().size(key);
if (size != null && size > 0) {
// 提取 recordId (去掉前缀)
String rid = key.replace(BizRedisKeys.EXAM_ANSWER_KEY, "");
redisTemplate.opsForSet().add(BizRedisKeys.EXAM_ACTIVE_RECORDS, rid);
}
}
} catch (Exception e) {
log.error("批量同步答案到数据库失败,尝试将 recordIds 放回 Redis 重试", e);
// 异常处理:把取出来的 ID 再塞回去,等待下次处理
redisTemplate.opsForSet().add(BizRedisKeys.EXAM_ACTIVE_RECORDS, recordIds.toArray(new String[0]));
}
}
}
}
生成题目快照:
java
private List<ExamRecordDetailEntity> createRecordDetails(ExamRecordEntity record, Integer paperId,
Integer isQuestionShuffle, Integer isOptionShuffle) {
List<ExamPaperRuleEntity> rules = examPaperRuleService.listByPaperId(paperId);
List<ExamRecordDetailEntity> details = new ArrayList<>();
// 全局题目排序
int globalSort = 1;
for (ExamPaperRuleEntity rule : rules) {
List<ExamQuestionEntity> questions;
// A. 获取题目列表
if (CollectionUtils.isNotEmpty(rule.getFixedQuestionIds())) {
// --- 固定组卷 ---
questions = examQuestionService.listByIds(rule.getFixedQuestionIds());
} else {
// --- 随机组卷 ---
questions = examQuestionService.listBySubjectIdTypeAndDifficulty(rule.getSubjectId(),
rule.getQuestionType(), rule.getRandomDifficulty(), rule.getQuestionCount());
// 校验题库数量是否足够
if (questions.size() < rule.getQuestionCount()) {
throw new RRException("规则[" + rule.getTitle() + "]要求" + rule.getQuestionCount()
+ "题,题库仅有" + questions.size() + "题,无法组卷");
}
}
// B. 题目乱序 (如果 Plan 开启了题目乱序)
// 注意:随机组卷本身就是乱的,这里主要针对固定组卷的再次打乱
if (isQuestionShuffle != null && isQuestionShuffle == 1) {
Collections.shuffle(questions);
}
// C. 构建快照
for (ExamQuestionEntity q : questions) {
ExamRecordDetailEntity detail = new ExamRecordDetailEntity();
detail.setRecordId(record.getId());
detail.setQuestionId(q.getId());
detail.setSort(globalSort++);
detail.setUserScore(0);
detail.setMaxScore(rule.getScorePerQuestion());
detail.setSubjectId(q.getSubjectId());
detail.setType(q.getType());
detail.setTitle(q.getTitle());
detail.setAnswer(q.getAnswer());
detail.setParse(q.getParse());
// 处理选项乱序 (核心防作弊)
List<OptionItem> optionsSnapshot = q.getOptions();
if (isOptionShuffle != null && isOptionShuffle == 1) {
if (CollectionUtils.isNotEmpty(optionsSnapshot)) {
// 深拷贝一份,防止影响缓存或原对象
List<OptionItem> shuffled = new ArrayList<>(optionsSnapshot);
Collections.shuffle(shuffled);
optionsSnapshot = shuffled;
}
}
// 存入打乱后的 JSON
detail.setOptionsSnapshot(optionsSnapshot);
details.add(detail);
}
}
// 批量保存
if (!details.isEmpty()) {
examRecordDetailService.saveBatch(details);
// 因为要拿到ids, 所以不能直接返回details:查询ids
return examRecordDetailService.listByRecordId(record.getId());
}
return details;
}
从0到1的项目,可作为生产级的考试系统,也可作为你的毕设选项。欢迎拍砖。