简介:学生选课系统源码包面向高校学生选课场景,完整覆盖学生选课、教师打印成绩、管理员维护成员信息三大核心业务流程,适用于Java Web课程设计、毕业设计或初学系统分析与设计的开发者。压缩包共61个文件,包含27个Java源文件、27个编译后的class文件、4个XML配置、项目结构文件及MySQL 8.0连接驱动jar包,整体大小2.16MB,源码与编译产物并存,便于直接运行与对照学习。已有79人学习下载。项目按dao、service、util、model等分层组织,体现了信息管理系统的模块化设计思路;同时融入人工智能数据分析与个性化推荐等扩展设想,可为读者提供从业务需求梳理到编码实现的完整参考,具备较高的实践借鉴价值。
1. Java 版学生选课系统:从仓库结构看三大核心功能怎么落地
一份能直接跑起来的学生选课系统源码,最怕的不是功能少,而是结构乱。这份压缩包里的student-course-main工程对应的是经典 Java 分层写法:src下拆出dao、service、model、util,根目录还有Test.java、test2.java,libs里只放了一个mysql-connector-java-8.0.18.jar。换句话说,这是一套不依赖 Spring 全家桶、用 JDBC 直连 MySQL 的轻量信息管理系统。对学生选课、教师打印成绩、管理员管理成员信息这三个场景,代码路径非常直观:model 封装实体,DAO 做查询,Service 管业务规则,最后通过控制台或 Swing 界面触发调用。它最适合两类人:一是想用 Java 把系统分析与设计完整走一遍的开发入门者,二是需要在课设或内网小工具上快速改出一套选课后台的从业者。
2. MySQL 8 数据建模:系统分析与设计阶段的角色、ER 与建表 SQL
2.1 先把三类用户的业务边界定下来
系统分析与设计的第一步,不是急着写代码,而是把使用者的诉求拆成功能点。这个系统里存在三种角色,它们对数据的操作权限完全不同。
学生关心的是“还能选什么课”和“我选了哪些课”,核心动作是浏览课程、提交选课、查看选课结果。教师关心的是“我这门课有哪些学生”以及“成绩怎么录入和打印”,核心动作是查看选课花名册、录入/修改分数、生成成绩单。管理员则拥有系统级权限,要能维护用户账号、重置密码、变更角色、停用异常账号。这三个需求最终会映射成两类模块:常规的 CRUD 功能,以及需要事务保护的选课和成绩提交功能。如果一开始就把这些边界画清楚,后面做数据库设计和代码分层时会少很多返工。
2.2 实体关系与核心表设计
从功能反推数据模型,最少需要三张基础表和一张关联表。学生、教师、管理员虽然角色不同,但都拥有账号密码等公共属性,可以把他们合并到一张sys_user表中,用role字段区分。课程信息单独放course表,包含课程编号、课程名、教师、上课时间、容量、已选人数等。学生和课程的关系放到student_course关联表,记录选课时间和成绩。
这种统一用户表的设计带来的直接好处是登录逻辑只需要写一套,管理员在维护成员信息时也不需要分别操作学生表和教师表。代价是用户表里会混入不同角色的额外属性,例如教师职称、学生班级这类字段,实践中可以把公共字段保留在sys_user,个性化字段再拆扩展表。考虑到这个项目的定位,单表加角色字段已经够用。
2.3 建表 SQL 与初始化数据
在 MySQL 8.0 环境里新建数据库后,可以按下面的脚本初始化。连接器版本是 8.0.18,所以理论上 MySQL 5.7 和 8.0 都能兼容,但推荐直接使用 MySQL 8.0 以匹配驱动版本。
CREATE DATABASE IF NOT EXISTS course_system DEFAULT CHARSET utf8mb4; USE course_system; CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(50) NOT NULL, role TINYINT NOT NULL DEFAULT 3 COMMENT '1管理员 2教师 3学生', status TINYINT NOT NULL DEFAULT 1 COMMENT '1正常 0禁用', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, course_no VARCHAR(20) NOT NULL UNIQUE, course_name VARCHAR(100) NOT NULL, teacher_id INT NOT NULL, credit DECIMAL(3,1) DEFAULT 2.0, max_students INT NOT NULL DEFAULT 50, selected_count INT NOT NULL DEFAULT 0, schedule_time VARCHAR(100) NOT NULL COMMENT '上课时间,如 周一第3-4节', location VARCHAR(100), FOREIGN KEY (teacher_id) REFERENCES sys_user(id) ); CREATE TABLE student_course ( id INT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, score DECIMAL(5,1) DEFAULT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_student_course (student_id, course_id), FOREIGN KEY (student_id) REFERENCES sys_user(id), FOREIGN KEY (course_id) REFERENCES course(id) );这里最值得注意的设计有两个。第一,sys_user通过role区分三种身份,初始化时插入管理员和教师测试账号非常方便,管理员管理成员信息时只需要 UPDATE 这一张表。第二,student_course表上加了联合唯一索引uk_student_course,这能从数据库层面挡住同一个学生重复选同一门课的情况。即使业务层没有做前置判断,重复插入也会直接抛DuplicateKeyException,相当于给选课功能加了一道保险。
2.4 初始化数据时的姿势
建完表后,至少要预置一组能跑通全流程的数据:一名管理员、一名教师、两名学生、两三门课程,再往student_course里放入一条选课记录。常见做法是直接写一个data.sql复制到 Navicat 或命令行执行,测试时再用Test.java里的 main 方法验证。
在真实项目里我一般会再加一张selection_log表记录选课日志,用来做后续的学生选课行为分析,也就是摘要里提到的智能推荐和异常检测。当前这套表结构已经具备产生分析数据的能力,只要student_course表里积累足够多的选课记录,统计每门课的选课人数、时间分布、同一学生选课的时间冲突都只需要一条 SQL 就能完成。
3. Model/DAO/Service 三层代码:JDBC 连接管理、资源释放与事务边界
3.1 为什么源码要把 model、dao、service 拆成三个包
打开压缩包里的src目录可以看到dao service model util几个包,这是 Java Web 出现之前就非常成熟的普通 Java 分层模式。model里的类对应数据库表结构,例如SysUser、Course、StudentCourse,属性名和表字段基本一一对应。dao层只负责拼 SQL、执行 SQL、把ResultSet转换成 model 对象,不处理业务规则。service层是业务逻辑的集中地,比如选课前检查容量、判断上课时间是否冲突、管理员修改成员信息前校验权限。util包则托管数据库连接工具和字符串处理等公共方法。
各层职责可以简单归纳为下面这张表:
| 层级 | 主要职责 | 依赖 | 典型操作 |
|---|---|---|---|
| model | 数据载体,字段与表对应 | 无 | 属性、getter/setter |
| dao | 数据库读写,SQL 封装 | model、util | 增删改查、ResultSet 映射 |
| service | 业务规则与事务边界 | dao、model | 校验、组合多个 DAO 调用 |
| controller/main | 接收用户输入,展示结果 | service | 控制台菜单、按钮事件 |
如果所有 JDBC 代码都堆在无限制的main或test2.java中,虽然小规模演示能跑,但一旦需要扩展功能,查改逻辑会互相干扰。尤其是教师打印成绩和管理员管理成员信息,这两个功能都要操作多张表,没有 Service 层做事务包裹,数据很容易出现半成功状态。
3.2 DbUtil:连接保存在哪里,为什么 8.0 驱动类名必须带 cj
util包里最常见的是一个DbUtil类。它负责加载 MySQL 驱动、创建 Connection、关闭资源。由于libs下依赖的是mysql-connector-java-8.0.18.jar,驱动类名必须使用com.mysql.cj.jdbc.Driver,旧版的com.mysql.jdbc.Driver在新版本中会给出过时警告,甚至在去掉兼容层时直接抛异常。
package util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DbUtil { private static final String URL = "jdbc:mysql://localhost:3306/course_system" + "?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(AutoCloseable... resources) { for (AutoCloseable res : resources) { if (res != null) { try { res.close(); } catch (Exception ignored) { } } } } }代码里serverTimezone=Asia/Shanghai是 MySQL 8.0 连接时非常关键的参数。如果缺失,驱动会把服务器时区记为 UTC,导致CURRENT_TIMESTAMP存的选课时间比本地时间慢八小时。characterEncoding=utf8保证中文校名和课程名称不会乱码,useSSL=false则用于跳过本地开发时的 SSL 握手。这套参数是 MySQL Connector/J 8.x 连接的标准配置,如果换成 MySQL 5.7 且驱动版本较低,时区参数可以不写,但 8.0 下建议保留。
3.3 UserDAO 与 CourseDAO:SQL 语句放在 DAO,业务判断放 Service
DAO 类的典型写法是每个表对应一个 DAO,内部方法尽量做到“一个方法只做一件事”。例如UserDAO负责findByUsername、insertUser、updateRole等操作,CourseDAO负责findAvailableCourses、increaseSelectedCount等。下面这段代码是UserDAO.findByUsername的一种实现方式,使用PreparedStatement防止 SQL 注入。
package dao; import model.SysUser; import util.DbUtil; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; public class UserDAO { public SysUser findByUsername(String username) { String sql = "SELECT id, username, password, real_name, role, status FROM sys_user WHERE username = ?"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { SysUser user = new SysUser(); user.setId(rs.getInt("id")); user.setUsername(rs.getString("username")); user.setPassword(rs.getString("password")); user.setRealName(rs.getString("real_name")); user.setRole(rs.getInt("role")); user.setStatus(rs.getInt("status")); return user; } } } catch (SQLException e) { e.printStackTrace(); } return null; } }注意这段代码在try-with-resources中同时管理 Connection 和 PreparedStatement,方法结束自动关闭,不需要手动调用DbUtil.close()。之前讲过DbUtil.close是为了兼容旧代码,新代码建议直接使用 try-with-resources。DAO 层不应该抛业务异常,而是把 SQL 异常转成运行时异常或向上层返回 null,由 Service 层决定是提示用户还是记录日志。
3.4 事务边界:一次选课里,插入选课记录和扣减课程名额必须同时成功
选课是这个系统里最容易出数据问题的操作。一个完整的选课动作涉及两次写操作:向student_course插入记录、把course.selected_count加一。如果没有事务包裹,插入成功但更新名额失败,就会出现“实际上没选上,但界面显示选上了”的状态。更严重的是并发场景下,两个学生同时抢同一门课,各自读到剩余名额为 1,同时执行插入,最终超过max_students。
Service 层正确做法是在一个事务里完成所有写操作,并在课程行上加锁。以 MySQL InnoDB 为例,可以使用SELECT ... FOR UPDATE锁定课程记录,直到事务提交或回滚。下面给你一个推荐写法:
public boolean selectCourse(int studentId, int courseId) { String lockSql = "SELECT max_students, selected_count FROM course WHERE id = ? FOR UPDATE"; String updateSql = "UPDATE course SET selected_count = selected_count + 1 WHERE id = ?"; String insertSql = "INSERT INTO student_course(student_id, course_id) VALUES(?, ?)"; try (Connection conn = DbUtil.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement lockPs = conn.prepareStatement(lockSql)) { lockPs.setInt(1, courseId); try (ResultSet rs = lockPs.executeQuery()) { if (!rs.next()) { conn.rollback(); return false; } int max = rs.getInt("max_students"); int selected = rs.getInt("selected_count"); if (selected >= max) { conn.rollback(); return false; } try (PreparedStatement updatePs = conn.prepareStatement(updateSql)) { updatePs.setInt(1, courseId); updatePs.executeUpdate(); } try (PreparedStatement insertPs = conn.prepareStatement(insertSql)) { insertPs.setInt(1, studentId); insertPs.setInt(2, courseId); insertPs.executeUpdate(); } conn.commit(); return true; } } catch (SQLException e) { conn.rollback(); throw new RuntimeException("选课失败", e); } } catch (SQLException e) { throw new RuntimeException("数据库连接失败", e); } }这里把setAutoCommit(false)放在 DAO 外层,实际上破坏了 DAO 的单层职责,所以更专业的做法是将这段逻辑放入SelectionService,然后调用CourseDAO和StudentCourseDAO的原子方法。不过当前资源没有使用 Spring,没有现成的@Transactional注解,只能自己控制事务,理解上面这段代码对你改造原生 JDBC 项目会有帮助。锁的粒度是单个课程行,并发选不同课程互不阻塞,并发选同一课程时第二个事务会等第一个提交或回滚后才继续判断剩余名额。这种机制能很好防止超选,前提是course.id是主键索引,否则FOR UPDATE会退化成表锁。
4. 三大核心功能实战:学生选课、教师打印成绩、管理员管理成员信息
4.1 学生选课:先查可报课程,再提交选课
学生端流程分成两步:查询可选课程和提交选课。可选课程需要同时满足两个条件:sys_user里对应学生未被禁用、course.selected_count小于max_students。查询语句可以直接写成下面这样,在 Service 层再排除掉已经选过的课程更好,因为 SQL 里的NOT EXISTS和业务判断混在一起会增加维护成本。
SELECT c.id, c.course_no, c.course_name, c.credit, c.schedule_time, c.location, c.teacher_id FROM course c WHERE c.selected_count < c.max_students AND c.status = 1 ORDER BY c.id;拿到课程列表后,学生选择一门课程,调用我们刚写过的selectCourse(studentId, courseId)即可。这里我建议在前端或控制台界面做一个“确认再次输入课程号”的交互,避免误触。提交后根据返回值提示“选课成功”或“课程已满”。
4.2 教师打印成绩:分数录入、统计与打印格式
教师端打印成绩的核心是“查出某课程的所有选课学生并组装成绩单”。如果没有单独的成绩录入页面,可以先按课程维度列出学生,再逐个更新student_course.score。查询成绩单的 SQL 如下:
SELECT u.username, u.real_name, c.course_name, sc.score, sc.select_time FROM student_course sc JOIN sys_user u ON sc.student_id = u.id JOIN course c ON sc.course_id = c.id WHERE sc.course_id = ? ORDER BY u.real_name;拿到数据后,教师端可以把结果输出成带分栏的文本文件或直接打印。常见做法是循环遍历ResultSet,每打印一行记录一条学号 姓名 分数,最后追加一行总分和平均分。如果要把数据输出到 Excel,可以参考 POI 库的SXSSFWorkbook写法,但这会引入额外依赖,当前资源里的 libs 并没有这些 jar。想做成网页打印,则可以把数据输出为 HTML 表格再用浏览器打印,这种方式不依赖额外库,还支持样式控制。
4.3 管理员管理成员信息:CRUD 与角色变更
管理员的核心操作是维护sys_user表,但并非所有操作都可以直接复用UserDAO。修改角色、禁用账号这类操作就要增加权限校验。在 Java 控制台版本中,最常见的做法是先用当前登录用户的role字段做判断,若为 1 才继续执行。下面这段代码展示了UserAdminService.changeRole的基本逻辑:
public boolean changeRole(int adminId, int targetUserId, int newRole) { SysUser admin = userDAO.findById(adminId); if (admin == null || admin.getRole() != 1) { return false; } String sql = "UPDATE sys_user SET role = ? WHERE id = ?"; try (Connection conn = DbUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, newRole); ps.setInt(2, targetUserId); return ps.executeUpdate() == 1; } catch (SQLException e) { e.printStackTrace(); return false; } }这里有一个容易被忽略的点:管理员也不应该直接修改密码字段为明文。即使课程设计阶段不要求加密,也建议至少用SHA-256做一次散列。mysql-connector-java-8.0.18.jar本身不提供加密工具,但 JDK 自带的MessageDigest就能实现,不用额外引包。
4.4 测试入口与冒烟路径
资源里的Test.java和test2.java应该是两个能直接运行的测试入口。我一般会把 Test 类设计成最小可运行程序:登录验证 → 输出菜单 → 根据输入调用 Service。例如管理员登录后,菜单分别包括查看用户列表、新增用户、修改角色、删除用户。每个功能调完都打印日志。如果你希望快速验证某个 Service 方法,可以在 main 里直接写一行调用并打印返回结果。
public class Test { public static void main(String[] args) { SelectionService service = new SelectionService(); boolean ok = service.selectCourse(2, 1); System.out.println("选课结果:" + ok); } }类似这种写法可以作为代码的“冒烟测试”,只要这条链路能通,说明连接池配置、DAO 映射、事务提交基本没有大问题。后面扩展 AI 推荐或异常检测时,也只把新的 Service 类接入同一个 main 方法验证即可。
5. MySQL 8 + Connector/J 8.0.18 环境下的并发控制与异常排查
5.1 连接参数怎么配才不容易踩坑
libs下只有mysql-connector-java-8.0.18.jar,说明项目默认走传统 JDBC 直连。很多人卡在“驱动类找不到”,其实应该检查两件事。第一,jar 是否真的被加入 IDEA 的 Project Structure 或 classpath。第二,Class.forName 中类名是否正确。8.0 系列的包名是com.mysql.cj.jdbc.Driver,不是com.mysql.jdbc.Driver。URL 写法上,推荐在数据库名后面补全以下参数:
| 参数 | 示例值 | 作用 |
|---|---|---|
| useSSL | false | 本地开发跳过 SSL 握手 |
| serverTimezone | Asia/Shanghai | 修正日期时间偏移 |
| characterEncoding | utf8 | 保证中文写入不乱码 |
| allowPublicKeyRetrieval | true | 解决 caching_sha2_password 认证报错 |
MySQL 8.0 默认认证插件是caching_sha2_password,部分旧版连接器无法使用。如果你用的就是自带的 8.0.18 驱动,绝大多数情况下是没问题的,但如果你把 jar 替换成了 5.x 版本,就必须在 URL 里增加allowPublicKeyRetrieval=true,或者把 MySQL 用户改为mysql_native_password。
5.2 用唯一索引挡住“重复选课”,用行锁挡住“超卖”
在之前建表 SQL 里,student_course表加了联合唯一索引,这能直接挡住同一学生对同一课程的重复插入。由于 Service 层有SELECT ... FOR UPDATE锁课程行,两个并发用户同一时刻选同一门课时,只有第一个事务能拿到锁并更新selected_count,第二个事务拿到锁后重新读取的就是最新值。这个组合能同时避免重复选课和超选,是原生 JDBC 下最可靠的方案。
如果你使用的是 Spring Boot,可以补一个@Transactional注解来管理事务,但这里讨论的是无框架环境,所以务必手动在selectCourse方法里把事务控制放对位置。注意FOR UPDATE必须在事务内使用,事务提交或回滚后锁会自动释放。如果代码里竟然先执行了commit再执行后续插入,锁会提前释放,超卖问题又会回来。
5.3 高频异常与处理手段
| 异常现象 | 可能原因 | 处理手段 |
|---|---|---|
| ClassNotFoundException: com.mysql.cj.jdbc.Driver | jar 未引入 classpath | 检查 IDEA Project Structure,确认 libs 目录已加入依赖 |
| Access denied for user 'root'@'localhost' | 密码错误或账号无访问权限 | 检查 DbUtil 中用户名密码;授权内网网段访问 |
| Unknown database 'course_system' | 数据库不存在或名字不一致 | 执行建表脚本前先 CREATE DATABASE |
| Connection reset / Communications link failure | 服务端主动断开或防火墙拦截 | 缩短空闲时间,使用连接池保持连接 |
| Deadlock found when trying to get lock; try restarting transaction | 多事务持锁顺序不一致 | 统一 lock 课程行的执行顺序,尽可能减少锁范围 |
在原生 DriverManager 环境下,连接并不会自动回收,每次用完不关闭最终会触发Too many connections。代码里所有Connection/Statement/ResultSet都必须放进 try-with-resources,或者使用DbUtil.close统一释放。
5.4 要不要引入连接池
当系统流量增大后,DriverManager 每次创建连接的开销会变得不可忽略。常见做法是替换成 HikariCP 或 Tomcat JDBC。资源包里没有这些依赖,但你可以把 libs 下新增的 jar 放进项目,并把 DbUtil 改成从HikariDataSource获取连接。连接池的核心参数通常包括maximumPoolSize、minimumIdle、connectionTimeout。选课系统这类信息管理系统,并发量一般几十到几百,maximumPoolSize设置到 10-20 已经足够,不需要盲目调大,连接池过大会反而增加数据库压力。
6. 把选课数据接入推荐与预警:一个可加进去的轻量规则模块
要让这套系统与“人工智能”产生实际联系,最简单可行的不是强行训练神经网络,而是基于选课历史做规则统计和异动识别。student_course表里已经存了学生、课程、选课时间,这就是高质量的行为数据。在原有service包下新增一个RecommendService,用 SQL 聚合就能产生初版推荐结果。
推荐逻辑可以从三个维度切入:热门课程、冲突时间、同专业偏好。热门课程用一条统计 SQL 就能算出,按选课次数降序取前五名。冲突时间需要比较course.schedule_time,提前判断两门课是否在同一时段。同专业偏好则要建立临时表记录学生专业,按相同专业学生的选课行为做协同过滤。下面是热门课程统计的示例:
SELECT c.id, c.course_name, COUNT(sc.course_id) AS select_cnt FROM student_course sc JOIN course c ON sc.course_id = c.id GROUP BY c.id, c.course_name ORDER BY select_cnt DESC LIMIT 5;在这条 SQL 的基础上,RecommendService可以取出结果 List,在选课成功页展示出来。不需要额外引入大数据框架,只需要在原有查询上做一次聚合,性能损耗对课设级系统完全可接受。更复杂的预测模型可以用 Java 的weka库,也可以把选课记录导出成 CSV 后用 Python 离线训练,再用 HTTP 接口为 Java 项目提供预测结果。
异常选课检测也能用相似思路实现。假设某门课在一分钟内被同一 IP 报选了 100 次,明显不是正常学生的操作习惯。在 Service 层加一个计数器,每执行一次选课就把该课程的 IP 计数加一,超过阈值时抛出告警并暂时锁定该课程。这种做法不依赖复杂 AI 框架,但很有效,同时也不会要求学生输入验证码,学习成本低。
接入后的验证方法我建议这样:先跑一遍Test.java的原有流程,确认管理员创建新用户、教师录入成绩、学生选课这三条主链路没被改坏。再单独执行推荐模块的查询,观察选课次数排序是否和真实选课行为一致。这里需要留意的是,推荐结果只能作为辅助信息展示,不能在界面上暗示学生“这门课必定热门”,否则会带偏选课决策。把推荐计算放到选课成功后的异步任务里,不污染主流程,新增的数据表继续保持与sys_user、course的主外键关联,系统整体便不会因为这个扩展模块失去稳定性。
本文还有配套的精品资源,点击获取