资讯动态

在线考试系统源码:SpringBoot+Vue骨架、组卷判分与联调避坑

发布时间:2026/10/27 12:21:18 来源:尧图企业网站定制
简介面向计算机专业毕业设计与课程实践场景的在线考试系统源码包适合需要完成考试类Web项目、参考完整业务流程的学生和初学者。系统兼顾平时模拟自测与正式在线考试学生可按科目和章节自主组卷、查看成绩与标准答案正式考试期间限定登录时间考前30、15、5分钟提醒未提交自动保存并对误关闭或误提交设有保护机制。评分环节客观题自动判分、主观题由教师批阅同时控制同场试卷难度均衡并留存试卷副本供查卷需求分析较完整便于理解设计与实现思路。包内共142个文件以C#源码、ASPX页面、RESX资源、DLL程序集及MDF/LDF数据库文件为主附少量配置、脚本与说明文档压缩包约2.59MB结构便于按模块查阅。已有342人学习适合作为毕业设计选题参考与二次开发基础。1. 一套能直接跑的在线考试系统最值钱的不是功能而是骨架答辩前一周拿到一份在线考试系统的源代码多数人第一反应是数功能——题库、组卷、判分、成绩统计数完觉得挺全打开 IDEA 却卡住了数据库连不上、前端一片 404、验证码不显示。带源代码的毕业设计项目值钱的地方从来不是功能列表有多长而是骨架是否完整表结构能不能自洽、接口前后端对不对得上、启动顺序写没写清楚。这套在线考试系统走 SpringBoot Vue 前后端分离的路子考生端覆盖登录、答题、倒计时自动交卷、成绩查询管理端覆盖题库维护、手动与随机组卷、成绩导出。适合拿它当底座改题目的同学也适合想找一个带权限和完整业务流程的中小型练手项目的人。2. 技术栈与目录结构前后端分离的骨架长什么样拿到源码先别急着改业务花二十分钟把工程结构和依赖过一遍后面能省掉大半天。这一章解决三件事用了什么、目录怎么分、数据怎么存。三件事都清楚了你才会知道自己要加的“错题本”“防作弊”该往哪个包里塞。2.1 技术栈清单与版本取舍在线考试这类项目的技术选型核心矛盾不是“先进”而是“能不能在答辩现场讲明白、出了错能不能搜到答案”。下面是这类项目最常见、也是这套源码采用的一套组合。层次选型承担的工作后端框架SpringBoot 2.7 一代统一依赖版本、内嵌 Tomcat、省掉 web.xml持久层MyBatis-Plus单表增删改查不用写 XML复杂组卷仍走手写 SQL数据库MySQL 8题库、试卷、考试记录、答题明细认证JWTjjwt无状态登录前端存 token、后端拦截器校验工具Hutool、Apache POI验证码、日期处理、成绩导出 Excel前端框架Vue Element UI表单密集的后台页面组件开箱即用前端请求axios 统一封装请求头注入 token、统一错误码处理图表ECharts成绩分布、正确率统计为什么不上 SpringCloud、不上 Redis 集群毕设体量下一套单体服务足够撑住几十上百人同时在线微服务只会让启动步骤从三条变成十页文档答辩时老师问一句“注册中心在哪”就得解释半天。JWT 取代 Session 也是同样的道理前后端分离 放服务器上给同学访问Session 的跨域和会话共享是纯负担token 塞到请求头里后端无状态部署简单。我一般会提醒一句不要把 MyBatis-Plus 当万能。组卷那种“按题型分别随机抽 N 题再拼起来”的逻辑用 Wrapper 拼出来的 SQL 又长又难调老老实实写 XML后期改题型比例时一眼就能看到改哪一行。2.2 目录结构后端分层与前端页面归位这类项目的目录结构大同小异认准几个关键位置即可后端controller管参数校验、service管业务和事务、mapper管 SQL、entity对应表字段前端views放页面、api放接口、router管路由和权限。exam-system/ ├── exam-server/ # 后端一个可独立打包的 SpringBoot 工程 │ ├── src/main/java/com/example/exam/ │ │ ├── controller/ # 只做参数接收与结果包装不写业务 │ │ │ ├── AuthController.java # 登录、登出、获取当前用户 │ │ │ ├── QuestionController.java # 题库 CRUD、批量导入 │ │ │ ├── PaperController.java # 组卷、发布考试 │ │ │ └── ExamController.java # 开始考试、交卷、成绩查询 │ │ ├── service/impl/ # 业务实现Transactional 加在这一层 │ │ ├── mapper/ # 接口 resources/mapper 下的 XML │ │ ├── entity/ # 与数据库表一一对应 │ │ ├── common/ # Result、全局异常、常量枚举 │ │ └── config/ # 跨域、拦截器注册、MyBatis-Plus 分页插件 │ └── src/main/resources/ │ ├── application.yml # 数据源、端口、JWT 密钥 │ └── mapper/ # 组卷、成绩统计等复杂 SQL ├── exam-web/ # 前端 Vue 工程 │ ├── src/api/ # 按模块拆的接口文件 │ ├── src/views/exam/ # 答题页、成绩页 │ ├── src/views/admin/ # 题库、试卷、用户管理 │ ├── src/router/index.js # 路由表 前置守卫 │ └── src/utils/request.js # axios 实例与拦截器 └── sql/ ├── schema.sql # 建表语句 └── data.sql # 初始账号、示例题库看结构就能判断这项目值不值得改如果controller里直接写 JDBC、如果前端所有页面挤在一个App.vue、如果 SQL 散落在 Java 字符串里那它就是“能演示但不耐改”趁早换。2.3 数据库表设计七张表撑起整条业务链表设计是整个项目的地基改起来最贵。这类系统常见的七张核心表如下表名作用关键字段t_user用户考生 / 教师 / 管理员id、username、password、rolet_question题库id、type、content、options、answer、score、difficulty、subject_idt_paper试卷id、title、total_score、duration、start_time、statust_paper_question试卷题目关联paper_id、question_id、sortt_exam_record一次考试的记录id、paper_id、user_id、start_time、submit_time、status、scoret_answer_record每道题的作答record_id、question_id、user_answer、is_right、got_scoret_subject科目 / 分类id、name、parent_id两个设计细节值得单独说。第一是t_exam_record里必须有status字段用0进行中 / 1已交卷 / 2已批阅这样的状态机而不是靠score是否为空去推断否则遇到“0 分”就分不清是没考还是考了零分。第二是t_answer_record里除了question_id最好再冗余一份题目分值和正确答案快照因为老师考完之后很可能去改题库一改历史答卷里的得分就对不上账了。注意题目表的options字段用 JSON 字符串存选项别拆成 A/B/C/D 四列。以后要加“多选题随机打乱选项顺序”四列方案会让你改表改到怀疑人生。3. 后端核心链路一次考试从登录到出分要经过什么后端只有三条真正的主干进来时认人鉴权、考试前发卷组卷、考试后算分判分。把这三条吃透剩下的增删改查都是体力活。3.1 登录鉴权JWT 拦截器别把角色写死在 Session 里登录接口做两件事校验密码、签发 token。密码用 BCrypt 存哈希别存明文也别用 MD5——答辩时被问一句“你这密码能反查出来吧”会很尴尬。token 里只放 userId 和 role其他信息从数据库查避免用户改了角色还要等 token 过期。PostMapping(/login) public ResultLoginVO login(RequestBody Valid LoginDTO dto) { User user userService.getByUsername(dto.getUsername()); // 用户不存在和密码错误统一返回同一句提示避免被枚举出有效账号 if (user null || !BCrypt.checkpw(dto.getPassword(), user.getPassword())) { throw new BizException(用户名或密码错误); } String token JwtUtil.sign( user.getId().toString(), MapUtil.of(role, user.getRole()), // role 只用于前端控制菜单显示 7200L // 秒与 application.yml 里保持一致 ); return Result.ok(new LoginVO(token, user.getNickname(), user.getRole())); }签发之后真正把关的是拦截器。这里有个细节容易被忽略浏览器发跨域请求时会先发一个 OPTIONS 预检如果拦截器把 OPTIONS 也拦下来前端会看到一堆莫名其妙的 403而浏览器控制台里连请求体都没有排查起来很玄学。Component public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 预检请求直接放行否则跨域时第一个 OPTIONS 就被判定为未登录 if (HttpMethod.OPTIONS.matches(request.getMethod())) { return true; } String header request.getHeader(Authorization); if (header null || !header.startsWith(Bearer )) { write(response, 401, 未登录或登录已过期); return false; } try { Claims claims JwtUtil.parse(header.substring(7)); // 同时校验签名与 exp // 塞进 ThreadLocalController 里 UserContext.getUserId() 直接取不用每个方法解一遍 UserContext.set(Long.valueOf(claims.getSubject()), claims.get(role, String.class)); } catch (ExpiredJwtException e) { write(response, 401, 登录已过期请重新登录); return false; } return true; } Override public void afterCompletion(HttpServletRequest req, HttpServletResponse resp, Object handler, Exception ex) { UserContext.clear(); // Tomcat 线程池复用线程不清会串到下一个请求 } private void write(HttpServletResponse response, int code, String msg) throws IOException { response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\: code ,\msg\:\ msg \}); } }参数含义preHandle返回 false 表示请求到此为止不会再进 ControllerafterCompletion无论成功失败都会执行是清理 ThreadLocal 的最后机会。claims.getSubject()存的是userId字符串取出来必须转Long直接强转成Integer会在用户量上来后报类型异常。注意UserContext.clear()这一行是血泪经验。本地单人测试永远发现不了问题一放到多人环境就会偶发“A 同学看到了 B 同学的成绩”。3.2 组卷随机抽题的 SQL 与去重组卷分两种老师手动勾题系统随机抽题。随机抽题的坑在于“按题型分别抽”单选题抽 10 道、多选题抽 5 道、判断题抽 5 道用一条 SQL 拼不出来得靠子查询UNION ALL。-- 按题型分组随机抽取参数由 Service 层传入 SELECT * FROM ( SELECT q.id, q.type, q.score, q.content FROM t_question q WHERE q.subject_id #{subjectId} AND q.type 1 -- 1单选 AND q.deleted 0 AND q.difficulty BETWEEN #{minDiff} AND #{maxDiff} ORDER BY RAND() LIMIT #{singleCount} ) t1 UNION ALL SELECT * FROM ( SELECT q.id, q.type, q.score, q.content FROM t_question q WHERE q.subject_id #{subjectId} AND q.type 2 AND q.deleted 0 ORDER BY RAND() LIMIT #{multipleCount} ) t2 UNION ALL SELECT * FROM ( SELECT q.id, q.type, q.score, q.content FROM t_question q WHERE q.subject_id #{subjectId} AND q.type 3 AND q.deleted 0 ORDER BY RAND() LIMIT #{judgeCount} ) t3;ORDER BY RAND()在几千条题库上完全够用但它会对整张表做一次随机排序题库涨到十万级就会明显变慢那时候的替代方案是“先取 id 区间再按随机 id 捞”或者干脆预生成一套卷子存进t_paper_question。毕设阶段不用过度设计但心里要有这根弦。抽完题之后必须落库成t_paper_question不能每次开考都现抽。原因很实在现抽的话同一个考生刷新页面就可能拿到另一套题成绩没法复核出了争议谁也说不清。Service 层还要做两件校验一是抽到的题数是否等于配置数题库不足时要提前报错而不是发一份残缺的卷子二是按sort字段排好序再返回前端答题卡的题号顺序依赖它。分值合计也要在这里算一遍写进t_paper.total_score别等到判分时再临时累加。3.3 判分客观题自动判、主观题留人工交卷接口是整条链路上唯一需要保证幂等的地方。考生网络卡顿连点两次、倒计时自动交卷和手动交卷同时触发都会让同一份答卷被处理两遍结果是分数翻倍或者记录重复。Transactional(rollbackFor Exception.class) public Long submit(Long recordId, Long userId, boolean auto) { // 加行锁读防止两个请求同时进来都判定为“进行中” ExamRecord record examRecordMapper.selectForUpdate(recordId); if (record null || !record.getUserId().equals(userId)) { throw new BizException(考试记录不存在); } if (record.getStatus() ! 0) { return record.getId(); // 已交过卷直接返回不报错也不重复判分 } ListAnswerRecord answers answerMapper.selectByRecordId(recordId); int total 0; for (AnswerRecord a : answers) { Question q questionMapper.selectById(a.getQuestionId()); if (q.getType() 4) { // 4主观题留人工批阅 a.setGotScore(null); answerMapper.updateById(a); continue; } // 多选答案入库前统一排序避免 A,C 与 C,A 被判成错 String right StringUtils.sortChars(q.getAnswer()); String user StringUtils.sortChars(a.getUserAnswer()); boolean ok right.equalsIgnoreCase(user); a.setIsRight(ok ? 1 : 0); a.setGotScore(ok ? q.getScore() : 0); total a.getGotScore(); answerMapper.updateById(a); } record.setScore(total); record.setSubmitTime(LocalDateTime.now()); record.setStatus(auto ? 1 : 1); examRecordMapper.updateById(record); return recordId; }几个参数和写法值得解释。selectForUpdate对应 XML 里的SELECT ... FOR UPDATE靠数据库行锁把并发交卷串行化这是最省事也最可靠的做法status ! 0时直接返回而不抛异常是为了让“自动交卷 手动交卷”同时发生时前端两个请求都能拿到正常响应不至于弹一个红色报错吓到考生主观题的gotScore存 null 而不是 0配合前端就能显示“待批阅”0 分和未批阅在成绩单上是两回事。判分完成后还要回写t_exam_record.status 2如果整卷全是客观题可以在同一次事务里直接置为已批阅并触发一次统计更新。统计别在查询成绩时实时算放进交卷事务里顺手累加页面打开速度会差出一个量级。4. 跑通全流程环境、初始化 SQL 与前后端联调代码看懂了不等于能跑起来。这类项目跑不起来的90%原因集中在三处数据库没建、配置没改、启动顺序反了。下面按真实操作顺序走一遍。4.1 环境与配置三个端口别打架本地需要 JDK 8 或 11、Maven 3.6、MySQL 8、Node 14/16。JDK 版本别乱升SpringBoot 2.7 配 JDK 17 有时会在反射和javax包上出问题报错信息还特别绕。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/exam_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8 mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true exam: jwt: secret: 至少32位字符的随机串上线前务必换掉 expire: 7200serverTimezoneAsia/Shanghai和jackson.time-zone这两项必须成对出现。只配一边考完试你会发现交卷时间比实际早了八小时倒计时也会跟着错乱排查时特别容易怀疑到前端身上。map-underscore-to-camel-case是 MyBatis-Plus 的驼峰映射开关submit_time自动映射成submitTime。关掉它所有查询结果里的字段都是 null而且不报错属于典型的黑匣子问题。4.2 建库与初始化数据# 1. 建库字符集一定用 utf8mb4题目里可能有公式符号和生僻字 mysql -uroot -p -e CREATE DATABASE exam_db DEFAULT CHARSET utf8mb4 COLLATE utf8mb4_general_ci; # 2. 先导结构再导数据顺序反了会报 Table doesnt exist mysql -uroot -p exam_db sql/schema.sql mysql -uroot -p exam_db sql/data.sql # 3. 确认初始账号在不在 mysql -uroot -p exam_db -e SELECT id, username, role FROM t_user LIMIT 5;导入后先看t_user有没有数据。很多源码包的data.sql里只有题目没有账号登录页输入 admin/123456 一直提示密码错误最后发现是表空的。这种情况自己插一条密码字段用 BCrypt 生成哈希网上随便找个在线工具生成后粘进去即可。顺手把t_question的条数也查一下题库太少会导致随机组卷抽不满报“题目数量不足”时你会以为是代码 bug。4.3 后端启动与接口自检# 打包跳过测试源码包里自带的测试用例常常因为环境差异跑不过 mvn clean package -DskipTests # 用 dev 配置启动方便改配置不用重新打包 java -jar target/exam-server.jar --spring.profiles.activedev # 另开一个终端先验登录接口再验一个需要鉴权的接口 curl -X POST http://localhost:8080/api/auth/login \ -H Content-Type: application/json \ -d {username:admin,password:123456}登录返回里拿到 token 后用它请求一次/api/question/page能返回数据说明鉴权链路通了。这一步别省很多“前端一直跳登录页”的问题根因就是后端根本没起来或者 token 没被正确解析。4.4 前端启动答题页倒计时与自动交卷的实现cd exam-web npm install --registryhttps://registry.npmmirror.com # 国内源能省十分钟 npm run serve前端起来后打开答题页倒计时和自动交卷是考生端最容易翻车的两个功能。export default { data() { return { left: 0, timer: null, submitting: false, recordId: null }; }, mounted() { // 剩余秒数由后端下发前端自己算的话改个系统时间就能白嫖考试时长 this.left this.examInfo.leftSeconds; this.recordId this.examInfo.recordId; this.timer setInterval(this.tick, 1000); }, beforeDestroy() { clearInterval(this.timer); // 路由切走必须清否则页面销毁了定时器还在跑 }, methods: { tick() { if (--this.left 0) { clearInterval(this.timer); this.submit(true); } }, async submit(auto) { if (this.submitting) return; // 手动点交卷与倒计时自动交卷可能撞在一起 this.submitting true; try { await this.$http.post(/api/exam/submit, { recordId: this.recordId, auto }); this.$router.replace(/score/ this.recordId); } catch (e) { this.submitting false; // 失败要放开锁否则考生再也交不了卷 } } } };leftSeconds由后端在“开始考试”接口里算好返回公式是试卷时长 - 已过去秒数这样即使考生中途刷新页面、换设备登录倒计时也不会重置。submitting这个标志位看着不起眼没有它的时候网络慢一点考生连点两下交卷后端就要处理两次请求虽然 service 里做了幂等但前端仍然会弹两个跳转。5. 避坑排查这 5 个地方我第一次跑全翻车了下面这几条都是真跑起来之后才暴露的按“现象 → 原因 → 解决”记下来遇到时对着查。5.1 启动与数据层三个让人怀疑人生的坑现象一登录成功但跳转后又弹回登录页。原因axios 拦截器里取 token 的 key和登录成功后写入 localStorage 的 key 不一致一个叫token一个叫Authorization或者请求头里漏了Bearer前缀导致后端startsWith(Bearer )判断失败。 解决把 key 抽成常量登录写入、请求读取、登出清除三处都引用同一个常量请求拦截器里统一拼Bearer别在业务代码里手写。现象二交卷时间比实际早八小时倒计时一开始就少了一大截。原因MySQL 连接的serverTimezone和 JVM/Jackson 的时区不一致LocalDateTime在数据库和 Java 之间转了一圈就漂了。 解决三处对齐——JDBC URL 加serverTimezoneAsia/Shanghai、spring.jackson.time-zone设为GMT8、服务器系统时区确认为东八区。改完重启别热部署配置类的改动热部署不生效。现象三题库列表接口返回的数据全是 null 字段。原因map-underscore-to-camel-case没开或者开了但 XML 里手写了resultMap把字段名写死成了下划线形式两套映射打架。 解决单表查询交给 MyBatis-Plus 自动映射复杂查询在 XML 里显式写resultMap两者别混用同一个实体。5.2 业务与并发两个上线才会暴露的问题现象四考生答题过程中刷新页面已填的答案全没了还在考试中却显示“未开始”。原因答案只存在 Vue 的data里没有落库也没有本地缓存t_exam_record的状态判断写成了“有 submit_time 才算进行中”。 解决每答一题调一次轻量的保存接口写进t_answer_record或者前端用sessionStorage按recordId做草稿状态判断只认status字段别用时间字段反推。现象五随机组卷偶尔抽出重复题目同一道题在卷子里出现两次。原因UNION ALL各子查询之间没有去重约束题库里如果存在同一题干被录入两次老师批量导入时常见抽出来就是重复的。 解决给t_question加(subject_id, content)唯一索引导入时用INSERT IGNORE或先查后插组卷落库前在 Java 里再用SetLong过滤一遍题目 id双保险。注意第五条那个唯一索引如果题干字段是TEXT类型会加不上得改成VARCHAR(255)存题干摘要或者用content_md5这样的哈希列做唯一约束。6. 二次开发把通用考试系统改成你自己的毕设题目源码能跑之后真正的活儿才开始。通用的在线考试系统满大街都是答辩想拿高分得有一两个属于自己的点。下面三条改造路线按难度递增选一条做透比三条都碰一遍强。改造方向改动位置工作量答辩加分点错题本与知识点薄弱分析新增t_wrong_book表 成绩页图表小有数据沉淀能讲清统计口径切屏检测与防作弊答题页visibilitychange 后端记录表中有前后端配合的完整闭环主观题批阅工作流t_answer_record加批阅状态 教师端页面大状态机设计能画清楚错题本这条最适合起步。判分时把is_right 0的记录同步写进错题表前端加一个按知识点聚合的柱状图改动集中在submit方法和一个新页面上两三天能出效果。切屏检测稍麻烦一点前端监听document.visibilitychange切出页面就post一次后端累计次数超过阈值自动交卷——这里一定要在后端做最终判定前端只负责上报否则稍微懂点技术的同学打开控制台就能把逻辑绕过去。主观题批阅是最能体现设计能力的。给t_answer_record加一个review_status0 待批阅 / 1 已批阅教师端按试卷维度拉出所有主观题作答逐条打分后回写总分并更新t_exam_record.status 2。难点在总分重算客观题分数在交卷时已经算过一遍批阅时只能做加法不能整卷重算否则并发下会覆盖掉之前的分数。改造完一定要跑一遍完整回归注册新考生 → 参加一次考试 → 中途刷新 → 自动交卷 → 教师批阅主观题 → 查成绩。这条链路我每次改完核心逻辑都会强制走一遍因为出问题的地方从来不在你改的那一行而在它上下游的某个状态判断上。有一次我只改了组卷 SQL结果把交卷幂等的行锁给漏了测试时单线程怎么都复现不出来最后是同学两台手机同时点交卷才炸出来。从那以后我每次动事务和锁都强制用两个浏览器窗口并发点一遍。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑