AI项目博客
记录 AI 工程实践与大模型应用

从零搭建 AI 在线考试系统(三):考试过程控制与防作弊

这一篇实现考试进行时的全部环节。在线考试最难的不是功能本身,而是边界情况:网络断了、刷新页面、重复交卷、前端倒计时被改。下面每个方法都能直接用。

一、进入考试与断线续考

@Override

public ExamTakePaperVO start(ExamStartBO bo) {

    Long userId = requireCurrentUserId();

    Long examId = bo.getExamId();

    ExamExamEntity exam = requireTakableExam(examId);   // 已发布 + 在时间窗内 + 未结束

    requireCandidate(examId, userId);                    // 必须在参考名单中

    LocalDateTime now = LocalDateTime.now();

    // 1. 已有进行中的答卷:未超时则续考,已超时则先按超时交卷再判断能否重考

    ExamRecordEntity ongoing = findOngoingRecord(examId, userId);

    if (ongoing != null) {

        if (now.isAfter(ongoing.getDeadlineTime())) {

            forceSubmit(ongoing);

        } else {

            // 续考:直接用答卷里定格的快照重建卷面,不重新抽题

            return buildPaperVO(ongoing, parseSnapshot(ongoing.getPaperSnapshot()));

        }

    }

    // 2. 次数校验

    int attemptLimit = exam.getAttemptLimit() == null ? 1 : exam.getAttemptLimit();

    int used = countAttempts(examId, userId);

    if (used >= attemptLimit) {

        throw new PlatformException("已达本场考试的最大作答次数(" + attemptLimit + " 次)");

    }

    // 3. 生成个人组卷快照并创建答卷

    ExamPaperEntity paper = examPaperMapper.selectById(exam.getPaperId());

    if (paper == null) {

        throw new PlatformException("试卷不存在,请联系管理员");

    }

    ExamPaperSnapshot snapshot = buildSnapshot(exam, paper);

    ExamRecordEntity record = new ExamRecordEntity();

    record.setExamId(examId);

    record.setPaperId(paper.getId());

    record.setUserId(userId);

    record.setUserName(currentUserName());

    record.setAttemptNo(used + 1);

    record.setPaperSnapshot(JSONUtil.toJsonStr(snapshot));

    record.setStartTime(now);

    record.setDeadlineTime(computeDeadline(now, exam));

    record.setTotalScore(BigDecimal.ZERO);

    record.setStatus(RecordStatusEnum.IN_PROGRESS.getCode());

    save(record);

    examCandidateService.updateStatus(examId, userId, CandidateStatusEnum.IN_PROGRESS.getCode());

    return buildPaperVO(record, snapshot);

}

注意第 1 步:如果已有进行中的答卷且未超时,直接返回它,不重新抽题。因为题已经写在快照里了,重新抽会让考生发现「刷新一下题目变了」。

1.1 截止时间的计算

/** 个人截止时间 = min(进入时间 + 考试时长, 考试结束时间) */

private LocalDateTime computeDeadline(LocalDateTime start, ExamExamEntity exam) {

    LocalDateTime byDuration = start.plusMinutes(

            exam.getDurationMin() == null ? 60 : exam.getDurationMin());

    LocalDateTime byExamEnd = exam.getEndTime();

    if (byExamEnd == null) {

        return byDuration;

    }

    return byDuration.isBefore(byExamEnd) ? byDuration : byExamEnd;

}

这个 min 是必须的。假设一场考试 60 分钟,但场次设置 11:00 结束,考生 10:50 才进来——他能考 60 分钟吗?不能,只能考 10 分钟。不做这个约束,他会跨过考试结束时间继续作答,而其他人已经交卷了。

考试场次列表

图:考试场次,时间窗、参考/交卷、状态与时间态。

二、个人组卷快照

这是整个系统里最重要的数据结构。进入考试时把题目顺序、分值、选项顺序全部定格:

private ExamPaperSnapshot buildSnapshot(ExamExamEntity exam, ExamPaperEntity paper) {

    List<ExamPaperQuestionVO> rows = examPaperQuestionMapper.selectPaperQuestions(paper.getId());

    if (CollectionUtils.isEmpty(rows)) {

        throw new PlatformException("试卷【" + paper.getPaperName() + "】没有题目,无法开始考试");

    }

    List<ExamSnapshotQuestion> questions = new ArrayList<>(rows.size());

    for (ExamPaperQuestionVO row : rows) {

        ExamSnapshotQuestion question = new ExamSnapshotQuestion();

        question.setQuestionId(row.getQuestionId());

        question.setQuestionType(row.getQuestionType());

        question.setStem(row.getStem());

        // selectPaperQuestions 只回填 optionsJson(原始 JSON 串),

        // options 列表必须在此显式反序列化 —— 否则快照里选项为空,考生端渲染出空白的选项组

        question.setOptions(parseOptions(row.getOptionsJson()));

        question.setAnswer(row.getAnswer());

        question.setAnalysis(row.getAnalysis());

        question.setScore(row.getScore());

        question.setDifficulty(row.getDifficulty());

        questions.add(question);

    }

    return assembleSnapshot(exam, paper, questions);

}

/** 组装快照:应用乱序开关、统一编号、写入生效及格分 */

private ExamPaperSnapshot assembleSnapshot(ExamExamEntity exam, ExamPaperEntity paper,

                                           List<ExamSnapshotQuestion> questions) {

    // 乱序开关属于「考试」而非「试卷」:同一份试卷在不同考试里可采用不同策略

    if (isFlagOn(exam.getShuffleQuestion())) {

        Collections.shuffle(questions, ThreadLocalRandom.current());

    }

    if (isFlagOn(exam.getShuffleOption())) {

        questions.forEach(question -> shuffleOptions(question, ThreadLocalRandom.current()));

    }

    int sortNo = 1;

    for (ExamSnapshotQuestion question : questions) {

        question.setSortNo(sortNo++);

    }

    ExamPaperSnapshot snapshot = new ExamPaperSnapshot();

    snapshot.setPaperId(paper.getId());

    snapshot.setPaperName(paper.getPaperName());

    snapshot.setTotalScore(paper.getTotalScore());

    // 考试未单独指定及格分时回落到试卷及格分;定格后判分不再回查试卷

    snapshot.setPassScore(exam.getPassScore() != null ? exam.getPassScore() : paper.getPassScore());

    // 判分策略同样随快照定格,保证与考生作答时的考试配置一致

    snapshot.setMultiplePartialScore(isFlagOn(exam.getMultiplePartialScore()) ? 1 : 0);

    snapshot.setBlankPartialScore(isFlagOn(exam.getBlankPartialScore()) ? 1 : 0);

    snapshot.setQuestionCount(questions.size());

    snapshot.setQuestions(questions);

    return snapshot;

}

为什么要定格?因为题库和试卷会被修改——题干可以改、分值可以调。如果不定格,一个月后回头看历史成绩会出争议:判分用的是新题目,考生答的是旧题目。快照让历史成绩成为不可变的事实。

2.1 选项乱序:必须联动重映射答案

/**

 * 选项乱序:打乱后按新顺序重新编号 A/B/C…,并把标准答案的选项 key 一并重映射,

 * 否则答案会与实际选项错位,判分必然出错。

 */

private void shuffleOptions(ExamSnapshotQuestion question, Random random) {

    List<ExamQuestionOptionBO> origin = question.getOptions();

    if (CollectionUtils.isEmpty(origin)) {

        return;

    }

    List<ExamQuestionOptionBO> shuffled = new ArrayList<>(origin);

    Collections.shuffle(shuffled, random);

    // 第一步:建立「旧 key → 新 key」映射,同时按新顺序重新编号

    Map<String, String> oldKeyToNewKey = new HashMap<>();

    List<ExamQuestionOptionBO> renumbered = new ArrayList<>(shuffled.size());

    for (int i = 0; i < shuffled.size(); i++) {

        String newKey = String.valueOf((char) (OPTION_KEY_START + i));   // 'A' + i

        oldKeyToNewKey.put(shuffled.get(i).getKey(), newKey);

        ExamQuestionOptionBO option = new ExamQuestionOptionBO();

        option.setKey(newKey);

        option.setContent(shuffled.get(i).getContent());

        renumbered.add(option);

    }

    question.setOptions(renumbered);

    if (!StringUtils.hasText(question.getAnswer())) {

        // 题库脏数据(无标准答案)时只重编号,不做映射,避免 NPE

        return;

    }

    // 第二步:把答案的每个 key 映射到新编号

    QuestionTypeEnum type = QuestionTypeEnum.fromCode(question.getQuestionType());

    if (type == QuestionTypeEnum.SINGLE) {

        String mapped = oldKeyToNewKey.get(question.getAnswer());

        if (mapped != null) {

            question.setAnswer(mapped);

        }

    } else if (type == QuestionTypeEnum.MULTIPLE) {

        List<String> mappedKeys = new ArrayList<>();

        for (String key : question.getAnswer().split(",", -1)) {

            if (StringUtils.hasText(key)) {

                String mapped = oldKeyToNewKey.get(key.trim().toUpperCase());

                if (mapped != null) {

                    mappedKeys.add(mapped);

                }

            }

        }

        // 重编号后字母顺序变了,答案需重新升序拼接,才与判分口径一致

        mappedKeys.sort(Comparator.naturalOrder());

        question.setAnswer(String.join(",", mappedKeys));

    }

}

这是全文最容易写错的地方。只重编号、不重映射答案,就会这样:原题答案 B,选项乱序后 B 指向了完全不同的内容。考生选对了,判分却按 B 去比对,判错。

而且这个 bug 很隐蔽——如果测试时恰好只有单选、且答案都是 A,可能完全测不出来。

三、答题草稿与断线续考

@Override

public void saveDraft(ExamDraftBO bo) {

    ExamRecordEntity record = requireOwnRecord(bo.getRecordId());

    requireInProgress(record);

    if (record.getDeadlineTime() != null && LocalDateTime.now().isAfter(record.getDeadlineTime())) {

        throw new PlatformException("作答时间已结束,请立即交卷");

    }

    examDraftStore.saveDraft(record.getId(), bo.getQuestionId(), bo.getAnswer());

}

草稿存在 Redis 里,不落 MySQL:

@Component

public class ExamDraftStore {

    private static final String KEY_PREFIX = "exam:draft:";

    /** 24 小时:远大于单场考试时长,又不会永久占用内存 */

    private static final Duration TTL = Duration.ofHours(24);

    private final StringRedisTemplate redis;

    public void saveDraft(Long recordId, Long questionId, String answer) {

        String key = KEY_PREFIX + recordId;

        // 用 Hash:一题一个 field,避免整卷 JSON 反复序列化/反序列化

        redis.opsForHash().put(key, String.valueOf(questionId),

                answer == null ? "" : answer);

        redis.expire(key, TTL);

    }

    public Map<Long, String> loadDrafts(Long recordId) {

        Map<Object, Object> raw = redis.opsForHash().entries(KEY_PREFIX + recordId);

        Map<Long, String> result = new HashMap<>(raw.size());

        raw.forEach((k, v) -> result.put(Long.valueOf(k.toString()),

                v == null ? null : v.toString()));

        return result;

    }

    public void clear(Long recordId) {

        redis.delete(KEY_PREFIX + recordId);

    }

}

一个必须写进文档的限制:草稿只落 Redis,没有同步落 MySQL。

这意味着如果 Redis 在考试期间丢数据,那些尚未交卷的作答会丢失(已交卷的不受影响,它们在 exam_answer 里)。

这是明确的取舍:用高频写入不压 MySQL,换取 Redis 故障时的数据风险。每题都写一次 MySQL 会在考试期间产生大量写入,而且大部分随后会被交卷时的正式落库覆盖。如果要更强的耐久性,可以加「定期落库 + 交卷合并」的双写机制。但这个取舍必须写进文档,不能让后来者误以为草稿是持久化的。

示意图

四、交卷:锁与事务的顺序

@Override

public ExamSubmitResultVO submit(ExamSubmitBO bo) {

    Long userId = requireCurrentUserId();

    String lockKey = SUBMIT_LOCK_PREFIX + bo.getRecordId();

    String token = redisLockHelper.tryLock(lockKey, SUBMIT_LOCK_TTL_MILLIS);

    if (token == null) {

        throw new PlatformException("答卷正在提交中,请勿重复操作");

    }

    try {

        // 关键:事务在锁内提交,避免「锁已释放但事务未提交」导致重复交卷

        return transactionTemplate.execute(status -> {

            ExamRecordEntity record = requireOwnRecord(bo.getRecordId());

            if (!userId.equals(record.getUserId())) {

                throw new PlatformException("无权操作他人答卷");

            }

            requireInProgress(record);

            return doSubmit(record, false);

        });

    } finally {

        redisLockHelper.unlock(lockKey, token);

    }

}

这段代码的写法是有讲究的。直觉做法是给方法加 @Transactional,然后在里面加锁——但那样是错的:

// ❌ 错误写法

@Transactional

public void submit(...) {

    lock.lock();

    try {

        // 业务逻辑

    } finally {

        lock.unlock();     // ← 事务还没提交!

    }

}

Spring 的 @Transactional 是在方法返回之后才提交事务的,而 finally 里的解锁已经先执行了。于是第二个请求能拿到锁,读到的是尚未提交的状态——重复交卷。

正确做法就是上面那样:不用方法级事务,改用 TransactionTemplate 在锁内部提交。

4.1 交卷主体

private ExamSubmitResultVO doSubmit(ExamRecordEntity record, boolean forced) {

    if (!RecordStatusEnum.IN_PROGRESS.getCode().equals(record.getStatus())) {

        throw new PlatformException("答卷已提交,请勿重复交卷");

    }

    LocalDateTime now = LocalDateTime.now();

    // 服务端权威判定超时:不信任前端倒计时

    boolean timeout = forced

            || (record.getDeadlineTime() != null && now.isAfter(record.getDeadlineTime()));

    ExamPaperSnapshot snapshot = parseSnapshot(record.getPaperSnapshot());

    Map<Long, String> answers = examDraftStore.loadDrafts(record.getId());

    ExamGradingResult grading = examAutoGrader.grade(snapshot, answers);

    // 先清旧明细:强制交卷与手动交卷可能并发到同一答卷,保证幂等

    examAnswerService.remove(new LambdaQueryWrapper<ExamAnswerEntity>()

            .eq(ExamAnswerEntity::getRecordId, record.getId()));

    examAnswerService.saveBatch(buildAnswers(record.getId(), grading.getDetails()));

    BigDecimal passScore = snapshot.getPassScore();

    boolean passed = passScore != null && grading.getTotalScore().compareTo(passScore) >= 0;

    boolean manualPending = grading.getManualCount() > 0;

    record.setSubmitTime(now);

    record.setTotalScore(grading.getTotalScore());

    record.setPassFlag(passed ? 1 : 0);

    record.setStatus(timeout ? RecordStatusEnum.TIMEOUT_SUBMITTED.getCode()

            : RecordStatusEnum.SUBMITTED.getCode());

    // 含主观题时本次总分只是「客观题得分」,须标记待阅卷

    record.setManualPending(manualPending ? 1 : 0);

    updateById(record);

    examCandidateService.updateStatus(record.getExamId(), record.getUserId(),

            CandidateStatusEnum.SUBMITTED.getCode());

    examDraftStore.clear(record.getId());

    // ... 组装 ExamSubmitResultVO 返回

}

注意 timeout 由服务端判定,forced 参数表示”强制交卷”(超时触发或监考强制),手动点交卷时传 false——只有在确实过了截止时间的情况下才会被标记为超时。

4.2 超时兜底任务

@Scheduled(cron = "0 * * * * ?")   // 每分钟

@SchedulerLock(name = "examAutoSubmitExpired", lockAtLeastFor = "50s", lockAtMostFor = "5m")

public void autoSubmitExpired() {

    // 扫描「进行中且已过截止时间」的答卷,逐个走交卷锁结算

    List<ExamRecordEntity> expired = list(new LambdaQueryWrapper<ExamRecordEntity>()

            .eq(ExamRecordEntity::getStatus, RecordStatusEnum.IN_PROGRESS.getCode())

            .lt(ExamRecordEntity::getDeadlineTime, LocalDateTime.now()));

    for (ExamRecordEntity record : expired) {

        submitWithLock(record, true);

    }

}

用 ShedLock 保证多实例只有一个在跑。它和手动交卷抢的是同一把锁——抢不到就留到下一轮,不会出现两条链路同时结算一份卷子。

五、轻量分布式锁

需要分布式锁,第一反应是上 Redisson。但这里没有,而是自己写了个 30 行的实现:

/**

 * 轻量分布式锁(SET NX PX + Lua 校验删除)。

 *

 * <p><b>为什么不用 Redisson</b>:redisson-spring-boot-starter 会注册

 * RedissonConnectionFactory 并优先于 Lettuce 生效,从而替换掉 sa-token

 * 会话存储所用的 Redis 连接。权限链路没有测试兜底,不值得为一把锁去动它。</p>

 */

@Component

@Slf4j

public class RedisLockHelper {

    /** 释放锁的 Lua 脚本:仅当锁的值仍是自己的 token 时才删除,避免误删他人持有的锁 */

    private static final DefaultRedisScript<Long> UNLOCK_SCRIPT = new DefaultRedisScript<>(

            "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end",

            Long.class);

    private final StringRedisTemplate stringRedisTemplate;

    public RedisLockHelper(StringRedisTemplate stringRedisTemplate) {

        this.stringRedisTemplate = stringRedisTemplate;

    }

    /** 尝试获取锁(不阻塞、不重试) */

    public String tryLock(String key, long ttlMillis) {

        String token = UUID.randomUUID().toString();

        Boolean acquired = stringRedisTemplate.opsForValue()

                .setIfAbsent(key, token, Duration.ofMillis(ttlMillis));

        return Boolean.TRUE.equals(acquired) ? token : null;

    }

    /** 释放锁(仅释放自己持有的锁) */

    public void unlock(String key, String token) {

        if (token == null) {

            return;

        }

        try {

            stringRedisTemplate.execute(UNLOCK_SCRIPT, Collections.singletonList(key), token);

        } catch (Exception ex) {

            // 释放失败不影响业务结果:锁会随 TTL 自然过期

            log.warn("释放分布式锁失败,key={},将由 TTL 兜底", key, ex);

        }

    }

}

能力边界必须知悉:没有 Redisson 的看门狗续期,锁有固定 TTL。一旦临界区超时,锁会自动释放(可能被第二个请求拿到)。这是「可用性优先」的取舍。交卷临界区只做「合并草稿 + 判分 + 落库」,毫秒级,10 秒 TTL 足够。

用十秒 TTL 换零新增依赖和零风险,这笔账是划算的。

六、防作弊

@Override

public ExamSwitchReportResultVO reportSwitch(ExamSwitchReportBO bo) {

    ExamRecordEntity record = requireOwnRecord(bo.getRecordId());

    requireInProgress(record);

    ExamExamEntity exam = examExamMapper.selectById(record.getExamId());

    // 未开启防作弊:不计数(前端本不应上报,这里兜底并回报当前状态)

    if (exam == null || !isFlagOn(exam.getAntiCheatEnabled())) {

        return buildSwitchResult(record.getSwitchCount(), exam, false);

    }

    // 原子自增,避免并发上报(visibilitychange + blur 可能同时触发)互相覆盖

    update(new LambdaUpdateWrapper<ExamRecordEntity>()

            .eq(ExamRecordEntity::getId, record.getId())

            .setSql("switch_count = switch_count + 1"));

    ExamRecordEntity latest = getById(record.getId());

    int switchCount = latest == null || latest.getSwitchCount() == null ? 1 : latest.getSwitchCount();

    int limit = exam.getSwitchLimit() == null ? 0 : exam.getSwitchLimit();

    boolean exceeded = limit > 0 && switchCount > limit;

    boolean submitted = false;

    if (exceeded) {

        submitted = forceSubmitImmediately(latest == null ? record : latest);

    }

    ExamSwitchReportResultVO vo = buildSwitchResult(switchCount, exam, exceeded);

    vo.setSubmitted(submitted);

    return vo;

}

服务端用 setSql("switch_count = switch_count + 1") 做原子自增,而不是「读出来 +1 再写回」——后者在并发上报时会互相覆盖。

6.1 强制交卷不按「超时」记账

/**

 * 立即强制交卷:复用交卷锁与判分链路,但<b>不按超时处理</b>。

 *

 * <p>切屏超限是「作弊超限」,监考收卷是「人为干预」,都不该伪装成超时;

 * 只有确实过了截止时间才记超时交卷。</p>

 */

private boolean forceSubmitImmediately(ExamRecordEntity record) {

    try {

        submitWithLock(record, false);   // 第二个参数 = false,不记超时

        return true;

    } catch (Exception ex) {

        // 考生/定时任务正在交卷时抢不到锁:另一条链路会把它结掉,视为已处理,

        // 不再向前端报错(前端会跳结果页,以库中真实状态为准)

        log.warn("切屏超限强制交卷未抢到锁,recordId={}", record.getId(), ex);

        return true;

    }

}

为什么区分这个?如果混在一起,管理端看到一堆「超时交卷」,会误以为考试时长设置有问题,而实际上是有考生在作弊。异常类型的语义不能混用。

6.2 前端要做的两件事

考生端在防作弊开启时挂监听,其中上报做了 1.2 秒去抖:

// 切屏检测:visibilitychange 与 blur 常成对触发,不去抖会把一次切屏记成两次

let switchTimer = null

function reportSwitch() {

  if (switchTimer) return          // 去抖窗口内重复触发直接忽略

  switchTimer = setTimeout(() => { switchTimer = null }, 1200)

  api.post('/exam/take/switchReport', { recordId })

}

document.addEventListener('visibilitychange', () => {

  if (document.hidden) reportSwitch()

})

window.addEventListener('blur', reportSwitch)

// 禁止复制/剪切/右键

;['copy', 'cut', 'contextmenu'].forEach(evt =>

  document.addEventListener(evt, e => e.preventDefault())

)

去抖是必须的。考生切了 3 次却被判定超限强制交卷——这种误判的杀伤力比漏判大得多。同一次切屏被记成两次,等于把限制悄悄收紧了一半。

监考大屏

图:监考大屏,参考人数、在线/交卷、通过率与异常告警。

七、小结

这一篇的每一条几乎都是边界情况,归纳成四句:

  1. 服务端是唯一权威——时间、状态、判分都不信任前端
  2. 锁必须在事务外、包住事务——@Transactional 配 finally 解锁是个沉默的 bug
  3. 选项乱序必须联动重映射答案——否则会出现”选对了判错”,且极难测试发现
  4. 误判比漏判更可怕——切屏去抖、异常类型不混用,都是为了不冤枉考生

下一篇讲判分与阅卷:完整的判分器实现、人工阅卷、答案可见性策略。

赞(0)
未经允许不得转载:AI项目博客 » 从零搭建 AI 在线考试系统(三):考试过程控制与防作弊

评论 抢沙发

AI项目博客

专注 AI 工程化落地 —— 覆盖智能体、大模型、Java AI 与 Python 的学习笔记,以及 AI 平台的项目实战记录。

关于本站文章归档

登录

找回密码

注册