☰
高校在线考试系统设计与实现:Vue+Node.js+MySQL全栈实践
2026/10/24 2:56:13 网站建设 项目流程

高校里每逢考试季,教务系统和任课老师都免不了一阵忙乱。线下试卷要印、要装订、要监考、要批改,考完还得录成绩、做分析,一套流程下来没有一周拿不下来。在线考试系统在高校里其实已经不算新鲜事了,但大多数学校用的还是采购来的商业产品,题量受限、科目配置僵硬、想改个规则还得提工单,体验并不好。所以我在做过几个校内的课程项目之后,决定从零搭建一套真正能落地使用的高校在线考试系统,前端选 Vue + Element UI,后端用 Node.js,数据库用 MySQL。这套组合谈不上多惊艳,但胜在开发效率高、改造成本低、部署简单,特别适合高校里小范围自用或者作为课程设计、毕设项目来深入理解。

这篇文章我会完整梳理这套系统的设计与实现过程,从需求拆解、表结构设计、接口规划,到前端工程化的组织方式、考试核心流程的代码实现,再到真实运行中踩过的坑,尽量把每一步为什么这么做都讲清楚。不管你是准备做类似项目,还是单纯想看看 Vue 全家桶加 Node.js 怎么搭一个有完整业务闭环的系统,这篇都应该能给你点实际参考。

1. 项目整体规划与需求剖析

1.1 高校考试场景的特殊性

在线考试系统看着和普通的 CRUD 管理员系统差不多,无非是用户管理、试题管理、考试管理、成绩管理这几摊事。但一旦放在高校场景里,很多细节就变得不一样了,这也是我一开始没有直接照搬开源商城或后台管理系统模板的原因。

高校考试的核心参与方有三类:管理员、教师、学生,但权限和诉求差异非常大。

教师需要的是快速组卷、发布考试、查看成绩统计,而不是每道题都手工录入到 Excel 再导入;学生要的是稳定的考试体验、公平的作答环境、以及交卷之后能立刻看到客观题得分;管理员在高校里通常是教务处或者信息中心的老师,他们要的是全院系、全课程的数据概览,而不是某个老师自己玩的小系统。

除了角色差异,考试本身还有一个强约束:时间窗口。高校考试通常有统一的开始和结束时间,但每个学生的进入时间不一定一致,这就意味着系统必须支持随到随考,并且在倒计时结束后强制收卷。这个逻辑听上去简单,真做起来涉及到服务器时间和客户端时间的对齐、自动交卷的可靠性、断网重连后的续考策略等一系列问题。

另外,高校课程的门类多,不同课程的题型差异很大。计算机类课程可能有程序填空、代码判断,外语类课程需要听力题,思政类课程偏重主观论述。系统如果只支持单选题和判断题,就很难真正铺开使用。所以在设计初期,我就把题型抽象成了可扩展的模型,而不是给每种题型都写一套死逻辑。

一次典型的在线考试流程大概是这个样子的:教师创建一场考试,配置考试时间、时长、总分、及格线,然后从题库中挑选试题或者设定抽题规则并发布;学生在规定时间内进入考试页面,系统开始倒计时并随机抽题;学生完成后手动交卷,或者倒计时结束系统自动交卷;客观题由系统立即判分,主观题由教师在线批阅;成绩发布后,学生可以查看成绩和答题明细,教师可以导出成绩并做简单的分数分布分析。

我把这套流程拆解成了几个核心模块:用户与权限模块、题库管理模块、组卷与考试发布模块、在线考试执行模块、自动阅卷与人工阅卷模块、成绩管理模块。整个系统的设计和代码编写都围绕这条主线展开。

1.2 技术选型的原因与取舍

技术选型是项目开始前最需要想清楚的事,因为后续所有开发都会围绕它展开。我当时在几个方案之间犹豫过:传统的前后端不分离方案、Spring Boot + Vue 方案、以及现在的 Vue + Node.js 方案。

节点选择上,最终敲定 Vue + Node.js,主要是基于这几点考虑:

第一,整个系统是典型的实时交互密集型应用。考试过程中,学生的操作非常频繁:切换题目、标记疑问、保存作答记录、倒计时刷新,如果用传统模版渲染那套思路,页面频繁刷新会严重影响考试体验。Vue 的响应式机制加上组件化开发,非常适合这种交互密集的场景。

第二,Node.js 的异步非阻塞模型对于考试系统的 IO 密集型场景很匹配。考试时大量学生同时提交答卷,后端主要做的是数据校验、落库、判分这些 IO 操作,Node.js 的异步模型能很自然地扛住这种突发并发。

第三,前后端同构或者接近的开发语言能显著降低维护成本。我选的是纯 JavaScript 技术栈,前端用 Vue,后端用 Express,这样一个人开发全栈项目时不需要在 Java 和 JavaScript 两套思维模式之间来回切换。如果有需要,甚至可以抽出一套公共的校验逻辑放到前后端共用。

第四,部署成本低。Node.js 应用可以非常轻量地部署在普通的 Linux 服务器上,甚至不需要专门的 Java 容器。对于高校里常见的低配服务器资源,这算是个实实在在的优势。

当然,这个方案也不是没有代价。Node.js 在 CPU 密集型的计算场景下表现一般,比如大规模的成绩统计分析会有点吃力,但高校单个院系的考试规模通常不会大到这个程度。真要处理几千人同时在线的大规模统考,这套架构还需要加 Redis 做会话管理和队列削峰,这个我在后面章节会提到。

Element UI 作为前端组件库是比较稳妥的选择。它的组件覆盖面广,表格、表单、弹窗、日期选择器、树形控件一应俱全,而且风格比较正式,很贴合管理系统和考试系统的视觉需求。Vue 3 发布之后 Element Plus 也成熟了,但我在这个项目里用的是 Vue 2.7 + Element UI 的组合,原因很朴素:它足够稳定,绕坑的资料多,而且我项目前期已经基于 Vue 2 写了部分代码,升级成本不划算。

1.3 核心功能模块地图

整个系统按角色和业务域划分,可以拆成六个模块,每个模块对应一张功能地图。

用户与权限模块管三件事:账号登录、角色识别、权限路由。学生账号可以由管理员批量导入,教师账号由管理员创建,密码默认统一设置,首次登录强制修改。这个模块表面简单,但权限控制贯穿前端路由、导航菜单、接口请求三层,属于典型的横切功能。

题库管理模块是整个系统的地基。题目要支持单选、多选、判断、填空、简答五种题型,每题要关联课程和知识点。这里最关键的设计是题目不能只服务一次考试,而是要进入公共题库,可以被多门课程、多场考试反复引用。另外还要支持富文本编辑,理工科老师往往需要在题干里放公式和图片,所以编辑器的选型也是一个隐藏决策点。

考试管理模块的核心是组卷。我实现了两种组卷方式:手动选题和随机抽题。手动选题适合小班课或者重点考试,老师逐题勾选;随机抽题适合大规模统考,系统按照老师设定的题型题量和知识点权重,从题库中抽取形成一套试卷。注意,每个学生的抽题结果可以是不同的,这需要在考试开始那一刻才生成试卷,而不是预先固定好,才能在实现组卷的同时兼顾防作弊需求。

在线考试模块是用户体验最关键的部分。界面采用左侧题号导航、右侧答题区的经典布局。题号导航可以区分待作答、已作答、已标记三种状态,底部显示倒计时条。答题过程中自动保存选项到本地存储,每 10 秒同步一次服务端。考试时间结束时自动交卷,同时把未答题目标记为空白。

阅卷与成绩模块分自动和人工两条线。客观题在交卷时由后端执行判定,主观题进入待批阅列表,由教师在批阅页面对照参考答案和评分标准打分。成绩发布后,学生端能看到总分和每题得分,教师端能导出 Excel 成绩表,并自动生成简单的及格率、平均分、分数段分布统计。

我一开始也想过把在线考试模块做得更重一些,比如加人脸识别、切屏监控、语音警告这些高级功能,但后来还是忍住了。原因很简单:长尾功能会让核心流程变得复杂,且高校考试的真实需求里,防作弊更多是靠监考制度和现场管理,技术手段只能起到辅助作用。系统稳定不卡顿、交卷不丢数据,远比花哨的监控功能重要。

2. 后端架构设计与核心实现

2.1 数据库表结构设计

数据库设计是整个系统中最需要用心的地方。考试系统的表结构核心在于把"人、题、卷、考、绩"这五类数据组织清楚。我最终设计了十张核心表,每一张都有它存在的必要。

用户相关的是用户表和学生考试记录表。用户表存账号、密码、姓名、角色、所属院系、学号工号。密码字段我用的是加盐哈希存储,虽然 Node.js 里有现成的加密库,但加盐设计和哈希算法的选型依然要明确,这关系到账号安全。

题库相关的是试题表和知识点表。试题表存课程 ID、题型、题干、选项、答案、难度、知识点 ID 等字段。这里有个关键设计:选择题和判断题的选项与答案,我用 JSON 字符串存在题目表的 option 和 answer 字段中。这个设计看似偷懒,实际上大大方便了题型扩展——以后要加多选题,只是选项数量不同,结构不用改。当然如果需求复杂到要在数据库层面做选项检索,这种设计就不合适了,但高校题库场景基本不需要。

考试相关的是考试表和试卷表。考试表描述一场考试的基本信息:课程 ID、考试名称、开始时间、结束时间、考试时长、总分、及格分数、试卷生成规则。试卷表则是快照——每场考试每个学生的试卷内容,学生在考试开始时生成的试题排列顺序、选项顺序都会记录在这张表中。这里需要注意,试卷表存的是考题的排列快照,而不是关联查询实时渲染,这是防止"考试过程中题目被老师修改导致学生试卷变样"的关键保障。

作答相关的是答题记录表。这张表记录考生对每道题的作答内容:学生 ID、考试 ID、题目 ID、作答内容、是否标记、得分、阅卷状态。题目是单选,作答内容就是选项编号;是主观题,作答内容就是一段文本。统一用文本字段保存,便于后续扩展新题型。

成绩相关的是成绩表和主观题批阅流水表。成绩表是最终结果,存总分、客观题得分、主观题得分、交卷时间、考试用时。批阅流水表则记录教师每次给某道题打了多少分、批阅备注、批阅时间,便于审计。

这十张表之间的关系,可以简单梳理成三条主链路:学生选课和教师授课的关系在用户表内部通过课程成员表维护;题库表服务于试卷生成;试卷、答题记录、成绩之间的数据流贯穿一场考试从开始到结束的完整生命周期。

2.2 接口设计与业务逻辑分层

后端我选择了 Express 框架。Express 虽然老,但稳定、资料多、中间件生态丰富,对于这个项目再合适不过了。

接口设计遵循 RESTful 风格,按资源划分。用户登录对应 POST /api/user/login,试题增删改查对应 /api/question 下的各个方法,考试管理对应 /api/exam,答题提交对应 POST /api/paper/submit。实际的业务逻辑并不直接写在路由里,而是拆成了 service 层。路由只做参数校验和 HTTP 响应,真正的业务逻辑放在 service 里,这样做的核心价值是便于单元测试和逻辑复用。比如自动判分的逻辑既会在学生交卷时被调用,也会在管理员重新判分时被调用,抽成一个独立的 service 让这两个场景都调用同一份代码,能避免逻辑分叉。

用户身份校验我用的是 JWT(JSON Web Token)方案。登录成功之后,后端签发一个带过期时间的 Token,前端每次请求都在请求头里带上。后端写了一个中间件,统一解析 Token、校验有效期、挂载用户信息到请求对象上。同时针对管理员和教师接口额外做角色校验,确保只有具有相应身份的用户才能访问。

对于考试这种对时间比较敏感的场景,接口还有一个细节:前端请求头统一携带时间戳,后端以服务器时间为准进行校验。比如学生交卷时提交的时间不能早于开考时间、不能晚于当前时间,这些校验逻辑放在了交卷的 service 中,从业务层面防住了一部分人为改时间的作弊手段。

2.3 登录鉴权与 JWT 的具体实现

我把登录鉴权的代码贴出来,这是整个系统最基础也最有代表性的后端代码之一。

服务端在 user 表中存密码时使用的是一层不可逆的哈希加上一份随机的盐。为了不让攻击者通过彩虹表破解,每个用户的盐都不同。Node.js 侧用 crypto 模块生成盐和摘要,判断登录时取同一套规则再哈希一次比对。

在 Express 项目中,我按下面的方式组织登录相关代码:

// routes/auth.js const express = require('express'); const router = express.Router(); const authService = require('../services/authService'); const { v4: uuidv4 } = require('uuid'); router.post('/login', async (req, res, next) => { try { const { username, password } = req.body; if (!username || !password) { return res.status(400).json({ code: 1, message: '用户名和密码不能为空' }); } const result = await authService.login(username, password); res.json({ code: 0, data: result }); } catch (error) { next(error); } });

这里有几个值得注意的细节。路由层只做两件事:拿到请求体、调 service。至于用户名存不存在、密码对不对,都是 service 层去判断的。这样做让路由层非常干净,出问题了也能快速定位是参数问题还是业务问题。

service 层代码:

// services/authService.js const crypto = require('crypto'); const jwt = require('jsonwebtoken'); const db = require('../config/db'); const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key'; async function login(username, password) { const [rows] = await db.query('SELECT * FROM user WHERE username = ?', [username]); if (rows.length === 0) { throw new Error('用户名或密码错误'); } const user = rows[0]; const hash = crypto.pbkdf2Sync(password, user.salt, 10000, 64, 'sha512').toString('hex'); if (hash !== user.password_hash) { throw new Error('用户名或密码错误'); } const token = jwt.sign( { userId: user.id, username: user.username, role: user.role, }, JWT_SECRET, { expiresIn: '12h' } ); return { token, userInfo: { id: user.id, username: user.username, realName: user.real_name, role: user.role, department: user.department, }, }; }

这个实现里最需要注意的是统一抛错机制。service 中 throw 出的错误,由 Express 的错误处理中间件统一捕获并包装成 JSON 响应返给前端。这样前端只需要按约定的格式解析即可,不需要每次为不同错误写法写不同的处理逻辑。

封装的鉴权中间件:

// middlewares/auth.js const jwt = require('jsonwebtoken'); module.exports = function (req, res, next) { const authHeader = req.headers.authorization; if (!authHeader || !authHeader.startsWith('Bearer ')) { return res.status(401).json({ code: 401, message: '未登录或登录已过期' }); } const token = authHeader.split(' ')[1]; try { const payload = jwt.verify(token, process.env.JWT_SECRET || 'your-secret-key'); req.user = payload; next(); } catch (error) { return res.status(401).json({ code: 401, message: '登录状态无效,请重新登录' }); } };

另外,为了适配不同的权限角色,我写了两个扩展中间件 requireTeacher 和 requireAdmin,它们内部先调用 auth,再判断 req.user.role 是否匹配。这样在教师新增试题、学生提交答案的接口上分别加上不同的中间件,权限控制就清晰了。

这里的业务逻辑虽然简单,但有个容易踩坑的点:JWT_SECRET 绝对不能硬编码在前端或用默认值上线。我在本地开发时会写死,上线前会通过环境变量注入真实密钥,否则攻击者可以伪造任意身份的 Token。

3. 前端工程化与页面实现

3.1 项目脚手架和目录组织

前端部分我用 Vue CLI 创建项目,然后按业务模块整理目录:

src/ api/ # 按模块拆分的接口请求封装 assets/ components/ # 通用组件 views/ login/ admin/ # 管理员页面 teacher/ # 教师页面 student/ # 学生页面 router/ store/ utils/ App.vue main.js

这套目录结构的核心思想是:按角色划分页面,按模块划分 API。好处是权限控制和导航菜单可以直接跟 views 对应起来。

路由设计上采用动态路由。静态路由只保留登录页和全局 404 页,其他路由在用户登录后根据角色动态生成。比如管理员登录后,动态路由会注册学员管理、院系管理、系统设置等页面;教师登录后动态路由注册题库管理、考试管理、阅卷管理;学生登录后注册我的考试、考试记录、成绩查询。

动态路由的实现依赖 Vue Router 的 addRoutes 方法。登录成功后,前端拿到用户角色,从一份预先定义好的角色路由映射表里拉出该角色对应的路由配置,再逐个动态注册。这个方案比静态路由写死所有页面,然后靠导航守卫拦截更安全——因为某些页面组件根本不会出现在未授权用户的路由表里。

我踩过一次坑是刷新后动态路由丢失。因为 Vue Router 的 addRoutes 只在内存中,刷新页面后内存清空,路由配置就没了。解决方法是把用户角色和可访问路径列表存到本地存储,刷新后先根据本地存储重建路由,再渲染页面。如果你在做这类项目,一定要先在根组件或者导航守卫里把路由恢复逻辑写好,否则学生会遇到"考试页面可以打开,一刷新就 404"的问题。

3.2 axios 封装与请求拦截

前端所有的 HTTP 请求都通过 axios 发起,我在封装的时候重点处理了两件事:统一附上 Token,统一处理过期和错误弹窗。

现在的系统都要求前端从 localStorage 中读取 Token,放进 Authorization 请求头。但写这个逻辑的时候我坚持不让前端直接操作 localStorage,而是封装了一个 auth 工具模块,统一做读写删的操作。这样后续如果要把 Token 改存到 cookie 或者内存中,只需要改工具模块,全站不用动。

// utils/auth.js const TOKEN_KEY = 'exam_token'; const USER_KEY = 'exam_user'; export function getToken() { return localStorage.getItem(TOKEN_KEY); } export function setToken(token) { localStorage.setItem(TOKEN_KEY, token); } export function getUser() { const raw = localStorage.getItem(USER_KEY); return raw ? JSON.parse(raw) : null; } export function setUser(user) { localStorage.setItem(USER_KEY, JSON.stringify(user)); } export function clearAuth() { localStorage.removeItem(TOKEN_KEY); localStorage.removeItem(USER_KEY); }

axios 的请求拦截器和响应拦截器是核心:

// utils/request.js import axios from 'axios'; import { getToken, clearAuth } from './auth'; import router from '../router'; const service = axios.create({ baseURL: process.env.VUE_APP_BASE_API || '/api', timeout: 15000, }); service.interceptors.request.use((config) => { const token = getToken(); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); service.interceptors.response.use( (response) => { const res = response.data; if (res.code !== 0) { if (res.code === 401) { clearAuth(); router.push('/login'); } return Promise.reject(new Error(res.message || '请求出错')); } return res; }, (error) => { if (error.response) { if (error.response.status === 401) { clearAuth(); router.push('/login'); } } return Promise.reject(error); } ); export default service;

这里唯一需要琢磨的是状态码的判断方式。前端的请求拦截器统一走后端返回的 { code, data, message } 结构,HTTP 状态码只用来标识网络层是否成功。考试提交这种高频且不能丢的请求,我在页面里还额外加了重试按钮,如果网络波动导致提交失败,学生可以看到明确提示并能重新提交。

3.3 主要页面组件的搭建

前端页面我按角色和业务域划成三类,先说学生端,这是交互最复杂的部分。

学生登录后的首页是考试列表页。页面展示当前登录学生已选课程下的所有考试,包括考试名称、课程名、考试时间、状态(未开始、进行中、已结束、已交卷)。状态刷新我用的定时轮询,每 30 秒调一次接口拉最新状态。这个方案不如 WebSocket 实时,但胜在实现简单且足够满足需求。如果学校规模再大,或者对实时性要求很高,可以加 WebSocket 或 SSE,但对这个小项目没必要。

进入考试页是整套系统的重头戏。考试页布局分三栏:左侧是题号导航区,题目用按钮排列,依据作答状态显示不同颜色;中间是题干和答题区;底部是倒计时和交卷按钮。页面核心逻辑集中在 exam.vue 组件里,它需要管理当前题目索引、所有题目的作答状态、剩余时间、标记状态、定期保存。

试卷加载的时候,后端会返回一份已经随机打乱题目和选项顺序的试卷 JSON,前端只需要负责渲染和记录。由于每题类型可能不同,答题区要根据题型动态渲染不同的输入组件:单选题渲染一组 radio,多选题渲染 checkbox,判断题渲染开关按钮,填空题渲染输入框,简答题渲染文本域。我用一个动态组件架构做了这件事,通过 computed 根据当前题目的类型值返回对应组件,这样新的题型接入时只需要新写一个组件,不需要改动考试页主体逻辑。

教师端和管理员端的页面相对常规。教师端的题库管理页面用了 Element UI 的表格展示题目,工具栏上有新增、批量导入、筛选操作。考试管理页面是一个两段式布局:上半部分展示所有考试列表,下半部分展示选中考试的试卷结构,可以直接调整试题分值。管理员端主要是用户管理页面和全局配置页面,用户管理页面支持单个创建和批量导入学生 Excel,我用了一个开源的前端表格解析库,在浏览器端直接解析 Excel 文件生成用户数组,再一次性调接口写入数据库。

Element UI 的组件在这个项目里承担了大量基础工作。表格、表单、分页、标签、消息提示、确认弹窗这类高频组件基本覆盖了需求,我几乎没有为样式写过自定义 CSS,最多通过 scoped 覆盖个别组件默认样式。前端的核心工作量集中在状态管理和考试页的交互细节上。

3.4 状态管理与数据流的思考

项目里我用 Vuex 做了全局状态管理。说实话,对这个项目而言 Vuex 不是必须的,但用了之后,权限路由和登录用户信息这种全局数据的管理确实变得更清爽了。

我在 store 里划分了三个模块:user 模块存当前用户信息和登录状态,exam 模块存考试相关状态,app 模块存全局 UI 状态。

user 模块在登录成功后统一提交 mutation 写入信息,在登出时清除。这里的核心考虑是让所有页面获取用户信息的方式保持一致,避免出现"这个页面从 localStorage 取用户、那个页面从 Vuex 取用户"的混乱局面。

考试相关的状态我原本不想放进 Vuex,因为考试页的作答数据量较大且频率很高,每次 mutation 都会触发响应式更新,可能会造成不必要的性能消耗。后来权衡了一下,还是把考试基本信息(剩余时间、当前题目、作答状态数组)放进了 Vuex,原因是交卷之后需要组装整份答卷数据,而且一旦考试页面发生意外跳转,Vuex 中还能保留部分状态,恢复体验会好一些。至于每道题作答的具体内容,我用了一个非响应式的普通对象来缓存,只有切换到下一题时才同步到 Vuex,避免高频输入导致 Vuex 频繁触发渲染更新。

这里有一个权衡局限:Vuex 数据刷新就丢,真正的持久化不能依赖它。所以考试页在 mounted 钩子中会从服务端拉取一份"考试进行中"的恢复快照,如果用户之前已经答过一部分题目,接口会将已保存的作答记录返回过来。也就是说,Vuex 只是临时缓存,服务端才是真正的数据源。理解这一点非常重要,我在做断点续考功能时就是基于这个思路实现的。

4. 考试核心流程的实现细节

4.1 试题抽取与试卷生成

组卷是考试系统里最容易被低估复杂度的模块。我刚做第一版时想过直接随机取题,但很快就发现,如果教师设置了一套"单选题 20 道、每题 2 分,知识点 A 覆盖 60%、知识点 B 覆盖 40%,难度中等为主"的规则,纯随机无法满足约束。

所以我实现了一个简单的抽题引擎。抽题引擎接收三个参数:课程 ID、抽题规则、试卷模板。

规则是一个 JSON:

{ "sections": [ { "type": "single", "count": 20, "scorePerQuestion": 2, "knowledgePoints": [ { "id": 1, "weight": 60 }, { "id": 2, "weight": 40 } ] } ] }

抽题引擎遍历每个 section,按照知识点权重分配该知识点的抽题数量,然后在对应知识点下随机选取题目。为了避免同一道题在考试页面中出现两次,整个抽取过程中我用了一个全局的已抽取题目 ID 集合,跨 section 也去重。

试卷生成的时间点有两个选择:一个是考试发布时生成一份固定试卷,所有学生都一样;另一个是学生进入考试时动态生成,每次结果可能不同。我选择了后者,因为随机抽题模式下每个学生的题序和选项序不同,学生之间直接对答案的难度大增。这个方案的实现写在后端的 examService 里,学生点击"开始考试"按钮后,后端先校验考试时间窗口,再调用抽题引擎为学生生成试卷快照,并写入 paper 表。

试题和选项乱序是个容易被忽视的细节。选择题如果选项顺序永远一样,前面座位的学生先交卷,后排学生就能看到选项排列规律。我在生成试卷时对题目顺序和选项顺序都做了 Fisher-Yates 洗牌,并将乱序后的结果存进试卷快照中。这样即便老师后面修改了题库中某道题的选项,学生已经生成的试卷快照不会受影响,彻底规避了运行时数据变更导致试卷变样的问题。

4.2 考试倒计时与自动交卷

倒计时是考试系统中事故高发的地带。最简单的做法是前端拿到考试总时长,在本地做 setInterval 递减。但这样做的风险很大:学生只要改一下浏览器时间,甚至只是暂时挂起页面,倒计时就会失真。正确做法是以后端服务器时间为准,前端只做展示和同步。

我实现的方式是:学生进入考试后,后端在返回试卷快照的同时返回两个字段——serverTime 和 examEndTime。前端计算本地时钟与服务器时钟的偏差,然后每秒用"当前服务器时间 = 本地当前时间 + 时间偏差"来刷新倒计时。这样即使学生手动改本地时间,只要时间偏差计算不变,倒计时依然准确。

自动交卷在真正执行时需要防止"前端计时结束时网络还在路上"的情况。所以我在后端也做了保护:提交答案的接口会校验当前服务器时间是否超过考试结束时间,如果未结束则正常判分;如果已超过,则允许提交但会非常严格地检查提交内容的时间窗口。同时,在前端计时到达 10 秒时,我会先自动执行一次保存答案,确保最坏情况下丢失的数据量小于 10 秒。

除此之外,我还实现了一套"心跳续约"机制:前端每 10 秒向后端发送一个心跳请求,后端根据心跳时间判断学生是否还在考试页面。如果超过 30 秒没有收到心跳,后端认为学生可能掉线,但不会立刻交卷,而是将考试状态标记为"考试中-离线",学生重新进入时可以恢复。这比单纯的"前端倒计时结束强制交卷"要温和得多,也符合真实考试场景。

4.3 自动判分与成绩统计

试卷提交到后端后,判分逻辑按题型分开处理。

单选和判断题直接比对选项编号,多选题因为选项顺序可能被打乱,判断逻辑不是简单地比较字符串,而是先将作答的选项编号拆分、排序、再比对,这样才能忽略学生勾选顺序的影响。填空题由于答案可能有多种等价写法,我的实现是允许教师预设多个正确答案,用||分隔,比如"北京||首都",学生答任何一个都算对,这比精确字符串匹配实用很多。

客观题得分即时写入答题记录表和成绩表。主观题则在成绩表中标记为"待批阅",待教师进入阅卷页面手动打分。

成绩统计我单独写了一个接口 /api/exam/{examId}/statistics,返回平均分、中位成绩、各分数段人数分布、每题正确率。这些统计信息生成后直接以 JSON 形式返回前端,前端用 Element UI 的图表组件渲染成柱状图或饼图。我本来想直接在数据库里用 SQL 做统计,后来发现涉及子查询和多表联查较多,还是用 Node.js 在内存里做聚合更直观,数据量不大,性能完全不是问题。

4.4 断点续考与异常恢复

在线考试最容易被投诉的情况是:学生答到一半,电脑死机或者断网,重进系统发现试卷白卷或者倒计时重新开始。为了把这个体验做好,我在设计时专门为考试流程加了状态机。

学生在考试过程中的状态有四种:未开始、考试中、已交卷、考试中断。核心思路是:只要学生没有点击交卷且考试时间未到截止时间,后端不会主动结束考试。哪怕是浏览器关闭,后端保留学生已保存的答案和剩余时间。学生重新登录后,"我的考试"列表里会显示"继续考试",点击后拉取历史答案快照,恢复现场。

恢复逻辑的代码在 paperService.getRecoveryData 中,实现要点是获取学生的 paper 快照和该考试下所有答题记录,组装成和初始试卷一样的结构,把已作答的答案回填到对应题目上。这个功能花了我不少时间,但回报是实实在在的——学院里试用期间,最受好评的功能就是这个断点续考。

5. 常见问题排查与踩坑记录

5.1 并发与性能问题

第一次全院模拟考试时,我遇到了并发性能问题。考试开始前的 30 分钟里,300 多名学生同时点击"进入考试",我的 Node.js 后端瞬间涌入大量请求,MySQL 连接数被打满,部分请求直接超时。

排查下来发现两个根因。第一,我用的 mysql 连接池配置太保守,默认最大连接数 10,显然不够。第二,试卷生成逻辑中包含了较多的同步查询串行执行,每个请求占用的连接时间太长。

解决方案有两步。第一步,把连接池调大,设置 connectionLimit 为 50,并且为考试开始前的访问高峰单独预留连接。第二步,优化试卷生成逻辑,将原本逐条查询题目的方式改为批量查询——一次把题目 ID 全部取出,再用 WHERE id IN (...) 一次性查出所有题目详情,在内存中完成组合。这个改动让试卷生成接口的耗时从平均 1.2 秒降到了 150 毫秒左右。

这里我最深刻的体会是:Node.js 的性能问题通常不出在语言本身,而出在数据库访问方式上。只要不是在循环里逐条发 SQL,大多数性能问题都能解决。

另外一个隐藏问题是静态资源。Element UI 的 JS 和 CSS 文件比较大,如果直接由 Node.js 服务,首屏加载会有点慢。我的解决办法是让 Nginx 直接托管前端静态资源,Node.js 只负责 API。前端页面构建后的 dist 目录直接指向 Nginx 的 root,API 请求通过 Nginx 反代到 Node.js 端口。这一层外部服务的引入,对考生的首屏加载速度提升非常明显。

5.2 考试时间不准确的问题

考试过程中出现过几次"学生倒计时已经结束但后端还没判定交卷"的情况。排查后发现原因出在服务器时间和学生本地时间的偏差累计上。前端每秒刷新倒计时,如果学生电脑关机或休眠一段时间,回来之后本地时间和实际时间的偏差会被浏览器重新计算,导致倒计时跳变。

我的修复方式是:每 60 秒重新向后端请求一次服务器时间校准偏移量。这个接口很轻量,只返回当前 Unix 时间戳,前端计算新的 offset 后替换旧值。这样即使学生休眠了几分钟,最坏的倒计时误差也就一分钟不到。

另外需要注意一个细节:不能用 Date.parse 新 Date() 这类带时区歧义的解析方式。前后端统一约定所有时间戳均为毫秒级 Unix 时间戳,传输和比较都用纯数字,彻底规避时区问题。个别业务字段需要展示给用户的时候,再在显示层转换为本地时间字符串。

5.3 阅卷流程中的状态同步

教师批阅主观题时,我遇到过一个比较隐蔽的状态同步问题。教师在批阅页面给一道题打了 5 分,然后切到下一道题,再切回来时,发现之前打的 5 分变成了"未批阅"。原因是前端在切题时直接调用了"获取下一题"接口,下一题的数据结构里不含上一题的评分状态,而 Vue 组件重新渲染时把上一题的输入框清空了。

解决的思路是前端维护一个批阅草稿本。教师在页面上的所有打分操作先写入前端内存中的草稿对象,只有点击"提交"时,才把整份试卷的批阅结果一次性传给后端。配合"批阅有改动未保存时离开页面要弹窗警告"的功能,能有效防止教师的工作成果意外丢失。

这个小问题暴露出一个常见设计误区:API 接口返回的数据结构应该完整反映业务对象状态,而不是按页面需要裁剪。我后续把"获取考题详情"接口改成了"获取当前批阅会话"接口,返回内容包括题目、参考答案、学生作答、当前打分、批阅备注,教师端就可以基于完整状态来做 UI 渲染。

5.4 一点总结性的避坑提醒

最后想着重提醒几件事,这些都是我在真实使用中吃过亏、事后总结出来的经验。

第一,不要把前端的 localStorage 当作数据库来用。考试页面中,答题答案我会实时存一份到 localStorage 作为临时草稿,但服务端持久化才是唯一的合法数据源。前端存的东西可能被用户清掉或篡改,心里要时刻有这个数。

第二,试卷快照非常重要。任何涉及考试的系统中,学生考试时的试卷内容必须和题库数据隔离。你改题库的选项、更新题目的题干,或者调整知识点分类,都不应该影响已经开始的考试。快照的做法保证了这一点,也为后续审计提供了依据。

第三,Element UI 的表格在同时渲染大量数据时会有点卡。如果一场考试的题目列表超过 300 条,建议用虚拟滚动或分批渲染,不要一次性全塞进 DOM。这个问题在教师端修改试卷时最容易出现。

第四,权限路由的守卫逻辑要覆盖"页面刷新"的场景。很多前端方案在登录后注入路由,但一刷新就失效,学生重新进入考试页面会直接跳回登录页。你在做的时候,务必把路由恢复的逻辑写在根组件启动时,而不是依赖用户重新登录。

6. 一点个人经验与扩展方向

项目做到这个程度,整套系统已经可以在小范围内稳定跑起来了。真实使用下来,我认为在线考试系统的核心价值不在于把线下考试搬上线,而在于把考试过程中的数据沉淀下来,形成对教学有反馈意义的信息链。自动判分省下的是教师的时间,逐题正确率和知识点的关联分析,才是这套系统真正值得挖掘的部分。

以我个人的开发经验来说,如果从头再做一遍,我会在以下两件事上投入更多时间。第一,把防作弊和考试行为分析做成更完善的功能,比如记录学生的切换题目次数、答题经过时长、交卷前修改次数,虽然不能完全阻止作弊,但可以为监考老师提供一份"重点关注名单"。第二,把前端工程进一步模块化,组件库和业务组件彻底分离,为将来接入移动端或者小程序留好接口。

这个项目的后续扩展空间其实不小:题库支持批量导入只做了 Excel 的简单模板,还没有做 Word 解析;成绩分析目前只做了基础统计,没有和教学评估系统打通;学生端的适配也主要针对 PC 浏览器,移动端体验还需要优化。如果你也在做类似系统,建议不要一开始就追求大而全,先把一场考试的完整链路跑通,再逐步往两侧延伸功能,这条路走起来会更扎实。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询