Springboot+Vue学生选课系统:前后端分离与权限控制实战
简介这套基于Spring Boot和Vue的前后端分离学生网上选课系统是一份完整的毕业设计项目资源面向需要完成毕设的计算机、软件工程专业学生也适合希望系统提升Java Web全栈开发能力的开发者。系统针对传统人工选课流程繁琐易错、效率低的问题实现了用户管理、课程信息管理、选课逻辑等核心功能通过RESTful接口完成前后端数据交互。压缩包共417个文件约21.82MB包含109个Java后端源码、56个Vue前端页面、数据库SQL脚本、万字设计论文以及安装和启动用的批处理脚本另有少量图片、样式、配置文件辅助理解。资源已经过开发者调试可直接运行作者还提供远程调试、二次开发和项目讲解服务能够帮助你深入掌握Spring Boot与Vue全栈开发流程并在此基础上进行功能扩展。目前已有33人学习适合作为毕业设计参考或实践项目。1. 学生网上选课系统SpringbootVue 前后端分离课程的完整落地每年毕设季选课系统都是 Java 方向的常青树但我翻过上百份源码真正能跑通、能交差、答辩不翻车的其实不多。这份基于 SpringbootVue 的学生网上选课系统我拆完之后最大的感受是它的模块划分很规矩——管理员、教师、学生三种角色权限清晰选课、退课、成绩录入、课程管理这些核心业务都覆盖到了正是课程设计和毕业设计最需要的那种“功能不少、复杂度适中、演示效果好”的项目。对于正在做 Java 毕设或者想快速掌握前后端分离开发流程的同学来说这套代码的参考价值在于它完整串起了从数据库设计到前端页面渲染的整条链路。更重要的是它不是一个只写了 CRUD 的空壳选课冲突检测、课程容量控制这类业务逻辑都有对应的实现这部分才是答辩时能讲出深度的关键。2. 选课系统的核心模型数据库设计与角色权限拆解2.1 从需求反推数据表五张核心表怎么设计拿到这类系统我一般不会先看代码而是先打开 SQL 脚本看表结构。表设计决定了业务逻辑的上限后端写得再花哨表字段缺了选课状态这种关键信息后面全是坑。这套系统的数据库表设计走的是经典的教学管理系统范式核心表基本围绕用户、课程、选课记录三个维度展开。用户表通常会拆成学生表和教师表而不是统一放一张 user 表因为学生有学号、班级、专业教师有工号、职称字段差异太大硬合并反而让查询变得臃肿。课程表则需要包含课程编号、课程名称、学分、上课时间、上课地点、容量、已选人数、授课教师 ID 这些字段其中“已选人数”这个字段是选课冲突检测的关键依据。选课表是整个系统的核心业务表它记录了学生和课程的关联关系同时携带选课状态字段已选、退选、成绩。这里有一个容易被忽视的设计细节选课表一定要加唯一约束联合主键或唯一索引建立在 student_id 和 course_id 上否则高并发下同一个学生可能重复选同一门课。虽然毕设场景不会真有什么并发压力但这是答辩时老师喜欢问的点。CREATE TABLE t_course ( id int(11) NOT NULL AUTO_INCREMENT, course_no varchar(20) NOT NULL COMMENT 课程编号, course_name varchar(50) NOT NULL COMMENT 课程名称, credit decimal(3,1) DEFAULT NULL COMMENT 学分, teacher_id int(11) DEFAULT NULL COMMENT 授课教师ID, capacity int(11) DEFAULT 50 COMMENT 课程容量, selected_count int(11) DEFAULT 0 COMMENT 已选人数, class_time varchar(100) DEFAULT NULL COMMENT 上课时间, class_location varchar(100) DEFAULT NULL COMMENT 上课地点, PRIMARY KEY (id), UNIQUE KEY uk_course_no (course_no) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4; CREATE TABLE t_student_course ( id int(11) NOT NULL AUTO_INCREMENT, student_id int(11) NOT NULL COMMENT 学生ID, course_id int(11) NOT NULL COMMENT 课程ID, status tinyint(1) DEFAULT 0 COMMENT 0-已选 1-退选 2-已录入成绩, score decimal(5,1) DEFAULT NULL COMMENT 成绩, create_time datetime DEFAULT NULL COMMENT 选课时间, PRIMARY KEY (id), UNIQUE KEY uk_student_course (student_id, course_id) ) ENGINEInnoDB AUTO_INCREMENT1 DEFAULT CHARSETutf8mb4;这里有两个字段值得留意。selected_count是冗余字段它不存关联表里而是直接冗余在课程表中目的就是选课时直接比对capacity和selected_count不用每次去 count 选课记录表虽然数据量小的时候性能差异不明显但在批量查询课程列表时这种设计能避免每门课都执行一次子查询。uk_student_course这个唯一索引是防止重复选课的兜底手段——即使业务代码里忘了做判重数据库层面也会直接拒绝第二插入。2.2 角色权限设计三套接口背后的 Spring Security 配置思路这套系统的权限模型是标准的三角色体系管理员管课程和用户教师录成绩、看课表学生选课、退课、查成绩。在前后端分离架构下权限控制的落点不在页面隐藏而在接口拦截。前端该不该显示某个按钮只是体验问题后端接口是否能被未授权身份调用才是安全问题。项目采用 JWT 做无状态认证登录成功后端返回 token前端把 token 存到 localStorage每次请求在 Axios 拦截器里拼到Authorization头。Spring Security 端只需要配置一个过滤器链放行登录接口和静态资源其余接口统一走 JWT 校验过滤器。这种设计的优点是服务端不需要维护 Session扩展性好毕设演示时也不存在 Session 失效的问题。但需要注意一个细节JWT 的 secret 密钥不要硬编码在 application.yml 里用明文虽然毕设项目被攻击的概率很低但把密钥单独抽到环境变量是让代码显得专业的加分项。Override protected void configure(HttpSecurity http) throws Exception { http.csrf().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS) .and() .authorizeRequests() .antMatchers(/api/auth/login).permitAll() .antMatchers(/api/admin/**).hasRole(ADMIN) .antMatchers(/api/teacher/**).hasRole(TEACHER) .antMatchers(/api/student/**).hasRole(STUDENT) .anyRequest().authenticated() .and() .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); }这段配置中/api/student/**这类路径匹配规则保证了角色与接口的严格对应。hasRole(ADMIN)会自动在传入角色前加ROLE_前缀所以数据库或 JWT 里的角色字段要存ROLE_ADMIN这种格式否则会一直 403这是新手最容易踩的坑。addFilterBefore把自定义的 JWT 过滤器挂在 UsernamePasswordAuthenticationFilter 之前意味着请求进来先解析 token、把用户信息塞进 SecurityContext后续的鉴权拦截器才能拿到身份信息做判断。3. 后端核心业务实现选课与退课的并发控制3.1 选课接口容量检查和防重复提交的完整链路选课接口是这套系统的核心业务它要同时满足两个约束课程不能超选学生不能重复选。如果代码只做了单重校验在并发场景下一定会出问题——两个请求同时读到剩余容量 1都通过了检查然后各自插入一条选课记录课程就超员了。低级的写法是查一下selected_count再插入但这种写法在并发下根本防不住。我在重构这类接口时一般会在 update 语句里做条件更新用数据库的行锁来保证原子性。具体思路先给学生插入选课记录然后执行 UPDATE 语句条件带selected_count capacity影响行数为 0 就说明课程已满需要回滚。这里必须开启事务把插入和更新放在同一个事务里任何一步失败都整体回滚。Transactional(rollbackFor Exception.class) public Result selectCourse(Long studentId, Long courseId) { // 先查课程是否存在 Course course courseMapper.selectById(courseId); if (course null) { return Result.error(课程不存在); } // 查重复选课记录 int exist studentCourseMapper.countByStudentAndCourse(studentId, courseId); if (exist 0) { return Result.error(请勿重复选课); } // 条件更新容量未满才更新成功 StudentCourse sc new StudentCourse(); sc.setStudentId(studentId); sc.setCourseId(courseId); sc.setStatus(0); sc.setCreateTime(new Date()); int insert studentCourseMapper.insertSelective(sc); if (insert 0) { return Result.error(选课失败请重试); } int updated courseMapper.incrSelectedCount(courseId); if (updated 0) { throw new RuntimeException(课程容量已满); } return Result.success(选课成功); }注意这里的执行顺序我设计的是先插入选课记录、再更新课程容量。这是因为如果先更新容量成功、后插入选课记录失败会出现容量被占用但选课记录不存在的情况学生没选上课却占了名额。反过来先插入后更新更新失败时事务回滚会把插入也撤销掉数据就一致了。Transactional(rollbackFor Exception.class)中的rollbackFor很关键Spring 默认只在遇到 RuntimeException 时才回滚如果你抛出的是自定义检查异常不加这个参数事务就不会回滚。3.2 退课接口容量释放与状态流转的细节退课逻辑看起来比选课简单但它也有一个容易翻车的点业务上应该做“软删除”而不是“物理删除”。也就是说退课不是 DELETE 掉选课记录而是把 status 字段从 0已选改成 1退选。原因是学生选课历史的完整性需要保留教师端和管理员端可能要看退课记录物理删除会让这些数据凭空消失。退课接口要做的操作有两步更新状态为退选、把课程的selected_count减一。同样的道理这两步必须在一个事务里。我见过一些版本的代码把两步拆在两个 Service 方法里没有加事务注解结果第一步成功、第二步抛异常选课记录显示已退但课程容量没释放后面想再选这门课就一直提示容量不足。Transactional(rollbackFor Exception.class) public Result dropCourse(Long studentId, Long courseId) { int updated studentCourseMapper.updateStatusByStudentAndCourse( studentId, courseId, 1); if (updated 0) { return Result.error(退课失败选课记录不存在或已退选); } courseMapper.decrSelectedCount(courseId); return Result.success(退课成功); }这里还需要考虑一个业务边界如果教师已经录入了成绩学生就应该不能再退课。这种状态约束在实际系统里必须有不加的话可能出现学生退掉一门已经有了成绩的课成绩单上这门课消失了的逻辑漏洞。我建议在 update 语句里带一个AND status 0条件只允许从“已选”状态退到“退选”成绩已录入的状态不能被 update 影响。3.3 MyBatis 动态 SQL批量开课与条件查询的拼接技巧后端大量查询涉及多条件组合学生按课程名称、学分、教师名筛选可选课程管理员按课程编号、教师名查开课情况。这种场景手写 SQL 拼接容易出错MyBatis 的if标签和where标签是标准解法。where标签会自动处理首个条件前面的 AND 或 OR避免出现WHERE AND这种 SQL 语法错误。select idselectCourseList resultTypecom.example.entity.Course SELECT c.*, t.real_name AS teacherName FROM t_course c LEFT JOIN t_teacher t ON c.teacher_id t.id where if testcourseName ! null and courseName ! AND c.course_name LIKE CONCAT(%, #{courseName}, %) /if if testcredit ! null AND c.credit #{credit} /if if testteacherName ! null and teacherName ! AND t.real_name LIKE CONCAT(%, #{teacherName}, %) /if /where ORDER BY c.id DESC /selectLIKE 查询的写法有个坑MySQL 的 CONCAT 函数解决了参数拼接的问题但如果你是直接写LIKE %${courseName}%那就是 SQL 注入口子。${}是直接字符串替换#{}才是预编译参数占位。毕设代码里如果出现${}拼条件答辩时被老师问出来会很狼狈。另外分页我一般搭配 PageHelper 用只需在 Service 层调用前设置PageHelper.startPage(pageNum, pageSize)它会自动拦截下一条 SQL 生成 limit不用手写分页参数。4. 前端 Vue 实现从页面路由到接口对接的完整链路4.1 前端项目结构Vue CLI Element UI 的目录划分方式前端这边用的是 Vue 2 Element UI 的组合目录结构是标准的前后端分离项目布局核心集中在src/views、src/router、src/api三个目录。src/views按角色分模块admin、teacher、student 三个文件夹下分别是各自的页面组件每个页面对应一个.vue文件。src/api目录下按模块封装了接口请求函数比如course.js里统一封装课程相关的增删改查请求组件里只调函数不直接写 axios。这种分层的好处是接口路径集中管理后端改路径时前端只需要改一个文件。src/utils/request.js里封装了 axios 实例统一设置了 baseURL、请求超时时间和响应拦截器响应拦截器里对 HTTP 状态码和业务状态码做了统一处理后端返回 code 为 200 时直接返回数据否则弹出错误提示。// src/utils/request.js import axios from axios import { Message } from element-ui import router from ../router const service axios.create({ baseURL: /api, timeout: 10000 }) service.interceptors.request.use(config { const token localStorage.getItem(token) if (token) { config.headers[Authorization] token } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { Message.error(res.message || 请求失败) return Promise.reject(new Error(res.message)) } return res }, error { if (error.response error.response.status 401) { localStorage.removeItem(token) router.push(/login) } Message.error(error.message || 网络异常) return Promise.reject(error) } ) export default service这里有一个需要说的点token 存储用的是 localStorage这在毕设项目里够用但它的隐患是 XSS 攻击可以读到 token——如果页面里用了v-html渲染后端返回的富文本又没做过滤恶意脚本就能把 localStorage 里的 token 偷走。更稳妥的方案是放 cookie 里配合 HttpOnly但前后端分离跨域场景下 cookie 配置比较麻烦毕设阶段不做强要求理解这个风险就行。4.2 前端路由与权限控制为什么不能只靠 router.beforeEach前端路由守卫的目的是控制页面访问权限Vue Router 的beforeEach钩子里判断 token 是否存在不存在就重定向到登录页。这个方案能挡住未登录用户通过 URL 直接跳转页面但它挡不住已登录用户的越权访问——一个学生手动把地址改成/admin/course路由守卫检查 token 存在就放行了但后端接口返回 403。// src/router/index.js router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() } else { if (!token) { next(/login) } else { next() } } })路由守卫能解决的第一层问题是“没登录不能看”解决不了“没权限不能看”。真正的拦截必须发生在后端接口层前端的路由守卫只负责用户体验——引导用户去登录、在请求 401 时自动跳登录页。这个认知很重要我在拆项目时看到不少人把前端路由守卫当成了安全边界实际上它连安全边界都算不上顶多算个门卫。前端权限的进阶做法是路由动态生成根据登录用户的角色动态添加路由表管理员登录时多挂几条 admin 路由。但因为后端接口已经把权限卡死了动态路由的价值主要是让界面更干净该给用户看的菜单才显示而不是安全层面的事。4.3 表格与表单页面Element UI 组件的数据绑定模式选课系统的前端页面有很大一部分是数据表格课程列表、学生列表、成绩列表都用到 el-table。Element UI 的表格用:data绑定数组列通过 el-table-column 的prop属性映射字段名操作列里放按钮触发对应方法。分页组件 el-pagination 和表格配合是固定套路当前页码和每页条数维护在 data 里切换页面时重新调接口查数据。课程管理页的新增和编辑用的是对话框里套表单el-dialog 和 el-form 组合表单校验规则写在:rules里。有一点容易踩坑el-dialog 关闭后表单数据不会自动清空需要在close事件里调用resetFields()否则下次打开时还残留上次的数据。这个 bug 很隐蔽演示的时候如果操作流程是先新增再修改很可能就会出现修改对话框里预填了新增时的数据。template div el-table :datacourseList border stripe el-table-column propcourseName label课程名称 width180/el-table-column el-table-column propcredit label学分 width80/el-table-column el-table-column propcapacity label容量 width80/el-table-column el-table-column propselectedCount label已选人数 width80/el-table-column el-table-column label操作 width150 template slot-scopescope el-button typetext clickopenEditDialog(scope.row)编辑/el-button el-button typetext stylecolor: #F56C6C clickhandleDelete(scope.row.id)删除/el-button /template /el-table-column /el-table el-pagination background layouttotal, prev, pager, next :totaltotal :page-sizequeryParams.pageSize current-changehandlePageChange /el-pagination /div /templateslot-scopescope取到当前行的数据scope.row就是这一行的完整对象操作按钮要通过它把当前行的 id 传给处理函数。表格的border和stripe是两个常用属性前者加边框让表格在演示时视觉上更清晰后者给奇数行加底色提高可读性。接口返回的数据结构里如果后端把课程名、学分这些字段名定义得和前端不一致页面就不会显示数据这是前后端联调第一天最容易碰到的问题。5. 部署与运维避坑从本机跑通到服务器发布的常见问题5.1 打包配置前后端分离项目的构建参数前端打包默认输出到 dist 目录关键配置在vue.config.js里。publicPath要写成相对路径./默认的根路径在部署到子目录时会找不到静态资源页面白屏。后端打包用 Maven 的mvn clean package -DskipTests生成 jar 包Springboot 内嵌 Tomcat 提供接口服务。这套系统我建议用 Nginx 托管前端静态文件反向代理/api请求到后端端口。前端生产模式下访问/api/course/listNginx 会把请求转发给http://localhost:8080/api/course/list。跨域问题在这种架构下不会出现因为浏览器请求的是 Nginx 的地址路径里没有跨域如果开发时前后端分开跑就需要在后端配 CORS允许 Vue 开发服务器的地址跨域访问。server { listen 80; server_name your-domain.com; location / { root /www/select-course/dist; index index.html; try_files $uri $uri/ /index.html; } location /api/ { proxy_pass http://127.0.0.1:8080/api/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }try_files $uri $uri/ /index.html这一行很重要Vue Router 使用 history 模式下直接刷新某个子路由页面会 404因为服务器上没有这个物理路径这一行配置把所有不存在的路径都回退到 index.html让前端路由接管页面渲染。如果你的前端路由用的是 hash 模式不会有这个问题但 URL 里会多个#视觉上不那么干净。5.2 踩坑记录选课系统部署中的五个典型问题现象一前端打包部署后页面白屏控制台报 Failed to load resource 404。原因大概率是publicPath写成了绝对路径/部署到子目录时找不到 JS 和 CSS 资源。解决方法是把vue.config.js里的publicPath改成./重新打包再发布。这个坑几乎每个做过部署的人都会踩一次排查的时候先在浏览器开发者工具里看 Network 标签找到哪个静态资源 404基本就能定位。现象二登录接口报 401但用户名密码是对的。原因一般不是密码错而是 JWT 的 secret 配置不一致——前后端约定好了 token 放请求头的字段名但后端的过滤器从Authorization头取值时大小写写错了或者 Spring Security 的过滤链配置里把登录接口也拦截了。解决方法是先检查登录接口的路径有没有在permitAll()里再看自定义 JWT 过滤器取 Header 的代码是否拼写正确。现象三后端返回数据正常但前端表格全部空白。原因是后端返回的 JSON 字段名是下划线风格比如course_name前端 el-table-column 的prop写的是驼峰courseName两边对不上。解决方法是统一字段命名后端加JsonProperty注解或者在配置里开启驼峰映射map-underscore-to-camel-case: true。现象四选课提示成功但已选人数不变或者退课后容量没恢复。原因基本能锁定在事务没生效。常见的翻车写法是把Transactional加在同类内部调用的方法上Spring 的代理机制绕过了自己调自己的情况事务注解根本没生效。解决方法是把事务方法拆分到不同 Service 类中或者直接在 Controller 层调用不带事务的 Service 方法真正有事务的方法放在独立的 Service Bean 里。现象五用 IDEA 直接启动后端没问题但打成 jar 包运行后接口都 404。原因是 Springboot 扫描不到 Controller常见于主类不在包的最外层导致默认组件扫描范围没有覆盖到 Controller 所在的包。解决方法是把启动类移动到com.example包顶层用SpringBootApplication(scanBasePackages com.example)显式指定扫描路径也行。这个坑在多人协作项目里特别容易翻车因为每个人建的包结构不完全一致。5.3 运行时日志排查第一次运行 SpringbootVue 项目该看什么从零跑通这套系统我建议先在本地把后端跑起来再单独跑前端两边都通之后再考虑打包部署。后端启动如果报数据库连接失败优先检查三个信息数据库地址、用户名密码、数据库名是否存在。Springboot 的报错日志会直接打出Access denied for user或Unknown database照着改 application.yml 里的配置就行。前端npm run serve起不来先排查 Node 版本和依赖安装是否完整。npm install 过程中经常因为网络原因缺包常见的有node-sass编译失败导致整个 dev 进程挂掉。如果遇到这种问题换成npm install重试或者用淘宝镜像源解决。启动成功后在浏览器访问 Vue 开发服务器地址能看到登录页说明前端正常再登录试试能否拿到 token这一步能同时验证前端请求是否成功转发到后端接口。6. 验证系统的完整流程从登录到成绩录入的一条龙功能走查拿到这套源码后从工程价值角度最有意义的验证方法是把它当成真实业务系统按照完整业务链路从管理员、教师、学生三个视角各走一遍流程。我按下面这个顺序做过一次全流程验证每一步对应一个核心功能模块的可用性检查。第一步是管理员登录系统进入课程管理页新增一门课程设置课程编号、名称、学分、容量和上课时间。课程列表页能看到新增记录已选人数为 0。第二步换学生账号登录在选课页面找到刚创建的课程执行选课操作。选课成功后回到课程列表可以看到已选人数从 0 变成了 1。此时再用同一个学生账号重复选这一门课系统应该提示“请勿重复选课”这一步验证的是防重复选课的判重逻辑是否生效。第三步继续用学生账号执行退课退课成功后已选人数应该恢复为 0。再选一次课然后用教师账号登录在成绩管理页面找到该学生的选课记录并录入成绩。录入完成后用学生账号查看成绩单应该能看到这门课的分数。如果成绩已经录入再尝试退课系统应该拦截并提示不能退。这个验证序列覆盖了系统最核心的业务闭环任何一个环节报错对应的功能模块就需要检查。验证数据的一致性时有个小技巧打开 MySQL 客户端工具直接执行查询看t_course的selected_count字段值和实际选课记录数量是否一致。执行SELECT c.course_name, c.selected_count, COUNT(sc.id) FROM t_course c LEFT JOIN t_student_course sc ON sc.course_id c.id AND sc.status 0 GROUP BY c.id如果两者不相等说明容量更新逻辑有问题——多半是退课后decrSelectedCount没被调用或者事务没生效导致部分步骤没有回滚。对于想拿这套系统做二次开发或者写进简历的同学有一个值得动手的方向把选课高峰期可能出现的同时抢课场景当成一个改造练习用 Redis 分布式锁重写selectCourse方法让容量检查在锁内完成这既提升了并发能力也给你在简历上多一条可写的技术亮点。我的习惯是拿到任何项目先跑通全流程再拆开一个核心模块做深入改造从那以后我每次上手新项目都强制走一遍完整流程验证这个习惯让我少走太多弯路希望帮到你。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →