简介:基于Java语言、Swing界面工具包以及MySQL关系型数据库构建的学生选课及成绩管理系统,是一份面向Java课程设计的完整项目源码包,主要服务正在学习图形界面编程和数据库设计的课设学生,能够有效覆盖选课、成绩管理等常见需求。功能上涵盖课程信息管理、学生信息管理、选课操作与成绩录入查询等模块,并演示了Swing组件和SQL结构化查询语句在系统中的整合方式。压缩包共53个文件、约8.61MB,包含16个Java源代码文件、8个XML配置文件、1个SQL脚本以及MySQL连接驱动,另有20张JPG和3张PNG界面截图与数据库E-R图辅助理解项目结构。包内文档完整、目录层次清晰,代码和脚本可直接导入开发环境运行,可作为课程设计报告、答辩演示与二次开发的参考资料。目前已有76人浏览/学习,对正在规划同类系统开发的读者具有较强的实际借鉴价值。
1. 基于Java+Swing+MySQL的学生选课及成绩管理系统,先想清楚它在解决什么问题
刚学完 Java 基础和 MySQL 的增删改查,Swing 勉强能拖出几个带按钮的窗体,就接到"基于Java+Swing+MySQL的学生选课及成绩管理系统"这个 Java 课程设计题目。此时最容易踩的坑是先把界面画出来,把库里排好的数据塞进去能跑就交差。真正拉开分数差距的是业务约束有没有落进代码:同一学生不能重复选同一门课、课程容量不能超、成绩区间必须被校验、选课失败时数据库不能留下半截数据。
这类系统的核心数据其实只有两类:选课关系和成绩字段。围绕这两个点展开的完整链路,包括登录鉴权、DAO 分层、事务提交与回滚,恰好是 Java 面试八股文里反复追问的内容。这套方案不依赖任何现成的课程设计源码,按一线工程师做小系统最常用的方式从头走一遍,从建表到并发验证一次讲清。适合正在赶课程设计、准备期末答辩,或者想在简历上放一个能讲明白的练手项目的读者。
2. 学生选课及成绩管理系统的表结构:用约束把规则锁进 MySQL
2.1 三张核心表:学生、课程、选课记录
学生和课程是多对多关系,所以必须有一张中间表,也就是选课记录表。很多初学者会把多个课程编号拼在一个字段里,这种"一列多值"的写法在查重、统计人数、按学期筛选时都会失控。比较稳妥的设计是拆成students、courses、enrollments三张表,enrollments只负责表达"谁选了哪门课",同时把成绩字段也放进去,score为NULL表示已选课未出成绩。
CREATE DATABASE course_db DEFAULT CHARACTER SET utf8mb4; USE course_db; CREATE TABLE students ( student_id VARCHAR(20) PRIMARY KEY COMMENT '学号,兼作登录账号', name VARCHAR(50) NOT NULL, password_hash CHAR(64) NOT NULL COMMENT 'SHA-256 十六进制串', role ENUM('student','admin') NOT NULL DEFAULT 'student', class_name VARCHAR(50) ); CREATE TABLE courses ( course_id INT AUTO_INCREMENT PRIMARY KEY, course_name VARCHAR(100) NOT NULL, credit DECIMAL(2,1) NOT NULL DEFAULT 2.0, capacity INT NOT NULL DEFAULT 30, teacher VARCHAR(20), semester VARCHAR(20) NOT NULL COMMENT '格式如 2025-2026-1', is_open TINYINT NOT NULL DEFAULT 1 COMMENT '1 开放选课,0 关闭' ); CREATE TABLE enrollments ( id INT AUTO_INCREMENT PRIMARY KEY, student_id VARCHAR(20) NOT NULL, course_id INT NOT NULL, score DECIMAL(4,1) NULL, enrolled_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_stu_course (student_id, course_id), KEY idx_course (course_id), CONSTRAINT fk_stu FOREIGN KEY (student_id) REFERENCES students(student_id), CONSTRAINT fk_course FOREIGN KEY (course_id) REFERENCES courses(course_id), CONSTRAINT chk_score CHECK (score IS NULL OR (score >= 0 AND score <= 100)) );这段建表 SQL 可以直接在 MySQL Workbench 或 Navicat 里执行,用命令行则是mysql -u root -p < schema.sql。需要注意CHAR(64)存的是 SHA-256 后的十六进制字符串,长度固定 64 位,不要用VARCHAR(32)否则摘要会被截断。uk_stu_course唯一索引让重复选课直接触发Duplicate entry报错,程序里只需捕获这个异常就能给出"你已经选过这门课"的提示。enrollments里没有冗余semester字段,学期信息在courses表上,统计时按课程 join 即可,避免同一门课出现两个学期版本时数据不一致。
2.2 成绩放哪:可空 score 与双重校验
把score放在enrollments表里是比单独建成绩表更常见的做法,因为"一次选课"和"一条成绩"天然一一对应。score允许为空恰好表达业务中间态:学生选上了课,但老师还没录入成绩。录入成绩用UPDATE ... SET score = ? WHERE id = ?,录完后这一行才变成一条完整记录。
这里有个 MySQL 版本相关的坑:CHECK (score IS NULL OR (score >= 0 AND score <= 100))在 MySQL 8.0.16 之前会被解析但不会真正强制,也就是说 5.7 环境里插入 120 分并不会报错。所以应用层必须做第二道校验,通常是在 DAO 里用if (score.compareTo(BigDecimal.ZERO) < 0 || score.compareTo(new BigDecimal("100")) > 0)提前拦截。两层校验的目的不是重复,而是防止"数据库约束失效 + 界面绕过"的组合情况。
2.3 索引用途与初始化数据
| 索引/约束 | 作用 | 说明 |
|---|---|---|
uk_stu_course | 防止同一学生重复选同一门课 | 唯一索引同时也覆盖"查询我的课程"场景 |
idx_course | 加速从课程反向查选课名单 | MySQL 会为外键自动建索引,这里显式声明让意图更清楚 |
chk_score | 限制成绩范围 | 8.0.16 前只解析不强制,应用层仍需校验 |
初始化数据用 mysql 或 Workbench 执行,注意密码字段要用SHA2函数而不是明文:
INSERT INTO students (student_id, name, password_hash, role, class_name) VALUES ('20250001', '张三', SHA2('123456', 256), 'student', '网工2401'), ('admin', '管理员', SHA2('admin123', 256), 'admin', NULL); INSERT INTO courses (course_name, credit, capacity, teacher, semester, is_open) VALUES ('Java程序设计', 3.0, 30, '王老师', '2025-2026-1', 1);SHA2('123456', 256)与后面 JDBC 代码里MessageDigest.getInstance("SHA-256")算出的摘要一致,这样无论是手工排查数据库还是程序登录,密文都能对得上。初始化时先建管理员账户而不是直接在代码里写死if (username.equals("admin")),是为了让权限判断走同一条数据库路径。
3. 界面层与数据层解耦:Swing + DAO 的最小工程结构
3.1 一个课程设计最常见的问题:业务逻辑全写进按钮事件
不少 Swing 课设把 SQL 和校验全部塞进JButton.addActionListener的匿名内部类里,一个窗口四五百行,报错时不知道是界面问题还是 SQL 问题,答辩被追问"如果换成网页前端怎么办"也答不上来。划分包是最直接的解法:entity放实体类,dao放所有 SQL 访问,view只放 JFrame 和 JPanel。
| 包名 | 职责 | 典型成员 |
|---|---|---|
entity | 与表字段对应的 Java 对象 | Student、Course、Enrollment |
dao | 封装全部 JDBC 操作,返回实体或集合 | StudentDao、CourseDao |
view | 负责组件布局和事件转发 | LoginFrame、StudentMainFrame |
3.2 实体类与 DAO 接口
实体类不需要任何框架注解,纯粹是字段和 getter/setter。DECIMAL类型在 Java 里要对应BigDecimal,用double会在成绩累加时出现精度误差:
public class Course { private int courseId; private String courseName; private BigDecimal credit; private int capacity; private String semester; private boolean open; // getter / setter 省略 }DAO 接口的意义在于,Swing 层只依赖接口方法名,不关心内部是 JDBC 还是 MyBatis。这个习惯在课程设计里属于"加分但不绕远"的部分,面试聊到 JDBC 和分层时也能顺带讲清楚:
public interface CourseDao { List<Course> findOpenCourses(String semester); int countEnrolled(int courseId); boolean enroll(String studentId, int courseId) throws SQLException; List<Enrollment> listByStudent(String studentId); }3.3 JDBC 连接与登录查询实现
MySQL 8 的驱动名是com.mysql.cj.jdbc.Driver,连接串需要带时区和字符集参数,否则插入中文会乱码或报服务器时区错误:
public class JdbcUtil { private static final String URL = "jdbc:mysql://localhost:3306/course_db" + "?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "你的密码"; public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }连接串参数里characterEncoding=utf8解决中文乱码,serverTimezone=Asia/Shanghai解决 MySQL 8 的时区告警。如果用的是免安装版 MySQL,启动服务前还要确认mysql命令行能连上3306端口,否则 JDBC 报Communications link failure时排查方向会跑偏。
登录方法用PreparedStatement的?占位符,避免字符串拼接 SQL 导致的注入问题:
public Student login(String studentId, String rawPassword) throws SQLException { String sql = "SELECT student_id, name, role, class_name FROM students " + "WHERE student_id = ? AND password_hash = SHA2(?, 256)"; try (Connection conn = JdbcUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, studentId); ps.setString(2, rawPassword); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { Student s = new Student(); s.setStudentId(rs.getString("student_id")); s.setName(rs.getString("name")); s.setRole(rs.getString("role")); s.setClassName(rs.getString("class_name")); return s; } } } return null; }这段代码把密码摘要放在 SQL 里用SHA2()计算,好处是登录和初始化数据走同一套加密规则,数据库中不需要再存原始密码。try-with-resources语法保证Connection、PreparedStatement、ResultSet在方法结束后自动关闭,避免连接没释放导致 MySQL 连接数耗尽。
4. 登录、选课、成绩录入三大核心流程的实现
4.1 登录流程与密码处理
登录按钮的事件里拿到JPasswordField的值时,用getPassword()返回的char[]而不是getText()返回的String。String是不可变对象,会留在堆内存里直到 GC,而char[]用完后可以手动覆盖。课程设计阶段不强制,但这个习惯属于 Java 基础里的高频考点。
String id = idField.getText().trim(); String password = new String(pwdField.getPassword()); Student student = dao.login(id, password); if (student == null) { JOptionPane.showMessageDialog(loginFrame, "学号或密码错误"); } else if ("admin".equals(student.getRole())) { loginFrame.dispose(); new AdminMainFrame(student).setVisible(true); } else { loginFrame.dispose(); new StudentMainFrame(student).setVisible(true); }切换窗体时先dispose()再创建新JFrame,而不是setVisible(false)藏着。藏着的窗体还占着内存,并且重新打开时状态容易混乱。trim()去掉首尾空格是为了防止用户在学号框里不小心打了空格导致查不到数据。
4.2 选课:容量校验必须放在同一个事务里
选课最核心的问题是并发:两个学生同时选只剩最后一个名额的课程,如果先查人数再插入,两个请求都会读到enrolled < capacity,最终超员。解决办法是在同一个事务里对课程行加排他锁,锁住之后其他人必须等当前事务提交才能继续。
public boolean enroll(String studentId, int courseId) throws SQLException { String lockSql = "SELECT capacity FROM courses WHERE course_id = ? AND is_open = 1 FOR UPDATE"; String countSql = "SELECT COUNT(*) FROM enrollments WHERE course_id = ?"; String insertSql = "INSERT INTO enrollments (student_id, course_id) VALUES (?, ?)"; Connection conn = null; try { conn = JdbcUtil.getConnection(); conn.setAutoCommit(false); int capacity; try (PreparedStatement ps = conn.prepareStatement(lockSql)) { ps.setInt(1, courseId); try (ResultSet rs = ps.executeQuery()) { if (!rs.next()) { throw new SQLException("课程不存在或未开放"); } capacity = rs.getInt(1); } } int enrolled; try (PreparedStatement ps = conn.prepareStatement(countSql)) { ps.setInt(1, courseId); try (ResultSet rs = ps.executeQuery()) { rs.next(); enrolled = rs.getInt(1); } } if (enrolled >= capacity) { return false; } try (PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setString(1, studentId); ps.setInt(2, courseId); ps.executeUpdate(); } conn.commit(); return true; } catch (SQLException e) { if (conn != null) { conn.rollback(); } throw e; } finally { if (conn != null) { conn.close(); } } }关键点在SELECT ... FOR UPDATE:这一行的排他锁会一直保持到commit或rollback。第二个学生执行同一句查询时会被阻塞,等第一个事务结束才能读,所以COUNT(*)拿到的总是最新人数。setAutoCommit(false)之后如果漏写commit(),关闭连接时未提交事务会被回滚,数据不会写进去,但代码里显式rollback()能让异常路径的行为更可控。事务内部不要弹出JOptionPane对话框等用户输入,锁持有时间越长,其他学生等待越久。
4.3 成绩录入:UPDATE 要多加一个防覆盖条件
成绩录入对应管理员的权限,SQL 本身不复杂,复杂的是防止重复录入覆盖已有成绩。UPDATE ... WHERE id = ? AND score IS NULL是关键,已录过成绩的记录不会再次命中:
String sql = "UPDATE enrollments SET score = ? WHERE id = ? AND score IS NULL"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setBigDecimal(1, score); ps.setLong(2, enrollmentId); int rows = ps.executeUpdate(); if (rows == 0) { JOptionPane.showMessageDialog(frame, "记录不存在或成绩已录入,无法覆盖"); } }rows == 0有两种情况:选课记录不存在,或者成绩已存在。界面提示用"记录不存在或成绩已录入"统一处理,避免向用户暴露详细的数据库状态。score传入的是BigDecimal,对应数据库中DECIMAL(4,1),界面层的JSpinner虽已限定 0 到 100,但 DAO 里仍然建议再做一次范围判断,这是因为 2.2 节提到的 MySQL 版本差异。
三个流程的 SQL 与事务特征可以这样对照:
| 流程 | 关键 SQL 写法 | 事务要求 |
|---|---|---|
| 登录 | WHERE student_id = ? AND password_hash = SHA2(?, 256) | 单条查询,不需要事务 |
| 选课 | SELECT ... FOR UPDATE+COUNT(*)+INSERT | 必须同一事务,锁课程行 |
| 成绩录入 | UPDATE ... SET score = ? WHERE id = ? AND score IS NULL | 单条更新,靠 WHERE 防覆盖 |
4.4 刷新表格用 SwingWorker 避免界面卡死
选课列表和成绩清单在启动时加载,如果直接在构造方法里执行查询,大数据量下窗口会白屏。原因是查询跑在事件分发线程(EDT)上,界面无法重绘。SwingWorker是 JDK 自带的方案:
new SwingWorker<List<Enrollment>, Void>() { @Override protected List<Enrollment> doInBackground() throws Exception { return dao.listByStudent(studentId); } @Override protected void done() { try { List<Enrollment> list = get(); DefaultTableModel model = (DefaultTableModel) table.getModel(); model.setRowCount(0); for (Enrollment e : list) { model.addRow(new Object[]{e.getCourseName(), e.getScore()}); } } catch (Exception e) { JOptionPane.showMessageDialog(frame, "加载选课记录失败"); } } }.execute();doInBackground()在后台线程执行数据库查询,done()回到 EDT 更新表格。get()如果抛异常,说明后台查询出错,此时界面给出统一提示,而不是让后台线程的异常直接打到控制台。这个模式在课程设计里用到一次,答辩时解释 EDT 概念,比单纯演示功能更有说服力。
5. 事务边界与并发验证:选课系统最容易丢分的两个细节
5.1 检查一:异常路径有没有漏掉回滚
写setAutoCommit(false)之后只考虑正常提交,忽略异常分支的rollback(),是选课系统最常见的隐患。表现为某些选课操作在出异常后,后续连接拿到一个残留的开着事务的连接,行为变得不可预测。处理方法在 4.2 的catch块里已经体现:捕获异常后先回滚再抛出,finally里保证关闭。
验证方法很简单:打开mysql命令行执行SHOW PROCESSLIST;,如果看到大量Time很大且State为Sleep的连接,多半是事务没有结束或连接没有归还。配合SHOW VARIABLES LIKE 'autocommit';确认会话默认值,能快速判断问题出在 JDBC 代码还是数据库配置。
5.2 检查二:并发选课真的会超员吗
写完代码可以开两个 mysql 终端手工模拟并发。终端 A 先锁行,终端 B 再锁,B 会进入等待状态:
-- 终端 A BEGIN; SELECT capacity FROM courses WHERE course_id = 1 FOR UPDATE; -- 终端 B BEGIN; SELECT capacity FROM courses WHERE course_id = 1 FOR UPDATE;此时 A 执行COMMIT;,B 的查询才返回。在 B 终端执行ROLLBACK;释放事务后,再重复整个流程,就能观察锁的阻塞效果:
| 时刻 | 终端 A | 终端 B |
|---|---|---|
| 初始 | 返回capacity=30 | 执行锁查询,阻塞等待 |
| A 提交 | COMMIT后事务结束 | 查询返回,读取到已提交数据 |
| 后续插入 | — | 根据最新人数决定是否允许 |
这个实验能直观验证FOR UPDATE确实让"检查人数 + 插入记录"变成原子操作。如果代码里去掉FOR UPDATE,两个终端分别BEGIN后同时执行COUNT(*),都能读到旧人数,最终结果会超员——这正是事务与普通 SELECT 的差别。
5.3 加分项:锁等待超时与最小化锁持有时间
MySQL 默认的innodb_lock_wait_timeout是 50 秒,手工验证时如果忘记提交,终端 B 会一直挂在那里。可以临时调低观察效果:
SET SESSION innodb_lock_wait_timeout = 10;改成 10 秒后,终端 B 会在大约 10 秒时返回Lock wait timeout exceeded; try restarting transaction。这个报错信息在调优并发程序时是一个明确的信号:锁被别的事务持有的时间过长。把锁等待超时调成 10s 能让问题提前暴露,排查并发选课问题时比盯着界面日志更快定位到锁冲突的位置。
本文还有配套的精品资源,点击获取