news 2026/9/26 14:03:22

SpringBoot+Vue前后端分离高校选课系统:乐观锁防超选与JWT鉴权实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SpringBoot+Vue前后端分离高校选课系统:乐观锁防超选与JWT鉴权实战

简介:这套资源是基于SpringBoot与Vue实现的高校学生选课系统完整Java源码,面向计算机相关专业毕业设计或需要快速搭建选课平台的开发者。系统采用前后端分离与B/S架构,覆盖学生教师账号管理、课程发布、选课冲突检测、结果查询等核心业务,并支持多媒体资源关联。资源共695个文件,约16.2MB,包含131个Java后端源码、98个Vue前端组件、161个SVG图标以及js/css、xml配置、数据库备份zbak等文件,目录结构清晰,便于定位与二次开发。目前已有74人学习浏览。压缩包内提供install.bat、build.bat等一键运行脚本,配合Maven依赖和MyBatisPlus持久层设计,可在Eclipse或IDEA中直接导入调试,适合作为毕业设计参考或SpringBoot+Vue实战练习。

1. 高校学生选课系统:SpringBoot与Vue前后端分离的Java源码,能直接跑通抢课流程

这份毕设源码解决的问题很具体:同一门课几十个学生同时抢,系统不能超选、不能重复提交,学生端要实时看到剩余名额,教师端要能导出选课名单。项目用SpringBoot做后端接口、Vue做前端页面、MySQL存数据,典型的Java全栈毕设结构。适合两类人:一类是正在做毕业设计、想找一套能改能跑的选课系统参考,另一类是刚学完Java和前端基础、想看看真实项目里鉴权、事务、分页这些功能是怎么串起来的。这套源码不是玩具demo,登录、选课、退课、课表查询、管理员审核这些环节都有完整实现,前后端接口对得上,导入数据库后能直接跑。

2. 系统拆解:前后端分离的模块划分与目录结构

2.1 技术选型:为什么是SpringBoot + Vue而不是别的组合

高校选课系统这个场景,数据量不大但业务规则杂:学生要能按学期选课、退课,教师要能维护课程信息,管理员要管理用户和审核开课。SpringBoot适合这种业务逻辑清晰的系统,它把配置收敛在application.yml里,不用像SSH框架那样写一大堆XML;MyBatis做数据访问,写SQL灵活,适合课程筛选这种条件经常变动的查询;前端Vue负责渲染和交互,ElementUI提供现成的表格、弹窗、表单组件,做管理端界面效率高。

前后端分离是本项目的核心结构。后端只负责业务逻辑和接口,前端页面通过HTTP请求调接口拿数据。这样做的好处是两个端可以独立开发、独立部署,而且接口写好了以后,以后想换小程序或者App端都能复用同一套后端。对毕设答辩来说,前后端分离也是一个值得展开讲的技术点,评委普遍会问“为什么要分离、数据怎么传输”。

2.2 三大角色与模块地图

系统里一共有三类角色,权责分得很清楚:

角色核心操作关键接口
学生选课、退课、查看已选课程、查看成绩/api/student/course/select、/api/student/course/drop
教师开设课程、维护课程信息、查看选课名单/api/teacher/course/add、/api/teacher/course/list
管理员用户管理、课程审核、学期管理/api/admin/user/manage、/api/admin/course/audit

模块上拆成四块:用户认证模块负责登录和权限校验;课程管理模块处理课程的增删改查和审核;选课模块是整个系统的核心,负责处理选课、退课以及容量校验;信息查询模块提供课表和选课名单的导出。这四个模块对应后端的四个包,也对应前端的几个独立页面。

2.3 项目目录结构:前后端代码分别在哪

拿到源码后先看目录结构,快速定位自己需要改的地方。

course-select-system/ ├── backend/ # SpringBoot后端 │ ├── src/main/java/com/course/ │ │ ├── controller/ # 接口层,接收前端请求 │ │ ├── service/ # 业务逻辑层,事务边界在这里 │ │ ├── mapper/ # MyBatis数据访问接口 │ │ ├── entity/ # 实体类,对应数据库表 │ │ ├── common/ # 统一返回体、异常处理、JWT工具类 │ │ └── config/ # 拦截器注册、跨域配置 │ └── src/main/resources/ │ ├── mapper/ # MyBatis的XML文件 │ └── application.yml # 数据库、端口、JWT密钥配置 ├── frontend/ # Vue前端 │ ├── src/ │ │ ├── api/ # 接口封装,统一管理请求地址 │ │ ├── router/ # 路由配置,含登录守卫 │ │ ├── views/ # 页面组件,每个角色一个文件夹 │ │ ├── store/ # Vuex状态管理,存用户信息和token │ │ └── utils/request.js # Axios拦截器,统一加token │ └── package.json └── sql/ └── course_select.sql # 建库建表脚本,含初始数据

这种分包方式是SpringBoot项目的标准分层,controller不写业务逻辑、service不直接操作数据库,每一层只做一件事。改代码的时候遵循这个分层就不会把项目改乱。前端部分,api目录是前端工程师最先要看的地方,所有接口地址都集中在这里,改后端接口地址时只需要动这一个文件。

3. 数据库设计:选课不超选的根基在三张表和一条乐观锁SQL

3.1 核心表结构:用户表、课程表、选课记录表

选课系统的数据模型不复杂,但表之间关系要设计对。用户表存三类角色的公共信息,用role字段区分身份;课程表存课程基本信息和容量;选课记录表是学生和课程的多对多关系表,学生选一门课就插入一条记录。

-- 用户表:学生、教师、管理员共用 CREATE TABLE sys_user ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录账号', password VARCHAR(100) NOT NULL COMMENT 'BCrypt加密后的密码', real_name VARCHAR(50) NOT NULL COMMENT '真实姓名', role TINYINT NOT NULL COMMENT '1学生 2教师 3管理员', student_no VARCHAR(20) NULL COMMENT '学号,仅学生有', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 课程表 CREATE TABLE course ( id INT AUTO_INCREMENT PRIMARY KEY, course_name VARCHAR(100) NOT NULL COMMENT '课程名称', teacher_id INT NOT NULL COMMENT '任课教师ID', credit DECIMAL(2,1) NOT NULL COMMENT '学分', max_students INT NOT NULL COMMENT '课程容量', selected_count INT NOT NULL DEFAULT 0 COMMENT '已选人数', semester VARCHAR(20) NOT NULL COMMENT '学年学期,如2024-2025-1', status TINYINT NOT NULL DEFAULT 0 COMMENT '0待审核 1已开课 2已结课' ); -- 选课记录表:学生与课程的多对多关系 CREATE TABLE student_course ( id INT AUTO_INCREMENT PRIMARY KEY, student_id INT NOT NULL COMMENT '学生ID', course_id INT NOT NULL COMMENT '课程ID', select_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '选课时间', UNIQUE KEY uk_student_course (student_id, course_id) );

三张表的核心设计点是student_course表的唯一索引uk_student_course,它在数据库层面保证了同一个学生只能选同一门课一次。这个索引看着简单,但在并发场景下是关键防线。另一个设计点是course表里的selected_count字段,每选课成功一次就加一,容量校验就是用这个字段和max_students做比较。

3.2 防超选原理:乐观锁SQL是怎么避免名额超卖的

选课系统最怕的问题是超选,也就是第50个学生抢课的时候,系统以为还有名额,但实际已经选了49个人。最常见的错法是先查询剩余名额再插入记录,这两步之间存在时间差,并发时就会超卖。

本项目里用了一个乐观锁思路解决:把“校验名额”和“扣减名额”合并成一条UPDATE语句,通过影响行数判断是否真的抢到了名额。

-- 核心防超选SQL:只有当前已选人数小于容量时才更新 UPDATE course SET selected_count = selected_count + 1 WHERE id = #{courseId} AND selected_count < max_students AND status = 1;

如果这条UPDATE语句影响的行数大于0,说明更新成功,名额扣减成功,可以继续插入选课记录;如果影响行数是0,说明课程已满或者课程状态不对,直接返回“选课失败,课程已满”。这个做法的精妙之处在于校验和扣减发生在同一条SQL里,数据库行锁保证同一时间只有一个事务能更新成功。

注意不要用乐观锁的版本号方案来做选课,也就是不要在course表里加version字段。版本号方案适合更新不频繁的数据,但选课是热点操作,会频繁触发版本号冲突,反而增加重试次数。用selected_count < max_students这个条件做天然校验,对选课这种场景更直接。

3.3 事务边界:选课记录的插入必须和课程名额扣减在同一个事务里

课程名额扣减成功之后,还要向student_course表插入一条选课记录。这两步必须放在同一个事务里,否则会出现扣了名额但没插入记录的情况。

我在实际写代码的时候,是把UPDATE课程表和INSERT选课记录放在同一个service方法里,方法上加@Transactional注解。项目源码里的处理方式也一样,事务只加在service层,controller层不加。如果把事务加到controller上,一个请求可能包含多个业务操作,事务范围过大会造成长事务,影响数据库连接池的回收效率。

4. 后端实现:JWT登录鉴权、选课接口与分页查询

4.1 登录鉴权链路:JWT Token的签发与拦截器校验

后端接口不能裸奔,每次请求都要校验身份。项目用的是JWT方案:用户登录成功后,后端把用户ID和角色信息加密生成一个Token返回给前端;前端把这个Token存在本地,之后每次请求都在Header里带上;后端通过拦截器统一校验Token的合法性。

// 登录成功后签发Token public String generateToken(Integer userId, String role) { return Jwts.builder() .claim("userId", userId) .claim("role", role) .setExpiration(new Date(System.currentTimeMillis() + 2 * 60 * 60 * 1000)) // 2小时过期 .signWith(SignatureAlgorithm.HS256, secretKey) .compact(); }

claim相当于Token里的自定义字段,这里存了userId和role两个信息。signWith指定了HS256签名算法和密钥,密钥放在application.yml配置里,不要硬编码在代码中。2小时过期时间适合选课这种短时操作,如果做完整的学生系统,通常改成7天加自动续期。

拦截器的逻辑是重写preHandle方法,这是Spring MVC里拦截器的一个标准写法:先从请求头里取出Authorization字段,解析出用户信息,解析失败就返回401,成功就把用户信息放进request对象供后续使用。

@Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token = request.getHeader("Authorization"); if (token == null || token.isEmpty()) { response.setStatus(401); return false; } try { Claims claims = Jwts.parser() .setSigningKey(secretKey) .parseClaimsJws(token) .getBody(); request.setAttribute("userId", claims.get("userId")); request.setAttribute("role", claims.get("role")); return true; } catch (Exception e) { response.setStatus(401); return false; } }

拦截器注册要注意排除登录接口本身,不然登录请求也会被拦截导致循环。项目源码里通过WebMvcConfigurer的addInterceptors方法注册,excludePathPatterns里放/api/user/login和/api/user/register。

4.2 选课接口:事务注解、防重校验与业务异常区分

选课的service方法体现的是完整业务闭环,这一部分把前面数据库设计的思路全部落到了代码上。

@Transactional(rollbackFor = Exception.class) public void selectCourse(Integer studentId, Integer courseId) { // 前置校验:检查选课记录是否已存在 int count = studentCourseMapper.countByStudentAndCourse(studentId, courseId); if (count > 0) { throw new BusinessException("不能重复选同一门课"); } // 核心操作:乐观锁扣减名额 int rows = courseMapper.decreaseStock(courseId); if (rows == 0) { throw new BusinessException("课程已满或课程不存在"); } // 插入选课记录 StudentCourse record = new StudentCourse(); record.setStudentId(studentId); record.setCourseId(courseId); studentCourseMapper.insert(record); }

@Transactional注解设置了rollbackFor = Exception.class,这意味着任何异常都会触发事务回滚。这里有个关键点:decreaseStock成功但插入选课记录失败时,事务回滚会把扣减的名额也一起回滚掉,不会出现数据不一致。BusinessException是自定义异常,被全局异常处理器捕获后转成{code: 500, message: "课程已满"}返回给前端。

初学者容易犯的错误是把selectCourse写成先查selected_count再比较再更新。我在很多学生代码里都看到过这种写法,一律是需要在并发下翻车的问题。多花点时间理解上面那条UPDATE语句,这是项目里最值得背下来的一段SQL。

4.3 MyBatis分页:PageHelper插件的使用与参数说明

课程列表和选课名单都需要分页,项目用了PageHelper插件。用法固定,需要注意它生效的位置。

public PageInfo<CourseVO> getCourseList(int pageNum, int pageSize, String keyword) { // 启动分页:下一行查询语句会被自动拼接LIMIT PageHelper.startPage(pageNum, pageSize); List<CourseVO> courses = courseMapper.selectByCondition(keyword); return new PageInfo<>(courses); }

PageHelper.startPage(pageNum, pageSize)这一行必须紧跟在要分页的查询语句之前,中间不能有其他查询。PageInfo里封装了总条数、总页数、当前页数据,前端展示分页组件时直接取这里的字段就行。如果碰到分页不生效的情况,九成是startPage和select之间多写了一条SQL,PageHelper把分页SQL作用到了错误的那条查询上。

5. 避坑指南:环境配置、前后端联调、运行期的六个常见问题

5.1 SpringBoot版本过高导致的循环依赖与javax包缺失

现象:导入源码后启动报错,提示AllowCircularReferences默认为false,或者代码里import javax.servlet找不到类。

原因:高版本SpringBoot 3.x基于Jakarta EE规范,javax.servlet包被替换成了jakarta.servlet;同时SpringBoot 2.6版本以后默认禁止循环依赖。这份源码是基于2.x写的,直接拿高版本跑必定报错。

解决:看源码pom.xml里parent标签的版本,用对应的JDK和SpringBoot版本。我的习惯是SpringBoot 2.7.x搭配JDK 1.8或11,这是目前兼容性最稳的组合;SpringBoot 3.x需要JDK 17,并且要全局替换javax为jakarta,改动量不小。拿到源码第一时间看pom.xml,别急着配环境。

5.2 Vue前端启动失败:Node版本与依赖安装问题

现象:npm run dev报错,或者提示digital envelope routines::unsupported。

原因:Node 17以上版本和旧版Vue CLI项目存在OpenSSL兼容问题,这是Node对Webpack加密库策略调整导致的。

解决:两种方案任选。方案一是用Node 16版本,这是Vue 2项目的黄金版本;方案二是在package.json的dev脚本后面加SET NODE_OPTIONS=--openssl-legacy-provider,Windows环境下用这个方式最省事。另外,npm install在中国网络环境下容易卡住,用npm install --registry=https://registry.npmmirror.com可以避开大部分问题。

5.3 前后端联调跨域:前端代理配置比后端CORS注解更实用

现象:前端页面能打开,但所有接口请求都在浏览器显示CORS error,或者请求是发出了但看不到结果。

原因:前端运行在localhost:8080,后端运行在localhost:9090,不同端口属于跨域。后端如果配置了@CrossOrigin注解,开发环境能通,但部署上线后域名变化又要改一套。

解决:项目前端用的是Vue CLI的proxy代理方案,在vue.config.js里配置devServer代理,把/api开头的请求转发到后端地址。这样做浏览器里看到的请求是同源的,完美避开跨域问题。

const { defineConfig } = require('@vue/cli-service') module.exports = defineConfig({ devServer: { port: 8080, proxy: { '/api': { target: 'http://localhost:9090', changeOrigin: true } } } })

5.4 时区问题导致的时间字段错乱

现象:选课记录表的select_time比系统时间早8小时,MySQL查出来的时间和实际对不上。

原因:MySQL驱动连接串没有指定时区,默认用了UTC,而系统是东八区。

解决:在application.yml的数据库连接URL上加&serverTimezone=Asia/Shanghai。这是检查顺序排在前面的一项,很多毕设答辩现场打开数据库看到时间不对就是因为这个。

5.5 MyBatis的XML文件在打包后丢失

现象:本地IDEA启动正常,打包成jar后运行,报Invalid bound statement (not found)。

原因:Maven打包时默认只打包src/main/resources目录下的内容,mapper XML放在java目录下就没被带进去。

解决:在pom.xml里配置resources,把mapper目录的XML文件强制打进去。项目源码如果结构规范,mapper XML本来就在resources目录下,就不会有这个问题。如果你改过路径,一定要记得动pom.xml。

5.6 端口冲突导致的后端启动失败

现象:启动后端时IDE提示Port 9090 was already in use。

原因:上一次运行没被关掉,或者有别的程序占用了端口。

解决:命令行执行netstat -ano | findstr 9090查出占用进程PID,然后在任务管理器里结束对应进程。这个命令对排查端口类问题通用,以后做别的项目也会用到。

6. 验证与进阶:并发压测确认不超选,再谈代码演进方向

整套环境跑通以后,建议按下面这个顺序做一遍验证,确认系统真的可靠。第一步,启动后端和前端,用学生账号登录,进入选课页面,选择一门容量10人的课程,快速连续点击选课按钮,页面上应该只有第一次点击生效,后续点击被前端loading状态挡住。第二步,用JMeter模拟并发请求,线程数设成20,全部请求同一门剩余名额5人的课程,跑完后去数据库执行SELECT selected_count FROM course WHERE id = ?,确认最终值不超过max_students,且选课记录数等于实际成功数。这是检验乐观锁是否生效的最直接方式。

第三步,验证权限隔离:用学生账号请求管理员的接口,比如/api/admin/user/list,应该返回403,同时在用JWT的调试工具篡改Token再访问,确认会被拦截器挡在门外。第四步,退课测试:退掉一门已选课程,确认课程表的selected_count减一,且student_course表对应记录被删除,再重新选这门课应该能成功。如果这些验证全部通过,这套系统就达到了可交付的状态。

源码层面再往下走,有两个方向是毕业答辩时能加分的内容。第一个是引入Redis做库存预热,把课程剩余名额提前加载到Redis,抢课时先用DECR命令扣减,扣减成功再写数据库,这个方案能把并发支撑能力提升一个量级,也是现在互联网公司秒杀系统的标准做法。第二个是引入RabbitMQ或简单线程池做选课流水异步落库,缓解高峰期的数据库压力。这两个方向适合学有余力的情况,如果时间紧,把现有这套乐观锁方案讲清楚就足够了。

做毕设这些年,我最深的感受是:能跑通只是第一步,能讲清楚“为什么这样设计”才是答辩的关键。这套选课系统在数据一致性上的处理思路,是除CRUD以外最值得花时间研究的点。如果自己是从零改起,强烈建议把我上文提到的那条防超选SQL、JWT拦截器、事务边界这三个部分,逐字逐句读懂再动手扩展功能。排版和细节我已经尽量说明白了,剩下的就是下载源码后照着跑一遍,有问题可以对照避坑章节逐项排查,希望帮到你。

本文还有配套的精品资源,点击获取

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/26 14:02:54

Java synchronized锁升级:偏向锁、轻量级锁与重量级锁原理

1. 先从 synchronized 的对象头说起&#xff1a;锁状态其实是“身份标签”聊 Java 并发&#xff0c;偏向锁、轻量级锁、重量级锁这三个词几乎一定绕不开。很多人把“锁升级”背成了一张流程图&#xff1a;先偏向&#xff0c;再轻量&#xff0c;最后重量。但真正到了线上&#x…

作者头像 李华
网站建设 2026/9/26 14:02:46

脑肿瘤活检实操指南:从靶点规划到分子病理的完整流程

脑肿瘤活检这个话题&#xff0c;在重庆神外圈子里一直热度不减。2026年了&#xff0c;技术演进比你想象中要快得多&#xff0c;但很多同行对新流程的认知还停留在“穿刺打点拿组织”的层面。这篇不写教科书式的定义&#xff0c;直接用行业内的实操视角把脑肿瘤活检的关键流程、…

作者头像 李华
网站建设 2026/9/26 14:02:32

Figma汉化版Windows安装教程:FigmaEX集成版配置与快捷键指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 14:01:42

5G MIMO信道容量随距离衰减:MATLAB仿真源码拆解

简介&#xff1a;面向5G通信系统设计与优化人员及通信专业学生&#xff0c;一套研究通信距离对信道容量影响的仿真源码提供了可直接运行的m文件实现。压缩包共8个m文件&#xff0c;大小仅9KB&#xff0c;覆盖多输入多输出多路复用、混合预编码、天线导向矢量、非视距路径损耗、…

作者头像 李华
网站建设 2026/9/26 14:01:30

通义灵码2.0迈入Agentic AI:TaoToken统一Key接入与settings.json配置实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/26 14:00:11

多数人搞错的并发误区,是服务性能上不去的根本原因

做Python后端开发&#xff0c;不管是Flask还是Django&#xff0c;大家平时写接口基本都是顺手写逻辑、调库、完成功能。很多人长期有一个误区&#xff1a;多线程就是并发、开多线程就能提升接口吞吐。 线上服务压测、流量上来之后&#xff0c;发现线程开了一堆&#xff0c;CPU …

作者头像 李华