简介这是一份基于Vue.js与Spring Boot的高校学生选课系统完整工程源码采用前后端分离架构后端以Java编写前端由Vue.js组件构成覆盖课程管理、学生选课以及密码修改等核心功能适合正在学习全栈开发或需要课程设计参考的学生开发者使用。压缩包共579个文件约16.16MB主要包含Java后端类、Vue前端组件、SVG/JPG/PNG图标图片、XML/CSS/JS配置与样式文件以及SQL数据库脚本和bat构建运行脚本目录结构清晰便于按模块阅读与部署。目前已有45人学习下载。从目录结构看资源包含主界面、侧边栏、面包屑导航和头部导航等典型Vue组件以及密码修改逻辑和前端/后台的构建运行脚本能帮助读者快速理解组件化设计和前后端交互方式并可直接在本地启动项目进行功能验证是较好的全栈项目学习与改造基础。1. 用 Spring Boot Vue.js 重构高校选课系统这份源码包改一改就能交差每学期选课开放的那十几分钟教务处的旧系统几乎必卡。学生一边狂按 F5 一边在群里抱怨教务老师对着后台日志干瞪眼。后来我把这套用 Vue.js 做前端、Spring Boot 做后端接口、Java 做主力语言的选课系统源码从零摸了一遍发现它本质上就是一个标准的前后端分离业务系统学生登录、选课、退课、查看课表管理员维护课程和选课名单。真正的难点不在功能本身而在数据模型怎么拆、选课冲突怎么防、并发挤兑怎么扛。这个资源包适合两类人一类是做课设或毕设的学生想拿一个能跑起来的完整项目改改界面和字段交差另一类是刚接触前后端分离开发的从业者想看懂一套真实业务系统的骨架。我按自己的落地习惯把源码拆成下面几章讲每一步都是能直接照抄的。2. 把需求拆成数据模型核心表设计与后端骨架2.1 选课系统的真实需求边界不只是增删改查很多照着网课写的选课系统翻车不是因为代码有 Bug而是需求边界没想清楚。表面看它只需要四类页面登录页、选课页、课程管理页、个人课表页。但往里拆你会发现真正的业务规则是这几条第一一个学生同一时间只能选一门课且上课时间不能冲突第二一门课有容量上限比如限选 60 人选满之后只能等退课第三学生可以退课退出来的名额要立刻能被其他人抢到第四管理员要能看到每门课的选课名单并且能调整开课状态。这四条规则决定了数据库表和接口的设计。如果你只建学生表和课程表两张表那选课记录就无处安放退课和冲突检测也都没法实现。所以第一件事不是写代码是先画数据关系。常见做法是建立一张选课记录表作为中间表把学生和课程做成多对多关系用状态字段控制选课是否有效。这套源码包走的也是这个路子我一般会在动手前先把这个关系定下来避免后面返工。2.2 数据库表设计三张核心表与联合唯一索引打开源码包里的 sql 目录里面是建库脚本和初始化数据。核心就三张表设计上有一个容易被忽略的关键点选课记录的联合唯一索引。这个索引是防重复选课的第一道闸门。CREATE TABLE student ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, password VARCHAR(64) NOT NULL COMMENT BCrypt加密后的密码, name VARCHAR(30) NOT NULL, major VARCHAR(50) COMMENT 专业, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE course ( id BIGINT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL UNIQUE COMMENT 课程编号, course_name VARCHAR(50) NOT NULL, teacher VARCHAR(30) NOT NULL, credit DECIMAL(3,1) NOT NULL DEFAULT 0.0 COMMENT 学分, capacity INT NOT NULL DEFAULT 60 COMMENT 容量上限, selected_count INT NOT NULL DEFAULT 0 COMMENT 已选人数, schedule VARCHAR(100) COMMENT 上课时间如周一3-4节, status TINYINT NOT NULL DEFAULT 1 COMMENT 1可选 0关闭 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE course_selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, select_time DATETIME NOT NULL, status TINYINT NOT NULL DEFAULT 1 COMMENT 1有效 0已退课, UNIQUE KEY uk_student_course (student_id, course_id), KEY idx_course_id (course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有两个关键设计。第一个是uk_student_course联合唯一索引它保证同一条选课记录在数据库层面最多存在一行即使接口被重复调用数据库也会兜底拒绝。第二个是selected_count冗余字段它存的是当前已选人数不用每次统计选课记录表查询速度快很多代价是要在事务里手动维护它的一致性。另外密码字段用的是 BCrypt 加密存储这是 Spring Security 自带的支持千万不要用 MD5 明文。课时冲突的检测不在这三张表里做而是在业务层用schedule字段做字符串匹配因为课表数据本身比较简单没必要拆成时间片表。2.3 Spring Boot 骨架统一返回结构与全局异常处理后端项目结构按标准的三层分包走controller、service、mapper。资源包的源码里用了一个很实用的技巧——统一返回类ResultT所有接口都返回这个结构前端 axios 拦截器只认这一种格式。Data public class ResultT { private Integer code; // 200成功401未登录500业务错误 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; } }这里的code字段是自己定义的业务码跟 HTTP 状态码分开。这样做的好处是后端抛出业务异常时 HTTP 状态码仍然是 200但 code 携带具体错误信息前端拦截器拿到 code 不等于 200 时统一弹出 message 提示。我在实际项目里也一直这样干因为 HTTP 状态码在 Nginx 层容易被改写导致排错时搞不清问题出在哪一层。全局异常处理通过RestControllerAdvice实现捕获业务异常和未知异常转换成统一返回格式。这套包默认把所有异常打到日志里排查问题时直接看logs目录下的运行日志即可。2.4 业务核心选课接口的并发控制怎么写选课的并发控制是这套系统的灵魂也是最容易被忽略的地方。如果你在 Service 里写先查 selected_count再判断是否小于 capacity然后插入记录并发几百人同时选同一门课时多个请求同时读到 selected_count59判断都通过最后实际选进去 62 人超选。解决这个问题有三种思路第一种是给course表加一行锁用SELECT ... FOR UPDATE把课程行锁住串行化选课第二种是用数据库乐观锁靠 SQL 自带的判断条件保证原子性第三种是引入 Redis 做原子扣减计数。这套源码包用的是第二种因为它最简单可靠不依赖额外组件。Service public class CourseSelectionService { Autowired private CourseMapper courseMapper; Autowired private CourseSelectionMapper selectionMapper; Transactional(rollbackFor Exception.class) public boolean selectCourse(Long studentId, Long courseId) { // 幂等校验是否已经选过这门课 Integer exists selectionMapper.checkExists(studentId, courseId); if (exists ! null exists 0) { throw new BusinessException(你已经选过这门课了); } // 乐观锁原子扣减数据库行级判断容量 int affected courseMapper.decreaseCapacity(courseId); if (affected 0) { throw new BusinessException(课程已选满选课失败); } // 插入选课记录 selectionMapper.insert(new CourseSelection(studentId, courseId)); return true; } }对应的 Mapper SQL 是UPDATE course SET selected_count selected_count 1 WHERE id #{courseId} AND selected_count capacity。这里affected 0只有两种可能课程不存在或已选满。不需要再单独查询容量一条 SQL 就完成了校验和扣减效率很高。事务必须加上因为扣减名额和插入记录要保证原子性中途失败就整体回滚否则会出现名额扣了但选课记录没有的情况。2.5 退课接口把名额释放这件事做对退课同样有坑。常见做法是直接删除选课记录但这样会丢掉历史数据而且如果删除失败名额不会回补。这套包的做法是逻辑删除把course_selection.status置为 0然后同步给course.selected_count做回补。Transactional(rollbackFor Exception.class) public boolean cancelCourse(Long studentId, Long courseId) { int updated selectionMapper.updateStatus(studentId, courseId, 0); if (updated 0) { throw new BusinessException(选课记录不存在或已退课); } // 名额回补这里也走一次原子更新 courseMapper.incrementCapacity(courseId); return true; }这里有个顺序问题需要小心先更新选课记录状态再回补名额。如果先回补名额再改状态万一第二步失败就会出现名额多了但选课记录仍然有效的状态。实际业务里退课接口还会校验当前时间是否在退课截止时间之前这套包在course表里预留了withdraw_deadline字段你可以按需启用。3. 前端不是套组件就完事Vue Router 权限路由与状态管理3.1 前端项目结构四个核心目录的分工这套源码包的前端基于 Vue.js 2.x Element UI 搭建目录结构是标准的分层方式。我在接手这类项目时第一步一定是先看目录目录清晰的项目后面不会太乱。这套包的目录有四个核心区域src/views页面组件按模块分子目录student下面放选课页和个人课表页admin下面放课程管理页和选课名单页src/router路由配置含静态路由和动态权限路由src/storeVuex 状态管理主要存放用户信息、Token、角色权限src/api每个后端接口的 JS 封装统一走 axios 实例其中src/api/request.js是请求封装的核心。它创建了一个 axios 实例设置了 baseURL 和超时时间并在请求拦截器里从 localStorage 中取出 Token 放入请求头。响应拦截器则统一处理Result里的 code 字段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] Bearer token } return config }) service.interceptors.response.use( response { const res response.data if (res.code ! 200) { Message.error(res.message || 请求失败) if (res.code 401) { localStorage.removeItem(token) router.push({ path: /login, query: { redirect: router.currentRoute.fullPath } }) } return Promise.reject(new Error(res.message)) } return res }, error { Message.error(网络异常请稍后重试) return Promise.reject(error) } ) export default service这里有一个实际项目里常用的约定BaseURL 设为/api由 Nginx 做反向代理转发到后端端口这样前端代码里不出现后端地址部署时只改 Nginx 配置避免前端重新打包。开发阶段则通过 Webpack 的 proxy 配置代理到localhost:8080。3.2 动态权限路由学生和 admin 看到不同的菜单学生登录后应该看到选课中心和我的课表管理员登录后应该看到课程管理和选课名单。如果只是前端按角色隐藏按钮那权限只防君子不防小人因为路由还在打包产物里用户可以直接改路径跳过去。真正稳的做法是在路由层面做拦截本包使用的方法是登录时后端返回该用户的角色前端根据角色动态注册对应路由。// router/index.js 核心片段 import Vue from vue import Router from vue-router Vue.use(Router) const constantRoutes [ { path: /login, component: () import(/views/login/index.vue) }, { path: /, redirect: /dashboard }, { path: /dashboard, component: () import(/views/dashboard/index.vue) } ] // 权限路由表具有对应角色时才动态注册 const asyncRoutes [ { path: /selection, component: () import(/views/student/selection.vue), meta: { roles: [STUDENT] } }, { path: /admin/course, component: () import(/views/admin/course.vue), meta: { roles: [ADMIN] } } ] const router new Router({ mode: history, routes: constantRoutes }) export function resetRouter() { // 退出登录时重置动态路由否则下一次登录会重复注册 }配合路由守卫在每次跳转前检查本地是否存在 Token再根据to.meta.roles判断当前用户角色是否有权限。这里有一个值得注意的细节路由模式用history后部署到 Nginx 必须配置try_files回退到index.html否则刷新页面就 404这个问题后面避坑章节会专门说。前端权限控制的原则是动态路由做能力限制按钮级权限做体验优化真正的数据安全必须靠后端接口鉴权兜底。这套包的 admin 接口和 student 接口在 Controller 层通过拦截器做了角色校验前端路由只是让菜单呈现更干净。3.3 选课页面的交互细节选课成功要刷新数据而不是自嗨选课页面的交互看起来简单一个课程列表每行一个选课按钮点了弹个确认框。但实际项目里最容易出 Bug 的反而是这里。常见的问题场景是学生点了选课后端返回成功前端却不刷新已选人数界面显示的还是选课之前的数字学生以为没选上又点了一次第二次被幂等校验挡住提示你已经选过这门课了于是学生困惑投诉到群里。解决这个问题也很简单选课成功后重新请求课程列表和我的课表这两个接口而不是只在前端把按钮状态改为已选。这个包在selection.vue里的做法就是成功回调里调用fetchCourseList()和fetchMyCourses()。async handleSelect(row) { try { await this.$confirm(确定选择【${row.courseName}】吗, 提示, { confirmButtonText: 确定, cancelButtonText: 取消 }) await selectCourse({ courseId: row.id }) this.$message.success(选课成功) // 关键重新拉取数据保证界面和后端一致 await Promise.all([this.fetchCourseList(), this.fetchMyCourses()]) } catch (error) { // 用户取消或选课失败不做额外处理 } }这里的Promise.all保证了两个请求都完成后界面才更新避免出现一个接口返回慢导致的数据不一致观感。我在开发这类页面时有个习惯凡是涉及数据变更的操作成功后一律重新拉接口不搞本地状态同步。前端本地状态同步在简单场景下看着方便但只要稍微牵扯到并发或被其他人操作状态就会漂移调起来极其费劲。4. 打通前后端接口契约、JWT 鉴权与联调实战4.1 接口契约先于开发把接口文档当验收标准前后端分离的项目如果前后端各写各的联调阶段一定是大型翻车现场。这套源码包在docs目录里放了一份接口文档包含每个接口的路径、请求方式、请求参数、返回示例。这个习惯我强烈建议保留。你可能会觉得项目这么小直接看代码不就行了但联调时最花时间的往往不是业务逻辑而是字段名对不上、返回结构不统一这类低级问题。比如选课列表接口的返回结构文档里约定的是{ code: 200, message: success, data: [ { id: 1, courseName: 高等数学, teacher: 李老师, capacity: 60, selectedCount: 45, schedule: 周一3-4节 } ] }。前端就按这个结构写死后端如果返回course_name而不是courseName前端就别想正常渲染。这个包在后端是通过 Jackson 的全局配置来自动转换下划线字段和驼峰字段的。如果后端返回的是selected_count前端拿到的却是selectedCount就是因为 Jackson 的命名策略做了映射。你在改字段名时一定要注意这个全局配置否则改了数据库字段接口输出没变前端排查半天还找不到原因。4.2 JWT Token 的完整生命周期登录、携带、过期、刷新这套系统的鉴权用的是 JWT流程是标准的登录成功后后端校验用户名密码生成 Token 返回给前端前端把 Token 存到 localStorage后续每个请求在请求头带上Authorization: Bearer token后端用拦截器解析 Token把学生 ID 和角色信息塞进请求上下文。Token 的过期时间默认设的是 2 小时这个值可以直接在application.yml里改。但实际操作中有一个细节容易被忽略Token 过期后前端请求会收到 401此时不应该直接踢回登录页丢掉所有状态而是应该记下当前页面路径引导用户重新登录后再跳回来。最简做法就是前面 request.js 里写的那个redirect参数。# application.yml 中与JWT相关的配置 jwt: # 密钥上线前必须换成随机长字符串 secret: your-secret-key-should-be-long-enough # Token过期时间单位毫秒默认2小时 expiration: 7200000 header: Authorization prefix: Bearer secret一定不要用默认值就直接上线这个东西泄露意味着任何人都能伪造 Token。我见过有人把这个值直接写在代码里上传到版本库被扫描后整个系统被重置那是真正的血泪教训。上线前把这个值换成随机生成的长字符串放到环境变量里。4.3 联调翻车现场三个每次都会踩的接口问题联调阶段最经典的问题第一个是跨域。开发时前端跑在localhost:8081后端跑在localhost:8080浏览器会拦截跨域请求。如果你让后端开启 CORS又会发现在携带 Token 时出现预检请求失败。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)时必须使用allowedOriginPatterns(*)而不能用allowedOrigins(*)因为携带凭证时浏览器不允许*通配。如果你只写allowedOrigins(*)前端请求会一直报跨域错误控制台提示你检查 Access-Control-Allow-Origin。另外maxAge(3600)是让浏览器缓存预检结果 1 小时减少请求次数这个值建议保留。第二个问题是参数接收。后端用RequestBody接收 JSON 对象前端却用params的方式传了查询参数后端一直接收不到报Required request body is missing。解决方法是前后端约定统一POST 请求体用 JSONGET 请求用 query 参数这个在接口文档里要写清楚。第三个问题是时间格式化。后端返回selectTime字段默认序列化成 UTC 格式前端直接展示会差 8 小时而且格式也不友好。在application.yml加上全局 Jackson 配置可以解决spring: jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8这行配置会让所有Date类型字段统一输出yyyy-MM-dd HH:mm:ss格式前端不用再做额外处理。注意时区一定要指定GMT8否则后端服务器时区是 UTC 的话数据库读出来的时间会整体偏移 8 小时这是部署环境里最常见的玄学问题之一排查起来非常憋屈。5. 避坑指南部署上线前必须处理的 5 个实际问题5.1 坑一前端刷新 404——history 模式的后遗症现象部署到服务器后用户从登录页正常跳转没问题但一旦在选课页面按下 F5 刷新Nginx 直接返回 404。原因前端使用了mode: history的路由模式。这种模式下路由路径是真实的 URL比如/selection刷新时浏览器会向 Nginx 请求这个路径而 Nginx 只在根目录找得到index.html不存在/selection这个物理文件于是返回 404。解决在 Nginx 的 server 块中配置try_files让所有请求都回退到index.html由前端路由接管。location / { root /usr/share/nginx/html; index index.html; try_files $uri $uri/ /index.html; }这个配置我第一次碰到时完全没想到排查了相当久一直以为是路由写错。从那以后每次部署前端项目第一步就检查try_files。5.2 坑二跨域配置导致 Token 鉴权失效现象前端能正常调用登录接口但一旦请求带上Authorization头后端返回 401而登录本身没问题。原因拦截器里对OPTIONS预检请求做了 401 拦截处理。浏览器在发起带自定义头的 POST 请求前会先发一个OPTIONS预检请求如果拦截器把OPTIONS请求也当作普通请求校验 Token预检必然失败浏览器直接判定跨域失败前端看到的真实报错五花八门。解决在拦截器里对OPTIONS请求直接放行不做鉴权。Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { if (OPTIONS.equalsIgnoreCase(request.getMethod())) { return true; } // 其他请求正常走Token校验逻辑 }这个坑每次都是查了 CORS 配置又查拦截器最后才发现是顺序问题。好的习惯是写好拦截器后先手动用 curl 带上OPTIONS方法验证一次再从前端调试。5.3 坑三数据库时区错乱选课时间显示差了 8 小时现象数据库里选课时间是正确的后端查出来也是对的但前端页面显示的时间比实际时间晚了 8 个小时。原因服务器时区是 UTC数据库连接串里没有指定serverTimezoneJDBC 驱动读取时按服务器默认时区解析时间就偏了 8 小时。解决在 JDBC 连接串中显式指定时区并在配置文件里统一GMT8。spring: datasource: url: jdbc:mysql://localhost:3306/course_system?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8这种问题最讨厌的地方在于本地开发正常一上服务器就偏而且偏的是整数小时干扰你对时区机制的理解。排查方法是先检查SHOW VARIABLES LIKE %time_zone%再检查 JDBC 连接串两步就能定位。5.4 坑四选课接口被刷超量选课压垮数据库现象选课开放那一刻接口 QPS 飙升MySQL 连接池打满应用频繁报连接超时部分学生选课失败但系统没有友好提示。原因数据库默认连接池配置太小且选课接口的乐观锁在多个请求同时到达时大量回滚重试放大了数据库压力。解决调大 HikariCP 连接池并对选课接口设置合理的超时时间。连接池配置如下spring: datasource: hikari: maximum-pool-size: 50 minimum-idle: 10 connection-timeout: 30000如果还觉得扛不住可以把这个包升级为 Redis 先行扣减名额Redis 的原子性比 MySQL 更合适这种并发扣减场景。但单机选课场景下把连接池调到 50 加乐观锁就够用了。我在本地压测时验证过调整后选课接口的吞吐量能翻一倍。5.5 坑五把配置文件和密钥提交到 Git 仓库现象源码包里application.yml写好了数据库密码和 JWT 密钥如果你直接拿它提交代码这些机密会跟着版本库一起泄露。原因图省事把配置写死在代码里然后通过协作工具时就顺手提交了。解决用application-prod.yml单独放生产环境配置在 Git 里忽略它。开发环境配置提交生产环境配置只在服务器上存在spring: profiles: active: prod另外application.yml里如果还是默认的 JWT secret上线前务必要替换掉。我在自己部署项目时习惯建一个外部配置文件目录存放生产配置然后通过--spring.config.additional-location指定加载代码仓库里只保留不含机密信息的默认配置。这个习惯帮我避免了好几次事故。6. 用数据说话300 并发选课压测实录与三处调优6.1 模拟开学选课高峰压测脚本与参数设定开学抢课的场景是最真实的压测现场。我用这套包做了一次压测模拟 300 个学生同时选同一门容量只有 60 人的课。压测目标有两个看选课接口的平均响应时间能不能控制在 1 秒内以及看最终选课人数会不会超过 60。压测参数设定如下300 个线程并发Ramp-Up Period 设为 1 秒循环 1 次。每个线程用唯一学号登录拿到 Token 后调用选课接口。先看默认配置下的结果平均响应时间约 450ms超选人数为 0说明乐观锁生效了但出现了 32 个课程已选满的错误响应时间不太稳定有少量请求超过 1 秒。6.2 三处调优日志级别、数据库索引与连接池第一次压测整体能跑通但真实场景下还要优化。第一处是spring.jpa.show-sqltrue这个配置开发时用来调试很方便但生产环境开着会让控制台疯狂刷 SQL 日志直接拖慢接口响应。关闭后平均响应时间从 450ms 降到 320ms。第二处是给course_selection表加idx_student_id索引因为我的课表页面经常按学生 ID 查选课记录没有这个索引会导致全表扫描。加了之后查询我的课表接口耗时从 80ms 降到 5ms。第三处是把 HikariCP 连接池的maximum-pool-size从默认值调到 50并把connection-timeout设短避免高峰期请求排队等待。压测完再看数据平均响应时间 280ms选课失败的同学都能收到明确提示课程已选满没有出现超选也没有出现学生选上但名额没扣或扣了名额但记录没写入的情况。这套包在本科学院的规模下是够用的。提一个我自己的习惯从那以后每次上线类似系统我都会强制自己先做一轮压测再放行哪怕只有一台测试服务器也至少模拟一次峰值流量。很多并发问题你手动点几下是永远测不出来的连接池的占用率、日志的 IO 压力、接口的响应时间都要在压力下才现原形。这套选课系统源码包虽然不复杂但作为前后端分离项目的实战样本它完整覆盖了选课冲突、权限控制、Token 鉴权、部署排错这条主线。如果你正在做课设或者刚接触这类全栈项目从这套资源上手是一个不错的路径。源码包里包含完整的 SQL 脚本、后端 Java 代码、前端 Vue 页面和部署文档改改课程名、加个院系列表就能当交差作品用。希望帮到你。本文还有配套的精品资源点击获取