ARTICLE DETAIL

资讯详情

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

Spring Boot+Vue学生选课系统开发实战:从表结构到并发防超卖

Spring Boot+Vue学生选课系统开发实战:从表结构到并发防超卖 简介这是一份基于Spring Boot和Vue技术栈开发的学生网上选课系统完整项目采用前后端分离架构后端以Java和Spring Boot为核心前端由Vue.js构建配合MySQL 5.7数据库为计算机专业学生及Java Web开发者提供了从设计到落地的完整实践范例尤其适合作为毕业设计参考。资源包共417个文件涵盖109个Java后端源码、56个Vue前端组件、数据库脚本、项目文档及配置类文件另有SVG、PNG等界面资源与JS、CSS等静态文件压缩包大小21.82MB结构清晰便于按模块学习。目前已有33人学习下载。项目资源包含可运行的前后台完整源码、SQL数据库脚本和万字设计文档源码均经过调试可直接部署可帮助读者理解选课流程、用户权限管理、课程信息管理及前后端API交互等关键实现同时文档对系统设计思路与核心代码进行了解析并对远程调试、二次开发和项目讲解提供支持能够有效提升实际开发能力。1. 学生网上选课系统是什么毕设、课设与小型前后端分离的最佳练手对象如果你见过那些“选课系统源码”的压缩包大概率会觉得它不过是课程表、选课按钮和后台管理三块拼起来的东西。但真正动手做过一遍就会发现学生网上选课系统是教务业务里需求最清晰、上下游最完整的场景之一学生按培养方案选课教师开课并录入成绩管理员排课和设定选课时间三者之间真正的矛盾只有一个——名额有限、先到先得。Springbootvue这个组合恰好是当前毕业设计和课程设计里最主流的搭配后端管事务、前端管交互一套完整的前后端分离项目能把认证、权限、CRUD、事务和并发问题全部覆盖到。适合两类人一是想找一个能讲清业务又有技术亮点的毕设题目的学生二是后端想补一层Vue、前端想补一层Java的从业者。这篇笔记就按我实际做过类似项目的顺序从表结构讲到并发选课和部署验证。2. 先把业务拆成表和接口需求边界、角色权限与选课状态机2.1 角色与权限学生、教师、管理员三种身份的边界在哪里选课系统的权限一般分三层管理员管理课程基础数据和选课时间窗口教师负责开课、排时间和录入成绩学生只做课程检索、选课、退课和查成绩三项操作。Springboot后端用JWT或Session管登录态Vue前端用路由守卫控制页面入口权限模型不需要过度设计。常见做法是user表里加一个role字段1学生、2教师、3管理员不引入完整的Spring Security RBAC因为引入之后你仍然要自己写接口鉴权反而是单纯增加配置量。真正需要定死的是操作边界学生不能调开课接口教师不能代替学生选课管理员不参与选课和退课。后端用拦截器按URL前缀拦截即可满足前端再把按钮隐藏掉双保险。角色可操作接口禁止操作学生课程查询、选课、退课、成绩查询课程增删改、用户管理教师开课申请、我的课程、成绩录入选课退课、用户管理管理员课程审核、学期配置、选课时间设定代替学生选课一个容易漏掉的边界是成绩录入和发布。教师录完成绩后学生立即可见会引发争议所以成绩表上要有status字段教师先暂存、再发布发布前学生只能看到“未出分”。这个细节在答辩时很加分因为它说明你想过状态流转而不只是写CRUD。2.2 表结构设计课程表与选课记录的约束怎么写选课系统核心表通常只有五张用户表、课程表、选课记录表、学期表和成绩表。课程表必须有capacity容量字段和selected_count已选数字段选课记录表用student_id加course_id做联合唯一索引从数据库层面杜绝同一学生重复选同一门课。为什么强调这两个字段因为选课是一个典型的短事务后端代码要做的第一步是让SQL完成条件扣减第二步再插入记录这样并发控制才有兜底而不是在业务里层层if判断。CREATE TABLE user ( id bigint NOT NULL AUTO_INCREMENT, username varchar(32) NOT NULL, password varchar(128) NOT NULL COMMENT 建议BCrypt毕设常用MD5加盐, role tinyint NOT NULL DEFAULT 1 COMMENT 1学生 2教师 3管理员, name varchar(32) DEFAULT NULL, created_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE course ( id bigint NOT NULL AUTO_INCREMENT, name varchar(64) NOT NULL, teacher_id bigint NOT NULL, capacity int NOT NULL DEFAULT 50, selected_count int NOT NULL DEFAULT 0, start_time datetime DEFAULT NULL COMMENT 上课开始时间, end_time datetime DEFAULT NULL COMMENT 上课结束时间, select_start datetime DEFAULT NULL COMMENT 选课开放时间, select_end datetime DEFAULT NULL COMMENT 选课截止时间, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE course_selection ( id bigint NOT NULL AUTO_INCREMENT, student_id bigint NOT NULL, course_id bigint NOT NULL, selected_at datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_student_course (student_id,course_id), KEY idx_course_id (course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这里有个参数值得细说联合唯一索引取名uk_student_course字段顺序是student_id在前、course_id在后这样既保证唯一性又让“查某学生所有选课记录”的查询能直接命中联合索引省掉多余回表。课程表里的select_start和select_end字段强烈建议由后端统一取服务器时间做判断前端传什么时间都不可信。很多项目把时间比较逻辑写在后端业务代码里用now()跟这两个字段比这是对的。2.3 技术栈选型为什么SpringbootVue而不是别的组合身边经验是毕设把SpringbootVue这套跑通的边际成本最低。Springboot的起步依赖把web、事务、数据库连接全部配置好写CRUD几乎只剩业务代码Vue的分层清晰组件化很适合课程列表和选课按钮这种强交互页面。如果换成SSM配JSP改一行样式都要重启服务调试体验很差如果强行上微服务光网关和服务注册就够写几千字答辩反而容易被追问到翻车。Springboot还在持续更新但选课系统用2.7.x或者3.x都行我常用的还是2.7.18因为网上搜得到的资料、博客、踩坑案例基本都是针对这个版本段遇到问题容易找到解法。3. 后端Springboot实现从依赖到选课接口的完整链路3.1 初始化项目pom.xml里的依赖选择与版本陷阱后端我一般会用IDEA直接创建Springboot项目初始依赖只勾Web和Validation剩下的MyBatis-Plus、MySQL驱动、Lombok、JWT全部写进pom.xml。这里最大的坑是版本搭配Springboot 2.7.18搭配MyBatis-Plus 3.5.x是常见组合如果盲目用MyBatis-Plus 3.4以下的老版本分页插件要单独维护还容易和Springboot 3的Jakarta命名空间冲突。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdcom.auth0/groupId artifactIdjava-jwt/artifactId version4.4.0/version /dependency /dependencies版本说明mysql-connector-j是MySQL官方新版坐标老教程里写mysql-connector-java虽然在2.7.18里也能解析但会报deprecation警告无伤大雅但建议顺手换掉。java-jwt 4.x的API和3.x不同create接口用JWT.create().withSubject()没有Jwts.builder那套写法别混着抄。3.2 数据访问层实现MyBatis-Plus还是原生Mapper选课系统的查询条件不复杂但联表查询多课程列表要带出教师姓名选课记录要带出课程名称。MyBatis-Plus的BaseMapper能覆盖单表CRUD联表那部分用自定义注解SQL两种方式混用是常见做法不要为了“统一风格”放弃自定义SQL。分页用MyBatis-Plus自带的PaginationInnerInterceptor配置一个Configuration类注册分页插件即可无需额外引入PageHelper避免两套分页插件同时生效的冲突。分页参数用current和size返回IPage对象前端只管传页码和每页条数。基于springboot vue的项目里课程列表分页是我最常用到的功能算是整个系统的基础底盘。3.3 选课与退课接口事务、锁与联合唯一索引选课接口是整个系统的核心也是并发最容易翻车的地方。新手写法是先查课程剩余名额判断大于0再insert选课记录最后update selected_count。这个顺序在高并发下必然超卖两个线程同时查到剩余1个名额然后同时insert成功最后selected_count变成负数。正确做法是把扣名额和条件判断合并成一条update语句数据库的行锁会帮我们把并发挡住。Transactional(rollbackFor Exception.class) public boolean selectCourse(Long studentId, Long courseId) { // 1. 条件扣减名额返回0说明满员或课程不存在 UpdateWrapperCourse uw new UpdateWrapper(); uw.eq(id, courseId) .gt(selected_count, 0) .apply(selected_count capacity) .setSql(selected_count selected_count 1); int rows courseMapper.update(null, uw); if (rows 0) { throw new BizException(课程已满或课程不存在); } // 2. 插入选课记录联合唯一索引兜底重复选课 CourseSelection cs new CourseSelection(); cs.setStudentId(studentId); cs.setCourseId(courseId); try { courseSelectionMapper.insert(cs); } catch (DuplicateKeyException e) { // 事务回滚扣减的名额自动释放 throw new BizException(你已经选过这门课); } return true; }这段代码需要掰开讲清楚。第一步的UpdateWrapper里gt(selected_count, 0)其实不必要因为apply里已经有selected_count capacity但保留它对可读性有帮助。关键是步骤1和步骤2的顺序不能颠倒先扣名额再插入记录如果先插入记录再扣名额插入成功但扣名额失败会导致误报“没选上”步骤2抛出DuplicateKeyException时事务整体回滚步骤1扣掉的名额也会释放这就是Transactional(rollbackFor Exception.class)的作用默认只能回滚RuntimeException必须显式声明rollbackFor为Exception才能兜住所有异常。退课接口是反向操作先delete选课记录再update selected_count减1减的时候同样加selected_count 0条件避免负值。4. 前端Vue实现路由、状态与选课交互4.1 项目结构与动态路由登录后根据角色生成菜单前端用Vue 3加Vite脚手架创建目录结构相对简单api目录装axios请求router目录管路由views目录按页面放组件。很多新手把请求逻辑直接写在组件里结果每个页面都重复一套loading和错误处理改成按模块拆分后维护成本会明显下降。动态路由是学生网上选课系统的刚需因为学生、教师、管理员登录后看到的菜单完全不同直接用静态路由写死会出现学生访问管理端页面路径直接可见的问题。vue-course-select/ ├── src/ │ ├── api/ │ │ ├── request.js │ │ ├── course.js │ │ └── auth.js │ ├── router/ │ │ └── index.js │ ├── stores/ │ │ └── user.js │ ├── views/ │ │ ├── Login.vue │ │ ├── SelectCourse.vue │ │ ├── MyCourses.vue │ │ └── AdminCourse.vue │ ├── App.vue │ └── main.js └── vite.config.js动态注册路由我一般写成这样在全局前置守卫里判断角色并addRoute。要注意的是addRoute之后必须使用next({ ...to, replace: true })重新进入目标路由否则路由表还没来得及更新就放行会白屏或404。router.beforeEach((to, from, next) { const token localStorage.getItem(token) const role localStorage.getItem(role) if (to.path /login) return next() if (!token) return next(/login) if (!router.hasRoute(student) role 1) { router.addRoute(student, { path: /student, name: Student, component: () import(/views/SelectCourse.vue) }) return next({ ...to, replace: true }) } next() })这里有两个参数说明。第一hasRoute判断很关键没有它每次路由跳转都会重复addRoute控制台会警告duplicate route。第二动态路由组件必须用箭头函数懒加载不能直接import一个组件对象否则打包时无法做代码分割首次加载时间会变长。页面级按钮的权限在校验时后端拦截器还需要二次校验前端的隐藏只是体验优化不是安全边界。4.2 axios请求封装token注入、401处理与统一错误提示选课系统的所有接口都要求登录后访问axios封装需要解决三件事每个请求自动带上token、收到401统一跳回登录页、业务错误统一弹提示。最常见的错误是把token处理写在每个页面里后来新增接口时忘写header排查半天才发现是token没带。import axios from axios import { ElMessage } from element-plus import router from /router const service axios.create({ baseURL: /api, timeout: 15000 }) 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) { ElMessage.error(res.msg || 请求失败) return Promise.reject(new Error(res.msg)) } return res.data }, error { if (error.response?.status 401) { localStorage.removeItem(token) localStorage.removeItem(role) router.push(/login) } else { ElMessage.error(error.message || 网络异常) } return Promise.reject(error) } ) export default service说明一下后端返回结构约定我一般统一用{ code, msg, data }code为200才进入正常分支业务异常比如课程已满也走这个结构由拦截器统一弹错误。401单独处理是因为它代表登录态失效需要清理本地存储并跳登录页。timeout设15秒选课接口偶尔会因为数据库锁等待变慢超过15秒直接提示网络异常比让用户无限等下去好。4.3 选课页面的核心交互剩余名额、已选状态与冲突提示选课页面的交互逻辑看起来只是调一个接口实际上要处理剩余名额、已选状态和选课冲突三种情况。课程列表加载后前端要在内存里维护已选课程ID集合这个集合来自“我的课表”接口也可以用Vue的store存起来避免切换页面后重新拉取。async function onSelect(course) { if (course.selected) { ElMessage.warning(你已经选过这门课) return } if (course.selected_count course.capacity) { ElMessage.warning(手慢了这门课已满) return } try { await selectCourseApi(course.id) ElMessage.success(选课成功) course.selected true course.selected_count } catch (e) { // 错误提示已由axios拦截器统一处理这里只做状态回退 refreshCourseList() } }这段代码的关键点是catch里调用refreshCourseList重新拉取列表而不是单纯把selected状态改回去。原因是选课失败可能源于名额被其他学生抢走本地只回退selected_count并不能反映真实数据必须从后端重新同步。课程表中selected_count字段我建议在前端展示时直接读取选课按钮的disabled条件同时判断selected和selected_count capacity两个条件这样能减少不必要的接口请求。5. 从联调到部署Springbootvue项目的避坑记录5.1 并发选课超选现象、原因与加锁方案现象用压测工具模拟20个学生同时选同一门只剩1个名额的课程最后course表里selected_count变成3超卖。原因代码里先查剩余名额再insert再update这种三段式操作在并发窗口期无法保证原子性两个请求可能同时通过名额判断。网上很多旧教程就是这么写的看起来没问题因为本地单用户测试永远复现不了。解决把扣名额和条件判断合并为一条update语句也就是第3章给出的写法让数据库行锁来串行化操作。如果面试或答辩被问到“你项目里是怎么处理并发的”就回答条件更新加事务回滚而不是回答用了synchronized因为synchronized只对单实例生效而且把锁放在应用层意味着所有请求要排队吞吐量直线下降。5.2 前后端联调跨域前端报错但后端日志正常现象前端npm run dev访问后端8080端口浏览器控制台报CORS error或net::ERR_FAILED后端日志显示请求确实到了响应也正常。原因前端开发服务器默认代理没有配置浏览器直接跨域请求http://localhost:8080却被后端没有设置Access-Control-Allow-Origin挡回来。解决开发环境不要在后端写CrossOrigin正确做法是配置Vite或Vue CLI的代理让前端请求走相对路径由开发服务器转发到后端。// vite.config.js export default { server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } }生产环境的跨域问题则交给Nginx统一处理后端不暴露到公网。项目打包后把dist目录交给Nginx请求路径/api由Nginx反代到后端服务同域请求不存在跨域。后端只需要在application.yml里配置context-path和端口不用再写一层CORS过滤器。5.3 时间窗口失效前端时区与服务器时间不一致现象选课页面显示选课已经开始点击选课却提示“不在选课时间范围内”。学生本人在本地服务器部署在云上两个时间差了8小时前端用本地时间判断导致误判。原因前端new Date()取的是浏览器所在时区时间后端now()取的是服务器时区时间。如果代码里把前端时间通过参数传给后端比较时区一错全盘崩。解决前端不判断时间窗口只渲染后端返回的select_start和select_end字段提交选课请求时后端拿数据库时间或服务器时间比较。后端比较时间统一用JDK的LocalDateTime.now()或数据库的NOW()不要在代码里手动给时间字符串拼时区转换那是最容易出bug的地方。这是一个“看起来很基础但线上总出问题”的经典坑值得单独写进测试用例。5.4 Maven版本过高导致Springboot启动失败现象idea创建Springboot项目后直接运行启动时报Caused by: java.lang.NoClassDefFoundError: javax/xml/bind/JAXBException。原因JDK版本过高比如JDK 17移除了Java EE模块而老版本Springboot或MySQL驱动依赖里的某些库还引用javax.xml.bind包。这在学校机房电脑上特别常见内存里拷的旧项目一运行就报这个错。解决pom.xml里加JAXB依赖或者把JDK换成8或11。Springboot 2.7.x对JDK 8和11支持最好JDK 17也能跑但需要补依赖建议直接装JDK 8少折腾。如果启动时报的是ClassNotFound相关的其他包优先检查是不是JDK版本和Springboot版本不匹配这是springboot配置里最常见的启动问题之一不要一上来就怀疑代码写错。6. 验证与进阶打包、部署与并发压测确认系统可用6.1 打包验证前后端分离部署的最小步骤系统能跑通不代表能交付把项目从IDE里变成可部署产物是必修课。后端在项目根目录执行mvn clean package生成jar包前端在vue项目目录执行npm run build生成dist目录。我一般会把nginx.conf配成根路径访问dist里的index.html/api路径反代到后端8080端口这样前端路由的history模式刷新页面不会404。注意用history模式要额外配置try_files $uri $uri/ /index.html否则用户在选课页面按F5会直接白屏这是vue路由和nginx配合时很容易踩的坑。部署到云服务器时jar包启动用nohup java -jar course-system.jar service.log 21 日志单独落盘排查问题时直接在service.log里搜异常栈。6.2 压测与验收用脚本确认并发选课不超选交付之前我会写一个简单的多线程脚本打选课接口确认在并发下不会超卖。不追求JMeter的完整报告只需要验证两个结论剩余1个名额时并发20个请求只有一个成功重复选同一门课被数据库唯一索引拦住。import requests import threading url http://localhost:8080/api/course/select s requests.Session() s.headers.update({Authorization: Bearer your-token}) def do_select(): try: resp s.post(url, json{courseId: 1}) print(resp.json().get(code), resp.json().get(msg)) except Exception as e: print(error:, e) threads [threading.Thread(targetdo_select) for _ in range(20)] for t in threads: t.start() for t in threads: t.join()跑完这个脚本后去数据库查course表里课程1的selected_count应该不多于capacity再直接往表里insert一条相同student_id和course_id的记录应该被唯一索引拒绝。这两个检查通过选课核心逻辑才算过关。我现在的习惯是这个脚本跑完再写验收报告因为口头代码永远说服不了人用数据展示才是做技术项目最有力的证据。希望这篇笔记能把选课系统从“会写”推到“能上线”的程度帮到你。本文还有配套的精品资源点击获取
返回列表