简介:面向计算机专业毕业设计与课程实践场景的在线考试系统源码包,适合需要完成考试类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 | 题库、试卷、考试记录、答题明细 |
| 认证 | JWT(jjwt) | 无状态登录,前端存 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、role |
| t_question | 题库 | id、type、content、options、answer、score、difficulty、subject_id |
| t_paper | 试卷 | id、title、total_score、duration、start_time、status |
| t_paper_question | 试卷题目关联 | paper_id、question_id、sort |
| t_exam_record | 一次考试的记录 | id、paper_id、user_id、start_time、submit_time、status、score |
| t_answer_record | 每道题的作答 | record_id、question_id、user_answer、is_right、got_score |
| t_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 Result<LoginVO> 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 // 塞进 ThreadLocal,Controller 里 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;charset=UTF-8"); response.getWriter().write("{\"code\":" + code + ",\"msg\":\"" + msg + "\"}"); } }参数含义:preHandle返回 false 表示请求到此为止,不会再进 Controller;afterCompletion无论成功失败都会执行,是清理 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(); // 已交过卷,直接返回,不报错也不重复判分 } List<AnswerRecord> 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?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT+8 mybatis-plus: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true exam: jwt: secret: 至少32位字符的随机串,上线前务必换掉 expire: 7200serverTimezone=Asia/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 doesn't 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.active=dev # 另开一个终端,先验登录接口,再验一个需要鉴权的接口 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 --registry=https://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 加serverTimezone=Asia/Shanghai、spring.jackson.time-zone设为GMT+8、服务器系统时区确认为东八区。改完重启,别热部署,配置类的改动热部署不生效。
现象三:题库列表接口返回的数据全是 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 里再用Set<Long>过滤一遍题目 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_status(0 待批阅 / 1 已批阅),教师端按试卷维度拉出所有主观题作答,逐条打分后回写总分并更新t_exam_record.status = 2。难点在总分重算:客观题分数在交卷时已经算过一遍,批阅时只能做加法,不能整卷重算,否则并发下会覆盖掉之前的分数。
改造完一定要跑一遍完整回归:注册新考生 → 参加一次考试 → 中途刷新 → 自动交卷 → 教师批阅主观题 → 查成绩。这条链路我每次改完核心逻辑都会强制走一遍,因为出问题的地方从来不在你改的那一行,而在它上下游的某个状态判断上。有一次我只改了组卷 SQL,结果把交卷幂等的行锁给漏了,测试时单线程怎么都复现不出来,最后是同学两台手机同时点交卷才炸出来。从那以后我每次动事务和锁,都强制用两个浏览器窗口并发点一遍。希望帮到你。
本文还有配套的精品资源,点击获取