
简介这是一套面向计算机专业学生与Java Web开发初学者的毕业设计完整源码基于JavaSpringBoot后端与Vue.js前端构建学生网上选课系统覆盖教室、教师、课程、教学计划、选课、成绩与学生管理及角色权限控制等核心模块适合作为毕设选题参考或课程设计实践素材。压缩包共417个文件约22.15MB其中109个java文件承载后端业务逻辑56个vue文件与17个js文件构成前端页面与交互另有161个svg、19个png、14个jpg等静态资源以及xml、yml、sql、bat等配置与数据库脚本目录结构清晰便于按模块检索学习。目前已有114人学习下载。读者可从中获得一套可直接运行的全栈项目方案理解SpringBoot分层架构、Vue组件化开发与前后端数据交互流程并借助sql脚本与bat启动脚本快速搭建环境对照源码梳理选课冲突处理、成绩统计与权限设计等实现思路为毕设答辩与项目实战积累经验。1. 学生网上选课系统为什么它是 Java 毕设里最容易翻车的一类项目每年到了毕设季选课系统几乎是出现频率最高的题目之一。原因很直接业务场景人人熟悉需求文档好写答辩时老师也容易理解。但真正动手做过的人都知道这类项目看着简单实际落地时坑非常密集——选课并发、时间冲突校验、容量控制、退课重选每一个点单独拎出来都能让系统在演示时当场翻车。这个标题指向的是一个基于 Java SpringBoot 的学生网上选课系统交付物是源码工程。它要解决的核心问题是让学生在线完成选课、退课、查课表让管理员维护课程和名额让教师查看选课名单。适合正在做毕设的本科生、需要快速搭出一个可演示可答辩系统的开发者以及想拿一个完整 SpringBoot 项目练手的人。我见过太多选课系统在本地跑得好好的一到答辩现场多人同时点选课就出问题。所以这篇不打算只讲“怎么把功能堆出来”而是按真实落地的顺序把技术选型、数据库设计、核心业务实现、并发处理和排错一条条讲清楚。你照着做至少能保证演示那天不丢人。2. 技术选型与工程骨架SpringBoot 版本、依赖和分层怎么定2.1 为什么用 SpringBoot 而不是传统 SSM毕设选课系统最常见的两套技术栈一套是传统 SSMSpring SpringMVC MyBatis一套是 SpringBoot MyBatis/MyBatis-Plus。我一般直接推荐后者理由不是“新”而是省事。传统 SSM 要手写一堆 XML 配置web.xml、spring-mvc.xml、applicationContext.xml、mybatis-config.xml光是让项目跑起来就得折腾半天而且版本冲突是玄学级别的。SpringBoot 把这些全部收敛到 application.yml 加几个 starter 依赖里启动类一跑就起来对毕设这种时间紧、要快速出成果的场景非常合适。但这里有个热词里反复出现的问题springboot版本太高。这是真实存在的坑。SpringBoot 3.x 要求 JDK 17 起步而且把 javax.* 换成了 jakarta.*很多老教程里的代码直接编译不过。如果你学校机房或者自己电脑装的是 JDK 8那必须用 SpringBoot 2.x。我的建议是场景JDKSpringBoot说明学校要求 JDK8 / 老教程82.7.x最稳资料最多自己电脑新想用新特性173.2.x注意 jakarta 包名不确定82.7.18保守选择不会错选 2.7.x 的另一个好处是网上绝大多数选课系统教程、MyBatis-Plus 文档都是基于这个版本的你遇到问题能搜到答案。用 3.x 你会发现自己成了少数派报错都没人踩过。2.2 工程目录结构一个能答辩、结构清晰的选课系统包结构我通常这样分com.example.course ├── CourseApplication.java // 启动类 ├── config/ // 配置类拦截器、跨域、MyBatis-Plus ├── controller/ // 接口层 ├── service/ // 业务层 │ └── impl/ ├── mapper/ // 数据访问层 ├── entity/ // 数据库实体 ├── dto/ // 请求参数对象 ├── vo/ // 返回给前端的对象 └── common/ // 统一返回、异常、工具类分层不是形式主义。答辩老师很爱问“你的业务逻辑写在哪”如果你把选课校验全塞在 Controller 里这个问题就答不好。Service 层承载业务规则Controller 只做参数接收和结果返回这是基本要求。2.3 核心依赖清单pom.xml 里必须有的依赖我列一下并说明每个的作用!-- Web 层提供 Controller、内置 Tomcat -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency !-- MyBatis-Plus比原生 MyBatis 少写大量 XML -- dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.1/version /dependency !-- MySQL 驱动 -- dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency !-- Lombok省掉 getter/setter -- dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency参数说明MyBatis-Plus 的版本要和 SpringBoot 版本匹配2.7.x 配 3.5.x 没问题MySQL 驱动 8.x 对应数据库 8.0连接串要加serverTimezoneAsia/Shanghai否则时间字段会差 8 小时这是血泪经验。提示如果你用 SpringBoot 3.xMySQL 驱动坐标和 MyBatis-Plus 版本都要换MyBatis-Plus 需要 3.5.3.2 以上才支持 jakarta。2.4 application.yml 关键配置server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/course_system?serverTimezoneAsia/ShanghaiuseUnicodetruecharacterEncodingutf8 username: root password: 你的密码 driver-class-name: com.mysql.cj.jdbc.Driver mybatis-plus: configuration: map-underscore-to-camel-case: true # 数据库下划线转Java驼峰 log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 打印SQL调试必备 global-config: db-config: logic-delete-field: deleted # 逻辑删除字段 logic-delete-value: 1 logic-not-delete-value: 0map-underscore-to-camel-case打开后数据库的student_id会自动映射到 Java 的studentId不用手写 ResultMap。log-impl打开 SQL 日志选课查不到数据时能直接看到执行的 SQL排查效率翻倍。3. 数据库设计选课系统表结构和那几个不能省掉的字段3.1 核心表清单选课系统的表不多但每张表的字段设计直接影响后面业务能不能写顺。核心五张表表名作用关键字段student学生信息id, student_no, name, password, class_idteacher教师信息id, teacher_no, namecourse课程id, course_no, name, teacher_id, capacity, selected_count, creditcourse_time上课时间id, course_id, week_day, start_section, end_sectionselection选课记录id, student_id, course_id, select_time, status这里重点说 course 表和 selection 表。course 里的capacity容量和selected_count已选人数是两个必须分开的字段。很多人图省事只存容量选课时去 count 一下 selection 表结果并发一来就超卖。把已选人数冗余到 course 表配合数据库行锁才能控制住。selection 表的status字段也别省。退课不是物理删除记录而是把 status 改成“已退选”。这样既保留了选课历史又能防止同一学生反复选退同一门课刷数据。3.2 建表 SQLCREATE TABLE course ( id BIGINT NOT NULL AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL COMMENT 课程编号, name VARCHAR(100) NOT NULL COMMENT 课程名, teacher_id BIGINT NOT NULL COMMENT 授课教师, capacity INT NOT NULL DEFAULT 50 COMMENT 容量上限, selected_count INT NOT NULL DEFAULT 0 COMMENT 已选人数, credit DECIMAL(3,1) DEFAULT 2.0 COMMENT 学分, deleted TINYINT DEFAULT 0 COMMENT 逻辑删除, PRIMARY KEY (id), UNIQUE KEY uk_course_no (course_no) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE selection ( id BIGINT NOT NULL AUTO_INCREMENT, student_id BIGINT NOT NULL, course_id BIGINT NOT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, status TINYINT DEFAULT 1 COMMENT 1已选 0已退, PRIMARY KEY (id), UNIQUE KEY uk_stu_course (student_id, course_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;参数说明uk_stu_course这个唯一索引是关键。它保证同一个学生对同一门课只能有一条记录从数据库层面挡住重复选课。就算你代码里忘了判断数据库也会拦下来。selected_count用 INT 而不是算出来的是为了后面加锁更新。3.3 时间冲突怎么存上课时间单独一张表因为一门课可能一周上两次。week_day存 1-7start_section和end_section存第几节到第几节。判断两门课时间冲突就是判断 week_day 相同且节次区间有重叠。这个逻辑放在 Service 层后面第 4 章会写。注意不要图省事把上课时间存成字符串“周一3-4节”那样冲突判断只能靠字符串解析又慢又容易错。结构化存储是底线。4. 选课核心业务并发控制、时间冲突和容量校验怎么写4.1 选课流程拆解一次选课请求进来Service 层要按顺序做这几件事校验学生是否已选过这门课查 selection 表 status1校验课程是否还有名额selected_count capacity校验时间是否与已选课程冲突扣减名额selected_count 1插入选课记录顺序不能乱。先做便宜的校验查记录、查名额再做时间冲突这种要联表计算的。任何一步失败就抛异常回滚。4.2 并发超卖为什么必须加锁假设课程容量 50已选 49两个学生同时点选课。两个线程都读到 selected_count49都判断“还有名额”然后都执行 1结果变成 51超卖。这就是典型的并发问题答辩时如果老师让你现场演示多人选课不加锁必翻车。解决办法有两种。一种是数据库悲观锁在查询课程时加for updateSelect(SELECT * FROM course WHERE id #{id} FOR UPDATE) Course selectForUpdate(Param(id) Long id);FOR UPDATE会锁住这一行第二个线程必须等第一个事务提交后才能读到最新值。缺点是并发高时排队严重但毕设场景完全够用。另一种是乐观锁用版本号或直接条件更新Update(UPDATE course SET selected_count selected_count 1 WHERE id #{id} AND selected_count capacity) int increaseSelectedCount(Param(id) Long id);这条 SQL 把“判断名额”和“扣减”合并成一个原子操作返回影响行数。如果返回 0说明名额已满直接抛异常。这种方式不用显式加锁性能更好我更推荐。4.3 选课 Service 实现Service public class SelectionServiceImpl implements SelectionService { Autowired private SelectionMapper selectionMapper; Autowired private CourseMapper courseMapper; Autowired private CourseTimeMapper courseTimeMapper; Override Transactional(rollbackFor Exception.class) public void selectCourse(Long studentId, Long courseId) { // 1. 是否已选status1 表示有效选课 Long count selectionMapper.countByStudentAndCourse(studentId, courseId); if (count 0) { throw new BizException(你已经选过这门课了); } // 2. 原子扣减名额返回0说明满了 int rows courseMapper.increaseSelectedCount(courseId); if (rows 0) { throw new BizException(课程名额已满); } // 3. 时间冲突校验 ListCourseTime newTimes courseTimeMapper.listByCourse(courseId); ListCourseTime myTimes courseTimeMapper.listByStudent(studentId); for (CourseTime nt : newTimes) { for (CourseTime mt : myTimes) { if (nt.getWeekDay().equals(mt.getWeekDay()) nt.getStartSection() mt.getEndSection() mt.getStartSection() nt.getEndSection()) { throw new BizException(与已选课程时间冲突); } } } // 4. 插入选课记录 Selection sel new Selection(); sel.setStudentId(studentId); sel.setCourseId(courseId); sel.setStatus(1); selectionMapper.insert(sel); } }逻辑说明Transactional保证任何一步抛异常前面的扣减都会回滚不会出现“名额扣了但记录没插进去”的脏数据。时间冲突判断用的是区间重叠公式A.start B.end B.start A.end这是判断两个区间是否相交的标准写法记住它。参数说明rollbackFor Exception.class必须写因为 Spring 默认只对 RuntimeException 回滚自定义的 BizException 如果继承 Exception 就不会回滚这是高频翻车点。4.4 退课实现退课就是把 selection 的 status 改成 0同时把 course 的 selected_count 减 1Override Transactional(rollbackFor Exception.class) public void dropCourse(Long studentId, Long courseId) { int rows selectionMapper.updateStatus(studentId, courseId, 0); if (rows 0) { throw new BizException(你没有选这门课); } courseMapper.decreaseSelectedCount(courseId); }decreaseSelectedCount里要加selected_count 0条件防止减成负数。退课和选课一样两步操作必须在同一个事务里。5. 避坑与排查选课系统演示前必须过的五道关5.1 时间字段差 8 小时现象选课记录里的 select_time 比实际时间早 8 小时或者查出来的时间对不上。原因MySQL 连接串没指定时区驱动默认用 UTC。解决连接串加serverTimezoneAsia/Shanghai同时确认数据库服务器时区。这个坑几乎每个新手都会踩一次。5.2 中文乱码现象课程名、学生姓名存进数据库变成问号。原因数据库、表、连接串三处字符集不一致。解决建库建表统一用utf8mb4连接串加useUnicodetruecharacterEncodingutf8。注意是 utf8mb4 不是 utf8后者存不了 emoji虽然选课系统用不上但统一规范省事。5.3 前端跨域 403现象前端页面调接口报 CORS 错误Postman 里却正常。原因浏览器同源策略拦截后端没配跨域。解决加一个全局跨域配置类或者用CrossOrigin注解。如果前端打包后放进 SpringBoot 的 static 目录就不存在跨域问题这也是热词里“vue打包放进springboot中”的常见做法。Configuration public class CorsConfig implements WebMvcConfigurer { Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping(/**) .allowedOriginPatterns(*) .allowedMethods(GET, POST, PUT, DELETE) .allowCredentials(true); } }5.4 逻辑删除后唯一索引冲突现象学生退课后重新选同一门课插入报唯一键冲突。原因selection 表有uk_stu_course唯一索引退课只是把 status 改成 0记录还在再插入就撞索引。解决重新选课时不要 insert而是把已有记录的 status 改回 1。或者把唯一索引改成包含 status 的联合索引但那样逻辑更绕。我一般用前者退课记录复用。5.5 事务失效现象选课抛异常了但名额还是被扣了。原因方法不是 public、自调用、异常被 catch 没抛出都会导致Transactional失效。解决确保选课方法 public、由外部调用、异常往外抛。自调用场景同类方法互相调要用 AopContext 或者拆到另一个 Service。这个坑很隐蔽建议在扣减名额后手动抛个异常测一下回滚。6. 让选课系统更抗压一个名额扣减的压测技巧和我的习惯前面讲的原子扣减到底有没有用不能靠嘴说。答辩前我一般会做一次简单压测用 JMeter 或者直接写个多线程测试模拟 100 个学生抢 50 个名额看最后 selected_count 是不是正好 50。Test public void testConcurrentSelect() throws InterruptedException { int threads 100; ExecutorService pool Executors.newFixedThreadPool(threads); CountDownLatch latch new CountDownLatch(threads); AtomicInteger success new AtomicInteger(); for (int i 0; i threads; i) { final long studentId i 1; pool.submit(() - { try { selectionService.selectCourse(studentId, 1L); // 抢课程1 success.incrementAndGet(); } catch (Exception e) { // 名额满或冲突正常失败 } finally { latch.countDown(); } }); } latch.await(); Course course courseMapper.selectById(1L); System.out.println(成功选课人数 success.get()); System.out.println(课程已选人数 course.getSelectedCount()); }跑完看两个数字是否相等且不超过容量。如果 selected_count 大于 50说明你的锁没生效回去检查 SQL 条件。这个测试花不了十分钟但能让你在答辩时底气十足。再分享一个我的习惯所有涉及“判断 更新”的业务我都尽量合并成一条带条件的 UPDATE靠影响行数判断成败而不是先查再改。选课扣名额、退课减名额、库存扣减都是这个套路。这样写出来的代码天然抗并发也少了很多 if-else。还有个细节课程列表查询时我会把selected_count和capacity一起返回给前端显示成“已选 32/50”。学生看到快满了会紧张这个体验比只显示课程名好得多答辩演示时也是个加分项。最后说一句选课系统这类项目功能谁都能堆出来真正拉开差距的是边界处理——名额满了怎么办、时间冲突怎么提示、退课重选怎么兼容。把这些想清楚你的系统就不只是“能跑”而是“敢演示”。希望帮到你。本文还有配套的精品资源点击获取