Java Swing进销存系统源码拆解:数据库事务与JDBC实战
简介Java Swing进销存管理系统源码包面向Java初学者及需要完成课程设计或毕业设计的开发者。系统涵盖客户、商品、供应商管理进货单、销售单处理库存盘点以及客户、商品、销售、入库等多维查询统计并支持操作权限管理可帮助企业或学生快速理解传统桌面端进销存业务流程。资源共325个文件包含75个Java源文件、169个编译后的class文件、56张界面运行截图、7份设计文档、6个依赖jar包以及sqlserver2000数据库文件ldf/mdf压缩包大小4.47MB结构清晰便于对照学习和二次开发。已有599人学习下载适合需要参考完整项目源码、数据库脚本及界面设计的人员直接部署运行也可作为SwingSQL Server开发实战的入门范本。1. 为什么这个Swing进销存项目值得拆一遍进销存管理系统是传统企业信息化里最常见的场景而Java Swing作为桌面端技术在很长一段时间里承担了这类业务系统的落地任务。这个源码包里的SystemMain、SaleGoods、GoodsInfo等class文件实际上对应了一个完整的Swing应用——客户、商品、供应商、进货、销售、库存盘点全部都在JFrame里实现。对于还在用Java做项目、或者准备Java面试的人来说这份代码的价值不只是“能运行”而是能让你看到老一代桌面项目是怎么组织类、SQL怎么和界面交错的。sqlserver2000虽然现在很少用了但表结构和JDBC操作思路完全能平移到MySQL和PostgreSQL上。想搞清楚Swing项目为什么这么分层以及编译后的class到底怎么还原出可维护的源码这篇拆解能给一个相对完整的答案。2. 模块拆解与SQL Server 2000下的库表设计2.1 五个功能模块的边界与调用关系从摘要里能看出这个系统把功能拆成了五个业务域信息管理负责客户、商品、供应商的基础档案维护业务管理处理进货单和销售单库存管理做盘点查询统计覆盖业务查询系统管理收敛操作员账号。这里“边界”的定义是每个模块只操作自己的核心表跨模块数据通过单号或外键关联。比如业务管理中的销售单并不会直接改库存表而是通过“销售出库”这个动作去触发库存数量变更。这在class文件里体现为SaleGoods和GoodsInfoDao的协作。我在拆这类项目时习惯先把类名映射到表和功能如表2-1所示。类名对应的表或功能说明GuestInfo客户表客户档案CRUDGoodsInfo商品表商品信息及库存数量SeverInfo供应商表供应商档案InGoods进货单采购入库流水SaleGoods销售单销售出库流水InsertGoodsInfo商品入库操作新商品登记JSalesQuery销售查询按条件组合查询GoodsInfoDao / SaleDao数据访问对象JDBC操作封装SystemMain主窗体菜单入口与权限控制表格定义了拆分的基础。实际操作时我一般会先把SystemMain里定义的菜单项列出来再对照每个菜单项的ActionListener去定位对应的业务类。这个过程能快速搞清楚哪些类是界面哪些是业务哪些是数据库操作。2.2 数据库表设计客户、商品、供应商、进货单、销售单、操作员由于系统明确标注是sqlserver2000所以建表脚本要遵循SQL Server 2000的语法不支持IF NOT EXISTS之后的CREATE TABLE合写也不支持NVARCHAR(MAX)只能用VARCHAR(8000)或者用TEXT。常见的做法就是每个表单独建并显式声明主键和外键。以商品表和进货单为例建表脚本大致是这样-- 商品信息表 CREATE TABLE GoodsInfo ( GoodsID INT IDENTITY(1,1) PRIMARY KEY, GoodsName VARCHAR(100) NOT NULL, Spec VARCHAR(50) NULL, Unit VARCHAR(10) NULL, PurchasePrice DECIMAL(10,2) NULL, SalePrice DECIMAL(10,2) NULL, StockQuantity INT NULL DEFAULT 0 ) -- 进货单表 CREATE TABLE InGoods ( InGoodsID INT IDENTITY(1,1) PRIMARY KEY, GoodsID INT NULL FOREIGN KEY REFERENCES GoodsInfo(GoodsID), SupplierID INT NULL, InQuantity INT NULL, InPrice DECIMAL(10,2) NULL, InDate DATETIME NULL, Operator VARCHAR(50) NULL )GoodsID用宿主键IDENTITY(1,1)生成这个在JDBC里可以用Statement.getGeneratedKeys()拿回刚插入的ID。PurchasePrice和SalePrice用DECIMAL(10,2)避免浮点误差这是做金额字段的基本要求。进货单里只放InQuantity和InPrice总金额在查询时用算式得出不在表里冗余存储符合第三范式。实际项目里为了查询速度也有冗余TotalAmount列的做法但在这个源码设计里并不需要。供应商表和销售单表结构类似不再重复。需要特别注意的是外键约束如果商品被某张订单引用那么在删除商品前要先清理该商品的引用否则SQL Server 2000会报外键冲突错误。早期老系统里经常有级联删除但后来都改成约束加业务校验原因就是误删会带来数据不可逆。2.3 外键关系与事务控制在设计数据关系时这一层和查询层是分离的。进货单InGoods和销售单SaleGoods都依赖GoodsInfo.GoodsID供应商表又被InGoods引用。查询统计模块中的销售查询、入库查询本质上都是对这些表做连接查询。问题在于库存字段直接放在GoodsInfo.StockQuantity上而进货和销售则写流水表。也就是说每做一次进货不仅要往InGoods插入一条记录还要同步UPDATE GoodsInfo SET StockQuantity StockQuantity 数量。这两条SQL必须在一个事务里执行否则会出现“流水存在但库存没变”的脏数据。JDBC中事务控制的经典写法是Connection conn null; try { conn DriverManager.getConnection(url, user, password); conn.setAutoCommit(false); PreparedStatement ps1 conn.prepareStatement( INSERT INTO InGoods (GoodsID, SupplierID, InQuantity, InPrice, InDate, Operator) VALUES (?,?,?,?,?,?)); // 给ps1设置参数后执行 ps1.executeUpdate(); PreparedStatement ps2 conn.prepareStatement( UPDATE GoodsInfo SET StockQuantity StockQuantity ? WHERE GoodsID ?); ps2.setInt(1, inQuantity); ps2.setInt(2, goodsId); ps2.executeUpdate(); conn.commit(); } catch (Exception e) { if (conn ! null) conn.rollback(); } finally { if (conn ! null) conn.close(); }这段代码里setAutoCommit(false)是关键参数调用它之后两条更新语句只有在commit()时才会真正生效任何一条抛异常回滚后数据库会恢复原状。实际开发中我会把连接获取和事务逻辑再封装一层避免每个DAO都写重复的try-catch。SQL Server 2000不支持WITH (UPDLOCK)以外的乐观锁语法所以并发度不高时这种行级锁通过UPDATE语句自动获取不需要显式锁定。这样设计下来每个业务动作要么全做要么全不做数据的完整性才能保住。这是拆完这个项目之后一个很有价值的启示界面类再乱只要数据库事务和约束设计得清楚系统就不会崩溃到哪去。3. Swing界面与业务代码实现3.1 MyEclipse下搭建运行环境运行这份源码之前先要解决“class文件怎么变成可执行项目”的问题。下载包里提供的是编译后的class但一般也会带上Java源文件或者在MyEclipse中导入后可重新编译。开发环境推荐用MyEclipse 8.x以上版本它内置了J2EE容器和数据库连接工具对老项目的兼容性比新版本的IDEA还好一些。先创建一个普通Java Project把源码解压后复制到src目录然后把sqlserver2000的JDBC驱动sqljdbc.jar或msbase.jar加入Build Path。数据库连接URL的格式如下jdbc:microsoft:sqlserver://127.0.0.1:1433;DatabaseNameinventory;SelectMethodcursor127.0.0.1是本地数据库地址1433是SQL Server默认监听端口inventory是数据库名。最后那个SelectMethodcursor参数是早期驱动连接游标型结果集时需要的如果你用com.microsoft.sqlserver.jdbc.SQLServerDriver新驱动也可以把这参数去掉。很多初学者连不上库问题不在代码而在于SQL Server 2000默认只开了本地连接需要先到“企业管理器-服务器网络实用工具”中启用TCP/IP并确保端口是1433。导入工程后先运行SystemMain这个主类。如果控制台报“找不到类”检查源码目录下是否有SystemMain.class对应的.java如果只有class文件可以用IDE反编译插件如JD-Eclipse还原出源码来看。注意class文件的反编译结果可能丢失注释但类名、方法签名和局部变量名基本都能保留够用来理解逻辑。3.2 主窗体SystemMain菜单与事件分发这个系统的主窗体类名是SystemMain本质上是一个继承JFrame的类。它负责创建菜单栏、工具栏和几个内部面板响应菜单点击后弹出对应的业务窗口。典型结构是public class SystemMain extends JFrame { private JMenuBar menuBar; private JMenu saleMenu; private JMenuItem saleItem; public SystemMain() { initUI(); } private void initUI() { setTitle(进销存管理系统); setBounds(200, 200, 900, 600); menuBar new JMenuBar(); saleMenu new JMenu(业务管理); saleItem new JMenuItem(销售单); saleItem.addActionListener(e - new SaleGoods(this).setVisible(true)); saleMenu.add(saleItem); menuBar.add(saleMenu); setJMenuBar(menuBar); } public static void main(String[] args) { System.out.println(使用Swing启动请确保sqlserver2000已启动); new SystemMain().setVisible(true); } }亮点在e - new SaleGoods(this).setVisible(true)这行。this被传给子窗口目的是让子窗口在保存成功后能回调主窗口刷新数据这是一种简单的观察者关系。因为Swing是单线程模型所有UI组件都必须在EDTEvent Dispatch Thread上操作所以不要在事件回调里执行耗时数据库操作否则界面会卡死。正确做法是启动一个后台线程做查询查到结果后再用SwingUtilities.invokeLater回到EDT更新JTable模型。3.3 进货单与销售单如何往数据库写业务数据进货单和销售单是最容易出错的模块因为要同时更新业务表和库存表。以销售单为例界面里会有一个表格显示商品列表操作员填写数量、单价后点击“保存”。保存逻辑不能只是单条INSERT而应该在一个事务里完成。下面是简化的保存代码public boolean saveSale(SaleGoods sale, ListSaleDetail detailList) throws SQLException { String sqlSale INSERT INTO SaleGoods (SaleDate, CustomerID, TotalAmount, Operator) VALUES (?,?,?,?);; String sqlUpdateStock UPDATE GoodsInfo SET StockQuantity StockQuantity - ? WHERE GoodsID ? AND StockQuantity ?; Connection conn DBHelper.getConnection(); boolean saved false; try { conn.setAutoCommit(false); PreparedStatement ps conn.prepareStatement(sqlSale, Statement.RETURN_GENERATED_KEYS); // 设置销售单日期、客户ID等参数 ps.setTimestamp(1, new Timestamp(System.currentTimeMillis())); ps.setInt(2, sale.getCustomerId()); ps.setBigDecimal(3, sale.getTotalAmount()); ps.setString(4, sale.getOperator()); ps.executeUpdate(); // 从销售单拿到生成的ID ResultSet keys ps.getGeneratedKeys(); int saleId 0; if (keys.next()) { saleId keys.getInt(1); } for (SaleDetail detail : detailList) { // 插入销售详情的SQL并设置saleId等字段 // 再执行更新库存的SQL PreparedStatement psDetail conn.prepareStatement(INSERT INTO SaleDetail (SaleID, GoodsID, SaleQuantity, SalePrice) VALUES (?,?,?,?)); psDetail.setInt(1, saleId); psDetail.setInt(2, detail.getGoodsId()); psDetail.setInt(3, detail.getQuantity()); psDetail.setBigDecimal(4, detail.getPrice()); psDetail.executeUpdate(); PreparedStatement psStock conn.prepareStatement(sqlUpdateStock); psStock.setInt(1, detail.getQuantity()); psStock.setInt(2, detail.getGoodsId()); psStock.setInt(3, detail.getQuantity()); psStock.executeUpdate(); } conn.commit(); saved true; } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.close(); } return saved; }这段代码有两个关键参数值得注意。第一Statement.RETURN_GENERATED_KEYS配合getGeneratedKeys()能拿到数据库自增主键SQL Server 2000的IDENTITY列是支持这个功能的。第二UPDATE GoodsInfo SET StockQuantity StockQuantity - ? WHERE GoodsID ? AND StockQuantity ?里的AND StockQuantity ?是一个数据库层面的“防超卖”检查如果库存不够本次UPDATE影响行数为0那么后面的executeUpdate()不会抛异常需要手动判断返回值并回滚。如果这份源码里的SaleDao没做这个判断你也可以自己把它加上。增强后的逻辑是影响行数不等于1就抛出RuntimeException外层事务回滚提示“库存不足”。面试中聊到这类业务时能讲出这个细节比单纯说“我做过一个进销存系统”要更有说服力。即便没有超卖场景这个参数化条件也是防止负数库存的好习惯。4. 查询统计与库存盘点的SQL实战4.1 销售查询与入库查询的参数化SQL查询统计模块里JSalesQuery类负责销售数据的组合条件查询。用户输入起止日期、商品名、客户名点查询后获得结果。这个场景最大的坑在于“条件不确定”——有的条件可能是空的。常规做法是动态拼SQL但要避免用字符串直接拼接用户输入否则会被注入。正确写法是用PreparedStatement的占位符按条件追加public ListSaleInfo querySales(String startDate, String endDate, String goodsName) { StringBuilder sql new StringBuilder(); sql.append(SELECT s.SaleID, c.CustomerName, g.GoodsName, si.SaleQuantity, si.SalePrice, s.SaleDate ); sql.append(FROM SaleGoods s ); sql.append(LEFT JOIN CustomerInfo c ON s.CustomerID c.CustomerID ); sql.append(LEFT JOIN SaleDetail si ON s.SaleID si.SaleID ); sql.append(LEFT JOIN GoodsInfo g ON si.GoodsID g.GoodsID ); sql.append(WHERE 11 ); ListObject params new ArrayList(); if (startDate ! null !startDate.isEmpty()) { sql.append(AND s.SaleDate ? ); params.add(startDate 00:00:00); } if (endDate ! null !endDate.isEmpty()) { sql.append(AND s.SaleDate ? ); params.add(endDate 23:59:59); } if (goodsName ! null !goodsName.isEmpty()) { sql.append(AND g.GoodsName LIKE ? ); params.add(% goodsName %); } // 执行PreparedStatement并遍历ResultSet }WHERE 11并不是多余而是为了方便后续无条件追加SQL片段。params列表保存了所有占位符对应的值之后按顺序绑定即可。LIKE ?配合%写在值里而不是拼在SQL里这样即使输入 or 11 --也不会被解释成SQL语法。对于老工程师来说这种写法是基本功但对刚毕业的求职者来说能主动写出PreparedStatement和参数绑定是加分项。SQL Server 2000的模糊查询默认不区分大小写但中文环境下有时候取决于排序规则遇到查询结果不匹配时先检查Chinese_PRC_CI_AS排序规则。4.2 库存盘点数量核对与差异报表库存盘点模块在代码里对应GoodsInfo的修改和汇总。盘点的核心是想知道“数据库库存数量”和“实际盘点的数量”差多少。通常做法是先把盘点结果暂存到一个盘点表然后与GoodsInfo.StockQuantity比对。下面这段SQL可以查出所有库存差异超过容忍度的商品SELECT g.GoodsName, g.StockQuantity AS db_qty, p.CheckQuantity AS real_qty, (p.CheckQuantity - g.StockQuantity) AS diff_qty FROM GoodsInfo g JOIN InventoryCheck p ON g.GoodsID p.GoodsID WHERE ABS(p.CheckQuantity - g.StockQuantity) 0 ORDER BY diff_qty DESC这个查询把“盘点数量”和“账面数量”的差作为diff_qty再用ABS过滤出有差异的记录。实际业务中可能还会加一个“允许误差范围”参数比如允许diff_qty在±2以内不计盈亏此时把 0换成 ?把参数设到界面里。SQL Server 2000里没有ABS函数不行所以这里的ABS是可行的。盘点的结果不只是报表还要决定是否更新库存。如果经过确认要把库存调整为实盘数就用一条UPDATE GoodsInfo SET StockQuantity ? WHERE GoodsID ?。在更新前务必在事务里重新读取一次当前库存避免其他操作在你盘点后修改库存。更规范的做法是给GoodsInfo加一个LockVersion字段每次更新时SET StockQuantity ?, LockVersion LockVersion 1 WHERE GoodsID ? AND LockVersion ?影响行数为0说明版本冲突需要重新盘点。这个思路在Java面试里可能被问成“乐观锁是怎么实现的”这里正好是一个落地的案例。4.3 常见SQL Server 2000错误与解决办法跑这个源码时最容易遇到的问题集中在JDBC连接和数据类型上。我把高频错误和应对方式列出来错误提示原因解决办法No suitable driver found for jdbc:microsoft:sqlserver://驱动包没加载或URL不对把sqljdbc.jar放进Build Path并确认URL前缀是jdbc:microsoft:sqlserverConnection refused: connectSQL Server的TCP/IP未启用或端口不对在企业管理器中开启TCP/IP设置为1433然后重启服务Syntax error converting datetime from character string日期参数以字符串拼接进SQL改用PreparedStatement.setTimestamp()传递日期Cannot insert the value NULL into column GoodsID商品ID尚未赋值检查Statement.getGeneratedKeys()返回值确认主键已生成事务已回滚多条UPDATE中有一条影响行数为0判断每条executeUpdate()的返回值为0时主动抛出异常触发回滚其中日期参数的问题最常见。SQL Server 2000对日期字符串的本地化支持不好用setString传日期很容易碰到“语法转换错误”。统一用java.sql.Timestamp传入就能避开这个坑。另外老版驱动对PreparedStatement的setObject支持有限建议显式调用setString、setInt、setBigDecimal不要让驱动去猜类型。5. 把老Swing代码改造成现代Java项目的小技巧5.1 用javap快速还原Class文件的结构拿到手的是class而不是java时可以用javap -p -c来看每个类的方法和字节码。比如想确认SaleGoods里有没有校验库存运行javap -p -c SaleGoods.class对照字节码中的invokevirtual和getfield指令能判断出它调用了哪个DAO的哪个方法。虽然比直接看源码慢但遇到“源码丢失但只有class”的交付包时这是最快速的定位手段。若需要完整反编译使用JD-GUI这类工具就能把整个包里的类还原成Java再手工修正泛型和lambda差异。5.2 用连接池替换DriverManager很多旧JDBC代码都用DriverManager.getConnection一旦遇到并发访问频繁创建连接会拖垮性能。常见做法是用DBCP或C3P0但SQL Server 2000的驱动太老HikariCP不一定兼容稳妥方案是DBCP 1.4。配置方式如下driverClassNamecom.microsoft.jdbc.sqlserver.SQLServerDriver urljdbc:microsoft:sqlserver://127.0.0.1:1433;DatabaseNameinventory;SelectMethodcursor usernamesa password123456 maxActive20 maxIdle10 minIdle5 initialSize5把DriverManager替换成BasicDataSource后所有DAO都从dataSource.getConnection()拿连接。注意maxActive20是按单业务规模估的如果你的系统同时在线超过100人要改成50以上否则高峰期会出现“连接不够用”。能做到这一步说明你已经不只是在“跑通”而是在思考重构。5.3 给JTable加一个刷新监听器最后分享一个我常用的Swing技巧子窗口保存数据后主动刷新主表格。不要用“重新打开窗口”这种笨办法而是给主窗口的JTable定义一个refreshData()方法在子窗口保存成功后调用。具体做法是定义JTable的数据模型DefaultTableModel刷新时先setRowCount(0)再重新查询并填充。这样用户点击保存后立刻能在父窗口看到新数据不会有“数据去哪了”的错觉。这个技巧对进销存系统尤其重要因为操作员经常要连续录入多张单界面不刷新会让他们怀疑系统崩溃。代码很简单public void refreshData() { model.setRowCount(0); ListGoodsInfo goodsList goodsInfoDao.findAll(); for (GoodsInfo g : goodsList) { model.addRow(new Object[]{g.getId(), g.getName(), g.getStock()}); } }这段代码背后有两个容易被忽略的点第一setRowCount(0)要写在addRow之前不然每次刷新都会追加第二如果查询耗时较长先用SwingWorker在后台执行然后回到EDT更新表格否则大列表查询会卡住整个窗口。结合前面的事务设计和参数化SQL这个老项目的可维护性能上一个台阶。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →