Java Swing+MySQL学生宿舍管理系统源码与JDBC三层课设实践
简介面向高校计算机专业课程设计与Java桌面应用入门者这套MySQLJavaSwing学生宿舍管理系统提供了可直接参考的完整项目涵盖管理员管理、学生管理、宿舍信息维护等典型业务帮助解决课程设计选题、数据库设计与桌面界面实现缺少范例的问题。压缩包共334个文件约112.72MB以class和java源码为主体辅以png界面截图、xml配置、db数据库文件、jar依赖及图表文档目录结构便于按模块查阅既能运行调试也可对照修改。已有268人学习下载。读者可获得Swing桌面端界面布局、MySQL表结构与数据访问逻辑、学生与宿舍管理模块的增删改查示例以及配套图表和课程设计文档适合作为选题原型、答辩演示和二次开发基础。1. 学生宿舍管理系统这套 MySQL Java Swing 源码适合谁拿去交课设每年期末前两周宿舍里总有人抱着笔记本问同一句话有没有一套 MySQL Java Swing 的学生宿舍管理系统能跑起来、能讲清楚、还能改两下。这套源码就是照着这个场景整理的——客户端用 Swing 画界面数据落在 MySQL中间用 JDBC 直连附带建库脚本、ER 图、流程图和一份按章节写的设计文档。它不追高并发也没上微服务定位很清楚课程设计交付外加第一次完整走通「界面—业务—数据库」三层的新手练手。适合两类人一类是两三周内要交出一份能答辩的课设另一类是想弄明白桌面端 CRUD 到底怎么串起来、又不想被框架配置淹没的开发者。如果你的目标是学 Spring 全家桶这套东西不是好的起点。2. 技术选型与工程结构Swing 客户端 JDBC 直连的三层怎么落地先把技术选型讲明白不然后面看目录会一头雾水。整套系统就是三个东西Swing 负责画窗口JDBC 负责跟数据库说话MySQL 负责存数据。没有 Web 容器没有 ORM没有依赖注入双击 jar 就能演示。2.1 为什么选 Swing 直连 JDBC而不是套一层 Spring Boot课设的评分点通常集中在三处功能是否完整、数据库设计是否合理、代码有没有分层。Swing 的优势是零部署成本评委坐在你旁边你双击一个 jar 就能登录进去点增删改查不用等 Tomcat 起来不用配浏览器地址。JDBC 直连的好处是 SQL 裸露在代码里答辩时能直接指着PreparedStatement讲“这条语句做了防注入”比讲 MyBatis 的映射文件直观得多。代价也得说清楚。没有连接池每次操作都要自己getConnection()、自己close()没有声明式事务setAutoCommit(false)、commit()、rollback()全手写界面和业务逻辑容易糊在一起新手写着写着就把 SQL 塞进按钮的actionPerformed里了。这套源码的目录结构就是为了对抗这种趋势而设计的强迫你把 SQL 关进 DAO 包。如果你的课设明确要求“必须用 SSM/Spring Boot”那这套只能当数据库设计和界面参考业务层得自己重写。如果要求是“Java 桌面应用 数据库”它就是标准答案的形态。2.2 目录结构从入口类到 DAO 的文件职责拿到压缩包解压后先别急着打开 IDE用文件管理器把树看一遍心里有个地图后面改代码才不会迷路。dorm-system/ ├── src/ │ ├── com/dorm/Main.java 程序入口启动登录窗口 │ ├── com/dorm/view/ 所有窗体一个业务面板一个类 │ │ ├── LoginFrame.java 登录页 │ │ ├── MainFrame.java 主框架左侧菜单 右侧 CardLayout │ │ ├── StudentPanel.java 学生信息管理 │ │ ├── DormPanel.java 楼栋 / 宿舍 / 床位管理 │ │ └── RepairPanel.java 报修单管理 │ ├── com/dorm/entity/ 实体类字段和表字段一一对应 │ ├── com/dorm/dao/ 数据访问层所有 SQL 只出现在这里 │ ├── com/dorm/util/ DBUtil、MD5Util、DateUtil │ └── com/dorm/common/ Constants、Session保存当前登录用户 ├── lib/ mysql-connector-java 驱动 jar ├── sql/dorm_db.sql 建库建表 初始账号数据 ├── doc/ 需求说明、数据库设计、ER 图、流程图、类图 └── README.md 运行步骤调用方向是单向的view调daodao返回entityentity只在内存里流转。view里不允许出现SELECT、UPDATE这类关键字dao里不允许出现JOptionPane。这条规矩听起来简单但它决定了你后面能不能只改一处就把功能换掉。common/Session是个容易忽略的小类本质就是一个静态字段保存登录用户的名字和角色public class Session { private static SysUser current; // 全局唯一的当前登录用户 public static SysUser getCurrent() { return current; } public static void setCurrent(SysUser u) { current u; } public static boolean isAdmin() { return current ! null ADMIN.equals(current.getRole()); } public static void clear() { current null; } // 退出登录时清空 }这个类的存在解决了“主界面要知道是谁登录的、是不是管理员”的问题。没有它你就得在LoginFrame→MainFrame→ 每个Panel的构造函数里一路传SysUser参数改一个构造器要动五六个文件。用静态 Session 有耦合但在单用户桌面程序里完全够用也是最省事的选择。2.3 数据库设计六张表撑起整个业务宿舍管理系统的表设计有个经典坑把床位写成bed1、bed2、bed3、bed4四个字段。这样写查询“还有哪些空床”要写一堆OR bed1 IS NULL扩展成六人间就得改表结构。正确做法是床位单独成表一行代表一个床位。表名作用关键字段关联关系sys_user登录账号username、password、role独立building宿舍楼id、name、floors1 对多 dormdorm宿舍房间id、building_id、room_no、capacity1 对多 bedbed床位id、dorm_id、bed_no、student_id与 student 一对一student学生id、sno、name、bed_id、status1 对 1 bedrepair报修单id、dorm_id、content、status、create_time多对 1 dorm建表脚本里几个细节值得单独抠出来看CREATE TABLE bed ( id INT PRIMARY KEY AUTO_INCREMENT, dorm_id INT NOT NULL COMMENT 所属宿舍, bed_no INT NOT NULL COMMENT 床位序号1~N, student_id INT DEFAULT NULL COMMENT 为空表示空闲, UNIQUE KEY uk_dorm_bed (dorm_id, bed_no), -- 同一宿舍内床位号不重复 CONSTRAINT fk_bed_dorm FOREIGN KEY (dorm_id) REFERENCES dorm(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT床位表;UNIQUE KEY uk_dorm_bed这一行是防止你在界面上连点两次“新增床位”结果同一间宿舍出现两个 1 号床。数据库层面的约束比在 Java 里写if判断可靠得多因为 Java 层可能被绕过约束不会。student_id DEFAULT NULL而不是给个 0是为了让“空床位”这个语义直接用IS NULL表达查询空闲床位就是一句SELECT * FROM bed WHERE dorm_id? AND student_id IS NULL干净利落。ENGINEInnoDB必须写。MySQL 5.5 之后默认就是 InnoDB但显式写出来有两个好处一是让你在文档里能讲清楚为什么需要事务MyISAM 不支持二是换到某些老环境时不吃亏。2.4 附带图表和文档的使用姿势doc/目录里的东西不是装饰。ER 图用来答辩时讲表关系流程图讲“从登录到分配床位”的完整链路类图讲分层需求说明里的用例图讲角色划分管理员、宿管、学生。这三张图基本覆盖了课设文档里最容易被扣分的部分。有个血泪经验文档里的表结构描述必须和sql/dorm_db.sql完全一致。我见过不止一次写到文档第三章时顺手给student加了个email字段代码里也加了唯独忘了回头改文档答辩老师翻到数据库设计那一页问“你这个字段代码里怎么没有”当场卡住。改表的顺序永远是先改.sql再导库再改实体最后回填文档。顺序反了就要返工。3. 环境搭建与首次跑通从建库到登录界面的完整链路这一章是按“拿到压缩包到看见主界面”的顺序走的中间任何一步失败都别硬往下推后面的报错会是前面问题的连锁反应。3.1 版本清单与依赖确认常见搭配是 JDK 8 MySQL 5.7 或 8.0 mysql-connector-java 8.0.x。JDK 8 是这类课设的事实标准用 11 或 17 也能跑但部分老写法比如某些sun.*包的引用可能报警告。驱动版本必须和 MySQL 主版本对得上MySQL 8.0 用 8.0.x 的驱动驱动类名是com.mysql.cj.jdbc.DriverMySQL 5.7 用 5.1.x 的驱动类名是com.mysql.jdbc.Driver。这两个类名搞混启动直接ClassNotFoundException。先确认三件事一条命令一个结果别凭感觉java -version # 确认是 1.8.x mysql -u root -p -e SELECT VERSION(); # 确认数据库能连上 ls lib/ # 确认驱动 jar 在且只有一个版本lib/下如果同时躺着 5.1.x 和 8.0.x 两个 jarDriverManager可能加载到错的那个报的错还很隐晦。删掉不用的那个只留一个。IDE 方面有个细节要注意如果view/目录下存在.form文件说明界面是 NetBeans 的 GUI Builder 生成的用 NetBeans 打开能可视化拖拽用 IDEA 或 Eclipse 打开.java文件照样能编译运行但改界面得手写布局代码。不改界面的话用哪个 IDE 都行。3.2 建库建表与初始数据sql/dorm_db.sql一般包含三部分CREATE DATABASE、所有CREATE TABLE、以及几条初始数据管理员账号、几栋楼、若干宿舍。用命令行导入比在客户端工具里逐条执行靠谱# 一条命令完成建库 建表 初始数据 mysql -u root -p --default-character-setutf8mb4 sql/dorm_db.sql导完立刻验证不要等到启动程序才发现表是空的USE dorm_db; SHOW TABLES; -- 应该看到 6 张表 SELECT * FROM sys_user; -- 应该有一条 admin 记录 SELECT COUNT(*) FROM bed; -- 应该和宿舍容量对得上初始管理员密码在脚本里通常是明文或者 MD5 值。如果是 MD5登录时程序会把你输入的密码做一次 MD5 再比对所以你不能直接在数据库里把密码改成明文123456得改成e10adc3949ba59abbe56e057f20f883e。这一点在第一次调试登录时最容易翻车。3.3 改配置再启动只需要动一个文件数据库连接参数集中在src/jdbc.properties有些版本放在src/com/dorm/util/下改完不用重新编译重新运行即可# 驱动类名必须和 MySQL 主版本匹配 jdbc.drivercom.mysql.cj.jdbc.Driver # characterEncoding 和 serverTimezone 两个参数漏一个都可能出问题 jdbc.urljdbc:mysql://127.0.0.1:3306/dorm_db?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue jdbc.usernameroot jdbc.password你的密码URL 里四个参数各管一件事缺一个就有一类报错。useUnicodecharacterEncoding管中文不加的话查出来的姓名是问号serverTimezone管时区MySQL 8.0 不加会抛The server time zone value is unrecognizeduseSSLfalse关掉握手本地开发不需要allowPublicKeyRetrievaltrue是 8.0 驱动用默认加密方式连接时必须加的否则报Public Key Retrieval is not allowed。启动入口是com.dorm.Main它的main方法通常只有几行public static void main(String[] args) { // Swing 的界面创建必须放在事件分发线程里 SwingUtilities.invokeLater(() - { try { UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()); } catch (Exception ignored) { // 换肤失败不影响功能用默认风格继续 } new LoginFrame().setVisible(true); }); }SwingUtilities.invokeLater这一层不能省。Swing 不是线程安全的所有组件创建和修改都必须在事件分发线程EDT里做。新手常见做法是直接在main里new LoginFrame().setVisible(true)大部分时候看不出问题但偶尔会出现界面白屏、按钮点了没反应排查起来毫无头绪。3.4 登录之后的验证路径能看见登录框只是第一步登录进去才是真正的开始。按下面顺序点一遍五个功能都通说明环境和代码都没问题用 admin 登录看主界面左侧菜单是否正常渲染右侧是否默认显示学生列表。学生管理里新增一个学生学号随便编一个没重复的保存后列表要立刻刷新出来。宿舍管理里选一栋楼进房间找到空床位把这个学生分进去。回到学生列表确认该学生的“宿舍号”和“床位号”字段有值。报修管理里提交一条报修状态是“待处理”再用管理员身份改成“已完成”。这五步跑完数据链路界面 → DAO → MySQL → 回显就验证完了。任何一步数据没落库或者没回显问题一定在对应的 DAO 方法里范围很小打开那个类看 SQL 就行。4. 核心模块拆解连接、鉴权、分配、查询四条关键链路挑四个真正决定这套系统能不能用的模块来拆。这四个吃透了剩下的报修、公告、统计都是同一套模式的复制粘贴。4.1 数据库连接工具类一个 DBUtil 撑起全局没有连接池的情况下DBUtil的责任就两个读配置、给连接。静态代码块保证配置只加载一次public final class DBUtil { private static String url, user, password; static { // 用类加载器读 classpath 下的配置打包成 jar 后照样能找到 try (InputStream in DBUtil.class.getClassLoader() .getResourceAsStream(jdbc.properties)) { if (in null) throw new IllegalStateException(找不到 jdbc.properties); Properties p new Properties(); p.load(in); url p.getProperty(jdbc.url); user p.getProperty(jdbc.username); password p.getProperty(jdbc.password); Class.forName(p.getProperty(jdbc.driver)); // 注册驱动只需一次 } catch (Exception e) { // 静态块里抛受检异常会被吞掉包成 Error 才能让启动直接失败 throw new ExceptionInInitializerError(数据库配置加载失败: e.getMessage()); } } private DBUtil() {} // 工具类禁止实例化 public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, user, password); } /** 统一关闭顺序必须是 结果集 - 语句 - 连接 */ public static void close(ResultSet rs, Statement st, Connection conn) { try { if (rs ! null) rs.close(); } catch (SQLException ignored) {} try { if (st ! null) st.close(); } catch (SQLException ignored) {} try { if (conn ! null) conn.close(); } catch (SQLException ignored) {} } }用getResourceAsStream而不是new FileInputStream(src/jdbc.properties)是关键决策。后者在 IDE 里跑得好好的一旦打成 jar 就报文件不存在因为src目录根本不在 jar 里。前者从 classpath 找两种运行方式都成立。ExceptionInInitializerError的包装也别省。静态块里如果直接e.printStackTrace()然后什么都不做url就是 null程序会跑起来但每次连接都失败报错信息指向DriverManager很容易把人带偏。让它启动即崩反而更快定位。连接关闭的顺序不能反先关ResultSet再关Statement最后关Connection。反过来的话某些驱动实现会在关闭连接时抛异常虽然不影响功能但日志会很脏。close方法写三个重载会更顺手比如只想关连接和语句时传两个参数调用处不用写close(null, st, conn)。这种小工具类写法看着琐碎但每个 DAO 方法都要调一次省下的重复代码很可观。4.2 登录与角色分流一条 SQL 决定你看到几个菜单登录的逻辑不复杂难在处理好密码怎么存和登录后跳哪这两件事private void doLogin() { String username txtUser.getText().trim(); String rawPwd new String(txtPwd.getPassword()); if (username.isEmpty() || rawPwd.isEmpty()) { lblTip.setText(账号和密码不能为空); return; } // 参数化查询杜绝 or 11 这类拼接注入 String sql SELECT id, username, role FROM sys_user WHERE username ? AND password ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, MD5Util.md5(rawPwd)); // 库里存的是 MD5 try (ResultSet rs ps.executeQuery()) { if (rs.next()) { SysUser u new SysUser(rs.getInt(id), rs.getString(username), rs.getString(role)); Session.setCurrent(u); // 存入全局会话 dispose(); // 关掉登录窗口 new MainFrame().setVisible(true); // 打开主界面 } else { lblTip.setText(账号或密码错误); } } } catch (SQLException e) { lblTip.setText(数据库连接异常请检查配置); } }注意这里用的是try-with-resourcesConnection和PreparedStatement会自动关闭比finally里手写三重close干净。JDK 7 以后都支持没必要为了兼容性退回老写法。角色分流不在登录里做放到MainFrame的构造里根据Session.isAdmin()决定菜单可见性public MainFrame() { initComponents(); if (!Session.isAdmin()) { menuUser.setVisible(false); // 普通宿管看不到账号管理 menuBuilding.setVisible(false); } lblUser.setText(当前用户 Session.getCurrent().getUsername()); }setVisible(false)只是把菜单藏起来如果用户手动调用对应方法还是能进来。课设场景下这够了但如果你想让权限更硬一点得在dao层再拦一次或者干脆在MainFrame里不创建那些 Panel 实例。这一点在答辩时如果被问到权限能不能绕过能答上来是加分项。4.3 宿舍分配两张表的同时更新必须放进事务这是整套系统里唯一真正需要事务的地方。分配一个床位要改两张表bed.student_id填上学生 IDstudent.bed_id填上床位 ID。两条UPDATE中间如果程序崩了或者数据库断了就会出现床位显示被占但学生没有宿舍的脏数据之后怎么查都对不上。public boolean assignBed(int studentId, int bedId) throws SQLException { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 第一步抢占床位。WHERE 里带 student_id IS NULL 是乐观锁 // 如果别人已经占了executeUpdate 返回 0直接回滚 String sql1 UPDATE bed SET student_id ? WHERE id ? AND student_id IS NULL; try (PreparedStatement ps conn.prepareStatement(sql1)) { ps.setInt(1, studentId); ps.setInt(2, bedId); if (ps.executeUpdate() ! 1) { conn.rollback(); return false; // 床位已被占用让界面提示用户 } } // 第二步回写学生。同样加 bed_id IS NULL防止一个学生占两张床 String sql2 UPDATE student SET bed_id ? WHERE id ? AND bed_id IS NULL; try (PreparedStatement ps conn.prepareStatement(sql2)) { ps.setInt(1, bedId); ps.setInt(2, studentId); if (ps.executeUpdate() ! 1) { conn.rollback(); return false; // 该学生已有床位 } } conn.commit(); // 两条都成功才提交 return true; } catch (SQLException e) { if (conn ! null) conn.rollback(); // 任何异常都回滚不留脏数据 throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); // 还原避免连接被复用后行为异常 conn.close(); } } }WHERE ... AND student_id IS NULL这个写法值得多说两句。它不是简单的条件过滤而是一种乐观并发控制谁先执行到谁的executeUpdate返回 1后到的返回 0因为条件不成立了。桌面单机程序并发少但用户连点两次分配按钮这种情况天天发生加了这一句第二次点就直接返回 false界面提示该床位已被占用不会产生两条脏记录。finally里把autoCommit还原成true是个容易被忘掉的细节。虽然这里连接用完就关了不复用但只要以后换成连接池一个没还原的连接回到池子里会让下一个使用者莫名其妙地怎么改都不生效。界面层的调用也很讲究不能在 EDT 里直接执行否则数据库慢的时候整个窗口卡死btnAssign.addActionListener(e - { int stuId table.getSelectedRow(); int bedId selectedBedId; // 放到后台线程执行完成后切回 EDT 刷新表格 new SwingWorkerBoolean, Void() { Override protected Boolean doInBackground() throws Exception { return dormDao.assignBed(stuId, bedId); } Override protected void done() { try { if (get()) { JOptionPane.showMessageDialog(null, 分配成功); refresh(); } else { JOptionPane.showMessageDialog(null, 床位已被占用); } } catch (Exception ex) { JOptionPane.showMessageDialog(null, 操作失败 ex.getMessage()); } } }.execute(); });SwingWorker的doInBackground在后台线程跑done自动回到 EDT界面刷新不会有线程安全问题。这是 Swing 里处理耗时操作的标准姿势比手写new Thread再SwingUtilities.invokeLater要稳妥。4.4 条件查询与 TableModel多条件拼接的正确写法报修管理的查询要支持宿舍号 状态 时间段三个条件自由组合这种动态 SQL 用字符串拼接很容易出事public ListRepair query(String dormNo, Integer status, Date from, Date to) { StringBuilder sql new StringBuilder( SELECT r.*, d.room_no FROM repair r JOIN dorm d ON r.dorm_id d.id WHERE 11); ListObject args new ArrayList(); if (dormNo ! null !dormNo.trim().isEmpty()) { sql.append( AND d.room_no LIKE ?); args.add(% dormNo.trim() %); } if (status ! null status 0) { sql.append( AND r.status ?); args.add(status); } if (from ! null) { sql.append( AND r.create_time ?); args.add(new Timestamp(from.getTime())); } if (to ! null) { sql.append( AND r.create_time ?); args.add(new Timestamp(to.getTime())); } sql.append( ORDER BY r.create_time DESC); ListRepair list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql.toString())) { for (int i 0; i args.size(); i) { ps.setObject(i 1, args.get(i)); // 索引从 1 开始别写成 i } try (ResultSet rs ps.executeQuery()) { while (rs.next()) { list.add(new Repair(rs.getInt(id), rs.getString(room_no), rs.getString(content), rs.getInt(status), rs.getTimestamp(create_time))); } } } catch (SQLException e) { e.printStackTrace(); } return list; }WHERE 11是让后面每个条件都能统一用AND开头不用判断是不是第一个条件。参数和占位符必须严格按顺序对应所以args的add顺序要和sql.append的顺序一致这是这种写法的唯一风险点。ps.setObject(i 1, ...)里的1是因为 JDBC 的索引从 1 开始写成i会报Parameter index out of range。结果集交给AbstractTableModel渲染而不是往JTable里一行行addRowpublic class RepairTableModel extends AbstractTableModel { private final String[] cols {单号, 宿舍, 报修内容, 状态, 提交时间}; private ListRepair data new ArrayList(); public void setData(ListRepair list) { this.data list; fireTableDataChanged(); // 通知表格整表重绘 } Override public int getRowCount() { return data.size(); } Override public int getColumnCount() { return cols.length; } Override public String getColumnName(int c) { return cols[c]; } Override public Object getValueAt(int row, int col) { Repair r data.get(row); switch (col) { case 0: return r.getId(); case 1: return r.getRoomNo(); case 2: return r.getContent(); case 3: return r.getStatus() 0 ? 待处理 : 已完成; // 码值转中文 case 4: return r.getCreateTime(); default: return ; } } }状态这种数字码在 Model 层就转成中文界面只管显示。如果放到界面里判断再setValueAt一旦别的地方也要显示状态就得再写一遍转换逻辑早晚不一致。5. 排查清单跑这套代码最容易翻车的五个地方下面五条都是看起来像别的问题、实际根因很简单的典型。建议在启动之前先读一遍能省掉大半天。5.1 启动就报 ClassNotFoundException: com.mysql.cj.jdbc.Driver现象是点运行之后控制台第一行就是驱动类找不到程序连登录框都出不来。原因通常有两个。一是lib/下的驱动 jar 没加进构建路径Eclipse 里叫 Add to Build PathIDEA 里叫 Add as Library只在文件夹里躺着不算数。二是驱动类名和数据库版本不匹配MySQL 8.0 的驱动必须写com.mysql.cj.jdbc.Driver写成com.mysql.jdbc.Driver虽然会有一个弃用警告但能连上反过来 5.1.x 的驱动写cj就直接找不到类。解决办法是先看 jar 文件名确认版本再改jdbc.properties里的jdbc.driver。如果两种都试过还不行把lib/下所有 jar 列出来确认没有两个版本的驱动同时存在——类加载器加载到哪一个是不确定的。5.2 中文全变问号或者插入时报 Incorrect string value现象是登录进去学生列表姓名那一列显示???或者新增学生时抛Incorrect string value: \xE5\xBC\xA0...。原因是编码链路上有一环是 latin1。三条链路都要检查数据库和表本身的字符集是不是utf8mb4JDBC URL 里有没有useUnicodetruecharacterEncodingutf8Java 源文件保存编码和编译时的-encoding参数是不是都是 UTF-8。一个常被忽略的点是sql/dorm_db.sql文件头如果没写SET NAMES utf8mb4;导入时 MySQL 会用客户端默认编码解析表建出来是 utf8mb4 但里面的初始数据已经乱码了。修复方式是把表和数据都清掉重新导入并在导入命令上加--default-character-setutf8mb4。5.3 点任何一个按钮界面都卡住三五秒现象是点查询或者保存窗口直接变白鼠标变成等待状态数据量大的时候要卡十几秒。原因是数据库操作写在了事件处理方法里而事件处理跑在 EDT 上。EDT 被占用Swing 就没法重绘界面表现出来就是卡死。数据量小的时候几百毫秒感觉不出来一旦表里有几千条记录问题就暴露了。解决办法是把所有 DAO 调用包进SwingWorker或者用CompletableFuture.supplyAsync加SwingUtilities.invokeLater。改的时候有个顺序先把耗时的查询改掉再改保存和删除一次改一个改完立刻测试一次性全改容易引入新问题。5.4 床位分配显示成功但学生列表里还是未分配现象是点分配按钮弹了分配成功回头看学生列表宿舍号和床位号还是空的但床位那边确实显示已占用。原因是只更新了bed表没更新student表或者两条UPDATE都执行了但没commit()。桌面程序的Connection默认autoCommit是true如果你手动setAutoCommit(false)之后忘了提交关闭连接时数据会回滚界面上却已经提示成功了。排查方式是直接连数据库看两张表SELECT * FROM bed WHERE id?和SELECT * FROM student WHERE id?对照student_id和bed_id是否互相指向。修复时把事务提交放在两条UPDATE都返回 1 之后rollback放在 catch 块里中间不要有任何可能吞异常的分支。5.5 打成 jar 之后图片和配置文件全部丢失现象是 IDE 里跑得好好的mvn package或者手动打 jar 之后双击运行登录框的图标变成了空白启动直接报找不到 jdbc.properties。原因是用了new FileInputStream(src/jdbc.properties)或者new ImageIcon(src/images/logo.png)这种相对路径读文件。文件系统路径在 IDE 里以项目根目录为基准打成 jar 之后根本没有src这个目录。解决办法统一改成 classpath 读取配置用getClassLoader().getResourceAsStream(jdbc.properties)图片用getClassLoader().getResource(images/logo.png)拿到 URL 再传给ImageIcon。同时确认打包命令把src下的资源文件一起打进去了jar -cvf的时候别只指定*.class。6. 二次开发改造把能交推到能讲从加一个统计功能开始课设做完能跑和答辩时能讲出东西中间差一次主动改造。最容易上手、也最容易被老师记住的改造是加一个统计面板——它同时用到了 SQL 聚合、Swing 表格渲染和跨表关联三个点都能聊。具体做法是加一个StatPanel按楼栋统计入住率public ListObject[] statByBuilding() { // 总床位数 vs 已占床位数一次查询算完别在 Java 里循环 count String sql SELECT b.name, COUNT(bd.id) AS total, COUNT(bd.student_id) AS used FROM building b JOIN dorm d ON d.building_id b.id JOIN bed bd ON bd.dorm_id d.id GROUP BY b.id, b.name ORDER BY b.id; ListObject[] rows new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { long total rs.getLong(total); long used rs.getLong(used); // 入住率直接算好避免界面层再处理除零 String rate total 0 ? 0.0% : String.format(%.1f%%, used * 100.0 / total); rows.add(new Object[]{rs.getString(name), total, used, rate}); } } catch (SQLException e) { e.printStackTrace(); } return rows; }这段 SQL 有个细节COUNT(bd.id)数的是床位数COUNT(bd.student_id)数的是非空的学生 ID因为COUNT(列名)不统计 NULL。一个COUNT(*)、一个COUNT(列名)同一个连接里就把总数和占用数都拿出来了不用发两次查询也不用把数据捞到 Java 里遍历。这种写法在答辩时讲出来比我加了个功能有分量得多。改造顺序建议是先在数据库里把这条 SQL 跑通确认结果对得上再写 DAO 方法用main方法测一下返回的 List最后接界面。顺序反了一旦数字不对你分不清是 SQL 写错、还是实体映射错、还是表格列绑错。改造完的验证方法很简单但必须做手动在数据库里插一条新床位刷新统计面板总数应该 1入住率应该下降再分配一个学生已用数应该 1。两个方向都动过才说明统计逻辑是活的不是把数字写死的。还有一个不费力但很讨巧的改造给student表加一个status字段把退宿做成软删除而不是DELETE。这样历史住宿记录还在统计不会因为删人而失真答辩时被问到数据删了还能查历史吗也能接得住。改动量只有一个字段加两处 SQL收益却很实在。从那以后我每次接这类课设改造都强制自己先跑通一遍原始的登录、增删改查、分配床位做完这五步再动第二行代码——因为跳过这五步的后果我已经赔进去不止一个通宵了。希望帮到你。本文还有配套的精品资源点击获取