以前接了一个在线考试系统的活儿,甲方最初只给了一句需求描述:能出题、能考试、能自动判分,最好还能有完整的源码、数据库和文档,方便后续二次开发。等真做起来才发现,这玩意儿的复杂度被严重低估了。一个看起来“不就是发试卷、收答案”的系统,实际包含了权限模型、题库设计、组卷策略、阅卷流程、并发防重、倒计时处理、成绩归档,甚至还要考虑考生中途掉线、刷新页面、批量导入题目这些边角场景。
本文就以“在线考试系统”这个项目为例,把完整的设计思路、数据库表结构、后端核心流程、前端实现要点和部署文档整理经验一次讲清楚。特别适合正在做课设、毕设或者刚入职需要快速搭建笔试平台的开发者,你能从里面直接拿到一套可落地的方案,包括关键的SQL、核心接口逻辑和那些常规文档里根本不会写的坑。
1. 内容整体设计与思路拆解
1.1 需求分析:谁在用、用哪些功能
在线考试系统的角色划分其实比想象中要清晰,绝大多数场景下就三类人:管理员、教师、考生。别急着堆功能,先把每个角色关心的事情列出来。
管理员关心的是系统和用户:要不要开新课、哪些老师能管理当前学期课程、系统整体运行是否正常。教师关心的是出题与考试:导入试题、手工组卷或随机抽题、发布考试、查看考生成绩分布。考生关心的更直接:我要参加一场什么考试,时间多少,题目难不难,考完能不能看到排名。
所以功能模块可以拆解成三个闭环:用户权限闭环、考试管理闭环、考后分析闭环。用户权限闭环就是登录鉴权和角色切换;考试管理闭环覆盖建题库、组卷、发布考试、进入考试、提交试卷;考后分析闭环则是自动阅卷、人工批改、成绩统计和导出。
很多新手容易犯的错是上来就画一大堆页面,把“题库管理”和“试题管理”拆成两个独立菜单,甚至给每张表都做一个增删改查页面。实际使用中,题库只是一个分类纬度,试题应该挂在题库下面统一操作;考试发布也应该和学员名单绑定,而不是让考生自己“选择考试”。这里要提醒一句:如果甲方没明确要“考生自主报名”,一定不要默认做报名功能,多一个入口就多一个权限漏洞。
1.2 技术选型:为什么是前后端分离、JWT认证
当前这个项目我采用的主流方案是:后端用Spring Boot,前端用Vue 3,数据库用MySQL,缓存和防重用Redis,前后端通过JSON接口交互,权限认证用JWT。这套组合在开发和部署上的成本相对适中,资料也多,适合作为快速交付的基底。
有人会问为什么还要加Redis?纯考试系统业务量不大,但有两个场景必须依赖它:一是保存验证码和登录令牌的短期状态,二是防止考生重复提交试卷。如果只靠数据库做唯一约束,并发高时容易爆出唯一索引冲突,处理起来不够优雅。Redis 的 setnx 可以做简单的防重标记,性能更好。
JWT 认证比 Session 适合这套系统的地方在于:前后端分离后,接口调用跨域尤其常见,Session 要维护会话状态,在分布式部署下还得做会话同步。用 JWT 签发 token,把角色和用户ID写进 payload,后端只需要在拦截器里验签名,前端在请求头带 token 即可,部署时少操心很多。
选择 Vue 而不是 jQuery + 模板渲染,是因为考试页面的交互密度很高:倒计时、答题卡、题目切换、未作答提醒,这些如果用传统页面刷新方式做,用户体验很差,而且很容易因为一次误刷新就丢状态。前端路由和本地暂存搭配,能大幅减少考生因误操作导致答题记录丢失的情况。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心细节解析与数据库设计
2.1 数据表的划分:七张表支撑整个系统
数据库是这类项目的核心资产,表结构设计不好,后期写SQL会非常痛苦。我把核心表分成七张:用户表、题库表、试题表、试卷表、试卷题目关联表、考试记录表、答题明细表。
用户表涵盖管理员、教师、考生三种角色,通过 role 字段区分,不再单独建多张用户表。题库表只做归属分类,比如“计算机网络题库”或“Java基础题库”。试题表保存具体题干、选项、答案、题型、难易度、分值。试卷表记录一场考试的基础信息,包括名称、开始时间、结束时间、时长、总分、及格分。试卷题目关联表解决“同一道题出现在不同试卷里”的多对多关系。考试记录表保存某位考生某场考试的提交状态和最终得分。答题明细表记录考生每道题的具体答案,是阅卷和复盘的关键。
这套设计有一个明显优势:试题和试卷分离,后续可以复用试题生成多套试卷;试卷和考试记录分离,同一份试卷可以多次作为不同场次考试发布,不需要重建数据。考试记录与答题明细分离,还能支持人工批改主观题后反向补充分数,不会污染考生对客观题的原始作答。
2.2 核心表字段与SQL参考
下面给出几张最关键的建表SQL,字段名和注释写清楚,后面写接口时会持续引用。
sql复制CREATE TABLE sys_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL UNIQUE COMMENT '登录账号',
password VARCHAR(128) NOT NULL COMMENT 'BCrypt加密后的密码',
real_name VARCHAR(64) NULL,
role TINYINT NOT NULL DEFAULT 3 COMMENT '1管理员 2教师 3考生',
status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0禁用',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
CREATE TABLE question (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
bank_id BIGINT NOT NULL COMMENT '所属题库',
question_type TINYINT NOT NULL COMMENT '1单选 2多选 3判断 4填空 5简答',
content TEXT NOT NULL COMMENT '题干',
options JSON NULL COMMENT '选择题选项,格式[{"key":"A","text":"xxx"}]',
answer TEXT NOT NULL COMMENT '标准答案',
score INT NOT NULL DEFAULT 5 COMMENT '分值',
difficulty TINYINT NOT NULL DEFAULT 1 COMMENT '1易 2中 3难',
create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='试题表';
CREATE TABLE exam (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
paper_id BIGINT NOT NULL COMMENT '关联试卷',
title VARCHAR(128) NOT NULL COMMENT '考试标题',
start_time DATETIME NOT NULL COMMENT '考试开始时间',
end_time DATETIME NOT NULL COMMENT '考试结束时间',
duration INT NOT NULL COMMENT '考试时长(分钟)',
total_score INT NOT NULL DEFAULT 100,
pass_score INT NOT NULL DEFAULT 60,
status TINYINT NOT NULL DEFAULT 0 COMMENT '0未发布 1已发布 2已结束'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试表';
CREATE TABLE exam_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
exam_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
start_time DATETIME NULL COMMENT '实际开考时间',
submit_time DATETIME NULL COMMENT '实际交卷时间',
score DECIMAL(6,2) NULL,
review_status TINYINT NOT NULL DEFAULT 0 COMMENT '0待提交 1已交卷待阅卷 2已阅卷',
UNIQUE KEY uk_exam_user (exam_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考试记录表';
CREATE TABLE answer_detail (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
record_id BIGINT NOT NULL,
question_id BIGINT NOT NULL,
exam_id BIGINT NOT NULL,
user_answer TEXT NULL COMMENT '用户答案',
is_correct TINYINT NULL COMMENT '客观题是否正确 1对 0错',
score DECIMAL(6,2) NULL COMMENT '每题得分',
KEY idx_record_id (record_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='答题明细表';
重点说说两个容易踩坑的字段。
options 我用了 JSON 类型,而不是单独建一张选项表。原因是选择题的选项数量基本固定,2到6个之间,JSON 存储足够灵活,查询时也不用二次关联。后端解析时用 Jackson 转 List 即可,比关系表少写一层 join。前提是团队明确不会针对选项做复杂统计,如果以后要用某个选项的错误率做学情分析,再做选项宽表也不迟。
exam_record 表里设置联合唯一索引 uk_exam_user (exam_id, user_id),这是防重复提交的最底层兜底。哪怕 Redis 挂了、前端按钮失灵,数据库也能挡住同一考生在同一个考试下生成两条记录。实际生产里这套三重防线是必要的。
2.3 数据库设计中的两个隐藏细节
第一个隐藏细节是时间精度。考试系统的业务强烈依赖时间判断:开考前不能进入,开考后多久可以交卷,结束后是否自动交卷。所有时间字段统一用 DATETIME,并且后端服务器、数据库服务器设置为同一时区,否则考生看到“还剩5分钟”时可能直接变成“考试已结束”。
第二个隐藏细节是答案存储格式。选择题的答案不要存成“A,B”,尽量直接存JSON 或者 使用逗号分隔统一规范。多选题会出现 AB、BA 的歧义,自动阅卷时必须先对用户答案做排序和标准化再比较,不然考生只是选择了不同顺序就被误判为错误。这个我在后面实操章节会给出代码示例。
3. 实操过程与核心环节实现
3.1 登录鉴权与角色权限控制
后端采用 JWT 结构,登录接口校验用户名密码成功后生成 token,同时把用户ID和角色放入 payload。为了保证后续接口的权限校验不用到处写 if/else,我习惯写一个自定义注解,比如 @RequireRole(role = 2),用拦截器统一读取 token,校验角色。
这里需要解释一个关键点:角色控制为什么要用注解而不是在每个 controller 里手写判断。在线考试系统的接口数量约在六十个以上,如果每个接口都写角色判断,权限调整时要全局找引用点,很容易漏掉。用拦截器加注解之后,新写接口时只需要声明可访问角色,逻辑集中且便于代码审查。
需要注意 token 过期策略。考试系统属于“中等时长操作”,倒计时最长可能90分钟,同一个考生的 token 有效期如果设置成30分钟,考到一半会突然被踢回登录页,这会让考生直接崩掉。我的做法是:普通接口的 token 有效期设短,1到2小时;进入考试时,为考生单独签一个有效期覆盖考试时长的 token 或者允许 token 刷新,确保交卷前不掉线。更稳妥的方案是拦截器里检测 token 剩余有效期不足30分钟时自动续期,但实现上要评估接口并发频率,项目初期不推荐做,容易给服务器增加不必要的写压力。
3.2 自动组卷的逻辑:随机抽题与分数配额
组卷模块是整个系统最容易出现“试卷平分秋毫错误”的地方。一般需求是:从题库中按题型占比抽取题目,并确保试卷总分正好是100分。
假设我们要求选择题20道、每题3分,判断题10道、每题2分,简答题2道、每题15分,加起来正好100。如果随机抽题时只随机类型不控制剩余题目数量,很容易抽到某题分数不够或超出,导致总分对不上。因此我的实现分两步:先按分数倒排,再按类型随机取样。
java复制public List<Question> generatePaper(Long bankId, Map<Integer, Integer> config) {
// config 配置例如 {1:60, 2:20, 3:20},key为题型,value为总分值
List<Question> all = questionMapper.selectByBankId(bankId);
List<Question> result = new ArrayList<>();
for (Map.Entry<Integer, Integer> e : config.entrySet()) {
int type = e.getKey();
int targetScore = e.getValue();
List<Question> pool = all.stream()
.filter(q -> q.getQuestionType() == type)
.collect(Collectors.toList());
// 按分值倒序,优先抽出分值大的题目,便于凑满目标总分
pool.sort((a, b) -> b.getScore() - a.getScore());
int currentScore = 0;
Random random = new Random();
while (currentScore < targetScore && !pool.isEmpty()) {
int idx = random.nextInt(pool.size());
Question q = pool.remove(idx);
if (currentScore + q.getScore() > targetScore && currentScore != targetScore - q.getScore()) {
continue;
}
result.add(q);
currentScore += q.getScore();
}
}
return result;
}
这段代码有个取巧的地方:通过“差值判断”来避免出现最后剩1分却找不到1分题的情况。实际应用中,当题库题目数量不充裕时,随机抽题很可能凑不出目标总分,所以一定要在建题库时约束:如果这一题型的总分不满足试卷配置,系统要提示“题库题目不足”,而不是默默生成一张缺分试卷。
3.3 自动阅卷:客观题判定与主观题标记
自动阅卷是“在线考试系统源码”中的核心亮点。客观题,包括单选、多选、判断,直接在提交时判分;主观题,如简答、填空,标记成“待人工批改”。
核心判分逻辑可以拆成三个方法:
- 单选:用户答案等于标准答案,得分,否则0分。
- 多选:常见规则是少选、错选都不得分。如果产品要求“少选得一半分”,要对答案List做 containsAll 校验,实现会更复杂。
- 判断:判断单选逻辑类似,本质是二选一。
多选题必须先排序再比对,原因前面提过。比如标准答案是“A,B”,用户提交“B,A”,如果直接比较字符串,肯定是错的。所以提交时后端先把用户答案按字母排序,标准化后再保存到 answer_detail,这样阅卷和统计都省事。
java复制private boolean judgeQuestion(Question q, String userAnswer) {
if (q.getQuestionType() == 1 || q.getQuestionType() == 3) {
return q.getAnswer().trim().equalsIgnoreCase(userAnswer.trim());
}
if (q.getQuestionType() == 2) {
List<String> std = Arrays.stream(q.getAnswer().split(","))
.map(String::trim).sorted().collect(Collectors.toList());
List<String> answer = Arrays.stream(userAnswer.split(","))
.map(String::trim).sorted().collect(Collectors.toList());
return std.equals(answer);
}
return false;
}
人工批改环节往往被忽略但极其重要。系统只记录客观题得分,主观题得分由教师在管理端逐题打分。打完后需要重算总分并更新 exam_record.score,这个操作要注意事务:先更新 answer_detail 每题分数,再更新 exam_record 总分,不能分别提交两个事务,否则考生端可能看到旧总分。
3.4 提交试卷的并发防重与时间校验
考生交卷是最容易出并发问题的瞬间。倒计时归零、考生手点交卷、意外刷新页面,三个动作可能同时触发后端接口。如果不做防重,就可能出现一条考试记录插入多次,或者重复计算成绩。
我的实现里按这个顺序做三重校验:
- 前端发放考试时,后端在 Redis 写入一个 key,值为
examId:userId,过期时间等于考试时长,交卷成功或 Redis 主动删除后,重复请求无法再次进入。 - 后端收到交卷请求时,先检查当前时间是否在考试起止时间内,超出结束时间则拒绝提交,并返回错误提示。
- 数据库唯一的联合索引
uk_exam_user,最后的兜底,直接捕获 DuplicateKeyException 返回“请勿重复交卷”。
经验提醒:前端倒计时归零时,要用后端时间作为最终判断依据,不要完全信任考生本机时间。考生修改系统时间,或者电脑睡眠后恢复,会导致倒计时出现偏差。正确做法是进入考试时后端返回一个 serverTime,前端用 Date.now() - serverTime 计算剩余时间,防止考生通过调系统时间偷时间。
4. 前端实现与联调:让考试过程顺畅
4.1 页面结构与答题卡交互
前端页面不需要太多,三个核心页面就够用了:考试列表页、考试答题页、成绩页。
考试列表页展示当前时间范围内可参加的考试。答题页是重点,设计时一定把倒计时固定在顶部,中间是题目区,底部是答题卡。答题卡里每个题号根据状态显示不同颜色:灰色是未作答,蓝色是已作答,红色是标记待定。这样考生对自己还有几道题没做一目了然。
我实际研发中最耗时的是题目切换时的状态保存。方案是:组件内部维护一个本地回答列表 answerMap,每次点下一题时把当前输入写入本地,但不立即请求接口。只有点击交卷才一次性提交。中途考生刷新页面,本地数据会丢失,因此每切换几道题或者每30秒,把当前 answerMap 快照保存到 localStorage。这招虽笨,但能极大避免“答了50道题,一个手滑全没了”的客诉。
4.2 倒计时与自动交卷的实现细节
倒计时组件用 setInterval 从后端返回的截止时间做差值。剩余时间小于0时,自动触发交卷,并且要锁定页面,让考生无法继续操作。
这里有一个模糊地带:是倒计时结束立即强制提交,还是倒计时结束后允许考生在30秒内手动提交?我强烈推荐后者。因为自动提交请求可能出现网络抖动,如果瞬间同时触发大量提交,后端压力很大。我采用的折中方案是:倒计时结束弹窗提示“时间到,正在自动交卷”,给后端留30秒重试窗口,窗口内用户不能编辑答案,但可以点击“重新提交”按钮。
前端代码大致长这样:
javascript复制const remainSec = Math.max(0, Math.floor((deadline - Date.now()) / 1000));
this.timer = setInterval(() => {
if (deadline - Date.now() <= 0) {
clearInterval(this.timer);
this.autoSubmit();
}
}, 1000);
注意 setInterval 在页面休眠时会被浏览器节流,比如考生把标签页切到后台,倒计时可能不准。所以前端除了每秒刷新之外,还要在页面重新聚焦时用后端时间重新校准。页面失焦时最好弹一个提示,但不强制禁止考生离开页面,因为合理场景下考生可能想打开计算器或查看上传的图片附件。
4.3 接口联调时的几个统一约定
前后端联调如果接口风格不一致,后端改起来非常痛苦。我在这个项目里把所有接口统一成以下格式:
json复制{
"code": 0,
"message": "success",
"data": {}
}
业务异常时 code 非0,前端统一拦截并弹出错误信息。这样处理的好处是,不管接口内部发生了什么错误,前端只管拿到 code 然后处理,不需要针对每个接口单独写 try/catch。
另外,所有接口都要求在请求头里携带 token,后端用拦截器统一校验,未登录用户返回 401。前端 axios 实例里在 request 拦截器统一带 token,在 response 拦截器统一处理401跳转登录页。这样能避免在每个页面里重复写权限判断。
联调阶段最容易忽略的接口是“获取考试剩余时间”。这个接口不返回题目内容,只返回服务器当前时间和考试结束时间,考生端倒计时必须依赖它。如果漏掉这个接口,部署后就会出现考生本地时间不准导致的交卷失败,而且这个问题在开发环境根本测不出来。
5. 部署、文档整理与常见问题排查
5.1 代码打包与数据库初始化
后端代码打成 jar 包,前端构建成静态资源,用 Nginx 反向代理处理 /api 前缀到后端服务。部署时顺序很重要:先导入数据库脚本,再设置配置文件,最后启动后端。
数据库初始化脚本要准备两份:一份是建库建表的 schema.sql,一份是演示数据 data.sql。data.sql 至少包含一个管理员账号、一个教师账号、几个考生账号、一套题库和两份试卷。没有演示数据,甲方验收时会对着空页面发呆,体验感会打折扣。
我遇到比较坑的一件事是 MySQL json 字段在返回给前端时,默认驱动包处理结果会带斜杠,比如 "[{\"key\":\"A\"}]",前端直接渲染会多出反斜杠。解决办法是在 Spring Boot 的全局配置里让 Jackson 正常序列化 String 类型,或者在实体里直接用 List<Option> 类型映射,而不是使用 String 接收。这个问题属于开发环境很难发现、上线后被前端提出来的经典坑。
正确做法是在实体类里把 options 声明成 String,再写一个方法用 JSON.parseArray 转成 List,前端拿到 JSON 字串后解析。后端返回时可以预先转成 List,减少前端工作量。
5.2 文档里应该装下哪些内容
项目标题里强调“源码+数据库+文档”,可见文档在甲方眼里很有分量。我交付时把文档分成四类:需求说明、数据库设计、接口文档、部署手册,缺一不可。
需求说明要把参与者角色、业务流程和权限边界写清楚,最好配时序流程。数据库设计文档需要把每张表的字段含义、关联关系写出来,还要付上 ER 图或 Word 表描述。接口文档在这里我推荐直接用 Swagger 自动生成,然后导出一个 Markdown 版本。如果团队不想引入太重依赖,也可以手写一个接口清单,列出方法名、请求地址、请求参数、返回示例。
部署手册是所有文档里容易被忽视但最影响评分的。一定要写清楚JDK版本、MySQL版本、Nginx配置、Redis启动命令、数据库导入命令、常见启动报错怎么解决。我曾经收到有人反馈“按部署手册跑不起来,报 memory not enough”,最后发现是没给 Java 调整最大堆内存。所以部署手册里要给出示例启动参数和端口占用排查方案。
5.3 常见问题速查表:直接抄作业
以下这几个问题,是考试系统上线后最容易遇到的,我整理成速查表,大家可以按表格排查。
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 考生能正常进入考试,但交卷后显示分数为0 | 提交时答案字段没有做大小写或排序标准化 | 检查后端 judgeQuestion 是否对答案排序,多选题尤其明显 |
| 倒计时提前结束或延迟结束 | 服务器时区不一致,前端使用本地时间 | 统一JVM/MySQL为同一时区,前端使用后端返回服务器时间 |
| 同一考生多次交卷生成多条记录 | 缺少唯一索引,或防重逻辑未生效 | 数据库加 uk_exam_user 唯一索引,提交接口做幂等校验 |
| 考试过程中经常被踢出登录 | token 有效期太短 | 针对考试接口设置更长有效期,或者允许续签 |
| 前端渲染试题选项时出现反斜杠 | MySQL JSON 字段与 Jackson 序列化冲突 | 统一实体类型,选项转 List 后再返回 |
| 批量导入题目时中文乱码 | 文件编码与项目编码不一致 | CSV/Excel 统一使用 UTF-8,解析时指定字符集 |
| 人工批改主观题保存后总分不变 | 只更新了明细表,未更新主表成绩 | 事务中同时更新 answer_detail 和 exam_record |
5.4 从接单到交付的几个项目经验
最后分享一点自己的体会。做在线考试系统这类项目,技术难点不在某个单点功能,而在于把时间、状态、持久化三者串起来。时间要统一,考试状态要迁移,答题记录要可恢复,三者缺一个就会出现“莫名丢分”“交不了卷”的体验事故。
我个人的建议是一定要在项目初期就画出状态图:考试记录从“未提交”到“已交卷待阅卷”到“已阅卷”,每个状态由什么操作触发,异常状态下如何回退。这套状态设计越早清晰,后面写接口和前端禁用逻辑就越省心。
如果后续想扩展,这套结构可以很自然地接上“人脸识别监考”“成绩导出”“题目导入模板下载”等功能。重点提醒一点:在线考试系统最容易被低估的是题目量,一旦题库到了几千道,列表查询和随机抽题都会明显变慢,到时候给 question 表的 question_type 和 bank_id 建好组合索引,是性价比最高的优化手段。
