简介:一套基于 Java Swing 和 MySQL 的房屋租赁管理系统项目,面向 Java 课程设计、毕业设计及桌面应用初学者。系统包含前台用户注册登录、房屋分类浏览与后台管理员登录、房源信息录入修改删除查询等完整功能,支持按不同条件检索房源,管理员可维护房型、价格、房主等关键信息,帮助读者掌握 Swing 图形界面与 MySQL 数据库的联动开发。压缩包共 29 个文件,以 11 个 Java 源码、SQL 数据库脚本、课程设计文档和项目配置为主,另含可运行 JAR 包,整体大小仅 3.89MB。已有 342 人学习使用,适合需要快速上手完整示例的读者,尤其适合参考其界面布局与数据库表设计。通过源码与文档可了解系统分层、用户/管理员权限设计及房源管理流程,数据库脚本便于直接导入运行,是较为实用的练习与参考项目。
1. 用 Java+Swing+MySQL 做房屋租赁管理系统,这条路还走得通吗?
用 Java+Swing+MySQL 写一套房屋租赁管理系统,在 Web 系统遍地走的今天听起来有点老派,但它恰恰是很多 Java 基础学习者和应届生绕不开的一个项目形态。这个标题里的“源码+数据库+文档”意味着交付物是完整的:前端界面是 Swing 桌面程序,后端数据落在 MySQL,配套的说明文档负责把表结构、运行步骤和功能清单交代清楚。它解决的是小房东或小中介最痛的几个问题——房源状态不透明、租客资料散落在 Excel、合同到期靠脑子记、收租记录对不上账。
这套系统合适的场景很具体:Java 课程设计或毕业设计要一个“能演示、能答辩、能跑通”的项目;小规模房屋租赁门店想低成本上一个内部管理工具;或者你想把 Java 基础、JDBC、Swing 组件、MySQL 这几个技能点串成一条线来练手。它不适合做成大型 SaaS,但作为一个人月内能落地、交付物齐全的桌面管理系统,性价比很高。下面按我从零搭这套系统的顺序,把表设计、界面实现、JDBC 封装和打包交付中真正会踩的坑讲一遍。
2. MySQL 表设计是底座:房屋租赁系统该建哪几张表,字段怎么定
2.1 从房东视角倒推需求:至少要有五张核心表
做房屋租赁管理系统,不能上来就写建表语句,先站在使用者角度过一遍业务。房东或门店管理员每天要干的活无非几件:登录系统看总览、登记新房源、录入租客身份信息、签合同收押金、每个月生成房租账单并登记收款、到期前查哪些合同要续签或退租。把这些动作拆开,数据库表就清楚了。
我这里的做法是分出五张表:用户表t_user管登录账号;房源表t_house记录房子本身的信息和当前状态;租客表t_tenant存租客身份资料;合同表t_contract关联房源和租客,是业务核心;账单表t_bill记录每期应收的房租、水电费及缴纳情况。表面上看合同表像是个“中间关联表”,实际它比那复杂一点——合同带时间范围、租金金额、押金和支付方式,这些字段直接决定后面账单怎么生成。表与表的关联关系是:合同指向一条房源和一位租客,账单挂在合同下面,一个合同可以产生多期账单。
另外两个容易被忽略的点:房源和租客不能做成强关联,因为同一套房退租后再租给下一个人是常态,租客也要在换房后保留历史记录。所以业务状态只放在合同和房源上,租客表保持纯粹。外键约束在 MySQL 里建议加上,但删除策略要用好,比如删除房源前先确认没有生效合同,否则数据库会直接报错。
2.2 建库建表:一份可以直接执行的 SQL 脚本
下面这段 SQL 是我常用的一套建表方案,字符集统一utf8mb4,存储引擎全部InnoDB。utf8mb4可以兼容手机号里出现特殊符号的情况,避免后期插入数据时因为字符集不够宽而报错。
CREATE DATABASE IF NOT EXISTS house_rental DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE house_rental; DROP TABLE IF EXISTS t_bill; DROP TABLE IF EXISTS t_contract; DROP TABLE IF EXISTS t_tenant; DROP TABLE IF EXISTS t_house; DROP TABLE IF EXISTS t_user; CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录账号', password VARCHAR(64) NOT NULL COMMENT '密码,演示项目可存明文,生产必须加盐哈希', real_name VARCHAR(50) COMMENT '真实姓名', role VARCHAR(20) DEFAULT 'ADMIN' COMMENT '角色:ADMIN/MANAGER', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统用户表'; CREATE TABLE t_house ( id INT PRIMARY KEY AUTO_INCREMENT, house_no VARCHAR(30) NOT NULL UNIQUE COMMENT '房号,如B3-1402', community VARCHAR(50) NOT NULL COMMENT '小区名称', address VARCHAR(100) COMMENT '详细地址', area DECIMAL(6,2) COMMENT '面积(平米)', floor_no VARCHAR(10) COMMENT '所在楼层', orientation VARCHAR(10) COMMENT '朝向', rent_price DECIMAL(8,2) NOT NULL COMMENT '月租金', status TINYINT DEFAULT 0 COMMENT '0空置 1已租 2维修中', remark VARCHAR(255) COMMENT '备注', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='房源表'; CREATE TABLE t_tenant ( id INT PRIMARY KEY AUTO_INCREMENT, tenant_name VARCHAR(50) NOT NULL COMMENT '租客姓名', phone VARCHAR(20) NOT NULL COMMENT '联系电话', id_card VARCHAR(18) COMMENT '身份证号', job VARCHAR(50) COMMENT '职业', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租客表'; CREATE TABLE t_contract ( id INT PRIMARY KEY AUTO_INCREMENT, contract_no VARCHAR(32) NOT NULL UNIQUE COMMENT '合同编号', house_id INT NOT NULL COMMENT '房源ID', tenant_id INT NOT NULL COMMENT '租客ID', sign_date DATE COMMENT '签约日期', start_date DATE NOT NULL COMMENT '起租日期', end_date DATE NOT NULL COMMENT '到期日期', monthly_rent DECIMAL(8,2) NOT NULL COMMENT '月租金', deposit DECIMAL(8,2) DEFAULT 0 COMMENT '押金', pay_method VARCHAR(10) DEFAULT '月付' COMMENT '月付/季付/半年付/年付', status TINYINT DEFAULT 1 COMMENT '1生效 0已退租', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_contract_house FOREIGN KEY (house_id) REFERENCES t_house(id), CONSTRAINT fk_contract_tenant FOREIGN KEY (tenant_id) REFERENCES t_tenant(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='租赁合同表'; CREATE TABLE t_bill ( id INT PRIMARY KEY AUTO_INCREMENT, contract_id INT NOT NULL COMMENT '合同ID', bill_type VARCHAR(20) DEFAULT '租金' COMMENT '租金/水费/电费/物业费', bill_period VARCHAR(20) COMMENT '账期,如2025-06', amount DECIMAL(10,2) NOT NULL COMMENT '应收金额', due_date DATE COMMENT '应缴日期', pay_status TINYINT DEFAULT 0 COMMENT '0未缴 1已缴', pay_time DATETIME NULL COMMENT '实际缴纳时间', create_time DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_bill_contract FOREIGN KEY (contract_id) REFERENCES t_contract(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='账单表';这段脚本有几个参数值得细说。金额字段都用DECIMAL而不是FLOAT/DOUBLE,因为浮点数存储租金会产生精度误差,合同上写着 3500.00,查询出来可能是 3499.9999,做SUM统计时尤其明显。电话号码、身份证号、房号这些看起来像数字的字段一律用VARCHAR,它们不需要参与算术运算,却可能在前面出现0或者包含字母——比如房号B3-1402。日期字段用DATE类型,只有记录创建时间才用DATETIME配DEFAULT CURRENT_TIMESTAMP,这样插入时代码里少写一个字段。
外键约束在课程设计里是加分项,但要注意删除顺序。脚本里先把t_bill、t_contract这些子表 DROP 掉再 DROPt_house/t_tenant,否则 MySQL 会因为你删父表时子表还引用着它而报错。这个顺序同样适用于后续手工清理测试数据。
2.3 预置数据:管理员账号和演示用基础数据
空表跑起来的系统没法演示,所以初始化脚本里要带两样东西:管理员账号和几套有状态的演示数据。管理员账号用来完成登录演示,演示数据则让登录后每个窗口都有内容可看。
INSERT INTO t_user (username, password, real_name, role) VALUES ('admin', 'admin123', '系统管理员', 'ADMIN'); INSERT INTO t_house (house_no, community, address, area, floor_no, orientation, rent_price, status, remark) VALUES ('B3-1402', '阳光花园', '3栋14层02号', 89.50, '14/18', '南', 3200.00, 0, '精装修,带家电'), ('A1-0503', '翠湖天地', '1栋5层03号', 76.00, '5/11', '东', 2800.00, 1, '近地铁'); INSERT INTO t_tenant (tenant_name, phone, id_card, job) VALUES ('张明', '13812345678', '320102199001011234', '程序员'), ('李慧', '13987654321', '320102199505054321', '设计师'); INSERT INTO t_contract (contract_no, house_id, tenant_id, sign_date, start_date, end_date, monthly_rent, deposit, pay_method, status) VALUES ('HT2025001', 2, 1, '2025-01-01', '2025-01-10', '2026-01-09', 2800.00, 2800.00, '季付', 1); INSERT INTO t_bill (contract_id, bill_type, bill_period, amount, due_date, pay_status, pay_time) VALUES (1, '租金', '2025-01', 2800.00, '2025-01-05', 1, '2025-01-05 10:00:00'), (1, '租金', '2025-02', 2800.00, '2025-02-05', 1, '2025-02-05 09:30:00'), (1, '租金', '2025-03', 2800.00, '2025-03-05', 0, NULL);这里有一个很多人做错的点:密码到底存不存明文。课程设计为了演示方便存明文能省事,但文档里要明确写一句“生产环境必须改为 BCrypt 加盐哈希”。业务上另一个值得注意的设计是:退款或者退租时不删除合同,而是把状态改成 0,让历史账单还能追查。这套预置数据故意只做了三笔账单、一笔合同,保证系统跑起来后“到期账单查询”这些功能有内容可查,又不会数据多到干扰截图和答辩演示。
3. 用 Swing 把界面搭起来:登录窗口、主界面和数据表格
3.1 登录窗口与 JFrame 布局参数
Swing 做登录窗口,套路是固定的:一个JFrame设定大小和关闭行为,上面放两个输入框和一个按钮。但布局管理器选哪个、窗口怎么居中、回车键能不能触发登录,这些细节决定了这个窗口像不像个正经软件。我一般用GridBagLayout,它虽然写起来啰嗦,但能精确控制组件位置和拉伸权重。
public class LoginFrame extends JFrame { private JTextField usernameField; private JPasswordField passwordField; private JButton loginButton; public LoginFrame() { setTitle("房屋租赁管理系统 - 登录"); setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); setSize(380, 220); setLocationRelativeTo(null); setResizable(false); JPanel panel = new JPanel(new GridBagLayout()); GridBagConstraints gbc = new GridBagConstraints(); gbc.insets = new Insets(8, 8, 8, 8); gbc.fill = GridBagConstraints.HORIZONTAL; gbc.gridx = 0; gbc.gridy = 0; panel.add(new JLabel("用户名:"), gbc); gbc.gridx = 1; gbc.weightx = 1.0; usernameField = new JTextField(16); panel.add(usernameField, gbc); gbc.gridx = 0; gbc.gridy = 1; gbc.weightx = 0; panel.add(new JLabel("密 码:"), gbc); gbc.gridx = 1; gbc.weightx = 1.0; passwordField = new JPasswordField(16); panel.add(passwordField, gbc); gbc.gridx = 1; gbc.gridy = 2; gbc.weightx = 0; loginButton = new JButton("登 录"); panel.add(loginButton, gbc); add(panel); setVisible(true); } }GridBagConstraints里最常用到的参数是gridx/gridy(组件所在的网格坐标)、weightx/weighty(布局变大时该组件占据的空间权重)和insets(组件之间的间距)。setLocationRelativeTo(null)是让窗口在屏幕上居中,这行容易漏,漏掉之后窗口默认出现在左上角,观感很差。setResizable(false)对登录窗口合适,但对后面的主界面就不能用,数据表格需要拉伸。
登录按钮接入回车键时,要给按钮调getRootPane().setDefaultButton(loginButton),这样按回车自动触发点击。这个小细节很多课程演示现场手忙脚乱地拿鼠标点按钮时,就显得好用不少。
3.2 用 JTabbedPane 做主界面功能区
登录成功后的主窗口,我更喜欢用JTabbedPane而不是铺一堆按钮的“功能菜单式”布局。原因很直接:租赁管理每天高频用到的工作台、房源管理、租客管理、合同账单这几个区域,做成 Tab 页切换最少点击,业务员不用记菜单路径。Tab 页数量控制在四到五个,标签文字用两个字或四个字,比如“工作台”“房源”“租客”“合同”“账单”。
public class MainFrame extends JFrame { public MainFrame(String loginUser) { setTitle("房屋租赁管理系统 - 欢迎:" + loginUser); setDefaultCloseOperation(WindowConstants.EXIT_ON_CLOSE); setSize(1024, 680); setLocationRelativeTo(null); JTabbedPane tabbedPane = new JTabbedPane(); tabbedPane.setTabPlacement(JTabbedPane.TOP); tabbedPane.addTab("工作台", new DashboardPanel()); tabbedPane.addTab("房源", new HousePanel()); tabbedPane.addTab("租客", new TenantPanel()); tabbedPane.addTab("合同", new ContractPanel()); tabbedPane.addTab("账单", new BillPanel()); add(tabbedPane, BorderLayout.CENTER); } }每个 Tab 页对应一个独立的自定义面板类,这样做的好处是各自的表格、按钮和查询逻辑互不干扰,主窗口代码非常干净。setTabPlacement默认就在顶部,写出来是提醒自己团队里的新手别随便改成左边或底部,那会让表格可用宽度变小。主窗口大小我习惯设为 1024×680,这个尺寸在 20 寸以上显示器上能展示表格完整列,又能容纳大多数笔记本屏幕。
3.3 JTable 展示数据:DefaultTableModel 的正确用法
房源列表和账单列表是这套系统使用频率最高的两块界面,实现核心是JTable加DefaultTableModel。JTable本身只管显示,数据由TableModel提供。你不需要继承AbstractTableModel去手写那些抽象方法,用一个DefaultTableModel实例,通过setDataVector或循环addRow就能把数据库查出来的数据灌进表格。
public class HousePanel extends JPanel { private JTable table; private DefaultTableModel model; public HousePanel() { setLayout(new BorderLayout(0, 10)); JButton refreshBtn = new JButton("刷新"); JPanel topBar = new JPanel(new FlowLayout(FlowLayout.LEFT)); topBar.add(refreshBtn); add(topBar, BorderLayout.NORTH); String[] columns = {"ID", "房号", "小区", "面积", "租金", "状态"}; model = new DefaultTableModel(columns, 0) { @Override public boolean isCellEditable(int row, int column) { return false; } }; table = new JTable(model); table.setRowHeight(28); table.getTableHeader().setReorderingAllowed(false); add(new JScrollPane(table), BorderLayout.CENTER); refreshBtn.addActionListener(e -> loadData()); loadData(); } private void loadData() { model.setRowCount(0); // 这里调用 Service 层查询,先留空,第4章补实现 } }DefaultTableModel(columns, 0)的第一个参数是表头数组,第二个是初始行数,改成 0 表示空表格模型。重写isCellEditable返回false非常关键,否则用户在界面上单击单元格就能改数值,而且改完不落库,容易产生“我明明改了怎么数据库没变”的困惑。setReorderingAllowed(false)禁止表头列拖拽换位,避免业务员无意中把“租金”和“状态”两列调换后看错数据。
JTable数据显示必须包一层JScrollPane,不包的话表头不会固定在窗口顶部,而且表格没有滚动条,数据超过窗口高度时就翻车了。加载数据时先model.setRowCount(0)清空旧数据再循环添加新行,比addRow前手动删除旧行少暴露数据错乱的概率。
3.4 事件监听与查询逻辑分离
界面上的“添加房源”“删除合同”“标记已缴”这些按钮,事件监听里直接写 SQL 是代码腐化的开始。监听器里只做三件事:取界面参数、调 Service 方法、刷新界面数据。比如点击“添加房源”按钮,getSource()是按钮实例,从输入框取出文本,拼成实体对象传给 Service,Service 返回主键 ID,界面再调用loadData()刷新表格。
loginButton.addActionListener(e -> { String username = usernameField.getText().trim(); String password = new String(passwordField.getPassword()).trim(); if (username.isEmpty() || password.isEmpty()) { JOptionPane.showMessageDialog(LoginFrame.this, "用户名和密码不能为空"); return; } boolean ok = new LoginService().checkLogin(username, password); if (ok) { dispose(); new MainFrame(username).setVisible(true); } else { JOptionPane.showMessageDialog(LoginFrame.this, "用户名或密码错误"); } });new String(passwordField.getPassword())是JPasswordField的标准取值方式,不要用getText(),后者已被标记为废弃。登录失败时用JOptionPane.showMessageDialog弹窗,同时保留当前窗口让用户改密码。判断用户名密码为空要在拼接参数之前做,这里简单的逻辑可以提前拦截大多数误触回车导致的空查询。
值得强调的是,登录查询用了真实的 Service 方法而不是直接在监听器里写 JDBC,这样后续把系统改成数据库连接池、把密码校验换成加密算法时,界面代码不需要动。桌面程序的维护难度往往不在某个窗口写得多复杂,而在业务规则散落在一堆按钮监听器里,等到你找不到某条规则在哪触发时,就会后悔当初没做这层分离。
4. JDBC 连接与业务逻辑:连接参数、工具类和 Service 分层
4.1 连接参数:MySQL 版本不同,驱动类和 URL 不一样
JDBC 连接 MySQL 的第一步是确认你用的 MySQL 版本,这决定了驱动类名和 URL 参数。MySQL 5.x 用com.mysql.jdbc.Driver,MySQL 8.x 之后改成com.mysql.cj.jdbc.Driver,如果驱动类写错,程序启动时直接抛ClassNotFoundException。另一个坑是时区参数,8.x 版本不在 URL 里指定serverTimezone,连接时会报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,原因是 MySQL 服务器时区没写进 JDBC 的识别表。
| 环境 | 驱动类 | JDBC URL |
|---|---|---|
| MySQL 5.7 + mysql-connector-java 5.x | com.mysql.jdbc.Driver | jdbc:mysql://localhost:3306/house_rental?useSSL=false&useUnicode=true&characterEncoding=utf8 |
| MySQL 8.0 + mysql-connector-java 8.x | com.mysql.cj.jdbc.Driver | jdbc:mysql://localhost:3306/house_rental?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 |
useSSL=false在本地开发时必须显式声明,否则 8.0 驱动默认尝试 SSL 握手,日志里会多出好几行警告,某些网络环境下还会拖慢连接建立。characterEncoding=utf8负责数据库读写时的中文字符集转换,漏掉这一项是中文乱码最常见的原因。参数里的house_rental要替换成你自己的数据库名,账号密码默认 root 和安装时设置的密码,不要带着默认密码直接交付。
4.2 一个顺手够用的 DBHelper 工具类
数据库连接不该在每个窗口类里通过DriverManager.getConnection获取,那样同一时间点每个窗口各开各的连接,资源浪费还难排查。我习惯先写一个静态工具类,统一管理驱动加载、连接获取和资源释放。
import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; public class DBHelper { private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/house_rental" + "?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "你的密码"; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL 驱动加载失败,检查 mysql-connector-java 是否在依赖中", e); } } private DBHelper() { } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(ResultSet rs, Statement stmt, Connection conn) { 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) {} } } }静态代码块里的Class.forName(DRIVER)保证类首次加载时就完成驱动注册,如果项目里忘了放 MySQL 驱动 jar,异常会在这里第一时间爆发,错误信息“驱动加载失败”比连接时再报错更容易定位。工具类的构造函数设为private,防止别人new DBHelper()制造出无意义实例。close方法按ResultSet、Statement、Connection的顺序倒序释放,因为结果集依赖语句、语句依赖连接,先关连接再关结果集会残留资源。
这里加一个提醒:密码硬编码在常量类里对项目演示够用,但当你把这个项目放进简历或团队仓库时,至少改成读取src/main/resources/db.properties配置文件。JDBC 连接配置和代码分离是进入开发现场的第一条规矩,数据库密码换掉时不用重新编译整个程序。
4.3 Service 层封装:把 SQL 从界面里挖出来
有了DBHelper,接下来就要把各类业务查询封装到对应的 Service 类中。拿登录来说,LoginService只负责校验账号密码;拿房源查询来说,HouseService只负责返回房源列表。界面层不接触Connection和PreparedStatement,它只拿到业务结果。
import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; public class HouseService { public List<String[]> queryHouseList(String keyword) { List<String[]> rows = new ArrayList<>(); String sql = "SELECT id, house_no, community, area, rent_price, " + "CASE status WHEN 0 THEN '空置' WHEN 1 THEN '已租' ELSE '维修中' END " + "FROM t_house WHERE community LIKE ? OR house_no LIKE ? ORDER BY id DESC"; try (Connection conn = DBHelper.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { String likePattern = "%" + keyword + "%"; ps.setString(1, likePattern); ps.setString(2, likePattern); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { rows.add(new String[]{ String.valueOf(rs.getInt("id")), rs.getString("house_no"), rs.getString("community"), rs.getString("area"), rs.getString("rent_price"), rs.getString("status") }); } } } catch (SQLException e) { e.printStackTrace(); } return rows; } }PreparedStatement的值要占位符?,不允许把参数直接拼接进 SQL 字符串。上面LIKE ?配合%keyword%能实现模糊搜索,如果直接"WHERE community LIKE '%" + keyword + "%'",用户输入一个单引号就可能让整条 SQL 崩溃,这就是老生常谈的 SQL 注入。Java 8 开始Connection、Statement、ResultSet都实现了AutoCloseable,所以try-with-resources语法可以自动关闭连接,不必手动调用DBHelper.close三件套。
CASE status WHEN 0 THEN '空置'这种写法是把数据库里的数字状态翻译成人类可读的中文,在 SQL 层完成映射比在 Java 里写一堆if/else if更符合查询直觉。返回值用List<String[]>是因为DefaultTableModel恰好接受String[]数组作为一行数据,省去在界面层再转型的代码。等你做到后面,可以给每张表配一个实体类,比如House对象,那是更规范的做法,但小型桌面系统用String[]传递行列数据效率最高、也最好懂。
4.4 登录校验的具体实现与参数传递
登录逻辑在系统里只会写一次,但它是理解“界面和业务分离”的最小例子。LoginService接收界面传来的用户名和密码,去t_user表里查有没有匹配记录,返回布尔值给界面弹窗或跳转。
public class LoginService { public boolean checkLogin(String username, String password) { String sql = "SELECT COUNT(*) FROM t_user WHERE username = ? AND password = ?"; try (Connection conn = DBHelper.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); ps.setString(2, password); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { return rs.getInt(1) > 0; } } } catch (SQLException e) { e.printStackTrace(); } return false; } }这里查询条件把密码直接放进了 SQL,是因为演示系统里t_user.password存的是明文。如果在第 2 章你按生产标准做了加盐哈希,那登录逻辑就要改成查“用户名是否存在”再在内存里比对哈希。逻辑上说,只按用户名查出用户记录,然后比密码摘要,更接近真实系统的做法。SELECT COUNT(*)返回的是第一列整数,所以读取时用rs.getInt(1)即可。用户名和密码的setString顺序必须与?出现的顺序一致,这个顺序错位是 JDBC 排错里最隐蔽的问题之一。
5. 避坑指南:中文乱码、连接失败、界面假死和打包问题排查
5.1 中文乱码:数据库读出 ????,Swing 界面上显示问号
现象:程序启动后,房源表里的小区名、租客姓名在 Swing 界面上显示成???,或者通过程序插入的“阳光花园”在 MySQL 命令行里变成????。
原因:字符集不统一。常见的有三类:数据库或表不是utf8mb4;JDBC URL 没有带characterEncoding=utf8;Java 源码文件本身不是 UTF-8 编码,IDE 默认 GBK 时中文字符串在编译期已经损坏。
解决:按三层逐一检查。第一层,建库建表时明确DEFAULT CHARACTER SET utf8mb4,对老库执行ALTER TABLE t_house CONVERT TO CHARACTER SET utf8mb4补救。第二层,JDBC URL 加上&characterEncoding=utf8,MySQL 8.0 还要确认serverTimezone=Asia/Shanghai。第三层,IDEA 里把 File Encoding 全部设为 UTF-8,并且在javac编译时指定-encoding UTF-8。这三层只要有一层不匹配,乱码就会阴魂不散。
5.2 数据库连接失败:Communications link failure 到底是谁的锅
现象:启动程序点击登录,控制台报Communications link failure或Access denied for user 'root'@'localhost',程序直接崩溃。
原因:前三位的嫌疑犯分别是 MySQL 服务没启动、用户名密码错误、驱动版本和数据库版本不匹配。其中端口被占用和防火墙拦截在本地开发中也会出现,但不常见。
解决:先用netstat -ano | findstr :3306确认 MySQL 端口在监听;再用命令行mysql -u root -p验证账号密码能否手动登录;最后核对 pom.xml 或 lib 目录里的驱动 jar 版本。如果你是跟着 mysql 安装配置教程装数据库时装偏了版本,比如驱动用 8.x 连 5.7,URL 还沿用老的com.mysql.jdbc.Driver,就会看到ClassNotFoundException,换成com.mysql.cj.jdbc.Driver后立刻恢复。另外localhost和127.0.0.1在某些 MySQL 用户授权表里是两个不同的账号,连接地址要和授权 Host 一致。
5.3 Swing 界面点击查询后假死:窗口变成“黑匣子”
现象:点击“加载数据”按钮后整个窗口无法拖动、无法关闭,标题栏显示“未响应”,有时候过几秒恢复,有时候永远卡死。
原因:这是 Swing 单线程模型的经典陷阱。按钮的ActionListener默认在事件分发线程EDT(Event Dispatch Thread)执行,你在监听器里执行 JDBC 查询、扫描大表、写文件,EDT 被阻塞,界面就无法处理重绘和点击事件。
解决:耗时操作丢到新线程,查询完成后再通过SwingUtilities.invokeLater回到 EDT 更新表格。
refreshBtn.addActionListener(e -> { refreshBtn.setEnabled(false); new Thread(() -> { List<String[]> data = new HouseService().queryHouseList(""); SwingUtilities.invokeLater(() -> { model.setRowCount(0); for (String[] row : data) { model.addRow(row); } refreshBtn.setEnabled(true); }); }).start(); });setEnabled(false)能防止用户在线程执行期间重复点击产生并发查询。invokeLater里更新表格能保证界面数据刷新和重绘发生在 EDT 上,不会出现半个表格刷新的撕裂现象。如果你不想手动管理线程,用SwingWorker<List<String[]>, Void>更强,但新手阶段先用Thread + invokeLater看清线程模型,等理解透了再换装备。
5.4 打包成 jar 后双击没反应:缺驱动和清单文件
现象:在 IDEA 里点 Run 一切正常,导出成可执行 jar 后双击图标没有任何反应,或者命令行执行java -jar house.jar直接报NoClassDefFoundError: com/mysql/cj/jdbc/Driver。
原因:这是 Fat Jar(带依赖的 jar)没打全。IDEA 的默认 Build Artifact 只打包项目自己的 class,MySQL 驱动在外部依赖里,自然不会被包含。另一个可能是MANIFEST.MF里没有Main-Class声明,jvm 不知道入口在哪。
解决:如果项目是 Maven,在pom.xml里配置maven-assembly-plugin或maven-shade-plugin打 fat jar;如果是纯 IDEA 工程,Artifacts 配置时把mysql-connector-java的 “Output Layout” 拖进 jar 包。验证是否可用:构建后执行jar tf target/house.jar | grep mysql,看到com/mysql/cj/jdbc/Driver.class才算成功。打完包之后用java -jar从命令行启动保留控制台输出,很多运行时错误能直接看到堆栈,比双击黑匣子靠谱得多。
5.5 JDBC 驱动找不到:lib 目录里的 jar 形同虚设
现象:程序编译通过,一运行时抛java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver,把 jar 放在项目根目录或桌面都没用。
原因:classpath 没有包含 jar。IDE 里必须把 jar 添加为 Library 或者用 Maven 声明依赖,文件存在但不在 classpath 上等于白放。
解决:IDEA 里选中mysql-connector-java-8.0.x.jar右键Add as Library,或者在pom.xml中声明mysql依赖后执行mvn dependency:copy-dependencies把所有依赖打到 target 目录。这里有一条血泪经验:不要在联网状态下一路点重试,下到一半的 jar 文件常常几百字节,放到 classpath 里会报奇怪的Bad magic number错误,看见这个报错就删掉 jar 重新下载。
6. 从“能跑”到“好用”:事务边界、到期提醒和交付物检查清单
系统界面和 CRUD 都跑通后,真正让它从课程设计升级成“能交付的内部工具”,靠的是三个进阶细节。第一是事务控制,比如管理员把一笔账单标记为已缴时,要同时更新账单状态和合同的租期记录,这两个操作必须在一个事务里,否则出现“钱收了但系统说没缴”就麻烦。用Connection手动控制事务时,conn.setAutoCommit(false)后执行多条 SQL,全部成功后conn.commit(),任何一步失败就conn.rollback(),注意这里不能把Connection放进 try-with-resources,否则commit前连接就被自动关了。
第二是合同到期提醒,这是我非常推荐加的一个功能。查询今天到未来七天内的合同,用 SQL 的DATEDIFF(end_date, CURDATE()) BETWEEN 0 AND 7,在“工作台”Tab 页用一个醒目的红色表格展示。这个小功能对房东的实际价值最大,但成本很低,大约只需要一个查询、一个表格和一个刷新按钮。第三类细节是给程序写一个简单的启动日志文件,比如在main方法里把启动时间和关键报错写入app.log,员工用系统时遇到问题,远程看一眼日志就能定位是数据库没启动还是代码里某个 NullPointer。
最后是交付物检查清单。源码目录要保证不含本机绝对路径的配置文件;数据库脚本house_rental.sql必须能在一台全新的 MySQL 上从头执行成功;文档至少要写清三部分:功能模块说明加每个窗口截图、表结构数据字典、从安装 MySQL 到启动 jar 的逐步流程。文档不用长,让一个没接触过项目的人能按步骤跑起来就够了。我自己曾经交付项目时漏掉了 MySQL 8.0 的serverTimezone参数,对方装完连不上,远程排查了半小时,后来把抢修过程补进了文档,之后类似的求助电话就再没响过。希望这套从表设计到打包排查的经验帮到你——做 Swing 项目不复杂,耐着性子把数据层和界面层拆干净,后面每一步都走得稳。
本文还有配套的精品资源,点击获取