简介:面向Java初学者与毕业设计学生的仓库管理系统完整开发资料包,围绕库存流动跟踪、商品与价格管理等核心业务,展示从Java SE基础到Spring、MyBatis、Servlet/JSP的Web应用构建思路。压缩包共104个文件,包含14个Java源文件、73个编译后的class文件、1个SQL数据库脚本,以及相关的jar依赖、jpg/gif预览图片和说明文档,整体容量仅5.51MB,便于本地运行与二次开发。目前已有1067人学习下载,内容涵盖MVC设计模式、用户认证授权、数据库第三范式设计、IDEA集成开发环境使用及Git版本控制等关键知识点。借助源码、数据库脚本和项目文件,可快速理清仓库管理各模块的协作关系,适合用于毕业设计选题、期末项目开发或Java后端系统入门实践。
1. 从 Longing 到 StoreManage:一份Java仓库管理系统的类名反推
看到 Longing、TianJia、ChangeSupplier 这一串类名,基本不用看 UML 就能判断出项目的底细:Longing 明显是 login 的误拼,TianJia 是“添加”的拼音直译,这是典型的 Java 课程设计与毕业设计产物。这类项目通常不引入 Spring 全家桶,而是用 Java SE 配合 Swing 或 JSP 搭界面,再用 JDBC 操作 MySQL,把登录、商品维护、出入库、供应商管理和汇总统计串成一条完整业务链。对正处于 Java 学习路线中段、想用一个完整 Java 项目把语法、集合、JDBC 和界面编程串起来的人,这个规模刚刚好:小到能看清每个类的边界,大到能覆盖一个业务系统的常见模块。下文按类清单反推架构,逐个模块给出可复现的代码、参数细节和最容易翻车的位置。
2. ProductInfo 与 StoreManage:Java 实体建模和仓储容器怎么选
2.1 类名就是需求文档:十个 class 文件的职责归属
把类名直译成业务动作,系统模块划分基本不需要再猜:
| 类名 | 直译含义 | 归属模块 |
|---|---|---|
| Longing | 登录验证(login 误拼) | 用户认证 |
| ProductInfo | 商品实体 | 商品信息 |
| StoreManage | 仓库主控 | 核心控制层 |
| AddCargob | 货物入库 | 入库模块 |
| GoCargob | 货物出库 | 出库模块 |
| TianJia | 新增数据 | 通用写入 |
| Change | 修改数据 | 通用更新 |
| ChangeSupplier | 修改供应商 | 供应商管理 |
| addsupplier | 添加供应商 | 供应商管理 |
| ViewInfo | 查看汇总 | 数据统计 |
这张表反映出一个事实:这个项目的控制流是直来直去的。用户先通过 Longing 完成登录,进入 StoreManage 主窗口,再通过按钮事件触发 AddCargob、GoCargob 或 ChangeSupplier,最终落库到 MySQL。没有 Service 层,也没有 DAO 接口抽象,类与类之间是直接 new 出来的依赖。很多人在课程设计里用“一个类负责一个页面”的方式写代码,不是因为它规范,而是因为这样最不容易迷路。后面无论拆哪个模块,心里都先锚定这张表——类名、职责、流向三者对齐了,改代码才敢下手。
2.2 ProductInfo 实体:POJO 字段怎么映射到 MySQL 表
商品实体是整个系统的数据底座,它的字段设计直接决定表结构和后续所有操作的成本。常见做法是让实体字段与数据库列严格一一对应:
// ProductInfo.java —— 商品实体,字段名与表列名保持对齐 public class ProductInfo { private int productId; // 商品ID,主键 private String productName; // 商品名称 private String category; // 分类 private double price; // 售价,单位:元 private int stock; // 当前库存数量 private String supplierId; // 关联供应商编号 public ProductInfo() { // 无参构造,供 JDBC 反射或手动 new 使用 } public ProductInfo(int productId, String productName, String category, double price, int stock, String supplierId) { this.productId = productId; this.productName = productName; this.category = category; this.price = price; this.stock = stock; this.supplierId = supplierId; } // getter / setter 用 IDEA 的 Alt+Insert 生成,不再赘述 }注意 price 用的是 double,这在课程设计里很常见,但严格来说金额应该用BigDecimal。double 在十进制小数运算时会产生精度误差,比如 0.1 + 0.2 得到 0.30000000000000004。做演示无所谓,如果系统要算库存货值并保证账实一致,至少要换成BigDecimal或在 SQL 层用DECIMAL(10,2)兜底。supplierId 字段为什么不存供应商名称?因为名称会变,存了 ID 之后只要维护好 supplier 表,改一次名称所有商品自动生效,这就是规范化的基本思路。
对应的建表语句,推荐直接写成可重复执行的 SQL:
-- product_info 表:与 ProductInfo 实体一一对应 CREATE TABLE IF NOT EXISTS product_info ( product_id INT PRIMARY KEY AUTO_INCREMENT, product_name VARCHAR(64) NOT NULL, category VARCHAR(32) DEFAULT '未分类', price DECIMAL(10, 2) NOT NULL DEFAULT 0.00, stock INT NOT NULL DEFAULT 0, supplier_id VARCHAR(16), created_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP );逻辑说明:product_id用自增主键,避免手动分配编号产生冲突;stock用INT,并在应用层保证不为负;price用DECIMAL(10,2)表示最大 99999999.99 的金额范围,对仓库系统足够。列名统一用下划线命名,Java 里用驼峰,两者映射时在 DAO 层手动rs.getInt("product_id")时注意别写成getInt("productId"),这是 JDBC 新手高频报错点。表结构没有外键约束,这是课程设计里常见的取舍——外键会影响插入性能,而且这个项目规模用代码层校验足够了。
2.3 StoreManage 主控类的内存仓储:为什么用 HashMap 而不是 List
StoreManage 在这个项目里承担两件事:作为主窗口承载界面元素,同时对外提供仓储操作的统一入口。很多课程设计版本会把库存数据直接放在 List 里,查询时遍历比对,代码看起来简单,但每次操作都是 O(n) 复杂度。这里的常见做法是用Map<Integer, ProductInfo>,以商品 ID 为键:
import java.util.*; // StoreManage.java —— 用 HashMap 充当内存货架 public class StoreManage { private final Map<Integer, ProductInfo> productMap = new HashMap<>(); private int maxStock = 100000; // 仓库容量上限,可按业务调整 // 入库:商品不存在则拒绝,避免静默丢失数据 public boolean addStock(int productId, int quantity) { ProductInfo p = productMap.get(productId); if (p == null) { return false; // 必须先建档再入库 } if (p.getStock() + quantity > maxStock) { return false; // 超出容量上限,拒绝 } p.setStock(p.getStock() + quantity); return true; } // 出库:库存不足返回 false,保证库存不为负 public boolean reduceStock(int productId, int quantity) { ProductInfo p = productMap.get(productId); if (p == null || p.getStock() < quantity) { return false; } p.setStock(p.getStock() - quantity); return true; } public ProductInfo getProduct(int productId) { return productMap.get(productId); } }代码逻辑说明:addStock先把商品从 Map 里取出来,判空,再检查累加后是否超过maxStock,全部通过才更新内存。reduceStock的p == null || p.getStock() < quantity一行合并了两个边界判断,利用短路运算避免空指针。这里所有操作都在内存中完成,数据库落库由调用方在操作成功后同步执行,这种设计叫先写缓存再落库,适合单机小系统。
参数说明:maxStock是一个总容量阈值,不是单个商品的上限。真实仓库里还需要按商品维度设置上下限,比如某些商品最高存 500 件、最低不低于 20 件,那就要在 ProductInfo 里再加minStock和maxStock两个字段,在 Schema 上并不增加复杂度,但业务完整性会明显提升。HashMap 的查找是 O(1),比 List 的线性遍历快一个量级;但要注意 HashMap 不是线程安全的,多线程同时出入库时可能出现两个线程同时读到 stock=50,各自加 10,最后写回 60 而不是 70 的问题,这点留到第 3 章展开。
3. AddCargob 与 GoCargob:出入库状态变更的校验边界和并发隐患
3.1 先建档还是先入库:AddCargob 的执行顺序
入库操作最常犯的错误是把“新增商品”和“给已有商品加库存”混在一个方法里处理。正确的姿势是先区分场景:商品档案不存在,先走 TianJia 建档;档案存在,走 AddCargob 累加库存。AddCargob 的职责应该非常纯:
// AddCargob.java —— 入库操作,只负责累加库存 public class AddCargob { private StoreManage store; public AddCargob(StoreManage store) { this.store = store; } public String execute(int productId, int quantity) { if (quantity <= 0) { return "入库数量必须大于 0"; } if (!store.addStock(productId, quantity)) { return "入库失败:请检查商品是否存在,或容量是否已满"; } return "入库成功"; } }参数逻辑说明:quantity <= 0的校验放在最前面,因为负数入库会让库存产生不可逆的错误状态。store.addStock(productId, quantity)返回 false 时,调用方只拿到一个失败提示,不关心内部是“商品不存在”还是“超容量”,这样 StoreManage 的修改不会波及界面层。执行顺序可以归纳成一张边界表:
| 执行步骤 | 判断条件 | 失败处理 |
|---|---|---|
| 参数校验 | quantity > 0 | 直接拒绝 |
| 存在性校验 | productMap.get(id) != null | 提示先建档 |
| 容量校验 | stock + quantity <= maxStock | 提示容量已满 |
| 内存更新 | setStock 写入新值 | 同步失败需回滚 |
这里有一个值得注意的点:第 4 步“内存更新”成功后,如果数据库写入失败,内存和库里的数据就不一致了。课程设计阶段通常不处理这种问题,但如果想让项目经得起追问,至少应该在execute里包一层事务,或者把内存更新放到数据库写成功之后。后一种方案更简单:先 update 数据库,返回受影响行数为 1 再更新内存,这样数据库是唯一事实来源,内存只是一份加速读操作的缓存。
3.2 GoCargob 出库:库存不足只是第一层校验
出库比入库更容易出错,原因在于它隐含着一个业务约束:库存不能为负。GoCargob 的逻辑和 AddCargob 对称,但多了一个库存充足判断:
// GoCargob.java —— 出库操作,保证库存不会变成负数 public class GoCargob { private StoreManage store; public GoCargob(StoreManage store) { this.store = store; } public String execute(int productId, int quantity) { if (quantity <= 0) { return "出库数量必须大于 0"; } if (!store.reduceStock(productId, quantity)) { return "出库失败:商品不存在或库存不足"; } return "出库成功"; } }边界场景我是这样梳理的:
| 输入场景 | 代码走向 | 用户看到的结果 |
|---|---|---|
| productId 不存在 | reduceStock 内判空失败 | 出库失败提示 |
| stock < quantity | 库存比较不通过 | 出库失败提示 |
| quantity = 0 | 第一层校验拦截 | 参数错误提示 |
| quantity 为负值 | 第一层校验拦截 | 参数错误提示 |
| 库存刚好相等 | stock - quantity = 0 | 出库成功,库存归零 |
“库存刚好相等”是最容易漏测的场景。很多人在测试时只测库存充足和库存不足,忽略了刚好扣到 0 的情况。0 是合法的库存状态,不允许为负,但允许为 0,这个边界必须在reduceStock里用p.getStock() < quantity而不是<=来保证。另外,出库单在真实系统里还对应着订单、发货单等上下游数据,GoCargob 成功后应该记录一条出库流水,哪怕只是往一个 log 表里插一行,都会让系统后续的排查容易很多。
3.3 出入库并发:synchronized 是不是过度设计
单线程环境下,上面的代码没有任何问题。但 Swing 应用的事件分发线程、或者 Java Web 版本里多个 HTTP 请求同时操作同一个商品时,HashMap 的非线程安全就会暴露问题。最轻量的修复是给仓储方法加synchronized:
// StoreManage.java —— 加锁后的出库方法 public synchronized boolean reduceStock(int productId, int quantity) { ProductInfo p = productMap.get(productId); if (p == null || p.getStock() < quantity) { return false; } p.setStock(p.getStock() - quantity); return true; }加上synchronized之后,多个线程同时调用reduceStock时会被 JVM 的 monitor 锁串行化,同一时刻只有一个线程能修改库存。这个方案在这个项目规模下完全够用,不算过度设计,因为它的成本只在出入库方法上,不影响查询。如果连查询也想并发读,可以把HashMap换成ConcurrentHashMap,然后对get和setStock组合操作继续保留锁,因为ConcurrentHashMap只保证单次操作的原子性,不保证“读-改-写”这个复合操作。
我一般不建议在这个阶段引入 Redis 做分布式锁,理由很简单:系统还没到多实例部署的程度,分布式锁解决的是多个 JVM 进程之间的互斥,synchronized解决的是单个 JVM 内的线程互斥,两者解决的问题不在一个层面。面试里被问到这点时,能把“什么时候该升级到分布式锁”讲明白,比单纯说“我会用 Redis 锁”更能体现对并发边界的理解。
4. Longing 登录、supplier 维护与 ViewInfo 汇总:辅助模块暗含的工程化取舍
4.1 Longing 登录:密码存储和会话状态怎么处理才对
Longing 这个名字一看就是 login 的误拼,但模块本身值得认真对待。课程设计的登录通常是一张 user 表加一个文本框比对,但至少要做对两件事:密码不能明文存,登录状态不能只靠一个 boolean 变量。
// Longing.java —— 登录认证,密码用 MD5 散列后比对 import java.security.MessageDigest; import java.util.HashMap; import java.util.Map; public class Longing { // 模拟用户表:生产环境应从数据库加载 private final Map<String, String> users = new HashMap<>(); public Longing() { // 存储的是 MD5 哈希值,而不是明文 users.put("admin", "e10adc3949ba59abbe56e057f20f883e"); // md5(123456) } public boolean login(String username, String password) { if (username == null || password == null) { return false; } String hashed = md5(password); return hashed.equals(users.get(username)); } private String md5(String input) { try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(input.getBytes("UTF-8")); StringBuilder sb = new StringBuilder(); for (byte b : digest) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (Exception e) { throw new RuntimeException(e); } } }逻辑说明:md5(password)将用户输入散列后再与库里存的值比对,至少避免了明文密码直接暴露。但 MD5 本身已被证明不安全,彩虹表可以秒破常见弱口令。课程设计里用 MD5 够交差,想做得像样一点,就换成加盐的 SHA-256 或直接引入 BCrypt。users.get(username)返回 null 时(用户不存在),equals方法同样返回 false,不会抛空指针,这一点比username.equals(users.get(...))的写法安全。
登录成功后的会话状态,在 Swing 应用中常见做法是持有一个currentUser全局变量,在 Java Web 版本里则用HttpSession存储。权限控制如果只有一个管理员角色,一级判断就够;如果拆了操作员和管理员两种角色,就应该用枚举而不是字符串比较。下面是一个简单的改造对照:
| 改造点 | 课程设计写法 | 工程化做法 |
|---|---|---|
| 密码存储 | 明文或 MD5 | BCrypt + 盐 |
| 会话状态 | boolean 常量 | Session / Token |
| 角色权限 | if 判断用户名 | 角色枚举 + 拦截器 |
| 登录失败 | 直接弹窗 | 失败计数 + 临时锁定 |
4.2 供应商数据链路:从 addsupplier 到 ChangeSupplier 的变更闭环
addsupplier 和 ChangeSupplier 对应供应商的增改操作。这个模块的业务逻辑简单,但它是展示 JDBC 基本功的最佳位置,尤其是PreparedStatement的使用。以 ChangeSupplier 为例:
// ChangeSupplier.java —— 更新供应商信息,使用 PreparedStatement 防注入 import java.sql.*; public class ChangeSupplier { public boolean update(String supplierId, String newName, String contact, String phone) { if (supplierId == null || newName == null) { return false; } String sql = "UPDATE supplier_info SET supplier_name = ?, contact = ?, phone = ? WHERE supplier_id = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, newName); ps.setString(2, contact); ps.setString(3, phone); ps.setString(4, supplierId); return ps.executeUpdate() > 0; } catch (SQLException e) { e.printStackTrace(); return false; } } }参数说明:?占位符对应setString的索引,从 1 开始,顺序不能错。使用PreparedStatement而不是拼接字符串,核心原因有两个:第一,预编译语句让数据库只解析一次 SQL,反复调用时性能更好;第二,参数会被当作文本而不是 SQL 代码处理,直接杜绝了最常见的注入方式。try-with-resources语法确保Connection和PreparedStatement在方法结束后自动关闭,避免连接泄漏——连接泄漏在 Java Web 版本里会导致数据库连接池耗尽,是排查起来非常头疼的问题。
addsupplier 的插入逻辑与这段代码对称,把 UPDATE 换成 INSERT,字段换成供应商编号和名称。这里要额外注意业务规则:供应商编号应该唯一,插入前先查一次重复,否则数据库没有唯一索引时会出现两条相同的 supplier_id,后续商品关联也会跟着出问题。
4.3 ViewInfo 汇总统计:空数据和精度是两个隐形坑
ViewInfo 负责把库存数据加工成可读的统计信息,比如总库存量、库存货值、分类占比。最直接的实现是遍历所有商品累加:
// ViewInfo.java —— 汇总统计,含空数据防护 import java.util.*; public class ViewInfo { public Map<String, Object> buildSummary(StoreManage store) { Map<String, Object> data = new HashMap<>(); List<ProductInfo> products = store.allProducts(); if (products.isEmpty()) { data.put("totalStock", 0); data.put("totalValue", 0.0); data.put("avgStock", 0.0); return data; } int totalStock = 0; double totalValue = 0.0; for (ProductInfo p : products) { totalStock += p.getStock(); totalValue += p.getPrice() * p.getStock(); } data.put("totalStock", totalStock); data.put("totalValue", Math.round(totalValue * 100) / 100.0); data.put("avgStock", totalStock / (double) products.size()); return data; } }逻辑说明:products.isEmpty()的提前返回是这段代码的关键。如果不判空,totalStock / products.size()会在没有任何商品时抛出ArithmeticException: / by zero。totalValue在累加后做了四舍五入到分的处理,避免 double 精度误差在展示层暴露成 0.30000000000000004 这种数字。avgStock把products.size()强转成 double,是为了让结果保留小数而不是整数除法直接截断。
一个值得深挖的点:如果只统计一个维度的总数,体现不出系统的分析价值。仓库管理里更常用的是分组统计,比如按 category 分类汇总货值、按供应商统计商品数量。这类需求在 Java SE 里用Map<String, Double> categoryValue分组累加,在 SQL 里对应GROUP BY category,两者都能做。课程设计里把 ViewInfo 写成遍历求和没有错,但能多做一个“按分类汇总”的维度,演示时讲出来的信息量完全不同。
5. 从课设到简历项目:数据层替换顺序、演示自检与面试切入点
5.1 最低成本的升级路径:把 JDBC 手工映射替换成 MyBatis
如果想把项目从“课程设计”提升到“简历可写”,第一个动刀的位置应该是数据访问层。现在代码里每一处rs.getInt("product_id")都是手工映射,表一多就变成体力活。常见做法是引入 MyBatis,用注解或 XML 代替手写映射。改动时可以沿着“只换落地不换入口”的顺序推进:保留 StoreManage、AddCargob 这些业务类的接口签名,只把里面DBUtil.getConnection()的部分换成 SqlSession。这样业务逻辑不用动,数据层替换的回归测试范围也小。Spring 框架在这个阶段可加可不加,但一旦引入 Spring,就会牵出动 Bean 管理和事务配置,课设的演示成本会直线上升。
5.2 交付前半小时的自检清单
我每次给这类系统做最终演示前,都会按这张表过一遍,避免在关键环节翻车:
| 检查项 | 操作 | 常见坑 |
|---|---|---|
| 数据库脚本 | 确认建表 SQL 能重复执行 | 没加IF NOT EXISTS,二次运行报错 |
| 中文编码 | 统一 UTF-8 | IDEA 控制台与数据库连接串编码不一致 |
| 空输入防御 | 不填任何字段直接点按钮 | JTextField.getText()返回空串导致空指针 |
| 商品重复建档 | 连续添加两次相同 ID | 没有唯一索引时静默产生两条记录 |
| 出库扣到 0 | 把库存全部出掉 | 边界判断用了<=导致无法归零 |
5.3 面试里怎么讲这个项目
面试官问“仓库管理系统有什么难点”时,不要回答“我用了 Java 和 MySQL”,这只是技术列表。要从边界条件讲起:GoCargob 出库时怎么保证库存不为负,AddCargob 入库时容量上限怎么校验,synchronized为什么加在仓储层而不是界面层,PreparedStatement如何同时解决注入和预编译性能。这几个点分别对应 Java 基础里的异常处理、并发控制、JDBC 安全,正好是 Java 面试八股文里的高频题目,而且有真实业务场景背书。如果被追问“如何扩展”,就顺着第 5.1 节的数据层替换思路,说清楚从 JDBC 裸写切换到 MyBatis 时哪些代码会被替换、哪些保持不变——能把“改哪里、为什么只改这里”讲明白,这个项目在面试里的价值就真正兑现了。
本文还有配套的精品资源,点击获取