
简介这是一份完整的JavaSwingMySQL房屋租赁管理系统项目资源面向Java初学者、课程设计或毕业设计人员帮助掌握桌面应用与数据库结合开发的完整流程。系统内置双角色用户可在前台注册登录、浏览房源列表、查看出租房屋详情管理员在后台负责登录验证、房屋信息的录入、修改、删除以及多条件检索同时可维护房型、价格、房东等关键资料完整呈现了Swing界面事件驱动与MySQL持久化存储的协作模式。压缩包共29个文件包含11个Java源文件、11个已编译的class文件、1个SQL数据库脚本、1份课程设计Word说明文档以及工程配置文件整体大小约3.89MB目录划分清楚导入IDE后可直接运行和二次修改。资源内置数据库建表语句、完整前端界面代码与后台业务逻辑方便逐模块对照调试附带的课程设计文档还可用于撰写实验报告或答辩说明。目前已有342人学习下载适合想通过实际项目快速熟悉Swing与MySQL整合开发的学习者。1. 用 JavaSwingMySQL 做房屋租赁管理系统桌面端还有没有搞头先说结论如果你需要的是一个能跑在 Windows 电脑上、不依赖浏览器、双击就能用的内部管理工具JavaSwingMySQL 这套组合依然是最省事的方案之一。很多中介门店、公寓管理公司甚至学校后勤至今还在用类似的桌面系统管房源和收租不是因为技术老而是因为业务简单房源登记、租客档案、合同签收、水电费结算、到期提醒。你用 Spring Boot 写一套 Web 系统当然也行但要部署服务器、配网络、搞权限对于只有一两台电脑的小团队来说反而累赘。Swing 的痛点主要是不好看、开发效率低但配合 MySQL 做数据持久化再补上一份完整的数据库脚本和文档这套源码的复用价值并不低。这篇笔记就按我自己的落地习惯从表结构设计、界面分层、核心业务实现到踩坑点完整拆一遍。适合刚做完 Java 基础、想拿一个完整项目练手的人也适合需要快速搭一个内部管理系统的开发者。2. 先把业务表和 Swing 界面结构定下来租赁系统的数据模型与界面分层2.1 数据模型设计五张核心表就能撑起租赁业务房屋租赁管理系统本质上就是围绕“房源”“租客”“合同”三个对象转。绝大多数毕设和交付源码里核心表不会超过六张。我一般会这样设计house存放房源信息tenant存放租客档案contract存放租赁合同payment存放收租记录user存放系统登录用户。如果涉及水电费再加一张utility_bill但初期可以先把水电费合并到payment的备注字段里减少联表复杂度。房源表的字段重点是状态。house.status我习惯用0-未出租 1-已出租 2-维修中而不是用布尔值因为之后要扩展“下架”或者“锁定”状态。租客表除了姓名电话一定要保留身份证号和紧急联系人这是租赁业务的刚需。合同表是核心需要关联house_id和tenant_id同时把押金、月租金、起止日期、付款方式都冗余存进去。为什么冗余因为合同一旦签订即使后续改了房源月租金历史合同仍然要按原价结算。如果只存关联 id 去实时查房价合同就不可信了。收租记录表payment要记录本次收的是哪个月的租金、金额、收款方式、经办人。很多新手会忽略“本次收的是哪一期”导致到期提醒算错。我的做法是在表里加一个period_start和period_end表示这笔钱覆盖的租期范围而不是只记一个付款时间。用户表就简单了用户名、密码、角色。密码不要明文存至少用 MD5 加盐。Swing 客户端直连数据库密码加密的意义并不是防黑客而是防止内部人员把数据库导出后直接看到密码。2.2 Swing 界面分层别把业务逻辑写进按钮事件里Swing 开发最容易翻车的地方就是所有人都在actionPerformed里写 JDBC。一个按钮里连数据库、拼 SQL、弹错误框界面和逻辑全揉在一起。系统小的时候没问题一旦要加报表、加导出代码就直接失控。我一般会按三层来组织包结构view只放 JFrame、JPanel、JTable 这些界面组件以及按钮事件里对 service 层的调用。service业务逻辑比如签合同的时候要检查房源状态、计算首期付款金额、生成收租记录。dao数据访问只写 JDBC 和 SQL不参与任何业务判断。这样做的好处是Swing 界面重画的时候不会误伤业务代码。比如你把JTable的渲染从默认的改成自定义单元格颜色只需要改 view 层。同理如果要把 MySQL 换成 PostgreSQL也只需要改 dao 层。界面布局上主窗口用BorderLayout顶部放工具栏、中间放JTabbedPane每个 Tab 对应一个业务模块。登录窗口用JDialog不要用JFrame不然关掉主窗口之后进程不退出特别容易让用户以为程序卡死了。2.3 从 ER 图到建表脚本MySQL 8.0 下的初始化 SQL数据库脚本是整套源码里最容易被忽略、但最值得仔细看的部分。很多开源项目给你一个.sql文件你导入的时候报错多半是因为字符集、外键顺序或者自增列语法不对。我习惯把建表脚本写成可重复执行的版本先DROP TABLE IF EXISTS再CREATE TABLE方便重新初始化。下面是一个最小可用的建表脚本覆盖房源、租客、合同、收租、用户五张表。-- 初始化房屋租赁系统数据库 DROP DATABASE IF EXISTS house_rent; CREATE DATABASE house_rent DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE house_rent; -- 用户表 CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL COMMENT MD5加密后的密码, role VARCHAR(20) DEFAULT admin ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 房源表 CREATE TABLE house ( id INT PRIMARY KEY AUTO_INCREMENT, house_no VARCHAR(20) NOT NULL COMMENT 房号如A-101, address VARCHAR(200) NOT NULL, area DECIMAL(8,2) COMMENT 面积平米, rent_price DECIMAL(10,2) NOT NULL COMMENT 月租金, status TINYINT DEFAULT 0 COMMENT 0-未出租 1-已出租 2-维修中, remark VARCHAR(500) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 租客表 CREATE TABLE tenant ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, phone VARCHAR(20) NOT NULL, id_card VARCHAR(18) NOT NULL COMMENT 身份证号, emergency_contact VARCHAR(50), emergency_phone VARCHAR(20), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 合同表 CREATE TABLE contract ( id INT PRIMARY KEY AUTO_INCREMENT, house_id INT NOT NULL, tenant_id INT NOT NULL, start_date DATE NOT NULL, end_date DATE NOT NULL, monthly_rent DECIMAL(10,2) NOT NULL, deposit DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT 押金, status TINYINT DEFAULT 0 COMMENT 0-执行中 1-已退租 2-已到期, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_contract_house FOREIGN KEY (house_id) REFERENCES house(id), CONSTRAINT fk_contract_tenant FOREIGN KEY (tenant_id) REFERENCES tenant(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; -- 收租记录表 CREATE TABLE payment ( id INT PRIMARY KEY AUTO_INCREMENT, contract_id INT NOT NULL, period_start DATE NOT NULL COMMENT 本期开始日期, period_end DATE NOT NULL, amount DECIMAL(10,2) NOT NULL, pay_type VARCHAR(20) DEFAULT 现金 COMMENT 现金/转账/支付宝, operator VARCHAR(50) COMMENT 经办人, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_payment_contract FOREIGN KEY (contract_id) REFERENCES contract(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段脚本需要解释几个关键点第一utf8mb4而不是utf8因为 MySQL 的utf8只支持最多 3 字节遇到生僻字或者特殊符号会报错或乱码utf8mb4才是完整的 UTF-8。第二外键约束我没有写在每个字段后面而是统一放到表级约束里这样能保证删除顺序正确避免“Cannot add foreign key constraint”的报错。第三deposit和monthly_rent都用DECIMAL而不是FLOAT钱永远不要用浮点数否则累计对账时会差几分钱。建表之后需要插入初始数据比如一个默认管理员账号还有几条测试房源。管理员密码用 MD5 加密后的值通常源码文档里会标明初始密码是admin123。测试房源可以造两三套方便你运行系统后立刻看到界面效果不用自己手动录入。3. 用 Swing 把登录和房源管理跑通关键组件与事件写法3.1 登录窗口JFrame JDialog 的跳转写法登录窗口是所有管理系统都有的第一个界面。Swing 里实现登录跳转的常见做法是主类里先显示一个LoginDialog它是一个JDialog模式设置为setModal(true)用户在对话框里输入用户名和密码点击登录后向 service 层发起校验。校验成功则把LoginDialog关掉然后创建主窗口MainFrame并显示。这样主窗口的启动逻辑反而简单。public class LoginDialog extends JDialog { private JTextField usernameField; private JPasswordField passwordField; private UserService userService new UserService(); public LoginDialog(JFrame owner) { super(owner, 登录, true); // 模态对话框 setSize(320, 180); setLocationRelativeTo(null); setDefaultCloseOperation(DISPOSE_ON_CLOSE); JPanel panel new JPanel(new GridLayout(3, 2, 8, 8)); panel.setBorder(BorderFactory.createEmptyBorder(10, 10, 10, 10)); panel.add(new JLabel(用户名:)); usernameField new JTextField(); panel.add(usernameField); panel.add(new JLabel(密码:)); passwordField new JPasswordField(); panel.add(passwordField); JButton loginBtn new JButton(登录); loginBtn.addActionListener(e - doLogin()); JButton cancelBtn new JButton(取消); cancelBtn.addActionListener(e - System.exit(0)); panel.add(loginBtn); panel.add(cancelBtn); setContentPane(panel); } private void doLogin() { String username usernameField.getText().trim(); String password new String(passwordField.getPassword()); User user userService.login(username, password); if (user ! null) { JOptionPane.showMessageDialog(this, 欢迎 user.getUsername()); dispose(); new MainFrame(user).setVisible(true); } else { JOptionPane.showMessageDialog(this, 用户名或密码错误, 登录失败, JOptionPane.ERROR_MESSAGE); passwordField.setText(); } } }这段代码有几个细节值得注意new String(passwordField.getPassword())而不是passwordField.getText()因为JPasswordField的getText()已经不推荐使用安全上也会留下明文引用。登录成功之后dispose()对话框主窗口设置为visible两者生命周期分离。这里简化了UserService.login()的实现实际代码里它会调用UserDao查询数据库把查到的密码和传入的密码做 MD5 比较。3.2 房源信息表格JTable DefaultTableModel 的增删改查房源管理模块的核心是一个JTable它展示所有房源。Swing 的JTable本身不存数据数据放在DefaultTableModel里。增删改查就是对这个TableModel做操作然后同步到数据库。最常用的方式是工具栏上放“新增、修改、删除、刷新”四个按钮点击新增时弹出一个表单对话框保存后调用HouseDao插入数据库再刷新表格。public class HousePanel extends JPanel { private JTable table; private DefaultTableModel model; private HouseDao houseDao new HouseDao(); public HousePanel() { setLayout(new BorderLayout()); model new DefaultTableModel(new String[]{ID, 房号, 地址, 面积, 月租金, 状态}, 0); table new JTable(model); add(new JScrollPane(table), BorderLayout.CENTER); JPanel toolBar new JPanel(); JButton addBtn new JButton(新增); addBtn.addActionListener(e - showHouseDialog(null)); JButton editBtn new JButton(修改); editBtn.addActionListener(e - { int row table.getSelectedRow(); if (row 0) { JOptionPane.showMessageDialog(this, 请先选择一行); return; } int id Integer.parseInt(model.getValueAt(row, 0).toString()); showHouseDialog(houseDao.findById(id)); }); JButton deleteBtn new JButton(删除); deleteBtn.addActionListener(e - { int row table.getSelectedRow(); if (row 0) return; int id Integer.parseInt(model.getValueAt(row, 0).toString()); if (houseDao.deleteById(id) 0) { model.removeRow(row); } }); JButton refreshBtn new JButton(刷新); refreshBtn.addActionListener(e - reloadData()); toolBar.add(addBtn); toolBar.add(editBtn); toolBar.add(deleteBtn); toolBar.add(refreshBtn); add(toolBar, BorderLayout.NORTH); reloadData(); } private void reloadData() { model.setRowCount(0); for (House h : houseDao.findAll()) { model.addRow(new Object[]{h.getId(), h.getHouseNo(), h.getAddress(), h.getArea(), h.getRentPrice(), h.getStatusText()}); } } private void showHouseDialog(House house) { HouseDialog dialog new HouseDialog(house); // 新增时 house 为 null dialog.setVisible(true); if (dialog.isSaved()) { reloadData(); } } }这里要注意showHouseDialog(null)和showHouseDialog(house)的写法新增时传入null修改时传入从数据库查出的完整对象。HouseDialog内部根据house是否为null来决定执行插入还是更新这个模式比分别写两个对话框要省很多代码。statusText是把0/1/2转成“未出租/已出租/维修中”的工具方法显示在界面上比裸数字友好存数据库时再转回数字。JTable默认不允许直接编辑用户想改数据必须通过表单对话框这是很多管理系统的标准做法。如果直接让JTable可编辑很容易误操作且不好校验。3.3 数据库连接JDBC 连接池与事务边界Swing 是长连接应用用户可能把窗口挂一整天。如果每次操作都新建连接、用完就关频繁开关连接在高频操作下会有明显卡顿。更好的做法是使用连接池。常见的连接池有HikariCP和Druid在 Swing 项目里引入一个连接池其实不复杂在pom.xml里加上依赖然后写一个工具类获取连接。import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; public class DBUtil { private static HikariDataSource dataSource; static { HikariConfig config new HikariConfig(); config.setJdbcUrl(jdbc:mysql://localhost:3306/house_rent?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/Shanghai); config.setUsername(root); config.setPassword(123456); config.setMaximumPoolSize(10); config.setMinimumIdle(2); config.setConnectionTimeout(3000); // 3秒拿不到连接就报错 dataSource new HikariDataSource(config); } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs ! null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt ! null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn ! null) { try { conn.close(); } catch (SQLException ignored) {} } // 归还到连接池 } }这个连接池配置有几个容易踩坑的点serverTimezoneAsia/Shanghai必须加否则新版 MySQL 驱动会报时区错误useSSLfalse是本地开发常用的避免 SSL 握手带来的延迟characterEncodingutf8mb4要保证和建表时的字符集一致。连接池的作用不是加速单次查询而是省掉反复握手和认证的开销。对 Swing 应用来说感觉最明显的是连续操作几十条记录时不会出现每次操作都停顿一下的情况。事务边界放在 service 层比如签合同要同时更新房源状态为“已出租”、插入合同记录、生成第一笔收租记录这三步必须在一个事务里。如果用连接池conn.setAutoCommit(false)之后三个 DAO 方法都传同一个Connection最后统一commit()。如果把事务写在 DAO 层就无法保证跨表操作的原子性。4. 合同与收租流程租赁业务最难的两个环节怎么落地4.1 合同创建关联房源、租客、押金与起止日期合同创建是整个系统里逻辑最密集的操作。用户从下拉框里选房源和租客填起止日期、月租金、押金点保存。这里至少要有三项校验房源必须处于“未出租”状态、租客不能已经存在执行中的合同避免一个租客同时租两套未退租的房子、结束日期必须大于开始日期。校验通过后执行一个事务性操作插入合同记录、把房源状态改为“已出租”、生成第一条收租记录首期租金加押金。下面的代码演示了 service 层如何组织这个事务public boolean createContract(Contract contract, BigDecimal deposit, BigDecimal firstRent) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); HouseDao houseDao new HouseDao(); House house houseDao.findById(contract.getHouseId()); if (house null || house.getStatus() ! 0) { throw new RuntimeException(房源不存在或已出租); } ContractDao contractDao new ContractDao(); int contractId contractDao.insert(conn, contract); // 返回自增id houseDao.updateStatus(conn, contract.getHouseId(), 1); // 房源置为已出租 PaymentDao paymentDao new PaymentDao(); paymentDao.insert(conn, contractId, contract.getStartDate(), contract.getStartDate().plusMonths(1).minusDays(1), firstRent.add(deposit), 现金, admin); conn.commit(); return true; } catch (Exception e) { try { if (conn ! null) conn.rollback(); } catch (SQLException ignored) {} throw new RuntimeException(创建合同失败: e.getMessage(), e); } finally { try { if (conn ! null) conn.setAutoCommit(true); conn.close(); } catch (SQLException e) {} } }这段代码的关键在于insert方法返回自增id然后用这个id去插入收租记录。period_end我写成了startDate.plusMonths(1).minusDays(1)意思是本次收租覆盖从起租日到次月前一天。如果合同约定“押一付三”这里就循环插入三条payment记录而不是只插一条。很多源码为了省事只插一条“首期收款”后续到期提醒就没法精确判断该收哪个月了。事务回滚的写法也要注意conn.rollback()之后一定要在finally里把autoCommit恢复成true然后关闭连接。如果不恢复连接池里的连接会带着false的自动提交状态被复用导致后续所有操作都不自动提交数据半天写不进去这是特别隐蔽的坑。4.2 收租与到期提醒用定时任务和状态字段实现收租功能的核心是生成一笔payment记录同时更新合同下一条应收租期的起始时间。常见设计是合同表里加一个next_payment_date字段每收一次租就往后推一个月。到期提醒则通过查询“当前日期大于next_payment_date且合同状态为执行中”的记录来实现。Swing 应用做定时任务不能用Thread.sleep死循环那会卡住界面线程。常见做法是用javax.swing.Timer它会在事件分发线程EDT里周期触发回调适合做轻量级提醒。比如每 10 分钟检查一次如果有到期合同就在主窗口右下角弹个气泡或者用JOptionPane提示。Timer timer new Timer(10 * 60 * 1000, e - { ListContract overdueList contractDao.findOverdueContracts(); if (!overdueList.isEmpty()) { StringBuilder sb new StringBuilder(以下合同即将到期或已逾期\n); for (Contract c : overdueList) { sb.append(c.getHouseNo()).append( - ).append(c.getTenantName()) .append( 下次收租日: ).append(c.getNextPaymentDate()).append(\n); } JOptionPane.showMessageDialog(MainFrame.this, sb.toString(), 收租提醒, JOptionPane.WARNING_MESSAGE); } }); timer.start();注意Timer的回调是在 EDT 里执行的所以直接弹JOptionPane是安全的。如果要去查数据库10 分钟一次查一下也没问题。但如果你把频繁的查询也塞进 EDT界面就会在查询期间卡住。折中方案是定时器只负责触发一个SwingWorker后台执行查询查询完成后把结果通过done()方法更新界面。参数说明Timer的第一个参数是毫秒10 * 60 * 1000就是 10 分钟。如果你希望测试时快速看到效果可以先改成 5000 毫秒跑一下确认提醒正常后再改回来。实际部署时建议 30 分钟到 1 小时检查一次即可频繁查库没有意义。另外提醒弹窗只弹一次就够了不要每次触发都弹否则用户会被烦死。可以在弹窗后记录上次提醒时间或者让findOverdueContracts只查询“超过提醒阈值且未提醒过”的合同。4.3 退租结算押金扣除与账单核销退租是最容易产生纠纷的业务。标准流程是用户选择一份执行中的合同点击“退租”系统自动计算该合同下还有多少未收租金以及押金应该退多少。如果租客提前退租通常要按合同约定扣除违约金。这些规则在不同项目中差异很大源码文档里一般会在说明中给出一个简单版本押金全额退回但未结清的水电费、欠租先从押金里扣。实现上退租操作同样是一个事务。首先把合同状态改为“已退租”然后把房源状态改回“未出租”最后生成一条负数的payment记录表示退款。这里也要把退款金额和原押金联动避免总账对不上。public void finishContract(int contractId, BigDecimal withholdAmount, String reason) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); ContractDao contractDao new ContractDao(); Contract contract contractDao.findById(contractId); if (contract null || contract.getStatus() ! 0) { throw new RuntimeException(合同不存在或已退租); } BigDecimal refund contract.getDeposit().subtract(withholdAmount); if (refund.compareTo(BigDecimal.ZERO) 0) { throw new RuntimeException(扣除金额超过押金); } contractDao.updateStatus(conn, contractId, 1); // 1-已退租 HouseDao houseDao new HouseDao(); houseDao.updateStatus(conn, contract.getHouseId(), 0); // 房源释放 PaymentDao paymentDao new PaymentDao(); paymentDao.insert(conn, contractId, LocalDate.now(), LocalDate.now(), refund.negate(), 退款, admin); // 负数表示退款 conn.commit(); } catch (Exception e) { try { if (conn ! null) conn.rollback(); } catch (SQLException ignored) {} throw new RuntimeException(退租结算失败: e.getMessage(), e); } finally { try { if (conn ! null) conn.setAutoCommit(true); conn.close(); } catch (SQLException e) {} } }这段代码踩过的坑是refund.negate()后再插入数据库数据库的DECIMAL(10,2)完全能存负数。但要注意payment表的period_start和period_end在退租时没有实际意义我传的是当前日期。如果你想严格记录退租时间最好再加一个refund_time或者remark在备注里写明扣款原因。另外如果退租后还要重新整理房源比如安排保洁、维修可以再给房源加一个“维修中”的状态而不是直接置为“未出租”这样更符合实际业务。这个可以根据自己的扩展需求调整不必拘泥于这里的写法。5. 避坑指南Swing MySQL 的 5 个典型翻车点5.1 中文乱码连接 URL 和建表字符集双管齐下现象界面和数据库里显示的都是问号或者乱码比如“房源”变成“????”。原因字符集不一致。MySQL 服务端默认latin1JDBC 连接字符串没指定 UTF-8Swing 界面的字体编码和 Java 源文件编码也不统一。解决三处必须一致。第一建库时用utf8mb4第二JDBC URL 带上useUnicodetruecharacterEncodingutf8mb4第三Java 源文件保存为 UTF-8编译时也指定 UTF-8。如果用的 IDE 是 Eclipse还需要在编译选项里设置-encoding UTF-8。还有一种隐蔽情况是 MySQL 连接器版本太高比如mysql-connector-java8.0 以上字符集参数名变成characterEncodingutf8也能兼容但utf8mb4更稳。注意连接参数里不要多写空格否则会被忽略。5.2 日期比较别用字符串用 java.time现象查询“当月到期的合同”时结果总是不对比如 9 月 1 日到期8 月 31 日查不到。原因很多人把日期存成VARCHAR然后用BETWEEN 2024-09-01 AND 2024-09-30做字符串比较。由于字符串日期格式不统一或者有人录入了2024-9-1这种没补零的格式排序和比较都会错乱。解决数据库的start_date、end_date字段一定用DATE类型Java 层面用LocalDate而不是java.util.Date。JDBC 驱动从 8.0 起ResultSet.getDate()返回java.sql.Date可以直接toLocalDate()转换。比较时用LocalDate.now().isAfter(contract.getEndDate())不要用after方法和字符串拼接。5.3 界面卡死SwingWorker 处理耗时查询现象点击“查询所有合同”后整个窗口拖不动、按钮点不了过几秒才恢复。原因查询数据库的代码直接写在了 EDT事件分发线程里。Swing 的所有界面刷新必须在 EDT 中执行但耗时的网络/数据库操作不应该阻塞 EDT否则用户会觉得程序死了。解决用SwingWorker把耗时任务放到后台线程完成后自动回调 EDT 更新界面。下面是一个标准写法SwingWorkerListContract, Void worker new SwingWorker() { Override protected ListContract doInBackground() { return contractDao.findAll(); // 后台线程执行查询 } Override protected void done() { try { ListContract list get(); // 获取结果 // 更新 JTable 模型 reloadTable(list); } catch (Exception e) { JOptionPane.showMessageDialog(MainFrame.this, 查询失败: e.getMessage()); } } }; worker.execute();注意doInBackground里不能碰任何 Swing 组件否则线程不安全。get()会抛出InterruptedException和ExecutionException需要捕获。实际项目里在我接触过的 Swing 系统中凡是做了列表查询卡死的十有八九都是因为这个。5.4 数据库连接泄漏finally 关闭资源现象程序运行几小时或连续操作几十次后突然报“Too many connections”或者程序崩溃。原因每次操作新建连接但没有关闭。特别是在使用连接池后很多人只调用了conn.close()但忘了关闭Statement和ResultSet。有时候conn是通过连接池拿的close()之后只是归还连接但关联的ResultSet没有关依然会占用资源。解决在 DAO 方法里用finally保证所有资源关闭。不要相信 Java 的垃圾回收它不会替你关闭数据库连接。如果项目里用了连接池conn.close()只是归还连接但stmt.close()和rs.close()也必须调用。代码里可以写一个工具方法就像之前DBUtil.close()那样把三个关闭操作放一起。另外打开连接后一定要在catch里回滚否则事务只提交了一半数据会处于不一致状态。5.5 房态并发同一房源被两单合同占用现象两个窗口同时操作或者同一个窗口快速点击两次“签合同”系统里出现两笔执行中的合同对应同一间房。原因createContract里先查房源状态为“未出租”再插入合同中间有窗口期。如果两次请求同时通过检查都会认为房源是“未出租”。解决在事务里用SELECT ... FOR UPDATE锁定房源行或者更简单的方式是给house表的status字段加约束。实际桌面应用并发量不高最简单可靠的办法是给房源表加一个逻辑锁在updateStatus前再查一次状态把更新语句写成UPDATE house SET status 1 WHERE id ? AND status 0如果更新的行数为 0说明已经被占用了抛出异常。这样即使两个请求同时进来数据库的行锁也会保证只有一个请求成功。代码里用houseDao.updateStatusWithCheck(conn, houseId, 1, 0)这样的模式。6. 让系统更接近可用报表、导出与一键部署6.1 用 JFreeChart 做月度收租统计能收租的软件才算真正能用。在 Swing 里做统计图最常用的库是 JFreeChart。它虽然不是最漂亮的但稳定、文档多适合集成到桌面程序里。下面这段代码演示如何从收租记录里按月份汇总金额然后生成一个柱状图放到主窗口的 Tab 页里。public JPanel createRentChartPanel() { ListPayment payments paymentDao.findAll(); MapString, BigDecimal monthSum new LinkedHashMap(); for (Payment p : payments) { String ym p.getPeriodStart().toString().substring(0, 7); // 取年-月 monthSum.merge(ym, p.getAmount(), BigDecimal::add); } DefaultCategoryDataset dataset new DefaultCategoryDataset(); for (Map.EntryString, BigDecimal e : monthSum.entrySet()) { dataset.addValue(e.getValue(), 租金, e.getKey()); } JFreeChart chart ChartFactory.createBarChart( 月度收租统计, 月份, 金额(元), dataset); ChartPanel chartPanel new ChartPanel(chart); chartPanel.setPreferredSize(new Dimension(600, 300)); return chartPanel; }merge方法很实用如果 key 不存在就放入新值存在就用BigDecimal::add累加。这里用了LocalDate.toString().substring(0, 7)太简单如果日期格式变了容易出错更稳妥的是用DateTimeFormatter.ofPattern(yyyy-MM)。图表生成后直接塞进JTabbedPane的某个 Tab 即可。注意ChartPanel是一个 Swing 组件可以直接 add 到任何容器。6.2 导出合同到 ExcelPOI 的简单用法管理人员大概率需要把合同列表导出成 Excel 发给财务。Apache POI 是最常见的 Java Excel 操作库。Swing 里导出的做法很直接点击“导出”按钮后弹出JFileChooser选好保存路径然后生成.xlsx文件。public void exportContractsToExcel(ListContract contracts, File file) { try (Workbook workbook new XSSFWorkbook()) { Sheet sheet workbook.createSheet(合同列表); String[] headers {合同编号, 房号, 租客, 起租日期, 退租日期, 月租金}; Row headerRow sheet.createRow(0); for (int i 0; i headers.length; i) { headerRow.createCell(i).setCellValue(headers[i]); } int rowIdx 1; for (Contract c : contracts) { Row row sheet.createRow(rowIdx); row.createCell(0).setCellValue(c.getId()); row.createCell(1).setCellValue(c.getHouseNo()); row.createCell(2).setCellValue(c.getTenantName()); row.createCell(3).setCellValue(c.getStartDate().toString()); row.createCell(4).setCellValue(c.getEndDate().toString()); row.createCell(5).setCellValue(c.getMonthlyRent().doubleValue()); } // 自动调整列宽 for (int i 0; i headers.length; i) { sheet.autoSizeColumn(i); } try (FileOutputStream out new FileOutputStream(file)) { workbook.write(out); } } catch (IOException e) { throw new RuntimeException(导出Excel失败, e); } }用try-with-resources可以自动关流避免文件占用。注意日期转字符串后再写入 Excel虽然 POI 也支持写Date类型但对单元格格式要求高字符串更省心。导出按钮的事件里只需要调用JFileChooser.showSaveDialog拿到的File对象传给这个方法。如果文件已存在POI 会覆盖建议在弹窗前检查file.exists()给用户一个确认提示。6.3 一键初始化数据库SQL 脚本 配置项分离交付源码的时候最怕对方拿到手不会跑。常见做法是把建表脚本放在sql/init.sql然后在 README 里写明“导入脚本改配置文件运行主类”。更友好的做法是做一个“初始化数据库”的菜单按钮程序里执行一个独立的初始化方法先连接一次 MySQL 服务器不指定库名执行脚本里的建库语句再切换到house_rent库执行建表和数据插入。public void initDatabase(String host, String port, String rootUser, String rootPwd) { String url jdbc:mysql:// host : port /?useUnicodetruecharacterEncodingutf8mb4useSSLfalseserverTimezoneAsia/Shanghai; String initSql loadFromResource(sql/init.sql); // 用类路径加载 try (Connection conn DriverManager.getConnection(url, rootUser, rootPwd); Statement stmt conn.createStatement()) { for (String sql : initSql.split(;)) { if (!sql.trim().isEmpty()) { stmt.execute(sql); } } } catch (SQLException e) { throw new RuntimeException(初始化失败: e.getMessage(), e); } }注意split(;)并不完全可靠如果 SQL 里有存储过程或者注释中带分号会出错。对于纯建表建库脚本还好。另外正式的初始化工具应该读取一个config.properties把数据库连接信息写在外面而不是硬编码在类里。这样用户拿到后只需要改一处配置文件不需要动源码。这也是很多源码仓库的规范做法src/main/resources下放config.properties里面写db.url、db.username、db.password程序启动时读取。这个方向值不值得做我的看法是如果你需要一个离线可用、数据可控的内部租赁管理工具JavaSwingMySQL 足够。但如果你要面对的是几十个门店的并发访问、网页端手机端都要用那还是尽早换成 Web 或者小程序架构。Swing 的边界很清楚单机或者局域网内少量客户端是它的舒适区超过这个范围就不要硬撑。我自己的习惯是先把 SQL 脚本和文档整理好再写界面最后用SwingWorker优化卡顿每一步都用真实数据跑一遍。希望帮到你。本文还有配套的精品资源点击获取