简介:面向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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 教师表 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' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 管理员表 CREATE TABLE admin ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 课程表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 选课关联表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段 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 连接串里必须带 serverTimezone=Asia/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-INF,WEB-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?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/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); } }数据库连接参数说明:useSSL=false 是因为本地开发不需要加密连接,不加这个参数 MySQL 8.0 会警告但也能连;characterEncoding=utf8 保证中文不乱码,注意 MySQL 驱动里 utf8 实际对应 utf8mb4;serverTimezone 参数只在 MySQL 8.0 下必须,MySQL 5.7 加不加都行。
4.3 从部署到访问:Tomcat 里常见的一次性配好步骤
多数情况下,在 IDEA 里直接配一个 Tomcat 运行环境最省事。步骤如下:Run -> Edit Configurations -> 添加 Tomcat Server -> Local,Application Server 指向你解压的 Tomcat 目录,然后在 Deployment 页签添加 Artifact,Application 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 加 URIEncoding="UTF-8"。数据库层面确认建表用了 utf8mb4 字符集,JDBC 连接串里带 characterEncoding=utf8,三层全对了基本不会乱码。
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”,日志里是 OutOfMemoryError
Tomcat 默认的堆内存不大,如果同时跑着 IDEA 和 Tomcat,内存容易被挤爆。设置方法:在 Tomcat 的启动脚本或 IDEA 的 VM options 里加 -Xms256m -Xmx512m。这类系统本身不重,512MB 完全够,关键是别让 IDEA 和 Tomcat 抢同一块内存。如果日志里频繁出现 PermGen 或 Metaspace 错误,加 -XX:MaxMetaspaceSize=256m 也能缓解。
5.5 别人的代码,自己的环境跑不起来:MySQL 密码规则不一致
很多网上下的源码里的 SQL 脚本是为 MySQL 5.7 写的,里面可能用了整库导出再导入的方式。MySQL 8.0 导入时可能因为默认认证插件问题导致连接失败。如果你用的是 MySQL 8.0,执行这条 SQL 可以把 root 的认证方式改成老驱动认识的格式:
ALTER USER 'root'@'localhost' 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 之间少走一段弯路,把更多时间留给真正需要改的业务逻辑上。
本文还有配套的精品资源,点击获取