简介:这是一份完整的Java+Swing+MySQL房屋租赁管理系统项目资源,面向Java初学者、课程设计或毕业设计人员,帮助掌握桌面应用与数据库结合开发的完整流程。系统内置双角色:用户可在前台注册登录、浏览房源列表、查看出租房屋详情;管理员在后台负责登录验证、房屋信息的录入、修改、删除以及多条件检索,同时可维护房型、价格、房东等关键资料,完整呈现了Swing界面事件驱动与MySQL持久化存储的协作模式。压缩包共29个文件,包含11个Java源文件、11个已编译的class文件、1个SQL数据库脚本、1份课程设计Word说明文档以及工程配置文件,整体大小约3.89MB,目录划分清楚,导入IDE后可直接运行和二次修改。资源内置数据库建表语句、完整前端界面代码与后台业务逻辑,方便逐模块对照调试;附带的课程设计文档还可用于撰写实验报告或答辩说明。目前已有342人学习下载,适合想通过实际项目快速熟悉Swing与MySQL整合开发的学习者。
1. 用 Java+Swing+MySQL 做房屋租赁管理系统,桌面端还有没有搞头?
先说结论:如果你需要的是一个能跑在 Windows 电脑上、不依赖浏览器、双击就能用的内部管理工具,Java+Swing+MySQL 这套组合依然是最省事的方案之一。很多中介门店、公寓管理公司甚至学校后勤,至今还在用类似的桌面系统管房源和收租,不是因为技术老,而是因为业务简单:房源登记、租客档案、合同签收、水电费结算、到期提醒。你用 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' ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 房源表 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) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 租客表 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 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 合同表 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 收租记录表 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`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这段脚本需要解释几个关键点:第一,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?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/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) {} } // 归还到连接池 } }这个连接池配置有几个容易踩坑的点:serverTimezone=Asia/Shanghai必须加,否则新版 MySQL 驱动会报时区错误;useSSL=false是本地开发常用的,避免 SSL 握手带来的延迟;characterEncoding=utf8mb4要保证和建表时的字符集一致。连接池的作用不是加速单次查询,而是省掉反复握手和认证的开销。对 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 -> { List<Contract> 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 服务端默认latin1,JDBC 连接字符串没指定 UTF-8,Swing 界面的字体编码和 Java 源文件编码也不统一。
解决:三处必须一致。第一,建库时用utf8mb4;第二,JDBC URL 带上useUnicode=true&characterEncoding=utf8mb4;第三,Java 源文件保存为 UTF-8,编译时也指定 UTF-8。如果用的 IDE 是 Eclipse,还需要在编译选项里设置-encoding UTF-8。还有一种隐蔽情况是 MySQL 连接器版本太高,比如mysql-connector-java8.0 以上,字符集参数名变成characterEncoding=utf8也能兼容,但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 更新界面。下面是一个标准写法:
SwingWorker<List<Contract>, Void> worker = new SwingWorker<>() { @Override protected List<Contract> doInBackground() { return contractDao.findAll(); // 后台线程执行查询 } @Override protected void done() { try { List<Contract> 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() { List<Payment> payments = paymentDao.findAll(); Map<String, 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.Entry<String, 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 导出合同到 Excel:POI 的简单用法
管理人员大概率需要把合同列表导出成 Excel 发给财务。Apache POI 是最常见的 Java Excel 操作库。Swing 里导出的做法很直接:点击“导出”按钮后弹出JFileChooser,选好保存路径,然后生成.xlsx文件。
public void exportContractsToExcel(List<Contract> 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 + "/?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/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,程序启动时读取。
这个方向值不值得做,我的看法是:如果你需要一个离线可用、数据可控的内部租赁管理工具,Java+Swing+MySQL 足够。但如果你要面对的是几十个门店的并发访问、网页端手机端都要用,那还是尽早换成 Web 或者小程序架构。Swing 的边界很清楚,单机或者局域网内少量客户端是它的舒适区,超过这个范围就不要硬撑。我自己的习惯是:先把 SQL 脚本和文档整理好,再写界面,最后用SwingWorker优化卡顿,每一步都用真实数据跑一遍。希望帮到你。
本文还有配套的精品资源,点击获取