ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

在线考试系统开发实战:SpringBoot + Vue前后端分离与JWT鉴权设计

在线考试系统开发实战:SpringBoot + Vue前后端分离与JWT鉴权设计 简介这是一套基于SpringBootVue前后端分离架构的在线考试系统源码适合Java全栈初学者、毕业设计学生以及需要快速搭建考试类项目的开发者。后端由SpringBoot提供RESTful接口前端采用Vue和Element-UI组件库构建页面实现了用户登录注册、试题管理、在线考试、自动判分、成绩查询等典型功能能帮助使用者完整理解前后端交互流程。压缩包内共165个文件以72个Java源码和34个Vue组件为主体另含14个JavaScript脚本、数据库SQL脚本、Maven构建文件、配置文件及演示截图与GIF动图整体仅2.39MB目录组织规范导入开发工具后即可查看运行效果。资源附带计算机网络试卷作为测试数据便于直接体验答题场景并排查部署问题。目前已有2825人学习下载无论是用于课程设计还是入门前后端分离项目都有不错的参考价值尤其适合希望将书本知识转化为实际项目的开发者。1. 在线考试系统选型为什么用 SpringBoot Vue 拆开做在线考试类项目网上一抓一大把但大多数只有“能登录、能维护题目”的骨架真正涉及到答题时间控制、重复交卷、答案字段防泄漏时细节全被省略了。这套基于 SpringBoot 和 Vue 2 Element-UI 的在线考试系统后端只管出题、鉴权、判分和落库前端独立负责页面跳转、答题交互和倒计时展示两边只通过 JSON 接口沟通。这样的拆分让开发期调试成本低部署时也能把静态页面和接口分开运维。项目把用户分成管理端和考生端两个入口管理端维护试卷和题库考生端完成从选卷到交卷的完整闭环在线演示环境里就配了一套“计算机网络”试卷作为测试数据。对做毕设或刚接触前后端分离的人来说值得照着源码把这条链路完整走一遍对已经有几年经验的开发看点是它怎么用一张用户表、一张考试记录表配合 JWT 和数据库唯一索引把权限隔离与防重复提交做得比较干净。2. SpringBoot 后端实体建模、MyBatis 持久层与 JWT 鉴权后端结构并不复杂表数量不多难在把每张表的职责边界定清楚。把用户、试卷、题库、考试记录拆成四个维度之后管理端和考生端的接口入口也就跟着清晰了。下面从实体设计开始逐步落到持久层 SQL 和登录拦截器。2.1 一张用户表如何同时服务管理端与考生端sys_user 是整套系统的入口表关键设计是使用 role 字段区分身份1 为管理员2 为考生。这两类角色在同一张表里维护比拆成 admin 表和 student 表方便得多登录逻辑只需要查一次库角色校验统一放在拦截器层面新增角色时也只改一个字段。字段类型说明idint主键自增usernamevarchar(64)登录账号唯一索引passwordvarchar(100)BCrypt 加密后的密码串roletinyint1 管理员2 考生nicknamevarchar(64)页面展示昵称密码不能明文落库这是底线。常见做法是后端用 BCrypt 加密后写入登录时用matches方法比对原文与密文而不是把数据库里的密码查出来后反解。这样即使数据库备份文件泄露也不会直接暴露登录凭据。如果项目里用的是 MyBatis-Plus实体类上要显式指定表名和主键策略否则默认的雪花 ID 会让前端拿到的学生 ID 变成 19 位长数字既不好看也不方便拼 URL。TableName(sys_user) public class SysUser { TableId(value id, type IdType.AUTO) private Integer id; private String username; private String password; /** 1 管理员2 考生 */ private Integer role; private String nickname; }这里的TableName(sys_user)把实体类映射到数据表IdType.AUTO强制走数据库自增主键。MyBatis-Plus 3.5.x 之后版本如果没有声明主键策略默认按雪花 ID 处理和自增表结构会直接冲突。配套的 application.yml 里通常还会配置 mapper 扫描路径和驼峰映射mybatis-plus: mapper-locations: classpath*:mapper/**/*.xml configuration: map-underscore-to-camel-case: truemap-underscore-to-camel-case要打开否则数据库里的submit_time无法自动映射到 Java 实体的submitTime字段。很多联调问题都出在这个配置忘记开接口返回的submit_time是 null。2.2 考试记录表结构用唯一索引防住重复交卷考试记录表是整个系统里最容易出问题的一张表。考生点击交卷前端向后端提交答卷后端写入一条记录这个操作天然存在并发场景用户双击按钮、浏览器重复请求、网络超时后的重试都会导致同一个人在同一张试卷上留下多条成绩。要根治不是在前端加 loading 状态而是在数据库层做约束。CREATE TABLE exam_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, paper_id INT NOT NULL, start_time DATETIME NOT NULL, submit_time DATETIME NULL, total_score DECIMAL(5,2) NOT NULL DEFAULT 0, status TINYINT NOT NULL DEFAULT 0, UNIQUE KEY uk_user_paper (user_id, paper_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;约束项实现方式作用唯一索引uk_user_paper(user_id, paper_id)同一考生同一试卷只能存在一条记录状态字段status0 答题中1 已交卷2 超时区分异常退出和正常交卷非法数据防护外键在逻辑层控制避免直接建物理外键拖慢写入性能user_id和paper_id的组合唯一索引是防重复交卷的第一道防线。多人同时交卷时数据库只允许第一次 insert 成功后续请求会抛出DuplicateKeyException后端捕获后转换为“该试卷已提交”的提示即可。status 字段用来处理“答题中退出”的场景。考生进入考场时先写入一条 status0 的记录交卷时再更新为 1。如果只做了交卷写入没做进入时预留中途断电再回来就不知道该恢复到哪道题。2.3 登录令牌JWT 签发、校验与拦截器排除路径管理端和考生端使用同一套登录接口后端签发 JWT前端把 token 存到 localStorage。JWT 里至少要有 userId 和 role 两个声明后端接口通过解析 token 决定放行还是拒绝。public class JwtUtil { private static final String SECRET your-secret-key; // 生成 token有效期 2 小时实际项目按需调整 public static String createToken(Integer userId, Integer role) { return Jwts.builder() .claim(userId, userId) .claim(role, role) .setExpiration(new Date(System.currentTimeMillis() 2 * 60 * 60 * 1000)) .signWith(SignatureAlgorithm.HS256, SECRET) .compact(); } }token 签发出来之后客户端每次请求都要带上后端用一个拦截器统一校验而不是在每个 Controller 里重复解析。拦截器要解析 token 并把 userId 和 role 放回 request 属性方便后面的业务方法直接读取。public class JwtInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 从请求头取原始 token常规写法是 Bearer 前缀 String token request.getHeader(Authorization); if (token ! null token.startsWith(Bearer )) { Claims claims JwtUtil.parse(token.substring(7)); if (claims ! null) { request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } } response.setStatus(401); response.setContentType(application/json;charsetUTF-8); response.getWriter().write({\code\:401,\message\:\登录已过期\}); return false; } }拦截器需要注册到 WebMvc 配置里并且明确排除路径否则登录接口自身也会被拦下来路径拦截策略/api/login放行/api/captcha放行/api/exam/**需要登录考生管理员均可访问/api/admin/**需要登录且 role 必须为 1对于只允许管理员访问的地址在拦截器里加一层 role 判断。为什么不直接引入 Spring Security因为这个项目角色只有两种接口量也不大拦截器加 JWT 已经能覆盖权限场景引入 Security 反而要维护一整套过滤链SpringBoot 版本升级时还容易踩配置类不兼容的坑。2.4 MyBatis 多表联查的两种姿势与答案字段脱敏后端有一类高频查询是“试卷详情连带题目列表”涉及 exam_paper 和 question_bank 两张表。MyBatis-Plus 的 Wrapper 能解决单表查询但遇到 LEFT JOIN 时还是写 XML 更直观。常见做法是把题目选项、题干、分值一次性查出来在前端按题目类型渲染。select idselectPaperDetail resultTypecom.exam.vo.PaperDetailVO SELECT e.id AS paperId, e.title, q.id AS questionId, q.type, q.content, q.options, q.score FROM exam_paper e LEFT JOIN question_bank q ON e.id q.paper_id WHERE e.id #{paperId} /select这个查询刻意没有 select answer 字段。在线考试系统的题目表通常会有一列正确答案但考生答题接口绝不能把它一起传出去否则打开浏览器控制台就能看到标准答案。正确做法是面向考生的查询把 answer 字段排除在 SELECT 之外判分时另走一条只查答案的 Service 方法在服务端完成比对。另外 options 字段建议存 JSON 字符串比如{A:TCP,B:UDP,C:IP,D:HTTP}一条记录搞定任意数量的选项而不是设计成 option_a、option_b、option_c 五个固定列。前端拿到 JSON 字符串后用JSON.parse解析成对象再循环渲染到 radio-group 里。3. Vue Element-UI 前端路由守卫、答题交互与时间边界前端工程按 Vue 2 Element-UI 的经典结构组织管理端页面和考生端页面通过路由分开。Element-UI 本身只服务 Vue 2如果项目升级到 Vue 3组件库要换成 Element Plus这是拿到源码后最容易踩的坑。3.1 路由守卫与登录态丢失后的回跳处理考试系统最不能接受的是考生答到一半刷新页面后 token 失效被踢回登录页重新登录完还得手动找回刚才的试卷。解决思路是路由守卫里只判断 token 是否存在同时把目标路径放进 query登录成功后自动回跳。const router new VueRouter({ mode: history, routes: [ { path: /login, component: Login }, { path: /exam/list, component: ExamList, meta: { requiresAuth: true } }, { path: /exam/start/:id, component: ExamRoom, meta: { requiresAuth: true } } ] }) router.beforeEach((to, from, next) { // 没有登录态时跳转登录页并带上回跳路径 const token localStorage.getItem(token) if (to.meta.requiresAuth !token) { next({ path: /login, query: { redirect: to.fullPath } }) return } next() })meta.requiresAuth用来标记需要登录才能访问的路由query.redirect里存的是用户原本要进入的完整路径。登录组件在拿到 token 之后判断存在 redirect 就router.replace(redirect)否则去考试列表页。这段逻辑虽然短但能明显降低真实考试场景中的流失率。3.2 答题卡数据结构不依赖 el-form 的答案收集方式考试作答页面不适合用 el-form 承载所有答案。el-form 擅长的是有明确表单项的页面而答题场景需要的是快速记录“题号 - 用户选择”的映射关系。用对象字面量就能解决key 是 questionIdvalue 是用户选的选项值。data() { return { paperId: this.$route.params.id, answers: {}, // 题号与答卷映射例如 { 101: A, 102: B } questionList: [] } }, watch: { // 深监听答案变化实时写入 localStorage避免刷新丢数据 answers: { handler(value) { localStorage.setItem(exam_answers_ this.paperId, JSON.stringify(value)) }, deep: true } }模板里用 el-radio-group 绑定answers[question.id]用户每点一次数据就自动写入 answers 对象。watch 里的 deep 是必须的否则只监听对象引用变化内部属性更新时不会触发保存。el-radio-group v-modelanswers[question.id] el-radio v-foropt in JSON.parse(question.options) :keyopt.key :labelopt.key {{ opt.key }}. {{ opt.value }} /el-radio /el-radio-grouplocalStorage 的 key 里带上 paperId可以防止同时打开两套试卷时互相覆盖。刷新页面后在 created 钩子里读回 exam_answers_ paperId把 JSON 反序列化回 answers 对象答题进度就恢复了。3.3 Element-UI 表单校验在题目维护页的正确用法Element-UI 的校验能力更适合放在管理端。管理员维护题目时题干、题型、分值都是必填项用 el-form 的 rules 做校验比后端返回错误再渲染页面友好得多。rules: { title: [{ required: true, message: 请输入题干, trigger: blur }], type: [{ required: true, message: 请选择题型, trigger: change }], score: [{ required: true, message: 请输入题目分值, trigger: change }] }注意trigger的差异input 类型用 blurselect 和 radio 用 change。管理端弹窗里的保存按钮调用this.$refs.paperForm.validate()全部通过才发请求否则表单下方会自动标红提示。页面场景输入类型校验触发推荐组件登录页用户名、密码blurel-input rules题目维护题干、分值、选项blur / changeel-form rules考试作答单选多选判断实时写入 answerMapel-radio-group / el-checkbox-group这套组合其实代表了两种交互模式管理端是标准的“填表后提交”校验放在提交前考生端是“边答边存”校验交给后端在交卷时统一裁决前端不做强拦截。3.4 倒计时显示与服务器时间校验分离考试倒计时不能只在前端做。浏览器时间可以被用户修改也可以被系统同步工具强行校正一旦前端信任本地时间超时判卷就会出现可被利用的漏洞。正确姿势是试卷详情接口同时返回serverTime和deadline前端用这两个值计算剩余秒数只负责展示。// serverTime 和 deadline 都由后端下发 const remain Math.max(0, Math.floor((this.deadline - Date.now()) / 1000)) this.minutes Math.floor(remain / 60) this.seconds remain % 60 // 剩余时间为 0 时调用自动交卷接口 if (remain 0 !this.submitted) { this.autoSubmit() }这里的deadline是后端根据开考时间加上考试时长计算出的绝对时间戳前端拿到后只做差值计算。真实判卷时后端再次用服务器当前时间与 deadline 比对即使前端把倒计时改慢或暂停也不会影响最终成绩判定。时间场景前端表现后端判定本地时间手动拨慢 10 分钟显示剩余时间变长后端发现已过 deadline按超时处理标签页切到后台倒计时降频展示可能短暂停顿不影响交卷最终判断倒计时归零瞬间点击交卷请求正常发出后端时间为准容错合理4. 接口联调、跨域配置与 SpringBoot 版本适配前后端分离项目联调时跨域是第一个拦路虎。前端跑在 3000 端口后端跑在 8080 端口浏览器默认不允许跨端口读取响应。处理这个问题要同时考虑开发环境和生产环境不能只在一边打补丁。4.1 开发环境跨域devServer 转发与后端 CORS 双保险开发环境最省事的方式是让 Vue CLI 的 devServer 把所有 /api 开头的请求转发到后端地址这样浏览器看到的请求 URL 是同源的不会触发 CORS。// vue.config.js module.exports { devServer: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }changeOrigin: true会把请求头里的 Host 字段重写为后端的地址避免部分后端框架根据 Host 做拦截时报 403。配置完成后前端请求路径写成/api/login即可不用写死 IP。后端起一个全局的 CORS 过滤器作为双保险解决不走 devServer 时的直连场景比如后端联调工具 Swagger 或移动端 WebView 直接访问Configuration public class CorsConfig { Bean public CorsFilter corsFilter() { CorsConfiguration config new CorsConfiguration(); config.addAllowedOriginPattern(*); config.addAllowedMethod(*); config.addAllowedHeader(*); config.setAllowCredentials(true); UrlBasedCorsConfigurationSource source new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration(/**, config); return new CorsFilter(source); } }setAllowCredentials(true)表示允许携带 Cookie但此时不能用addAllowedOrigin(*)因为浏览器不允许在 credentials 模式下使用通配符 Origin必须改成AllowedOriginPattern。前后端分离时 token 一般放在 Authorization 头里而非 Cookie所以这里的 credentials 是给特殊场景预留的。4.2 axios 二次封装Token 注入、统一解包与 401 跳转考试系统里几乎所有接口都需要 token如果在每个页面各自拼 header很容易出现漏加的情况。封装一个 axios 实例在请求拦截器里统一注入。import axios from axios const service axios.create({ baseURL: /api, timeout: 8000 }) // 请求发出前把 token 塞进 Authorization 头 service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config }) // 响应回来先判断业务状态码 service.interceptors.response.use( response { const res response.data if (res.code 401) { // 登录过期清除本地会话并跳转登录页 localStorage.removeItem(token) window.location.href /login return Promise.reject(new Error(登录过期)) } return res.data }, error { // 网络错误或后端 5xx 统一走这里 return Promise.reject(error) } )baseURL统一写成/api这样开发环境走 devServer 转发生产环境由网关或 Web 服务器把 /api 前缀指向后端。超时时间 8000 毫秒是折中值考试判分接口如果涉及多表更新可能耗时偏长可以单独给提交接口设置更长的timeout。4.3 统一接口返回格式 code / message / data前后端分离项目如果每个人返回格式不一样前端拦截器就失去意义。后端所有接口都封装成统一的三段式结构前端拿固定字段解析。code含义前端处理200成功直接使用 data 渲染400参数错误或逻辑校验失败提示 message401未登录或 token 过期清除登录态并跳转登录页409重复提交或冲突提示“请勿重复交卷”500服务端异常统一弹窗提示并写日志后端通常用一个 Result 类包装返回例如Result.fail(409, 该试卷已提交)。重点是把 409 和 500 区分开不能把重复提交当成系统异常返回否则前端无法给出准确提示。数据库唯一索引触发的DuplicateKeyException要在全局异常处理器里转换成业务码 409。4.4 SpringBoot 2.x 升级 3.x 的兼容性改动如果你拿到这套源码之后想升级到较新版本会碰到几个明显的兼容问题。SpringBoot 3.x 把 javax.servlet 包替换成了 jakarta.servlet旧项目里凡是import javax.servlet.*的代码全部要改包名。同时 MyBatis-Plus 也需要换成专门的 SpringBoot3 starterartifactId 是mybatis-plus-spring-boot3-starter。数据库连接池如果是 Druid需要升级到 1.2.18 以上旧版本会报类找不到。另一个差异是 JDK 要求。SpringBoot 3.x 强制 JDK 17而很多旧课程项目默认跑在 JDK 8 上。如果不想动源码就继续用 SpringBoot 2.7 的配套依赖如果一定要用 3.x先在本地确认java -version是 17 以上再处理依赖坐标否则编译阶段就过不去。5. 上线验收防重复交卷、服务端时间裁判与 curl 自检前面把前后端链路搭通了最后一步是上线前的验证。考试系统最怕的不是功能缺失而是关键时刻数据错乱所以验收要聚焦在三个最容易出问题的点上。5.1 用数据库唯一索引兜住多标签页并发提交前端交卷按钮加 disabled 只是辅助手段真正的防线在数据库。用户开两个标签页同时答题两个页面各自发送提交请求后端并发执行 insert唯一索引保证只有一条记录能成功写入。捕获到异常后要返回业务提示而不是让用户看到 500。try { examRecordMapper.insert(record); } catch (DuplicateKeyException e) { // 组合唯一索引触发说明已经交过卷直接提示即可 return Result.fail(409, 该试卷已提交请勿重复交卷); }如果项目部署了多个后端实例单机唯一索引依然有效因为数据库是共享的。需要额外注意的点是insert 和后续的判分逻辑要包在同一个事务里否则记录写入了但成绩没算出来就会出现交卷成功却没分数的中间状态。5.2 交卷时间以服务端为准前端只做倒计时展示时间边界处理直接决定考试公平性。交卷接口收到请求后不要在请求体里取“客户端提交时间”要用后端自己的系统时间。开考时间从 exam_paper 表查出加上 duration_minutes 得出 deadline再用LocalDateTime.now()与 deadline 比较。LocalDateTime deadline examPaper.getStartTime() .plusMinutes(examPaper.getDurationMinutes()); // 超时交卷已做题目照常判分未做题目全部记 0 分 if (LocalDateTime.now().isAfter(deadline)) { resultService.autoScoreWithTimeout(answers); return Result.success(已超时按已答题目自动判分); }这样前端倒计时只承担展示职责就算有人改了本地时间后端也会按真实服务器时间判断。如果对时间一致性要求更高可以在答题中途定时向后端上报心跳但小型考试系统里开始时间和结束时间都由服务端判定已经足够。5.3 curl 验证三个最关键接口部署完成后先在命令行验证接口不要急着登录页面点按钮。用 curl 打三个关键路径能快速确认后端、登录链路和重复提交防护是否生效。# 1. 健康检查后端正常运行会返回 HTTP 200 和 ok curl -s http://localhost:8080/api/health # 2. 登录接口返回的 data.token 在下一步当令牌使用 curl -s -X POST http://localhost:8080/api/login \ -H Content-Type: application/json \ -d {username:admin,password:123456} # 3. 重复提交同一份答卷期望第二次返回 code409 curl -s -X POST http://localhost:8080/api/exam/submit \ -H Authorization: Bearer token \ -H Content-Type: application/json \ -d {paperId:1,answers:{101:A,102:B}}第二条命令拿到 token 后把它复制到第三条的token位置连续执行两次。第一次返回成功第二次能看到 409 和“该试卷已提交”的 message说明唯一索引和全局异常处理都生效了。这条验证链路跑通考试系统才算真正具备上线条件。本文还有配套的精品资源点击获取
返回列表