
简介这是一套面向计算机相关专业毕设学生与Java实战学习者的JavaWeb在线考试系统基于Java EE技术栈采用JSP、Tomcat与MySQL构建可直接作为毕业设计或课程设计参考。系统区分普通用户与管理员两类角色普通用户可在线考试、查询成绩、修改密码管理员负责考生信息、考试成绩、试卷与题目的管理功能覆盖考试业务的主要环节。资源包共148个文件约13.69MB包含23个java源文件、23个class编译文件、21个jsp页面、27个jar依赖包以及js、css、xml、properties等配置与静态资源并附数据库sql脚本源码结构完整、层次清晰。项目经严格调试在Eclipse中可正常运行已有1384人学习下载。读者可借此理解Action、Dao、实体类之间的分层调用关系掌握试卷与题目管理、在线答题与成绩统计的实现思路并在此基础上进行二次开发或功能扩展。1. JavaWeb在线考试系统从组卷到自动判分的完整落地路径做过在线考试系统的人都知道这类项目最怕的不是功能多而是并发交卷那一刻的数据一致性。我见过太多学生或者初级开发照着教程搭出来的系统平时跑得好好的一到考试截止前五分钟几十个人同时点交卷主观题答案丢了、客观题分数算重了、试卷状态卡在考试中改不过来。JavaWeb在线考试系统这个题目看起来像是课程设计级别的练手项目但真正要把它做到能上线、能扛住一个班级甚至一个年级的并发里面涉及的知识点从 Servlet 生命周期、Session 管理、事务隔离级别到前端倒计时与后端时间校验的配合一个都绕不开。这篇文章面向的是已经学过 JavaWeb 基础、想拿一个完整案例把知识点串起来的开发者也适合需要交付课程设计但不想只做能跑就行版本的同学。我会按数据库怎么设计 → 后端接口怎么写 → 前端怎么配合 → 部署怎么避坑的顺序把一套可复现的方案讲清楚。你跟着走完能拿到一个支持单选、多选、判断、简答四种题型具备自动判分、防刷新丢失答案、交卷幂等处理的在线考试系统。技术栈用最经典的 Servlet JSP MySQL不引入 Spring 全家桶目的是让你看清 JavaWeb 的底层脉络后面换框架时心里有底。2. 数据库表结构与组卷逻辑先想清楚再动手2.1 五张核心表怎么划分职责在线考试系统的数据模型核心是把题库和试卷解耦。题库是长期积累的资产试卷是某次考试的快照。我一般会建这五张表user用户、question题库、paper试卷、paper_question试卷题目关联、exam_record考试记录。其中paper_question是关键它决定了组卷的灵活性——同一道题可以出现在不同试卷里且每张试卷可以单独设置该题的分值。-- 题库表存储所有题目与具体试卷无关 CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, type TINYINT NOT NULL COMMENT 1单选 2多选 3判断 4简答, content TEXT NOT NULL COMMENT 题干, options JSON COMMENT 选项单选多选使用格式[{key:A,text:...}], answer VARCHAR(255) NOT NULL COMMENT 正确答案多选用逗号分隔如A,B, score INT NOT NULL DEFAULT 5 COMMENT 默认分值, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 试卷题目关联表决定某张试卷考哪些题、每题多少分 CREATE TABLE paper_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, paper_id BIGINT NOT NULL, question_id BIGINT NOT NULL, score INT NOT NULL COMMENT 该题在本试卷中的分值, sort_no INT NOT NULL DEFAULT 0 COMMENT 题目顺序, UNIQUE KEY uk_paper_question (paper_id, question_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有个设计取舍值得说options字段我用了 JSON 类型而不是拆成单独的选项表。原因是选项本身没有独立查询需求拆表只会增加 join 成本。MySQL 5.7 以后 JSON 类型支持完善读写都方便。如果你的 MySQL 版本低于 5.7退化成 TEXT 存 JSON 字符串也能用只是失去了数据库层面的格式校验。paper_question上的唯一索引uk_paper_question是防止同一道题被重复添加到同一张试卷这个约束在组卷接口里能省掉一次查询判断。sort_no用来控制题目展示顺序组卷时按题型分组打乱保证每次生成的试卷题目顺序不同。2.2 组卷接口的实现与随机抽题组卷分两种模式固定组卷老师手动选题和随机组卷按题型和数量自动抽。随机组卷的 SQL 写法有讲究很多人用ORDER BY RAND() LIMIT n数据量小的时候没问题题库上万条时性能会明显下降因为 RAND() 会对全表每一行计算一次。// 随机组卷按题型分别抽取指定数量的题目 public ListQuestion randomPick(int type, int count) { // 先查该题型总数用随机偏移量代替 ORDER BY RAND() String countSql SELECT COUNT(*) FROM question WHERE type ?; int total jdbcTemplate.queryForObject(countSql, Integer.class, type); if (total count) { throw new BizException(题型 type 题量不足当前 total 道); } // 生成不重复的随机偏移集合 SetInteger offsets new TreeSet(); Random random new Random(); while (offsets.size() count) { offsets.add(random.nextInt(total)); } // 用 IN 加偏移量查询MySQL 中 LIMIT offset, 1 逐条取 ListQuestion result new ArrayList(); String sql SELECT * FROM question WHERE type ? LIMIT ?, 1; for (int offset : offsets) { result.add(jdbcTemplate.queryForObject(sql, new QuestionRowMapper(), type, offset)); } return result; }这段代码的核心思路是先数总数再随机偏移。LIMIT ?, 1里的偏移量是随机生成的避免了全表排序。参数count由前端传入但后端必须校验它不超过题库实际数量否则会抛异常。TreeSet保证偏移量不重复防止同一道题被抽中两次。这个方案在题库十万级以内表现稳定再往上就该考虑预生成试卷或者用更专业的抽题算法了。组卷完成后把选中的题目批量插入paper_question同时计算试卷总分写入paper表。这一步要放在同一个事务里否则可能出现试卷创建了但题目没关联上的脏数据。3. 考试过程的后端接口设计Session、倒计时与答案暂存3.1 开始考试接口与 Session 状态管理学生点击开始考试时后端要做三件事校验是否已有未完成的考试记录、创建新的考试记录、把试卷题目返回给前端。这里的关键是防止重复开考——同一个学生对同一张试卷如果已经有一条状态为进行中的记录应该直接返回那条记录而不是新建。// 开始考试幂等处理避免重复创建考试记录 public ExamStartVO startExam(Long userId, Long paperId) { // 1. 查询是否已有进行中的记录 String querySql SELECT * FROM exam_record WHERE user_id ? AND paper_id ? AND status 0; ListExamRecord existing jdbcTemplate.query(querySql, new ExamRecordRowMapper(), userId, paperId); if (!existing.isEmpty()) { // 已有进行中记录直接返回剩余时间和题目 return buildStartVO(existing.get(0)); } // 2. 校验试卷是否存在且已发布 Paper paper paperDao.findById(paperId); if (paper null || paper.getStatus() ! 1) { throw new BizException(试卷不存在或未发布); } // 3. 创建考试记录deadline 由服务端计算不信任前端 ExamRecord record new ExamRecord(); record.setUserId(userId); record.setPaperId(paperId); record.setStartTime(new Date()); record.setDeadline(new Date(System.currentTimeMillis() paper.getDuration() * 60_000L)); record.setStatus(0); examRecordDao.insert(record); return buildStartVO(record); }deadline字段是服务端根据试卷时长算出来的绝对时间戳前端倒计时只是展示真正的交卷时间校验以服务端为准。这一点非常重要我见过有系统把剩余时间存在前端学生改一下本地时间就能无限延长考试。status字段用 0 表示进行中、1 表示已交卷、2 表示超时自动交卷状态机简单但够用。3.2 答案暂存与防刷新丢失考试过程中学生每答一道题前端应该异步提交到服务端暂存。这样即使刷新页面或者浏览器崩溃重新进入时答案还在。暂存接口的设计要点是按题粒度更新而不是整卷覆盖避免并发写冲突。// 暂存单题答案存在则更新不存在则插入 public void saveAnswer(Long recordId, Long questionId, String answer) { // 校验考试记录状态和是否超时 ExamRecord record examRecordDao.findById(recordId); if (record null || record.getStatus() ! 0) { throw new BizException(考试已结束无法保存答案); } if (record.getDeadline().before(new Date())) { throw new BizException(考试已超时); } // UPSERT利用唯一索引实现存在即更新 String sql INSERT INTO answer_temp (record_id, question_id, answer, update_time) VALUES (?, ?, ?, NOW()) ON DUPLICATE KEY UPDATE answer ?, update_time NOW(); jdbcTemplate.update(sql, recordId, questionId, answer, answer); }answer_temp表需要在(record_id, question_id)上建唯一索引ON DUPLICATE KEY UPDATE才能生效。这个写法比先查后判断再更新少一次数据库往返在高频暂存场景下优势明显。参数answer直接存字符串多选存A,B格式简答存原始文本判分时再按题型解析。前端调用这个接口的时机我一般建议在onchange事件后加 500ms 防抖避免学生每敲一个字就发一次请求。同时每 30 秒做一次全量兜底提交防止某个题目的暂存请求丢失。3.3 交卷接口的幂等与事务边界交卷是整个系统最容易出问题的地方。学生可能因为网络卡顿连点多次交卷按钮也可能在超时边缘反复提交。交卷接口必须做到幂等同一条考试记录无论调用多少次结果一致且只判分一次。Transactional(rollbackFor Exception.class) public ExamResultVO submitExam(Long recordId) { // 1. 行锁锁定考试记录防止并发交卷 String lockSql SELECT * FROM exam_record WHERE id ? FOR UPDATE; ExamRecord record jdbcTemplate.queryForObject(lockSql, new ExamRecordRowMapper(), recordId); if (record.getStatus() ! 0) { // 已交卷直接返回已有成绩保证幂等 return buildResultVO(record); } // 2. 拉取暂存答案和试卷题目 ListAnswerTemp answers answerTempDao.findByRecordId(recordId); ListPaperQuestion questions paperQuestionDao.findByPaperId(record.getPaperId()); // 3. 逐题判分 int totalScore 0; for (PaperQuestion pq : questions) { String studentAnswer findAnswer(answers, pq.getQuestionId()); Question question questionDao.findById(pq.getQuestionId()); if (isCorrect(question, studentAnswer)) { totalScore pq.getScore(); } } // 4. 更新记录状态和分数 record.setStatus(1); record.setScore(totalScore); record.setSubmitTime(new Date()); examRecordDao.update(record); return buildResultVO(record); }SELECT ... FOR UPDATE是这里的关键它在事务内对考试记录行加排他锁第二个并发请求会阻塞直到第一个事务提交然后读到status ! 0直接返回已有成绩。Transactional保证判分和状态更新在同一个事务里任何一步失败都回滚。判分逻辑里isCorrect方法按题型区分单选判断直接字符串比较多选需要把答案排序后比较简答目前只能人工阅卷可以先标记为待批改。4. 前端交互与倒计时同步别让浏览器时间骗了你4.1 倒计时的正确实现方式前端倒计时不能基于new Date()本地时间计算因为学生可以改系统时间。正确做法是开始考试时后端返回deadline时间戳和serverTime时间戳前端算出两者差值作为基准偏移之后每秒用serverTime 已过秒数来推算剩余时间。// 基于服务端时间的倒计时避免本地时间篡改 let deadline, offset; function initTimer(serverDeadline, serverTime) { deadline serverDeadline; // 计算服务端时间与本地时间的偏移量 offset serverTime - Date.now(); } function tick() { // 用偏移量校正本地时间得到接近服务端的当前时间 const now Date.now() offset; const remain Math.max(0, deadline - now); const minutes Math.floor(remain / 60000); const seconds Math.floor((remain % 60000) / 1000); document.getElementById(timer).textContent ${String(minutes).padStart(2, 0)}:${String(seconds).padStart(2, 0)}; if (remain 0) { clearInterval(timerId); autoSubmit(); // 时间到自动交卷 } } const timerId setInterval(tick, 1000);offset是服务端时间和本地时间的差值每次 tick 都用它校正。这样即使学生把本地时间调快或调慢倒计时依然准确。autoSubmit在剩余时间为零时触发调用交卷接口。注意setInterval在浏览器标签页切到后台时可能被节流所以每次 tick 都要重新计算剩余时间而不是简单减一。4.2 答案暂存的防抖与批量提交前端暂存答案要处理两个问题一是频繁请求二是页面关闭时的最后提交。防抖解决第一个问题beforeunload事件配合同步请求解决第二个。// 答案变更时防抖暂存页面关闭时同步提交 const pending {}; // 待提交的答案缓存 let debounceTimer null; function onAnswerChange(questionId, answer) { pending[questionId] answer; clearTimeout(debounceTimer); debounceTimer setTimeout(flushAnswers, 500); } function flushAnswers() { const batch { ...pending }; // 清空缓存避免重复提交 Object.keys(pending).forEach(k delete pending[k]); fetch(/exam/saveAnswerBatch, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ answers: batch }) }); } // 页面关闭前用 sendBeacon 保证请求发出 window.addEventListener(beforeunload, () { if (Object.keys(pending).length 0) { navigator.sendBeacon(/exam/saveAnswerBatch, new Blob([JSON.stringify({ answers: pending })], { type: application/json })); } });sendBeacon是浏览器提供的专门用于页面卸载时发送数据的 API它不阻塞页面关闭比在beforeunload里发同步 XHR 体验好得多。后端对应的批量暂存接口接收一个 map循环调用单题暂存逻辑即可。注意sendBeacon发送的是 POST 请求Content-Type 需要后端支持解析。5. 部署与并发避坑那些让我熬夜的翻车现场5.1 交卷时答案丢失事务隔离级别没设对现象学生交卷后部分已暂存的答案在判分时查不到导致分数偏低。原因暂存答案和交卷判分是两个独立事务如果数据库隔离级别是 READ COMMITTED交卷事务开始时可能读不到暂存事务尚未提交的数据。更隐蔽的情况是暂存接口用了异步线程池请求返回了但事务还没提交。解决把暂存接口的事务传播行为设为 REQUIRED确保同步提交交卷接口在FOR UPDATE锁定记录后再查暂存答案此时暂存事务要么已提交要么已回滚不会读到中间状态。如果用了连接池检查autoCommit配置确保没有被意外打开。5.2 倒计时归零但没自动交卷setInterval 被浏览器节流现象学生切到其他标签页再切回来发现倒计时停在某个数字不动了时间到了也没自动交卷。原因浏览器为了省电会把后台标签页的setInterval最小间隔拉长到 1 秒甚至更久极端情况下会暂停。如果倒计时逻辑依赖每次 tick 减一就会累积误差。解决每次 tick 都基于deadline - (Date.now() offset)重新计算剩余时间而不是做减法。同时在visibilitychange事件里监听页面重新可见时立即执行一次 tick把倒计时拉回正确状态。5.3 多选判分错误答案顺序和空格没处理现象学生选了 B 和 A正确答案是 A 和 B系统判错。原因多选答案直接做了字符串相等比较没有排序和去空格。解决判分前把学生答案和标准答案都按逗号分割、去空格、排序再拼接比较。代码里加一个normalizeAnswer方法统一处理。private String normalizeAnswer(String answer) { if (answer null) return ; String[] parts answer.split(,); Arrays.sort(parts); return String.join(,, parts).trim().toUpperCase(); }5.4 超时交卷时间不准服务端时间被前端覆盖现象学生反映明明还有时间系统却提示超时。原因交卷接口里用了前端传来的submitTime判断是否超时而不是服务端当前时间。解决所有时间判断一律用new Date()服务端时间前端传的时间只做展示参考。deadline在开始考试时由服务端写入数据库交卷时从数据库读取比较不信任任何前端时间参数。5.5 试卷题目顺序每次刷新都变随机种子没固定现象学生刷新页面后题目顺序变了导致答题卡对应不上。原因组卷时用了随机排序但每次查询试卷题目都重新随机。解决组卷时把sort_no固定写入paper_question表查询时按sort_no排序。随机只发生在组卷那一刻之后顺序就固定了。如果需要每个学生顺序不同可以在考试记录里存一个随机种子查询时用种子做稳定排序。6. 判分策略的进阶玩法从自动判分到人工复核的平滑过渡自动判分能覆盖单选、多选、判断三种客观题但简答题必须人工介入。一个实用的做法是交卷时先自动判完客观题把简答题标记为待批改考试记录状态设为部分批改。老师在后台看到待批改列表逐份打分后系统自动汇总总分并更新状态为已完成。这样学生能尽快看到客观题分数老师也不用一次性批改所有试卷。实现上exam_record表加一个objective_score字段存客观题得分subjective_score存主观题得分score是两者之和。交卷时只算客观题status设为 3待批改。老师批改接口接收recordId和每道简答题的得分更新subjective_score后把status改为 1。-- 批改简答题更新主观题得分并汇总 UPDATE exam_record SET subjective_score ( SELECT SUM(score) FROM answer_detail WHERE record_id ? AND question_type 4 ), score objective_score ( SELECT SUM(score) FROM answer_detail WHERE record_id ? AND question_type 4 ), status 1 WHERE id ?;这里answer_detail是判分时生成的明细表记录每道题的得分。批改时老师更新answer_detail里简答题的score字段然后触发上面的汇总 SQL。这个设计把判分过程拆成了客观题自动 主观题手动两段状态机清晰也方便后续加申诉重判功能。验证判分是否正确我习惯写一个单元测试构造一份已知答案的试卷跑完交卷接口后断言总分等于预期值。特别是多选和判断的边界情况比如学生没作答、答案为空字符串、答案包含小写字母都要覆盖到。这个测试用例在后续改判分逻辑时就是后悔药能第一时间发现回归问题。最后说个习惯每次改完判分相关代码我都会手动跑一遍开始考试 → 答几题 → 刷新页面 → 继续答 → 交卷的完整流程确认答案没丢、分数没错。这个流程看起来笨但比任何自动化测试都接近真实使用场景。希望帮到你。本文还有配套的精品资源点击获取