SpringBoot+Vue+MyBatis企业级选修选课系统从源码到部署全实践

发布时间:2026/10/21 12:02:20
SpringBoot+Vue+MyBatis企业级选修选课系统从源码到部署全实践
企业级大学生选修选课系统从源码到部署的完整实践记录很多人看到“企业级”三个字就容易犯怵觉得那是大厂才玩得转的东西。但说句实在话选课系统这类业务反而是最适合拿来学习和复用的企业级项目因为它的业务边界足够清晰、权限模型足够典型、并发场景也足够真实。我最近把这套SpringBoot Vue MyBatis架构 MySQL数据库的选修选课系统从源码到部署完整过了一遍前前后后踩了不少坑也积累了不少实战心得这篇文章就把整个拆解过程和核心实现细节一次讲清楚。这套系统核心解决的是高校场景里三类角色——学生、教师、管理员——的选课全流程管理问题。学生能在线浏览课程、预选抽签、退课查分教师能开课、录分、查看选课名单管理员负责课程审核、选课周期配置和基础数据维护。适合正在做毕业设计的学生、想转型全栈的Java开发以及需要快速搭建内部管理后台的团队参考。1. 整体设计与架构思路1.1 为什么选SpringBoot Vue这个组合选课系统这种项目最怕的是把架构搞复杂。市面上确实有微服务版本的选课系统实际验收和部署时反而是徒增成本。单体应用 前后端分离是这个体量最合理的折中。SpringBoot负责提供RESTful API内置Tomcat容器打成一个jar包就能跑。Vue负责前端交互用axios或者fetch调后端接口。MyBatis作为ORM层负责数据库操作。MySQL存储业务数据。这套组合几方配合分工明确各自都很成熟。这样的架构有一个很直接的好处开发效率高。SpringBoot的自动配置特性省掉了大量XML配置MyBatis写SQL足够灵活复杂查询比如多表联查、条件动态拼接都很方便Vue的组件化开发让前端页面复用起来非常顺手。不用学一堆中间件掌握这套就能做出一个完整可交付的系统。1.2 系统核心功能模块拆解选课系统表面上看着功能不多真正梳理需求时才发现模块之间的关联度很高。我按角色维度把整个系统拆成了三大业务域再加一个基础支撑域学生端门户课程浏览检索、选课操作、退课、课表查看、成绩查询这是最核心的用户流量入口。教师端工作台课程开设申请、课程维护、选课情况统计、成绩录入与发布。管理端控制台用户管理、院系/专业维护、课程审核、选课规则配置比如容量限制、选课时间段、学分上限、系统参数设置。公共支撑模块登录认证、权限控制、日志记录、数据统计大屏。这些模块虽然不起眼却是系统能不能叫作“企业级”的关键。1.3 数据模型设计的几个关键决策数据表设计我从一开始就坚持“宁可多一张关联表也不要在单表里塞冗余字段”。以选课记录表为例很多简洁版的设计会在学生表和课程表里各自维护一个选课数量字段这种做法在校验时确实省事但并发场景下频繁更新同一行极易产生多版本冲突。我最终采用的设计是学生表(student)学号、姓名、院系、年级、密码盐值等不做任何选课量相关的字段。课程表(course)课程名称、教师ID、学分、预选人数上限、已选人数、课程时间、教室这个“已选人数”是必要的性能优化字段。选课记录表(course_selection)ID、学生ID、课程ID、选课时间、状态下核心业务表。授课表(course_teacher)课程ID、教师ID用于处理教师调用课程列表的多对多关系。关于已选人数这个冗余字段我做过一次压测对比。不加冗余字段每次查询课程详情都需要count选课记录表加了冗余字段之后只要在选课和退课的事务里同步更新这一行即可。查询性能和一致性之间取了个平衡。项目的核心逻辑也确实集中在这一段后面会详细展开。2. 核心细节解析与实操要点2.1 SpringBoot后端架构的三层划分后端代码规整成Controller、Service、Mapper三个层级这是Java Web项目最经典的职责分工方式。Controller层只做参数接收、简单校验和结果封装一个接口一个方法保持轻薄。Service层承载真正的事务逻辑与业务规则校验。Mapper层通过MyBatis操作数据库接口方法跟SQL映射一一配对。以选课流程为例学生请求POST /api/selection传入课程ID和学生IDController直接透传给Service。Service内部先校验选课时间是否在开放期内再判断课程是否还有余量然后检查学生是否已经选过这门课最后检查是否超过学分上限。四项规则都通过之后才真正插入选课记录——这个串行校验的业务逻辑是在Service层完成的而不是散落在Controller里。2.2 MyBatis关键SQL的渐进式写法MyBatis最爽的地方在于动态SQL。比如学生端课程列表的检索条件是可变的可能按课程名称模糊搜按院系限制按学分范围过滤也可能什么都不输直接分页。我用的就是where标签配ifselect idqueryCoursePage resultTypecom.example.entity.CourseVO SELECT c.*, t.name AS teacher_name FROM course c LEFT JOIN teacher t ON c.teacher_id t.id where if testcourseName ! null and courseName ! AND c.course_name LIKE CONCAT(%, #{courseName}, %) /if if testdepartment ! null and department ! AND c.department #{department} /if if testminCredit ! null AND c.credit gt; #{minCredit} /if if testmaxCredit ! null AND c.credit lt; #{maxCredit} /if /where ORDER BY c.create_time DESC LIMIT #{offset}, #{pageSize} /select这里我踩过一个很典型的坑MySQL的分页必须用LIMIT #{offset}, #{pageSize}但MyBatis在传入参数时会因为#{offset}不能直接用做LIMIT参数而报错需要在Mapper接口上用Param(offset)注解显式声明。这是用MyBatis写分页查询时最容易遇到的一个老问题记忆格外深刻。2.3 Vue前端的状态管理与路由守卫Vue前端我用Vue Router做页面路由控制配合Router守卫实现登录鉴权和角色权限控制。实现逻辑其实很直接本地存储里维护一个token和用户角色每次路由跳转前检查目标路由的meta.roles是否包含当前角色。router.beforeEach((to, from, next) { const token localStorage.getItem(token) if (to.path /login) { next() return } if (!token) { next({ path: /login }) return } const roles localStorage.getItem(roles) if (to.meta.roles !to.meta.roles.includes(roles)) { next({ path: /403 }) return } next() })这个方案在落地时确实能防住页面级的越权访问但必须说明它只有防御展示层面的作用。真正的权限控制永远要在后端Service层再做一遍校验前端路由守卫只是体验优化。有人只用前端鉴权就把管理端的按钮藏起来结果有人直接拼接口URL就能调删除接口后端一个鉴权逻辑都没写——这是选课系统开发里必须避免的设计漏洞。3. 实操过程与核心环节实现3.1 从零搭建数据库设计与初始化脚本动手写代码之前先把数据表建清楚。我按标准第三范式设计核心表一共8张数据表关键字段备注userid, username, password, role, status统一账号表角色区分学生/教师/管理员studentid, user_id, student_no, name, dept学生信息扩展表teacherid, user_id, teacher_no, name, dept教师信息扩展表courseid, course_name, credit, capacity, selected课程基本信息表teacher_courseid, teacher_id, course_id教师授课关联表selectionid, student_id, course_id, select_time, status选课记录表gradeid, selection_id, score, comment成绩表system_configid, config_key, config_value系统参数配置初始化脚本里我预置了三类账号用于测试密码全部经过BCrypt加密存储。开发阶段推荐先执行这个脚本造一批测试数据不然前端开发时会因为没数据而处处碰壁。3.2 学生选课的并发防护实战选课系统尤其是选修课存在一个很典型的强并发场景热门课程开放选课的第一分钟大量学生同时点击选课。如果后台不做任何控制就极容易出现“超选”现象——系统显示还有2个名额却有10个人同时提交成功。我从一开始就确定了方案乐观锁改为数据库行锁。不靠应用层的synchronized或分布式锁而是直接依赖MySQL自身的行级锁。核心代码逻辑Transactional(rollbackFor Exception.class) public void selectCourse(Long studentId, Long courseId) { // 1. 查询学生信息校验角色 Student student studentMapper.selectById(studentId); // 2. 锁定课程行防止并发超选 Course course courseMapper.selectByIdForUpdate(courseId); if (course null) { throw new BizException(课程不存在); } // 3. 校验课程余量 if (course.getSelected() course.getCapacity()) { throw new BizException(课程已满); } // 4. 校验学生是否重复选课 int cnt selectionMapper.countByStudentAndCourse(studentId, courseId); if (cnt 0) { throw new BizException(你已选过这门课程); } // 5. 校验学分上限 int totalCredit selectionMapper.sumCreditsByStudent(studentId); if (totalCredit course.getCredit() MAX_CREDIT) { throw new BizException(超过学分上限); } // 6. 执行选课 selectionMapper.insert(new Selection(studentId, courseId)); courseMapper.increaseSelected(courseId); }关键细节在第2步selectByIdForUpdate给课程行加了排他锁。这样并发请求过来时会排队执行后一个请求在前一个事务提交之后才读到最新的selected值。为了保证锁真正生效事务一定要标注Transactional并且这个查询语句必须走主键索引。实际用JMeter模拟200并发抢同一门课的压测数据是最终成功插入的选课记录数恰好等于课程容量没有出现一条超卖数据。3.3 教师端成绩录入与批量提交教师端的成绩录入我采用了单条录入批量提交双模式。单条录入适合调整某个学生的成绩批量提交适合期中期末考试一起录分。批量提交的实现方案是前端一次性把整张成绩表格的数据传到后端后端在Service层循环插入整个方法加事务Transactional(rollbackFor Exception.class) public void batchSaveGrades(ListGradeDTO gradeList) { if (CollectionUtils.isEmpty(gradeList)) { throw new BizException(成绩数据不能为空); } // 校验学生是否确实选了这门课 Long courseId gradeList.get(0).getCourseId(); ListLong studentIds gradeList.stream() .map(GradeDTO::getStudentId) .collect(Collectors.toList()); ListLong validIds selectionMapper.findSelectedStudentIds(courseId, studentIds); if (validIds.size() ! studentIds.size()) { throw new BizException(存在未选课的学生数据); } gradeMapper.batchInsert(gradeList); }这里有一个经验我可以直接分享批量校验一定要在插入之前一次性完成不要在循环里多次查数据库。一次IN查询换取前十几次网络开销数据量大的时候性能差距非常明显。3.4 学生退课与容量回补退课的逻辑比选课简单但有个坑必须保证“退课成功则容量必回补”是一个原子操作。Transactional(rollbackFor Exception.class) public void dropCourse(Long studentId, Long courseId) { int rows selectionMapper.deleteByStudentAndCourse(studentId, courseId); if (rows 0) { throw new BizException(你未选过该课程); } courseMapper.decreaseSelected(courseId); }通过deleteByStudentAndCourse返回值来判断是否真正删除成功天然避免了重复退课导致的超额回补。如果你把这个顺序反过来先回补容量再删记录出现异常时事务虽然能回滚但会多出排查事务失效的风险点。4. 常见问题与排查技巧实录4.1 MyBatis分页失效怎么回事这是我在集成MyBatis时遇到最多的问题。表现是前端请求page1pageSize10返回结果却永远是全部数据。排查思路是先看Controller有没有把参数正确传给Service再看Mapper接口上有没有Param注解。如果参数名里有pageNum、pageSize这类单词MyBatis可能识别不了。还有一种常见坑是使用了多个参数但没加Param导致参数名变成arg0、arg1。最佳实践是ListCourseVO queryCoursePage(Param(courseName) String courseName, Param(offset) Integer offset, Param(pageSize) Integer pageSize);这个方法签名固定参数名跟XML里#{offset}完全对应不容易出问题。4.2 CORS跨域问题与解决方案前后端分离一定会碰跨域。开发阶段我用前端脚手架里的代理配置解决生产环境用Nginx反向代理统一入口。开发环境的Vue配置在vue.config.js里module.exports { devServer: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, pathRewrite: { ^/api: } } } } }生产环境在Nginx里配置location /api/ { proxy_pass http://127.0.0.1:8080/; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; }我不建议在后端Controller上直接加CrossOrigin( * )开发阶段图省事感觉还行生产环境会把安全问题暴露出来——任何来源的请求都能跨域调用接口对选课系统这类带权限校验的项目非常危险。权限校验配合白名单配置才是一个可交付项目的正常水准。4.3 前端“已选课程列表”出现脏数据排查过程里发现一个有趣的问题学生退课成功后在“已选课程”列表里可能还要刷新两次才消失。原因不是后端删数据失败而是前端状态管理没有及时更新——列表数据是进入页面时加载的退课后没有重新拉取。这类问题的解法是退课接口在成功回调里直接更新本地state同时调用一次列表刷新接口保持同步。这里我顺手做了个优化列表接口针对学生这个维度做本地缓存缓存命中时毫秒级返回只有选课或退课操作后主动失效。4.4 常见问题速查表问题现象可能原因解决方案报504网关超时数据库连接池耗尽或SQL慢查询检查连接池配置用EXPLAIN分析慢SQL学生重复选课成功Service层缺少重复校验在选课逻辑中增加唯一约束与业务校验前端登录后跳转404路由表缺少对应路由检查路由配置与动态路由加载逻辑成绩明明是0却显示为空前端把0当作空字符串处理判空条件改为value null删除学生失败外键冲突该学生已有选课或成绩记录级联删除业务数据或做逻辑删除项目启动时端口占用本机已有进程占用8080换端口或杀死占用进程再启动5. 部署上线与运维要点5.1 本地开发环境的完整启动步骤这套环境如果从零配步骤大概是安装JDK 8配置JAVA_HOME环境变量。安装MySQL 5.7执行项目里的init.sql脚本建库建表。安装Node.js 14用来跑Vue前端开发服务。在application.yml中修改数据库用户名、密码、URL。先启动后端SpringBoot服务确认8080端口监听。在Vue项目目录执行npm install再执行npm run serve启动前端。提示后端和前端必须都能各自独立启动成功才算环境就绪。如果后端没起来前端调接口会全部失败先在浏览器直接访问后端接口确认状态再继续调前端。5.2 生产环境打包与部署生产环境部署我用了最简单的方案前端打包成静态文件后端打成一个jar包统一用Nginx托管。前端打包npm run build打包完成后dist目录下的文件就是需要部署的静态资源传到服务器的/usr/share/nginx/html目录即可。后端打包mvn clean package -DskipTests生成的jar包传到服务器用nohup启动nohup java -jar selection-system.jar --server.port8080 /var/log/selection-system.log 21 Nginx配置需要注意的点静态资源用try_files处理接口请求反代到后端前端路由必须用history模式时配好try_files $uri $uri/ /index.html;否则刷新页面就是404。5.3 数据库备份与恢复数据库备份是选课系统这种教务系统最不该省的一环。我建议每天做一次全量备份mysqldump -u root -p selection_system /backup/selection_system_$(date %F).sql备份文件定期拷贝到其他服务器或对象存储防止服务器磁盘损坏导致备份一起丢失。恢复时mysql -u root -p selection_system /backup/selection_system_2025-01-01.sql6. 实战总结与后续扩展思路整个项目做下来我最深的体会有三点。第一选课系统真正难的不是功能多而是并发下的数据一致性。学生选课那一刻的竞争这个场景用课堂上学到的乐观锁、悲观锁、事务这些概念在真实场景里验证一遍比刷十道理论题都管用。第二前后端分离项目的调试思路必须先确定边界。前端报错后端接口是不是通的后端正常前端有没有把参数传对按这个思路逐层排查能节省大量时间。第三这套系统后续扩展能力很强。当前版本支持选修课选课后续可以加入必修课自动排课、抢课队列、消息通知、Excel批量导入学生信息等功能。我在设计数据库时特意给course表预留了course_type字段后续扩展成必修/选修并行时无需改表结构。如果让我再优化一次这个系统我会优先考虑这三件事给选课接口加上消息队列做异步削峰把选课高峰期的数据库压力前置到缓存层增加定时任务自动关闭超过截止时间的选课窗口优化导出成绩、课表的Excel功能为流式读取避免大数据量内存溢出。不过这些方向都是基于业务压力才需要的调整现阶段这套SpringBootVueMyBatis的选修选课系统已经完全有能力支撑一个中型规模的高校教务场景了。