简介:这份资源是面向高校计算机专业学生与Java Web初学者的一套学生选课管理系统完整项目资料,围绕教学管理场景,解决课程发布、选课退课、成绩录入与权限控制等实际业务问题,适合作为课程设计、毕业设计或Java Web入门练手参考。压缩包共5个文件,包含2个zip源码与数据库脚本、2个mp4部署演示视频以及1个sql文件,整体约208.74MB,源码、建库脚本与部署录屏相互配套,便于对照理解项目从搭建到运行的完整链路。目前已有152人学习下载。读者可从中获取基于Spring Boot与MySQL的三层架构实现思路,涵盖登录注册、课程管理、并发选课冲突处理、退课与成绩管理等模块,并借助部署视频完成JDK、Tomcat与数据库环境配置,快速跑通系统、排查常见问题,是理解Java Web实际项目开发流程的实用素材。
1. 学生选课管理系统:为什么“能跑通”和“能交付”是两回事
每年毕业季,计算机专业的学生都会扎堆做同一类题目——学生选课管理系统。标题里写着“基于 Java”“源代码”“数据库”“部署视频”,看起来像是一个打包好的课程设计资源。但真正做过的人都知道,这类系统最难的从来不是写代码,而是让它在别人的机器上也能跑起来。你本地用 IDEA 跑得好好的,换一台电脑,数据库连不上、JDK 版本不对、Tomcat 端口被占,随便一个环节都能卡住半天。
这篇文章面向两类人:一是正在做课程设计、需要一套能讲清楚、能演示、能答辩的选课系统的学生;二是刚入行、想通过一个完整项目理解 Java Web 开发全流程的初级开发。我会把“基于 Java 的学生选课管理系统”拆成可落地的技术方案,从技术选型、数据库设计、核心业务实现,到部署上线的完整路径,再到那些只有踩过才知道的坑。源代码和数据库脚本是结果,但更重要的是理解每一步为什么这么做。部署视频能帮你省时间,但如果你不知道背后的配置逻辑,换个环境照样翻车。
2. 技术选型:为什么这套组合拳最适合课程设计与企业入门
2.1 后端为什么选 Spring Boot + MyBatis 而不是纯 Servlet
很多课程设计的默认方案是 JSP + Servlet + JDBC,理由是“老师只教了这个”。但如果你想让项目在简历上拿得出手,或者答辩时能多讲几句,Spring Boot + MyBatis 是更务实的选择。Spring Boot 的内嵌 Tomcat 让你不需要单独装服务器,main方法直接启动;MyBatis 把 SQL 从 Java 代码里抽出来,改查询不用重新编译。
具体依赖版本上,JDK 用 8 或 11 都行,Spring Boot 选 2.7.x 系列最稳,MyBatis Starter 用 2.3.x。不要追最新版,Spring Boot 3.x 要求 JDK 17,很多学校机房环境跟不上。数据库驱动用mysql-connector-java8.0.x,连接 MySQL 5.7 和 8.0 都兼容。
<!-- pom.xml 核心依赖 --> <parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> </parent> <dependencies> <!-- Web 层:提供 REST 接口和内置 Tomcat --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- 持久层:MyBatis 与 Spring Boot 整合 --> <dependency> <groupId>org.mybatis.spring.boot</groupId> <artifactId>mybatis-spring-boot-starter</artifactId> <version>2.3.2</version> </dependency> <!-- MySQL 驱动 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> <!-- 简化实体类编写 --> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>这段配置里,spring-boot-starter-parent统一管理了所有依赖的版本号,你不需要在每个依赖里写<version>。mybatis-spring-boot-starter会自动扫描@Mapper注解的接口,省去手动配置SqlSessionFactory的麻烦。Lombok 用@Data替代 getter/setter,代码量能少三分之一。
2.2 前端用 Thymeleaf 还是前后端分离
课程设计场景下,我建议用 Thymeleaf 做服务端渲染。原因很直接:你不需要额外学 Vue 或 React,不需要处理跨域,不需要单独部署前端项目。一个 Spring Boot 的 jar 包丢过去就能跑,部署视频里也不用讲 Nginx 配置。
但如果你想让项目看起来更“现代”,可以用 Vue + Element UI 做前后端分离。代价是你要多写一套 axios 请求封装,多配一个跨域过滤器,部署时要么把前端打包进static目录,要么单独起一个 Nginx。对于答辩只有十分钟的场景,Thymeleaf 的性价比更高。
# application.yml 关键配置 spring: datasource: url: jdbc:mysql://localhost:3306/course_selection?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: 123456 driver-class-name: com.mysql.cj.jdbc.Driver thymeleaf: cache: false # 开发阶段关缓存,改页面不用重启 prefix: classpath:/templates/ suffix: .html mybatis: mapper-locations: classpath:mapper/*.xml type-aliases-package: com.example.entity configuration: map-underscore-to-camel-case: true # 数据库 snake_case 自动映射 Java camelCaseserverTimezone=Asia/Shanghai这个参数不加,MySQL 8.0 会报时区错误。map-underscore-to-camel-case开启后,数据库的student_id会自动映射到 Java 的studentId,不用在 SQL 里写别名。thymeleaf.cache=false在开发阶段必开,否则你改了 HTML 刷新页面看不到变化,会以为代码写错了。
2.3 数据库选 MySQL 5.7 还是 8.0
两者在选课系统这个量级下没有本质区别。MySQL 8.0 的窗口函数和 CTE 在这个场景用不上,但 8.0 的默认字符集是utf8mb4,支持 emoji,5.7 默认是latin1,建库时不指定会出乱码。如果你用 5.7,建库语句必须写DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci。
另一个坑是驱动类名。MySQL 5.7 用com.mysql.jdbc.Driver,8.0 用com.mysql.cj.jdbc.Driver。用错了启动时报Loading class 'com.mysql.jdbc.Driver' is deprecated,虽然还能跑,但日志里一堆警告。直接统一用com.mysql.cj.jdbc.Driver,5.7 也兼容。
3. 数据库设计:五张表撑起选课核心业务
3.1 表结构设计与字段类型选择
选课系统的核心表就五张:学生表、教师表、课程表、选课记录表、用户表。用户表存登录信息,学生和教师表存业务信息,通过user_id关联。选课记录表是核心中的核心,它决定了“一个学生同一门课只能选一次”这个约束。
-- 用户表:所有角色的登录入口 CREATE TABLE `sys_user` ( `user_id` int NOT NULL AUTO_INCREMENT, `username` varchar(50) NOT NULL COMMENT '登录名', `password` varchar(100) NOT NULL COMMENT 'MD5加密后的密码', `role` varchar(20) NOT NULL COMMENT 'STUDENT/TEACHER/ADMIN', `create_time` datetime DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`user_id`), UNIQUE KEY `uk_username` (`username`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 学生表:关联用户表,存学号、班级等 CREATE TABLE `student` ( `student_id` int NOT NULL AUTO_INCREMENT, `user_id` int NOT NULL, `student_no` varchar(20) NOT NULL COMMENT '学号', `name` varchar(50) NOT NULL, `class_name` varchar(50) DEFAULT NULL, `major` varchar(50) DEFAULT NULL, PRIMARY KEY (`student_id`), UNIQUE KEY `uk_student_no` (`student_no`), KEY `fk_student_user` (`user_id`), CONSTRAINT `fk_student_user` FOREIGN KEY (`user_id`) REFERENCES `sys_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 课程表:存课程名、学分、容量、授课教师 CREATE TABLE `course` ( `course_id` int NOT NULL AUTO_INCREMENT, `course_name` varchar(100) NOT NULL, `credit` decimal(3,1) NOT NULL DEFAULT '0.0', `capacity` int NOT NULL DEFAULT '50' COMMENT '课容量', `selected_count` int NOT NULL DEFAULT '0' COMMENT '已选人数', `teacher_id` int DEFAULT NULL, `semester` varchar(20) DEFAULT NULL COMMENT '开课学期', PRIMARY KEY (`course_id`), KEY `fk_course_teacher` (`teacher_id`), CONSTRAINT `fk_course_teacher` FOREIGN KEY (`teacher_id`) REFERENCES `teacher` (`teacher_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 选课记录表:学生与课程的多对多关系 CREATE TABLE `course_selection` ( `id` int NOT NULL AUTO_INCREMENT, `student_id` int NOT NULL, `course_id` int NOT NULL, `select_time` datetime DEFAULT CURRENT_TIMESTAMP, `status` tinyint DEFAULT '1' COMMENT '1-已选 0-已退', PRIMARY KEY (`id`), UNIQUE KEY `uk_student_course` (`student_id`,`course_id`), KEY `fk_selection_course` (`course_id`), CONSTRAINT `fk_selection_student` FOREIGN KEY (`student_id`) REFERENCES `student` (`student_id`), CONSTRAINT `fk_selection_course` FOREIGN KEY (`course_id`) REFERENCES `course` (`course_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;uk_student_course这个唯一索引是关键。它从数据库层面保证了同一个学生不能对同一门课产生两条选课记录。没有这个约束,你只能在 Java 代码里查一次再插入,并发场景下两个请求同时查都说不存在,然后都插入,数据就脏了。selected_count字段是冗余设计,用空间换时间,选课时直接更新这个字段比每次COUNT(*)快得多。
3.2 选课容量控制:乐观锁还是悲观锁
选课系统最典型的并发场景是:一门课剩最后一个名额,两个学生同时点选课。如果代码写成“先查容量,再插入,再更新计数”,两个请求都查到容量没满,都插入成功,最后selected_count变成 51,超卖了。
常见做法有两种。悲观锁用SELECT ... FOR UPDATE锁住课程行,事务提交前其他请求阻塞。优点是简单,缺点是并发高时大量请求排队,响应变慢。乐观锁用版本号或直接UPDATE course SET selected_count = selected_count + 1 WHERE course_id = ? AND selected_count < capacity,根据影响行数判断是否成功。
// 乐观锁方案:选课核心逻辑 @Transactional public String selectCourse(Integer studentId, Integer courseId) { // 先检查是否已经选过(唯一索引兜底,这里只是友好提示) int count = selectionMapper.countByStudentAndCourse(studentId, courseId); if (count > 0) { return "你已经选过这门课"; } // 原子更新:只有容量未满时才增加计数 int updated = courseMapper.increaseSelectedCount(courseId); if (updated == 0) { return "课程已满,选课失败"; } // 插入选课记录 selectionMapper.insert(studentId, courseId); return "选课成功"; }对应的 MyBatis SQL:
<update id="increaseSelectedCount"> UPDATE course SET selected_count = selected_count + 1 WHERE course_id = #{courseId} AND selected_count < capacity </update>increaseSelectedCount返回的是影响行数。如果返回 0,说明selected_count >= capacity,课程已满。这个判断和更新在一条 SQL 里完成,是原子操作,不存在并发间隙。@Transactional保证选课记录插入失败时计数回滚。注意<是 XML 里小于号的转义写法,直接写<会解析报错。
3.3 退课与重选的数据一致性
退课不是DELETE,而是把status改成 0,同时selected_count减一。这样做的原因是保留选课历史,方便统计和审计。重选时先查有没有status=0的记录,有就更新为 1,没有才插入新记录。
-- 退课:软删除 + 计数减一 UPDATE course_selection SET status = 0 WHERE student_id = ? AND course_id = ? AND status = 1; UPDATE course SET selected_count = selected_count - 1 WHERE course_id = ? AND selected_count > 0;selected_count > 0这个条件防止计数变成负数。虽然正常流程不会出现,但如果有管理员手动删了选课记录却没更新计数,退课时就会触发。加上这个条件,至少不会把数据搞得更乱。
4. 核心功能实现:从登录拦截到选课接口的完整链路
4.1 登录与权限拦截:Session 还是 JWT
课程设计场景下,Session 足够用,而且实现简单。用户登录成功后把user对象放进HttpSession,写一个HandlerInterceptor拦截所有/student/**、/teacher/**、/admin/**的请求,检查 Session 里有没有用户信息,没有就重定向到登录页。
// 登录拦截器 public class LoginInterceptor implements HandlerInterceptor { @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { HttpSession session = request.getSession(); Object user = session.getAttribute("loginUser"); if (user == null) { // 未登录,重定向到登录页 response.sendRedirect(request.getContextPath() + "/login"); return false; } return true; } }注册拦截器时要注意排除登录页、注册页和静态资源:
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addInterceptors(InterceptorRegistry registry) { registry.addInterceptor(new LoginInterceptor()) .addPathPatterns("/**") .excludePathPatterns("/login", "/doLogin", "/register", "/css/**", "/js/**", "/images/**"); } }addPathPatterns("/**")拦截所有请求,excludePathPatterns放行登录相关和静态资源。如果不放行/css/**,登录页的样式加载不出来,页面会变成纯文本。这个坑我见过不止一次,排查半天以为是路径写错了,其实是拦截器把 CSS 请求也拦了。
4.2 选课接口的参数校验与异常处理
选课接口接收courseId,从 Session 里拿studentId。参数校验用@RequestParam的required=true保证courseId不为空,业务校验在 Service 层做。
@Controller @RequestMapping("/student") public class StudentController { @Autowired private CourseService courseService; @PostMapping("/selectCourse") @ResponseBody public Result selectCourse(@RequestParam("courseId") Integer courseId, HttpSession session) { User user = (User) session.getAttribute("loginUser"); if (user == null) { return Result.error("请先登录"); } try { String msg = courseService.selectCourse(user.getUserId(), courseId); return "选课成功".equals(msg) ? Result.success(msg) : Result.error(msg); } catch (Exception e) { // 唯一索引冲突兜底 if (e.getMessage().contains("Duplicate entry")) { return Result.error("你已经选过这门课"); } return Result.error("系统繁忙,请稍后重试"); } } }Result是一个统一返回类,包含code、msg、data三个字段。前端根据code判断成功还是失败。Duplicate entry是 MySQL 唯一索引冲突的报错信息,虽然前面已经查过一次,但并发场景下两个请求同时通过检查,第二个插入时会触发唯一索引,这里捕获后返回友好提示,避免 500 错误页。
4.3 课程列表分页与模糊查询
课程列表要支持按课程名模糊查、按教师名查、按学期查,还要分页。用 PageHelper 插件最省事,一行代码搞定分页。
<!-- pom.xml 添加 PageHelper --> <dependency> <groupId>com.github.pagehelper</groupId> <artifactId>pagehelper-spring-boot-starter</artifactId> <version>1.4.7</version> </dependency>public PageInfo<CourseVO> listCourses(String keyword, Integer pageNum, Integer pageSize) { PageHelper.startPage(pageNum, pageSize); List<CourseVO> list = courseMapper.selectByKeyword(keyword); return new PageInfo<>(list); }PageHelper.startPage必须紧跟在查询方法之前,中间不能插入其他数据库操作,否则分页会作用到错误的查询上。CourseVO是视图对象,比实体类多一个teacherName字段,通过LEFT JOIN teacher查出来。
<select id="selectByKeyword" resultType="com.example.vo.CourseVO"> SELECT c.*, t.name AS teacherName FROM course c LEFT JOIN teacher t ON c.teacher_id = t.teacher_id <where> <if test="keyword != null and keyword != ''"> AND (c.course_name LIKE CONCAT('%', #{keyword}, '%') OR t.name LIKE CONCAT('%', #{keyword}, '%')) </if> </where> ORDER BY c.course_id DESC </select><where>标签会自动处理第一个AND,不用写WHERE 1=1。CONCAT('%', #{keyword}, '%')是 MySQL 的字符串拼接,用#{}预编译防止 SQL 注入。如果用${}拼接,输入' OR '1'='1就能查出所有数据。
5. 部署避坑:从本地跑通到换机可用的五条血泪经验
5.1 数据库连接失败:时区、驱动、权限三连坑
现象:本地 IDEA 启动正常,换一台电脑导入项目后启动报Communications link failure或Access denied for user。
原因:第一种是 MySQL 8.0 的时区问题,serverTimezone没配或配错。第二种是驱动类名用了旧版com.mysql.jdbc.Driver。第三种是数据库用户没有远程访问权限,只能localhost连接。
解决:连接 URL 统一写成jdbc:mysql://localhost:3306/course_selection?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false。驱动类名用com.mysql.cj.jdbc.Driver。如果换机后连不上,先ping数据库 IP,再用mysql -u root -p -h IP测试远程连接,不通就检查user表的host字段是不是%。
5.2 端口占用:8080 被占用的快速排查
现象:启动时报Port 8080 was already in use。
原因:本机已经有一个 Tomcat 或 Spring Boot 应用在跑,或者上次启动的进程没杀干净。
解决:Windows 下netstat -ano | findstr :8080找到 PID,taskkill /PID xxx /F杀掉。Mac 或 Linux 用lsof -i:8080然后kill -9 PID。更省事的办法是在application.yml里改端口server.port: 8081,但部署视频里要提醒观众改端口后访问地址也要跟着变。
5.3 静态资源 404:拦截器放行规则写漏了
现象:登录页能打开,但 CSS 和 JS 全部 404,页面样式全乱。
原因:拦截器addPathPatterns("/**")把静态资源请求也拦了,而excludePathPatterns里没写/css/**、/js/**。
解决:在excludePathPatterns里补上静态资源路径。如果用了 Thymeleaf,/templates/**下的 HTML 不需要放行,因为它们是服务端渲染后返回的。但/static/**下的资源必须放行。Spring Boot 默认把classpath:/static/映射到根路径,所以/css/style.css实际找的是static/css/style.css。
5.4 中文乱码:从数据库到页面的全链路排查
现象:课程名在数据库里显示正常,页面上是问号或乱码。
原因:数据库字符集、连接 URL 字符集、页面编码三者不一致。常见的是数据库建库时用了latin1,或者连接 URL 没写characterEncoding=utf8。
解决:建库语句必须写DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci。连接 URL 加useUnicode=true&characterEncoding=utf8。Thymeleaf 页面<meta charset="UTF-8">。如果还乱码,检查 MySQL 的character_set_server变量,SHOW VARIABLES LIKE 'character%'看是不是utf8mb4。
5.5 选课计数不一致:手动改数据后的修复脚本
现象:课程列表显示的已选人数和实际选课记录数对不上。
原因:有人直接改了course_selection表的数据,但没有同步更新course.selected_count。或者退课时只改了status没减计数。
解决:写一个修复脚本,以course_selection表为准重新计算。
-- 修复 selected_count 与实际选课记录不一致 UPDATE course c SET c.selected_count = ( SELECT COUNT(*) FROM course_selection cs WHERE cs.course_id = c.course_id AND cs.status = 1 );这条 SQL 在数据量不大时直接跑没问题。如果数据量大,可以加WHERE c.selected_count != (SELECT COUNT(*) ...)只更新不一致的行。跑之前先备份,跑完用SELECT验证一遍。
6. 从能跑到能讲:答辩演示与二次开发的进阶技巧
答辩演示最怕两件事:一是演示到一半报错,二是老师问“这个功能怎么实现的”答不上来。我的习惯是提前准备一个“演示专用”数据库,里面预置好学生、教师、课程和选课记录,演示时只走正常流程,不现场造数据。选课演示要选一门容量充足的课,不要选只剩最后一个名额的,万一并发演示时翻车,场面会很尴尬。
二次开发最常见的需求是加“选课时间窗口”限制。实现方式是在course表加select_start和select_end两个datetime字段,选课前判断当前时间是否在窗口内。
// 选课时间窗口校验 public String selectCourse(Integer studentId, Integer courseId) { Course course = courseMapper.selectById(courseId); LocalDateTime now = LocalDateTime.now(); if (course.getSelectStart() != null && now.isBefore(course.getSelectStart())) { return "选课尚未开始"; } if (course.getSelectEnd() != null && now.isAfter(course.getSelectEnd())) { return "选课已结束"; } // ... 后续选课逻辑 }另一个常见需求是导出选课名单为 Excel。用 EasyExcel 最简单,加一个依赖,写一个@ExcelProperty注解的 VO 类,三行代码导出。
// 导出选课名单 public void exportSelection(Integer courseId, HttpServletResponse response) throws IOException { List<SelectionExportVO> list = selectionMapper.selectExportByCourseId(courseId); response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment;filename=selection.xlsx"); EasyExcel.write(response.getOutputStream(), SelectionExportVO.class).sheet("选课名单").doWrite(list); }SelectionExportVO里用@ExcelProperty("学号")、@ExcelProperty("姓名")标注字段,导出的表头就是中文。response.setContentType必须设对,否则浏览器会当成 HTML 打开而不是下载。
最后说一个我自己的教训:不要等到答辩前一天才部署到演示电脑上。我见过太多人本地跑得好好的,到了教室发现 JDK 版本不对、MySQL 没装、端口被占。提前一天在演示环境完整走一遍,把数据库脚本、JDK、Maven 依赖都准备好,比多写两个功能重要得多。希望帮到你。
本文还有配套的精品资源,点击获取