资讯动态

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

发布时间:2026/10/13 10:35:28 来源:尧图企业网站定制
最近后台收到不少读者留言问题高度一致毕设需要一个 Java Web 项目但不知道选什么题目能在答辩时有话可说简历里想写一个“前后端分离”的项目但翻了几天教程要么只有后端、前端是个空壳要么前后端代码对不上根本跑不起来。在线考试系统恰好是一个能把 SpringBoot、Vue、MySQL、登录权限、事务处理、统计查询全部串起来的典型题目。先说判断这个项目看起来是“增删改查”但真正值钱的是它覆盖了一条完整的开发链路——从用户登录、题库管理、考试创建、在线答题到自动判分和成绩查询。几乎每一个企业级 Web 项目要面对的问题它都触及了。把它做明白你的收获不只是几个 CRUD 接口而是对前后端分离开发模式的真实体感。接下来我会用一套可运行的代码骨架讲清楚每一层该写什么、为什么这么写以及联调时最容易踩的坑。如果你的开发环境已经装好照着文章走一遍两小时跑通是现实的如果你是第一次接触前后端分离建议把时间放宽到半天重点不是敲完代码而是理解请求是怎么从前端页面一路到达数据库的。1. 这篇文章真正要解决的问题很多同学拿到这类项目后的第一反应是把源码下载下来启动数据库改几个配置文件看到页面能跳就觉得自己“会了”。但这种做法有两个问题一是答辩或面试时老师/面试官问一句“你的登录是怎么鉴权的”你就答不上来二是项目没有经过自己的手出了问题你连从哪里排查都不知道。所以这篇文章要解决的不只是“怎么把项目跑起来”而是“这个项目到底是怎么设计出来的”。具体来说我会把在线考试系统拆成几个核心模块讲清楚数据库表之间是怎么关联的为什么考试和题目要拆成多张表后端如何提供统一格式的接口前端拿到 JSON 数据后如何渲染自动判分到底在哪一层做怎么做才不容易出错登录、跨域、重复提交这类“非功能需求”在实际项目里怎么处理。什么样的人最适合读这篇文章我认为是这三类正在准备毕业设计需要选一个“结构完整但不复杂”的 Java Web 题目的学生已经学过 SpringBoot 基础但没完整做过前后端分离项目想补一个实战经验的初学者想在简历里增加一个亮点项目但不知道如何描述和扩展的开发者。至于“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, rolesubject科目表id, namequestion题库表id, subject_id, type, content, options, answer, scoreexam考试表id, title, subject_id, start_time, end_time, duration, total_score, statusexam_question考试题目关联表id, exam_id, question_idexam_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, passwordGET/api/question/list分页查询题库page, size, keywordPOST/api/question/save新增/修改题目Question JSONDELETE/api/question/{id}删除题目idGET/api/exam/list考试列表page, sizeGET/api/exam/questions获取考试题目examIdPOST/api/exam/submit提交答卷examId, answers[]GET/api/exam/record成绩记录userId接口路径的设计原则是“名词复数 操作”这也是最常见的 REST 风格。后端不要直接把实体对象返回给前端而是包一层统一的Result方便前端统一处理成功和失败。3. 环境准备与项目初始化3.1 环境清单在动手之前先确认本机环境。下面是我推荐的组合版本请以你实际安装为准不必强求最新工具用途版本建议JDK运行 SpringBoot1.8 或 11SpringBoot 2.717SpringBoot 3.xMaven后端依赖管理3.6 及以上Node.js运行 Vue 工程16 或 18 及以上Vite 要求MySQL数据库5.7 或 8.xIntelliJ 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 vitelatest exam-front -- --template vue cd exam-front npm install npm install axios element-plus vue-router4 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependenciesapplication.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?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue 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必须带serverTimezoneAsia/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 ResultT { private Integer code; private String message; private T data; public static T ResultT ok(T data) { ResultT r new Result(); r.code 200; r.message success; r.data data; return r; } public static T ResultT error(String message) { ResultT 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 BaseMapperQuestion { }Controller 层可以直接复用这些方法。下面的代码包含了分页查询、保存修改和删除三个操作覆盖了“增删改查”里的主要部分。// 文件路径src/main/java/com/example/exam/controller/QuestionController.java RestController RequestMapping(/api/question) public class QuestionController { Autowired private QuestionMapper questionMapper; GetMapping(/list) public ResultIPageQuestion list(RequestParam(defaultValue 1) Integer page, RequestParam(defaultValue 10) Integer size) { PageQuestion pageParam new Page(page, size); return Result.ok(questionMapper.selectPage(pageParam, null)); } PostMapping(/save) public ResultString save(RequestBody Question question) { questionMapper.insertOrUpdate(question); return Result.ok(保存成功); } DeleteMapping(/{id}) public ResultString 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 ResultInteger 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 ListExamSubmitItem 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 classlogin-page el-card classlogin-card h2在线考试系统/h2 el-form :modelloginForm label-width60px el-form-item label账号 el-input v-modelloginForm.username placeholder请输入账号 / /el-form-item el-form-item label密码 el-input v-modelloginForm.password typepassword placeholder请输入密码 / /el-form-item el-button typeprimary :loadingloading stylewidth: 100% clickhandleLogin 登录 /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?page1size10如果返回结构类似{code:200,message:success,data:{...}}说明数据库连接和接口链路是通的。第二步启动前端。cd exam-front npm run dev看到 Vite 输出的本地地址后在浏览器打开http://localhost:5173。此时访问登录页输入账号密码如果能跳转到考试列表页说明前后端联调成功。6.2 业务验证流程建议按下面这个完整流程走一遍而不是只在登录页打转用管理员账号登录进入题库管理新增一道单选题进入考试管理创建一场考试设置起止时间和时长把刚才的题目加入考试退出管理员账号用学生账号登录在考试列表中进入该考试看到题目并作答提交答卷查看得分回到后端数据库查询exam_record表确认成绩记录已经写入。走完这一条链路才说明“题库 - 组卷 - 作答 - 判分 - 成绩”整个业务闭环是通的。6.3 如何判断成功判断标准有四个后端控制台没有红色异常日志浏览器 Network 面板中所有api请求的响应状态都是 200exam_record表中出现了对应记录分数和预期一致前端页面在提交后给出了明确的成功提示。如果提交后分数是 0先检查两个地方question.answer是否写入了正确答案前端answers结构是否与ExamSubmitItem的字段名一致。字段名不一致是前后端联调里最常见的低级错误。7. 常见问题与排查思路以下问题是这类项目里出现频率最高的按“现象 原因 排查方式 解决方案”的形式整理成清单问题现象可能原因排查方式解决方案后端启动报 Failed to configure a DataSource数据库没建、账号密码错误、URL 配置不对看完整堆栈检查 application.yml执行建库 SQL核对 username/passwordMySQL 时区报错URL 缺少 serverTimezone看错误信息里的时区提示加上 serverTimezoneAsia/ShanghaiMaven 依赖下载很慢或失败默认中央仓库网络不稳定查看 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接收请求、参数校验、调用 ServiceService业务逻辑、事务控制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 章的流程跑一遍全链路最后找一处你最感兴趣的代码动手修改。源码只是起点能讲清楚“为什么这么写”才是在答辩和面试里真正值钱的地方。祝你的毕设顺利简历项目能经得住追问。如果这篇文章对你有帮助建议收藏备用遇到联调问题可以随时翻回排查清单对照。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑