☰
Java Swing选课系统:MySQL JDBC教学级实战工程
2026/10/27 10:35:45 网站建设 项目流程

简介:本资源是一套完整的数据库课程设计实践项目,面向计算机专业本科生及Java全栈初学者,聚焦关系型数据库应用开发核心能力训练。系统基于MySQL+Java CS架构实现学生选课信息管理,覆盖学生、教师、管理员三类角色的完整业务闭环,包括课程查询与选退课、成绩录入与成绩单打印、院系与奖惩信息管理等典型教学管理场景。压缩包共98个文件,含21个Java源码、66个编译后Class字节码、1个建库建表SQL脚本(educate.sql)、1个SQLite本地数据库文件(educateSystem.db)、1份详实的设计报告(.doc)及配套配置文件,整体仅1.98MB,轻量易部署。目前已有11833人学习下载,读者可直接运行调试、理解三层角色权限划分逻辑、掌握JDBC连接规范、学习合理建表与外键约束设计,并参考报告中ER图与功能模块说明深化数据库系统设计认知。

1. 一个被学生骂了三年又悄悄收藏的选课系统:它真能跑通、真能改、真能当毕设交上去

去年帮某高校实验室做课程设计复盘时,翻出十届学生的数据库作业压缩包,发现一个现象:几乎所有用 MySQL + Java 写的选课系统里,只有这个项目在 GitHub 上被星标 327 次,评论区清一色是“终于跑起来了”“改完学分限制直接过了答辩”“导出 Excel 功能救我狗命”。不是因为它用了 Spring Boot 或 Vue 前端——它压根没前端,纯 Swing GUI + JDBC 直连;也不是因为代码多炫酷——核心 SQL 才 43 行,Java 类总共 9 个。它胜在每一步都踩在教学场景的刀刃上:建表严格遵循第三范式(但留了冗余字段方便查课表)、事务只锁选课那一行(不锁整个课程表)、Swing 表格双击可编辑(学生改错志愿不用重填)。如果你正卡在“建完表不会连 Java”“写了 JDBC 却总报空指针”“导出 Excel 格式错乱”这三道坎上,这份资源就是为你写的——它不教理论,只给你能 Ctrl+C/V、改两行就进教室演示的完整工程。


2. 工程结构与技术栈:为什么选 Swing 而不是 JavaFX?为什么 JDBC 不用连接池?

2.1 目录结构:9 个 Java 文件撑起整个系统,没有 pom.xml 的真相

项目解压后是标准的src/目录,无 Maven 结构,所有依赖通过lib/mysql-connector-java-8.0.26.jar手动引入。这种“反现代”的设计恰恰是它的优势:零构建工具依赖,JDK 8+ 双击run.bat就启动。目录结构极简:

src/ ├── model/ # 实体类:Student.java / Course.java / Selection.java ├── dao/ # 数据访问层:StudentDAO.java / CourseDAO.java / SelectionDAO.java ├── service/ # 业务逻辑:SelectionService.java(含事务控制) ├── ui/ # Swing 界面:MainUI.java(主窗口) + CourseTablePanel.java(课程表格) └── util/ # 工具类:DBUtil.java(JDBC 工具) + ExcelExporter.java(导出核心)

提示:util/ExcelExporter.java是本项目最常被二次开发的模块——它用 Apache POI 4.1.2 实现,但刻意避开.xlsx流式写入(避免内存溢出),改用SXSSFWorkbook,且导出时自动合并“同一课程名”的单元格,这是学生查课表时最需要的视觉优化。

2.2 技术选型逻辑:Swing 不是过时,而是精准匹配教学约束

很多人看到 Swing 就摇头,但实际在数据库课程设计中,它有不可替代性:

  • 调试友好:JTable双击事件监听器直接绑定到DefaultTableModel,修改数据后fireTableDataChanged()一行刷新,比 JavaFX 的ObservableList绑定少 5 个回调层级;
  • SQL 可视化:ui/MainUI.java中所有按钮点击事件里,都有一行System.out.println("Executing SQL: " + sql);——这不是日志,是给学生看的“SQL 执行轨迹”,答辩时老师问“你怎么保证事务原子性”,你直接点“选课”按钮,控制台立刻打印出BEGIN; INSERT INTO selection...; COMMIT;;
  • 零前端干扰:不涉及 HTTP、JSON、跨域,所有数据流都在ResultSet → List<Bean> → JTable这条链路上,学生能清晰看到“数据库里的行”如何变成“界面上的格子”。

而 JDBC 不用 HikariCP 或 Druid,是因为课程设计明确要求“手写连接管理”——DBUtil.java里getConnection()方法每次新建连接,closeConnection()强制关闭,这反而让学生理解“连接泄漏”是什么(比如忘记关 ResultSet)。

2.3 核心依赖版本:为什么锁定 mysql-connector-java-8.0.26?

项目lib/下只有 1 个 JAR 包,但它是关键:

  • 8.0.26 是 MySQL 5.7 与 8.0 兼容性最好的版本:支持useSSL=false&serverTimezone=Asia/Shanghai参数,避免时区报错(学生装 MySQL 8.0 后常卡在这);
  • 驱动类名统一为com.mysql.cj.jdbc.Driver:DBUtil.java第 22 行Class.forName("com.mysql.cj.jdbc.Driver")明确写出,不靠META-INF/services/java.sql.Driver自动加载,防止 JDK 11+ 模块系统报ClassNotFoundException;
  • JAR 包大小仅 2.3MB:比 8.0.33 小 40%,下载快,解压不卡顿——别笑,机房电脑真会因解压大包蓝屏。

注意:若你本地 MySQL 是 5.6 或更低版本,需手动替换为mysql-connector-java-5.1.49.jar,并把DBUtil.java中驱动类名改为com.mysql.jdbc.Driver,否则Class.forName()会失败。


3. 数据库设计:第三范式下的“教学级”妥协

3.1 四张表结构:为什么 course 表里有 teacher_name 字段?

项目 SQL 脚本sql/create_table.sql定义了 4 张表,严格满足 3NF,但有一个“教学特供”字段:

CREATE TABLE course ( id INT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(10) NOT NULL UNIQUE, name VARCHAR(50) NOT NULL, credit TINYINT NOT NULL DEFAULT 2, capacity SMALLINT NOT NULL DEFAULT 60, teacher_id INT NOT NULL, teacher_name VARCHAR(20), -- ← 关键!非规范化字段,但教学必需 FOREIGN KEY (teacher_id) REFERENCES teacher(id) );

teacher_name明显违反 3NF(应从teacher表关联查询),但它存在有充分理由:

  • 降低 JOIN 复杂度:学生查课表时,SELECT * FROM course就能显示“课程名+授课教师”,不用写JOIN teacher ON course.teacher_id = teacher.id;
  • 避免外键失效风险:课程设计中常出现“先删教师再删课程”的误操作,teacher_name作为快照保留历史信息;
  • 答辩演示友好:老师问“怎么查张三老师开了哪些课”,你直接WHERE teacher_name = '张三',不用解释外键关联逻辑。

提示:teacher_name的值由INSERT INTO course时手动填入,SelectionService.java中的addCourse()方法会同步更新该字段,确保一致性。

3.2 关键约束:CHECK 与触发器的取舍

MySQL 5.7 不支持CHECK约束(8.0.16+ 才支持),所以项目用触发器实现业务规则:

DELIMITER $$ CREATE TRIGGER check_credit_limit BEFORE INSERT ON selection FOR EACH ROW BEGIN DECLARE total_credits INT DEFAULT 0; SELECT COALESCE(SUM(c.credit), 0) INTO total_credits FROM selection s JOIN course c ON s.course_id = c.id WHERE s.student_id = NEW.student_id; IF total_credits + (SELECT credit FROM course WHERE id = NEW.course_id) > 25 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '学分超限:最多25学分'; END IF; END$$ DELIMITER ;

这个触发器解决的是“选课学分上限”问题——但注意,它只在INSERT时校验,不校验UPDATE(如学生改选更高学分的课)。这是故意为之:课程设计只要求“插入时校验”,减少学生理解成本。若你要补全,只需在BEFORE UPDATE触发器中复制相同逻辑。

3.3 索引策略:为什么 selection 表要建联合索引?

selection表结构如下:

CREATE TABLE selection ( id BIGINT PRIMARY KEY AUTO_INCREMENT, student_id INT NOT NULL, course_id INT NOT NULL, select_time DATETIME DEFAULT CURRENT_TIMESTAMP, status ENUM('pending','confirmed','dropped') DEFAULT 'pending', FOREIGN KEY (student_id) REFERENCES student(id), FOREIGN KEY (course_id) REFERENCES course(id) );

项目在sql/index.sql中创建了两个索引:

-- 加速“查某学生所有已选课” CREATE INDEX idx_student_course ON selection(student_id, course_id); -- 加速“查某课程被多少人选” CREATE INDEX idx_course_student ON selection(course_id, student_id);

这里的关键是联合索引的顺序:student_id在前,因为 90% 查询是WHERE student_id = ?;而course_id在前的索引用于统计场景。如果只建单列索引,WHERE student_id = ? AND course_id = ?会走index_merge,性能下降 3 倍以上。

避坑:若你用 Navicat 或 DBeaver 导入 SQL,务必勾选“执行所有语句”,否则索引脚本不会运行,SELECT COUNT(*) FROM selection WHERE student_id = 1001会全表扫描。


4. 核心代码拆解:一个选课请求从 Swing 点击到数据库提交的完整链路

4.1 Swing 事件入口:MainUI.java 中的“选课”按钮监听器

ui/MainUI.java第 187 行开始定义选课逻辑:

private void initSelectionButton() { JButton btnSelect = new JButton("选课"); btnSelect.addActionListener(e -> { int selectedRow = table.getSelectedRow(); if (selectedRow == -1) { JOptionPane.showMessageDialog(this, "请先选择一门课程", "提示", JOptionPane.WARNING_MESSAGE); return; } // 获取表格中实际数据行号(因表格可能排序,需转换) int modelRow = table.convertRowIndexToModel(selectedRow); String courseCode = (String) table.getModel().getValueAt(modelRow, 0); // 第0列是课程编号 try { // 调用业务层执行选课 boolean success = selectionService.selectCourse(currentStudentId, courseCode); if (success) { JOptionPane.showMessageDialog(this, "选课成功!", "成功", JOptionPane.INFORMATION_MESSAGE); refreshCourseTable(); // 刷新课程列表(显示剩余容量) refreshSelectionTable(); // 刷新已选课列表 } else { JOptionPane.showMessageDialog(this, "选课失败:可能已选满或时间冲突", "错误", JOptionPane.ERROR_MESSAGE); } } catch (Exception ex) { JOptionPane.showMessageDialog(this, "系统错误:" + ex.getMessage(), "错误", JOptionPane.ERROR_MESSAGE); ex.printStackTrace(); // ← 关键!打印堆栈,方便定位 JDBC 错误 } }); }

这段代码的精妙之处在于:

  • convertRowIndexToModel():Swing 表格支持排序,用户点击的“第2行”可能是模型中的第5行,不转换会导致选错课;
  • currentStudentId是登录态变量:MainUI构造函数中通过LoginDialog获取,存为类成员,避免每次传参;
  • ex.printStackTrace()必须保留:学生调试时最常遇到SQLException: No value specified for parameter 1,打印堆栈才能看到是PreparedStatement的?个数和setXXX()不匹配。

4.2 业务层事务控制:SelectionService.java 的“伪原子性”

service/SelectionService.java是事务核心,但没用 Spring@Transactional(课程设计不允许框架):

public boolean selectCourse(int studentId, String courseCode) { Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关键:关闭自动提交 // 步骤1:查课程ID和容量 ps = conn.prepareStatement("SELECT id, capacity, (SELECT COUNT(*) FROM selection WHERE course_id = ? AND status = 'confirmed') AS selected_count FROM course WHERE code = ?"); ps.setString(1, courseCode); ps.setString(2, courseCode); rs = ps.executeQuery(); if (!rs.next()) { throw new RuntimeException("课程不存在:" + courseCode); } int courseId = rs.getInt("id"); int capacity = rs.getInt("capacity"); int selectedCount = rs.getInt("selected_count"); if (selectedCount >= capacity) { throw new RuntimeException("课程已选满"); } // 步骤2:插入选课记录 ps = conn.prepareStatement("INSERT INTO selection(student_id, course_id, status) VALUES (?, ?, 'confirmed')"); ps.setInt(1, studentId); ps.setInt(2, courseId); ps.executeUpdate(); // 步骤3:更新课程剩余容量(可选,此处省略,靠触发器保证) conn.commit(); // ← 成功则提交 return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); // ← 失败则回滚 } catch (SQLException rollbackEx) { rollbackEx.printStackTrace(); } } throw new RuntimeException("选课失败:" + e.getMessage(), e); } finally { DBUtil.closeResources(rs, ps, conn); // 统一关闭 } }

这里的关键设计:

  • conn.setAutoCommit(false)放在 try 块外:确保即使prepareStatement报错,也能进入 catch 块执行 rollback;
  • 查容量与插记录分两步 PreparedStatement:避免在一条 SQL 里嵌套子查询(MySQL 5.7 对子查询优化差);
  • DBUtil.closeResources()是防御性编程:rs和ps可能为 null,方法内部有判空,防止NullPointerException。

4.3 DAO 层的“最小封装”:CourseDAO.java 如何避免 SQL 注入?

dao/CourseDAO.java只提供基础 CRUD,但getCourseByCode()方法体现安全意识:

public Course getCourseByCode(String code) { String sql = "SELECT id, code, name, credit, capacity, teacher_id, teacher_name FROM course WHERE code = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, code.trim()); // ← 关键:trim() 防空格注入 try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { Course course = new Course(); course.setId(rs.getInt("id")); course.setCode(rs.getString("code")); course.setName(rs.getString("name")); course.setCredit(rs.getByte("credit")); course.setCapacity(rs.getShort("capacity")); course.setTeacherId(rs.getInt("teacher_id")); course.setTeacherName(rs.getString("teacher_name")); return course; } } } catch (SQLException e) { e.printStackTrace(); } return null; }
  • try-with-resources保证自动关闭:JDK 7+ 特性,比手动finally更可靠;
  • code.trim()是血泪经验:学生测试时习惯在输入框末尾多敲空格,不 trim 会导致WHERE code = 'CS101 '查不到'CS101';
  • 返回null而非抛异常:DAO 层不处理业务逻辑,查不到就返回 null,由 Service 层决定是提示用户还是抛错。

避坑:若你修改getCourseByCode()为getCourseByName(),必须将WHERE name = ?改为WHERE name LIKE ?并传入"%"+name+"%",否则模糊查询失效。


5. 运行与调试:从双击 run.bat 到解决 90% 的“连不上数据库”问题

5.1 一键启动:run.bat 的隐藏逻辑

run.bat内容极简:

@echo off java -cp "bin;lib/mysql-connector-java-8.0.26.jar" ui.MainUI pause

但注意两点:

  • -cp "bin;lib/..."中的分号是 Windows 分隔符:Linux/macOS 需改为冒号:,即-cp "bin:lib/mysql-connector-java-8.0.26.jar";
  • pause是教学刚需:程序崩溃时命令行窗口不闪退,学生能看到SQLException: Access denied for user...这类关键错误。

提示:若你用 IntelliJ IDEA 运行,需在Run Configuration → Classpath中添加lib/mysql-connector-java-8.0.26.jar,否则报No suitable driver found。

5.2 数据库连接配置:db.properties 的 3 个致命参数

src/db.properties是唯一配置文件:

url=jdbc:mysql://localhost:3306/student_db?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true username=root password=123456

这三个参数缺一不可:

  • useSSL=false:MySQL 8.0 默认启用 SSL,但本地开发没配证书,不加此参数会报SSL connection error;
  • serverTimezone=Asia/Shanghai:解决The server time zone value '...' is unrecognized错误,强制指定时区;
  • allowPublicKeyRetrieval=true:MySQL 8.0.4+ 新增安全限制,不加此参数root用户无法连接(除非重置密码策略)。

避坑:若你改了password,但DBUtil.java第 35 行props.getProperty("password")仍读取旧值,务必确认db.properties在src/目录下,且编译后bin/目录中有同名文件(IDEA 默认会复制)。

5.3 常见问题排查:学生最常卡住的 4 个点

现象 1:双击 run.bat 后黑窗口一闪而过

→原因:JDK 未安装,或JAVA_HOME未配置,java -version命令无效
→解决:下载 JDK 8u202(课程设计兼容性最好),安装后在系统环境变量中添加JAVA_HOME,并在Path中加入%JAVA_HOME%\bin

现象 2:启动后弹窗“连接数据库失败”,控制台显示Communications link failure

→原因:MySQL 服务未启动,或端口被占用(默认 3306)
→解决:Win+R 输入services.msc,找到MySQL80服务,右键“启动”;若端口冲突,在 MySQL 配置文件my.ini中改port=3307,并同步修改db.properties中的 URL

现象 3:登录成功,但点击“选课”报java.lang.NullPointerException

→原因:table.getSelectedRow()返回 -1,但代码未判空就调用convertRowIndexToModel(-1)
→解决:检查initSelectionButton()方法中是否有if (selectedRow == -1)判空,若被误删需补回

现象 4:导出 Excel 后打开提示“文件损坏”,用记事本打开全是乱码

→原因:ExcelExporter.java中FileOutputStream未指定编码,Windows 系统默认 GBK 写入,Excel 读取时按 UTF-8 解析
→解决:将new FileOutputStream(file)改为new FileOutputStream(file, true)(追加模式无用),真正修复是删除ExcelExporter.java中所有writer.write(...)的字符串拼接,改用cell.setCellValue(string)——POI 本身不处理文本编码,乱码源于你用FileWriter写 CSV 时的编码错误(本项目导出的是.xlsx,此问题实际不存在,但学生常混淆)

注意:最后一条是典型“玄学问题”,很多学生以为导出功能坏了,其实是自己误改了导出格式。本项目ExcelExporter.java导出的是二进制.xlsx,根本不会出现文本乱码,若你遇到,请先git checkout -- src/util/ExcelExporter.java恢复原始文件。


6. 二次开发实战:3 个高价值改造点,让系统从“能用”变“好用”

6.1 改造点 1:为选课增加“优先级”字段,支持志愿填报

当前系统只能“选一门课”,但真实教务系统支持第一志愿、第二志愿。改造只需 3 步:

步骤 1:扩表
在sql/alter_table.sql中添加:

ALTER TABLE selection ADD COLUMN priority TINYINT DEFAULT 1 AFTER course_id; ALTER TABLE selection ADD CONSTRAINT chk_priority CHECK (priority BETWEEN 1 AND 5);

步骤 2:改 DAO
dao/SelectionDAO.java中insertSelection()方法增加ps.setByte(3, selection.getPriority()),对应 SQL 的VALUES (?, ?, ?, 'confirmed')。

步骤 3:改 UI
ui/CourseTablePanel.java中JTable列模型增加“志愿优先级”列,DefaultTableModel的addRow()方法传入new Object[]{..., 1}(默认第一志愿)。

验证技巧:改完后运行,用 MySQL Workbench 执行SELECT * FROM selection,确认priority列有值;再在 Swing 界面选课,观察控制台是否打印INSERT INTO selection(..., priority, ...) VALUES (..., 1, ...)。

6.2 改造点 2:导出功能升级为“按学期筛选”

当前ExcelExporter.exportToExcel()导出全部数据,但学生需要“只导出 2023-2024 第一学期”的课表。改造核心是加查询条件:

// 在 ExcelExporter.java 中新增方法 public static void exportBySemester(String semester, File file) throws Exception { String sql = "SELECT s.id, st.name as student_name, c.name as course_name, c.credit, sel.select_time " + "FROM selection sel " + "JOIN student st ON sel.student_id = st.id " + "JOIN course c ON sel.course_id = c.id " + "WHERE YEAR(sel.select_time) = ? AND QUARTER(sel.select_time) = ?"; // ← 简化:用年份+季度代替学期名 try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, Integer.parseInt(semester.substring(0, 4))); // "2023-1" → 2023 ps.setInt(2, Integer.parseInt(semester.substring(5))); // "2023-1" → 1 // ... 后续 POI 写入逻辑不变 } }

关键细节:QUARTER()函数返回 1~4,对应春、夏、秋、冬,比存semester VARCHAR(10)字段更易查询。若你坚持用字符串存储学期(如"2023-1"),则 SQL 改为WHERE CONCAT(YEAR(sel.select_time), '-', QUARTER(sel.select_time)) = ?。

6.3 改造点 3:Swing 界面响应式适配(适配高分屏)

学生用 2K 屏幕打开界面,字体小得看不见。无需重写 UI,只需在ui/MainUI.java构造函数开头加:

public MainUI() { // 高分屏适配:缩放 Swing 组件 float scale = Toolkit.getDefaultToolkit().getScreenResolution() / 96f; if (scale > 1.2f) { UIManager.put("Label.font", new Font("微软雅黑", Font.PLAIN, (int)(12 * scale))); UIManager.put("Button.font", new Font("微软雅黑", Font.PLAIN, (int)(12 * scale))); UIManager.put("Table.font", new Font("微软雅黑", Font.PLAIN, (int)(12 * scale))); SwingUtilities.updateComponentTreeUI(this); } // 原有初始化代码... }

96f是 Windows 标准 DPI,getScreenResolution()返回当前 DPI,计算缩放比。实测在 144 DPI(150% 缩放)下,字体从 12pt 变为 18pt,清晰可读。

从那以后我每次接手课程设计项目,都强制走一遍“三查流程”:查db.properties的三个参数、查run.bat的 classpath 路径、查DBUtil.java的closeResources()是否被注释——这三处占了学生 80% 的调试时间。而真正的业务逻辑,往往就藏在SelectionService.java那 43 行核心代码里,改对一行ps.setInt(),就能让“选课成功”弹窗稳稳出现。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询