
简介基于SpringBoot与Vue前后端分离架构实现的学生选课与成绩管理系统适合正在学习全栈开发或需要课程设计、毕业设计参考的开发者也可用于快速理解教务管理场景中的常见业务逻辑。系统围绕管理员、教师、学生三类角色设计覆盖课程信息维护、教师与学生的增删改查、课程选修退选、选课学生名单查看、成绩登记与个人成绩查询等完整环节形成闭环的教学管理流程。资源包共137个文件压缩包大小约25.56MB。文件构成以Java后端源码、Vue前端组件、SQL数据库脚本为主另有XML配置文件、YML与properties环境配置、可执行JAR包、启动脚本、Maven相关文件等能支撑从数据库初始化到前后端启动的完整流程。前端涉及Vuex状态管理、Vue Router路由、Axios通信及Element UI组件库后端基于Spring Boot 2.6.3并整合Mybatis持久层框架。目前已有37人学习下载。获取后可预先导入SQL脚本再根据描述调整MySQL连接参数以运行项目。通过阅读Controller、Service、Mapper三层代码与Vue页面的对照关系可深入掌握前后端分离开发的关键技巧也方便在此基础上扩展权限管理、课程评价等功能是项目实战或毕业设计二次开发的不错起点。1. 从学生选课系统看 Spring Boot Vue 前后端分离的真实分工做过教务系统的人都有一个共识选课系统看着简单真拆起来却要同时处理权限模型、并发选课、成绩录入、前后端联调这些事。这个基于 Spring Boot 2.6.3 与 Vue 的学生选课与成绩管理系统恰好把一条完整链路摆了出来——后端负责登录认证和事务控制前端用 Vuex 管状态、sessionStorage 管会话中间靠 Axios 和 Token 串起来。适合正在做课程设计但没有实战经验的人也适合刚接手前后端分离项目、想快速理解工程结构和启动方式的在职开发。全文会从冷启动开始一直讲到选课并发场景下的数据库处理最后附上上手就能用的排错清单。2. 启动一个前后端分离项目需要先处理的三件事MySQL 脚本、端口与打包产物2.1 数据库脚本导入不是双击执行那么简单大多数下载包里都有.sql脚本但直接双击导入经常遇到字符集错乱和表名大小写问题。项目用的是 MySQL常见做法是用命令行工具手动导入这样才能控制编码格式和导入路径。mysql -u root -p --default-character-setutf8mb4 sct_db.sql这段命令将sct_db.sql文件导入到本机 MySQL--default-character-setutf8mb4指定客户端连接字符集。系统里课程名称、学生姓名这类中文数据较多如果这里不加参数很容易出现乱码。建议在导入前先建好库再指定库导入CREATE DATABASE IF NOT EXISTS sct_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE sct_db; SOURCE /path/to/sct_db.sql;一个容易踩的坑是 MySQL 8.x 与 5.7 对排序规则的默认值不同。脚本里如果写了utf8_general_ci在 MySQL 8.0 上也能跑但用utf8mb4_general_ci更稳妥因为utf8mb4能覆盖表情符号和生僻字。导入完成后重点检查三张业务表学生表、教师表、课程表以及选课关联表。这个系统的表名如果是复数形式而 Linux 下 MySQL 默认lower_case_table_names0Windows 下是 1跨平台迁移时就会出现“表不存在”的报错后面部署到服务器时尤其要注意。2.2 后端的两种启动方式源码启动与 JAR 包差异项目里同时给了源码和student_server.jar这很贴心但很多人不理解为什么会有两种启动方式。源码启动适合调试JAR 包启动适合演示和部署。先说源码启动Spring Boot 项目通过 Maven 管理依赖mvnw.cmd是 Windows 下的 Maven 包装器脚本它的作用是锁定 Maven 版本避免本机环境变量里 Maven 版本不一致导致的编译问题。mvnw.cmd clean package -DskipTests java -jar target/student_server-0.0.1-SNAPSHOT.jar注意摘要里明确说明默认服务器端口是 10086这个端口需要在application.yml或application.properties里预先配置server: port: 10086 spring: datasource: url: jdbc:mysql://localhost:3306/sct_db?useSSLfalseserverTimezoneAsia/ShanghaiallowPublicKeyRetrievaltrue username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver这里serverTimezoneAsia/Shanghai是必须的否则插入时间字段时会报错。allowPublicKeyRetrievaltrue只在 MySQL 8.x 使用 caching_sha2_password 插件时需要如果数据库账号密码检测不通过可以先检查这两项。JAR 包模式则更简单java -jar student_server.jar但注意JAR 包里的数据库账号密码是预设好的如果本机 MySQL 的 root 密码不等于预设值直接启动必失败。这就是为什么很多人在拿到 JAR 包后第一步就卡住——不是代码问题是配置对齐问题。2.3 前端工程的启动与反向代理前端部分默认是基于 Vue CLI 或 Vite 构建的工程。启动前先安装依赖npm install npm run serve前端默认跑在 8080 端口后端跑在 10086两者端口不同浏览器直接请求后端接口必然会触发跨域。解决方式有两种开发环境用 Vue CLI 的代理生产环境用 Nginx。开发环境配置在vue.config.jsmodule.exports { devServer: { port: 8080, proxy: { /api: { target: http://localhost:10086, changeOrigin: true, pathRewrite: { ^/api: } } } } };这段配置的意思是前端所有/api开头的请求都会被转发到http://localhost:10086并且把路径前缀/api去掉。changeOrigin: true是为了让后端拿到的 Host 头指向目标地址避免部分框架做域名校验时报 403。生产环境则交给 Nginxlocation / { root /usr/share/nginx/html; try_files $uri $uri/ /index.html; } location /prod-api/ { proxy_pass http://localhost:10086/; }从启动链路能看出这个项目的接口前缀设计直接决定了代理规则的写法。如果后端 Controller 的 RequestMapping 里没有统一前缀前端代理时就需要精确到具体路径段否则会出现“前端能登录、但列表加载不出来”这种典型联调事故。建议一开始就在后端加server.servlet.context-path: /api让整套代理规则统一。3. 后端权限链路拆解从登录认证到接口鉴权3.1 登录接口的认证数据流看项目里的SCTcontroller.java这是一个典型的聚合控制器把登录、选课、成绩查询等接口全部收拢在一个类里。先看登录部分学生和教师走同一张登录逻辑但背后查询的表不一样。伪造一个最常见的登录接口PostMapping(/login) public Result login(RequestBody LoginDTO loginDTO) { String username loginDTO.getUsername(); String password MD5Util.md5(loginDTO.getPassword()); if (student.equals(loginDTO.getRole())) { Student student studentService.login(username, password); return Result.ok(buildToken(student.getId(), student)); } else if (teacher.equals(loginDTO.getRole())) { Teacher teacher teacherService.login(username, password); return Result.ok(buildToken(teacher.getId(), teacher)); } else { Admin admin adminService.login(username, password); return Result.ok(buildToken(admin.getId(), admin)); } }这里的关键点是Role字段的处理。很多前后端分离项目把角色信息直接放在前端判断导致接口可以被轻易仿造。正确做法是后端在登录接口就根据角色分别查表并把角色写进 Token后续每次请求都在拦截器里校验角色权限。MD5Util.md5是常见封装但要注意明文密码与 MD5 拼接盐值的问题实际开发中建议至少用加盐 MD5 或 BCrypt此处因为是学习项目MD5 是简化处理。3.2 Token 校验与拦截器设计项目没有大量使用 Shiro 或 Spring Security而是选择了轻量的拦截器方案这在中小型前后端分离项目中非常常见。核心逻辑如下Component public class AuthInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token request.getHeader(Authorization); if (token null || !token.startsWith(Bearer )) { response.setStatus(401); return false; } Claims claims JwtUtil.parse(token.substring(7)); if (claims null) { response.setStatus(401); return false; } request.setAttribute(userId, claims.get(userId)); request.setAttribute(role, claims.get(role)); return true; } }拦截器的作用是统一身份验证避免每个 Controller 里重复写 Token 解析逻辑。request.setAttribute将解析出来的用户信息放入请求上下文后续 Controller 直接从 request 里取即可。注册拦截器时需要排除登录接口和静态资源路径Configuration public class WebConfig implements WebMvcConfigurer { Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new AuthInterceptor()) .addPathPatterns(/**) .excludePathPatterns(/login, /register, /favicon.ico); } }这里有个容易被忽略的细节excludePathPatterns的路径匹配是 Ant 风格如果你的接口前缀是/api/login这里就必须写/api/login否则会被拦截器拦住。很多课程设计项目在这个地方浪费大量时间其实是路径写错了。另外从student_server.jar中提取源码时还要注意构建时有没有将前端静态资源打进 JAR如果项目在src/main/resources/static下放了index.html和favicon.ico表示打包后前端文件由 Spring Boot 直接托管此时排除路径需要额外处理首页访问。3.3 Mybatis 数据持久化与动态 SQL 要点项目后端基于 Mybatis 框架这一点从CourseTeacherService.java和SCTService.java的依赖关系能看出来。Mybatis 的 XML Mapper 是这个项目的核心所在选课列表、学生名单、成绩单这些核心数据的读取都依赖动态 SQL。以学生选课列表为例select idselectAvailableCourses resultTypejava.util.Map SELECT c.course_id, c.course_name, c.teacher_id, t.teacher_name, c.capacity, (SELECT COUNT(*) FROM sct_selection s WHERE s.course_id c.course_id) AS selected_count FROM sct_course c LEFT JOIN sct_teacher t ON c.teacher_id t.teacher_id WHERE c.course_id NOT IN ( SELECT course_id FROM sct_selection WHERE student_id #{studentId} ) /select这段 SQL 的查询条件是核心用NOT IN排除当前学生已经选过的课程用子查询统计每个课程的已选人数。#{studentId}是预编译参数能防止 SQL 注入这是 Mybatis 和字符串拼接 SQL 最大的区别。项目里常见的错误是把#{}写成${}后者虽然也能跑通但存在注入风险且无法被 MySQL 预编译优化。CourseTeacherService.java的职责是关联课程表与教师表这类关联查询的返回结果通常用Map接收便于前端直接渲染。但要注意Map 的 key 与数据库列名大小写可能不一致最好在 XML 里用别名统一规范例如teacher_name AS teacherName否则前端从接口拿到的字段可能是下划线风格与 Vue 模板中的驼峰命名对不上。这块如果不统一就会变成前端取不到值的隐性 bug排查起来相当头疼。4. 选课与成绩管理中的事务与并发控制4.1 选课接口的核心实现选课是这个项目里含金量最高的一个功能因为它涉及事务、锁和状态一致性。一个标准的学生选课接口是这样的Transactional(rollbackFor Exception.class) public Result selectCourse(Long studentId, Long courseId) { Course course courseMapper.selectById(courseId); if (course null) { return Result.error(课程不存在); } int selected selectionMapper.countByCourseId(courseId); if (selected course.getCapacity()) { return Result.error(课程已满); } Selection selection new Selection(studentId, courseId); return selectionMapper.insert(selection) 0 ? Result.ok() : Result.error(选课失败); }Transactional(rollbackFor Exception.class)是事务注解的核心配置rollbackFor指定了只有遇到Exception级别的异常才回滚不写会导致运行时异常与编译异常的回滚策略不一致。这里的常规流程是先查课程是否满员再做插入。看似完整实际上并发场景下一旦两个学生同秒提交countByCourseId可能读到同一个未满的数据导致超选。这是选课系统最常见的问题下面讲改进。4.2 并发选课与数据库锁的取舍解决并发选课超员业界主流做法是使用数据库行锁或乐观锁。行锁的实现方式是改造查询语句SELECT capacity FROM sct_course WHERE course_id #{courseId} FOR UPDATE;FOR UPDATE是排他锁事务提交前其他事务无法读取该行。配合Transactional注解就能保证从检查课容量到插入选课记录是一个原子操作。但FOR UPDATE也有代价——如果事务处理时间过长会阻塞其他学生的选课请求用户体验变差。另一种方案是乐观锁在课程表加一个version字段UPDATE sct_course SET capacity capacity - 1, version version 1 WHERE course_id #{courseId} AND version #{oldVersion}受影响行数为 0 时说明版本冲突选课失败。这种方案吞吐量更高但实现复杂度上升。对这个项目的数据量来说参考写法是直接在选课表上加唯一约束从根上杜绝同一学生重复选同一门课ALTER TABLE sct_selection ADD UNIQUE KEY uk_student_course (student_id, course_id);数据库中加上这个唯一索引后即使出现并发insert数据库也能保证同一对学生和课程只存在一条记录。抓取 DuplicateKeyException 后返回“请勿重复选课”用最少代码解决重复选课问题。4.3 成绩录入与状态更新教师端录入成绩的逻辑同样在SCTService.java中体现。成绩一旦录入通常不允许学生自己修改所以更新语句要侧重条件覆盖Transactional public int updateScore(Long selectionId, Double score, Double newScore) { Score scoreRecord scoreMapper.selectBySelectionId(selectionId); if (scoreRecord ! null scoreRecord.getScore() ! null) { return scoreMapper.updateScore(selectionId, newScore, 1); } return scoreMapper.insertScore(selectionId, newScore); }这里使用的是插入与更新二段式逻辑如果成绩已存在则更新否则插入。实际项目中更推荐使用 MySQL 的INSERT ... ON DUPLICATE KEY UPDATE语法一条 SQL 解决问题。此外成绩的合法性校验也应当放在后端score的范围是 0 到 100前端 Element UI 的校验只是体验优化后端必须硬校验否则绕过前端直接调用接口就能写入非法成绩。更新成绩时加乐观锁也是必要的防止两位老师同时改一个学生的成绩后面的覆盖前面的这在教务系统的操作日志里经常能看到相似问题。curl -X POST http://localhost:10086/score/update \ -H Authorization: Bearer token \ -H Content-Type: application/json \ -d {selectionId: 12, score: 95}后端返回 JSON 结构里一般包含code、message、data三件套前端在 Axios 响应拦截器里统一判断code 200避免每个页面都写一遍错误提示逻辑。5. 前端 Vue 工程与状态管理从登录页面到角色菜单渲染5.1 路由守卫控制页面权限前端工程里Vue Router 的作用不只是跳转页面更是页面的第一道门禁。项目采用 sessionStorage 存储会话数据因此路由守卫的写法也能看得清楚router.beforeEach((to, from, next) { const userInfo JSON.parse(sessionStorage.getItem(userInfo)); if (to.path ! /login !userInfo) { next(/login); } else { next(); } });这是一个最基础的全局前置守卫用户未登录时访问任何页面都会被重定向到登录页。项目里如果还有角色区分还应在meta字段中定义角色const routes [ { path: /admin, component: AdminLayout, meta: { roles: [admin] } }, { path: /teacher/courses, component: TeacherCourses, meta: { roles: [teacher] } } ];然后在守卫里加一段角色判断先拿 sessionStorage 里的角色字段再对比路由的meta.roles。要注意一个常见的坑登录后将用户信息写入 sessionStorage但刷新页面时信息还在某些写法把用户状态只放在 Vuex 里刷新后 Vuex 清空又得重新拉用户信息页面上会出现短暂的白屏闪烁。这里用 sessionStorage 的好处是刷新不丢失关闭浏览器即失效逻辑恰好匹配管理系统的会话要求。5.2 Axios 拦截器统一携带 Token 与错误处理项目中凡是涉及接口调用的页面都依赖 Axios。拦截器的作用是统一把 Token 放进请求头、统一兜底处理错误码让每一个业务页面都不用关心“怎么带 Token 这个重复动作”。参考写法如下import axios from axios; const service axios.create({ baseURL: /api, timeout: 10000 }); service.interceptors.request.use(config { const token sessionStorage.getItem(token); if (token) { config.headers.Authorization Bearer ${token}; } return config; }); service.interceptors.response.use( response { const res response.data; if (res.code 401) { sessionStorage.clear(); window.location.href /login; return Promise.reject(new Error(未登录或登录已过期)); } return res; }, error { return Promise.reject(error); } );请求拦截器在发出请求前自动附加 Authorization 头这样每个业务接口写起来非常干净。响应拦截器里专门处理 401 状态码意思是 Token 失效或者没有登录此时清空会话并跳到登录页。注意在 JWT 场景下前端是无法判断 Token 是否过期的只能通过后端返回 401 来感知。一些项目会在这里加入“刷新 Token”逻辑用refresh_token自动续期学习项目有意识不加保持简单链路便于看清楚身份持久化的核心路径。5.3 Vuex 与 sessionStorage 的分工边界初学者常把 Vuex 和 sessionStorage 混用项目里这两者的分工是一个可以做范本的案例。Vuex 保存的是“当前登录人的实时状态”比如用户昵称、角色sessionStorage 保存的是“Full 会话快照”刷新后恢复 Vuex 状态来源。理论上 Vuex 不是持久化存储页面一刷新就回到初始值而 sessionStorage 是浏览器 Storage 层天然持久。所以推荐的做法是登录成功后同时写 Vuex 和 sessionStorage刷新页面时优先从 sessionStorage 中恢复数据const userInfo JSON.parse(sessionStorage.getItem(userInfo)); if (userInfo) { store.commit(SET_USER, userInfo); } else { router.push(/login); }这一段通常放在main.js或 App.vue 的created钩子里执行保证页面加载完成且 Vuex 初始化之后立刻填充用户状态。如果把这段逻辑只放在某个页面的mounted中刷新浏览器时会有一瞬间的“未登录状态”再闪回真实页面影响体验。5.4 按角色动态渲染菜单与按钮项目把管理员、教师、学生三种角色集中在同一个前端工程中菜单渲染的常见方案是后端登录接口返回role字段前端根据 role 决定展示哪个菜单项。最小实现方式computed: { menus() { const role this.$store.state.user.role; if (role admin) { return this.adminMenus; } else if (role teacher) { return this.teacherMenus; } else { return this.studentMenus; } } }menu 列表是纯前端配置缺点是新增角色时必须改前端代码发布。生产级系统会改成后端接口返回权限菜单树前端只负责渲染。但作为课程设计项目前端配置已经很够用。重点在sessionStorage与 Vuex 同时包含角色信息并确保退出登录时同时清除两处否则会出现“页面菜单变了但接口权限还是旧角色”的状态错乱。这个小点在 Spring Boot 后端有拦截器保护的情况下不会造成安全问题但会让前端交互变得非常诡异排查时先看存储再谈接口能省下大把时间。6. 排错清单把启动失败从一小时缩短到三分钟6.1 端口被占用与 root 权限问题后端默认端口是 10086这个端口在公司内网环境很常被其他进程占掉。Windows 下先查占用再杀掉对应进程两条命令就够netstat -ano | findstr 10086 taskkill /PID 12345 /F由于 10086 是非标准端口使用没有被防火墙或云安全组放通就有可能导致前端能访问但接口全部连接超时的现象。如果把服务部署到 ECS除核实进程外还要看向外暴露的安全策略规则端口不开通是部署新手的常见盲区。6.2 数据库密码和时区错误JAR 包无法修改内置配置如果你执行java -jar student_server.jar时控制台报Access denied for user rootlocalhost说明你的 MySQL 密码与配置不符。解决方案是把 JAR 包启动方式改为源码启动修改 YAML 里的password字段。这类错误在 StackOverflow 中出现的频率极高真正常见的原因有两个一是 MySQL 8.0 的默认认证插件是caching_sha2_password而 Spring Boot 2.6.3 的驱动兼容性没问题但依赖版本可能导致驱动类加载异常二是 YAML 文件里serverTimezone没写或写错成UTC导致日期字段偏移问题日志中常见The server time zone value йʱ is unrecognized。统一使用serverTimezoneAsia/Shanghai即可解决。6.3 前端页面能打开但接口 404这种问题通常出现在生产环境或代理配置有残留的场景。开发环境出现404时先看路径前缀是否匹配。如果后端没有设置context-path前端baseURL: /api会在本地代理时把/api前缀剥掉但后端接口本身没有/api前缀如果 Nginx 的proxy_pass结尾带不带/会导致不同的拼接效果。更简单的排查方式是用浏览器开发者工具观察请求 URL 的实际路径再对照后端的 Controller 映射路径通常一眼就能看出是缺了前缀还是多了前缀。处理规则统一开发环境代理加pathRewrite生产环境 Nginxproxy_pass以/结尾保证后端只写/login这种相对路径前端不用关心前缀拼接。6.4 选课列表表名大小写与数据库一致性最后给一个项目里最隐蔽的坑Linux 服务器 MySQL 表名默认区分大小写Windows 上不区分。本地开发时项目运行得好好的部署到服务器上就报Table sct_db.sct_selection doesnt exist。根源在于前端代码里某个 SQL 的表名大小写与数据库建表脚本不一致Windows 上 MySQL 会忽略这种差异Linux 上则是两个完全不同的表。解决办法是把所有 Mapper XML 和建表语句统一换成小写表名并在 MySQL 配置中设置lower_case_table_names1需要在初始化前配置否则改动不生效。这个问题出现的隐蔽程度很高也是把本地项目搬到服务器后最常见的失败原因之一。建议在项目初始阶段就约定好全库表名小写避免后续迁移时逐一排查 SQL 语句的成本。本文还有配套的精品资源点击获取