基于Spring Boot+Vue+MyBatis-Plus的学生成绩管理系统实战:从CRUD到工程化交付 简介这份资源是面向Java Web初学者与进阶开发者的前后端分离实战教程以学生成绩管理系统为案例讲解Spring Boot、Vue.js与MyBatisPlus的综合运用帮助读者打通数据库设计、后端接口开发与前端界面构建的完整链路。压缩包内为1个PDF文档约2.8MB内容围绕am_user、am_student、am_score三张表的字段设计与软删除机制展开并给出Spring Boot 2.7.17的pom.xml依赖配置、MyBatisPlus的Mapper与实体类写法以及Vue组件划分和axios调用RESTful接口的示例。教程还涉及登录、学生列表、成绩录入等模块的实现思路适合希望从零搭建一套可运行管理系统的开发者参考。目前已有448人学习可作为课程设计或毕业设计的实践蓝本。1. 学生成绩管理系统为什么“能跑通”和“能交付”之间隔着一整套工程决策很多同学做毕业设计或练手项目时第一反应是打开 IDE 新建一个 Spring Boot 工程把 Controller、Service、Mapper 三层一铺前端随便糊几个页面接口能返回 JSON 就觉得大功告成。但真正拿到一个“学生成绩管理系统”的需求时你会发现事情远不止 CRUD成绩录入涉及批量导入和并发覆盖权限要区分管理员、教师、学生三种角色前端 Vue 要处理动态路由和按钮级权限MyBatis-Plus 的分页和逻辑删除稍不留神就会在联调时翻车。这套技术栈之所以成为前后端分离项目实战的经典组合恰恰是因为它把“工程化”三个字拆成了一个个具体决策用不用 DTO、分页插件怎么配、跨域在哪一层解决、Token 存哪里、成绩统计走 SQL 还是走内存。这篇文章面向的是已经会写增删改查、但没完整交付过一个前后端分离系统的开发者我会按真实落地顺序把每个环节的选型理由、可复制的代码和踩过的坑讲清楚让你少走弯路。2. 技术选型与工程骨架为什么是 Spring Boot Vue MyBatis-Plus2.1 三层架构在成绩管理系统里的具体落点学生成绩管理系统的业务边界其实很清晰基础数据维护学生、教师、课程、班级、成绩录入与修改、成绩查询与统计、权限控制。把这四块映射到前后端分离架构上后端负责数据持久化、业务规则校验和接口输出前端负责页面渲染、交互反馈和路由守卫。Spring Boot 在这里的价值不是“新”而是它把内嵌 Tomcat、自动配置、起步依赖打包成了一套开箱即用的约定让你不用在 XML 里配 DispatcherServlet 和数据源。MyBatis-Plus 则是在 MyBatis 基础上补了通用 Mapper、条件构造器、分页插件和逻辑删除成绩管理系统里大量单表查询和条件筛选正好吃这套红利。Vue 选 3.x 还是 2.x 取决于团队习惯但核心思路一致用组件化拆页面用 Vue Router 管导航用 Axios 管请求用 Pinia 或 Vuex 管登录态和用户信息。我一般会把后端拆成这几个包controller只做参数接收和响应封装service写业务逻辑和事务边界mapper继承BaseMapperentity对应数据库表dto和vo分别承接入参和出参。成绩管理系统里最典型的坑就是直接把 Entity 返回给前端结果学生表里的密码字段、逻辑删除标记全暴露出去。所以从第一天起就要养成 DTO/VO 分层的习惯哪怕前期多写几个类后期改字段时你会感谢自己。2.2 用 Spring Initializr 搭后端骨架的最小命令不用纠结版本号选当前稳定的 Spring Boot 3.x 或 2.7.x 都行关键是依赖别漏。下面是我常用的pom.xml核心依赖片段直接抄!-- Spring Boot Web提供 REST 接口和内嵌 Tomcat -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis-Plus通用 Mapper 和分页插件 -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.5/version /dependency !-- MySQL 驱动运行时才需要scope 设为 runtime -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- Lombok减少 getter/setter 样板代码 -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency逻辑说明spring-boot-starter-web是 Web 层的基础没有它连RestController都识别不了mybatis-plus-boot-starter已经包含了 MyBatis 和 Spring 的整合不要再单独引mybatis-spring-boot-starter否则版本冲突会让你在启动时看到一堆NoSuchMethodError。参数上MyBatis-Plus 3.5.x 对 Spring Boot 3 的支持需要额外注意如果用的是 Spring Boot 3建议把 MyBatis-Plus 升到 3.5.3 以上并且把javax包名换成jakarta。2.3 前端 Vue 工程的初始化与目录约定前端用 Vite 创建 Vue 3 项目是目前最顺手的做法命令如下# 用 Vite 创建 Vue 3 项目模板选 vue npm create vitelatest grade-frontend -- --template vue # 进入目录后安装路由、状态管理和请求库 cd grade-frontend npm install vue-router4 pinia axios element-plus逻辑说明vue-router4是 Vue 3 配套的路由版本pinia替代 Vuex 做状态管理axios负责 HTTP 请求element-plus提供表格、表单、分页这些成绩管理系统高频组件。目录上我习惯把api单独抽一层每个模块一个文件比如api/student.js、api/score.js里面只写请求函数不写业务逻辑。这样后端接口路径变了只需要改一个文件不用全局搜索替换。3. 数据库设计与 MyBatis-Plus 落地从建表到分页查询3.1 成绩管理系统的表结构设计与字段取舍成绩管理系统的核心表不多但字段设计直接影响后续查询复杂度。我一般会建这几张表student学生、teacher教师、course课程、score成绩、user登录账号。其中score表是重点它至少要有student_id、course_id、score、exam_type、create_time、deleted这几个字段。exam_type用来区分期中、期末、平时成绩没有它后面做成绩统计时你会后悔。deleted是 MyBatis-Plus 逻辑删除的标记字段类型用tinyint默认 0。一个容易忽略的点是唯一索引。同一个学生同一门课同一种考试类型理论上只应该有一条成绩记录。所以我会在score表上加一个联合唯一索引ALTER TABLE score ADD UNIQUE KEY uk_student_course_exam (student_id, course_id, exam_type, deleted);注意这里把deleted也放进唯一索引是因为逻辑删除后deleted变成 1如果不加进去删除后再录入同一学生的同一门课成绩会触发唯一键冲突。这是血泪经验很多教程不会提。3.2 MyBatis-Plus 分页插件与逻辑删除的配置MyBatis-Plus 的分页不是自动生效的必须手动注册拦截器。下面是一个典型的配置类Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); // 分页插件指定数据库类型为 MySQL interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }逻辑说明PaginationInnerInterceptor负责在 SQL 执行前拼接LIMIT语句如果不注册这个 Bean你调用page()方法返回的 total 永远是 0。参数上DbType.MYSQL要和你实际数据库一致用 PostgreSQL 却写 MySQL 会导致分页 SQL 语法错误。逻辑删除则是在application.yml里配mybatis-plus: global-config: db-config: logic-delete-field: deleted # 实体类中逻辑删除字段名 logic-delete-value: 1 # 删除后的值 logic-not-delete-value: 0 # 未删除的值配完之后所有select会自动带上deleted 0deleteById会变成update set deleted 1。但要注意手写 XML 的 SQL 不会自动加这个条件需要自己拼。3.3 成绩录入接口的批量插入与事务控制成绩录入最常见的场景是教师选完课程后一次性提交全班学生的成绩。这时候不能一条一条 insert要用批量插入。MyBatis-Plus 的saveBatch底层是循环插入性能一般数据量大时建议自己写 XML 做真正的批量插入。下面是一个 Service 层的写法Service public class ScoreServiceImpl extends ServiceImplScoreMapper, Score implements ScoreService { Override Transactional(rollbackFor Exception.class) public void batchSaveScores(ListScoreDTO scoreList) { // 先删除该课程该考试类型下的旧成绩避免重复录入 LambdaQueryWrapperScore wrapper new LambdaQueryWrapper(); wrapper.eq(Score::getCourseId, scoreList.get(0).getCourseId()) .eq(Score::getExamType, scoreList.get(0).getExamType()); this.remove(wrapper); // 再批量插入新成绩 ListScore entities scoreList.stream().map(dto - { Score score new Score(); score.setStudentId(dto.getStudentId()); score.setCourseId(dto.getCourseId()); score.setScore(dto.getScore()); score.setExamType(dto.getExamType()); return score; }).collect(Collectors.toList()); this.saveBatch(entities, 500); } }逻辑说明Transactional保证删除和插入在同一个事务里要么全成功要么全回滚避免删了旧成绩但新成绩插入失败导致数据丢失。saveBatch的第二个参数 500 是分批大小太小会导致频繁提交太大可能超过数据库的max_allowed_packet。参数上rollbackFor Exception.class是为了让受检异常也触发回滚默认只回滚运行时异常。4. 前后端联调与权限控制跨域、Token 与动态路由4.1 跨域问题的三种解法与选择依据前后端分离项目联调时第一个拦路虎就是跨域。浏览器同源策略要求协议、域名、端口完全一致前端跑在localhost:5173后端跑在localhost:8080端口不同就跨域。常见解法有三种后端加CrossOrigin注解、配置全局 CORS、前端配代理。我一般推荐开发阶段用前端代理生产环境用 Nginx 反向代理后端 CORS 作为兜底。前端 Vite 代理配置如下// vite.config.js export default defineConfig({ server: { proxy: { /api: { target: http://localhost:8080, // 后端地址 changeOrigin: true, // 修改请求头中的 Origin rewrite: (path) path.replace(/^\/api/, ) // 去掉 /api 前缀 } } } })逻辑说明changeOrigin: true让后端收到的请求头里 Host 变成目标地址避免某些框架的 Host 校验拦截。rewrite把/api/student/list重写成/student/list这样后端接口不用带/api前缀。参数上 target 要写后端实际启动的地址和端口别照抄。4.2 JWT Token 的生成、校验与前端存储权限控制的核心是登录后拿到 Token后续请求带上它。后端用 JWT 生成 Token 的代码大致如下public String generateToken(Long userId, String role) { // 设置过期时间为 2 小时 Date expireDate new Date(System.currentTimeMillis() 2 * 60 * 60 * 1000); return Jwts.builder() .setSubject(String.valueOf(userId)) .claim(role, role) // 把角色写进 Token .setIssuedAt(new Date()) .setExpiration(expireDate) .signWith(SignatureAlgorithm.HS256, SECRET_KEY) // 对称加密 .compact(); }逻辑说明claim(role, role)把角色信息塞进 Token前端可以解析出来做按钮级权限但后端不能信任前端传来的角色必须从 Token 里解。SECRET_KEY要足够长且不能硬编码在代码里建议放配置文件或环境变量。前端存储我一般放localStorage请求拦截器里统一加Authorization头// axios 请求拦截器 axios.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers.Authorization Bearer ${token} } return config })注意Bearer后面有个空格后端解析时要去掉这个前缀否则 Token 校验永远失败。4.3 动态路由与按钮级权限的实现思路学生、教师、管理员看到的菜单不一样这就涉及动态路由。我的做法是前端定义一份完整的路由表每条路由带meta.roles字段登录后根据后端返回的角色过滤出可访问的路由再用router.addRoute动态添加。按钮级权限则用一个自定义指令// 权限指令 v-permission app.directive(permission, { mounted(el, binding) { const roles JSON.parse(localStorage.getItem(roles) || []) if (!roles.includes(binding.value)) { el.parentNode.removeChild(el) // 没有权限直接移除按钮 } } })逻辑说明binding.value是指令传入的角色值比如v-permissionadmin。mounted阶段判断当前用户角色是否包含该值不包含就移除 DOM。参数上要注意角色列表从 localStorage 读的时候要处理空值否则JSON.parse(null)会抛异常。5. 避坑与排查成绩管理系统联调中最容易翻车的五个点5.1 分页查询返回 total 为 0现象前端传了pageNum和pageSize后端也调了page()方法但返回的total始终是 0列表数据却正常。原因没有注册PaginationInnerInterceptor或者注册了但DbType配错。解决检查配置类里是否加了分页拦截器DbType是否和实际数据库一致。如果用的是多数据源每个数据源都要单独配。5.2 逻辑删除后唯一索引冲突现象删除一条成绩记录后重新录入同一学生同一课程同一考试类型的成绩报Duplicate entry错误。原因唯一索引没有把deleted字段包含进去逻辑删除只是把deleted改成 1记录还在表里。解决重建唯一索引把deleted加进去如 3.1 节所示。如果表数据量大加索引前先评估锁表时间。5.3 跨域配置了但 OPTIONS 请求仍然 403现象前端发 POST 请求浏览器先发 OPTIONS 预检后端返回 403正式请求根本没发出去。原因Spring Security 或自定义拦截器拦截了 OPTIONS 请求没有放行。解决在拦截器里判断HttpMethod.OPTIONS直接放行或者在 CORS 配置里允许所有方法。如果用了 Spring Security要配置http.cors()并放行 OPTIONS。5.4 Token 过期后前端没有跳转登录页现象Token 过期后后端返回 401但前端页面卡在原地用户不知道发生了什么。原因Axios 响应拦截器没有统一处理 401 状态码。解决在响应拦截器里判断error.response.status 401清除 localStorage 里的 Token 并跳转登录页。注意要防止多个请求同时返回 401 导致重复跳转可以加一个标志位。5.5 批量插入时事务失效现象batchSaveScores方法加了Transactional但删除旧成绩成功、插入新成绩失败时旧成绩没有回滚。原因方法被同类内部调用或者异常被 catch 了没有抛出。解决确保Transactional方法是通过代理调用的不要在同一个类里直接this.batchSaveScores()。另外 catch 块里如果吞了异常事务不会回滚要么重新抛出要么手动TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()。6. 成绩统计的进阶写法用 SQL 聚合替代内存计算成绩管理系统做到后期一定会遇到统计需求班级平均分、课程及格率、学生排名。新手容易犯的错是把所有成绩查出来在 Java 里用 Stream 算平均分和排名。数据量小的时候没问题一旦上千条记录内存和响应时间都会明显上升。更稳妥的做法是把聚合逻辑下推到 SQL让数据库做它擅长的事。以班级平均分为例我一般会写一个自定义 Mapper 方法!-- ScoreMapper.xml -- select idselectClassAvg resultTypecom.example.vo.ClassAvgVO SELECT c.class_id AS classId, c.course_name AS courseName, AVG(s.score) AS avgScore, COUNT(CASE WHEN s.score 60 THEN 1 END) / COUNT(*) AS passRate FROM score s INNER JOIN course c ON s.course_id c.id WHERE s.deleted 0 AND s.exam_type #{examType} GROUP BY c.class_id, c.course_name /select逻辑说明AVG(s.score)直接由数据库计算平均分COUNT(CASE WHEN...)算及格人数再除以总人数得到及格率。参数examType用来区分期中期末避免把不同考试混在一起统计。注意s.deleted 0要手动加因为手写 XML 不会自动应用逻辑删除。排名场景稍微复杂一点MySQL 8.0 以上可以用窗口函数SELECT student_id, score, RANK() OVER (PARTITION BY course_id ORDER BY score DESC) AS rank_num FROM score WHERE deleted 0 AND exam_type finalRANK()遇到相同分数会并列下一个名次跳号如果希望连续排名用DENSE_RANK()。这个区别在做奖学金评定的时候很关键并列第一之后是第三还是第二取决于业务规则。验证统计结果是否正确我习惯用一个小技巧先手动算一个班的平均分再调接口对比。如果对不上先检查 SQL 的 WHERE 条件是不是漏了deleted 0或者exam_type过滤这两个是最常见的偏差来源。另外AVG函数会自动忽略 NULL 值如果成绩字段允许为空平均分会被拉高建议在录入时就做非空校验。最后说一个我自己的习惯每次写完统计 SQL我都会在数据库客户端里先跑一遍把结果和预期对一遍再贴进 XML。直接写进代码里调试改一次重启一次效率太低。希望帮到你。本文还有配套的精品资源点击获取