ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

在线考试系统实战:SpringBoot+Vue前后端分离开发全解析

在线考试系统实战:SpringBoot+Vue前后端分离开发全解析

最近后台收到不少读者留言,问题高度一致:毕设需要一个 Java Web 项目,但不知道选什么题目能在答辩时有话可说;简历里想写一个“前后端分离”的项目,但翻了几天教程,要么只有后端、前端是个空壳,要么前后端代码对不上,根本跑不起来。在线考试系统恰好是一个能把 SpringBoot、Vue、MySQL、登录权限、事务处理、统计查询全部串起来的典型题目。

先说判断:这个项目看起来是“增删改查”,但真正值钱的是它覆盖了一条完整的开发链路——从用户登录、题库管理、考试创建、在线答题,到自动判分和成绩查询。几乎每一个企业级 Web 项目要面对的问题,它都触及了。把它做明白,你的收获不只是几个 CRUD 接口,而是对前后端分离开发模式的真实体感。

接下来我会用一套可运行的代码骨架,讲清楚每一层该写什么、为什么这么写,以及联调时最容易踩的坑。如果你的开发环境已经装好,照着文章走一遍,两小时跑通是现实的;如果你是第一次接触前后端分离,建议把时间放宽到半天,重点不是敲完代码,而是理解请求是怎么从前端页面一路到达数据库的。

1. 这篇文章真正要解决的问题

很多同学拿到这类项目后的第一反应是:把源码下载下来,启动数据库,改几个配置文件,看到页面能跳就觉得自己“会了”。但这种做法有两个问题:一是答辩或面试时,老师/面试官问一句“你的登录是怎么鉴权的”,你就答不上来;二是项目没有经过自己的手,出了问题你连从哪里排查都不知道。

所以这篇文章要解决的,不只是“怎么把项目跑起来”,而是“这个项目到底是怎么设计出来的”。具体来说,我会把在线考试系统拆成几个核心模块,讲清楚:

  • 数据库表之间是怎么关联的,为什么考试和题目要拆成多张表;
  • 后端如何提供统一格式的接口,前端拿到 JSON 数据后如何渲染;
  • 自动判分到底在哪一层做,怎么做才不容易出错;
  • 登录、跨域、重复提交这类“非功能需求”,在实际项目里怎么处理。

什么样的人最适合读这篇文章?我认为是这三类:

  1. 正在准备毕业设计,需要选一个“结构完整但不复杂”的 Java Web 题目的学生;
  2. 已经学过 SpringBoot 基础,但没完整做过前后端分离项目,想补一个实战经验的初学者;
  3. 想在简历里增加一个亮点项目,但不知道如何描述和扩展的开发者。

至于“2小时能不能搭建完”,我的建议是不要纠结这个绝对数字。如果你电脑里已经装好了 JDK、MySQL、Node.js,手里有一份结构清晰的项目源码参考,2 小时足够跑通。如果你还要现场装环境、解决依赖冲突,那多花一倍时间也很正常。关键是先把“跑通”和“理解”分开。

2. 核心概念与系统设计

2.1 前后端分离到底在分离什么

在线考试系统这类项目,现在主流的技术方案是前后端分离。要理解这个概念,可以先回忆一下传统的 JSP 单体项目是怎么工作的。

在 JSP 时代,后端工程师既要写 Java 逻辑,又要写 HTML 和 JSP 标签,前端页面由服务器动态渲染。这种方式的缺点是:前端改动一点样式,可能要把整个应用重新打包部署;后端给不了前端的“接口文档”,因为页面和数据是揉在一起的。更别提一个后端要同时服务网页端、移动端时,几乎没有办法复用。

前后端分离的含义是:前端项目(Vue)只负责页面展示和用户交互,通过 HTTP 请求访问后端接口;后端项目(SpringBoot)只负责业务逻辑和数据库操作,对外返回 JSON 数据。两者独立开发、独立部署,通过事先约定好的“接口格式”协作。

但这里有一个新手常误解的地方:前后端分离不等于“前后端没有任何联系”。反而,他们之间的联系比单体时代更依赖“约定”。比如统一返回结构、统一错误码、统一分页参数。如果你的后端返回{code: 1, msg: "success"},前端却等着取{code: 200},那联调时就会互相甩锅。

2.2 在线考试系统的核心模块

按业务划分,一个完整的在线考试系统通常包含以下几个模块:

模块功能说明涉及角色
用户登录与权限登录、鉴权、区分管理员和学生管理员、学生
题库管理题目的新增、修改、删除、分页查询管理员
考试管理创建考试、选择题目、设置考试时间与时长管理员
在线考试获取考试题目、倒计时、提交答卷学生
自动判分与成绩查询提交后自动计算总分、保存成绩、查询历史记录学生、管理员

这五个模块正好对应了“在线考试”这个业务闭环:先有题库,才能组卷;先有考试,才能答题;答完卷,要能判分;判完分,要能查成绩。单独做一个 CRUD 很容易,难的是把这条链路串起来不出逻辑漏洞。

2.3 数据库表设计

根据模块划分,数据表最少需要 6 张。这里给出一个通用设计,字段可以根据实际需要增减。

表名说明核心字段
user用户表id, username, password, real_name, role
subject科目表id, name
question题库表id, subject_id, type, content, options, answer, score
exam考试表id, title, subject_id, start_time, end_time, duration, total_score, status
exam_question考试题目关联表id, exam_id, question_id
exam_record考试记录表id, exam_id, user_id, score, submit_time

这里有两个设计要点值得注意。

第一,question.options用 JSON 字符串存储选项。比如单选题有 A、B、C、D 四个选项,存成["选项A","选项B","选项C","选项D"]。这么做的好处是题目类型变化时不需要频繁改表结构,缺点是查询出来后要手动解析。对于毕设和简历项目来说,这个取舍是合理的。

第二,exam_question是一张关联表,而不是把题目直接塞进 exam 表。因为一道题可以被多场考试复用,而一场考试又包含多道题,这是典型的多对多关系。如果你把题目列表直接按逗号拼接存在 exam 表里,后面查某道题被哪些考试用过,就会非常痛苦。

2.4 接口设计约定

后端接口统一加/api前缀,并使用统一的返回格式:

方法路径说明参数
POST/api/user/login登录username, password
GET/api/question/list分页查询题库page, size, keyword
POST/api/question/save新增/修改题目Question JSON
DELETE/api/question/{id}删除题目id
GET/api/exam/list考试列表page, size
GET/api/exam/questions获取考试题目examId
POST/api/exam/submit提交答卷examId, answers[]
GET/api/exam/record成绩记录userId

接口路径的设计原则是“名词复数 + 操作”,这也是最常见的 REST 风格。后端不要直接把实体对象返回给前端,而是包一层统一的Result,方便前端统一处理成功和失败。

3. 环境准备与项目初始化

3.1 环境清单

在动手之前,先确认本机环境。下面是我推荐的组合,版本请以你实际安装为准,不必强求最新:

工具用途版本建议
JDK运行 SpringBoot1.8 或 11(SpringBoot 2.7);17(SpringBoot 3.x)
Maven后端依赖管理3.6 及以上
Node.js运行 Vue 工程16 或 18 及以上(Vite 要求)
MySQL数据库5.7 或 8.x
IntelliJ IDEA后端开发Community 版即可
VSCode前端开发配合 Volar 插件

后端我建议以 SpringBoot 2.7.x + JDK 8/11 为例,因为网上资料最多、最容易搜到排错方案。如果你想用 SpringBoot 3.x,需要注意两个变化:包名从javax.*换成jakarta.*,MyBatis-Plus 要选择mybatis-plus-spring-boot3-starter版本。这几个差异在翻老教程时非常容易踩坑。

3.2 数据库初始化

先在 MySQL 中创建数据库,并执行初始化 SQL。

CREATE DATABASE IF NOT EXISTS exam_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE exam_db; CREATE TABLE user ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, real_name VARCHAR(50), role TINYINT DEFAULT 1 COMMENT '1-学生 2-管理员', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, subject_id BIGINT, type TINYINT COMMENT '1-单选 2-多选 3-判断', content TEXT, options TEXT COMMENT 'JSON数组', answer VARCHAR(255), score INT DEFAULT 5, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE exam ( id BIGINT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100), subject_id BIGINT, start_time DATETIME, end_time DATETIME, duration INT COMMENT '考试时长,单位分钟', total_score INT DEFAULT 0, status TINYINT DEFAULT 0 COMMENT '0-未发布 1-已发布', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE exam_question ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_id BIGINT, question_id BIGINT ); CREATE TABLE exam_record ( id BIGINT PRIMARY KEY AUTO_INCREMENT, exam_id BIGINT, user_id BIGINT, score INT, submit_time DATETIME DEFAULT CURRENT_TIMESTAMP );

执行完成后,建议往 user 表插入一个管理员账号和一个学生账号,密码先用明文,后面再讨论加密的问题。

INSERT INTO user (username, password, real_name, role) VALUES ('admin', '123456', '管理员', 2); INSERT INTO user (username, password, real_name, role) VALUES ('student', '123456', '张三', 1);

3.3 前后端工程初始化

后端工程可以用 IDEA 直接创建 Spring Initializr 项目,也可以从已有的项目骨架修改。前端工程推荐使用 Vite 创建 Vue 3 项目:

npm create vite@latest exam-front -- --template vue cd exam-front npm install npm install axios element-plus vue-router@4 pinia npm run dev

这里使用的组合是 Vue 3 + Vite + Element Plus + Vue Router + Pinia,目前资料最多、社区活跃度最高。如果你更熟悉 Vue 2 + Element UI,也不是不行,但新项目不建议再开旧坑。

4. 后端核心代码实现

4.1 项目依赖与基础配置

后端pom.xml的核心依赖如下。这里以 SpringBoot 2.7 为例,如果你用 SpringBoot 3.x,请换成对应的 starter。

<!-- 文件路径:pom.xml --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.1</version> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <scope>runtime</scope> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>

application.yml是后端最需要仔细检查的配置文件。90% 启动失败的问题都出在这里。

# 文件路径:src/main/resources/application.yml server: port: 8080 spring: application: name: exam-system datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://localhost:3306/exam_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username: root password: root jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: Asia/Shanghai mybatis-plus: configuration: map-underscore-to-camel-case: true log-impl: org.apache.ibatis.logging.stdout.StdOutImpl

这里有几个容易出错的地方:

  • url必须带serverTimezone=Asia/Shanghai,否则 MySQL 驱动会报时区错误;
  • username和password要改成你自己 MySQL 的账号密码;
  • map-underscore-to-camel-case开启后,数据库字段real_name会自动映射到 Java 属性的realName,不需要写一堆 XML 映射。

别忘了主启动类要加@MapperScan,否则 MyBatis-Plus 扫描不到 Mapper 接口。

// 文件路径:src/main/java/com/example/exam/ExamApplication.java @SpringBootApplication @MapperScan("com.example.exam.mapper") public class ExamApplication { public static void main(String[] args) { SpringApplication.run(ExamApplication.class, args); } }

4.2 统一返回结构

前后端联调时最怕各写各的格式。所以后端第一步,先定义一个统一返回类。

// 文件路径:src/main/java/com/example/exam/common/Result.java public class Result<T> { private Integer code; private String message; private T data; public static <T> Result<T> ok(T data) { Result<T> r = new Result<>(); r.code = 200; r.message = "success"; r.data = data; return r; } public static <T> Result<T> error(String message) { Result<T> r = new Result<>(); r.code = 500; r.message = message; return r; } public Integer getCode() { return code; } public void setCode(Integer code) { this.code = code; } public String getMessage() { return message; } public void setMessage(String message) { this.message = message; } public T getData() { return data; } public void setData(T data) { this.data = data; } }

这个类看起来简单,但它是前后端协作的“契约”。后续所有 Controller 都返回Result,前端就只需要统一处理code字段。

4.3 实体类与 Mapper

以题库表为例,实体类使用 MyBatis-Plus 的注解映射。

// 文件路径:src/main/java/com/example/exam/entity/Question.java import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.time.LocalDateTime; @Data @TableName("question") public class Question { @TableId(type = IdType.AUTO) private Long id; private Long subjectId; private Integer type; private String content; private String options; private String answer; private Integer score; private LocalDateTime createTime; }

Mapper 接口不需要写任何实现类,继承 MyBatis-Plus 的BaseMapper即可获得常用 CRUD 方法。

// 文件路径:src/main/java/com/example/exam/mapper/QuestionMapper.java public interface QuestionMapper extends BaseMapper<Question> { }

Controller 层可以直接复用这些方法。下面的代码包含了分页查询、保存修改和删除三个操作,覆盖了“增删改查”里的主要部分。

// 文件路径:src/main/java/com/example/exam/controller/QuestionController.java @RestController @RequestMapping("/api/question") public class QuestionController { @Autowired private QuestionMapper questionMapper; @GetMapping("/list") public Result<IPage<Question>> list(@RequestParam(defaultValue = "1") Integer page, @RequestParam(defaultValue = "10") Integer size) { Page<Question> pageParam = new Page<>(page, size); return Result.ok(questionMapper.selectPage(pageParam, null)); } @PostMapping("/save") public Result<String> save(@RequestBody Question question) { questionMapper.insertOrUpdate(question); return Result.ok("保存成功"); } @DeleteMapping("/{id}") public Result<String> delete(@PathVariable Long id) { questionMapper.deleteById(id); return Result.ok("删除成功"); } }

第一版先把 Mapper 直接用在 Controller,目的是快速跑通;后面的工程建议部分会讲如何引入 Service 层。实际项目中,业务逻辑一多,Controller 直接操作 Mapper 就会变得难以维护。

4.4 自动判分与提交业务

考试提交是整个系统里最有业务价值的地方。自动判分的关键在于:

  • 校验考试时间,避免考试还没开始或已经结束还能提交;
  • 对比答案,单选和判断直接比较,多选要先排序再比较;
  • 判分和保存记录必须在一个事务里,防止“算出了分但记录没保存”。

这里给出一个简化但完整的提交逻辑。

// 文件路径:src/main/java/com/example/exam/service/ExamService.java @Service public class ExamService { @Autowired private ExamMapper examMapper; @Autowired private QuestionMapper questionMapper; @Autowired private ExamRecordMapper examRecordMapper; @Transactional(rollbackFor = Exception.class) public Result<Integer> submit(ExamSubmitDTO dto) { Exam exam = examMapper.selectById(dto.getExamId()); if (exam == null) { return Result.error("考试不存在"); } LocalDateTime now = LocalDateTime.now(); if (now.isBefore(exam.getStartTime()) || now.isAfter(exam.getEndTime())) { return Result.error("当前不在考试时间内"); } int totalScore = 0; for (ExamSubmitItem item : dto.getAnswers()) { Question question = questionMapper.selectById(item.getQuestionId()); if (question == null) { continue; } if (checkAnswer(question, item.getAnswer())) { totalScore += question.getScore(); } } ExamRecord record = new ExamRecord(); record.setExamId(dto.getExamId()); record.setUserId(dto.getUserId()); record.setScore(totalScore); record.setSubmitTime(now); examRecordMapper.insert(record); return Result.ok(totalScore); } private boolean checkAnswer(Question question, String answer) { if (question == null || answer == null) { return false; } if (question.getType() == 2) { // 多选题:对答案排序后比较,避免 "A,B" 和 "B,A" 误判 return normalize(question.getAnswer()).equals(normalize(answer)); } return question.getAnswer().trim().equalsIgnoreCase(answer.trim()); } private String normalize(String answer) { String[] arr = answer.split(","); Arrays.sort(arr); return String.join(",", arr); } }

这段代码解决了两个经典问题:一是多选答案顺序问题,二是判分和保存记录的事务一致性问题。考试类系统最忌讳的就是“分数算了,但提交记录丢了”,加了@Transactional(rollbackFor = Exception.class)之后,只要保存失败,整个方法都会回滚。

提交参数可以用 DTO(数据传输对象)接收,避免直接拿 Map 传参导致前端字段名随意变化。

// 文件路径:src/main/java/com/example/exam/dto/ExamSubmitDTO.java public class ExamSubmitDTO { private Long examId; private Long userId; private List<ExamSubmitItem> answers; // getter/setter 省略 } public class ExamSubmitItem { private Long questionId; private String answer; // getter/setter 省略 }

4.5 跨域配置

前后端分离开发时,前端运行在http://localhost:5173,后端运行在http://localhost:8080,浏览器会拦截这种跨源请求。开发阶段最简单的办法是后端开启全局跨域。

// 文件路径:src/main/java/com/example/exam/config/CorsConfig.java @Configuration public class CorsConfig implements WebMvcConfigurer { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedOriginPatterns("*") .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) .maxAge(3600); } }

allowCredentials(true)表示允许携带 Cookie 或认证信息,但注意它和allowedOriginPatterns("*")的组合在不同浏览器版本上表现不完全一致,如果遇到跨域问题,优先检查这条配置。

5. 前端 Vue 核心实现

5.1 请求封装

在前端项目中,强烈建议统一封装 axios 实例,而不是在每个页面里直接axios.get。这样如果后端改了统一返回结构,前端只需要改一个文件。

// 文件路径:src/api/request.js import axios from 'axios' import { ElMessage } from 'element-plus' const request = axios.create({ baseURL: '/api', timeout: 10000 }) request.interceptors.response.use( (response) => { const res = response.data if (res.code !== 200) { ElMessage.error(res.message || '请求失败') return Promise.reject(new Error(res.message)) } return res.data }, (error) => { ElMessage.error(error.response?.data?.message || error.message) return Promise.reject(error) } ) export default request

需要注意的是,这里baseURL写的是/api,而不是http://localhost:8080/api。开发环境下,前端 Vite 配置代理把/api转发到后端端口,这样既避免跨域,又方便上线后由 Nginx 统一转发。相关配置在vite.config.js:

// 文件路径:vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [vue()], server: { port: 5173, proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } } } })

这一步很多人会漏,导致前端请求直接打到http://localhost:5173/api,然后出现 404 或者 CORS 报错。加了这个代理配置后,前后端联调会顺畅很多。

5.2 路由设计

页面路由按功能划分,登录页不需要鉴权,考试相关页面需要登录后才能访问。这里先给出路由的最小版本。

// 文件路径:src/router/index.js import { createRouter, createWebHistory } from 'vue-router' const routes = [ { path: '/login', component: () => import('../views/Login.vue') }, { path: '/exam-list', component: () => import('../views/ExamList.vue') }, { path: '/exam/:id', component: () => import('../views/ExamDetail.vue') }, { path: '/result/:recordId', component: () => import('../views/ExamResult.vue') } ] const router = createRouter({ history: createWebHistory(), routes }) router.beforeEach((to, from, next) => { const token = localStorage.getItem('token') if (to.path !== '/login' && !token) { next('/login') } else { next() } }) export default router

这一段简单实现了“未登录不能访问”的前端拦截。真正的后端鉴权还需要 JWT 或 Session 配合,前端拦截只是用户体验层面的第一道门槛。

5.3 登录页与答题页

登录页的核心逻辑是把账号密码发给后端,拿到 token 后存到本地,然后跳转到考试列表。

<!-- 文件路径:src/views/Login.vue --> <template> <div class="login-page"> <el-card class="login-card"> <h2>在线考试系统</h2> <el-form :model="loginForm" label-width="60px"> <el-form-item label="账号"> <el-input v-model="loginForm.username" placeholder="请输入账号" /> </el-form-item> <el-form-item label="密码"> <el-input v-model="loginForm.password" type="password" placeholder="请输入密码" /> </el-form-item> <el-button type="primary" :loading="loading" style="width: 100%" @click="handleLogin"> 登录 </el-button> </el-form> </el-card> </div> </template> <script setup> import { ref } from 'vue' import { useRouter } from 'vue-router' import { ElMessage } from 'element-plus' import request from '../api/request' const router = useRouter() const loading = ref(false) const loginForm = ref({ username: 'student', password: '123456' }) async function handleLogin() { loading.value = true try { const data = await request.post('/user/login', loginForm.value) localStorage.setItem('token', data.token) localStorage.setItem('user', JSON.stringify(data.user)) ElMessage.success('登录成功') router.push('/exam-list') } finally { loading.value = false } } </script>

答题页是另一个关键页面。它需要完成三件事:加载考试题目、倒计时、提交答案。这里展示核心逻辑部分。

<!-- 文件路径:src/views/ExamDetail.vue --> <script setup> import { ref, onMounted, onUnmounted } from 'vue' import { useRoute, useRouter } from 'vue-router' import { ElMessageBox, ElMessage } from 'element-plus' import request from '../api/request' const route = useRoute() const router = useRouter() const exam = ref(null) const questions = ref([]) const answers = ref({}) const remainSeconds = ref(0) let timer = null onMounted(async () => { const examId = route.params.id exam.value = await request.get('/exam/detail', { params: { id: examId } }) questions.value = await request.get('/exam/questions', { params: { examId } }) remainSeconds.value = exam.value.duration * 60 timer = setInterval(() => { remainSeconds.value-- if (remainSeconds.value <= 0) { clearInterval(timer) handleSubmit(true) } }, 1000) }) onUnmounted(() => { if (timer) clearInterval(timer) }) async function handleSubmit(auto = false) { if (timer) clearInterval(timer) if (!auto) { await ElMessageBox.confirm('确认提交试卷?提交后不可修改。', '提示') } const payload = { examId: exam.value.id, answers: Object.entries(answers.value).map(([questionId, answer]) => ({ questionId: Number(questionId), answer })) } const score = await request.post('/exam/submit', payload) ElMessage.success(`提交成功,得分:${score}`) router.push('/exam-list') } </script>

这里你可能会发现,前端在提交时并没有传 userId,实际项目中 user 信息应该从登录状态中获取,而不是每次由前端传。后端应该从 token 里解析出当前登录用户,这是一个安全边界问题,后面的最佳实践部分会专门说明。

6. 运行效果与联调验证

代码写完不代表项目就能跑。下面是一套建议的联调顺序。

6.1 启动步骤

第一步,启动后端。在 IDEA 中直接运行ExamApplication主类,或者用命令行:

mvn spring-boot:run

当控制台出现Started ExamApplication且没有报错时,说明后端启动成功。此时可以先用浏览器或 Postman 请求一个接口验证:

curl http://localhost:8080/api/question/list?page=1&size=10

如果返回结构类似{"code":200,"message":"success","data":{...}},说明数据库连接和接口链路是通的。

第二步,启动前端。

cd exam-front npm run dev

看到 Vite 输出的本地地址后,在浏览器打开http://localhost:5173。此时访问登录页,输入账号密码,如果能跳转到考试列表页,说明前后端联调成功。

6.2 业务验证流程

建议按下面这个完整流程走一遍,而不是只在登录页打转:

  1. 用管理员账号登录,进入题库管理,新增一道单选题;
  2. 进入考试管理,创建一场考试,设置起止时间和时长,把刚才的题目加入考试;
  3. 退出管理员账号,用学生账号登录;
  4. 在考试列表中进入该考试,看到题目并作答;
  5. 提交答卷,查看得分;
  6. 回到后端数据库,查询exam_record表,确认成绩记录已经写入。

走完这一条链路,才说明“题库 -> 组卷 -> 作答 -> 判分 -> 成绩”整个业务闭环是通的。

6.3 如何判断成功

判断标准有四个:

  • 后端控制台没有红色异常日志;
  • 浏览器 Network 面板中所有api请求的响应状态都是 200;
  • exam_record表中出现了对应记录,分数和预期一致;
  • 前端页面在提交后给出了明确的成功提示。

如果提交后分数是 0,先检查两个地方:question.answer是否写入了正确答案;前端answers结构是否与ExamSubmitItem的字段名一致。字段名不一致是前后端联调里最常见的低级错误。

7. 常见问题与排查思路

以下问题是这类项目里出现频率最高的,按“现象 + 原因 + 排查方式 + 解决方案”的形式整理成清单:

问题现象可能原因排查方式解决方案
后端启动报 Failed to configure a DataSource数据库没建、账号密码错误、URL 配置不对看完整堆栈;检查 application.yml执行建库 SQL;核对 username/password
MySQL 时区报错URL 缺少 serverTimezone看错误信息里的时区提示加上 serverTimezone=Asia/Shanghai
Maven 依赖下载很慢或失败默认中央仓库网络不稳定查看 Maven 日志在 settings.xml 配置阿里云镜像
npm install 卡住npm 源访问慢执行 npm config get registry换成国内镜像源
前端请求 /api 返回 404Vite 代理未配置看 Network 面板请求 URL在 vite.config.js 配置 proxy
跨域报错 CORS后端没有开启跨域看浏览器 Console添加 CorsConfig 配置
提交之后分数为 0答案字段名不一致或答案未存查 exam_record 与 question.answer统一字段名;确认答案已录入
页面上题库是空的数据库没有数据或分页参数错直接请求接口看返回插入测试数据;检查 page/size 参数
考完还能重复提交后端没有幂等校验看提交接口日志用 exam_id + user_id + 考试状态做唯一校验

其中最容易忽视的是“考完还能重复提交”。如果前端只是简单跳转,没有后端校验,学生完全可以手动构造出第二次提交请求。解决办法是在exam_record表加唯一索引(exam_id, user_id),或者在提交接口里先查询该用户是否已有本场考试记录,再做插入。

8. 最佳实践与工程建议

8.1 密码安全

本文为了演示方便使用了明文密码,但这在真实项目中绝对不行。至少要使用 BCrypt 对密码做哈希,Spring Security 自带BCryptPasswordEncoder,也可以单独引入spring-security-crypto。比较密码时用encode校验,不要在数据库里明文存储。

8.2 登录鉴权

登录成功后,后端返回给前端一个 token,前端存储在 localStorage 中。但比这更重要的是后端接口本身要能识别 token。实际项目里常用 JWT 实现:用户登录后签发一个包含 userId 和角色的 token,前端每次请求放在Authorization请求头,后端通过拦截器(HandlerInterceptor)解析 token 并设置当前用户。

这样做的意义在于:后端不再信任前端传来的 userId,系统安全性会明显提升。毕设答辩时,面试官问“你的项目安全怎么做的”,你能说出这几点,已经超越了大部分同类项目。

8.3 事务与并发

判分逻辑加上@Transactional只是第一步。考试系统还有一个更隐蔽的问题:学生提交答案时,如果用户在最后几秒连续点了两次提交,后端可能插入两条成绩记录。解决思路是:

  • 在exam_record表对(exam_id, user_id)建唯一索引;
  • 提交接口先查询该用户是否已有本场考试记录,有则直接返回已有成绩;
  • 更严格的方案是用数据库分布式锁或唯一键冲突捕获。

毕设阶段做到前两点就足够了。

8.4 代码分层

前面为了让代码短一些,Controller 直接用了 Mapper。但真正的项目,分层应该是:

  • Controller:接收请求、参数校验、调用 Service;
  • Service:业务逻辑、事务控制;
  • Mapper:数据库操作。

尤其是判分、考试创建这种包含多条数据库操作的逻辑,一定放在 Service 里,因为事务注解放在 Service 层才有效。Controller 只是薄薄一层,不应该承载业务判断。

8.5 部署方式

后端打成 jar 包,前端构建成静态文件,然后统一由 Nginx 托管,这是目前很常见的部署方式。

# 后端打包 mvn clean package -DskipTests java -jar target/exam-system-1.0.0.jar # 前端构建 cd exam-front npm run build

前端dist目录里的静态文件放到 Nginx 的html目录,再把/api反向代理到后端端口。这样做的好处是生产环境没有跨域问题,因为浏览器访问的始终是同源地址,/api前缀在 Nginx 层被转发掉了。

8.6 简历与答辩建议

如果要把这个项目写进简历,建议不要只写“实现了增删改查”,而是写清楚这几个点:

  • 基于 SpringBoot + Vue 实现前后端分离,后端提供 RESTful 接口;
  • 包含题库管理、考试创建、在线答题、自动判分等完整业务闭环;
  • 自动判分支持单选、多选、判断,并解决多选乱序比较问题;
  • 提交判分采用事务控制,防止数据不一致。

答辩时主动说出“多选答案需要排序后比较”“成绩记录用事务保证一致性”,会明显提升项目的可信度。因为这说明你不仅写了代码,还理解代码背后的边界问题。

9. 总结与后续学习方向

在线考试系统看起来是一个经典到不能再经典的增删改查项目,但它能成为长期热门的毕设选题,原因在于:它麻雀虽小、五脏俱全,涵盖了 Web 开发中最核心的那些问题。

读到这里,我希望你已经清楚了这几件事:

  • 数据库为什么要拆成六张表,关联表解决了什么问题;
  • 统一返回结构为什么重要,Result类维护的是前后端之间的契约;
  • 自动判分和事务处理的结合点在哪里,多选答案为什么必须规范化再比较;
  • 跨域和接口代理只是开发环境问题,生产环境用 Nginx 转发才是正解。

如果你现在是照着本文跑通的第一版,下一步建议至少做三件事:

第一,把密码改成 BCrypt 加密,把登录接口改成返回 JWT,并在后端加一个拦截器解析 token。这是从“能跑”迈向“像真实项目”最关键的一步。

第二,增加一个防重复提交逻辑,无论是唯一索引还是业务查询,选一种实现。

第三,不要停留在跑通,而是动手改一个功能。比如增加“随机组卷”,或者增加“成绩导出 Excel”。改代码的过程,才是真正把项目变成你自己的过程。

如果你拿到的是完整的源码包,也不要直接照抄。先按本文第 2 章的模块划分浏览目录,再按第 6 章的流程跑一遍全链路,最后找一处你最感兴趣的代码动手修改。源码只是起点,能讲清楚“为什么这么写”,才是在答辩和面试里真正值钱的地方。

祝你的毕设顺利,简历项目能经得住追问。如果这篇文章对你有帮助,建议收藏备用,遇到联调问题可以随时翻回排查清单对照。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进