
简介基于Java语言、Swing界面工具包以及MySQL关系型数据库构建的学生选课及成绩管理系统是一份面向Java课程设计的完整项目源码包主要服务正在学习图形界面编程和数据库设计的课设学生能够有效覆盖选课、成绩管理等常见需求。功能上涵盖课程信息管理、学生信息管理、选课操作与成绩录入查询等模块并演示了Swing组件和SQL结构化查询语句在系统中的整合方式。压缩包共53个文件、约8.61MB包含16个Java源代码文件、8个XML配置文件、1个SQL脚本以及MySQL连接驱动另有20张JPG和3张PNG界面截图与数据库E-R图辅助理解项目结构。包内文档完整、目录层次清晰代码和脚本可直接导入开发环境运行可作为课程设计报告、答辩演示与二次开发的参考资料。目前已有76人浏览/学习对正在规划同类系统开发的读者具有较强的实际借鉴价值。1. 基于JavaSwingMySQL的学生选课及成绩管理系统先想清楚它在解决什么问题刚学完 Java 基础和 MySQL 的增删改查Swing 勉强能拖出几个带按钮的窗体就接到基于JavaSwingMySQL的学生选课及成绩管理系统这个 Java 课程设计题目。此时最容易踩的坑是先把界面画出来把库里排好的数据塞进去能跑就交差。真正拉开分数差距的是业务约束有没有落进代码同一学生不能重复选同一门课、课程容量不能超、成绩区间必须被校验、选课失败时数据库不能留下半截数据。这类系统的核心数据其实只有两类选课关系和成绩字段。围绕这两个点展开的完整链路包括登录鉴权、DAO 分层、事务提交与回滚恰好是 Java 面试八股文里反复追问的内容。这套方案不依赖任何现成的课程设计源码按一线工程师做小系统最常用的方式从头走一遍从建表到并发验证一次讲清。适合正在赶课程设计、准备期末答辩或者想在简历上放一个能讲明白的练手项目的读者。2. 学生选课及成绩管理系统的表结构用约束把规则锁进 MySQL2.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、Enrollmentdao封装全部 JDBC 操作返回实体或集合StudentDao、CourseDaoview负责组件布局和事件转发LoginFrame、StudentMainFrame3.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 { ListCourse findOpenCourses(String semester); int countEnrolled(int courseId); boolean enroll(String studentId, int courseId) throws SQLException; ListEnrollment 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 ?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PASSWORD 你的密码; public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }连接串参数里characterEncodingutf8解决中文乱码serverTimezoneAsia/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 UPDATECOUNT(*)INSERT必须同一事务锁课程行成绩录入UPDATE ... SET score ? WHERE id ? AND score IS NULL单条更新靠 WHERE 防覆盖4.4 刷新表格用 SwingWorker 避免界面卡死选课列表和成绩清单在启动时加载如果直接在构造方法里执行查询大数据量下窗口会白屏。原因是查询跑在事件分发线程EDT上界面无法重绘。SwingWorker是 JDK 自带的方案new SwingWorkerListEnrollment, Void() { Override protected ListEnrollment doInBackground() throws Exception { return dao.listByStudent(studentId); } Override protected void done() { try { ListEnrollment 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初始返回capacity30执行锁查询阻塞等待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 能让问题提前暴露排查并发选课问题时比盯着界面日志更快定位到锁冲突的位置。本文还有配套的精品资源点击获取