简介:基于Java Swing与MySQL实现的仓库管理系统设计源码,面向Java初、中级开发者及需要快速搭建仓储管理应用的学生与团队,覆盖商品入库、出库、库存查询、权限控制与报表生成等核心环节。压缩包共39个文件、约8.45MB,以15个Java源文件为核心,配合10个JAR运行依赖包、7个XML配置文件、Git忽略文件、IML与属性配置以及说明文档和图片,工程结构完整,便于导入IDE运行和二次修改。目前已有118人浏览学习。源码按数据访问层、实体类、工具类、界面框架与配置模块分层组织,弹窗示例和组件示例直观展示Swing布局与交互方式;研读DAO层与事务处理代码,还可掌握MySQL表设计和仓库管理业务流程的落地方法,整体可读性强,适合课程设计、毕业设计或小型仓储项目参考。
1. 仓库管理系统这套Swing+MySQL源码,为什么至今仍是练手首选
如果只给你三天时间交一个能演示的仓库管理系统,用 Java Swing 加 MySQL 是大多数人兜底最稳的路线。Swing 不需要额外的前端框架和 Node 环境,MySQL 本地装完就能建库,整套源码从建表语句到界面事件都摊在明面上,改动范围可控,不像接一个前后端分离的工业级项目那样有黑匣子。这套组合解决的是中小仓库最朴素的需求:商品建档、入库、出库、库存查询和基础统计,数据量在几万行以内完全撑得住。它适合三类人:做课程设计的学生,想给内网团队做桌面工具的后端开发,以及第一次接触桌面数据库应用、想把 Java 基础串起来的自学者。这篇就按我实际搭过的思路,把表设计、JDBC 连接、Swing 交互和排查顺序一次讲清。
2. 先设计表结构再动手写界面:四个核心表与数据流向
2.1 分三层的源码结构:界面、业务、数据访问各管什么
拿到一份“设计源码”,最怕的是上千行代码全挤在 JFrame 子类里。Swing 的写法天然鼓励“把逻辑写在按钮监听器里”,因为这样离数据最近,写完立刻能跑。但仓库管理这个题目,商品、库存、流水、用户四类数据互相牵制,全堆在一起,后期改一个字段要翻三四个类,改完还会互相踩。我一般把源码按三层拆:界面层放 JFrame、JPanel、对话框和 TableModel,只负责展示和收集输入;业务层放入库、出库、盘点这些操作,负责参数校验和决定更新哪些表;数据访问层放 SQL 和 JDBC 代码,每类实体对应一个 DAO 类,例如 ProductDao、StockDao、StockRecordDao。
拆完之后最直接的收益是排查路径变短:界面点按钮没反应,先看监听器调用的业务方法;某个 SQL 报错,直接去 DAO 里找语句;如果要换数据库方言,只需要动数据访问层。初学者最容易犯的错是把 DAO 层当成“写着玩的文件夹”,结果业务逻辑里到处 new Connection,换库的时候欲哭无泪。源码里只要出现 DAO 或者 Service 字样,就应该坚持这个边界,别让按钮监听器里出现 SELECT 开头的字符串。
2.2 流水表加汇总表:为什么库存表要拆成两张
很多初版实现只建一张出入库明细表,查询当前库存时用 SUM 现算,数据量小看不出问题,录上几万条流水后每次打开列表都明显变慢;反过来只存一张当前库存表又是另一种翻车,只 update 当前值,历史明细全丢,月底对账无据可查。仓库管理系统里最常见的正确做法是把两者都保留:t_stock 只存每个商品当前有多少,作为查询和判断库存是否足够的依据;t_stock_record 记每一次入库、出库的明细,作为追溯和报表依据。写代码时把“更新汇总表、插入流水表”放进同一个数据库事务里,两张表不会出现不一致。
这个设计的成本只有一张表的代码量,收益却很实在。做库存列表时查 t_stock,一行一个商品,界面秒出;做流水查询或按日期统计时查 t_stock_record,按时间倒序分页。两个查询各司其职,都不用做全表聚合。还有一个容易被忽略的细节:流水表里必须记录变动前后的库存值 before_quantity 和 after_quantity,这样将来就算汇总表被改错,也能靠流水把账算回来,这是给“后悔药”留的后门。
2.3 建库建表脚本:核心字段与外键一次到位
-- 建库:统一用 utf8mb4,避免中文和生僻字乱码 CREATE DATABASE IF NOT EXISTS warehouse_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE warehouse_db; -- 系统用户表 CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录名', password VARCHAR(64) NOT NULL COMMENT '密码摘要,示例用MD5,正式环境换加盐哈希', real_name VARCHAR(50) COMMENT '姓名', role TINYINT NOT NULL DEFAULT 0 COMMENT '0管理员 1仓管员', created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间' ) ENGINE=InnoDB COMMENT='系统用户表'; -- 商品信息表 CREATE TABLE t_product ( id INT PRIMARY KEY AUTO_INCREMENT, sku_code VARCHAR(32) NOT NULL UNIQUE COMMENT '商品编码', name VARCHAR(100) NOT NULL COMMENT '商品名称', category VARCHAR(50) COMMENT '分类', spec VARCHAR(100) COMMENT '规格型号', unit VARCHAR(10) DEFAULT '件' COMMENT '计量单位', price DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '参考单价', safe_stock INT NOT NULL DEFAULT 0 COMMENT '库存预警下限', created_at DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB COMMENT='商品信息表'; -- 库存汇总表:一个商品一条记录,只存当前量 CREATE TABLE t_stock ( id INT PRIMARY KEY AUTO_INCREMENT, product_id INT NOT NULL UNIQUE COMMENT '商品ID,与商品一一对应', quantity INT NOT NULL DEFAULT 0 COMMENT '当前库存数量', updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, CONSTRAINT fk_stock_product FOREIGN KEY (product_id) REFERENCES t_product(id) ) ENGINE=InnoDB COMMENT='库存汇总表'; -- 出入库流水表:每次变动一条记录,用于追溯 CREATE TABLE t_stock_record ( id INT PRIMARY KEY AUTO_INCREMENT, product_id INT NOT NULL COMMENT '商品ID', record_type TINYINT NOT NULL COMMENT '1入库 2出库', quantity INT NOT NULL COMMENT '变动数量,正数', before_quantity INT NOT NULL COMMENT '变动前库存', after_quantity INT NOT NULL COMMENT '变动后库存', operator_id INT COMMENT '操作人,关联t_user.id', remark VARCHAR(255) COMMENT '备注,如采购入库/销售出库', created_at DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_record_product FOREIGN KEY (product_id) REFERENCES t_product(id) ) ENGINE=InnoDB COMMENT='出入库流水表';这段脚本里有几个参数决定了后续能不能顺利跑起来。第一是 ENGINE=InnoDB,事务和行级锁都靠它,MyISAM 不支持事务,入库更新库存和写流水必须原子执行,所以千万不能把引擎换成 MyISAM。第二是字符集,建库时指定 utf8mb4,比 utf8 多支持一部分字符,连接 URL 里还要配合 characterEncoding=utf8 才不会乱码。第三是外键,product_id 被库存表和流水表引用,能阻止插入不存在的商品记录,代价是删除商品时会受约束,这个坑在第 5 章单独讲。COMMENT 注释建议保留,写代码时能看到字段含义,后面生成报表也方便。
2.4 初始化测试数据:账号商品库存一次铺好
建完表后库里是空的,界面上什么都看不到。我习惯把测试数据直接写进一个 init.sql,每次重装环境执行一遍就能恢复到可演示状态:
INSERT INTO t_user (username, password, real_name, role) VALUES ('admin', MD5('123456'), '系统管理员', 0); INSERT INTO t_product (sku_code, name, category, unit, price, safe_stock) VALUES ('SKU-001', 'A4复印纸', '办公耗材', '箱', 89.00, 10), ('SKU-002', '中性笔', '办公耗材', '盒', 12.50, 20), ('SKU-003', '无线鼠标', '外设', '个', 45.00, 5); INSERT INTO t_stock (product_id, quantity) SELECT id, 50 FROM t_product;库存初始化用 SELECT id FROM t_product 而不是手写具体 ID,这样无论商品表数据怎么调整,库存始终和外键对齐。第三段的 SELECT 写法也适合在新增商品后自动补一条库存记录:先插商品,再执行一次 INSERT INTO t_stock SELECT id, 0 FROM t_product WHERE id = 某个值。这里提前铺三条商品和初始库存,是因为入库出库操作需要真实数据来演示,空库状态下按钮点了没有反馈,演示容易翻车。
3. JDBC 连接 MySQL:驱动参数、DBHelper 封装与查询的正确写法
3.1 驱动版本与 URL 参数:时区、SSL、编码一个都不能少
先把连接参数讲清楚,因为这段配置是整套源码里最容易报错的地方。MySQL 8.x 用的驱动类名是 com.mysql.cj.jdbc.Driver,老教程里常见的 com.mysql.jdbc.Driver 是 5.x 时代的写法,照抄到 8.x 驱动下类名不存在,直接 ClassNotFoundException。我一般把 URL 拆成多行拼接,每个参数单独占一行,出问题也好排查:
private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/warehouse_db" + "?useSSL=false" + "&serverTimezone=Asia/Shanghai" + "&characterEncoding=utf8" + "&allowPublicKeyRetrieval=true" + "&rewriteBatchedStatements=true";几个参数的作用分别是:useSSL=false 关闭 SSL 握手,本地开发环境不需要加密,少了 SSL 相关告警;serverTimezone=Asia/Shanghai 解决连接时报 “The server time zone value” 错误,这是 8.x 驱动强制要求时区导致的;characterEncoding=utf8 和建库的 utf8mb4 配套,保证中文在传输过程中不乱码;allowPublicKeyRetrieval=true 解决 MySQL 8 默认认证插件 caching_sha2_password 下连不上报 “Public Key Retrieval is not allowed” 的问题。rewriteBatchedStatements=true 是可选的,批量插入流水时性能提升明显,数据量小的系统加了也无副作用。还有个细节:URL 里多个参数用 & 连接,但在 XML 配置文件里需要写成 &,如果是从配置中心读取就没事。
这里把最常见的驱动连接报错整理成一张对照表,排查时直接对号入座:
| 报错片段 | 通常原因 | 处理方法 |
|---|---|---|
| ClassNotFoundException: com.mysql.cj.jdbc.Driver | jar 不是 8.x 驱动 | 换驱动包并改用 cj 类名 |
| The server time zone value | 未指定时区 | URL 加 serverTimezone=Asia/Shanghai |
| Public Key Retrieval is not allowed | 认证插件不兼容 | 加 allowPublicKeyRetrieval=true |
| Unknown database 'warehouse_db' | 库名拼错或未建库 | 先执行第 2 章建库脚本 |
| Access denied for user | 账号密码或权限不对 | 核对 USER/PASSWORD 和远程访问权限 |
3.2 统一封装 DBHelper:连接获取与资源释放
连接管理最好集中在一个类里,而不是每个 DAO 各写一套,这样改数据库账号、换驱动、加日志都只动一个文件。一个够用的 DBHelper 长这样:
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/warehouse_db" + "?useSSL=false&serverTimezone=Asia/Shanghai" + "&characterEncoding=utf8&allowPublicKeyRetrieval=true"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError("MySQL驱动加载失败,请检查mysql-connector的jar包"); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } 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) { } } } }驱动的 Class.forName 放在 static 块里,类第一次被加载时只执行一次,省得每个 DAO 重复注册。close 方法接收三个资源对象,内部逐个判空关闭,谁先创建谁后关:先关 ResultSet,再关 Statement,最后关 Connection,这个顺序能避免底层连接池的占用。注意 USER 和 PASSWORD 直接写死在类里,这套源码如果只在本机跑没问题,要提交到团队协作或部署到内网,建议改成从 config.properties 读取,否则别人拿到源码第一件事就是改密码和 URL,改完还可能因为编码问题读不到——这也是常见的翻车点。
提示:DBHelper 的字段全部用 private static final,连接参数集中管理。开发机上先用本地测试库,别直接拿生产库账号试。
3.3 用 PreparedStatement 做增删改查,别碰字符串拼接
JDBC 里查询最忌讳的是拼 SQL,比如String sql = "SELECT * FROM t_user WHERE username='" + username + "'"。这种写法在带登录功能的项目里纯属送人头,输入框里填一个引号就能让语句语义改变,还会遇到整型字段空值拼接报错。正确写法是预编译占位符:
public User findUser(String username, String password) { String sql = "SELECT id, real_name, role FROM t_user " + "WHERE username = ? AND password = MD5(?)"; 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()) { User u = new User(); u.setId(rs.getInt("id")); u.setRealName(rs.getString("real_name")); u.setRole(rs.getInt("role")); return u; } } } catch (SQLException e) { e.printStackTrace(); } return null; }占位符从 1 开始编号,setString 的参数顺序必须和 SQL 里问号的顺序一一对应,顺序错了数据就查错人。这里把 MD5 求值放在 SQL 端,也可以先在 Java 里算好摘要再 setString,两种都能跑,区别是前者把密码逻辑留在数据库。try-with-resources 语法会自动关闭 Connection 和 PreparedStatement,省掉了 finally 里的样板代码,这在小项目里很实用,但要注意它只能管住 try 块内创建的连接,如果连接是外面传进来的,关闭时机还是要由调用方控制。每次查询都重新获取连接,对这个体量的桌面应用完全没问题,不需要为它提前引入连接池。
4. Swing 界面这样搭:主窗体、库存表格与入库出库操作
4.1 主窗体骨架:菜单、工具栏与选项卡布局
Swing 界面第一刀切在主窗体结构上。仓库管理系统的功能就四块:库存列表、入库登记、出库登记、流水查询,用 JTabbedPane 做成选项卡比开四个 JFrame 窗口体验好得多,窗口多了用户容易找不到,任务栏也乱。我习惯把主窗体代码保持精简,只负责组装面板:
public class MainFrame extends JFrame { public MainFrame() { setTitle("仓库管理系统"); setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); setSize(1024, 680); setLocationRelativeTo(null); JTabbedPane tabs = new JTabbedPane(); tabs.addTab("库存列表", new StockPanel()); tabs.addTab("入库登记", new InboundPanel()); tabs.addTab("出库登记", new OutboundPanel()); tabs.addTab("流水查询", new RecordPanel()); setContentPane(tabs); } public static void main(String[] args) { SwingUtilities.invokeLater(() -> new MainFrame().setVisible(true)); } }main 方法里套一层 SwingUtilities.invokeLater 是必须的,Swing 组件只能在事件分发线程(EDT)上创建和修改,直接 new JFrame 在偶发情况下会拿到不可见的窗口或者布局错乱。setSize 和 setLocationRelativeTo 组合让窗口居中显示,1024x680 对库存列表和表单都够用。四个 Panel 各自是独立类,哪里出问题改哪里,后期加一个“盘点”选项卡只需新增一个 JPanel 子类并加一行 addTab。
4.2 库存列表不卡界面:JTable 配 TableModel 加 SwingWorker 刷新
库存列表是整个系统的门面,用户打开窗口第一眼就是它。直接用 JTable 默认模型也能显示数据,但列名会变成 A、B、C,列宽不可控,所以要自定义 AbstractTableModel。刷新按钮的点击事件里如果直接执行 JDBC 查询,数据库一慢整个窗口就变成“未响应”,正确做法是把查询丢给 SwingWorker 后台线程:
public class StockPanel extends JPanel { private JTable table; private StockTableModel model; private JButton btnRefresh; public StockPanel() { setLayout(new BorderLayout()); model = new StockTableModel(); table = new JTable(model); table.setRowHeight(26); add(new JScrollPane(table), BorderLayout.CENTER); btnRefresh = new JButton("刷新"); btnRefresh.addActionListener(e -> refreshAsync()); add(btnRefresh, BorderLayout.SOUTH); refreshAsync(); } private void refreshAsync() { btnRefresh.setEnabled(false); new SwingWorker<List<Object[]>, Void>() { @Override protected List<Object[]> doInBackground() { return new StockDao().findAll(); } @Override protected void done() { try { model.setRows(get()); model.fireTableDataChanged(); } catch (Exception ex) { JOptionPane.showMessageDialog(StockPanel.this, "查询失败:" + ex.getMessage()); } finally { btnRefresh.setEnabled(true); } } }.execute(); } }doInBackground 在后台线程执行 JDBC 查询,done 切回 EDT 更新表格,这样查询耗时再久,窗口也能正常拖动缩放。SwingWorker 的 execute 方式是异步的,点击刷新后马上返回,不会阻塞界面。配套的 TableModel 需要实现四个方法:
public class StockTableModel extends AbstractTableModel { private final String[] columns = {"商品编码", "商品名称", "分类", "库存", "单位", "参考单价"}; private List<Object[]> rows = new ArrayList<>(); public void setRows(List<Object[]> rows) { this.rows = rows; } @Override public int getRowCount() { return rows.size(); } @Override public int getColumnCount() { return columns.length; } @Override public String getColumnName(int col) { return columns[col]; } @Override public Object getValueAt(int row, int col) { return rows.get(row)[col]; } }setRows 只是替换内存中的数据,真正让表格重绘的是 fireTableDataChanged(),它通知 JTable 行数可能变化,需要重新渲染。数据用 Object[] 数组承载简单直接,列顺序和 columns 数组严格对齐;如果嫌数组不安全,也可以改成 List ,getValueAt 里按列号手动取值。库存量这一列建议在 SQL 里就 CAST 成 INT,让 getValueAt 返回数值类型,否则默认右对齐和排序行为都会跑偏。
4.3 入库出库的数据层:一个事务里更新库存并写流水
入库和出库是镜像操作,核心逻辑只有一处不同:入库数量为正,出库数量为负。但无论哪种,都要保证“库存表被更新”和“流水表被插入”同时成功或同时失败,否则就会出现库存变了但没记录,或者有记录但库存没变的脏账。使用事务的写法如下(以入库为例):
public void doInbound(int productId, int quantity, int operatorId, String remark) throws SQLException { String sqlQuery = "SELECT quantity FROM t_stock WHERE product_id = ? FOR UPDATE"; String sqlUpdate = "UPDATE t_stock SET quantity = quantity + ? WHERE product_id = ?"; String sqlInsert = "INSERT INTO t_stock_record" + "(product_id, record_type, quantity, before_quantity, after_quantity, operator_id, remark) " + "VALUES (?, ?, ?, ?, ?, ?, ?)"; try (Connection conn = DBHelper.getConnection()) { conn.setAutoCommit(false); int before; try (PreparedStatement psQuery = conn.prepareStatement(sqlQuery)) { psQuery.setInt(1, productId); try (ResultSet rs = psQuery.executeQuery()) { if (!rs.next()) { throw new SQLException("库存记录不存在,请先初始化该商品的库存"); } before = rs.getInt(1); } } int after = before + quantity; try (PreparedStatement psUpdate = conn.prepareStatement(sqlUpdate)) { psUpdate.setInt(1, quantity); psUpdate.setInt(2, productId); psUpdate.executeUpdate(); } try (PreparedStatement psInsert = conn.prepareStatement(sqlInsert)) { psInsert.setInt(1, productId); psInsert.setInt(2, 1); psInsert.setInt(3, quantity); psInsert.setInt(4, before); psInsert.setInt(5, after); psInsert.setInt(6, operatorId); psInsert.setString(7, remark); psInsert.executeUpdate(); } conn.commit(); } catch (SQLException e) { throw new SQLException("入库失败,已回滚:" + e.getMessage(), e); } }conn.setAutoCommit(false) 表示后续语句不再每条自动提交,直到手动 commit。SELECT ... FOR UPDATE 把商品对应的库存行锁住,防止两个人同时操作同一商品时算出相同的 before 值,这在桌面应用多开窗口时尤其重要。若中途任何一条 SQL 抛异常,try 块会退出导致 Connection 关闭,未提交的事务被数据库自动回滚,所以不需要显式 rollback 也安全。出库方法把 SQL 里的加号换成减号,并在 after 小于 0 时抛异常拒绝执行,同理可以整理共用一个 doStockChange(recordType) 方法。
5. Swing+MySQL 避坑指南:五类高频问题的排查方法
5.1 中文乱码:建库、连接、编译三个环节逐个查
现象:界面上显示“???”或者从数据库查询出来的中文全是问号,插入的中文也存成问号。原因往往不是单点,而是三个环节至少一处断裂:建库字符集不是 utf8mb4、连接 URL 没带 characterEncoding=utf8、Java 源文件编译时用了平台默认编码。解决方法是逐一确认:先看建库语句有没有 DEFAULT CHARACTER SET utf8mb4;再看 URL 参数里 characterEncoding 是否为 utf8,注意这里写 utf8 而不是 utf8mb4,驱动按 utf8 传输即可覆盖;最后检查 IDE 的编译选项把编码设为 UTF-8,Maven 工程则要在配置里加 project.build.sourceEncoding。最省事的检验办法是执行一条 SELECT HEX(name) FROM t_product WHERE id=1,如果中文对应的十六进制是 E5 开头,说明存储正常,乱码出在显示层;如果是 3F,就是存储阶段已经坏了。
5.2 Too many connections:连接泄漏问题与排查命令
现象:程序跑一段时间后报 “Too many connections”,重启又正常,再过一会又报。原因绝大多数是 JDBC 连接没有被关闭,常见于查询抛异常后 finally 里没关 Connection,或者忘了关 PreparedStatement、ResultSet,底层连接一直被占用。解决思路分两步。第一步用 SHOW PROCESSLIST 看当前连接状态,重点找大量 Sleep 的连接,它们的来源 IP 和端口通常集中在同一个进程,说明是某个 DAO 方法反复拿连接却不还;第二步检查所有 DAO 方法,确保每条路径上都执行了 DBHelper.close 或使用 try-with-resources,特别是 catch 分支里提前 return 的情况。这里有个经验:不要在 finally 里只关 Connection 不关 Statement 和 ResultSet,三样都要成对处理,否则连接即使归还了,语句资源还挂着。
5.3 一操作界面就卡死:JDBC 查询阻塞了事件线程
现象:点击刷新或登录按钮后整个窗口变成灰白色,标题栏显示“未响应”,过几秒才恢复。原因是按钮监听器直接在 EDT 上执行了 JDBC 查询,数据库响应慢或网络有延迟时,事件分发线程被阻塞,重绘、鼠标事件全部排队。解决方法是把所有数据库操作放进后台线程,最简单的就是第 4 章用到的 SwingWorker:doInBackground 执行查询,done 里更新组件。另一个容易忽略的点是,像 JOptionPane.showMessageDialog 这类模态对话框要留在 EDT 上调用,别放进 doInBackground,否则弹窗出现在错误的事件上下文。判断方法很简单:在查询前打印一条日志,如果窗口卡住时点击其他按钮没有任何反应,基本就是 EDT 被占用。
5.4 商品删不掉:外键约束与删除顺序
现象:删除一个商品时 SQL 报错 “Cannot delete or update a parent row: a foreign key constraint fails”。原因:t_product 被 t_stock 和 t_stock_record 的外键引用,只要库存表或流水表里还有这个商品的记录,主表就不能直接删。解决方法是按依赖关系倒着删:先删除 t_stock_record 里该商品的所有流水,再删除 t_stock 里的库存记录,最后删 t_product。如果业务上不允许删除历史流水,常见做法是给商品表加一个 is_deleted 字段,删除操作变成 UPDATE is_deleted=1,查询列表时默认过滤掉,这就是逻辑删除。桌面系统里逻辑删除的成本很低,而且保留了完整历史,对仓库这种需要审计的场景更有价值。另一个相关坑是新增商品时忘了同步插库存记录,入库操作就会报“库存记录不存在”,解决方法是把商品新增和库存初始化的两个 INSERT 放在同一个事务或同一个 DAO 方法里。
5.5 驱动版本与 MySQL 不匹配:连跑都跑不起来的典型报错
现象:启动程序就报 “ClassNotFoundException: com.mysql.cj.jdbc.Driver” 或 “Communications link failure”,前者是驱动类不存在,后者是协议版本不兼容。原因:工程里放的是 5.x 的 mysql-connector-java 驱动包,却连了 MySQL 8.x 数据库,或者反过来,驱动太老不认识新版数据库的认证插件。解决方法是统一成 MySQL 8.x 时代的驱动,类名用 com.mysql.cj.jdbc.Driver,导入 jar 时注意别出现两个版本的驱动包同时出现在 classpath,这种冲突最隐蔽,编译不报错,运行才炸。检查驱动的快捷方式是在 IDE 的依赖列表里搜 mysql,确认只有一个 jar,版本和数据库实例大版本一致。数据库服务端如果是 5.7,用 8.x 驱动也能向下兼容,反而更稳。
6. 从能跑到能演示:代码整理、密码加盐与导出报表的小技巧
源码能跑只是及格线,演示和答辩看的是代码习惯。三个改动不大但加分明显的小技巧值得做。第一,把所有 SQL 字符串收进 DAO 类,界面层看不到一条 SELECT,检查代码的人一眼就能看出分层意识;顺带把散落的 System.out.println 换成统一日志输出,没引日志库就先用 e.printStackTrace() 打到控制台,也比到处是调试输出强。第二,登录密码不要用裸 MD5,演示代码里为了省事可以用,但要在注释里写明正式场景要换加盐哈希,比如 SHA-256 加随机盐;盐存单独字段,校验时读出来重新计算对比。第三,加一个“导出 CSV”按钮,仓库管理的报表是高频需求,用 StringBuilder 拼行比用 Excel 库轻量得多:
public static void exportCsv(List<Object[]> rows, File file) throws IOException { StringBuilder sb = new StringBuilder(); sb.append('\uFEFF'); // 加BOM,防止Excel打开中文乱码 sb.append("商品编码,商品名称,当前库存,单位\n"); for (Object[] r : rows) { sb.append(r[0]).append(',').append(r[1]) .append(',').append(r[2]).append(',') .append(r[3]).append('\n'); } Files.write(file.toPath(), sb.toString().getBytes(StandardCharsets.UTF_8)); }CSV 开头写一个 \uFEFF(BOM),Excel 才能正确识别 UTF-8 编码,不写的话双击打开全是乱码,这个细节能省下一堆答疑时间。字段内容如果包含逗号和引号,记得用双引号包一层并转义内部引号,否则导出文件列会错位。导出前先跑一遍完整入库出库流程,确认数据总量和时间范围,演示时打开导出的文件核对几条关键的出入库记录,这一套验证下来基本不会在台上翻车。
我最早做这套方案时把 SQL 全写在按钮监听器里,改一个报表需求要从界面类找到数据库层,前后改了一整晚,后来老老实实按 DAO 分层才算止损。仓库管理系统用 Swing 加 MySQL 不新,但胜在链路短、边界清楚,把表结构和事务这两块地基打牢,后续加什么功能都接得住。希望帮到你。
本文还有配套的精品资源,点击获取