简介:这是一套面向Java初学者与毕业设计学生的物资管理系统完整源码项目,聚焦仓储管理核心场景,解决企业库存登记、采购出入库、供应商协同等实际业务痛点。资源包含212个文件,以36个Java源文件、51个JSP页面、30个XML配置文件及24个JAR依赖库为主体,覆盖MVC分层结构与数据库交互逻辑;JSP实现用户友好的操作界面,Java类封装DAO与业务逻辑,XML配置数据源与Spring框架,整体压缩包仅11.7MB,轻量易部署。已有319人学习下载,适合课程设计、毕设开发或Java Web技术栈综合实践。读者可直接导入Eclipse运行,获得含商品/供应商/采购/出入库全模块的可执行系统,配套文档清晰,代码结构规范,DAO层命名(如InstorageDAO、OutStorageDAO)与实体类(Goods、User、Provider)对应严谨,是理解JDBC连接MySQL、前后端交互与权限验证机制的优质教学案例。
1. 这不是又一个CRUD练习——它用JDBC直连MySQL跑通了真实仓储业务闭环
你可能已经见过几十个“Java图书管理系统”“学生成绩系统”,但这个物资管理系统源码,是少数几个把「入库单→库存扣减→供应商对账→出库审批流」全链路用原生JDBC落地的毕业设计级项目。它不依赖Spring Boot自动装配,不包装MyBatis动态SQL,而是用InstorageDAO.class、OutStorageDAO.class、ProviderDAO.class等12个DAO类,手写PreparedStatement参数绑定、事务控制和结果集映射。这意味着:你能清晰看到每一条SQL如何影响goods表的stock_quantity字段,也能在LoanForm.class里读到出库申请被驳回时的status = 'REJECTED'状态机逻辑。它适合两类人:一是正在准备Java后端面试、需要讲清“JDBC事务怎么保证入库不出错”的应届生;二是想快速搭建轻量级内部仓储工具、又不想引入Spring生态复杂度的中小团队运维人员。所有DAO操作都基于MySQL 5.7+,建表语句藏在src/main/resources/sql/下,连外键约束和索引命名都按生产环境习惯(如idx_goods_provider_id),不是教学玩具。
2. 从DAO层切入:为什么用原生JDBC而不是ORM框架?
2.1 选型依据:轻量、可控、易调试的仓储场景适配
物资管理系统的典型操作是高频小事务:单次入库新增3条记录(instorage主表、instorage_detail明细表、goods库存更新),要求原子性且响应快。Spring Data JPA的二级缓存在此类场景反而增加一致性风险,而MyBatis的XML映射层在简单CRUD中显得冗余。本项目选择JDBC直连,核心优势有三点:第一,Connection.setAutoCommit(false)配合commit()/rollback()可精确控制跨表事务边界,比如InstorageDAO.addInstorage()方法内先插入主单再循环插入明细,任一环节失败即回滚;第二,ResultSetMetaData能动态获取列名,使QueryDAO.queryByCondition()支持任意字段组合查询(如SELECT * FROM goods WHERE category = ? AND stock_quantity < ?);第三,无反射代理开销,UserDAO.login()验证密码时直接调用MessageDigest.getInstance("SHA-256")哈希比对,避免Hibernate的@Column(transform = ...)等扩展语法。
提示:不要误以为“不用ORM=不专业”。在库存扣减这类强一致性场景,手写
UPDATE goods SET stock_quantity = stock_quantity - ? WHERE id = ? AND stock_quantity >= ?并检查executeUpdate()返回值是否为1,比任何ORM的乐观锁更直观可靠。
2.2 DAO类结构解析:以InstorageDAO为例看事务封装
InstorageDAO.class是入库模块的核心,其addInstorage(Instorage instorage, List<InstorageDetail> details)方法实现典型的“主-明细”事务。代码逻辑如下:
public boolean addInstorage(Instorage instorage, List<InstorageDetail> details) { Connection conn = null; PreparedStatement psMain = null; PreparedStatement psDetail = null; try { conn = JDBCUtil.getConnection(); // 从Druid连接池获取 conn.setAutoCommit(false); // 关闭自动提交 // 1. 插入入库主单 psMain = conn.prepareStatement( "INSERT INTO instorage (id, provider_id, instorage_date, operator_id, remark) VALUES (?, ?, ?, ?, ?)", Statement.RETURN_GENERATED_KEYS ); psMain.setString(1, instorage.getId()); psMain.setString(2, instorage.getProviderId()); psMain.setDate(3, new java.sql.Date(instorage.getInstorageDate().getTime())); psMain.setString(4, instorage.getOperatorId()); psMain.setString(5, instorage.getRemark()); psMain.executeUpdate(); // 2. 获取自增主键(用于关联明细) ResultSet rs = psMain.getGeneratedKeys(); String instorageId = null; if (rs.next()) { instorageId = rs.getString(1); } // 3. 批量插入明细并更新商品库存 psDetail = conn.prepareStatement( "INSERT INTO instorage_detail (instorage_id, goods_id, quantity, unit_price) VALUES (?, ?, ?, ?)" ); for (InstorageDetail detail : details) { psDetail.setString(1, instorageId); psDetail.setString(2, detail.getGoodsId()); psDetail.setInt(3, detail.getQuantity()); psDetail.setDouble(4, detail.getUnitPrice()); psDetail.addBatch(); // 同步更新goods表库存 PreparedStatement psUpdate = conn.prepareStatement( "UPDATE goods SET stock_quantity = stock_quantity + ? WHERE id = ?" ); psUpdate.setInt(1, detail.getQuantity()); psUpdate.setString(2, detail.getGoodsId()); int rows = psUpdate.executeUpdate(); if (rows != 1) { throw new SQLException("商品库存更新失败,goods_id=" + detail.getGoodsId()); } } psDetail.executeBatch(); conn.commit(); return true; } catch (SQLException e) { if (conn != null) { try { conn.rollback(); // 显式回滚 } catch (SQLException rollbackEx) { e.addSuppressed(rollbackEx); } } e.printStackTrace(); return false; } finally { JDBCUtil.close(psDetail, psMain, conn); } }2.2.1 关键参数与异常处理逻辑说明
Statement.RETURN_GENERATED_KEYS:必须显式声明,否则getGeneratedKeys()返回空结果集,导致明细表无法关联主单ID;psDetail.addBatch():批量插入提升性能,但需注意MySQL默认max_allowed_packet限制(建议设为32M);- 库存更新校验
if (rows != 1):防止商品ID不存在或并发修改,这是比乐观锁更早的防御层; e.addSuppressed(rollbackEx):将回滚异常作为抑制异常附加到主异常上,便于排查事务中断原因。
2.3 数据库表关系与JDBC映射策略
系统共8张核心表,通过外键形成强约束关系。关键设计点如下表所示:
| 表名 | 主要字段 | 外键约束 | JDBC映射要点 |
|---|---|---|---|
goods | id,name,category,stock_quantity,unit | 无 | stock_quantity用getInt()读取,避免getDouble()精度丢失 |
provider | id,name,contact,phone | 无 | contact字段含中文,PreparedStatement.setString()自动处理编码 |
instorage | id,provider_id,instorage_date | provider_id → provider.id | 日期字段用java.sql.Date转换,非LocalDateTime(JDBC 4.2前不支持) |
instorage_detail | instorage_id,goods_id,quantity | instorage_id → instorage.id,goods_id → goods.id | 主键为联合主键,executeBatch()前需确保instorage_id已生成 |
注意:
OutStorageDAO.class中出库逻辑与入库镜像,但库存更新SQL为stock_quantity = stock_quantity - ?,且必须前置校验SELECT stock_quantity FROM goods WHERE id = ?是否足够,否则UPDATE返回0行却无异常——这是新手最常踩的坑。
3. 用户认证与权限控制:基于Session的轻量级安全实现
3.1 登录流程中的密码安全实践
UserDAO.class的login(String username, String password)方法不存储明文密码,而是采用SHA-256哈希加盐。关键代码如下:
public User login(String username, String password) { String sql = "SELECT id, username, password_hash, salt, role FROM user WHERE username = ?"; try (Connection conn = JDBCUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { String storedSalt = rs.getString("salt"); String storedHash = rs.getString("password_hash"); String inputHash = hashPassword(password, storedSalt); // 盐值拼接后哈希 if (inputHash.equals(storedHash)) { User user = new User(); user.setId(rs.getString("id")); user.setUsername(rs.getString("username")); user.setRole(rs.getString("role")); // role字段值为"ADMIN"或"OPERATOR" return user; } } } } catch (SQLException e) { e.printStackTrace(); } return null; }3.1.1 密码哈希实现细节
- 盐值(
salt)为16字节随机字符串,存于数据库user.salt字段,每次注册独立生成; hashPassword()方法使用MessageDigest.getInstance("SHA-256"),输入为password + salt的UTF-8字节数组;- 对比时严格使用
String.equals()而非==,避免字符串驻留导致的时序攻击风险。
3.2 基于角色的菜单与操作拦截
系统未使用Shiro或Spring Security,而是通过IfDeal.class(名称暗示“是否可操作”)实现运行时权限判断。例如OutStorageDAO.checkPermission(String userId, String action)方法:
public boolean checkPermission(String userId, String action) { // 查询用户角色 String roleSql = "SELECT role FROM user WHERE id = ?"; String role = null; try (Connection conn = JDBCUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(roleSql)) { ps.setString(1, userId); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { role = rs.getString("role"); } } } catch (SQLException e) { e.printStackTrace(); return false; } // 角色-操作矩阵 Map<String, Set<String>> permissionMatrix = new HashMap<>(); permissionMatrix.put("ADMIN", Set.of("CREATE_INSTORAGE", "CREATE_OUTSTORAGE", "MANAGE_USER")); permissionMatrix.put("OPERATOR", Set.of("CREATE_INSTORAGE", "CREATE_OUTSTORAGE")); return permissionMatrix.getOrDefault(role, Collections.emptySet()).contains(action); }3.2.1 权限校验的调用时机与位置
- 界面层:
User.class的getMenuItems()方法根据role返回不同菜单列表(如ADMIN显示“用户管理”项,OPERATOR不显示); - DAO层:
OutStorageDAO.createOutStorage()开头调用checkPermission(userId, "CREATE_OUTSTORAGE"),失败则抛出自定义InsufficientPermissionException; - Servlet层:
OutStorageServlet中doPost()先验证Session中用户ID有效性,再委托DAO执行。
提示:这种硬编码权限矩阵适合角色少于5个的系统。若需动态权限,应扩展
role_permission中间表,并在checkPermission()中改为SQL查询SELECT COUNT(*) FROM role_permission WHERE role = ? AND action = ?。
4. 部署与本地运行:绕过Maven陷阱的实操指南
4.1 MySQL环境准备与初始化脚本执行
项目依赖MySQL 5.7+,需手动创建数据库并执行初始化SQL。步骤如下:
# 1. 登录MySQL(假设root密码为空) mysql -u root -p # 2. 创建数据库(注意字符集) CREATE DATABASE instorage_system CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; # 3. 切换数据库并执行建表脚本 USE instorage_system; SOURCE /path/to/your/project/src/main/resources/sql/create_tables.sql;create_tables.sql包含完整建表语句,关键约束示例如下:
CREATE TABLE goods ( id VARCHAR(32) PRIMARY KEY, name VARCHAR(100) NOT NULL, category VARCHAR(50), stock_quantity INT NOT NULL DEFAULT 0, unit VARCHAR(20), created_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE instorage ( id VARCHAR(32) PRIMARY KEY, provider_id VARCHAR(32) NOT NULL, instorage_date DATE NOT NULL, operator_id VARCHAR(32) NOT NULL, remark TEXT, created_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (provider_id) REFERENCES provider(id), FOREIGN KEY (operator_id) REFERENCES user(id) );4.1.1 字符集与排序规则说明
utf8mb4:支持Emoji及四字节Unicode字符(如某些生僻汉字),避免INSERT时乱码;utf8mb4_unicode_ci:比utf8mb4_general_ci更准确的Unicode排序,尤其对中文姓名排序更合理;DEFAULT CURRENT_TIMESTAMP:MySQL 5.6+支持,无需Java层生成时间戳。
4.2 JDBC连接配置与Druid连接池参数调优
数据库连接信息配置在src/main/resources/jdbc.properties中:
jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/instorage_system?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true jdbc.username=root jdbc.password=your_password注意:
serverTimezone=Asia/Shanghai必须显式指定,否则java.sql.Date转换时区错误导致日期偏移;allowPublicKeyRetrieval=true解决MySQL 8.0+默认禁用公钥检索的问题。
Druid连接池配置在JDBCUtil.class的静态块中:
static { DruidDataSource dataSource = new DruidDataSource(); dataSource.setDriverClassName(props.getProperty("jdbc.driver")); dataSource.setUrl(props.getProperty("jdbc.url")); dataSource.setUsername(props.getProperty("jdbc.username")); dataSource.setPassword(props.getProperty("jdbc.password")); // 关键调优参数 dataSource.setInitialSize(5); // 初始化5个连接 dataSource.setMinIdle(5); // 最小空闲连接数 dataSource.setMaxActive(20); // 最大活跃连接数(根据并发预估) dataSource.setMaxWait(60000); // 获取连接最大等待60秒 dataSource.setTimeBetweenEvictionRunsMillis(60000); // 每分钟检测空闲连接 dataSource.setMinEvictableIdleTimeMillis(300000); // 连接空闲5分钟才回收 dataSource.setValidationQuery("SELECT 1"); // 连接有效性检测SQL dataSource.setTestWhileIdle(true); // 空闲时检测 dataSource.setPoolPreparedStatements(true); // 开启PSCache dataSource.setMaxOpenPreparedStatements(20); // PSCache最大数量 instance = dataSource; }4.2.1 生产环境参数调整建议
| 参数 | 开发环境值 | 生产环境建议值 | 调整原因 |
|---|---|---|---|
maxActive | 20 | 50~100 | 支持更高并发入库请求 |
maxWait | 60000 | 30000 | 缩短超时时间,快速失败而非阻塞 |
timeBetweenEvictionRunsMillis | 60000 | 30000 | 更频繁检测失效连接 |
minEvictableIdleTimeMillis | 300000 | 600000 | 延长空闲连接存活时间,减少重建开销 |
4.3 Eclipse运行配置与常见启动错误排错
在Eclipse中运行需正确配置构建路径和运行参数:
- 构建路径:右键项目 →
Properties→Java Build Path→Libraries→ 添加mysql-connector-java-8.0.28.jar和druid-1.2.8.jar(版本需与pom.xml一致); - 运行配置:
Run→Run Configurations→Java Application→ 新建 →Main class填com.example.instorage.MainServlet(入口类名依实际而定); - VM参数(可选):添加
-Dfile.encoding=UTF-8确保日志中文不乱码。
4.3.1 三类高频启动错误及解决方案
| 错误现象 | 根本原因 | 解决方案 |
|---|---|---|
java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver | MySQL驱动jar未加入Build Path | 将mysql-connector-java-*.jar拖入Referenced Libraries文件夹,或通过Maven依赖管理 |
java.sql.SQLException: Access denied for user 'root'@'localhost' | JDBC配置中用户名/密码错误,或MySQL未授权本地访问 | 执行GRANT ALL PRIVILEGES ON instorage_system.* TO 'root'@'localhost' IDENTIFIED BY 'your_password'; FLUSH PRIVILEGES; |
java.lang.NoClassDefFoundError: javax/servlet/HttpServlet | 缺少Servlet API依赖 | 在pom.xml中添加<dependency><groupId>javax.servlet</groupId><artifactId>javax.servlet-api</artifactId><version>4.0.1</version><scope>provided</scope></dependency> |
5. 实战技巧:用QueryDAO快速诊断库存不平问题
5.1 库存不平的典型场景与SQL定位法
当发现goods.stock_quantity与instorage_detail/outstorage_detail汇总不一致时,不能仅靠日志排查。QueryDAO.class提供了针对性查询方法,可直接在MySQL命令行执行等效SQL:
-- 步骤1:查某商品所有入库总量 SELECT SUM(quantity) AS total_in FROM instorage_detail d JOIN instorage i ON d.instorage_id = i.id WHERE d.goods_id = 'G001' AND i.status = 'APPROVED'; -- 步骤2:查某商品所有出库总量 SELECT SUM(quantity) AS total_out FROM outstorage_detail d JOIN outstorage o ON d.outstorage_id = o.id WHERE d.goods_id = 'G001' AND o.status = 'APPROVED'; -- 步骤3:对比库存表当前值 SELECT stock_quantity FROM goods WHERE id = 'G001'; -- 验证公式:stock_quantity = (初始值) + total_in - total_out5.1.1 自动化校验脚本(Bash+MySQL)
将上述逻辑封装为可定时执行的校验脚本,保存为check_inventory_balance.sh:
#!/bin/bash MYSQL_CMD="mysql -u root -pyour_password instorage_system -N -s" # 获取所有商品ID goods_ids=$($MYSQL_CMD -e "SELECT id FROM goods") for gid in $goods_ids; do # 计算入库总量 total_in=$($MYSQL_CMD -e "SELECT IFNULL(SUM(d.quantity),0) FROM instorage_detail d JOIN instorage i ON d.instorage_id=i.id WHERE d.goods_id='$gid' AND i.status='APPROVED'") # 计算出库总量 total_out=$($MYSQL_CMD -e "SELECT IFNULL(SUM(d.quantity),0) FROM outstorage_detail d JOIN outstorage o ON d.outstorage_id=o.id WHERE d.goods_id='$gid' AND o.status='APPROVED'") # 获取当前库存 current_stock=$($MYSQL_CMD -e "SELECT stock_quantity FROM goods WHERE id='$gid'") # 计算理论库存(假设初始值为0) expected_stock=$((total_in - total_out)) if [ "$current_stock" != "$expected_stock" ]; then echo "ALERT: 商品 $gid 库存不平!当前:$current_stock, 理论:$expected_stock" echo " 入库:$total_in, 出库:$total_out" fi done赋予执行权限并加入crontab:
chmod +x check_inventory_balance.sh # 每日凌晨2点执行 echo "0 2 * * * /path/to/check_inventory_balance.sh >> /var/log/inventory_check.log 2>&1" | crontab -5.2 使用LoanForm.class复现出库审批流断点
LoanForm.class代表出库申请单,其status字段流转为PENDING → APPROVED/REJECTED。当业务方反馈“申请提交后卡在PENDING”,可模拟审批操作快速验证:
// 在测试类中手动触发审批 public void testApproveLoanForm() { String formId = "LF20240501001"; OutStorageDAO dao = new OutStorageDAO(); // 1. 检查当前状态 String currentStatus = dao.getLoanFormStatus(formId); System.out.println("当前状态: " + currentStatus); // 应输出 PENDING // 2. 手动审批(跳过前端按钮) boolean approved = dao.approveLoanForm(formId, "ADMIN_USER_ID"); System.out.println("审批结果: " + approved); // true表示成功 // 3. 验证库存是否扣减 String goodsId = dao.getGoodsIdByLoanForm(formId); int afterStock = dao.getGoodsStock(goodsId); System.out.println("商品" + goodsId + "扣减后库存: " + afterStock); }此技巧的价值在于:绕过前端JavaScript校验和网络延迟,直接验证DAO层事务逻辑是否完备。若approveLoanForm()返回true但库存未变,则问题必在UPDATE goods SET stock_quantity = stock_quantity - ?语句的WHERE条件或参数绑定错误。
本文还有配套的精品资源,点击获取