2026/9/26 11:24:04

Java Swing + MySQL 仓库管理系统实战:从 Excel 台账到带事务的桌面应用

Java Swing + MySQL 仓库管理系统实战:从 Excel 台账到带事务的桌面应用 简介这份资源是一套基于Java Swing与MySQL的仓库管理系统设计源码面向学习Java桌面应用开发、课程设计或毕业设计的学生与开发者帮助理解GUI界面搭建与数据库交互的完整实现。压缩包共39个文件约8.45MB以15个Java源文件为核心覆盖入库、出库、库存查询、报表生成等业务逻辑另有10个JAR包提供依赖支持7个XML配置文件负责数据库连接与框架参数并附带属性文件、项目文件及图片资源目录结构清晰便于按模块阅读。系统采用Swing组件构建跨平台界面后端依托MySQL存储商品、库存、出入库记录与用户权限等数据通过表结构设计与事务机制保障数据一致性与操作安全同时包含表单校验、权限控制、操作日志与自定义报表导出等功能。目前已有117人学习适合作为理解面向对象设计与软件工程实践的参考案例。1. 从一张 Excel 台账说起Java Swing MySQL 仓库管理系统到底解决什么问题很多中小仓库、五金店、汽配档口的库存管理起点都是一张 Excel 台账。刚开始几十个 SKU 还能靠人眼扫等到品类上千、出入库一天几十单问题就集中爆发两个人同时改同一个文件后保存的把先保存的覆盖掉月底盘点发现账面数量和实物对不上却查不出是哪一笔出的错想按供应商、按时间段统计进货金额只能手动筛选复制。这套「基于 Java Swing 和 MySQL 的仓库管理系统」本质就是把这张 Excel 台账升级成一个带图形界面、带事务、带权限的桌面应用。它适合谁一是计算机、软件工程专业做课程设计或毕业设计的学生Java Swing MySQL 是经典组合资料多、上手快、答辩好讲二是想给自己小店做一套轻量进销存的小老板或兼职开发者不想买 SaaS、不想联网、数据放自己电脑上三是刚学完 Java 基础、想找一个完整项目练手的初学者Swing 的事件驱动模型和 JDBC 的增删改查能一次性练透。它不适合高并发、多仓库、多终端协同的场景那是 B/S 架构和微服务的活。这篇文章就按「界面怎么搭、数据库怎么设计、代码怎么串、坑在哪」的顺序把一套能跑起来的方案讲清楚。2. 技术选型与整体架构为什么是 Swing 加 MySQL而不是别的组合2.1 Swing 做桌面端的三个现实理由选 Swing 不是因为它是「最新技术」而是因为它在课程设计和轻量工具这个场景里性价比最高。第一JDK 自带不用装 Node、不用配前端构建工具javac编译完java -jar就能跑对新手极其友好。第二Swing 的组件模型JFrame、JPanel、JTable、JButton是纯 Java 的跨平台Windows 上写的界面拷到 Linux 上照样显示这对「跨平台」这个需求是天然满足的。第三事件监听机制ActionListener、MouseListener和 JDBC 的阻塞式调用配合得很自然一个按钮点下去查库、刷新表格逻辑线性不需要理解异步、Promise 那一套。当然要认清它的边界Swing 的界面观感停留在十年前做不出炫酷的动效它默认的单线程模型意味着耗时操作会卡界面后面会讲怎么用SwingWorker绕开。如果你的目标是做一个能上架、有设计感的商业软件Swing 不是好选择但如果目标是「三天内做出一个能演示、能答辩、能实际用的库存工具」它是最短的路径。2.2 MySQL 存什么、怎么连MySQL 在这个项目里承担持久化。选它而不是 SQLite 或文件核心原因是事务。仓库管理最怕的就是「出库扣了库存但出库单没写进去」这种半截操作MySQL 的 InnoDB 引擎支持事务能把「扣库存 写流水」绑成一个原子操作要么全成要么全滚。另外 MySQL 的AUTO_INCREMENT主键、外键约束、GROUP BY统计正好对应商品编号、分类关联、月度报表这些需求。连接方式用 JDBC驱动是mysql-connector-java。这里有个新手最容易翻车的点MySQL 8.x 的驱动类名是com.mysql.cj.jdbc.Driver不再是老教程里的com.mysql.jdbc.DriverURL 也要带时区和 SSL 参数。下面是一个最小可用的连接工具类import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { // MySQL 8.x 必须用 cj 驱动URL 带时区和禁用 SSL 提示 private static final String URL jdbc:mysql://localhost:3306/warehouse?useUnicodetruecharacterEncodingutf8 serverTimezoneAsia/ShanghaiuseSSLfalseallowPublicKeyRetrievaltrue; private static final String USER root; private static final String PASSWORD your_password; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new RuntimeException(MySQL 驱动未加载检查 jar 是否在 classpath, e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }逻辑说明静态代码块在类加载时注册驱动只执行一次getConnection每次返回新连接调用方用完必须关闭。参数说明serverTimezoneAsia/Shanghai不加会报时区错误useSSLfalse是本地开发省去证书配置生产环境应改为true并配证书allowPublicKeyRetrievaltrue解决 MySQL 8 默认加密插件下的连接拒绝。这套连接方式在单机、低并发下完全够用不需要一上来就上连接池。2.3 三层结构怎么分不要把所有代码堆在一个MainFrame里那样改一个字段要翻几百行。常见做法是分三层entity放实体类Product、StockRecorddao放数据库操作ProductDao、RecordDaoui放界面MainFrame、ProductPanel。界面只调 daodao 只碰数据库实体类只做数据载体。这样分工的好处是将来要把 Swing 换成 Web 前端dao 层几乎不用动。下面这张表是各层的职责和典型类层次职责典型类依赖entity数据载体字段与表列对应Product、StockRecord无dao增删改查、事务控制ProductDao、RecordDaoDBUtilui界面渲染、事件响应MainFrame、ProductPaneldaoutil连接、日期、校验工具DBUtil、DateUtil无分层不是形式主义它直接决定了你后期加功能时是「改一处」还是「改十处」。3. 数据库设计五张表撑起进销存字段和索引怎么定3.1 核心表结构与建表语句仓库管理的数据模型不复杂抓住「商品、分类、供应商、出入库流水、用户」五个实体就够了。商品表存静态信息流水表存动态变化库存数量可以由流水汇总得出也可以冗余在商品表里做快速查询——我一般两者都留商品表存当前库存用于列表展示流水表存每一笔变动用于追溯和对账。CREATE DATABASE IF NOT EXISTS warehouse DEFAULT CHARACTER SET utf8mb4; USE warehouse; -- 商品分类 CREATE TABLE category ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL UNIQUE ) ENGINEInnoDB; -- 供应商 CREATE TABLE supplier ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, contact VARCHAR(50), phone VARCHAR(20) ) ENGINEInnoDB; -- 商品 CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, code VARCHAR(32) NOT NULL UNIQUE, -- 商品编号业务唯一键 name VARCHAR(100) NOT NULL, category_id INT, supplier_id INT, price DECIMAL(10,2) DEFAULT 0.00, stock INT DEFAULT 0, -- 当前库存冗余字段 warn_line INT DEFAULT 10, -- 库存预警线 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_name (name), INDEX idx_category (category_id), FOREIGN KEY (category_id) REFERENCES category(id), FOREIGN KEY (supplier_id) REFERENCES supplier(id) ) ENGINEInnoDB; -- 出入库流水 CREATE TABLE stock_record ( id INT PRIMARY KEY AUTO_INCREMENT, product_id INT NOT NULL, type TINYINT NOT NULL, -- 1 入库2 出库 quantity INT NOT NULL, operator VARCHAR(50), remark VARCHAR(255), record_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_product (product_id), INDEX idx_time (record_time), FOREIGN KEY (product_id) REFERENCES product(id) ) ENGINEInnoDB; -- 用户 CREATE TABLE sys_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, -- 存 MD5 或 SHA-256不存明文 role TINYINT DEFAULT 1 -- 1 普通2 管理员 ) ENGINEInnoDB;逻辑说明product.code加唯一约束防止同一商品重复录入stock是冗余字段每次出入库在同一事务里更新它避免列表页每次都SUM流水stock_record上的idx_product和idx_time支撑「按商品查历史」和「按时间段统计」两个高频查询。参数说明金额用DECIMAL(10,2)而不是FLOAT浮点数算钱会出现0.10.20.30000000000000004这种玄学字符集统一utf8mb4否则商品名里的生僻字或 emoji 会存成问号。3.2 出入库事务一个方法保证账实一致出入库是这套系统的心脏也是最容易出 bug 的地方。正确做法是把「写流水」和「改库存」放进同一个事务任何一步失败就整体回滚。下面这个 dao 方法演示了完整流程public boolean stockIn(int productId, int qty, String operator, String remark) { String insertSql INSERT INTO stock_record(product_id,type,quantity,operator,remark) VALUES(?,?,?,?,?); String updateSql UPDATE product SET stock stock ? WHERE id ?; Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交开启事务 try (PreparedStatement ps1 conn.prepareStatement(insertSql); PreparedStatement ps2 conn.prepareStatement(updateSql)) { ps1.setInt(1, productId); ps1.setInt(2, 1); // 1 表示入库 ps1.setInt(3, qty); ps1.setString(4, operator); ps1.setString(5, remark); ps1.executeUpdate(); ps2.setInt(1, qty); ps2.setInt(2, productId); ps2.executeUpdate(); conn.commit(); // 两步都成功才提交 return true; } } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }逻辑说明setAutoCommit(false)之后两条 SQL 处于同一事务commit()之前任何异常都会走rollback()保证不会出现「流水写了库存没加」的脏数据。参数说明出库方法把type改成 2、stock ?改成stock - ?并且要在执行前先SELECT stock判断是否够扣不够就抛异常回滚。这里用PreparedStatement而不是字符串拼接既防 SQL 注入又避免商品名里带单引号导致语法错误。4. Swing 界面搭建从登录窗到主界面的可复现步骤4.1 登录窗口与主界面骨架Swing 界面的组织原则是「一个窗口一个 JFrame一个功能模块一个 JPanel」。登录窗验证通过后dispose()掉自己再new MainFrame()不要用多个 JFrame 互相嵌套。主界面用JTabbedPane把商品管理、出入库、统计报表分成几个标签页每个标签页塞一个自定义 JPanel结构清晰。public class LoginFrame extends JFrame { private JTextField userField; private JPasswordField passField; public LoginFrame() { setTitle(仓库管理系统 - 登录); setSize(360, 220); setLocationRelativeTo(null); // 居中显示 setDefaultCloseOperation(EXIT_ON_CLOSE); setLayout(new GridLayout(3, 2, 10, 10)); add(new JLabel(用户名, SwingConstants.RIGHT)); userField new JTextField(); add(userField); add(new JLabel(密码, SwingConstants.RIGHT)); passField new JPasswordField(); add(passField); JButton loginBtn new JButton(登录); loginBtn.addActionListener(e - doLogin()); add(new JLabel()); // 占位让按钮靠右 add(loginBtn); } private void doLogin() { String user userField.getText().trim(); String pass new String(passField.getPassword()); if (user.isEmpty() || pass.isEmpty()) { JOptionPane.showMessageDialog(this, 用户名和密码不能为空); return; } UserDao dao new UserDao(); if (dao.checkLogin(user, pass)) { dispose(); SwingUtilities.invokeLater(() - new MainFrame(user).setVisible(true)); } else { JOptionPane.showMessageDialog(this, 用户名或密码错误); } } }逻辑说明setLocationRelativeTo(null)让窗口屏幕居中比手动算坐标省事JPasswordField取密码用getPassword()返回 char 数组转字符串后交给 dao 做哈希比对登录成功后先dispose()再开主窗避免残留窗口。参数说明GridLayout(3,2,10,10)是 3 行 2 列、横纵间距各 10 像素界面不会挤在一起。4.2 JTable 绑定数据与实时刷新商品列表用JTable展示数据源用DefaultTableModel。关键点是查询返回ListProduct后先model.setRowCount(0)清空旧数据再逐行addRow最后table.repaint()。不要每次查询都new JTable那样会丢失列宽、排序状态和选中行。public class ProductPanel extends JPanel { private DefaultTableModel model; private JTable table; private ProductDao dao new ProductDao(); public ProductPanel() { setLayout(new BorderLayout()); String[] cols {ID, 编号, 名称, 分类, 单价, 库存, 预警线}; model new DefaultTableModel(cols, 0) { Override public boolean isCellEditable(int row, int col) { return false; // 表格只读编辑走弹窗 } }; table new JTable(model); table.setRowHeight(26); add(new JScrollPane(table), BorderLayout.CENTER); JButton refresh new JButton(刷新); refresh.addActionListener(e - loadData()); JPanel top new JPanel(new FlowLayout(FlowLayout.LEFT)); top.add(refresh); add(top, BorderLayout.NORTH); loadData(); } private void loadData() { model.setRowCount(0); for (Product p : dao.findAll()) { model.addRow(new Object[]{ p.getId(), p.getCode(), p.getName(), p.getCategoryName(), p.getPrice(), p.getStock(), p.getWarnLine() }); } } }逻辑说明重写isCellEditable返回 false防止用户直接在表格里改数据绕过校验JScrollPane包裹表格数据多了自动出滚动条loadData先清空再填充保证刷新后不重复。参数说明setRowHeight(26)让行高适合中文显示默认行高偏矮会显得拥挤。库存低于预警线的行可以用TableCellRenderer标红这是后面进阶部分的内容。5. 避坑与排查五个让新手卡半天的真实问题5.1 中文乱码数据库、连接、界面三处都要对现象商品名存进去变成???或者界面显示成方块。原因通常有三层数据库建库时没指定utf8mb4JDBC URL 没带characterEncodingutf8Swing 默认字体不支持中文。解决建库语句加DEFAULT CHARACTER SET utf8mb4URL 加编码参数界面统一设置字体new Font(微软雅黑, Font.PLAIN, 14)并递归应用到所有组件。三处缺一处都会乱码这是血泪经验。5.2 界面卡死耗时查询必须离开事件线程现象点「统计报表」按钮后窗口白屏、按钮点不动几秒后才恢复。原因Swing 的事件分发线程EDT是单线程的你在actionPerformed里直接跑一个几万行的查询界面就没机会重绘。解决把耗时操作放进SwingWorker的doInBackground查询完在done里更新表格。简单场景也可以用new Thread(() - {...}).start()但更新 UI 时必须SwingUtilities.invokeLater切回 EDT。5.3 连接泄漏Connection 不关跑一天就崩现象程序运行几小时后报Too many connections。原因每个 dao 方法getConnection()后忘了在finally里close()连接越积越多。解决所有数据库操作统一用 try-with-resources或者用连接池HikariCP、Druid。单机小工具用 try-with-resources 就够别嫌麻烦漏一个连接就是一颗定时炸弹。5.4 出库超卖并发下库存扣成负数现象两个操作员同时给同一商品出库库存 10各出 8结果库存变成 -6。原因先SELECT判断再UPDATE之间存在时间窗口两个线程都读到 10都认为够扣。解决把判断和扣减合并成一条带条件的 SQLUPDATE product SET stock stock - ? WHERE id ? AND stock ?根据executeUpdate()返回的影响行数判断是否成功返回 0 就是库存不足。这一条能根治超卖。5.5 驱动版本不匹配ClassNotFound 和时区报错现象启动报ClassNotFoundException: com.mysql.jdbc.Driver或者The server time zone value is unrecognized。原因用了 MySQL 8 的数据库却配了 5.x 的驱动类名或者 URL 没带serverTimezone。解决驱动类名改成com.mysql.cj.jdbc.DriverURL 加serverTimezoneAsia/Shanghaijar 包用mysql-connector-java 8.x。这两个报错几乎每个新手都会遇到一次记住就好。6. 进阶技巧库存预警标红与统计报表的 SQL 写法界面能跑通之后真正让这套系统「好用」的是两个细节库存预警的视觉提示和统计报表的聚合查询。先说预警标红用自定义TableCellRenderer判断库存列是否低于预警线低于就设背景色table.getColumnModel().getColumn(5).setCellRenderer(new DefaultTableCellRenderer() { Override public Component getTableCellRendererComponent(JTable t, Object value, boolean isSelected, boolean hasFocus, int row, int col) { Component c super.getTableCellRendererComponent(t, value, isSelected, hasFocus, row, col); int stock Integer.parseInt(value.toString()); int warn Integer.parseInt(t.getValueAt(row, 6).toString()); if (stock warn) { c.setBackground(new Color(255, 200, 200)); // 低于预警线标红 } else { c.setBackground(isSelected ? t.getSelectionBackground() : Color.WHITE); } return c; } });逻辑说明渲染器每次绘制单元格时被调用从同一行的第 6 列取预警线做比较注意isSelected时要还原选中色否则选中行看不出高亮。参数说明颜色用new Color(255,200,200)淡红比纯红柔和长时间看不刺眼。再说统计报表。月度进货金额、各分类库存占比这类需求用一条GROUP BY就能出结果不要查出来在 Java 里循环累加-- 各分类的库存总量和库存金额 SELECT c.name AS category, SUM(p.stock) AS total_stock, SUM(p.stock * p.price) AS total_value FROM product p LEFT JOIN category c ON p.category_id c.id GROUP BY c.id, c.name ORDER BY total_value DESC; -- 指定时间段内的出入库汇总 SELECT p.name, SUM(CASE WHEN r.type 1 THEN r.quantity ELSE 0 END) AS in_qty, SUM(CASE WHEN r.type 2 THEN r.quantity ELSE 0 END) AS out_qty FROM stock_record r JOIN product p ON r.product_id p.id WHERE r.record_time BETWEEN ? AND ? GROUP BY p.id, p.name;逻辑说明第一条用LEFT JOIN保证没有商品的分类也出现在结果里SUM(stock * price)直接算出库存金额第二条用CASE WHEN把入库和出库拆成两列一条 SQL 出结果比查两次再合并快得多。参数说明BETWEEN ? AND ?用占位符传日期注意结束日期要传当天的23:59:59否则会漏掉当天下午的数据这个坑我踩过不止一次。最后说一个习惯每次改完 dao 层的 SQL先在 MySQL 客户端里手动跑一遍确认结果对再写进 Java。界面报错信息往往被 Swing 吞掉直接看数据库返回什么比在代码里加一堆System.out.println高效得多。这套系统不大但把事务、索引、渲染器这几个点吃透你对桌面应用和数据库配合的理解会上一个台阶。希望帮到你。本文还有配套的精品资源点击获取