打造严肃在线考试系统——织码教育平台题库管理、组卷引擎与防作弊方案

引言

在线考试是企业培训中不可或缺的环节——合规培训要考、技能认证要考、入职培训也要考。但"在线考试"四个字背后,藏着一系列技术难题:题库怎么设计才能覆盖多种题型?成千上万道题怎么高效导入?如何按规则自动组卷?考试过程中怎么防止学员作弊?

本文将深入拆解织码在线教育系统的题库与考试模块,覆盖多维题库设计、Excel/Word 批量导入方案、智能组卷引擎、防切屏监考策略以及自动计分与考试报告生成,看看一套"严肃"的在线考试系统是如何炼成的。


一、多维题库设计

1.1 题型支持

织码在线教育系统支持四种核心题型,覆盖绝大多数企业考试场景:

题型代码说明自动判分
单选题SINGLE一个正确答案
多选题MULTI多个正确答案,可设置漏选得分
判断题JUDGE对/错
问答题ESSAY主观题,需人工评分

1.2 题目数据模型

-- 题目主表
CREATE TABLE `edu_question` (
  `id` bigint NOT NULL,
  `category_id` bigint NOT NULL COMMENT '题库分类ID',
  `type` tinyint NOT NULL COMMENT '题型: 1单选 2多选 3判断 4问答',
  `difficulty` tinyint DEFAULT 2 COMMENT '难度: 1简单 2中等 3困难',
  `content` longtext NOT NULL COMMENT '题干内容(支持富文本/图片)',
  `analysis` text COMMENT '答案解析',
  `score` decimal(5,2) DEFAULT 0 COMMENT '默认分值',
  `options` json COMMENT '选项列表(单选/多选题)',
  `answer` json COMMENT '正确答案',
  `partial_score` tinyint DEFAULT 0 COMMENT '多选题是否漏选得分',
  `partial_score_ratio` decimal(3,2) COMMENT '漏选得分比例',
  `status` tinyint DEFAULT 1 COMMENT '1启用 0禁用',
  `created_by` bigint COMMENT '创建人',
  `created_at` datetime DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  KEY `idx_category_type_diff` (`category_id`, `type`, `difficulty`),
  KEY `idx_status` (`status`)
) ENGINE=InnoDB COMMENT='题目表';

选项与答案的 JSON 结构设计:

// 单选题 - options 字段
[
  { "key": "A", "content": "选项A的内容" },
  { "key": "B", "content": "选项B的内容" },
  { "key": "C", "content": "选项C的内容" },
  { "key": "D", "content": "选项D的内容" }
]

// 单选题 - answer 字段
"A"

// 多选题 - answer 字段
["A", "C", "D"]

// 判断题 - answer 字段
true

// 问答题 - answer 字段(参考答案,供人工评分参考)
"需要包含以下要点:1. xxx  2. xxx  3. xxx"

1.3 题库分类管理

题库支持多级分类树,方便按学科、按培训项目组织题目:

@Data
public class QuestionCategory {
    private Long id;
    private Long parentId;
    private String name;
    private Integer sort;
    private List<QuestionCategory> children;

    /**
     * 构建多级分类树
     */
    public static List<QuestionCategory> buildTree(List<QuestionCategory> list) {
        Map<Long, QuestionCategory> map = list.stream()
            .collect(Collectors.toMap(QuestionCategory::getId, c -> c));
        List<QuestionCategory> roots = new ArrayList<>();
        for (QuestionCategory c : list) {
            if (c.getParentId() == null || c.getParentId() == 0) {
                roots.add(c);
            } else {
                QuestionCategory parent = map.get(c.getParentId());
                if (parent != null) {
                    parent.getChildren().add(c);
                }
            }
        }
        return roots;
    }
}

二、批量导入方案:Excel / Word 模板

2.1 导入方案选型

企业考试题量通常较大(几百到上千道),手动逐题录入效率极低。系统提供 Excel 和 Word 两种批量导入方案:

格式适用场景优势
Excel结构化题目(选项固定、无复杂排版)解析快、校验直观、错误定位精确
Word含公式、图片、复杂排版的题目所见即所得,适合专业学科题目

2.2 Excel 导入实现

@Service
public class QuestionImportService {

    /**
     * Excel 批量导入题目
     */
    @Transactional
    public ImportResult importFromExcel(MultipartFile file, Long categoryId) {
        ImportResult result = new ImportResult();

        try (InputStream is = file.getInputStream()) {
            // 1. 使用 EasyExcel 读取
            List<QuestionExcelDTO> rows = EasyExcel.read(is)
                .head(QuestionExcelDTO.class)
                .sheet()
                .doReadSync();

            // 2. 逐行校验并转换
            List<Question> questions = new ArrayList<>();
            List<String> errors = new ArrayList<>();

            for (int i = 0; i < rows.size(); i++) {
                QuestionExcelDTO row = rows.get(i);
                try {
                    validateRow(row, i + 2); // Excel 行号从2开始
                    Question q = convertToQuestion(row, categoryId);
                    questions.add(q);
                } catch (ImportException e) {
                    errors.add(e.getMessage());
                }
            }

            // 3. 批量入库
            if (!questions.isEmpty()) {
                questionMapper.batchInsert(questions);
            }

            result.setTotal(rows.size());
            result.setSuccess(questions.size());
            result.setFailed(errors.size());
            result.setErrors(errors);

        } catch (IOException e) {
            throw new BizException("文件读取失败");
        }

        return result;
    }

    /**
     * Excel 行数据校验
     */
    private void validateRow(QuestionExcelDTO row, int rowNum) {
        if (StringUtils.isBlank(row.getContent())) {
            throw new ImportException("第" + rowNum + "行:题干不能为空");
        }
        if (row.getType() == null) {
            throw new ImportException("第" + rowNum + "行:题型不能为空");
        }
        // 单选/多选/判断题必须有答案
        if (row.getType() != 4 && StringUtils.isBlank(row.getAnswer())) {
            throw new ImportException("第" + rowNum + "行:客观题必须填写答案");
        }
        // 多选题答案格式校验
        if (row.getType() == 2) {
            if (!row.getAnswer().matches("^([A-Z],)*[A-Z]$")) {
                throw new ImportException("第" + rowNum + "行:多选题答案格式应为 A,B,C");
            }
        }
    }
}

Excel 模板格式示例:

题型题干选项A选项B选项C选项D正确答案分值难度解析
单选Vue3中用于响应式数据的API是?refdatareactivecomputedA,C5中等ref和reactive均可创建响应式数据
多选以下哪些是Spring Cloud组件?EurekaGatewayFeignMyBatisA,B,C5简单MyBatis是ORM框架,非微服务组件
判断Java 17是LTS版本2简单Java 8/11/17/21均为LTS

2.3 Word 导入方案

Word 导入使用 Apache POI 解析 .docx 文件,按自定义标记符号区分题型和答案:

@Service
public class WordImportService {

    public ImportResult importFromWord(MultipartFile file, Long categoryId) {
        try (XWPFDocument doc = new XWPFDocument(file.getInputStream())) {

            List<Question> questions = new ArrayList<>();
            Question current = null;

            // 按段落解析
            for (XWPFParagraph para : doc.getParagraphs()) {
                String text = para.getText().trim();
                if (StringUtils.isBlank(text)) continue;

                // 【单选】【多选】【判断】【问答】作为题型标记
                if (text.startsWith("【单选】") || text.startsWith("【多选】")) {
                    if (current != null) questions.add(current);
                    current = parseChoiceQuestion(text);
                } else if (text.startsWith("【判断】")) {
                    if (current != null) questions.add(current);
                    current = parseJudgeQuestion(text);
                } else if (text.startsWith("【问答】")) {
                    if (current != null) questions.add(current);
                    current = parseEssayQuestion(text);
                } else if (current != null) {
                    // 选项行:A.xxx  B.xxx
                    if (text.matches("^[A-Z][..、].*")) {
                        current.addOption(text);
                    }
                    // 答案行:答案:A
                    else if (text.startsWith("答案:") || text.startsWith("答案:")) {
                        current.setAnswer(text.substring(3).trim());
                    }
                    // 解析行
                    else if (text.startsWith("解析:") || text.startsWith("解析:")) {
                        current.setAnalysis(text.substring(3).trim());
                    }
                }
            }
            if (current != null) questions.add(current);

            // 批量入库
            questionMapper.batchInsert(questions);
            // ...
        }
    }
}

三、智能组卷引擎

3.1 组卷策略

织码在线教育系统支持规则抽题组卷,管理员配置抽题规则后,系统自动从题库中随机抽取题目组成试卷:

组卷规则配置:

┌─────────────────────────────────────────────┐
│              试卷基本信息                     │
│  试卷名称: 2024年新员工入职考试              │
│  总分: 100分    及格分: 60分    时长: 60分钟  │
├─────────────────────────────────────────────┤
│              抽题规则配置                     │
│                                             │
│  规则1: [分类: 企业文化] [难度: 简单]        │
│         [题型: 单选] [数量: 10] [分值: 2分]  │
│                                             │
│  规则2: [分类: 企业文化] [难度: 中等]        │
│         [题型: 多选] [数量: 5] [分值: 4分]   │
│                                             │
│  规则3: [分类: 安全合规] [难度: 中等]        │
│         [题型: 判断] [数量: 10] [分值: 2分]  │
│                                             │
│  规则4: [分类: 综合应用] [难度: 困难]        │
│         [题型: 问答] [数量: 2] [分值: 10分]  │
│                                             │
│  ✅ 预计总分: 10×2 + 5×4 + 10×2 + 2×10 = 100│
└─────────────────────────────────────────────┘

在这里插入图片描述

3.2 抽题算法实现

@Service
public class PaperGenerateService {

    /**
     * 按规则组卷
     */
    public Paper generatePaper(PaperRuleDTO rule) {
        Paper paper = new Paper();
        paper.setTitle(rule.getTitle());
        paper.setPassScore(rule.getPassScore());
        paper.setDuration(rule.getDuration());

        List<PaperQuestion> paperQuestions = new ArrayList<>();
        BigDecimal totalScore = BigDecimal.ZERO;

        for (PaperRuleDTO.RuleItem item : rule.getRules()) {
            // 1. 查询符合条件的题目池
            List<Question> pool = questionMapper.selectByCondition(
                item.getCategoryId(),
                item.getType(),
                item.getDifficulty(),
                item.getExcludeIds()  // 排除已抽中的题目
            );

            // 2. 题目数量校验
            if (pool.size() < item.getCount()) {
                throw new BizException(String.format(
                    "题库不足:分类[%s] 题型[%s] 难度[%s] 需要%d题,仅有%d题",
                    item.getCategoryName(), item.getTypeName(),
                    item.getDifficultyName(), item.getCount(), pool.size()
                ));
            }

            // 3. 随机抽题
            Collections.shuffle(pool);
            List<Question> selected = pool.subList(0, item.getCount());

            // 4. 构建试卷题目关联
            for (Question q : selected) {
                PaperQuestion pq = new PaperQuestion();
                pq.setQuestionId(q.getId());
                pq.setScore(item.getScore());
                pq.setType(q.getType());
                paperQuestions.add(pq);
                totalScore = totalScore.add(item.getScore());
            }
        }

        paper.setTotalScore(totalScore);
        paper.setQuestions(paperQuestions);
        return paper;
    }
}

3.3 题目乱序与选项乱序

为防止学员间互相抄袭,每位学员的试卷可进行题目顺序随机选项顺序随机

// 前端:学员进入考试时,对题目和选项进行乱序
function shufflePaperQuestions(questions) {
  // 1. 题目乱序
  const shuffled = [...questions].sort(() => Math.random() - 0.5)

  // 2. 选项乱序(仅客观题)
  return shuffled.map(q => {
    if (q.type === 1 || q.type === 2) {
      const options = [...q.options].sort(() => Math.random() - 0.5)
      return { ...q, options }
    }
    return q
  })
}

四、在线考试防作弊策略

4.1 防切屏监考

这是系统防作弊的核心策略。考试过程中,前端实时监控学员的切屏行为(切换标签页、最小化窗口、切换应用),超过设定次数自动提交试卷。

在这里插入图片描述

前端监控实现:

// 考试防切屏监控 Hook
export function useAntiCheat(examConfig) {
  const switchCount = ref(0)
  const maxSwitch = examConfig.maxSwitchCount || 3
  const warnings = ref([])

  // 1. 监听页面可见性变化(切标签页/最小化)
  const handleVisibilityChange = () => {
    if (document.hidden) {
      switchCount.value++
      warnings.value.push({
        time: new Date(),
        message: `检测到离开考试页面(第${switchCount.value}次)`
      })

      if (switchCount.value >= maxSwitch) {
        // 超过最大次数,自动提交
        ElMessageBox.alert(
          `您已切屏${switchCount.value}次,考试将自动提交`,
          '考试结束',
          { type: 'error', showClose: false }
        ).then(() => {
          autoSubmitExam()
        })
      } else {
        ElMessage.warning(
          `警告:检测到切屏行为,剩余${maxSwitch - switchCount.value}次机会`
        )
      }

      // 上报切屏事件
      api.post('/api/exam/cheat-report', {
        examRecordId: examConfig.recordId,
        type: 'SWITCH_TAB',
        count: switchCount.value
      })
    }
  }

  // 2. 监听窗口失焦(Alt+Tab切应用)
  const handleBlur = () => {
    if (!document.hidden) {
      // 窗口失焦但页面未隐藏(如弹出系统通知),做轻量提示
      ElMessage.warning('请勿离开考试窗口')
    }
  }

  // 3. 禁止右键菜单
  const handleContextMenu = (e) => e.preventDefault()

  // 4. 禁止复制粘贴
  const handleCopy = (e) => e.preventDefault()

  // 5. 全屏模式(可选)
  const enterFullscreen = () => {
    document.documentElement.requestFullscreen?.()
  }

  onMounted(() => {
    document.addEventListener('visibilitychange', handleVisibilityChange)
    window.addEventListener('blur', handleBlur)
    document.addEventListener('contextmenu', handleContextMenu)
    document.addEventListener('copy', handleCopy)
    enterFullscreen()
  })

  onUnmounted(() => {
    document.removeEventListener('visibilitychange', handleVisibilityChange)
    window.removeEventListener('blur', handleBlur)
    document.removeEventListener('contextmenu', handleContextMenu)
    document.removeEventListener('copy', handleCopy)
  })

  return { switchCount, maxSwitch, warnings }
}

4.2 后端防作弊策略

@Service
public class ExamCheatService {

    /**
     * 记录作弊行为
     */
    public void reportCheat(Long recordId, CheatType type, String detail) {
        ExamCheatLog log = ExamCheatLog.builder()
            .recordId(recordId)
            .type(type)
            .detail(detail)
            .reportTime(LocalDateTime.now())
            .build();
        cheatLogMapper.insert(log);

        // 如果超过阈值,自动提交试卷
        ExamRecord record = examRecordMapper.selectById(recordId);
        int cheatCount = cheatLogMapper.countByRecordId(recordId);
        if (cheatCount >= record.getMaxCheatCount()) {
            forceSubmitExam(recordId, "防作弊触发自动提交");
        }
    }

    /**
     * 考试时长校验(防止修改本地时间作弊)
     */
    public void validateDuration(ExamRecord record) {
        long actualDuration = Duration.between(
            record.getStartTime(), LocalDateTime.now()
        ).getSeconds();
        long maxDuration = record.getDuration() * 60L + 60; // 允许60秒误差

        if (actualDuration > maxDuration) {
            forceSubmitExam(record.getId(), "考试超时自动提交");
        }
    }
}

五、自动计分与考试报告

5.1 自动判分逻辑

@Service
public class ExamScoreService {

    /**
     * 提交考试并自动判分
     */
    @Transactional
    public ExamResultVO submitExam(ExamSubmitDTO dto) {
        ExamRecord record = examRecordMapper.selectById(dto.getRecordId());
        List<PaperQuestion> paperQuestions = paperQuestionMapper
            .selectByPaperId(record.getPaperId());

        BigDecimal totalScore = BigDecimal.ZERO;
        BigDecimal objectiveScore = BigDecimal.ZERO; // 客观题得分
        List<QuestionResult> results = new ArrayList<>();
        boolean needManualReview = false;

        for (PaperQuestion pq : paperQuestions) {
            Question question = questionMapper.selectById(pq.getQuestionId());
            String userAnswer = dto.getAnswers().get(pq.getId());

            QuestionResult qr = new QuestionResult();
            qr.setQuestionId(question.getId());
            qr.setUserAnswer(userAnswer);

            if (question.getType() == QuestionType.ESSAY) {
                // 问答题:标记为待人工评分
                qr.setStatus(ReviewStatus.PENDING);
                needManualReview = true;
            } else {
                // 客观题:自动判分
                BigDecimal earned = autoGrade(question, userAnswer, pq.getScore());
                qr.setEarnedScore(earned);
                qr.setCorrect(earned.compareTo(BigDecimal.ZERO) > 0);
                objectiveScore = objectiveScore.add(earned);
                totalScore = totalScore.add(earned);
            }
            results.add(qr);
        }

        // 保存答题记录
        answerRecordMapper.batchInsert(results, record.getId());

        // 更新考试记录
        record.setObjectiveScore(objectiveScore);
        record.setTotalScore(totalScore);
        record.setStatus(needManualReview ? ExamStatus.PENDING_REVIEW : ExamStatus.GRADED);
        record.setSubmitTime(LocalDateTime.now());

        // 如果没有问答题,直接判定是否通过
        if (!needManualReview) {
            record.setPassed(totalScore.compareTo(record.getPassScore()) >= 0);
        }
        examRecordMapper.updateById(record);

        return buildResultVO(record, results);
    }

    /**
     * 客观题自动判分
     */
    private BigDecimal autoGrade(Question q, String userAnswer, BigDecimal fullScore) {
        if (StringUtils.isBlank(userAnswer)) return BigDecimal.ZERO;

        switch (q.getType()) {
            case SINGLE:
            case JUDGE:
                // 单选/判断:完全匹配
                return userAnswer.equals(q.getAnswer()) ? fullScore : BigDecimal.ZERO;

            case MULTI:
                // 多选:全对满分,漏选按比例得分,错选不得分
                Set<String> correctSet = new HashSet<>(q.getAnswerList());
                Set<String> userSet = new HashSet<>(Arrays.asList(userAnswer.split(",")));

                if (correctSet.equals(userSet)) {
                    return fullScore; // 全对
                } else if (correctSet.containsAll(userSet)) {
                    // 漏选:按比例得分
                    if (q.getPartialScore()) {
                        double ratio = (double) userSet.size() / correctSet.size();
                        return fullScore.multiply(BigDecimal.valueOf(ratio))
                            .setScale(2, RoundingMode.HALF_UP);
                    }
                    return BigDecimal.ZERO;
                } else {
                    return BigDecimal.ZERO; // 错选
                }

            default:
                return BigDecimal.ZERO;
        }
    }
}

5.2 考试报告生成

考试完成后,系统自动生成详细的考试报告:

@Data
@Builder
public class ExamReportVO {
    // 基本信息
    private String examTitle;
    private String userName;
    private String deptName;
    private LocalDateTime submitTime;

    // 成绩信息
    private BigDecimal totalScore;
    private BigDecimal passScore;
    private Boolean passed;
    private Integer rank;           // 排名
    private Integer totalExaminees; // 参考人数

    // 答题分析
    private Integer correctCount;   // 答对数
    private Integer wrongCount;     // 答错数
    private Integer blankCount;     // 未答数
    private BigDecimal accuracy;    // 正确率

    // 分类得分
    private List<CategoryScore> categoryScores;  // 按题库分类的得分情况

    // 错题列表
    private List<WrongQuestion> wrongQuestions;
}

5.3 错题本功能

系统自动将学员答错的题目归入"错题本",方便学员针对性复习:

@Service
public class WrongBookService {

    @Transactional
    public void addToWrongBook(Long userId, Long examRecordId) {
        // 查询本次考试答错的题目
        List<AnswerRecord> wrongAnswers = answerRecordMapper
            .selectWrongByRecordId(examRecordId);

        List<WrongBook> records = wrongAnswers.stream().map(ar ->
            WrongBook.builder()
                .userId(userId)
                .questionId(ar.getQuestionId())
                .userAnswer(ar.getUserAnswer())
                .correctAnswer(ar.getCorrectAnswer())
                .examRecordId(examRecordId)
                .reviewed(false)  // 是否已复习
                .build()
        ).collect(Collectors.toList());

        // 批量插入(已存在的不重复添加)
        wrongBookMapper.batchInsertIgnore(records);
    }
}

六、考试流程全景

完整考试流程:

管理员                              学员                          系统
  │                                  │                            │
  │  1. 创建题库、导入题目            │                            │
  │  2. 配置组卷规则、生成试卷        │                            │
  │  3. 创建考试(绑定试卷+时间+人员)  │                            │
  │─────────────────────────────────────────────────────────────▶│
  │                                  │                            │
  │                                  │  4. 进入考试页面            │
  │                                  │  5. 全屏 + 防切屏监控启动    │
  │                                  │  6. 逐题作答(自动保存)     │
  │                                  │  7. 提交试卷                │
  │                                  │───────────────────────────▶│
  │                                  │                            │ 8. 客观题自动判分
  │                                  │                            │ 9. 生成考试报告
  │                                  │                            │ 10. 错题归入错题本
  │                                  │                            │ 11. 判定是否通过
  │                                  │                            │ 12. 通过则触发证书颁发
  │                                  │◀───────────────────────────│
  │                                  │  13. 查看成绩与报告          │
  │  14. 查看考试统计(通过率/排名)    │                            │
  │◀─────────────────────────────────────────────────────────────│

在这里插入图片描述


七、总结

织码在线教育系统的考试模块通过四个层面构建了完整的严肃在线考试能力:

  1. 题库层面:多维题型设计 + 多级分类管理 + Excel/Word 批量导入,解决"出题效率"问题
  2. 组卷层面:规则抽题引擎 + 题目/选项乱序,解决"组卷效率与防抄"问题
  3. 监考层面:防切屏监控 + 全屏模式 + 禁止复制右键 + 服务端时长校验,解决"防作弊"问题
  4. 评分层面:客观题自动判分 + 漏选按比例得分 + 错题本 + 考试报告,解决"判分与反馈"问题

这套方案已经在企业合规培训、新员工入职考试、技能认证等场景中稳定运行。如果你对某个技术细节感兴趣,欢迎评论区深入交流。

如需私有化部署报价、远程产品演示,可访问官网https://www.weavecodes.com/,私信作者领取企业落地案例。

织码在线教育系统——让每一场考试都严肃、公平、高效。了解更多功能,欢迎留言交流。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值