ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot+Vue前后端分离选课系统:从架构到并发防超卖实战

Spring Boot+Vue前后端分离选课系统:从架构到并发防超卖实战 简介一套基于Spring Boot与Vue技术栈的学生网上选课系统完整项目适合Java Web开发学习者、毕业设计学生以及希望快速掌握前后端分离案例的开发者。系统后端使用Java与Spring Boot实现用户管理、课程信息维护、选课逻辑处理和数据库访问等核心功能前端采用Vue构建简洁直观的选课页面并通过RESTful API与后端交互搭配MySQL 5.7数据库涵盖完整前后台源码、数据库脚本与万字说明文档便于学习调试。资源包共417个文件大小约21.82MB主要包含Java后端源码、Vue组件、SVG图标、SQL脚本以及PNG/JPG界面截图、DOC论文文档和启动批处理等辅助材料目录结构清晰便于定位和二次开发。目前已有33人学习项目经调试可正常运行附带完整文档与清晰目录结构便于后续二次开发和部署维护可帮助读者深入理解从设计、实现到部署的完整开发流程。1. 学生网上选课系统Springboot Vue 前后端分离的毕设天花板每年毕业季基于 Springboot Vue 的选题总会占据毕设题库的半壁江山而“学生网上选课系统”又是其中最具代表性的一个。它不像电商系统那样堆砌订单和支付也不像管理系统那样只做 CRUD选课系统天然包含“课程资源有限、学生并发抢选、冲突检测、退补选闭环”这类业务痛点恰好能把 Springboot 的后端接口设计、Vue 的前端交互、以及数据库的事务处理全部串起来。拿到标题里这个 .zip 包你实际上拿到的是一个完整可运行的前后端分离项目而不是一个只能演示的静态页面。这篇笔记我会以一线开发者的视角把这个系统从架构拆解到落地部署再把选课并发、时间窗口、超卖这类高频翻车点逐一讲透。这个方案适合三类人第一类是需要快速跑通一个能答辩的毕设项目的在校生第二类是刚接触前后端分离、想找个完整案例练手的初级开发者第三类是手里已经有一套代码、但不知道怎么改造成自己题目的同学。无论你是哪种情况核心思路都一样先让系统在本地跑起来再吃透它的业务闭环最后把其中的一两个模块改成带自己思考的设计这才是这个标题能带给你的全部价值。2. 把 .zip 拆成可运行的工程项目结构、环境版本与启动顺序2.1 拿到压缩包后的第一件事看清根目录里是什么解压后你会看到典型的backend或serverfrontend或web双目录结构外加一个sql文件夹存放数据库脚本。如果作者稍微规范一点还会附带README.md或部署文档.docx里面写了环境版本要求。但现实情况是很多毕设包里的文档写得极其简略甚至版本号都是错的所以不要盲目相信文档要以代码为准。第一步是把两个目录分别用 IDEA 和 VSCode 打开。IDEA 负责后端VSCode 负责前端。打开后先看一眼后端pom.xml里的 Springboot 版本以及前端package.json里的 Vue 版本和脚手架类型Vue CLI 还是 Vite。这两个版本组合决定了你接下来的排错方向。我见过大量翻车案例本质都是 Springboot 2.x 搭配了 JDK 17 导致 javax 命名空间找不到或者 Vue 2 项目被误用 Vue 3 的语法解析。# 在项目根目录下快速查看关键版本信息 cd backend grep -n spring-boot-starter-parent pom.xml | head -5 grep -n java.version pom.xml cd ../frontend cat package.json | grep -E (vue|vue/cli-service|vite)逻辑说明Springboot 的父级依赖里会锁定所有 starter 的版本所以只需要看spring-boot-starter-parent的版本号就能确定整个后端的生态基线。而前端要区分是 Vue 2 还是 Vue 3因为两者的生命周期钩子、路由 API、响应式原理完全不同后续所有组件写法都受这个约束。参数说明如果java.version是 8JDK 就装 8是 11 或 17就按对应版本装否则 IDE 里直接编译报错。2.2 数据库初始化别急着跑代码先让表结构落地打开sql文件夹里面通常是.sql脚本文件包含建库、建表和初始数据这三段内容。用 Navicat 或 MySQL Workbench 执行时要注意脚本开头如果是CREATE DATABASE而非仅USE你需要先手动创建数据库连接再执行脚本。很多新手在这里翻车报错诡异得像数据库不存在其实只是没选对 schema。-- 建议手动执行的方式 CREATE DATABASE IF NOT EXISTS course_select DEFAULT CHARACTER SET utf8mb4; USE course_select; SOURCE /你的解压路径/sql/course_select.sql;如果你没有命令行环境就打开 Navicat新建一个名为course_select的数据库字符集选utf8mb4然后在查询窗口里直接打开.sql文件执行。执行完后检查三张核心表student学生表、course课程表、student_course选课记录表。其中student_course表的设计直接决定了系统能否处理“同一个学生重复选同一门课”的边界情况一般会在这张表上建联合唯一索引但很多毕设代码里根本没建这会成为后面业务层校验之外的最后一道防线。2.3 后端启动三步改配置、装依赖、跑主类后端启动前必须改两样配置数据库连接信息和 Redis 地址如果项目用了 Redis 做缓存。打开application.yml或application.properties把用户名密码、URL 里的库名改成你本机的实际值。如果你的项目没有引入 Redis那这一步跳过引入了但本地没装 Redis启动会直接失败控制台报Connection refused。spring: datasource: url: jdbc:mysql://localhost:3306/course_select?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver jackson: date-format: yyyy-MM-dd HH:mm:ss time-zone: GMT8逻辑说明MySQL 8.x 必须加serverTimezone参数否则 JDBC 驱动会拿默认时区去解析时间导致差 8 小时。useSSLfalse是本地开发常规关闭项线上才需要证书。修改完成后在 IDEA 里找到标注了SpringBootApplication的启动类右键运行。观察控制台日志出现Tomcat started on port(s): 8080就说明后端起来了。2.4 前端运行依赖安装与代理转发配置前端项目如果是 Vue CLI 创建的依赖安装用npm install但经常遇到版本冲突或网络慢的情况。这里我建议先删掉package-lock.json如果有再执行安装因为毕设包里的 lock 文件往往和当前 node 版本不兼容。装完后用npm run serve启动开发服务器默认端口是 8080而后端 Tomcat 也是 8080所以必须改前端端口或者后端端口通常做法是改 Vue 的vue.config.js。// vue.config.js module.exports { devServer: { port: 3000, proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } } } };逻辑说明前端开发服务器跑在 3000 端口所有以/api开头的请求都转发到后端 8080 端口。pathRewrite的作用是把/api前缀剥掉因为后端 Controller 的 RequestMapping 里没有/api这个统一前缀。参数说明如果你的后端接口确实带/api前缀那就把pathRewrite这一行删掉changeOrigin必须为true否则后端收到请求的 Host 头是 localhost:3000部分严格校验的框架会报跨域错误。3. 选课核心业务的设计与实现从表结构到接口的完整闭环3.1 三张核心表的关系为什么选课记录表是业务的心脏课程表和学生表相对简单真正承载业务逻辑的是选课记录表。它的字段设计决定了你能实现的功能上限。一个规范的设计至少包含id主键、student_id学生外键、course_id课程外键、selected_time选课时间、status状态已选/已退/待确认。很多毕设还会加semester学期字段用来区分不同学期的选课数据这个字段在做“历史选课记录查询”时非常关键。CREATE TABLE student_course ( id bigint NOT NULL AUTO_INCREMENT, student_id bigint NOT NULL COMMENT 学生ID, course_id bigint NOT NULL COMMENT 课程ID, semester varchar(20) DEFAULT NULL COMMENT 学期标识如2024-2025-1, selected_time datetime DEFAULT NULL COMMENT 选课时间, status tinyint DEFAULT 1 COMMENT 1已选 0已退, PRIMARY KEY (id), UNIQUE KEY uk_student_course (student_id,course_id,semester), KEY idx_course_id (course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT选课记录表;逻辑说明联合唯一索引uk_student_course是在数据库层面防止同一个学生重复选择同一门课这是并发场景下的兜底方案。业务层的Service里也会做一次查询校验但校验通过到插入数据之间存在时间窗口两个并发请求可能同时通过校验此时唯一索引会直接拒绝第二条插入抛出DuplicateKeyException。参数说明status用tinyint而不是char因为后续做统计时要对status1的记录数做条件聚合数字类型效率更高。idx_course_id是为了支撑“查某门课已选人数”的高频查询没有这个索引选课峰值期全表扫描会把数据库拖垮。3.2 选课接口的实现事务 行锁防超卖选课接口是系统的核心接口它的逻辑可以拆成三步校验学生是否存在且处于可选课状态、校验课程是否有余量、插入选课记录并扣减课程余量。这里的灵魂在于“扣减余量”这一步如果用先查询再更新的方式并发时必然出现超卖。正确做法是用数据库的行级锁在一条 UPDATE 语句里完成余量扣减和条件判断。Override Transactional(rollbackFor Exception.class) public boolean selectCourse(Long studentId, Long courseId) { // 1. 查询课程信息加悲观锁读 Course course courseMapper.selectByIdForUpdate(courseId); if (course null) { throw new BizException(课程不存在); } if (course.getSelectedCount() course.getMaxCount()) { throw new BizException(课程余量不足); } // 2. 检查学生是否已选过该课程 int existCount studentCourseMapper.countByStudentIdAndCourseId(studentId, courseId); if (existCount 0) { throw new BizException(不能重复选课); } // 3. 插入选课记录并在课程表上原子更新余量 studentCourseMapper.insert(new StudentCourse(studentId, courseId, SemesterUtil.current())); int rows courseMapper.increaseSelectedCount(courseId); if (rows 0) { throw new BizException(选课失败课程名额已满); } return true; }逻辑说明selectByIdForUpdate对应 SQL 里的SELECT ... FOR UPDATE这条语句会在事务内锁定该课程的行记录其他事务要修改这条记录必须等待当前事务提交。这样一来两个学生同时选同一门只剩一个名额的课时第二个请求会阻塞在FOR UPDATE上等第一个请求提交后它读到的selected_count已经是最新值从而正确地抛出“余量不足”。参数说明Transactional必须指定rollbackFor Exception.class否则 Spring 默认只在抛出RuntimeException时回滚而业务异常如果声明的是Exception事务不会回滚数据就永久不一致了。3.3 退课接口对称逻辑中的不对称坑退课看起来就是选课的逆操作但有一个容易被忽略的边界退课后释放的名额是否允许同一学生再次选择同一门课如果允许选课记录的状态机要足够清晰如果不允许那么student_course表里的记录应该物理删除还是逻辑标记为已退就有完全不同的实现方式。-- 推荐逻辑删除保留历史轨迹 UPDATE student_course SET status 0 WHERE student_id #{studentId} AND course_id #{courseId} AND semester #{semester}; UPDATE course SET selected_count selected_count - 1 WHERE id #{courseId} AND selected_count 0;我在实际项目里会保留历史记录因为在答辩时评委经常问“学生退课后再次选同一门课数据怎么体现”逻辑删除可以完美回答这个问题——查记录时status0代表曾经选过又退了重新选课会插入一条新记录或者把status改回 1。参数说明第二条 UPDATE 加AND selected_count 0的条件防止多线程退课时把余量扣成负数。3.4 分页查询课程列表MyBatis Plus 的配置要看懂课程列表通常带分页和条件过滤毕设项目大部分会用 MyBatis Plus 的Page对象。这里要注意MyBatis Plus 的分页插件必须显式配置否则查询出的数据不会自动分页而是查出全量数据后 List 被塞进 Page 对象前端拿到total是实际总数但数据完全错乱。Configuration public class MybatisPlusConfig { Bean public MybatisPlusInterceptor mybatisPlusInterceptor() { MybatisPlusInterceptor interceptor new MybatisPlusInterceptor(); interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL)); return interceptor; } }逻辑说明分页插件的本质是对SELECT语句做拦截改写自动拼上LIMIT ? OFFSET ?。如果缺少这个配置类Page对象虽然能作为参数传入 Mapper 方法但执行的 SQL 不会带分页语句结果就是全表数据加载到内存仅仅是返回对象里保留了分页信息页面表现可能是“没有翻页效果”或者“数据量大了卡死”。参数说明DbType.MYSQL要和你实际的数据库类型保持一致如果项目用的是 PostgreSQL 却配了 MySQL生成的方言分页语句直接报语法错误。4. 前端实现Vue 组件拆解、状态管理与接口联调4.1 从登录到选课页面路由和守卫的设计思路前端页面一般包括登录页、首页课程列表、我的课表已选课程、个人中心。路由设计上Vue Router 需要配置前置守卫实现“未登录跳转到登录页”的效果。很多毕设代码的坑在于路由守卫只判断了 localStorage 里有没有 token但忽略了 token 过期的情况导致用户停留页面很久后点击选课请求返回 401前端没有任何提示。// router/index.js router.beforeEach((to, from, next) { const token localStorage.getItem(token); if (to.path /login) { next(); return; } if (!token) { next(/login); return; } // 如果token存在但可能过期做一次静默校验 if (store.state.userInfo null) { store.dispatch(fetchUserInfo).then(() next()).catch(() { localStorage.removeItem(token); next(/login); }); } else { next(); } });逻辑说明进入页面前先看本地有没有 token没有就踢回登录页有 token 但内存中没有用户信息时调用后端接口拉取用户信息接口返回 401 则清除本地 token 并跳转登录。这个设计解决了“刷新页面后 Vuex 状态清空导致路由守卫失效”的经典问题。参数说明store.state.userInfo null的判断表意清晰但注意刷新页面时 Vuex 会重置所以这里不能依赖 state 做唯一判断必须向后端发一次请求确认 token 有效性。4.2 axios 封装统一处理 token、错误码和消息提示前后端联调时最常见的就是跨域和鉴权问题。axios 封装不仅要配置 baseURL 和超时时间还必须加上请求拦截器和响应拦截器。请求拦截器负责把 token 塞进请求头响应拦截器负责统一处理业务错误码、HTTP 状态码和网络错误避免每个页面组件里都写一遍错误提示逻辑。// utils/request.js 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.msg || 请求失败); return Promise.reject(new Error(res.msg)); } return res; }, error { if (error.response error.response.status 401) { localStorage.removeItem(token); router.push(/login); } else { Message.error(网络异常请稍后重试); } return Promise.reject(error); } );逻辑说明后端返回的数据格式一般是{ code: 200, msg: success, data: {...} }响应拦截器统一取出code判断业务是否成功失败时直接弹出提示组件里只需要关心成功数据。401 统一清除登录态跳转登录页这样后端返回的未授权信息不会散布在各组件里。参数说明baseURL: /api依赖开发环境的 proxy 配置上线部署时则依赖 Nginx 的location /api/反向代理两种场景下前端代码不用改这是前后端分离项目的标准实践。4.3 课程列表页卡片展示 选课按钮的状态联动课程列表页正常情况下是表格或卡片布局每门课显示课程名、教师、学分、上课时间、余量。选课按钮的可用状态要由前端根据余量和已选状态动态计算但这个计算必须以后端返回的数据为准。每次进入页面或者点击选课成功后都要重新拉取课程列表而不是在前端本地手动把余量减一否则多开页面时数据立刻不一致。Vue 组件里用computed计算每个课程卡片上按钮的文案和禁用状态是最合理的做法。因为选课状态本质上依赖两项数据课程的selectedCount和当前学生的selectedCourseIds列表。把它们映射为 vuex 或组件 data 后computed 能自动追踪变化并更新视图。要注意的是selectedCourseIds在每次选课或退课成功后必须同步更新否则用户已经选过一门课刷新前按钮仍然是可点状态点击后又收到后端的“重复选课”错误提示体验很差。5. 选课系统避坑指南并发、时间窗口与数据一致性5.1 并发选课导致课程超卖余量扣减不是查出来再减现象一门课限选 50 人50 个同学同时点击选课最终系统里有 57 条选课记录课程余量变成负数。原因业务代码先SELECT selected_count FROM course WHERE id ?判断小于max_count后执行 INSERT再执行 UPDATE 扣减余量。两个并发请求同时通过 SELECT 校验同时执行 UPDATE后一个 UPDATE 会把余量覆盖为负值。问题本质是“先查后改”没有做并发控制selected_count不是一个原子操作。解决把 SELECT 语句改成SELECT selected_count FROM course WHERE id ? FOR UPDATE让选课事务持有该课程行的排他锁其他选课事务必须等待上一个事务提交才能继续。还有一种做法是抛弃 SELECT 校验直接执行UPDATE course SET selected_count selected_count 1 WHERE id ? AND selected_count max_count利用受影响行数判断是否成功这也是常见的乐观锁方案。5.2 学生重复选同一门课接口校验被并发绕过现象学生快速双击选课按钮或者开了两个浏览器标签页同时操作数据库里出现同一个学生对同一门课的两条选课记录。原因Service 层先查student_course表有没有记录没有就插入。双击产生的两次请求一前一后到达第一次请求的插入还没提交第二次请求的查询已经执行同样查不到记录于是也执行插入双双成功。解决办法有三层最可靠的是数据库层面的联合唯一索引直接在表结构上拦住重复数据其次是 Service 方法上加synchronized或者 Redis 分布式锁前端还要在按钮点击后立即禁用并防止双击。三层都做好才是合格方案。5.3 学生退课后余量不恢复逻辑删除与物理删除没有区分现象学生退了一门课课程列表里的余量没有增加或者后台管理里已选人数统计错乱。原因如果退课操作是物理 DELETE但同时课程表的selected_count没有减一余量自然不变如果是逻辑删除但更新语句把状态置为 0统计已选人数时查询条件没有过滤status 1导致把退课的人也算进去了。解决退课接口必须在一个事务里同时做两条 SQL更新student_course表状态和扣减course表的selected_count。统计已选人数时SQL 一律加WHERE status 1条件和选课接口保持同一套状态机。5.4 前端跨域与代理失效接口 404 而页面能开现象前端页面正常打开但所有接口请求都报 404 或 403控制台显示 Failed to load resource且响应头里没有后端返回的 JSON。原因大概率是 proxy 配置没生效。一是vue.config.js改了但没重启开发服务器devServer 配置只在启动时读取二是 proxy 的 target 写错了端口后端实际是 8081 而 target 写了 8080三是请求路径不是以/api开头代理规则匹配不上。解决先确认后端启动日志里的实际端口再修改 devServer target。修改完必须重启npm run serve。如果仍然不行打开浏览器控制台 Network 面板查看请求 URL 和响应状态确认代理确实把/api转发到了目标地址。一种常见排查技巧是直接在浏览器访问http://localhost:8080/接口路径能通说明后端没问题问题一定在代理层。5.5 时间窗口“未开放选课”边界现象管理员配置了 9:00 开放选课但学生 8:59:59 发起请求时竟然选课成功。原因后端接口没有对当前时间与选课窗口时间段做校验前端可能在 9 点时才显示可选按钮但接口可以被直接调用。只要有人知道接口地址用 Postman 手动请求就能绕过前端限制。解决在 Service 层新增时间窗口校验方法查询系统配置表里的选课开始和结束时间用数据库时间或服务器当前时间与之比较。注意要用服务器时间而不是前端传入的时间否则用户修改本地系统时间就能绕过限制。这个校验逻辑是毕设答辩时评委最爱问的一个点实现了它项目深度立刻不一样。6. 进阶验证与部署技巧从“跑起来”到“能答辩”6.1 用 JMeter 验证并发选课接口很多同学答辩时说系统“支持并发”但没有数据支撑。用 JMeter 对选课接口做一次压测把结果截图放进答辩 PPT 里说服力远强于空口描述。压测场景设计线程数设为 20Ramp-up period 设为 1 秒循环次数 1 次给/api/student/course/select接口发送 POST 请求请求体带courseId和模拟的studentId。压测前要确保数据库里已初始化一门余量为 15 的课程和至少 20 个学生账号。通过聚合报告可以看到吞吐量、平均响应时间、错误率如果场景 2 的防超卖实现正确错误率里应该出现“课程余量不足”的响应但数据库最终余量绝不会为负数。6.2 用 Maven 打生产包并部署到服务器!-- 确保打包时跳过测试 -- plugin groupIdorg.springframework.boot/groupId artifactIdspring-boot-maven-plugin/artifactId configuration skipTeststrue/skipTests /configuration /plugin在项目根目录执行mvn clean package -DskipTests构建产物在target/目录下是一个可执行的 JAR。把application.yml里的数据库地址改成云服务器上的 MySQLURL 里改成公网或内网 IP同时将server.port暴露出来。传输 JAR 到服务器后执行java -jar course-select-system.jar日志输出里看到 Tomcat 启动完成即可用浏览器访问。前端构建同理npm run build生成dist/目录把它交给 Nginx 托管配置一份反向代理把/api转发到 JAR 进程的端口即可。前端静态文件放在 Nginx 的/usr/share/nginx/html/下配置后记得nginx -s reload。我自己的习惯是最终把整套部署命令写进一个deploy.sh脚本把数据库初始化、JAR 包更新、Nginx 配置检查全部串起来这样换一台服务器部署成本几乎为零。6.3 答辩前必做的三个自测场景第一个是“选课 — 退课 — 再选课”的完整流程验证状态机是否自洽第二个是“余量仅剩 1 时两个学生同时选课”验证不会超卖第三个是“选课窗口未开放时手动调用接口”验证时间校验生效。这三个场景各截一张图配合压测报告整个系统的技术深度就能完全展示出来。如果每个场景都能稳定复现再准备一段 2 分钟的讲解词描述从表结构到并发控制的完整思路导师再挑问题也能从容应对。希望我的这些踩坑经验能让你在动手时少走几段弯路。本文还有配套的精品资源点击获取
返回列表