生产级的考试系统

技术栈说明

  • 后端框架: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的项目,可作为生产级的考试系统,也可作为你的毕设选项。欢迎拍砖。

相关推荐
康小庄2 小时前
通过NGINX实现将小程序HTTPS请求转为内部HTTP请求
java·spring boot·nginx·spring·http·小程序
Swift社区2 小时前
Date / LocalDateTime 转换错误,一次踩坑后的完整复盘
java·spring boot·spring
Seven972 小时前
PriorityQueue的秘密:堆结构的高效应用与实现原理
java
Foreverthinker2 小时前
平台唯一编号实现方案
java
我是一只小青蛙8882 小时前
Java分层开发:PO、BO、DTO、VO全解析
java
步步为营DotNet2 小时前
深度剖析.NET 中CancellationToken:精准控制异步操作的关键
java·前端·.net
a努力。2 小时前
得物Java面试被问:B+树的分裂合并和范围查询优化
java·开发语言·后端·b树·算法·面试·职场和发展
a程序小傲2 小时前
中国电网Java面试被问:Kafka Consumer的Rebalance机制和分区分配策略
java·服务器·开发语言·面试·职场和发展·kafka·github
lbb 小魔仙2 小时前
从零搭建 Spring Cloud 微服务项目:注册中心 + 网关 + 配置中心全流程
java·python·spring cloud·微服务