这一篇实现考试进行时的全部环节。在线考试最难的不是功能本身,而是边界情况:网络断了、刷新页面、重复交卷、前端倒计时被改。下面每个方法都能直接用。
一、进入考试与断线续考
@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 次却被判定超限强制交卷——这种误判的杀伤力比漏判大得多。同一次切屏被记成两次,等于把限制悄悄收紧了一半。

图:监考大屏,参考人数、在线/交卷、通过率与异常告警。
七、小结
这一篇的每一条几乎都是边界情况,归纳成四句:
- 服务端是唯一权威——时间、状态、判分都不信任前端
- 锁必须在事务外、包住事务——
@Transactional配finally解锁是个沉默的 bug - 选项乱序必须联动重映射答案——否则会出现”选对了判错”,且极难测试发现
- 误判比漏判更可怕——切屏去抖、异常类型不混用,都是为了不冤枉考生
下一篇讲判分与阅卷:完整的判分器实现、人工阅卷、答案可见性策略。

AI项目博客






