
简介面向Java课程设计与毕业设计的学生选课管理系统源码基于JSP、Servlet、JDBC与MySQL开发支持教师和学生双角色登录代码经过实际运行验证。教师端可管理学生信息、课程信息、选课信息并可设置必修学分的下限与上限学生端能完成选课、查看已选课程、修改个人信息系统还会展示未修够最低学分的学生名单业务模块完整能够覆盖选课管理的主要流程。压缩包共105个文件主要包含Java源码及编译后的class文件、JSP页面、CSS样式、XML配置、SQL数据库脚本、JAR依赖包以及PNG/JPG效果截图核心代码与页面文件占比高便于阅读和二次开发另附完整论文docx和高清运行指导视频mp4资源包大小约108.89MB。目前已有4704人学习下载。配套的导入运行文档与视频教程涵盖数据库导入、Eclipse/MyEclipse部署等具体步骤可帮助在JDK1.8、Tomcat8及MySQL5.5/5.6/5.7环境下快速启动系统数据库脚本可直接初始化表结构。整体采用DAO、Servlet与JSP分层组织适合需要快速跑通并深入研究设计思路的Java学习者。1. 学生选课管理系统一个 JSP Servlet MySQL 的老组合为什么现在还值得动手如果你在找一套能直接跑起来的 JavaWeb 课设或毕设源码学生选课管理系统几乎是出现频率最高的题目。它的技术栈固定得很死——JSP 负责页面、Servlet 处理请求、MySQL 存数据再加一个 Tomcat 就能跑代码量不大但五脏俱全。这类系统一般带学生、教师、管理员三种角色的登录覆盖选课、退课、成绩录入、课表查询这些完整业务闭环用来应付课程设计验收、简历上的技术描述或者作为你学 Servlet 阶段的一个综合练习都非常合适。我看了不少这类源码真正能“拿下来直接运行”的其实不多。多数问题出在 JDBC 驱动版本不匹配、MySQL 8.0 的时区和加密规则变化、Tomcat 版本与 Servlet 注解解析的差异以及 IDE 里 Artifacts 打包方式不对。这篇文章就按我实际调试这类项目的顺序把数据库建模、多角色登录实现、选课事务处理、本地部署这几个关键环节逐一拆开连同我踩过的坑一起写出来。2. 多角色登录的权限设计一张用户表还是三张角色表决定了你后面所有的代码2.1 先想清楚三种角色分别能干什么学生选课管理系统的核心是“不同的人看不同的东西”。管理员管基础数据教师管课程和学生成绩学生只关心选课和查成绩。角色权限如果不提前设计好后面写 Servlet 的时候会在每个请求里堆 if/else代码很快变成一团乱麻。常见做法是设计三张角色表——student、teacher、admin各自存专属字段。student 表里需要学号、姓名、班级、密码teacher 表里需要工号、姓名、职称、密码admin 表最简单账号加密码就够。选课关系单独建一张 student_course 关联表不往学生表里硬塞课程字段。这种拆分的好处是数据模型跟业务语义一一对应查询的时候也直观但代价是登录时你得先判断“这个人到底属于哪个角色”于是多写一步。另一种做法是单用户表加 role 字段。把学号、工号、账号统一叫 username密码统一再加一个 role 字段区分身份。好处是登录逻辑简单只需要查一张表坏处是学生和教师的专属属性没地方放除非你再额外加 profile 表去补。对于课设级别的系统我建议直接用三张表的方式因为评审老师更看重“数据模型是否贴合业务”多写一个登录分发并不费事。2.2 建表 SQL把主键、外键和唯一约束一次性想好我一般把数据库名取为 course_select字符集用 utf8mb4排序规则选 utf8mb4_general_ci。以下是核心表结构直接能在 MySQL 5.7 和 8.0 里执行-- 学生表 CREATE TABLE student ( id INT PRIMARY KEY AUTO_INCREMENT, stu_no VARCHAR(20) NOT NULL UNIQUE COMMENT 学号, stu_name VARCHAR(50) NOT NULL COMMENT 姓名, stu_class VARCHAR(50) COMMENT 班级, password VARCHAR(64) NOT NULL DEFAULT 123456 COMMENT 初始密码, created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 教师表 CREATE TABLE teacher ( id INT PRIMARY KEY AUTO_INCREMENT, tea_no VARCHAR(20) NOT NULL UNIQUE COMMENT 工号, tea_name VARCHAR(50) NOT NULL COMMENT 姓名, title VARCHAR(50) COMMENT 职称, password VARCHAR(64) NOT NULL DEFAULT 123456 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 管理员表 CREATE TABLE admin ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 课程表 CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL UNIQUE COMMENT 课程编号, course_name VARCHAR(100) NOT NULL, teacher_id INT, credit DECIMAL(2,1) DEFAULT 2.0, capacity INT DEFAULT 60 COMMENT 选课容量, selected_count INT DEFAULT 0 COMMENT 已选人数, CONSTRAINT fk_course_teacher FOREIGN KEY (teacher_id) REFERENCES teacher(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 选课关联表 CREATE TABLE student_course ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,2) DEFAULT NULL COMMENT 成绩未录入时为空, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id), CONSTRAINT fk_sc_student FOREIGN KEY (student_id) REFERENCES student(id), CONSTRAINT fk_sc_course FOREIGN KEY (course_id) REFERENCES course(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段 SQL 里有两个细节值得注意。第一student_course 表加了联合唯一约束 uk_student_course从数据库层面杜绝学生重复选同一门课很多项目只在代码里判断“是否已选”遇到并发请求就穿帮唯一约束是最后的保险丝。第二course 表里我放了 selected_count 字段来记录已选人数这属于空间换时间的思路查课表和显示剩余名额时不用每次 count 关联表。2.3 登录分发LoginServlet 里按角色查表而不是一条 SQL 查到底登录流程是这套系统的门面也是多角色设计的第一个考验。我的做法是在 LoginServlet 里依次尝试三张表找到哪张表里账号和密码匹配就跳转到哪个角色对应的主页。虽然多查两次数据库但课设级别的并发量完全无所谓换来的是代码直白易懂。WebServlet(/login) public class LoginServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); String role req.getParameter(role); // 页面下拉框传值 User user null; // 按角色只查一张表避免全表扫描三层 if (student.equals(role)) { user StudentDAO.findByNoAndPwd(username, password); } else if (teacher.equals(role)) { user TeacherDAO.findByNoAndPwd(username, password); } else if (admin.equals(role)) { user AdminDAO.findByUsernameAndPwd(username, password); } if (user null) { req.setAttribute(error, 账号或密码错误或角色选择不正确); req.getRequestDispatcher(/login.jsp).forward(req, resp); return; } // 登录成功把身份写进 session后续所有页面都认这个 HttpSession session req.getSession(); session.setAttribute(loginUser, user); session.setAttribute(role, role); String redirect student.equals(role) ? /student/home.jsp : teacher.equals(role) ? /teacher/home.jsp : /admin/home.jsp; resp.sendRedirect(req.getContextPath() redirect); } }参数说明页面登录表单里必须有一个 role 下拉框值对应 student/teacher/admin 三个选项。如果页面不做角色选择那后端就只能按“账号密码先查学生、再查教师、最后查管理员”的顺序逐个试代码会绕一些。LoginServlet 上的 WebServlet(/login) 注解依赖 Servlet 3.0 及以上规范Tomcat 7 之后都没问题。登录态写入 session 后后续页面直接从 session 里取 loginUser 对象即可。3. 核心功能落地选课事务、退课名额回补、成绩录入的状态机3.1 选课逻辑先查容量再插入中间用事务兜底选课是这套系统最容易出并发问题的环节。常规思路是先查课程剩余名额大于 0 就插入选课记录再把课程表的 selected_count 加一。但如果不加事务两个学生同时选最后一门课时可能都查到了剩余名额 1然后都插入成功最后 selected_count 变成 2课程超员。WebServlet(/selectCourse) public class SelectCourseServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session req.getSession(); User user (User) session.getAttribute(loginUser); if (user null || !student.equals(session.getAttribute(role))) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } int courseId Integer.parseInt(req.getParameter(courseId)); int studentId user.getId(); Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 查当前已选人数 String checkSql SELECT capacity, selected_count FROM course WHERE id ? FOR UPDATE; PreparedStatement ps conn.prepareStatement(checkSql); ps.setInt(1, courseId); ResultSet rs ps.executeQuery(); if (rs.next()) { int capacity rs.getInt(capacity); int selected rs.getInt(selected_count); if (selected capacity) { conn.rollback(); req.setAttribute(msg, 该课程已满员); req.getRequestDispatcher(/student/select.jsp).forward(req, resp); return; } } else { conn.rollback(); req.setAttribute(msg, 课程不存在); req.getRequestDispatcher(/student/select.jsp).forward(req, resp); return; } // 插入选课记录 String insertSql INSERT INTO student_course (student_id, course_id) VALUES (?, ?); PreparedStatement ps2 conn.prepareStatement(insertSql); ps2.setInt(1, studentId); ps2.setInt(2, courseId); ps2.executeUpdate(); // 已选人数 1 String updateSql UPDATE course SET selected_count selected_count 1 WHERE id ?; PreparedStatement ps3 conn.prepareStatement(updateSql); ps3.setInt(1, courseId); ps3.executeUpdate(); conn.commit(); req.setAttribute(msg, 选课成功); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } // 捕捉重复选课的 DuplicateKeyException给用户友好提示 if (e.getMessage() ! null e.getMessage().contains(Duplicate)) { req.setAttribute(msg, 你已经选过这门课了); } else { req.setAttribute(msg, 选课失败 e.getMessage()); } req.getRequestDispatcher(/student/select.jsp).forward(req, resp); } } }这个 Servlet 里有两个点值得重点讲。第一是 SELECT ... FOR UPDATE它把课程表的这一行锁住直到事务提交才释放。两个并发请求同时进来时第二个会阻塞在锁上等第一个事务结束再读读到的 selected_count 就是加完之后的数字从机制上消除超员。第二是插入选课记录前其实还应该先查一次“是否已选”虽然数据库有唯一约束兜底但先查一次能让用户看到更友好的中文提示而不是看到 MySQL 的 Duplicate 异常堆栈。3.2 退课逻辑删除选课记录的同时回补名额别忘了事务退课看起来只是删除一条记录但有个必须配套的动作course 表里的 selected_count 要减一否则名额越退越少最后学生选不了课。这个“删除 更新”的组合同样需要事务。WebServlet(/dropCourse) public class DropCourseServlet extends HttpServlet { protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session req.getSession(); User user (User) session.getAttribute(loginUser); if (user null || !student.equals(session.getAttribute(role))) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } int courseId Integer.parseInt(req.getParameter(courseId)); int studentId user.getId(); Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 删除选课记录 String deleteSql DELETE FROM student_course WHERE student_id ? AND course_id ?; PreparedStatement ps conn.prepareStatement(deleteSql); ps.setInt(1, studentId); ps.setInt(2, courseId); int rows ps.executeUpdate(); if (rows 0) { conn.rollback(); req.setAttribute(msg, 你未选择这门课); req.getRequestDispatcher(/student/courseList.jsp).forward(req, resp); return; } // 回补名额 String updateSql UPDATE course SET selected_count selected_count - 1 WHERE id ? AND selected_count 0; PreparedStatement ps2 conn.prepareStatement(updateSql); ps2.setInt(1, courseId); ps2.executeUpdate(); conn.commit(); req.setAttribute(msg, 退课成功); } catch (Exception e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } req.setAttribute(msg, 退课失败 e.getMessage()); } req.getRequestDispatcher(/student/courseList.jsp).forward(req, resp); } }这里 update 语句里加了一个 WHERE selected_count 0 条件属于防御式写法。正常逻辑下退课必然发生在选课之后selected_count 不可能为 0但如果数据是从旧系统迁移来的可能存在脏数据这个条件能防止名额被减成负数。另一个值得注意的细节是 rows 0 的情况——用户可能连续点击退课按钮第一次成功第二次删不到记录如果不加这个判断第二次会把课程名额错误地减一。3.3 成绩录入教师端的状态判断录过的成绩不能覆盖教师端录入成绩相对简单但有一个业务规则容易被忽略学生已经评定过的成绩要不要允许教师覆盖我在项目里的做法是成绩为空时允许录入成绩非空时提示“该生已有成绩如需修改请联系管理员”。这样既简化了权限设计又避免教师误操作覆盖真实成绩。UPDATE student_course SET score ? WHERE student_id ? AND course_id ? AND score IS NULL关键就在最后这个 AND score IS NULL。它把“录入”和“修改”在 SQL 层面区分开。如果更新影响行数为 0就说明要么选课记录不存在要么成绩已经录过教师端给出对应的提示即可。这种做法比先查再改少一次查询代码也更干净。4. 把项目跑起来的完整路径从 JDK 和 MySQL 环境到 Tomcat 部署4.1 本地环境的最小配置清单这套系统对运行环境的要求很传统说不上高但版本之间确实有兼容性讲究。比较稳的组合是 JDK 8 或 11、Tomcat 8.5 或 9.0、MySQL 5.7 或 8.0。JDK 版本太高比如 17在 Tomcat 9 下也能跑但有些 IDE 的插件支持不好没必要给自己找麻烦。MySQL 8.0 有两个坑必须先处理掉。第一是时区问题JDBC 连接串里必须带 serverTimezoneAsia/Shanghai否则报 CST 无法识别的错第二是 MySQL 8.0 默认的认证插件是 caching_sha2_password老版本的 mysql-connector-java 驱动不认识它要么换 8.x 的驱动要么在建库时指定 mysql_native_password。一个实用的做法是驱动直接选 mysql-connector-java 5.1.49配 MySQL 5.7这个组合最稳网上能找到的资料和踩坑贴也最多。4.2 项目目录结构和 JDBC 工具类JSP Servlet 项目的目录结构比 Spring Boot 这类框架项目直观很多。WebRoot或 webapp下面放 JSP 页面和 WEB-INFWEB-INF/web.xml 是部署描述符WEB-INF/lib 放依赖 jar 包WEB-INF/classes 放编译后的 .class 和配置文件。使用 IDEA 开发时Project Structure 里 Artifacts 选择 Web Application Exploded 或 Archive然后把 lib 下的 jar 加进去这一步很多人漏掉部署后启动不报错但一访问就 ClassNotFoundException。public class DBUtil { private static final String DRIVER com.mysql.jdbc.Driver; private static final String URL jdbc:mysql://localhost:3306/course_select?useSSLfalsecharacterEncodingutf8serverTimezoneAsia/Shanghai; private static final String USERNAME root; private static final String PASSWORD 你的密码; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(MySQL驱动加载失败请检查lib目录); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USERNAME, PASSWORD); } }数据库连接参数说明useSSLfalse 是因为本地开发不需要加密连接不加这个参数 MySQL 8.0 会警告但也能连characterEncodingutf8 保证中文不乱码注意 MySQL 驱动里 utf8 实际对应 utf8mb4serverTimezone 参数只在 MySQL 8.0 下必须MySQL 5.7 加不加都行。4.3 从部署到访问Tomcat 里常见的一次性配好步骤多数情况下在 IDEA 里直接配一个 Tomcat 运行环境最省事。步骤如下Run - Edit Configurations - 添加 Tomcat Server - LocalApplication Server 指向你解压的 Tomcat 目录然后在 Deployment 页签添加 ArtifactApplication context 设为 /course这个值决定你访问时的路径。启动后浏览器输入 http://localhost:8080/course/login.jsp 就能看到登录页。如果走手动部署路线把项目打成 war 包丢到 Tomcat 的 webapps 目录下启动 Tomcat 后会自动解压。这里提醒一个坑war 包解压后如果修改了 JSP 或 Java 代码必须重新打 war 覆盖只改 webapps 下已解压的文件有时不生效因为 Tomcat 会缓存部分资源。我遇到过几次改了 JSP 刷新页面还是旧内容的情况去 Tomcat 的 work 目录清一下缓存就好了。5. 避坑手册JSP Servlet MySQL 项目最常见的五个翻车现场5.1 页面中文乱码表单数据存进数据库后变成问号多半不是 MySQL 的问题而是请求、响应、数据库三层中有一层没指定 UTF-8。在 Servlet 里做 req.setCharacterEncoding(UTF-8) 只对 POST 请求生效GET 请求的中文参数需要在 Tomcat 的 server.xml 里给 Connector 加 URIEncodingUTF-8。数据库层面确认建表用了 utf8mb4 字符集JDBC 连接串里带 characterEncodingutf8三层全对了基本不会乱码。5.2 启动报 ClassNotFoundException: com.mysql.jdbc.Driver这个错在 IDEA 部署后最常见。原因是 mysql-connector.jar 没有打包进 Artifacts 的 WEB-INF/lib 目录。检查方式Tomcat 启动后去项目的解压目录看 WEB-INF/lib 是否真的有这个 jar。IDEA 用户注意右侧的 Artifacts 面板需要把 jar 从 External Libraries 拖进 “Put into WEB-INF/lib”不是放在 External Libraries 里就算数。5.3 登录成功后跳转 404但 URL 手工输入能访问根因通常是跳转路径少了 Context Path。如果你的项目访问前缀是 /course那么 resp.sendRedirect(/student/home.jsp) 会跳到 localhost:8080/student/home.jsp少了 /course 当然 404。正确写法是 resp.sendRedirect(req.getContextPath() /student/home.jsp)。JSP 页面里的表单 action 同样要带 ${pageContext.request.contextPath} 前缀这是 JSP Servlet 项目最容易被忽略的坑没有之一。5.4 选课提示“服务器内部错误 500”日志里是 OutOfMemoryErrorTomcat 默认的堆内存不大如果同时跑着 IDEA 和 Tomcat内存容易被挤爆。设置方法在 Tomcat 的启动脚本或 IDEA 的 VM options 里加 -Xms256m -Xmx512m。这类系统本身不重512MB 完全够关键是别让 IDEA 和 Tomcat 抢同一块内存。如果日志里频繁出现 PermGen 或 Metaspace 错误加 -XX:MaxMetaspaceSize256m 也能缓解。5.5 别人的代码自己的环境跑不起来MySQL 密码规则不一致很多网上下的源码里的 SQL 脚本是为 MySQL 5.7 写的里面可能用了整库导出再导入的方式。MySQL 8.0 导入时可能因为默认认证插件问题导致连接失败。如果你用的是 MySQL 8.0执行这条 SQL 可以把 root 的认证方式改成老驱动认识的格式ALTER USER rootlocalhost IDENTIFIED WITH mysql_native_password BY 你的密码; FLUSH PRIVILEGES;命令说明第一条语句把 root 的密码规则改回 mysql_native_password解决老驱动无法认证的问题第二条让权限立即生效。如果你已经用 8.x 的驱动这一步可以跳过。6. 验证“代码绝对可以正常运行”一套不依赖文档的自检顺序拿到任何一套 JSP Servlet MySQL 课设源码我都会按固定顺序验证它是否真的能跑不轻信 README 里的“亲测可用”。第一步查 web.xml 或注解里的 Servlet 映射与 JSP 页面表单 action 是否一致这步能筛掉一半的坏源码第二步看数据库脚本能否在当前 MySQL 版本无报错执行特别注意外键约束的插入顺序必须先插教师再插课程否则外键报错第三步用管理员账号登录把所有增删改查页面点一遍重点测试选课后再次选同一门课会怎样。深入一点还要验证两个地方。一是 session 失效处理——直接访问 /admin/home.jsp 会不会报空指针异常因为 session 里没有 loginUser 对象JSP 里直接取属性会得到 null调用 getter 方法就 NPE。合格的源码应该在 JSP 页面顶部或通过 Filter 做登录校验。二是并发选课——开两个浏览器窗口同时点选课虽然本地环境并发量不大但代码里如果用了 FOR UPDATE 或先查再插的原子逻辑说明原作者确实考虑过这个问题如果完全没有哪怕是课设也建议自己加上。走完这套流程之后我一般还会把 MySQL 的 general_log 打开看一遍真实跑过的 SQL 语句确认没有隐式全表扫描或循环里查库的低效写法。这套系统做完你得到的不只是一个能交差的选课管理系统而是一套“拿到任何 JavaWeb 源码都能快速验证能不能跑”的肌肉记忆。希望这些跑通思路和调试顺序能帮你在数据库和 Servlet 之间少走一段弯路把更多时间留给真正需要改的业务逻辑上。本文还有配套的精品资源点击获取